feat: Phase C co-sim tests + Doppler compare wired into CI

- Add 15 Tier 5 pipeline co-sim tests (cross-layer): decimator, MTI,
  Doppler, DC notch, CFAR, and full-chain golden match
- Add 4 SoftwareFPGA tests (test_v7): golden fixture integrity,
  process_chirps wiring validation against manual chain
- Wire compare_doppler.py into run_regression.sh with 3 scenarios
  (stationary, moving, two_targets) replacing single unvalidated run
- Harden _load_npy to raise on missing files; expand sentinel check
  to all 14 golden artifacts
- DC notch test verifies both zero and pass-through bins

Test counts: 172 python + 55 cross-layer + 21 MCU + 29 FPGA = 277 total
This commit is contained in:
Jason
2026-04-15 15:21:06 +05:45
parent fffac4107d
commit cac86f024b
3 changed files with 678 additions and 3 deletions
@@ -1100,3 +1100,392 @@ class TestTier4BannedPatterns:
+ "\n".join(f" {h}" for h in all_hits[:20])
+ ("\n ... and more" if len(all_hits) > 20 else "")
)
# ===================================================================
# TIER 5: Pipeline Co-Simulation (Python golden_reference round-trip)
# ===================================================================
#
# These tests load committed .npy golden reference data and run the
# Python bit-accurate model functions on intermediate outputs to verify
# stage-to-stage consistency. This catches:
# - Golden reference function drift (model changed but .npy not regenerated)
# - Stage boundary mismatches (one stage's output shape/scale doesn't
# match the next stage's expected input)
# - Bit-accuracy regressions in the Python model
#
# The .npy files were generated by golden_reference.py:main() using
# real ADI Phaser test data. These tests do NOT re-run golden_reference
# main() — they verify that each processing function, when fed the
# committed intermediate outputs, reproduces the committed downstream
# outputs. This is fundamentally different from self-blessing because
# the .npy files are committed artifacts (not regenerated each run).
import numpy as np # noqa: E402
_COSIM_HEX_DIR = cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "tb" / "cosim" / "real_data" / "hex"
_GOLDEN_REF_DIR = cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "tb" / "cosim" / "real_data"
# Add golden_reference to path for imports
if str(_GOLDEN_REF_DIR) not in sys.path:
sys.path.insert(0, str(_GOLDEN_REF_DIR))
def _load_npy(name: str) -> np.ndarray:
"""Load a committed .npy file from the cosim hex directory.
Raises FileNotFoundError if the file is missing — use _skip_if_no_golden()
as a sentinel check at the start of each test to skip gracefully when the
entire golden dataset is absent.
"""
path = _COSIM_HEX_DIR / name
return np.load(str(path))
# Every Tier 5 artifact that is loaded by these tests. If any is missing, the
# committed golden dataset is incomplete and all Tier 5 tests are skipped.
_GOLDEN_SENTINELS = [
"decimated_range_i.npy",
"decimated_range_q.npy",
"doppler_map_i.npy",
"doppler_map_q.npy",
"fullchain_mti_i.npy",
"fullchain_mti_q.npy",
"fullchain_mti_doppler_i.npy",
"fullchain_mti_doppler_q.npy",
"fullchain_cfar_flags.npy",
"fullchain_cfar_mag.npy",
"fullchain_cfar_thr.npy",
"range_fft_all_i.npy",
"range_fft_all_q.npy",
"detection_mag.npy",
]
def _skip_if_no_golden():
"""Skip test if committed golden .npy data is not available."""
for sentinel in _GOLDEN_SENTINELS:
if not (_COSIM_HEX_DIR / sentinel).exists():
pytest.skip(f"Golden data incomplete: {sentinel} not found")
class TestTier5PipelineDecimToMTI:
"""Verify decimated range → MTI canceller stage consistency."""
def test_mti_canceller_reproduces_committed_output(self):
"""run_mti_canceller(decimated) must match fullchain_mti .npy files."""
_skip_if_no_golden()
from golden_reference import run_mti_canceller
dec_i = _load_npy("decimated_range_i.npy")
dec_q = _load_npy("decimated_range_q.npy")
expected_i = _load_npy("fullchain_mti_i.npy")
expected_q = _load_npy("fullchain_mti_q.npy")
got_i, got_q = run_mti_canceller(dec_i, dec_q, enable=True)
np.testing.assert_array_equal(got_i, expected_i,
err_msg="MTI I output drifted from committed golden data")
np.testing.assert_array_equal(got_q, expected_q,
err_msg="MTI Q output drifted from committed golden data")
def test_mti_passthrough_equals_input(self):
"""MTI with enable=False must pass through unchanged."""
_skip_if_no_golden()
from golden_reference import run_mti_canceller
dec_i = _load_npy("decimated_range_i.npy")
dec_q = _load_npy("decimated_range_q.npy")
got_i, got_q = run_mti_canceller(dec_i, dec_q, enable=False)
np.testing.assert_array_equal(got_i, dec_i)
np.testing.assert_array_equal(got_q, dec_q)
def test_mti_first_chirp_is_zeroed(self):
"""MTI 2-pulse canceller must zero the first chirp (no previous data)."""
_skip_if_no_golden()
from golden_reference import run_mti_canceller
dec_i = _load_npy("decimated_range_i.npy")
dec_q = _load_npy("decimated_range_q.npy")
got_i, got_q = run_mti_canceller(dec_i, dec_q, enable=True)
np.testing.assert_array_equal(got_i[0], 0,
err_msg="MTI chirp 0 should be all zeros")
np.testing.assert_array_equal(got_q[0], 0,
err_msg="MTI chirp 0 should be all zeros")
class TestTier5PipelineDoppler:
"""Verify Doppler FFT stage consistency against committed golden data."""
def test_doppler_no_mti_reproduces_committed_output(self):
"""run_doppler_fft(range_fft_all) must match doppler_map .npy (direct path).
NOTE: doppler_map was generated from full range FFT output (32, 1024),
NOT from decimated data. The function only processes the first 64
columns, but it needs the twiddle file that golden_reference.py used.
"""
_skip_if_no_golden()
from golden_reference import run_doppler_fft
range_i = _load_npy("range_fft_all_i.npy")
range_q = _load_npy("range_fft_all_q.npy")
expected_i = _load_npy("doppler_map_i.npy")
expected_q = _load_npy("doppler_map_q.npy")
twiddle_16 = str(cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "fft_twiddle_16.mem")
if not Path(twiddle_16).exists():
pytest.skip("fft_twiddle_16.mem not found")
got_i, got_q = run_doppler_fft(range_i, range_q, twiddle_file_16=twiddle_16)
np.testing.assert_array_equal(got_i, expected_i,
err_msg="Doppler I (direct path) drifted from committed golden data")
np.testing.assert_array_equal(got_q, expected_q,
err_msg="Doppler Q (direct path) drifted from committed golden data")
def test_doppler_mti_reproduces_committed_output(self):
"""run_doppler_fft(mti_output) must match fullchain_mti_doppler .npy."""
_skip_if_no_golden()
from golden_reference import run_doppler_fft
mti_i = _load_npy("fullchain_mti_i.npy")
mti_q = _load_npy("fullchain_mti_q.npy")
expected_i = _load_npy("fullchain_mti_doppler_i.npy")
expected_q = _load_npy("fullchain_mti_doppler_q.npy")
twiddle_16 = str(cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "fft_twiddle_16.mem")
if not Path(twiddle_16).exists():
pytest.skip("fft_twiddle_16.mem not found")
got_i, got_q = run_doppler_fft(mti_i, mti_q, twiddle_file_16=twiddle_16)
np.testing.assert_array_equal(got_i, expected_i,
err_msg="Doppler I (MTI path) drifted from committed golden data")
np.testing.assert_array_equal(got_q, expected_q,
err_msg="Doppler Q (MTI path) drifted from committed golden data")
def test_doppler_output_shape(self):
"""Doppler output must be (64, 32) — 64 range bins x 32 Doppler bins."""
_skip_if_no_golden()
from golden_reference import run_doppler_fft
dec_i = _load_npy("decimated_range_i.npy")
dec_q = _load_npy("decimated_range_q.npy")
twiddle_16 = str(
cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "fft_twiddle_16.mem"
)
tw = twiddle_16 if Path(twiddle_16).exists() else None
got_i, got_q = run_doppler_fft(dec_i, dec_q, twiddle_file_16=tw)
assert got_i.shape == (64, 32), f"Expected (64,32), got {got_i.shape}"
assert got_q.shape == (64, 32), f"Expected (64,32), got {got_q.shape}"
def test_doppler_chained_from_decimated_via_mti(self):
"""Full chain: decimated → MTI → Doppler must match committed output."""
_skip_if_no_golden()
from golden_reference import run_mti_canceller, run_doppler_fft
dec_i = _load_npy("decimated_range_i.npy")
dec_q = _load_npy("decimated_range_q.npy")
expected_i = _load_npy("fullchain_mti_doppler_i.npy")
expected_q = _load_npy("fullchain_mti_doppler_q.npy")
twiddle_16 = str(cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "fft_twiddle_16.mem")
if not Path(twiddle_16).exists():
pytest.skip("fft_twiddle_16.mem not found")
mti_i, mti_q = run_mti_canceller(dec_i, dec_q, enable=True)
got_i, got_q = run_doppler_fft(mti_i, mti_q, twiddle_file_16=twiddle_16)
np.testing.assert_array_equal(got_i, expected_i,
err_msg="Chained decim→MTI→Doppler I mismatches committed golden")
np.testing.assert_array_equal(got_q, expected_q,
err_msg="Chained decim→MTI→Doppler Q mismatches committed golden")
class TestTier5PipelineCFAR:
"""Verify CFAR stage against committed golden data."""
# Parameters matching golden_reference.py:main() at lines 1266-1269
CFAR_GUARD = 2
CFAR_TRAIN = 8
CFAR_ALPHA = 0x30
CFAR_MODE = "CA"
DC_NOTCH_WIDTH = 2
def test_cfar_reproduces_committed_output(self):
"""Full chain: MTI Doppler → DC notch → CFAR must match committed .npy."""
_skip_if_no_golden()
from golden_reference import run_dc_notch, run_cfar_ca
doppler_i = _load_npy("fullchain_mti_doppler_i.npy")
doppler_q = _load_npy("fullchain_mti_doppler_q.npy")
expected_flags = _load_npy("fullchain_cfar_flags.npy")
expected_mag = _load_npy("fullchain_cfar_mag.npy")
expected_thr = _load_npy("fullchain_cfar_thr.npy")
# Apply DC notch first (matching golden_reference.py:main() flow)
notch_i, notch_q = run_dc_notch(doppler_i, doppler_q,
width=self.DC_NOTCH_WIDTH)
got_flags, got_mag, got_thr = run_cfar_ca(
notch_i, notch_q,
guard=self.CFAR_GUARD,
train=self.CFAR_TRAIN,
alpha_q44=self.CFAR_ALPHA,
mode=self.CFAR_MODE,
)
np.testing.assert_array_equal(got_flags, expected_flags,
err_msg="CFAR flags drifted from committed golden data")
np.testing.assert_array_equal(got_mag, expected_mag,
err_msg="CFAR magnitudes drifted from committed golden data")
np.testing.assert_array_equal(got_thr, expected_thr,
err_msg="CFAR thresholds drifted from committed golden data")
def test_cfar_output_shapes(self):
"""CFAR outputs must be (64, 32)."""
_skip_if_no_golden()
from golden_reference import run_dc_notch, run_cfar_ca
doppler_i = _load_npy("fullchain_mti_doppler_i.npy")
doppler_q = _load_npy("fullchain_mti_doppler_q.npy")
notch_i, notch_q = run_dc_notch(
doppler_i, doppler_q, width=self.DC_NOTCH_WIDTH,
)
flags, mag, thr = run_cfar_ca(
notch_i, notch_q,
guard=self.CFAR_GUARD,
train=self.CFAR_TRAIN,
alpha_q44=self.CFAR_ALPHA,
mode=self.CFAR_MODE,
)
assert flags.shape == (64, 32)
assert mag.shape == (64, 32)
assert thr.shape == (64, 32)
def test_cfar_flags_are_boolean(self):
"""CFAR flags should be boolean (0 or 1 only)."""
_skip_if_no_golden()
flags = _load_npy("fullchain_cfar_flags.npy")
unique = set(np.unique(flags))
assert unique <= {0, 1}, \
f"CFAR flags contain non-boolean values: {unique}"
def test_cfar_threshold_ge_zero(self):
"""CFAR thresholds must be non-negative (unsigned in RTL)."""
_skip_if_no_golden()
thr = _load_npy("fullchain_cfar_thr.npy")
assert np.all(thr >= 0), "CFAR thresholds contain negative values"
def test_cfar_detection_implies_mag_gt_threshold(self):
"""Where CFAR flags are True, magnitude must exceed threshold."""
_skip_if_no_golden()
flags = _load_npy("fullchain_cfar_flags.npy")
mag = _load_npy("fullchain_cfar_mag.npy")
thr = _load_npy("fullchain_cfar_thr.npy")
detected = flags.astype(bool)
if not np.any(detected):
pytest.skip("No CFAR detections in golden data")
# Where detected, magnitude must be > threshold (strict inequality)
violations = detected & (mag <= thr)
n_violations = int(np.sum(violations))
assert n_violations == 0, (
f"{n_violations} cells detected but mag <= threshold"
)
def test_dc_notch_zeros_correct_bins(self):
"""DC notch with width=2 must zero bins {0,1,15,16,17,31}.
Logic: bin_within_sf < width OR bin_within_sf > (15 - width + 1)
width=2: < 2 → {0,1} per subframe; > 14 → {15} per subframe
Global: {0, 1, 15, 16, 17, 31}
"""
_skip_if_no_golden()
from golden_reference import run_dc_notch
doppler_i = _load_npy("fullchain_mti_doppler_i.npy")
doppler_q = _load_npy("fullchain_mti_doppler_q.npy")
notch_i, notch_q = run_dc_notch(doppler_i, doppler_q, width=2)
# Per subframe: bins < 2 → {0,1} and bins > 14 → {15}
expected_zero_bins = {0, 1, 15, 16, 17, 31}
for dbin in range(32):
if dbin in expected_zero_bins:
assert np.all(notch_i[:, dbin] == 0), \
f"DC notch failed: bin {dbin} should be zero"
assert np.all(notch_q[:, dbin] == 0), \
f"DC notch failed: bin {dbin} should be zero"
else:
assert np.array_equal(notch_i[:, dbin], doppler_i[:, dbin]), \
f"DC notch modified non-notched I bin {dbin}"
assert np.array_equal(notch_q[:, dbin], doppler_q[:, dbin]), \
f"DC notch modified non-notched Q bin {dbin}"
class TestTier5FullPipelineChain:
"""End-to-end: decimated → MTI → Doppler → DC notch → CFAR."""
def test_full_chain_matches_committed_cfar(self):
"""Chaining all stages from decimated input must match committed CFAR output."""
_skip_if_no_golden()
from golden_reference import (
run_mti_canceller, run_doppler_fft, run_dc_notch, run_cfar_ca,
)
dec_i = _load_npy("decimated_range_i.npy")
dec_q = _load_npy("decimated_range_q.npy")
expected_flags = _load_npy("fullchain_cfar_flags.npy")
expected_mag = _load_npy("fullchain_cfar_mag.npy")
twiddle_16 = str(cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "fft_twiddle_16.mem")
if not Path(twiddle_16).exists():
pytest.skip("fft_twiddle_16.mem not found")
# Run full chain
mti_i, mti_q = run_mti_canceller(dec_i, dec_q, enable=True)
dop_i, dop_q = run_doppler_fft(mti_i, mti_q, twiddle_file_16=twiddle_16)
notch_i, notch_q = run_dc_notch(dop_i, dop_q, width=2)
flags, mag, _thr = run_cfar_ca(
notch_i, notch_q, guard=2, train=8, alpha_q44=0x30, mode="CA",
)
np.testing.assert_array_equal(flags, expected_flags,
err_msg="Full chain flags mismatch — pipeline drift detected")
np.testing.assert_array_equal(mag, expected_mag,
err_msg="Full chain magnitudes mismatch — pipeline drift detected")
def test_detection_mag_matches_committed(self):
"""Simple threshold detection magnitude must match committed detection_mag.npy.
detection_mag.npy was generated from the DIRECT path (no decimator, no MTI):
range_fft_all → doppler_fft → run_detection
"""
_skip_if_no_golden()
from golden_reference import run_doppler_fft, run_detection
range_i = _load_npy("range_fft_all_i.npy")
range_q = _load_npy("range_fft_all_q.npy")
expected_mag = _load_npy("detection_mag.npy")
twiddle_16 = str(cp.REPO_ROOT / "9_Firmware" / "9_2_FPGA" / "fft_twiddle_16.mem")
if not Path(twiddle_16).exists():
pytest.skip("fft_twiddle_16.mem not found")
# Direct path (matching golden_reference.py:main() flow for detection_mag)
dop_i, dop_q = run_doppler_fft(range_i, range_q, twiddle_file_16=twiddle_16)
got_mag, _det = run_detection(dop_i, dop_q, threshold=10000)
np.testing.assert_array_equal(got_mag, expected_mag,
err_msg="Detection magnitude drifted from committed golden data")