222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""Tests for the Kamil ADC 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.kamil_adc.processing import (
|
|
KamilAdcProcessingParams,
|
|
KamilAdcSweepProcessor,
|
|
)
|
|
|
|
|
|
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))
|
|
|
|
|
|
# Calibration shared by the processor tests: phase 0 rad -> 2 GHz, phase 100 rad
|
|
# -> 4 GHz (slope 2e7 Hz/rad). Band [2.5, 3.5] GHz corresponds to phase [25, 75].
|
|
_CALIBRATION = dict(
|
|
phase0_rad=0.0,
|
|
freq0_hz=2_000_000_000.0,
|
|
phase1_rad=100.0,
|
|
freq1_hz=4_000_000_000.0,
|
|
band_start_hz=2_500_000_000.0,
|
|
band_stop_hz=3_500_000_000.0,
|
|
band_points=11,
|
|
)
|
|
|
|
|
|
class ProcessingParamsTest(unittest.TestCase):
|
|
def _params(self, **overrides: float) -> KamilAdcProcessingParams:
|
|
return KamilAdcProcessingParams(**{**_CALIBRATION, **overrides}) # type: ignore[arg-type]
|
|
|
|
def test_slope_is_constant_from_anchors(self) -> None:
|
|
self.assertAlmostEqual(self._params().hz_per_rad, 2.0e7)
|
|
|
|
def test_rejects_equal_phase_anchors(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "distinct phases"):
|
|
self._params(phase0_rad=5.0, phase1_rad=5.0)
|
|
|
|
def test_rejects_equal_frequency_anchors(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "distinct frequencies"):
|
|
self._params(freq0_hz=3.0e9, freq1_hz=3.0e9)
|
|
|
|
def test_rejects_inverted_band(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "stop_hz must be greater"):
|
|
self._params(band_start_hz=4.0e9, band_stop_hz=3.0e9)
|
|
|
|
def test_rejects_degenerate_point_count(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
|
|
self._params(band_points=1)
|
|
|
|
|
|
class SweepProcessorTest(unittest.TestCase):
|
|
def _processor(self, **overrides: float) -> KamilAdcSweepProcessor:
|
|
return KamilAdcSweepProcessor(KamilAdcProcessingParams(**{**_CALIBRATION, **overrides})) # type: ignore[arg-type]
|
|
|
|
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)
|
|
self.assertIsNotNone(s_short)
|
|
self.assertIsNotNone(s_long)
|
|
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)
|
|
self.assertIsNotNone(result)
|
|
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)
|
|
self.assertIsNotNone(result)
|
|
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)
|
|
self.assertIsNotNone(result)
|
|
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)
|
|
self.assertIsNotNone(result)
|
|
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)
|
|
self.assertIsNotNone(result)
|
|
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) # <pi: untouched
|
|
self.assertAlmostEqual(kept_anchor, 0.40, places=6)
|
|
wrapped, wrapped_anchor = processor._align_phase_branch(np.array([0.05, 1.0, 2.0]) - 2.0 * np.pi)
|
|
np.testing.assert_allclose(wrapped, np.array([0.05, 1.0, 2.0]), atol=1e-9) # turn undone
|
|
self.assertAlmostEqual(wrapped_anchor, 0.05, places=6)
|
|
|
|
def test_rejected_sweep_does_not_update_branch_tracker(self) -> 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
|
|
self.assertIsNotNone(anchor_after_accept)
|
|
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))
|
|
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.
|
|
self.assertAlmostEqual(processor._previous_anchor_rad, 3.3, places=2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|