Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+198
View File
@@ -0,0 +1,198 @@
"""Analytical checks for one coherent moving reflector across mock radar modes."""
from __future__ import annotations
from pathlib import Path
import unittest
from unittest import mock
import numpy as np
from python_app.hardware_full.matrix_radar_service import create_acquisition_service
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_model import RunConfigModel
from python_app.models.run_config_schema import GprRxGeometryModel, GprTxGeometryModel
from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession
from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
_EPOCH_NS = 10_000_000_000
_CLOCK = "python_app.hardware_full.point_target_mock_service.time.monotonic_ns"
_SLEEP = "python_app.hardware_full.point_target_mock_service.time.sleep"
def _config() -> RunConfigModel:
config = RunConfigModel()
config.radar.model = RunConfigModel.LIBREVNA_MODEL
config.radar.driver_mode = "mock"
config.radar.sweep.start_hz = 300e6
config.radar.sweep.stop_hz = 900e6
config.radar.sweep.points = 32
config.input_switch.positions = config.output_switch.positions = 2
config.input_switch.driver_mode = config.output_switch.driver_mode = "mock"
config.runtime.settling_ms = 0
config.combos = config.build_runtime_combos()
config.gpr.tx_geometry = [GprTxGeometryModel(0, -1), GprTxGeometryModel(1, 1)]
config.gpr.rx_geometry = [GprRxGeometryModel(0, -.4, .1, .2), GprRxGeometryModel(1, .5, -.1, .3)]
return config
class PointTargetMockTest(unittest.TestCase):
def setUp(self) -> None:
self.clock = self.enterContext(mock.patch(_CLOCK, return_value=_EPOCH_NS))
self.sleep = self.enterContext(mock.patch(_SLEEP))
self.config = _config()
self.service = PointTargetMockService(self.config)
self.service.configure(self.config.radar.sweep)
def test_all_channels_follow_bistatic_and_monostatic_distance(self) -> None:
collection = self.service.acquire_collection(collection_id=43)
target = np.array([0, .25, 3.75])
self.assertEqual(collection.collection_id, 43)
self.assertEqual([(t.combo.input, t.combo.output) for t in collection.traces], [(0, 0), (1, 0), (0, 1), (1, 1)])
for trace in collection.traces:
tx = self.config.gpr.tx_geometry[trace.combo.output]
rx = self.config.gpr.rx_geometry[trace.combo.input]
r_tx = np.linalg.norm(target - [tx.x_m, tx.y_m, tx.z_m])
r_rx = np.linalg.norm(target - [rx.x_m, rx.y_m, rx.z_m])
phase_per_m = -2j * np.pi * trace.frequency_hz.astype(np.float64) / 3e8
np.testing.assert_allclose(trace.s21, .5 / (r_tx * r_rx) * np.exp(phase_per_m * (r_tx + r_rx)), rtol=1e-6)
np.testing.assert_allclose(trace.s11, .5 / r_tx**2 * np.exp(phase_per_m * 2 * r_tx), rtol=1e-6)
self.assertEqual((trace.capture_start_ns, trace.capture_end_ns), (_EPOCH_NS, _EPOCH_NS))
self.assertEqual(trace.s21.dtype, np.dtype(np.complex64))
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns, collection.monotonic_ns), (_EPOCH_NS,) * 3)
def test_coincident_antennas_have_identical_traces_regardless_of_channel_number(self) -> None:
self.config.gpr.tx_geometry = [GprTxGeometryModel(0, 1), GprTxGeometryModel(1, 1)]
self.config.gpr.rx_geometry = [GprRxGeometryModel(0, -.5), GprRxGeometryModel(1, -.5)]
traces = self.service.acquire_collection().traces
for trace in traces[1:]:
np.testing.assert_array_equal(trace.s21, traces[0].s21)
np.testing.assert_array_equal(trace.s11, traces[0].s11)
def test_geometry_changes_receiver_path_without_changing_transmitter_echo(self) -> None:
before = self.service.acquire_collection().traces[0]
self.config.gpr.rx_geometry[0].z_m = 1
after = self.service.acquire_collection().traces[0]
self.assertFalse(np.array_equal(before.s21, after.s21))
np.testing.assert_array_equal(before.s11, after.s11)
def test_motion_uses_elapsed_time_and_returns_after_eight_seconds(self) -> None:
tx = {0: (-1., 0., 0.), 1: (1., 0., 0.)}
rx = {0: (-.4, .1, .2), 1: (.5, -.1, .3)}
for seconds, displacement in [(0, 0), (2, 1), (4, 0), (6, -1), (8, 0)]:
np.testing.assert_allclose(
self.service._target_position(_EPOCH_NS + seconds * 1_000_000_000, tx, rx),
[1.25 * displacement, .25, 3.75 + .5 * displacement], atol=1e-12,
)
first = self.service.acquire_collection(1)
repeated = self.service.acquire_collection(999)
for a, b in zip(first.traces, repeated.traces):
np.testing.assert_array_equal(a.s21, b.s21)
self.clock.return_value = _EPOCH_NS + 2_000_000_000
moved = self.service.acquire_collection(1)
self.assertFalse(np.array_equal(first.traces[0].s21, moved.traces[0].s21))
def test_pacing_excludes_wait_from_measurement_window(self) -> None:
current_ns = _EPOCH_NS
def sleep(seconds: float) -> None:
nonlocal current_ns
current_ns += round(seconds * 1e9)
self.clock.side_effect = lambda: current_ns
self.sleep.side_effect = sleep
first = self.service.acquire_collection()
current_ns += 20_000_000
second = self.service.acquire_collection()
self.sleep.assert_called_once()
self.assertAlmostEqual(self.sleep.call_args.args[0], .03)
self.assertEqual(second.capture_start_ns - first.capture_start_ns, 50_000_000)
self.assertEqual(second.capture_start_ns, second.capture_end_ns)
def test_full_motion_stays_inside_original_sweep_unambiguous_range(self) -> None:
# The existing 201-point 1 MHz–6 GHz profile aliases paths beyond about 5 m.
# Check its actual 4x4 geometry throughout the cycle, including both extremes.
self.config.input_switch.positions = self.config.output_switch.positions = 4
self.config.combos = self.config.build_runtime_combos()
tx = {i: (x, 0., 0.) for i, x in enumerate([.905, -.905, .905, -.905])}
rx = {i: (x, 0., 0.) for i, x in enumerate([-.18, .485, -.49, .185])}
self.config.gpr.tx_geometry = [GprTxGeometryModel(i, *position) for i, position in tx.items()]
self.config.gpr.rx_geometry = [GprRxGeometryModel(i, *position) for i, position in rx.items()]
service = PointTargetMockService(self.config)
unambiguous_range_m = 3e8 / (2 * ((6e9 - 1e6) / (201 - 1)))
ranges = []
for seconds in np.linspace(0, 8, 81):
target = service._target_position(_EPOCH_NS + round(seconds * 1e9), tx, rx)
for transmitter in tx.values():
for receiver in rx.values():
ranges.append((np.linalg.norm(target - transmitter) + np.linalg.norm(target - receiver)) / 2)
self.assertLess(max(ranges), unambiguous_range_m)
def test_single_manual_capture_selects_actual_antenna_and_keeps_capture_time(self) -> None:
session = SequentialCaptureSession(self.config, "s21_calibration", "mock_geometry")
session.open()
try:
first = session.capture_current_combo()
second = session.capture_current_combo()
finally:
session.close()
self.assertEqual((first.combo, second.combo), (ComboKey(0, 0), ComboKey(1, 0)))
self.assertFalse(np.array_equal(first.s21, second.s21))
self.assertEqual((second.capture_start_ns, second.capture_end_ns), (_EPOCH_NS, _EPOCH_NS))
def test_factories_share_scene_and_mock_widened_matrix_needs_no_hardware(self) -> None:
single = create_single_radar_service(self.config)
self.assertIsInstance(single, PointTargetMockService)
single.configure(self.config.radar.sweep)
self.assertIsInstance(create_acquisition_service(self.config), PointTargetMockService)
self.config.radar.model = RunConfigModel.LIBREVNA_MULTI_MODEL
self.config.radar.multi_device.stimulus_switch_positions = 2
self.config.radar.multi_device.input_switch_positions = 2
self.config.radar.multi_device.output_switch_positions = 2
self.config.apply_device_model_constraints()
matrix = create_acquisition_service(self.config)
self.assertIsInstance(matrix, PointTargetMockService)
matrix.open()
matrix.configure(self.config.radar.sweep)
collection = matrix.acquire_collection()
matrix.close()
self.assertEqual(len(collection.traces), 64)
self.assertEqual([(t.combo.input, t.combo.output) for t in collection.traces], [(i, o) for o in range(8) for i in range(8)])
np.testing.assert_array_equal(collection.traces[0].s21, single.acquire().s21)
def test_multi_variant_single_capture_drains_each_variant_before_sampling(self) -> None:
radar = mock.Mock(wraps=self.service)
events = []
radar.drain_after_switch = mock.Mock(side_effect=lambda: events.append("drain"))
radar.acquire.side_effect = lambda: (events.append("acquire"), self.service.acquire())[1]
variants = [RadarConfigVariant(Path(name), name, self.config, name) for name in ("a", "b")]
with mock.patch(
"python_app.workflows.multi_radar_capture_workflow.create_single_radar_service",
return_value=radar,
):
session = MultiRadarSequentialCaptureSession(
base_config=self.config, kind="s21_calibration", set_name="variants", radar_variants=variants,
)
session.open()
try:
session.capture_current_combo()
batch = session.capture_current_combo()
finally:
session.close()
self.assertEqual(events, ["drain", "acquire"] * 4)
self.assertEqual([trace.combo for trace in batch.traces], [ComboKey(1, 0)] * 2)
for trace in batch.traces:
self.assertEqual((trace.capture_start_ns, trace.capture_end_ns), (_EPOCH_NS, _EPOCH_NS))
def test_invalid_or_unconfigured_mock_fails_clearly(self) -> None:
with self.assertRaisesRegex(ValueError, "outside the antenna matrix"):
self.service.acquire_combo_collection(input_pos=2, output_pos=0)
with self.assertRaisesRegex(RuntimeError, "not configured"):
PointTargetMockService(self.config).acquire_collection()
self.config.input_switch.positions = 0
self.config.combos = []
with self.assertRaisesRegex(ValueError, "Switch positions must be positive integers"):
PointTargetMockService(self.config)