diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index 95886a3..9545035 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -127,6 +127,9 @@ class AppWindow( 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_median_sweep_count = max( + 1, int(self._gui_defaults.preprocess_dialog.median_sweep_count) + ) self._preprocess_radar_variants: list[RadarConfigVariant] = [] self._preprocess_radar_scan_summary = RadarConfigScanSummary( directory_path=self._preprocess_radar_config_dir, 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 7b00e45..93d64c8 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 @@ -470,6 +470,7 @@ class AppWindowConfigProfileIOMixin: 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._preprocess_median_sweep_count = max(1, int(gui_state.preprocess_dialog.median_sweep_count)) self._apply_history_limit_from_config(config) self._gpr_geometry_signature = None self._gpr_selected_geometry = None @@ -487,6 +488,7 @@ class AppWindowConfigProfileIOMixin: 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_median_sweep_count(self._preprocess_median_sweep_count) self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False) self._apply_initial_radar_limits() 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 aa485c7..b41fc6a 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -273,6 +273,12 @@ class AppWindowConfigStateBuildersMixin: self._preprocess_use_all_radar_configs = self._preprocess_dialog.use_all_radar_configs() return bool(self._preprocess_use_all_radar_configs) + def _current_preprocess_median_sweep_count(self) -> int: + """Return current per-combo median sweep count from the dialog.""" + if self._preprocess_dialog is not None: + self._preprocess_median_sweep_count = self._preprocess_dialog.median_sweep_count() + return max(1, int(self._preprocess_median_sweep_count)) + def _build_gui_state(self) -> GuiStateModel: """Build GUI-only persistent state from current widget values.""" return GuiStateModel( @@ -367,6 +373,7 @@ class AppWindowConfigStateBuildersMixin: 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(), + median_sweep_count=self._current_preprocess_median_sweep_count(), ), ) diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 307b960..15aa44d 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -70,7 +70,7 @@ class AppWindowPipelineMixin: + ", ".join(missing_assets) ) - combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos] + combo_keys = [ComboKey(input=combo.input, output=combo.output) for combo in config.combos] active_preprocess_keys = runtime_preprocess_asset_keys(config) preprocess_summary = "; ".join( f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}" diff --git a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py index a23b273..8d18316 100644 --- a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py @@ -95,7 +95,7 @@ def rebuild_bscan_history_from_results( for collection in result_tail: for block in collection.blocks: - key = (block.combo.input_pos, block.combo.output_pos) + key = (block.combo.input, block.combo.output) for payload in block.payloads: if payload.kind != 1 or payload.processing_name != "bscan": continue diff --git a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py index 40dc878..48d99c8 100644 --- a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py @@ -152,7 +152,7 @@ class AppWindowTracePlotMixin: x_min = np.inf x_max = -np.inf for block in collection.blocks: - combo_key = (int(block.combo.input_pos), int(block.combo.output_pos)) + combo_key = (int(block.combo.input), int(block.combo.output)) if combo_filter is not None and combo_key not in combo_filter: continue if combo_key not in combo_colors: @@ -384,7 +384,7 @@ class AppWindowTracePlotMixin: ) magnitude_plot.addItem(magnitude_curve) self._trace_magnitude_curves[ - (int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__") + (int(trace.combo.input), int(trace.combo.output), 0, "__single_trace__") ] = magnitude_curve if show_phase: @@ -396,7 +396,7 @@ class AppWindowTracePlotMixin: ) phase_plot.addItem(phase_curve) self._trace_phase_curves[ - (int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__") + (int(trace.combo.input), int(trace.combo.output), 0, "__single_trace__") ] = phase_curve phase_plot.setYRange(-180.0, 180.0, padding=0.02) diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index f11ecc7..a27cd69 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -43,7 +43,7 @@ class AppWindowPreprocessMixin: 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}" + f"input={trace.combo.input} output={trace.combo.output}" ) return entries @@ -134,7 +134,12 @@ class AppWindowPreprocessMixin: f"set={TMP_REFERENCE_SET_NAME}, radar_key={radar_key}" ) - radar_key, collection = capture_reference_set(config, TMP_REFERENCE_SET_NAME, self._store) + radar_key, collection = capture_reference_set( + config, + TMP_REFERENCE_SET_NAME, + self._store, + median_sweep_count=self._preprocess_median_sweep_count, + ) self._selected_preprocess_sets["s21_reference"] = TMP_REFERENCE_SET_NAME self._selected_preprocess_radar_key = radar_key self._processor_run_signature = None @@ -176,11 +181,13 @@ class AppWindowPreprocessMixin: 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_median_sweep_count(self._preprocess_median_sweep_count) 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.median_sweep_count_changed.connect(self._on_preprocess_median_sweep_count_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) @@ -206,6 +213,11 @@ class AppWindowPreprocessMixin: self._preprocess_use_all_radar_configs = dialog.use_all_radar_configs() self._refresh_sets() + def _on_preprocess_median_sweep_count_changed(self) -> None: + """Persist updated per-combo median sweep count from the preprocessing dialog.""" + dialog = self._ensure_preprocess_dialog() + self._preprocess_median_sweep_count = dialog.median_sweep_count() + def _on_preprocess_selection_changed(self) -> None: """Persist selected preprocessing set names from dialog.""" dialog = self._ensure_preprocess_dialog() @@ -314,17 +326,31 @@ class AppWindowPreprocessMixin: try: config = self._build_config() display_name = preprocess_asset_display_name(kind) + median_sweep_count = dialog.median_sweep_count() + self._preprocess_median_sweep_count = median_sweep_count if self._preprocess_use_all_radar_configs: - session = self._build_multi_radar_capture_session(config=config, kind=kind, set_name=set_name) + session = self._build_multi_radar_capture_session( + config=config, + kind=kind, + set_name=set_name, + median_sweep_count=median_sweep_count, + ) radar_summary = ( f"radar_variants={session.radar_variant_count()}, " - f"combos={session.state().total_count}" + f"combos={session.state().total_count}, " + f"median_sweep_count={median_sweep_count}" ) else: - session = self._build_single_radar_capture_session(config=config, kind=kind, set_name=set_name) + session = self._build_single_radar_capture_session( + config=config, + kind=kind, + set_name=set_name, + median_sweep_count=median_sweep_count, + ) radar_summary = ( f"radar_key={self._radar_key(config)}, " - f"combos={session.state().total_count}" + f"combos={session.state().total_count}, " + f"median_sweep_count={median_sweep_count}" ) session.open() @@ -431,6 +457,7 @@ class AppWindowPreprocessMixin: config, kind: str, set_name: str, + median_sweep_count: int, ) -> SequentialCaptureSession: """Validate and create the existing single-radar capture session.""" radar_key = self._radar_key(config) @@ -438,7 +465,12 @@ class AppWindowPreprocessMixin: 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) + return SequentialCaptureSession( + config=config, + kind=kind, + set_name=set_name, + median_sweep_count=median_sweep_count, + ) def _build_multi_radar_capture_session( self, @@ -446,6 +478,7 @@ class AppWindowPreprocessMixin: config, kind: str, set_name: str, + median_sweep_count: int, ) -> MultiRadarSequentialCaptureSession: """Validate and create the multi-radar capture session.""" self._refresh_preprocess_radar_variants() @@ -469,6 +502,7 @@ class AppWindowPreprocessMixin: kind=kind, set_name=set_name, radar_variants=self._preprocess_radar_variants, + median_sweep_count=median_sweep_count, ) def _capture_next_combo(self) -> None: @@ -554,8 +588,8 @@ class AppWindowPreprocessMixin: 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 + input_pos = trace.combo.input + output_pos = trace.combo.output extra_details = "" dialog.append_capture_log_entry( @@ -600,8 +634,8 @@ class AppWindowPreprocessMixin: 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 + removed_input = removed_capture.combo.input + removed_output = removed_capture.combo.output extra_details = "" dialog.set_capture_log_entries(self._capture_log_entries_for_session(session)) diff --git a/python_app/gui/preprocess_dialog.py b/python_app/gui/preprocess_dialog.py index 5f3ccec..3fc8c29 100644 --- a/python_app/gui/preprocess_dialog.py +++ b/python_app/gui/preprocess_dialog.py @@ -17,6 +17,7 @@ from PyQt6.QtWidgets import ( QPlainTextEdit, QPushButton, QScrollArea, + QSpinBox, QVBoxLayout, QWidget, ) @@ -38,6 +39,7 @@ class PreprocessDialog(QDialog): selection_changed = pyqtSignal() radar_config_dir_changed = pyqtSignal() multi_radar_option_changed = pyqtSignal() + median_sweep_count_changed = pyqtSignal() start_sequence_requested = pyqtSignal(str) capture_next_requested = pyqtSignal() capture_all_requested = pyqtSignal() @@ -50,9 +52,10 @@ class PreprocessDialog(QDialog): """Initialize window metadata and compose dialog UI.""" super().__init__(parent) self._set_combos: dict[str, QComboBox] = {} - self._preview_plot: pg.PlotWidget | None = None + self._amplitude_plot: pg.PlotWidget | None = None + self._phase_plot: pg.PlotWidget | None = None self._preview_placeholder: QLabel | None = None - self._preview_host_layout: QVBoxLayout | None = None + self._preview_host_layout: QHBoxLayout | None = None self._preview_plot_unavailable = False self._init_window() self._build_ui() @@ -102,11 +105,28 @@ class PreprocessDialog(QDialog): header_row.addWidget(self._kamil_adc_neutral_sets_button) header_row.addWidget(refresh_button) layout.addLayout(header_row) + layout.addLayout(self._build_median_sweep_row(group)) layout.addWidget(self._build_radar_config_group(group)) layout.addWidget(self._build_selector_group("S21", VISIBLE_PREPROCESS_ASSET_KEYS, group)) return group + def _build_median_sweep_row(self, parent: QGroupBox) -> QHBoxLayout: + """Build per-combo median sweep-count selector for capture sessions.""" + row = QHBoxLayout() + self._median_sweep_count_input = QSpinBox(parent) + self._median_sweep_count_input.setRange(1, 99) + self._median_sweep_count_input.setValue(5) + self._median_sweep_count_input.setToolTip( + "How many sweeps to capture for each combo. The per-frequency median of all " + "sweeps is saved, suppressing single-sweep outliers (e.g. random phase glitches)." + ) + self._median_sweep_count_input.valueChanged.connect(self.median_sweep_count_changed.emit) + row.addWidget(QLabel("Sweeps per combo (median)")) + row.addWidget(self._median_sweep_count_input) + row.addStretch(1) + return row + def _build_radar_config_group(self, parent: QGroupBox) -> QGroupBox: """Build radar-config directory controls for multi-radar preprocessing.""" group = QGroupBox("Radar Config Variants", parent) @@ -221,11 +241,16 @@ class PreprocessDialog(QDialog): root_layout.addWidget(self._status_label) def _build_preview_plot(self, root_layout: QVBoxLayout) -> None: - """Build lazy preview host used after each successful capture.""" + """Build lazy preview host used after each successful capture. + + The host holds a side-by-side amplitude (left) and phase (right) plot + pair, created lazily on first capture and replacing a placeholder + label when the PyQtGraph build supports it. + """ host = QWidget(self) - layout = QVBoxLayout(host) + layout = QHBoxLayout(host) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) + layout.setSpacing(6) placeholder = QLabel("Preview will appear after the first successful capture.", host) placeholder.setWordWrap(True) @@ -260,6 +285,15 @@ class PreprocessDialog(QDialog): """Update multi-radar capture checkbox state.""" self._use_all_radar_configs_checkbox.setChecked(bool(enabled)) + def median_sweep_count(self) -> int: + """Return configured number of sweeps to median-combine per combo.""" + return max(1, int(self._median_sweep_count_input.value())) + + def set_median_sweep_count(self, count: int) -> None: + """Update per-combo median sweep-count spinbox.""" + with QSignalBlocker(self._median_sweep_count_input): + self._median_sweep_count_input.setValue(max(1, int(count))) + def set_radar_config_summary( self, *, @@ -385,40 +419,51 @@ class PreprocessDialog(QDialog): self._kamil_adc_neutral_sets_button.setEnabled(bool(visible)) 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("") + """Clear preview surfaces and restore default empty-state text when possible.""" + if self._amplitude_plot is not None: + self._amplitude_plot.clear() + if self._phase_plot is not None: + self._phase_plot.clear() 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.""" + """Draw amplitude (dB) and wrapped phase (deg, ±180) panes for the latest capture.""" samples = trace.s11 if channel == "s11" else trace.s21 magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) - if self._ensure_preview_plot(): - assert self._preview_plot is not None - self._preview_plot.clear() - self._preview_plot.plot( + phase_deg = np.degrees(np.angle(samples)) + + if self._ensure_preview_plots(): + assert self._amplitude_plot is not None + assert self._phase_plot is not None + self._amplitude_plot.clear() + self._amplitude_plot.plot( trace.frequency_hz, magnitude_db, pen=pg.mkPen("#4cc9f0", width=1.8), ) + self._phase_plot.clear() + self._phase_plot.plot( + trace.frequency_hz, + phase_deg, + pen=pg.mkPen("#f48c06", width=1.8), + ) elif self._preview_placeholder is not None: self._preview_placeholder.setText( f"{title}\n" - f"input={trace.combo.input_pos}, output={trace.combo.output_pos}, " + f"input={trace.combo.input}, output={trace.combo.output}, " f"points={trace.frequency_hz.size}\n" f"Preview plot is unavailable on this PyQtGraph/PyQt6 build." ) + combo = trace.combo self._status_label.setText( - f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}" + f"{title}: input={combo.input}, output={combo.output}, points={trace.frequency_hz.size}" ) - def _ensure_preview_plot(self) -> bool: - """Create preview plot lazily and keep a text fallback when unavailable.""" - if self._preview_plot is not None: + def _ensure_preview_plots(self) -> bool: + """Create the amplitude+phase plot pair lazily on first successful capture.""" + if self._amplitude_plot is not None and self._phase_plot is not None: return True if self._preview_plot_unavailable: return False @@ -426,11 +471,8 @@ class PreprocessDialog(QDialog): return False try: - plot = pg.PlotWidget(background="#101418", enableMenu=False) - plot.showGrid(x=True, y=True, alpha=0.2) - plot.setLabel("bottom", "Frequency", units="Hz") - plot.setLabel("left", "Magnitude", units="dB") - plot.setMinimumHeight(320) + amplitude_plot = self._build_preview_axis("Magnitude", "dB") + phase_plot = self._build_preview_axis("Phase", "deg") except Exception: self._preview_plot_unavailable = True return False @@ -440,10 +482,22 @@ class PreprocessDialog(QDialog): self._preview_placeholder.deleteLater() self._preview_placeholder = None - self._preview_plot = plot - self._preview_host_layout.addWidget(plot) + self._amplitude_plot = amplitude_plot + self._phase_plot = phase_plot + self._preview_host_layout.addWidget(amplitude_plot, stretch=1) + self._preview_host_layout.addWidget(phase_plot, stretch=1) return True + @staticmethod + def _build_preview_axis(left_label: str, left_units: str) -> "pg.PlotWidget": + """Return one PlotWidget configured with the project's preview style.""" + plot = pg.PlotWidget(background="#101418", enableMenu=False) + plot.showGrid(x=True, y=True, alpha=0.2) + plot.setLabel("bottom", "Frequency", units="Hz") + plot.setLabel("left", left_label, units=left_units) + plot.setMinimumHeight(320) + return plot + def _emit_selection_changed(self) -> None: """Emit current selection snapshot change.""" self.selection_changed.emit() diff --git a/python_app/hardware_full/librevna_multi_device_driver/controller.py b/python_app/hardware_full/librevna_multi_device_driver/controller.py index 8b2257c..22c1e23 100644 --- a/python_app/hardware_full/librevna_multi_device_driver/controller.py +++ b/python_app/hardware_full/librevna_multi_device_driver/controller.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Iterator, Sequence from dataclasses import replace from typing import Optional +import threading import time from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import ( @@ -85,7 +86,17 @@ class MultiDeviceVnaController: *, master_stimulus_ports: Sequence[int] = (1, 2), ) -> None: - """Apply reference/sweep settings and leave devices sweeping.""" + """Apply reference/sweep settings and leave devices sweeping. + + When the requested configuration already matches the running sweep, + the host-side packet queues are drained and the device sweep is + left untouched, so back-to-back acquires do not pay the SET_IDLE + + SWEEP_SETTINGS round-trip cost. The drain is performed in parallel + across devices to keep the cross-device timing skew below the USB + latency variance, which is what previously let a hardware cycle + wrap slip between master and slave drains and desynchronise the + per-device cycle counters. + """ if self._is_closed: raise RuntimeError("Controller is already closed") stimulus_ports = self._normalize_master_stimulus_ports(master_stimulus_ports) @@ -93,12 +104,6 @@ class MultiDeviceVnaController: if not self._reference_configuration_applied: self._configure_reference_clocks() - # Even when the device-side configuration matches and we skip reconfiguration, - # the host-side packet queue has been accumulating datapoints from cycles that - # ran between calls. Draining here guarantees the next collect_running_sweep_cycles - # returns a freshly-arriving cycle (the cycle tracker waits for point_index==0). - # Without this drain, callers would receive whichever stale cycle happened to be - # at the head of the queue — e.g. data from before a manual cable swap. self._drain_all_received_packets() if ( @@ -111,6 +116,10 @@ class MultiDeviceVnaController: if self._sweep_is_running: self._send_idle_to_all_devices() time.sleep(self._reconfigure_delay_s) + # The old sweep keeps streaming datapoints until each device + # processes SET_IDLE. Drain again after the idle settling delay + # so the new sweep starts on an empty queue. + self._drain_all_received_packets() self._configure_sweep_on_all_devices( sweep_configuration, @@ -192,12 +201,16 @@ class MultiDeviceVnaController: pass def _send_idle_to_all_devices(self) -> None: + # SET_IDLE is a one-shot stop command. The ACK may be delayed only by the + # in-flight datapoint queue, which drains within a few hundred ms. A short, + # single-shot timeout keeps recovery snappy when one device stops responding + # so callers (e.g. multi-radar capture) do not block for minutes on retries. for device_connection in self._all_devices: self._try_send_command_without_failing( device_connection, PacketType.SET_IDLE, - timeout_seconds=3.0, - retry_count=1, + timeout_seconds=1.0, + retry_count=0, ) self._sweep_is_running = False @@ -245,8 +258,30 @@ class MultiDeviceVnaController: self._sweep_is_running = True def _drain_all_received_packets(self) -> None: - for device_connection in self._all_devices: - device_connection.drain_received_packets() + # Drain every device queue in parallel rather than one after another: + # serial drain leaves up to a few hundred microseconds of skew between + # the master and slave drain moments, which is enough room for a + # hardware cycle wrap to slip between drains and desynchronise the + # per-device cycle counters. Each device has its own queue and lock, + # so concurrent get_nowait calls do not contend. A single device case + # just runs inline to avoid the thread-spawn overhead. + if len(self._all_devices) < 2: + for device_connection in self._all_devices: + device_connection.drain_received_packets() + return + + drain_threads = [ + threading.Thread( + target=device_connection.drain_received_packets, + name=f"drain-{device_connection.serial_number}", + daemon=True, + ) + for device_connection in self._all_devices + ] + for drain_thread in drain_threads: + drain_thread.start() + for drain_thread in drain_threads: + drain_thread.join() @staticmethod def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]: diff --git a/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py b/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py index 0030cfd..a0f2b1b 100644 --- a/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py +++ b/python_app/hardware_full/librevna_multi_device_driver/cycle_collection.py @@ -69,6 +69,13 @@ def collect_complete_running_sweep_cycles( else: datapoint_timeout_seconds = max(0.5, float(datapoint_timeout_seconds)) + # Upper bound on how long we wait for the first cycle-start datapoint + # (point_index==0). Without it, a device that keeps streaming non-zero + # indices but never wraps (e.g. after a misconfigured sweep restart) + # would refresh `last_datapoint_timestamp` on every incoming packet and + # stall capture indefinitely. + cycle_start_guard_seconds = max(2.0, datapoint_timeout_seconds * 4.0) + def collect_datapoints_from_device( device_connection: LibreVnaUsbBulkConnection, handle_datapoint: Callable[[ParsedVnaDatapoint], bool], @@ -76,12 +83,15 @@ def collect_complete_running_sweep_cycles( datapoints_received = 0 expected_datapoint_count = cycle_count * point_count last_datapoint_timestamp = time.monotonic() + collection_loop_start = last_datapoint_timestamp + has_consumed_any_datapoint = False while datapoints_received < expected_datapoint_count: if stop_collection_requested.is_set(): return - remaining_timeout_seconds = (last_datapoint_timestamp + datapoint_timeout_seconds) - time.monotonic() + now = time.monotonic() + remaining_timeout_seconds = (last_datapoint_timestamp + datapoint_timeout_seconds) - now if remaining_timeout_seconds <= 0: collection_errors.append( TimeoutError( @@ -93,6 +103,17 @@ def collect_complete_running_sweep_cycles( stop_collection_requested.set() return + if not has_consumed_any_datapoint and (now - collection_loop_start) > cycle_start_guard_seconds: + collection_errors.append( + TimeoutError( + f"Device {device_connection.serial_number} streamed datapoints but never " + f"reached point_index=0 within {cycle_start_guard_seconds:.1f} s " + f"(sweep cycle did not restart)" + ) + ) + stop_collection_requested.set() + return + try: packet_type, payload = device_connection.receive_packet( timeout_seconds=min(1.0, remaining_timeout_seconds) @@ -118,35 +139,41 @@ def collect_complete_running_sweep_cycles( last_datapoint_timestamp = time.monotonic() datapoint_was_consumed = handle_datapoint(parsed_datapoint) if datapoint_was_consumed: + has_consumed_any_datapoint = True datapoint_counts_by_device_serial[device_connection.serial_number] += 1 datapoints_received += 1 def build_cycle_tracking_handler( cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None], ) -> Callable[[ParsedVnaDatapoint], bool]: + # The controller restarts the sweep before every collection, so the + # first packet each device emits is point 0 of a brand-new cycle 0. + # Anchoring cycle 0 on the first observed point_index==0 — instead of + # synthesising it from a wrap — pins master and slave threads to the + # same physical cycle even if a stale straggler from the just-stopped + # sweep escaped the post-idle drain: such a straggler always carries + # a non-zero point_index and is discarded until the genuine cycle 0 + # arrives. From that anchor, each subsequent wrap advances the cycle + # counter normally. cycle_tracking_state = { "current_cycle_index": 0, "previous_point_index": -1, - "has_seen_cycle_start": False, + "synchronized": False, } def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool: current_point_index = parsed_datapoint.point_index - if not cycle_tracking_state["has_seen_cycle_start"]: + + if not cycle_tracking_state["synchronized"]: if current_point_index != 0: - cycle_tracking_state["previous_point_index"] = current_point_index return False - cycle_tracking_state["has_seen_cycle_start"] = True + cycle_tracking_state["synchronized"] = True cycle_tracking_state["previous_point_index"] = current_point_index cycle_aware_handler(parsed_datapoint, 0) return True - if ( - cycle_tracking_state["previous_point_index"] >= 0 - and current_point_index < cycle_tracking_state["previous_point_index"] - ): + if current_point_index < cycle_tracking_state["previous_point_index"]: cycle_tracking_state["current_cycle_index"] += 1 - cycle_tracking_state["previous_point_index"] = current_point_index current_cycle_index = cycle_tracking_state["current_cycle_index"] if current_cycle_index >= cycle_count: diff --git a/python_app/hardware_full/multi_device_service.py b/python_app/hardware_full/multi_device_service.py index b0dd3d2..100d14c 100644 --- a/python_app/hardware_full/multi_device_service.py +++ b/python_app/hardware_full/multi_device_service.py @@ -163,7 +163,7 @@ class MultiDeviceLibreVnaService: for input_pos, s_parameter_name in enumerate(_INPUT_S_PARAMETERS_BY_OUTPUT[output_pos]): traces.append( TraceData( - combo=ComboKey(input_pos=input_pos, output_pos=output_pos), + combo=ComboKey(input=input_pos, output=output_pos), frequency_hz=frequencies, s11=reflection, s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name), @@ -200,7 +200,7 @@ class MultiDeviceLibreVnaService: s21 = (gain * np.cos(phase) + 1j * gain * np.sin(phase)).astype(np.complex64) traces.append( TraceData( - combo=ComboKey(input_pos=input_pos, output_pos=output_pos), + combo=ComboKey(input=input_pos, output=output_pos), frequency_hz=frequencies, s11=s11, s21=s21, diff --git a/python_app/models/dataset_model.py b/python_app/models/dataset_model.py index 69f13dd..bfe27d8 100644 --- a/python_app/models/dataset_model.py +++ b/python_app/models/dataset_model.py @@ -26,8 +26,8 @@ def _empty_f32_matrix() -> np.ndarray: class ComboKey: """Switch combination key: input position + output position.""" - input_pos: int - output_pos: int + input: int + output: int @dataclass(slots=True) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index 7df9820..5d3a142 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -471,6 +471,15 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.preprocess_dialog.use_all_radar_configs, "gui.preprocess_dialog", ), + median_sweep_count=max( + 1, + _optional_int( + preprocess_dialog_object, + "median_sweep_count", + gui.preprocess_dialog.median_sweep_count, + "gui.preprocess_dialog", + ), + ), ) profile.gui = gui @@ -564,6 +573,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "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, + "median_sweep_count": gui.preprocess_dialog.median_sweep_count, }, } return payload diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 0d5ab2b..646459f 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -127,6 +127,7 @@ class GuiPreprocessDialogStateModel: set_name: str = "set_001" radar_config_dir: str = "" use_all_radar_configs: bool = False + median_sweep_count: int = 5 @dataclass(slots=True) diff --git a/python_app/orchestration/shm/decoder.py b/python_app/orchestration/shm/decoder.py index 0f721ab..2ca1fec 100644 --- a/python_app/orchestration/shm/decoder.py +++ b/python_app/orchestration/shm/decoder.py @@ -47,7 +47,7 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect traces.append( TraceData( - combo=ComboKey(input_pos=input_pos, output_pos=output_pos), + combo=ComboKey(input=input_pos, output=output_pos), frequency_hz=freq, s11=s11, s21=s21, @@ -155,7 +155,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection: blocks.append( ResultBlock( - combo=ComboKey(input_pos=input_pos, output_pos=output_pos), + combo=ComboKey(input=input_pos, output=output_pos), payloads=payloads, ) ) diff --git a/python_app/scripts/convert_legacy_preprocess_sets.py b/python_app/scripts/convert_legacy_preprocess_sets.py index 71102bf..ba297ae 100644 --- a/python_app/scripts/convert_legacy_preprocess_sets.py +++ b/python_app/scripts/convert_legacy_preprocess_sets.py @@ -124,7 +124,7 @@ def _load_legacy_collection(meta_path: Path, npz_path: Path, *, target_kind: str traces.append( TraceData( - combo=ComboKey(input_pos=input_pos, output_pos=output_pos), + combo=ComboKey(input=input_pos, output=output_pos), frequency_hz=frequency_hz, s11=s11, s21=s21, diff --git a/python_app/scripts/hardware_raw_orchestrator_test.py b/python_app/scripts/hardware_raw_orchestrator_test.py index d2ffe60..a06557e 100644 --- a/python_app/scripts/hardware_raw_orchestrator_test.py +++ b/python_app/scripts/hardware_raw_orchestrator_test.py @@ -238,7 +238,7 @@ class RawOrchestratorViewer(QMainWindow): for idx, trace in enumerate(collection.traces): magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12)) - label = f"input={trace.combo.input_pos}, output={trace.combo.output_pos}" + label = f"input={trace.combo.input}, output={trace.combo.output}" self._plot.plot( trace.frequency_hz, magnitude_db, diff --git a/python_app/scripts/kamil_adc_raw_producer.py b/python_app/scripts/kamil_adc_raw_producer.py index 021d336..d20fed0 100644 --- a/python_app/scripts/kamil_adc_raw_producer.py +++ b/python_app/scripts/kamil_adc_raw_producer.py @@ -79,7 +79,7 @@ def main() -> int: sweep = radar.acquire() traces.append( TraceData( - combo=ComboKey(input_pos=combo.input, output_pos=combo.output), + combo=ComboKey(input=combo.input, output=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), diff --git a/python_app/scripts/manual_smoke_run.py b/python_app/scripts/manual_smoke_run.py index cf34cc1..f4488dd 100644 --- a/python_app/scripts/manual_smoke_run.py +++ b/python_app/scripts/manual_smoke_run.py @@ -74,7 +74,7 @@ def build_synthetic_collection( traces.append( TraceData( - combo=ComboKey(input_pos=combo.input, output_pos=combo.output), + combo=ComboKey(input=combo.input, output=combo.output), frequency_hz=frequency_hz, s11=s11, s21=s21, diff --git a/python_app/storage/npz/serialize.py b/python_app/storage/npz/serialize.py index b9f4bd0..a0097bb 100644 --- a/python_app/storage/npz/serialize.py +++ b/python_app/storage/npz/serialize.py @@ -35,7 +35,7 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes if freq.size != s21.size: raise ValueError("Trace frequency and S21 sizes must match") - buffer.extend(struct.pack(" bytes: serialize_payload(buffer, payload) for block in collection.blocks: - buffer.extend(struct.pack(" traces_meta: list[dict[str, int | str]] = [] for trace in collection.traces: - tag = f"i{trace.combo.input_pos}_o{trace.combo.output_pos}" + tag = f"i{trace.combo.input}_o{trace.combo.output}" freq = np.asarray(trace.frequency_hz, dtype=np.float32) s11 = np.asarray(trace.s11, dtype=np.complex64) s21 = np.asarray(trace.s21, dtype=np.complex64) @@ -166,8 +166,8 @@ def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) -> np.save(collection_dir / f"{tag}_s21.npy", s21) traces_meta.append( { - "input": int(trace.combo.input_pos), - "output": int(trace.combo.output_pos), + "input": int(trace.combo.input), + "output": int(trace.combo.output), "points": int(freq.size), "freq_file": f"{tag}_freq.npy", "s11_file": f"{tag}_s11.npy", @@ -260,7 +260,7 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection]) blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = [] for block_index, block in enumerate(collection.blocks): - block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input_pos}_o{block.combo.output_pos}" + block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input}_o{block.combo.output}" block_dir.mkdir(parents=True, exist_ok=False) payload_meta: list[dict[str, int | str | float]] = [] @@ -325,8 +325,8 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection]) blocks_meta.append( { - "input": int(block.combo.input_pos), - "output": int(block.combo.output_pos), + "input": int(block.combo.input), + "output": int(block.combo.output), "payload_count": len(block.payloads), "dir": block_dir.name, "payloads": payload_meta, diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index b679179..7e5789c 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -48,7 +48,7 @@ class NpzStore(StoreApi): combo_records: list[dict[str, str | int]] = [] for trace in collection.traces: - suffix = f"i{trace.combo.input_pos}_o{trace.combo.output_pos}" + suffix = f"i{trace.combo.input}_o{trace.combo.output}" freq_key = f"freq_{suffix}" s11_key = f"s11_{suffix}" s21_key = f"s21_{suffix}" @@ -57,8 +57,8 @@ class NpzStore(StoreApi): payload[s21_key] = np.asarray(trace.s21, dtype=np.complex64) combo_records.append( { - "input": trace.combo.input_pos, - "output": trace.combo.output_pos, + "input": trace.combo.input, + "output": trace.combo.output, "freq_key": freq_key, "s11_key": s11_key, "s21_key": s21_key, @@ -94,7 +94,7 @@ class NpzStore(StoreApi): s21 = np.asarray(arrays[combo["s21_key"]], dtype=np.complex64) traces.append( TraceData( - combo=ComboKey(input_pos=int(combo["input"]), output_pos=int(combo["output"])), + combo=ComboKey(input=int(combo["input"]), output=int(combo["output"])), frequency_hz=freq, s11=s11, s21=s21, @@ -119,8 +119,8 @@ class NpzStore(StoreApi): def has_combo_coverage(self, kind: str, radar_key: str, set_name: str, combos: list[ComboKey]) -> bool: """Validate that named set covers all required switch combinations.""" collection = self.load_set(kind, radar_key, set_name) - existing = {(trace.combo.input_pos, trace.combo.output_pos) for trace in collection.traces} - required = {(combo.input_pos, combo.output_pos) for combo in combos} + existing = {(trace.combo.input, trace.combo.output) for trace in collection.traces} + required = {(combo.input, combo.output) for combo in combos} return required.issubset(existing) def export_set_bundle(self, kind: str, radar_key: str, set_name: str, output_path: Path) -> Path: @@ -295,7 +295,7 @@ class NpzStore(StoreApi): combos = sorted( { - (int(trace.combo.input_pos), int(trace.combo.output_pos)) + (int(trace.combo.input), int(trace.combo.output)) for collection in [*selected_raw, *selected_preprocessed] for trace in collection.traces } diff --git a/python_app/storage/npz/vna_history_json.py b/python_app/storage/npz/vna_history_json.py index d311ece..a147b3e 100644 --- a/python_app/storage/npz/vna_history_json.py +++ b/python_app/storage/npz/vna_history_json.py @@ -39,7 +39,7 @@ def _select_trace_samples(trace: TraceData, channel: str) -> np.ndarray: def _pick_trace(collection: SweepCollection, input_index: int, output_index: int) -> TraceData | None: for trace in collection.traces: - if int(trace.combo.input_pos) == int(input_index) and int(trace.combo.output_pos) == int(output_index): + if int(trace.combo.input) == int(input_index) and int(trace.combo.output) == int(output_index): return trace return None diff --git a/python_app/tests/test_kamil_adc_neutral_preprocess.py b/python_app/tests/test_kamil_adc_neutral_preprocess.py index cbc1c57..c965252 100644 --- a/python_app/tests/test_kamil_adc_neutral_preprocess.py +++ b/python_app/tests/test_kamil_adc_neutral_preprocess.py @@ -42,7 +42,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase): self.assertEqual(len(calibration.traces), 2) self.assertEqual(len(reference.traces), 2) self.assertEqual( - [(trace.combo.input_pos, trace.combo.output_pos) for trace in calibration.traces], + [(trace.combo.input, trace.combo.output) for trace in calibration.traces], [(0, 0), (1, 0)], ) for trace in calibration.traces: diff --git a/python_app/workflows/calibration_workflow.py b/python_app/workflows/calibration_workflow.py index b8c9c90..d1a824b 100644 --- a/python_app/workflows/calibration_workflow.py +++ b/python_app/workflows/calibration_workflow.py @@ -5,13 +5,18 @@ from __future__ import annotations from python_app.models.dataset_model import SweepCollection from python_app.models.run_config_model import RunConfigModel from python_app.storage.npz_store import NpzStore -from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession +from python_app.workflows.sequential_capture_workflow import ( + DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT, + SequentialCaptureSession, +) def capture_calibration_set( config: RunConfigModel, set_name: str, store: NpzStore, + *, + median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT, ) -> tuple[str, SweepCollection]: """Capture all switch combinations and persist them as calibration set.""" if config.is_multi_device: @@ -21,7 +26,12 @@ def capture_calibration_set( "and captured explicitly." ) - session = SequentialCaptureSession(config=config, kind="s21_calibration", set_name=set_name) + session = SequentialCaptureSession( + config=config, + kind="s21_calibration", + set_name=set_name, + median_sweep_count=median_sweep_count, + ) try: session.open() while not session.is_complete(): diff --git a/python_app/workflows/kamil_adc_neutral_preprocess.py b/python_app/workflows/kamil_adc_neutral_preprocess.py index 72efd24..a6015cc 100644 --- a/python_app/workflows/kamil_adc_neutral_preprocess.py +++ b/python_app/workflows/kamil_adc_neutral_preprocess.py @@ -62,7 +62,7 @@ def _neutral_collection( point_count = int(frequency_hz.size) traces.append( TraceData( - combo=ComboKey(input_pos=int(combo.input), output_pos=int(combo.output)), + combo=ComboKey(input=int(combo.input), output=int(combo.output)), frequency_hz=frequency_hz.copy(), s11=np.zeros(point_count, dtype=np.complex64), s21=np.full(point_count, s21_value, dtype=np.complex64), diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index b3f2e68..9bf29ae 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -18,6 +18,8 @@ from python_app.workflows.radar_config_variants import RadarConfigVariant from python_app.workflows.sequential_capture_workflow import ( MULTI_DEVICE_MANUAL_CAPTURE_KINDS, SequentialCaptureState, + combine_collections_via_median, + combine_traces_via_median, select_trace_for_combo, ) @@ -55,6 +57,7 @@ class MultiRadarSequentialCaptureSession: kind: str, set_name: str, radar_variants: list[RadarConfigVariant], + median_sweep_count: int = 1, ) -> 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"}: @@ -63,11 +66,14 @@ class MultiRadarSequentialCaptureSession: raise RuntimeError("Set name is required") if not radar_variants: raise RuntimeError("At least one radar variant is required") + if int(median_sweep_count) < 1: + raise RuntimeError("median_sweep_count must be >= 1") self._base_config = base_config self._kind = kind self._set_name = set_name self._radar_variants = list(radar_variants) + self._median_sweep_count = int(median_sweep_count) self._is_multi_device = base_config.is_multi_device self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS self._combos = ( @@ -196,16 +202,23 @@ class MultiRadarSequentialCaptureSession: 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) - collection = self._radar.acquire_collection(collection_id=1) - if not collection.traces: - raise RuntimeError(f"Multi-device variant {variant.display_name} returned no traces") + collections: list[SweepCollection] = [] + for _ in range(self._median_sweep_count): + collection = self._radar.acquire_collection(collection_id=1) + if not collection.traces: + raise RuntimeError( + f"Multi-device variant {variant.display_name} returned no traces" + ) + collections.append(collection) if self._manual_multi_device_capture: - trace = select_trace_for_combo(collection, combo) + per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections] + trace = combine_traces_via_median(per_sweep_traces) pending_traces_by_radar_key[variant.radar_key] = [trace] display_traces.append(trace) else: - pending_traces_by_radar_key[variant.radar_key] = list(collection.traces) - display_traces.append(collection.traces[-1]) + combined_collection = combine_collections_via_median(collections) + pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces) + display_traces.append(combined_collection.traces[-1]) variant_labels.append(variant.display_name) else: assert self._input_switch is not None @@ -219,13 +232,18 @@ class MultiRadarSequentialCaptureSession: 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), - ) + sweep_traces: list[TraceData] = [] + for _ in range(self._median_sweep_count): + sweep = self._radar.acquire() + sweep_traces.append( + TraceData( + combo=ComboKey(input=combo.input, output=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), + ) + ) + trace = combine_traces_via_median(sweep_traces) pending_traces_by_radar_key[variant.radar_key] = [trace] display_traces.append(trace) variant_labels.append(variant.display_name) diff --git a/python_app/workflows/reference_workflow.py b/python_app/workflows/reference_workflow.py index 53c8b2c..6785aee 100644 --- a/python_app/workflows/reference_workflow.py +++ b/python_app/workflows/reference_workflow.py @@ -5,16 +5,26 @@ from __future__ import annotations from python_app.models.dataset_model import SweepCollection from python_app.models.run_config_model import RunConfigModel from python_app.storage.npz_store import NpzStore -from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession +from python_app.workflows.sequential_capture_workflow import ( + DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT, + SequentialCaptureSession, +) def capture_reference_set( config: RunConfigModel, set_name: str, store: NpzStore, + *, + median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT, ) -> tuple[str, SweepCollection]: """Capture all switch combinations and persist them as reference set.""" - session = SequentialCaptureSession(config=config, kind="s21_reference", set_name=set_name) + session = SequentialCaptureSession( + config=config, + kind="s21_reference", + set_name=set_name, + median_sweep_count=median_sweep_count, + ) try: session.open() while not session.is_complete(): diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 3d6caf4..d69e2af 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -16,6 +16,7 @@ from python_app.models.run_config_model import ComboModel, RunConfigModel from python_app.storage.npz_store import NpzStore, radar_key_from_config MULTI_DEVICE_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"}) +DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5 @dataclass(slots=True) @@ -36,16 +37,26 @@ class SequentialCaptureState: class SequentialCaptureSession: """Manage hardware and switch stepping for full combo capture sequence.""" - def __init__(self, config: RunConfigModel, kind: str, set_name: str) -> None: + def __init__( + self, + config: RunConfigModel, + kind: str, + set_name: str, + *, + median_sweep_count: int = 1, + ) -> None: """Create capture session for one preprocess asset set.""" 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 int(median_sweep_count) < 1: + raise RuntimeError("median_sweep_count must be >= 1") self._config = config self._kind = kind self._set_name = set_name + self._median_sweep_count = int(median_sweep_count) self._is_multi_device = config.is_multi_device self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS self._combos = ( @@ -154,18 +165,23 @@ class SequentialCaptureSession: raise RuntimeError("Capture session is already complete") if self._is_multi_device: - collection = self._radar.acquire_collection(collection_id=1) - if not collection.traces: - raise RuntimeError("Multi-device capture returned no traces") + collections: list[SweepCollection] = [] + for _ in range(self._median_sweep_count): + collection = self._radar.acquire_collection(collection_id=1) + if not collection.traces: + raise RuntimeError("Multi-device capture returned no traces") + collections.append(collection) if self._manual_multi_device_capture: - trace = select_trace_for_combo(collection, combo) + per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections] + trace = combine_traces_via_median(per_sweep_traces) self._traces.append(trace) self._next_index += 1 return trace - self._traces.extend(collection.traces) + combined_collection = combine_collections_via_median(collections) + self._traces.extend(combined_collection.traces) self._next_index = len(self._combos) - return collection.traces[-1] + return combined_collection.traces[-1] assert self._input_switch is not None assert self._output_switch is not None @@ -174,13 +190,18 @@ class SequentialCaptureSession: if self._config.runtime.settling_ms > 0: time.sleep(self._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), - ) + sweep_traces: list[TraceData] = [] + for _ in range(self._median_sweep_count): + sweep = self._radar.acquire() + sweep_traces.append( + TraceData( + combo=ComboKey(input=combo.input, output=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), + ) + ) + trace = combine_traces_via_median(sweep_traces) self._traces.append(trace) self._next_index += 1 return trace @@ -203,8 +224,8 @@ class SequentialCaptureSession: 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) + int(removed_trace.combo.input) != int(expected_combo.input) + or int(removed_trace.combo.output) != int(expected_combo.output) ): raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo") self._next_index -= 1 @@ -259,8 +280,94 @@ def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> Tr """Return the trace matching a virtual combo from a full multi-device capture.""" for trace in collection.traces: if ( - int(trace.combo.input_pos) == int(combo.input) - and int(trace.combo.output_pos) == int(combo.output) + int(trace.combo.input) == int(combo.input) + and int(trace.combo.output) == int(combo.output) ): return trace raise RuntimeError(f"Multi-device capture is missing trace for input={combo.input}, output={combo.output}") + + +def combine_traces_via_median(traces: list[TraceData]) -> TraceData: + """Return one trace whose S11/S21 are the per-point median of the inputs. + + A single-element input is returned unchanged. With multiple inputs, the real + and imaginary parts of each complex sample are medianed independently so a + single bad sweep (e.g. an outlier with random phase) is rejected without + corrupting the saved calibration trace. + """ + if not traces: + raise RuntimeError("Cannot combine empty sweep list") + if len(traces) == 1: + return traces[0] + + first = traces[0] + combo = first.combo + point_count = first.frequency_hz.size + for index, trace in enumerate(traces[1:], start=1): + if ( + int(trace.combo.input) != int(combo.input) + or int(trace.combo.output) != int(combo.output) + ): + raise RuntimeError( + f"Median combine combo mismatch at sweep {index}: " + f"({trace.combo.input},{trace.combo.output}) " + f"vs ({combo.input},{combo.output})" + ) + if trace.frequency_hz.size != point_count: + raise RuntimeError( + f"Median combine point-count mismatch at sweep {index}: " + f"{trace.frequency_hz.size} vs {point_count}" + ) + + s11_stack = np.stack([np.asarray(t.s11, dtype=np.complex64) for t in traces], axis=0) + s21_stack = np.stack([np.asarray(t.s21, dtype=np.complex64) for t in traces], axis=0) + s11_median = ( + np.median(s11_stack.real, axis=0) + 1j * np.median(s11_stack.imag, axis=0) + ).astype(np.complex64) + s21_median = ( + np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0) + ).astype(np.complex64) + return TraceData( + combo=ComboKey(input=int(combo.input), output=int(combo.output)), + frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32), + s11=s11_median, + s21=s21_median, + ) + + +def combine_collections_via_median(collections: list[SweepCollection]) -> SweepCollection: + """Combine multi-device matrix captures into one collection with per-combo medians.""" + if not collections: + raise RuntimeError("Cannot combine empty collection list") + if len(collections) == 1: + return collections[0] + + reference = collections[0] + expected_combos = [(trace.combo.input, trace.combo.output) for trace in reference.traces] + medianed_traces: list[TraceData] = [] + for combo_index, (input_pos, output_pos) in enumerate(expected_combos): + per_sweep_traces: list[TraceData] = [] + for collection_index, collection in enumerate(collections): + if combo_index >= len(collection.traces): + raise RuntimeError( + f"Median collections have mismatched trace counts at sweep {collection_index}" + ) + trace = collection.traces[combo_index] + if ( + int(trace.combo.input) != int(input_pos) + or int(trace.combo.output) != int(output_pos) + ): + raise RuntimeError( + f"Median collections trace order mismatch at sweep {collection_index}, " + f"combo index {combo_index}" + ) + per_sweep_traces.append(trace) + medianed_traces.append(combine_traces_via_median(per_sweep_traces)) + + return SweepCollection( + collection_id=int(reference.collection_id), + monotonic_ns=int(reference.monotonic_ns), + traces=medianed_traces, + capture_start_ns=int(reference.capture_start_ns), + capture_end_ns=int(reference.capture_end_ns), + )