test(cosim): T-7 strict MF thresholds + T-8 doppler 32->48 (3 sub-frames)

T-7 (compare_mf.py): replace "energy ratio 0.001-1000" cargo-cult bounds
with strict Parseval/correlation gates — energy 0.95-1.05, mag_corr >=
0.95, peak_overlap_10 >= 0.90, corr_i/corr_q >= 0.90. All four MF cosim
scenarios still pass (energy=1.000 mag_corr=1.000 peak=1.000) but the
script now bites on any drift instead of rubber-stamping.

T-8 (doppler cosim 32->48): bump cosim/TBs/Python model to production
3-subframe / 48-bin config (PR-F). DopplerProcessor parameterised over
NUM_SUBFRAMES (default 3, legacy 2 still callable). radar_scene now uses
SHORT/MEDIUM/LONG slow-time matching chirp_scheduler.v. Goldens
regenerated; tb_doppler_cosim drops the legacy CHIRPS_PER_FRAME=32
override; all 3 doppler scenarios pass bit-exact (energy=1.0000
peak_agree=1.000 mag_corr=1.000) at production config.

tb_doppler_realdata kept on the legacy override — its goldens are
bit-exact ADI CN0566 captures (32 chirps x 64 range bins) and the
3-subframe regen needs new hardware captures + golden_reference.py
rewrite, deferred to PR-I.

Full regression: 37/41 (same 4 pre-existing T-2..T-5 failures, no new
regressions).
This commit is contained in:
Jason
2026-05-01 11:49:28 +05:45
parent 58792d0e7d
commit b7a841a32c
16 changed files with 95141 additions and 21379 deletions
+16 -17
View File
@@ -33,9 +33,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Configuration
# =============================================================================
DOPPLER_FFT = 32
DOPPLER_FFT = 48 # 3 sub-frames x 16-pt FFT (PR-F)
RANGE_BINS = 512
TOTAL_OUTPUTS = RANGE_BINS * DOPPLER_FFT # 16384
TOTAL_OUTPUTS = RANGE_BINS * DOPPLER_FFT # 24576
SUBFRAME_SIZE = 16
SCENARIOS = {
@@ -246,7 +246,7 @@ def compare_scenario(name, config, base_dir):
# ---- Pass/Fail ----
checks = []
checks.append(('RTL output count == 16384', count_ok))
checks.append((f'RTL output count == {TOTAL_OUTPUTS}', count_ok))
energy_ok = (ENERGY_RATIO_MIN < energy_ratio < ENERGY_RATIO_MAX)
checks.append((f'Energy ratio in bounds '
@@ -316,23 +316,22 @@ def main():
for name in run_scenarios:
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
results.append((name, passed, result))
# Summary
all_pass = True
for _name, passed, result in results:
if not result:
all_pass = False
print(f'[FAIL] doppler {name}: missing input/output CSV')
elif passed:
print(f'[PASS] doppler {name}: '
f'count={result["rtl_count"]} '
f'energy={result["energy_ratio"]:.4f} '
f'peak_agree={result["peak_agreement"]:.3f} '
f'mag_corr={result["avg_mag_corr"]:.3f}')
else:
if not passed:
all_pass = False
if all_pass:
pass
else:
pass
print(f'[FAIL] doppler {name}: '
f'count={result["rtl_count"]} '
f'energy={result["energy_ratio"]:.4f} '
f'peak_agree={result["peak_agreement"]:.3f} '
f'mag_corr={result["avg_mag_corr"]:.3f}')
all_pass = all(p and r for _, p, r in results)
sys.exit(0 if all_pass else 1)
+47 -43
View File
@@ -61,14 +61,17 @@ SCENARIOS = {
},
}
# Thresholds for pass/fail
# These are generous because of the fundamental twiddle arithmetic differences
# between the SIMULATION branch (float twiddles) and Python model (fixed twiddles)
ENERGY_CORR_MIN = 0.80 # Min correlation of magnitude spectra
TOP_PEAK_OVERLAP_MIN = 0.50 # At least 50% of top-N peaks must overlap
RMS_RATIO_MAX = 50.0 # Max ratio of RMS energies (generous, since gain differs)
ENERGY_RATIO_MIN = 0.001 # Min ratio (total energy RTL / total energy Python)
ENERGY_RATIO_MAX = 1000.0 # Max ratio
# Pass/fail thresholds (PR-Tests-1 / T-7).
# Two correct FFTs of identical input must be Parseval-bounded (energy
# matches), and the same dominant tones imply high spectral / I-Q correlation.
# SIMULATION-mode runs use float twiddles vs the Python fixed-point reference,
# so a small gain offset is expected — but anything outside these bars
# indicates real drift between the behavioral and synthesis paths.
ENERGY_RATIO_MIN = 0.95
ENERGY_RATIO_MAX = 1.05
MAG_CORR_MIN = 0.95 # Pearson correlation of L2 magnitude spectra
PEAK_OVERLAP_MIN = 0.90 # Top-10 spectral-peak overlap fraction
IQ_CORR_MIN = 0.90 # Pearson correlation on I and Q channels
# =============================================================================
@@ -221,41 +224,41 @@ def compare_scenario(scenario_name, config, base_dir):
# ---- Pass/Fail Decision ----
# The SIMULATION branch uses floating-point twiddles ($cos/$sin) while
# the Python model uses the fixed-point twiddle ROM (matching synthesis).
# These are fundamentally different FFT implementations. We do NOT expect
# structural similarity (correlation, peak overlap) between them.
#
# What we CAN verify:
# 1. Both produce non-trivial output (state machine completes)
# 2. Output count is correct (1024 samples)
# 3. Energy is in a reasonable range (not wildly wrong)
#
# The true bit-accuracy comparison will happen when the synthesis branch
# is simulated (xsim on remote server) using the same fft_engine.v that
# the Python model was built to match.
# Strict (PR-Tests-1 / T-7). Two correct FFTs of the same input must be
# Parseval-bounded and share dominant tones; we now gate on energy ratio,
# spectral correlation, top-N peak overlap, and per-channel I/Q
# correlation — not just "did the state machine reach $finish".
checks = []
# Check 1: Both produce output
both_have_output = py_energy > 0 and rtl_energy > 0
checks.append(('Both produce output', both_have_output))
# Check 2: RTL produced expected sample count
correct_count = len(rtl_i) == FFT_SIZE
checks.append(('Correct output count (2048)', correct_count))
# Check 3: Energy ratio within generous bounds
# Allow very wide range since twiddle differences cause large gain variation
energy_ok = ENERGY_RATIO_MIN < energy_ratio < ENERGY_RATIO_MAX
checks.append((f'Energy ratio in bounds ({ENERGY_RATIO_MIN}-{ENERGY_RATIO_MAX})',
energy_ok))
energy_ok = ENERGY_RATIO_MIN <= energy_ratio <= ENERGY_RATIO_MAX
checks.append((f'Energy ratio in [{ENERGY_RATIO_MIN},{ENERGY_RATIO_MAX}] '
f'(actual={energy_ratio:.3f})', energy_ok))
mag_corr_ok = mag_corr >= MAG_CORR_MIN
checks.append((f'mag_corr >= {MAG_CORR_MIN} (actual={mag_corr:.3f})',
mag_corr_ok))
peak_overlap_ok = peak_overlap_10 >= PEAK_OVERLAP_MIN
checks.append((f'peak_overlap_10 >= {PEAK_OVERLAP_MIN} '
f'(actual={peak_overlap_10:.3f})', peak_overlap_ok))
iq_corr_ok = corr_i >= IQ_CORR_MIN and corr_q >= IQ_CORR_MIN
checks.append((f'corr_i,corr_q >= {IQ_CORR_MIN} '
f'(actual={corr_i:.3f}/{corr_q:.3f})', iq_corr_ok))
# Print checks
all_pass = True
failed_names = []
for _name, passed in checks:
if not passed:
all_pass = False
failed_names.append(_name)
result = {
'scenario': scenario_name,
@@ -271,6 +274,7 @@ def compare_scenario(scenario_name, config, base_dir):
'corr_i': corr_i,
'corr_q': corr_q,
'passed': all_pass,
'failed_checks': failed_names,
}
# Write detailed comparison CSV
@@ -306,23 +310,23 @@ def main():
for name in run_scenarios:
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
results.append((name, passed, result))
# Summary
all_pass = True
for _name, passed, result in results:
if not result:
all_pass = False
print(f'[FAIL] mf {name}: missing input/output CSV')
elif passed:
print(f'[PASS] mf {name}: '
f'energy={result["energy_ratio"]:.3f} '
f'mag_corr={result["mag_corr"]:.3f} '
f'peak10={result["peak_overlap_10"]:.3f} '
f'corr_i={result["corr_i"]:.3f} corr_q={result["corr_q"]:.3f}')
else:
if not passed:
all_pass = False
if all_pass:
pass
else:
pass
print(f'[FAIL] mf {name}: '
f'energy={result["energy_ratio"]:.3f} '
f'mag_corr={result["mag_corr"]:.3f} '
f'peak10={result["peak_overlap_10"]:.3f} '
f'corr_i={result["corr_i"]:.3f} corr_q={result["corr_q"]:.3f} '
f'-> {result["failed_checks"]}')
all_pass = all(p and r for _, p, r in results)
sys.exit(0 if all_pass else 1)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33 -21
View File
@@ -1088,29 +1088,44 @@ HAMMING_WINDOW = [
class DopplerProcessor:
"""
Bit-accurate model of doppler_processor_optimized.v (dual 16-pt FFT architecture).
Bit-accurate model of doppler_processor_optimized.v (per-subframe 16-pt FFT).
The staggered-PRF frame has 32 chirps total:
- Sub-frame 0 (long PRI): chirps 0-15 -> 16-pt Hamming -> 16-pt FFT -> bins 0-15
- Sub-frame 1 (short PRI): chirps 16-31 -> 16-pt Hamming -> 16-pt FFT -> bins 16-31
PR-F: 48 chirps total, 3 sub-frames (SHORT/MEDIUM/LONG):
- Sub-frame 0 (SHORT PRI): chirps 0..15 -> 16-pt Hamming -> 16-pt FFT -> bins 0..15
- Sub-frame 1 (MEDIUM PRI): chirps 16..31 -> 16-pt Hamming -> 16-pt FFT -> bins 16..31
- Sub-frame 2 (LONG PRI): chirps 32..47 -> 16-pt Hamming -> 16-pt FFT -> bins 32..47
Output: doppler_bin[4:0] = {sub_frame_id, bin_in_subframe[3:0]}
Total output per range bin: 32 bins (16 + 16), same interface as before.
Output: doppler_bin[5:0] = {sub_frame_id[1:0], bin_in_subframe[3:0]}
Total output per range bin: 48 bins (3 x 16).
NUM_SUBFRAMES is parameterised so legacy 2-subframe call sites still work
(set CHIRPS_PER_FRAME=32, NUM_SUBFRAMES=2). Default tracks production.
"""
DOPPLER_FFT_SIZE = 16 # Per sub-frame
RANGE_BINS = 512
CHIRPS_PER_FRAME = 32
CHIRPS_PER_SUBFRAME = 16
NUM_SUBFRAMES = 3
CHIRPS_PER_FRAME = NUM_SUBFRAMES * CHIRPS_PER_SUBFRAME # 48
def __init__(self, twiddle_file_16=None):
def __init__(self, twiddle_file_16=None, num_subframes=None,
chirps_per_frame=None):
"""
For 16-point FFT, we need the 16-point twiddle file.
If not provided, we generate twiddle factors mathematically
(cos(2*pi*k/16) for k=0..3, quarter-wave ROM with 4 entries).
num_subframes / chirps_per_frame override the class defaults for
legacy 2-subframe co-sim or future variants.
"""
self.fft16 = None
self._twiddle_file_16 = twiddle_file_16
if num_subframes is not None:
self.NUM_SUBFRAMES = num_subframes
self.CHIRPS_PER_FRAME = self.NUM_SUBFRAMES * self.CHIRPS_PER_SUBFRAME
if chirps_per_frame is not None:
self.CHIRPS_PER_FRAME = chirps_per_frame
self.NUM_SUBFRAMES = chirps_per_frame // self.CHIRPS_PER_SUBFRAME
@staticmethod
def window_multiply(data_16, window_16):
@@ -1132,20 +1147,20 @@ class DopplerProcessor:
def process_frame(self, chirp_data_i, chirp_data_q):
"""
Process one complete Doppler frame using dual 16-pt FFTs.
Process one complete Doppler frame using NUM_SUBFRAMES x 16-pt FFTs.
Args:
chirp_data_i: 2D array [32 chirps][512 range bins] of signed 16-bit I
chirp_data_q: 2D array [32 chirps][512 range bins] of signed 16-bit Q
chirp_data_i: 2D array [CHIRPS_PER_FRAME][RANGE_BINS] of signed 16-bit I
chirp_data_q: 2D array [CHIRPS_PER_FRAME][RANGE_BINS] of signed 16-bit Q
Returns:
(doppler_map_i, doppler_map_q): 2D arrays [512 range bins][32 doppler bins]
of signed 16-bit
Bins 0-15 = sub-frame 0 (long PRI)
Bins 16-31 = sub-frame 1 (short PRI)
(doppler_map_i, doppler_map_q): 2D arrays
[RANGE_BINS][NUM_SUBFRAMES * DOPPLER_FFT_SIZE] of signed 16-bit.
Sub-frame s occupies bins [s*16 .. s*16+15].
"""
doppler_map_i = []
doppler_map_q = []
total_bins = self.NUM_SUBFRAMES * self.DOPPLER_FFT_SIZE
# Generate 16-pt twiddle factors (quarter-wave cos, 4 entries)
# cos(2*pi*k/16) for k=0..3
@@ -1164,12 +1179,11 @@ class DopplerProcessor:
fft16.mem_im = [0] * 16
for rbin in range(self.RANGE_BINS):
# Output bins for this range bin: 32 total (16 from each sub-frame)
out_re = [0] * 32
out_im = [0] * 32
out_re = [0] * total_bins
out_im = [0] * total_bins
# Process each sub-frame independently
for sf in range(2):
for sf in range(self.NUM_SUBFRAMES):
chirp_start = sf * self.CHIRPS_PER_SUBFRAME
bin_offset = sf * self.DOPPLER_FFT_SIZE
@@ -1188,10 +1202,8 @@ class DopplerProcessor:
fft_in_re.append(win_re)
fft_in_im.append(win_im)
# 16-point forward FFT
fft_out_re, fft_out_im = fft16.compute(fft_in_re, fft_in_im, inverse=False)
# Pack into output: sub-frame 0 -> bins 0-15, sub-frame 1 -> bins 16-31
for b in range(self.DOPPLER_FFT_SIZE):
out_re[bin_offset + b] = fft_out_re[b]
out_im[bin_offset + b] = fft_out_im[b]
@@ -3,12 +3,14 @@
Generate Doppler processor co-simulation golden reference data.
Uses the bit-accurate Python model (fpga_model.py) to compute the expected
Doppler FFT output for the dual 16-pt FFT architecture. Also generates the
input hex files consumed by the Verilog testbench (tb_doppler_cosim.v).
Doppler FFT output for the 3-subframe / 16-pt FFT architecture (PR-F). Also
generates the input hex files consumed by the Verilog testbench
(tb_doppler_cosim.v).
Architecture:
Sub-frame 0 (long PRI): chirps 0-15 -> 16-pt Hamming -> 16-pt FFT -> bins 0-15
Sub-frame 1 (short PRI): chirps 16-31 -> 16-pt Hamming -> 16-pt FFT -> bins 16-31
Architecture (matches chirp_scheduler.v ordering SHORT, MEDIUM, LONG):
Sub-frame 0 (SHORT PRI): chirps 0..15 -> 16-pt Hamming -> 16-pt FFT -> bins 0..15
Sub-frame 1 (MEDIUM PRI): chirps 16..31 -> 16-pt Hamming -> 16-pt FFT -> bins 16..31
Sub-frame 2 (LONG PRI): chirps 32..47 -> 16-pt Hamming -> 16-pt FFT -> bins 32..47
Usage:
cd ~/PLFM_RADAR/9_Firmware/9_2_FPGA/tb/cosim
@@ -34,10 +36,11 @@ from radar_scene import Target, generate_doppler_frame
# =============================================================================
DOPPLER_FFT_SIZE = 16 # Per sub-frame
DOPPLER_TOTAL_BINS = 32 # Total output (2 sub-frames x 16)
NUM_SUBFRAMES = 3
DOPPLER_TOTAL_BINS = NUM_SUBFRAMES * DOPPLER_FFT_SIZE # 48
RANGE_BINS = 512
CHIRPS_PER_FRAME = 32
TOTAL_SAMPLES = CHIRPS_PER_FRAME * RANGE_BINS # 16384
CHIRPS_PER_FRAME = NUM_SUBFRAMES * 16 # 48
TOTAL_SAMPLES = CHIRPS_PER_FRAME * RANGE_BINS # 24576
# =============================================================================
@@ -87,11 +90,12 @@ def make_scenario_stationary():
def make_scenario_moving():
"""Single target with moderate Doppler shift."""
# v = 15 m/s fd = 2*v*fc/c 1050 Hz
# Long PRI = 167 us → sub-frame 0 bin = fd * 16 * 167e-6 2.8 → bin ~3
# Short PRI = 175 us → sub-frame 1 bin = fd * 16 * 175e-6 2.9 bin 16+3 = 19
# v = 15 m/s -> fd = 2*v*fc/c ~= 1050 Hz
# SHORT PRI 175 us: bin = fd * 16 * 175e-6 ~= 2.94 -> sf0 bin ~3
# MEDIUM PRI 175 us: bin = fd * 16 * 175e-6 ~= 2.94 -> sf1 bin 16+3 = 19
# LONG PRI 167 us: bin = fd * 16 * 167e-6 ~= 2.81 -> sf2 bin 32+3 = 35
targets = [Target(range_m=500, velocity_mps=15.0, rcs_dbsm=20.0)]
return targets, "Single moving target v=15m/s (~1050Hz Doppler, sf0 bin~3, sf1 bin~19)"
return targets, "Single moving target v=15m/s (~1050Hz Doppler, sf0~3 sf1~19 sf2~35)"
def make_scenario_two_targets():
@@ -117,7 +121,7 @@ SCENARIOS = {
def generate_scenario(name, targets, description, base_dir):
"""Generate input hex + golden output for one scenario."""
# Generate Doppler frame (32 chirps x 64 range bins)
# Generate Doppler frame (48 chirps x RANGE_BINS, 3 sub-frames)
frame_i, frame_q = generate_doppler_frame(targets, seed=42)
+26 -14
View File
@@ -59,25 +59,28 @@ ADC_BITS = 8 # ADC resolution
T_LONG_CHIRP = 30e-6 # 30 us long chirp duration
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
T_LISTEN_LONG = 137e-6 # 137 us listening window
T_PRI_LONG = 167e-6 # 30 us chirp + 137 us listen
T_PRI_SHORT = 175e-6 # staggered short-PRI sub-frame
T_PRI_LONG = 167e-6 # 30 us chirp + 137 us listen (sub-frame 2: LONG)
T_PRI_SHORT = 175e-6 # 1 us chirp + 174 us listen (sub-frame 0: SHORT)
T_PRI_MEDIUM = 175e-6 # 5 us chirp + 170 us listen (sub-frame 1: MEDIUM)
N_SAMPLES_LISTEN = int(T_LISTEN_LONG * FS_ADC) # 54800 samples
# Processing chain
# Updated for 2048-pt range FFT + 4x decimation → 512 range bins per chirp.
# Must stay in sync with radar_params.vh: RP_FFT_SIZE=2048, RP_NUM_RANGE_BINS=512.
# Must stay in sync with radar_params.vh: RP_FFT_SIZE=2048, RP_NUM_RANGE_BINS=512,
# RP_CHIRPS_PER_FRAME=48, RP_NUM_DOPPLER_BINS=48 (PR-F: 3 sub-frames x 16).
CIC_DECIMATION = 4
FFT_SIZE = 2048
RANGE_BINS = 512
DOPPLER_FFT_SIZE = 16 # Per sub-frame
DOPPLER_TOTAL_BINS = 32 # Total output bins (2 sub-frames x 16)
NUM_SUBFRAMES = 3 # SHORT, MEDIUM, LONG
DOPPLER_TOTAL_BINS = NUM_SUBFRAMES * DOPPLER_FFT_SIZE # 48
CHIRPS_PER_SUBFRAME = 16
CHIRPS_PER_FRAME = 32
CHIRPS_PER_FRAME = NUM_SUBFRAMES * CHIRPS_PER_SUBFRAME # 48
# Derived
RANGE_RESOLUTION = C_LIGHT / (2 * CHIRP_BW) # 7.5 m
MAX_UNAMBIGUOUS_RANGE = C_LIGHT * T_LISTEN_LONG / 2 # ~20.55 km
VELOCITY_RESOLUTION_LONG = WAVELENGTH / (2 * CHIRPS_PER_SUBFRAME * T_PRI_LONG)
VELOCITY_RESOLUTION_MEDIUM = WAVELENGTH / (2 * CHIRPS_PER_SUBFRAME * T_PRI_MEDIUM)
VELOCITY_RESOLUTION_SHORT = WAVELENGTH / (2 * CHIRPS_PER_SUBFRAME * T_PRI_SHORT)
# Short chirp LUT (60 entries, 8-bit unsigned)
@@ -378,15 +381,18 @@ def generate_baseband_samples(targets, n_samples_baseband, noise_stddev=0.5,
def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
n_range_bins=RANGE_BINS, noise_stddev=0.5, seed=42):
"""
Generate a complete Doppler frame (32 chirps x 64 range bins).
Generate a complete Doppler frame (48 chirps x N range bins, 3 sub-frames).
Each chirp sees a phase rotation due to target velocity:
phase_shift_per_chirp = 2*pi * doppler_hz * T_chirp_repeat
Sub-frame ordering matches chirp_scheduler.v: 0=SHORT, 1=MEDIUM, 2=LONG.
Slow-time accumulates each chirp's PRI from the start of the frame.
Args:
targets: list of Target objects
n_chirps: chirps per frame (32)
n_range_bins: range bins per chirp (64)
n_chirps: chirps per frame (48)
n_range_bins: range bins per chirp (default RANGE_BINS=512)
Returns:
(frame_i, frame_q): [n_chirps][n_range_bins] arrays of signed 16-bit
@@ -425,13 +431,19 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
amp = target.amplitude / 4.0
# Doppler phase for this chirp.
# The frame uses staggered PRF: chirps 0-15 use the long PRI,
# chirps 16-31 use the short PRI.
# 3-subframe staggered PRF (chirp_scheduler.v ordering):
# chirps 0..15 -> SHORT PRI
# chirps 16..31 -> MEDIUM PRI (after 16 SHORT)
# chirps 32..47 -> LONG PRI (after 16 SHORT + 16 MEDIUM)
if chirp_idx < CHIRPS_PER_SUBFRAME:
slow_time_s = chirp_idx * T_PRI_LONG
slow_time_s = chirp_idx * T_PRI_SHORT
elif chirp_idx < 2 * CHIRPS_PER_SUBFRAME:
slow_time_s = (CHIRPS_PER_SUBFRAME * T_PRI_SHORT) + \
((chirp_idx - CHIRPS_PER_SUBFRAME) * T_PRI_MEDIUM)
else:
slow_time_s = (CHIRPS_PER_SUBFRAME * T_PRI_LONG) + \
((chirp_idx - CHIRPS_PER_SUBFRAME) * T_PRI_SHORT)
slow_time_s = (CHIRPS_PER_SUBFRAME * T_PRI_SHORT) + \
(CHIRPS_PER_SUBFRAME * T_PRI_MEDIUM) + \
((chirp_idx - 2 * CHIRPS_PER_SUBFRAME) * T_PRI_LONG)
doppler_phase = 2 * math.pi * target.doppler_hz * slow_time_s
total_phase = doppler_phase + target.phase_deg * math.pi / 180.0