Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -6,11 +6,12 @@ from contextlib import suppress
from dataclasses import dataclass
import logging
import time
from typing import cast
import numpy as np
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.hardware_full.single_radar_service import SingleRadarService, create_single_radar_service
from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
@@ -22,6 +23,13 @@ MATRIX_RADAR_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5
def trace_capture_window(traces: list[TraceData]) -> tuple[int, int]:
"""Return the total measured window, retaining zero when timing was not reported."""
starts = [int(trace.capture_start_ns) for trace in traces if int(trace.capture_start_ns) > 0]
ends = [int(trace.capture_end_ns) for trace in traces if int(trace.capture_end_ns) > 0]
return min(starts, default=0), max(ends, default=0)
@dataclass(slots=True)
class SequentialCaptureState:
"""Snapshot of sequential capture progress for the GUI/controller."""
@@ -81,8 +89,9 @@ class SequentialCaptureSession:
self._median_sweep_count,
)
self._radar: MatrixRadarService | SingleRadarService
if self._is_matrix_radar:
self._radar: MatrixRadarService = create_matrix_radar_service(config)
self._radar = create_matrix_radar_service(config)
self._input_switch = None
self._output_switch = None
else:
@@ -121,14 +130,10 @@ class SequentialCaptureSession:
def close(self) -> None:
"""Close all opened hardware resources."""
was_open = self._opened
with suppress(Exception):
if self._output_switch is not None:
self._output_switch.close()
with suppress(Exception):
if self._input_switch is not None:
self._input_switch.close()
with suppress(Exception):
self._radar.close()
for resource in (self._output_switch, self._input_switch, self._radar):
if resource is not None:
with suppress(Exception):
resource.close()
self._opened = False
if was_open:
logger.info("Sequential capture session closed (kind=%s set=%s)", self._kind, self._set_name)
@@ -156,12 +161,13 @@ class SequentialCaptureSession:
raise RuntimeError("Capture session is already complete")
if self._is_matrix_radar:
radar = cast(MatrixRadarService, self._radar)
if self._manual_matrix_radar_capture:
# Only this combo is kept, so acquire the smallest collection that
# contains it instead of the full (switch-widened) matrix.
per_sweep_traces = [
select_trace_for_combo(
acquire_matrix_combo_collection(self._radar, combo), combo
acquire_matrix_combo_collection(radar, combo), combo
)
for _ in range(self._median_sweep_count)
]
@@ -173,7 +179,7 @@ class SequentialCaptureSession:
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = self._radar.acquire_collection(collection_id=1)
collection = radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError("Matrix radar capture returned no traces")
collections.append(collection)
@@ -189,32 +195,7 @@ class SequentialCaptureSession:
self._input_switch.switch_to(combo.input)
if self._config.runtime.settling_ms > 0:
time.sleep(self._config.runtime.settling_ms / 1000.0)
# A free-running streaming radar (Kamil ADC) keeps a sweep captured in the
# previous combination buffered, and may have a transition-straddling sweep
# in flight. Drop those so this capture holds data from the new switch state
# — otherwise the trace is labelled with this combo but carries the previous
# one's data (an off-by-one across the sequence). Discrete radars (LibreVNA)
# acquire a fresh sweep per call and expose no such method, so skip them.
drain_after_switch = getattr(self._radar, "drain_after_switch", None)
if callable(drain_after_switch):
drain_after_switch()
sweep_traces: list[TraceData] = []
for _ in range(self._median_sweep_count):
sweep_start_ns = time.monotonic_ns()
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),
capture_start_ns=sweep_start_ns,
capture_end_ns=time.monotonic_ns(),
)
)
trace = combine_traces_via_median(sweep_traces)
trace = capture_single_radar_combo(cast(SingleRadarService, self._radar), combo, self._median_sweep_count)
self._traces.append(trace)
self._next_index += 1
logger.debug(
@@ -279,10 +260,13 @@ class SequentialCaptureSession:
if not self.is_complete():
raise RuntimeError("Capture session is not complete")
capture_start_ns, capture_end_ns = trace_capture_window(self._traces)
collection = SweepCollection(
collection_id=1,
monotonic_ns=time.monotonic_ns(),
traces=list(self._traces),
capture_start_ns=capture_start_ns,
capture_end_ns=capture_end_ns,
)
radar_key = radar_key_from_config(
model_name=self._config.radar.model,
@@ -311,6 +295,32 @@ class SequentialCaptureSession:
return self._combos[self._next_index]
def capture_single_radar_combo(
radar: SingleRadarService, combo: ComboModel, median_sweep_count: int,
) -> TraceData:
"""Capture the selected antenna, discarding buffered data from the previous one."""
set_active_combo = getattr(radar, "set_active_combo", None)
if callable(set_active_combo):
set_active_combo(ComboKey(combo.input, combo.output))
# Streaming Kamil sweeps may predate or straddle the switch transition.
drain_after_switch = getattr(radar, "drain_after_switch", None)
if callable(drain_after_switch):
drain_after_switch()
traces = []
for _ in range(median_sweep_count):
sweep_start_ns = time.monotonic_ns()
sweep = radar.acquire()
traces.append(TraceData(
combo=ComboKey(combo.input, 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),
capture_start_ns=sweep.capture_start_ns or sweep_start_ns,
capture_end_ns=sweep.capture_end_ns or time.monotonic_ns(),
))
return combine_traces_via_median(traces)
def acquire_matrix_combo_collection(
radar: MatrixRadarService,
combo: ComboModel,
@@ -325,11 +335,11 @@ def acquire_matrix_combo_collection(
"""
acquire_combo = getattr(radar, "acquire_combo_collection", None)
if callable(acquire_combo):
collection = acquire_combo(
collection = cast(SweepCollection, acquire_combo(
input_pos=int(combo.input),
output_pos=int(combo.output),
collection_id=collection_id,
)
))
else:
collection = radar.acquire_collection(collection_id=collection_id)
if not collection.traces:
@@ -379,6 +389,8 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
f"Median combine point-count mismatch at sweep {index}: "
f"{trace.frequency_hz.size} vs {point_count}"
)
if not np.array_equal(trace.frequency_hz, first.frequency_hz):
raise RuntimeError(f"Median combine frequency-axis mismatch at sweep {index}")
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)
@@ -389,15 +401,14 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
).astype(np.complex64)
# The median is built from every input sweep, so its window spans all of them.
capture_starts = [int(t.capture_start_ns) for t in traces if int(t.capture_start_ns) > 0]
capture_ends = [int(t.capture_end_ns) for t in traces if int(t.capture_end_ns) > 0]
capture_start_ns, capture_end_ns = trace_capture_window(traces)
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,
capture_start_ns=min(capture_starts) if capture_starts else 0,
capture_end_ns=max(capture_ends) if capture_ends else 0,
capture_start_ns=capture_start_ns,
capture_end_ns=capture_end_ns,
)
@@ -409,15 +420,14 @@ def combine_collections_via_median(collections: list[SweepCollection]) -> SweepC
return collections[0]
reference = collections[0]
for index, collection in enumerate(collections[1:], start=1):
if len(collection.traces) != len(reference.traces):
raise RuntimeError(f"Median collections have mismatched trace counts at sweep {index}")
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)
@@ -434,6 +444,6 @@ def combine_collections_via_median(collections: list[SweepCollection]) -> SweepC
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),
capture_start_ns=min((int(item.capture_start_ns) for item in collections if item.capture_start_ns > 0), default=0),
capture_end_ns=max((int(item.capture_end_ns) for item in collections if item.capture_end_ns > 0), default=0),
)