diff --git a/.codex b/.codex deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index ddd847c..ac9c31b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__/ *.py[codz] *$py.class build* +.codex +.DS_Store # C extensions python_app/data* *.so diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index bbcb5b5..6e99764 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -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.gui_session_state import GuiSessionState, GuiSessionStateStore 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.process_supervisor import ProcessSupervisor from python_app.orchestration.shm_reader import ShmRingReader @@ -79,6 +80,7 @@ class AppWindow( self._supervisor = ProcessSupervisor(self._project_root) self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.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: """Resolve startup profile path, load active profile, and queue fallback notices.""" @@ -107,6 +109,7 @@ class AppWindow( "INFO", 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) def _init_reader_handles(self) -> None: @@ -197,9 +200,58 @@ class AppWindow( self._log(f"Active config profile: {self._active_profile_path}") self._refresh_preprocess_summary_labels() self._apply_initial_radar_limits() + self._start_locator_service() self._write_live_processing_config() 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: """Resolve active profile path from session-state or root fallback path.""" try: @@ -434,6 +486,9 @@ class AppWindow( self._abort_capture_sequence(resume_pipeline=False) # 2) Stop all managed processes/readers. 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. if self._preprocess_dialog is not None: self._preprocess_dialog.close() diff --git a/python_app/gui/controllers/app_window_config/__init__.py b/python_app/gui/controllers/app_window_config/__init__.py new file mode 100644 index 0000000..c31a79a --- /dev/null +++ b/python_app/gui/controllers/app_window_config/__init__.py @@ -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", +] diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py new file mode 100644 index 0000000..7ed2a79 --- /dev/null +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -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 ''}, " + f"outputs={self._gpr_output_positions_input.text().strip() or ''}, " + 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() diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py new file mode 100644 index 0000000..e0c7cb4 --- /dev/null +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -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) diff --git a/python_app/gui/controllers/app_window_config/radar_limits_mixin.py b/python_app/gui/controllers/app_window_config/radar_limits_mixin.py new file mode 100644 index 0000000..f42c57d --- /dev/null +++ b/python_app/gui/controllers/app_window_config/radar_limits_mixin.py @@ -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 ''} -> {current_start}") + if prev_stop != current_stop: + adjustments.append(f"Stop Hz: {prev_stop or ''} -> {current_stop}") + if prev_points != current_points: + adjustments.append(f"Points: {prev_points or ''} -> {current_points}") + if prev_ifbw != current_ifbw: + adjustments.append(f"IF BW Hz: {prev_ifbw or ''} -> {current_ifbw}") + if prev_power != current_power: + adjustments.append(f"Stimulus Power dBm: {prev_power or ''} -> {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", + ) + ) diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py new file mode 100644 index 0000000..285d5ec --- /dev/null +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -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 diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 7cc4a41..16d1554 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -1,1033 +1,19 @@ -"""Configuration and live-processing binding mixin for the main window.""" +"""Facade mixin composing focused config/profile/runtime controller mixins.""" -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.hardware_full.librevna_service import LibreVnaService -from python_app.gui.runtime.constraints import validate_processing_mode_constraints -from python_app.models.gui_profile_model import ( - GuiBscanStateModel, - GuiDataActionsStateModel, - GuiGprStateModel, - GuiPassThroughStateModel, - GuiPreprocessDialogStateModel, - GuiProcessingStateModel, - GuiProfileModel, - GuiStateModel, - GuiSwitchStateModel, +from python_app.gui.controllers.app_window_config import ( + AppWindowConfigProfileIOMixin, + AppWindowConfigStateBuildersMixin, + AppWindowLiveProcessingMixin, + AppWindowRadarLimitsMixin, ) -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.live_processing_config import ProcessingLiveConfig -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 AppWindowConfigMixin: - """Builds runtime config models from current UI state.""" +class AppWindowConfigMixin( + AppWindowConfigStateBuildersMixin, + AppWindowConfigProfileIOMixin, + AppWindowLiveProcessingMixin, + AppWindowRadarLimitsMixin, +): + """Backward-compatible facade for AppWindow config-related behavior.""" - 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(), - ) - - @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 _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: RunConfigModel) -> 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._on_processing_mode_changed(gui_state.processing.selected_mode) - self._update_history_indicator() - self._remember_active_profile_path(profile_path) - - 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()), - ) - - 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 _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_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 == "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 ''}, " - f"outputs={self._gpr_output_positions_input.text().strip() or ''}, " - 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() - - 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 - - points = self._clamp_line_edit_int(self._points_input, 1, max_points) - ifbw = self._clamp_line_edit_float(self._ifbw_input, min_ifbw_hz, max_ifbw_hz) - power = 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 ''} -> {current_start}") - if prev_stop != current_stop: - adjustments.append(f"Stop Hz: {prev_stop or ''} -> {current_stop}") - if prev_points != current_points: - adjustments.append(f"Points: {prev_points or ''} -> {current_points}") - if prev_ifbw != current_ifbw: - adjustments.append(f"IF BW Hz: {prev_ifbw or ''} -> {current_ifbw}") - if prev_power != current_power: - adjustments.append(f"Stimulus Power dBm: {prev_power or ''} -> {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", - ) - ) - - @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 + pass diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 3f9168a..c373d70 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -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.models.dataset_model import ComboKey, ResultCollection, SweepCollection 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 ( PREPROCESS_ASSET_SPECS, REQUIRED_PREPROCESS_ASSET_KEYS, @@ -260,6 +261,7 @@ class AppWindowPipelineMixin: self._log_error(report.format()) try: + self._drain_locator_speed_updates() if self._raw_reader is not None: self._read_all_raw() self._read_all_preprocessed() @@ -353,6 +355,11 @@ class AppWindowPipelineMixin: break if record_result_history(self._result_history, collection): latest = collection + if ( + self._processing_mode.currentText() == "gpr" + and collection_has_gpr_payloads(collection) + ): + self._publish_locator_snapshot_from_collection(collection) return latest def _drain_rings_once_for_history(self) -> None: @@ -452,3 +459,35 @@ class AppWindowPipelineMixin: 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) diff --git a/python_app/gui/controllers/app_window_plot/__init__.py b/python_app/gui/controllers/app_window_plot/__init__.py new file mode 100644 index 0000000..091f3de --- /dev/null +++ b/python_app/gui/controllers/app_window_plot/__init__.py @@ -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", +] diff --git a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py new file mode 100644 index 0000000..ef0df26 --- /dev/null +++ b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py @@ -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() diff --git a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py new file mode 100644 index 0000000..311b3d9 --- /dev/null +++ b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py @@ -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 diff --git a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py new file mode 100644 index 0000000..e359fde --- /dev/null +++ b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py @@ -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) diff --git a/python_app/gui/controllers/app_window_plot_mixin.py b/python_app/gui/controllers/app_window_plot_mixin.py index 348ea88..1f23ec1 100644 --- a/python_app/gui/controllers/app_window_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot_mixin.py @@ -1,26 +1,21 @@ -"""Plot rendering mixin for processed radar result collections.""" +"""Facade mixin for processed-radar plot rendering.""" from __future__ import annotations -from PyQt6.QtCore import QRectF, Qt -import numpy as np -import pyqtgraph as pg - -from python_app.gui.plotting.bscan_history import ( - build_bscan_signature, - pick_bscan_display_key, - rebuild_bscan_history_from_results, +from python_app.gui.controllers.app_window_plot import ( + AppWindowBscanPlotMixin, + AppWindowGprPlotMixin, + AppWindowTracePlotMixin, ) -from python_app.gui.plotting.bscan_math import ( - bscan_levels, - bscan_lookup_table, - build_lut, -) -from python_app.models.dataset_model import ResultCollection, TraceData +from python_app.models.dataset_model import ResultCollection -class AppWindowPlotMixin: - """Renders result collections on the main pyqtgraph plot.""" +class AppWindowPlotMixin( + AppWindowTracePlotMixin, + AppWindowBscanPlotMixin, + AppWindowGprPlotMixin, +): + """Routes plotting to trace, B-scan, or GPR-specific mixins.""" def _draw_preferred_collection( self, @@ -40,1247 +35,6 @@ class AppWindowPlotMixin: return self._draw_gpr_map(collection) return self._draw_trace_lines(collection) - 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] - combo_label = f"in{combo_key[0]}/out{combo_key[1]}" - - 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_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() - - 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.""" - 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 - - @staticmethod - def _collection_payloads_by_prefix(collection: ResultCollection, prefix: str, kind: int | None = None): - """Return collection payloads matching processing-name prefix.""" - payloads = [] - 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 _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.""" - points_payload = self._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 = self._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 _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 - def _result_collection_has_trace(self, collection: ResultCollection) -> bool: """Return `True` when collection contains at least one trace payload.""" if collection.collection_payloads: @@ -1290,66 +44,3 @@ class AppWindowPlotMixin: if payload.kind == 1 and payload.trace.size > 0: return True return False - - 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) diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index bfb0c92..7c907ea 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -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_mean_count.valueChanged.connect(owner._on_processing_live_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_visible_x_min_m.valueChanged.connect(owner._on_gpr_visual_settings_changed) - owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_visual_settings_changed) - owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_visual_settings_changed) - owner._gpr_visible_z_max_m.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_locator_window_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_locator_window_changed) + owner._gpr_visible_z_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed) owner._on_processing_mode_changed(owner._processing_mode.currentText()) return group diff --git a/python_app/gui/plotting/__init__.py b/python_app/gui/plotting/__init__.py deleted file mode 100644 index c066fbf..0000000 --- a/python_app/gui/plotting/__init__.py +++ /dev/null @@ -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", -] diff --git a/python_app/gui/plotting/bscan_history.py b/python_app/gui/plotting/bscan_history.py deleted file mode 100644 index ecd31ef..0000000 --- a/python_app/gui/plotting/bscan_history.py +++ /dev/null @@ -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())) diff --git a/python_app/gui/plotting/bscan_math.py b/python_app/gui/plotting/bscan_math.py deleted file mode 100644 index 476f128..0000000 --- a/python_app/gui/plotting/bscan_math.py +++ /dev/null @@ -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 diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index c7b8aed..efb8823 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -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_tap_ring_payload = _as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap") 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.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( 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") _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, "continuous": model.runtime.continuous, "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], }, "preprocess": { diff --git a/python_app/models/run_config_model.py b/python_app/models/run_config_model.py index cb92aba..5a142ce 100644 --- a/python_app/models/run_config_model.py +++ b/python_app/models/run_config_model.py @@ -6,6 +6,7 @@ from python_app.models.run_config_schema import ( GprModel, GprRxGeometryModel, GprTxGeometryModel, + LocatorServerRuntimeModel, PreprocessAssetModel, PreprocessModel, RadarModel, @@ -30,6 +31,7 @@ __all__ = [ "GprModel", "GprRxGeometryModel", "GprTxGeometryModel", + "LocatorServerRuntimeModel", "PreprocessAssetModel", "PreprocessModel", "RadarModel", diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index f209267..268ba22 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -75,6 +75,19 @@ class RingsModel: 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) class RuntimeModel: """Runtime process behavior and paths.""" @@ -83,6 +96,7 @@ class RuntimeModel: idle_sleep_ms: int = 2 continuous: bool = False processing_live_config_path: str = "" + locator_server: LocatorServerRuntimeModel = field(default_factory=LocatorServerRuntimeModel) @dataclass(slots=True) diff --git a/python_app/orchestration/gpr_locator.py b/python_app/orchestration/gpr_locator.py new file mode 100644 index 0000000..97f2acb --- /dev/null +++ b/python_app/orchestration/gpr_locator.py @@ -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] diff --git a/python_app/orchestration/locator_runtime.py b/python_app/orchestration/locator_runtime.py new file mode 100644 index 0000000..e916e8b --- /dev/null +++ b/python_app/orchestration/locator_runtime.py @@ -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(" 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() diff --git a/run_config.json b/run_config.json index 752c74a..6dea05f 100644 --- a/run_config.json +++ b/run_config.json @@ -43,6 +43,15 @@ "idle_sleep_ms": 2, "continuous": true, "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": [ {"input": 0, "output": 0}, {"input": 1, "output": 0},