Skip to content

Commit deb3d77

Browse files
authored
feat: add ComplementaryFilter, fusing a drifting rate with a noisy absolute reading (#7614)
The classic pair is an accelerometer and a gyroscope measuring the same tilt: one never drifts but picks up every vibration, the other is smooth but has to be integrated, so any bias in it walks away without limit. Their errors live in different parts of the spectrum, and the filter is a high pass on the integrated rate plus a low pass on the absolute reading whose transfer functions sum to unity, so the true signal passes through untouched and no lag is introduced. The single parameter is exposed as a time constant as well, tau = a * dt / (1 - a), which fixes the price of the trade exactly: a rate with a constant bias b leaves a steady state error of tau * b and no more. The tests check that identity, the geometric convergence onto the reference, the noise reduction of sqrt((1 - a) / (1 + a)), and that a ramp is followed without the lag a plain low pass on the reference would add. Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com> Co-authored-by: alxkm <19151554+alxkm@users.noreply.github.com>
1 parent b2b9d3a commit deb3d77

2 files changed

Lines changed: 497 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package com.thealgorithms.streaming;
2+
3+
/**
4+
* The <b>complementary filter</b>: one estimate out of two sensors that are each wrong in a
5+
* different way.
6+
*
7+
* <p>The classic pair is an accelerometer and a gyroscope measuring the same tilt. The accelerometer
8+
* knows where down is and never drifts, but every vibration of the frame shows up in it. The
9+
* gyroscope is smooth and immune to vibration, but it measures a rate, so using it means integrating,
10+
* and the smallest bias in that rate integrates into an angle that walks away without limit. Neither
11+
* is usable alone; their errors live in different parts of the spectrum, which is exactly the
12+
* situation this filter is for.
13+
*
14+
* <pre>
15+
* value &lt;- a * (value + rate * dt) + (1 - a) * reference
16+
* </pre>
17+
*
18+
* <p>Read as a pair of filters that add up to one, it is a high pass on the integrated rate and a low
19+
* pass on the absolute reading: the drift of the first is cut off below the corner frequency and the
20+
* noise of the second above it. The two transfer functions sum to unity at every frequency, so the
21+
* true signal passes through untouched whatever {@code a} is. That is where the name comes from, and
22+
* it is also why the filter cannot introduce a lag of its own the way a plain low pass on the
23+
* accelerometer would.
24+
*
25+
* <p>The single parameter is best thought of as a time constant rather than as a number near one:
26+
*
27+
* <pre>
28+
* tau = a * dt / (1 - a)
29+
* </pre>
30+
*
31+
* <p>Below {@code tau} the answer comes from the gyroscope, above it from the accelerometer. That
32+
* also fixes the price of the trade exactly: a gyroscope with a constant bias {@code b} leaves a
33+
* steady state error of {@code tau * b} and no more, where plain integration would have grown without
34+
* limit. Use {@link #ofTimeConstant(double, double)} to set it that way round.
35+
*
36+
* <p>Against {@link KalmanFilter}: the Kalman filter is the right answer when the noise of both
37+
* sensors is known and worth modelling, and it will beat this one when it is. The complementary
38+
* filter needs no covariance, no model of the process, two multiplications per sample and one number
39+
* of state, and it degrades gracefully when the noise is not what anybody assumed. That is why it is
40+
* what actually runs on small flight controllers.
41+
*
42+
* <h2>Usage</h2>
43+
*
44+
* <pre>{@code
45+
* ComplementaryFilter tilt = ComplementaryFilter.ofTimeConstant(0.5, 0.01);
46+
* for (Reading reading : imu) {
47+
* double angle = tilt.accept(reading.gyroscopeRate(), reading.accelerometerAngle(), reading.dt());
48+
* }
49+
* }</pre>
50+
*
51+
* <p>Each sample costs O(1) time and the filter keeps one number of state. This class is not
52+
* thread-safe.
53+
*
54+
* @see KalmanFilter
55+
* @see <a href="https://en.wikipedia.org/wiki/Complementary_filter">Complementary filter</a>
56+
*/
57+
public final class ComplementaryFilter {
58+
59+
/** Weight given to the integrated rate when none is chosen, the usual setting for an IMU. */
60+
public static final double DEFAULT_COEFFICIENT = 0.98;
61+
62+
private final double coefficient;
63+
64+
private double value;
65+
private long count;
66+
67+
/**
68+
* Creates a filter that leans on the rate with the customary weight of {@code 0.98}.
69+
*/
70+
public ComplementaryFilter() {
71+
this(DEFAULT_COEFFICIENT);
72+
}
73+
74+
/**
75+
* Creates a filter.
76+
*
77+
* @param coefficient how much of the estimate comes from the integrated rate, in {@code (0, 1)};
78+
* closer to one trusts the rate for longer, closer to zero follows the reference more quickly
79+
* @throws IllegalArgumentException if {@code coefficient} is outside {@code (0, 1)}
80+
*/
81+
public ComplementaryFilter(double coefficient) {
82+
if (!(coefficient > 0.0) || !(coefficient < 1.0)) {
83+
throw new IllegalArgumentException("The coefficient must lie in (0, 1), but was " + coefficient);
84+
}
85+
this.coefficient = coefficient;
86+
}
87+
88+
/**
89+
* Creates a filter from the time constant that separates the two sensors, which is usually the
90+
* quantity that is actually known: {@code a = tau / (tau + dt)}.
91+
*
92+
* @param timeConstant how long the rate is trusted before the reference takes over, strictly positive
93+
* @param samplingInterval the interval between samples, strictly positive and in the same unit
94+
* @return a new filter
95+
* @throws IllegalArgumentException if either argument is not finite and strictly positive
96+
*/
97+
public static ComplementaryFilter ofTimeConstant(double timeConstant, double samplingInterval) {
98+
requirePositive(timeConstant, "time constant");
99+
requirePositive(samplingInterval, "sampling interval");
100+
return new ComplementaryFilter(timeConstant / (timeConstant + samplingInterval));
101+
}
102+
103+
/**
104+
* Feeds one pair of readings taken one unit of time after the previous one.
105+
*
106+
* @param rate the reading of the drifting sensor, a derivative of the estimated quantity
107+
* @param reference the reading of the noisy but drift free sensor, in the unit of the estimate
108+
* @return the updated estimate
109+
* @throws IllegalArgumentException if a reading is NaN or infinite
110+
*/
111+
public double accept(double rate, double reference) {
112+
return accept(rate, reference, 1.0);
113+
}
114+
115+
/**
116+
* Feeds one pair of readings.
117+
*
118+
* @param rate the reading of the drifting sensor, a derivative of the estimated quantity
119+
* @param reference the reading of the noisy but drift free sensor, in the unit of the estimate
120+
* @param elapsed time since the previous pair, strictly positive
121+
* @return the updated estimate; the very first pair is answered with the reference alone, because
122+
* there is nothing yet to integrate from
123+
* @throws IllegalArgumentException if a reading is NaN or infinite, or if {@code elapsed} is not
124+
* finite and strictly positive
125+
*/
126+
public double accept(double rate, double reference, double elapsed) {
127+
requireFinite(rate, "rate");
128+
requireFinite(reference, "reference");
129+
requirePositive(elapsed, "elapsed time");
130+
131+
if (count == 0) {
132+
value = reference;
133+
} else {
134+
value = coefficient * (value + rate * elapsed) + (1.0 - coefficient) * reference;
135+
}
136+
count++;
137+
return value;
138+
}
139+
140+
/**
141+
* Runs the filter over a whole pair of recordings sampled at unit intervals.
142+
*
143+
* @param rates the readings of the drifting sensor
144+
* @param references the readings of the drift free sensor, as many as there are rates
145+
* @return a new array of the same length holding the estimate after every sample
146+
* @throws IllegalArgumentException if the two recordings differ in length or hold a reading that
147+
* is NaN or infinite
148+
* @throws NullPointerException if a recording is {@code null}
149+
*/
150+
public double[] scan(double[] rates, double[] references) {
151+
if (rates.length != references.length) {
152+
throw new IllegalArgumentException("Every rate needs a reference, but there were " + rates.length + " and " + references.length);
153+
}
154+
double[] estimates = new double[rates.length];
155+
for (int i = 0; i < rates.length; i++) {
156+
estimates[i] = accept(rates[i], references[i]);
157+
}
158+
return estimates;
159+
}
160+
161+
/**
162+
* Returns the current estimate.
163+
*
164+
* @return the estimate after the last pair of readings, {@code 0} before the first one
165+
*/
166+
public double value() {
167+
return value;
168+
}
169+
170+
/**
171+
* Returns the weight given to the integrated rate.
172+
*
173+
* @return the coefficient given at construction time
174+
*/
175+
public double coefficient() {
176+
return coefficient;
177+
}
178+
179+
/**
180+
* Returns the time constant the filter works out to at a given sampling interval, that is
181+
* {@code a * dt / (1 - a)}: the horizon below which the rate decides the answer and above which
182+
* the reference does.
183+
*
184+
* @param samplingInterval the interval between samples, strictly positive
185+
* @return the time constant, in the unit of the interval
186+
* @throws IllegalArgumentException if {@code samplingInterval} is not finite and strictly positive
187+
*/
188+
public double timeConstant(double samplingInterval) {
189+
requirePositive(samplingInterval, "sampling interval");
190+
return coefficient * samplingInterval / (1.0 - coefficient);
191+
}
192+
193+
/**
194+
* Returns how many pairs of readings have been filtered since the last reset.
195+
*
196+
* @return the sample count
197+
*/
198+
public long count() {
199+
return count;
200+
}
201+
202+
/**
203+
* Forgets everything seen so far, so that the next reference seeds the estimate again.
204+
*/
205+
public void reset() {
206+
value = 0.0;
207+
count = 0;
208+
}
209+
210+
/**
211+
* Restarts the filter from a known estimate, which is what to do after the process has been moved
212+
* by something the sensors could not see.
213+
*
214+
* @param estimate the value to carry on from
215+
* @throws IllegalArgumentException if {@code estimate} is NaN or infinite
216+
*/
217+
public void reset(double estimate) {
218+
requireFinite(estimate, "estimate");
219+
value = estimate;
220+
count = 1;
221+
}
222+
223+
@Override
224+
public String toString() {
225+
return "ComplementaryFilter{coefficient=" + coefficient + ", value=" + value + ", samples=" + count + "}";
226+
}
227+
228+
private static void requireFinite(double value, String name) {
229+
if (!Double.isFinite(value)) {
230+
throw new IllegalArgumentException("The " + name + " must be finite, but was " + value);
231+
}
232+
}
233+
234+
private static void requirePositive(double value, String name) {
235+
if (!(value > 0.0) || !Double.isFinite(value)) {
236+
throw new IllegalArgumentException("The " + name + " must be finite and strictly positive, but was " + value);
237+
}
238+
}
239+
}

0 commit comments

Comments
 (0)