"""One moving point reflector shared by single and multi-radar mock acquisition.""" from __future__ import annotations from dataclasses import dataclass, field import time import numpy as np from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.run_config_model import RadarSweepModel, RunConfigModel _WAVE_SPEED_M_S = 3e8 _FRAME_PERIOD_S = 0.05 _MOTION_PERIOD_S = 8.0 @dataclass(slots=True) class PointTargetMockService: """Simulate bistatic propagation from configured antennas to one moving target. The target moves back and forth along the array and between depths 3.25–4.25 m. One monotonic sample time determines every channel of a collection; channel numbers never contribute an arbitrary gain, range, or phase offset. """ config: RunConfigModel _frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False) _phase_per_m: np.ndarray | None = field(init=False, default=None, repr=False) _last_capture_ns: int = field(init=False, default=0, repr=False) _epoch_ns: int = field(init=False, repr=False) _combos: tuple[ComboKey, ...] = field(init=False, repr=False) _active_combo: ComboKey = field(init=False, repr=False) def __post_init__(self) -> None: self._epoch_ns = time.monotonic_ns() self._combos = tuple(ComboKey(c.input, c.output) for c in ( self.config.combos or self.config.build_runtime_combos() )) self._active_combo = self._combos[0] def open(self) -> None: """Mock acquisition owns no hardware resources.""" def close(self) -> None: """Mock acquisition owns no hardware resources.""" def recover(self) -> None: """Mock acquisition has no hardware connection to recover.""" def read_device_limits(self) -> dict[str, float | int]: raise RuntimeError("Mock radar has no hardware device limits") def configure(self, sweep: RadarSweepModel) -> None: self._frequency_hz = np.linspace(sweep.start_hz, sweep.stop_hz, sweep.points, dtype=np.float32) self._phase_per_m = -2j * np.pi * self._frequency_hz.astype(np.float64) / _WAVE_SPEED_M_S def set_active_combo(self, combo: ComboKey) -> None: if not (0 <= combo.input < self.config.input_switch.positions and 0 <= combo.output < self.config.output_switch.positions): raise ValueError(f"Mock combo is outside the antenna matrix: {combo}") self._active_combo = ComboKey(combo.input, combo.output) def acquire(self) -> SweepResult: collection = self._capture((self._active_combo,), 1) trace = collection.traces[0] return SweepResult( x=trace.frequency_hz, traces={"s11": trace.s11, "s21": trace.s21}, capture_start_ns=trace.capture_start_ns, capture_end_ns=trace.capture_end_ns, ) def acquire_collection(self, collection_id: int = 1) -> SweepCollection: return self._capture(self._combos, collection_id) def acquire_combo_collection( self, *, input_pos: int, output_pos: int, collection_id: int = 1, ) -> SweepCollection: self.set_active_combo(ComboKey(input_pos, output_pos)) return self._capture((self._active_combo,), collection_id) def _target_position( self, sample_ns: int, tx: dict[int, tuple[float, float, float]], rx: dict[int, tuple[float, float, float]], ) -> np.ndarray: coordinates = [*tx.values(), *rx.values()] or [(0.0, 0.0, 0.0)] xs = [position[0] for position in coordinates] displacement = np.sin(2 * np.pi * (sample_ns - self._epoch_ns) / 1e9 / _MOTION_PERIOD_S) return np.array([ (min(xs) + max(xs)) / 2 + max(0.75, (max(xs) - min(xs)) / 2 + 0.25) * displacement, 0.25, 3.75 + 0.5 * displacement, ]) def _capture(self, combos: tuple[ComboKey, ...], collection_id: int) -> SweepCollection: if self._frequency_hz is None or self._phase_per_m is None: raise RuntimeError("Mock radar is not configured") delay_s = _FRAME_PERIOD_S - (time.monotonic_ns() - self._last_capture_ns) / 1e9 if delay_s > 0: time.sleep(delay_s) capture_start_ns = time.monotonic_ns() tx = {p.output_pos: (p.x_m, p.y_m, p.z_m) for p in self.config.gpr.tx_geometry} rx = {p.input_pos: (p.x_m, p.y_m, p.z_m) for p in self.config.gpr.rx_geometry} target = self._target_position(capture_start_ns, tx, rx) traces = [] for combo in combos: r_tx = float(np.linalg.norm(target - tx.get(combo.output, (0.0, 0.0, 0.0)))) r_rx = float(np.linalg.norm(target - rx.get(combo.input, (0.0, 0.0, 0.0)))) traces.append(TraceData( combo=combo, frequency_hz=self._frequency_hz, s11=np.asarray(0.5 / max(1.0, r_tx * r_tx) * np.exp(self._phase_per_m * 2 * r_tx), dtype=np.complex64), s21=np.asarray(0.5 / max(1.0, r_tx * r_rx) * np.exp(self._phase_per_m * (r_tx + r_rx)), dtype=np.complex64), capture_start_ns=capture_start_ns, )) capture_end_ns = time.monotonic_ns() for trace in traces: trace.capture_end_ns = capture_end_ns self._last_capture_ns = capture_start_ns return SweepCollection( collection_id=collection_id, monotonic_ns=capture_end_ns, traces=traces, capture_start_ns=capture_start_ns, capture_end_ns=capture_end_ns, )