Python FIR Filter API¶
Direct-form FIR filter backed by fir_state_t.
Accepts real (float32) or complex (complex64) taps; input must be complex64.
Source:
src/doppler/filter/__init__.py
Tap types¶
| Tap dtype | C path | Cost/tap/sample | When to use |
|---|---|---|---|
float32 |
real | 1 FMA | scipy.signal.firwin, any symmetric LP/HP/BP |
complex64 |
complex | 2 FMA + permute | Hilbert transformer, frequency-shifted designs |
Examples¶
Low-pass filter (real taps)¶
from doppler.filter import FIR
from scipy.signal import firwin
import numpy as np
taps = firwin(63, cutoff=0.1, window="hamming").astype(np.float32)
filt = FIR(taps)
x = np.random.randn(4096).astype(np.complex64)
y = filt.execute(x) # complex64 out, length 4096
Reusing across blocks (phase-continuous)¶
from doppler.filter import FIR
from scipy.signal import firwin
import numpy as np
taps = firwin(63, cutoff=0.2).astype(np.float32)
filt = FIR(taps)
# a couple of complex64 blocks standing in for a live capture stream
stream = [np.random.randn(256).astype(np.complex64) for _ in range(3)]
for block in stream: # generator of complex64 arrays
out = filt.execute(block) # state preserved across calls
Complex taps — Hilbert transformer¶
from doppler.filter import FIR
import numpy as np
# Simple 4-tap complex example; use scipy for real designs
ctaps = np.array([0+1j, 0+1j, 0+1j, 0+1j], dtype=np.complex64) / 4
filt = FIR(ctaps)
print(filt.is_real) # False
Stream discontinuity¶
FIR
¶
Create a FIR filter from complex CF32 tap coefficients. Implements a direct-form FIR convolution: y[n] = sum_k h[k]*x[n-k]. The tap array is copied at creation; the caller may free it afterward. Use fir_create_real() instead when all imaginary parts are zero — that path costs 1 FMA/tap versus 2 FMA + permute + mul here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
taps
|
NDArray[complex64]
|
Array of num_taps CF32 coefficients (I+jQ each), copied. |
...
|
num_taps
property
¶
Number of tap coefficients supplied at creation. This equals the filter group delay plus one, and determines the minimum input block length for which no latency is observable.
is_real
property
¶
True when the filter was created with real-valued tap coefficients. Real-tap filters (fir_create_real) use a cheaper inner loop: 1 FMA/tap versus the 2 FMA + lane permute required for complex multiplication. Use this flag to confirm which constructor path was used at runtime.
reset
¶
Zero the delay line; preserve taps and scratch capacity. After a reset the filter behaves identically to a freshly constructed instance of the same length, without paying the allocation cost again. Call this between unrelated signal segments to prevent inter-segment leakage through the delay line.
Examples:
>>> import numpy as np
>>> from doppler.filter import FIR
>>> taps = np.array([0.25+0j, 0.5+0j, 0.25+0j], dtype=np.complex64)
>>> fir = FIR(taps)
>>> x = np.array([1+0j, 0+0j, 0+0j], dtype=np.complex64)
>>> _ = fir.execute(x)
>>> fir.reset()
>>> y = fir.execute(x)
>>> [round(float(v.real), 4) for v in y]
[0.25, 0.5, 0.25]
execute
¶
Filter n_in CF32 samples and write the results to out. Each output sample is the inner product of the tap vector with the current delay line. The delay line is updated with each input sample so state carries over across successive calls — process frames of any size without gaps or overlap. The scratch buffer is grown lazily on the first call and reused on subsequent calls of the same size.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Number of output samples written (always == n_in). |
Examples:
>>> import numpy as np
>>> from doppler.filter import FIR
>>> taps = np.array([0.25+0j, 0.5+0j, 0.25+0j], dtype=np.complex64)
>>> fir = FIR(taps)
>>> x = np.array([1+0j, 0+0j, 0+0j], dtype=np.complex64)
>>> y = fir.execute(x)
>>> y.dtype
dtype('complex64')
>>> y.shape
(3,)
>>> [round(float(v.real), 4) for v in y]
[0.25, 0.5, 0.25]
Moving average (boxcar)¶
MovingAverage is a sliding-window boxcar filter over the last len complex
samples — one output per input sample (no rate change). Each step adds the new
sample and subtracts the sample leaving the window, so it is O(1) per sample
regardless of window length (a running window sum, not a re-summed convolution).
The output is the window mean times an optional output gain, folded into a
single cached scale = gain/len so applying the gain is free. The delay ring is
a fixed in-struct array, so the state is pointer-free POD: it embeds by value
into a composing object (a carrier loop's I/Q arm, a smoother ahead of a
detector) and serializes as a whole-struct snapshot.
import numpy as np
from doppler.filter import MovingAverage
ma = MovingAverage(2) # 2-sample window, unit gain
ma.steps(np.ones(3, np.complex64)).real # [0.5, 1.0, 1.0] — ramps in
ma2 = MovingAverage(4, gain=2.0) # gain folded into the mean
y = ma2.step(1.0 + 0.0j) # one sample, returns the gained mean
MovingAverage
¶
MovingAverage component.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
len
|
int
|
len constructor parameter. |
4
|
gain
|
float
|
gain constructor parameter. |
1.0
|
Examples:
Create with defaults:
step
¶
Slide the window by one sample; return the gained moving average.
O(1): add x, drop the sample leaving the window, return acc · scale (=
gain · acc / len) — one multiply.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
complex
|
One input sample. |
required |
Returns:
| Type | Description |
|---|---|
complex
|
The gained window mean after admitting x. |
steps
¶
Filter a block: write the gained moving average of each sample.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Output. |
reset
¶
Clear the window (zero the ring and the running sum); keep the configured length and gain.
Fixed-point halfband decimator (Q15)¶
HBDecimQ15 is a fixed-point halfband 2:1 decimator for interleaved-I/Q
int16 streams — the integer-pipeline counterpart to resample.HalfbandDecimator.
The FIR branch taps are supplied as float and converted internally to Q15 (with
the ×0.5 polyphase rate scaling). The halfband prototype is sparse — every other
tap is zero — so you supply only the non-zero branch taps, not the full
prototype. See the HBDecimQ15 example for the
passband/stopband response.
import numpy as np
from doppler.filter import HBDecimQ15
# non-zero branch taps of a halfband prototype (float; converted to Q15)
taps = np.array([-0.03, 0.28, 0.5, 0.28, -0.03], np.float32)
dec = HBDecimQ15(taps)
x = (np.random.randn(4096) * 8192).astype(np.int16) # interleaved I/Q
y = dec.execute(x) # int16, half the length
HBDecimQ15
¶
Allocate and initialise a fixed-point halfband 2:1 decimator. The FIR branch coefficients are supplied as float and converted internally to Q15 with a x0.5 polyphase rate scaling. The full halfband prototype is sparse (every other tap is zero); supply only the non-zero FIR branch taps, not the full sparse prototype.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
h
|
NDArray[float32]
|
Float FIR branch coefficients of length num_taps. Must be symmetric ( |
...
|
num_taps
property
¶
FIR branch length as supplied to the constructor. This is the count of non-zero symmetric taps in the FIR branch, not the full sparse halfband prototype length. Useful for introspection when chaining multiple stages with programmatically computed filter banks.
rate
property
¶
The sample-rate reduction factor; always 0.5 for 2:1 decimation. Exposed as a read-only property so pipelines can query the rate of each stage programmatically without hard-coding the 2:1 assumption.
execute
¶
Decimate a block of interleaved IQ int16 samples by 2. Input must be interleaved int16_t IQ pairs (I₀ Q₀ I₁ Q₁ …); pass a 1-D array of 2*n_complex elements. Each pair of complex input samples produces one complex output sample, so an array of length 2N yields at most N output pairs (2N int16 output values). If n_in is odd the trailing IQ pair is buffered and consumed on the next call.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[int16]
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[int16]
|
Number of int16_t values written to out. |
Examples:
>>> import numpy as np
>>> from doppler.filter import HBDecimQ15
>>> h = np.array([0.25, 0.5, 0.25], dtype=np.float32)
>>> dec = HBDecimQ15(h)
>>> x = np.array([1000, 0, 1000, 0, 1000, 0, 1000, 0], dtype=np.int16)
>>> y = dec.execute(x)
>>> y.dtype
dtype('int16')
>>> y.shape
(4,)
>>> y.tolist()
[0, 0, 625, 0]
reset
¶
Zero all delay rings and clear the pending-sample flag. After a reset the decimator behaves identically to a freshly constructed instance: the four dual-write delay rings are zeroed and has_pending is cleared, so no partial IQ pair carries over. Call this between unrelated signal segments to prevent inter-segment leakage.
Examples:
>>> import numpy as np
>>> from doppler.filter import HBDecimQ15
>>> h = np.array([0.25, 0.5, 0.25], dtype=np.float32)
>>> dec = HBDecimQ15(h)
>>> x = np.array([1000, 0, 1000, 0, 1000, 0, 1000, 0], dtype=np.int16)
>>> _ = dec.execute(x)
>>> dec.reset()
>>> y = dec.execute(x)
>>> y.tolist()
[0, 0, 625, 0]