122 lines
5.5 KiB
Python
122 lines
5.5 KiB
Python
"""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,
|
||
)
|