chirp-v2 PR-C: chirp_reference_rom replaces chirp_memory_loader_param

Drop the chirp-v1 1-bit use_long_chirp memory loader and its 6 .mem files;
introduce chirp_reference_rom — wave_sel-native, single 8192x16 BRAM array
per Q15 lane, 4-region init (SHORT, MEDIUM, LONG seg0/seg1) loaded from the
PR-B mem files. Same 1-clk read latency as the legacy module so the RX-B
autocorrelation alignment fix carries through unchanged.

Receiver-side wave_sel shim added in radar_receiver_final.v:
  wire [1:0] wave_sel = use_long_chirp ? RP_WAVE_LONG : RP_WAVE_SHORT;
This is a 1-line transitional bridge while radar_mode_controller still
emits 1-bit use_long_chirp; PR-D deletes the shim and wires chirp_scheduler
straight through. MEDIUM is loaded into the ROM but unreachable through
the production path until PR-D.

BRAM cost: 8 RAMB18 (was 6 in chirp-v1). +2 BRAM is the cost of adding
MEDIUM to the waveform set; not avoidable.

Files added:
  - chirp_reference_rom.v
Files removed:
  - chirp_memory_loader_param.v
  - long_chirp_seg{0,1}_{i,q}.mem (4 files)
  - short_chirp_{i,q}.mem (2 files)
  - tb/cosim/validate_mem_files.py (legacy file-set validator; replaced by
    gen_chirp_mem.py's internal verify_phase_match)
  - tb/cosim/analyze_short_chirp_mismatch.py (one-shot tool from the
    chirp-v1 TX-I investigation; finding incorporated, references the
    deleted short_chirp_*.mem files)
Files updated for module rename:
  - radar_receiver_final.v        — instance, comments, wave_sel shim
  - radar_mode_controller.v       — header comment
  - matched_filter_processing_chain.v — header comment
  - scripts/200t/build_200t.tcl   — explicit RTL list
  - run_regression.sh             — 5 spots
  - tb/tb_rxb_fullchain_latency.v — instance, wave_sel shim, mem filenames,
                                    SHORT_LEN 50 → 100 (1 µs at 100 MHz)
  - tb/tb_system_e2e.v            — header comment

Verification:
  - chirp_reference_rom standalone iverilog compile: clean
  - Full receiver chain compile (21 RTL files): clean
  - tb_rxb_fullchain_latency runs end-to-end with new ROM + new mem files
    + 100-sample SHORT chirp; autocorrelation peak at bin 0, peak |I|+|Q|
    = 15115. Confirms 1-clk ROM read latency is preserved and the RX-B
    direct-wire-with-1-FF alignment still holds.
  - 50T build script (scripts/50t/build_50t.tcl) uses glob *.v — no edit
    needed; it picks up the new file automatically.
This commit is contained in:
Jason
2026-04-30 19:37:43 +05:45
parent f5b8e7a20b
commit 4238eb1b99
17 changed files with 169 additions and 9137 deletions
@@ -1,188 +0,0 @@
#!/usr/bin/env python3
# ruff: noqa: T201
"""
analyze_short_chirp_mismatch.py — quantify TX-I matched-filter mismatch loss.
Background
----------
TX path (`plfm_chirp_controller.v:74,118-127`):
60-sample inline LUT, 8-bit unsigned offset binary (DAC center = 128),
played at fs_tx = 120 MHz over 0.5 us. Real-valued passband chirp.
Module comment claims "30 MHz to 10 MHz" downchirp.
RX matched-filter reference (`gen_chirp_mem.py:81-101` -> `short_chirp_{i,q}.mem`):
50-sample complex baseband, Q15, fs_rx = 100 MHz over 0.5 us.
Generated as a 0 -> +20 MHz baseband upchirp:
phi(t) = pi * (BW/T) * t^2, BW = 20 MHz, T = 0.5 us
I(n) = cos(phi), Q(n) = sin(phi), scaled by 0.9*Q15
These are claimed by the ledger to be ~2-3 dB mismatched. This script
derives the implied baseband chirp from the TX LUT (modeling the IF chain
and DDC by NCO at 120 MHz, decimation 4x to 100 MHz), then computes the
true matched-filter peak power lost to template mismatch by:
1. Loading the TX LUT, computing the analytic signal (Hilbert),
verifying instantaneous-frequency trajectory + claimed bandwidth.
2. Modeling the DDC: mix by 120 MHz NCO at 400 MHz ADC sample rate,
low-pass + decimate 4x to recover 100 MHz baseband. Since the TX
LUT is only at 120 MHz, we upsample 120->400 first via zero-stuff +
filter (the radar's analog chain does this naturally).
3. Producing the implied 50-sample Q15 baseband reference.
4. Computing the ambiguity peak between
a) implied-from-TX reference cross-correlated with itself
b) implied-from-TX reference cross-correlated with the existing
short_chirp_{i,q}.mem
The dB ratio of (b) peak / (a) peak is the mismatch loss.
Output: report only. Does not modify any .mem files.
"""
import os
import re
import sys
import numpy as np
from scipy.signal import hilbert, resample_poly
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
RTL_DIR = os.path.join(THIS_DIR, "..", "..")
FS_TX = 120e6 # DAC sample rate
FS_RX = 100e6 # post-DDC processing rate
T_CHIRP = 0.5e-6
N_TX = 60 # samples in TX LUT
N_RX = 50 # samples in RX reference
# --- Parse TX LUT inline-coded in plfm_chirp_controller.v ----------------
def read_tx_lut() -> np.ndarray:
path = os.path.join(RTL_DIR, "plfm_chirp_controller.v")
with open(path) as f:
src = f.read()
# Capture every "short_chirp_lut[<idx>] = 8'd<value>;"
pairs = re.findall(r"short_chirp_lut\[\s*(\d+)\s*\]\s*=\s*8'd\s*(\d+)\s*;", src)
if len(pairs) != N_TX:
sys.exit(f"expected {N_TX} TX LUT entries, got {len(pairs)}")
arr = np.zeros(N_TX, dtype=np.int32)
for idx_s, val_s in pairs:
arr[int(idx_s)] = int(val_s)
# Convert from 8-bit unsigned offset binary (DAC center = 128) to signed.
return arr - 128 # int range roughly [-128, +127]
# --- Parse existing RX reference .mem files -------------------------------
def read_q15_mem(name: str) -> np.ndarray:
path = os.path.join(RTL_DIR, name)
out = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
v = int(line, 16)
if v >= 0x8000:
v -= 0x10000
out.append(v)
return np.array(out, dtype=np.int32)
# --- Derive implied 50-sample baseband reference from the TX LUT ---------
def derive_baseband_from_tx(tx: np.ndarray) -> np.ndarray:
"""
1) Treat tx as fs=120 MHz real samples.
2) Compute analytic signal (Hilbert) -> single-sided spectrum copy.
3) Find the chirp's center frequency from the analytic signal's
mean instantaneous frequency, then mix it down to baseband by
multiplying by exp(-j*2*pi*fc*t).
4) Resample 120 -> 100 MHz to get exactly N_RX = 50 samples
(matching the existing reference grid).
5) Return as complex float64.
"""
x = tx.astype(np.float64)
z = hilbert(x) # complex analytic, fs=120 MHz
n = np.arange(len(z))
# Instantaneous phase + frequency
inst_phase = np.unwrap(np.angle(z))
inst_freq = np.diff(inst_phase) * FS_TX / (2 * np.pi)
fc = float(np.mean(inst_freq)) # rough center frequency in Hz
# Mix to baseband
bb_120 = z * np.exp(-1j * 2 * np.pi * fc * n / FS_TX)
# Resample 120 MHz -> 100 MHz: use up=5, down=6 (5/6 = 100/120).
bb_100 = resample_poly(bb_120, up=5, down=6)
# Trim/pad to exactly N_RX samples
if len(bb_100) >= N_RX:
bb_100 = bb_100[:N_RX]
else:
bb_100 = np.concatenate([bb_100, np.zeros(N_RX - len(bb_100), dtype=complex)])
return bb_100, fc, inst_freq
# --- Mismatch loss in dB --------------------------------------------------
def peak_corr_db(ref: np.ndarray, sig: np.ndarray) -> float:
"""Peak |ref dot conj(sig_shifted)| over all integer shifts, normalised."""
# Both arrays equal length; cross-correlate.
c = np.correlate(sig, ref, mode="full")
return 20 * np.log10(np.max(np.abs(c)) + 1e-30)
def main() -> int:
tx = read_tx_lut()
rx_i = read_q15_mem("short_chirp_i.mem")
rx_q = read_q15_mem("short_chirp_q.mem")
if len(rx_i) != N_RX or len(rx_q) != N_RX:
sys.exit(f"RX .mem files expected {N_RX} samples, got I={len(rx_i)} Q={len(rx_q)}")
rx = (rx_i + 1j * rx_q).astype(complex)
# Derive implied baseband reference from TX LUT
bb, fc, inst_freq = derive_baseband_from_tx(tx)
# Bandwidth check from instantaneous frequency
f_lo, f_hi = float(np.min(inst_freq)), float(np.max(inst_freq))
bw = f_hi - f_lo
print("=== TX LUT analysis ===")
print(f" samples: {N_TX} @ {FS_TX/1e6:.0f} MHz, duration {N_TX/FS_TX*1e6:.3f} us")
print(f" inst-freq range: {f_lo/1e6:+7.2f} MHz .. {f_hi/1e6:+7.2f} MHz")
print(f" bandwidth swept: {bw/1e6:6.2f} MHz")
print(f" center frequency: {fc/1e6:+7.2f} MHz (inferred from mean inst freq)")
sweep_dir = "UP" if inst_freq[-1] > inst_freq[0] else "DOWN"
print(f" sweep direction: {sweep_dir} (start={inst_freq[0]/1e6:+.2f} MHz, "
f"end={inst_freq[-1]/1e6:+.2f} MHz)")
print()
print("=== Existing RX reference (short_chirp_{i,q}.mem) ===")
rx_phase = np.unwrap(np.angle(rx + 1e-30))
rx_inst_freq = np.diff(rx_phase) * FS_RX / (2 * np.pi)
rx_lo, rx_hi = float(np.min(rx_inst_freq)), float(np.max(rx_inst_freq))
print(f" samples: {N_RX} @ {FS_RX/1e6:.0f} MHz")
print(f" inst-freq range: {rx_lo/1e6:+7.2f} MHz .. {rx_hi/1e6:+7.2f} MHz")
print(f" bandwidth swept: {(rx_hi - rx_lo)/1e6:6.2f} MHz")
rx_sweep = "UP" if rx_inst_freq[-1] > rx_inst_freq[0] else "DOWN"
print(f" sweep direction: {rx_sweep}")
print()
print("=== Mismatch loss (matched-filter peak: implied-vs-existing) ===")
# Normalise both to unit energy so the only thing the ratio reflects is shape.
bb_n = bb / np.sqrt(np.sum(np.abs(bb) ** 2) + 1e-30)
rx_n = rx / np.sqrt(np.sum(np.abs(rx) ** 2) + 1e-30)
auto_db = peak_corr_db(bb_n, bb_n)
cross_db = peak_corr_db(bb_n, rx_n)
loss_db = auto_db - cross_db
print(f" auto-correlation peak (implied vs implied): {auto_db:+6.2f} dB")
print(f" cross-corr peak (implied vs existing RX): {cross_db:+6.2f} dB")
print(f" MISMATCH LOSS (matched filter): {loss_db:6.2f} dB")
print()
# Decision aid
if loss_db < 0.5:
verdict = "AGREEMENT — TX LUT and RX reference are consistent within 0.5 dB."
elif loss_db < 2.0:
verdict = ("MILD MISMATCH — within ledger's 2-3 dB note; refresh "
"recommended but not blocking.")
else:
verdict = "SIGNIFICANT MISMATCH — RX reference should be regenerated from TX LUT."
print(f"VERDICT: {verdict}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,492 +0,0 @@
#!/usr/bin/env python3
"""
validate_mem_files.py — Validate all .mem files against AERIS-10 radar parameters.
Checks:
1. Structural: line counts, hex format, value ranges for all 12 .mem files
2. FFT twiddle files: bit-exact match against cos(2*pi*k/N) in Q15
3. Long chirp .mem files: reverse-engineer parameters, check for chirp structure
4. Short chirp .mem files: check length, value range, spectral content
Usage:
python3 validate_mem_files.py
"""
import math
import os
import sys
# ============================================================================
# AERIS-10 System Parameters (from radar_scene.py)
# ============================================================================
F_CARRIER = 10.5e9 # 10.5 GHz carrier
C_LIGHT = 3.0e8
F_IF = 120e6 # IF frequency
CHIRP_BW = 20e6 # 20 MHz sweep
FS_ADC = 400e6 # ADC sample rate
FS_SYS = 100e6 # System clock (100 MHz, after CIC 4x)
T_LONG_CHIRP = 30e-6 # 30 us long chirp
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
CIC_DECIMATION = 4
FFT_SIZE = 1024
DOPPLER_FFT_SIZE = 16
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000 at 100 MHz
# Overlap-save parameters
OVERLAP_SAMPLES = 128
SEGMENT_ADVANCE = FFT_SIZE - OVERLAP_SAMPLES # 896
LONG_SEGMENTS = 4
MEM_DIR = os.path.join(os.path.dirname(__file__), '..', '..')
pass_count = 0
fail_count = 0
warn_count = 0
def check(condition, _label):
global pass_count, fail_count
if condition:
pass_count += 1
else:
fail_count += 1
def warn(_label):
global warn_count
warn_count += 1
def read_mem_hex(filename):
"""Read a .mem file, return list of integer values (16-bit signed)."""
path = os.path.join(MEM_DIR, filename)
values = []
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
continue
val = int(line, 16)
# Interpret as 16-bit signed
if val >= 0x8000:
val -= 0x10000
values.append(val)
return values
# ============================================================================
# TEST 1: Structural validation of all .mem files
# ============================================================================
def test_structural():
expected = {
# FFT twiddle files (quarter-wave cosine ROMs)
'fft_twiddle_1024.mem': {'lines': 256, 'desc': '1024-pt FFT quarter-wave cos ROM'},
'fft_twiddle_16.mem': {'lines': 4, 'desc': '16-pt FFT quarter-wave cos ROM'},
# Long chirp segments (4 segments x 1024 samples each)
'long_chirp_seg0_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 I'},
'long_chirp_seg0_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 Q'},
'long_chirp_seg1_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 I'},
'long_chirp_seg1_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 Q'},
'long_chirp_seg2_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 I'},
'long_chirp_seg2_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 Q'},
'long_chirp_seg3_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 I'},
'long_chirp_seg3_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 Q'},
# Short chirp (50 samples)
'short_chirp_i.mem': {'lines': 50, 'desc': 'Short chirp I'},
'short_chirp_q.mem': {'lines': 50, 'desc': 'Short chirp Q'},
}
for fname, info in expected.items():
path = os.path.join(MEM_DIR, fname)
exists = os.path.isfile(path)
check(exists, f"{fname} exists")
if not exists:
continue
vals = read_mem_hex(fname)
check(len(vals) == info['lines'],
f"{fname}: {len(vals)} data lines (expected {info['lines']})")
# Check all values are in 16-bit signed range
in_range = all(-32768 <= v <= 32767 for v in vals)
check(in_range, f"{fname}: all values in [-32768, 32767]")
# ============================================================================
# TEST 2: FFT Twiddle Factor Validation
# ============================================================================
def test_twiddle_1024():
vals = read_mem_hex('fft_twiddle_1024.mem')
max_err = 0
err_details = []
for k in range(min(256, len(vals))):
angle = 2.0 * math.pi * k / 1024.0
expected = round(math.cos(angle) * 32767.0)
expected = max(-32768, min(32767, expected))
actual = vals[k]
err = abs(actual - expected)
if err > max_err:
max_err = err
if err > 1:
err_details.append((k, actual, expected, err))
check(max_err <= 1,
f"fft_twiddle_1024.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
if err_details:
for _, _act, _exp, _e in err_details[:5]:
pass
def test_twiddle_16():
vals = read_mem_hex('fft_twiddle_16.mem')
max_err = 0
for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0
expected = round(math.cos(angle) * 32767.0)
expected = max(-32768, min(32767, expected))
actual = vals[k]
err = abs(actual - expected)
if err > max_err:
max_err = err
check(max_err <= 1,
f"fft_twiddle_16.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
# Print all 4 entries for reference
for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0
expected = round(math.cos(angle) * 32767.0)
# ============================================================================
# TEST 3: Long Chirp .mem File Analysis
# ============================================================================
def test_long_chirp():
# Load all 4 segments
all_i = []
all_q = []
for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
all_i.extend(seg_i)
all_q.extend(seg_q)
total_samples = len(all_i)
check(total_samples == 4096,
f"Total long chirp samples: {total_samples} (expected 4096 = 4 segs x 1024)")
# Compute magnitude envelope
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(all_i, all_q, strict=False)]
max_mag = max(magnitudes)
min(magnitudes)
sum(magnitudes) / len(magnitudes)
# Check if this looks like it came from generate_reference_chirp_q15
# That function uses 32767 * 0.9 scaling => max magnitude ~29490
expected_max_from_model = 32767 * 0.9
uses_model_scaling = max_mag > expected_max_from_model * 0.8
if uses_model_scaling:
pass
else:
warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model "
f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.")
# Check non-zero content: how many samples are non-zero?
sum(1 for v in all_i if v != 0)
sum(1 for v in all_q if v != 0)
# Analyze instantaneous frequency via phase differences
phases = []
for i_val, q_val in zip(all_i, all_q, strict=False):
if abs(i_val) > 5 or abs(q_val) > 5: # Skip near-zero samples
phases.append(math.atan2(q_val, i_val))
else:
phases.append(None)
# Compute phase differences (instantaneous frequency)
freq_estimates = []
for n in range(1, len(phases)):
if phases[n] is not None and phases[n-1] is not None:
dp = phases[n] - phases[n-1]
# Unwrap
while dp > math.pi:
dp -= 2 * math.pi
while dp < -math.pi:
dp += 2 * math.pi
# Frequency in Hz (at 100 MHz sample rate, since these are post-DDC)
f_inst = dp * FS_SYS / (2 * math.pi)
freq_estimates.append(f_inst)
if freq_estimates:
sum(freq_estimates[:50]) / 50 if len(freq_estimates) > 50 else freq_estimates[0]
sum(freq_estimates[-50:]) / 50 if len(freq_estimates) > 50 else freq_estimates[-1]
f_min = min(freq_estimates)
f_max = max(freq_estimates)
f_range = f_max - f_min
# A chirp should show frequency sweep
is_chirp = f_range > 0.5e6 # At least 0.5 MHz sweep
check(is_chirp,
f"Long chirp shows frequency sweep ({f_range/1e6:.2f} MHz > 0.5 MHz)")
# Check if bandwidth roughly matches expected
bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50%
if bw_match:
pass
else:
warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz")
# Compare segment boundaries for overlap-save consistency
# In proper overlap-save, the chirp data should be segmented at 896-sample boundaries
# with segments being 1024-sample FFT blocks
for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
seg_mags = [math.sqrt(i*i + q*q) for i, q in zip(seg_i, seg_q, strict=False)]
sum(seg_mags) / len(seg_mags)
max(seg_mags)
# Check segment 3 zero-padding (chirp is 3000 samples, seg3 starts at 3072)
# Samples 3000-4095 should be zero (or near-zero) if chirp is exactly 3000 samples
if seg == 3:
# Seg3 covers chirp samples 3072..4095
# If chirp is only 3000 samples, then only samples 0..(3000-3072) = NONE are valid
# Actually chirp has 3000 samples total. Seg3 starts at index 3*1024=3072.
# So seg3 should only have 3000-3072 = -72 -> no valid chirp data!
# Wait, but the .mem files have 1024 lines with non-trivial data...
# Let's check if seg3 has significant data
zero_count = sum(1 for m in seg_mags if m < 2)
if zero_count > 500:
pass
else:
pass
else:
pass
# ============================================================================
# TEST 4: Short Chirp .mem File Analysis
# ============================================================================
def test_short_chirp():
short_i = read_mem_hex('short_chirp_i.mem')
short_q = read_mem_hex('short_chirp_q.mem')
check(len(short_i) == 50, f"Short chirp I: {len(short_i)} samples (expected 50)")
check(len(short_q) == 50, f"Short chirp Q: {len(short_q)} samples (expected 50)")
# Expected: 0.5 us chirp at 100 MHz = 50 samples
expected_samples = int(T_SHORT_CHIRP * FS_SYS)
check(len(short_i) == expected_samples,
f"Short chirp length matches T_SHORT_CHIRP * FS_SYS = {expected_samples}")
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(short_i, short_q, strict=False)]
max(magnitudes)
sum(magnitudes) / len(magnitudes)
# Check non-zero
nonzero = sum(1 for m in magnitudes if m > 1)
check(nonzero == len(short_i), f"All {nonzero}/{len(short_i)} samples non-zero")
# Check it looks like a chirp (phase should be quadratic)
phases = [math.atan2(q, i) for i, q in zip(short_i, short_q, strict=False)]
freq_est = []
for n in range(1, len(phases)):
dp = phases[n] - phases[n-1]
while dp > math.pi:
dp -= 2 * math.pi
while dp < -math.pi:
dp += 2 * math.pi
freq_est.append(dp * FS_SYS / (2 * math.pi))
if freq_est:
freq_est[0]
freq_est[-1]
# ============================================================================
# TEST 5: Generate Expected Chirp .mem and Compare
# ============================================================================
def test_chirp_vs_model():
# Generate reference using the same method as radar_scene.py
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
model_i = []
model_q = []
n_chirp = min(FFT_SIZE, LONG_CHIRP_SAMPLES) # 1024
for n in range(n_chirp):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
re_val = round(32767 * 0.9 * math.cos(phase))
im_val = round(32767 * 0.9 * math.sin(phase))
model_i.append(max(-32768, min(32767, re_val)))
model_q.append(max(-32768, min(32767, im_val)))
# Read seg0 from .mem
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
# Compare magnitudes
model_mags = [math.sqrt(i*i + q*q) for i, q in zip(model_i, model_q, strict=False)]
mem_mags = [math.sqrt(i*i + q*q) for i, q in zip(mem_i, mem_q, strict=False)]
model_max = max(model_mags)
mem_max = max(mem_mags)
# Check if they match (they almost certainly won't based on magnitude analysis)
matches = sum(1 for a, b in zip(model_i, mem_i, strict=False) if a == b)
if matches > len(model_i) * 0.9:
pass
else:
warn(".mem files do NOT match Python model. They likely have different provenance.")
# Try to detect scaling
if mem_max > 0:
model_max / mem_max
# Check phase correlation (shape match regardless of scaling)
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
# Compute phase differences
phase_diffs = []
for mp, fp in zip(model_phases, mem_phases, strict=False):
d = mp - fp
while d > math.pi:
d -= 2 * math.pi
while d < -math.pi:
d += 2 * math.pi
phase_diffs.append(d)
sum(phase_diffs) / len(phase_diffs)
max_phase_diff = max(abs(d) for d in phase_diffs)
phase_match = max_phase_diff < 0.5 # within 0.5 rad
check(
phase_match,
f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg "
f"(tolerance: 28.6 deg)",
)
# ============================================================================
# TEST 7: Cross-check chirp memory loader addressing
# ============================================================================
def test_memory_addressing():
# chirp_memory_loader_param uses: long_addr = {segment_select[1:0], sample_addr[9:0]}
# This creates a 12-bit address: seg[1:0] ++ addr[9:0]
# Segment 0: addresses 0x000..0x3FF (0..1023)
# Segment 1: addresses 0x400..0x7FF (1024..2047)
# Segment 2: addresses 0x800..0xBFF (2048..3071)
# Segment 3: addresses 0xC00..0xFFF (3072..4095)
for seg in range(4):
base = seg * 1024
end = base + 1023
addr_from_concat = (seg << 10) | 0 # {seg[1:0], 10'b0}
addr_end = (seg << 10) | 1023
check(
addr_from_concat == base,
f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} "
f"(expected {base})",
)
check(addr_end == end,
f"Seg {seg} end address: {{{seg}[1:0], 10'h3FF}} = {addr_end} (expected {end})")
# Memory is declared as: reg [15:0] long_chirp_i [0:4095]
# $readmemh loads seg0 to [0:1023], seg1 to [1024:2047], etc.
# Addressing via {segment_select, sample_addr} maps correctly.
# ============================================================================
# TEST 8: Seg3 zero-padding analysis
# ============================================================================
def test_seg3_padding():
# The long chirp has 3000 samples (30 us at 100 MHz).
# With 4 segments of 1024 samples = 4096 total memory slots.
# Segments are loaded contiguously into memory:
# Seg0: chirp samples 0..1023
# Seg1: chirp samples 1024..2047
# Seg2: chirp samples 2048..3071
# Seg3: chirp samples 3072..4095
#
# But the chirp only has 3000 samples! So seg3 should have:
# Valid chirp data at indices 0..(3000-3072-1) = NEGATIVE
# Wait — 3072 > 3000, so seg3 has NO valid chirp samples if chirp is exactly 3000.
#
# However, the overlap-save algorithm in matched_filter_multi_segment.v
# collects data differently:
# Seg0: collect 896 DDC samples, buffer[0:895], zero-pad [896:1023]
# Seg1: overlap from seg0[768:895] → buffer[0:127], collect 896 → buffer[128:1023]
# ...
# The chirp reference is indexed by segment_select + sample_addr,
# so it reads ALL 1024 values for each segment regardless.
#
# If the chirp is 3000 samples but only 4*1024=4096 slots exist,
# the question is: do the .mem files contain 3000 samples of real chirp
# data spread across 4096 slots, or something else?
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
mags = [math.sqrt(i*i + q*q) for i, q in zip(seg3_i, seg3_q, strict=False)]
# Count trailing zeros (samples after chirp ends)
trailing_zeros = 0
for m in reversed(mags):
if m < 2:
trailing_zeros += 1
else:
break
nonzero = sum(1 for m in mags if m > 2)
if nonzero == 1024:
# This means the .mem files encode 4096 chirp samples, not 3000
# The chirp duration used for .mem generation was different from T_LONG_CHIRP
actual_chirp_samples = 4 * 1024 # = 4096
actual_duration = actual_chirp_samples / FS_SYS
warn(f"Chirp in .mem files appears to be {actual_chirp_samples} samples "
f"({actual_duration*1e6:.1f} us), not {LONG_CHIRP_SAMPLES} samples "
f"({T_LONG_CHIRP*1e6:.1f} us)")
elif trailing_zeros > 100:
# Some padding at end
3072 + (1024 - trailing_zeros)
# ============================================================================
# MAIN
# ============================================================================
def main():
test_structural()
test_twiddle_1024()
test_twiddle_16()
test_long_chirp()
test_short_chirp()
test_chirp_vs_model()
test_memory_addressing()
test_seg3_padding()
if fail_count == 0:
pass
else:
pass
return 0 if fail_count == 0 else 1
if __name__ == '__main__':
sys.exit(main())