added median sweep and fixed multi device issue

This commit is contained in:
Ayzen
2026-05-20 16:52:06 +03:00
parent 0da8b1283c
commit 1c544aa582
29 changed files with 444 additions and 126 deletions
+3
View File
@@ -127,6 +127,9 @@ class AppWindow(
self._preprocess_set_name = str(self._gui_defaults.preprocess_dialog.set_name)
self._preprocess_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))
+80 -26
View File
@@ -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:
for device_connection in self._all_devices:
device_connection.drain_received_packets()
# Drain every device queue in parallel rather than one after another:
# serial drain leaves up to a few hundred microseconds of skew between
# the master and slave drain moments, which is enough room for a
# hardware cycle wrap to slip between drains and desynchronise the
# per-device cycle counters. Each device has its own queue and lock,
# so concurrent get_nowait calls do not contend. A single device case
# just runs inline to avoid the thread-spawn overhead.
if len(self._all_devices) < 2:
for device_connection in self._all_devices:
device_connection.drain_received_packets()
return
drain_threads = [
threading.Thread(
target=device_connection.drain_received_packets,
name=f"drain-{device_connection.serial_number}",
daemon=True,
)
for device_connection in self._all_devices
]
for drain_thread in drain_threads:
drain_thread.start()
for drain_thread in drain_threads:
drain_thread.join()
@staticmethod
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
@@ -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,
+2 -2
View File
@@ -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)
+10
View File
@@ -471,6 +471,15 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.preprocess_dialog.use_all_radar_configs,
"gui.preprocess_dialog",
),
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
+1
View File
@@ -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)
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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:
+6 -6
View File
@@ -157,7 +157,7 @@ def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) ->
traces_meta: list[dict[str, int | str]] = []
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,
+7 -7
View File
@@ -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
}
+1 -1
View File
@@ -39,7 +39,7 @@ def _select_trace_samples(trace: TraceData, channel: str) -> np.ndarray:
def _pick_trace(collection: SweepCollection, input_index: int, output_index: int) -> TraceData | None:
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:
+12 -2
View File
@@ -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)
collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError(f"Multi-device variant {variant.display_name} returned no traces")
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError(
f"Multi-device variant {variant.display_name} returned no traces"
)
collections.append(collection)
if self._manual_multi_device_capture:
trace = select_trace_for_combo(collection, combo)
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces)
pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace)
else:
pending_traces_by_radar_key[variant.radar_key] = list(collection.traces)
display_traces.append(collection.traces[-1])
combined_collection = combine_collections_via_median(collections)
pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces)
display_traces.append(combined_collection.traces[-1])
variant_labels.append(variant.display_name)
else:
assert self._input_switch is not None
@@ -219,13 +232,18 @@ class MultiRadarSequentialCaptureSession:
self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
sweep = self._radar.acquire()
trace = TraceData(
combo=ComboKey(input_pos=combo.input, output_pos=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
)
sweep_traces: list[TraceData] = []
for _ in range(self._median_sweep_count):
sweep = self._radar.acquire()
sweep_traces.append(
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
)
)
trace = combine_traces_via_median(sweep_traces)
pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace)
variant_labels.append(variant.display_name)
+12 -2
View File
@@ -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:
collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError("Multi-device capture returned no traces")
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError("Multi-device capture returned no traces")
collections.append(collection)
if self._manual_multi_device_capture:
trace = select_trace_for_combo(collection, combo)
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces)
self._traces.append(trace)
self._next_index += 1
return trace
self._traces.extend(collection.traces)
combined_collection = combine_collections_via_median(collections)
self._traces.extend(combined_collection.traces)
self._next_index = len(self._combos)
return collection.traces[-1]
return combined_collection.traces[-1]
assert self._input_switch is not None
assert self._output_switch is not None
@@ -174,13 +190,18 @@ class SequentialCaptureSession:
if self._config.runtime.settling_ms > 0:
time.sleep(self._config.runtime.settling_ms / 1000.0)
sweep = self._radar.acquire()
trace = TraceData(
combo=ComboKey(input_pos=combo.input, output_pos=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
)
sweep_traces: list[TraceData] = []
for _ in range(self._median_sweep_count):
sweep = self._radar.acquire()
sweep_traces.append(
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
)
)
trace = combine_traces_via_median(sweep_traces)
self._traces.append(trace)
self._next_index += 1
return trace
@@ -203,8 +224,8 @@ class SequentialCaptureSession:
expected_combo = self._combos[self._next_index - 1]
removed_trace = self._traces[-1]
if (
int(removed_trace.combo.input_pos) != int(expected_combo.input)
or int(removed_trace.combo.output_pos) != int(expected_combo.output)
int(removed_trace.combo.input) != int(expected_combo.input)
or int(removed_trace.combo.output) != int(expected_combo.output)
):
raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo")
self._next_index -= 1
@@ -259,8 +280,94 @@ def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> Tr
"""Return the trace matching a virtual combo from a full multi-device capture."""
for trace in collection.traces:
if (
int(trace.combo.input_pos) == int(combo.input)
and int(trace.combo.output_pos) == int(combo.output)
int(trace.combo.input) == int(combo.input)
and int(trace.combo.output) == int(combo.output)
):
return trace
raise RuntimeError(f"Multi-device capture is missing trace for input={combo.input}, output={combo.output}")
def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
"""Return one trace whose S11/S21 are the per-point median of the inputs.
A single-element input is returned unchanged. With multiple inputs, the real
and imaginary parts of each complex sample are medianed independently so a
single bad sweep (e.g. an outlier with random phase) is rejected without
corrupting the saved calibration trace.
"""
if not traces:
raise RuntimeError("Cannot combine empty sweep list")
if len(traces) == 1:
return traces[0]
first = traces[0]
combo = first.combo
point_count = first.frequency_hz.size
for index, trace in enumerate(traces[1:], start=1):
if (
int(trace.combo.input) != int(combo.input)
or int(trace.combo.output) != int(combo.output)
):
raise RuntimeError(
f"Median combine combo mismatch at sweep {index}: "
f"({trace.combo.input},{trace.combo.output}) "
f"vs ({combo.input},{combo.output})"
)
if trace.frequency_hz.size != point_count:
raise RuntimeError(
f"Median combine point-count mismatch at sweep {index}: "
f"{trace.frequency_hz.size} vs {point_count}"
)
s11_stack = np.stack([np.asarray(t.s11, dtype=np.complex64) for t in traces], axis=0)
s21_stack = np.stack([np.asarray(t.s21, dtype=np.complex64) for t in traces], axis=0)
s11_median = (
np.median(s11_stack.real, axis=0) + 1j * np.median(s11_stack.imag, axis=0)
).astype(np.complex64)
s21_median = (
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
).astype(np.complex64)
return TraceData(
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32),
s11=s11_median,
s21=s21_median,
)
def combine_collections_via_median(collections: list[SweepCollection]) -> SweepCollection:
"""Combine multi-device matrix captures into one collection with per-combo medians."""
if not collections:
raise RuntimeError("Cannot combine empty collection list")
if len(collections) == 1:
return collections[0]
reference = collections[0]
expected_combos = [(trace.combo.input, trace.combo.output) for trace in reference.traces]
medianed_traces: list[TraceData] = []
for combo_index, (input_pos, output_pos) in enumerate(expected_combos):
per_sweep_traces: list[TraceData] = []
for collection_index, collection in enumerate(collections):
if combo_index >= len(collection.traces):
raise RuntimeError(
f"Median collections have mismatched trace counts at sweep {collection_index}"
)
trace = collection.traces[combo_index]
if (
int(trace.combo.input) != int(input_pos)
or int(trace.combo.output) != int(output_pos)
):
raise RuntimeError(
f"Median collections trace order mismatch at sweep {collection_index}, "
f"combo index {combo_index}"
)
per_sweep_traces.append(trace)
medianed_traces.append(combine_traces_via_median(per_sweep_traces))
return SweepCollection(
collection_id=int(reference.collection_id),
monotonic_ns=int(reference.monotonic_ns),
traces=medianed_traces,
capture_start_ns=int(reference.capture_start_ns),
capture_end_ns=int(reference.capture_end_ns),
)