Skip to main content

Command Palette

Search for a command to run...

Moving Average Filter FIR: The Simplest ADC Post-Processor That Actually Works

Updated
6 min read

The moving average is the most-deployed FIR filter in embedded control systems — one accumulator, one subtraction, zero multiply overhead on a power-of-2 window. But "simplest" does not mean "unintelligent." The box-car spectrum punches sharp nulls at exact multiples of fs/N, its group delay is fixed at (N-1)/2 samples regardless of frequency, and the -3 dB point scales predictably with window length. This post quantifies all of that for N=16 taps at fs = 44 100 Hz.

https://youtu.be/A7ikr0YXQ1g


Transfer Function

An N-tap causal moving average computes:

y[n] = (1/N) · (x[n] + x[n-1] + ... + x[n-(N-1)])

Taking the z-transform:

H(z) = (1/N) · (1 + z⁻¹ + z⁻² + ... + z⁻⁽ᴺ⁻¹⁾)

For N = 16:

H(z) = (1/16) · Σ_{k=0}^{15} z⁻ᵏ

Coefficients — all taps equal 1/N:

Tap index k b_k
0 – 15 0.0625

All poles sit at z = 0 (origin); all 15 zeros lie uniformly on the unit circle at z = e^{j·2πk/N} for k = 1, 2, ..., N-1.

Constant group delay (linear phase property of symmetric FIR):

τ_g = (N - 1) / 2 = 7.5 samples  →  170 µs at fs = 44 100 Hz

This is the single most important property for control loops: every frequency component is delayed by exactly the same amount, so the transient waveform shape is preserved perfectly.


Frequency Response

Bode Plot — 16-Tap Moving Average FIR

Dark-theme Bode plot: magnitude (top) and phase (bottom) for 16-tap moving average at fs = 44 100 Hz.

Key observations for N = 16, fs = 44 100 Hz:

Metric Value
-3 dB cutoff 1 222 Hz
First null (k = 1) 2 756 Hz
Rolloff to first null −∞ dB (hard zero)
Attenuation at 5 kHz −20.1 dB
Group delay 7.5 samples = 170 µs
Stopband ripple −13 dB (sinc sidelobes)

The first null frequency scales as f_null = fs / N — doubling the window length halves both the -3 dB point and the null spacing. The stopband sidelobes do not diminish with N, which is the primary reason the moving average is unsuitable as a sharp anti-aliasing filter.


Python Implementation

import numpy as np
from scipy.signal import lfilter

N  = 16
FS = 44100.0
b  = np.ones(N) / N    # all taps = 1/N
a  = np.array([1.0])   # FIR — no feedback

def filter_signal(x: np.ndarray, fs: float = FS) -> np.ndarray:
    """Apply N-tap moving-average FIR."""
    return lfilter(b, a, x)

scipy.signal.lfilter handles the circular-buffer logic internally. For real-time streaming, use lfilter_zi to preserve state across buffer boundaries.


C Implementation

Direct Form with a circular buffer delivers O(1) per sample — one add, one subtract, one divide (or right-shift for power-of-2 N):

#define MA_N  16

typedef struct {
    float buf[MA_N];  /* circular input buffer */
    int   idx;        /* write pointer          */
    float sum;        /* running accumulator    */
} MAState;

float ma_process(MAState *s, float x)
{
    s->sum -= s->buf[s->idx];   /* remove oldest sample */
    s->buf[s->idx] = x;
    s->sum += x;                /* add newest sample    */
    s->idx = (s->idx + 1) % MA_N;
    return s->sum / (float)MA_N;
}

Fixed-point note: For N = 2^k, replace /MA_N with a right arithmetic shift by k bits: return s->sum >> 4; (N=16). This eliminates the division entirely and keeps the loop at a single clock cycle on most MCUs.


MATLAB Implementation

N  = 16;
fs = 44100;
b  = ones(1, N) / N;
a  = 1;

% Frequency response
[H, f] = freqz(b, a, 2048, fs);
plot(f, 20*log10(abs(H) + 1e-12));
xlabel('Frequency (Hz)'); ylabel('Magnitude (dB)');
title(sprintf('%d-Tap Moving Average', N));
yline(-3, 'r--', '-3 dB'); grid on;

% Pole-zero (all zeros on unit circle, all poles at origin)
zplane(b, a);

% Time-domain filtering
y = filter(b, a, noisy_signal);

MATLAB's zplane confirms 15 zeros equally spaced on the unit circle — the most even distribution possible for a linear-phase FIR.


Design Trade-offs

Rolloff vs. sidelobe level. The -13 dB maximum stopband attenuation is determined by the rectangular window, not N. A Hann-windowed FIR of the same length achieves -44 dB at the cost of a wider transition band. Use the moving average only where stopband sidelobe level is not a concern (e.g., ADC noise smoothing with a large noise floor).

Latency vs. cutoff. Since fc ≈ 0.443·fs/N, reducing the cutoff by 2× requires doubling N — which also doubles the group delay. There is no free parameter to decouple them. A single-pole IIR lowpass (EMA) achieves lower cutoff with only 2 coefficients and 0.5-sample equivalent delay, at the cost of non-linear phase.

Cascading. Two cascaded 8-tap MAs produce a Bartlett (triangular) window. The cascade reduces sidelobe level to about -26 dB but maintains the same -3 dB point and adds group delay.

Coefficient sensitivity. All coefficients are exactly equal, so there is no quantization sensitivity concern — even a 1-bit shift does not change the symmetry.


Key Takeaways

  • Group delay is constant: (N-1)/2 samples for all frequencies — the defining advantage over IIR for waveform-shape-critical control signals.

  • First null is deterministic: f_null = fs/N — choose N to place this null at a known interference frequency (e.g., N = fs/100 = 441 to null 100 Hz hum).

  • O(1) update with circular buffer: the running-sum trick makes the per-sample cost independent of window length.

  • Power-of-2 N → division-free: replace /N with arithmetic right-shift in firmware for zero-overhead smoothing.

  • Not a sharp lowpass: -13 dB stopband ripple makes the moving average unsuitable for anti-aliasing; use a windowed-sinc or Butterworth for that role.



Engineering question: If you need to simultaneously reject 100 Hz mains hum and keep group delay linear below 500 Hz, would you choose a notch followed by a moving average, or a single windowed-sinc FIR — and what window function would you pick?

More from this blog

S

SW related to Power electronics

18 posts

SW related to Power electronics