Skip to content

Mixed-Precision Gyro Filter

Source: tests/examples/dsp_lowpass.cpp (runs in CI on every build).

Flight controllers low-pass the gyro signal to strip motor and propeller vibration before the rate loop sees it. This example builds that filter and asks a hardware question: which number format should it run in?

A gyro on a multirotor measures two things at once: the body motion the controller cares about, and vibration from the motors and propellers, which it does not. Here the motion is a 2 Hz roll rate (0.5 rad/s) and the vibration a 180 Hz tone (0.3 rad/s), sampled at 1 kHz:

What the gyro measures 2 Hz body motion + 180 Hz motor vibration = the signal the filter receives body motionmotor vibrationgyro input (sum) -0.8-0.40.00.40.8body motion, 2 Hz-0.8-0.40.00.40.8motor vibration, 180 Hz-0.8-0.40.00.40.8gyro input = motion + vibration0.900.951.001.051.10time [s]rad/s
200 ms around t = 1 s. The vibration is nearly as large as the motion and 90 times faster; fed straight into the rate controller, it would make the motors chase noise. Hover (or focus and use the arrow keys) for values.

A 2nd-order Butterworth low-pass with cutoff fc=30f_c = 30 Hz at sample rate fs=1f_s = 1 kHz, designed with the (prewarped) bilinear transform. The terms are explained below:

K=tan⁡πfcfs,H(z)=b0+b1z−1+b2z−21+a1z−1+a2z−2K = \tan\frac{\pi f_c}{f_s}, \qquad H(z) = \frac{b_0 + b_1 z^{-1} + b_2 z^{-2}}{1 + a_1 z^{-1} + a_2 z^{-2}}

realized in transposed direct form II as a 2-state state-space system with MTL5 dense matrices:

xk+1=Axk+Buk,yk=Cxk+Dukx_{k+1} = A x_k + B u_k, \qquad y_k = C x_k + D u_k

In double the design meets its spec: ∣H(2 Hz)∣=0.99999|H(2\,\text{Hz})| = 0.99999, and the 180 Hz vibration is cut by 33.1 dB, to about 2.2% of its amplitude:

The filter recovers the motion vibration cut 33.1 dB; the output follows the motion with a 7.5 ms lag (too small to see here, see the zoom) gyro inputbody motion (truth)filter output -0.8-0.40.00.40.8gyro inputbody motionfilter output0.500.751.001.251.501.752.00time [s]rad/s
The settled run (t = 0.5 to 2 s; the first 0.5 s start-up transient is excluded). The thick gray band is what the gyro reports; the filter output sits on the true motion. At this scale the output's delay is invisible; the zoom below shows it.

A filter cannot remove the vibration without also delaying the motion it passes. Zoomed in on the moment the roll rate crosses zero:

The output lags the motion by 7.5 ms phase -5.4° at 2 Hz; residual 180 Hz ripple is 2.2% of the vibration gyro inputbody motion (truth)filter output -0.5-0.30.00.30.5gyro inputbody motionfilter output7.5 ms delay0102030405060time from 0.97 s [ms]rad/s
60 ms around the rising zero crossing at t = 1 s. The filter output crosses zero 7.5 ms after the true motion. The small wobble on the output is the residual 180 Hz ripple.

From the exact coefficients, evaluating HH on the unit circle:

FrequencyGain ∣H∣\lvert H \rvertPhasePhase delayGroup delay
2 Hz (body motion)0.99999 (−0.0 dB)−5.4°7.49 ms7.51 ms
30 Hz (= fcf_c)0.7071 (−3.0 dB)−90.0°8.33 ms7.55 ms
180 Hz (vibration)0.0222 (−33.1 dB)−167.8°2.59 ms0.24 ms
  • Phase delay is how far a sinusoid at that frequency is shifted in time, −∠H/(2πf)-\angle H / (2\pi f). Group delay is how far the envelope of a narrow band around it is shifted, −d∠H/dω-d\angle H / d\omega. Across the passband the two agree, about 7.5 ms from DC up to fcf_c, so the motion comes out as a near-pure time shift, not a distortion.
  • The test suite measures the lag (by projecting the filtered motion onto sine and cosine over three settled periods) and requires it to match the analytic phase delay to within 50 µs. Today they agree to the microsecond: 7.491 ms.

Why it matters. The rate controller now sees the vehicle’s motion 7.5 ms late. Delay in a feedback loop costs phase margin: the controller corrects for where the vehicle was. Lowering fcf_c removes more vibration but adds more delay. That is the central trade-off in gyro filtering, and the reason flight controllers track the motor frequency with narrow dynamic notch filters instead of reaching for an ever-lower low-pass.

The same filter runs in each format, and its output is compared with the double reference (signal RMS 0.354 rad/s):

FormatBitsError RMS (rad/s)SNR
float325.5e-7116.1 dB
posit<32,2>322.6e-8142.8 dB
IEEE half (cfloat<16,5>)165.0e-337.0 dB
Q3.12 fixed point (fixpnt<16,12>)163.3e-340.6 dB
posit<16,1>161.1e-349.9 dB

Identical under GCC 13 and Clang 18.

Reading it. A gyro signal lives near magnitude 1, where posits put most of their precision (tapered accuracy). At 32 bits that makes posit<32,2> about 27 dB more accurate than float. At 16 bits, the width an embedded DSP path or a narrow accelerator datapath would use, posit<16,1> beats both IEEE half (+13 dB) and the classic Q-format fixed-point choice (+9 dB). Fixed point needs the designer to pick the binary point; the posit needs no such tuning.

fsf_s, the sample rate (1 kHz). How often the gyro is read. A sampled signal can only represent frequencies up to the Nyquist frequency fs/2f_s/2 = 500 Hz; everything in a digital filter’s design lives on the interval 0…fs/20 \ldots f_s/2.

fcf_c, the cutoff frequency (30 Hz). Where the filter’s gain has fallen to 1/21/\sqrt{2}, i.e. −3 dB (half the power). Below fcf_c the motion passes almost unchanged. Above it the gain falls at 40 dB per decade, the slope of a 2nd-order filter: −33 dB by 180 Hz. Butterworth is the choice of filter shape: the flattest possible passband, with no ripple below fcf_c.

H(z)H(z), the transfer function. The filter’s input-to-output relation in the zz-domain. Each z−1z^{-1} means “one sample ago”, so

H(z)=b0+b1z−1+b2z−21+a1z−1+a2z−2H(z) = \frac{b_0 + b_1 z^{-1} + b_2 z^{-2}}{1 + a_1 z^{-1} + a_2 z^{-2}}

is a compact way of writing the difference equation the filter executes every sample:

yk=b0uk+b1uk−1+b2uk−2−a1yk−1−a2yk−2y_k = b_0 u_k + b_1 u_{k-1} + b_2 u_{k-2} - a_1 y_{k-1} - a_2 y_{k-2}

The bb coefficients weight recent inputs; the aa coefficients feed back recent outputs, which is what makes this a recursive (IIR) filter, and what makes it cheap: five multiplies per sample. Here b0=b2=0.00782b_0 = b_2 = 0.00782, b1=0.01564b_1 = 0.01564, a1=−1.7347a_1 = -1.7347, a2=0.7660a_2 = 0.7660. Setting z=ej2πf/fsz = e^{j 2\pi f / f_s}, a point on the unit circle, gives the frequency response: ∣H∣|H| is the gain and ∠H\angle H the phase in the table above.

The example runs the same filter in state-space form. With two internal states holding the “memory” of past samples (transposed direct form II):

A=[−a11−a20],B=[b1−a1b0b2−a2b0],C=[10],D=b0.A = \begin{bmatrix} -a_1 & 1 \\ -a_2 & 0 \end{bmatrix},\quad B = \begin{bmatrix} b_1 - a_1 b_0 \\ b_2 - a_2 b_0 \end{bmatrix},\quad C = \begin{bmatrix} 1 & 0 \end{bmatrix},\quad D = b_0 .

Same input-output behavior, written as matrix operations MTL5 can execute in any number type.

The bilinear transform and KK. Butterworth filters are defined in continuous time (the analog ss-domain). The bilinear transform turns an analog design into a digital one by substituting

s=2fs 1−z−11+z−1.s = 2 f_s \, \frac{1 - z^{-1}}{1 + z^{-1}} .

This mapping is stable and exact in shape, but it squeezes the analog frequency axis, which runs to infinity, into the digital interval 0…fs/20 \ldots f_s/2. Frequencies are warped: analog frequency Ω\Omega and digital frequency ff are related by

Ω=2fstan⁡πffs.\Omega = 2 f_s \tan\frac{\pi f}{f_s} .

Near 0 Hz the warping is negligible (tan⁡x≈x\tan x \approx x). Toward Nyquist it grows without bound. K=tan⁡(πfc/fs)K = \tan(\pi f_c / f_s) (= 0.0945 here) is this relation evaluated at the cutoff: the cutoff as the analog prototype must see it.

Prewarping. Designing the analog prototype at the warped frequency, i.e. using K=tan⁡(πfc/fs)K = \tan(\pi f_c/f_s) instead of the naive πfc/fs\pi f_c / f_s, so that after the bilinear transform the digital filter’s −3 dB point lands exactly on fcf_c. Without prewarping, the cutoff drifts low, and the drift grows as fcf_c approaches Nyquist. At fsf_s = 1 kHz:

Designed fcf_cActual −3 dB, prewarpedActual −3 dB, not prewarped
30 Hz (this example)30.0 Hz29.9 Hz (negligible)
100 Hz100.0 Hz96.9 Hz
250 Hz250.0 Hz211.9 Hz

The same warping explains the stopband number. An analog 2nd-order Butterworth gives about −31 dB at 6× fcf_c. Compressing the frequency axis toward Nyquist adds attenuation up there, hence −33.1 dB at 180 Hz.

The figures are generated from the C++ example, so they show exactly what the filter computes:

Terminal window
cmake --preset gcc-debug && cmake --build --preset gcc-debug
mkdir -p build/gyro-trace
REFLEX_DSP_TRACE_DIR=build/gyro-trace ./build/gcc-debug/tests/dsp_lowpass "*phase delay*"
node docs-site/scripts/gen-gyro-figures.mjs build/gyro-trace

The SVGs under docs-site/src/figures/gyro-lowpass/ are committed; the docs build doesn’t run C++.

The filter’s input enters as a 1-vector (B * u with u a dense_vector) rather than B * scalar. MTL5’s scalar×matrix and scalar×vector operators currently accept only built-in arithmetic types (stillwater-sc/mtl5#538).