From bf83d35917d578dafc123a540852e0c1556259ed Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:26 +0545 Subject: [PATCH] =?UTF-8?q?test(fpga):=20PR-M.4=20=E2=80=94=20redesign=20T?= =?UTF-8?q?-6=20drift=20invariants=20for=20scaled-FFT=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../9_2_FPGA/tb/cosim/compare_independent.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare_independent.py b/9_Firmware/9_2_FPGA/tb/cosim/compare_independent.py index e58f0cc..647d4bc 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/compare_independent.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/compare_independent.py @@ -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)