Files
radar_system/python_app/hardware_full/switched_matrix_radar_service.py
2026-10-06 12:32:26 +03:00

175 lines
7.4 KiB
Python

"""Matrix radar behind real GPIO switches on the stimulus and/or receiver path."""
from __future__ import annotations
from dataclasses import dataclass, field, replace
import logging
import time
from python_app.hardware_full.matrix_radar_service import MatrixRadarService
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_schema import RadarSweepModel, SwitchModel
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class SwitchedMatrixRadarService:
"""Widen a matrix radar's combo matrix with real switch positions.
Implements the ``MatrixRadarService`` protocol, so the producer and the
capture workflows treat it as an ordinary matrix radar that simply reports
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: 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.
_last_inner_end_ns: int = field(init=False, default=0, repr=False)
def open(self) -> None:
"""Open the inner radar and both switches."""
try:
self.inner.open()
if self.output_switch is not None:
self.output_switch.open()
if self.input_switch is not None:
self.input_switch.open()
except Exception:
self.close()
raise
def close(self) -> None:
"""Close switches first, then the inner radar; never raises."""
for resource in (self.input_switch, self.output_switch, self.inner):
if resource is not None:
try:
resource.close()
except Exception as exc: # noqa: BLE001 — shutdown path
logger.warning("Switched matrix cleanup failed: %s", exc)
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings to the inner radar."""
self.inner.configure(sweep)
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()
total_inputs, total_outputs = self._shape()
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=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 _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]:
"""Drive both switches to one step, settle, and collect its widened traces.
Every returned trace carries the monotonic window of the inner collection
that produced it, so a consumer can tell when each combo of a switched
matrix was really measured instead of only when the whole cycle began and
ended. The switch drive and settling are deliberately outside the window.
"""
step_start_ns = time.monotonic_ns()
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_and_settle=%.2f ms, inner_collect=%.2f ms",
collection_id,
out_k,
in_k,
(
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
if self._last_inner_end_ns
else "n/a"
),
(settled_ns - step_start_ns) / 1e6,
(inner_end_ns - settled_ns) / 1e6,
)
self._last_inner_end_ns = inner_end_ns
return [
replace(
trace,
combo=ComboKey(
input=in_k * self.inner_input_positions + int(trace.combo.input),
output=out_k * self.inner_output_positions + int(trace.combo.output),
),
# Keep the inner service's own per-trace window when it reports one
# (it knows its internal port order better than this step does);
# otherwise fall back to the window of this inner collection.
capture_start_ns=int(trace.capture_start_ns) or settled_ns,
capture_end_ns=int(trace.capture_end_ns) or inner_end_ns,
)
for trace in sub.traces
]
def build_physical_switch(
model: SwitchModel,
physical_positions: int,
radar_driver_mode: str,
) -> SwitchService | None:
"""Build the driver for a real switch described by a virtual switch section.
The config section carries the LOGICAL axis size and a forced "mock" mode so
the C++ loader accepts it; the real driver needs the PHYSICAL position count
and native mode. Mock radar runs keep mock switches so the whole path can be
exercised without GPIO.
"""
if physical_positions <= 1:
return None
driver_mode = "mock" if radar_driver_mode.strip().lower() == "mock" else "native"
return SwitchService.from_model(
replace(model, positions=physical_positions, driver_mode=driver_mode)
)