added median sweep and fixed multi device issue
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user