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.
34    ///
35    /// # Returns
36    ///
37    /// A smoother awaiting its first sample.
38    pub fn new(weight: f32) -> Self {
39        Self {
40            weight: unit_interval(weight),
41            value: None,
42        }
43    }
44
45    /// Folds a new sample into the average and returns the smoothed value.
46    ///
47    /// The first sample seeds the average and is returned unchanged.
48    ///
49    /// # Arguments
50    ///
51    /// * `sample` - the latest raw reading.
52    ///
53    /// # Returns
54    ///
55    /// The smoothed value after including `sample`.
56    pub fn update(&mut self, sample: f32) -> f32 {
57        let smoothed = match self.value {
58            Some(previous) => self.weight * sample + (1.0 - self.weight) * previous,
59            None => sample,
60        };
61        self.value = Some(smoothed);
62        smoothed
63    }
64
65    /// Returns the current smoothed value, or `None` before the first sample.
66    ///
67    /// # Returns
68    ///
69    /// `Some(value)` once at least one sample has been seen, otherwise `None`.
70    pub fn value(&self) -> Option<f32> {
71        self.value
72    }
73
74    /// Forgets the smoothed value so the next sample seeds the average afresh.
75    pub fn reset(&mut self) {
76        self.value = None;
77    }
78}
79
80// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
81#[allow(clippy::manual_clamp)]
82fn unit_interval(value: f32) -> f32 {
83    if value < 0.0 {
84        0.0
85    } else if value > 1.0 {
86        1.0
87    } else {
88        value
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn first_sample_seeds_the_average() {
98        let mut smoother = Smoother::new(0.5);
99        assert_eq!(smoother.update(10.0), 10.0);
100        assert_eq!(smoother.value(), Some(10.0));
101    }
102
103    #[test]
104    fn blends_toward_newer_samples() {
105        let mut smoother = Smoother::new(0.5);
106        smoother.update(0.0);
107        assert!((smoother.update(10.0) - 5.0).abs() < 1e-6);
108        assert!((smoother.update(10.0) - 7.5).abs() < 1e-6);
109    }
110
111    #[test]
112    fn weight_is_clamped_into_the_unit_interval() {
113        let mut smoother = Smoother::new(2.0); // clamps to 1.0: no smoothing
114        smoother.update(1.0);
115        assert!((smoother.update(9.0) - 9.0).abs() < 1e-6);
116    }
117
118    #[test]
119    fn reset_forgets_the_value() {
120        let mut smoother = Smoother::new(0.5);
121        smoother.update(4.0);
122        smoother.reset();
123        assert_eq!(smoother.value(), None);
124        assert_eq!(smoother.update(8.0), 8.0);
125    }
126}