"""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) )