mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-06-08 14:44:56 +00:00
test(fpga): PR-M.4 — redesign T-6 drift invariants for scaled-FFT chain
Three sub-checks in compare_independent.py were red because the test
inputs assumed an UNSCALED FFT but PR-O moved both the RTL xFFT path
and fpga_model.FFTEngine to LogiCORE v9.1 Scaled mode (one >>>1 per
butterfly stage with conv-rounding, total /N applied across LOG2N
stages). At small input amplitudes the per-bin output rounded to zero
and the test invariants no longer described meaningful behaviour.
The fpga_reference.py side already mirrors the scaled-mode convention
(np.fft.fft(x)/n on forward, ifft on inverse — see line 104, 137-138,
207). The fix is purely in the test inputs:
- FFT-2048(impulse): amp 1000 → 32000 (≈ Q15 max). Expected per-bin
is now round(amp/N) = round(32000/2048) = 16 (banker's). The
impulse has b=0 at every butterfly so there is no twiddle
interaction; banker's rounding keeps every bin within ±1 LSB.
Tightened tolerance from 5 to 2.
- MF peak position + MF peak-to-median: amp 200 → 4000. Chain
output peak under scaled-mode is correlation/N² ≈ pulse_len*amp²
/N² = amp²/16384 (256 / 4_194_304). At amp=200 the peak collapsed
to 2.4 (mostly Q15 quantization noise — argmax wandered to bin 2);
at amp=4000 the peak rises to ≈ 977 with sidelobes near LSB
floor. Peak-to-median ratio observed: 974 vs threshold 5.
Runtime verification: compare_independent.py 13/13 PASS standalone.
Full FPGA regression: 36/1/6 → 37/0/6 (the single FAIL was this test;
no other tests touched).
This commit is contained in:
@@ -257,16 +257,28 @@ def check_fft_invariants(result: CheckResult):
|
||||
n = 2048
|
||||
fft = FFTEngine(n=n)
|
||||
|
||||
# Impulse → flat spectrum at amplitude (no saturation; amp < 32767/N is overkill).
|
||||
in_re = [1000] + [0] * (n - 1)
|
||||
# Impulse → flat spectrum at amplitude. Under LogiCORE v9.1 Scaled mode
|
||||
# (one >>>1 per stage with conv-rounding, total /N applied across LOG2N
|
||||
# stages — see PR-O / fpga_model.FFTEngine docstring), each bin lands at
|
||||
# round(amp/N), not amp. Lift the input well above the rounding floor so
|
||||
# the test catches twiddle / butterfly drift rather than noise. amp=32000
|
||||
# ≈ Q15 max keeps every per-stage value within 16-bit headroom and gives
|
||||
# expected_per_bin ≈ 16 with no twiddle interaction (impulse has b=0 at
|
||||
# every butterfly), so banker's rounding keeps us within ±1 LSB.
|
||||
impulse_amp = 32000
|
||||
expected_per_bin = round(impulse_amp / n) # 32000/2048 → 16 (banker's)
|
||||
in_re = [impulse_amp] + [0] * (n - 1)
|
||||
in_im = [0] * n
|
||||
twin_re, twin_im = fft.compute(in_re, in_im, inverse=False)
|
||||
flat_max = max(max(twin_re) - 1000, 1000 - min(twin_re),
|
||||
max(twin_im), -min(twin_im))
|
||||
flat_max = max(
|
||||
max(abs(v - expected_per_bin) for v in twin_re),
|
||||
max(abs(v) for v in twin_im),
|
||||
)
|
||||
result.check(
|
||||
flat_max <= 5,
|
||||
"FFT-2048(impulse): all bins ≈ amplitude (1000)",
|
||||
f"max |bin - 1000| = {flat_max}"
|
||||
flat_max <= 2,
|
||||
f"FFT-2048(impulse): all bins ≈ amp/N ({expected_per_bin}) "
|
||||
f"[scaled-mode, amp={impulse_amp}]",
|
||||
f"max |bin - {expected_per_bin}| = {flat_max}"
|
||||
)
|
||||
|
||||
# Single COMPLEX tone (cos + j*sin) → single peak at bin_k (no conjugate
|
||||
@@ -315,12 +327,17 @@ def check_mf_invariants(result: CheckResult):
|
||||
|
||||
delay = 100
|
||||
bin_k = 17
|
||||
amp = 200
|
||||
# Under scaled-mode FFTs the chain output peak ≈ correlation/N². For a
|
||||
# 256-sample tone matched against itself, correlation peak = pulse_len*amp²,
|
||||
# so chain output peak ≈ pulse_len*amp² / N² = amp²/16384. Lift amp from
|
||||
# 200 (peak ≈ 2.4, swamped by Q15 quantization) to 4000 (peak ≈ 977 — clean
|
||||
# margin against the 16-bit IFFT-output saturation at the chain boundary).
|
||||
amp = 4000
|
||||
pulse_len = 256
|
||||
sig_re = [0] * n
|
||||
sig_im = [0] * n
|
||||
ref_re_in = [0] * n
|
||||
ref_im_in = [0] * n
|
||||
pulse_len = 256
|
||||
for i in range(pulse_len):
|
||||
ref_re_in[i] = round(amp * math.cos(2 * math.pi * bin_k * i / pulse_len))
|
||||
ref_im_in[i] = round(amp * math.sin(2 * math.pi * bin_k * i / pulse_len))
|
||||
@@ -337,11 +354,13 @@ def check_mf_invariants(result: CheckResult):
|
||||
ref_peak = int(np.argmax(ref_mag))
|
||||
result.check(
|
||||
twin_peak == ref_peak == delay,
|
||||
f"MF: peak at injected delay (bin {delay})",
|
||||
f"MF: peak at injected delay (bin {delay}) [scaled-mode, amp={amp}]",
|
||||
f"twin={twin_peak}, ref={ref_peak}"
|
||||
)
|
||||
|
||||
# Sidelobe behaviour: peak should be N*stronger than median.
|
||||
# Sidelobe behaviour: peak should be ≥ 5× the median magnitude. Under
|
||||
# scaled-mode at amp=4000 the peak rises to ~977 while sidelobes stay
|
||||
# near the LSB floor, easily clearing the threshold.
|
||||
twin_peak_val = float(twin_mag[delay])
|
||||
twin_median = float(np.median(twin_mag))
|
||||
pk_ratio = twin_peak_val / max(twin_median, 1.0)
|
||||
|
||||
Reference in New Issue
Block a user