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}