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.
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.
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.
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.
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.
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:
Plug in time and watch it walk away:
| Time running gyro-only | Angle error = 0.5°/s × t |
|---|---|
| 1 second | 0.5° — negligible, beautiful |
| 10 seconds | 5° — a drone is already leaning |
| 60 seconds | 30° — the horizon is badly wrong |
| 5 minutes | 150° — 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.
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.
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.
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:
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.
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:
| Tick | Elapsed time | True angle | Gyro-integrated angle |
|---|---|---|---|
| 0 | 0.00 s | 0° | 0.000° |
| 1 | 0.01 s | 0° | 0.005° |
| 100 | 1.00 s | 0° | 0.500° |
| 1000 | 10.0 s | 0° | 5.000° |
| 6000 | 60.0 s | 0° | 30.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 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:
| Phase | True rate | Gyro reads | True angle gained | Gyro angle gained |
|---|---|---|---|---|
| Turn (0–3 s) | 10°/s | 10.5°/s | 10 × 3 = 30° | 10.5 × 3 = 31.5° |
| Hold (3–60 s) | 0°/s | 0.5°/s | 0° | 0.5 × 57 = 28.5° |
| Total at 60 s | 30° | 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.
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.
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.
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.
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.
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 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.
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:
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:
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.
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 tilt | ax = sin(θ) | az = cos(θ) | arctan(ax/az) | vector length |
|---|---|---|---|---|
| 0° (level) | 0.000 | 1.000 | 0° | 1.000 g |
| 30° | 0.500 | 0.866 | 30° | 1.000 g |
| 45° | 0.707 | 0.707 | 45° | 1.000 g |
| 60° | 0.866 | 0.500 | 60° | 1.000 g |
| 90° (on its side) | 1.000 | 0.000 | 90° | 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 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:
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.
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.
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.
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.
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.
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:
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:
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).
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:
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:
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.
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:
| Tick | Gyro pushes to | θnew = 0.98(pred) + 0.02(0) | Result |
|---|---|---|---|
| 1 | 0 + 0.05 = 0.050 | 0.98 × 0.050 + 0.02 × 0 | 0.0490° |
| 2 | 0.049 + 0.05 = 0.099 | 0.98 × 0.099 | 0.0970° |
| 10 | … | … | ≈ 0.45° |
| ∞ | settles | balance 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.
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:
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:
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.
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:
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.
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 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.
The complementary filter is so simple that the hard part is the conceptual edges. Three recurring confusions, settled:
atan2 on the first line of the code) starts you correct immediately — a free, obvious win that beginners routinely skip.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."
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.
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.
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 part | Drifts on the gyro? | Absolute anchor | Reference direction |
|---|---|---|---|
| Pitch & Roll (tilt) | yes | Accelerometer | gravity (straight down) |
| Yaw (heading) | yes | Magnetometer | magnetic 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.)
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:
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.
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.
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.
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.
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.
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.
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.
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:
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.
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:
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.
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.
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:
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 as | a single angle | quaternion | quaternion |
| Correction philosophy | weighted average | P-I feedback control | gradient descent |
| The "trust" knob | α | kP (and kI) | β |
| Estimates gyro bias? | no | yes (integral term) | optionally (a variant does) |
| 3-D, no gimbal lock? | no (single axis) | yes | yes |
| Cost per tick | tiny | tiny | tiny |
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.
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.
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.
Don't just watch; falsify. Run this sequence and confirm each prediction with your own eyes:
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.
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.
Notice the recurring logic in why each product reaches for this specific filter:
| Product | Quantity estimated | Why 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 + heading | self-calibrating gyro bias (Mahony); robust, decades-proven |
| VR headset | head orientation | gyro 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 |
| Smartwatch | wrist orientation | runs all day on a coin cell; battery favors a tiny filter |
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.
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:
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.
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.
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:
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 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:
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:
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.
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:
| Tick | Accel reading (ax, az) | ∥accel∥ | |mag − g| | Action |
|---|---|---|---|---|
| 1 (still) | (1.70, 9.66) | 9.81 | 0.00 < 0.5 | trust accel — correct toward 10° |
| 2 (turn starts) | (6.0, 9.66) | 11.4 | 1.6 > 0.5 | skip accel — coast on gyro |
| 3 (peak) | (7.5, 9.66) | 12.2 | 2.4 > 0.5 | skip accel — coast on gyro |
| 4 (turn ends) | (1.71, 9.66) | 9.81 | 0.00 < 0.5 | trust 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.
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.
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.
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.
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.
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.
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.
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.
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.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.
| Check | How | Catches |
|---|---|---|
| Accel magnitude vs 1 g | log ∥accel∥; does error spike when it leaves 1 g? | Trap 1 (linear accel) |
| Mag magnitude & throttle | log field strength; correlate heading error with throttle | Trap 2 (mag interference) |
| Still test / shake test | still → drift means α too high; shake → jitter means α too low | Trap 3 (gain mistuning) |
| Quaternion norm | log ∥q∥; should stay 1.0; check sign continuity | Trap 4 (norm decay / sign flip) |
| Known-angle rotation | turn 90° by hand; reads 90°? measure real Δt | Trap 5 (wrong dt / units) |
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.
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.
Screenshot this. It is the whole lesson collapsed into a single lookup:
| Filter | Orientation as | Correction | Estimates gyro bias? | When to use |
|---|---|---|---|---|
| Linear complementary | one angle | weighted average (α) | no | single-axis tilt, learning, balancing robots |
| Mahony | quaternion | P-I feedback (kP, kI) | yes | drones/MCU; cheap, self-calibrating |
| Madgwick | quaternion | gradient-descent step (β) | optional | wearables, VR; smooth, low-latency |
| Kalman (Lesson 5) | state + covariance | optimal, automatic gain | yes (augmented state) | need optimality / uncertainty; can afford the cost |
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.
These lessons go deeper on machinery we touched: