Sensor Fusion: Classical to Modern · Lesson 6 of 22

Complementary Filters & AHRSThe Cheapest Sensor Fusion

A drone or a phone must always know which way is up. The gyroscope answers instantly but slowly lies; the accelerometer is honest but jittery. Trust the gyro at high frequency and the accelerometer at low frequency, weight them so the two halves sum to one, and you get an orientation estimate that is both smooth and stable — with about ten lines of arithmetic.

Prerequisites: basic arithmetic + the idea of an angle. That's it.
11
Chapters
7
Simulations
0
Assumed Knowledge

Chapter 0: A Machine Must Always Know Which Way Is Up

A quadcopter is hovering in your living room. To stay there it does one thing, hundreds of times a second: it asks "which way is up?", measures how far it has tilted away from level, and nudges its motors to push back. Get that answer wrong by even five degrees and the drone leans, slides, and within a second is on the floor. The single most important number a flying machine computes is not its position or its speed — it is its orientation, often called its attitude: how it is tilted and rotated in space.

The same is true of the phone in your pocket. Rotate it from portrait to landscape and the screen flips. Tilt it in a racing game and the car steers. Raise it to your face and the screen wakes. Every one of those tricks needs the phone to know its orientation, and to know it smoothly — if the estimate jittered, the screen would flicker between portrait and landscape and the game car would shake in your hands.

Both machines carry the same tiny chip to answer the question: an inertial measurement unit (IMU), a sliver of silicon with two kinds of motion sensor packed inside. The first is a gyroscope, which measures how fast the device is rotating right now — turn rate, in degrees per second. The second is an accelerometer, which measures the forces pushing on the device, including the steady downward pull of gravity. Each sensor alone can almost tell you the orientation. Neither can do it reliably alone. That gap is the whole lesson.

Common misconception. "The gyroscope tells you your orientation." It does not. A gyroscope tells you your turn rate — how fast you are spinning, not how far you have turned. To get orientation you must add up (integrate) the turn rate over time, and that addition quietly accumulates a tiny error every single tick. Within seconds to minutes the orientation you computed has drifted away from the truth, even though the gyroscope itself never broke. Orientation from a gyro is like distance from a step-counter: each step is fine, but the running total slowly walks away from reality.

Here is the trap stated plainly. The gyroscope is fast — it reacts instantly to the smallest twitch — but it has no idea where "up" actually is, so its integrated orientation slowly drifts. The accelerometer is the reverse: it can feel gravity, which always points down, so it has an honest, drift-free reference for "up" — but the moment the device accelerates (the drone darts sideways, your hand jerks the phone), gravity gets buried under that motion and the reading turns to jitter. One sensor is fast but drifts; the other is stable but jittery. If that pairing sounds familiar, it should: it is exactly the complementary relationship from Lesson 1, and the cure is the same — fuse them.

Let's watch the failure, and the fix, before we name anything. Below, a board tilts back and forth (the dashed line is the true tilt angle). Watch three estimates of that angle: the gyro-only estimate, which starts perfect and slowly slides away; the accel-only estimate, which never drifts but trembles; and the fused estimate, which is somehow both smooth and stable.

Three ways to estimate tilt — one drifts, one jitters, one is just right

The dashed line is the board's true tilt angle as it sways. Press Run and watch the three estimates. Gyro-only integrates turn rate — perfect at first, then it peels away from the truth. Accel-only reads "up" from gravity — never drifts, but noisy. Fused trusts the gyro short-term and the accel long-term, getting the best of both.

ready

The gyro line is gorgeous for the first second — it tracks every wiggle — then it begins to climb away from the truth and never comes back, because nothing ever corrects its drift. The accel line stays centered on the truth (it can always see gravity) but it shivers, because every small motion of the board corrupts the gravity reading. The fused line does what neither can alone: it follows the fast wiggles like the gyro and stays anchored to the truth like the accel. That is a complementary filter, and building it from scratch is the heart of this lesson.

Why the gyro drift is unavoidable — one number on the back of an envelope

It is worth feeling how fast a gyro's orientation drifts, because the number explains the entire urgency of fusion. A gyroscope reports turn rate, and to get an angle you integrate: angle = (turn rate) × (time). Suppose the gyro has a tiny constant bias — it consistently reads 0.5 degrees per second too high even when perfectly still (a genuinely typical value for a cheap MEMS gyro before calibration). That bias looks like nothing per reading. But integrate it, and the angle error grows linearly with time:

angle error(t) = (bias) × t = 0.5 °/s × t

Plug in time and watch it walk away:

Time running gyro-onlyAngle error = 0.5°/s × t
1 second0.5° — negligible, beautiful
10 seconds5° — a drone is already leaning
60 seconds30° — the horizon is badly wrong
5 minutes150° — the device thinks "up" is nearly sideways

That steady linear march is the killer. For a fraction of a second the gyro is flawless. By a minute it is off by 30 degrees, and it never self-corrects — the bias is constant, so the error just keeps accumulating in the same direction. (Random gyro noise also adds a slower wandering drift on top, but the constant bias is the dominant, most teachable culprit.) The accelerometer is the reverse: its reading of "down" is noisy moment to moment, but its average never drifts, because gravity is a permanent, unchanging reference. Their error behaviors are mirror images — the gyro is wonderful short-term and disastrous long-term; the accel is unreliable short-term and rock-solid long-term. Fusion lives exactly at that crossover: trust the gyro on the fast time-scale, trust the accel to reset the slow drift.

The one-sentence thesis of this lesson. The gyroscope gives you a fast but drifting orientation; the accelerometer gives you a slow but stable reference for "up." A complementary filter blends them by frequency: high-frequency information from the gyro, low-frequency information from the accel, with weights that sum to exactly one. The result is an orientation that is fast, smooth, and never drifts — the cheapest, most intuitive sensor fusion there is.

A 100-million-year-old idea

Like all good fusion, this is not an engineering invention; it is one of evolution's oldest tricks. You run a complementary filter every waking second. Your inner ear holds a fluid-filled vestibular system — a biological gyroscope and accelerometer in one — that senses head rotation and the pull of gravity. Spin in an office chair and stop suddenly: the room keeps spinning for a moment. That is your inner-ear "gyro" having drifted, and your eyes (the slow, absolute reference, like the accelerometer) haven't reconciled it yet. Close your eyes and tilt your head slowly and you still know which way is up — gravity, through your inner ear, is your accelerometer. Your brain has been fusing these exactly the way we are about to, since long before anyone wrote down the word "filter."

The engineering versions have names we'll meet by the end. The basic linear complementary filter is folklore from the early days of aerospace. The two modern nonlinear versions that run in nearly every drone and headset today are Mahony's filter (Robert Mahony, Tarek Hamel, and Jean-Michel Pflimlin, published in IEEE Transactions on Automatic Control in 2008) and Madgwick's filter (Sebastian Madgwick, presented at the IEEE International Conference on Rehabilitation Robotics, ICORR, in 2011). When a system fuses gyro, accelerometer, and magnetometer into a full orientation, the assembly is called an AHRS — an Attitude and Heading Reference System. By the end of this lesson you will be able to build one.

Why can't a drone just use its gyroscope alone to know its orientation?

Chapter 1: The Gyroscope — Fast, Responsive, and Quietly Drifting

To fuse two sensors well you must know each one's personality intimately — what it is brilliant at and exactly how it betrays you. We spend a chapter on each before combining them. Start with the gyroscope, the fast half of the pair.

A gyroscope measures angular velocity — the rate at which the device is rotating, in degrees per second (or radians per second). It does not measure orientation. If the device sits perfectly still, an ideal gyro reads zero. If you rotate it at a steady 90 degrees per second, the gyro reads 90 the whole time you are turning, and drops back to zero the instant you stop. A modern phone or drone gyro is a tiny MEMS (micro-electro-mechanical system) device — a vibrating silicon structure whose vibration shifts when the chip rotates — and it reports its three axes hundreds to thousands of times a second.

Its great virtue is speed and responsiveness. Because it directly senses rotation, it reacts instantly to motion, with almost no lag and very little high-frequency noise. If you want to know "am I rotating right now, and how fast?", the gyro answers immediately and cleanly. This is why the gyro carries the fast part of the fused estimate: it captures every quick twitch the slower accelerometer would smear out.

From turn rate to angle: integration, and the worm in the apple

We don't want turn rate; we want orientation — an angle. To turn a rate into an angle you integrate: at each tick, multiply the current turn rate by the small time step and add it to a running total. In words: "the new angle equals the old angle plus how much I turned during this tick." That is the gyro's contribution to orientation, and it is just repeated addition:

θnew = θold + ω · Δt

where θ (theta) is the angle, ω (omega) is the gyro's measured turn rate, and Δt (delta-t) is the time between readings (for a 100 Hz gyro, Δt = 0.01 seconds). Run this every tick and you have an orientation that follows the device's motion in real time.

The worm in the apple is the word "add." Every gyro reading carries a sliver of error — partly random noise, but mostly a small constant bias, a fixed offset where the gyro reads, say, 0.5°/s even while motionless. Integration doesn't average that bias away; it piles it up. Each tick adds the true rotation plus a little bias, and the bias contributions stack into an ever-growing error. This is exactly the drift we computed on the envelope in Chapter 0.

Watching drift accumulate, by hand

Let's make the drift concrete with real numbers. Suppose the device is perfectly still — the true angle never changes, it stays at 0° forever. But our gyro has a bias of 0.5°/s and we sample at 100 Hz, so Δt = 0.01 s. Each tick we add (0.5°/s)(0.01 s) = 0.005° to our estimate, even though nothing moved. Watch the running total:

TickElapsed timeTrue angleGyro-integrated angle
00.00 s0.000°
10.01 s0.005°
1001.00 s0.500°
100010.0 s5.000°
600060.0 s30.00°

The device never moved, yet after one minute the gyro insists it has rotated 30 degrees. Notice the error grows in a perfectly straight line: 0.5° per second, forever, in the same direction. That is the signature of a constant bias under integration — a linear ramp. It is also why, if you could measure the bias and subtract it, the drift would nearly vanish; remembering that fact is the seed of the bias estimation trick we'll meet in Chapter 5.

The deep reason gyro drift is unfixable alone. The gyro only ever measures change in orientation, never the orientation itself. It has no anchor to the outside world — no sense of where "up" or "north" actually is. So any error in its readings has nothing to correct it; the error simply accumulates in the running total. A more expensive gyro drifts slower (a navigation-grade ring-laser gyro drifts a fraction of a degree per hour, not per second), but it still drifts. The only true cure is a second sensor that does have an external anchor — which is precisely the accelerometer's job, coming next chapter.

A second worked example — the drift hides inside real motion

The still-device example is the cleanest way to see the bias, but it can mislead you into thinking "I'll just notice the drift and ignore it." You won't, because in real use the drift hides inside genuine motion. Suppose the device actually rotates at a true 10°/s for 3 seconds, then stops — a true final angle of 30°. Our gyro, with its 0.5°/s bias, reads 10.5°/s during the turn and 0.5°/s after. Integrate:

PhaseTrue rateGyro readsTrue angle gainedGyro angle gained
Turn (0–3 s)10°/s10.5°/s10 × 3 = 30°10.5 × 3 = 31.5°
Hold (3–60 s)0°/s0.5°/s0.5 × 57 = 28.5°
Total at 60 s30°60°

The device ended exactly where it started turning — 30° — yet the gyro insists it is at 60°, double the truth, after just a minute. And crucially, during the turn the gyro looked almost perfect (31.5° vs 30°): the error was a tiny 1.5° while motion was happening. The drift accumulated silently in the still phase afterward, where you would least expect to be wrong. This is why drift is so insidious — it builds up most where nothing seems to be happening, hidden inside an otherwise-believable estimate.

The three kinds of gyro error — and which one matters

Real gyros carry more than just a constant bias, and it helps to name the three error types so you know which one the filter must beat:

For this lesson the constant bias is the one to keep in mind: it is the largest error, it produces the clean linear ramp you can see in every sim, and defeating it is the whole reason we bolt an absolute reference onto the gyro. If you can kill the constant bias, the remaining noise and slow wander are small enough for the complementary filter to mop up.

Where the gyro reading physically comes from

It is worth a paragraph on why a MEMS gyro is biased, because it demystifies the whole problem. Inside the chip, a tiny silicon mass is kept constantly vibrating. When the chip rotates, the Coriolis effect — the same sideways push you feel walking across a spinning merry-go-round — nudges the vibrating mass perpendicular to its motion, and the size of that nudge is proportional to the rotation rate. The chip measures the nudge as a tiny capacitance change and reports it as ω. The bias creeps in because the manufacturing isn't perfect: the vibrating structure isn't perfectly symmetric, the electronics have small offsets, and temperature changes the silicon's stiffness. None of these break the gyro — they just add that fixed (and slowly wandering) offset to every reading, which integration then amplifies into drift. The sensor is doing its job honestly; integration is simply unforgiving of even a microscopic error.

Play with the drift

Below, set the gyro's bias and noise and watch its integrated angle peel away from the (flat, zero) truth. Crank the bias up and the drift steepens into a faster ramp; add noise and the line gets a jagged wander on top. Notice the drift never recovers — nothing pulls it back. That helplessness is the entire motivation for fusion.

Gyro integration drift — tune bias and noise

The true angle is flat at 0° (dashed). The gyro-integrated angle should also stay at 0° — but a constant bias makes it ramp away linearly, and noise adds a jagged wander. Press Run, then drag the sliders to see how quickly drift builds.

Gyro bias (°/s) 0.50 Gyro noise (°/s) 0.10
press Run

The gyro in code

Integrating a gyro into an angle is a one-line loop. The drift is not a bug in this code — it is the honest consequence of integrating a slightly-wrong rate:

python
def gyro_angle(gyro_readings, dt, bias=0.0):
    # gyro_readings: list of measured turn rates (deg/s), one per tick
    theta = 0.0                          # running orientation estimate
    history = []
    for w in gyro_readings:
        theta += (w - bias) * dt          # integrate: angle += rate * timestep
        history.append(theta)
    return history

# device is perfectly still, but the gyro has a 0.5 deg/s bias:
still = [0.5] * 6000                  # 60 s at 100 Hz, reads 0.5 instead of 0
gyro_angle(still, dt=0.01)[-1]        # -> 30.0 deg of drift, from a still device

# if we KNEW the bias and subtracted it, drift vanishes:
gyro_angle(still, dt=0.01, bias=0.5)[-1]   # -> 0.0 deg  (perfect, but we rarely know the bias)

The last two lines are the whole story of the gyro: it is exact if you know the bias, and it drifts the moment you don't. Real systems almost never know the bias precisely (it even changes with temperature), so the integrated angle always drifts. Holding onto that "if we knew the bias..." thought, because the Mahony filter in Chapter 5 will actually estimate the bias on the fly and subtract it — turning that wish into running code.

The gyroscope, in one line. Fast and responsive, almost no lag, captures every quick motion — but it measures only change in orientation, has no external anchor, and so its integrated angle drifts without bound (linearly with bias, ever-growing). It is the perfect short-term orientation sensor and a disastrous long-term one. To stop the drift we need a sensor that can see the outside world. That is the accelerometer.
A device sits perfectly still, but its gyroscope has a constant bias of 0.5°/s. After 60 seconds of integrating, what does the gyro-only estimate say, and why?

Chapter 2: The Accelerometer — A Stable Tilt Reference the Gyro Lacks

The gyro's fatal flaw was having no anchor — nothing outside itself to say where "up" really is. The accelerometer fixes exactly that, because it can feel the one direction that never moves: down.

An accelerometer measures the forces pushing on the device, reported as acceleration. Here is the key, slightly counterintuitive fact: even a device sitting perfectly still reads a force. A motionless phone on a table reads an upward acceleration of about 9.8 meters per second squared — one g, the strength of gravity. Why? Because to hold the phone still against gravity, the table must push up on it, and the accelerometer feels that supporting push. The reading is the force needed to resist falling, and at rest that force points exactly opposite to gravity — straight up.

This gives us a free, permanent reference. When the device is level, the gravity vector lands entirely on one axis. When you tilt the device, gravity's pull spreads across the axes in a way that depends only on the tilt angle — and that spread is the tilt. By looking at how the constant downward gravity vector lands on the device's own axes, the accelerometer can compute the tilt with no integration and no drift: gravity is a fixed star, and the accel reads the device's angle against it directly.

The mental model: a marble in a bowl. Imagine a marble resting at the bottom of a shallow bowl glued to your device. Tilt the device and the marble rolls to the low side — it always finds "down." The accelerometer is that marble: it always knows which way gravity points, so it always knows your tilt, no matter how long ago you last measured. It has the external anchor the gyro completely lacks. The catch — coming in a moment — is that if you shake the bowl, the marble bounces around and stops pointing reliably down.

Tilt from gravity, by hand

Let's compute a tilt angle from a real accelerometer reading. A 2-axis accelerometer (call the axes x and z) reports how much of gravity it feels on each axis. When the device is level, all of gravity is on z and none on x. When it tilts by an angle θ, gravity splits according to the geometry of the tilt:

ax = g · sin(θ),    az = g · cos(θ)

Here g is gravity's strength (about 9.8 m/s²), ax and az are the readings on the two axes, and θ is the tilt angle we want. Notice we can recover the angle without knowing g by taking the ratio — g cancels:

tan(θ) = ax / az  ⇒   θ = arctan( ax / az )

Plug in real numbers. Say the accelerometer reads ax = 4.9 m/s² and az = 8.49 m/s². The ratio is 4.9 / 8.49 = 0.577, and arctan(0.577) = 30°. The device is tilted 30 degrees, computed directly from one instantaneous reading — no integration, no running total, no drift. Tilt it back to level (ax = 0, az = 9.8) and arctan(0 / 9.8) = 0°. The accelerometer reads tilt the way a carpenter's level reads it: absolutely, in the moment, with gravity as the fixed reference.

Let's verify the geometry actually holds by checking the magnitude. At 30°, ax = g·sin(30°) = 9.8 × 0.5 = 4.9, and az = g·cos(30°) = 9.8 × 0.866 = 8.49 — exactly the numbers above. And the total length of the vector is √(4.9² + 8.49²) = √(24.0 + 72.1) = √96.1 = 9.8 = g. That magnitude check is gold: when the device feels only gravity, the accelerometer vector's length is always exactly 1 g, no matter how it's tilted, because tilting just rotates a fixed-length gravity vector among the axes. Hold onto this — it is the exact test we'll use in Chapter 8 to detect when the accelerometer is lying.

The accelerometer's superpower. It measures tilt absolutely — directly from gravity, with no integration — so it has no drift. Where the gyro's error grew forever, the accel's tilt estimate is just as good after an hour as after a second. It is the long-term anchor the gyro needs. But — and this is the whole reason we can't just use it alone — that gravity reference only holds when the device isn't accelerating.

A small table of tilts — reading gravity off the axes

To build intuition for how gravity spreads across the axes as you tilt, here is a little table you can sanity-check by hand. Each row is a tilt angle; the columns are what each axis reads (in g, so just sin and cos), and the recovered angle via arctan:

True tiltax = sin(θ)az = cos(θ)arctan(ax/az)vector length
0° (level)0.0001.0001.000 g
30°0.5000.86630°1.000 g
45°0.7070.70745°1.000 g
60°0.8660.50060°1.000 g
90° (on its side)1.0000.00090°1.000 g

Read the last column top to bottom: it is always 1.000 g. The gravity vector never gets longer or shorter as you tilt — it just pours from one axis into another. At 45° it splits evenly (0.707 each, and 0.707² + 0.707² = 1). At 90° it has fully migrated from z to x. The accelerometer recovers the angle perfectly every time, because the geometry is exact and there is no integration to corrupt. This is the clean, drift-free reference the gyro can only dream of — as long as gravity is the only force in the reading.

The fatal flaw: linear acceleration looks exactly like tilt

The accelerometer cannot tell the difference between gravity and any other acceleration. It measures the total force, and when the device is being pushed — the drone darts sideways, a car brakes, your hand jerks the phone — that pushing force adds to gravity in the reading. The accelerometer then reports a "down" direction that is the sum of true gravity plus the motion, which points the wrong way. To the accel, "I am tilted" and "I am accelerating" produce the same signature. This is its blind spot.

Make it concrete. A phone lies perfectly flat (true tilt = 0°). It should read ax = 0, az = 9.8, giving arctan(0/9.8) = 0°. Now someone jerks it sideways with a horizontal acceleration of 4 m/s². The accelerometer now reads ax = 4 (the sideways jerk) and az = 9.8 (gravity, unchanged). It computes:

θaccel = arctan( 4 / 9.8 ) = arctan(0.408) ≈ 22°

The phone is dead flat, but the accelerometer insists it is tilted 22 degrees — because it mistook the sideways jerk for a leaning of the gravity vector. The moment the jerk stops, the reading snaps back to 0°. This is why the accel-only line in the Chapter 0 sim jittered: every little motion of the board threw a spike of fake tilt into its estimate. The accelerometer is honest about its average ("down" is correct over time) but unreliable in any single jerky instant.

Now watch the magnitude give the lie away. With the 4 m/s² shove, the vector length is √(4² + 9.8²) = √(16 + 96.04) = √112.04 = 10.58 m/s² — that is 1.08 g, not 1.000 g. The accelerometer is feeling more than just gravity, and the excess length is the fingerprint of motion. The clean 1-g rows from the tilt table are gone the instant the device accelerates. This is the single most useful diagnostic in attitude estimation: if the accelerometer's magnitude is not 1 g, do not trust its tilt reading. We will turn exactly this observation into the adaptive-gain trick in Chapter 8.

One more worked case, to feel how badly braking corrupts a tilt estimate. A phone is mounted dead-level on a car dashboard (true tilt 0°). The driver brakes hard at 7 m/s² (about 0.7 g, an emergency stop). The accelerometer now reads ax = 7 (the deceleration) and az = 9.8 (gravity). It computes arctan(7/9.8) = arctan(0.714) ≈ 35.5° of phantom tilt — the phone "thinks" the car has pitched nose-down 35 degrees, when really it is dead level and merely decelerating. And the magnitude is √(49 + 96.04) = √145 = 12.0 m/s² = 1.23 g, loudly announcing the corruption. A complementary filter that blindly trusted this reading would tilt the estimate 35 degrees the wrong way during every hard stop. That is why the magnitude check, and the gyro's immunity to linear acceleration, matter so much.

Common misconception. "Just filter the accelerometer harder to remove the jitter." You can low-pass it — average over time — and the jitter shrinks. But heavy averaging makes the accel slow: it now lags real tilt by a noticeable delay, which is fatal for a drone that must react in milliseconds. You're trapped: a fast accel is jittery, a smooth accel is laggy. The escape is not better filtering of one sensor — it is to get the fast response from the gyro (which doesn't see linear acceleration at all) and the stable reference from the accel, and blend them. That blend is the complementary filter.

Watch motion corrupt the tilt reading

Below, the device's true tilt is fixed at the angle you set, but you can inject a burst of linear acceleration. With no motion, the accel reads the true tilt cleanly. Add a sideways shove and watch the accel-derived tilt jump to a wrong value — the bigger the shove, the bigger the lie — then snap back when the shove ends.

Linear acceleration masquerading as tilt

Set the device's true tilt; the gravity arrow (teal) shows it. Now inject a sideways linear acceleration and watch the accel-measured tilt (the arrow it thinks is down) swing away from the true gravity arrow. The accel can't tell the shove from a tilt — this is its blind spot.

True tilt (°) 20 Linear accel (m/s²) 0.0
drag the linear-accel slider

The accelerometer in code

Computing tilt from an accel reading is two lines; corrupting it with motion is one more. Watch the same arctan produce the truth when still and a lie when shoved:

python
import math

def accel_tilt(ax, az):
    return math.degrees(math.atan2(ax, az))   # tilt from gravity ratio, no integration

# device flat and still: gravity entirely on z
accel_tilt(0.0, 9.8)        # -> 0.0 deg   (correct: flat)

# device tilted 30 deg, still: gravity splits across x and z
accel_tilt(4.9, 8.49)      # -> 30.0 deg  (correct: it sees the real tilt)

# device FLAT but jerked sideways by 4 m/s^2: motion poisons the reading
accel_tilt(4.0, 9.8)        # -> 22.2 deg  (WRONG: it's flat, but the shove looks like tilt)

The atan2 function is just a robust arctan that handles all four quadrants and the divide-by-zero case — the math is identical to what we did by hand. The three calls tell the whole story: perfect at rest, perfect tilted-but-still, and badly wrong the instant motion intrudes. The accel is a beautiful long-term reference poisoned by short-term motion — the exact opposite failure from the gyro, which is why they fuse so well.

A phone is lying perfectly flat, but you jerk it sideways with a 4 m/s² acceleration. Its accelerometer reports a tilt of about 22°. Why?

Chapter 3: The Complementary Filter — High-Pass Gyro, Low-Pass Accel

We have two sensors with mirror-image flaws. The gyro is fast but drifts on the long time-scale; the accel is stable but jitters on the short time-scale. The trick that fuses them is almost embarrassingly simple, and it has a name that tells you exactly how it works: the complementary filter.

The core idea is frequency separation. The gyro's good information lives at high frequency — fast changes, quick twitches — while its bad information (drift) is a slow, low-frequency creep. The accelerometer is the reverse: its good information (the true average "down") lives at low frequency, while its bad information (motion jitter) is fast, high-frequency noise. So the recipe writes itself: keep the high frequencies from the gyro and the low frequencies from the accel, and throw away each sensor's bad band. We high-pass the gyro and low-pass the accel, then add the two surviving halves together.

Why "complementary." A high-pass filter passes fast changes and blocks slow drift. A low-pass filter passes slow steady values and blocks fast jitter. Add a high-pass and a low-pass that are designed to be exact opposites — whatever fraction one blocks, the other passes — and the two filters sum to one at every frequency. Together they reconstruct the whole signal with nothing lost and nothing double-counted. That "they add up to one" property is what makes them complementary, and it is the entire mathematical content of the filter.

The one formula, and why the weights sum to one

Here is the complete complementary filter. Every tick, you have your previous angle estimate, a fresh gyro turn rate, and a fresh accel tilt reading. You blend them like this:

θnew = α · (θold + ω · Δt) + (1 − α) · θaccel

Read it slowly, because this one line is the lesson. The term old + ω · Δt) is the gyro's prediction — take where we were and add this tick's rotation (the integration from Chapter 1). The term θaccel is the accelerometer's fresh, absolute tilt reading (the arctan from Chapter 2). And α (alpha) is a single number between 0 and 1 — the filter gain — that decides how much we lean on each. Crucially, the two weights are α and (1 − α), and those two always sum to exactly one:

α + (1 − α) = 1    (always, for any α)

That "sum to one" is not a coincidence we arranged for tidiness — it is the whole point. It guarantees the fused estimate is a true weighted average that stays in the right units and the right range: we never amplify or shrink the angle, we only split our trust between two estimates of the same thing. The α-weighted gyro term carries the fast motion (it's a high-pass on the gyro, because the slow drift gets continually overwritten); the (1−α)-weighted accel term carries the slow anchor (a low-pass on the accel, because each jittery reading only nudges the estimate by a small (1−α) fraction, averaging the noise out over many ticks).

What α really means. α is a trust slider, exactly like the Kalman gain you'll meet in the next lessons. Set α close to 1 (say 0.98) and you trust the gyro heavily — the estimate is smooth and fast, but a tiny sliver (1−α = 0.02) of accel correction leaks in each tick to slowly cancel the drift. Set α close to 0 and you trust the accel heavily — the estimate stops drifting almost instantly but inherits the accel's jitter. The art of tuning a complementary filter is finding the α that drifts slowly enough and jitters little enough. Typical drone values are α = 0.95 to 0.99.

One full tick, worked by hand with real numbers

Let's run the filter for a single tick, every arithmetic step shown. Suppose:

Step 1 — the gyro's prediction. Integrate this tick's rotation onto the old angle:

θold + ω · Δt = 10.0 + 20 × 0.01 = 10.0 + 0.2 = 10.2°

Step 2 — weight the two estimates. The gyro prediction (10.2°) gets weight α = 0.98; the accel reading (9.0°) gets weight (1 − α) = 0.02:

θnew = 0.98 × 10.2 + 0.02 × 9.0
= 9.996 + 0.18 = 10.176°

Read the result. The fused estimate, 10.176°, sits almost exactly on the gyro's prediction (10.2°) because we trusted the gyro at 98%. But it was tugged down by 0.024° toward the accelerometer's 9.0°. That tiny tug is doing the crucial work: every single tick, the accel pulls the estimate a hair toward true gravity, and those tiny tugs accumulate to exactly cancel the gyro's drift over time. The gyro carries the fast motion; the accel, 2% at a time, prevents the slow walk-off. Run this 100 times a second and you get the smooth, anchored green line from Chapter 0.

Watch the tugs cancel the drift

Let's verify that "tiny tugs cancel drift" claim with a second hand-trace. Imagine the device is perfectly still at a true tilt of 0°, but the gyro has a bias that pushes the prediction up by +0.05° every tick (its drift). The accel always reads the true 0°. With α = 0.98, watch the estimate reach a steady state instead of drifting away:

TickGyro pushes toθnew = 0.98(pred) + 0.02(0)Result
10 + 0.05 = 0.0500.98 × 0.050 + 0.02 × 00.0490°
20.049 + 0.05 = 0.0990.98 × 0.0990.0970°
10≈ 0.45°
settlesbalance point≈ 2.45° (bounded!)

This is the magic. A pure gyro would have drifted to 0.05° × (number of ticks) — unbounded, climbing forever. But the complementary filter reaches a steady-state error and stops: the constant gyro push (+0.05°/tick) is exactly balanced by the constant accel pull (0.02 of the gap back toward 0). The drift no longer accumulates; it parks at a small bounded offset (here about 2.45°, equal to the per-tick push divided by (1−α)). The accel's tiny 2% tug, applied relentlessly, defeats the gyro's relentless drift. That is why the fused line in Chapter 0 didn't peel away.

Where the steady-state number comes from, derived

Let's pin down that 2.45° with one clean argument, because it tells you exactly how to size the filter. At steady state the estimate has stopped changing — whatever the gyro adds each tick, the accel pull removes. Call the per-tick gyro push b (here b = 0.05°) and the steady-state error e. Each tick the gyro raises the estimate to (e + b), then the accel pulls it back toward the true 0° by the fraction (1−α). For the estimate to stay at e, the pull must exactly undo the push:

(1 − α) · (e + b) = b

Solve for e: divide both sides by (1−α), giving (e + b) = b / (1−α), so e = b/(1−α) − b = b · [1 − (1−α)] / (1−α) = b · α/(1−α). Plug in b = 0.05° and α = 0.98:

e = 0.05 · (0.98 / 0.02) = 0.05 × 49 = 2.45°

There it is — the bounded error the table converged to, from first principles. And the formula is deeply instructive: the steady-state drift error is the per-tick push times α/(1−α). Push α toward 1 and that ratio explodes (at α = 0.999 it's 999), so the residual drift balloons — too much gyro trust lets the drift survive. Lower α and the ratio shrinks, killing the drift faster — but at the cost of letting more accel jitter through. The single formula e = b·α/(1−α) is the drift-versus-jitter trade-off, written down.

Why this is literally a high-pass + low-pass that sum to one

We've been saying "high-pass the gyro, low-pass the accel." Here is why that's not just a slogan. Rearrange the filter equation by splitting the gyro term:

θnew = α·θold + α·ω·Δt + (1−α)·θaccel

The recursion θnew = α·θold + (1−α)·θaccel (ignoring the gyro increment for a moment) is exactly the textbook one-pole low-pass filter applied to the accelerometer: each new output is mostly the old output plus a small (1−α) sip of the new input — that's averaging, which passes slow signals and blocks fast jitter. Meanwhile the gyro enters as its increment ω·Δt (a change, a derivative), and feeding a rate-of-change into the same recursion is precisely a high-pass filter: it passes fast changes and lets slow constant offsets wash out. Because the two paths share the same α and (1−α) weights, the high-pass and low-pass are exact complements — at every frequency, the fraction the accel-path blocks is exactly the fraction the gyro-path passes, and vice versa. Their frequency responses add up to 1.0 across the whole spectrum, which is the formal meaning of "complementary." You get the entire signal back, with each band sourced from the sensor that's good in that band.

The complementary filter in one breath. Every tick, predict the new angle with the gyro (fast, but drifty), then pull that prediction a small fraction (1−α) of the way toward the accelerometer's absolute reading (slow, but drift-free). The gyro gives speed; the accel kills the drift; the weights sum to one so it's an honest average. High-pass the gyro, low-pass the accel, add them — one line of code, and you have an attitude estimate that beats either sensor alone.

The whole filter in code

Here is the complete complementary filter — the gyro integration and the accel correction, fused, in about six lines. This exact loop runs in countless hobby drones and balancing robots:

python
import math

def complementary_filter(gyro, accel_xz, dt, alpha=0.98):
    # gyro: list of turn rates (deg/s); accel_xz: list of (ax, az) readings
    theta = math.degrees(math.atan2(accel_xz[0][0], accel_xz[0][1]))  # init from accel
    out = [theta]
    for w, (ax, az) in zip(gyro[1:], accel_xz[1:]):
        theta_accel = math.degrees(math.atan2(ax, az))   # absolute tilt from gravity
        theta = alpha * (theta + w * dt) + (1 - alpha) * theta_accel  # THE filter
        out.append(theta)
    return out

The single load-bearing line is the last one inside the loop. Read it as a sentence: "the new angle is 98% (the gyro's prediction of where we'd be after this tick's rotation) plus 2% (the accelerometer's fresh, absolute reading of where down is)." Change alpha and you slide the trust between fast-but-drifty and slow-but-stable. Initialize theta from the accel (the first line) so you start at the right place rather than at zero. That's a complete, working attitude estimator.

Below, run the full filter on a swaying board and drag the α slider. Watch how α trades drift for jitter in real time — this is the single most important intuition in the lesson, and Chapter 6 will let you break it on purpose.

The complementary filter live — tune the gain α

The board sways along the dashed truth. The fused estimate blends a drifty gyro and a jittery accel with gain α. Push α high → smooth but slow to correct drift; push it low → anchored but jittery. Find the sweet spot.

Gain α 0.980 ready

Three questions people always ask about α

The complementary filter is so simple that the hard part is the conceptual edges. Three recurring confusions, settled:

How this becomes the Kalman filter

One more connection worth planting, because it frames everything ahead. The complementary filter has one hand-tuned trust knob, α, that you set once and leave fixed. The Kalman filter (Lesson 5, just before this one) is the same predict-then-correct structure — but instead of a fixed α, it computes the optimal trust split every single tick from each sensor's noise statistics, and it tracks how uncertain the estimate currently is. When the gyro has been integrating for a while (uncertainty grown), the Kalman filter automatically leans more on the accel; right after a good accel correction (uncertainty low), it leans back on the gyro. It is, in a precise sense, a complementary filter whose α adapts itself optimally — the 1975 Higgins paper in the references proves the complementary filter is a special case of it. You can think of this lesson as the Kalman filter with the math sanded off: same idea, fixed knob, a tenth of the cost. Master this and the Kalman filter is just "the version that picks α for you."

Build the complementary filter yourself

You've read the one line; now write it. Below, the board sways along a known true tilt. You're handed the drifty gyro rate (true rate + a constant bias) and the jittery accel-tilt reading (true tilt + noise). As shipped, the loop returns the gyro-only integration — you'll see it peel away from the truth exactly like Chapter 1. Your job is the single fusion line that pulls it back.

In the complementary filter θnew = α(θold + ω·Δt) + (1−α)θaccel, why is it essential that the two weights are α and (1−α)?

Chapter 4: Adding the Magnetometer — An Anchor for Heading

The complementary filter we just built fixes tilt — pitch and roll, how far the device leans forward/back and side/side. But orientation has a third part the accelerometer simply cannot touch: heading, also called yaw — which compass direction the device is pointing (north, east, south, west). And there is a deep reason the accelerometer is blind to it.

The accelerometer anchors tilt by watching gravity, which points straight down. But rotating the device around the vertical axis — spinning it like a turntable while keeping it level — does not change how gravity lands on it: gravity still points straight down through the same axis. So the accelerometer reads the same thing whether you face north or south. Gravity gives you a great reference for "which way is down," but no reference at all for "which way is north." Heading drifts on the gyro with nothing to stop it — the exact problem we just solved for tilt, returning for yaw.

The pattern repeats: every drifting axis needs an absolute anchor. Tilt drifts on the gyro → the accelerometer (gravity) anchors it. Heading drifts on the gyro → we need a different absolute reference, one that distinguishes north from south. Gravity can't (it's vertical). The answer is the other thing that points in a fixed horizontal direction everywhere on Earth: the planet's magnetic field. The sensor that reads it is the magnetometer, and it does for heading exactly what the accelerometer does for tilt.

The magnetometer: a digital compass

A magnetometer measures the direction and strength of the magnetic field around the device. The dominant field, outdoors and away from metal, is the Earth's own — it points roughly toward magnetic north, tilted into the ground at an angle that depends on your latitude. By reading which way that field lands on the device's horizontal axes, the magnetometer recovers heading the same way a compass needle does: it has a fixed external reference (magnetic north) that the gyro lacks, so its heading estimate doesn't drift.

So now we have a complete set of absolute references that exactly cover the gyro's three drifting axes:

Orientation partDrifts on the gyro?Absolute anchorReference direction
Pitch & Roll (tilt)yesAccelerometergravity (straight down)
Yaw (heading)yesMagnetometermagnetic north (horizontal)

The recipe is identical to before: the gyro provides fast yaw changes (it sees you turning instantly), and the magnetometer provides the slow, drift-free heading anchor. A complementary filter blends them — high-pass the gyro's yaw, low-pass the magnetometer's heading, weights summing to one. With accelerometer correcting tilt and magnetometer correcting heading, all three orientation axes are now anchored. A gyro + accelerometer + magnetometer fused into full orientation is, by definition, an AHRS — an Attitude and Heading Reference System. (A "6-axis" IMU is gyro + accel only and can't fix heading drift; a "9-axis" IMU adds the magnetometer and can.)

Heading from the magnetometer, by hand

The arithmetic mirrors the accelerometer's tilt computation exactly. Suppose the device is level and the magnetometer reads the horizontal field components mx (pointing out the device's nose) and my (out its left side). The heading — the angle between the device's nose and magnetic north — is again an arctangent:

heading = arctan( −my / mx )

Plug in numbers. Say mx = 22 (a lot of the field is along the nose) and my = 12.7 (some to the side). Then −my/mx = −0.577, and arctan(−0.577) = −30° — the device is pointing 30 degrees east of magnetic north. No integration, no drift; the field is a fixed reference just like gravity. When the device tilts, you must first tilt-compensate the magnetometer (rotate its readings flat using the pitch and roll the accelerometer already gave you) before computing heading — but the principle is unchanged: an absolute reference replaces drift with an honest, bounded reading.

Why tilt-compensation is non-negotiable

That parenthetical — "first tilt-compensate" — is easy to skip and ruinous to get wrong, so it earns a worked example. The Earth's field is not horizontal; in most of the world it dips steeply into the ground (the inclination angle, around 60–70° at mid-latitudes). So the field has a big vertical component. If the device is tilted, some of that vertical field leaks onto the horizontal axes you use for heading, and your arctan returns garbage. Concretely: a device pointing due north but pitched forward 30° will read magnetic field on its nose axis that includes a chunk of the downward field, and a naive heading calc can be off by tens of degrees — the heading would appear to change just because you pitched, even though you never turned.

The fix uses the pitch and roll the accelerometer already gave us (Chapter 2): mathematically rotate the magnetometer's three-axis reading back to a level frame — "un-tilt" it — so that the horizontal components used in the arctan really are horizontal. Then heading depends only on the actual facing, not on the tilt. The key insight is that the AHRS is built in stages: the accelerometer's tilt estimate is a prerequisite for the magnetometer's heading estimate. You cannot get a correct heading without first knowing the tilt — which is one more reason the three sensors are a team, not three independent voters.

Declination: magnetic north is not true north

One more honest wrinkle. The magnetometer points to magnetic north, which differs from true (geographic) north by the declination angle — up to 20° or more depending on where you are on Earth, and it slowly changes over years as the magnetic pole wanders. For most attitude work this doesn't matter: the filter only needs a stable heading reference to stop the gyro drifting, and magnetic north is perfectly stable on the time-scale of a flight. But if you need true north (for navigation against a map), you add the local declination (looked up from your GPS position) as a fixed offset. The complementary filter doesn't care — it anchors to whatever north the magnetometer reports; declination is a downstream correction, not a filter concern.

Common misconception — and the magnetometer's blind spot. "A magnetometer always points north." Only if the dominant field is the Earth's. The Earth's field is feeble — about half a gauss — and it is easily swamped by anything magnetic nearby: a drone's own motors and current-carrying wires, the steel in a building's frame, a speaker, a car. Near any of these the magnetometer points toward the interference, not toward north, and your heading is wrong. This is the magnetometer's equivalent of the accelerometer's linear-acceleration flaw: a stable absolute reference that gets corrupted by a specific, predictable enemy (here, stray magnetic fields). We'll see exactly this failure in the debugging chapter.

Watch the compass anchor the gyro — and an iron magnet wreck it

Below, the device turns (its true heading is the dashed line). The gyro-only heading drifts away as always; the fused heading stays locked to true north via the magnetometer. Then drag in a magnetic disturbance (a nearby motor or chunk of iron) and watch the fused heading get pulled off true — the anchor itself is now lying.

Heading: gyro drifts, magnetometer anchors — until interference hits

The device's true heading is the dashed line. Gyro-only yaw drifts off; the fused heading rides the magnetometer back to true. Drag the magnetic interference slider (a nearby motor/iron) and watch the fused heading get yanked toward the false field — the absolute anchor is only as good as the field it reads.

Mag interference (°) 0 ready

The full 9-axis blend in code

Adding heading is just a second copy of the same complementary line, fed by the magnetometer instead of the accelerometer. Tilt and heading run side by side, each with its own gain:

python
import math

def ahrs_step(pitch, yaw, gyro, accel, mag, dt, a_tilt=0.98, a_head=0.98):
    gx, gz, gyaw = gyro                       # gyro rates: tilt axis + yaw axis
    ax, az       = accel                       # accel for tilt
    mx, my       = mag                          # magnetometer for heading

    pitch_accel = math.degrees(math.atan2(ax, az))      # absolute tilt (gravity)
    yaw_mag     = math.degrees(math.atan2(-my, mx))     # absolute heading (north)

    pitch = a_tilt * (pitch + gx   * dt) + (1 - a_tilt) * pitch_accel   # tilt filter
    yaw   = a_head * (yaw   + gyaw * dt) + (1 - a_head) * yaw_mag       # heading filter
    return pitch, yaw

Two complementary filters, run in parallel: one fuses gyro+accel for tilt, the other fuses gyro+magnetometer for heading. That is a minimal AHRS. The structure is identical — predict with the gyro, correct toward the absolute reference — only the reference differs (gravity for tilt, north for heading). Notice each axis carries its own drifting gyro term and its own absolute anchor; the gyro can't fix itself, but the right reference fixes each axis it can see.

Why three sensors, not two. The gyro drifts on all three orientation axes. Gravity (the accelerometer) anchors only the two that change how "down" lands on the device — pitch and roll. Heading (yaw) is invisible to gravity, so it needs its own absolute reference: magnetic north, read by the magnetometer. Gyro + accel + magnetometer = every drifting axis anchored = a full AHRS. Drop the magnetometer (a 6-axis IMU) and your heading will slowly, inevitably drift, because nothing is correcting it.
Why can't the accelerometer correct heading (yaw) drift, the way it corrects tilt drift?

Chapter 5: Mahony & Madgwick — The Nonlinear, Quaternion Filters Drones Actually Run

The linear complementary filter we built works beautifully for a single axis of tilt. But a real drone tumbles in three dimensions at once, and 3-D rotations don't add up like simple angles — rotating 90° about x then 90° about y is not the same as doing them in the other order. The folklore single-axis blend breaks down. The two filters that dominate real flight controllers — Mahony and Madgwick — are the same complementary idea, generalized to full 3-D orientation. You already understand them; we just need to upgrade the representation and the correction.

Quaternions, just enough to use them

To track full 3-D orientation without the pitfalls of stacking angles, both filters use a quaternion — a compact bundle of four numbers (written q) that encodes "an orientation in space." You do not need the algebra to follow this chapter. Hold onto just three facts:

The mental model: a globe you keep nudging straight. Picture the orientation as a little globe. Every tick the gyro spins the globe by the measured turn rate (fast, but the spin axis is slightly off, so the globe slowly drifts crooked). The accelerometer says "down should be there" and the magnetometer says "north should be there"; together they tell you how crooked the globe has drifted. Both Mahony and Madgwick compute that "crookedness" — the error between where the sensors say down/north are and where your current orientation predicts they'd be — and gently rotate the globe to reduce it. Predict with the gyro, correct toward gravity and north: the complementary idea, in 3-D.

Why not just use three angles? The gimbal-lock trap

You might wonder why we can't just run three of our Chapter-3 single-axis filters — one for pitch, one for roll, one for yaw — and call it a 3-D AHRS. The naive answer fails in a specific, famous way called gimbal lock. When you describe orientation as three stacked angles (the "Euler angles" of pitch, roll, yaw), there are special orientations where two of the three axes line up and become the same rotation — for example, when a drone pitches straight up to 90°, its roll axis and yaw axis collapse onto each other. At that instant you lose a degree of freedom: there is no unique way to split a rotation between roll and yaw, the math divides by zero, and the estimate flips or freezes. This isn't a rare edge case for an aerobatic drone or a tumbling phone — they pass through these orientations routinely. The Apollo guidance computer famously had to warn astronauts away from gimbal-lock orientations of its physical gyro platform.

Quaternions exist precisely to dodge this. Because a quaternion encodes orientation as "a single axis and an angle around it" (four numbers with one constraint), it never has two axes that can collide — every orientation, including pitch-straight-up, has a smooth, unique representation. There are no special angles where the math breaks. The price is that you stop thinking in intuitive pitch/roll/yaw and start thinking in four abstract numbers — which is exactly why this lesson keeps the intuition (predict with gyro, correct toward gravity+north) and lets the quaternion algebra stay under the hood. The takeaway: real 3-D attitude filters use quaternions not for elegance but for survival, because stacked angles literally break in flight.

Mahony: a PI controller on the rotation group

The Mahony filter (Mahony, Hamel & Pflimlin, 2008) frames the correction as a feedback controller — the same proportional-integral (P-I) control idea used to hold a thermostat at temperature. Each tick it:

  1. Predicts the new orientation by rotating the quaternion with the gyro (fast, drifty).
  2. Computes the error: where do the accel and magnetometer say down/north are, versus where the current orientation predicts they are? The mismatch is a small rotation — the "crookedness."
  3. Applies a proportional (P) correction — nudge the orientation a fraction of the way toward fixing the error right now (this is exactly the (1−α) tug from Chapter 3, generalized to 3-D). The proportional gain kP plays the role of (1−α).
  4. Applies an integral (I) correction — accumulate the error over time and use it to estimate the gyro's bias, then subtract that bias from the gyro before integrating.

Step 4 is the gift. Remember Chapter 1's wish: "the gyro is exact if we knew the bias." The Mahony filter learns the bias on the fly. If the orientation is consistently being tugged the same direction tick after tick, that persistent error means the gyro is biased — so the integral term builds up an estimate of that bias and removes it at the source. The result: the gyro's drift is killed at its root, not just patched after the fact. This is why Mahony runs on tiny microcontrollers in millions of drones — it is cheap (a handful of multiplies per tick) and it self-calibrates the gyro.

Why the integral term is bias estimation — the intuition spelled out

Here is the logical chain that makes the integral term click. The proportional term corrects the error this tick, but it never asks why the error keeps appearing. If the gyro is biased, then every single tick the prediction leans the same way, the accel/mag say "you're crooked the same way again," and the proportional term keeps applying the same small correction — forever. A persistent, same-direction error is the unmistakable signature of a constant bias. So the integral term accumulates that error over time: the longer the same-direction correction persists, the larger the integral grows, until it equals the bias itself. At that point the filter subtracts the integral from the gyro reading before integrating, the prediction stops leaning, the error vanishes, and the integral holds steady at the right value. The filter has, in effect, watched its own corrections and reverse-engineered the gyro's hidden offset — exactly the "if we knew the bias" wish from Chapter 1, now granted automatically. (This is the same reason a thermostat with only proportional control settles a few degrees below its setpoint, while adding an integral term drives the steady-state error to zero: the integral exists precisely to eliminate persistent, constant errors.)

A quick numerical feel for it. Say the true gyro bias is 0.5°/s and the integral gain is small. Early on, the integral estimate is 0, so the full 0.5°/s leaks into the prediction and the proportional term fights it tick after tick. As the integral accumulates the persistent error, the bias estimate climbs — 0.1, then 0.25, then 0.4, then 0.5°/s — and as it approaches the truth, the leaked bias shrinks (0.5 minus the estimate), the corrections shrink with it, and the estimate eases to a stop right at 0.5°/s. From then on the gyro is effectively bias-free, and the orientation rides the gyro with almost no drift for the proportional term to fix. The plain complementary filter could never do this — it has no memory of why the error recurs, so it pays the steady-state drift cost (the e = b·α/(1−α) from Chapter 3) forever. Mahony's integral term is precisely the memory that pays it off once and for all.

Mahony = complementary filter + gyro-bias estimation. The proportional term is the familiar "pull toward the absolute reference" (the accel/mag correction, our (1−α) tug). The new integral term watches the long-run error and concludes "the gyro must be biased by this much," then subtracts it. So Mahony doesn't merely fight the drift each tick — it removes the cause of the drift. That bias estimate is the single biggest upgrade over the plain complementary filter.

Madgwick: one gradient-descent step toward the best-fitting orientation

The Madgwick filter (Madgwick, 2011) does the same job — correct the gyro's drift using gravity and north — but frames the correction as optimization instead of control. Each tick it asks: "given my accelerometer and magnetometer readings, what orientation would best explain them?" That is, which orientation makes the predicted down/north match the measured down/north most closely? Finding that orientation exactly would be expensive, so Madgwick takes a single cheap step toward it using gradient descent — the "roll downhill toward the best fit" idea.

Concretely, each tick Madgwick:

  1. Predicts the new orientation with the gyro (fast, drifty) — same as always.
  2. Defines an error function: how badly does the current orientation disagree with what the accel (gravity) and magnetometer (north) are reporting? Zero error means the orientation perfectly explains the sensors.
  3. Takes one gradient-descent step — computes which direction to rotate the orientation to most quickly reduce that error, and nudges a small amount that way (the step size is a tunable gain, β, playing the role of (1−α)).
  4. Blends the gyro prediction with that corrective step.

The two filters arrive at nearly identical behavior by different philosophies: Mahony thinks like a control engineer ("there's an error, apply feedback to cancel it"), Madgwick thinks like an optimizer ("there's a cost, take a step downhill"). Both are, at heart, the complementary filter — trust the gyro for fast motion, correct slowly toward the absolute references — dressed in 3-D quaternion clothing. Madgwick's gain β tunes the same trade-off as α: large β corrects drift fast but lets sensor noise in; small β is smooth but slower to correct.

Linear complementary (Ch 3)Mahony (2008)Madgwick (2011)
Represents orientation asa single anglequaternionquaternion
Correction philosophyweighted averageP-I feedback controlgradient descent
The "trust" knobαkP (and kI)β
Estimates gyro bias?noyes (integral term)optionally (a variant does)
3-D, no gimbal lock?no (single axis)yesyes
Cost per ticktinytinytiny

The shared skeleton in code

Stripped to its essence, every one of these filters is the same three-line rhythm — predict, measure the error against the absolute references, correct — differing only in how the correction is computed:

python
# The shared skeleton of complementary / Mahony / Madgwick (pseudocode)
def attitude_step(q, gyro, accel, mag, dt):
    gyro = gyro - bias_estimate          # (Mahony: subtract the learned bias)
    q_pred = integrate_gyro(q, gyro, dt) # PREDICT: rotate q forward (fast, drifty)

    error = mismatch(q_pred, accel, mag) # where do gravity/north say I am vs my prediction?

    # CORRECT -- the only thing that differs between the filters:
    #   complementary: q = (1-a)*q_accelmag + a*q_pred   (weighted average)
    #   Mahony:        q = q_pred rotated by  kP*error    (+ kI integral -> bias)
    #   Madgwick:      q = q_pred nudged by  -beta*grad(error)  (one descent step)
    q = apply_correction(q_pred, error)
    return normalize(q)                   # keep q a valid unit quaternion

The comments are the whole point: predict with the gyro, correct toward gravity and north — identical to the linear filter. Mahony and Madgwick just compute that correction more cleverly (feedback vs descent) and in 3-D (quaternions). If you understood Chapter 3, you understand the filters running in your drone right now; they are the same idea wearing a better coat. The final normalize keeps the four quaternion numbers describing a valid rotation — forgetting it is a classic bug we'll meet in Chapter 9.

What is the key capability the Mahony filter adds over the plain linear complementary filter?

Chapter 6: Showcase — A Live Attitude Estimator You Can Break

Every idea from Chapters 0–5 now runs in one interactive instrument. A board tilts back and forth (driven by a simulated gyro and accelerometer), and you watch three estimates race the truth: the drifting gyro-only, the jittery accel-only, and the fused complementary estimate. Two sliders let you set the filter gain α and inject linear acceleration to corrupt the accelerometer — exactly the two knobs that decide whether the filter sings or fails. This is the payoff chapter: no new theory, just every concept made visible and breakable.

This is the whole lesson in one sim. The tilting board on the left shows the truth (teal) and the fused estimate (green) as physical orientations. The plot on the right traces all three estimates against the dashed truth over time. The α slider sets the gyro/accel trust split (Chapter 3). The linear-accel slider injects motion that poisons the accelerometer (Chapter 2). Push α too high and watch drift creep in; push it too low and watch jitter take over; inject acceleration and watch the accel-anchor lie.
Attitude estimator — tune α, inject acceleration, break it on purpose

Press Run. The board (left) tilts along the true angle; the green needle is the fused estimate. The trace (right) shows gyro-only (drifts away), accel-only (jitters, spikes on motion), and fused (smooth + anchored). Drag α to trade drift for jitter; drag linear accel to poison the accel reference. Try the two preset buttons to see α too-high and too-low.

Gain α 0.980 Linear accel (m/s²) 0.0
press Run

A guided experiment — reproduce every claim in five steps

Don't just watch; falsify. Run this sequence and confirm each prediction with your own eyes:

  1. Press Run with α = 0.98 (the "good" preset). The fused green line hugs the dashed truth: smooth like the gyro, anchored like the accel. This is the baseline that works.
  2. Drag α to 0.999 (or hit "α too high"). The fused line goes glassy-smooth but slowly peels away from the truth — with so little accel correction (1−α = 0.001), the gyro drift wins. Too much trust in the gyro re-creates the gyro's own drift problem.
  3. Drag α to 0.6 (or hit "α too low"). The fused line stops drifting entirely but starts to shiver — it has inherited the accelerometer's jitter, because (1−α = 0.4) lets 40% of every noisy accel reading through. Too much trust in the accel re-creates the accel's jitter problem.
  4. Set α back to 0.98, then drag linear accel up to 6 m/s². Watch the blue accel-only line spike to wildly wrong tilts during the motion — and notice the fused green line wobbles a little too, because the corrupted accel is still pulling on it (1−α) each tick. The accel anchor is only as honest as the gravity it can see.
  5. Keep the acceleration high and lower α toward 0.5. The fusion gets worse, not better — you've increased your trust in the very sensor that's currently lying. The best α depends on the motion: trust the accel more when still, less when accelerating — which is exactly the adaptive-gain idea Chapter 8 will recommend.

The crucial contrast is steps 2 and 3 against each other. Both extremes fail, in opposite ways: too-high α drifts (the gyro's flaw); too-low α jitters (the accel's flaw). The good estimate lives in the narrow band where the gyro carries the fast motion and the accel quietly cancels the drift without injecting its noise. Tuning α is choosing where on the drift-versus-jitter trade-off you want to live — and step 5 shows that the right spot even depends on how hard the device is moving.

You just watched the entire lesson fail and succeed in real time. The complementary filter is one number's worth of judgment: α says how much to trust the fast-but-drifty gyro versus the stable-but-jittery accel. Get it right and you beat both sensors. Get it wrong in either direction and you inherit one sensor's specific weakness. And no fixed α is perfect, because the accel's trustworthiness changes with motion — the seed of every practical refinement in the next chapter.

Chapter 7: Use Cases & Real Products — The Complementary Filter In the Wild

Every Engineermaxxing lesson in this series ends with the same four chapters — Use Cases, Practical Application, Debugging, Connections — because a concept you can't point at in a shipped product is a concept you don't really own yet. The complementary filter and its nonlinear cousins (Mahony, Madgwick) are, by a wide margin, the most-deployed sensor fusion algorithm on Earth. They run in nearly every device that needs to know its orientation, precisely because they cost almost nothing and work well.

Drone flight controllers (PX4, ArduPilot, Betaflight). Open-source autopilots that fly millions of drones run a complementary-style filter as the core of their attitude estimator. ArduPilot's DCM (direction-cosine-matrix) estimator is a complementary filter with gyro-bias correction — structurally a Mahony filter. Betaflight, the firmware in most racing quads, runs a tuned complementary filter so the loop can update thousands of times a second on a cheap microcontroller. The drone uses the fused attitude to hold level and to translate your stick commands into motor speeds; if the estimate drifted or lagged, the quad would lean and crash.

VR / AR headsets. A Meta Quest or Apple Vision Pro must know your head orientation with almost no latency — if the view lagged your head turn by even tens of milliseconds, you'd feel motion sick. The gyro provides that instant, low-latency response (the high-frequency half); the accelerometer (and sometimes cameras) provide the slow drift correction so the virtual world doesn't slowly rotate away from where you left it. A complementary/Madgwick-style fusion is the backbone of headset orientation tracking (cameras then add positional and further drift correction on top).

Phone screen rotation & games. The portrait/landscape flip is a complementary filter deciding "which way is down" stably enough not to flicker. Tilt-steering games and the level/compass apps use the same fused attitude. The accelerometer alone would make the screen flip every time you walked or jostled the phone (motion jitter); the gyro alone would slowly rotate the "up" direction away. Fused, the screen flips only when you actually rotate the phone and holds steady otherwise.

Fitness wearables & smartwatches. A watch counts steps, recognizes a wrist-raise to wake the screen, and classifies your activity (walking vs cycling vs swimming) from its orientation and motion over time. A lightweight complementary/Madgwick filter fuses the watch's gyro and accelerometer to track wrist orientation cheaply enough to run all day on a coin-cell battery — battery life is exactly why these devices use a few-multiply complementary filter rather than a heavier Kalman filter.

The pattern across products

Notice the recurring logic in why each product reaches for this specific filter:

ProductQuantity estimatedWhy complementary/Mahony/Madgwick
Racing drone (Betaflight)attitude (pitch/roll/yaw)must run thousands of Hz on a cheap MCU; cost ≈ a handful of multiplies
Autopilot (PX4/ArduPilot)attitude + headingself-calibrating gyro bias (Mahony); robust, decades-proven
VR headsethead orientationgyro gives zero-latency response; accel/camera kill drift
Phone (screen, games)tilt / "which way is down"stable enough not to flicker; instant enough to feel responsive
Smartwatchwrist orientationruns all day on a coin cell; battery favors a tiny filter

The deep case: why a $5 drone board and a $3000 headset use the same idea

The most striking thing about the complementary filter is its range: it is the right tool at both ends of the price spectrum, for opposite reasons. On a $5 racing-drone board, you choose it because it is the only thing cheap enough — the control loop must run 8,000 times a second on a microcontroller with kilobytes of RAM, and a Kalman filter's matrix math (Lesson 5, and the upcoming filters) won't fit the time budget. A complementary filter is a few multiplies per axis; it fits trivially. On a $3000 headset with a powerful processor, you could run a heavier filter — and the positional tracking does use more sophisticated fusion — but for the core low-latency orientation you still want the gyro's instant response carried straight through, and a Madgwick-style filter delivers exactly that with negligible lag. Cheap hardware needs it because nothing else fits; expensive hardware uses it because the latency is unbeatable.

This is also where it sits relative to the Kalman filter (the next lessons). A Kalman filter is the optimal complementary fuser — it computes the trust split automatically from each sensor's noise statistics, and it tracks uncertainty. But it costs more (matrix inverses), needs tuning of noise covariances, and is harder to get right. The complementary filter is the Kalman filter's poorer-but-tougher cousin: not optimal, but cheap, robust, and easy to reason about. The industry rule of thumb: reach for the complementary/Mahony/Madgwick filter first; upgrade to a Kalman filter only when you need the extra accuracy or the uncertainty estimate, and you can afford the cost.

A walk through one real product's pipeline — a VR headset turning your head

Let's trace the data through an actual product to see every chapter's idea in its place. You turn your head 90° to the right in a VR headset, and the virtual world must rotate with you in under 20 milliseconds or you feel sick. Here is the journey:

  1. The gyro fires first (Chapter 1). At the instant your head starts turning, the headset's gyro reports the turn rate — hundreds of degrees per second, with essentially zero lag. The filter integrates it and the view begins rotating immediately. This is why VR feels responsive: the high-frequency motion comes straight from the gyro, the fastest sensor in the box.
  2. The accelerometer quietly anchors tilt (Chapters 2–3). Meanwhile, the accelerometer keeps reading "down," and the complementary filter's (1−α) tug keeps the headset's sense of vertical from slowly tipping — so the virtual horizon stays level over the whole session even as the gyro would otherwise drift it.
  3. The adaptive gate handles your head's acceleration (Chapter 8). When you whip your head, your head also accelerates linearly, briefly corrupting the accelerometer. The filter detects the magnitude leaving 1 g and leans harder on the gyro through the fast motion — then resumes accel correction once your head settles. Without this, the horizon would lurch on every quick glance.
  4. Cameras correct the slow residual drift (beyond this lesson). Over minutes, even the corrected gyro+accel heading drifts a little (no magnetometer indoors near all that metal and electronics). So headsets add cameras watching the room, which provide a slow absolute reference for heading and position — a higher-level fusion layered on top of the same complementary core.

Notice the layering: the complementary/Madgwick filter handles the fast, low-latency orientation (the part that must never lag), and a slower, heavier fusion (cameras) handles the drift and position on top. This division — cheap fast filter underneath, expensive slow corrector above — is the template for almost every modern attitude/pose system. The complementary filter isn't replaced by the fancy stuff; it's the responsive foundation the fancy stuff sits on.

The deployable insight. When orientation must be estimated cheaply, with low latency, on limited hardware — which is to say, almost everywhere — the complementary filter (or its nonlinear forms, Mahony and Madgwick) is the default. It buys you a smooth, drift-free, fast attitude estimate for the price of a few multiplications per tick. The Kalman filter is the upgrade you reach for when you need optimality or an uncertainty estimate and can pay for the matrix math. Most of the world ships the complementary filter.
A racing-drone flight controller must update its attitude estimate 8,000 times per second on a tiny microcontroller. Why does it use a complementary/Mahony filter rather than a Kalman filter?

Chapter 8: Practical Application — Choosing the Gain, Calibrating, and Knowing When to Trust the Accel

You now know how the filter works. This recurring "now build it" chapter turns that into a procedure you can run on a real device — how to pick the gain, how to calibrate the IMU so the filter even has a chance, and the single most important refinement: only trust the accelerometer when it is actually telling the truth.

Choosing the gain α — the cutoff-frequency way to think about it

Picking α by trial and error works, but there is a principled way that turns the knob into a meaningful number. The complementary filter has a crossover frequency — the boundary above which you trust the gyro and below which you trust the accel. Information that changes faster than the crossover comes from the gyro; information slower than it comes from the accel. The relationship to α and the sample time Δt is:

τ = α · Δt / (1 − α),    crossover frequency ≈ 1 / (2πτ)

where τ (tau) is the filter's time constant. Read it intuitively: a large α makes τ large, pushing the crossover to a low frequency — you trust the gyro across almost the whole band and only let the accel correct very slow drift. A small α makes τ small, raising the crossover — the accel corrects faster (less drift) but lets more jitter through. Work an example: at Δt = 0.01 s (100 Hz) and α = 0.98, τ = 0.98 × 0.01 / 0.02 = 0.49 s, so the crossover is about 1/(2π × 0.49) ≈ 0.32 Hz. Translation: the filter trusts the gyro for anything faster than about a third of a hertz, and leans on the accel only for slower drift. That is a sensible default for a drone.

The reusable rule. Set the crossover so it sits below your real motion's frequencies (so the gyro carries your actual maneuvers) but above the gyro's drift rate (so the accel catches the drift before it grows). For most handheld and flying devices, a time constant of roughly 0.5–1 second — an α around 0.98–0.99 at 100 Hz — is the standard starting point. Then tune by watching the drift-vs-jitter trade-off from the showcase.

Calibrating the IMU — without it, the filter is doomed

The filter assumes the raw sensors are roughly honest. They are not, out of the box. Three calibrations matter, and skipping them is the most common reason a filter that "should work" doesn't:

The order matters. Calibrate before you tune α. An uncalibrated gyro has a huge bias, so you'd be forced to set α low (lean hard on the accel) just to fight the runaway drift — trading away the gyro's responsiveness to compensate for a bias you should have simply subtracted. Calibrate the bias out first, and you can then run a high α (smooth, responsive) because there's barely any drift left to correct. Calibration buys you a better operating point on the trade-off curve.

The key refinement: only trust the accel when the device isn't accelerating

Recall the showcase's step 5: the accelerometer lies whenever the device is being pushed, so a fixed α is a compromise — too much accel trust during motion, too little while still. The professional fix is an adaptive gain: detect when the accelerometer is trustworthy, and only let it correct then. The detector is delightfully simple. When the device is not accelerating, the accelerometer's total magnitude should equal exactly 1 g (it feels only gravity). When the device is accelerating, the magnitude departs from 1 g (gravity plus the motion). So:

trust the accel ⇔ | ∥accel∥ − g | < small threshold

Concretely: compute the length of the accelerometer vector each tick. If it's close to 1 g (say within 5%), the device is roughly still — apply the accel correction normally (use your tuned (1−α)). If it's far from 1 g, the device is accelerating — reduce or skip the accel correction this tick and coast on the gyro until the motion settles. This one check turns the brittle fixed-α filter into a robust one that ignores the accelerometer exactly when it's lying. Mahony and Madgwick implementations almost universally include some form of this gating.

python
import math

def adaptive_step(theta, w, ax, az, dt, alpha=0.98, g=9.81, tol=0.5):
    mag = math.hypot(ax, az)                       # length of the accel vector
    accel_trustworthy = abs(mag - g) < tol         # near 1 g => only gravity
    theta_pred = theta + w * dt                    # gyro prediction (always)
    if accel_trustworthy:
        theta_accel = math.degrees(math.atan2(ax, az))
        return alpha * theta_pred + (1 - alpha) * theta_accel   # correct
    else:
        return theta_pred                            # device accelerating: coast on gyro

The single if is the whole upgrade. When the accel is honest (magnitude near g) the filter behaves exactly as Chapter 3; when the accel is corrupted by motion (magnitude far from g) it stops listening to it and rides the gyro through the disturbance. Because real motions are usually brief, the gyro barely drifts in those short windows, and the accel resumes correcting as soon as the device settles. This is the difference between a textbook filter and one that survives a real flight.

The adaptive gate, traced over a hard maneuver

Let's watch the gate work over a few ticks of a hard turn, with tol = 0.5 m/s² (so we trust the accel only when its magnitude is within 0.5 of g = 9.81). The device's true tilt is steady at 10° throughout, but it accelerates hard in the middle:

TickAccel reading (ax, az)∥accel∥|mag − g|Action
1 (still)(1.70, 9.66)9.810.00 < 0.5trust accel — correct toward 10°
2 (turn starts)(6.0, 9.66)11.41.6 > 0.5skip accel — coast on gyro
3 (peak)(7.5, 9.66)12.22.4 > 0.5skip accel — coast on gyro
4 (turn ends)(1.71, 9.66)9.810.00 < 0.5trust accel — resume correcting

During ticks 2–3 the accel magnitude balloons to 11–12 m/s² — the unmistakable fingerprint of linear acceleration on top of gravity — so the gate skips the accel correction and the estimate coasts on the (briefly drift-free-enough) gyro. The moment the maneuver ends and the magnitude snaps back to 9.81, the gate re-opens and the accel resumes anchoring. A fixed-α filter would instead have pulled the estimate toward arctan(7.5/9.66) ≈ 38° during the peak — a 28-degree lurch away from the true 10°. The gate turns that lurch into a smooth coast. This single check is the difference between an attitude estimate that wobbles on every aggressive move and one that holds rock-steady.

Calibrating gyro bias, the actual procedure

The most impactful calibration — gyro bias — is also the easiest, so it's worth seeing the exact recipe. At startup, hold the device still and average the gyro for, say, 200 readings (2 seconds at 100 Hz). Because the true rate is zero while still, the average is the bias. Suppose the 200 readings average to 0.48°/s on one axis; that's your bias estimate. From then on, subtract 0.48 from every reading before integrating. The drift you computed in Chapter 1 (30° in 60 s from a 0.5°/s bias) collapses to whatever tiny residual remains (perhaps 0.02°/s × 60 s = 1.2° in a minute — a 25× improvement) before the accel correction even gets involved. This one-time average, plus Mahony's continuous integral re-estimation to track slow temperature drift, is why production AHRS systems barely drift at all.

Sweep the gain and find the sweet spot

The trade-off from Chapter 3 says α→1 keeps the gyro's drift and α→0 keeps the accel's jitter, so the best α lives somewhere in the middle. Don't take that on faith — sweep it. Run the same scenario at many gains, record the fused error at each, and the curve should dip to a clear interior minimum: a U with a sweet spot.

What is the simplest reliable way to detect that the accelerometer is currently being corrupted by linear acceleration, so the filter can stop trusting it?

Chapter 9: Debugging & Failure Modes — The Five Ways an AHRS Goes Wrong

A complementary filter rarely crashes loudly. It fails quietly — the orientation slowly leans, or jitters, or jumps once and recovers — and the symptom rarely points at the cause. This recurring chapter catalogs the classic failure modes, the symptom each presents in the field, and the specific test that exposes it before it ships.

Trap 1 — Linear acceleration corrupting the gravity reference

The single most common AHRS bug. The accelerometer, recall, can't tell gravity from any other force. During an aggressive maneuver — a hard turn, a sudden climb, a dropped phone — the device accelerates, the accelerometer's "down" swings away from true gravity, and the filter dutifully corrects the orientation toward the wrong down. The estimate tilts the wrong way for the duration of the motion.

Symptom & detection. Symptom: the attitude estimate is accurate when the device is still or moving gently, but tilts the wrong way during accelerations — a drone's horizon "leans into" hard turns, or the estimate lurches when the device is bumped. Detection: log the accelerometer's magnitude alongside the estimate; whenever the error spikes, check if the magnitude departed from 1 g at the same moment. If they correlate, linear acceleration is the culprit. Fix: the adaptive-gain gate from Chapter 8 — stop trusting the accel when its magnitude isn't near 1 g.

Trap 2 — Magnetometer interference (motors, metal, currents)

The heading equivalent of Trap 1. The magnetometer assumes the dominant field is Earth's, but a drone's own motors (carrying tens of amps) and any nearby steel produce far stronger local fields. The magnetometer points toward the interference, the heading filter corrects yaw toward a false north, and the device's sense of direction is wrong — often worst at high throttle, exactly when the motors draw the most current.

Symptom & detection. Symptom: heading is fine on the bench but wrong in flight; it drifts or jumps when you change throttle, fly near a building, or pass over rebar. Detection: log the magnetometer's magnitude — like the accel's, it should be roughly constant (the local Earth-field strength); a sudden change means interference. Cross-check: does the heading error correlate with motor throttle? Fix: hard/soft-iron calibration (Chapter 8), physically mount the magnetometer far from the power wiring (on a GPS mast, as real drones do), and gate the heading correction when the field magnitude is abnormal.

Trap 3 — Gain mistuning (drift vs jitter)

Straight out of the showcase. Set α too high (too much gyro trust) and the estimate slowly drifts, because the accel correction is too weak to catch the gyro's bias. Set α too low (too much accel trust) and the estimate jitters and lurches during motion, because too much of the noisy/corrupted accel leaks through. Both look like "the filter is bad" but the cure is opposite in each case.

Symptom & detection. Symptom A (too high): the estimate is buttery-smooth but slowly leans away from truth over tens of seconds — that's drift, α is too high, lower it. Symptom B (too low): the estimate tracks the average well but is shaky and spikes on motion — that's jitter, α is too low, raise it. Detection: leave the device still and watch for drift (catches A); shake it and watch for jitter (catches B). The two tests pin the direction to turn the knob. Fix: tune toward the crossover-frequency target from Chapter 8, after calibrating the gyro bias.

Trap 4 — Quaternion sign-flip and normalization decay

Specific to the Mahony/Madgwick quaternion filters. Two related bugs. First, a quaternion and its negation represent the same orientation, so a naive implementation can suddenly "flip sign" between ticks — the math is still correct, but if you log or interpolate quaternions, the flip looks like a 360° jump and downstream code (a smoother, a blend) goes haywire. Second, floating-point round-off slowly makes the quaternion's length drift away from 1, and a non-unit quaternion no longer represents a pure rotation — the orientation subtly scales and skews over time.

Symptom & detection. Symptom: occasional instantaneous 180°/360° jumps in logged orientation (sign flip), or a slow, mysterious degradation of accuracy over minutes (normalization decay). Detection: log the quaternion's norm every tick — if it wanders from 1.0, you forgot to normalize; if consecutive quaternions have a large negative dot product, you hit a sign flip. Fix: normalize the quaternion every tick (the normalize(q) line from Chapter 5 — never optional), and enforce a consistent sign (e.g. keep the scalar part non-negative) before logging or blending.

Trap 5 — Wrong dt (the silent scale error)

The gyro integration is θ += ω · Δt, so every integrated angle is scaled by the time step. If your Δt is wrong — you hard-coded 0.01 s but the loop actually runs at 90 Hz, or timing jitter makes ticks uneven, or you used milliseconds where the code expected seconds — then every rotation is mis-scaled. A Δt that's 10% too large makes the gyro over-rotate by 10%; a Δt in the wrong units (1000× off) makes the estimate explode or freeze.

Symptom & detection. Symptom: the estimate consistently over- or under-rotates relative to a known physical motion (turn the device exactly 90° and it reads 81° or 99°), or it diverges instantly (units error). Detection: rotate the device by a known angle by hand and compare; check that your measured Δt (timestamp differences) matches what the code assumes. Fix: measure Δt from real timestamps each tick rather than assuming a fixed rate, and triple-check units (seconds vs milliseconds, degrees vs radians).

A debugging checklist you can run on any AHRS

CheckHowCatches
Accel magnitude vs 1 glog ∥accel∥; does error spike when it leaves 1 g?Trap 1 (linear accel)
Mag magnitude & throttlelog field strength; correlate heading error with throttleTrap 2 (mag interference)
Still test / shake teststill → drift means α too high; shake → jitter means α too lowTrap 3 (gain mistuning)
Quaternion normlog ∥q∥; should stay 1.0; check sign continuityTrap 4 (norm decay / sign flip)
Known-angle rotationturn 90° by hand; reads 90°? measure real ΔtTrap 5 (wrong dt / units)
The meta-lesson. Almost every AHRS bug is one of the absolute references lying (accel corrupted by motion, magnetometer corrupted by metal), the trust dial set wrong (α mistuned), or the bookkeeping broken (un-normalized quaternion, wrong Δt). The cure is always the same discipline: log the magnitudes (accel and mag should sit at known constants), test the extremes (still for drift, shaking for jitter), and verify the bookkeeping (norm = 1, Δt correct). The filter is simple; the failures all come from the sensors not being as honest as the filter assumes.

Watch a burst corrupt the accel — and see who survives

Trap 1 in the flesh. The board sits at a steady true tilt, but a linear-acceleration burst hits the middle of the run, throwing a big fake tilt into the accel reading. Run two complementary filters on the same corrupted signal — one gyro-trusting (high α), one accel-trusting (low α) — and watch which one gets yanked off the truth during the burst.

A drone's attitude estimate is accurate in gentle flight but its horizon "leans into" every hard, fast turn, recovering once the turn ends. Which failure mode is this, and what's the fix?

Chapter 10: Connections, References & Cheat-Sheet

You can now build the most-deployed sensor fusion algorithm in the world from scratch. Before we point ahead, here is everything in one place — the cheat-sheet to screenshot, the motto, the real sources, and the cross-links.

The complementary filter & AHRS — cheat-sheet

The problem: the gyro is fast but drifts (it integrates turn rate, so bias piles up linearly); the accel is stable but jittery (it reads gravity, but linear acceleration corrupts it).

The complementary filter (one axis):
  θnew = α(θold + ω·Δt) + (1−α)θaccel
· High-pass the gyro (keep fast motion, drop drift) + low-pass the accel (keep stable down, drop jitter).
· Weights α and (1−α) sum to one → honest weighted average.
· α is a trust slider: high = smooth but drifts; low = anchored but jittery. Typical 0.95–0.99.

Full orientation (AHRS): gyro + accel anchors tilt (gravity); gyro + magnetometer anchors heading (north). Yaw is invisible to gravity, so it needs the magnetometer. 9-axis = full AHRS.

Nonlinear versions (drones run these):
· Mahony — P-I feedback on quaternions; integral term estimates gyro bias and removes it.
· Madgwick — one gradient-descent step toward the orientation that best explains accel+mag. Gain β.
· Both = the complementary idea in 3-D quaternions: predict with gyro, correct toward gravity+north.

Practical: calibrate first (gyro bias by averaging at rest, accel six-position, mag figure-eight), then tune α toward a ~0.5–1 s time constant. Adaptive gain: only trust the accel when ∥accel∥ ≈ 1 g.

Five failure modes: linear accel poisons gravity · magnetic interference poisons north · α mistuned (drift vs jitter) · quaternion sign-flip / un-normalized · wrong Δt / units.

The motto

"What I cannot create, I cannot understand." — Richard Feynman.
You can now create an attitude estimator from a gyro and an accelerometer — and, more importantly, predict exactly how it drifts, jitters, or lies, before you fly it.

The filter family in one quick-reference table

Screenshot this. It is the whole lesson collapsed into a single lookup:

FilterOrientation asCorrectionEstimates gyro bias?When to use
Linear complementaryone angleweighted average (α)nosingle-axis tilt, learning, balancing robots
MahonyquaternionP-I feedback (kP, kI)yesdrones/MCU; cheap, self-calibrating
Madgwickquaterniongradient-descent step (β)optionalwearables, VR; smooth, low-latency
Kalman (Lesson 5)state + covarianceoptimal, automatic gainyes (augmented state)need optimality / uncertainty; can afford the cost

Where this lesson sits in the series

This was Lesson 6 of 22. The recurring chapter structure you just walked (Why → concepts → showcase → Use Cases, Practical Application, Debugging, Connections) repeats in every lesson, so you always know where you are. The complementary filter is the gateway drug to estimation: it shows the core move — predict with a fast sensor, correct with a stable one — in its simplest possible form. Everything ahead refines that same move with more math, more rigor, and more sensors.

Right before this, Lesson 5: The Kalman Filter introduced the optimal version of exactly this idea — it computes the trust split (the analog of α) automatically from each sensor's noise, and tracks uncertainty. The complementary filter is its cheap, hand-tuned cousin. Next up, Lesson 7 continues into the nonlinear estimation tools (the Extended and Unscented Kalman filters) that handle sensors whose models aren't straight lines — which is nearly all real sensors. Further on, Lesson 13: IMU & Inertial Navigation takes the very same gyro/accel pairing and extends it from orientation to full position tracking — the dead-reckoning that carries a vehicle through a GPS dropout.

Related lessons on Engineermaxxing

These lessons go deeper on machinery we touched:

References

  1. Madgwick, S. O. H. "An efficient orientation filter for inertial and inertial/magnetic sensor arrays." Proceedings of the IEEE International Conference on Rehabilitation Robotics (ICORR), 2011. The gradient-descent attitude filter that became a standard in drones and wearables. x-io.co.uk (report PDF)
  2. Mahony, R., Hamel, T., and Pflimlin, J.-M. "Nonlinear Complementary Filters on the Special Orthogonal Group." IEEE Transactions on Automatic Control, vol. 53, no. 5, pp. 1203–1218, 2008. The P-I complementary filter on the rotation group, with gyro-bias estimation. doi:10.1109/TAC.2008.923738
  3. Higgins, W. T. "A Comparison of Complementary and Kalman Filtering." IEEE Transactions on Aerospace and Electronic Systems, vol. AES-11, no. 3, pp. 321–325, 1975. The classic reference establishing the complementary filter as a special case of the Kalman filter. doi:10.1109/TAES.1975.308081
A colleague says "our drone uses a gyro and an accelerometer with a complementary filter, so its heading will never drift." What's the precise correction?