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}