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:
Jason
2026-05-05 12:37:26 +05:45
parent 0100967eac
commit bf83d35917
@@ -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)