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
@@ -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),
)