Skip to content

Commit e31a58b

Browse files
feat: add complementary filter (#172)
* add complementary filter * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d59132b commit e31a58b

11 files changed

Lines changed: 355 additions & 171 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
2121
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, MPC, Saturation, Rate Limiter, Slew-Limited Saturation, Feedforward/2-DOF |
2222
| [Dynamics](doc/dynamics/README.md) | Euler-Lagrange, Newton-Euler, Recursive Newton-Euler, ABA |
2323
| [Estimators](doc/estimators/README.md) | Linear Regression, Yule-Walker (offline), Recursive Least Squares (online) |
24-
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, FIR, IIR, Exponential Moving Average, Moving Average, Median Filter |
24+
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Complementary, FIR, IIR, Exponential Moving Average, Moving Average, Median Filter |
2525
| [Kinematics](doc/kinematics/README.md) | Forward Kinematics |
2626
| [Neural Network](doc/neural_network/README.md) | Layers, activations, losses, model |
2727
| [Optimization](doc/optimization/README.md) | Gradient Descent |

ROADMAP.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ Difficulty legend:
2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
3030
| 8 | Alpha-beta / alpha-beta-gamma filter | `filters/active` | ★★☆☆☆ |
31-
| 9 | Complementary filter | `filters/active` | ★★☆☆☆ |
3231
| 10 | Gain-scheduled controller | `controllers` | ★★☆☆☆ |
3332
| 11 | Convolution & correlation utilities | `analysis` | ★★☆☆☆ |
3433
| 12 | Polynomial least-squares curve fitting | `estimators/offline` | ★★☆☆☆ |
@@ -106,12 +105,6 @@ Difficulty legend:
106105
- **Algorithm / paper:** P. Kalata, "The tracking index: A generalized parameter for α-β and α-β-γ target trackers," *IEEE Trans. AES*, 20(2), 1984.
107106
- **Reuses:** `math::LinearTimeInvariant`, `filters/active` patterns.
108107

109-
### 9. Complementary filter
110-
- **What:** Frequency-domain fusion of a fast/biased sensor (gyro) with a slow/noisy one (accel/mag): `θ = a·(θ+ω·Ts) + (1−a)·θ_accel`.
111-
- **Embedded value:** The default low-cost attitude/tilt estimator on every IMU-based project.
112-
- **Algorithm / paper:** W. T. Higgins, "A Comparison of Complementary and Kalman Filtering," *IEEE Trans. AES*, 11(3), 1975.
113-
- **Reuses:** `math::Geometry3D`, item 1.
114-
115108
### 10. Gain-scheduled controller
116109
- **What:** Interpolates a set of precomputed controller gains across a scheduling variable (speed, load, operating point).
117110
- **Embedded value:** Extends linear controllers to mildly nonlinear plants without online redesign.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Complementary Filter
2+
3+
## Overview & Motivation
4+
5+
Real-time attitude and heading estimation requires fusing two fundamentally different sensor
6+
modalities: a **gyroscope** that integrates angular rate to produce a short-term angle estimate
7+
(fast response, low noise, but subject to drift) and an **accelerometer or magnetometer** that
8+
reads the angle directly (accurate at rest, noisy during motion, slow dynamics). Neither sensor
9+
alone is sufficient. The complementary filter solves the fusion problem with two multiplies
10+
and two adds per sample, making it the default tilt estimator on virtually every IMU-based
11+
embedded project where a full Kalman filter is unaffordable.
12+
13+
## Mathematical Theory
14+
15+
### Frequency-Domain Complement
16+
17+
The two sensor paths are complementary in the transfer-function sense: the gyro path acts as a
18+
**first-order high-pass filter** and the direct-angle path acts as a **first-order low-pass filter**
19+
sharing the same crossover frequency $\omega_c = 1/\tau$. Their sum is identically unity for all
20+
frequencies:
21+
22+
$$H_{HP}(s) + H_{LP}(s) = 1$$
23+
24+
This ensures that no frequency content is amplified or attenuated by the fusion itself.
25+
26+
### Discrete-Time Update Equation
27+
28+
Given the fused angle $\theta_k$, gyro rate $\omega_k$, accelerometer angle $\theta_{acc,k}$,
29+
sample period $T_s$, and blend weight $\alpha \in [0,1]$:
30+
31+
$$\theta_{k+1} = \alpha\,(\theta_k + \omega_k\,T_s) + (1-\alpha)\,\theta_{acc,k}$$
32+
33+
The term $\theta_k + \omega_k T_s$ is the **high-pass path** (integration of the fast sensor),
34+
and $\theta_{acc,k}$ is the **low-pass path** (direct measurement from the slow sensor).
35+
36+
### Design Parameter
37+
38+
The single tuning knob is the crossover time constant $\tau$, which maps to $\alpha$ via:
39+
40+
$$\alpha = \frac{\tau}{\tau + T_s}$$
41+
42+
Below $1/\tau$ the filter trusts the accelerometer; above it, the gyroscope. Typical embedded
43+
values are $\tau = 0.5$–$2$ s, corresponding to $\alpha \approx 0.98$ at $T_s = 10$ ms.
44+
45+
### Heading Wrap (Shortest-Arc Blend)
46+
47+
When the state is a heading angle in $(-\pi, \pi]$, a naive linear blend can jump by $2\pi$ near
48+
the seam. Instead the blend is performed along the shortest arc:
49+
50+
$$\delta = \mathrm{WrapToPi}(\theta_{acc} - \hat{\theta})$$
51+
$$\theta_{k+1} = \mathrm{WrapToPi}\!\left(\hat{\theta} + (1-\alpha)\,\delta\right)$$
52+
53+
where $\mathrm{WrapToPi}(x) = \bigl((x + \pi) \bmod 2\pi\bigr) - \pi$.
54+
55+
## Complexity Analysis
56+
57+
| Operation | Time | Space | Notes |
58+
|-----------|--------|--------|--------------------------------------------|
59+
| Update | $O(1)$ | $O(1)$ | 2 multiplies, 2 adds; 1 fmod when wrapping |
60+
| Reset | $O(1)$ | $O(1)$ | Single state write |
61+
62+
Total storage: one angle word plus two constant coefficients.
63+
64+
## Step-by-Step Walkthrough
65+
66+
**Setup:** $\alpha = 0.98$, $T_s = 10$ ms, initial angle $= 0$.
67+
68+
**Sample 1:** gyro rate $\omega = 10$ deg/s $= 0.1745$ rad/s, accel reads $\theta_{acc} = 0.01$ rad.
69+
70+
$$\hat{\theta} = 0 + 0.1745 \times 0.01 = 0.001745 \text{ rad} \quad (\text{gyro path})$$
71+
$$\theta_1 = 0.98 \times 0.001745 + 0.02 \times 0.01 = 0.001710 + 0.000200 = 0.001910 \text{ rad}$$
72+
73+
**After many samples with zero rate and accel $= 0.2$ rad:** the low-pass term accumulates
74+
and $\theta \to 0.2$ rad as $(1-\alpha)^n \to 0$.
75+
76+
## Pitfalls & Edge Cases
77+
78+
- **Alpha at 1:** the gyro path integrates without bound; any constant bias drifts the angle
79+
indefinitely. This is intentional (gyro-only mode) but must be avoided in production.
80+
- **Alpha at 0:** the output equals the accelerometer reading at every step; the gyro is ignored.
81+
- **Heading wrap:** linear blending without shortest-arc correction produces a $2\pi$ jump when
82+
the heading crosses $\pm\pi$. Always enable the wrap mode for heading estimation.
83+
- **Gyro bias:** the filter has no bias estimator. A constant gyro bias produces a bounded
84+
steady-state error of approximately $\text{bias} \cdot T_s \cdot \alpha / (1-\alpha)$.
85+
Pre-subtract a calibrated bias before calling Update.
86+
- **Accelerometer noise during dynamics:** the accel path is unreliable when linear acceleration
87+
is present (non-gravitational). Reduce $(1-\alpha)$ or temporarily freeze the accel correction.
88+
89+
## Variants & Generalizations
90+
91+
| Variant | Key Difference |
92+
|----------------------------|-----------------------------------------------------------------------|
93+
| **Mahony filter** | 3-D quaternion formulation with integral gyro-bias estimator |
94+
| **Madgwick filter** | Gradient-descent quaternion fusion; no linearisation |
95+
| **Alpha-Beta filter** | Fixed-gain tracking without a slow sensor; pure high-pass integration |
96+
| **Kalman filter** | Optimal (minimum-variance) fusion; requires noise covariance tuning |
97+
| **Two-step complementary** | Separate pitch/roll from heading; common on 6-DOF IMUs |
98+
99+
## Applications
100+
101+
- **IMU tilt estimation** — Roll and pitch from a 6-axis MEMS sensor at low computational cost.
102+
- **Heading fusion** — Combining gyro yaw rate with magnetometer heading.
103+
- **Servo/motor feedback** — Fusing encoder velocity with potentiometer position.
104+
- **Altitude hold** — Mixing barometer (low-pass) with accelerometer integration (high-pass).
105+
106+
## Connections to Other Algorithms
107+
108+
| Algorithm | Relationship |
109+
|----------------------------------------------------------------------|--------------------------------------------------------------------------|
110+
| [Exponential Moving Average](../passive/ExponentialMovingAverage.md) | The low-pass path in isolation; $\alpha_{EMA} = 1-\alpha_{CF}$ |
111+
| [Alpha-Beta Filter](AlphaBetaFilter.md) | Complementary filter without a slow-sensor reference; fixed-gain tracker |
112+
| [AHRS Madgwick/Mahony](AhrsMadgwickMahony.md) | 3-D quaternion generalization with gyro-bias estimation |
113+
| [Kalman Filter](KalmanFilter.md) | Statistically optimal generalization requiring $Q$ and $R$ tuning |
114+
115+
## References & Further Reading
116+
117+
- W. T. Higgins, "A Comparison of Complementary and Kalman Filtering," *IEEE Transactions on
118+
Aerospace and Electronic Systems*, 11(3), pp. 321–325, 1975.
119+
- S. Madgwick, "An Efficient Orientation Filter for Inertial and Inertial/Magnetic Sensor Arrays,"
120+
Technical Report, University of Bristol, 2010.
121+
- R. Mahony, T. Hamel, and J.-M. Pflimlin, "Nonlinear Complementary Filters on the Special
122+
Orthogonal Group," *IEEE Transactions on Automatic Control*, 53(5), pp. 1203–1218, 2008.

numerical/filters/active/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ target_link_libraries(numerical.filters.active ${NUMERICAL_VISIBILITY}
1212
)
1313

1414
target_sources(numerical.filters.active PRIVATE
15+
ComplementaryFilter.hpp
1516
ExtendedKalmanFilter.hpp
1617
KalmanFilter.hpp
1718
KalmanFilterBase.hpp
@@ -20,6 +21,7 @@ target_sources(numerical.filters.active PRIVATE
2021
)
2122

2223
numerical_add_coverage_sources(numerical.filters.active
24+
ComplementaryFilter.cpp
2325
ExtendedKalmanFilter.cpp
2426
KalmanFilter.cpp
2527
KalmanFilterBase.cpp
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include "numerical/filters/active/ComplementaryFilter.hpp"
2+
3+
namespace filters
4+
{
5+
template class ComplementaryFilter<float>;
6+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#pragma once
2+
3+
#if defined(__GNUC__) || defined(__clang__)
4+
#pragma GCC optimize("O3", "fast-math")
5+
#endif
6+
7+
#include "numerical/math/CompilerOptimizations.hpp"
8+
#include <cassert>
9+
#include <cmath>
10+
#include <numbers>
11+
#include <type_traits>
12+
13+
namespace filters
14+
{
15+
template<typename T>
16+
class ComplementaryFilter
17+
{
18+
static_assert(std::is_floating_point_v<T>, "ComplementaryFilter supports floating-point types");
19+
20+
public:
21+
explicit ComplementaryFilter(T alpha, T Ts, T initial = T{}, bool wrapAngle = false) noexcept;
22+
23+
OPTIMIZE_FOR_SPEED T Update(T rate, T measuredAngle) noexcept;
24+
void Reset(T angle = T{}) noexcept;
25+
void SetAlpha(T alpha) noexcept;
26+
27+
static T AlphaFromTau(T tau, T Ts) noexcept;
28+
29+
private:
30+
T alpha;
31+
T Ts;
32+
T angle;
33+
bool wrapAngle;
34+
35+
static T WrapToPi(T x) noexcept;
36+
};
37+
38+
//// Implementation ////
39+
40+
template<typename T>
41+
ComplementaryFilter<T>::ComplementaryFilter(T alpha, T Ts, T initial, bool wrapAngle) noexcept
42+
: alpha{ alpha }
43+
, Ts{ Ts }
44+
, angle{ initial }
45+
, wrapAngle{ wrapAngle }
46+
{
47+
assert(alpha >= T{} && alpha <= T{ 1 });
48+
assert(Ts > T{});
49+
}
50+
51+
template<typename T>
52+
OPTIMIZE_FOR_SPEED T ComplementaryFilter<T>::Update(T rate, T measuredAngle) noexcept
53+
{
54+
T predicted{ angle + rate * Ts };
55+
if (wrapAngle)
56+
{
57+
T delta{ WrapToPi(measuredAngle - predicted) };
58+
angle = WrapToPi(predicted + (T{ 1 } - alpha) * delta);
59+
}
60+
else
61+
{
62+
angle = alpha * predicted + (T{ 1 } - alpha) * measuredAngle;
63+
}
64+
return angle;
65+
}
66+
67+
template<typename T>
68+
void ComplementaryFilter<T>::Reset(T value) noexcept
69+
{
70+
angle = value;
71+
}
72+
73+
template<typename T>
74+
void ComplementaryFilter<T>::SetAlpha(T newAlpha) noexcept
75+
{
76+
assert(newAlpha >= T{} && newAlpha <= T{ 1 });
77+
alpha = newAlpha;
78+
}
79+
80+
template<typename T>
81+
T ComplementaryFilter<T>::AlphaFromTau(T tau, T Ts) noexcept
82+
{
83+
return tau / (tau + Ts);
84+
}
85+
86+
template<typename T>
87+
T ComplementaryFilter<T>::WrapToPi(T x) noexcept
88+
{
89+
constexpr T pi{ std::numbers::pi_v<T> };
90+
constexpr T twoPi{ T{ 2 } * std::numbers::pi_v<T> };
91+
T wrapped{ std::fmod(x + pi, twoPi) };
92+
if (wrapped < T{})
93+
wrapped += twoPi;
94+
return wrapped - pi;
95+
}
96+
97+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
98+
extern template class ComplementaryFilter<float>;
99+
#endif
100+
}

numerical/filters/active/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ target_link_libraries(numerical.filters.active_test PUBLIC
88
)
99

1010
target_sources(numerical.filters.active_test PRIVATE
11+
TestComplementaryFilter.cpp
1112
TestExtendedKalmanFilter.cpp
1213
TestKalmanFilter.cpp
1314
TestKalmanSmoother.cpp

0 commit comments

Comments
 (0)