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

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

View File

@@ -115,8 +115,21 @@ always @(posedge clk_120m) begin
end
// Short PLFM chirp LUT initialization (too small for BRAM, keep inline)
//
// TX-I (analyzed 2026-04-28; tb/cosim/analyze_short_chirp_mismatch.py):
// 60 samples @ fs_dac=120 MHz over 0.5 us, real-valued passband.
// Hilbert analysis confirms an UPCHIRP from ~10 to ~30 MHz baseband
// (BW ~19.4 MHz). The old comment "30MHz to 10MHz" had the sweep
// direction reversed and is corrected below.
//
// End-to-end frequency plan (from adf4382a_manager.h + ddc_400m.v):
// TX upmix: LO=10.500 GHz, high-side -> RF: 10.510..10.530 GHz
// RX downmix: LO=10.380 GHz, high-side -> IF: 130..150 MHz
// DDC NCO: 120 MHz exactly -> baseband: 10..30 MHz
// The matched-filter reference in tb/cosim/gen_chirp_mem.py was updated
// to include the +10 MHz baseband offset to match this band.
initial begin
// Complete Short PLFM chirp LUT (0.5us, 30MHz to 10MHz)
// Complete Short PLFM chirp LUT (0.5us, ~10MHz to ~30MHz upchirp)
short_chirp_lut[ 0] = 8'd255; short_chirp_lut[ 1] = 8'd237; short_chirp_lut[ 2] = 8'd187; short_chirp_lut[ 3] = 8'd118; short_chirp_lut[ 4] = 8'd 49; short_chirp_lut[ 5] = 8'd 6; short_chirp_lut[ 6] = 8'd 7; short_chirp_lut[ 7] = 8'd 54;
short_chirp_lut[ 8] = 8'd132; short_chirp_lut[ 9] = 8'd210; short_chirp_lut[10] = 8'd253; short_chirp_lut[11] = 8'd237; short_chirp_lut[12] = 8'd167; short_chirp_lut[13] = 8'd 75; short_chirp_lut[14] = 8'd 10; short_chirp_lut[15] = 8'd 10;
short_chirp_lut[16] = 8'd 80; short_chirp_lut[17] = 8'd180; short_chirp_lut[18] = 8'd248; short_chirp_lut[19] = 8'd237; short_chirp_lut[20] = 8'd150; short_chirp_lut[21] = 8'd 45; short_chirp_lut[22] = 8'd 1; short_chirp_lut[23] = 8'd 54;

View File

@@ -1,50 +1,50 @@
7332
7330
730d
7276
70e0
6d8f
679c
5e0a
4fe8
3c80
2399
05ca
e4c2
c380
a653
9271
8d21
9a5d
bb20
ebd7
2399
54f5
70e0
6ba2
4289
0000
bb20
90cb
9729
5c56
1e0c
d044
2399
65a3
6dff
325b
d440
9729
9271
9f85
f753
57d5
6f35
c9c7
2238
679c
6a91
2399
c10f
8d21
b576
8e95
db07
4fe8
1e0c
6d8f
0d00
57d5
ebd7
92e6
ad06
2399
7276
38c3
b7b1
92e6
0000
6dff
3ef1
b234
9bc2
a653
24f9
2399
7219
0173
8de7
e4c2
6d8f
290f
956f
d440
6ba2
2399
9012
f021
7330
f021
9271
38c3
54f5
9f85
ddc8

View File

@@ -1,50 +1,50 @@
0000
0173
05ca
0d00
1702
2399
325b
4289
52fa
6208
44e0
6f35
68d7
2fbc
dc67
9a5d
9201
cda5
2bc0
6d8f
730d
6fee
6208
484f
2399
f753
c9c7
a3aa
8e95
607b
08ad
a82b
90cb
dc67
4a8a
716b
24f9
b018
9271
b234
e8fe
290f
5e0a
7332
5c56
1e0c
d044
9729
f300
643e
59ad
db07
8cce
ddc8
607b
54f5
c73d
9271
c9c7
2238
679c
6a91
2399
c10f
8d21
b576
1e0c
0fdf
7330
0fdf
9012
dc67
6ba2
2bc0
956f
d6f1
6d8f
57d5
ebd7
92e6
ad06
2399
7276
38c3
b7b1
92e6
1b3e
8de7
fe8d
7219
dc67
9bc2
4dcc
3ef1
9201

View File

@@ -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())

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:

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)