diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index 0022c3e..bdd2a8b 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -1,4 +1,8 @@ -"""Main GUI window composed from focused mixins.""" +"""Main GUI composition root. + +This module wires UI/controller mixins together and owns application-level +state shared across them (runtime services, readers, history buffers, timer). +""" from __future__ import annotations @@ -35,28 +39,50 @@ class AppWindow( AppWindowSnapshotMixin, QMainWindow, ): - """Top-level application window coordinating UI and acquisition runtime.""" + """Top-level window coordinating GUI state and acquisition runtime.""" def __init__(self, project_root: Path) -> None: - """Initialize application state, services, UI, and polling timer.""" + """Initialize all app subsystems in deterministic order.""" super().__init__() + + self._init_paths_and_defaults(project_root) + self._init_runtime_services() + self._init_reader_handles() + self._init_preprocess_state() + self._init_capture_state() + self._init_history_state() + self._init_runtime_limits() + self._init_polling_timer() + self._bootstrap_ui_runtime() + + def _init_paths_and_defaults(self, project_root: Path) -> None: + """Initialize project paths and baseline run configuration.""" self._project_root = project_root self._defaults_config_path = project_root / "run_config.json" self._defaults_config = RunConfigModel.load_from_path(self._defaults_config_path) - self._store = NpzStore(project_root / "python_app/data") - self._config_writer = ConfigWriter(project_root / "python_app/runtime") - self._supervisor = ProcessSupervisor(project_root) - self._live_config_writer = ProcessingLiveConfigWriter(project_root / "python_app/runtime/processing_live.json") + def _init_runtime_services(self) -> None: + """Initialize long-lived service objects used by mixins.""" + runtime_dir = self._project_root / "python_app/runtime" + self._store = NpzStore(self._project_root / "python_app/data") + self._config_writer = ConfigWriter(runtime_dir) + self._supervisor = ProcessSupervisor(self._project_root) + self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json") + def _init_reader_handles(self) -> None: + """Initialize SHM readers as detached (not connected) handles.""" self._raw_reader: ShmRingReader | None = None self._pre_reader: ShmRingReader | None = None self._result_reader: ShmRingReader | None = None + def _init_preprocess_state(self) -> None: + """Initialize preprocessing dialog and selected set names.""" self._preprocess_dialog: PreprocessDialog | None = None self._selected_calibration_set = str(self._defaults_config.preprocess.calibration_set) self._selected_reference_set = str(self._defaults_config.preprocess.reference_set) + def _init_capture_state(self) -> None: + """Initialize one-shot capture and sequence-control flags.""" self._capture_session: SequentialCaptureSession | None = None self._resume_pipeline_after_capture = False self._single_capture_active = False @@ -64,19 +90,16 @@ class AppWindow( self._single_capture_seen_raw = False self._single_capture_target_collection_id: int | None = None - self._raw_history: deque[SweepCollection] = deque(maxlen=512) - self._pre_history: deque[SweepCollection] = deque(maxlen=512) - result_history_limit = max( - 1, - min( - int(self._defaults_config.rings.preprocessed.capacity), - int(self._defaults_config.rings.results.capacity), - 50, - ), - ) - self._result_history: deque[ResultCollection] = deque(maxlen=result_history_limit) + def _init_history_state(self) -> None: + """Initialize runtime history buffers and render-cache state.""" + history_limit = self._history_limit_from_config() + self._raw_history: deque[SweepCollection] = deque(maxlen=history_limit) + self._pre_history: deque[SweepCollection] = deque(maxlen=history_limit) + self._result_history: deque[ResultCollection] = deque(maxlen=history_limit) + + # Sequence id must survive GUI restarts so history commands stay monotonic. self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path) - self._bscan_history_limit = result_history_limit + self._bscan_history_limit = history_limit self._bscan_history_by_combo = {} self._bscan_depth_axis_by_combo = {} self._bscan_history_floor_collection_id = 0 @@ -85,22 +108,43 @@ class AppWindow( self._history_run_signature = None self._radar_limits: dict[str, float | int] | None = None + def _history_limit_from_config(self) -> int: + """Return unified GUI history limit derived from configured ring capacities.""" + return max( + 1, + min( + int(self._defaults_config.rings.raw_tap.capacity), + int(self._defaults_config.rings.preprocessed_tap.capacity), + int(self._defaults_config.rings.results.capacity), + ), + ) + + def _init_runtime_limits(self) -> None: + """Initialize read/drain loop limits used by polling and snapshot code.""" self._max_pop_per_poll = 256 self._max_pop_per_snapshot_drain = 4096 + def _init_polling_timer(self) -> None: + """Create periodic timer that polls SHM rings for new data.""" self._timer = QTimer(self) self._timer.setInterval(50) self._timer.timeout.connect(self._poll_rings) + def _bootstrap_ui_runtime(self) -> None: + """Build UI and apply initial runtime-bound state after widgets exist.""" self._build_ui() self._refresh_preprocess_summary_labels() - if self._radar_mode.currentText() == "native": - self._refresh_radar_limits_from_device() - else: - self._apply_radar_limits_to_ui(None) + self._apply_initial_radar_limits() self._write_live_processing_config() self._timer.start() + def _apply_initial_radar_limits(self) -> None: + """Apply startup radar-limits strategy according to selected radar mode.""" + if self._radar_mode.currentText() == "native": + self._refresh_radar_limits_from_device() + return + self._apply_radar_limits_to_ui(None) + def _log(self, text: str) -> None: """Append a line to the runtime log panel.""" self._log_box.appendPlainText(text) @@ -111,6 +155,7 @@ class AppWindow( try: payload = json.loads(config_path.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 + # Missing or malformed file should not block startup. return 0 raw_value = payload.get("history_command_seq", 0) @@ -129,8 +174,11 @@ class AppWindow( """Ensure workers and dialogs are closed before window destruction.""" try: self._resume_pipeline_after_capture = False + # 1) Abort active capture first (releases exclusive hardware resources). self._abort_capture_sequence(resume_pipeline=False) + # 2) Stop all managed processes/readers. self._stop_all_processes() + # 3) Close auxiliary dialog windows. if self._preprocess_dialog is not None: self._preprocess_dialog.close() finally: diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 1c82d9d..5a264fa 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -179,12 +179,12 @@ class AppWindowConfigMixin: if self._result_history: self._sync_bscan_history_from_results() if not self._draw_bscan_heatmap_from_history(): - self._plot.clear() + self._bscan_plot.clear() return if self._result_history: self._draw_results(self._result_history[-1]) return - self._plot.clear() + self._bscan_plot.clear() self._clear_trace_plots() def _on_radar_identity_changed(self, *_args) -> None: @@ -317,6 +317,16 @@ class AppWindowConfigMixin: def _sync_bscan_frequency_limits_with_radar(self) -> bool: """Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds.""" + required_widgets = ( + "_start_hz_input", + "_stop_hz_input", + "_bscan_start_freq_mhz", + "_bscan_stop_freq_mhz", + ) + if not all(hasattr(self, widget_name) for widget_name in required_widgets): + # Processing callbacks can fire while UI groups are still being built. + return False + try: radar_start_hz = float(self._start_hz_input.text().strip()) radar_stop_hz = float(self._stop_hz_input.text().strip()) diff --git a/python_app/gui/controllers/app_window_plot_mixin.py b/python_app/gui/controllers/app_window_plot_mixin.py index 51cda45..cda4164 100644 --- a/python_app/gui/controllers/app_window_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot_mixin.py @@ -59,6 +59,29 @@ class AppWindowPlotMixin: """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: # noqa: BLE001 + pass + 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: # noqa: BLE001 + pass + 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.""" @@ -69,8 +92,8 @@ class AppWindowPlotMixin: magnitude_plot.setVisible(show_magnitude) phase_plot.setVisible(show_phase) - self._clear_trace_plots() if not show_magnitude and not show_phase: + self._clear_trace_plots() return False if show_magnitude: @@ -103,44 +126,90 @@ class AppWindowPlotMixin: "#1982c4", ] - color_index = 0 + 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: - for payload in block.payloads: + 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) - color = palette[color_index % len(palette)] if show_magnitude: magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12)) - magnitude_curve = pg.PlotCurveItem( - payload.frequency_hz, - magnitude_values, - pen=pg.mkPen(color, width=1.4), - ) - magnitude_plot.addItem(magnitude_curve) + 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: - phase_values = np.degrees(np.angle(payload.trace)) - phase_curve = pg.PlotCurveItem( - payload.frequency_hz, - phase_values, - pen=pg.mkPen(color, width=1.2, style=Qt.PenStyle.DashLine), - ) - phase_plot.addItem(phase_curve) + 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 - color_index += 1 + 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): @@ -152,6 +221,105 @@ class AppWindowPlotMixin: phase_plot.setYRange(-180.0, 180.0, padding=0.02) 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: # noqa: BLE001 + pass + 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: # noqa: BLE001 + pass + + 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() @@ -189,18 +357,18 @@ class AppWindowPlotMixin: image_item.setLookupTable(self._bscan_lookup_table(axis_mode)) image_item.setLevels(self._bscan_levels(sweeps, axis_mode)) - self._plot.clear() - view_box = self._plot.getViewBox() + self._bscan_plot.clear() + view_box = self._bscan_plot.getViewBox() view_box.invertY(True) view_box.enableAutoRange(x=False, y=False) - self._plot.getPlotItem().showAxis("left", show=True) - self._plot.getPlotItem().showAxis("bottom", show=True) - self._plot.setLabel("bottom", "Sweep #") - self._plot.setLabel("left", "Depth", units="m") - self._plot.addItem(image_item) - self._plot.setXRange(x_min, x_max, padding=0.02) - self._plot.setYRange(depth_min, depth_max, padding=0.02) - self._plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}") + 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._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) + self._bscan_plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}") return True def _sync_bscan_history_from_results(self) -> None: @@ -284,7 +452,7 @@ class AppWindowPlotMixin: def _ensure_phase_view_box(self) -> pg.ViewBox: """Create or return secondary right-axis ViewBox for phase curves.""" - plot_item = self._plot.getPlotItem() + plot_item = self._bscan_plot.getPlotItem() phase_view_box = self._phase_viewbox if phase_view_box is None: phase_view_box = pg.ViewBox() @@ -301,7 +469,7 @@ class AppWindowPlotMixin: phase_view_box = self._phase_viewbox if phase_view_box is None: return - plot_item = self._plot.getPlotItem() + 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) @@ -358,6 +526,9 @@ class AppWindowPlotMixin: 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_deg = np.degrees(np.angle(trace.s21)) @@ -367,6 +538,9 @@ class AppWindowPlotMixin: 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: diff --git a/python_app/gui/controllers/app_window_ui_mixin.py b/python_app/gui/controllers/app_window_ui_mixin.py index e008a25..720ea7f 100644 --- a/python_app/gui/controllers/app_window_ui_mixin.py +++ b/python_app/gui/controllers/app_window_ui_mixin.py @@ -1,4 +1,11 @@ -"""UI construction mixin for the main radar control window.""" +"""UI construction mixin for the main radar control window. + +Layout is intentionally split into two independent plot surfaces: +- single `PlotWidget` for B-scan heatmap rendering; +- stacked magnitude/phase `PlotWidget`s for pass-through traces. + +`_set_plot_mode()` switches between these surfaces via `QStackedWidget`. +""" from __future__ import annotations @@ -29,10 +36,21 @@ from python_app.gui.controllers.sections import ( class AppWindowUiMixin: - """Builds and wires all static UI widgets.""" + """Build and wire all static widgets used by :class:`AppWindow`. + + This mixin only creates UI objects and connects lightweight UI signals. + Runtime logic (pipeline start/stop, polling, drawing, snapshot saving) + is implemented in other mixins. + """ def _build_ui(self) -> None: - """Build main window widgets, plot area, and settings panel.""" + """Build complete main-window layout. + + High-level structure: + 1. Left: plot stack (`B-scan` page + `trace magnitude/phase` page). + 2. Middle: narrow toggle button to collapse/expand settings. + 3. Right: scrollable settings panel + status/history + runtime log. + """ self.setWindowTitle("Radar System Control") root = QWidget(self) self.setCentralWidget(root) @@ -41,15 +59,37 @@ class AppWindowUiMixin: layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(14) + self._build_plot_area(layout, root) + self._build_settings_toggle(layout) + self._build_settings_panel(layout, root) + + self._toggle_settings_panel(visible=True) + self.resize(1650, 940) + + def _build_plot_area(self, root_layout: QHBoxLayout, root: QWidget) -> None: + """Build left plot area with stacked B-scan and trace pages.""" + # Plot stack holds mutually exclusive visualization surfaces. + # We create both upfront and only switch active page at runtime. self._plot_stack = QStackedWidget(root) + self._build_bscan_plot_page() + self._build_trace_plot_page() - self._plot = pg.PlotWidget(background="#0f141c") - self._plot.showGrid(x=True, y=True, alpha=0.2) - self._plot.setLabel("bottom", "Frequency", units="Hz") - self._plot.setLabel("left", "Magnitude", units="dB") - self._plot_stack.addWidget(self._plot) + # Default view on startup is pass-through traces. + self._plot_stack.setCurrentWidget(self._trace_plots_container) + root_layout.addWidget(self._plot_stack, stretch=11) - self._trace_plots_container = QWidget(root) + def _build_bscan_plot_page(self) -> None: + """Create B-scan page in plot stack.""" + # B-scan surface: one PlotWidget used as canvas for ImageItem heatmap. + self._bscan_plot = pg.PlotWidget(background="#0f141c") + self._bscan_plot.showGrid(x=True, y=True, alpha=0.2) + self._plot_stack.addWidget(self._bscan_plot) + + def _build_trace_plot_page(self) -> None: + """Create pass-through page with magnitude and phase plots.""" + # Pass-through surface: container with two synchronized line plots. + # Upper plot shows magnitude, lower plot shows phase. + self._trace_plots_container = QWidget() trace_layout = QVBoxLayout(self._trace_plots_container) trace_layout.setContentsMargins(0, 0, 0, 0) trace_layout.setSpacing(6) @@ -70,53 +110,40 @@ class AppWindowUiMixin: self._trace_phase_plot.getPlotItem().setClipToView(True) trace_layout.addWidget(self._trace_phase_plot, stretch=1) - self._plot_stack.addWidget(self._trace_plots_container) - self._plot_stack.setCurrentWidget(self._trace_plots_container) - layout.addWidget(self._plot_stack, stretch=11) + # Legends are created lazily only when trace mode draws line series. + self._trace_magnitude_legend = None + self._trace_phase_legend = None + self._trace_magnitude_legend_combo_keys = set() + self._trace_phase_legend_combo_keys = set() + self._trace_magnitude_curves = {} + self._trace_phase_curves = {} + self._trace_phase_render_max_points = 400 + self._plot_stack.addWidget(self._trace_plots_container) + + def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None: + """Create narrow button used to collapse or show settings panel.""" self._settings_toggle_button = QPushButton("<") self._settings_toggle_button.setObjectName("settingsToggleButton") self._settings_toggle_button.setFixedWidth(26) - self._settings_toggle_button.clicked.connect(self._toggle_settings_panel) - layout.addWidget(self._settings_toggle_button, stretch=0) + self._settings_toggle_button.clicked.connect(lambda: self._toggle_settings_panel()) + 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.""" self._settings_panel = QWidget(root) self._settings_panel.setMinimumWidth(659) right_layout = QVBoxLayout(self._settings_panel) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(10) - # Build log widget early so error handlers can safely write during UI construction. + # Build log early so `_show_error()` can append text even during + # subsequent group construction if something fails. self._log_box = QPlainTextEdit(self._settings_panel) self._log_box.setReadOnly(True) self._log_box.setMinimumHeight(170) - pipeline_group = self._build_pipeline_group() - hardware_actions_group = self._build_hardware_actions_group() - data_actions_group = self._build_data_actions_group() - preprocess_summary_group = self._build_preprocess_summary_group() - radar_group = self._build_radar_group() - processing_group = self._build_processing_group() - switch_group = self._build_switch_group() - - controls = QWidget(self._settings_panel) - controls_layout = QVBoxLayout(controls) - controls_layout.setContentsMargins(0, 0, 0, 0) - controls_layout.setSpacing(10) - controls_layout.addWidget(pipeline_group) - controls_layout.addWidget(hardware_actions_group) - controls_layout.addWidget(data_actions_group) - controls_layout.addWidget(preprocess_summary_group) - controls_layout.addWidget(processing_group) - controls_layout.addWidget(radar_group) - controls_layout.addWidget(switch_group) - controls_layout.addStretch(1) - - scroll = QScrollArea(self._settings_panel) - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setWidget(controls) - right_layout.addWidget(scroll, stretch=1) + right_layout.addWidget(self._build_settings_scroll(), stretch=1) self._status_label = QLabel("Status: idle", self._settings_panel) self._status_label.setObjectName("statusLabel") @@ -127,17 +154,46 @@ class AppWindowUiMixin: right_layout.addWidget(self._history_label) right_layout.addWidget(self._log_box, stretch=0) + root_layout.addWidget(self._settings_panel, stretch=8) - layout.addWidget(self._settings_panel, stretch=8) - self._set_settings_panel_visible(True) - self.resize(1650, 940) + def _build_settings_scroll(self) -> QScrollArea: + """Build scroll area with all control groups in display order.""" + # All control groups are placed into a scroll area so right panel + # 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.setSpacing(10) + for group in self._build_control_groups(): + controls_layout.addWidget(group) + controls_layout.addStretch(1) - def _toggle_settings_panel(self) -> None: - """Toggle settings panel visibility.""" - self._set_settings_panel_visible(not self._settings_panel.isVisible()) + scroll = QScrollArea(self._settings_panel) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setWidget(controls) + return scroll - def _set_settings_panel_visible(self, visible: bool) -> None: - """Set settings panel visibility and update toggle button glyph.""" + def _build_control_groups(self) -> list[QGroupBox]: + """Create all settings groups in top-to-bottom order.""" + return [ + build_pipeline_group(self), + build_hardware_actions_group(self), + build_data_actions_group(self), + build_preprocess_summary_group(self), + build_processing_group(self), + build_radar_group(self), + build_switch_group(self), + ] + + def _toggle_settings_panel(self, *, visible: bool | None = None) -> None: + """Toggle settings panel visibility or force a specific state. + + When `visible` is `None`, state is toggled. + When `visible` is set, panel visibility is forced to that value. + """ + if visible is None: + visible = not self._settings_panel.isVisible() self._settings_panel.setVisible(visible) if visible: self._settings_toggle_button.setText(">") @@ -147,40 +203,16 @@ class AppWindowUiMixin: self._settings_toggle_button.setToolTip("Show settings panel") def _set_plot_mode(self, mode: str) -> None: - """Switch visible plot surface based on processing mode.""" + """Switch visible plot page according to processing mode. + + `bscan` -> show `self._bscan_plot` (single heatmap surface) + otherwise -> show `self._trace_plots_container` (magnitude + phase) + """ if mode == "bscan": - self._plot_stack.setCurrentWidget(self._plot) + self._plot_stack.setCurrentWidget(self._bscan_plot) return self._plot_stack.setCurrentWidget(self._trace_plots_container) - def _build_pipeline_group(self) -> QGroupBox: - """Build pipeline controls section.""" - return build_pipeline_group(self) - - def _build_hardware_actions_group(self) -> QGroupBox: - """Build hardware actions section.""" - return build_hardware_actions_group(self) - - def _build_data_actions_group(self) -> QGroupBox: - """Build data actions section.""" - return build_data_actions_group(self) - - def _build_preprocess_summary_group(self) -> QGroupBox: - """Build selected preprocess sets summary section.""" - return build_preprocess_summary_group(self) - - def _build_processing_group(self) -> QGroupBox: - """Build processing mode section.""" - return build_processing_group(self) - - def _build_radar_group(self) -> QGroupBox: - """Build radar settings section.""" - return build_radar_group(self) - - def _build_switch_group(self) -> QGroupBox: - """Build switch settings section.""" - return build_switch_group(self) - @staticmethod def _set_combo_current_text(combo: QComboBox, value: str) -> None: """Select combo item by text, appending it when missing.""" diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index 77d026b..6d68c6b 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -6,7 +6,6 @@ from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import ( QComboBox, QDialog, - QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, @@ -23,7 +22,13 @@ from python_app.models.dataset_model import TraceData class PreprocessDialog(QDialog): - """Standalone dialog for preprocessing capture workflows.""" + """Standalone dialog for preprocessing capture workflows. + + The dialog combines three concerns: + 1. Set selection (calibration/reference). + 2. Sequential capture controls for filling all N*M combinations. + 3. Quick preview of the last captured trace. + """ refresh_requested = pyqtSignal() selection_changed = pyqtSignal(str, str) @@ -32,64 +37,97 @@ class PreprocessDialog(QDialog): abort_sequence_requested = pyqtSignal() def __init__(self, parent=None) -> None: - """Create dialog and build all widgets.""" + """Initialize window metadata and compose dialog UI.""" super().__init__(parent) - self.setWindowTitle("Preprocessing Setup") - self.resize(1040, 760) + self._init_window() self._build_ui() + def _init_window(self) -> None: + """Set static window properties.""" + self.setWindowTitle("Preprocessing Setup") + self.resize(1040, 760) + def _build_ui(self) -> None: - """Build dialog layout, controls, and preview plot.""" - layout = QVBoxLayout(self) + """Build root dialog layout and all sections.""" + root_layout = QVBoxLayout(self) + root_layout.addWidget(self._build_sets_group()) + root_layout.addWidget(self._build_sequence_group()) + self._build_status_line(root_layout) + self._build_preview_plot(root_layout) - sets_group = QGroupBox("Calibration / Reference Sets", self) - sets_layout = QGridLayout(sets_group) + def _build_sets_group(self) -> QGroupBox: + """Build set-management controls used for preprocessing snapshots.""" + group = QGroupBox("Calibration / Reference Sets", self) + layout = QGridLayout(group) - self._set_name_input = QLineEdit("set_001", sets_group) - self._calibration_combo = QComboBox(sets_group) - self._reference_combo = QComboBox(sets_group) + self._set_name_input = QLineEdit("set_001", group) + self._calibration_combo = QComboBox(group) + self._reference_combo = QComboBox(group) - refresh_button = QPushButton("Refresh Sets", sets_group) + refresh_button = QPushButton("Refresh Sets", group) refresh_button.clicked.connect(self.refresh_requested.emit) self._calibration_combo.currentTextChanged.connect(self._emit_selection_changed) self._reference_combo.currentTextChanged.connect(self._emit_selection_changed) - sets_layout.addWidget(QLabel("Set name"), 0, 0) - sets_layout.addWidget(self._set_name_input, 0, 1) - sets_layout.addWidget(refresh_button, 0, 2) + layout.addWidget(QLabel("Set name"), 0, 0) + layout.addWidget(self._set_name_input, 0, 1) + layout.addWidget(refresh_button, 0, 2) - sets_layout.addWidget(QLabel("Calibration set"), 1, 0) - sets_layout.addWidget(self._calibration_combo, 1, 1, 1, 2) + layout.addWidget(QLabel("Calibration set"), 1, 0) + layout.addWidget(self._calibration_combo, 1, 1, 1, 2) - sets_layout.addWidget(QLabel("Reference set"), 2, 0) - sets_layout.addWidget(self._reference_combo, 2, 1, 1, 2) + layout.addWidget(QLabel("Reference set"), 2, 0) + layout.addWidget(self._reference_combo, 2, 1, 1, 2) + return group - layout.addWidget(sets_group) + def _build_sequence_group(self) -> QGroupBox: + """Build sequential-capture controls and capture log.""" + group = QGroupBox("Sequential Capture (Fill Full N*M)", self) + layout = QGridLayout(group) - sequence_group = QGroupBox("Sequential Capture (Fill Full N*M)", self) - sequence_layout = QGridLayout(sequence_group) + self._active_kind_label = QLabel("", group) + self._progress_label = QLabel("0 / 0", group) + self._combo_label = QLabel("", group) - self._active_kind_label = QLabel("", sequence_group) - self._progress_label = QLabel("0 / 0", sequence_group) - self._combo_label = QLabel("", sequence_group) - - self._tx_antenna_label_input = QLineEdit(sequence_group) - self._rx_antenna_label_input = QLineEdit(sequence_group) + self._tx_antenna_label_input = QLineEdit(group) + self._rx_antenna_label_input = QLineEdit(group) self._tx_antenna_label_input.setPlaceholderText("e.g. TX_A") self._rx_antenna_label_input.setPlaceholderText("e.g. RX_B") - start_calibration_button = QPushButton("Start Calibration Sequence", sequence_group) + button_row = self._build_sequence_button_row(group) + + layout.addWidget(QLabel("Active type"), 0, 0) + layout.addWidget(self._active_kind_label, 0, 1) + layout.addWidget(QLabel("Progress"), 1, 0) + layout.addWidget(self._progress_label, 1, 1) + layout.addWidget(QLabel("Current combo"), 2, 0) + layout.addWidget(self._combo_label, 2, 1) + layout.addWidget(QLabel("TX antenna label"), 3, 0) + layout.addWidget(self._tx_antenna_label_input, 3, 1) + layout.addWidget(QLabel("RX antenna label"), 4, 0) + layout.addWidget(self._rx_antenna_label_input, 4, 1) + layout.addLayout(button_row, 5, 0, 1, 2) + + self._capture_log = QPlainTextEdit(group) + self._capture_log.setReadOnly(True) + self._capture_log.setPlaceholderText("Capture history per combo") + layout.addWidget(self._capture_log, 6, 0, 1, 2) + return group + + def _build_sequence_button_row(self, parent: QGroupBox) -> QHBoxLayout: + """Build action buttons for sequence flow control.""" + start_calibration_button = QPushButton("Start Calibration Sequence", parent) start_calibration_button.clicked.connect(lambda: self.start_sequence_requested.emit("calibration")) - start_reference_button = QPushButton("Start Reference Sequence", sequence_group) + start_reference_button = QPushButton("Start Reference Sequence", parent) start_reference_button.clicked.connect(lambda: self.start_sequence_requested.emit("reference")) - self._capture_next_button = QPushButton("Capture Current Combo", sequence_group) + self._capture_next_button = QPushButton("Capture Current Combo", parent) self._capture_next_button.clicked.connect(self.capture_next_requested.emit) self._capture_next_button.setEnabled(False) - self._abort_button = QPushButton("Abort Sequence", sequence_group) + self._abort_button = QPushButton("Abort Sequence", parent) self._abort_button.clicked.connect(self.abort_sequence_requested.emit) self._abort_button.setEnabled(False) @@ -98,34 +136,20 @@ class PreprocessDialog(QDialog): button_row.addWidget(start_reference_button) button_row.addWidget(self._capture_next_button) button_row.addWidget(self._abort_button) + return button_row - sequence_layout.addWidget(QLabel("Active type"), 0, 0) - sequence_layout.addWidget(self._active_kind_label, 0, 1) - sequence_layout.addWidget(QLabel("Progress"), 1, 0) - sequence_layout.addWidget(self._progress_label, 1, 1) - sequence_layout.addWidget(QLabel("Current combo"), 2, 0) - sequence_layout.addWidget(self._combo_label, 2, 1) - sequence_layout.addWidget(QLabel("TX antenna label"), 3, 0) - sequence_layout.addWidget(self._tx_antenna_label_input, 3, 1) - sequence_layout.addWidget(QLabel("RX antenna label"), 4, 0) - sequence_layout.addWidget(self._rx_antenna_label_input, 4, 1) - sequence_layout.addLayout(button_row, 5, 0, 1, 2) + def _build_status_line(self, root_layout: QVBoxLayout) -> None: + """Build one-line status output for dialog operations.""" + self._status_label = QLabel("Ready", self) + root_layout.addWidget(self._status_label) - self._capture_log = QPlainTextEdit(sequence_group) - self._capture_log.setReadOnly(True) - self._capture_log.setPlaceholderText("Capture history per combo") - sequence_layout.addWidget(self._capture_log, 6, 0, 1, 2) - - layout.addWidget(sequence_group) - - self._status = QLabel("Ready", self) - layout.addWidget(self._status) - - self._plot = pg.PlotWidget(background="#101418") - self._plot.showGrid(x=True, y=True, alpha=0.2) - self._plot.setLabel("bottom", "Frequency", units="Hz") - self._plot.setLabel("left", "Magnitude", units="dB") - layout.addWidget(self._plot, stretch=1) + def _build_preview_plot(self, root_layout: QVBoxLayout) -> None: + """Build trace preview plot used after each successful capture.""" + self._preview_plot = pg.PlotWidget(background="#101418") + self._preview_plot.showGrid(x=True, y=True, alpha=0.2) + self._preview_plot.setLabel("bottom", "Frequency", units="Hz") + self._preview_plot.setLabel("left", "Magnitude", units="dB") + root_layout.addWidget(self._preview_plot, stretch=1) def set_name(self) -> str: """Return requested target set name.""" @@ -220,19 +244,19 @@ class PreprocessDialog(QDialog): def set_status(self, message: str) -> None: """Set short human-readable status line.""" - self._status.setText(message) + self._status_label.setText(message) def draw_last_trace(self, trace: TraceData, title: str) -> None: """Draw the latest captured sweep trace in dB scale.""" magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12)) - self._plot.clear() - self._plot.plot( + self._preview_plot.clear() + self._preview_plot.plot( trace.frequency_hz, magnitude_db, pen=pg.mkPen("#4cc9f0", width=1.8), ) combo = trace.combo - self._status.setText( + self._status_label.setText( f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}" ) diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 6d33e26..faf80df 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -2,12 +2,19 @@ from __future__ import annotations -import json -from pathlib import Path from typing import Any from python_app.models.run_config_schema import ComboModel, RunConfigModel -from python_app.models.run_config_validation import as_dict, load_ring_payload, load_switch_payload +from python_app.models.run_config_validation import load_ring_payload, load_switch_payload + + +def _as_dict(value: Any, context: str) -> dict[str, Any]: + """Validate payload node is object-like, treating missing values as empty object.""" + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError(f"{context} must be a JSON object") + return value def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: @@ -15,19 +22,19 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: # Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json. model = RunConfigModel() - radar_payload = as_dict(payload.get("radar"), "radar") - sweep_payload = as_dict(radar_payload.get("sweep"), "radar.sweep") - switches_payload = as_dict(payload.get("switches"), "switches") - port1_payload = as_dict(switches_payload.get("port1"), "switches.port1") - port2_payload = as_dict(switches_payload.get("port2"), "switches.port2") - run_payload = as_dict(payload.get("run"), "run") - preprocess_payload = as_dict(payload.get("preprocess"), "preprocess") - rings_payload = as_dict(payload.get("rings"), "rings") - raw_ring_payload = as_dict(rings_payload.get("raw"), "rings.raw") - raw_tap_ring_payload = as_dict(rings_payload.get("raw_tap"), "rings.raw_tap") - 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") + radar_payload = _as_dict(payload.get("radar"), "radar") + sweep_payload = _as_dict(radar_payload.get("sweep"), "radar.sweep") + switches_payload = _as_dict(payload.get("switches"), "switches") + port1_payload = _as_dict(switches_payload.get("port1"), "switches.port1") + port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2") + run_payload = _as_dict(payload.get("run"), "run") + preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess") + rings_payload = _as_dict(payload.get("rings"), "rings") + raw_ring_payload = _as_dict(rings_payload.get("raw"), "rings.raw") + raw_tap_ring_payload = _as_dict(rings_payload.get("raw_tap"), "rings.raw_tap") + 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") model.radar.model = str(radar_payload.get("model", model.radar.model)) model.radar.serial = str(radar_payload.get("serial", model.radar.serial)) @@ -71,7 +78,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: model.combos = [] if isinstance(combos_payload, list): for combo in combos_payload: - combo_payload = as_dict(combo, "run.combos[]") + combo_payload = _as_dict(combo, "run.combos[]") model.combos.append( ComboModel( input=int(combo_payload.get("input", 0)), @@ -82,15 +89,6 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: model.ensure_combos() return model - -def load_run_config(path: Path) -> RunConfigModel: - """Load JSON config from path and decode into :class:`RunConfigModel`.""" - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError(f"Config root must be JSON object: {path}") - return run_config_from_dict(payload) - - def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: """Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure.""" model.ensure_combos() diff --git a/python_app/models/run_config_model.py b/python_app/models/run_config_model.py index 8a0088c..d7c4908 100644 --- a/python_app/models/run_config_model.py +++ b/python_app/models/run_config_model.py @@ -1,6 +1,6 @@ """Facade module for run configuration schema, codec, and validation helpers.""" -from python_app.models.run_config_codec import load_run_config, run_config_from_dict, run_config_to_dict +from python_app.models.run_config_codec import run_config_from_dict, run_config_to_dict from python_app.models.run_config_schema import ( ComboModel, PreprocessModel, @@ -13,7 +13,6 @@ from python_app.models.run_config_schema import ( SwitchModel, ) from python_app.models.run_config_validation import ( - as_dict, load_ring_payload, load_switch_payload, parse_combos_from_text, @@ -29,9 +28,7 @@ __all__ = [ "RunConfigModel", "RuntimeModel", "SwitchModel", - "as_dict", "load_ring_payload", - "load_run_config", "load_switch_payload", "parse_combos_from_text", "run_config_from_dict", diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index ce6a459..cc91ebe 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +import json from pathlib import Path from typing import Any @@ -131,9 +132,10 @@ class RunConfigModel: @classmethod def load_from_path(cls, path: Path) -> RunConfigModel: """Load JSON file from disk and decode into model.""" - from python_app.models.run_config_codec import load_run_config - - return load_run_config(path) + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"Config root must be JSON object: {path}") + return cls.from_dict(payload) def clone(self) -> RunConfigModel: """Create deep copy through codec round-trip.""" diff --git a/python_app/models/run_config_validation.py b/python_app/models/run_config_validation.py index e7f4a5c..26080d0 100644 --- a/python_app/models/run_config_validation.py +++ b/python_app/models/run_config_validation.py @@ -6,16 +6,6 @@ from typing import Any from python_app.models.run_config_schema import ComboModel, RingEndpointModel, SwitchModel - -def as_dict(value: Any, context: str) -> dict[str, Any]: - """Validate that a payload node is a JSON object and return it.""" - if value is None: - return {} - if not isinstance(value, dict): - raise ValueError(f"{context} must be a JSON object") - return value - - def load_switch_payload( payload: dict[str, Any], target: SwitchModel, diff --git a/run_config.json b/run_config.json index 4e0f2de..fc749a5 100644 --- a/run_config.json +++ b/run_config.json @@ -57,27 +57,27 @@ "rings": { "raw": { "name": "/radar_raw", - "capacity": 64, + "capacity": 50, "slot_size_bytes": 2097152 }, "raw_tap": { "name": "/radar_raw_tap", - "capacity": 64, + "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed": { "name": "/radar_preprocessed", - "capacity": 64, + "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed_tap": { "name": "/radar_preprocessed_tap", - "capacity": 64, + "capacity": 50, "slot_size_bytes": 2097152 }, "results": { "name": "/radar_results", - "capacity": 64, + "capacity": 50, "slot_size_bytes": 2097152 } }