some refactoring and socket server added
This commit is contained in:
@@ -3,6 +3,8 @@ __pycache__/
|
|||||||
*.py[codz]
|
*.py[codz]
|
||||||
*$py.class
|
*$py.class
|
||||||
build*
|
build*
|
||||||
|
.codex
|
||||||
|
.DS_Store
|
||||||
# C extensions
|
# C extensions
|
||||||
python_app/data*
|
python_app/data*
|
||||||
*.so
|
*.so
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from python_app.models.run_config_model import RunConfigModel
|
|||||||
from python_app.orchestration.config_writer import ConfigWriter
|
from python_app.orchestration.config_writer import ConfigWriter
|
||||||
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
|
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
|
||||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
|
||||||
|
from python_app.orchestration.locator_runtime import LocatorTcpService
|
||||||
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
||||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||||
from python_app.orchestration.shm_reader import ShmRingReader
|
from python_app.orchestration.shm_reader import ShmRingReader
|
||||||
@@ -79,6 +80,7 @@ class AppWindow(
|
|||||||
self._supervisor = ProcessSupervisor(self._project_root)
|
self._supervisor = ProcessSupervisor(self._project_root)
|
||||||
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
|
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
|
||||||
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
|
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
|
||||||
|
self._locator_service: LocatorTcpService | None = None
|
||||||
|
|
||||||
def _init_config_profile_state(self) -> None:
|
def _init_config_profile_state(self) -> None:
|
||||||
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
||||||
@@ -107,6 +109,7 @@ class AppWindow(
|
|||||||
"INFO",
|
"INFO",
|
||||||
f"Loaded legacy run config without GUI defaults: {active_profile_path}",
|
f"Loaded legacy run config without GUI defaults: {active_profile_path}",
|
||||||
)
|
)
|
||||||
|
self._locator_service = self._build_locator_service(self._defaults_config)
|
||||||
self._remember_active_profile_path(active_profile_path, startup=True)
|
self._remember_active_profile_path(active_profile_path, startup=True)
|
||||||
|
|
||||||
def _init_reader_handles(self) -> None:
|
def _init_reader_handles(self) -> None:
|
||||||
@@ -197,9 +200,58 @@ class AppWindow(
|
|||||||
self._log(f"Active config profile: {self._active_profile_path}")
|
self._log(f"Active config profile: {self._active_profile_path}")
|
||||||
self._refresh_preprocess_summary_labels()
|
self._refresh_preprocess_summary_labels()
|
||||||
self._apply_initial_radar_limits()
|
self._apply_initial_radar_limits()
|
||||||
|
self._start_locator_service()
|
||||||
self._write_live_processing_config()
|
self._write_live_processing_config()
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
|
def _start_locator_service(self) -> None:
|
||||||
|
"""Start embedded locator TCP service without failing the GUI."""
|
||||||
|
try:
|
||||||
|
if self._locator_service is None:
|
||||||
|
self._locator_service = self._build_locator_service(self._defaults_config)
|
||||||
|
self._locator_service.start()
|
||||||
|
self._locator_service.publish_empty()
|
||||||
|
self._log(
|
||||||
|
f"Locator TCP server listening on "
|
||||||
|
f"{self._locator_service.host}:{self._locator_service.port}"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_exception("Failed to start locator TCP server", exc, level="WARN")
|
||||||
|
|
||||||
|
def _build_locator_service(self, config: RunConfigModel) -> LocatorTcpService:
|
||||||
|
"""Create locator service instance from stable run config."""
|
||||||
|
locator_server = config.runtime.locator_server
|
||||||
|
return LocatorTcpService(
|
||||||
|
host=str(locator_server.host),
|
||||||
|
port=int(locator_server.port),
|
||||||
|
device_id=int(locator_server.device_id),
|
||||||
|
protocol_version=int(locator_server.protocol_version),
|
||||||
|
max_payload_bytes=int(locator_server.max_payload_bytes),
|
||||||
|
client_queue_size=int(locator_server.client_queue_size),
|
||||||
|
logger_name=str(locator_server.logger_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reload_locator_service_from_config(self) -> None:
|
||||||
|
"""Rebuild locator service using current stable config and restart if needed."""
|
||||||
|
previous_service = self._locator_service
|
||||||
|
was_running = previous_service is not None and previous_service.is_running()
|
||||||
|
if previous_service is not None:
|
||||||
|
previous_service.stop()
|
||||||
|
|
||||||
|
self._locator_service = self._build_locator_service(self._defaults_config)
|
||||||
|
if not was_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._locator_service.start()
|
||||||
|
self._locator_service.publish_empty()
|
||||||
|
self._log(
|
||||||
|
"Locator TCP server reloaded from config: "
|
||||||
|
f"{self._locator_service.host}:{self._locator_service.port}"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_exception("Failed to reload locator TCP server from config", exc, level="WARN")
|
||||||
|
|
||||||
def _resolve_startup_profile_path(self) -> Path:
|
def _resolve_startup_profile_path(self) -> Path:
|
||||||
"""Resolve active profile path from session-state or root fallback path."""
|
"""Resolve active profile path from session-state or root fallback path."""
|
||||||
try:
|
try:
|
||||||
@@ -434,6 +486,9 @@ class AppWindow(
|
|||||||
self._abort_capture_sequence(resume_pipeline=False)
|
self._abort_capture_sequence(resume_pipeline=False)
|
||||||
# 2) Stop all managed processes/readers.
|
# 2) Stop all managed processes/readers.
|
||||||
self._stop_all_processes()
|
self._stop_all_processes()
|
||||||
|
# 3) Stop embedded locator service.
|
||||||
|
if self._locator_service is not None:
|
||||||
|
self._locator_service.stop()
|
||||||
# 3) Close auxiliary dialog windows.
|
# 3) Close auxiliary dialog windows.
|
||||||
if self._preprocess_dialog is not None:
|
if self._preprocess_dialog is not None:
|
||||||
self._preprocess_dialog.close()
|
self._preprocess_dialog.close()
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Focused mixins used by the AppWindow config facade."""
|
||||||
|
|
||||||
|
from python_app.gui.controllers.app_window_config.live_processing_mixin import (
|
||||||
|
AppWindowLiveProcessingMixin,
|
||||||
|
)
|
||||||
|
from python_app.gui.controllers.app_window_config.profile_io_mixin import (
|
||||||
|
AppWindowConfigProfileIOMixin,
|
||||||
|
)
|
||||||
|
from python_app.gui.controllers.app_window_config.radar_limits_mixin import (
|
||||||
|
AppWindowRadarLimitsMixin,
|
||||||
|
)
|
||||||
|
from python_app.gui.controllers.app_window_config.state_builders import (
|
||||||
|
AppWindowConfigStateBuildersMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppWindowConfigProfileIOMixin",
|
||||||
|
"AppWindowConfigStateBuildersMixin",
|
||||||
|
"AppWindowLiveProcessingMixin",
|
||||||
|
"AppWindowRadarLimitsMixin",
|
||||||
|
]
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Live-processing bindings and runtime reactions for the main window."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QSignalBlocker
|
||||||
|
|
||||||
|
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||||
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowLiveProcessingMixin:
|
||||||
|
"""Handle live processing updates, redraws, and locator republishing."""
|
||||||
|
|
||||||
|
def _validate_processing_mode_selection(self, mode: str) -> None:
|
||||||
|
"""Validate requested processing mode against current stable/live GUI state."""
|
||||||
|
validate_processing_mode_constraints(
|
||||||
|
mode,
|
||||||
|
self._build_config(),
|
||||||
|
self._live_processing_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
|
||||||
|
"""Build live processing config from current processing widgets."""
|
||||||
|
self._sync_bscan_frequency_limits_with_radar()
|
||||||
|
self._sync_gpr_frequency_limits_with_radar()
|
||||||
|
y_min_db = float(self._pass_through_y_min_db.value())
|
||||||
|
y_max_db = float(self._pass_through_y_max_db.value())
|
||||||
|
return ProcessingLiveConfig(
|
||||||
|
processor_mode=self._processing_mode.currentText(),
|
||||||
|
pass_through_channel="s21",
|
||||||
|
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||||
|
pass_through_y_min_db=min(y_min_db, y_max_db),
|
||||||
|
pass_through_y_max_db=max(y_min_db, y_max_db),
|
||||||
|
bscan_axis=self._bscan_axis.currentText(),
|
||||||
|
bscan_channel="s21",
|
||||||
|
bscan_cut_m=float(self._bscan_cut_m.value()),
|
||||||
|
bscan_max_depth_m=float(self._bscan_max_depth_m.value()),
|
||||||
|
bscan_gain=float(self._bscan_gain.value()),
|
||||||
|
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||||
|
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||||
|
gpr_input_positions=self._parse_csv_int_list(self._gpr_input_positions_input.text()),
|
||||||
|
gpr_output_positions=self._parse_csv_int_list(self._gpr_output_positions_input.text()),
|
||||||
|
gpr_min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||||
|
gpr_max_depth_m=float(self._gpr_max_depth_m.value()),
|
||||||
|
gpr_comp_power=float(self._gpr_comp_power.value()),
|
||||||
|
gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||||
|
gpr_stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
||||||
|
gpr_speed_m_s=float(self._gpr_speed_m_s.value()),
|
||||||
|
gpr_look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
||||||
|
gpr_snr_thresh=float(self._gpr_snr_thresh.value()),
|
||||||
|
gpr_snr_comp_max=float(self._gpr_snr_comp_max.value()),
|
||||||
|
gpr_background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||||
|
gpr_background_mean_count=int(self._gpr_background_mean_count.value()),
|
||||||
|
history_command_seq=int(self._history_command_seq),
|
||||||
|
history_command=str(history_command),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_live_processing_config(self, *, history_command: str = "none", bump_history_seq: bool = False) -> None:
|
||||||
|
"""Persist current live processing config to runtime JSON file."""
|
||||||
|
if bump_history_seq:
|
||||||
|
self._history_command_seq += 1
|
||||||
|
self._live_config_writer.write(self._live_processing_config(history_command=history_command))
|
||||||
|
|
||||||
|
def _apply_external_gpr_speed_update(self, speed_m_s: float) -> None:
|
||||||
|
"""Apply GPR speed received from locator clients without recursive signals."""
|
||||||
|
with QSignalBlocker(self._gpr_speed_m_s):
|
||||||
|
self._gpr_speed_m_s.setValue(float(speed_m_s))
|
||||||
|
self._write_live_processing_config()
|
||||||
|
|
||||||
|
def _on_processing_live_settings_changed(self, *_args) -> None:
|
||||||
|
"""Handle live-processing setting changes and trigger redraw when needed."""
|
||||||
|
try:
|
||||||
|
self._write_live_processing_config()
|
||||||
|
current_mode = self._processing_mode.currentText()
|
||||||
|
if current_mode == "bscan":
|
||||||
|
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||||
|
self._sync_bscan_history_from_results()
|
||||||
|
self._draw_bscan_heatmap_from_history()
|
||||||
|
elif current_mode == "gpr":
|
||||||
|
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
|
||||||
|
if self._result_history:
|
||||||
|
if not self._draw_results(self._result_history[-1]):
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
else:
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
elif self._result_history:
|
||||||
|
self._draw_results(self._result_history[-1])
|
||||||
|
else:
|
||||||
|
self._clear_trace_plots()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._show_exception("Failed to update live processing settings", exc)
|
||||||
|
|
||||||
|
def _on_gpr_visual_settings_changed(self, *_args) -> None:
|
||||||
|
"""Redraw current GPR result using updated GUI-only render settings."""
|
||||||
|
if self._processing_mode.currentText() != "gpr":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._result_history and self._draw_results(self._result_history[-1]):
|
||||||
|
return
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._show_exception("Failed to update GPR render settings", exc)
|
||||||
|
|
||||||
|
def _on_gpr_locator_threshold_changed(self, *_args) -> None:
|
||||||
|
"""Redraw GPR view and republish locator snapshot after threshold changes."""
|
||||||
|
self._on_gpr_visual_settings_changed()
|
||||||
|
if self._processing_mode.currentText() != "gpr":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._publish_locator_snapshot_from_latest_result()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._show_exception("Failed to update locator GPR threshold", exc)
|
||||||
|
|
||||||
|
def _on_gpr_locator_window_changed(self, *_args) -> None:
|
||||||
|
"""Redraw GPR view and republish locator snapshot after visible X/Z changes."""
|
||||||
|
self._on_gpr_visual_settings_changed()
|
||||||
|
if self._processing_mode.currentText() != "gpr":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._publish_locator_snapshot_from_latest_result()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._show_exception("Failed to update locator GPR window", exc)
|
||||||
|
|
||||||
|
def _on_processing_mode_changed(self, mode: str) -> None:
|
||||||
|
"""Switch processing parameter page and refresh corresponding visualization."""
|
||||||
|
previous_mode = getattr(self, "_active_processing_mode", "pass_through")
|
||||||
|
if mode != previous_mode:
|
||||||
|
try:
|
||||||
|
self._validate_processing_mode_selection(mode)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
with QSignalBlocker(self._processing_mode):
|
||||||
|
self._set_combo_current_text(self._processing_mode, previous_mode)
|
||||||
|
self._show_error(
|
||||||
|
f"Cannot switch processing mode to {mode}",
|
||||||
|
details=str(exc) or type(exc).__name__,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._active_processing_mode = mode
|
||||||
|
mode_to_page = {
|
||||||
|
"pass_through": 0,
|
||||||
|
"bscan": 1,
|
||||||
|
"gpr": 2,
|
||||||
|
}
|
||||||
|
self._set_plot_mode(mode)
|
||||||
|
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
|
||||||
|
current_page = self._processing_mode_pages.currentWidget()
|
||||||
|
if current_page is not None:
|
||||||
|
self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height())
|
||||||
|
self._processing_mode_pages.updateGeometry()
|
||||||
|
self._on_processing_live_settings_changed()
|
||||||
|
if mode == "gpr":
|
||||||
|
self._publish_locator_snapshot_from_latest_result()
|
||||||
|
elif previous_mode == "gpr":
|
||||||
|
self._locator_service.publish_empty()
|
||||||
|
if mode == "pass_through":
|
||||||
|
self._log(
|
||||||
|
"Processing mode selected: pass_through "
|
||||||
|
f"(show_magnitude={self._show_magnitude_checkbox.isChecked()}, "
|
||||||
|
f"show_phase={self._show_phase_checkbox.isChecked()}, "
|
||||||
|
f"fixed_y={self._pass_through_fixed_y_enabled.isChecked()}, "
|
||||||
|
f"y_range={self._pass_through_y_min_db.value():g}..{self._pass_through_y_max_db.value():g} dB)"
|
||||||
|
)
|
||||||
|
elif mode == "bscan":
|
||||||
|
self._log(
|
||||||
|
"Processing mode selected: bscan "
|
||||||
|
f"(axis={self._bscan_axis.currentText()}, "
|
||||||
|
f"cut={self._bscan_cut_m.value():g} m, "
|
||||||
|
f"max_depth={self._bscan_max_depth_m.value():g} m, "
|
||||||
|
f"gain={self._bscan_gain.value():g}, "
|
||||||
|
f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz)"
|
||||||
|
)
|
||||||
|
elif mode == "gpr":
|
||||||
|
self._log(
|
||||||
|
"Processing mode selected: gpr "
|
||||||
|
f"(inputs={self._gpr_input_positions_input.text().strip() or '<all>'}, "
|
||||||
|
f"outputs={self._gpr_output_positions_input.text().strip() or '<all>'}, "
|
||||||
|
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
||||||
|
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
|
||||||
|
f"speed={self._gpr_speed_m_s.value():g} m/s, "
|
||||||
|
f"look_angle={self._gpr_look_angle_deg.value():g} deg, "
|
||||||
|
f"snr_thresh={self._gpr_snr_thresh.value():g}, "
|
||||||
|
f"snr_comp_max={self._gpr_snr_comp_max.value():g}, "
|
||||||
|
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
||||||
|
f"mean_count={self._gpr_background_mean_count.value()}, "
|
||||||
|
f"render_mode={self._gpr_render_mode.currentText()}, "
|
||||||
|
f"min_pairs={self._gpr_min_visible_pair_count.value()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_history_mode_caches(self) -> None:
|
||||||
|
"""Drop cached render state for pass-through, B-scan, and GPR views."""
|
||||||
|
self._bscan_history_floor_collection_id = 0
|
||||||
|
self._clear_bscan_plot_history()
|
||||||
|
if hasattr(self, "_bscan_plot"):
|
||||||
|
self._bscan_plot.clear()
|
||||||
|
self._configure_bscan_plot_axes()
|
||||||
|
self._clear_trace_plots()
|
||||||
|
if hasattr(self, "_gpr_plot"):
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
|
||||||
|
def _redraw_after_history_deletion(self) -> None:
|
||||||
|
"""Refresh plot immediately after destructive history deletion."""
|
||||||
|
if self._processing_mode.currentText() == "bscan":
|
||||||
|
if self._result_history:
|
||||||
|
self._sync_bscan_history_from_results()
|
||||||
|
if not self._draw_bscan_heatmap_from_history():
|
||||||
|
self._bscan_plot.clear()
|
||||||
|
self._configure_bscan_plot_axes()
|
||||||
|
return
|
||||||
|
if self._processing_mode.currentText() == "gpr":
|
||||||
|
if self._result_history and self._draw_results(self._result_history[-1]):
|
||||||
|
return
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return
|
||||||
|
if self._result_history:
|
||||||
|
self._draw_results(self._result_history[-1])
|
||||||
|
return
|
||||||
|
self._bscan_plot.clear()
|
||||||
|
self._configure_bscan_plot_axes()
|
||||||
|
self._clear_trace_plots()
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Profile load/save workflows for the main window config facade."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from contextlib import ExitStack
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QSignalBlocker
|
||||||
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
from python_app.models.gui_profile_model import GuiProfileModel
|
||||||
|
from python_app.orchestration.preprocess_assets import (
|
||||||
|
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||||
|
preprocess_asset_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowConfigProfileIOMixin:
|
||||||
|
"""Persist and restore full GUI profiles without restarting the app."""
|
||||||
|
|
||||||
|
def _write_gui_profile_to_path(self, output_path: Path, *, allow_overwrite: bool = True) -> GuiProfileModel:
|
||||||
|
"""Serialize current full GUI profile to `output_path` and return the persisted model."""
|
||||||
|
if not allow_overwrite and output_path.exists():
|
||||||
|
raise FileExistsError(f"Config profile output already exists: {output_path}")
|
||||||
|
|
||||||
|
profile = self._build_gui_profile()
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(profile.to_dict(), indent=2), encoding="utf-8")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def _set_combo_selection_mode(self, mode: str) -> None:
|
||||||
|
"""Highlight current combo mode and enable only the relevant editors."""
|
||||||
|
text_selected = mode != "single"
|
||||||
|
self._run_combos_select_button.setChecked(text_selected)
|
||||||
|
self._single_combo_select_button.setChecked(not text_selected)
|
||||||
|
self._combos_text.setEnabled(text_selected)
|
||||||
|
self._single_combo_output.setEnabled(not text_selected)
|
||||||
|
self._single_combo_input.setEnabled(not text_selected)
|
||||||
|
|
||||||
|
def _sync_pass_through_y_controls(self) -> None:
|
||||||
|
"""Enable Y-range editors only when fixed Y mode is active."""
|
||||||
|
enabled = bool(self._pass_through_fixed_y_enabled.isChecked())
|
||||||
|
self._pass_through_y_min_db.setEnabled(enabled)
|
||||||
|
self._pass_through_y_max_db.setEnabled(enabled)
|
||||||
|
|
||||||
|
def _apply_history_limit_from_config(self, config) -> None:
|
||||||
|
"""Resize in-memory history buffers to match the loaded config."""
|
||||||
|
history_limit = self._history_limit_for_config(config)
|
||||||
|
self._raw_history = deque(self._raw_history, maxlen=history_limit)
|
||||||
|
self._pre_history = deque(self._pre_history, maxlen=history_limit)
|
||||||
|
self._result_history = deque(self._result_history, maxlen=history_limit)
|
||||||
|
self._bscan_history_limit = history_limit
|
||||||
|
self._clear_bscan_plot_history()
|
||||||
|
|
||||||
|
def _save_current_config(self) -> None:
|
||||||
|
"""Persist current full GUI profile to a user-selected JSON file."""
|
||||||
|
try:
|
||||||
|
suggested_path = str(self._active_profile_path)
|
||||||
|
selected_path, _selected_filter = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save Config Profile",
|
||||||
|
suggested_path,
|
||||||
|
"JSON Files (*.json);;All Files (*)",
|
||||||
|
)
|
||||||
|
if not selected_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
output_path = self._normalize_profile_path(Path(selected_path))
|
||||||
|
if not output_path.suffix:
|
||||||
|
output_path = output_path.with_suffix(".json")
|
||||||
|
|
||||||
|
profile = self._write_gui_profile_to_path(output_path)
|
||||||
|
|
||||||
|
self._defaults_config = profile.run_config.clone()
|
||||||
|
self._gui_defaults = profile.gui
|
||||||
|
self._remember_active_profile_path(output_path)
|
||||||
|
self._log(
|
||||||
|
f"Config profile saved: path={output_path}, "
|
||||||
|
f"combos={len(profile.run_config.combos)}, "
|
||||||
|
f"sweep={profile.run_config.radar.sweep.start_hz:g}.."
|
||||||
|
f"{profile.run_config.radar.sweep.stop_hz:g} Hz, "
|
||||||
|
f"points={profile.run_config.radar.sweep.points}, "
|
||||||
|
f"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, "
|
||||||
|
f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, "
|
||||||
|
f"processing_mode={profile.gui.processing.selected_mode}"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._show_exception("Failed to save config profile", exc)
|
||||||
|
|
||||||
|
def _load_config_from_dialog(self) -> None:
|
||||||
|
"""Load full GUI profile from a user-selected JSON file."""
|
||||||
|
if self._capture_session is not None:
|
||||||
|
self._show_error(
|
||||||
|
"Cannot load config during active capture sequence",
|
||||||
|
details=self._capture_state_details(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self._supervisor.is_running():
|
||||||
|
self._show_error(
|
||||||
|
"Stop all pipeline processes before loading a config profile",
|
||||||
|
details=self._process_state_details(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_path, _selected_filter = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Load Config Profile",
|
||||||
|
str(self._active_profile_path),
|
||||||
|
"JSON Files (*.json);;All Files (*)",
|
||||||
|
)
|
||||||
|
if not selected_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_path = self._normalize_profile_path(Path(selected_path))
|
||||||
|
profile = GuiProfileModel.load_from_path(normalized_path)
|
||||||
|
processor_running = self._supervisor.is_processor_running()
|
||||||
|
self._apply_loaded_profile(profile, normalized_path)
|
||||||
|
profile_kind = "legacy run config" if profile.gui is None else "full GUI profile"
|
||||||
|
message = (
|
||||||
|
f"Config profile loaded: path={normalized_path}, "
|
||||||
|
f"kind={profile_kind}, "
|
||||||
|
f"combos={len(self._defaults_config.combos)}, "
|
||||||
|
f"processing_mode={self._processing_mode.currentText()}"
|
||||||
|
)
|
||||||
|
if processor_running:
|
||||||
|
message += (
|
||||||
|
"; data_processor is still running, so live processing settings were applied immediately "
|
||||||
|
"and stable settings are now staged in the UI for the next Start"
|
||||||
|
)
|
||||||
|
self._log(message)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._show_exception("Failed to load config profile", exc)
|
||||||
|
|
||||||
|
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
|
||||||
|
"""Apply already parsed profile to GUI state without restarting the pipeline."""
|
||||||
|
config = profile.run_config.clone()
|
||||||
|
gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config)
|
||||||
|
selected_preprocess_sets = {
|
||||||
|
key: str(preprocess_asset_model(config, key).set_name)
|
||||||
|
for key in VISIBLE_PREPROCESS_ASSET_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
radio_widgets = (
|
||||||
|
self._serial_input,
|
||||||
|
self._radar_mode,
|
||||||
|
self._start_hz_input,
|
||||||
|
self._stop_hz_input,
|
||||||
|
self._points_input,
|
||||||
|
self._ifbw_input,
|
||||||
|
self._power_input,
|
||||||
|
self._settling_ms,
|
||||||
|
self._combos_text,
|
||||||
|
self._single_combo_output,
|
||||||
|
self._single_combo_input,
|
||||||
|
self._run_combos_select_button,
|
||||||
|
self._single_combo_select_button,
|
||||||
|
self._processing_mode,
|
||||||
|
self._show_magnitude_checkbox,
|
||||||
|
self._show_phase_checkbox,
|
||||||
|
self._pass_through_fixed_y_enabled,
|
||||||
|
self._pass_through_y_min_db,
|
||||||
|
self._pass_through_y_max_db,
|
||||||
|
self._bscan_axis,
|
||||||
|
self._bscan_cut_m,
|
||||||
|
self._bscan_max_depth_m,
|
||||||
|
self._bscan_gain,
|
||||||
|
self._bscan_start_freq_mhz,
|
||||||
|
self._bscan_stop_freq_mhz,
|
||||||
|
self._gpr_config_mode,
|
||||||
|
self._gpr_relative_permittivity,
|
||||||
|
self._gpr_tx_geometry_input,
|
||||||
|
self._gpr_rx_geometry_input,
|
||||||
|
self._gpr_input_positions_input,
|
||||||
|
self._gpr_output_positions_input,
|
||||||
|
self._gpr_min_depth_m,
|
||||||
|
self._gpr_max_depth_m,
|
||||||
|
self._gpr_comp_power,
|
||||||
|
self._gpr_start_freq_mhz,
|
||||||
|
self._gpr_stop_freq_mhz,
|
||||||
|
self._gpr_speed_m_s,
|
||||||
|
self._gpr_look_angle_deg,
|
||||||
|
self._gpr_snr_thresh,
|
||||||
|
self._gpr_snr_comp_max,
|
||||||
|
self._gpr_background_subtract_enabled,
|
||||||
|
self._gpr_background_mean_count,
|
||||||
|
self._gpr_render_mode,
|
||||||
|
self._gpr_min_visible_pair_count,
|
||||||
|
self._gpr_visible_x_min_m,
|
||||||
|
self._gpr_visible_x_max_m,
|
||||||
|
self._gpr_visible_z_min_m,
|
||||||
|
self._gpr_visible_z_max_m,
|
||||||
|
self._save_count,
|
||||||
|
self._save_path_input,
|
||||||
|
self._save_name_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
with ExitStack() as blockers:
|
||||||
|
for widget in radio_widgets:
|
||||||
|
blockers.enter_context(QSignalBlocker(widget))
|
||||||
|
|
||||||
|
self._serial_input.setText(str(config.radar.serial))
|
||||||
|
self._set_combo_current_text(self._radar_mode, str(config.radar.driver_mode))
|
||||||
|
self._start_hz_input.setText(f"{config.radar.sweep.start_hz:g}")
|
||||||
|
self._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}")
|
||||||
|
self._points_input.setText(str(int(config.radar.sweep.points)))
|
||||||
|
self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}")
|
||||||
|
self._power_input.setText(f"{config.radar.sweep.power_dbm:g}")
|
||||||
|
self._settling_ms.setText(str(int(config.runtime.settling_ms)))
|
||||||
|
|
||||||
|
self._combos_text.setText(str(gui_state.switches.combos_text))
|
||||||
|
self._single_combo_output.setText(str(gui_state.switches.single_output))
|
||||||
|
self._single_combo_input.setText(str(gui_state.switches.single_input))
|
||||||
|
self._set_combo_selection_mode(gui_state.switches.combo_mode)
|
||||||
|
|
||||||
|
self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode)
|
||||||
|
self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude))
|
||||||
|
self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase))
|
||||||
|
self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled))
|
||||||
|
self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db))
|
||||||
|
self._pass_through_y_max_db.setValue(float(gui_state.processing.pass_through.y_max_db))
|
||||||
|
|
||||||
|
self._set_combo_current_text(self._bscan_axis, gui_state.processing.bscan.axis)
|
||||||
|
self._bscan_cut_m.setValue(float(gui_state.processing.bscan.cut_m))
|
||||||
|
self._bscan_max_depth_m.setValue(float(gui_state.processing.bscan.max_depth_m))
|
||||||
|
self._bscan_gain.setValue(float(gui_state.processing.bscan.gain))
|
||||||
|
self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz))
|
||||||
|
self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz))
|
||||||
|
|
||||||
|
self._set_combo_current_text(self._gpr_config_mode, str(config.gpr.mode))
|
||||||
|
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
|
||||||
|
self._gpr_tx_geometry_input.setPlainText(
|
||||||
|
"\n".join(
|
||||||
|
f"{int(entry.output_pos)} {float(entry.x_m):g}"
|
||||||
|
for entry in config.gpr.tx_geometry
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._gpr_rx_geometry_input.setPlainText(
|
||||||
|
"\n".join(
|
||||||
|
f"{int(entry.input_pos)} {float(entry.x_m):g}"
|
||||||
|
for entry in config.gpr.rx_geometry
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._gpr_input_positions_input.setText(str(gui_state.processing.gpr.input_positions))
|
||||||
|
self._gpr_output_positions_input.setText(str(gui_state.processing.gpr.output_positions))
|
||||||
|
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
|
||||||
|
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
|
||||||
|
self._gpr_comp_power.setValue(float(gui_state.processing.gpr.comp_power))
|
||||||
|
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
|
||||||
|
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
||||||
|
self._gpr_speed_m_s.setValue(float(gui_state.processing.gpr.speed_m_s))
|
||||||
|
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
||||||
|
self._gpr_snr_thresh.setValue(float(gui_state.processing.gpr.snr_thresh))
|
||||||
|
self._gpr_snr_comp_max.setValue(float(gui_state.processing.gpr.snr_comp_max))
|
||||||
|
self._gpr_background_subtract_enabled.setChecked(
|
||||||
|
bool(gui_state.processing.gpr.background_subtract_enabled)
|
||||||
|
)
|
||||||
|
self._gpr_background_mean_count.setValue(int(gui_state.processing.gpr.background_mean_count))
|
||||||
|
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
|
||||||
|
self._gpr_min_visible_pair_count.setValue(int(gui_state.processing.gpr.min_visible_pair_count))
|
||||||
|
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
|
||||||
|
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
|
||||||
|
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
|
||||||
|
self._gpr_visible_z_max_m.setValue(float(gui_state.processing.gpr.visible_z_max_m))
|
||||||
|
|
||||||
|
self._save_count.setValue(int(gui_state.data_actions.save_count))
|
||||||
|
self._save_path_input.setText(str(gui_state.data_actions.save_path))
|
||||||
|
self._save_name_input.setText(str(gui_state.data_actions.save_name))
|
||||||
|
|
||||||
|
self._defaults_config = config
|
||||||
|
self._gui_defaults = gui_state
|
||||||
|
self._selected_preprocess_sets = selected_preprocess_sets
|
||||||
|
self._selected_preprocess_radar_key = self._radar_key(config)
|
||||||
|
self._preprocess_set_name = str(gui_state.preprocess_dialog.set_name)
|
||||||
|
self._apply_history_limit_from_config(config)
|
||||||
|
self._gpr_geometry_signature = None
|
||||||
|
self._gpr_selected_geometry = None
|
||||||
|
self._sync_pass_through_y_controls()
|
||||||
|
self._refresh_preprocess_summary_labels()
|
||||||
|
|
||||||
|
if self._preprocess_dialog is not None:
|
||||||
|
with ExitStack() as dialog_blockers:
|
||||||
|
dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._set_name_input))
|
||||||
|
for combo in self._preprocess_dialog._set_combos.values():
|
||||||
|
dialog_blockers.enter_context(QSignalBlocker(combo))
|
||||||
|
self._preprocess_dialog.set_set_name(self._preprocess_set_name)
|
||||||
|
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
|
||||||
|
|
||||||
|
self._apply_initial_radar_limits()
|
||||||
|
self._reload_locator_service_from_config()
|
||||||
|
self._on_processing_mode_changed(gui_state.processing.selected_mode)
|
||||||
|
self._update_history_indicator()
|
||||||
|
self._remember_active_profile_path(profile_path)
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Radar device limit sync and clamping logic for the main window."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowRadarLimitsMixin:
|
||||||
|
"""Handle LibreVNA capability probing and dependent UI clamping."""
|
||||||
|
|
||||||
|
def _on_radar_identity_changed(self, *_args) -> None:
|
||||||
|
"""Refresh device limits when radar identity/mode changes."""
|
||||||
|
self._reset_preprocess_selection_after_radar_key_change()
|
||||||
|
if self._radar_mode.currentText() != "native":
|
||||||
|
self._apply_radar_limits_to_ui(None)
|
||||||
|
return
|
||||||
|
changed = self._refresh_radar_limits_from_device()
|
||||||
|
if changed:
|
||||||
|
self._on_processing_live_settings_changed()
|
||||||
|
|
||||||
|
def _on_radar_sweep_limits_changed(self) -> None:
|
||||||
|
"""Clamp processing frequency bounds after sweep start/stop edits."""
|
||||||
|
self._reset_preprocess_selection_after_radar_key_change()
|
||||||
|
if self._sync_processing_frequency_limits_with_radar():
|
||||||
|
self._on_processing_live_settings_changed()
|
||||||
|
|
||||||
|
def _refresh_radar_limits_from_device(self) -> bool:
|
||||||
|
"""Query native LibreVNA limits and apply them to GUI fields."""
|
||||||
|
serial = self._serial_input.text().strip()
|
||||||
|
radar_service = LibreVnaService(serial=serial or None)
|
||||||
|
if not radar_service.driver_available:
|
||||||
|
self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
limits = radar_service.read_device_limits()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_exception("Failed to query LibreVNA limits; using UI fallback", exc, level="WARN")
|
||||||
|
self._apply_radar_limits_to_ui(None)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._apply_radar_limits_to_ui(limits)
|
||||||
|
|
||||||
|
def _fallback_to_mock_mode(self, reason: str) -> None:
|
||||||
|
"""Handle unavailable native limits without mutating JSON-backed mode."""
|
||||||
|
self._log_warning(reason)
|
||||||
|
self._apply_radar_limits_to_ui(None)
|
||||||
|
|
||||||
|
def _apply_radar_limits_to_ui(self, limits: dict[str, float | int] | None) -> bool:
|
||||||
|
"""Apply optional radar limits and clamp dependent GUI fields."""
|
||||||
|
previous_limits = dict(self._radar_limits) if self._radar_limits is not None else None
|
||||||
|
if limits is None:
|
||||||
|
self._radar_limits = None
|
||||||
|
self._radar_start_label.setText("Start Hz")
|
||||||
|
self._radar_stop_label.setText("Stop Hz")
|
||||||
|
self._radar_points_label.setText("Points")
|
||||||
|
self._radar_ifbw_label.setText("IF BW Hz")
|
||||||
|
self._radar_power_label.setText("Stimulus Power dBm")
|
||||||
|
if self._radar_mode.currentText() == "native":
|
||||||
|
self._radar_limits_hint.setText("Device limits unavailable in native mode (device not connected).")
|
||||||
|
else:
|
||||||
|
self._radar_limits_hint.setText("Mock mode: device limits are not applied.")
|
||||||
|
self._power_input.setToolTip("Device power limits are available only in native mode.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
min_freq_hz = float(limits["min_frequency_hz"])
|
||||||
|
max_freq_hz = float(limits["max_frequency_hz"])
|
||||||
|
min_ifbw_hz = float(limits["min_ifbw_hz"])
|
||||||
|
max_ifbw_hz = float(limits["max_ifbw_hz"])
|
||||||
|
max_points = int(limits["max_points"])
|
||||||
|
min_power_dbm = float(limits["min_power_dbm"])
|
||||||
|
max_power_dbm = float(limits["max_power_dbm"])
|
||||||
|
|
||||||
|
self._radar_limits = limits
|
||||||
|
|
||||||
|
self._radar_start_label.setText(f"Start Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||||
|
self._radar_stop_label.setText(f"Stop Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||||
|
self._radar_points_label.setText(f"Points (1..{max_points:d})")
|
||||||
|
self._radar_ifbw_label.setText(f"IF BW Hz ({min_ifbw_hz:g}..{max_ifbw_hz:g})")
|
||||||
|
self._radar_power_label.setText(f"Stimulus Power dBm ({min_power_dbm:g}..{max_power_dbm:g})")
|
||||||
|
self._radar_limits_hint.setText(
|
||||||
|
f"Limits: Freq {min_freq_hz:g}..{max_freq_hz:g} Hz, Points 1..{max_points:d}, "
|
||||||
|
f"IF BW {min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, Power {min_power_dbm:g}..{max_power_dbm:g} dBm."
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
prev_start = self._start_hz_input.text().strip()
|
||||||
|
prev_stop = self._stop_hz_input.text().strip()
|
||||||
|
prev_points = self._points_input.text().strip()
|
||||||
|
prev_ifbw = self._ifbw_input.text().strip()
|
||||||
|
prev_power = self._power_input.text().strip()
|
||||||
|
|
||||||
|
start_hz = self._clamp_line_edit_float(self._start_hz_input, min_freq_hz, max_freq_hz)
|
||||||
|
stop_hz = self._clamp_line_edit_float(self._stop_hz_input, min_freq_hz, max_freq_hz)
|
||||||
|
if start_hz > stop_hz:
|
||||||
|
stop_hz = start_hz
|
||||||
|
self._stop_hz_input.setText(f"{stop_hz:g}")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
self._clamp_line_edit_int(self._points_input, 1, max_points)
|
||||||
|
self._clamp_line_edit_float(self._ifbw_input, min_ifbw_hz, max_ifbw_hz)
|
||||||
|
self._clamp_line_edit_float(self._power_input, min_power_dbm, max_power_dbm)
|
||||||
|
self._power_input.setToolTip(f"Device range: {min_power_dbm:g}..{max_power_dbm:g} dBm")
|
||||||
|
|
||||||
|
changed = (
|
||||||
|
changed
|
||||||
|
or prev_start != self._start_hz_input.text().strip()
|
||||||
|
or prev_stop != self._stop_hz_input.text().strip()
|
||||||
|
or prev_points != self._points_input.text().strip()
|
||||||
|
or prev_ifbw != self._ifbw_input.text().strip()
|
||||||
|
or prev_power != self._power_input.text().strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
applied_limits_changed = previous_limits != limits
|
||||||
|
if applied_limits_changed:
|
||||||
|
self._log(
|
||||||
|
"Applied radar device limits: "
|
||||||
|
f"freq={min_freq_hz:g}..{max_freq_hz:g} Hz, "
|
||||||
|
f"points=1..{max_points}, "
|
||||||
|
f"ifbw={min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, "
|
||||||
|
f"power={min_power_dbm:g}..{max_power_dbm:g} dBm"
|
||||||
|
)
|
||||||
|
|
||||||
|
adjustments: list[str] = []
|
||||||
|
current_start = self._start_hz_input.text().strip()
|
||||||
|
current_stop = self._stop_hz_input.text().strip()
|
||||||
|
current_points = self._points_input.text().strip()
|
||||||
|
current_ifbw = self._ifbw_input.text().strip()
|
||||||
|
current_power = self._power_input.text().strip()
|
||||||
|
if prev_start != current_start:
|
||||||
|
adjustments.append(f"Start Hz: {prev_start or '<empty>'} -> {current_start}")
|
||||||
|
if prev_stop != current_stop:
|
||||||
|
adjustments.append(f"Stop Hz: {prev_stop or '<empty>'} -> {current_stop}")
|
||||||
|
if prev_points != current_points:
|
||||||
|
adjustments.append(f"Points: {prev_points or '<empty>'} -> {current_points}")
|
||||||
|
if prev_ifbw != current_ifbw:
|
||||||
|
adjustments.append(f"IF BW Hz: {prev_ifbw or '<empty>'} -> {current_ifbw}")
|
||||||
|
if prev_power != current_power:
|
||||||
|
adjustments.append(f"Stimulus Power dBm: {prev_power or '<empty>'} -> {current_power}")
|
||||||
|
if adjustments:
|
||||||
|
self._log_warning(
|
||||||
|
"Radar fields were adjusted to satisfy device limits.",
|
||||||
|
details="\n".join(adjustments),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sync_processing_frequency_limits_with_radar()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clamp_line_edit_float(widget, min_value: float, max_value: float) -> float:
|
||||||
|
"""Clamp float line-edit value to inclusive range and rewrite widget text."""
|
||||||
|
try:
|
||||||
|
value = float(widget.text().strip())
|
||||||
|
except ValueError:
|
||||||
|
value = min_value
|
||||||
|
value = min(max(value, min_value), max_value)
|
||||||
|
widget.setText(f"{value:g}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clamp_line_edit_int(widget, min_value: int, max_value: int) -> int:
|
||||||
|
"""Clamp integer line-edit value to inclusive range and rewrite widget text."""
|
||||||
|
try:
|
||||||
|
value = int(float(widget.text().strip()))
|
||||||
|
except ValueError:
|
||||||
|
value = min_value
|
||||||
|
value = min(max(value, min_value), max_value)
|
||||||
|
widget.setText(str(value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _sync_processing_frequency_limits_with_radar(self) -> bool:
|
||||||
|
"""Synchronize all processing frequency widgets with radar sweep bounds."""
|
||||||
|
bscan_changed = self._sync_bscan_frequency_limits_with_radar()
|
||||||
|
gpr_changed = self._sync_gpr_frequency_limits_with_radar()
|
||||||
|
return bscan_changed or gpr_changed
|
||||||
|
|
||||||
|
def _sync_frequency_spinboxes_with_radar(self, widget_names: tuple[str, ...]) -> bool:
|
||||||
|
"""Synchronize one or more MHz spin boxes with current radar sweep bounds."""
|
||||||
|
required_widgets = ("_start_hz_input", "_stop_hz_input", *widget_names)
|
||||||
|
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
radar_start_hz = float(self._start_hz_input.text().strip())
|
||||||
|
radar_stop_hz = float(self._stop_hz_input.text().strip())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
radar_min_mhz = min(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||||
|
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
widget_labels = {
|
||||||
|
"_bscan_start_freq_mhz": "B-scan Start MHz",
|
||||||
|
"_bscan_stop_freq_mhz": "B-scan Stop MHz",
|
||||||
|
"_gpr_start_freq_mhz": "GPR Start MHz",
|
||||||
|
"_gpr_stop_freq_mhz": "GPR Stop MHz",
|
||||||
|
}
|
||||||
|
widgets = [getattr(self, widget_name) for widget_name in widget_names]
|
||||||
|
previous_values = {widget_name: getattr(self, widget_name).value() for widget_name in widget_names}
|
||||||
|
for widget in widgets:
|
||||||
|
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
|
||||||
|
changed = True
|
||||||
|
widget.blockSignals(True)
|
||||||
|
widget.setRange(radar_min_mhz, radar_max_mhz)
|
||||||
|
widget.blockSignals(False)
|
||||||
|
|
||||||
|
for widget in widgets:
|
||||||
|
clamped_value = min(max(widget.value(), radar_min_mhz), radar_max_mhz)
|
||||||
|
if clamped_value != widget.value():
|
||||||
|
changed = True
|
||||||
|
widget.blockSignals(True)
|
||||||
|
widget.setValue(clamped_value)
|
||||||
|
widget.blockSignals(False)
|
||||||
|
clamped_fields = []
|
||||||
|
for widget_name in widget_names:
|
||||||
|
current_value = getattr(self, widget_name).value()
|
||||||
|
previous_value = previous_values[widget_name]
|
||||||
|
if current_value == previous_value:
|
||||||
|
continue
|
||||||
|
clamped_fields.append(
|
||||||
|
f"{widget_labels.get(widget_name, widget_name)}: {previous_value:g} -> {current_value:g}"
|
||||||
|
)
|
||||||
|
if clamped_fields:
|
||||||
|
self._log_warning(
|
||||||
|
"Processing frequency limits were clamped to the active radar sweep.",
|
||||||
|
details="\n".join(clamped_fields),
|
||||||
|
)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
||||||
|
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
||||||
|
return self._sync_frequency_spinboxes_with_radar(
|
||||||
|
(
|
||||||
|
"_bscan_start_freq_mhz",
|
||||||
|
"_bscan_stop_freq_mhz",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_gpr_frequency_limits_with_radar(self) -> bool:
|
||||||
|
"""Synchronize GPR start/stop MHz widget ranges with radar sweep bounds."""
|
||||||
|
return self._sync_frequency_spinboxes_with_radar(
|
||||||
|
(
|
||||||
|
"_gpr_start_freq_mhz",
|
||||||
|
"_gpr_stop_freq_mhz",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""State builders and config parsing helpers for the main window."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from python_app.models.gui_profile_model import (
|
||||||
|
GuiBscanStateModel,
|
||||||
|
GuiDataActionsStateModel,
|
||||||
|
GuiGprStateModel,
|
||||||
|
GuiPassThroughStateModel,
|
||||||
|
GuiPreprocessDialogStateModel,
|
||||||
|
GuiProcessingStateModel,
|
||||||
|
GuiProfileModel,
|
||||||
|
GuiStateModel,
|
||||||
|
GuiSwitchStateModel,
|
||||||
|
)
|
||||||
|
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
|
||||||
|
from python_app.models.run_config_validation import validate_gpr_model
|
||||||
|
from python_app.orchestration.config_writer import parse_combos_from_text
|
||||||
|
from python_app.orchestration.preprocess_assets import (
|
||||||
|
PREPROCESS_ASSET_KEYS,
|
||||||
|
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||||
|
preprocess_asset_model,
|
||||||
|
)
|
||||||
|
from python_app.storage.npz_store import radar_key_from_config
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowConfigStateBuildersMixin:
|
||||||
|
"""Build stable and GUI-only config models from current widget state."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_csv_int_list(text: str) -> list[int]:
|
||||||
|
"""Parse comma-separated integer selection list."""
|
||||||
|
cleaned = text.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
values: list[int] = []
|
||||||
|
for part in cleaned.split(","):
|
||||||
|
token = part.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
values.append(int(token))
|
||||||
|
return values
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
|
||||||
|
"""Parse line-based Tx geometry editor text."""
|
||||||
|
entries: list[GprTxGeometryModel] = []
|
||||||
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"Invalid Tx geometry line {line_number}: expected `output_pos x_m`")
|
||||||
|
entries.append(
|
||||||
|
GprTxGeometryModel(
|
||||||
|
output_pos=int(parts[0]),
|
||||||
|
x_m=float(parts[1]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
|
||||||
|
"""Parse line-based Rx geometry editor text."""
|
||||||
|
entries: list[GprRxGeometryModel] = []
|
||||||
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"Invalid Rx geometry line {line_number}: expected `input_pos x_m`")
|
||||||
|
entries.append(
|
||||||
|
GprRxGeometryModel(
|
||||||
|
input_pos=int(parts[0]),
|
||||||
|
x_m=float(parts[1]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_combos_text_from_config(config: RunConfigModel) -> str:
|
||||||
|
"""Render configured combos for UI text editor, keeping full matrix as empty."""
|
||||||
|
combos = list(config.combos)
|
||||||
|
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||||
|
if len(combos) == len(full_combos) and all(
|
||||||
|
int(left.input) == int(right.input) and int(left.output) == int(right.output)
|
||||||
|
for left, right in zip(combos, full_combos, strict=True)
|
||||||
|
):
|
||||||
|
return ""
|
||||||
|
return ",".join(f"{int(combo.input)}:{int(combo.output)}" for combo in combos)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_gpr_input_positions_from_config(config: RunConfigModel) -> str:
|
||||||
|
"""Build default live GPR input-position selection from stable config."""
|
||||||
|
geometry_values = {int(entry.input_pos) for entry in config.gpr.rx_geometry}
|
||||||
|
combo_values = {int(combo.input) for combo in config.combos}
|
||||||
|
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
|
||||||
|
return ",".join(str(value) for value in values)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_gpr_output_positions_from_config(config: RunConfigModel) -> str:
|
||||||
|
"""Build default live GPR output-position selection from stable config."""
|
||||||
|
geometry_values = {int(entry.output_pos) for entry in config.gpr.tx_geometry}
|
||||||
|
combo_values = {int(combo.output) for combo in config.combos}
|
||||||
|
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
|
||||||
|
return ",".join(str(value) for value in values)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_gpr_visible_x_bounds_from_config(config: RunConfigModel) -> tuple[float, float]:
|
||||||
|
"""Build default visible X-range for GPR object-only rendering."""
|
||||||
|
x_values = [float(entry.x_m) for entry in config.gpr.tx_geometry]
|
||||||
|
x_values.extend(float(entry.x_m) for entry in config.gpr.rx_geometry)
|
||||||
|
if not x_values:
|
||||||
|
return (-2.0, 2.0)
|
||||||
|
margin_m = 2.0
|
||||||
|
return (min(x_values) - margin_m, max(x_values) + margin_m)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _history_limit_for_config(config: RunConfigModel) -> int:
|
||||||
|
"""Return unified GUI history limit derived from config ring capacities."""
|
||||||
|
return max(
|
||||||
|
1,
|
||||||
|
min(
|
||||||
|
int(config.rings.raw_tap.capacity),
|
||||||
|
int(config.rings.preprocessed_tap.capacity),
|
||||||
|
int(config.rings.results.capacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel:
|
||||||
|
"""Build fallback GUI-only defaults for a stable run config."""
|
||||||
|
default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
|
||||||
|
default_mode = "single" if len(config.combos) == 1 else "text"
|
||||||
|
default_gpr_x_min_m, default_gpr_x_max_m = self._default_gpr_visible_x_bounds_from_config(config)
|
||||||
|
return GuiStateModel(
|
||||||
|
switches=GuiSwitchStateModel(
|
||||||
|
combo_mode=default_mode,
|
||||||
|
combos_text=self._format_combos_text_from_config(config),
|
||||||
|
single_input=str(int(default_combo.input)),
|
||||||
|
single_output=str(int(default_combo.output)),
|
||||||
|
),
|
||||||
|
processing=GuiProcessingStateModel(
|
||||||
|
selected_mode="pass_through",
|
||||||
|
pass_through=GuiPassThroughStateModel(
|
||||||
|
show_magnitude=True,
|
||||||
|
show_phase=True,
|
||||||
|
fixed_y_enabled=False,
|
||||||
|
y_min_db=-100.0,
|
||||||
|
y_max_db=0.0,
|
||||||
|
),
|
||||||
|
bscan=GuiBscanStateModel(
|
||||||
|
axis="abs",
|
||||||
|
cut_m=0.824,
|
||||||
|
max_depth_m=1.0,
|
||||||
|
gain=1.0,
|
||||||
|
start_freq_mhz=100.0,
|
||||||
|
stop_freq_mhz=8800.0,
|
||||||
|
),
|
||||||
|
gpr=GuiGprStateModel(
|
||||||
|
input_positions=self._default_gpr_input_positions_from_config(config),
|
||||||
|
output_positions=self._default_gpr_output_positions_from_config(config),
|
||||||
|
min_depth_m=2.0,
|
||||||
|
max_depth_m=14.0,
|
||||||
|
comp_power=0.2,
|
||||||
|
start_freq_mhz=3000.0,
|
||||||
|
stop_freq_mhz=6000.0,
|
||||||
|
speed_m_s=0.0,
|
||||||
|
look_angle_deg=0.0,
|
||||||
|
snr_thresh=4.5,
|
||||||
|
snr_comp_max=25.0,
|
||||||
|
background_subtract_enabled=True,
|
||||||
|
background_mean_count=10,
|
||||||
|
render_mode="heatmap",
|
||||||
|
min_visible_pair_count=1,
|
||||||
|
visible_x_min_m=default_gpr_x_min_m,
|
||||||
|
visible_x_max_m=default_gpr_x_max_m,
|
||||||
|
visible_z_min_m=0.0,
|
||||||
|
visible_z_max_m=14.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data_actions=GuiDataActionsStateModel(
|
||||||
|
save_count=10,
|
||||||
|
save_path=str(self._project_root / "python_app/data/snapshots"),
|
||||||
|
save_name="snapshot_manual",
|
||||||
|
),
|
||||||
|
preprocess_dialog=GuiPreprocessDialogStateModel(set_name="set_001"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _current_preprocess_set_name(self) -> str:
|
||||||
|
"""Return current preprocess dialog set name, even when dialog is still closed."""
|
||||||
|
if self._preprocess_dialog is not None:
|
||||||
|
self._preprocess_set_name = self._preprocess_dialog.set_name()
|
||||||
|
return self._preprocess_set_name
|
||||||
|
|
||||||
|
def _build_gui_state(self) -> GuiStateModel:
|
||||||
|
"""Build GUI-only persistent state from current widget values."""
|
||||||
|
return GuiStateModel(
|
||||||
|
switches=GuiSwitchStateModel(
|
||||||
|
combo_mode="single" if self._single_combo_select_button.isChecked() else "text",
|
||||||
|
combos_text=self._combos_text.text().strip(),
|
||||||
|
single_input=self._single_combo_input.text().strip(),
|
||||||
|
single_output=self._single_combo_output.text().strip(),
|
||||||
|
),
|
||||||
|
processing=GuiProcessingStateModel(
|
||||||
|
selected_mode=self._processing_mode.currentText(),
|
||||||
|
pass_through=GuiPassThroughStateModel(
|
||||||
|
show_magnitude=bool(self._show_magnitude_checkbox.isChecked()),
|
||||||
|
show_phase=bool(self._show_phase_checkbox.isChecked()),
|
||||||
|
fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||||
|
y_min_db=float(self._pass_through_y_min_db.value()),
|
||||||
|
y_max_db=float(self._pass_through_y_max_db.value()),
|
||||||
|
),
|
||||||
|
bscan=GuiBscanStateModel(
|
||||||
|
axis=self._bscan_axis.currentText(),
|
||||||
|
cut_m=float(self._bscan_cut_m.value()),
|
||||||
|
max_depth_m=float(self._bscan_max_depth_m.value()),
|
||||||
|
gain=float(self._bscan_gain.value()),
|
||||||
|
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||||
|
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||||
|
),
|
||||||
|
gpr=GuiGprStateModel(
|
||||||
|
input_positions=self._gpr_input_positions_input.text().strip(),
|
||||||
|
output_positions=self._gpr_output_positions_input.text().strip(),
|
||||||
|
min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||||
|
max_depth_m=float(self._gpr_max_depth_m.value()),
|
||||||
|
comp_power=float(self._gpr_comp_power.value()),
|
||||||
|
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||||
|
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
||||||
|
speed_m_s=float(self._gpr_speed_m_s.value()),
|
||||||
|
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
||||||
|
snr_thresh=float(self._gpr_snr_thresh.value()),
|
||||||
|
snr_comp_max=float(self._gpr_snr_comp_max.value()),
|
||||||
|
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||||
|
background_mean_count=int(self._gpr_background_mean_count.value()),
|
||||||
|
render_mode=self._gpr_render_mode.currentText(),
|
||||||
|
min_visible_pair_count=int(self._gpr_min_visible_pair_count.value()),
|
||||||
|
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
|
||||||
|
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
|
||||||
|
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
||||||
|
visible_z_max_m=float(self._gpr_visible_z_max_m.value()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data_actions=GuiDataActionsStateModel(
|
||||||
|
save_count=int(self._save_count.value()),
|
||||||
|
save_path=self._save_path_input.text().strip(),
|
||||||
|
save_name=self._save_name_input.text().strip(),
|
||||||
|
),
|
||||||
|
preprocess_dialog=GuiPreprocessDialogStateModel(
|
||||||
|
set_name=self._current_preprocess_set_name(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_gui_profile(self) -> GuiProfileModel:
|
||||||
|
"""Build full GUI config profile from current window state."""
|
||||||
|
return GuiProfileModel(
|
||||||
|
run_config=self._build_config(),
|
||||||
|
gui=self._build_gui_state(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_config(self) -> RunConfigModel:
|
||||||
|
"""Build `RunConfigModel` from current GUI widget values."""
|
||||||
|
config = self._defaults_config.clone()
|
||||||
|
|
||||||
|
config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
|
||||||
|
config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
|
||||||
|
config.radar.sweep.points = int(self._points_input.text().strip())
|
||||||
|
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
|
||||||
|
config.radar.sweep.power_dbm = float(self._power_input.text().strip())
|
||||||
|
|
||||||
|
config.runtime.settling_ms = int(self._settling_ms.text().strip())
|
||||||
|
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
|
||||||
|
|
||||||
|
if self._single_combo_select_button.isChecked():
|
||||||
|
config.combos = [
|
||||||
|
ComboModel(
|
||||||
|
input=int(self._single_combo_input.text().strip()),
|
||||||
|
output=int(self._single_combo_output.text().strip()),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
combo_text = self._combos_text.text()
|
||||||
|
config.combos = parse_combos_from_text(combo_text)
|
||||||
|
config.ensure_combos()
|
||||||
|
if self._switches_are_effectively_static(config):
|
||||||
|
config.combos = [ComboModel(input=0, output=0)]
|
||||||
|
|
||||||
|
for key in PREPROCESS_ASSET_KEYS:
|
||||||
|
preprocess_asset_model(config, key).bundle_path = ""
|
||||||
|
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
|
||||||
|
preprocess_asset_model(config, key).set_name = self._selected_preprocess_sets.get(key, "")
|
||||||
|
config.gpr.mode = self._gpr_config_mode.currentText()
|
||||||
|
config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
|
||||||
|
config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
|
||||||
|
config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
|
||||||
|
validate_gpr_model(
|
||||||
|
config.gpr,
|
||||||
|
input_switch_positions=config.input_switch.positions,
|
||||||
|
output_switch_positions=config.output_switch.positions,
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _radar_key(self, config: RunConfigModel) -> str:
|
||||||
|
"""Build radar key used by preprocess-set storage lookup."""
|
||||||
|
return radar_key_from_config(
|
||||||
|
model_name=config.radar.model,
|
||||||
|
serial=config.radar.serial,
|
||||||
|
sweep_start_hz=config.radar.sweep.start_hz,
|
||||||
|
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||||
|
sweep_points=config.radar.sweep.points,
|
||||||
|
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||||
|
power_dbm=config.radar.sweep.power_dbm,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _radar_key_from_ui(self) -> str:
|
||||||
|
"""Build current radar key directly from radar widgets only."""
|
||||||
|
return radar_key_from_config(
|
||||||
|
model_name=self._defaults_config.radar.model,
|
||||||
|
serial=self._serial_input.text().strip(),
|
||||||
|
sweep_start_hz=float(self._start_hz_input.text().strip()),
|
||||||
|
sweep_stop_hz=float(self._stop_hz_input.text().strip()),
|
||||||
|
sweep_points=int(self._points_input.text().strip()),
|
||||||
|
ifbw_hz=float(self._ifbw_input.text().strip()),
|
||||||
|
power_dbm=float(self._power_input.text().strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
|
||||||
|
"""Return `True` when switch setup effectively yields one fixed combo."""
|
||||||
|
has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1
|
||||||
|
both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
|
||||||
|
return has_single_position or both_mock
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from python_app.gui.runtime.history import build_run_history_signature, record_r
|
|||||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||||
from python_app.models.run_config_model import RunConfigModel
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
|
from python_app.orchestration.gpr_locator import collection_has_gpr_payloads
|
||||||
from python_app.orchestration.preprocess_assets import (
|
from python_app.orchestration.preprocess_assets import (
|
||||||
PREPROCESS_ASSET_SPECS,
|
PREPROCESS_ASSET_SPECS,
|
||||||
REQUIRED_PREPROCESS_ASSET_KEYS,
|
REQUIRED_PREPROCESS_ASSET_KEYS,
|
||||||
@@ -260,6 +261,7 @@ class AppWindowPipelineMixin:
|
|||||||
self._log_error(report.format())
|
self._log_error(report.format())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
self._drain_locator_speed_updates()
|
||||||
if self._raw_reader is not None:
|
if self._raw_reader is not None:
|
||||||
self._read_all_raw()
|
self._read_all_raw()
|
||||||
self._read_all_preprocessed()
|
self._read_all_preprocessed()
|
||||||
@@ -353,6 +355,11 @@ class AppWindowPipelineMixin:
|
|||||||
break
|
break
|
||||||
if record_result_history(self._result_history, collection):
|
if record_result_history(self._result_history, collection):
|
||||||
latest = collection
|
latest = collection
|
||||||
|
if (
|
||||||
|
self._processing_mode.currentText() == "gpr"
|
||||||
|
and collection_has_gpr_payloads(collection)
|
||||||
|
):
|
||||||
|
self._publish_locator_snapshot_from_collection(collection)
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
def _drain_rings_once_for_history(self) -> None:
|
def _drain_rings_once_for_history(self) -> None:
|
||||||
@@ -452,3 +459,35 @@ class AppWindowPipelineMixin:
|
|||||||
config,
|
config,
|
||||||
self._live_processing_config(),
|
self._live_processing_config(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _drain_locator_speed_updates(self) -> None:
|
||||||
|
"""Apply queued speed updates received by the embedded locator server."""
|
||||||
|
latest_speed = self._locator_service.drain_speed_updates()
|
||||||
|
if latest_speed is None:
|
||||||
|
return
|
||||||
|
self._apply_external_gpr_speed_update(0.0) # TODO: remove temporary stub and apply real locator client speed.
|
||||||
|
|
||||||
|
def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None:
|
||||||
|
"""Publish one locator snapshot from a GPR result collection."""
|
||||||
|
self._locator_service.publish_collection(
|
||||||
|
collection,
|
||||||
|
float(self._gpr_min_visible_pair_count.value()),
|
||||||
|
visible_bounds=self._gpr_visible_object_bounds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _publish_locator_snapshot_from_latest_result(self) -> None:
|
||||||
|
"""Publish current locator-visible snapshot from latest cached GPR result."""
|
||||||
|
if self._processing_mode.currentText() != "gpr":
|
||||||
|
self._locator_service.publish_empty()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._result_history:
|
||||||
|
self._locator_service.publish_empty()
|
||||||
|
return
|
||||||
|
|
||||||
|
latest = self._result_history[-1]
|
||||||
|
if not collection_has_gpr_payloads(latest):
|
||||||
|
self._locator_service.publish_empty()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._publish_locator_snapshot_from_collection(latest)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Plot-rendering mixins split by rendering mode."""
|
||||||
|
|
||||||
|
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
|
||||||
|
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
||||||
|
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppWindowBscanPlotMixin",
|
||||||
|
"AppWindowGprPlotMixin",
|
||||||
|
"AppWindowTracePlotMixin",
|
||||||
|
]
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""B-scan rendering and cache helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QRectF
|
||||||
|
import numpy as np
|
||||||
|
import pyqtgraph as pg
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ResultCollection
|
||||||
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _result_tail(
|
||||||
|
*,
|
||||||
|
result_history: list[ResultCollection],
|
||||||
|
history_limit: int,
|
||||||
|
floor_collection_id: int,
|
||||||
|
) -> list[ResultCollection]:
|
||||||
|
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
|
||||||
|
filtered = [
|
||||||
|
collection
|
||||||
|
for collection in result_history[-history_limit:]
|
||||||
|
if int(collection.collection_id) > int(floor_collection_id)
|
||||||
|
]
|
||||||
|
unique_reversed_tail: list[ResultCollection] = []
|
||||||
|
seen_keys: set[tuple[int, int]] = set()
|
||||||
|
for collection in reversed(filtered):
|
||||||
|
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
unique_reversed_tail.append(collection)
|
||||||
|
|
||||||
|
unique_reversed_tail.reverse()
|
||||||
|
return unique_reversed_tail
|
||||||
|
|
||||||
|
|
||||||
|
def build_bscan_signature(
|
||||||
|
live_config: ProcessingLiveConfig,
|
||||||
|
result_history: list[ResultCollection],
|
||||||
|
history_limit: int,
|
||||||
|
floor_collection_id: int,
|
||||||
|
) -> tuple[object, ...]:
|
||||||
|
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
||||||
|
result_tail = _result_tail(
|
||||||
|
result_history=result_history,
|
||||||
|
history_limit=history_limit,
|
||||||
|
floor_collection_id=floor_collection_id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
str(live_config.bscan_axis),
|
||||||
|
str(live_config.bscan_channel),
|
||||||
|
float(live_config.bscan_cut_m),
|
||||||
|
float(live_config.bscan_max_depth_m),
|
||||||
|
float(live_config.bscan_gain),
|
||||||
|
float(live_config.bscan_start_freq_mhz),
|
||||||
|
float(live_config.bscan_stop_freq_mhz),
|
||||||
|
int(floor_collection_id),
|
||||||
|
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_bscan_history_from_results(
|
||||||
|
result_history: list[ResultCollection],
|
||||||
|
history_limit: int,
|
||||||
|
floor_collection_id: int,
|
||||||
|
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||||
|
"""Rebuild B-scan history and depth axes from processed result payloads."""
|
||||||
|
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||||
|
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||||
|
|
||||||
|
result_tail = _result_tail(
|
||||||
|
result_history=result_history,
|
||||||
|
history_limit=history_limit,
|
||||||
|
floor_collection_id=floor_collection_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for collection in result_tail:
|
||||||
|
for block in collection.blocks:
|
||||||
|
key = (block.combo.input_pos, block.combo.output_pos)
|
||||||
|
for payload in block.payloads:
|
||||||
|
if payload.kind != 1 or payload.processing_name != "bscan":
|
||||||
|
continue
|
||||||
|
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
|
||||||
|
continue
|
||||||
|
if payload.frequency_hz.size != payload.trace.size:
|
||||||
|
continue
|
||||||
|
|
||||||
|
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||||
|
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
|
||||||
|
if depth_axis.size == 0 or amplitudes.size == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
history = history_by_combo.get(key)
|
||||||
|
stored_axis = depth_axis_by_combo.get(key)
|
||||||
|
if (
|
||||||
|
history is None
|
||||||
|
or stored_axis is None
|
||||||
|
or stored_axis.shape != depth_axis.shape
|
||||||
|
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||||
|
):
|
||||||
|
history = deque(maxlen=history_limit)
|
||||||
|
history_by_combo[key] = history
|
||||||
|
depth_axis_by_combo[key] = depth_axis.copy()
|
||||||
|
|
||||||
|
history.append(amplitudes.copy())
|
||||||
|
|
||||||
|
return history_by_combo, depth_axis_by_combo
|
||||||
|
|
||||||
|
|
||||||
|
def pick_bscan_display_key(
|
||||||
|
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
"""Choose combo key to display when multiple histories are present."""
|
||||||
|
if not history_by_combo:
|
||||||
|
return None
|
||||||
|
return next(iter(history_by_combo.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
|
||||||
|
"""Build B-scan colormap table for selected axis mode."""
|
||||||
|
if axis_mode == "abs":
|
||||||
|
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
|
||||||
|
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||||
|
"""Interpolate hex color stops into 8-bit RGB LUT array."""
|
||||||
|
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
|
||||||
|
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
|
||||||
|
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
|
||||||
|
|
||||||
|
lut = np.empty((size, 3), dtype=np.uint8)
|
||||||
|
for channel in range(3):
|
||||||
|
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
|
||||||
|
return lut
|
||||||
|
|
||||||
|
|
||||||
|
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||||
|
"""Compute image levels for B-scan data based on axis mode."""
|
||||||
|
min_value = float(np.min(sweeps))
|
||||||
|
max_value = float(np.max(sweeps))
|
||||||
|
if axis_mode == "abs":
|
||||||
|
if max_value <= min_value:
|
||||||
|
return min_value, min_value + 1e-6
|
||||||
|
return min_value, max_value
|
||||||
|
|
||||||
|
max_abs = max(abs(min_value), abs(max_value), 1e-6)
|
||||||
|
return -max_abs, max_abs
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowBscanPlotMixin:
|
||||||
|
"""Renders B-scan heatmaps and maintains B-scan history caches."""
|
||||||
|
|
||||||
|
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
|
||||||
|
"""Draw B-scan image rebuilt from processed result history."""
|
||||||
|
self._disable_phase_axis()
|
||||||
|
self._sync_bscan_history_from_results()
|
||||||
|
return self._draw_bscan_heatmap_from_history()
|
||||||
|
|
||||||
|
def _configure_bscan_plot_axes(self) -> None:
|
||||||
|
"""Apply persistent B-scan plot axis labels and base view settings."""
|
||||||
|
plot = self._bscan_plot
|
||||||
|
plot_item = plot.getPlotItem()
|
||||||
|
plot_item.showAxis("left", show=True)
|
||||||
|
plot_item.showAxis("bottom", show=True)
|
||||||
|
plot.setLabel("bottom", "Sweep #")
|
||||||
|
plot.setLabel("left", "Range", units="m")
|
||||||
|
view_box = plot.getViewBox()
|
||||||
|
view_box.invertY(False)
|
||||||
|
view_box.enableAutoRange(x=False, y=False)
|
||||||
|
|
||||||
|
def _draw_bscan_heatmap_from_history(self) -> bool:
|
||||||
|
"""Render B-scan heatmap from currently cached history arrays."""
|
||||||
|
display_key = self._pick_bscan_display_key()
|
||||||
|
if display_key is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
history = self._bscan_history_by_combo.get(display_key)
|
||||||
|
depth_axis = self._bscan_depth_axis_by_combo.get(display_key)
|
||||||
|
if not history or depth_axis is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
sweeps = np.vstack(history).astype(np.float32, copy=False)
|
||||||
|
if sweeps.size == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
depth_min = float(np.min(depth_axis))
|
||||||
|
depth_max = float(np.max(depth_axis))
|
||||||
|
depth_span = max(depth_max - depth_min, 1e-6)
|
||||||
|
sweep_count = sweeps.shape[0]
|
||||||
|
sweep_width = float(max(sweep_count, 1))
|
||||||
|
x_min = 0.5
|
||||||
|
x_max = x_min + sweep_width
|
||||||
|
|
||||||
|
image_item = pg.ImageItem(axisOrder="row-major")
|
||||||
|
image_item.setImage(sweeps.T, autoLevels=False)
|
||||||
|
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
|
||||||
|
|
||||||
|
axis_mode = self._bscan_axis.currentText()
|
||||||
|
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
|
||||||
|
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
|
||||||
|
|
||||||
|
self._bscan_plot.clear()
|
||||||
|
self._configure_bscan_plot_axes()
|
||||||
|
self._bscan_plot.addItem(image_item)
|
||||||
|
self._bscan_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02)
|
||||||
|
bscan_channel = "S21"
|
||||||
|
self._bscan_plot.setTitle(
|
||||||
|
f"B-scan {bscan_channel} in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _sync_bscan_history_from_results(self) -> None:
|
||||||
|
"""Rebuild B-scan history cache when live params or inputs changed."""
|
||||||
|
self._advance_bscan_floor_to_cpp_window()
|
||||||
|
signature = self._bscan_signature()
|
||||||
|
if signature == self._bscan_render_signature:
|
||||||
|
return
|
||||||
|
self._rebuild_bscan_history_from_results()
|
||||||
|
self._bscan_render_signature = signature
|
||||||
|
|
||||||
|
def _bscan_signature(self) -> tuple[object, ...]:
|
||||||
|
"""Build state signature for B-scan history cache invalidation."""
|
||||||
|
live_config = self._live_processing_config()
|
||||||
|
result_history = list(self._result_history)
|
||||||
|
return build_bscan_signature(
|
||||||
|
live_config=live_config,
|
||||||
|
result_history=result_history,
|
||||||
|
history_limit=self._bscan_history_limit,
|
||||||
|
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rebuild_bscan_history_from_results(self) -> None:
|
||||||
|
"""Recompute B-scan history cache from results history buffer."""
|
||||||
|
result_history = list(self._result_history)
|
||||||
|
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
||||||
|
result_history=result_history,
|
||||||
|
history_limit=self._bscan_history_limit,
|
||||||
|
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||||
|
)
|
||||||
|
self._bscan_history_by_combo = history_by_combo
|
||||||
|
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
||||||
|
|
||||||
|
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
|
||||||
|
"""Choose combo history key to render."""
|
||||||
|
display_key = pick_bscan_display_key(self._bscan_history_by_combo)
|
||||||
|
available_keys = sorted(self._bscan_history_by_combo.keys())
|
||||||
|
if display_key is not None and len(available_keys) > 1:
|
||||||
|
combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys)
|
||||||
|
details = "\n".join(
|
||||||
|
f"- in{input_pos}/out{output_pos}"
|
||||||
|
for input_pos, output_pos in available_keys
|
||||||
|
)
|
||||||
|
self._log(
|
||||||
|
f"B-scan auto-selected combo in{display_key[0]}/out{display_key[1]} because multiple combos are available.",
|
||||||
|
once_key=f"bscan_auto_display_{combo_signature}",
|
||||||
|
)
|
||||||
|
self._log_warning(
|
||||||
|
"B-scan has multiple combo histories but the UI currently renders only one at a time.",
|
||||||
|
details=details,
|
||||||
|
once_key=f"bscan_multi_combo_warning_{combo_signature}",
|
||||||
|
)
|
||||||
|
return display_key
|
||||||
|
|
||||||
|
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
|
||||||
|
"""Return lookup table for current B-scan axis mode."""
|
||||||
|
return bscan_lookup_table(axis_mode)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||||
|
"""Backward-compatible wrapper around LUT builder."""
|
||||||
|
return build_lut(stops, size=size)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||||
|
"""Return display levels for B-scan image."""
|
||||||
|
return bscan_levels(sweeps, axis_mode)
|
||||||
|
|
||||||
|
def _clear_bscan_plot_history(self) -> None:
|
||||||
|
"""Drop cached B-scan history and invalidate cache signature."""
|
||||||
|
self._bscan_history_by_combo.clear()
|
||||||
|
self._bscan_depth_axis_by_combo.clear()
|
||||||
|
self._bscan_render_signature = None
|
||||||
|
|
||||||
|
def _advance_bscan_floor_to_cpp_window(self) -> None:
|
||||||
|
"""Clamp B-scan source history to C++ available replay window."""
|
||||||
|
if not self._result_history:
|
||||||
|
return
|
||||||
|
|
||||||
|
cpp_window_limit = min(
|
||||||
|
int(self._defaults_config.rings.preprocessed.capacity),
|
||||||
|
int(self._defaults_config.rings.results.capacity),
|
||||||
|
)
|
||||||
|
cpp_window_limit = max(1, cpp_window_limit)
|
||||||
|
latest_collection_id = int(self._result_history[-1].collection_id)
|
||||||
|
current_floor = int(self._bscan_history_floor_collection_id)
|
||||||
|
|
||||||
|
# Collection ids restart from 1 on new C++ run; release floor only while
|
||||||
|
# acquisition is running, so manual "remove last" behavior in stopped mode
|
||||||
|
# remains deterministic.
|
||||||
|
if latest_collection_id < current_floor and self._supervisor.is_running():
|
||||||
|
self._bscan_history_floor_collection_id = 0
|
||||||
|
current_floor = 0
|
||||||
|
|
||||||
|
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
|
||||||
|
if floor_candidate > current_floor:
|
||||||
|
self._bscan_history_floor_collection_id = floor_candidate
|
||||||
|
|
||||||
|
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
||||||
|
"""Create or return secondary right-axis ViewBox for phase curves."""
|
||||||
|
plot_item = self._bscan_plot.getPlotItem()
|
||||||
|
phase_view_box = self._phase_viewbox
|
||||||
|
if phase_view_box is None:
|
||||||
|
phase_view_box = pg.ViewBox()
|
||||||
|
self._phase_viewbox = phase_view_box
|
||||||
|
plot_item.scene().addItem(phase_view_box)
|
||||||
|
plot_item.getAxis("right").linkToView(phase_view_box)
|
||||||
|
phase_view_box.setXLink(plot_item.vb)
|
||||||
|
plot_item.vb.sigResized.connect(self._update_phase_view_box_geometry)
|
||||||
|
self._update_phase_view_box_geometry()
|
||||||
|
return phase_view_box
|
||||||
|
|
||||||
|
def _update_phase_view_box_geometry(self) -> None:
|
||||||
|
"""Keep right-axis ViewBox geometry in sync with main plot ViewBox."""
|
||||||
|
phase_view_box = self._phase_viewbox
|
||||||
|
if phase_view_box is None:
|
||||||
|
return
|
||||||
|
plot_item = self._bscan_plot.getPlotItem()
|
||||||
|
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
|
||||||
|
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
|
||||||
|
|
||||||
|
def _clear_phase_overlay(self) -> None:
|
||||||
|
"""Remove all phase curves from secondary ViewBox."""
|
||||||
|
self._trace_phase_plot.clear()
|
||||||
|
|
||||||
|
def _disable_phase_axis(self) -> None:
|
||||||
|
"""Hide right axis and clear phase overlay when phase is not rendered."""
|
||||||
|
self._clear_phase_overlay()
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
"""GPR heatmap and object-only rendering helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QRectF
|
||||||
|
import numpy as np
|
||||||
|
import pyqtgraph as pg
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ResultCollection
|
||||||
|
from python_app.orchestration.gpr_locator import (
|
||||||
|
collection_payload_by_name as gpr_collection_payload_by_name,
|
||||||
|
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
||||||
|
gpr_object_rows as extract_gpr_object_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowGprPlotMixin:
|
||||||
|
"""Renders GPR accumulator heatmaps and detected object overlays."""
|
||||||
|
|
||||||
|
def _clear_gpr_plot(self) -> None:
|
||||||
|
"""Clear latest GPR plot surface."""
|
||||||
|
if not hasattr(self, "_gpr_plot"):
|
||||||
|
return
|
||||||
|
self._configure_gpr_plot_axes()
|
||||||
|
self._clear_gpr_point_labels()
|
||||||
|
self._clear_gpr_region_labels()
|
||||||
|
self._clear_gpr_region_masks()
|
||||||
|
if self._gpr_image_item is not None:
|
||||||
|
self._gpr_image_item.hide()
|
||||||
|
if self._gpr_tx_item is not None:
|
||||||
|
self._gpr_tx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_tx_item.hide()
|
||||||
|
if self._gpr_rx_item is not None:
|
||||||
|
self._gpr_rx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_rx_item.hide()
|
||||||
|
if self._gpr_points_item is not None:
|
||||||
|
self._gpr_points_item.setData(x=[], y=[])
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
if self._gpr_region_centers_item is not None:
|
||||||
|
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
self._gpr_plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
|
||||||
|
|
||||||
|
def _configure_gpr_plot_axes(self) -> None:
|
||||||
|
"""Apply persistent GPR plot axis labels and base view settings."""
|
||||||
|
plot = self._gpr_plot
|
||||||
|
plot_item = plot.getPlotItem()
|
||||||
|
plot_item.showAxis("left", show=True)
|
||||||
|
plot_item.showAxis("bottom", show=True)
|
||||||
|
plot_item.setClipToView(True)
|
||||||
|
plot.setLabel("bottom", "X", units="m")
|
||||||
|
plot.setLabel("left", "Range", units="m")
|
||||||
|
view_box = plot.getViewBox()
|
||||||
|
view_box.invertY(False)
|
||||||
|
view_box.enableAutoRange(x=False, y=False)
|
||||||
|
|
||||||
|
def _ensure_gpr_plot_items(self) -> None:
|
||||||
|
"""Create persistent GPR plot items once and reuse them on redraw."""
|
||||||
|
if self._gpr_image_item is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
plot = self._gpr_plot
|
||||||
|
self._configure_gpr_plot_axes()
|
||||||
|
|
||||||
|
if self._gpr_lookup_table is None:
|
||||||
|
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
|
||||||
|
|
||||||
|
self._gpr_image_item = pg.ImageItem(axisOrder="row-major")
|
||||||
|
self._gpr_image_item.setZValue(0)
|
||||||
|
self._gpr_image_item.hide()
|
||||||
|
plot.addItem(self._gpr_image_item)
|
||||||
|
|
||||||
|
self._gpr_tx_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_tx_item.setZValue(20)
|
||||||
|
self._gpr_tx_item.hide()
|
||||||
|
plot.addItem(self._gpr_tx_item)
|
||||||
|
|
||||||
|
self._gpr_rx_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_rx_item.setZValue(20)
|
||||||
|
self._gpr_rx_item.hide()
|
||||||
|
plot.addItem(self._gpr_rx_item)
|
||||||
|
|
||||||
|
self._gpr_points_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_points_item.setZValue(30)
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
plot.addItem(self._gpr_points_item)
|
||||||
|
|
||||||
|
self._gpr_region_centers_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_region_centers_item.setZValue(30)
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
plot.addItem(self._gpr_region_centers_item)
|
||||||
|
|
||||||
|
def _clear_gpr_point_labels(self) -> None:
|
||||||
|
"""Remove dynamic point-score labels from GPR plot."""
|
||||||
|
for item in self._gpr_point_labels:
|
||||||
|
try:
|
||||||
|
self._gpr_plot.removeItem(item)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to remove GPR point label: {type(exc).__name__}: {exc}",
|
||||||
|
once_key="gpr_remove_point_label_failed",
|
||||||
|
)
|
||||||
|
self._gpr_point_labels.clear()
|
||||||
|
|
||||||
|
def _clear_gpr_region_labels(self) -> None:
|
||||||
|
"""Remove dynamic region labels from GPR plot."""
|
||||||
|
for item in self._gpr_region_center_labels:
|
||||||
|
try:
|
||||||
|
self._gpr_plot.removeItem(item)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to remove GPR region label: {type(exc).__name__}: {exc}",
|
||||||
|
once_key="gpr_remove_region_label_failed",
|
||||||
|
)
|
||||||
|
self._gpr_region_center_labels.clear()
|
||||||
|
|
||||||
|
def _clear_gpr_region_masks(self) -> None:
|
||||||
|
"""Remove dynamic region contour carriers from GPR plot."""
|
||||||
|
for item in self._gpr_region_mask_items:
|
||||||
|
try:
|
||||||
|
self._gpr_plot.removeItem(item)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to remove GPR region mask: {type(exc).__name__}: {exc}",
|
||||||
|
once_key="gpr_remove_region_mask_failed",
|
||||||
|
)
|
||||||
|
self._gpr_region_mask_items.clear()
|
||||||
|
self._gpr_region_contours.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collection_payload_by_name(collection: ResultCollection, name: str, kind: int | None = None):
|
||||||
|
"""Return first collection payload matching name and optional kind."""
|
||||||
|
return gpr_collection_payload_by_name(collection, name, kind)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collection_payloads_by_prefix(collection: ResultCollection, prefix: str, kind: int | None = None):
|
||||||
|
"""Return collection payloads matching processing-name prefix."""
|
||||||
|
return gpr_collection_payloads_by_prefix(collection, prefix, kind)
|
||||||
|
|
||||||
|
def _selected_gpr_geometry(self) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Resolve selected Tx/Rx geometry arrays for current GPR selection."""
|
||||||
|
requested_inputs = tuple(self._parse_csv_int_list(self._gpr_input_positions_input.text()))
|
||||||
|
requested_outputs = tuple(self._parse_csv_int_list(self._gpr_output_positions_input.text()))
|
||||||
|
signature = (
|
||||||
|
self._gpr_tx_geometry_input.toPlainText(),
|
||||||
|
self._gpr_rx_geometry_input.toPlainText(),
|
||||||
|
requested_inputs,
|
||||||
|
requested_outputs,
|
||||||
|
)
|
||||||
|
if signature == self._gpr_geometry_signature and self._gpr_selected_geometry is not None:
|
||||||
|
return self._gpr_selected_geometry
|
||||||
|
|
||||||
|
tx_entries = self._parse_gpr_tx_geometry_text(signature[0])
|
||||||
|
rx_entries = self._parse_gpr_rx_geometry_text(signature[1])
|
||||||
|
requested_input_set = set(requested_inputs)
|
||||||
|
requested_output_set = set(requested_outputs)
|
||||||
|
|
||||||
|
rx_entries = sorted(rx_entries, key=lambda entry: int(entry.input_pos))
|
||||||
|
tx_entries = sorted(tx_entries, key=lambda entry: int(entry.output_pos))
|
||||||
|
if requested_input_set:
|
||||||
|
rx_entries = [entry for entry in rx_entries if int(entry.input_pos) in requested_input_set]
|
||||||
|
if requested_output_set:
|
||||||
|
tx_entries = [entry for entry in tx_entries if int(entry.output_pos) in requested_output_set]
|
||||||
|
|
||||||
|
x_tx = np.asarray([float(entry.x_m) for entry in tx_entries], dtype=np.float32)
|
||||||
|
x_rx = np.asarray([float(entry.x_m) for entry in rx_entries], dtype=np.float32)
|
||||||
|
self._gpr_geometry_signature = signature
|
||||||
|
self._gpr_selected_geometry = (x_tx, x_rx)
|
||||||
|
return self._gpr_selected_geometry
|
||||||
|
|
||||||
|
def _draw_gpr_map(self, collection: ResultCollection) -> bool:
|
||||||
|
"""Draw latest collection-level GPR plot according to current render mode."""
|
||||||
|
if self._gpr_render_mode.currentText() == "objects_only":
|
||||||
|
return self._draw_gpr_objects_only(collection)
|
||||||
|
return self._draw_gpr_heatmap(collection)
|
||||||
|
|
||||||
|
def _draw_gpr_heatmap(self, collection: ResultCollection) -> bool:
|
||||||
|
"""Draw latest collection-level GPR accumulator and annotations."""
|
||||||
|
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
|
||||||
|
if accumulator_payload is None:
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return False
|
||||||
|
|
||||||
|
image = np.asarray(accumulator_payload.image, dtype=np.float32)
|
||||||
|
x_axis = np.asarray(accumulator_payload.image_x_axis, dtype=np.float32)
|
||||||
|
y_axis = np.asarray(accumulator_payload.image_y_axis, dtype=np.float32)
|
||||||
|
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return False
|
||||||
|
|
||||||
|
x_min = float(x_axis[0])
|
||||||
|
x_max = float(x_axis[-1])
|
||||||
|
y_min = float(y_axis[0])
|
||||||
|
y_max = float(y_axis[-1])
|
||||||
|
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
|
||||||
|
|
||||||
|
plot = self._gpr_plot
|
||||||
|
plot.setUpdatesEnabled(False)
|
||||||
|
try:
|
||||||
|
self._ensure_gpr_plot_items()
|
||||||
|
plot.getViewBox().invertY(False)
|
||||||
|
self._clear_gpr_point_labels()
|
||||||
|
self._clear_gpr_region_labels()
|
||||||
|
self._clear_gpr_region_masks()
|
||||||
|
|
||||||
|
self._gpr_image_item.setImage(image, autoLevels=False)
|
||||||
|
self._gpr_image_item.setRect(rect)
|
||||||
|
self._gpr_image_item.setLookupTable(self._gpr_lookup_table)
|
||||||
|
self._gpr_image_item.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
|
||||||
|
self._gpr_image_item.show()
|
||||||
|
|
||||||
|
plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
plot.setYRange(self._gpr_display_y_min(y_min, y_max), y_max, padding=0.02)
|
||||||
|
|
||||||
|
self._draw_gpr_geometry_markers()
|
||||||
|
|
||||||
|
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
|
||||||
|
if points_payload is not None and np.asarray(points_payload.table).size > 0:
|
||||||
|
points = np.asarray(points_payload.table, dtype=np.float32)
|
||||||
|
self._gpr_points_item.setData(
|
||||||
|
x=points[:, 0],
|
||||||
|
y=points[:, 1],
|
||||||
|
symbol="d",
|
||||||
|
size=11,
|
||||||
|
brush=pg.mkBrush("#ffffff"),
|
||||||
|
pen=pg.mkPen("#111111", width=1.1),
|
||||||
|
)
|
||||||
|
self._gpr_points_item.show()
|
||||||
|
for x_value, y_value, score in points:
|
||||||
|
label = pg.TextItem(text=f"{float(score):.0f}", color="#ffffff", anchor=(0.0, 1.0))
|
||||||
|
label.setZValue(40)
|
||||||
|
label.setPos(float(x_value), float(y_value))
|
||||||
|
plot.addItem(label)
|
||||||
|
self._gpr_point_labels.append(label)
|
||||||
|
else:
|
||||||
|
self._gpr_points_item.setData(x=[], y=[])
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
|
||||||
|
region_centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
|
||||||
|
if region_centers_payload is not None and np.asarray(region_centers_payload.table).size > 0:
|
||||||
|
centers = np.asarray(region_centers_payload.table, dtype=np.float32)
|
||||||
|
self._gpr_region_centers_item.setData(
|
||||||
|
x=centers[:, 0],
|
||||||
|
y=centers[:, 1],
|
||||||
|
symbol="o",
|
||||||
|
size=10,
|
||||||
|
brush=pg.mkBrush("#80ed99"),
|
||||||
|
pen=pg.mkPen("#081c15", width=1.1),
|
||||||
|
)
|
||||||
|
self._gpr_region_centers_item.show()
|
||||||
|
for row in centers:
|
||||||
|
label = pg.TextItem(text=f"{float(row[2]):.0f}", color="#d8f3dc", anchor=(0.0, 1.0))
|
||||||
|
label.setZValue(40)
|
||||||
|
label.setPos(float(row[0]), float(row[1]))
|
||||||
|
plot.addItem(label)
|
||||||
|
self._gpr_region_center_labels.append(label)
|
||||||
|
else:
|
||||||
|
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
|
||||||
|
for payload in self._collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
|
||||||
|
mask = np.asarray(payload.image, dtype=np.float32)
|
||||||
|
if mask.ndim != 2 or mask.size == 0:
|
||||||
|
continue
|
||||||
|
mask_image = pg.ImageItem(axisOrder="row-major")
|
||||||
|
mask_image.setZValue(5)
|
||||||
|
mask_image.setImage(mask, autoLevels=False)
|
||||||
|
mask_image.setRect(rect)
|
||||||
|
mask_image.setOpacity(0.0)
|
||||||
|
plot.addItem(mask_image)
|
||||||
|
contour = pg.IsocurveItem(data=mask, level=0.5, pen=pg.mkPen("#4cc9f0", width=1.3))
|
||||||
|
contour.setParentItem(mask_image)
|
||||||
|
self._gpr_region_mask_items.append(mask_image)
|
||||||
|
self._gpr_region_contours.append(contour)
|
||||||
|
|
||||||
|
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
|
||||||
|
finally:
|
||||||
|
plot.setUpdatesEnabled(True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
|
||||||
|
"""Return ordered display bounds with a non-zero span."""
|
||||||
|
lower = min(float(start), float(stop))
|
||||||
|
upper = max(float(start), float(stop))
|
||||||
|
if upper - lower >= minimum_span:
|
||||||
|
return lower, upper
|
||||||
|
center = 0.5 * (lower + upper)
|
||||||
|
half_span = 0.5 * minimum_span
|
||||||
|
return center - half_span, center + half_span
|
||||||
|
|
||||||
|
def _gpr_visible_object_bounds(self) -> tuple[float, float, float, float]:
|
||||||
|
"""Return normalized object-only visible X/Z bounds from GUI controls."""
|
||||||
|
x_min, x_max = self._normalized_display_range(
|
||||||
|
float(self._gpr_visible_x_min_m.value()),
|
||||||
|
float(self._gpr_visible_x_max_m.value()),
|
||||||
|
minimum_span=0.1,
|
||||||
|
)
|
||||||
|
z_min, z_max = self._normalized_display_range(
|
||||||
|
float(self._gpr_visible_z_min_m.value()),
|
||||||
|
float(self._gpr_visible_z_max_m.value()),
|
||||||
|
minimum_span=0.1,
|
||||||
|
)
|
||||||
|
return x_min, x_max, z_min, z_max
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||||
|
"""Return lower GPR display bound with a small negative margin for antenna markers."""
|
||||||
|
lower = min(0.0, float(z_min))
|
||||||
|
span = max(float(z_max) - float(z_min), 1e-6)
|
||||||
|
marker_margin = max(span * 0.03, 0.06)
|
||||||
|
return lower - marker_margin
|
||||||
|
|
||||||
|
def _draw_gpr_geometry_markers(self) -> None:
|
||||||
|
"""Render selected Tx/Rx geometry markers on current GPR plot."""
|
||||||
|
x_tx, x_rx = self._selected_gpr_geometry()
|
||||||
|
if x_tx.size > 0:
|
||||||
|
self._gpr_tx_item.setData(
|
||||||
|
x=x_tx,
|
||||||
|
y=np.zeros_like(x_tx),
|
||||||
|
symbol="t",
|
||||||
|
size=13,
|
||||||
|
brush=pg.mkBrush("#ff595e"),
|
||||||
|
pen=pg.mkPen("#ffca3a", width=1.0),
|
||||||
|
)
|
||||||
|
self._gpr_tx_item.show()
|
||||||
|
else:
|
||||||
|
self._gpr_tx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_tx_item.hide()
|
||||||
|
|
||||||
|
if x_rx.size > 0:
|
||||||
|
self._gpr_rx_item.setData(
|
||||||
|
x=x_rx,
|
||||||
|
y=np.zeros_like(x_rx),
|
||||||
|
symbol="t1",
|
||||||
|
size=13,
|
||||||
|
brush=pg.mkBrush("#4cc9f0"),
|
||||||
|
pen=pg.mkPen("#e0fbfc", width=1.0),
|
||||||
|
)
|
||||||
|
self._gpr_rx_item.show()
|
||||||
|
else:
|
||||||
|
self._gpr_rx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_rx_item.hide()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_gpr_object_label(x_m: float, z_m: float, pair_count: float) -> str:
|
||||||
|
"""Format object-only annotation text with pair count and coordinates."""
|
||||||
|
return f"{int(round(pair_count))} | x={x_m:.1f} | z={z_m:.1f}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expanded_scene_rect(rect: QRectF, *, padding_px: float = 4.0) -> QRectF:
|
||||||
|
"""Return scene rect padded to keep labels visually separated."""
|
||||||
|
return rect.adjusted(-padding_px, -padding_px, padding_px, padding_px)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scene_rect_intersects_any(rect: QRectF, occupied_rects: list[QRectF]) -> bool:
|
||||||
|
"""Return whether candidate label rect intersects any already placed label."""
|
||||||
|
return any(rect.intersects(occupied_rect) for occupied_rect in occupied_rects)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _gpr_object_label_candidates(
|
||||||
|
x_m: float,
|
||||||
|
z_m: float,
|
||||||
|
*,
|
||||||
|
x_span: float,
|
||||||
|
z_span: float,
|
||||||
|
) -> list[tuple[float, float, tuple[float, float]]]:
|
||||||
|
"""Return candidate label placements around one object."""
|
||||||
|
x_offset = max(x_span * 0.015, 0.02)
|
||||||
|
z_offset = max(z_span * 0.02, 0.02)
|
||||||
|
return [
|
||||||
|
(x_m + x_offset, z_m - z_offset, (0.0, 1.0)),
|
||||||
|
(x_m + x_offset, z_m + z_offset, (0.0, 0.0)),
|
||||||
|
(x_m - x_offset, z_m - z_offset, (1.0, 1.0)),
|
||||||
|
(x_m - x_offset, z_m + z_offset, (1.0, 0.0)),
|
||||||
|
(x_m, z_m - (z_offset * 1.35), (0.5, 1.0)),
|
||||||
|
(x_m, z_m + (z_offset * 1.35), (0.5, 0.0)),
|
||||||
|
(x_m + (x_offset * 2.2), z_m - (z_offset * 1.5), (0.0, 1.0)),
|
||||||
|
(x_m + (x_offset * 2.2), z_m + (z_offset * 1.5), (0.0, 0.0)),
|
||||||
|
(x_m - (x_offset * 2.2), z_m - (z_offset * 1.5), (1.0, 1.0)),
|
||||||
|
(x_m - (x_offset * 2.2), z_m + (z_offset * 1.5), (1.0, 0.0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _place_gpr_object_label(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
label: pg.TextItem,
|
||||||
|
x_m: float,
|
||||||
|
z_m: float,
|
||||||
|
x_span: float,
|
||||||
|
z_span: float,
|
||||||
|
occupied_scene_rects: list[QRectF],
|
||||||
|
) -> None:
|
||||||
|
"""Place one object label using the first non-overlapping candidate position."""
|
||||||
|
last_rect: QRectF | None = None
|
||||||
|
for label_x, label_z, anchor in self._gpr_object_label_candidates(
|
||||||
|
x_m,
|
||||||
|
z_m,
|
||||||
|
x_span=x_span,
|
||||||
|
z_span=z_span,
|
||||||
|
):
|
||||||
|
label.setAnchor(anchor)
|
||||||
|
label.setPos(label_x, label_z)
|
||||||
|
candidate_rect = self._expanded_scene_rect(label.sceneBoundingRect())
|
||||||
|
last_rect = candidate_rect
|
||||||
|
if not self._scene_rect_intersects_any(candidate_rect, occupied_scene_rects):
|
||||||
|
occupied_scene_rects.append(candidate_rect)
|
||||||
|
return
|
||||||
|
|
||||||
|
if last_rect is not None:
|
||||||
|
occupied_scene_rects.append(last_rect)
|
||||||
|
|
||||||
|
def _gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
||||||
|
"""Return object rows as `[x_m, z_m, pair_count]` from current GPR result payload."""
|
||||||
|
return extract_gpr_object_rows(collection)
|
||||||
|
|
||||||
|
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
||||||
|
"""Return object rows filtered by minimum pair count and visible X/Z bounds."""
|
||||||
|
rows = self._gpr_object_rows(collection)
|
||||||
|
if rows.size == 0:
|
||||||
|
return rows
|
||||||
|
|
||||||
|
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||||
|
min_pair_count = float(self._gpr_min_visible_pair_count.value())
|
||||||
|
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||||
|
visible_mask = (
|
||||||
|
finite_mask
|
||||||
|
& (rows[:, 2] >= min_pair_count)
|
||||||
|
& (rows[:, 0] >= x_min)
|
||||||
|
& (rows[:, 0] <= x_max)
|
||||||
|
& (rows[:, 1] >= z_min)
|
||||||
|
& (rows[:, 1] <= z_max)
|
||||||
|
)
|
||||||
|
return rows[visible_mask]
|
||||||
|
|
||||||
|
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||||
|
"""Draw only detected GPR objects inside configured X/Z bounds."""
|
||||||
|
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
|
||||||
|
object_rows = self._filtered_gpr_object_rows(collection)
|
||||||
|
if accumulator_payload is None and object_rows.size == 0:
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return False
|
||||||
|
|
||||||
|
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||||
|
plot = self._gpr_plot
|
||||||
|
plot.setUpdatesEnabled(False)
|
||||||
|
try:
|
||||||
|
self._ensure_gpr_plot_items()
|
||||||
|
plot.getViewBox().invertY(False)
|
||||||
|
self._clear_gpr_point_labels()
|
||||||
|
self._clear_gpr_region_labels()
|
||||||
|
self._clear_gpr_region_masks()
|
||||||
|
|
||||||
|
self._gpr_image_item.hide()
|
||||||
|
self._draw_gpr_geometry_markers()
|
||||||
|
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
|
||||||
|
if object_rows.size > 0:
|
||||||
|
self._gpr_points_item.setData(
|
||||||
|
x=object_rows[:, 0],
|
||||||
|
y=object_rows[:, 1],
|
||||||
|
symbol="o",
|
||||||
|
size=18,
|
||||||
|
brush=pg.mkBrush("#ff4d4f"),
|
||||||
|
pen=pg.mkPen("#ff4d4f", width=1.6),
|
||||||
|
)
|
||||||
|
self._gpr_points_item.show()
|
||||||
|
|
||||||
|
occupied_scene_rects: list[QRectF] = []
|
||||||
|
x_span = x_max - x_min
|
||||||
|
z_span = z_max - z_min
|
||||||
|
for x_value, z_value, pair_count in object_rows:
|
||||||
|
label = pg.TextItem(
|
||||||
|
text=self._format_gpr_object_label(
|
||||||
|
float(x_value),
|
||||||
|
float(z_value),
|
||||||
|
float(pair_count),
|
||||||
|
),
|
||||||
|
color="#ffd6d9",
|
||||||
|
anchor=(0.0, 1.0),
|
||||||
|
)
|
||||||
|
label.setZValue(40)
|
||||||
|
plot.addItem(label)
|
||||||
|
self._place_gpr_object_label(
|
||||||
|
label=label,
|
||||||
|
x_m=float(x_value),
|
||||||
|
z_m=float(z_value),
|
||||||
|
x_span=x_span,
|
||||||
|
z_span=z_span,
|
||||||
|
occupied_scene_rects=occupied_scene_rects,
|
||||||
|
)
|
||||||
|
self._gpr_point_labels.append(label)
|
||||||
|
else:
|
||||||
|
self._gpr_points_item.setData(x=[], y=[])
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
|
||||||
|
plot.setXRange(x_min, x_max, padding=0.0)
|
||||||
|
plot.setYRange(self._gpr_display_y_min(z_min, z_max), z_max, padding=0.0)
|
||||||
|
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()} Objects Only")
|
||||||
|
finally:
|
||||||
|
plot.setUpdatesEnabled(True)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Trace plot rendering helpers for pass-through and single-trace views."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
import numpy as np
|
||||||
|
import pyqtgraph as pg
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ResultCollection, TraceData
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowTracePlotMixin:
|
||||||
|
"""Renders pass-through trace plots on stacked magnitude/phase widgets."""
|
||||||
|
|
||||||
|
def _show_magnitude_curves(self) -> bool:
|
||||||
|
"""Return whether magnitude curves should be rendered."""
|
||||||
|
return self._show_magnitude_checkbox.isChecked()
|
||||||
|
|
||||||
|
def _show_phase_curves(self) -> bool:
|
||||||
|
"""Return whether phase curves should be rendered."""
|
||||||
|
return self._show_phase_checkbox.isChecked()
|
||||||
|
|
||||||
|
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
|
||||||
|
"""Return normalized magnitude Y-range override for pass-through mode."""
|
||||||
|
y_min = float(self._pass_through_y_min_db.value())
|
||||||
|
y_max = float(self._pass_through_y_max_db.value())
|
||||||
|
return bool(self._pass_through_fixed_y_enabled.isChecked()), min(y_min, y_max), max(y_min, y_max)
|
||||||
|
|
||||||
|
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
|
||||||
|
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
|
||||||
|
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
|
||||||
|
view_box = plot.getViewBox()
|
||||||
|
view_box.invertY(False)
|
||||||
|
view_box.enableAutoRange(x=True, y=not fixed_y_enabled)
|
||||||
|
if fixed_y_enabled:
|
||||||
|
plot.setYRange(y_min, y_max, padding=0.0)
|
||||||
|
|
||||||
|
def _on_trace_visibility_changed(self, *_args) -> None:
|
||||||
|
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
||||||
|
if self._processing_mode.currentText() in {"bscan", "gpr"}:
|
||||||
|
return
|
||||||
|
if self._result_history:
|
||||||
|
self._draw_results(self._result_history[-1])
|
||||||
|
return
|
||||||
|
self._clear_trace_plots()
|
||||||
|
|
||||||
|
def _clear_trace_plots(self) -> None:
|
||||||
|
"""Clear pass-through magnitude and phase plots."""
|
||||||
|
self._trace_magnitude_plot.clear()
|
||||||
|
self._trace_phase_plot.clear()
|
||||||
|
self._clear_trace_legends()
|
||||||
|
self._trace_magnitude_curves.clear()
|
||||||
|
self._trace_phase_curves.clear()
|
||||||
|
|
||||||
|
def _clear_trace_legends(self) -> None:
|
||||||
|
"""Remove trace plot legends to avoid stale combo-color mappings."""
|
||||||
|
mag_legend = self._trace_magnitude_legend
|
||||||
|
if mag_legend is not None:
|
||||||
|
try:
|
||||||
|
self._trace_magnitude_plot.getPlotItem().removeItem(mag_legend)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to remove pass-through magnitude legend: {type(exc).__name__}: {exc}",
|
||||||
|
once_key="plot_remove_magnitude_legend_failed",
|
||||||
|
)
|
||||||
|
self._trace_magnitude_legend = None
|
||||||
|
self._trace_magnitude_legend_combo_keys.clear()
|
||||||
|
|
||||||
|
phase_legend = self._trace_phase_legend
|
||||||
|
if phase_legend is not None:
|
||||||
|
try:
|
||||||
|
self._trace_phase_plot.getPlotItem().removeItem(phase_legend)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to remove pass-through phase legend: {type(exc).__name__}: {exc}",
|
||||||
|
once_key="plot_remove_phase_legend_failed",
|
||||||
|
)
|
||||||
|
self._trace_phase_legend = None
|
||||||
|
self._trace_phase_legend_combo_keys.clear()
|
||||||
|
|
||||||
|
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
|
||||||
|
"""Draw result payload traces as stacked magnitude/phase plots."""
|
||||||
|
show_magnitude = self._show_magnitude_curves()
|
||||||
|
show_phase = self._show_phase_curves()
|
||||||
|
magnitude_plot = self._trace_magnitude_plot
|
||||||
|
phase_plot = self._trace_phase_plot
|
||||||
|
pass_through_channel = "S21"
|
||||||
|
|
||||||
|
magnitude_plot.setVisible(show_magnitude)
|
||||||
|
phase_plot.setVisible(show_phase)
|
||||||
|
if not show_magnitude and not show_phase:
|
||||||
|
self._clear_trace_plots()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if show_magnitude:
|
||||||
|
mag_item = magnitude_plot.getPlotItem()
|
||||||
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
|
mag_item.showAxis("left", show=True)
|
||||||
|
mag_item.showAxis("bottom", show=not show_phase)
|
||||||
|
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||||
|
magnitude_plot.setTitle(f"Pass-Through {pass_through_channel}")
|
||||||
|
if not show_phase:
|
||||||
|
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||||
|
|
||||||
|
if show_phase:
|
||||||
|
phase_item = phase_plot.getPlotItem()
|
||||||
|
phase_plot.getViewBox().invertY(False)
|
||||||
|
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||||
|
phase_item.showAxis("left", show=True)
|
||||||
|
phase_item.showAxis("bottom", show=True)
|
||||||
|
phase_plot.setLabel("left", "Phase", units="deg")
|
||||||
|
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||||
|
phase_plot.setTitle(f"Pass-Through {pass_through_channel}")
|
||||||
|
|
||||||
|
palette = [
|
||||||
|
"#4cc9f0",
|
||||||
|
"#f72585",
|
||||||
|
"#b8f2e6",
|
||||||
|
"#ffd166",
|
||||||
|
"#90be6d",
|
||||||
|
"#ff595e",
|
||||||
|
"#6a4c93",
|
||||||
|
"#1982c4",
|
||||||
|
]
|
||||||
|
|
||||||
|
combo_colors: dict[tuple[int, int], str] = {}
|
||||||
|
legend_source_magnitude: dict[tuple[int, int], pg.PlotCurveItem] = {}
|
||||||
|
legend_source_phase: dict[tuple[int, int], pg.PlotCurveItem] = {}
|
||||||
|
active_magnitude_keys: set[tuple[int, int, int, str]] = set()
|
||||||
|
active_phase_keys: set[tuple[int, int, int, str]] = set()
|
||||||
|
has_data = False
|
||||||
|
x_min = np.inf
|
||||||
|
x_max = -np.inf
|
||||||
|
for block in collection.blocks:
|
||||||
|
combo_key = (int(block.combo.input_pos), int(block.combo.output_pos))
|
||||||
|
if combo_key not in combo_colors:
|
||||||
|
combo_colors[combo_key] = palette[len(combo_colors) % len(palette)]
|
||||||
|
color = combo_colors[combo_key]
|
||||||
|
|
||||||
|
for payload_index, payload in enumerate(block.payloads):
|
||||||
|
if payload.kind != 1 or payload.trace.size == 0:
|
||||||
|
continue
|
||||||
|
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
|
||||||
|
continue
|
||||||
|
curve_key = (
|
||||||
|
combo_key[0],
|
||||||
|
combo_key[1],
|
||||||
|
int(payload_index),
|
||||||
|
str(payload.processing_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
local_x_min = float(np.min(payload.frequency_hz))
|
||||||
|
local_x_max = float(np.max(payload.frequency_hz))
|
||||||
|
x_min = min(x_min, local_x_min)
|
||||||
|
x_max = max(x_max, local_x_max)
|
||||||
|
|
||||||
|
if show_magnitude:
|
||||||
|
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
|
||||||
|
active_magnitude_keys.add(curve_key)
|
||||||
|
magnitude_curve = self._trace_magnitude_curves.get(curve_key)
|
||||||
|
if magnitude_curve is None:
|
||||||
|
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4))
|
||||||
|
self._trace_magnitude_curves[curve_key] = magnitude_curve
|
||||||
|
magnitude_plot.addItem(magnitude_curve)
|
||||||
|
else:
|
||||||
|
magnitude_curve.setPen(pg.mkPen(color, width=1.4))
|
||||||
|
magnitude_curve.setData(payload.frequency_hz, magnitude_values)
|
||||||
|
legend_source_magnitude.setdefault(combo_key, magnitude_curve)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if show_phase:
|
||||||
|
active_phase_keys.add(curve_key)
|
||||||
|
phase_curve = self._trace_phase_curves.get(curve_key)
|
||||||
|
if phase_curve is None:
|
||||||
|
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2))
|
||||||
|
self._trace_phase_curves[curve_key] = phase_curve
|
||||||
|
phase_plot.addItem(phase_curve)
|
||||||
|
else:
|
||||||
|
phase_curve.setPen(pg.mkPen(color, width=1.2))
|
||||||
|
phase_x, phase_values = self._phase_display_arrays(payload.frequency_hz, payload.trace)
|
||||||
|
phase_curve.setData(phase_x, phase_values)
|
||||||
|
legend_source_phase.setdefault(combo_key, phase_curve)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if show_magnitude:
|
||||||
|
self._remove_inactive_trace_curves(
|
||||||
|
plot=magnitude_plot,
|
||||||
|
cache=self._trace_magnitude_curves,
|
||||||
|
active_keys=active_magnitude_keys,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._remove_all_trace_curves(plot=magnitude_plot, cache=self._trace_magnitude_curves)
|
||||||
|
|
||||||
|
if show_phase:
|
||||||
|
self._remove_inactive_trace_curves(
|
||||||
|
plot=phase_plot,
|
||||||
|
cache=self._trace_phase_curves,
|
||||||
|
active_keys=active_phase_keys,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._remove_all_trace_curves(plot=phase_plot, cache=self._trace_phase_curves)
|
||||||
|
|
||||||
|
self._sync_trace_legends(
|
||||||
|
show_magnitude=show_magnitude,
|
||||||
|
show_phase=show_phase,
|
||||||
|
magnitude_sources=legend_source_magnitude,
|
||||||
|
phase_sources=legend_source_phase,
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_data:
|
||||||
|
if np.isfinite(x_min) and np.isfinite(x_max):
|
||||||
|
if show_magnitude:
|
||||||
|
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
if show_phase:
|
||||||
|
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
if show_phase:
|
||||||
|
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||||
|
if show_magnitude:
|
||||||
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
|
return has_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_inactive_trace_curves(
|
||||||
|
*,
|
||||||
|
plot: pg.PlotWidget,
|
||||||
|
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
|
||||||
|
active_keys: set[tuple[int, int, int, str]],
|
||||||
|
) -> None:
|
||||||
|
"""Delete curve items no longer present in latest result collection."""
|
||||||
|
for key in list(cache.keys()):
|
||||||
|
if key in active_keys:
|
||||||
|
continue
|
||||||
|
curve = cache.pop(key)
|
||||||
|
plot.removeItem(curve)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_all_trace_curves(
|
||||||
|
*,
|
||||||
|
plot: pg.PlotWidget,
|
||||||
|
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
|
||||||
|
) -> None:
|
||||||
|
"""Delete all cached curves from selected plot."""
|
||||||
|
for curve in cache.values():
|
||||||
|
plot.removeItem(curve)
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def _phase_display_arrays(self, frequency_hz: np.ndarray, trace: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Return phase display arrays with decimation for faster rendering."""
|
||||||
|
max_points = int(getattr(self, "_trace_phase_render_max_points", 1200))
|
||||||
|
if max_points > 0 and trace.size > max_points:
|
||||||
|
step = max(1, int(np.ceil(trace.size / max_points)))
|
||||||
|
frequency_hz = frequency_hz[::step]
|
||||||
|
trace = trace[::step]
|
||||||
|
phase_values = np.arctan2(trace.imag, trace.real) * (180.0 / np.pi)
|
||||||
|
return frequency_hz, phase_values
|
||||||
|
|
||||||
|
def _sync_trace_legends(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
show_magnitude: bool,
|
||||||
|
show_phase: bool,
|
||||||
|
magnitude_sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||||
|
phase_sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||||
|
) -> None:
|
||||||
|
"""Rebuild legends only when active combo set changes."""
|
||||||
|
self._sync_single_trace_legend(
|
||||||
|
show=show_magnitude,
|
||||||
|
plot=self._trace_magnitude_plot,
|
||||||
|
legend_attr="_trace_magnitude_legend",
|
||||||
|
legend_keys_attr="_trace_magnitude_legend_combo_keys",
|
||||||
|
sources=magnitude_sources,
|
||||||
|
)
|
||||||
|
self._sync_single_trace_legend(
|
||||||
|
show=show_phase,
|
||||||
|
plot=self._trace_phase_plot,
|
||||||
|
legend_attr="_trace_phase_legend",
|
||||||
|
legend_keys_attr="_trace_phase_legend_combo_keys",
|
||||||
|
sources=phase_sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_single_trace_legend(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
show: bool,
|
||||||
|
plot: pg.PlotWidget,
|
||||||
|
legend_attr: str,
|
||||||
|
legend_keys_attr: str,
|
||||||
|
sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||||
|
) -> None:
|
||||||
|
"""Rebuild one legend from provided combo->curve mapping when needed."""
|
||||||
|
legend = getattr(self, legend_attr)
|
||||||
|
existing_keys = getattr(self, legend_keys_attr)
|
||||||
|
active_keys = set(sources.keys())
|
||||||
|
if not show or not active_keys:
|
||||||
|
if legend is not None:
|
||||||
|
try:
|
||||||
|
plot.getPlotItem().removeItem(legend)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to clear plot legend: {type(exc).__name__}: {exc}",
|
||||||
|
once_key=f"{legend_attr}_clear_failed",
|
||||||
|
)
|
||||||
|
setattr(self, legend_attr, None)
|
||||||
|
existing_keys.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if legend is not None and existing_keys == active_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
if legend is not None:
|
||||||
|
try:
|
||||||
|
plot.getPlotItem().removeItem(legend)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_warning(
|
||||||
|
f"Failed to replace plot legend: {type(exc).__name__}: {exc}",
|
||||||
|
once_key=f"{legend_attr}_replace_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
legend = plot.addLegend(offset=(8, 8))
|
||||||
|
for combo_key in sorted(active_keys):
|
||||||
|
curve = sources[combo_key]
|
||||||
|
legend.addItem(curve, f"in{combo_key[0]}/out{combo_key[1]}")
|
||||||
|
setattr(self, legend_attr, legend)
|
||||||
|
existing_keys.clear()
|
||||||
|
existing_keys.update(active_keys)
|
||||||
|
|
||||||
|
def _draw_single_trace(self, trace: TraceData, title: str, *, channel: str = "s21") -> None:
|
||||||
|
"""Draw one trace on stacked magnitude/phase plots."""
|
||||||
|
show_magnitude = self._show_magnitude_curves()
|
||||||
|
show_phase = self._show_phase_curves()
|
||||||
|
magnitude_plot = self._trace_magnitude_plot
|
||||||
|
phase_plot = self._trace_phase_plot
|
||||||
|
samples = trace.s11 if channel == "s11" else trace.s21
|
||||||
|
|
||||||
|
magnitude_plot.setVisible(show_magnitude)
|
||||||
|
phase_plot.setVisible(show_phase)
|
||||||
|
self._clear_trace_plots()
|
||||||
|
if not show_magnitude and not show_phase:
|
||||||
|
return
|
||||||
|
|
||||||
|
if show_magnitude:
|
||||||
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
|
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
||||||
|
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||||
|
magnitude_plot.setTitle(title)
|
||||||
|
if not show_phase:
|
||||||
|
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||||
|
if show_phase:
|
||||||
|
phase_plot.getViewBox().invertY(False)
|
||||||
|
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||||
|
phase_plot.getPlotItem().showAxis("bottom", show=True)
|
||||||
|
phase_plot.setLabel("left", "Phase", units="deg")
|
||||||
|
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||||
|
phase_plot.setTitle(title)
|
||||||
|
|
||||||
|
if show_magnitude:
|
||||||
|
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
|
||||||
|
magnitude_curve = pg.PlotCurveItem(
|
||||||
|
trace.frequency_hz,
|
||||||
|
magnitude_db,
|
||||||
|
pen=pg.mkPen("#ffd166", width=1.8),
|
||||||
|
)
|
||||||
|
magnitude_plot.addItem(magnitude_curve)
|
||||||
|
self._trace_magnitude_curves[
|
||||||
|
(int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__")
|
||||||
|
] = magnitude_curve
|
||||||
|
|
||||||
|
if show_phase:
|
||||||
|
phase_values = np.degrees(np.angle(samples))
|
||||||
|
phase_curve = pg.PlotCurveItem(
|
||||||
|
trace.frequency_hz,
|
||||||
|
phase_values,
|
||||||
|
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
||||||
|
)
|
||||||
|
phase_plot.addItem(phase_curve)
|
||||||
|
self._trace_phase_curves[
|
||||||
|
(int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__")
|
||||||
|
] = phase_curve
|
||||||
|
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||||
|
|
||||||
|
if np.size(trace.frequency_hz) > 1:
|
||||||
|
x_min = float(np.min(trace.frequency_hz))
|
||||||
|
x_max = float(np.max(trace.frequency_hz))
|
||||||
|
if show_magnitude:
|
||||||
|
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
|
if show_phase:
|
||||||
|
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -328,11 +328,11 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
|
||||||
owner._gpr_min_visible_pair_count.valueChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_min_visible_pair_count.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
|
||||||
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
owner._gpr_visible_z_max_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
|
owner._gpr_visible_z_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||||
|
|
||||||
owner._on_processing_mode_changed(owner._processing_mode.currentText())
|
owner._on_processing_mode_changed(owner._processing_mode.currentText())
|
||||||
return group
|
return group
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Plotting helpers for trace and B-scan visualization."""
|
|
||||||
|
|
||||||
from python_app.gui.plotting.bscan_history import (
|
|
||||||
build_bscan_signature,
|
|
||||||
pick_bscan_display_key,
|
|
||||||
rebuild_bscan_history_from_results,
|
|
||||||
)
|
|
||||||
from python_app.gui.plotting.bscan_math import (
|
|
||||||
bscan_levels,
|
|
||||||
bscan_lookup_table,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"bscan_levels",
|
|
||||||
"bscan_lookup_table",
|
|
||||||
"build_bscan_signature",
|
|
||||||
"pick_bscan_display_key",
|
|
||||||
"rebuild_bscan_history_from_results",
|
|
||||||
]
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""Helpers for B-scan history signatures and cache rebuilding."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from python_app.models.dataset_model import ResultCollection
|
|
||||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _result_tail(
|
|
||||||
*,
|
|
||||||
result_history: list[ResultCollection],
|
|
||||||
history_limit: int,
|
|
||||||
floor_collection_id: int,
|
|
||||||
) -> list[ResultCollection]:
|
|
||||||
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
|
|
||||||
filtered = [
|
|
||||||
collection
|
|
||||||
for collection in result_history[-history_limit:]
|
|
||||||
if int(collection.collection_id) > int(floor_collection_id)
|
|
||||||
]
|
|
||||||
unique_reversed_tail: list[ResultCollection] = []
|
|
||||||
seen_keys: set[tuple[int, int]] = set()
|
|
||||||
for collection in reversed(filtered):
|
|
||||||
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
|
||||||
if key in seen_keys:
|
|
||||||
continue
|
|
||||||
seen_keys.add(key)
|
|
||||||
unique_reversed_tail.append(collection)
|
|
||||||
|
|
||||||
unique_reversed_tail.reverse()
|
|
||||||
return unique_reversed_tail
|
|
||||||
|
|
||||||
|
|
||||||
def build_bscan_signature(
|
|
||||||
live_config: ProcessingLiveConfig,
|
|
||||||
result_history: list[ResultCollection],
|
|
||||||
history_limit: int,
|
|
||||||
floor_collection_id: int,
|
|
||||||
) -> tuple[object, ...]:
|
|
||||||
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
|
||||||
result_tail = _result_tail(
|
|
||||||
result_history=result_history,
|
|
||||||
history_limit=history_limit,
|
|
||||||
floor_collection_id=floor_collection_id,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
str(live_config.bscan_axis),
|
|
||||||
str(live_config.bscan_channel),
|
|
||||||
float(live_config.bscan_cut_m),
|
|
||||||
float(live_config.bscan_max_depth_m),
|
|
||||||
float(live_config.bscan_gain),
|
|
||||||
float(live_config.bscan_start_freq_mhz),
|
|
||||||
float(live_config.bscan_stop_freq_mhz),
|
|
||||||
int(floor_collection_id),
|
|
||||||
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def rebuild_bscan_history_from_results(
|
|
||||||
result_history: list[ResultCollection],
|
|
||||||
history_limit: int,
|
|
||||||
floor_collection_id: int,
|
|
||||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
|
||||||
"""Rebuild B-scan history and depth axes from processed result payloads."""
|
|
||||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
|
||||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
|
||||||
|
|
||||||
result_tail = _result_tail(
|
|
||||||
result_history=result_history,
|
|
||||||
history_limit=history_limit,
|
|
||||||
floor_collection_id=floor_collection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
for collection in result_tail:
|
|
||||||
for block in collection.blocks:
|
|
||||||
key = (block.combo.input_pos, block.combo.output_pos)
|
|
||||||
for payload in block.payloads:
|
|
||||||
if payload.kind != 1 or payload.processing_name != "bscan":
|
|
||||||
continue
|
|
||||||
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
|
|
||||||
continue
|
|
||||||
if payload.frequency_hz.size != payload.trace.size:
|
|
||||||
continue
|
|
||||||
|
|
||||||
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
|
|
||||||
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
|
|
||||||
if depth_axis.size == 0 or amplitudes.size == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
history = history_by_combo.get(key)
|
|
||||||
stored_axis = depth_axis_by_combo.get(key)
|
|
||||||
if (
|
|
||||||
history is None
|
|
||||||
or stored_axis is None
|
|
||||||
or stored_axis.shape != depth_axis.shape
|
|
||||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
|
||||||
):
|
|
||||||
history = deque(maxlen=history_limit)
|
|
||||||
history_by_combo[key] = history
|
|
||||||
depth_axis_by_combo[key] = depth_axis.copy()
|
|
||||||
|
|
||||||
history.append(amplitudes.copy())
|
|
||||||
|
|
||||||
return history_by_combo, depth_axis_by_combo
|
|
||||||
|
|
||||||
|
|
||||||
def pick_bscan_display_key(
|
|
||||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
|
||||||
) -> tuple[int, int] | None:
|
|
||||||
"""Choose combo key to display when multiple histories are present."""
|
|
||||||
if not history_by_combo:
|
|
||||||
return None
|
|
||||||
return next(iter(history_by_combo.keys()))
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Color-scaling helpers for B-scan visualization."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pyqtgraph as pg
|
|
||||||
|
|
||||||
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
|
|
||||||
"""Build B-scan colormap table for selected axis mode."""
|
|
||||||
if axis_mode == "abs":
|
|
||||||
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
|
|
||||||
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
|
|
||||||
|
|
||||||
|
|
||||||
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
|
||||||
"""Interpolate hex color stops into 8-bit RGB LUT array."""
|
|
||||||
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
|
|
||||||
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
|
|
||||||
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
|
|
||||||
|
|
||||||
lut = np.empty((size, 3), dtype=np.uint8)
|
|
||||||
for channel in range(3):
|
|
||||||
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
|
|
||||||
return lut
|
|
||||||
|
|
||||||
|
|
||||||
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
|
||||||
"""Compute image levels for B-scan data based on axis mode."""
|
|
||||||
min_value = float(np.min(sweeps))
|
|
||||||
max_value = float(np.max(sweeps))
|
|
||||||
if axis_mode == "abs":
|
|
||||||
if max_value <= min_value:
|
|
||||||
return min_value, min_value + 1e-6
|
|
||||||
return min_value, max_value
|
|
||||||
|
|
||||||
max_abs = max(abs(min_value), abs(max_value), 1e-6)
|
|
||||||
return -max_abs, max_abs
|
|
||||||
@@ -48,6 +48,10 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
pre_ring_payload = _as_dict(rings_payload.get("preprocessed"), "rings.preprocessed")
|
pre_ring_payload = _as_dict(rings_payload.get("preprocessed"), "rings.preprocessed")
|
||||||
pre_tap_ring_payload = _as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap")
|
pre_tap_ring_payload = _as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap")
|
||||||
result_ring_payload = _as_dict(rings_payload.get("results"), "rings.results")
|
result_ring_payload = _as_dict(rings_payload.get("results"), "rings.results")
|
||||||
|
locator_server_payload = _as_dict(
|
||||||
|
run_payload.get("locator_server", run_payload.get("locator")),
|
||||||
|
"run.locator_server",
|
||||||
|
)
|
||||||
|
|
||||||
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
||||||
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
||||||
@@ -71,6 +75,39 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
model.runtime.processing_live_config_path = str(
|
model.runtime.processing_live_config_path = str(
|
||||||
run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path)
|
run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path)
|
||||||
)
|
)
|
||||||
|
model.runtime.locator_server.device_id = int(
|
||||||
|
locator_server_payload.get("device_id", model.runtime.locator_server.device_id)
|
||||||
|
)
|
||||||
|
model.runtime.locator_server.protocol_version = int(
|
||||||
|
locator_server_payload.get(
|
||||||
|
"protocol_version",
|
||||||
|
model.runtime.locator_server.protocol_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model.runtime.locator_server.host = str(
|
||||||
|
locator_server_payload.get("host", model.runtime.locator_server.host)
|
||||||
|
)
|
||||||
|
model.runtime.locator_server.port = int(
|
||||||
|
locator_server_payload.get("port", model.runtime.locator_server.port)
|
||||||
|
)
|
||||||
|
model.runtime.locator_server.max_payload_bytes = int(
|
||||||
|
locator_server_payload.get(
|
||||||
|
"max_payload_bytes",
|
||||||
|
model.runtime.locator_server.max_payload_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model.runtime.locator_server.client_queue_size = int(
|
||||||
|
locator_server_payload.get(
|
||||||
|
"client_queue_size",
|
||||||
|
model.runtime.locator_server.client_queue_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model.runtime.locator_server.logger_name = str(
|
||||||
|
locator_server_payload.get(
|
||||||
|
"logger_name",
|
||||||
|
model.runtime.locator_server.logger_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21")
|
s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21")
|
||||||
_load_preprocess_asset(
|
_load_preprocess_asset(
|
||||||
@@ -203,6 +240,15 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
|||||||
"idle_sleep_ms": model.runtime.idle_sleep_ms,
|
"idle_sleep_ms": model.runtime.idle_sleep_ms,
|
||||||
"continuous": model.runtime.continuous,
|
"continuous": model.runtime.continuous,
|
||||||
"processing_live_config_path": model.runtime.processing_live_config_path,
|
"processing_live_config_path": model.runtime.processing_live_config_path,
|
||||||
|
"locator_server": {
|
||||||
|
"device_id": model.runtime.locator_server.device_id,
|
||||||
|
"protocol_version": model.runtime.locator_server.protocol_version,
|
||||||
|
"host": model.runtime.locator_server.host,
|
||||||
|
"port": model.runtime.locator_server.port,
|
||||||
|
"max_payload_bytes": model.runtime.locator_server.max_payload_bytes,
|
||||||
|
"client_queue_size": model.runtime.locator_server.client_queue_size,
|
||||||
|
"logger_name": model.runtime.locator_server.logger_name,
|
||||||
|
},
|
||||||
"combos": [{"input": combo.input, "output": combo.output} for combo in model.combos],
|
"combos": [{"input": combo.input, "output": combo.output} for combo in model.combos],
|
||||||
},
|
},
|
||||||
"preprocess": {
|
"preprocess": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from python_app.models.run_config_schema import (
|
|||||||
GprModel,
|
GprModel,
|
||||||
GprRxGeometryModel,
|
GprRxGeometryModel,
|
||||||
GprTxGeometryModel,
|
GprTxGeometryModel,
|
||||||
|
LocatorServerRuntimeModel,
|
||||||
PreprocessAssetModel,
|
PreprocessAssetModel,
|
||||||
PreprocessModel,
|
PreprocessModel,
|
||||||
RadarModel,
|
RadarModel,
|
||||||
@@ -30,6 +31,7 @@ __all__ = [
|
|||||||
"GprModel",
|
"GprModel",
|
||||||
"GprRxGeometryModel",
|
"GprRxGeometryModel",
|
||||||
"GprTxGeometryModel",
|
"GprTxGeometryModel",
|
||||||
|
"LocatorServerRuntimeModel",
|
||||||
"PreprocessAssetModel",
|
"PreprocessAssetModel",
|
||||||
"PreprocessModel",
|
"PreprocessModel",
|
||||||
"RadarModel",
|
"RadarModel",
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ class RingsModel:
|
|||||||
results: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
results: RingEndpointModel = field(default_factory=lambda: RingEndpointModel(name=""))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LocatorServerRuntimeModel:
|
||||||
|
"""Embedded locator TCP server configuration stored in run config."""
|
||||||
|
|
||||||
|
device_id: int = 3
|
||||||
|
protocol_version: int = 1
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8888
|
||||||
|
max_payload_bytes: int = 64 * 1024
|
||||||
|
client_queue_size: int = 32
|
||||||
|
logger_name: str = "locator_runtime"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class RuntimeModel:
|
class RuntimeModel:
|
||||||
"""Runtime process behavior and paths."""
|
"""Runtime process behavior and paths."""
|
||||||
@@ -83,6 +96,7 @@ class RuntimeModel:
|
|||||||
idle_sleep_ms: int = 2
|
idle_sleep_ms: int = 2
|
||||||
continuous: bool = False
|
continuous: bool = False
|
||||||
processing_live_config_path: str = ""
|
processing_live_config_path: str = ""
|
||||||
|
locator_server: LocatorServerRuntimeModel = field(default_factory=LocatorServerRuntimeModel)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Helpers for extracting GPR objects and locator observations from results."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ResultCollection, ResultPayload
|
||||||
|
|
||||||
|
|
||||||
|
def collection_payload_by_name(
|
||||||
|
collection: ResultCollection,
|
||||||
|
name: str,
|
||||||
|
kind: int | None = None,
|
||||||
|
) -> ResultPayload | None:
|
||||||
|
"""Return the first collection payload matching name and optional kind."""
|
||||||
|
for payload in collection.collection_payloads:
|
||||||
|
if payload.processing_name != name:
|
||||||
|
continue
|
||||||
|
if kind is not None and int(payload.kind) != int(kind):
|
||||||
|
continue
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collection_payloads_by_prefix(
|
||||||
|
collection: ResultCollection,
|
||||||
|
prefix: str,
|
||||||
|
kind: int | None = None,
|
||||||
|
) -> list[ResultPayload]:
|
||||||
|
"""Return collection payloads matching a processing-name prefix."""
|
||||||
|
payloads: list[ResultPayload] = []
|
||||||
|
for payload in collection.collection_payloads:
|
||||||
|
if not str(payload.processing_name).startswith(prefix):
|
||||||
|
continue
|
||||||
|
if kind is not None and int(payload.kind) != int(kind):
|
||||||
|
continue
|
||||||
|
payloads.append(payload)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
|
||||||
|
def collection_has_gpr_payloads(collection: ResultCollection) -> bool:
|
||||||
|
"""Return whether collection carries GPR-specific collection payloads."""
|
||||||
|
return any(
|
||||||
|
str(payload.processing_name).startswith("gpr_")
|
||||||
|
for payload in collection.collection_payloads
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
|
||||||
|
"""Return object rows as `[x_m, z_m, pair_count]` from a GPR collection."""
|
||||||
|
points_payload = collection_payload_by_name(collection, "gpr_points", kind=4)
|
||||||
|
if points_payload is not None:
|
||||||
|
points = np.asarray(points_payload.table, dtype=np.float32)
|
||||||
|
if points.ndim == 2 and points.shape[1] >= 3:
|
||||||
|
return points[:, :3]
|
||||||
|
|
||||||
|
centers_payload = collection_payload_by_name(collection, "gpr_region_centers", kind=4)
|
||||||
|
if centers_payload is not None:
|
||||||
|
centers = np.asarray(centers_payload.table, dtype=np.float32)
|
||||||
|
if centers.ndim == 2 and centers.shape[1] >= 3:
|
||||||
|
return centers[:, :3]
|
||||||
|
|
||||||
|
return np.zeros((0, 3), dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def locator_observations_from_collection(
|
||||||
|
collection: ResultCollection,
|
||||||
|
min_pair_count: float,
|
||||||
|
*,
|
||||||
|
visible_bounds: tuple[float, float, float, float] | None = None,
|
||||||
|
) -> list[dict[str, float]]:
|
||||||
|
"""Build locator observations from GPR rows using pair threshold and optional X/Z bounds."""
|
||||||
|
rows = gpr_object_rows(collection)
|
||||||
|
if rows.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||||
|
visible_mask = finite_mask & (rows[:, 2] >= float(min_pair_count))
|
||||||
|
if visible_bounds is not None:
|
||||||
|
x_min, x_max, z_min, z_max = (float(value) for value in visible_bounds)
|
||||||
|
visible_mask &= (
|
||||||
|
(rows[:, 0] >= x_min)
|
||||||
|
& (rows[:, 0] <= x_max)
|
||||||
|
& (rows[:, 1] >= z_min)
|
||||||
|
& (rows[:, 1] <= z_max)
|
||||||
|
)
|
||||||
|
filtered = rows[visible_mask]
|
||||||
|
|
||||||
|
observations: list[dict[str, float]] = []
|
||||||
|
for x_m, z_m, _pair_count in filtered:
|
||||||
|
observations.append(
|
||||||
|
{
|
||||||
|
"dst": round(float(z_m), 2),
|
||||||
|
"crs": round(float(x_m), 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return observations
|
||||||
|
|
||||||
|
|
||||||
|
def build_locator_payload(
|
||||||
|
observations: list[dict[str, float]],
|
||||||
|
*,
|
||||||
|
protocol_version: int,
|
||||||
|
status: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assemble one outbound locator payload from precomputed observations."""
|
||||||
|
return {
|
||||||
|
"ver": int(protocol_version),
|
||||||
|
"tim": _format_timestamp(),
|
||||||
|
"sts": int(status),
|
||||||
|
"obs": observations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_timestamp() -> str:
|
||||||
|
"""Return wall-clock timestamp with millisecond precision."""
|
||||||
|
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""Event-driven locator TCP service fed by already-consumed GUI GPR results."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ResultCollection
|
||||||
|
from python_app.orchestration.gpr_locator import (
|
||||||
|
build_locator_payload,
|
||||||
|
locator_observations_from_collection,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PACKET_HEADER_STRUCT = struct.Struct("<II")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_packet(payload: dict[str, Any], device_id: int) -> bytes:
|
||||||
|
"""Serialize a JSON payload with the protocol binary header."""
|
||||||
|
payload_bytes = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return _PACKET_HEADER_STRUCT.pack(device_id, len(payload_bytes)) + payload_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def decode_packet(header_bytes: bytes, payload_bytes: bytes) -> tuple[int, Any]:
|
||||||
|
"""Decode one protocol packet from its binary header and JSON payload."""
|
||||||
|
if len(header_bytes) != _PACKET_HEADER_STRUCT.size:
|
||||||
|
raise ValueError(f"Packet header must be exactly {_PACKET_HEADER_STRUCT.size} bytes long.")
|
||||||
|
|
||||||
|
device_id, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
|
||||||
|
if payload_length != len(payload_bytes):
|
||||||
|
raise ValueError("Payload length does not match the header value.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
except UnicodeDecodeError as error:
|
||||||
|
raise ValueError("Payload is not valid UTF-8.") from error
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise ValueError("Payload is not valid JSON.") from error
|
||||||
|
|
||||||
|
return device_id, payload
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vlc(payload: dict[str, Any]) -> float:
|
||||||
|
"""Validate and normalize inbound speed payload."""
|
||||||
|
try:
|
||||||
|
vlc = float(payload["vlc"])
|
||||||
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
|
raise ValueError("Payload field 'vlc' must be numeric.") from error
|
||||||
|
|
||||||
|
if not math.isfinite(vlc):
|
||||||
|
raise ValueError("Payload field 'vlc' must be finite.")
|
||||||
|
return vlc
|
||||||
|
|
||||||
|
|
||||||
|
def format_peer_name(writer: asyncio.StreamWriter) -> str:
|
||||||
|
"""Return a readable peer address for logs."""
|
||||||
|
peer_name = writer.get_extra_info("peername")
|
||||||
|
if isinstance(peer_name, tuple) and len(peer_name) >= 2:
|
||||||
|
return f"{peer_name[0]}:{peer_name[1]}"
|
||||||
|
return str(peer_name or "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
async def read_packet_with_limit(reader: asyncio.StreamReader, max_payload_bytes: int) -> tuple[int, Any]:
|
||||||
|
"""Read and decode a single packet using the requested payload limit."""
|
||||||
|
header_bytes = await reader.readexactly(_PACKET_HEADER_STRUCT.size)
|
||||||
|
_, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
|
||||||
|
if payload_length > int(max_payload_bytes):
|
||||||
|
raise ValueError(
|
||||||
|
"Payload length %d exceeds the %d byte limit."
|
||||||
|
% (payload_length, int(max_payload_bytes))
|
||||||
|
)
|
||||||
|
payload_bytes = await reader.readexactly(payload_length)
|
||||||
|
return decode_packet(header_bytes, payload_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(eq=False, slots=True)
|
||||||
|
class _ClientConnection:
|
||||||
|
"""Runtime state for one connected locator client."""
|
||||||
|
|
||||||
|
writer: asyncio.StreamWriter
|
||||||
|
peer_name: str
|
||||||
|
queue: asyncio.Queue[bytes]
|
||||||
|
closed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LocatorTcpService:
|
||||||
|
"""Background-thread TCP service for locator packets."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
device_id: int,
|
||||||
|
protocol_version: int,
|
||||||
|
max_payload_bytes: int,
|
||||||
|
client_queue_size: int,
|
||||||
|
logger_name: str,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create a stopped service instance."""
|
||||||
|
self._host = host
|
||||||
|
self._port = int(port)
|
||||||
|
self._device_id = int(device_id)
|
||||||
|
self._protocol_version = int(protocol_version)
|
||||||
|
self._max_payload_bytes = int(max_payload_bytes)
|
||||||
|
self._logger = logger or logging.getLogger(str(logger_name))
|
||||||
|
self._client_queue_size = int(client_queue_size)
|
||||||
|
self._speed_updates: queue.Queue[float] = queue.Queue()
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._server: asyncio.AbstractServer | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._startup_event = threading.Event()
|
||||||
|
self._startup_error: Exception | None = None
|
||||||
|
self._clients: set[_ClientConnection] = set()
|
||||||
|
self._snapshot_lock = threading.Lock()
|
||||||
|
self._latest_packet: bytes | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self) -> str:
|
||||||
|
"""Return bind host."""
|
||||||
|
return self._host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port(self) -> int:
|
||||||
|
"""Return bind port."""
|
||||||
|
return self._port
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the background event loop and TCP listener."""
|
||||||
|
if self.is_running():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._startup_event = threading.Event()
|
||||||
|
self._startup_error = None
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._thread_main,
|
||||||
|
name="locator-tcp-service",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
if not self._startup_event.wait(timeout=5.0):
|
||||||
|
raise RuntimeError("Timed out waiting for locator TCP service startup.")
|
||||||
|
|
||||||
|
if self._startup_error is not None:
|
||||||
|
error = self._startup_error
|
||||||
|
self.stop()
|
||||||
|
raise RuntimeError(f"Failed to start locator TCP service: {error}") from error
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop listener, disconnect clients, and join the background thread."""
|
||||||
|
loop = self._loop
|
||||||
|
thread = self._thread
|
||||||
|
|
||||||
|
if loop is not None:
|
||||||
|
with contextlib.suppress(RuntimeError):
|
||||||
|
loop.call_soon_threadsafe(loop.stop)
|
||||||
|
|
||||||
|
if thread is not None:
|
||||||
|
thread.join(timeout=5.0)
|
||||||
|
|
||||||
|
self._thread = None
|
||||||
|
self._loop = None
|
||||||
|
self._server = None
|
||||||
|
self._clients.clear()
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Return whether the background loop is alive."""
|
||||||
|
return self._thread is not None and self._thread.is_alive() and self._loop is not None
|
||||||
|
|
||||||
|
def publish_collection(
|
||||||
|
self,
|
||||||
|
collection: ResultCollection,
|
||||||
|
min_pair_count: float,
|
||||||
|
*,
|
||||||
|
visible_bounds: tuple[float, float, float, float] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Publish one locator payload derived from a GPR result collection."""
|
||||||
|
observations = locator_observations_from_collection(
|
||||||
|
collection,
|
||||||
|
min_pair_count,
|
||||||
|
visible_bounds=visible_bounds,
|
||||||
|
)
|
||||||
|
payload = build_locator_payload(
|
||||||
|
observations,
|
||||||
|
protocol_version=self._protocol_version,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
self._publish_packet(encode_packet(payload, device_id=self._device_id))
|
||||||
|
|
||||||
|
def publish_empty(self) -> None:
|
||||||
|
"""Publish an empty locator snapshot."""
|
||||||
|
payload = build_locator_payload(
|
||||||
|
[],
|
||||||
|
protocol_version=self._protocol_version,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
self._publish_packet(encode_packet(payload, device_id=self._device_id))
|
||||||
|
|
||||||
|
def drain_speed_updates(self) -> float | None:
|
||||||
|
"""Drain queued speed updates and return the newest one, if any."""
|
||||||
|
latest: float | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
latest = float(self._speed_updates.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
return latest
|
||||||
|
|
||||||
|
def _publish_packet(self, packet: bytes) -> None:
|
||||||
|
"""Store latest packet and broadcast it to all connected clients."""
|
||||||
|
with self._snapshot_lock:
|
||||||
|
self._latest_packet = packet
|
||||||
|
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with contextlib.suppress(RuntimeError):
|
||||||
|
loop.call_soon_threadsafe(self._broadcast_packet, packet)
|
||||||
|
|
||||||
|
def _get_latest_packet(self) -> bytes | None:
|
||||||
|
"""Return the latest stored packet snapshot."""
|
||||||
|
with self._snapshot_lock:
|
||||||
|
return self._latest_packet
|
||||||
|
|
||||||
|
def _thread_main(self) -> None:
|
||||||
|
"""Own the event loop and TCP listener lifecycle."""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
self._loop = loop
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._server = loop.run_until_complete(
|
||||||
|
asyncio.start_server(self._handle_client, self._host, self._port)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._startup_error = exc
|
||||||
|
self._startup_event.set()
|
||||||
|
self._loop = None
|
||||||
|
asyncio.set_event_loop(None)
|
||||||
|
loop.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._startup_event.set()
|
||||||
|
try:
|
||||||
|
loop.run_forever()
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
loop.run_until_complete(self._shutdown_async())
|
||||||
|
asyncio.set_event_loop(None)
|
||||||
|
loop.close()
|
||||||
|
self._server = None
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
async def _shutdown_async(self) -> None:
|
||||||
|
"""Close listener and all active client connections."""
|
||||||
|
server = self._server
|
||||||
|
if server is not None:
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
|
||||||
|
clients = list(self._clients)
|
||||||
|
self._clients.clear()
|
||||||
|
for client in clients:
|
||||||
|
client.closed = True
|
||||||
|
client.writer.close()
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||||
|
await client.writer.wait_closed()
|
||||||
|
|
||||||
|
pending = [
|
||||||
|
task
|
||||||
|
for task in asyncio.all_tasks()
|
||||||
|
if task is not asyncio.current_task()
|
||||||
|
]
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
for task in pending:
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
await task
|
||||||
|
|
||||||
|
async def _handle_client(
|
||||||
|
self,
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
"""Handle one client until disconnect or protocol failure."""
|
||||||
|
peer_name = format_peer_name(writer)
|
||||||
|
client = _ClientConnection(
|
||||||
|
writer=writer,
|
||||||
|
peer_name=peer_name,
|
||||||
|
queue=asyncio.Queue(maxsize=self._client_queue_size),
|
||||||
|
)
|
||||||
|
self._clients.add(client)
|
||||||
|
self._logger.info("Locator client connected: %s", peer_name)
|
||||||
|
|
||||||
|
latest_packet = self._get_latest_packet()
|
||||||
|
if latest_packet is not None:
|
||||||
|
self._enqueue_packet(client, latest_packet)
|
||||||
|
|
||||||
|
send_task = asyncio.create_task(
|
||||||
|
self._send_packets(client),
|
||||||
|
name=f"locator_send:{peer_name}",
|
||||||
|
)
|
||||||
|
receive_task = asyncio.create_task(
|
||||||
|
self._receive_packets(reader, client),
|
||||||
|
name=f"locator_receive:{peer_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{send_task, receive_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
for task in pending:
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
self._clients.discard(client)
|
||||||
|
client.closed = True
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
for task in done:
|
||||||
|
exception = task.exception()
|
||||||
|
if exception is None:
|
||||||
|
continue
|
||||||
|
if isinstance(exception, asyncio.IncompleteReadError):
|
||||||
|
self._logger.info("Locator client closed the connection: %s", peer_name)
|
||||||
|
continue
|
||||||
|
if isinstance(exception, (BrokenPipeError, ConnectionResetError)):
|
||||||
|
self._logger.info("Locator connection lost: %s", peer_name)
|
||||||
|
continue
|
||||||
|
if isinstance(exception, ValueError):
|
||||||
|
self._logger.warning(
|
||||||
|
"Closing locator client %s after protocol error: %s",
|
||||||
|
peer_name,
|
||||||
|
exception,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._logger.error(
|
||||||
|
"Unexpected locator client error: %s",
|
||||||
|
peer_name,
|
||||||
|
exc_info=(type(exception), exception, exception.__traceback__),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._logger.info("Locator client disconnected: %s", peer_name)
|
||||||
|
|
||||||
|
async def _send_packets(self, client: _ClientConnection) -> None:
|
||||||
|
"""Drain one client's outbound queue."""
|
||||||
|
while True:
|
||||||
|
packet = await client.queue.get()
|
||||||
|
client.writer.write(packet)
|
||||||
|
await client.writer.drain()
|
||||||
|
|
||||||
|
async def _receive_packets(
|
||||||
|
self,
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
client: _ClientConnection,
|
||||||
|
) -> None:
|
||||||
|
"""Receive inbound client packets and queue valid speed updates."""
|
||||||
|
while True:
|
||||||
|
device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes)
|
||||||
|
if isinstance(payload, dict) and "vlc" in payload:
|
||||||
|
self._speed_updates.put(parse_vlc(payload))
|
||||||
|
self._logger.debug(
|
||||||
|
"Received locator speed from %s: device_id=%d payload=%s",
|
||||||
|
client.peer_name,
|
||||||
|
device_id,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Received locator payload from %s: device_id=%d payload=%s",
|
||||||
|
client.peer_name,
|
||||||
|
device_id,
|
||||||
|
json.dumps(payload, ensure_ascii=True, separators=(",", ":")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _broadcast_packet(self, packet: bytes) -> None:
|
||||||
|
"""Enqueue one packet for all connected clients."""
|
||||||
|
for client in list(self._clients):
|
||||||
|
self._enqueue_packet(client, packet)
|
||||||
|
|
||||||
|
def _enqueue_packet(self, client: _ClientConnection, packet: bytes) -> None:
|
||||||
|
"""Enqueue one packet or disconnect a backpressured client."""
|
||||||
|
if client.closed:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.queue.put_nowait(packet)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
client.closed = True
|
||||||
|
self._logger.warning(
|
||||||
|
"Disconnecting locator client %s after outbound queue overflow.",
|
||||||
|
client.peer_name,
|
||||||
|
)
|
||||||
|
client.writer.close()
|
||||||
@@ -43,6 +43,15 @@
|
|||||||
"idle_sleep_ms": 2,
|
"idle_sleep_ms": 2,
|
||||||
"continuous": true,
|
"continuous": true,
|
||||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||||
|
"locator_server": {
|
||||||
|
"device_id": 3,
|
||||||
|
"protocol_version": 1,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8888,
|
||||||
|
"max_payload_bytes": 65536,
|
||||||
|
"client_queue_size": 32,
|
||||||
|
"logger_name": "locator_runtime"
|
||||||
|
},
|
||||||
"combos": [
|
"combos": [
|
||||||
{"input": 0, "output": 0},
|
{"input": 0, "output": 0},
|
||||||
{"input": 1, "output": 0},
|
{"input": 1, "output": 0},
|
||||||
|
|||||||
Reference in New Issue
Block a user