Part II: Classical Signal Processing and Feature Engineering
Chapter 6: Filtering and Denoising Sensor Signals

Filter design for real-time, low-latency systems

"The filter had a beautiful frequency response. It also needed the next 200 samples to compute the current one, which is a problem when the current one is what triggers the airbag."

A Suddenly Practical AI Agent

Prerequisites

This section assumes the FIR and IIR structures from Section 6.2, the notion of a filter's frequency response and cutoff from Section 6.3, and the sampling clock and sample period \(T_s = 1/f_s\) from Chapter 3. You do not need the quantitative delay-versus-noise analysis of Section 6.7; here we treat latency as a hard budget the implementation must respect, not a knob to optimize.

Why a filter that works offline can fail online

Every filter you have met so far was, implicitly, a batch operation: you handed it an array and it handed one back. A real sensor gives you one sample at a time, forever, and something downstream is waiting on the output before the next sample arrives. That single change of setting reshapes the whole design problem. A filter for a real-time system must be causal (it may use past and present samples but never future ones), it must finish computing each output inside one sample period, it must survive on bounded memory, and it must remain numerically stable after millions of updates on a device with no float unit. The most elegant frequency response is worthless if it cannot honor those four constraints. This section is about designing filters that meet a deadline, because on a brake controller, a pacemaker, or a drone's attitude loop, a late answer is a wrong answer.

The real-time contract: causal, bounded, on time

What a real-time filter signs up for is a contract with three clauses. It must be causal, so the output at sample \(n\) depends only on inputs up to and including \(n\); a zero-phase filter that runs the data forwards and backwards, or a symmetric FIR that centers its window, both peek into the future and are therefore offline-only. It must be bounded in state, keeping a fixed, small number of past values rather than the whole history. And it must be bounded in time: the work per sample must complete before the next sample lands, which at \(f_s = 1\) kHz means every output is due in one millisecond, no exceptions.

Why the deadline dominates is that sensor loops are usually closed. In a robot's attitude controller (Chapter 24) the filtered gyro drives a motor command that changes the very quantity being measured. A filter that is even one sample late injects extra phase lag into that loop, and phase lag is what turns a stable controller into an oscillating one. How you honor the contract begins with picking a structure whose cost per sample is a small, fixed number of multiply-accumulate operations, and whose latency you can state in samples before you write a line of code.

Latency here has two parts. Algorithmic latency is the group delay the filter imposes by construction: a length-\(M\) linear-phase FIR delays everything by \((M-1)/2\) samples no matter how fast your processor is. Computational latency is the wall-clock time to evaluate the filter each sample. The first is a property of the math and is the subject of Section 6.7; the second is a property of your implementation and hardware, and is what the rest of this section attacks.

Streaming implementations that carry their state

The idea that makes online filtering possible is that both FIR and IIR filters are recursive in their memory: the only thing an output needs from the past is a short vector of previous inputs and outputs, not the samples themselves. A direct-form IIR filter of order \(N\) computes

$$y[n] = \sum_{k=0}^{N} b_k\, x[n-k] \;-\; \sum_{k=1}^{N} a_k\, y[n-k],$$

so its entire dependence on history is \(2N\) stored numbers. Between calls you save that state; on the next sample you use it and update it. This is exactly what lets a batch routine and a sample-by-sample loop produce identical output: you split the stream into blocks and thread the filter's internal state from the end of one block into the start of the next. Skip that threading and you get an audible click or a spurious spike at every block boundary, because the filter silently restarts from zero.

Block size is a latency dial, not a correctness dial

Given carried state, processing one sample at a time and processing a block of 256 give identical outputs; the filter math does not know or care about the boundaries. What the block size changes is latency and efficiency. Big blocks amortize per-call overhead and vectorize well, but you cannot emit the first output of a block until the whole block has arrived, so a 256-sample block at 1 kHz adds up to 256 ms of buffering delay before the filter's own group delay is even counted. Real-time low-latency systems therefore favor small blocks, often a single sample, and pay for it in per-call overhead. The delay you buy with buffering is pure dead time, worse than group delay because it cannot be design-tuned away.

The compute and memory budget

Once the structure is fixed, cost is countable. A direct-form IIR filter of order \(N\) costs about \(2N+1\) multiply-accumulate operations (MACs) per sample; an FIR of length \(M\) costs \(M\). This is the central reason IIR filters dominate low-latency embedded work: an elliptic IIR of order 6 can match the stopband of an FIR of length 100 or more, so it does a twentieth of the arithmetic and stores a handful of state values instead of a hundred. On a microcontroller running your filter at \(f_s\) samples per second, the arithmetic budget is simply \(\text{MACs per sample} \times f_s\), and it competes with everything else the Chapter 61 workload wants to do on the same core.

Two further budgets bite on real hardware. Memory: FIR length \(M\) needs an \(M\)-tap delay line, which for sharp low-frequency filters can dwarf the IIR alternative. Numerics: many embedded targets have no floating-point unit, so filters run in fixed-point integer arithmetic, where a high-order direct-form IIR is dangerously sensitive to coefficient rounding and can drift or even go unstable. The fix is not to abandon IIR but to factor it, which we come to next.

The wrist that noticed a heartbeat in nine milliseconds

A wearable team building a PPG-based heart-rate watch (the sensing of Chapter 30) needed a bandpass filter around 0.5 to 4 Hz to isolate the pulse from motion and ambient-light drift, running on a Cortex-M0 with no FPU at 100 Hz. Their first prototype used a length-201 linear-phase FIR designed in a notebook. It looked perfect offline and was hopeless on device: 201 MACs per sample plus a 201-word delay line blew the RAM budget, and its one-second group delay meant the displayed rate lagged reality by a full beat during exercise. They replaced it with a fourth-order Butterworth IIR realized as two cascaded biquads: 9 MACs per sample, 8 words of state, integer coefficients, and a group delay under 200 ms. The output met the per-sample deadline with the core mostly idle, leaving headroom for the peak detector. Same passband, one twentieth of the cost, and it shipped.

Numerical stability: cascade the filter into biquads

The professional way to implement any IIR filter of order above two is not one big direct-form section but a cascade of second-order sections (SOS), each a biquad handling one pole pair and one zero pair. Mathematically the product of the sections equals the original transfer function; numerically it is a different world. In a single high-order direct form, the poles crowd together and a tiny rounding error in one coefficient can shove a pole outside the unit circle, and the filter blows up. Splitting into biquads keeps each section's coefficients well-conditioned, so fixed-point rounding perturbs the response slightly instead of destabilizing it. Every serious embedded DSP library ships biquad primitives for exactly this reason. The code below shows the streaming pattern that matters: carry the SOS state across blocks so a chunked stream matches a one-shot filtering exactly.

import numpy as np
from scipy.signal import butter, sosfilt, sosfilt_zi

fs = 100.0
# 4th-order Butterworth bandpass, 0.5-4 Hz, as second-order sections:
sos = butter(4, [0.5, 4.0], btype="band", fs=fs, output="sos")

# Initialize the filter's internal state (one state vector per section):
zi = sosfilt_zi(sos)                       # steady-state for a DC input

def stream(chunk, zi):
    """Filter one arriving chunk, returning output and the UPDATED state."""
    y, zi = sosfilt(sos, chunk, zi=zi)     # zi carried across calls
    return y, zi

# Simulate a live stream arriving 10 samples at a time:
signal = np.random.randn(1000)
zi = zi * signal[0]                        # prime state to avoid a startup thump
out = []
for start in range(0, len(signal), 10):    # blocks of 10 -> 100 ms of samples
    y, zi = stream(signal[start:start+10], zi)
    out.append(y)
streamed = np.concatenate(out)

# Identical to filtering the whole array at once (state threading works):
oneshot, _ = sosfilt(sos, signal, zi=sosfilt_zi(sos) * signal[0])
print("max block-vs-oneshot difference:", np.max(np.abs(streamed - oneshot)))
A fourth-order bandpass realized as second-order sections, filtered in 10-sample blocks with the state zi threaded from one call to the next. The printed difference against one-shot filtering is at floating-point noise level, confirming that block boundaries are invisible when state is carried. Drop the zi plumbing and each block would restart from silence, stamping a transient at every boundary.

Notice two production details in that loop. State is threaded through zi, so the blocked result equals the one-shot result to machine precision, which is the invariant every streaming filter must preserve. And the state is primed to the first sample rather than left at zero, which suppresses the startup transient that would otherwise take the filter's settling time to decay, a thump that in a control loop reads as a real event.

Right tool: SOS filtering instead of a hand-rolled biquad cascade

Implementing this by hand means writing the biquad difference equation, chaining sections, and manually saving and restoring each section's two state words across every call: roughly 30 to 40 lines, and the state-threading bugs are exactly the kind that pass an offline test and fail live. SciPy's sosfilt with its zi argument collapses the whole thing to two lines and guarantees the block-invariance shown above.

sos = butter(4, [0.5, 4.0], btype="band", fs=fs, output="sos")
y, zi = sosfilt(sos, chunk, zi=zi)   # design + stable cascade + carried state
Two lines replace a 30-to-40-line biquad cascade with manual per-section state management. The library handles section ordering, scaling, and state layout; you supply the chunk and thread the returned state.

The library handles filter design, decomposition into well-conditioned sections, and the state bookkeeping. For microcontroller deployment the same SOS coefficients feed CMSIS-DSP's arm_biquad_cascade_df1 primitive, so the design you validate in Python is the one that runs on the device.

When you cannot afford any group delay

Sometimes even a few samples of algorithmic delay is too much, and there are two escape routes. The first is a minimum-phase filter: for a given magnitude response, the minimum-phase realization has the smallest possible group delay, at the cost of a nonlinear phase that distorts waveform shape. Trade it when you care about when an event happens (a threshold crossing, an impact) more than about preserving pulse shape. The second route abandons fixed filters for a recursive estimator, the Kalman filter of Chapter 9, which is strictly causal, updates in fixed cost per sample, and produces an optimal current-time estimate with no look-ahead by using a model of how the signal evolves rather than a fixed frequency template. For many low-latency sensing loops the right answer is not a sharper filter but a model-based estimator, which is precisely why the book pivots to state estimation next.

Exercise 6.6

You must filter a 2 kHz accelerometer stream on a microcontroller whose spare budget is 20,000 MACs per second. (a) How many MACs per sample can you afford? (b) A candidate linear-phase FIR needs length 120 to meet the stopband; does it fit, and what is its group delay in milliseconds? (c) An elliptic IIR of order 6 meets the same stopband; give its approximate MACs per sample using the \(2N+1\) estimate and state whether it fits. (d) Your chosen filter runs in blocks of 128 samples for efficiency. Compute the buffering latency this adds and argue whether that is acceptable if the loop deadline is 5 ms.

Self-check

1. Why can a zero-phase (forward-backward) filter never run in a real-time loop, no matter how fast the processor?

2. Two implementations filter the same stream, one sample at a time versus in blocks of 256. Their outputs differ by a spike at every 256th sample. What single thing was forgotten, and why does block size not otherwise affect the result?

3. You port a stable order-8 direct-form IIR to a fixed-point microcontroller and it drifts, then diverges. What restructuring fixes it without changing the intended frequency response?

What's Next

In Section 6.7, we make the tradeoff this section treated as a fixed budget into a design variable, quantifying exactly how much noise reduction each millisecond of delay buys you, and how to choose the operating point deliberately rather than by accident.