Skip to main content

pamoja_kit/
window.rs

1//! Keeping a rolling window of recent readings.
2
3/// A fixed-capacity window over the most recent `N` readings.
4///
5/// Many field decisions look not at the latest reading but at the recent run of them: the
6/// lowest battery voltage in the last minute, the average flow over the last ten samples,
7/// how widely a tank level is bouncing. A [`Window`] keeps the last `N` readings in a ring
8/// buffer - no allocation, so it runs on a microcontroller - and reports their spread. It
9/// is the base the forecasting helpers build on.
10///
11/// The population [`variance`](Window::variance) is given directly; the standard deviation
12/// is its square root, left to the caller so the type stays dependency-free. Capacity `N`
13/// should be at least one; a zero-capacity window simply holds nothing.
14/// [`with_capacity`](Window::with_capacity) keeps fewer than `N`, for a window sized at run
15/// time.
16///
17/// A reading that is not a finite number, such as the NaN a failed sensor reports, is not
18/// kept, so one bad reading cannot turn the mean into NaN for as long as it stays in the
19/// window.
20///
21/// # Examples
22///
23/// ```
24/// use pamoja_kit::Window;
25///
26/// // Keep the last four tank-level readings and read their spread.
27/// let mut levels = Window::<4>::new();
28/// for reading in [40.0, 42.0, 38.0, 41.0] {
29///     levels.push(reading);
30/// }
31/// assert!(levels.is_full());
32/// assert_eq!(levels.min(), Some(38.0));
33/// assert_eq!(levels.max(), Some(42.0));
34/// assert_eq!(levels.range(), Some(4.0));
35/// assert_eq!(levels.latest(), Some(41.0));
36/// ```
37#[derive(Clone, Copy, Debug)]
38pub struct Window<const N: usize> {
39    samples: [f32; N],
40    capacity: usize,
41    len: usize,
42    next: usize,
43}
44
45impl<const N: usize> Window<N> {
46    /// Creates an empty window with capacity `N`.
47    ///
48    /// # Returns
49    ///
50    /// A window holding no readings yet.
51    pub fn new() -> Self {
52        Self::with_capacity(N)
53    }
54
55    /// Creates an empty window that keeps at most `capacity` readings.
56    ///
57    /// The storage is still `N` readings; this is how a window sized at run time, such as
58    /// one a configuration chooses, keeps fewer.
59    ///
60    /// # Arguments
61    ///
62    /// * `capacity` - the most readings to keep. One above `N` is taken as `N`.
63    ///
64    /// # Returns
65    ///
66    /// A window holding no readings yet.
67    pub fn with_capacity(capacity: usize) -> Self {
68        Self {
69            samples: [0.0; N],
70            capacity: capacity.min(N),
71            len: 0,
72            next: 0,
73        }
74    }
75
76    /// Adds a reading, evicting the oldest once the window is full.
77    ///
78    /// # Arguments
79    ///
80    /// * `reading` - the value to record. One that is not a finite number is not kept.
81    pub fn push(&mut self, reading: f32) {
82        if self.capacity == 0 || !reading.is_finite() {
83            return;
84        }
85        self.samples[self.next] = reading;
86        self.next = (self.next + 1) % self.capacity;
87        if self.len < self.capacity {
88            self.len += 1;
89        }
90    }
91
92    /// Returns the number of readings currently held, at most the capacity.
93    pub fn len(&self) -> usize {
94        self.len
95    }
96
97    /// Returns `true` if the window holds no readings.
98    pub fn is_empty(&self) -> bool {
99        self.len == 0
100    }
101
102    /// Returns `true` if the window holds as many readings as its capacity.
103    pub fn is_full(&self) -> bool {
104        self.len == self.capacity
105    }
106
107    /// Returns the most readings the window keeps: `N`, or less when made with
108    /// [`with_capacity`](Window::with_capacity).
109    pub fn capacity(&self) -> usize {
110        self.capacity
111    }
112
113    /// Returns the most recent reading, or [`None`] if the window is empty.
114    pub fn latest(&self) -> Option<f32> {
115        if self.len == 0 {
116            return None;
117        }
118        Some(self.samples[(self.next + self.capacity - 1) % self.capacity])
119    }
120
121    /// Returns the oldest reading still held, or [`None`] if the window is empty.
122    pub fn oldest(&self) -> Option<f32> {
123        if self.len == 0 {
124            return None;
125        }
126        let index = if self.is_full() { self.next } else { 0 };
127        Some(self.samples[index])
128    }
129
130    /// Returns the smallest reading in the window, or [`None`] if it is empty.
131    pub fn min(&self) -> Option<f32> {
132        self.samples[..self.len].iter().copied().reduce(f32::min)
133    }
134
135    /// Returns the largest reading in the window, or [`None`] if it is empty.
136    pub fn max(&self) -> Option<f32> {
137        self.samples[..self.len].iter().copied().reduce(f32::max)
138    }
139
140    /// Returns the spread (largest minus smallest), or [`None`] if the window is empty.
141    pub fn range(&self) -> Option<f32> {
142        Some(self.max()? - self.min()?)
143    }
144
145    /// Returns the mean of the readings, or [`None`] if the window is empty.
146    pub fn mean(&self) -> Option<f32> {
147        if self.len == 0 {
148            return None;
149        }
150        let sum: f32 = self.samples[..self.len].iter().sum();
151        Some(sum / self.len as f32)
152    }
153
154    /// Returns the population variance of the readings, or [`None`] if the window is empty.
155    ///
156    /// # Returns
157    ///
158    /// The mean squared deviation from the mean. The standard deviation is its square root.
159    pub fn variance(&self) -> Option<f32> {
160        let mean = self.mean()?;
161        let sum_squared: f32 = self.samples[..self.len]
162            .iter()
163            .map(|reading| {
164                let deviation = reading - mean;
165                deviation * deviation
166            })
167            .sum();
168        Some(sum_squared / self.len as f32)
169    }
170}
171
172impl<const N: usize> Default for Window<N> {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn approx(a: f32, b: f32) -> bool {
183        (a - b).abs() < 1e-4
184    }
185
186    #[test]
187    fn an_empty_window_has_no_statistics() {
188        let window = Window::<3>::new();
189        assert!(window.is_empty());
190        assert_eq!(window.len(), 0);
191        assert_eq!(window.min(), None);
192        assert_eq!(window.max(), None);
193        assert_eq!(window.mean(), None);
194        assert_eq!(window.range(), None);
195        assert_eq!(window.variance(), None);
196        assert_eq!(window.latest(), None);
197        assert_eq!(window.oldest(), None);
198    }
199
200    #[test]
201    fn fills_to_capacity_then_stays_full() {
202        let mut window = Window::<3>::new();
203        window.push(1.0);
204        assert_eq!(window.len(), 1);
205        assert!(!window.is_full());
206        window.push(2.0);
207        window.push(3.0);
208        assert!(window.is_full());
209        window.push(4.0);
210        assert_eq!(window.len(), 3);
211        assert_eq!(window.capacity(), 3);
212    }
213
214    #[test]
215    fn reports_spread_over_the_held_readings() {
216        let mut window = Window::<4>::new();
217        for reading in [40.0, 42.0, 38.0, 41.0] {
218            window.push(reading);
219        }
220        assert_eq!(window.min(), Some(38.0));
221        assert_eq!(window.max(), Some(42.0));
222        assert_eq!(window.range(), Some(4.0));
223        assert!(approx(window.mean().unwrap(), 40.25));
224    }
225
226    #[test]
227    fn variance_matches_the_hand_computed_value() {
228        // Readings 2, 4, 6: mean 4, squared deviations 4 + 0 + 4 = 8, over 3 samples.
229        let mut window = Window::<3>::new();
230        for reading in [2.0, 4.0, 6.0] {
231            window.push(reading);
232        }
233        assert!(approx(window.variance().unwrap(), 8.0 / 3.0));
234    }
235
236    #[test]
237    fn the_oldest_reading_is_evicted_when_full() {
238        let mut window = Window::<3>::new();
239        for reading in [10.0, 20.0, 30.0, 40.0] {
240            window.push(reading);
241        }
242        // 10 has been pushed out; the window holds 20, 30, 40.
243        assert_eq!(window.oldest(), Some(20.0));
244        assert_eq!(window.latest(), Some(40.0));
245        assert_eq!(window.min(), Some(20.0));
246        assert_eq!(window.max(), Some(40.0));
247    }
248
249    #[test]
250    fn a_smaller_capacity_keeps_fewer_readings() {
251        let mut window = Window::<32>::with_capacity(3);
252        for reading in [10.0, 20.0, 30.0, 40.0] {
253            window.push(reading);
254        }
255        assert_eq!(window.capacity(), 3);
256        assert!(window.is_full());
257        assert_eq!(window.oldest(), Some(20.0));
258        assert_eq!(window.latest(), Some(40.0));
259        assert_eq!(Window::<4>::with_capacity(9).capacity(), 4);
260    }
261
262    #[test]
263    fn a_reading_that_is_not_a_number_is_not_kept() {
264        let mut window = Window::<4>::new();
265        window.push(2.0);
266        window.push(f32::NAN);
267        window.push(f32::INFINITY);
268        window.push(4.0);
269        assert_eq!(window.len(), 2);
270        assert_eq!(window.mean(), Some(3.0));
271        assert_eq!(window.max(), Some(4.0));
272    }
273
274    #[test]
275    fn latest_and_oldest_track_before_the_window_fills() {
276        let mut window = Window::<5>::new();
277        window.push(7.0);
278        assert_eq!(window.latest(), Some(7.0));
279        assert_eq!(window.oldest(), Some(7.0));
280        window.push(9.0);
281        assert_eq!(window.latest(), Some(9.0));
282        assert_eq!(window.oldest(), Some(7.0));
283    }
284}