mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-06-10 23:41:18 +00:00
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:
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user