Skip to main content

pamoja_kit/
smoothing.rs

1//! Smoothing a noisy reading.
2
3/// Smooths a noisy signal with an exponential moving average.
4///
5/// Cheap sensors are noisy, and a single bad sample should not trip an alarm or
6/// flip an actuator. A [`Smoother`] dampens that jitter. The technique one layer
7/// down is an exponential moving average: each output is a weighted blend of the
8/// newest sample and the previous output, so recent readings count for more
9/// without storing a history buffer.
10///
11/// # Examples
12///
13/// ```
14/// use pamoja_kit::Smoother;
15///
16/// let mut smoother = Smoother::new(0.5);
17/// assert_eq!(smoother.update(10.0), 10.0); // the first sample seeds the average
18/// assert_eq!(smoother.update(0.0), 5.0); // then each output blends halfway
19/// ```
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct Smoother {
22    weight: f32,
23    value: Option<f32>,
24}
25
26impl Smoother {
27    /// Creates a smoother with the given responsiveness.
28    ///
29    /// # Arguments
30    ///
31    /// * `weight` - how much the newest sample counts, clamped to `[0.0, 1.0]`.
32    ///   `1.0` disables smoothing so the output follows the input; values near
33    ///   `0.0` smooth heavily and react slowly. A weight that is not a number is
34    ///   taken as `1.0`.
35    ///
36    /// # Returns
37    ///
38    /// A smoother awaiting its first sample.
39    pub fn new(weight: f32) -> Self {
40        Self {
41            weight: unit_interval(weight),
42            value: None,
43        }
44    }
45
46    /// Folds a new sample into the average and returns the smoothed value.
47    ///
48    /// The first sample seeds the average and is returned unchanged. A sample that is not a
49    /// finite number, such as the NaN a failed sensor reports, is ignored: the average stays
50    /// where it was and is returned as it stood.
51    ///
52    /// # Arguments
53    ///
54    /// * `sample` - the latest raw reading.
55    ///
56    /// # Returns
57    ///
58    /// The smoothed value after including `sample`, or the sample itself when it is not a
59    /// finite number and no real one has arrived yet.
60    pub fn update(&mut self, sample: f32) -> f32 {
61        if !sample.is_finite() {
62            return self.value.unwrap_or(sample);
63        }
64        let smoothed = match self.value {
65            Some(previous) => self.weight * sample + (1.0 - self.weight) * previous,
66            None => sample,
67        };
68        self.value = Some(smoothed);
69        smoothed
70    }
71
72    /// Returns the current smoothed value, or `None` before the first sample.
73    ///
74    /// # Returns
75    ///
76    /// `Some(value)` once at least one sample has been seen, otherwise `None`.
77    pub fn value(&self) -> Option<f32> {
78        self.value
79    }
80
81    /// Forgets the smoothed value so the next sample seeds the average afresh.
82    pub fn reset(&mut self) {
83        self.value = None;
84    }
85}
86
87// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
88#[allow(clippy::manual_clamp)]
89fn unit_interval(value: f32) -> f32 {
90    if value.is_nan() || value > 1.0 {
91        1.0
92    } else if value < 0.0 {
93        0.0
94    } else {
95        value
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn first_sample_seeds_the_average() {
105        let mut smoother = Smoother::new(0.5);
106        assert_eq!(smoother.update(10.0), 10.0);
107        assert_eq!(smoother.value(), Some(10.0));
108    }
109
110    #[test]
111    fn blends_toward_newer_samples() {
112        let mut smoother = Smoother::new(0.5);
113        smoother.update(0.0);
114        assert!((smoother.update(10.0) - 5.0).abs() < 1e-6);
115        assert!((smoother.update(10.0) - 7.5).abs() < 1e-6);
116    }
117
118    #[test]
119    fn weight_is_clamped_into_the_unit_interval() {
120        let mut smoother = Smoother::new(2.0); // clamps to 1.0: no smoothing
121        smoother.update(1.0);
122        assert!((smoother.update(9.0) - 9.0).abs() < 1e-6);
123    }
124
125    #[test]
126    fn a_sample_that_is_not_a_number_leaves_the_average_where_it_was() {
127        let mut smoother = Smoother::new(0.5);
128        smoother.update(10.0);
129        assert_eq!(smoother.update(f32::NAN), 10.0);
130        assert_eq!(smoother.update(f32::INFINITY), 10.0);
131        assert_eq!(smoother.update(0.0), 5.0);
132        assert!(Smoother::new(0.5).update(f32::NAN).is_nan());
133    }
134
135    #[test]
136    fn a_weight_that_is_not_a_number_follows_the_input() {
137        let mut smoother = Smoother::new(f32::NAN);
138        smoother.update(1.0);
139        assert_eq!(smoother.update(9.0), 9.0);
140    }
141
142    #[test]
143    fn reset_forgets_the_value() {
144        let mut smoother = Smoother::new(0.5);
145        smoother.update(4.0);
146        smoother.reset();
147        assert_eq!(smoother.value(), None);
148        assert_eq!(smoother.update(8.0), 8.0);
149    }
150}