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