feat: 2048-pt FFT upgrade with decimation=4, 512 output bins, 6m spacing

Complete cross-layer upgrade from 1024-pt/64-bin to 2048-pt/512-bin FFT:

FPGA RTL (14+ modules):
- radar_params.vh: FFT_SIZE=2048, RANGE_BINS=512, 9-bit range, 6-bit stream
- fft_engine.v: 2048-pt FFT with XPM BRAM
- chirp_memory_loader_param.v: 2 segments x 2048 (was 4 x 1024)
- matched_filter_multi_segment.v: BRAM inference for overlap_cache, explicit ov_waddr
- mti_canceller.v: BRAM inference for prev_i/q arrays (was fabric FFs)
- doppler_processor.v: 16384-deep memory, 14-bit addressing
- cfar_ca.v: 512 rows, indentation fix
- radar_receiver_final.v: rising-edge detector for frame_complete, 11-bit sample_addr
- range_bin_decimator.v: 512 output bins
- usb_data_interface_ft2232h.v: bulk per-frame with Manhattan magnitude
- radar_mode_controller.v: XOR edge detector for toggle signals
- rx_gain_control.v: updated for new bin count

Python GUI + Protocol (8 files):
- radar_protocol.py: 512-bin bulk frame parser, LSB-first bitmap
- GUI_V65_Tk.py, v7/*.py: updated for 512 bins, 6m range resolution

Golden data + tests:
- All .hex/.csv/.npy golden references regenerated for 2048/512
- fft_twiddle_2048.mem added
- Deleted stale seg2/seg3 chirp mem files
- 9 new bulk frame cross-layer tests, deleted 6 stale per-sample tests
- Deleted stale tb_cross_layer_ft2232h.v and dead contract_parser functions
- Updated validate_mem_files.py for 2048/2-segment config

MCU: RadarSettings.cpp max_distance/map_size 1536->3072

All 4 CI jobs pass: 285 tests, 0 failures, 0 skips
This commit is contained in:
Jason
2026-04-16 17:27:55 +05:45
parent affa40a9d3
commit e9705e40b7
178 changed files with 687738 additions and 122880 deletions
+53 -53
View File
@@ -66,8 +66,8 @@ class TestRadarSettings(unittest.TestCase):
def test_defaults(self):
s = _models().RadarSettings()
self.assertEqual(s.system_frequency, 10.5e9)
self.assertEqual(s.coverage_radius, 1536)
self.assertEqual(s.max_distance, 1536)
self.assertEqual(s.coverage_radius, 3072)
self.assertEqual(s.max_distance, 3072)
class TestGPSData(unittest.TestCase):
@@ -430,16 +430,16 @@ class TestWaveformConfig(unittest.TestCase):
self.assertEqual(wc.chirp_duration_s, 30e-6)
self.assertEqual(wc.pri_s, 167e-6)
self.assertEqual(wc.center_freq_hz, 10.5e9)
self.assertEqual(wc.n_range_bins, 64)
self.assertEqual(wc.n_range_bins, 512)
self.assertEqual(wc.n_doppler_bins, 32)
self.assertEqual(wc.fft_size, 1024)
self.assertEqual(wc.decimation_factor, 16)
self.assertEqual(wc.fft_size, 2048)
self.assertEqual(wc.decimation_factor, 4)
def test_range_resolution(self):
"""bin_spacing_m should be ~24.0 m/bin with PLFM defaults."""
"""bin_spacing_m should be ~6.0 m/bin with PLFM defaults."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(wc.bin_spacing_m, 23.98, places=1)
self.assertAlmostEqual(wc.bin_spacing_m, 5.996, places=1)
def test_range_resolution_physical(self):
"""range_resolution_m = c/(2*BW), ~7.5 m at 20 MHz BW."""
@@ -460,7 +460,7 @@ class TestWaveformConfig(unittest.TestCase):
"""max_range_m = bin_spacing * n_range_bins."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(wc.max_range_m, wc.bin_spacing_m * 64, places=1)
self.assertAlmostEqual(wc.max_range_m, wc.bin_spacing_m * 512, places=1)
def test_max_velocity(self):
"""max_velocity_mps = velocity_resolution * n_doppler_bins / 2."""
@@ -613,15 +613,15 @@ class TestSoftwareFPGASignalChain(unittest.TestCase):
from v7.software_fpga import SoftwareFPGA
from radar_protocol import RadarFrame
# Load decimated range data as minimal input (32 chirps x 64 bins)
# Load decimated range data as minimal input (32 chirps x 512 bins)
dec_i = np.load(os.path.join(self.COSIM_DIR, "decimated_range_i.npy"))
dec_q = np.load(os.path.join(self.COSIM_DIR, "decimated_range_q.npy"))
# Build fake 1024-sample chirps from decimated data (pad with zeros)
# Build fake 2048-sample chirps from decimated data (pad with zeros)
n_chirps = dec_i.shape[0]
iq_i = np.zeros((n_chirps, 1024), dtype=np.int64)
iq_q = np.zeros((n_chirps, 1024), dtype=np.int64)
# Put decimated data into first 64 bins so FFT has something
iq_i = np.zeros((n_chirps, 2048), dtype=np.int64)
iq_q = np.zeros((n_chirps, 2048), dtype=np.int64)
# Put decimated data into first bins so FFT has something
iq_i[:, :dec_i.shape[1]] = dec_i
iq_q[:, :dec_q.shape[1]] = dec_q
@@ -631,11 +631,11 @@ class TestSoftwareFPGASignalChain(unittest.TestCase):
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.frame_number, 42)
self.assertAlmostEqual(frame.timestamp, 1.0)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.range_doppler_q.shape, (64, 32))
self.assertEqual(frame.magnitude.shape, (64, 32))
self.assertEqual(frame.detections.shape, (64, 32))
self.assertEqual(frame.range_profile.shape, (64,))
self.assertEqual(frame.range_doppler_i.shape, (512, 32))
self.assertEqual(frame.range_doppler_q.shape, (512, 32))
self.assertEqual(frame.magnitude.shape, (512, 32))
self.assertEqual(frame.detections.shape, (512, 32))
self.assertEqual(frame.range_profile.shape, (512,))
self.assertEqual(frame.detection_count, int(frame.detections.sum()))
def test_cfar_enable_changes_detections(self):
@@ -644,8 +644,8 @@ class TestSoftwareFPGASignalChain(unittest.TestCase):
self.skipTest("co-sim data not found")
from v7.software_fpga import SoftwareFPGA
iq_i = np.zeros((32, 1024), dtype=np.int64)
iq_q = np.zeros((32, 1024), dtype=np.int64)
iq_i = np.zeros((32, 2048), dtype=np.int64)
iq_q = np.zeros((32, 2048), dtype=np.int64)
# Inject a single strong tone in bin 10 of every chirp
iq_i[:, 10] = 5000
iq_q[:, 10] = 3000
@@ -662,8 +662,8 @@ class TestSoftwareFPGASignalChain(unittest.TestCase):
# Just verify both produce valid frames — exact counts depend on chain
self.assertIsNotNone(frame_thresh)
self.assertIsNotNone(frame_cfar)
self.assertEqual(frame_thresh.magnitude.shape, (64, 32))
self.assertEqual(frame_cfar.magnitude.shape, (64, 32))
self.assertEqual(frame_thresh.magnitude.shape, (512, 32))
self.assertEqual(frame_cfar.magnitude.shape, (512, 32))
class TestGoldenReferenceReplayFixtures(unittest.TestCase):
@@ -794,11 +794,11 @@ class TestGoldenReferenceReplayFixtures(unittest.TestCase):
from v7.software_fpga import SoftwareFPGA
from radar_protocol import RadarFrame
# Use decimated data padded to 1024 as input (so range FFT has content)
# Use decimated data padded to 2048 as input (so range FFT has content)
dec_i = self._load("decimated_range_i.npy")
dec_q = self._load("decimated_range_q.npy")
iq_i = np.zeros((32, 1024), dtype=np.int64)
iq_q = np.zeros((32, 1024), dtype=np.int64)
iq_i = np.zeros((32, 2048), dtype=np.int64)
iq_q = np.zeros((32, 2048), dtype=np.int64)
iq_i[:, :dec_i.shape[1]] = dec_i
iq_q[:, :dec_q.shape[1]] = dec_q
@@ -809,10 +809,10 @@ class TestGoldenReferenceReplayFixtures(unittest.TestCase):
frame = fpga.process_chirps(iq_i, iq_q, frame_number=99)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.magnitude.shape, (64, 32))
self.assertEqual(frame.detections.shape, (64, 32))
self.assertEqual(frame.range_profile.shape, (64,))
self.assertEqual(frame.range_doppler_i.shape, (512, 32))
self.assertEqual(frame.magnitude.shape, (512, 32))
self.assertEqual(frame.detections.shape, (512, 32))
self.assertEqual(frame.range_profile.shape, (512,))
self.assertEqual(frame.frame_number, 99)
def test_software_fpga_wiring_matches_manual_chain(self):
@@ -824,8 +824,8 @@ class TestGoldenReferenceReplayFixtures(unittest.TestCase):
This catches wiring bugs: wrong stage order, wrong default params,
missing DC notch, etc.
"""
from v7.software_fpga import SoftwareFPGA, TWIDDLE_1024, TWIDDLE_16
if not os.path.exists(TWIDDLE_1024) or not os.path.exists(TWIDDLE_16):
from v7.software_fpga import SoftwareFPGA, TWIDDLE_2048, TWIDDLE_16
if not os.path.exists(TWIDDLE_2048) or not os.path.exists(TWIDDLE_16):
self.skipTest("twiddle files not found")
import sys as _sys
@@ -843,8 +843,8 @@ class TestGoldenReferenceReplayFixtures(unittest.TestCase):
# Deterministic synthetic input — small int16 values to avoid overflow
rng = np.random.RandomState(42)
iq_i = rng.randint(-500, 500, size=(32, 1024), dtype=np.int64)
iq_q = rng.randint(-500, 500, size=(32, 1024), dtype=np.int64)
iq_i = rng.randint(-500, 500, size=(32, 2048), dtype=np.int64)
iq_q = rng.randint(-500, 500, size=(32, 2048), dtype=np.int64)
# --- SoftwareFPGA path (what we're testing) ---
fpga = SoftwareFPGA()
@@ -858,7 +858,7 @@ class TestGoldenReferenceReplayFixtures(unittest.TestCase):
range_q = np.zeros_like(iq_q)
for c in range(32):
range_i[c], range_q[c] = run_range_fft(
iq_i[c], iq_q[c], twiddle_file=TWIDDLE_1024,
iq_i[c], iq_q[c], twiddle_file=TWIDDLE_2048,
)
dec_i, dec_q = run_range_bin_decimator(range_i, range_q)
mti_i, mti_q = run_mti_canceller(dec_i, dec_q, enable=True)
@@ -897,24 +897,24 @@ class TestQuantizeRawIQ(unittest.TestCase):
def test_3d_input(self):
"""3-D (frames, chirps, samples) → uses first frame."""
from v7.software_fpga import quantize_raw_iq
raw = np.random.randn(5, 32, 1024) + 1j * np.random.randn(5, 32, 1024)
raw = np.random.randn(5, 32, 2048) + 1j * np.random.randn(5, 32, 2048)
iq_i, iq_q = quantize_raw_iq(raw)
self.assertEqual(iq_i.shape, (32, 1024))
self.assertEqual(iq_q.shape, (32, 1024))
self.assertEqual(iq_i.shape, (32, 2048))
self.assertEqual(iq_q.shape, (32, 2048))
self.assertTrue(np.all(np.abs(iq_i) <= 32767))
self.assertTrue(np.all(np.abs(iq_q) <= 32767))
def test_2d_input(self):
"""2-D (chirps, samples) → works directly."""
from v7.software_fpga import quantize_raw_iq
raw = np.random.randn(32, 1024) + 1j * np.random.randn(32, 1024)
raw = np.random.randn(32, 2048) + 1j * np.random.randn(32, 2048)
iq_i, _iq_q = quantize_raw_iq(raw)
self.assertEqual(iq_i.shape, (32, 1024))
self.assertEqual(iq_i.shape, (32, 2048))
def test_zero_input(self):
"""All-zero complex input → all-zero output."""
from v7.software_fpga import quantize_raw_iq
raw = np.zeros((32, 1024), dtype=np.complex128)
raw = np.zeros((32, 2048), dtype=np.complex128)
iq_i, iq_q = quantize_raw_iq(raw)
self.assertTrue(np.all(iq_i == 0))
self.assertTrue(np.all(iq_q == 0))
@@ -952,7 +952,7 @@ class TestDetectFormat(unittest.TestCase):
from v7.replay import detect_format, ReplayFormat
import tempfile
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
np.save(f, np.zeros((2, 32, 1024), dtype=np.complex128))
np.save(f, np.zeros((2, 32, 2048), dtype=np.complex128))
tmp = f.name
try:
self.assertEqual(detect_format(tmp), ReplayFormat.RAW_IQ_NPY)
@@ -1004,8 +1004,8 @@ class TestReplayEngineCosim(unittest.TestCase):
engine = ReplayEngine(self.COSIM_DIR)
frame = engine.get_frame(0)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.magnitude.shape, (64, 32))
self.assertEqual(frame.range_doppler_i.shape, (512, 32))
self.assertEqual(frame.magnitude.shape, (512, 32))
def test_get_frame_out_of_range(self):
if not self._available():
@@ -1055,7 +1055,7 @@ class TestReplayEngineRawIQ(unittest.TestCase):
engine = ReplayEngine(tmp, software_fpga=fpga)
frame = engine.get_frame(0)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.range_doppler_i.shape, (512, 32))
self.assertEqual(frame.frame_number, 0)
finally:
os.unlink(tmp)
@@ -1100,7 +1100,7 @@ class TestReplayEngineHDF5(unittest.TestCase):
try:
with h5py.File(tmp, "w") as hf:
hf.attrs["creator"] = "test"
hf.attrs["range_bins"] = 64
hf.attrs["range_bins"] = 512
hf.attrs["doppler_bins"] = 32
grp = hf.create_group("frames")
for i in range(3):
@@ -1109,15 +1109,15 @@ class TestReplayEngineHDF5(unittest.TestCase):
fg.attrs["frame_number"] = i
fg.attrs["detection_count"] = 0
fg.create_dataset("range_doppler_i",
data=np.zeros((64, 32), dtype=np.int16))
data=np.zeros((512, 32), dtype=np.int16))
fg.create_dataset("range_doppler_q",
data=np.zeros((64, 32), dtype=np.int16))
data=np.zeros((512, 32), dtype=np.int16))
fg.create_dataset("magnitude",
data=np.zeros((64, 32), dtype=np.float64))
data=np.zeros((512, 32), dtype=np.float64))
fg.create_dataset("detections",
data=np.zeros((64, 32), dtype=np.uint8))
data=np.zeros((512, 32), dtype=np.uint8))
fg.create_dataset("range_profile",
data=np.zeros(64, dtype=np.float64))
data=np.zeros(512, dtype=np.float64))
engine = ReplayEngine(tmp)
self.assertEqual(engine.fmt, ReplayFormat.HDF5)
@@ -1126,7 +1126,7 @@ class TestReplayEngineHDF5(unittest.TestCase):
frame = engine.get_frame(1)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.frame_number, 1)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.range_doppler_i.shape, (512, 32))
engine.close()
finally:
os.unlink(tmp)
@@ -1161,9 +1161,9 @@ 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, bin_spacing=23.98)
targets = extract_targets_from_frame(frame, bin_spacing=5.996)
self.assertEqual(len(targets), 1)
self.assertAlmostEqual(targets[0].range, 10 * 23.98, places=1)
self.assertAlmostEqual(targets[0].range, 10 * 5.996, places=1)
self.assertAlmostEqual(targets[0].velocity, 0.0, places=2)
def test_velocity_sign(self):