Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -7,9 +7,10 @@ import logging
import time
from python_app.hardware_full.matrix_radar_service import MatrixRadarService
from python_app.hardware_full.switch_service import SwitchService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.hardware_full.switch_drivers.interface import SwitchDriverProtocol
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, SwitchModel
from python_app.models.run_config_schema import RadarSweepModel, SwitchModel
logger = logging.getLogger(__name__)
@@ -20,17 +21,17 @@ class SwitchedMatrixRadarService:
Implements the ``MatrixRadarService`` protocol, so the producer and the
capture workflows treat it as an ordinary matrix radar that simply reports
more positions. The hardware sweep is never stopped: switches are only ever
driven BETWEEN ``acquire_collection`` calls, and the inner service's
free-running collection discards any partially swept cycle.
more positions. Switches settle before each inner acquisition. A free-running
matrix discards the partial cycle; a single device starts one standby sweep.
"""
inner: MatrixRadarService
output_switch: SwitchService | None
input_switch: SwitchService | None
output_switch: SwitchDriverProtocol | None
input_switch: SwitchDriverProtocol | None
inner_output_positions: int
inner_input_positions: int
settling_ms: int = 0
combos: list[ComboKey] | None = None
# Monotonic end of the previous inner collection, so the DEBUG timing trace can
# report how long the gap between "sweep collected" and "switch driven" really is
# — that gap is where a stale in-flight point 0 can still slip past the drain.
@@ -61,85 +62,50 @@ class SwitchedMatrixRadarService:
"""Apply sweep settings to the inner radar."""
self.inner.configure(sweep)
def recover(self) -> None:
"""Reconnect the inner radar; switches are not on the USB transport."""
self.inner.recover()
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire the full widened matrix, one inner collection per switch step.
def acquire_collection(self, collection_id: int = 1, *, combo: ComboKey | None = None) -> SweepCollection:
"""Acquire the matrix, or only the physical switch step containing combo.
A partial failure raises instead of returning a short collection: the
preprocessor requires every runtime combo to be present, so half a matrix
is worse than a dropped frame.
"""
capture_start_ns = time.monotonic_ns()
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
total_inputs = in_steps * self.inner_input_positions
total_outputs = out_steps * self.inner_output_positions
total_inputs, total_outputs = self._shape()
# Place each trace at its canonical index rather than appending. The GPR stage
# rejects a collection whose trace order differs from run.combos, and run.combos
# is built output-major (`build_full_combos`) while these loops run switch-major.
# Appending happens to agree for an output switch and to disagree for an input one.
slots: list[TraceData | None] = [None] * (total_inputs * total_outputs)
for out_k in range(out_steps):
for in_k in range(in_steps):
for trace in self._acquire_step_traces(out_k, in_k, collection_id):
slots[trace.combo.output * total_inputs + trace.combo.input] = trace
if any(trace is None for trace in slots):
missing = sum(1 for trace in slots if trace is None)
raise RuntimeError(
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
)
combos = [combo] if combo is not None else self.combos
if combos is None:
combos = [
ComboKey(input_pos, output_pos)
for output_pos in range(total_outputs)
for input_pos in range(total_inputs)
]
if any(not (0 <= c.input < total_inputs and 0 <= c.output < total_outputs) for c in combos):
raise ValueError("Requested combo is outside the switched matrix")
steps = dict.fromkeys(
(c.output // self.inner_output_positions, c.input // self.inner_input_positions)
for c in combos
)
traces = {
trace.combo: trace
for out_k, in_k in steps
for trace in self._acquire_step_traces(out_k, in_k, collection_id)
}
missing = set(combos) - traces.keys()
if missing:
raise RuntimeError(f"Switched matrix collection is incomplete: {len(missing)} combos missing")
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=[trace for trace in slots if trace is not None],
traces=list(traces.values()) if combo is not None else [traces[key] for key in combos],
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
)
def acquire_combo_collection(
self,
*,
input_pos: int,
output_pos: int,
collection_id: int = 1,
) -> SweepCollection:
"""Acquire only the physical switch step that carries one widened combo.
The per-combo capture workflows need a single trace at a time; sweeping
every switch position for that (a full ``acquire_collection``) multiplies
the capture time by the number of physical steps and freezes the caller
for the whole sweep. One widened combo lives entirely inside one
(out_k, in_k) step, so acquiring just that step is sufficient. The result
contains that step's traces with widened combo keys, including the
requested combo.
"""
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
total_inputs = in_steps * self.inner_input_positions
total_outputs = out_steps * self.inner_output_positions
if not (0 <= int(input_pos) < total_inputs and 0 <= int(output_pos) < total_outputs):
raise ValueError(
f"Widened combo out of range: input={input_pos} (of {total_inputs}), "
f"output={output_pos} (of {total_outputs})"
)
capture_start_ns = time.monotonic_ns()
out_k = int(output_pos) // self.inner_output_positions
in_k = int(input_pos) // self.inner_input_positions
traces = self._acquire_step_traces(out_k, in_k, collection_id)
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
def _shape(self) -> tuple[int, int]:
return (
self.inner_input_positions * (self.input_switch.position_count() if self.input_switch else 1),
self.inner_output_positions * (self.output_switch.position_count() if self.output_switch else 1),
)
def _acquire_step_traces(self, out_k: int, in_k: int, collection_id: int) -> list[TraceData]:
@@ -151,22 +117,14 @@ class SwitchedMatrixRadarService:
ended. The switch drive and settling are deliberately outside the window.
"""
step_start_ns = time.monotonic_ns()
if self.output_switch is not None:
self.output_switch.switch_to(out_k)
if self.input_switch is not None:
self.input_switch.switch_to(in_k)
switched_ns = time.monotonic_ns()
# Settle AFTER the last switch change and BEFORE collecting, so the
# cycle we anchor on starts with the RF path already stable.
if self.settling_ms > 0:
time.sleep(self.settling_ms / 1000.0)
select_and_settle(self.output_switch, self.input_switch, out_k, in_k, self.settling_ms)
settled_ns = time.monotonic_ns()
sub = self.inner.acquire_collection(collection_id)
inner_end_ns = time.monotonic_ns()
logger.debug(
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
"switch_and_settle=%.2f ms, inner_collect=%.2f ms",
collection_id,
out_k,
in_k,
@@ -175,8 +133,7 @@ class SwitchedMatrixRadarService:
if self._last_inner_end_ns
else "n/a"
),
(switched_ns - step_start_ns) / 1e6,
(settled_ns - switched_ns) / 1e6,
(settled_ns - step_start_ns) / 1e6,
(inner_end_ns - settled_ns) / 1e6,
)
self._last_inner_end_ns = inner_end_ns