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
@@ -1,46 +0,0 @@
"""One-shot workflow for capturing a full calibration set."""
from __future__ import annotations
import logging
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 (
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
SequentialCaptureSession,
)
logger = logging.getLogger(__name__)
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."""
logger.info("Starting one-shot calibration capture: set=%s", set_name)
if config.is_matrix_radar:
raise RuntimeError(
"Matrix-radar S21 through calibration is not supported by this one-shot full-set helper. "
"Use the sequential preprocess capture flow so each virtual combo can be connected through "
"and captured explicitly."
)
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():
session.capture_current_combo()
return session.finalize(store)
finally:
session.close()
@@ -6,9 +6,8 @@ reference is subtracted, so applying both leaves the measured S21 untouched. Tha
lets an operator start the pipeline before any real calibration exists, which the
required-asset check in `_start_run` would otherwise refuse.
Supported models: Kamil ADC (axis from the ADC processing grid) and every
VNA-style model, including synchronized multi-device LibreVNA (axis from the
configured linear sweep grid).
The shortcut is enabled for verified axes: Kamil ADC's processing grid,
synchronized multi-device LibreVNA, and the physical single LibreVNA mock.
"""
from __future__ import annotations
@@ -29,26 +28,14 @@ logger = logging.getLogger(__name__)
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
"""Return whether neutral S21 sets can be generated for this radar model.
Enabled for the Kamil ADC and for synchronized multi-device LibreVNA, the two
models whose emitted frequency axis is fully derivable from the config alone.
Other models still work through `build_neutral_s21_sets`, but are kept out of the
UI shortcut until their axis has been verified against real hardware.
"""
return bool(config.is_kamil_adc or config.is_multi_device)
"""Enable the shortcut only when the emitted frequency grid is verified."""
return bool(config.is_kamil_adc or config.is_multi_device or (
config.radar.model == RunConfigModel.LIBREVNA_MODEL and config.radar.driver_mode == "mock"
))
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
"""Return the exact per-trace frequency axis the configured radar emits.
Neutral sets must line up sample-for-sample with live sweeps, so the axis comes
from the same source the acquisition path uses: the ADC processing grid for Kamil
ADC, and the configured linear sweep grid for every VNA-style model (LibreVNA
single and multi-device, SN9000, Compact-M). The C++ preprocessor re-checks this
axis against the measured one within a tolerance, so a mismatch fails loudly
instead of silently corrupting the correction.
"""
"""Derive the neutral axis; the preprocessor verifies it against measured data."""
if config.is_kamil_adc:
# Single source of truth for the axis: the same grid the processor emits.
processor = KamilAdcSweepProcessor(
@@ -59,8 +46,6 @@ def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
points = int(config.radar.sweep.points)
if points < 1:
raise ValueError("Neutral sets require radar.sweep.points >= 1")
if points == 1:
return np.array([float(config.radar.sweep.start_hz)], dtype=np.float32)
# Mirrors both acquisition paths: the native collector seeds this same linspace
# and the mock backend generates it outright.
return np.linspace(
@@ -110,19 +95,6 @@ def build_neutral_s21_sets(
return calibration, reference
def build_kamil_adc_neutral_s21_sets(
config: RunConfigModel,
) -> tuple[SweepCollection, SweepCollection]:
"""Build neutral S21 sets, rejecting anything but the Kamil ADC radar.
Kept as the model-checked entry point for the ADC path; new callers that must
work for several radar models should use `build_neutral_s21_sets` instead.
"""
if not config.is_kamil_adc:
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
return build_neutral_s21_sets(config)
def _neutral_collection(
*,
combos: list[ComboModel],
@@ -6,13 +6,12 @@ from contextlib import suppress
from dataclasses import dataclass
import logging
import time
import numpy as np
from typing import cast
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.dataset_model import SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant
@@ -20,9 +19,11 @@ from python_app.workflows.sequential_capture_workflow import (
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
SequentialCaptureState,
acquire_matrix_combo_collection,
capture_single_radar_combo,
combine_collections_via_median,
combine_traces_via_median,
select_trace_for_combo,
trace_capture_window,
)
logger = logging.getLogger(__name__)
@@ -103,8 +104,9 @@ class MultiRadarSequentialCaptureSession:
self._is_matrix_radar,
)
self._radar: MatrixRadarService | SingleRadarService
if self._is_matrix_radar:
self._radar: MatrixRadarService = create_matrix_radar_service(base_config)
self._radar = create_matrix_radar_service(base_config)
self._input_switch = None
self._output_switch = None
else:
@@ -145,14 +147,10 @@ class MultiRadarSequentialCaptureSession:
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("Multi-radar capture session closed (kind=%s set=%s)", self._kind, self._set_name)
@@ -189,6 +187,7 @@ class MultiRadarSequentialCaptureSession:
variant_labels: list[str] = []
if self._is_matrix_radar:
radar = cast(MatrixRadarService, self._radar)
for variant in self._radar_variants:
self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0:
@@ -198,7 +197,7 @@ class MultiRadarSequentialCaptureSession:
# 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)
]
@@ -208,7 +207,7 @@ class MultiRadarSequentialCaptureSession:
else:
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(
f"Matrix radar variant {variant.display_name} returned no traces"
@@ -230,21 +229,9 @@ 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_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,
)
pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace)
variant_labels.append(variant.display_name)
@@ -343,10 +330,13 @@ class MultiRadarSequentialCaptureSession:
saved_sets: list[MultiRadarSavedSet] = []
for variant in self._radar_variants:
traces = list(self._traces_by_radar_key[variant.radar_key])
capture_start_ns, capture_end_ns = trace_capture_window(traces)
collection = SweepCollection(
collection_id=1,
monotonic_ns=monotonic_ns,
traces=traces,
capture_start_ns=capture_start_ns,
capture_end_ns=capture_end_ns,
)
store.save_set(self._kind, variant.radar_key, self._set_name, collection)
saved_sets.append(
+3 -12
View File
@@ -143,18 +143,9 @@ def _load_radar_config_variant(path: Path, *, base_config: RunConfigModel) -> Ra
if not any(key in sweep_payload for key in _RADAR_SWEEP_KEYS):
raise ValueError("radar.sweep does not contain any supported sweep keys")
config = base_config.clone()
sweep = config.radar.sweep
if "start_hz" in sweep_payload:
sweep.start_hz = float(sweep_payload["start_hz"])
if "stop_hz" in sweep_payload:
sweep.stop_hz = float(sweep_payload["stop_hz"])
if "points" in sweep_payload:
sweep.points = int(sweep_payload["points"])
if "if_bandwidth_hz" in sweep_payload:
sweep.if_bandwidth_hz = float(sweep_payload["if_bandwidth_hz"])
if "stimulus_power_dbm" in sweep_payload:
sweep.power_dbm = float(sweep_payload["stimulus_power_dbm"])
config_payload = base_config.to_dict()
config_payload["radar"]["sweep"].update(sweep_payload)
config = RunConfigModel.from_dict(config_payload)
radar_key = radar_key_from_config(
model_name=config.radar.model,
@@ -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),
)