From 202993325d2c5fb9778f586e1d8e9364e048fc18 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 7 Apr 2026 12:55:26 +0300 Subject: [PATCH] new GPR parameters and some UI fixes --- .../include/processing_live_config.hpp | 2 + .../src/processing_live_config.cpp | 12 + .../processors/src/gpr_processor.cpp | 9 +- python_app/gui/app_window.py | 3 + .../controllers/app_window_config_mixin.py | 103 +++- .../controllers/app_window_pipeline_mixin.py | 22 +- .../gui/controllers/app_window_plot_mixin.py | 543 ++++++++++++++++-- .../app_window_preprocess_mixin.py | 234 ++++++-- .../gui/controllers/app_window_ui_mixin.py | 73 ++- .../sections/data_actions_section.py | 6 +- .../controllers/sections/layout_helpers.py | 60 ++ .../sections/preprocess_summary_section.py | 13 +- .../sections/primary_actions_section.py | 6 +- .../sections/processing_section.py | 163 ++++-- .../gui/controllers/sections/radar_section.py | 32 +- python_app/gui/preprocess_dialog.py | 52 +- python_app/gui/runtime/constraints.py | 5 +- python_app/gui/theme.py | 6 + python_app/models/gui_profile_codec.py | 64 +++ python_app/models/gui_profile_schema.py | 8 + .../orchestration/live_processing_config.py | 4 + python_app/runtime/gui_session_state.json | 3 + python_app/runtime/processing_live.json | 35 ++ python_app/runtime/run_config.json | 172 ++++++ python_app/runtime/s21_calibration_bundle.bin | Bin 64536 -> 64552 bytes python_app/runtime/s21_reference_bundle.bin | Bin 64536 -> 64552 bytes .../workflows/sequential_capture_workflow.py | 32 ++ 27 files changed, 1494 insertions(+), 168 deletions(-) create mode 100644 python_app/gui/controllers/sections/layout_helpers.py create mode 100644 python_app/runtime/gui_session_state.json create mode 100644 python_app/runtime/processing_live.json create mode 100644 python_app/runtime/run_config.json diff --git a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp index 21737e6..38afe31 100644 --- a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp +++ b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp @@ -36,6 +36,8 @@ struct ProcessingLiveConfig { float gpr_stop_freq_mhz = 6000.0F; float gpr_speed_m_s = 0.0F; float gpr_look_angle_deg = 0.0F; + float gpr_snr_thresh = 4.5F; + float gpr_snr_comp_max = 25.0F; bool gpr_background_subtract_enabled = true; std::uint32_t gpr_background_mean_count = 10U; std::uint64_t history_command_seq = 0; diff --git a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp index 988aa8d..1968ba5 100644 --- a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp +++ b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp @@ -211,6 +211,18 @@ using Json = nlohmann::json; } config.gpr_look_angle_deg = static_cast(found->get()); } + if (const auto found = root.find("gpr_snr_thresh"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_snr_thresh must be number"); + } + config.gpr_snr_thresh = static_cast(found->get()); + } + if (const auto found = root.find("gpr_snr_comp_max"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_snr_comp_max must be number"); + } + config.gpr_snr_comp_max = static_cast(found->get()); + } if (const auto found = root.find("gpr_background_subtract_enabled"); found != root.end()) { if (!found->is_boolean()) { throw std::runtime_error("processing.gpr_background_subtract_enabled must be bool"); diff --git a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp index e8ec958..0f4c83c 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp @@ -20,8 +20,6 @@ constexpr double kPi = 3.14159265358979323846; constexpr double kSpeedOfLightMetersPerSec = 299'792'458.0; constexpr double kAccumulatorXMarginM = 2.0; constexpr double kAccumulatorZMinM = 0.20; -constexpr double kSnrThresh = 4.5; -constexpr double kSnrCompMax = 25.0; constexpr std::size_t kGridWidth = 300U; constexpr std::size_t kGridHeight = 300U; constexpr double kGaussianSigma = 3.0; @@ -1197,6 +1195,8 @@ auto GprProcessor::process_collection( kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast(run_config.gpr.relative_permittivity))); const double start_hz = static_cast(live_config.gpr_start_freq_mhz) * 1'000'000.0; const double stop_hz = static_cast(live_config.gpr_stop_freq_mhz) * 1'000'000.0; + const double snr_thresh = std::max(0.0, static_cast(live_config.gpr_snr_thresh)); + const double snr_comp_max = std::max(0.0, static_cast(live_config.gpr_snr_comp_max)); std::unordered_map ascans_by_pair{}; double bandwidth_hz = 0.0; @@ -1248,7 +1248,8 @@ auto GprProcessor::process_collection( 4.0, std::floor(((velocity_mps / (2.0 * bandwidth_hz)) / z_step) * 0.7) )); - const auto peak_indices = find_peak_indices(ascan.amplitude, min_index, max_index, noise * kSnrThresh, min_distance); + const auto peak_indices = + find_peak_indices(ascan.amplitude, min_index, max_index, noise * snr_thresh, min_distance); auto& peaks = peaks_by_pair[key]; peaks.reserve(peak_indices.size()); for (const auto peak_index : peak_indices) { @@ -1259,7 +1260,7 @@ auto GprProcessor::process_collection( attenuation / attenuation_at_depth(tx_index, rx_index, 3.0, selection.x_tx, selection.x_rx); const double snr_comp = std::min( snr_raw / (std::pow(attenuation_norm, static_cast(live_config.gpr_comp_power)) + 1e-12), - kSnrCompMax + snr_comp_max ); peaks.push_back(PeakRecord{ .z_app = z_app, diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index b48eb3b..bbcb5b5 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -123,6 +123,7 @@ class AppWindow( key: str(preprocess_asset_model(self._defaults_config, key).set_name) for key in VISIBLE_PREPROCESS_ASSET_KEYS } + self._selected_preprocess_radar_key = self._radar_key(self._defaults_config) def _init_capture_state(self) -> None: """Initialize one-shot capture and sequence-control flags.""" @@ -161,6 +162,8 @@ class AppWindow( self._gpr_selected_geometry = None self._phase_viewbox = None self._history_run_signature = None + self._processor_run_signature = None + self._active_processing_mode = "pass_through" self._radar_limits: dict[str, float | int] | None = None def _history_limit_from_config(self) -> int: diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 7df7d80..7cc4a41 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -11,6 +11,7 @@ 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, @@ -37,6 +38,14 @@ from python_app.storage.npz_store import radar_key_from_config class AppWindowConfigMixin: """Builds runtime config models from current UI state.""" + 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.""" @@ -117,6 +126,16 @@ class AppWindowConfigMixin: 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.""" @@ -133,6 +152,7 @@ class AppWindowConfigMixin: """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, @@ -167,8 +187,16 @@ class AppWindowConfigMixin: 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( @@ -221,8 +249,16 @@ class AppWindowConfigMixin: 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( @@ -404,8 +440,16 @@ class AppWindowConfigMixin: 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, @@ -466,10 +510,18 @@ class AppWindowConfigMixin: 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)) @@ -478,6 +530,7 @@ class AppWindowConfigMixin: 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 @@ -552,6 +605,18 @@ class AppWindowConfigMixin: 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() @@ -580,6 +645,8 @@ class AppWindowConfigMixin: 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), @@ -615,8 +682,33 @@ class AppWindowConfigMixin: 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, @@ -655,8 +747,12 @@ class AppWindowConfigMixin: 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"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: @@ -665,6 +761,7 @@ class AppWindowConfigMixin: 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() @@ -676,6 +773,7 @@ class AppWindowConfigMixin: 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]): @@ -686,10 +784,12 @@ class AppWindowConfigMixin: 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 @@ -699,6 +799,7 @@ class AppWindowConfigMixin: 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() diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index d393c1b..3f9168a 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -21,6 +21,10 @@ from python_app.orchestration.shm_reader import ShmRingReader class AppWindowPipelineMixin: """Controls start/stop, readers, and periodic polling of pipeline rings.""" + def _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool: + """Return whether alive `data_processor` was started with different stable run settings.""" + return self._supervisor.is_processor_running() and self._processor_run_signature != run_signature + def _start_single_capture(self) -> None: """Start acquisition in single-capture mode.""" self._start_run(single_capture=True) @@ -40,11 +44,15 @@ class AppWindowPipelineMixin: try: processor_was_running = self._supervisor.is_processor_running() config = self._build_config() + run_signature = self._build_run_history_signature(config) + if self._processor_requires_restart(run_signature): + self._log("Restarting data_processor because stable run settings changed") + self._stop_all_processes() + processor_was_running = False if not processor_was_running: self._reset_runtime_history() self._validate_processing_mode_constraints(config) - run_signature = self._build_run_history_signature(config) radar_key = self._radar_key(config) missing_assets = [ @@ -102,6 +110,7 @@ class AppWindowPipelineMixin: self._raw_reader = ShmRingReader(config.rings.raw_tap.name) self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name) self._result_reader = ShmRingReader(config.rings.results.name) + self._processor_run_signature = run_signature self._single_capture_active = single_capture self._single_capture_start_ns = None self._single_capture_seen_raw = False @@ -138,6 +147,7 @@ class AppWindowPipelineMixin: return was_running = self._supervisor.is_running() + processor_only_running = self._supervisor.is_processor_running() and not was_running if was_running: self._stop_run() @@ -145,6 +155,15 @@ class AppWindowPipelineMixin: if self._radar_mode.currentText() == "native": self._refresh_radar_limits_from_device() config = self._build_config() + run_signature = self._build_run_history_signature(config) + if processor_only_running and self._processor_requires_restart(run_signature): + self._stop_all_processes() + self._reset_runtime_history() + self._history_run_signature = None + self._log( + "Stable run settings changed; stopped data_processor because cached history no longer " + "matches the current config" + ) self._prepare_radar_for_native_acquisition(config) self._log( "Radar settings applied: " @@ -213,6 +232,7 @@ class AppWindowPipelineMixin: self._single_capture_start_ns = None self._single_capture_seen_raw = False self._single_capture_target_collection_id = None + self._processor_run_signature = None self._update_history_indicator() self._status_label.setText("Status: idle") if was_running: diff --git a/python_app/gui/controllers/app_window_plot_mixin.py b/python_app/gui/controllers/app_window_plot_mixin.py index 26d35c2..348ea88 100644 --- a/python_app/gui/controllers/app_window_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot_mixin.py @@ -359,6 +359,249 @@ class AppWindowPlotMixin: 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() @@ -391,13 +634,7 @@ class AppWindowPlotMixin: image_item.setLevels(self._bscan_levels(sweeps, axis_mode)) self._bscan_plot.clear() - view_box = self._bscan_plot.getViewBox() - view_box.invertY(True) - view_box.enableAutoRange(x=False, y=False) - self._bscan_plot.getPlotItem().showAxis("left", show=True) - self._bscan_plot.getPlotItem().showAxis("bottom", show=True) - self._bscan_plot.setLabel("bottom", "Sweep #") - self._bscan_plot.setLabel("left", "Depth", units="m") + 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) @@ -538,6 +775,7 @@ class AppWindowPlotMixin: """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() @@ -557,23 +795,27 @@ class AppWindowPlotMixin: self._gpr_region_centers_item.hide() self._gpr_plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}") - 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 - + 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", "Depth", 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"]) @@ -694,6 +936,12 @@ class AppWindowPlotMixin: 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: @@ -729,36 +977,9 @@ class AppWindowPlotMixin: self._gpr_image_item.show() plot.setXRange(x_min, x_max, padding=0.02) - plot.setYRange(min(0.0, y_min), y_max, padding=0.02) + plot.setYRange(self._gpr_display_y_min(y_min, y_max), y_max, padding=0.02) - 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() + 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: @@ -824,6 +1045,242 @@ class AppWindowPlotMixin: 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: diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index c4c78cf..3a2f514 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -15,6 +15,67 @@ from python_app.workflows.sequential_capture_workflow import SequentialCaptureSe class AppWindowPreprocessMixin: """Handles preprocess set management and sequential capture workflows.""" + @staticmethod + def _capture_log_entries_for_session(session: SequentialCaptureSession) -> list[str]: + """Build capture-log rows from current session traces.""" + display_name = preprocess_asset_display_name(session.kind) + total_count = session.state().total_count + entries: list[str] = [] + for index, trace in enumerate(session.captured_traces(), start=1): + entries.append( + f"{display_name}: {index}/{total_count} | " + f"input={trace.combo.input_pos} output={trace.combo.output_pos}" + ) + return entries + + def _available_preprocess_sets_for_radar_key(self, radar_key: str) -> dict[str, list[str]]: + """Load available preprocess-set names for one radar key.""" + return { + key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key) + for key in VISIBLE_PREPROCESS_ASSET_KEYS + } + + def _reset_preprocess_selection_after_radar_key_change(self) -> None: + """Clear selected preprocess sets when radar-key-defining settings change.""" + try: + radar_key = self._radar_key_from_ui() + except Exception: + return + + previous_radar_key = getattr(self, "_selected_preprocess_radar_key", radar_key) + if radar_key == previous_radar_key: + return + + self._selected_preprocess_radar_key = radar_key + cleared = { + key: value + for key, value in self._selected_preprocess_sets.items() + if value + } + if not cleared: + if self._preprocess_dialog is not None: + self._refresh_sets() + return + + self._selected_preprocess_sets = { + key: "" + for key in VISIBLE_PREPROCESS_ASSET_KEYS + } + self._refresh_preprocess_summary_labels() + + if self._preprocess_dialog is not None: + self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False) + self._refresh_sets() + + cleared_summary = ", ".join( + f"{preprocess_asset_display_name(key)}={value}" + for key, value in cleared.items() + ) + self._log( + "Preprocess selection reset after radar settings changed: " + f"{previous_radar_key} -> {radar_key}; cleared {cleared_summary}" + ) + def _open_preprocess_panel(self) -> None: """Open preprocessing dialog and refresh available sets.""" dialog = self._ensure_preprocess_dialog() @@ -42,6 +103,9 @@ class AppWindowPreprocessMixin: dialog.selection_changed.connect(self._on_preprocess_selection_changed) dialog.start_sequence_requested.connect(self._start_capture_sequence) dialog.capture_next_requested.connect(self._capture_next_combo) + dialog.capture_all_requested.connect(self._capture_all_remaining) + dialog.undo_last_requested.connect(self._undo_last_capture) + dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence) dialog.abort_sequence_requested.connect(self._abort_capture_sequence) self._update_capture_dialog_state() return dialog @@ -72,14 +136,11 @@ class AppWindowPreprocessMixin: def _refresh_sets(self) -> None: """Refresh preprocess set lists for current radar key.""" - config = self._build_config() - radar_key = self._radar_key(config) + radar_key = self._radar_key_from_ui() + self._selected_preprocess_radar_key = radar_key dialog = self._ensure_preprocess_dialog() - available_sets = { - key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key) - for key in VISIBLE_PREPROCESS_ASSET_KEYS - } + available_sets = self._available_preprocess_sets_for_radar_key(radar_key) dialog.set_available_sets(available_sets) unavailable_selections: list[str] = [] @@ -137,7 +198,9 @@ class AppWindowPreprocessMixin: self._capture_session = session dialog.clear_capture_log() + dialog.reset_preview() dialog.set_status(f"{display_name} sequence started") + self._clear_trace_plots() self._update_capture_dialog_state() self._log( f"{display_name} sequence started: set={set_name}, radar_key={radar_key}, " @@ -155,47 +218,134 @@ class AppWindowPreprocessMixin: self._show_error("No active capture sequence") return - dialog = self._ensure_preprocess_dialog() - try: trace = session.capture_current_combo() + self._record_preprocess_capture(session, trace) + except Exception as exc: # noqa: BLE001 + self._show_exception("Failed to capture preprocess combo", exc) + self._abort_capture_sequence() + + def _capture_all_remaining(self) -> None: + """Capture all remaining combos for the active preprocess session.""" + session = self._capture_session + if session is None: + self._show_error("No active capture sequence") + return + if session.is_complete(): + self._show_error( + "Capture sequence is already complete", + details=self._capture_state_details(), + ) + return + + display_name = preprocess_asset_display_name(session.kind) + dialog = self._ensure_preprocess_dialog() + dialog.set_status(f"{display_name} batch capture started") + self._log( + f"{display_name} batch capture started: remaining=" + f"{session.state().total_count - session.state().captured_count}" + ) + try: + while not session.is_complete(): + trace = session.capture_current_combo() + self._record_preprocess_capture(session, trace) + except Exception as exc: # noqa: BLE001 + self._show_exception("Failed to capture preprocess combo", exc) + self._abort_capture_sequence() + + def _record_preprocess_capture(self, session: SequentialCaptureSession, trace) -> None: + """Update UI, preview, and logs after one successful preprocess capture.""" + dialog = self._ensure_preprocess_dialog() + state = session.state() + display_name = preprocess_asset_display_name(session.kind) + channel = preprocess_asset_channel(session.kind) + + dialog.append_capture_log_entry( + kind=display_name, + captured_count=state.captured_count, + total_count=state.total_count, + input_pos=trace.combo.input_pos, + output_pos=trace.combo.output_pos, + ) + dialog.draw_last_trace(trace, title=f"{display_name} captured", channel=channel) + self._draw_single_trace(trace, title=f"{display_name} last trace", channel=channel) + self._log( + f"{display_name} capture: {state.captured_count}/{state.total_count} | " + f"input={trace.combo.input_pos} output={trace.combo.output_pos}" + ) + + self._update_capture_dialog_state() + if session.is_complete(): + dialog.set_status(f"{display_name} sequence complete. Review captures or save the set.") + self._log( + f"{display_name} sequence capture complete: " + f"{state.captured_count}/{state.total_count}; waiting for save or undo" + ) + + def _undo_last_capture(self) -> None: + """Remove the most recently captured combo and rewind capture cursor.""" + session = self._capture_session + if session is None: + self._show_error("No active capture sequence") + return + + dialog = self._ensure_preprocess_dialog() + try: + removed_trace = session.undo_last_capture() state = session.state() display_name = preprocess_asset_display_name(session.kind) channel = preprocess_asset_channel(session.kind) - dialog.append_capture_log_entry( - kind=display_name, - captured_count=state.captured_count, - total_count=state.total_count, - input_pos=trace.combo.input_pos, - output_pos=trace.combo.output_pos, - ) - - dialog.draw_last_trace(trace, title=f"{display_name} captured", channel=channel) - self._draw_single_trace(trace, title=f"{display_name} last trace", channel=channel) - - self._log( - f"{display_name} capture: {state.captured_count}/{state.total_count} | " - f"input={trace.combo.input_pos} output={trace.combo.output_pos}" - ) - - if session.is_complete(): - radar_key, collection = session.finalize(self._store) - set_name = session.set_name - kind = session.kind - display_name = preprocess_asset_display_name(kind) - self._cleanup_capture_session() - - self._selected_preprocess_sets[kind] = set_name - self._refresh_sets() - dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)") - self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}") - self._resume_pipeline_if_needed() + dialog.set_capture_log_entries(self._capture_log_entries_for_session(session)) + last_trace = session.last_captured_trace() + if last_trace is None: + dialog.reset_preview() + dialog.set_status(f"{display_name} last capture removed. No captured combos remain.") + self._clear_trace_plots() else: - self._update_capture_dialog_state() + dialog.draw_last_trace(last_trace, title=f"{display_name} last trace", channel=channel) + dialog.set_status(f"{display_name} last capture removed. Ready to recapture.") + self._draw_single_trace(last_trace, title=f"{display_name} last trace", channel=channel) + + self._update_capture_dialog_state() + self._log( + f"{display_name} undo last capture: removed input={removed_trace.combo.input_pos} " + f"output={removed_trace.combo.output_pos}; remaining={state.captured_count}/{state.total_count}" + ) except Exception as exc: # noqa: BLE001 - self._show_exception("Failed to capture preprocess combo", exc) - self._abort_capture_sequence() + self._show_exception("Failed to undo last preprocess capture", exc) + + def _finalize_capture_sequence(self) -> None: + """Persist completed capture session into preprocess-set storage.""" + session = self._capture_session + if session is None: + self._show_error("No active capture sequence") + return + if not session.is_complete(): + self._show_error( + "Capture sequence is not complete", + details=( + f"Captured {session.state().captured_count}/{session.state().total_count} combos. " + "Finish the remaining captures before saving." + ), + ) + return + + dialog = self._ensure_preprocess_dialog() + try: + radar_key, collection = session.finalize(self._store) + set_name = session.set_name + kind = session.kind + display_name = preprocess_asset_display_name(kind) + self._cleanup_capture_session() + + self._selected_preprocess_sets[kind] = set_name + self._refresh_sets() + dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)") + self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}") + self._resume_pipeline_if_needed() + except Exception as exc: # noqa: BLE001 + self._show_exception("Failed to save preprocess set", exc) def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None: """Abort active capture session and optionally resume pipeline.""" @@ -222,6 +372,9 @@ class AppWindowPreprocessMixin: total_count=0, next_input=None, next_output=None, + can_undo=False, + can_finalize=False, + can_capture_all=False, ) return @@ -238,6 +391,9 @@ class AppWindowPreprocessMixin: total_count=state.total_count, next_input=next_input, next_output=next_output, + can_undo=state.can_undo, + can_finalize=state.is_complete, + can_capture_all=(not state.is_complete and state.current_combo is not None), ) def _cleanup_capture_session(self) -> None: diff --git a/python_app/gui/controllers/app_window_ui_mixin.py b/python_app/gui/controllers/app_window_ui_mixin.py index 2752873..80d0fa8 100644 --- a/python_app/gui/controllers/app_window_ui_mixin.py +++ b/python_app/gui/controllers/app_window_ui_mixin.py @@ -77,7 +77,7 @@ class AppWindowUiMixin: # Default view on startup is pass-through traces. self._plot_stack.setCurrentWidget(self._trace_plots_container) - root_layout.addWidget(self._plot_stack, stretch=12) + root_layout.addWidget(self._plot_stack, stretch=11) @staticmethod def _create_plot_widget(*, background: str) -> pg.PlotWidget: @@ -89,6 +89,7 @@ class AppWindowUiMixin: # B-scan surface: one PlotWidget used as canvas for ImageItem heatmap. self._bscan_plot = self._create_plot_widget(background="#0f141c") self._bscan_plot.showGrid(x=True, y=True, alpha=0.2) + self._configure_bscan_plot_axes() self._plot_stack.addWidget(self._bscan_plot) def _build_trace_plot_page(self) -> None: @@ -131,6 +132,7 @@ class AppWindowUiMixin: """Create GPR page in plot stack.""" self._gpr_plot = self._create_plot_widget(background="#0f141c") self._gpr_plot.showGrid(x=True, y=True, alpha=0.2) + self._configure_gpr_plot_axes() self._plot_stack.addWidget(self._gpr_plot) def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None: @@ -142,7 +144,7 @@ class AppWindowUiMixin: root_layout.addWidget(self._settings_toggle_button, stretch=0) def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None: - """Build right settings panel with controls, status labels, and log.""" + """Build right settings panel with controls, history summary, and log.""" self._settings_panel = QWidget(root) self._settings_panel.setMinimumWidth(610) right_layout = QVBoxLayout(self._settings_panel) @@ -151,6 +153,31 @@ class AppWindowUiMixin: # Build log early so `_show_error()` can append text even during # subsequent group construction if something fails. + self._log_panel = self._build_log_panel() + + right_layout.addWidget(build_primary_actions_group(self), stretch=0) + right_layout.addWidget(self._build_settings_scroll(), stretch=1) + + self._status_label = QLabel("Status: idle", self._settings_panel) + self._status_label.setObjectName("statusLabel") + self._status_label.hide() + + self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel) + self._history_label.setObjectName("hintLabel") + right_layout.addWidget(self._history_label) + + right_layout.addWidget(self._log_panel, stretch=0) + root_layout.addWidget(self._settings_panel, stretch=7) + + def _build_log_panel(self) -> QWidget: + """Build runtime log block with collapsible log body.""" + self._log_panel_title = QLabel("Runtime Log", self._settings_panel) + self._log_panel_title.setObjectName("hintLabel") + + self._log_toggle_button = QPushButton(self._settings_panel) + self._log_toggle_button.setObjectName("sectionToggleButton") + self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel()) + self._log_box = QTextEdit(self._settings_panel) self._log_box.setObjectName("runtimeLogBox") self._log_box.setReadOnly(True) @@ -158,19 +185,24 @@ class AppWindowUiMixin: self._log_box.setMinimumHeight(170) self._log_box.document().setMaximumBlockCount(1200) - right_layout.addWidget(build_primary_actions_group(self), stretch=0) - right_layout.addWidget(self._build_settings_scroll(), stretch=1) + panel = QWidget(self._settings_panel) + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(6) - self._status_label = QLabel("Status: idle", self._settings_panel) - self._status_label.setObjectName("statusLabel") - right_layout.addWidget(self._status_label) + header_row = QWidget(panel) + header_layout = QHBoxLayout(header_row) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(8) + header_layout.addWidget(self._log_panel_title) + header_layout.addStretch(1) + header_layout.addWidget(self._log_toggle_button) - self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel) - self._history_label.setObjectName("hintLabel") - right_layout.addWidget(self._history_label) + panel_layout.addWidget(header_row) + panel_layout.addWidget(self._log_box) - right_layout.addWidget(self._log_box, stretch=0) - root_layout.addWidget(self._settings_panel, stretch=7) + self._toggle_log_panel(visible=True) + return panel def _build_settings_scroll(self) -> QScrollArea: """Build scroll area with all control groups in display order.""" @@ -178,7 +210,7 @@ class AppWindowUiMixin: # remains usable on smaller screens and with future extra controls. controls = QWidget(self._settings_panel) controls_layout = QVBoxLayout(controls) - controls_layout.setContentsMargins(0, 0, 0, 0) + controls_layout.setContentsMargins(0, 0, 12, 0) controls_layout.setSpacing(10) for group in self._build_control_groups(): controls_layout.addWidget(group) @@ -193,8 +225,8 @@ class AppWindowUiMixin: def _build_control_groups(self) -> list[QGroupBox]: """Create all settings groups in top-to-bottom order.""" return [ - build_switch_group(self), build_data_actions_group(self), + build_switch_group(self), build_preprocess_summary_group(self), build_processing_group(self), build_radar_group(self), @@ -216,6 +248,19 @@ class AppWindowUiMixin: self._settings_toggle_button.setText("<") self._settings_toggle_button.setToolTip("Show settings panel") + def _toggle_log_panel(self, *, visible: bool | None = None) -> None: + """Toggle runtime log body visibility or force a specific state.""" + if visible is None: + visible = not self._log_box.isVisible() + + self._log_box.setVisible(visible) + if visible: + self._log_toggle_button.setText("Collapse") + self._log_toggle_button.setToolTip("Hide runtime log entries") + else: + self._log_toggle_button.setText("Expand") + self._log_toggle_button.setToolTip("Show runtime log entries") + def _set_plot_mode(self, mode: str) -> None: """Switch visible plot page according to processing mode. diff --git a/python_app/gui/controllers/sections/data_actions_section.py b/python_app/gui/controllers/sections/data_actions_section.py index ffb1a55..5dd09d5 100644 --- a/python_app/gui/controllers/sections/data_actions_section.py +++ b/python_app/gui/controllers/sections/data_actions_section.py @@ -31,9 +31,6 @@ def build_data_actions_group(owner) -> QGroupBox: remove_last_button = QPushButton("Remove Last Measurement") remove_last_button.clicked.connect(owner._remove_last_runtime_history) remove_last_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - clear_history_button = QPushButton("Clear Runtime History") - clear_history_button.clicked.connect(owner._clear_all_runtime_history) - clear_history_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) owner._save_count = QSpinBox() owner._save_count.setMinimum(1) owner._save_count.setMaximum(10_000) @@ -44,8 +41,7 @@ def build_data_actions_group(owner) -> QGroupBox: button_grid.setVerticalSpacing(8) button_grid.addWidget(save_button, 0, 0) button_grid.addWidget(save_vna_json_button, 0, 1) - button_grid.addWidget(remove_last_button, 1, 0) - button_grid.addWidget(clear_history_button, 1, 1) + button_grid.addWidget(remove_last_button, 1, 0, 1, 2) button_grid.setColumnStretch(0, 1) button_grid.setColumnStretch(1, 1) layout.addLayout(button_grid) diff --git a/python_app/gui/controllers/sections/layout_helpers.py b/python_app/gui/controllers/sections/layout_helpers.py new file mode 100644 index 0000000..24141af --- /dev/null +++ b/python_app/gui/controllers/sections/layout_helpers.py @@ -0,0 +1,60 @@ +"""Shared layout helpers for section builders.""" + +from __future__ import annotations + +from typing import TypeAlias + +from PyQt6.QtWidgets import QFormLayout, QHBoxLayout, QWidget + + +FormLabel: TypeAlias = str | QWidget +FormRow: TypeAlias = QWidget | tuple[FormLabel, QWidget] + + +def build_two_column_form_widget( + parent: QWidget, + rows: list[FormRow], + *, + split_index: int | None = None, +) -> QWidget: + """Build one widget containing two side-by-side form columns.""" + panel = QWidget(parent) + panel_layout = QHBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(12) + + left_column = QWidget(panel) + left_form = _create_form_layout(left_column) + right_column = QWidget(panel) + right_form = _create_form_layout(right_column) + + panel_layout.addWidget(left_column, stretch=1) + panel_layout.addWidget(right_column, stretch=1) + + actual_split_index = (len(rows) + 1) // 2 if split_index is None else split_index + actual_split_index = max(0, min(actual_split_index, len(rows))) + + for row in rows[:actual_split_index]: + _add_form_row(left_form, row) + for row in rows[actual_split_index:]: + _add_form_row(right_form, row) + return panel + + +def _create_form_layout(parent: QWidget) -> QFormLayout: + """Create a compact growing form layout for one column.""" + form = QFormLayout(parent) + form.setContentsMargins(0, 0, 0, 0) + form.setHorizontalSpacing(10) + form.setVerticalSpacing(6) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + return form + + +def _add_form_row(form: QFormLayout, row: FormRow) -> None: + """Append one form row, with or without an explicit label.""" + if isinstance(row, tuple): + label, field = row + form.addRow(label, field) + return + form.addRow(row) diff --git a/python_app/gui/controllers/sections/preprocess_summary_section.py b/python_app/gui/controllers/sections/preprocess_summary_section.py index a855ac7..d1c3c51 100644 --- a/python_app/gui/controllers/sections/preprocess_summary_section.py +++ b/python_app/gui/controllers/sections/preprocess_summary_section.py @@ -2,20 +2,25 @@ from __future__ import annotations -from PyQt6.QtWidgets import QFormLayout, QGroupBox, QLabel +from PyQt6.QtWidgets import QGroupBox, QLabel, QVBoxLayout +from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name def build_preprocess_summary_group(owner) -> QGroupBox: """Create selected preprocess-set summary section.""" group = QGroupBox("Selected Preprocess Sets") - form = QFormLayout(group) - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + layout = QVBoxLayout(group) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) owner._selected_preprocess_labels = {} + rows = [] for key in VISIBLE_PREPROCESS_ASSET_KEYS: label = QLabel("") owner._selected_preprocess_labels[key] = label - form.addRow(preprocess_asset_display_name(key), label) + rows.append((preprocess_asset_display_name(key), label)) + + layout.addWidget(build_two_column_form_widget(group, rows, split_index=(len(rows) + 1) // 2)) return group diff --git a/python_app/gui/controllers/sections/primary_actions_section.py b/python_app/gui/controllers/sections/primary_actions_section.py index 6cdb902..5a67e56 100644 --- a/python_app/gui/controllers/sections/primary_actions_section.py +++ b/python_app/gui/controllers/sections/primary_actions_section.py @@ -45,7 +45,11 @@ def build_primary_actions_group(owner) -> QGroupBox: preprocess_button = _expanding_button("Preprocessing") preprocess_button.clicked.connect(owner._open_preprocess_panel) - layout.addWidget(preprocess_button, 2, 0, 1, 3) + layout.addWidget(preprocess_button, 2, 0) + + clear_history_button = _expanding_button("Clear Runtime History") + clear_history_button.clicked.connect(owner._clear_all_runtime_history) + layout.addWidget(clear_history_button, 2, 1, 1, 2) layout.setColumnStretch(0, 1) layout.setColumnStretch(1, 1) diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 23699c4..bfb0c92 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -13,9 +13,13 @@ from PyQt6.QtWidgets import ( QSizePolicy, QSpinBox, QStackedWidget, + QVBoxLayout, QWidget, ) +from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget + + def _format_tx_geometry(owner) -> str: """Render Tx geometry defaults into editable line-based text.""" return "\n".join( @@ -32,6 +36,17 @@ def _format_rx_geometry(owner) -> str: ) +def _build_processing_mode_page(parent: QStackedWidget, rows: list[FormRow], *, split_index: int | None = None) -> QWidget: + """Create one processing-mode page with a two-column form body.""" + page = QWidget(parent) + page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + page_layout = QVBoxLayout(page) + page_layout.setContentsMargins(0, 0, 0, 0) + page_layout.setSpacing(0) + page_layout.addWidget(build_two_column_form_widget(page, rows, split_index=split_index)) + return page + + def build_processing_group(owner) -> QGroupBox: """Create processing mode section with pass-through, B-scan, and GPR pages.""" group = QGroupBox("Processing") @@ -49,11 +64,6 @@ def build_processing_group(owner) -> QGroupBox: owner._processing_mode_pages = QStackedWidget(group) owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - pass_through_page = QWidget(owner._processing_mode_pages) - pass_through_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - pass_through_form = QFormLayout(pass_through_page) - pass_through_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - owner._show_magnitude_checkbox = QCheckBox("Show magnitude") owner._show_magnitude_checkbox.setChecked(bool(pass_defaults.show_magnitude)) @@ -77,18 +87,19 @@ def build_processing_group(owner) -> QGroupBox: owner._sync_pass_through_y_controls() - pass_through_form.addRow(owner._show_magnitude_checkbox) - pass_through_form.addRow(owner._show_phase_checkbox) - pass_through_form.addRow(owner._pass_through_fixed_y_enabled) - pass_through_form.addRow("Y min dB", owner._pass_through_y_min_db) - pass_through_form.addRow("Y max dB", owner._pass_through_y_max_db) + pass_through_page = _build_processing_mode_page( + owner._processing_mode_pages, + [ + owner._show_magnitude_checkbox, + owner._show_phase_checkbox, + owner._pass_through_fixed_y_enabled, + ("Y min dB", owner._pass_through_y_min_db), + ("Y max dB", owner._pass_through_y_max_db), + ], + split_index=3, + ) owner._processing_mode_pages.addWidget(pass_through_page) - bscan_page = QWidget(owner._processing_mode_pages) - bscan_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - bscan_form = QFormLayout(bscan_page) - bscan_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - owner._bscan_axis = QComboBox() owner._bscan_axis.addItems(["abs", "real", "phase"]) owner._set_combo_current_text(owner._bscan_axis, bscan_defaults.axis) @@ -123,18 +134,20 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_stop_freq_mhz.setSingleStep(10.0) owner._bscan_stop_freq_mhz.setValue(float(bscan_defaults.stop_freq_mhz)) - bscan_form.addRow("Axis", owner._bscan_axis) - bscan_form.addRow("Cut m", owner._bscan_cut_m) - bscan_form.addRow("Max depth m", owner._bscan_max_depth_m) - bscan_form.addRow("Gain", owner._bscan_gain) - bscan_form.addRow("Start MHz", owner._bscan_start_freq_mhz) - bscan_form.addRow("Stop MHz", owner._bscan_stop_freq_mhz) + bscan_page = _build_processing_mode_page( + owner._processing_mode_pages, + [ + ("Axis", owner._bscan_axis), + ("Cut m", owner._bscan_cut_m), + ("Max depth m", owner._bscan_max_depth_m), + ("Gain", owner._bscan_gain), + ("Start MHz", owner._bscan_start_freq_mhz), + ("Stop MHz", owner._bscan_stop_freq_mhz), + ], + split_index=3, + ) owner._processing_mode_pages.addWidget(bscan_page) - gpr_page = QWidget(owner._processing_mode_pages) - gpr_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - gpr_form = QFormLayout(gpr_page) - gpr_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) gpr_defaults = owner._defaults_config.gpr owner._gpr_config_mode = QComboBox() @@ -149,11 +162,11 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner)) owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m") - owner._gpr_tx_geometry_input.setMinimumHeight(88) + owner._gpr_tx_geometry_input.setFixedHeight(78) owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner)) owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m") - owner._gpr_rx_geometry_input.setMinimumHeight(120) + owner._gpr_rx_geometry_input.setFixedHeight(78) owner._gpr_input_positions_input = QLineEdit(str(gpr_live_defaults.input_positions)) owner._gpr_input_positions_input.setPlaceholderText("0,1,2") @@ -203,6 +216,18 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_look_angle_deg.setSingleStep(0.1) owner._gpr_look_angle_deg.setValue(float(gpr_live_defaults.look_angle_deg)) + owner._gpr_snr_thresh = QDoubleSpinBox() + owner._gpr_snr_thresh.setDecimals(2) + owner._gpr_snr_thresh.setRange(0.0, 1_000.0) + owner._gpr_snr_thresh.setSingleStep(0.1) + owner._gpr_snr_thresh.setValue(float(gpr_live_defaults.snr_thresh)) + + owner._gpr_snr_comp_max = QDoubleSpinBox() + owner._gpr_snr_comp_max.setDecimals(2) + owner._gpr_snr_comp_max.setRange(0.0, 1_000.0) + owner._gpr_snr_comp_max.setSingleStep(0.5) + owner._gpr_snr_comp_max.setValue(float(gpr_live_defaults.snr_comp_max)) + owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections") owner._gpr_background_subtract_enabled.setChecked(bool(gpr_live_defaults.background_subtract_enabled)) @@ -210,21 +235,67 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_background_mean_count.setRange(0, 10_000) owner._gpr_background_mean_count.setValue(int(gpr_live_defaults.background_mean_count)) - gpr_form.addRow("Config mode", owner._gpr_config_mode) - gpr_form.addRow("Relative permittivity", owner._gpr_relative_permittivity) - gpr_form.addRow("Tx geometry", owner._gpr_tx_geometry_input) - gpr_form.addRow("Rx geometry", owner._gpr_rx_geometry_input) - gpr_form.addRow("Input positions", owner._gpr_input_positions_input) - gpr_form.addRow("Output positions", owner._gpr_output_positions_input) - gpr_form.addRow("Min depth m", owner._gpr_min_depth_m) - gpr_form.addRow("Max depth m", owner._gpr_max_depth_m) - gpr_form.addRow("Comp power", owner._gpr_comp_power) - gpr_form.addRow("Start MHz", owner._gpr_start_freq_mhz) - gpr_form.addRow("Stop MHz", owner._gpr_stop_freq_mhz) - gpr_form.addRow("Speed m/s", owner._gpr_speed_m_s) - gpr_form.addRow("Look angle deg", owner._gpr_look_angle_deg) - gpr_form.addRow(owner._gpr_background_subtract_enabled) - gpr_form.addRow("Mean count", owner._gpr_background_mean_count) + owner._gpr_render_mode = QComboBox() + owner._gpr_render_mode.addItems(["heatmap", "objects_only"]) + owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode) + + owner._gpr_min_visible_pair_count = QSpinBox() + owner._gpr_min_visible_pair_count.setRange(1, 10_000) + owner._gpr_min_visible_pair_count.setValue(int(gpr_live_defaults.min_visible_pair_count)) + + owner._gpr_visible_x_min_m = QDoubleSpinBox() + owner._gpr_visible_x_min_m.setDecimals(2) + owner._gpr_visible_x_min_m.setRange(-100.0, 100.0) + owner._gpr_visible_x_min_m.setSingleStep(0.1) + owner._gpr_visible_x_min_m.setValue(float(gpr_live_defaults.visible_x_min_m)) + + owner._gpr_visible_x_max_m = QDoubleSpinBox() + owner._gpr_visible_x_max_m.setDecimals(2) + owner._gpr_visible_x_max_m.setRange(-100.0, 100.0) + owner._gpr_visible_x_max_m.setSingleStep(0.1) + owner._gpr_visible_x_max_m.setValue(float(gpr_live_defaults.visible_x_max_m)) + + owner._gpr_visible_z_min_m = QDoubleSpinBox() + owner._gpr_visible_z_min_m.setDecimals(2) + owner._gpr_visible_z_min_m.setRange(0.0, 100.0) + owner._gpr_visible_z_min_m.setSingleStep(0.1) + owner._gpr_visible_z_min_m.setValue(float(gpr_live_defaults.visible_z_min_m)) + + owner._gpr_visible_z_max_m = QDoubleSpinBox() + owner._gpr_visible_z_max_m.setDecimals(2) + owner._gpr_visible_z_max_m.setRange(0.0, 100.0) + owner._gpr_visible_z_max_m.setSingleStep(0.1) + owner._gpr_visible_z_max_m.setValue(float(gpr_live_defaults.visible_z_max_m)) + + gpr_page = _build_processing_mode_page( + owner._processing_mode_pages, + [ + ("Config mode", owner._gpr_config_mode), + ("Relative permittivity", owner._gpr_relative_permittivity), + ("Input positions", owner._gpr_input_positions_input), + ("Output positions", owner._gpr_output_positions_input), + ("Min depth m", owner._gpr_min_depth_m), + ("Max depth m", owner._gpr_max_depth_m), + ("Comp power", owner._gpr_comp_power), + ("SNR thresh", owner._gpr_snr_thresh), + ("SNR comp max", owner._gpr_snr_comp_max), + ("Speed m/s", owner._gpr_speed_m_s), + ("Render mode", owner._gpr_render_mode), + ("Min visible pairs", owner._gpr_min_visible_pair_count), + ("Tx geometry", owner._gpr_tx_geometry_input), + ("Rx geometry", owner._gpr_rx_geometry_input), + ("Start MHz", owner._gpr_start_freq_mhz), + ("Stop MHz", owner._gpr_stop_freq_mhz), + ("Look angle deg", owner._gpr_look_angle_deg), + ("Visible X min m", owner._gpr_visible_x_min_m), + ("Visible X max m", owner._gpr_visible_x_max_m), + ("Visible Z min m", owner._gpr_visible_z_min_m), + ("Visible Z max m", owner._gpr_visible_z_max_m), + owner._gpr_background_subtract_enabled, + ("Mean count", owner._gpr_background_mean_count), + ], + split_index=11, + ) owner._processing_mode_pages.addWidget(gpr_page) owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed) @@ -252,8 +323,16 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_snr_thresh.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_snr_comp_max.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_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._on_processing_mode_changed(owner._processing_mode.currentText()) return group diff --git a/python_app/gui/controllers/sections/radar_section.py b/python_app/gui/controllers/sections/radar_section.py index 48071a9..415c0ea 100644 --- a/python_app/gui/controllers/sections/radar_section.py +++ b/python_app/gui/controllers/sections/radar_section.py @@ -2,14 +2,17 @@ from __future__ import annotations -from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit +from PyQt6.QtWidgets import QComboBox, QGroupBox, QLabel, QLineEdit, QVBoxLayout + +from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget def build_radar_group(owner) -> QGroupBox: """Create radar settings controls and labels.""" group = QGroupBox("Radar") - form = QFormLayout(group) - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + layout = QVBoxLayout(group) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) defaults = owner._defaults_config.radar owner._serial_input = QLineEdit(defaults.serial) @@ -30,6 +33,9 @@ def build_radar_group(owner) -> QGroupBox: owner._radar_mode.currentTextChanged.connect(owner._on_radar_identity_changed) owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) + owner._points_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) + owner._ifbw_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) + owner._power_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) owner._radar_start_label = QLabel("Start Hz") owner._radar_stop_label = QLabel("Stop Hz") @@ -40,10 +46,18 @@ def build_radar_group(owner) -> QGroupBox: owner._radar_limits_hint = QLabel("Mock mode: device limits are not applied.") owner._radar_limits_hint.setObjectName("hintLabel") - form.addRow(owner._radar_start_label, owner._start_hz_input) - form.addRow(owner._radar_stop_label, owner._stop_hz_input) - form.addRow(owner._radar_points_label, owner._points_input) - form.addRow(owner._radar_ifbw_label, owner._ifbw_input) - form.addRow(owner._radar_power_label, owner._power_input) - form.addRow(owner._radar_limits_hint) + layout.addWidget( + build_two_column_form_widget( + group, + [ + (owner._radar_start_label, owner._start_hz_input), + (owner._radar_stop_label, owner._stop_hz_input), + (owner._radar_points_label, owner._points_input), + (owner._radar_ifbw_label, owner._ifbw_input), + (owner._radar_power_label, owner._power_input), + ], + split_index=3, + ) + ) + layout.addWidget(owner._radar_limits_hint) return group diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index eaf91cd..ba051f2 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -36,6 +36,9 @@ class PreprocessDialog(QDialog): selection_changed = pyqtSignal() start_sequence_requested = pyqtSignal(str) capture_next_requested = pyqtSignal() + capture_all_requested = pyqtSignal() + undo_last_requested = pyqtSignal() + finalize_sequence_requested = pyqtSignal() abort_sequence_requested = pyqtSignal() def __init__(self, parent=None) -> None: @@ -97,6 +100,8 @@ class PreprocessDialog(QDialog): for key in keys: combo = QComboBox(group) + combo.setPlaceholderText("") + combo.setCurrentIndex(-1) combo.currentTextChanged.connect(self._emit_selection_changed) self._set_combos[key] = combo form.addRow(self._asset_row_label(key), combo) @@ -142,12 +147,27 @@ class PreprocessDialog(QDialog): self._capture_next_button.clicked.connect(self.capture_next_requested.emit) self._capture_next_button.setEnabled(False) + self._capture_all_button = QPushButton("Capture All Remaining", parent) + self._capture_all_button.clicked.connect(self.capture_all_requested.emit) + self._capture_all_button.setEnabled(False) + + self._undo_last_button = QPushButton("Undo Last Capture", parent) + self._undo_last_button.clicked.connect(self.undo_last_requested.emit) + self._undo_last_button.setEnabled(False) + + self._save_sequence_button = QPushButton("Save Captured Set", parent) + self._save_sequence_button.clicked.connect(self.finalize_sequence_requested.emit) + self._save_sequence_button.setEnabled(False) + self._abort_button = QPushButton("Abort Sequence", parent) self._abort_button.clicked.connect(self.abort_sequence_requested.emit) self._abort_button.setEnabled(False) layout = QHBoxLayout() layout.addWidget(self._capture_next_button) + layout.addWidget(self._capture_all_button) + layout.addWidget(self._undo_last_button) + layout.addWidget(self._save_sequence_button) layout.addWidget(self._abort_button) layout.addStretch(1) return layout @@ -189,6 +209,10 @@ class PreprocessDialog(QDialog): """Clear capture history text box.""" self._capture_log.clear() + def set_capture_log_entries(self, entries: list[str]) -> None: + """Replace capture history text with provided rows.""" + self._capture_log.setPlainText("\n".join(entries)) + def append_capture_log_entry( self, *, @@ -212,6 +236,9 @@ class PreprocessDialog(QDialog): total_count: int, next_input: int | None, next_output: int | None, + can_undo: bool, + can_finalize: bool, + can_capture_all: bool, ) -> None: """Update sequence progress/status widgets.""" if kind is None: @@ -219,20 +246,27 @@ class PreprocessDialog(QDialog): self._progress_label.setText("0 / 0") self._combo_label.setText("") self._capture_next_button.setEnabled(False) + self._capture_all_button.setEnabled(False) + self._undo_last_button.setEnabled(False) + self._save_sequence_button.setEnabled(False) self._abort_button.setEnabled(False) + self._capture_all_button.setText("Capture All Remaining") return active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind self._active_kind_label.setText(active_label) self._progress_label.setText(f"{captured_count} / {total_count}") + self._undo_last_button.setEnabled(bool(can_undo)) + self._save_sequence_button.setEnabled(bool(can_finalize)) + self._capture_all_button.setEnabled(bool(can_capture_all)) + self._abort_button.setEnabled(True) + self._capture_all_button.setText("Capture All Remaining") if next_input is None or next_output is None: self._combo_label.setText("") self._capture_next_button.setEnabled(False) - self._abort_button.setEnabled(True) else: self._combo_label.setText(f"input={next_input}, output={next_output}") self._capture_next_button.setEnabled(True) - self._abort_button.setEnabled(True) def set_available_sets(self, available_sets: dict[str, list[str]]) -> None: """Replace combo-box choices for all preprocess assets.""" @@ -245,10 +279,11 @@ class PreprocessDialog(QDialog): """Apply selected set names to all comboboxes and optionally emit update.""" for key in VISIBLE_PREPROCESS_ASSET_KEYS: selected_value = selected_sets.get(key, "") - if not selected_value: - continue combo = self._set_combos[key] with QSignalBlocker(combo): + if not selected_value: + combo.setCurrentIndex(-1) + continue index = combo.findText(selected_value) if index < 0: combo.addItem(selected_value) @@ -261,6 +296,14 @@ class PreprocessDialog(QDialog): """Set short human-readable status line.""" self._status_label.setText(message) + def reset_preview(self) -> None: + """Clear preview surface and restore default empty-state text when possible.""" + if self._preview_plot is not None: + self._preview_plot.clear() + self._preview_plot.setTitle("") + if self._preview_placeholder is not None: + self._preview_placeholder.setText("Preview will appear after the first successful capture.") + def draw_last_trace(self, trace: TraceData, title: str, *, channel: str) -> None: """Draw the latest captured sweep trace for the requested channel in dB scale.""" samples = trace.s11 if channel == "s11" else trace.s21 @@ -328,6 +371,7 @@ class PreprocessDialog(QDialog): combo.clear() combo.addItems(names) if not current_text: + combo.setCurrentIndex(-1) return index = combo.findText(current_text) if index < 0: diff --git a/python_app/gui/runtime/constraints.py b/python_app/gui/runtime/constraints.py index 13b5995..9eb3a4e 100644 --- a/python_app/gui/runtime/constraints.py +++ b/python_app/gui/runtime/constraints.py @@ -27,6 +27,10 @@ def validate_processing_mode_constraints( if processing_mode != "gpr": return + configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos} + if len(configured_combos) < 2: + raise RuntimeError(f"GPR requires at least 2 distinct run combos (now {len(configured_combos)})") + available_inputs = sorted({int(entry.input_pos) for entry in config.gpr.rx_geometry}) available_outputs = sorted({int(entry.output_pos) for entry in config.gpr.tx_geometry}) if not available_inputs or not available_outputs: @@ -46,7 +50,6 @@ def validate_processing_mode_constraints( raise RuntimeError(f"GPR output positions are missing from geometry config: {missing_outputs}") required_combos = {(int(input_pos), int(output_pos)) for input_pos in selected_inputs for output_pos in selected_outputs} - configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos} missing_combos = sorted(required_combos - configured_combos) if missing_combos: raise RuntimeError( diff --git a/python_app/gui/theme.py b/python_app/gui/theme.py index 34b630f..29b67ea 100644 --- a/python_app/gui/theme.py +++ b/python_app/gui/theme.py @@ -71,6 +71,12 @@ QPushButton#settingsToggleButton { font-weight: 700; } +QPushButton#sectionToggleButton { + min-width: 84px; + padding: 5px 10px; + border-radius: 7px; +} + QLineEdit, QPlainTextEdit, QTextEdit, diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index 6cc8b76..b0e452d 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -208,6 +208,18 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.look_angle_deg, "gui.processing.gpr", ), + snr_thresh=_optional_float( + gpr_object, + "snr_thresh", + gui.processing.gpr.snr_thresh, + "gui.processing.gpr", + ), + snr_comp_max=_optional_float( + gpr_object, + "snr_comp_max", + gui.processing.gpr.snr_comp_max, + "gui.processing.gpr", + ), background_subtract_enabled=_optional_bool( gpr_object, "background_subtract_enabled", @@ -220,12 +232,56 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.background_mean_count, "gui.processing.gpr", ), + render_mode=_optional_string( + gpr_object, + "render_mode", + gui.processing.gpr.render_mode, + "gui.processing.gpr", + ), + min_visible_pair_count=_optional_int( + gpr_object, + "min_visible_pair_count", + gui.processing.gpr.min_visible_pair_count, + "gui.processing.gpr", + ), + visible_x_min_m=_optional_float( + gpr_object, + "visible_x_min_m", + gui.processing.gpr.visible_x_min_m, + "gui.processing.gpr", + ), + visible_x_max_m=_optional_float( + gpr_object, + "visible_x_max_m", + gui.processing.gpr.visible_x_max_m, + "gui.processing.gpr", + ), + visible_z_min_m=_optional_float( + gpr_object, + "visible_z_min_m", + gui.processing.gpr.visible_z_min_m, + "gui.processing.gpr", + ), + visible_z_max_m=_optional_float( + gpr_object, + "visible_z_max_m", + gui.processing.gpr.visible_z_max_m, + "gui.processing.gpr", + ), ), ) if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr"}: raise ValueError("gui.processing.selected_mode must be one of: pass_through, bscan, gpr") if gui.processing.bscan.axis not in {"abs", "real", "phase"}: raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase") + if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}: + raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only") + if gui.processing.gpr.snr_thresh < 0.0: + raise ValueError("gui.processing.gpr.snr_thresh must be >= 0") + if gui.processing.gpr.snr_comp_max < 0.0: + raise ValueError("gui.processing.gpr.snr_comp_max must be >= 0") + if gui.processing.gpr.min_visible_pair_count < 1: + raise ValueError("gui.processing.gpr.min_visible_pair_count must be >= 1") data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions") gui.data_actions = GuiDataActionsStateModel( @@ -302,8 +358,16 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, "speed_m_s": gui.processing.gpr.speed_m_s, "look_angle_deg": gui.processing.gpr.look_angle_deg, + "snr_thresh": gui.processing.gpr.snr_thresh, + "snr_comp_max": gui.processing.gpr.snr_comp_max, "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, "background_mean_count": gui.processing.gpr.background_mean_count, + "render_mode": gui.processing.gpr.render_mode, + "min_visible_pair_count": gui.processing.gpr.min_visible_pair_count, + "visible_x_min_m": gui.processing.gpr.visible_x_min_m, + "visible_x_max_m": gui.processing.gpr.visible_x_max_m, + "visible_z_min_m": gui.processing.gpr.visible_z_min_m, + "visible_z_max_m": gui.processing.gpr.visible_z_max_m, }, }, "data_actions": { diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 2a164c6..094e76d 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -57,8 +57,16 @@ class GuiGprStateModel: stop_freq_mhz: float = 6000.0 speed_m_s: float = 0.0 look_angle_deg: float = 0.0 + snr_thresh: float = 4.5 + snr_comp_max: float = 25.0 background_subtract_enabled: bool = True background_mean_count: int = 10 + render_mode: str = "heatmap" + min_visible_pair_count: int = 1 + visible_x_min_m: float = -2.0 + visible_x_max_m: float = 2.0 + visible_z_min_m: float = 0.0 + visible_z_max_m: float = 14.0 @dataclass(slots=True) diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 174e4dc..4b01126 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -32,6 +32,8 @@ class ProcessingLiveConfig: gpr_stop_freq_mhz: float = 6000.0 gpr_speed_m_s: float = 0.0 gpr_look_angle_deg: float = 0.0 + gpr_snr_thresh: float = 4.5 + gpr_snr_comp_max: float = 25.0 gpr_background_subtract_enabled: bool = True gpr_background_mean_count: int = 10 history_command_seq: int = 0 @@ -72,6 +74,8 @@ class ProcessingLiveConfig: "gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz), "gpr_speed_m_s": float(self.gpr_speed_m_s), "gpr_look_angle_deg": float(self.gpr_look_angle_deg), + "gpr_snr_thresh": float(self.gpr_snr_thresh), + "gpr_snr_comp_max": float(self.gpr_snr_comp_max), "gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled), "gpr_background_mean_count": int(self.gpr_background_mean_count), "history_command_seq": int(self.history_command_seq), diff --git a/python_app/runtime/gui_session_state.json b/python_app/runtime/gui_session_state.json new file mode 100644 index 0000000..3d3fea6 --- /dev/null +++ b/python_app/runtime/gui_session_state.json @@ -0,0 +1,3 @@ +{ + "last_profile_path": "/home/europa/Documents/radar_system/run_config.json" +} \ No newline at end of file diff --git a/python_app/runtime/processing_live.json b/python_app/runtime/processing_live.json new file mode 100644 index 0000000..81790b5 --- /dev/null +++ b/python_app/runtime/processing_live.json @@ -0,0 +1,35 @@ +{ + "processor_mode": "gpr", + "pass_through_channel": "s21", + "pass_through_fixed_y_enabled": false, + "pass_through_y_min_db": -100.0, + "pass_through_y_max_db": 0.0, + "bscan_axis": "abs", + "bscan_channel": "s21", + "bscan_cut_m": 0.824, + "bscan_max_depth_m": 1.0, + "bscan_gain": 1.0, + "bscan_start_freq_mhz": 100.0, + "bscan_stop_freq_mhz": 6000.0, + "gpr_input_positions": [ + 0, + 1, + 2, + 3 + ], + "gpr_output_positions": [ + 0, + 3 + ], + "gpr_min_depth_m": 2.0, + "gpr_max_depth_m": 14.0, + "gpr_comp_power": 0.2, + "gpr_start_freq_mhz": 3000.0, + "gpr_stop_freq_mhz": 6000.0, + "gpr_speed_m_s": 0.0, + "gpr_look_angle_deg": 0.0, + "gpr_background_subtract_enabled": false, + "gpr_background_mean_count": 10, + "history_command_seq": 0, + "history_command": "none" +} \ No newline at end of file diff --git a/python_app/runtime/run_config.json b/python_app/runtime/run_config.json new file mode 100644 index 0000000..5c1b3be --- /dev/null +++ b/python_app/runtime/run_config.json @@ -0,0 +1,172 @@ +{ + "radar": { + "model": "librevna", + "serial": "", + "driver_mode": "native", + "mock_signal_hz": 5000000.0, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "native", + "driver": "h7992", + "radar_port": 1, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 17, + "pin_b": 27, + "invert_logic": false + }, + "port2": { + "name": "port2", + "driver_mode": "native", + "driver": "h7992", + "radar_port": 2, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 22, + "pin_b": 23, + "invert_logic": false + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": false, + "processing_live_config_path": "/home/europa/Documents/radar_system/python_app/runtime/processing_live.json", + "combos": [ + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 3 + }, + { + "input": 1, + "output": 3 + }, + { + "input": 2, + "output": 3 + }, + { + "input": 3, + "output": 3 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "set_name": "smoke_cal", + "bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/s21_calibration_bundle.bin" + }, + "reference": { + "set_name": "smoke_ref", + "bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/s21_reference_bundle.bin" + } + }, + "s11": { + "calibration": { + "open": { + "set_name": "", + "bundle_path": "" + }, + "short": { + "set_name": "", + "bundle_path": "" + }, + "load": { + "set_name": "", + "bundle_path": "" + } + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + } + }, + "gpr": { + "mode": "point", + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.905 + }, + { + "output_pos": 3, + "x_m": -0.905 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18 + }, + { + "input_pos": 1, + "x_m": 0.485 + }, + { + "input_pos": 2, + "x_m": -0.49 + }, + { + "input_pos": 3, + "x_m": 0.185 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +} \ No newline at end of file diff --git a/python_app/runtime/s21_calibration_bundle.bin b/python_app/runtime/s21_calibration_bundle.bin index 7917932a5b74a48d602cd971dc3d9ccc6dff1507..5aebe29c8203e7686e98086e6ddf9ed5728c4868 100644 GIT binary patch delta 26 UcmbR7gL%ad<_!{m1Q^f&0B->W3jhEB delta 9 QcmZ4SgL%de<_!{m02y`!`v3p{ diff --git a/python_app/runtime/s21_reference_bundle.bin b/python_app/runtime/s21_reference_bundle.bin index 415b014a42e3c080bdc2b34c34ac7ae378b43ed9..83639988e1a42a83b882e476d62b53264da45596 100644 GIT binary patch delta 26 UcmbR7gL%ad<_!{m1Q^f&0B->W3jhEB delta 9 QcmZ4SgL%de<_!{m02y`!`v3p{ diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 525842c..0904630 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -24,6 +24,8 @@ class SequentialCaptureState: captured_count: int total_count: int current_combo: ComboModel | None + can_undo: bool + is_complete: bool class SequentialCaptureSession: @@ -112,6 +114,8 @@ class SequentialCaptureSession: captured_count=len(self._traces), total_count=len(self._combos), current_combo=current_combo, + can_undo=bool(self._traces), + is_complete=self.is_complete(), ) def capture_current_combo(self) -> TraceData: @@ -138,6 +142,34 @@ class SequentialCaptureSession: self._next_index += 1 return trace + def undo_last_capture(self) -> TraceData: + """Remove the most recently captured trace and rewind cursor by one combo.""" + if not self._opened: + raise RuntimeError("Capture session is not opened") + if not self._traces or self._next_index <= 0: + raise RuntimeError("No captured combo is available to undo") + + expected_combo = self._combos[self._next_index - 1] + removed_trace = self._traces[-1] + if ( + int(removed_trace.combo.input_pos) != int(expected_combo.input) + or int(removed_trace.combo.output_pos) != int(expected_combo.output) + ): + raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo") + self._next_index -= 1 + self._traces.pop() + return removed_trace + + def last_captured_trace(self) -> TraceData | None: + """Return the most recently captured trace, if any.""" + if not self._traces: + return None + return self._traces[-1] + + def captured_traces(self) -> list[TraceData]: + """Return captured traces in acquisition order.""" + return list(self._traces) + def is_complete(self) -> bool: """Return `True` when all combos were captured.""" return self._next_index >= len(self._combos)