diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index 6e99764..f875833 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -35,6 +35,8 @@ from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_ from python_app.orchestration.process_supervisor import ProcessSupervisor from python_app.orchestration.shm_reader import ShmRingReader from python_app.storage.npz_store import NpzStore +from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession +from python_app.workflows.radar_config_variants import RadarConfigScanSummary, RadarConfigVariant from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession @@ -122,6 +124,17 @@ class AppWindow( """Initialize preprocessing dialog and selected set names.""" self._preprocess_dialog: PreprocessDialog | None = None self._preprocess_set_name = str(self._gui_defaults.preprocess_dialog.set_name) + self._preprocess_radar_config_dir = str(self._gui_defaults.preprocess_dialog.radar_config_dir) + self._preprocess_use_all_radar_configs = bool(self._gui_defaults.preprocess_dialog.use_all_radar_configs) + self._preprocess_radar_variants: list[RadarConfigVariant] = [] + self._preprocess_radar_scan_summary = RadarConfigScanSummary( + directory_path=self._preprocess_radar_config_dir, + json_file_count=0, + valid_variant_count=0, + skipped_file_count=0, + duplicate_variant_count=0, + issues=(), + ) self._selected_preprocess_sets = { key: str(preprocess_asset_model(self._defaults_config, key).set_name) for key in VISIBLE_PREPROCESS_ASSET_KEYS @@ -130,7 +143,7 @@ class AppWindow( def _init_capture_state(self) -> None: """Initialize one-shot capture and sequence-control flags.""" - self._capture_session: SequentialCaptureSession | None = None + self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None self._resume_pipeline_after_capture = False self._single_capture_active = False self._single_capture_start_ns: int | None = None diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index e0c7cb4..cdb12a7 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -274,6 +274,8 @@ class AppWindowConfigProfileIOMixin: 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._preprocess_radar_config_dir = str(gui_state.preprocess_dialog.radar_config_dir) + self._preprocess_use_all_radar_configs = bool(gui_state.preprocess_dialog.use_all_radar_configs) self._apply_history_limit_from_config(config) self._gpr_geometry_signature = None self._gpr_selected_geometry = None @@ -283,12 +285,18 @@ class AppWindowConfigProfileIOMixin: if self._preprocess_dialog is not None: with ExitStack() as dialog_blockers: dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._set_name_input)) + dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._radar_config_dir_input)) + dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._use_all_radar_configs_checkbox)) for combo in self._preprocess_dialog._set_combos.values(): dialog_blockers.enter_context(QSignalBlocker(combo)) self._preprocess_dialog.set_set_name(self._preprocess_set_name) + self._preprocess_dialog.set_radar_config_dir(self._preprocess_radar_config_dir) + self._preprocess_dialog.set_use_all_radar_configs(self._preprocess_use_all_radar_configs) self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False) self._apply_initial_radar_limits() + if self._preprocess_dialog is not None: + self._refresh_sets() self._reload_locator_service_from_config() self._on_processing_mode_changed(gui_state.processing.selected_mode) self._update_history_indicator() diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 285d5ec..2a05d6b 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -185,7 +185,11 @@ class AppWindowConfigStateBuildersMixin: save_path=str(self._project_root / "python_app/data/snapshots"), save_name="snapshot_manual", ), - preprocess_dialog=GuiPreprocessDialogStateModel(set_name="set_001"), + preprocess_dialog=GuiPreprocessDialogStateModel( + set_name="set_001", + radar_config_dir="", + use_all_radar_configs=False, + ), ) def _current_preprocess_set_name(self) -> str: @@ -194,6 +198,18 @@ class AppWindowConfigStateBuildersMixin: self._preprocess_set_name = self._preprocess_dialog.set_name() return self._preprocess_set_name + def _current_preprocess_radar_config_dir(self) -> str: + """Return current preprocess dialog radar-config directory path.""" + if self._preprocess_dialog is not None: + self._preprocess_radar_config_dir = self._preprocess_dialog.radar_config_dir() + return self._preprocess_radar_config_dir + + def _current_preprocess_use_all_radar_configs(self) -> bool: + """Return whether multi-radar preprocessing is enabled in the dialog.""" + if self._preprocess_dialog is not None: + self._preprocess_use_all_radar_configs = self._preprocess_dialog.use_all_radar_configs() + return bool(self._preprocess_use_all_radar_configs) + def _build_gui_state(self) -> GuiStateModel: """Build GUI-only persistent state from current widget values.""" return GuiStateModel( @@ -249,6 +265,8 @@ class AppWindowConfigStateBuildersMixin: ), preprocess_dialog=GuiPreprocessDialogStateModel( set_name=self._current_preprocess_set_name(), + radar_config_dir=self._current_preprocess_radar_config_dir(), + use_all_radar_configs=self._current_preprocess_use_all_radar_configs(), ), ) diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 3a2f514..737347f 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -9,6 +9,11 @@ from python_app.orchestration.preprocess_assets import ( preprocess_asset_channel, preprocess_asset_display_name, ) +from python_app.workflows.multi_radar_capture_workflow import ( + MultiRadarCaptureBatch, + MultiRadarSequentialCaptureSession, +) +from python_app.workflows.radar_config_variants import scan_radar_config_variants from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession @@ -16,10 +21,18 @@ class AppWindowPreprocessMixin: """Handles preprocess set management and sequential capture workflows.""" @staticmethod - def _capture_log_entries_for_session(session: SequentialCaptureSession) -> list[str]: + def _capture_log_entries_for_session(session: SequentialCaptureSession | MultiRadarSequentialCaptureSession) -> list[str]: """Build capture-log rows from current session traces.""" display_name = preprocess_asset_display_name(session.kind) total_count = session.state().total_count + if isinstance(session, MultiRadarSequentialCaptureSession): + return [ + f"{display_name}: {index}/{total_count} | " + f"input={batch.combo.input} output={batch.combo.output} | " + f"radar_configs={session.radar_variant_count()}" + for index, batch in enumerate(session.captured_batches(), start=1) + ] + entries: list[str] = [] for index, trace in enumerate(session.captured_traces(), start=1): entries.append( @@ -98,18 +111,36 @@ class AppWindowPreprocessMixin: dialog = PreprocessDialog(self) self._preprocess_dialog = dialog dialog.set_set_name(self._preprocess_set_name) + dialog.set_radar_config_dir(self._preprocess_radar_config_dir) + dialog.set_use_all_radar_configs(self._preprocess_use_all_radar_configs) dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False) dialog.refresh_requested.connect(self._refresh_sets) dialog.selection_changed.connect(self._on_preprocess_selection_changed) + dialog.radar_config_dir_changed.connect(self._on_preprocess_radar_config_inputs_changed) + dialog.multi_radar_option_changed.connect(self._on_preprocess_radar_config_inputs_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) + dialog.set_radar_config_summary( + directory_path=self._preprocess_radar_scan_summary.directory_path, + json_file_count=self._preprocess_radar_scan_summary.json_file_count, + valid_variant_count=self._preprocess_radar_scan_summary.valid_variant_count, + skipped_file_count=self._preprocess_radar_scan_summary.skipped_file_count, + duplicate_variant_count=self._preprocess_radar_scan_summary.duplicate_variant_count, + ) self._update_capture_dialog_state() return dialog + def _on_preprocess_radar_config_inputs_changed(self) -> None: + """Persist preprocess radar-config directory options and refresh the scan result.""" + dialog = self._ensure_preprocess_dialog() + self._preprocess_radar_config_dir = dialog.radar_config_dir() + self._preprocess_use_all_radar_configs = dialog.use_all_radar_configs() + self._refresh_sets() + def _on_preprocess_selection_changed(self) -> None: """Persist selected preprocessing set names from dialog.""" dialog = self._ensure_preprocess_dialog() @@ -139,9 +170,12 @@ class AppWindowPreprocessMixin: radar_key = self._radar_key_from_ui() self._selected_preprocess_radar_key = radar_key dialog = self._ensure_preprocess_dialog() + self._preprocess_radar_config_dir = dialog.radar_config_dir() + self._preprocess_use_all_radar_configs = dialog.use_all_radar_configs() available_sets = self._available_preprocess_sets_for_radar_key(radar_key) dialog.set_available_sets(available_sets) + self._refresh_preprocess_radar_variants() unavailable_selections: list[str] = [] for key, names in available_sets.items(): @@ -166,6 +200,32 @@ class AppWindowPreprocessMixin: details="\n".join(unavailable_selections), ) + def _refresh_preprocess_radar_variants(self) -> None: + """Reload valid radar sweep variants from the preprocess dialog directory.""" + config = self._build_config() + variants, summary = scan_radar_config_variants( + self._preprocess_radar_config_dir, + base_config=config, + ) + self._preprocess_radar_variants = variants + self._preprocess_radar_scan_summary = summary + + if self._preprocess_dialog is not None: + self._preprocess_dialog.set_radar_config_summary( + directory_path=summary.directory_path, + json_file_count=summary.json_file_count, + valid_variant_count=summary.valid_variant_count, + skipped_file_count=summary.skipped_file_count, + duplicate_variant_count=summary.duplicate_variant_count, + ) + + if summary.issues: + self._log_warning( + "Some radar config JSON files were skipped during preprocess scan.", + details="\n".join(summary.issues), + once_key=f"preprocess_radar_config_scan_{summary.directory_path}", + ) + def _start_capture_sequence(self, kind: str) -> None: """Start sequential capture session for requested preprocess asset.""" if self._capture_session is not None: @@ -187,13 +247,20 @@ class AppWindowPreprocessMixin: try: config = self._build_config() - radar_key = self._radar_key(config) - existing_sets = self._store.list_sets(PREPROCESS_ASSET_SPECS[kind].set_kind, radar_key) display_name = preprocess_asset_display_name(kind) - if set_name in existing_sets: - raise RuntimeError(f"Set '{set_name}' already exists for {display_name} and cannot be overwritten") + if self._preprocess_use_all_radar_configs: + session = self._build_multi_radar_capture_session(config=config, kind=kind, set_name=set_name) + radar_summary = ( + f"radar_variants={session.radar_variant_count()}, " + f"combos={session.state().total_count}" + ) + else: + session = self._build_single_radar_capture_session(config=config, kind=kind, set_name=set_name) + radar_summary = ( + f"radar_key={self._radar_key(config)}, " + f"combos={session.state().total_count}" + ) - session = SequentialCaptureSession(config=config, kind=kind, set_name=set_name) session.open() self._capture_session = session @@ -203,14 +270,59 @@ class AppWindowPreprocessMixin: self._clear_trace_plots() self._update_capture_dialog_state() self._log( - f"{display_name} sequence started: set={set_name}, radar_key={radar_key}, " - f"combos={session.state().total_count}" + f"{display_name} sequence started: set={set_name}, {radar_summary}" ) except Exception as exc: # noqa: BLE001 self._cleanup_capture_session() self._show_exception(f"Failed to start {kind} sequence", exc) self._resume_pipeline_if_needed() + def _build_single_radar_capture_session( + self, + *, + config, + kind: str, + set_name: str, + ) -> SequentialCaptureSession: + """Validate and create the existing single-radar capture session.""" + radar_key = self._radar_key(config) + existing_sets = self._store.list_sets(PREPROCESS_ASSET_SPECS[kind].set_kind, radar_key) + display_name = preprocess_asset_display_name(kind) + if set_name in existing_sets: + raise RuntimeError(f"Set '{set_name}' already exists for {display_name} and cannot be overwritten") + return SequentialCaptureSession(config=config, kind=kind, set_name=set_name) + + def _build_multi_radar_capture_session( + self, + *, + config, + kind: str, + set_name: str, + ) -> MultiRadarSequentialCaptureSession: + """Validate and create the multi-radar capture session.""" + self._refresh_preprocess_radar_variants() + if not self._preprocess_radar_variants: + raise RuntimeError("No valid radar config variants were found in the selected directory") + + display_name = preprocess_asset_display_name(kind) + duplicate_set_keys: list[str] = [] + for variant in self._preprocess_radar_variants: + existing_sets = self._store.list_sets(PREPROCESS_ASSET_SPECS[kind].set_kind, variant.radar_key) + if set_name in existing_sets: + duplicate_set_keys.append(f"{variant.display_name} -> {variant.radar_key}") + if duplicate_set_keys: + raise RuntimeError( + f"Set '{set_name}' already exists for {display_name} in these radar variants:\n" + + "\n".join(duplicate_set_keys) + ) + + return MultiRadarSequentialCaptureSession( + base_config=config, + kind=kind, + set_name=set_name, + radar_variants=self._preprocess_radar_variants, + ) + def _capture_next_combo(self) -> None: """Capture next combo in active sequential capture session.""" session = self._capture_session @@ -219,8 +331,8 @@ class AppWindowPreprocessMixin: return try: - trace = session.capture_current_combo() - self._record_preprocess_capture(session, trace) + capture_result = session.capture_current_combo() + self._record_preprocess_capture(session, capture_result) except Exception as exc: # noqa: BLE001 self._show_exception("Failed to capture preprocess combo", exc) self._abort_capture_sequence() @@ -247,31 +359,47 @@ class AppWindowPreprocessMixin: ) try: while not session.is_complete(): - trace = session.capture_current_combo() - self._record_preprocess_capture(session, trace) + capture_result = session.capture_current_combo() + self._record_preprocess_capture(session, capture_result) 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: + def _record_preprocess_capture( + self, + session: SequentialCaptureSession | MultiRadarSequentialCaptureSession, + capture_result, + ) -> 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) + if isinstance(session, MultiRadarSequentialCaptureSession): + assert isinstance(capture_result, MultiRadarCaptureBatch) + trace = capture_result.display_trace + input_pos = capture_result.combo.input + output_pos = capture_result.combo.output + extra_details = f" | radar_configs={len(capture_result.traces)}" + else: + trace = capture_result + input_pos = trace.combo.input_pos + output_pos = trace.combo.output_pos + extra_details = "" 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, + input_pos=input_pos, + output_pos=output_pos, + extra_details=extra_details, ) 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}" + f"input={input_pos} output={output_pos}{extra_details}" ) self._update_capture_dialog_state() @@ -291,10 +419,19 @@ class AppWindowPreprocessMixin: dialog = self._ensure_preprocess_dialog() try: - removed_trace = session.undo_last_capture() + removed_capture = session.undo_last_capture() state = session.state() display_name = preprocess_asset_display_name(session.kind) channel = preprocess_asset_channel(session.kind) + if isinstance(session, MultiRadarSequentialCaptureSession): + assert isinstance(removed_capture, MultiRadarCaptureBatch) + removed_input = removed_capture.combo.input + removed_output = removed_capture.combo.output + extra_details = f" | radar_configs={len(removed_capture.traces)}" + else: + removed_input = removed_capture.combo.input_pos + removed_output = removed_capture.combo.output_pos + extra_details = "" dialog.set_capture_log_entries(self._capture_log_entries_for_session(session)) last_trace = session.last_captured_trace() @@ -309,8 +446,8 @@ class AppWindowPreprocessMixin: 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}" + f"{display_name} undo last capture: removed input={removed_input} " + f"output={removed_output}{extra_details}; remaining={state.captured_count}/{state.total_count}" ) except Exception as exc: # noqa: BLE001 self._show_exception("Failed to undo last preprocess capture", exc) @@ -333,16 +470,33 @@ class AppWindowPreprocessMixin: 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) + if isinstance(session, MultiRadarSequentialCaptureSession): + saved_sets = session.finalize(self._store) + else: + radar_key, collection = session.finalize(self._store) 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}") + if isinstance(session, MultiRadarSequentialCaptureSession): + assert isinstance(saved_sets, list) + dialog.set_status( + f"{display_name} set saved: {set_name} ({len(saved_sets)} radar variants)" + ) + saved_summary = ", ".join( + f"{saved.display_name}:{saved.trace_count}" + for saved in saved_sets + ) + self._log( + f"{display_name} sequence completed and saved: set={set_name}, " + f"radar_variants={len(saved_sets)} [{saved_summary}]" + ) + else: + 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) @@ -375,6 +529,7 @@ class AppWindowPreprocessMixin: can_undo=False, can_finalize=False, can_capture_all=False, + variant_count=1, ) return @@ -394,6 +549,7 @@ class AppWindowPreprocessMixin: can_undo=state.can_undo, can_finalize=state.is_complete, can_capture_all=(not state.is_complete and state.current_combo is not None), + variant_count=state.variant_count, ) def _cleanup_capture_session(self) -> None: diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index ba051f2..b6d8e9f 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -4,8 +4,10 @@ from __future__ import annotations from PyQt6.QtCore import QSignalBlocker, pyqtSignal from PyQt6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, + QFileDialog, QFormLayout, QGridLayout, QGroupBox, @@ -34,6 +36,8 @@ class PreprocessDialog(QDialog): refresh_requested = pyqtSignal() selection_changed = pyqtSignal() + radar_config_dir_changed = pyqtSignal() + multi_radar_option_changed = pyqtSignal() start_sequence_requested = pyqtSignal(str) capture_next_requested = pyqtSignal() capture_all_requested = pyqtSignal() @@ -88,10 +92,39 @@ class PreprocessDialog(QDialog): header_row.addWidget(self._set_name_input, stretch=1) header_row.addWidget(refresh_button) layout.addLayout(header_row) + layout.addWidget(self._build_radar_config_group(group)) layout.addWidget(self._build_selector_group("S21", VISIBLE_PREPROCESS_ASSET_KEYS, group)) return group + def _build_radar_config_group(self, parent: QGroupBox) -> QGroupBox: + """Build radar-config directory controls for multi-radar preprocessing.""" + group = QGroupBox("Radar Config Variants", parent) + layout = QVBoxLayout(group) + + dir_row = QHBoxLayout() + self._radar_config_dir_input = QLineEdit(group) + self._radar_config_dir_input.setPlaceholderText("Directory with radar JSON configs") + self._radar_config_dir_input.editingFinished.connect(self.radar_config_dir_changed.emit) + browse_button = QPushButton("Browse...", group) + browse_button.clicked.connect(self._choose_radar_config_dir) + dir_row.addWidget(QLabel("Directory")) + dir_row.addWidget(self._radar_config_dir_input, stretch=1) + dir_row.addWidget(browse_button) + layout.addLayout(dir_row) + + self._radar_config_summary_label = QLabel("No radar config directory selected.", group) + self._radar_config_summary_label.setWordWrap(True) + layout.addWidget(self._radar_config_summary_label) + + self._use_all_radar_configs_checkbox = QCheckBox( + "Capture current combo for all found radar configs", + group, + ) + self._use_all_radar_configs_checkbox.toggled.connect(self.multi_radar_option_changed.emit) + layout.addWidget(self._use_all_radar_configs_checkbox) + return group + def _build_selector_group(self, title: str, keys: tuple[str, ...], parent: QGroupBox) -> QGroupBox: """Build one selector subgroup for a channel family.""" group = QGroupBox(title, parent) @@ -201,6 +234,41 @@ class PreprocessDialog(QDialog): """Replace requested target set name.""" self._set_name_input.setText(value) + def radar_config_dir(self) -> str: + """Return configured radar-config directory path.""" + return self._radar_config_dir_input.text().strip() + + def set_radar_config_dir(self, value: str) -> None: + """Replace configured radar-config directory path.""" + self._radar_config_dir_input.setText(value) + + def use_all_radar_configs(self) -> bool: + """Return whether multi-radar capture mode is enabled.""" + return bool(self._use_all_radar_configs_checkbox.isChecked()) + + def set_use_all_radar_configs(self, enabled: bool) -> None: + """Update multi-radar capture checkbox state.""" + self._use_all_radar_configs_checkbox.setChecked(bool(enabled)) + + def set_radar_config_summary( + self, + *, + directory_path: str, + json_file_count: int, + valid_variant_count: int, + skipped_file_count: int, + duplicate_variant_count: int, + ) -> None: + """Update one-line radar-config scan summary.""" + if not directory_path: + self._radar_config_summary_label.setText("No radar config directory selected.") + return + self._radar_config_summary_label.setText( + f"Directory: {directory_path} | " + f"json={json_file_count}, valid={valid_variant_count}, " + f"skipped={skipped_file_count}, duplicates={duplicate_variant_count}" + ) + def selection_snapshot(self) -> dict[str, str]: """Return currently selected set names keyed by preprocess asset key.""" return {key: self._set_combos[key].currentText().strip() for key in VISIBLE_PREPROCESS_ASSET_KEYS} @@ -221,11 +289,12 @@ class PreprocessDialog(QDialog): total_count: int, input_pos: int, output_pos: int, + extra_details: str = "", ) -> None: """Append one capture progress row to dialog log.""" self._capture_log.appendPlainText( f"{kind}: {captured_count}/{total_count} | " - f"input={input_pos} output={output_pos}" + f"input={input_pos} output={output_pos}{extra_details}" ) def set_capture_state( @@ -239,6 +308,7 @@ class PreprocessDialog(QDialog): can_undo: bool, can_finalize: bool, can_capture_all: bool, + variant_count: int = 1, ) -> None: """Update sequence progress/status widgets.""" if kind is None: @@ -265,7 +335,10 @@ class PreprocessDialog(QDialog): self._combo_label.setText("") self._capture_next_button.setEnabled(False) else: - self._combo_label.setText(f"input={next_input}, output={next_output}") + combo_text = f"input={next_input}, output={next_output}" + if int(variant_count) > 1: + combo_text += f" | radar configs={int(variant_count)}" + self._combo_label.setText(combo_text) self._capture_next_button.setEnabled(True) def set_available_sets(self, available_sets: dict[str, list[str]]) -> None: @@ -360,6 +433,18 @@ class PreprocessDialog(QDialog): """Emit current selection snapshot change.""" self.selection_changed.emit() + def _choose_radar_config_dir(self) -> None: + """Pick radar-config directory and notify controller code.""" + selected_dir = QFileDialog.getExistingDirectory( + self, + "Select Radar Config Directory", + self.radar_config_dir(), + ) + if not selected_dir: + return + self._radar_config_dir_input.setText(selected_dir) + self.radar_config_dir_changed.emit() + @staticmethod def _asset_row_label(key: str) -> str: """Return short selector label for one preprocess asset.""" diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index b0e452d..fae07e7 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -313,6 +313,18 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.preprocess_dialog.set_name, "gui.preprocess_dialog", ), + radar_config_dir=_optional_string( + preprocess_dialog_object, + "radar_config_dir", + gui.preprocess_dialog.radar_config_dir, + "gui.preprocess_dialog", + ), + use_all_radar_configs=_optional_bool( + preprocess_dialog_object, + "use_all_radar_configs", + gui.preprocess_dialog.use_all_radar_configs, + "gui.preprocess_dialog", + ), ) profile.gui = gui @@ -377,6 +389,8 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: }, "preprocess_dialog": { "set_name": gui.preprocess_dialog.set_name, + "radar_config_dir": gui.preprocess_dialog.radar_config_dir, + "use_all_radar_configs": gui.preprocess_dialog.use_all_radar_configs, }, } return payload diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 094e76d..be29138 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -93,6 +93,8 @@ class GuiPreprocessDialogStateModel: """UI-only defaults for preprocessing dialog controls.""" set_name: str = "set_001" + radar_config_dir: str = "" + use_all_radar_configs: bool = False @dataclass(slots=True) diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py new file mode 100644 index 0000000..3606b10 --- /dev/null +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -0,0 +1,261 @@ +"""Sequential capture workflow that captures each combo across multiple radar sweep variants.""" + +from __future__ import annotations + +from contextlib import suppress +from dataclasses import dataclass +import time + +import numpy as np + +from python_app.hardware_full.librevna_service import LibreVnaService +from python_app.hardware_full.switch_service import SwitchService +from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData +from python_app.models.run_config_model import ComboModel, RunConfigModel +from python_app.storage.npz_store import NpzStore +from python_app.workflows.radar_config_variants import RadarConfigVariant +from python_app.workflows.sequential_capture_workflow import SequentialCaptureState + + +@dataclass(frozen=True, slots=True) +class MultiRadarCaptureBatch: + """One completed combo capture across all configured radar variants.""" + + combo: ComboModel + traces: tuple[TraceData, ...] + variant_labels: tuple[str, ...] + + @property + def display_trace(self) -> TraceData: + """Return the last trace in the batch for preview rendering.""" + return self.traces[-1] + + +@dataclass(frozen=True, slots=True) +class MultiRadarSavedSet: + """One preprocess set persisted for one radar variant.""" + + display_name: str + radar_key: str + trace_count: int + + +class MultiRadarSequentialCaptureSession: + """Capture a full combo matrix for one preprocess asset across multiple radar sweep variants.""" + + def __init__( + self, + *, + base_config: RunConfigModel, + kind: str, + set_name: str, + radar_variants: list[RadarConfigVariant], + ) -> None: + """Create capture session for one preprocess asset set and multiple radar variants.""" + if kind not in {"s21_calibration", "s21_reference", "s11_open", "s11_short", "s11_load", "s11_reference"}: + raise RuntimeError(f"Unsupported capture kind: {kind}") + if not set_name: + raise RuntimeError("Set name is required") + if not radar_variants: + raise RuntimeError("At least one radar variant is required") + + self._base_config = base_config + self._kind = kind + self._set_name = set_name + self._radar_variants = list(radar_variants) + self._combos = RunConfigModel.build_full_combos( + base_config.input_switch.positions, + base_config.output_switch.positions, + ) + if not self._combos: + raise RuntimeError("No switch combinations available for capture") + + self._captured_batches: list[MultiRadarCaptureBatch] = [] + self._traces_by_radar_key = { + variant.radar_key: [] + for variant in self._radar_variants + } + self._next_index = 0 + self._opened = False + + self._radar = LibreVnaService(serial=base_config.radar.serial or None) + self._input_switch = SwitchService( + name=base_config.input_switch.name, + positions=base_config.input_switch.positions, + mode=base_config.input_switch.driver_mode, + driver=base_config.input_switch.driver, + gpio_chip=base_config.input_switch.gpio_chip, + pin_a=base_config.input_switch.pin_a, + pin_b=base_config.input_switch.pin_b, + invert_logic=base_config.input_switch.invert_logic, + ) + self._output_switch = SwitchService( + name=base_config.output_switch.name, + positions=base_config.output_switch.positions, + mode=base_config.output_switch.driver_mode, + driver=base_config.output_switch.driver, + gpio_chip=base_config.output_switch.gpio_chip, + pin_a=base_config.output_switch.pin_a, + pin_b=base_config.output_switch.pin_b, + invert_logic=base_config.output_switch.invert_logic, + ) + + @property + def kind(self) -> str: + """Return canonical preprocess asset key for this capture session.""" + return self._kind + + @property + def set_name(self) -> str: + """Return destination set name.""" + return self._set_name + + def open(self) -> None: + """Open radar and switch resources.""" + if self._opened: + return + self._opened = True + try: + self._radar.open() + self._radar.configure(self._base_config.radar.sweep) + self._input_switch.open() + self._output_switch.open() + except Exception: + self.close() + raise + + def close(self) -> None: + """Close all opened hardware resources.""" + with suppress(Exception): + self._output_switch.close() + with suppress(Exception): + self._input_switch.close() + with suppress(Exception): + self._radar.close() + self._opened = False + + def state(self) -> SequentialCaptureState: + """Return current progress snapshot.""" + current_combo = self._current_combo() + return SequentialCaptureState( + kind=self._kind, + set_name=self._set_name, + captured_count=len(self._captured_batches), + total_count=len(self._combos), + current_combo=current_combo, + can_undo=bool(self._captured_batches), + is_complete=self.is_complete(), + variant_count=len(self._radar_variants), + ) + + def capture_current_combo(self) -> MultiRadarCaptureBatch: + """Capture the current combo across all radar variants and advance the combo cursor.""" + if not self._opened: + raise RuntimeError("Capture session is not opened") + combo = self._current_combo() + if combo is None: + raise RuntimeError("Capture session is already complete") + + self._output_switch.switch_to(combo.output) + self._input_switch.switch_to(combo.input) + if self._base_config.runtime.settling_ms > 0: + time.sleep(self._base_config.runtime.settling_ms / 1000.0) + + traces: list[TraceData] = [] + variant_labels: list[str] = [] + for variant in self._radar_variants: + self._radar.configure(variant.config.radar.sweep) + if self._base_config.runtime.settling_ms > 0: + time.sleep(self._base_config.runtime.settling_ms / 1000.0) + sweep = self._radar.acquire() + trace = TraceData( + combo=ComboKey(input_pos=combo.input, output_pos=combo.output), + frequency_hz=np.asarray(sweep.x, dtype=np.float32), + s11=np.asarray(sweep.trace("s11"), dtype=np.complex64), + s21=np.asarray(sweep.trace("s21"), dtype=np.complex64), + ) + traces.append(trace) + variant_labels.append(variant.display_name) + self._traces_by_radar_key[variant.radar_key].append(trace) + + batch = MultiRadarCaptureBatch( + combo=combo, + traces=tuple(traces), + variant_labels=tuple(variant_labels), + ) + self._captured_batches.append(batch) + self._next_index += 1 + return batch + + def undo_last_capture(self) -> MultiRadarCaptureBatch: + """Remove the most recently captured combo batch and rewind the cursor.""" + if not self._opened: + raise RuntimeError("Capture session is not opened") + if not self._captured_batches or self._next_index <= 0: + raise RuntimeError("No captured combo is available to undo") + + expected_combo = self._combos[self._next_index - 1] + removed_batch = self._captured_batches[-1] + if ( + int(removed_batch.combo.input) != int(expected_combo.input) + or int(removed_batch.combo.output) != int(expected_combo.output) + ): + raise RuntimeError("Capture session state is inconsistent; last batch does not match rewind combo") + + for variant in self._radar_variants: + traces = self._traces_by_radar_key[variant.radar_key] + if not traces: + raise RuntimeError("Capture session state is inconsistent; missing trace during undo") + traces.pop() + + self._next_index -= 1 + self._captured_batches.pop() + return removed_batch + + def last_captured_trace(self) -> TraceData | None: + """Return the most recent trace from the most recent combo batch, if any.""" + if not self._captured_batches: + return None + return self._captured_batches[-1].display_trace + + def captured_batches(self) -> list[MultiRadarCaptureBatch]: + """Return completed combo batches in capture order.""" + return list(self._captured_batches) + + def radar_variant_count(self) -> int: + """Return how many radar variants are captured per combo.""" + return len(self._radar_variants) + + def is_complete(self) -> bool: + """Return `True` when all combos were captured.""" + return self._next_index >= len(self._combos) + + def finalize(self, store: NpzStore) -> list[MultiRadarSavedSet]: + """Persist completed captures as one preprocess set per radar variant.""" + if not self.is_complete(): + raise RuntimeError("Capture session is not complete") + + monotonic_ns = time.monotonic_ns() + saved_sets: list[MultiRadarSavedSet] = [] + for variant in self._radar_variants: + traces = list(self._traces_by_radar_key[variant.radar_key]) + collection = SweepCollection( + collection_id=1, + monotonic_ns=monotonic_ns, + traces=traces, + ) + store.save_set(self._kind, variant.radar_key, self._set_name, collection) + saved_sets.append( + MultiRadarSavedSet( + display_name=variant.display_name, + radar_key=variant.radar_key, + trace_count=len(traces), + ) + ) + return saved_sets + + def _current_combo(self) -> ComboModel | None: + """Return next combo to capture, or `None` if session is complete.""" + if self._next_index >= len(self._combos): + return None + return self._combos[self._next_index] diff --git a/python_app/workflows/radar_config_variants.py b/python_app/workflows/radar_config_variants.py new file mode 100644 index 0000000..2c02521 --- /dev/null +++ b/python_app/workflows/radar_config_variants.py @@ -0,0 +1,154 @@ +"""Helpers for loading radar sweep variants from a directory of JSON files.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path + +from python_app.models.run_config_model import RunConfigModel +from python_app.storage.npz_store import radar_key_from_config + +_RADAR_SWEEP_KEYS = ( + "start_hz", + "stop_hz", + "points", + "if_bandwidth_hz", + "stimulus_power_dbm", +) + + +@dataclass(frozen=True, slots=True) +class RadarConfigVariant: + """One capture-ready radar sweep variant loaded from JSON.""" + + source_path: Path + display_name: str + config: RunConfigModel + radar_key: str + + +@dataclass(frozen=True, slots=True) +class RadarConfigScanSummary: + """Scan results for one radar-config directory refresh.""" + + directory_path: str + json_file_count: int + valid_variant_count: int + skipped_file_count: int + duplicate_variant_count: int + issues: tuple[str, ...] + + +def scan_radar_config_variants( + directory_path: str, + *, + base_config: RunConfigModel, +) -> tuple[list[RadarConfigVariant], RadarConfigScanSummary]: + """Load valid radar sweep variants from `directory_path` using `base_config` as the baseline.""" + normalized_path = str(directory_path).strip() + if not normalized_path: + return [], RadarConfigScanSummary( + directory_path="", + json_file_count=0, + valid_variant_count=0, + skipped_file_count=0, + duplicate_variant_count=0, + issues=(), + ) + + directory = Path(normalized_path).expanduser() + if not directory.exists(): + return [], RadarConfigScanSummary( + directory_path=str(directory), + json_file_count=0, + valid_variant_count=0, + skipped_file_count=0, + duplicate_variant_count=0, + issues=(f"Directory does not exist: {directory}",), + ) + if not directory.is_dir(): + return [], RadarConfigScanSummary( + directory_path=str(directory), + json_file_count=0, + valid_variant_count=0, + skipped_file_count=0, + duplicate_variant_count=0, + issues=(f"Path is not a directory: {directory}",), + ) + + variants: list[RadarConfigVariant] = [] + issues: list[str] = [] + duplicate_variant_count = 0 + seen_radar_keys: set[str] = set() + json_paths = sorted(path for path in directory.glob("*.json") if path.is_file()) + + for path in json_paths: + try: + variant = _load_radar_config_variant(path, base_config=base_config) + except Exception as exc: # noqa: BLE001 + issues.append(f"{path.name}: {type(exc).__name__}: {exc}") + continue + if variant.radar_key in seen_radar_keys: + duplicate_variant_count += 1 + issues.append( + f"{path.name}: duplicate radar variant key {variant.radar_key}; keeping the first matching file only" + ) + continue + seen_radar_keys.add(variant.radar_key) + variants.append(variant) + + return variants, RadarConfigScanSummary( + directory_path=str(directory), + json_file_count=len(json_paths), + valid_variant_count=len(variants), + skipped_file_count=max(0, len(json_paths) - len(variants) - duplicate_variant_count), + duplicate_variant_count=duplicate_variant_count, + issues=tuple(issues), + ) + + +def _load_radar_config_variant(path: Path, *, base_config: RunConfigModel) -> RadarConfigVariant: + """Load one radar sweep variant by overlaying JSON `radar.sweep` onto `base_config`.""" + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("JSON root must be an object") + + radar_payload = payload.get("radar") + if not isinstance(radar_payload, dict): + raise ValueError("Missing radar section") + + sweep_payload = radar_payload.get("sweep") + if not isinstance(sweep_payload, dict): + raise ValueError("Missing radar.sweep section") + if not any(key in sweep_payload for key in _RADAR_SWEEP_KEYS): + raise ValueError("radar.sweep does not contain any supported sweep keys") + + config = base_config.clone() + sweep = config.radar.sweep + if "start_hz" in sweep_payload: + sweep.start_hz = float(sweep_payload["start_hz"]) + if "stop_hz" in sweep_payload: + sweep.stop_hz = float(sweep_payload["stop_hz"]) + if "points" in sweep_payload: + sweep.points = int(sweep_payload["points"]) + if "if_bandwidth_hz" in sweep_payload: + sweep.if_bandwidth_hz = float(sweep_payload["if_bandwidth_hz"]) + if "stimulus_power_dbm" in sweep_payload: + sweep.power_dbm = float(sweep_payload["stimulus_power_dbm"]) + + radar_key = radar_key_from_config( + model_name=config.radar.model, + serial=config.radar.serial, + sweep_start_hz=config.radar.sweep.start_hz, + sweep_stop_hz=config.radar.sweep.stop_hz, + sweep_points=config.radar.sweep.points, + ifbw_hz=config.radar.sweep.if_bandwidth_hz, + power_dbm=config.radar.sweep.power_dbm, + ) + return RadarConfigVariant( + source_path=path, + display_name=path.stem, + config=config, + radar_key=radar_key, + ) diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 0904630..529c666 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -26,6 +26,7 @@ class SequentialCaptureState: current_combo: ComboModel | None can_undo: bool is_complete: bool + variant_count: int = 1 class SequentialCaptureSession: