added median sweep and fixed multi device issue

This commit is contained in:
Ayzen
2026-05-20 16:52:06 +03:00
parent 0da8b1283c
commit 1c544aa582
29 changed files with 444 additions and 126 deletions
+3
View File
@@ -127,6 +127,9 @@ class AppWindow(
self._preprocess_set_name = str(self._gui_defaults.preprocess_dialog.set_name) 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_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_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_variants: list[RadarConfigVariant] = []
self._preprocess_radar_scan_summary = RadarConfigScanSummary( self._preprocess_radar_scan_summary = RadarConfigScanSummary(
directory_path=self._preprocess_radar_config_dir, directory_path=self._preprocess_radar_config_dir,
@@ -470,6 +470,7 @@ class AppWindowConfigProfileIOMixin:
self._preprocess_set_name = str(gui_state.preprocess_dialog.set_name) 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_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_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._apply_history_limit_from_config(config)
self._gpr_geometry_signature = None self._gpr_geometry_signature = None
self._gpr_selected_geometry = 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_set_name(self._preprocess_set_name)
self._preprocess_dialog.set_radar_config_dir(self._preprocess_radar_config_dir) 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_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._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
self._apply_initial_radar_limits() self._apply_initial_radar_limits()
@@ -273,6 +273,12 @@ class AppWindowConfigStateBuildersMixin:
self._preprocess_use_all_radar_configs = self._preprocess_dialog.use_all_radar_configs() self._preprocess_use_all_radar_configs = self._preprocess_dialog.use_all_radar_configs()
return bool(self._preprocess_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: def _build_gui_state(self) -> GuiStateModel:
"""Build GUI-only persistent state from current widget values.""" """Build GUI-only persistent state from current widget values."""
return GuiStateModel( return GuiStateModel(
@@ -367,6 +373,7 @@ class AppWindowConfigStateBuildersMixin:
set_name=self._current_preprocess_set_name(), set_name=self._current_preprocess_set_name(),
radar_config_dir=self._current_preprocess_radar_config_dir(), radar_config_dir=self._current_preprocess_radar_config_dir(),
use_all_radar_configs=self._current_preprocess_use_all_radar_configs(), use_all_radar_configs=self._current_preprocess_use_all_radar_configs(),
median_sweep_count=self._current_preprocess_median_sweep_count(),
), ),
) )
@@ -70,7 +70,7 @@ class AppWindowPipelineMixin:
+ ", ".join(missing_assets) + ", ".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) active_preprocess_keys = runtime_preprocess_asset_keys(config)
preprocess_summary = "; ".join( preprocess_summary = "; ".join(
f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}" f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}"
@@ -95,7 +95,7 @@ def rebuild_bscan_history_from_results(
for collection in result_tail: for collection in result_tail:
for block in collection.blocks: 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: for payload in block.payloads:
if payload.kind != 1 or payload.processing_name != "bscan": if payload.kind != 1 or payload.processing_name != "bscan":
continue continue
@@ -152,7 +152,7 @@ class AppWindowTracePlotMixin:
x_min = np.inf x_min = np.inf
x_max = -np.inf x_max = -np.inf
for block in collection.blocks: 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: if combo_filter is not None and combo_key not in combo_filter:
continue continue
if combo_key not in combo_colors: if combo_key not in combo_colors:
@@ -384,7 +384,7 @@ class AppWindowTracePlotMixin:
) )
magnitude_plot.addItem(magnitude_curve) magnitude_plot.addItem(magnitude_curve)
self._trace_magnitude_curves[ 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 ] = magnitude_curve
if show_phase: if show_phase:
@@ -396,7 +396,7 @@ class AppWindowTracePlotMixin:
) )
phase_plot.addItem(phase_curve) phase_plot.addItem(phase_curve)
self._trace_phase_curves[ 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_curve
phase_plot.setYRange(-180.0, 180.0, padding=0.02) phase_plot.setYRange(-180.0, 180.0, padding=0.02)
@@ -43,7 +43,7 @@ class AppWindowPreprocessMixin:
for index, trace in enumerate(session.captured_traces(), start=1): for index, trace in enumerate(session.captured_traces(), start=1):
entries.append( entries.append(
f"{display_name}: {index}/{total_count} | " 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 return entries
@@ -134,7 +134,12 @@ class AppWindowPreprocessMixin:
f"set={TMP_REFERENCE_SET_NAME}, radar_key={radar_key}" 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_sets["s21_reference"] = TMP_REFERENCE_SET_NAME
self._selected_preprocess_radar_key = radar_key self._selected_preprocess_radar_key = radar_key
self._processor_run_signature = None self._processor_run_signature = None
@@ -176,11 +181,13 @@ class AppWindowPreprocessMixin:
dialog.set_set_name(self._preprocess_set_name) dialog.set_set_name(self._preprocess_set_name)
dialog.set_radar_config_dir(self._preprocess_radar_config_dir) dialog.set_radar_config_dir(self._preprocess_radar_config_dir)
dialog.set_use_all_radar_configs(self._preprocess_use_all_radar_configs) 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.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
dialog.refresh_requested.connect(self._refresh_sets) dialog.refresh_requested.connect(self._refresh_sets)
dialog.selection_changed.connect(self._on_preprocess_selection_changed) dialog.selection_changed.connect(self._on_preprocess_selection_changed)
dialog.radar_config_dir_changed.connect(self._on_preprocess_radar_config_inputs_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.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.start_sequence_requested.connect(self._start_capture_sequence)
dialog.capture_next_requested.connect(self._capture_next_combo) dialog.capture_next_requested.connect(self._capture_next_combo)
dialog.capture_all_requested.connect(self._capture_all_remaining) 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._preprocess_use_all_radar_configs = dialog.use_all_radar_configs()
self._refresh_sets() 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: def _on_preprocess_selection_changed(self) -> None:
"""Persist selected preprocessing set names from dialog.""" """Persist selected preprocessing set names from dialog."""
dialog = self._ensure_preprocess_dialog() dialog = self._ensure_preprocess_dialog()
@@ -314,17 +326,31 @@ class AppWindowPreprocessMixin:
try: try:
config = self._build_config() config = self._build_config()
display_name = preprocess_asset_display_name(kind) 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: 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 = ( radar_summary = (
f"radar_variants={session.radar_variant_count()}, " 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: 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 = ( radar_summary = (
f"radar_key={self._radar_key(config)}, " 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() session.open()
@@ -431,6 +457,7 @@ class AppWindowPreprocessMixin:
config, config,
kind: str, kind: str,
set_name: str, set_name: str,
median_sweep_count: int,
) -> SequentialCaptureSession: ) -> SequentialCaptureSession:
"""Validate and create the existing single-radar capture session.""" """Validate and create the existing single-radar capture session."""
radar_key = self._radar_key(config) radar_key = self._radar_key(config)
@@ -438,7 +465,12 @@ class AppWindowPreprocessMixin:
display_name = preprocess_asset_display_name(kind) display_name = preprocess_asset_display_name(kind)
if set_name in existing_sets: if set_name in existing_sets:
raise RuntimeError(f"Set '{set_name}' already exists for {display_name} and cannot be overwritten") 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( def _build_multi_radar_capture_session(
self, self,
@@ -446,6 +478,7 @@ class AppWindowPreprocessMixin:
config, config,
kind: str, kind: str,
set_name: str, set_name: str,
median_sweep_count: int,
) -> MultiRadarSequentialCaptureSession: ) -> MultiRadarSequentialCaptureSession:
"""Validate and create the multi-radar capture session.""" """Validate and create the multi-radar capture session."""
self._refresh_preprocess_radar_variants() self._refresh_preprocess_radar_variants()
@@ -469,6 +502,7 @@ class AppWindowPreprocessMixin:
kind=kind, kind=kind,
set_name=set_name, set_name=set_name,
radar_variants=self._preprocess_radar_variants, radar_variants=self._preprocess_radar_variants,
median_sweep_count=median_sweep_count,
) )
def _capture_next_combo(self) -> None: def _capture_next_combo(self) -> None:
@@ -554,8 +588,8 @@ class AppWindowPreprocessMixin:
extra_details = f" | radar_configs={len(capture_result.traces)}" extra_details = f" | radar_configs={len(capture_result.traces)}"
else: else:
trace = capture_result trace = capture_result
input_pos = trace.combo.input_pos input_pos = trace.combo.input
output_pos = trace.combo.output_pos output_pos = trace.combo.output
extra_details = "" extra_details = ""
dialog.append_capture_log_entry( dialog.append_capture_log_entry(
@@ -600,8 +634,8 @@ class AppWindowPreprocessMixin:
removed_output = removed_capture.combo.output removed_output = removed_capture.combo.output
extra_details = f" | radar_configs={len(removed_capture.traces)}" extra_details = f" | radar_configs={len(removed_capture.traces)}"
else: else:
removed_input = removed_capture.combo.input_pos removed_input = removed_capture.combo.input
removed_output = removed_capture.combo.output_pos removed_output = removed_capture.combo.output
extra_details = "" extra_details = ""
dialog.set_capture_log_entries(self._capture_log_entries_for_session(session)) dialog.set_capture_log_entries(self._capture_log_entries_for_session(session))
+80 -26
View File
@@ -17,6 +17,7 @@ from PyQt6.QtWidgets import (
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSpinBox,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -38,6 +39,7 @@ class PreprocessDialog(QDialog):
selection_changed = pyqtSignal() selection_changed = pyqtSignal()
radar_config_dir_changed = pyqtSignal() radar_config_dir_changed = pyqtSignal()
multi_radar_option_changed = pyqtSignal() multi_radar_option_changed = pyqtSignal()
median_sweep_count_changed = pyqtSignal()
start_sequence_requested = pyqtSignal(str) start_sequence_requested = pyqtSignal(str)
capture_next_requested = pyqtSignal() capture_next_requested = pyqtSignal()
capture_all_requested = pyqtSignal() capture_all_requested = pyqtSignal()
@@ -50,9 +52,10 @@ class PreprocessDialog(QDialog):
"""Initialize window metadata and compose dialog UI.""" """Initialize window metadata and compose dialog UI."""
super().__init__(parent) super().__init__(parent)
self._set_combos: dict[str, QComboBox] = {} 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_placeholder: QLabel | None = None
self._preview_host_layout: QVBoxLayout | None = None self._preview_host_layout: QHBoxLayout | None = None
self._preview_plot_unavailable = False self._preview_plot_unavailable = False
self._init_window() self._init_window()
self._build_ui() self._build_ui()
@@ -102,11 +105,28 @@ class PreprocessDialog(QDialog):
header_row.addWidget(self._kamil_adc_neutral_sets_button) header_row.addWidget(self._kamil_adc_neutral_sets_button)
header_row.addWidget(refresh_button) header_row.addWidget(refresh_button)
layout.addLayout(header_row) layout.addLayout(header_row)
layout.addLayout(self._build_median_sweep_row(group))
layout.addWidget(self._build_radar_config_group(group)) layout.addWidget(self._build_radar_config_group(group))
layout.addWidget(self._build_selector_group("S21", VISIBLE_PREPROCESS_ASSET_KEYS, group)) layout.addWidget(self._build_selector_group("S21", VISIBLE_PREPROCESS_ASSET_KEYS, group))
return 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: def _build_radar_config_group(self, parent: QGroupBox) -> QGroupBox:
"""Build radar-config directory controls for multi-radar preprocessing.""" """Build radar-config directory controls for multi-radar preprocessing."""
group = QGroupBox("Radar Config Variants", parent) group = QGroupBox("Radar Config Variants", parent)
@@ -221,11 +241,16 @@ class PreprocessDialog(QDialog):
root_layout.addWidget(self._status_label) root_layout.addWidget(self._status_label)
def _build_preview_plot(self, root_layout: QVBoxLayout) -> None: 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) host = QWidget(self)
layout = QVBoxLayout(host) layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(6)
placeholder = QLabel("Preview will appear after the first successful capture.", host) placeholder = QLabel("Preview will appear after the first successful capture.", host)
placeholder.setWordWrap(True) placeholder.setWordWrap(True)
@@ -260,6 +285,15 @@ class PreprocessDialog(QDialog):
"""Update multi-radar capture checkbox state.""" """Update multi-radar capture checkbox state."""
self._use_all_radar_configs_checkbox.setChecked(bool(enabled)) 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( def set_radar_config_summary(
self, self,
*, *,
@@ -385,40 +419,51 @@ class PreprocessDialog(QDialog):
self._kamil_adc_neutral_sets_button.setEnabled(bool(visible)) self._kamil_adc_neutral_sets_button.setEnabled(bool(visible))
def reset_preview(self) -> None: def reset_preview(self) -> None:
"""Clear preview surface and restore default empty-state text when possible.""" """Clear preview surfaces and restore default empty-state text when possible."""
if self._preview_plot is not None: if self._amplitude_plot is not None:
self._preview_plot.clear() self._amplitude_plot.clear()
self._preview_plot.setTitle("") if self._phase_plot is not None:
self._phase_plot.clear()
if self._preview_placeholder is not None: if self._preview_placeholder is not None:
self._preview_placeholder.setText("Preview will appear after the first successful capture.") self._preview_placeholder.setText("Preview will appear after the first successful capture.")
def draw_last_trace(self, trace: TraceData, title: str, *, channel: str) -> None: 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 samples = trace.s11 if channel == "s11" else trace.s21
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
if self._ensure_preview_plot(): phase_deg = np.degrees(np.angle(samples))
assert self._preview_plot is not None
self._preview_plot.clear() if self._ensure_preview_plots():
self._preview_plot.plot( 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, trace.frequency_hz,
magnitude_db, magnitude_db,
pen=pg.mkPen("#4cc9f0", width=1.8), 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: elif self._preview_placeholder is not None:
self._preview_placeholder.setText( self._preview_placeholder.setText(
f"{title}\n" 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"points={trace.frequency_hz.size}\n"
f"Preview plot is unavailable on this PyQtGraph/PyQt6 build." f"Preview plot is unavailable on this PyQtGraph/PyQt6 build."
) )
combo = trace.combo combo = trace.combo
self._status_label.setText( 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: def _ensure_preview_plots(self) -> bool:
"""Create preview plot lazily and keep a text fallback when unavailable.""" """Create the amplitude+phase plot pair lazily on first successful capture."""
if self._preview_plot is not None: if self._amplitude_plot is not None and self._phase_plot is not None:
return True return True
if self._preview_plot_unavailable: if self._preview_plot_unavailable:
return False return False
@@ -426,11 +471,8 @@ class PreprocessDialog(QDialog):
return False return False
try: try:
plot = pg.PlotWidget(background="#101418", enableMenu=False) amplitude_plot = self._build_preview_axis("Magnitude", "dB")
plot.showGrid(x=True, y=True, alpha=0.2) phase_plot = self._build_preview_axis("Phase", "deg")
plot.setLabel("bottom", "Frequency", units="Hz")
plot.setLabel("left", "Magnitude", units="dB")
plot.setMinimumHeight(320)
except Exception: except Exception:
self._preview_plot_unavailable = True self._preview_plot_unavailable = True
return False return False
@@ -440,10 +482,22 @@ class PreprocessDialog(QDialog):
self._preview_placeholder.deleteLater() self._preview_placeholder.deleteLater()
self._preview_placeholder = None self._preview_placeholder = None
self._preview_plot = plot self._amplitude_plot = amplitude_plot
self._preview_host_layout.addWidget(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 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: def _emit_selection_changed(self) -> None:
"""Emit current selection snapshot change.""" """Emit current selection snapshot change."""
self.selection_changed.emit() self.selection_changed.emit()
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from dataclasses import replace from dataclasses import replace
from typing import Optional from typing import Optional
import threading
import time import time
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import ( 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), master_stimulus_ports: Sequence[int] = (1, 2),
) -> None: ) -> 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: if self._is_closed:
raise RuntimeError("Controller is already closed") raise RuntimeError("Controller is already closed")
stimulus_ports = self._normalize_master_stimulus_ports(master_stimulus_ports) stimulus_ports = self._normalize_master_stimulus_ports(master_stimulus_ports)
@@ -93,12 +104,6 @@ class MultiDeviceVnaController:
if not self._reference_configuration_applied: if not self._reference_configuration_applied:
self._configure_reference_clocks() 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() self._drain_all_received_packets()
if ( if (
@@ -111,6 +116,10 @@ class MultiDeviceVnaController:
if self._sweep_is_running: if self._sweep_is_running:
self._send_idle_to_all_devices() self._send_idle_to_all_devices()
time.sleep(self._reconfigure_delay_s) 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( self._configure_sweep_on_all_devices(
sweep_configuration, sweep_configuration,
@@ -192,12 +201,16 @@ class MultiDeviceVnaController:
pass pass
def _send_idle_to_all_devices(self) -> None: 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: for device_connection in self._all_devices:
self._try_send_command_without_failing( self._try_send_command_without_failing(
device_connection, device_connection,
PacketType.SET_IDLE, PacketType.SET_IDLE,
timeout_seconds=3.0, timeout_seconds=1.0,
retry_count=1, retry_count=0,
) )
self._sweep_is_running = False self._sweep_is_running = False
@@ -245,8 +258,30 @@ class MultiDeviceVnaController:
self._sweep_is_running = True self._sweep_is_running = True
def _drain_all_received_packets(self) -> None: def _drain_all_received_packets(self) -> None:
# 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: for device_connection in self._all_devices:
device_connection.drain_received_packets() 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 @staticmethod
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]: def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
@@ -69,6 +69,13 @@ def collect_complete_running_sweep_cycles(
else: else:
datapoint_timeout_seconds = max(0.5, float(datapoint_timeout_seconds)) 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( def collect_datapoints_from_device(
device_connection: LibreVnaUsbBulkConnection, device_connection: LibreVnaUsbBulkConnection,
handle_datapoint: Callable[[ParsedVnaDatapoint], bool], handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
@@ -76,12 +83,15 @@ def collect_complete_running_sweep_cycles(
datapoints_received = 0 datapoints_received = 0
expected_datapoint_count = cycle_count * point_count expected_datapoint_count = cycle_count * point_count
last_datapoint_timestamp = time.monotonic() last_datapoint_timestamp = time.monotonic()
collection_loop_start = last_datapoint_timestamp
has_consumed_any_datapoint = False
while datapoints_received < expected_datapoint_count: while datapoints_received < expected_datapoint_count:
if stop_collection_requested.is_set(): if stop_collection_requested.is_set():
return 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: if remaining_timeout_seconds <= 0:
collection_errors.append( collection_errors.append(
TimeoutError( TimeoutError(
@@ -93,6 +103,17 @@ def collect_complete_running_sweep_cycles(
stop_collection_requested.set() stop_collection_requested.set()
return 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: try:
packet_type, payload = device_connection.receive_packet( packet_type, payload = device_connection.receive_packet(
timeout_seconds=min(1.0, remaining_timeout_seconds) timeout_seconds=min(1.0, remaining_timeout_seconds)
@@ -118,35 +139,41 @@ def collect_complete_running_sweep_cycles(
last_datapoint_timestamp = time.monotonic() last_datapoint_timestamp = time.monotonic()
datapoint_was_consumed = handle_datapoint(parsed_datapoint) datapoint_was_consumed = handle_datapoint(parsed_datapoint)
if datapoint_was_consumed: if datapoint_was_consumed:
has_consumed_any_datapoint = True
datapoint_counts_by_device_serial[device_connection.serial_number] += 1 datapoint_counts_by_device_serial[device_connection.serial_number] += 1
datapoints_received += 1 datapoints_received += 1
def build_cycle_tracking_handler( def build_cycle_tracking_handler(
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None], cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
) -> Callable[[ParsedVnaDatapoint], bool]: ) -> 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 = { cycle_tracking_state = {
"current_cycle_index": 0, "current_cycle_index": 0,
"previous_point_index": -1, "previous_point_index": -1,
"has_seen_cycle_start": False, "synchronized": False,
} }
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool: def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
current_point_index = parsed_datapoint.point_index 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: if current_point_index != 0:
cycle_tracking_state["previous_point_index"] = current_point_index
return False 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_tracking_state["previous_point_index"] = current_point_index
cycle_aware_handler(parsed_datapoint, 0) cycle_aware_handler(parsed_datapoint, 0)
return True return True
if ( if current_point_index < cycle_tracking_state["previous_point_index"]:
cycle_tracking_state["previous_point_index"] >= 0
and current_point_index < cycle_tracking_state["previous_point_index"]
):
cycle_tracking_state["current_cycle_index"] += 1 cycle_tracking_state["current_cycle_index"] += 1
cycle_tracking_state["previous_point_index"] = current_point_index cycle_tracking_state["previous_point_index"] = current_point_index
current_cycle_index = cycle_tracking_state["current_cycle_index"] current_cycle_index = cycle_tracking_state["current_cycle_index"]
if current_cycle_index >= cycle_count: if current_cycle_index >= cycle_count:
@@ -163,7 +163,7 @@ class MultiDeviceLibreVnaService:
for input_pos, s_parameter_name in enumerate(_INPUT_S_PARAMETERS_BY_OUTPUT[output_pos]): for input_pos, s_parameter_name in enumerate(_INPUT_S_PARAMETERS_BY_OUTPUT[output_pos]):
traces.append( traces.append(
TraceData( TraceData(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos), combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequencies, frequency_hz=frequencies,
s11=reflection, s11=reflection,
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name), 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) s21 = (gain * np.cos(phase) + 1j * gain * np.sin(phase)).astype(np.complex64)
traces.append( traces.append(
TraceData( TraceData(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos), combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequencies, frequency_hz=frequencies,
s11=s11, s11=s11,
s21=s21, s21=s21,
+2 -2
View File
@@ -26,8 +26,8 @@ def _empty_f32_matrix() -> np.ndarray:
class ComboKey: class ComboKey:
"""Switch combination key: input position + output position.""" """Switch combination key: input position + output position."""
input_pos: int input: int
output_pos: int output: int
@dataclass(slots=True) @dataclass(slots=True)
+10
View File
@@ -471,6 +471,15 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.preprocess_dialog.use_all_radar_configs, gui.preprocess_dialog.use_all_radar_configs,
"gui.preprocess_dialog", "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 profile.gui = gui
@@ -564,6 +573,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"set_name": gui.preprocess_dialog.set_name, "set_name": gui.preprocess_dialog.set_name,
"radar_config_dir": gui.preprocess_dialog.radar_config_dir, "radar_config_dir": gui.preprocess_dialog.radar_config_dir,
"use_all_radar_configs": gui.preprocess_dialog.use_all_radar_configs, "use_all_radar_configs": gui.preprocess_dialog.use_all_radar_configs,
"median_sweep_count": gui.preprocess_dialog.median_sweep_count,
}, },
} }
return payload return payload
+1
View File
@@ -127,6 +127,7 @@ class GuiPreprocessDialogStateModel:
set_name: str = "set_001" set_name: str = "set_001"
radar_config_dir: str = "" radar_config_dir: str = ""
use_all_radar_configs: bool = False use_all_radar_configs: bool = False
median_sweep_count: int = 5
@dataclass(slots=True) @dataclass(slots=True)
+2 -2
View File
@@ -47,7 +47,7 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect
traces.append( traces.append(
TraceData( TraceData(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos), combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=freq, frequency_hz=freq,
s11=s11, s11=s11,
s21=s21, s21=s21,
@@ -155,7 +155,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
blocks.append( blocks.append(
ResultBlock( ResultBlock(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos), combo=ComboKey(input=input_pos, output=output_pos),
payloads=payloads, payloads=payloads,
) )
) )
@@ -124,7 +124,7 @@ def _load_legacy_collection(meta_path: Path, npz_path: Path, *, target_kind: str
traces.append( traces.append(
TraceData( TraceData(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos), combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequency_hz, frequency_hz=frequency_hz,
s11=s11, s11=s11,
s21=s21, s21=s21,
@@ -238,7 +238,7 @@ class RawOrchestratorViewer(QMainWindow):
for idx, trace in enumerate(collection.traces): for idx, trace in enumerate(collection.traces):
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12)) 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( self._plot.plot(
trace.frequency_hz, trace.frequency_hz,
magnitude_db, magnitude_db,
+1 -1
View File
@@ -79,7 +79,7 @@ def main() -> int:
sweep = radar.acquire() sweep = radar.acquire()
traces.append( traces.append(
TraceData( 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), frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64), s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64), s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
+1 -1
View File
@@ -74,7 +74,7 @@ def build_synthetic_collection(
traces.append( traces.append(
TraceData( TraceData(
combo=ComboKey(input_pos=combo.input, output_pos=combo.output), combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=frequency_hz, frequency_hz=frequency_hz,
s11=s11, s11=s11,
s21=s21, s21=s21,
+2 -2
View File
@@ -35,7 +35,7 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
if freq.size != s21.size: if freq.size != s21.size:
raise ValueError("Trace frequency and S21 sizes must match") raise ValueError("Trace frequency and S21 sizes must match")
buffer.extend(struct.pack("<III", trace.combo.input_pos, trace.combo.output_pos, int(freq.size))) buffer.extend(struct.pack("<III", trace.combo.input, trace.combo.output, int(freq.size)))
buffer.extend(freq.astype("<f4", copy=False).tobytes()) buffer.extend(freq.astype("<f4", copy=False).tobytes())
_write_interleaved_complex(buffer, s11) _write_interleaved_complex(buffer, s11)
@@ -122,7 +122,7 @@ def serialize_result_collection(collection: ResultCollection) -> bytes:
serialize_payload(buffer, payload) serialize_payload(buffer, payload)
for block in collection.blocks: for block in collection.blocks:
buffer.extend(struct.pack("<II", block.combo.input_pos, block.combo.output_pos)) buffer.extend(struct.pack("<II", block.combo.input, block.combo.output))
buffer.extend(struct.pack("<I", len(block.payloads))) buffer.extend(struct.pack("<I", len(block.payloads)))
for payload in block.payloads: for payload in block.payloads:
+6 -6
View File
@@ -157,7 +157,7 @@ def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) ->
traces_meta: list[dict[str, int | str]] = [] traces_meta: list[dict[str, int | str]] = []
for trace in collection.traces: 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) freq = np.asarray(trace.frequency_hz, dtype=np.float32)
s11 = np.asarray(trace.s11, dtype=np.complex64) s11 = np.asarray(trace.s11, dtype=np.complex64)
s21 = np.asarray(trace.s21, 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) np.save(collection_dir / f"{tag}_s21.npy", s21)
traces_meta.append( traces_meta.append(
{ {
"input": int(trace.combo.input_pos), "input": int(trace.combo.input),
"output": int(trace.combo.output_pos), "output": int(trace.combo.output),
"points": int(freq.size), "points": int(freq.size),
"freq_file": f"{tag}_freq.npy", "freq_file": f"{tag}_freq.npy",
"s11_file": f"{tag}_s11.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]]]] = [] blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
for block_index, block in enumerate(collection.blocks): 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) block_dir.mkdir(parents=True, exist_ok=False)
payload_meta: list[dict[str, int | str | float]] = [] 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( blocks_meta.append(
{ {
"input": int(block.combo.input_pos), "input": int(block.combo.input),
"output": int(block.combo.output_pos), "output": int(block.combo.output),
"payload_count": len(block.payloads), "payload_count": len(block.payloads),
"dir": block_dir.name, "dir": block_dir.name,
"payloads": payload_meta, "payloads": payload_meta,
+7 -7
View File
@@ -48,7 +48,7 @@ class NpzStore(StoreApi):
combo_records: list[dict[str, str | int]] = [] combo_records: list[dict[str, str | int]] = []
for trace in collection.traces: 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}" freq_key = f"freq_{suffix}"
s11_key = f"s11_{suffix}" s11_key = f"s11_{suffix}"
s21_key = f"s21_{suffix}" s21_key = f"s21_{suffix}"
@@ -57,8 +57,8 @@ class NpzStore(StoreApi):
payload[s21_key] = np.asarray(trace.s21, dtype=np.complex64) payload[s21_key] = np.asarray(trace.s21, dtype=np.complex64)
combo_records.append( combo_records.append(
{ {
"input": trace.combo.input_pos, "input": trace.combo.input,
"output": trace.combo.output_pos, "output": trace.combo.output,
"freq_key": freq_key, "freq_key": freq_key,
"s11_key": s11_key, "s11_key": s11_key,
"s21_key": s21_key, "s21_key": s21_key,
@@ -94,7 +94,7 @@ class NpzStore(StoreApi):
s21 = np.asarray(arrays[combo["s21_key"]], dtype=np.complex64) s21 = np.asarray(arrays[combo["s21_key"]], dtype=np.complex64)
traces.append( traces.append(
TraceData( 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, frequency_hz=freq,
s11=s11, s11=s11,
s21=s21, 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: 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.""" """Validate that named set covers all required switch combinations."""
collection = self.load_set(kind, radar_key, set_name) collection = self.load_set(kind, radar_key, set_name)
existing = {(trace.combo.input_pos, trace.combo.output_pos) for trace in collection.traces} existing = {(trace.combo.input, trace.combo.output) for trace in collection.traces}
required = {(combo.input_pos, combo.output_pos) for combo in combos} required = {(combo.input, combo.output) for combo in combos}
return required.issubset(existing) return required.issubset(existing)
def export_set_bundle(self, kind: str, radar_key: str, set_name: str, output_path: Path) -> Path: 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( 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 collection in [*selected_raw, *selected_preprocessed]
for trace in collection.traces for trace in collection.traces
} }
+1 -1
View File
@@ -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: def _pick_trace(collection: SweepCollection, input_index: int, output_index: int) -> TraceData | None:
for trace in collection.traces: 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 trace
return None return None
@@ -42,7 +42,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
self.assertEqual(len(calibration.traces), 2) self.assertEqual(len(calibration.traces), 2)
self.assertEqual(len(reference.traces), 2) self.assertEqual(len(reference.traces), 2)
self.assertEqual( 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)], [(0, 0), (1, 0)],
) )
for trace in calibration.traces: for trace in calibration.traces:
+12 -2
View File
@@ -5,13 +5,18 @@ from __future__ import annotations
from python_app.models.dataset_model import SweepCollection from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import RunConfigModel from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz_store import NpzStore 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( def capture_calibration_set(
config: RunConfigModel, config: RunConfigModel,
set_name: str, set_name: str,
store: NpzStore, store: NpzStore,
*,
median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
) -> tuple[str, SweepCollection]: ) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as calibration set.""" """Capture all switch combinations and persist them as calibration set."""
if config.is_multi_device: if config.is_multi_device:
@@ -21,7 +26,12 @@ def capture_calibration_set(
"and captured explicitly." "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: try:
session.open() session.open()
while not session.is_complete(): while not session.is_complete():
@@ -62,7 +62,7 @@ def _neutral_collection(
point_count = int(frequency_hz.size) point_count = int(frequency_hz.size)
traces.append( traces.append(
TraceData( 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(), frequency_hz=frequency_hz.copy(),
s11=np.zeros(point_count, dtype=np.complex64), s11=np.zeros(point_count, dtype=np.complex64),
s21=np.full(point_count, s21_value, dtype=np.complex64), s21=np.full(point_count, s21_value, dtype=np.complex64),
@@ -18,6 +18,8 @@ from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import ( from python_app.workflows.sequential_capture_workflow import (
MULTI_DEVICE_MANUAL_CAPTURE_KINDS, MULTI_DEVICE_MANUAL_CAPTURE_KINDS,
SequentialCaptureState, SequentialCaptureState,
combine_collections_via_median,
combine_traces_via_median,
select_trace_for_combo, select_trace_for_combo,
) )
@@ -55,6 +57,7 @@ class MultiRadarSequentialCaptureSession:
kind: str, kind: str,
set_name: str, set_name: str,
radar_variants: list[RadarConfigVariant], radar_variants: list[RadarConfigVariant],
median_sweep_count: int = 1,
) -> None: ) -> None:
"""Create capture session for one preprocess asset set and multiple radar variants.""" """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"}: 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") raise RuntimeError("Set name is required")
if not radar_variants: if not radar_variants:
raise RuntimeError("At least one radar variant is required") 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._base_config = base_config
self._kind = kind self._kind = kind
self._set_name = set_name self._set_name = set_name
self._radar_variants = list(radar_variants) self._radar_variants = list(radar_variants)
self._median_sweep_count = int(median_sweep_count)
self._is_multi_device = base_config.is_multi_device 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._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._combos = ( self._combos = (
@@ -196,16 +202,23 @@ class MultiRadarSequentialCaptureSession:
self._radar.configure(variant.config.radar.sweep) self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0: if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0) time.sleep(self._base_config.runtime.settling_ms / 1000.0)
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = self._radar.acquire_collection(collection_id=1) collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces: if not collection.traces:
raise RuntimeError(f"Multi-device variant {variant.display_name} returned no traces") raise RuntimeError(
f"Multi-device variant {variant.display_name} returned no traces"
)
collections.append(collection)
if self._manual_multi_device_capture: 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] pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace) display_traces.append(trace)
else: else:
pending_traces_by_radar_key[variant.radar_key] = list(collection.traces) combined_collection = combine_collections_via_median(collections)
display_traces.append(collection.traces[-1]) 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) variant_labels.append(variant.display_name)
else: else:
assert self._input_switch is not None assert self._input_switch is not None
@@ -219,13 +232,18 @@ class MultiRadarSequentialCaptureSession:
self._radar.configure(variant.config.radar.sweep) self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0: if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0) time.sleep(self._base_config.runtime.settling_ms / 1000.0)
sweep_traces: list[TraceData] = []
for _ in range(self._median_sweep_count):
sweep = self._radar.acquire() sweep = self._radar.acquire()
trace = TraceData( sweep_traces.append(
combo=ComboKey(input_pos=combo.input, output_pos=combo.output), TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32), frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64), s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), 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] pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace) display_traces.append(trace)
variant_labels.append(variant.display_name) variant_labels.append(variant.display_name)
+12 -2
View File
@@ -5,16 +5,26 @@ from __future__ import annotations
from python_app.models.dataset_model import SweepCollection from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import RunConfigModel from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz_store import NpzStore 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( def capture_reference_set(
config: RunConfigModel, config: RunConfigModel,
set_name: str, set_name: str,
store: NpzStore, store: NpzStore,
*,
median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
) -> tuple[str, SweepCollection]: ) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as reference set.""" """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: try:
session.open() session.open()
while not session.is_complete(): while not session.is_complete():
@@ -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 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"}) MULTI_DEVICE_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5
@dataclass(slots=True) @dataclass(slots=True)
@@ -36,16 +37,26 @@ class SequentialCaptureState:
class SequentialCaptureSession: class SequentialCaptureSession:
"""Manage hardware and switch stepping for full combo capture sequence.""" """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.""" """Create capture session for one preprocess asset set."""
if kind not in {"s21_calibration", "s21_reference", "s11_open", "s11_short", "s11_load", "s11_reference"}: if kind not in {"s21_calibration", "s21_reference", "s11_open", "s11_short", "s11_load", "s11_reference"}:
raise RuntimeError(f"Unsupported capture kind: {kind}") raise RuntimeError(f"Unsupported capture kind: {kind}")
if not set_name: if not set_name:
raise RuntimeError("Set name is required") raise RuntimeError("Set name is required")
if int(median_sweep_count) < 1:
raise RuntimeError("median_sweep_count must be >= 1")
self._config = config self._config = config
self._kind = kind self._kind = kind
self._set_name = set_name self._set_name = set_name
self._median_sweep_count = int(median_sweep_count)
self._is_multi_device = config.is_multi_device 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._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._combos = ( self._combos = (
@@ -154,18 +165,23 @@ class SequentialCaptureSession:
raise RuntimeError("Capture session is already complete") raise RuntimeError("Capture session is already complete")
if self._is_multi_device: if self._is_multi_device:
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = self._radar.acquire_collection(collection_id=1) collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces: if not collection.traces:
raise RuntimeError("Multi-device capture returned no traces") raise RuntimeError("Multi-device capture returned no traces")
collections.append(collection)
if self._manual_multi_device_capture: 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._traces.append(trace)
self._next_index += 1 self._next_index += 1
return trace 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) self._next_index = len(self._combos)
return collection.traces[-1] return combined_collection.traces[-1]
assert self._input_switch is not None assert self._input_switch is not None
assert self._output_switch is not None assert self._output_switch is not None
@@ -174,13 +190,18 @@ class SequentialCaptureSession:
if self._config.runtime.settling_ms > 0: if self._config.runtime.settling_ms > 0:
time.sleep(self._config.runtime.settling_ms / 1000.0) time.sleep(self._config.runtime.settling_ms / 1000.0)
sweep_traces: list[TraceData] = []
for _ in range(self._median_sweep_count):
sweep = self._radar.acquire() sweep = self._radar.acquire()
trace = TraceData( sweep_traces.append(
combo=ComboKey(input_pos=combo.input, output_pos=combo.output), TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32), frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64), s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64), s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
) )
)
trace = combine_traces_via_median(sweep_traces)
self._traces.append(trace) self._traces.append(trace)
self._next_index += 1 self._next_index += 1
return trace return trace
@@ -203,8 +224,8 @@ class SequentialCaptureSession:
expected_combo = self._combos[self._next_index - 1] expected_combo = self._combos[self._next_index - 1]
removed_trace = self._traces[-1] removed_trace = self._traces[-1]
if ( if (
int(removed_trace.combo.input_pos) != int(expected_combo.input) int(removed_trace.combo.input) != int(expected_combo.input)
or int(removed_trace.combo.output_pos) != int(expected_combo.output) or int(removed_trace.combo.output) != int(expected_combo.output)
): ):
raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo") raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo")
self._next_index -= 1 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.""" """Return the trace matching a virtual combo from a full multi-device capture."""
for trace in collection.traces: for trace in collection.traces:
if ( if (
int(trace.combo.input_pos) == int(combo.input) int(trace.combo.input) == int(combo.input)
and int(trace.combo.output_pos) == int(combo.output) and int(trace.combo.output) == int(combo.output)
): ):
return trace return trace
raise RuntimeError(f"Multi-device capture is missing trace for input={combo.input}, output={combo.output}") 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),
)