Skip to content

HBDecimQ15 — Fixed-Point Halfband Decimator

What you're seeing

Three panels share the same frequency axis (0 to Nyquist of the respective sample rate).

Top — frequency response of the FIR branch. The halfband filter's magnitude response (dB) plotted against normalised frequency. The passband (0 to 0.4) is flat within ±0.5 dB; the transition band (0.4–0.6) rolls off sharply; the stopband (0.6 to 0.5 = alias zone) is attenuated by the design target (typically 60 dB). The halfband symmetry means the response is always exactly 0.5 (−6 dBFS) at the midpoint, f = 0.25.

Middle — input IQ spectrum. A broadband noise source plus a passband tone at f = 0.08 and a stopband jammer at f = 0.35. Both land within the ±Nyquist of the input sample rate.

Bottom — output IQ spectrum after 2:1 decimation. The output Nyquist has halved; the passband tone at f = 0.08 survives near full amplitude; the stopband jammer at f = 0.35 has been suppressed by ≥ 60 dB. The broadband noise floor has risen by 3 dB because both the original band and its alias fold together — expected for any decimator without pre-filtering.

The halfband structure

A halfband FIR of length 2N+1 (N even) has the polyphase property: every other coefficient is exactly zero. This decomposes into two branches:

x[n] → [even samples x[0], x[2], x[4], …] → FIR branch  → +→ y[m]
     → [odd  samples x[1], x[3], x[5], …] → pure delay  → +

The FIR branch applies the non-zero coefficients to the even-indexed inputs. The delay branch passes the odd-indexed input at the center lag (x[2m−N]) scaled by 0.5 (the halfband polyphase rate identity).

Only N/2 multiplications are needed per output sample instead of N because the FIR coefficients are symmetric: h[k] = h[N−1−k]. The inner loop folds the delay line so it computes Σ h[k] × (x[k] + x[N−1−k]) with N/4 unique multiplications.

The caller supplies the FIR branch only — not the full sparse prototype. _halfband_bank returns a (2, N) matrix; the row with the smaller peak coefficient is the FIR branch (peak ≈ 0.63); the other row is the delay branch (peak = 1.0).

AVX2 inner loop

The AVX2 implementation avoids computing the explicit fold a[k] + a[N-1-k] as an int16_t because the sum of two near-full-scale samples overflows (e.g., 20000 + 20000 = 40000 > 32767). Instead it performs two separate vpmaddwd passes on the un-folded delay-line slices:

DELAY LINE (I channel, shown for K_pad=16 coefficients):

forward slice:   a[0]  a[1]  a[2]  …  a[15]           (vmovdqu, unaligned)
reverse slice:   a[N-1] a[N-2] … a[N-16]  then rev16   (vmovdqu + rev16_256)
coefficients:    cv[0] cv[1]  …  cv[15]                 (vmovdqa, 32-byte aligned)

Pass 1:  vpmaddwd ymm_acc, ymm_fwd, ymm_cv
         → 8 × int32:  Σ cv[2k]*a[2k] + cv[2k+1]*a[2k+1]

Pass 2:  vpmaddwd ymm_p2,  ymm_rev, ymm_cv
         vpaddd   ymm_acc, ymm_acc, ymm_p2
         → acc += Σ cv[2k]*a[N-1-2k] + cv[2k+1]*a[N-2-2k]

hsum:    8 × int32 → int64  (via two _mm_add_epi32 + _mm_cvtepi32_epi64)

Result:  Σ_{k=0}^{K-1} cv[k] × (a[k] + a[N-1-k])
         without ever computing a[k]+a[N-1-k] as int16.

I and Q channels share the same coefficient vector and run as two independent madd chains — the superscalar core executes them in parallel at no cost.

Why two passes instead of fold+madd

The naive approach is to fold first: fold[k] = a[k] + a[N-1-k] (int16), then madd(fold, coeffs). This fails above −6 dBFS: for a tone at amplitude 20000, two in-phase samples sum to 38042, which exceeds the int16 maximum of 32767. Saturating addition (_mm256_adds_epi16) would clip the result, introducing a non-linear distortion that cannot be corrected downstream.

The two-pass approach keeps every operand in [−32768, 32767]. Each pass multiplies an un-folded delay-line slice (max ≈ 32767) by a coefficient (max ≈ 21299 after ×0.5 polyphase scaling). The vpmaddwd product fits in int32 (max = 2 × 21299 × 32767 ≈ 1.4 × 10⁹ < 2³¹), so no per-lane overflow occurs before the horizontal reduction to int64.

Q15 arithmetic budget

Quantity Range Notes
Input sample [−32768, 32767] int16, full ADC range
FIR coefficient [−21845, 21845] Q15 × 0.5 polyphase scaling
Center-tap multiplier 16384 = 0.5 in Q15 (1 \<< 14)
Per-lane madd product ≤ ±1.43 × 10⁹ fits int32
Accumulator before hsum 8 × int32 lanes no int32 overflow for K ≤ 64
Final accumulator int64 Q30 fixed-point
Output rounding += 1 << 14 round-to-nearest
Output shift >> 15 Q30 → Q15
Output saturation [−32768, 32767] clips only above 0 dBFS

The coefficient quantization to Q15 limits effective stopband attenuation to roughly 60–70 dB regardless of the design target; a longer filter does not push the Q15 floor lower.

Coefficient format

HBDecimQ15 takes the FIR polyphase branch, not the full sparse prototype.

_halfband_bank(atten, pb, sb) returns a (2, N) float32 array. One row is the FIR branch (N non-trivial coefficients, symmetric, peak ≈ 0.63 at the center); the other row is the delay branch (a single 1.0 flanked by numerical noise). Select the FIR row by minimum peak magnitude:

from doppler.resample import _halfband_bank
import numpy as np

bank = _halfband_bank(60.0, 0.4, 0.6)
fir_row = int(np.argmin([np.max(np.abs(bank[r])) for r in range(2)]))
h = bank[fir_row].astype(np.float32)

The sum sum(h[:K]) where K = len(h) // 2 is approximately 0.5 — not 1.0. The coefficients already encode the ×0.5 polyphase identity; hbdecim_q15_create applies a second ×0.5 when converting to Q15 (scaling by 0.5 × 32768). Together they give unity DC gain through the combined FIR + delay branches.

Parameters

Parameter Type Default Description
h np.ndarray (float32) FIR branch coefficients, length N. Must be symmetric.
num_taps int (read-only) len(h) FIR branch length as supplied.
rate float (read-only) 0.5 Decimation factor — always 0.5 (2:1).

Usage example

import numpy as np
from doppler.filter import HBDecimQ15
from doppler.resample import _halfband_bank

# ── design ────────────────────────────────────────────────────────────────
bank = _halfband_bank(atten=60.0, pb=0.4, sb=0.6)
fir_row = int(np.argmin([np.max(np.abs(bank[r])) for r in range(2)]))
h = bank[fir_row].astype(np.float32)

# ── create ────────────────────────────────────────────────────────────────
dec = HBDecimQ15(h)
print(f"num_taps={dec.num_taps}, rate={dec.rate}")  # 19, 0.5

# ── generate IQ signal: two tones, interleaved int16 ──────────────────────
fs_in   = 1.0          # normalised; 1 sample/cycle
N       = 4096
t       = np.arange(N)
amp     = 20000        # -4 dBFS (int16 full scale = 32767)
f_pass  = 0.05         # in passband (0 → 0.4 × fs_in/2)
f_stop  = 0.35         # in stopband (0.6 × fs_in/2 → fs_in/2)

x_c = (amp * np.exp(2j * np.pi * f_pass * t) +
       amp * 0.1 * np.exp(2j * np.pi * f_stop * t))
x_iq = np.empty(2 * N, dtype=np.int16)
x_iq[0::2] = x_c.real.astype(np.int16)
x_iq[1::2] = x_c.imag.astype(np.int16)

# ── execute ───────────────────────────────────────────────────────────────
# execute() returns a zero-copy view; copy before the next call overwrites it.
y_iq = dec.execute(x_iq).copy()
print(f"output length: {len(y_iq) // 2} complex samples")  # 2048

# ── decode ────────────────────────────────────────────────────────────────
settle = dec.num_taps
y_c = (y_iq[0::2].astype(np.float64) +
       1j * y_iq[1::2].astype(np.float64))

# ── measure passband amplitude after filter settles ───────────────────────
w  = np.hanning(len(y_c) - settle)
S  = np.abs(np.fft.fft(y_c[settle:] * w))
pb_amp = np.max(S) / ((len(y_c) - settle) * w.mean())
print(f"passband amplitude: {pb_amp / amp:.4f}  (expect ≈ 1.0)")

# ── streaming: feed in blocks, copy each result ───────────────────────────
dec.reset()
chunk = 128   # 64 IQ pairs per call
results = []
for i in range(0, len(x_iq), chunk):
    results.append(dec.execute(x_iq[i:i + chunk]).copy())
y_stream = np.concatenate(results)
print(f"streaming match: {np.array_equal(y_iq, y_stream)}")  # True

# ── context manager ───────────────────────────────────────────────────────
with HBDecimQ15(h) as d:
    y2 = d.execute(x_iq).copy()

# ── explicit destroy ──────────────────────────────────────────────────────
dec.destroy()

SNR vs bit depth

HBDecimQ15 targets ADC-grade input at 12–16 bits. The Q15 arithmetic budget limits the in-band SNR ceiling independently of signal level:

Metric Value Notes
Q15 dynamic range ≈ 90 dB 15 bits × 6.02 dB + 1.76 dB
Effective SNR (60 dB design) 55–65 dB measured at −4 dBFS, 60 dB filter
Coefficient quantization floor ≈ −65 dBFS limits stopband regardless of filter length
SNR vs float reference > 30 dB measured difference between Q15 and float64 output
Saturation threshold 0 dBFS output clips at int16 range; input should stay ≤ −1 dBFS

For signals below −6 dBFS the folded sums in the scalar path stay within int16 range. The two-pass AVX2 path is safe at any level up to 0 dBFS because it never forms the 16-bit fold.