feat: CI test suite phases A+B, WaveformConfig separation, dead golden code cleanup
- Phase A: Remove self-blessing golden test from FPGA regression, wire MF co-sim (4 scenarios) into run_regression.sh, add opcode count guards to cross-layer tests (+3 tests) - Phase B: Add radar_params.vh parser and architectural param consistency tests (+7 tests), add banned stale-value pattern scanner (+1 test) - Separate WaveformConfig.range_resolution_m (physical, bandwidth-dependent) from bin_spacing_m (sample-rate dependent); rename all callers - Remove 151 lines of dead golden generate/compare code from tb_radar_receiver_final.v; testbench now runs structural + bounds only - Untrack generated MF co-sim CSV files, gitignore tb/golden/ directory CI: 256 tests total (168 python + 40 cross-layer + 27 FPGA + 21 MCU), all green
This commit is contained in:
@@ -58,9 +58,9 @@ class TestRadarSettings(unittest.TestCase):
|
||||
|
||||
def test_has_physical_conversion_fields(self):
|
||||
s = _models().RadarSettings()
|
||||
self.assertIsInstance(s.range_resolution, float)
|
||||
self.assertIsInstance(s.range_bin_spacing, float)
|
||||
self.assertIsInstance(s.velocity_resolution, float)
|
||||
self.assertGreater(s.range_resolution, 0)
|
||||
self.assertGreater(s.range_bin_spacing, 0)
|
||||
self.assertGreater(s.velocity_resolution, 0)
|
||||
|
||||
def test_defaults(self):
|
||||
@@ -436,10 +436,19 @@ class TestWaveformConfig(unittest.TestCase):
|
||||
self.assertEqual(wc.decimation_factor, 16)
|
||||
|
||||
def test_range_resolution(self):
|
||||
"""range_resolution_m should be ~24.0 m/bin with PLFM defaults."""
|
||||
"""bin_spacing_m should be ~24.0 m/bin with PLFM defaults."""
|
||||
from v7.models import WaveformConfig
|
||||
wc = WaveformConfig()
|
||||
self.assertAlmostEqual(wc.range_resolution_m, 23.98, places=1)
|
||||
self.assertAlmostEqual(wc.bin_spacing_m, 23.98, places=1)
|
||||
|
||||
def test_range_resolution_physical(self):
|
||||
"""range_resolution_m = c/(2*BW), ~7.5 m at 20 MHz BW."""
|
||||
from v7.models import WaveformConfig
|
||||
wc = WaveformConfig()
|
||||
self.assertAlmostEqual(wc.range_resolution_m, 7.49, places=1)
|
||||
# 30 MHz BW → 5.0 m resolution
|
||||
wc30 = WaveformConfig(bandwidth_hz=30e6)
|
||||
self.assertAlmostEqual(wc30.range_resolution_m, 4.996, places=1)
|
||||
|
||||
def test_velocity_resolution(self):
|
||||
"""velocity_resolution_mps should be ~2.67 m/s/bin."""
|
||||
@@ -448,10 +457,10 @@ class TestWaveformConfig(unittest.TestCase):
|
||||
self.assertAlmostEqual(wc.velocity_resolution_mps, 2.67, places=1)
|
||||
|
||||
def test_max_range(self):
|
||||
"""max_range_m = range_resolution * n_range_bins."""
|
||||
"""max_range_m = bin_spacing * n_range_bins."""
|
||||
from v7.models import WaveformConfig
|
||||
wc = WaveformConfig()
|
||||
self.assertAlmostEqual(wc.max_range_m, wc.range_resolution_m * 64, places=1)
|
||||
self.assertAlmostEqual(wc.max_range_m, wc.bin_spacing_m * 64, places=1)
|
||||
|
||||
def test_max_velocity(self):
|
||||
"""max_velocity_mps = velocity_resolution * n_doppler_bins / 2."""
|
||||
@@ -467,9 +476,9 @@ class TestWaveformConfig(unittest.TestCase):
|
||||
"""Non-default parameters correctly change derived values."""
|
||||
from v7.models import WaveformConfig
|
||||
wc1 = WaveformConfig()
|
||||
# Matched-filter: range_per_bin = c/(2*fs)*dec — proportional to 1/fs
|
||||
wc2 = WaveformConfig(sample_rate_hz=200e6) # double fs → halve range res
|
||||
self.assertAlmostEqual(wc2.range_resolution_m, wc1.range_resolution_m / 2, places=2)
|
||||
# Matched-filter: bin_spacing = c/(2*fs)*dec — proportional to 1/fs
|
||||
wc2 = WaveformConfig(sample_rate_hz=200e6) # double fs → halve bin spacing
|
||||
self.assertAlmostEqual(wc2.bin_spacing_m, wc1.bin_spacing_m / 2, places=2)
|
||||
|
||||
def test_zero_center_freq_velocity(self):
|
||||
"""Zero center freq should cause ZeroDivisionError in velocity calc."""
|
||||
@@ -927,7 +936,7 @@ class TestExtractTargetsFromFrame(unittest.TestCase):
|
||||
"""Detection at range bin 10 → range = 10 * range_resolution."""
|
||||
from v7.processing import extract_targets_from_frame
|
||||
frame = self._make_frame(det_cells=[(10, 16)]) # dbin=16 = center → vel=0
|
||||
targets = extract_targets_from_frame(frame, range_resolution=23.98)
|
||||
targets = extract_targets_from_frame(frame, bin_spacing=23.98)
|
||||
self.assertEqual(len(targets), 1)
|
||||
self.assertAlmostEqual(targets[0].range, 10 * 23.98, places=1)
|
||||
self.assertAlmostEqual(targets[0].velocity, 0.0, places=2)
|
||||
@@ -956,7 +965,7 @@ class TestExtractTargetsFromFrame(unittest.TestCase):
|
||||
pitch=0.0, heading=90.0)
|
||||
frame = self._make_frame(det_cells=[(10, 16)])
|
||||
targets = extract_targets_from_frame(
|
||||
frame, range_resolution=100.0, gps=gps)
|
||||
frame, bin_spacing=100.0, gps=gps)
|
||||
# Should be roughly east of radar position
|
||||
self.assertAlmostEqual(targets[0].latitude, 41.9, places=2)
|
||||
self.assertGreater(targets[0].longitude, 12.5)
|
||||
|
||||
@@ -105,11 +105,11 @@ class RadarSettings:
|
||||
tab and Opcode enum in radar_protocol.py. This dataclass holds only
|
||||
host-side display/map settings and physical-unit conversion factors.
|
||||
|
||||
range_resolution and velocity_resolution should be calibrated to
|
||||
range_bin_spacing and velocity_resolution should be calibrated to
|
||||
the actual waveform parameters.
|
||||
"""
|
||||
system_frequency: float = 10.5e9 # Hz (PLFM TX LO, verified from ADF4382 config)
|
||||
range_resolution: float = 24.0 # Meters per decimated range bin (c/(2*100MSPS)*16)
|
||||
range_bin_spacing: float = 24.0 # Meters per decimated range bin (c/(2*100MSPS)*16)
|
||||
velocity_resolution: float = 2.67 # m/s per Doppler bin (lam/(2*32*167us))
|
||||
max_distance: float = 1536 # Max detection range (m) -- 64 bins x 24 m (3 km mode)
|
||||
map_size: float = 1536 # Map display size (m)
|
||||
@@ -216,18 +216,30 @@ class WaveformConfig:
|
||||
decimation_factor: int = 16 # 1024 → 64
|
||||
|
||||
@property
|
||||
def range_resolution_m(self) -> float:
|
||||
def bin_spacing_m(self) -> float:
|
||||
"""Meters per decimated range bin (matched-filter receiver).
|
||||
|
||||
For matched-filter pulse compression: bin spacing = c / (2 * fs).
|
||||
After decimation the bin spacing grows by *decimation_factor*.
|
||||
This is independent of chirp bandwidth (BW affects resolution, not
|
||||
bin spacing).
|
||||
This is independent of chirp bandwidth (BW affects physical
|
||||
resolution, not bin spacing).
|
||||
"""
|
||||
c = 299_792_458.0
|
||||
raw_bin = c / (2.0 * self.sample_rate_hz)
|
||||
return raw_bin * self.decimation_factor
|
||||
|
||||
@property
|
||||
def range_resolution_m(self) -> float:
|
||||
"""Physical range resolution in meters, set by chirp bandwidth.
|
||||
|
||||
range_resolution = c / (2 * BW).
|
||||
At 20 MHz BW → 7.5 m; at 30 MHz BW → 5.0 m.
|
||||
This is distinct from bin_spacing_m (which depends on sample rate
|
||||
and decimation factor, not bandwidth).
|
||||
"""
|
||||
c = 299_792_458.0
|
||||
return c / (2.0 * self.bandwidth_hz)
|
||||
|
||||
@property
|
||||
def velocity_resolution_mps(self) -> float:
|
||||
"""m/s per Doppler bin. lambda / (2 * n_doppler * PRI)."""
|
||||
@@ -238,7 +250,7 @@ class WaveformConfig:
|
||||
@property
|
||||
def max_range_m(self) -> float:
|
||||
"""Maximum unambiguous range in meters."""
|
||||
return self.range_resolution_m * self.n_range_bins
|
||||
return self.bin_spacing_m * self.n_range_bins
|
||||
|
||||
@property
|
||||
def max_velocity_mps(self) -> float:
|
||||
|
||||
@@ -490,7 +490,7 @@ def polar_to_geographic(
|
||||
|
||||
def extract_targets_from_frame(
|
||||
frame,
|
||||
range_resolution: float = 1.0,
|
||||
bin_spacing: float = 1.0,
|
||||
velocity_resolution: float = 1.0,
|
||||
gps: GPSData | None = None,
|
||||
) -> list[RadarTarget]:
|
||||
@@ -503,8 +503,8 @@ def extract_targets_from_frame(
|
||||
----------
|
||||
frame : RadarFrame
|
||||
Frame with populated ``detections``, ``magnitude``, ``range_doppler_i/q``.
|
||||
range_resolution : float
|
||||
Meters per range bin.
|
||||
bin_spacing : float
|
||||
Meters per range bin (bin spacing, NOT bandwidth-limited resolution).
|
||||
velocity_resolution : float
|
||||
m/s per Doppler bin.
|
||||
gps : GPSData | None
|
||||
@@ -525,7 +525,7 @@ def extract_targets_from_frame(
|
||||
mag = float(frame.magnitude[rbin, dbin])
|
||||
snr = 10.0 * math.log10(max(mag, 1.0)) if mag > 0 else 0.0
|
||||
|
||||
range_m = float(rbin) * range_resolution
|
||||
range_m = float(rbin) * bin_spacing
|
||||
velocity_ms = float(dbin - doppler_center) * velocity_resolution
|
||||
|
||||
lat, lon, azimuth, elevation = 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
@@ -169,7 +169,7 @@ class RadarDataWorker(QThread):
|
||||
The FPGA already does: FFT, MTI, CFAR, DC notch.
|
||||
Host-side DSP adds: clustering, tracking, geo-coordinate mapping.
|
||||
|
||||
Bin-to-physical conversion uses RadarSettings.range_resolution
|
||||
Bin-to-physical conversion uses RadarSettings.range_bin_spacing
|
||||
and velocity_resolution (should be calibrated to actual waveform).
|
||||
"""
|
||||
targets: list[RadarTarget] = []
|
||||
@@ -180,7 +180,7 @@ class RadarDataWorker(QThread):
|
||||
|
||||
# Extract detections from FPGA CFAR flags
|
||||
det_indices = np.argwhere(frame.detections > 0)
|
||||
r_res = self._settings.range_resolution
|
||||
r_res = self._settings.range_bin_spacing
|
||||
v_res = self._settings.velocity_resolution
|
||||
|
||||
for idx in det_indices:
|
||||
@@ -559,7 +559,7 @@ class ReplayWorker(QThread):
|
||||
# Target extraction
|
||||
targets = self._extract_targets(
|
||||
frame,
|
||||
range_resolution=self._waveform.range_resolution_m,
|
||||
bin_spacing=self._waveform.bin_spacing_m,
|
||||
velocity_resolution=self._waveform.velocity_resolution_mps,
|
||||
gps=self._gps,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user