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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user