From 6a9610efd4cc7c7c8c07c9892dac03edbd7d3698 Mon Sep 17 00:00:00 2001 From: Meisam Bahadori Date: Sun, 2 Aug 2026 18:51:45 +0200 Subject: [PATCH] =?UTF-8?q?*=20E-241:=20fft/spec=20=E2=80=94=20fix=20ampli?= =?UTF-8?q?tude=20normalization=20for=20non-power-of-2=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First numerical-correctness (wrong-number, not crash) find of a correctness-audit campaign that checked ngspice analyses against analytic and numpy oracles. com_fft.c has two paths: FFTW3 (exact length-point transform, scale=length/2, correct) and a Green's radix-2 FFT (no FFTW3) that zero-pads the `length` input samples up to the next power of two N. The non-FFTW path normalized the single-sided amplitude by the PADDED size (scale=N/2) instead of the true sample count (length/2), so every FFT whose sample count is not a power of two reported amplitudes too small by length/N -- up to 2x. A .tran essentially never yields exactly 2^k points, so this bit the common case silently. The DC bin is the clean discriminator (always exactly bin 0, no scalloping): a 2.0 V DC offset read back as 2.0*length/N, e.g. 1.0009 for a 1025-sample record padded to 2048. spec had the same error in its power normalization (intres = N*N instead of length*length; the FFTW path already used length*length). Fix: scale = length/2 and intres = length*length in the non-FFTW path, matching FFTW. The frequency axis was already padding-aware, so only the magnitude scale needed changing; power-of-2 records (length == N) and FFTW-linked builds are unchanged. Validated against numpy: after the fix ngspice's fft matches numpy.fft.rfft of the same zero-padded record (normalized by length) bin-by-bin to ~1e-9. fft/spec amplitudes now change for non-power-of-2 records (they become correct). Regression 199/199. Co-Authored-By: Claude Opus 4.8 --- src/frontend/com_fft.c | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/frontend/com_fft.c b/src/frontend/com_fft.c index 062f40977..13a48354a 100644 --- a/src/frontend/com_fft.c +++ b/src/frontend/com_fft.c @@ -80,7 +80,14 @@ com_fft(wordlist *wl) M++; } fpts = N/2 + 1; - scale = ((double)N)/2; + /* E-241: normalize the amplitude by the ACTUAL sample count `length`, not the + * zero-padded FFT size N. The Green's radix-2 FFT pads `length` samples up to + * the next power of two N; the single-sided amplitude of a bin is 2*|X|/length + * (independent of padding -- padding only interpolates the spectrum), so the + * scale must be length/2 to match the FFTW path above. Using N/2 made every + * FFT whose sample count is not a power of two report amplitudes too small by + * length/N (up to 2x): e.g. a pure DC offset read half its value. */ + scale = ((double)length)/2.0; #endif win = TMALLOC(double, length); @@ -460,7 +467,10 @@ com_psd(wordlist *wl) fftFree(); /* Re(x[0]), Re(x[N/2]), Re(x[1]), Im(x[1]), Re(x[2]), Im(x[2]), ... Re(x[N/2-1]), Im(x[N/2-1]). */ - intres = (double)N * (double)N; + /* E-241: normalize the PSD by length^2 (the actual sample count), not the + * zero-padded FFT size N^2 -- matching the FFTW path above. Using N^2 made + * the PSD of a non-power-of-two-length record too small by (length/N)^2. */ + intres = (double)length * (double)length; fdvec[i][0].cx_real = reald[0]*reald[0]/intres; fdvec[i][0].cx_imag = 0; noipower = fdvec[i][0].cx_real;