fix(fpga): TX-I — align matched-filter reference with actual post-DDC band

The DAC short/long chirp LUTs are 10..30 MHz upchirps (Hilbert-confirmed).
With TX_LO=10.500 GHz, RX_LO=10.380 GHz (adf4382a_manager.h) and the
120 MHz DDC NCO (ddc_400m.v), high-side mixing places the post-DDC echo
at 10..30 MHz baseband. The matched-filter reference (gen_chirp_mem.py)
was generating 0..20 MHz, implicitly assuming the chirp's low edge mixed
to DC. This caused a 10 MHz spectral offset and ~5 dB matched-filter loss.

Adds F_BASEBAND_LOW=10e6 in both gen_chirp_mem.py and radar_scene.py,
with phase formula 2*pi*F_BASEBAND_LOW*t + pi*rate*t^2 in all chirp
generators. Regenerates the 6 .mem files. Adds analyze_short_chirp_mismatch.py
for the Hilbert-based diagnosis. Fixes the misleading "30MHz to 10MHz"
comment in plfm_chirp_controller.v and adds an end-to-end frequency plan
in the LUT header.

Sideband orientation (high-side at both mixers) is the conventional choice
and consistent with antenna match (10.25..10.75 GHz, 8x16 patch designed
at 10.5 GHz). Loopback capture would settle definitively; if either mixer
is low-side the F_BASEBAND_LOW sign flips and/or chirp direction reverses.
This commit is contained in:
Jason
2026-04-29 11:41:19 +05:45
parent b7ac2de1a4
commit 5ff5671fe2
10 changed files with 5242 additions and 4996 deletions
@@ -0,0 +1,188 @@
#!/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())
+37 -7
View File
@@ -24,10 +24,32 @@ Short chirp:
T_SHORT_CHIRP and CHIRP_BW.
Phase model (baseband, post-DDC):
phase(n) = pi * chirp_rate * t^2, t = n / FS_SYS
phase(n) = 2*pi*F_BASEBAND_LOW*t + pi * chirp_rate * t^2, t = n / FS_SYS
chirp_rate = CHIRP_BW / T_chirp
F_BASEBAND_LOW = 10 MHz (DAC chirp low-edge frequency)
Scaling: 0.9 * 32767 (Q15), matching radar_scene.py generate_reference_chirp_q15()
This produces a F_BASEBAND_LOW..(F_BASEBAND_LOW+CHIRP_BW) baseband upchirp.
End-to-end frequency plan (TX-I, 2026-04-28):
DAC LUT : 10..30 MHz @ fs_dac=120 MHz (plfm_chirp_controller.v;
Hilbert-confirmed for both
long and short LUTs)
TX upmix : LO=10.500 GHz (adf4382a_manager.h:35), high-side
-> RF transmitted: 10.510..10.530 GHz
RX downmix: LO=10.380 GHz (adf4382a_manager.h:36), high-side
-> IF at ADC: 130..150 MHz
DDC NCO : 120 MHz exactly (ddc_400m.v:201)
-> baseband: 10..30 MHz <-- matched-filter reference
Sideband orientation (high-side at both mixers) is the conventional choice
and consistent with all design comments / antenna match (10.25..10.75 GHz);
loopback capture would settle it definitively. If either mixer turns out to
be low-side, the sign of F_BASEBAND_LOW flips and/or the chirp direction
reverses; revisit before re-generating .mem files.
radar_scene.py uses the same F_BASEBAND_LOW; both must stay in sync.
Scaling: 0.9 * 32767 (Q15)
Usage:
python3 gen_chirp_mem.py
@@ -45,6 +67,13 @@ FS_SYS = 100e6 # System clock (100 MHz, post-CIC)
T_LONG_CHIRP = 30e-6 # 30 us long chirp duration
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp duration
FFT_SIZE = 2048
# DAC chirp baseband low-edge frequency. The TX LUT in plfm_chirp_controller.v
# is a 10..30 MHz upchirp at fs_dac=120 MHz (Hilbert-confirmed for both long
# and short LUTs). With TX_LO=10.500 GHz, RX_LO=10.380 GHz (adf4382a_manager.h)
# and the 120 MHz DDC NCO (ddc_400m.v), high-side mixing places the post-DDC
# echo at 10..30 MHz baseband, not 0..20 MHz. The matched-filter reference
# must include this +10 MHz DC offset.
F_BASEBAND_LOW = 10e6
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000
SHORT_CHIRP_SAMPLES = int(T_SHORT_CHIRP * FS_SYS) # 50
LONG_SEGMENTS = 2
@@ -69,7 +98,7 @@ def generate_full_long_chirp():
for n in range(LONG_CHIRP_SAMPLES):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
phase = 2 * math.pi * F_BASEBAND_LOW * t + math.pi * chirp_rate * t * t
re_val = round(Q15_MAX * SCALE * math.cos(phase))
im_val = round(Q15_MAX * SCALE * math.sin(phase))
chirp_i.append(max(-32768, min(32767, re_val)))
@@ -92,7 +121,7 @@ def generate_short_chirp():
for n in range(SHORT_CHIRP_SAMPLES):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
phase = 2 * math.pi * F_BASEBAND_LOW * t + math.pi * chirp_rate * t * t
re_val = round(Q15_MAX * SCALE * math.cos(phase))
im_val = round(Q15_MAX * SCALE * math.sin(phase))
chirp_i.append(max(-32768, min(32767, re_val)))
@@ -155,13 +184,14 @@ def main():
# ---- Verification summary ----
# Cross-check seg0 against radar_scene.py generate_reference_chirp_q15()
# That function generates exactly the first 1024 samples of the chirp
# Self-check: recompute the phase formula and verify the seg0 .mem matches.
# radar_scene.py.generate_reference_chirp_q15() uses the same phase form
# and the same F_BASEBAND_LOW; the two stay in sync by construction.
chirp_rate = CHIRP_BW / T_LONG_CHIRP
mismatches = 0
for n in range(FFT_SIZE):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
phase = 2 * math.pi * F_BASEBAND_LOW * t + math.pi * chirp_rate * t * t
expected_i = max(-32768, min(32767, round(Q15_MAX * SCALE * math.cos(phase))))
expected_q = max(-32768, min(32767, round(Q15_MAX * SCALE * math.sin(phase))))
if long_i[n] != expected_i or long_q[n] != expected_q:
+30 -15
View File
@@ -32,11 +32,23 @@ F_CARRIER = 10.5e9 # 10.5 GHz carrier
C_LIGHT = 3.0e8 # Speed of light (m/s)
WAVELENGTH = C_LIGHT / F_CARRIER # ~0.02857 m
# Chirp parameters
F_IF = 120e6 # IF frequency (120 MHz)
CHIRP_BW = 20e6 # Chirp bandwidth (30 MHz -> 10 MHz = 20 MHz sweep)
F_CHIRP_START = 30e6 # Chirp start frequency (relative to IF)
F_CHIRP_END = 10e6 # Chirp end frequency (relative to IF)
# Chirp parameters.
#
# End-to-end frequency plan (TX-I, 2026-04-28):
# DAC LUT : 10..30 MHz @ fs_dac=120 MHz (plfm_chirp_controller.v;
# Hilbert-confirmed for both
# long and short LUTs)
# TX upmix : LO=10.500 GHz (adf4382a_manager.h:35), high-side
# -> RF transmitted: 10.510..10.530 GHz
# RX downmix: LO=10.380 GHz (adf4382a_manager.h:36), high-side
# -> IF at ADC: 130..150 MHz
# DDC NCO : 120 MHz exactly (ddc_400m.v:201)
# -> baseband: 10..30 MHz
F_IF = 120e6 # DDC NCO frequency (120 MHz)
F_BASEBAND_LOW = 10e6 # DAC chirp baseband low-edge frequency
CHIRP_BW = 20e6 # Chirp bandwidth (10 MHz -> 30 MHz upchirp = 20 MHz sweep)
F_CHIRP_START = F_BASEBAND_LOW # 10 MHz at DAC baseband
F_CHIRP_END = F_BASEBAND_LOW + CHIRP_BW # 30 MHz at DAC baseband
# Sampling
FS_ADC = 400e6 # ADC sample rate (400 MSPS)
@@ -153,12 +165,13 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
chirp_q = []
chirp_rate = chirp_bw / (n_samples / fs) # Hz/s
# IF chirp starts at f_if + F_BASEBAND_LOW and sweeps up over chirp_bw,
# i.e. 130..150 MHz for the nominal high-side / 120 MHz NCO chain.
f_lo = f_if + F_BASEBAND_LOW
for n in range(n_samples):
t = n / fs
# Instantaneous frequency: f_if - chirp_bw/2 + chirp_rate * t
# Phase: integral of 2*pi*f(t)*dt
_f_inst = f_if - chirp_bw / 2 + chirp_rate * t
phase = 2 * math.pi * (f_if - chirp_bw / 2) * t + math.pi * chirp_rate * t * t
_f_inst = f_lo + chirp_rate * t
phase = 2 * math.pi * f_lo * t + math.pi * chirp_rate * t * t
chirp_i.append(math.cos(phase))
chirp_q.append(math.sin(phase))
@@ -188,10 +201,10 @@ def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, _f_if=F_IF,
for n in range(chirp_samples):
t = n / FS_SYS
# After DDC, the chirp is at baseband
# The beat frequency from a target at delay tau is: f_beat = chirp_rate * tau
# Reference chirp is the TX chirp at baseband (zero delay)
phase = math.pi * chirp_rate * t * t
# After DDC, the chirp is at baseband F_BASEBAND_LOW..(F_BASEBAND_LOW+BW),
# i.e. 10..30 MHz for the nominal chain. Reference chirp is the TX chirp
# at baseband (zero delay). Phase formula must match gen_chirp_mem.py.
phase = 2 * math.pi * F_BASEBAND_LOW * t + math.pi * chirp_rate * t * t
re_val = round(32767 * 0.9 * math.cos(phase))
im_val = round(32767 * 0.9 * math.sin(phase))
ref_re[n] = max(-32768, min(32767, re_val))
@@ -263,8 +276,10 @@ def generate_adc_samples(targets, n_samples, noise_stddev=3.0,
t = n / FS_ADC
t_delayed = n_delayed / FS_ADC
# Signal at IF: cos(2*pi*f_if*t + pi*chirp_rate*t_delayed^2 + doppler + phase)
phase = (2 * math.pi * F_IF * t
# Signal at IF: chirp starts at (F_IF + F_BASEBAND_LOW) and sweeps
# up by chirp_rate (130..150 MHz for the nominal chain).
f_lo_if = F_IF + F_BASEBAND_LOW
phase = (2 * math.pi * f_lo_if * t
+ math.pi * chirp_rate * t_delayed * t_delayed
+ 2 * math.pi * doppler_hz * t
+ phase0)