Skip to main content

pamoja_kit/
complementary.rs

1//! Fusing a fast rate sensor with a slow absolute one.
2
3/// Blends a drifting rate measurement with a noisy absolute one into a steady estimate.
4///
5/// A gyroscope gives a smooth rate of turn but drifts over time; an accelerometer gives an
6/// absolute tilt that is right on average but noisy. A complementary filter trusts the rate
7/// over the short term and the absolute reading over the long term, so the result is both
8/// smooth and drift-free. Each step it integrates the rate onto its estimate, then nudges
9/// that toward the absolute reading by an amount set by `alpha`. The same filter fuses any
10/// fast-rate-plus-slow-absolute pair, not only an IMU.
11///
12/// # Examples
13///
14/// ```
15/// use pamoja_kit::Complementary;
16///
17/// // Heavily trust the integrated rate, lightly correct toward the absolute reading.
18/// let mut tilt = Complementary::new(0.98, 0.0);
19/// // The rate reads +10 per second for 0.1 s while the absolute reads about 1.
20/// let angle = tilt.update(10.0, 1.0, 0.1);
21/// assert!((angle - 1.0).abs() < 0.05); // about 0.98 * 1 + 0.02 * 1
22/// ```
23#[derive(Clone, Copy, Debug)]
24pub struct Complementary {
25    estimate: f32,
26    alpha: f32,
27}
28
29impl Complementary {
30    /// Creates a filter.
31    ///
32    /// # Arguments
33    ///
34    /// * `alpha` - the weight on the integrated rate, in `[0.0, 1.0]`; near `1.0` trusts the
35    ///   rate and corrects slowly, near `0.0` follows the absolute reading. Clamped to the
36    ///   unit interval, and one that is not a number is taken as `0.0`, which follows the
37    ///   absolute reading since that one cannot drift.
38    /// * `initial` - the starting estimate.
39    ///
40    /// # Returns
41    ///
42    /// A filter seeded with `initial`.
43    pub fn new(alpha: f32, initial: f32) -> Self {
44        Self {
45            estimate: initial,
46            alpha: unit_interval(alpha),
47        }
48    }
49
50    /// Fuses a rate and an absolute reading over a time step and returns the new estimate.
51    ///
52    /// If the rate, the absolute reading, or the time step is not a finite number, such as the
53    /// NaN a failed sensor reports, the update is ignored and the estimate returned as it stood.
54    ///
55    /// # Arguments
56    ///
57    /// * `rate` - the rate of change, such as degrees per second from a gyroscope.
58    /// * `absolute` - the absolute reading, such as a tilt from an accelerometer.
59    /// * `dt` - the time since the previous update.
60    ///
61    /// # Returns
62    ///
63    /// The fused estimate, `alpha * (estimate + rate * dt) + (1 - alpha) * absolute`.
64    pub fn update(&mut self, rate: f32, absolute: f32, dt: f32) -> f32 {
65        if !rate.is_finite() || !absolute.is_finite() || !dt.is_finite() {
66            return self.estimate;
67        }
68        let integrated = self.estimate + rate * dt;
69        self.estimate = self.alpha * integrated + (1.0 - self.alpha) * absolute;
70        self.estimate
71    }
72
73    /// Returns the current fused estimate.
74    pub fn estimate(&self) -> f32 {
75        self.estimate
76    }
77}
78
79// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
80#[allow(clippy::manual_clamp)]
81fn unit_interval(value: f32) -> f32 {
82    if value.is_nan() || value < 0.0 {
83        0.0
84    } else if value > 1.0 {
85        1.0
86    } else {
87        value
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn alpha_one_integrates_the_rate_only() {
97        let mut filter = Complementary::new(1.0, 0.0);
98        assert!((filter.update(10.0, 999.0, 1.0) - 10.0).abs() < 1e-6); // ignores absolute
99        assert!((filter.update(10.0, 999.0, 1.0) - 20.0).abs() < 1e-6);
100    }
101
102    #[test]
103    fn alpha_zero_follows_the_absolute_reading() {
104        let mut filter = Complementary::new(0.0, 0.0);
105        assert!((filter.update(10.0, 3.0, 1.0) - 3.0).abs() < 1e-6);
106    }
107
108    #[test]
109    fn it_blends_rate_and_absolute() {
110        let mut filter = Complementary::new(0.9, 0.0);
111        // integrated = 0 + 2 * 1 = 2; 0.9 * 2 + 0.1 * 0 = 1.8
112        assert!((filter.update(2.0, 0.0, 1.0) - 1.8).abs() < 1e-6);
113    }
114
115    #[test]
116    fn alpha_is_clamped_to_the_unit_interval() {
117        let mut filter = Complementary::new(5.0, 0.0); // clamps to 1.0
118        assert!((filter.update(4.0, 100.0, 1.0) - 4.0).abs() < 1e-6);
119    }
120
121    #[test]
122    fn an_input_that_is_not_a_number_is_ignored() {
123        let mut tilt = Complementary::new(0.98, 0.0);
124        let settled = tilt.update(10.0, 1.0, 0.1);
125        assert_eq!(tilt.update(f32::NAN, 1.0, 0.1), settled);
126        assert_eq!(tilt.update(10.0, f32::INFINITY, 0.1), settled);
127        assert_eq!(tilt.update(10.0, 1.0, f32::NAN), settled);
128        assert!(tilt.update(10.0, 1.0, 0.1).is_finite());
129    }
130
131    #[test]
132    fn an_alpha_that_is_not_a_number_follows_the_absolute_reading() {
133        let mut tilt = Complementary::new(f32::NAN, 0.0);
134        assert_eq!(tilt.update(10.0, 3.0, 0.1), 3.0);
135    }
136}