"""Tests for the RadioPhotonic radar (RPH) reference-channel signal processing. References are built from an explicit *unwrapped* phase ramp whose first sample lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that ``np.unwrap(np.angle(ref))`` recovers exactly the phase we specify. This mirrors how a real swept reference behaves (absolute phase anchored at sample 0, accumulating forward) and lets us reason precisely about the frequency mapping. """ from __future__ import annotations import unittest import numpy as np from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor from python_app.models.run_config_schema import RphRadarModel, RphRadarBandModel, RphRadarPhaseCalibrationModel def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray: """A reference signal whose unwrapped phase equals ``phase`` (radians).""" return amplitude * np.exp(1j * np.asarray(phase, dtype=np.float64)) class SweepProcessorTest(unittest.TestCase): def _processor(self, **phase: float) -> RphRadarSweepProcessor: calibration = {"phase0_rad": 0., "freq0_hz": 2e9, "phase1_rad": 100., "freq1_hz": 4e9} calibration.update(phase) return RphRadarSweepProcessor(RphRadarModel( phase_calibration=RphRadarPhaseCalibrationModel(**calibration), band=RphRadarBandModel(start_hz=2.5e9, stop_hz=3.5e9, points=11), )) def test_grid_is_fixed_and_identical_across_calls(self) -> None: processor = self._processor() grid = processor.grid_hz self.assertEqual(grid.shape, (11,)) self.assertAlmostEqual(float(grid[0]), 2.5e9) self.assertAlmostEqual(float(grid[-1]), 3.5e9) np.testing.assert_array_equal(grid, processor.grid_hz) def test_frequency_axis_follows_calibration_law(self) -> None: processor = self._processor() # A dense ramp 0 -> 50 rad: endpoints map to 2 GHz and 3 GHz. ref = _reference(np.linspace(0.0, 50.0, 201)) freqs = processor.reference_frequency_axis(ref) self.assertAlmostEqual(float(freqs[0]), 2.0e9, delta=1.0) self.assertAlmostEqual(float(freqs[-1]), 3.0e9, delta=1.0) def test_uses_config_constants_not_sweep_endpoints(self) -> None: """Two sweeps with different phase spans map a given absolute phase to the SAME frequency — proving fixed config anchors, not endpoint normalization.""" processor = self._processor() ref_short = _reference(np.linspace(0.0, 100.0, 401)) # spans phase [0, 100] ref_long = _reference(np.linspace(0.0, 130.0, 521)) # spans phase [0, 130] # Encode S(f) = (f - 3 GHz) / 1 GHz, a line in TRUE frequency. line = lambda ref: ((processor.reference_frequency_axis(ref) - 3.0e9) / 1.0e9) * np.abs(ref) s_short = processor.process(line(ref_short), ref_short) s_long = processor.process(line(ref_long), ref_long) assert s_short is not None assert s_long is not None expected = (processor.grid_hz.astype(np.float64) - 3.0e9) / 1.0e9 # Endpoint normalization would compress the longer sweep's axis and break this. np.testing.assert_allclose(s_short.real, expected, atol=1e-3) np.testing.assert_allclose(s_long.real, expected, atol=1e-3) np.testing.assert_allclose(s_short.imag, 0.0, atol=1e-3) def test_amplitude_normalization_divides_by_reference_magnitude(self) -> None: processor = self._processor() ref = _reference(np.linspace(0.0, 100.0, 401), amplitude=4.0) # |main| = 8 everywhere -> |S| = 8 / 4 = 2. main = 8.0 * np.exp(1j * np.angle(ref)) result = processor.process(main, ref) assert result is not None np.testing.assert_allclose(np.abs(result), 2.0, atol=1e-3) def test_rejects_sweep_that_does_not_cover_band(self) -> None: processor = self._processor() # Phase [0, 40] -> freq [2.0, 2.8] GHz, short of the 3.5 GHz band stop. ref = _reference(np.linspace(0.0, 40.0, 201)) self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), ref)) def test_accepts_sweep_that_covers_band(self) -> None: processor = self._processor() ref = _reference(np.linspace(0.0, 100.0, 401)) # freq [2.0, 4.0] GHz result = processor.process(np.abs(ref).astype(np.complex128), ref) assert result is not None self.assertEqual(result.shape, (11,)) self.assertEqual(result.dtype, np.complex64) np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3) # main=|ref| -> |S|=1 def test_interpolates_linear_trace_onto_grid(self) -> None: processor = self._processor() ref = _reference(np.linspace(0.0, 100.0, 401)) freqs = processor.reference_frequency_axis(ref) # S(f) = (f - 2.5 GHz) / 1 GHz -> must resample to that same line on the grid. main = ((freqs - 2.5e9) / 1.0e9) * np.abs(ref) result = processor.process(main, ref) assert result is not None expected = (processor.grid_hz.astype(np.float64) - 2.5e9) / 1.0e9 np.testing.assert_allclose(result.real, expected, atol=1e-3) np.testing.assert_allclose(result.imag, 0.0, atol=1e-3) def test_handles_descending_phase_direction(self) -> None: # phase 0 -> 2 GHz, phase -100 -> 4 GHz (negative slope). Phase ramp # 0 -> -100 therefore sweeps frequency UP across the band. processor = self._processor(phase1_rad=-100.0) ref = _reference(np.linspace(0.0, -100.0, 401)) result = processor.process(np.abs(ref).astype(np.complex128), ref) assert result is not None self.assertEqual(result.shape, (11,)) np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3) def test_rejects_too_few_points(self) -> None: processor = self._processor() one = np.ones(1, dtype=np.complex128) self.assertIsNone(processor.process(one, one)) def test_rejects_length_mismatch(self) -> None: processor = self._processor() self.assertIsNone( processor.process(np.ones(10, dtype=np.complex128), np.ones(9, dtype=np.complex128)) ) def test_drops_zero_amplitude_reference_points(self) -> None: processor = self._processor() ref = _reference(np.linspace(0.0, 100.0, 401)) main = np.abs(ref).astype(np.complex128) ref[10] = 0.0 # vanished reference samples must be ignored, not crash ref[200] = 0.0 result = processor.process(main, ref) assert result is not None self.assertTrue(np.all(np.isfinite(result.real))) self.assertTrue(np.all(np.isfinite(result.imag))) # -- cross-sweep branch tracking ------------------------------------------ def test_align_phase_branch_anchors_first_sweep_to_calibration(self) -> None: # No previous anchor yet -> snap onto the branch nearest phase0 (=0 here). processor = self._processor() phase = np.array([0.05, 1.0, 2.0]) + 2.0 * np.pi # one turn above phase0 aligned, anchor = processor._align_phase_branch(phase) np.testing.assert_allclose(aligned, np.array([0.05, 1.0, 2.0]), atol=1e-9) self.assertAlmostEqual(anchor, 0.05, places=6) def test_align_phase_branch_snaps_to_previous_anchor(self) -> None: # A genuine sub-pi float is preserved; a full-turn anchor wrap is undone. processor = self._processor() processor._previous_anchor_rad = 0.05 kept, kept_anchor = processor._align_phase_branch(np.array([0.40, 1.4, 2.4])) np.testing.assert_allclose(kept, np.array([0.40, 1.4, 2.4]), atol=1e-9) # None: # The anchor is committed only on accepted sweeps, so a rejected sweep # cannot latch the tracker onto a wrong branch. processor = self._processor() covering = _reference(np.linspace(0.0, 100.0, 401)) self.assertIsNotNone(processor.process(np.abs(covering).astype(np.complex128), covering)) anchor_after_accept = processor._previous_anchor_rad assert anchor_after_accept is not None short = _reference(np.linspace(0.0, 40.0, 201)) # does not span the band self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), short)) self.assertEqual(processor._previous_anchor_rad, anchor_after_accept) def test_cross_sweep_unwrap_recovers_continuous_anchor_across_a_wrap(self) -> None: # Two physically adjacent sweeps whose anchor straddles +pi: np.angle wraps # the second's anchor by ~2*pi, but the cross-sweep tracking must recover # the continuous value (~3.3), not the wrapped one (~-2.98). processor = self._processor() ramp_home = np.linspace(3.0, 80.0, 401) # anchor 3.0 (< pi), covers band ramp_drift = np.linspace(3.3, 80.3, 401) # anchor 3.3 (> pi) -> angle wraps ref_home = _reference(ramp_home) ref_drift = _reference(ramp_drift) self.assertIsNotNone(processor.process(np.abs(ref_home).astype(np.complex128), ref_home)) assert processor._previous_anchor_rad is not None self.assertAlmostEqual(processor._previous_anchor_rad, 3.0, places=2) self.assertIsNotNone(processor.process(np.abs(ref_drift).astype(np.complex128), ref_drift)) # Without correction this would be ~-2.98 (one turn below); corrected it # continues smoothly from 3.0 to ~3.3. assert processor._previous_anchor_rad is not None self.assertAlmostEqual(processor._previous_anchor_rad, 3.3, places=2) if __name__ == "__main__": unittest.main()