Add matched-filter co-simulation: bit-perfect validation of Python model vs synthesis-branch RTL (4/4 scenarios, correlation=1.0)

This commit is contained in:
Jason
2026-03-16 16:23:01 +02:00
parent baa24fd01e
commit e506a80db5
35 changed files with 33684 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""
Co-simulation Comparison: RTL vs Python Model for AERIS-10 Matched Filter.
Compares the RTL matched filter output (from tb_mf_cosim.v) against the
Python model golden reference (from gen_mf_cosim_golden.py).
Two modes of operation:
1. Synthesis branch (no -DSIMULATION): RTL uses fft_engine.v with fixed-point
twiddle ROM (fft_twiddle_1024.mem) and frequency_matched_filter.v. The
Python model was built to match this exactly. Expect BIT-PERFECT results
(correlation = 1.0, energy ratio = 1.0).
2. SIMULATION branch (-DSIMULATION): RTL uses behavioral FFT with floating-
point twiddles ($rtoi($cos*32767)) and shift-then-add conjugate multiply.
Python model uses fixed-point twiddles and add-then-round. Expect large
numerical differences; only state-machine mechanics are validated.
Usage:
python3 compare_mf.py [scenario|all]
scenario: chirp, dc, impulse, tone5 (default: chirp)
all: run all scenarios
Author: Phase 0.5 matched-filter co-simulation suite for PLFM_RADAR
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# =============================================================================
# Configuration
# =============================================================================
FFT_SIZE = 1024
SCENARIOS = {
'chirp': {
'golden_csv': 'mf_golden_py_chirp.csv',
'rtl_csv': 'rtl_mf_chirp.csv',
'description': 'Radar chirp: 2 targets vs ref chirp',
},
'dc': {
'golden_csv': 'mf_golden_py_dc.csv',
'rtl_csv': 'rtl_mf_dc.csv',
'description': 'DC autocorrelation (I=0x1000)',
},
'impulse': {
'golden_csv': 'mf_golden_py_impulse.csv',
'rtl_csv': 'rtl_mf_impulse.csv',
'description': 'Impulse autocorrelation (delta at n=0)',
},
'tone5': {
'golden_csv': 'mf_golden_py_tone5.csv',
'rtl_csv': 'rtl_mf_tone5.csv',
'description': 'Tone autocorrelation (bin 5, amp=8000)',
},
}
# 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
# =============================================================================
# Helper functions
# =============================================================================
def load_csv(filepath):
"""Load CSV with columns (bin, out_i/range_profile_i, out_q/range_profile_q)."""
vals_i = []
vals_q = []
with open(filepath, 'r') as f:
header = f.readline()
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(',')
vals_i.append(int(parts[1]))
vals_q.append(int(parts[2]))
return vals_i, vals_q
def magnitude_spectrum(vals_i, vals_q):
"""Compute magnitude = |I| + |Q| for each bin (L1 norm, matches RTL)."""
return [abs(i) + abs(q) for i, q in zip(vals_i, vals_q)]
def magnitude_l2(vals_i, vals_q):
"""Compute magnitude = sqrt(I^2 + Q^2) for each bin."""
return [math.sqrt(i*i + q*q) for i, q in zip(vals_i, vals_q)]
def total_energy(vals_i, vals_q):
"""Compute total energy (sum of I^2 + Q^2)."""
return sum(i*i + q*q for i, q in zip(vals_i, vals_q))
def rms_magnitude(vals_i, vals_q):
"""Compute RMS of complex magnitude."""
n = len(vals_i)
if n == 0:
return 0.0
return math.sqrt(sum(i*i + q*q for i, q in zip(vals_i, vals_q)) / n)
def pearson_correlation(a, b):
"""Compute Pearson correlation coefficient between two lists."""
n = len(a)
if n < 2:
return 0.0
mean_a = sum(a) / n
mean_b = sum(b) / n
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
std_a_sq = sum((x - mean_a) ** 2 for x in a)
std_b_sq = sum((x - mean_b) ** 2 for x in b)
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
return cov / math.sqrt(std_a_sq * std_b_sq)
def find_peak(vals_i, vals_q):
"""Find the bin with the maximum L1 magnitude."""
mags = magnitude_spectrum(vals_i, vals_q)
peak_bin = 0
peak_mag = mags[0]
for i in range(1, len(mags)):
if mags[i] > peak_mag:
peak_mag = mags[i]
peak_bin = i
return peak_bin, peak_mag
def top_n_peaks(mags, n=10):
"""Find the top-N peak bins by magnitude. Returns set of bin indices."""
indexed = sorted(enumerate(mags), key=lambda x: -x[1])
return set(idx for idx, _ in indexed[:n])
def spectral_peak_overlap(mags_a, mags_b, n=10):
"""Fraction of top-N peaks from A that also appear in top-N of B."""
peaks_a = top_n_peaks(mags_a, n)
peaks_b = top_n_peaks(mags_b, n)
if len(peaks_a) == 0:
return 1.0
overlap = peaks_a & peaks_b
return len(overlap) / len(peaks_a)
# =============================================================================
# Comparison for one scenario
# =============================================================================
def compare_scenario(scenario_name, config, base_dir):
"""Compare one scenario. Returns (pass/fail, result_dict)."""
print(f"\n{'='*60}")
print(f"Scenario: {scenario_name}{config['description']}")
print(f"{'='*60}")
golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path):
print(f" ERROR: Golden CSV not found: {golden_path}")
print(f" Run: python3 gen_mf_cosim_golden.py")
return False, {}
if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
print(f" Run the RTL testbench first")
return False, {}
py_i, py_q = load_csv(golden_path)
rtl_i, rtl_q = load_csv(rtl_path)
print(f" Python model: {len(py_i)} samples")
print(f" RTL output: {len(rtl_i)} samples")
if len(py_i) != FFT_SIZE or len(rtl_i) != FFT_SIZE:
print(f" ERROR: Expected {FFT_SIZE} samples from each")
return False, {}
# ---- Metric 1: Energy ----
py_energy = total_energy(py_i, py_q)
rtl_energy = total_energy(rtl_i, rtl_q)
py_rms = rms_magnitude(py_i, py_q)
rtl_rms = rms_magnitude(rtl_i, rtl_q)
if py_energy > 0 and rtl_energy > 0:
energy_ratio = rtl_energy / py_energy
rms_ratio = rtl_rms / py_rms
elif py_energy == 0 and rtl_energy == 0:
energy_ratio = 1.0
rms_ratio = 1.0
else:
energy_ratio = float('inf') if py_energy == 0 else 0.0
rms_ratio = float('inf') if py_rms == 0 else 0.0
print(f"\n Energy:")
print(f" Python total energy: {py_energy}")
print(f" RTL total energy: {rtl_energy}")
print(f" Energy ratio (RTL/Py): {energy_ratio:.4f}")
print(f" Python RMS: {py_rms:.2f}")
print(f" RTL RMS: {rtl_rms:.2f}")
print(f" RMS ratio (RTL/Py): {rms_ratio:.4f}")
# ---- Metric 2: Peak location ----
py_peak_bin, py_peak_mag = find_peak(py_i, py_q)
rtl_peak_bin, rtl_peak_mag = find_peak(rtl_i, rtl_q)
print(f"\n Peak location:")
print(f" Python: bin={py_peak_bin}, mag={py_peak_mag}")
print(f" RTL: bin={rtl_peak_bin}, mag={rtl_peak_mag}")
# ---- Metric 3: Magnitude spectrum correlation ----
py_mag = magnitude_l2(py_i, py_q)
rtl_mag = magnitude_l2(rtl_i, rtl_q)
mag_corr = pearson_correlation(py_mag, rtl_mag)
print(f"\n Magnitude spectrum correlation: {mag_corr:.6f}")
# ---- Metric 4: Top-N peak overlap ----
# Use L1 magnitudes for peak finding (matches RTL)
py_mag_l1 = magnitude_spectrum(py_i, py_q)
rtl_mag_l1 = magnitude_spectrum(rtl_i, rtl_q)
peak_overlap_10 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=10)
peak_overlap_20 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=20)
print(f" Top-10 peak overlap: {peak_overlap_10:.2%}")
print(f" Top-20 peak overlap: {peak_overlap_20:.2%}")
# ---- Metric 5: I and Q channel correlation ----
corr_i = pearson_correlation(py_i, rtl_i)
corr_q = pearson_correlation(py_q, rtl_q)
print(f"\n Channel correlation:")
print(f" I-channel: {corr_i:.6f}")
print(f" Q-channel: {corr_q:.6f}")
# ---- 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.
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 (1024)', 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))
# Print checks
print(f"\n Pass/Fail Checks:")
all_pass = True
for name, passed in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {name}")
if not passed:
all_pass = False
result = {
'scenario': scenario_name,
'py_energy': py_energy,
'rtl_energy': rtl_energy,
'energy_ratio': energy_ratio,
'rms_ratio': rms_ratio,
'py_peak_bin': py_peak_bin,
'rtl_peak_bin': rtl_peak_bin,
'mag_corr': mag_corr,
'peak_overlap_10': peak_overlap_10,
'peak_overlap_20': peak_overlap_20,
'corr_i': corr_i,
'corr_q': corr_q,
'passed': all_pass,
}
# Write detailed comparison CSV
compare_csv = os.path.join(base_dir, f'compare_mf_{scenario_name}.csv')
with open(compare_csv, 'w') as f:
f.write('bin,py_i,py_q,rtl_i,rtl_q,py_mag,rtl_mag,diff_i,diff_q\n')
for k in range(FFT_SIZE):
f.write(f'{k},{py_i[k]},{py_q[k]},{rtl_i[k]},{rtl_q[k]},'
f'{py_mag_l1[k]},{rtl_mag_l1[k]},'
f'{rtl_i[k]-py_i[k]},{rtl_q[k]-py_q[k]}\n')
print(f"\n Detailed comparison: {compare_csv}")
return all_pass, result
# =============================================================================
# Main
# =============================================================================
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
else:
arg = 'chirp'
if arg == 'all':
run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS:
run_scenarios = [arg]
else:
print(f"Unknown scenario: {arg}")
print(f"Valid: {', '.join(SCENARIOS.keys())}, all")
sys.exit(1)
print("=" * 60)
print("Matched Filter Co-Simulation Comparison")
print("RTL (synthesis branch) vs Python model (bit-accurate)")
print(f"Scenarios: {', '.join(run_scenarios)}")
print("=" * 60)
results = []
for name in run_scenarios:
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
results.append((name, passed, result))
# Summary
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")
print(f"\n {'Scenario':<12} {'Energy Ratio':>13} {'Mag Corr':>10} "
f"{'Peak Ovlp':>10} {'Py Peak':>8} {'RTL Peak':>9} {'Status':>8}")
print(f" {'-'*12} {'-'*13} {'-'*10} {'-'*10} {'-'*8} {'-'*9} {'-'*8}")
all_pass = True
for name, passed, result in results:
if not result:
print(f" {name:<12} {'ERROR':>13} {'':>10} {'':>10} "
f"{'':>8} {'':>9} {'FAIL':>8}")
all_pass = False
else:
status = "PASS" if passed else "FAIL"
print(f" {name:<12} {result['energy_ratio']:>13.4f} "
f"{result['mag_corr']:>10.4f} "
f"{result['peak_overlap_10']:>9.0%} "
f"{result['py_peak_bin']:>8d} "
f"{result['rtl_peak_bin']:>9d} "
f"{status:>8}")
if not passed:
all_pass = False
print()
if all_pass:
print("ALL TESTS PASSED")
else:
print("SOME TESTS FAILED")
print(f"{'='*60}")
sys.exit(0 if all_pass else 1)
if __name__ == '__main__':
main()
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
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Generate matched-filter co-simulation golden reference data.
Uses the bit-accurate Python model (fpga_model.py) to compute the expected
matched filter output for the bb_mf_test + ref_chirp test vectors.
Also generates additional test cases (DC, impulse, tone) for completeness.
The RTL testbench (tb_mf_cosim.v) runs the same inputs through the
SIMULATION-branch behavioral FFT in matched_filter_processing_chain.v.
compare_mf.py then compares the two.
Usage:
cd ~/PLFM_RADAR/9_Firmware/9_2_FPGA/tb/cosim
python3 gen_mf_cosim_golden.py
Author: Phase 0.5 matched-filter co-simulation suite for PLFM_RADAR
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fpga_model import (
FFTEngine, FreqMatchedFilter, MatchedFilterChain,
RangeBinDecimator, sign_extend, saturate
)
FFT_SIZE = 1024
def load_hex_16bit(filepath):
"""Load 16-bit hex file (one value per line, with optional // comments)."""
values = []
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
continue
val = int(line, 16)
values.append(sign_extend(val, 16))
return values
def write_hex_16bit(filepath, data):
"""Write list of signed 16-bit integers as 4-digit hex, one per line."""
with open(filepath, 'w') as f:
for val in data:
v = val & 0xFFFF
f.write(f"{v:04X}\n")
def write_csv(filepath, col_names, *columns):
"""Write CSV with header and columns."""
with open(filepath, 'w') as f:
f.write(','.join(col_names) + '\n')
n = len(columns[0])
for i in range(n):
row = ','.join(str(col[i]) for col in columns)
f.write(row + '\n')
def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
write_inputs=False):
"""
Run matched filter through Python model and save golden output.
If write_inputs=True, also writes the input hex files that the RTL
testbench expects (mf_sig_<case>_i.hex, mf_sig_<case>_q.hex,
mf_ref_<case>_i.hex, mf_ref_<case>_q.hex).
Returns dict with case info and results.
"""
print(f"\n--- {case_name}: {description} ---")
assert len(sig_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}"
assert len(sig_q) == FFT_SIZE
assert len(ref_i) == FFT_SIZE
assert len(ref_q) == FFT_SIZE
# Write input hex files for RTL testbench if requested
if write_inputs:
write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_i.hex"), sig_i)
write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_q.hex"), sig_q)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_i.hex"), ref_i)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q)
print(f" Wrote input hex: mf_sig_{case_name}_{{i,q}}.hex, "
f"mf_ref_{case_name}_{{i,q}}.hex")
# Run through bit-accurate Python model
mf = MatchedFilterChain(fft_size=FFT_SIZE)
out_i, out_q = mf.process(sig_i, sig_q, ref_i, ref_q)
# Find peak
peak_mag = -1
peak_bin = 0
for k in range(FFT_SIZE):
mag = abs(out_i[k]) + abs(out_q[k])
if mag > peak_mag:
peak_mag = mag
peak_bin = k
print(f" Output: {len(out_i)} samples")
print(f" Peak bin: {peak_bin}, magnitude: {peak_mag}")
print(f" Peak I={out_i[peak_bin]}, Q={out_q[peak_bin]}")
# Save golden output hex
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i)
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_q_{case_name}.hex"), out_q)
# Save golden output CSV for comparison
indices = list(range(FFT_SIZE))
write_csv(
os.path.join(outdir, f"mf_golden_py_{case_name}.csv"),
['bin', 'out_i', 'out_q'],
indices, out_i, out_q
)
return {
'case_name': case_name,
'description': description,
'peak_bin': peak_bin,
'peak_mag': peak_mag,
'peak_i': out_i[peak_bin],
'peak_q': out_q[peak_bin],
'out_i': out_i,
'out_q': out_q,
}
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Matched Filter Co-Sim Golden Reference Generator")
print("Using bit-accurate Python model (fpga_model.py)")
print("=" * 60)
results = []
# ---- Case 1: bb_mf_test + ref_chirp (realistic radar scenario) ----
bb_i_path = os.path.join(base_dir, "bb_mf_test_i.hex")
bb_q_path = os.path.join(base_dir, "bb_mf_test_q.hex")
ref_i_path = os.path.join(base_dir, "ref_chirp_i.hex")
ref_q_path = os.path.join(base_dir, "ref_chirp_q.hex")
if all(os.path.exists(p) for p in [bb_i_path, bb_q_path, ref_i_path, ref_q_path]):
bb_i = load_hex_16bit(bb_i_path)
bb_q = load_hex_16bit(bb_q_path)
ref_i = load_hex_16bit(ref_i_path)
ref_q = load_hex_16bit(ref_q_path)
r = generate_case("chirp", bb_i, bb_q, ref_i, ref_q,
"Radar chirp: 2 targets (500m, 1500m) vs ref chirp",
base_dir)
results.append(r)
else:
print("\nWARNING: bb_mf_test / ref_chirp hex files not found.")
print("Run radar_scene.py first.")
# ---- Case 2: DC autocorrelation ----
dc_val = 0x1000 # 4096
sig_i = [dc_val] * FFT_SIZE
sig_q = [0] * FFT_SIZE
ref_i = [dc_val] * FFT_SIZE
ref_q = [0] * FFT_SIZE
r = generate_case("dc", sig_i, sig_q, ref_i, ref_q,
"DC autocorrelation: I=0x1000, Q=0",
base_dir, write_inputs=True)
results.append(r)
# ---- Case 3: Impulse autocorrelation ----
sig_i = [0] * FFT_SIZE
sig_q = [0] * FFT_SIZE
ref_i = [0] * FFT_SIZE
ref_q = [0] * FFT_SIZE
sig_i[0] = 0x7FFF # 32767
ref_i[0] = 0x7FFF
r = generate_case("impulse", sig_i, sig_q, ref_i, ref_q,
"Impulse autocorrelation: delta at n=0, I=0x7FFF",
base_dir, write_inputs=True)
results.append(r)
# ---- Case 4: Tone autocorrelation at bin 5 ----
amp = 8000
k = 5
sig_i = []
sig_q = []
for n in range(FFT_SIZE):
angle = 2.0 * math.pi * k * n / FFT_SIZE
sig_i.append(saturate(int(round(amp * math.cos(angle))), 16))
sig_q.append(saturate(int(round(amp * math.sin(angle))), 16))
ref_i = list(sig_i)
ref_q = list(sig_q)
r = generate_case("tone5", sig_i, sig_q, ref_i, ref_q,
"Tone autocorrelation: bin 5, amplitude 8000",
base_dir, write_inputs=True)
results.append(r)
# ---- Summary ----
print("\n" + "=" * 60)
print("Summary:")
print("=" * 60)
for r in results:
print(f" {r['case_name']:10s}: peak at bin {r['peak_bin']}, "
f"mag={r['peak_mag']}, I={r['peak_i']}, Q={r['peak_q']}")
print(f"\nGenerated {len(results)} golden reference cases.")
print("Files written to:", base_dir)
print("=" * 60)
if __name__ == '__main__':
main()
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
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
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
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
`timescale 1ns / 1ps
/**
* tb_mf_cosim.v
*
* Co-simulation testbench for matched_filter_processing_chain.v
* (SIMULATION behavioral branch).
*
* Loads signal and reference hex files, feeds 1024 samples,
* captures range profile output to CSV for comparison with
* the Python model golden reference.
*
* Compile:
* iverilog -g2001 -DSIMULATION -o tb/tb_mf_cosim.vvp \
* tb/tb_mf_cosim.v matched_filter_processing_chain.v
*
* Scenarios (select one via -D):
* -DSCENARIO_CHIRP : bb_mf_test + ref_chirp (default if none)
* -DSCENARIO_DC : DC autocorrelation
* -DSCENARIO_IMPULSE : Impulse autocorrelation
* -DSCENARIO_TONE5 : Tone at bin 5 autocorrelation
*/
module tb_mf_cosim;
// ============================================================================
// Parameters
// ============================================================================
localparam FFT_SIZE = 1024;
localparam CLK_PERIOD = 10.0; // 100 MHz
localparam TIMEOUT = 200000; // Max clocks to wait for completion
// ============================================================================
// Scenario selection
// ============================================================================
`ifdef SCENARIO_DC
localparam [511:0] SCENARIO_NAME = "dc";
localparam [511:0] SIG_I_HEX = "tb/cosim/mf_sig_dc_i.hex";
localparam [511:0] SIG_Q_HEX = "tb/cosim/mf_sig_dc_q.hex";
localparam [511:0] REF_I_HEX = "tb/cosim/mf_ref_dc_i.hex";
localparam [511:0] REF_Q_HEX = "tb/cosim/mf_ref_dc_q.hex";
localparam [511:0] OUTPUT_CSV = "tb/cosim/rtl_mf_dc.csv";
`elsif SCENARIO_IMPULSE
localparam [511:0] SCENARIO_NAME = "impulse";
localparam [511:0] SIG_I_HEX = "tb/cosim/mf_sig_impulse_i.hex";
localparam [511:0] SIG_Q_HEX = "tb/cosim/mf_sig_impulse_q.hex";
localparam [511:0] REF_I_HEX = "tb/cosim/mf_ref_impulse_i.hex";
localparam [511:0] REF_Q_HEX = "tb/cosim/mf_ref_impulse_q.hex";
localparam [511:0] OUTPUT_CSV = "tb/cosim/rtl_mf_impulse.csv";
`elsif SCENARIO_TONE5
localparam [511:0] SCENARIO_NAME = "tone5";
localparam [511:0] SIG_I_HEX = "tb/cosim/mf_sig_tone5_i.hex";
localparam [511:0] SIG_Q_HEX = "tb/cosim/mf_sig_tone5_q.hex";
localparam [511:0] REF_I_HEX = "tb/cosim/mf_ref_tone5_i.hex";
localparam [511:0] REF_Q_HEX = "tb/cosim/mf_ref_tone5_q.hex";
localparam [511:0] OUTPUT_CSV = "tb/cosim/rtl_mf_tone5.csv";
`else
// Default: SCENARIO_CHIRP
localparam [511:0] SCENARIO_NAME = "chirp";
localparam [511:0] SIG_I_HEX = "tb/cosim/bb_mf_test_i.hex";
localparam [511:0] SIG_Q_HEX = "tb/cosim/bb_mf_test_q.hex";
localparam [511:0] REF_I_HEX = "tb/cosim/ref_chirp_i.hex";
localparam [511:0] REF_Q_HEX = "tb/cosim/ref_chirp_q.hex";
localparam [511:0] OUTPUT_CSV = "tb/cosim/rtl_mf_chirp.csv";
`endif
// ============================================================================
// Clock and reset
// ============================================================================
reg clk;
reg reset_n;
initial clk = 0;
always #(CLK_PERIOD / 2) clk = ~clk;
// ============================================================================
// Test data memory
// ============================================================================
reg signed [15:0] sig_mem_i [0:FFT_SIZE-1];
reg signed [15:0] sig_mem_q [0:FFT_SIZE-1];
reg signed [15:0] ref_mem_i [0:FFT_SIZE-1];
reg signed [15:0] ref_mem_q [0:FFT_SIZE-1];
// ============================================================================
// DUT signals
// ============================================================================
reg [15:0] adc_data_i;
reg [15:0] adc_data_q;
reg adc_valid;
reg [5:0] chirp_counter;
reg [15:0] long_chirp_real;
reg [15:0] long_chirp_imag;
reg [15:0] short_chirp_real;
reg [15:0] short_chirp_imag;
wire signed [15:0] range_profile_i;
wire signed [15:0] range_profile_q;
wire range_profile_valid;
wire [3:0] chain_state;
// ============================================================================
// DUT instantiation
// ============================================================================
matched_filter_processing_chain dut (
.clk(clk),
.reset_n(reset_n),
.adc_data_i(adc_data_i),
.adc_data_q(adc_data_q),
.adc_valid(adc_valid),
.chirp_counter(chirp_counter),
.long_chirp_real(long_chirp_real),
.long_chirp_imag(long_chirp_imag),
.short_chirp_real(short_chirp_real),
.short_chirp_imag(short_chirp_imag),
.range_profile_i(range_profile_i),
.range_profile_q(range_profile_q),
.range_profile_valid(range_profile_valid),
.chain_state(chain_state)
);
// ============================================================================
// Output capture
// ============================================================================
reg signed [15:0] cap_out_i [0:FFT_SIZE-1];
reg signed [15:0] cap_out_q [0:FFT_SIZE-1];
integer cap_count;
integer cap_file;
// ============================================================================
// Test procedure
// ============================================================================
integer i;
integer wait_count;
integer pass_count;
integer fail_count;
integer test_count;
task check;
input cond;
input [511:0] label;
begin
test_count = test_count + 1;
if (cond) begin
$display("[PASS] %0s", label);
pass_count = pass_count + 1;
end else begin
$display("[FAIL] %0s", label);
fail_count = fail_count + 1;
end
end
endtask
task apply_reset;
begin
reset_n <= 1'b0;
adc_data_i <= 16'd0;
adc_data_q <= 16'd0;
adc_valid <= 1'b0;
chirp_counter <= 6'd0;
long_chirp_real <= 16'd0;
long_chirp_imag <= 16'd0;
short_chirp_real <= 16'd0;
short_chirp_imag <= 16'd0;
repeat(4) @(posedge clk);
reset_n <= 1'b1;
@(posedge clk);
end
endtask
// ============================================================================
// Main test
// ============================================================================
initial begin
// VCD dump
$dumpfile("tb_mf_cosim.vcd");
$dumpvars(0, tb_mf_cosim);
pass_count = 0;
fail_count = 0;
test_count = 0;
cap_count = 0;
// Load test data
$readmemh(SIG_I_HEX, sig_mem_i);
$readmemh(SIG_Q_HEX, sig_mem_q);
$readmemh(REF_I_HEX, ref_mem_i);
$readmemh(REF_Q_HEX, ref_mem_q);
$display("============================================================");
$display("Matched Filter Co-Sim Testbench");
$display("Scenario: %0s", SCENARIO_NAME);
$display("============================================================");
// ---- Reset ----
apply_reset;
check(chain_state == 4'd0, "State is IDLE after reset");
// ---- Feed 1024 samples ----
$display("\nFeeding %0d samples...", FFT_SIZE);
for (i = 0; i < FFT_SIZE; i = i + 1) begin
@(posedge clk);
adc_data_i <= sig_mem_i[i];
adc_data_q <= sig_mem_q[i];
long_chirp_real <= ref_mem_i[i];
long_chirp_imag <= ref_mem_q[i];
short_chirp_real <= 16'd0;
short_chirp_imag <= 16'd0;
adc_valid <= 1'b1;
end
@(posedge clk);
adc_valid <= 1'b0;
adc_data_i <= 16'd0;
adc_data_q <= 16'd0;
long_chirp_real <= 16'd0;
long_chirp_imag <= 16'd0;
$display("All samples fed. Waiting for processing...");
// ---- Wait for first valid output ----
// Also capture while waiting valid may start before we see it
wait_count = 0;
cap_count = 0;
while (cap_count < FFT_SIZE && wait_count < TIMEOUT) begin
@(posedge clk);
#1;
if (range_profile_valid) begin
cap_out_i[cap_count] = range_profile_i;
cap_out_q[cap_count] = range_profile_q;
cap_count = cap_count + 1;
end
wait_count = wait_count + 1;
end
$display("Captured %0d output samples (waited %0d clocks)", cap_count, wait_count);
// Check that we went through output state
check(cap_count == FFT_SIZE, "Got 1024 output samples");
// ---- Wait for DONE -> IDLE ----
i = 0;
while (chain_state != 4'd0 && i < 100) begin
@(posedge clk);
i = i + 1;
end
check(chain_state == 4'd0, "Returned to IDLE state");
// ---- Find peak ----
begin : find_peak
integer peak_bin;
reg signed [15:0] peak_i_val, peak_q_val;
integer peak_mag, cur_mag;
integer abs_i, abs_q;
peak_mag = -1;
peak_bin = 0;
peak_i_val = 0;
peak_q_val = 0;
for (i = 0; i < cap_count; i = i + 1) begin
abs_i = (cap_out_i[i] < 0) ? -cap_out_i[i] : cap_out_i[i];
abs_q = (cap_out_q[i] < 0) ? -cap_out_q[i] : cap_out_q[i];
cur_mag = abs_i + abs_q;
if (cur_mag > peak_mag) begin
peak_mag = cur_mag;
peak_bin = i;
peak_i_val = cap_out_i[i];
peak_q_val = cap_out_q[i];
end
end
$display("\nPeak: bin=%0d, mag=%0d, I=%0d, Q=%0d",
peak_bin, peak_mag, peak_i_val, peak_q_val);
end
// ---- Write CSV ----
cap_file = $fopen(OUTPUT_CSV, "w");
if (cap_file == 0) begin
$display("ERROR: Cannot open output CSV: %0s", OUTPUT_CSV);
end else begin
$fwrite(cap_file, "bin,range_profile_i,range_profile_q\n");
for (i = 0; i < cap_count; i = i + 1) begin
$fwrite(cap_file, "%0d,%0d,%0d\n", i, cap_out_i[i], cap_out_q[i]);
end
$fclose(cap_file);
$display("Output written to: %0s", OUTPUT_CSV);
end
// ---- Summary ----
$display("\n============================================================");
$display("Results: %0d/%0d PASS", pass_count, test_count);
if (fail_count == 0)
$display("ALL TESTS PASSED");
else
$display("SOME TESTS FAILED");
$display("============================================================");
$finish;
end
endmodule