Skip to main content

pamoja_kit/
median.rs

1//! A rolling median filter for rejecting spikes.
2
3/// A median filter over the most recent `N` readings.
4///
5/// A single bad sample - a spike from electrical noise or a flaky contact - drags a mean or
6/// an exponential average off course, because it is blended into the result. The median
7/// ignores it: one outlier cannot move the middle value of a sorted window. That makes a
8/// [`Median`] the right filter when the noise is occasional spikes rather than steady
9/// jitter; for steady jitter reach for [`Smoother`](crate::Smoother) instead. Keep the
10/// window small and odd (3, 5, 7) so there is a single middle reading; with an even `N` the
11/// median is the average of the two middle readings. A wide window rejects longer bursts
12/// and lags a real step by half its length, so a filter of 32 passes a new level 16
13/// readings late. [`with_capacity`](Median::with_capacity) keeps fewer than `N`.
14///
15/// A reading that is not a finite number, such as the NaN a failed sensor reports, is not
16/// kept.
17///
18/// # Examples
19///
20/// ```
21/// use pamoja_kit::Median;
22///
23/// let mut filtered = Median::<5>::new();
24/// // A lone spike among steady readings is rejected.
25/// for reading in [10.0, 10.0, 99.0, 10.0, 10.0] {
26///     filtered.update(reading);
27/// }
28/// assert_eq!(filtered.median(), Some(10.0));
29/// ```
30#[derive(Clone, Copy, Debug)]
31pub struct Median<const N: usize> {
32    samples: [f32; N],
33    capacity: usize,
34    len: usize,
35    next: usize,
36}
37
38impl<const N: usize> Median<N> {
39    /// Creates an empty median filter over a window of `N` readings.
40    ///
41    /// # Returns
42    ///
43    /// A filter holding no readings yet.
44    pub fn new() -> Self {
45        Self::with_capacity(N)
46    }
47
48    /// Creates an empty median filter that keeps at most `capacity` readings.
49    ///
50    /// The storage is still `N` readings; this is how a filter sized at run time keeps
51    /// fewer.
52    ///
53    /// # Arguments
54    ///
55    /// * `capacity` - the most readings to keep. One above `N` is taken as `N`.
56    ///
57    /// # Returns
58    ///
59    /// A filter holding no readings yet.
60    pub fn with_capacity(capacity: usize) -> Self {
61        Self {
62            samples: [0.0; N],
63            capacity: capacity.min(N),
64            len: 0,
65            next: 0,
66        }
67    }
68
69    /// Adds a reading and returns the median of the current window.
70    ///
71    /// # Arguments
72    ///
73    /// * `reading` - the latest raw reading.
74    ///
75    /// # Returns
76    ///
77    /// The median of the readings now in the window. With a zero-length window the reading
78    /// passes through unchanged, and so does one that is not a finite number while the
79    /// window is empty.
80    pub fn update(&mut self, reading: f32) -> f32 {
81        self.push(reading);
82        self.median().unwrap_or(reading)
83    }
84
85    /// Adds a reading to the window, evicting the oldest once it is full.
86    ///
87    /// # Arguments
88    ///
89    /// * `reading` - the latest raw reading. One that is not a finite number is not kept.
90    pub fn push(&mut self, reading: f32) {
91        if self.capacity == 0 || !reading.is_finite() {
92            return;
93        }
94        self.samples[self.next] = reading;
95        self.next = (self.next + 1) % self.capacity;
96        if self.len < self.capacity {
97            self.len += 1;
98        }
99    }
100
101    /// Returns the most readings the filter keeps: `N`, or less when made with
102    /// [`with_capacity`](Median::with_capacity).
103    pub fn capacity(&self) -> usize {
104        self.capacity
105    }
106
107    /// Returns the median of the readings in the window, or [`None`] if it is empty.
108    ///
109    /// # Returns
110    ///
111    /// The middle reading of the sorted window for an odd count, the average of the two
112    /// middle readings for an even count, or [`None`] before any reading.
113    pub fn median(&self) -> Option<f32> {
114        if self.len == 0 {
115            return None;
116        }
117        let mut sorted = [0.0f32; N];
118        sorted[..self.len].copy_from_slice(&self.samples[..self.len]);
119        let window = &mut sorted[..self.len];
120        window.sort_unstable_by(f32::total_cmp);
121        let mid = self.len / 2;
122        if self.len % 2 == 1 {
123            Some(window[mid])
124        } else {
125            Some((window[mid - 1] + window[mid]) / 2.0)
126        }
127    }
128
129    /// Returns the number of readings currently held, at most the capacity.
130    pub fn len(&self) -> usize {
131        self.len
132    }
133
134    /// Returns `true` if the filter holds no readings.
135    pub fn is_empty(&self) -> bool {
136        self.len == 0
137    }
138}
139
140impl<const N: usize> Default for Median<N> {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn odd_window_takes_the_middle_value() {
152        let mut median = Median::<3>::new();
153        median.update(3.0);
154        median.update(1.0);
155        assert_eq!(median.update(2.0), 2.0); // sorted [1, 2, 3] -> 2
156    }
157
158    #[test]
159    fn even_window_averages_the_two_middle_values() {
160        // The standard convention: the median of {1, 2, 3, 4} is (2 + 3) / 2 = 2.5.
161        let mut median = Median::<4>::new();
162        for reading in [1.0, 2.0, 3.0, 4.0] {
163            median.push(reading);
164        }
165        assert_eq!(median.median(), Some(2.5));
166    }
167
168    #[test]
169    fn a_single_spike_is_rejected() {
170        let mut median = Median::<5>::new();
171        for reading in [10.0, 10.0, 99.0, 10.0, 10.0] {
172            median.push(reading);
173        }
174        assert_eq!(median.median(), Some(10.0));
175    }
176
177    #[test]
178    fn an_empty_filter_has_no_median() {
179        let median = Median::<3>::new();
180        assert!(median.is_empty());
181        assert_eq!(median.median(), None);
182    }
183
184    #[test]
185    fn the_window_evicts_oldest_when_full() {
186        let mut median = Median::<3>::new();
187        for reading in [1.0, 2.0, 3.0, 100.0, 100.0] {
188            median.push(reading);
189        }
190        // The window holds the last three readings, 3, 100, 100: median 100.
191        assert_eq!(median.median(), Some(100.0));
192        assert_eq!(median.len(), 3);
193    }
194
195    #[test]
196    fn a_smaller_capacity_follows_a_step_sooner() {
197        let mut small = Median::<32>::with_capacity(3);
198        let mut wide = Median::<32>::new();
199        for _ in 0..10 {
200            small.update(10.0);
201            wide.update(10.0);
202        }
203        small.update(20.0);
204        wide.update(20.0);
205        assert_eq!(small.update(20.0), 20.0);
206        assert_eq!(wide.update(20.0), 10.0);
207        assert_eq!(small.capacity(), 3);
208    }
209
210    #[test]
211    fn a_reading_that_is_not_a_number_is_not_kept() {
212        let mut median = Median::<3>::new();
213        median.update(10.0);
214        assert_eq!(median.update(f32::NAN), 10.0);
215        assert_eq!(median.update(f32::INFINITY), 10.0);
216        assert_eq!(median.len(), 1);
217        assert!(Median::<3>::new().update(f32::NAN).is_nan());
218    }
219}