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.
37    /// * `initial` - the starting estimate.
38    ///
39    /// # Returns
40    ///
41    /// A filter seeded with `initial`.
42    pub fn new(alpha: f32, initial: f32) -> Self {
43        Self {
44            estimate: initial,
45            alpha: unit_interval(alpha),
46        }
47    }
48
49    /// Fuses a rate and an absolute reading over a time step and returns the new estimate.
50    ///
51    /// # Arguments
52    ///
53    /// * `rate` - the rate of change, such as degrees per second from a gyroscope.
54    /// * `absolute` - the absolute reading, such as a tilt from an accelerometer.
55    /// * `dt` - the time since the previous update.
56    ///
57    /// # Returns
58    ///
59    /// The fused estimate, `alpha * (estimate + rate * dt) + (1 - alpha) * absolute`.
60    pub fn update(&mut self, rate: f32, absolute: f32, dt: f32) -> f32 {
61        let integrated = self.estimate + rate * dt;
62        self.estimate = self.alpha * integrated + (1.0 - self.alpha) * absolute;
63        self.estimate
64    }
65
66    /// Returns the current fused estimate.
67    pub fn estimate(&self) -> f32 {
68        self.estimate
69    }
70}
71
72// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
73#[allow(clippy::manual_clamp)]
74fn unit_interval(value: f32) -> f32 {
75    if value < 0.0 {
76        0.0
77    } else if value > 1.0 {
78        1.0
79    } else {
80        value
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn alpha_one_integrates_the_rate_only() {
90        let mut filter = Complementary::new(1.0, 0.0);
91        assert!((filter.update(10.0, 999.0, 1.0) - 10.0).abs() < 1e-6); // ignores absolute
92        assert!((filter.update(10.0, 999.0, 1.0) - 20.0).abs() < 1e-6);
93    }
94
95    #[test]
96    fn alpha_zero_follows_the_absolute_reading() {
97        let mut filter = Complementary::new(0.0, 0.0);
98        assert!((filter.update(10.0, 3.0, 1.0) - 3.0).abs() < 1e-6);
99    }
100
101    #[test]
102    fn it_blends_rate_and_absolute() {
103        let mut filter = Complementary::new(0.9, 0.0);
104        // integrated = 0 + 2 * 1 = 2; 0.9 * 2 + 0.1 * 0 = 1.8
105        assert!((filter.update(2.0, 0.0, 1.0) - 1.8).abs() < 1e-6);
106    }
107
108    #[test]
109    fn alpha_is_clamped_to_the_unit_interval() {
110        let mut filter = Complementary::new(5.0, 0.0); // clamps to 1.0
111        assert!((filter.update(4.0, 100.0, 1.0) - 4.0).abs() < 1e-6);
112    }
113}