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