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///
15/// # Examples
16///
17/// ```
18/// use pamoja_kit::Window;
19///
20/// // Keep the last four tank-level readings and read their spread.
21/// let mut levels = Window::<4>::new();
22/// for reading in [40.0, 42.0, 38.0, 41.0] {
23///     levels.push(reading);
24/// }
25/// assert!(levels.is_full());
26/// assert_eq!(levels.min(), Some(38.0));
27/// assert_eq!(levels.max(), Some(42.0));
28/// assert_eq!(levels.range(), Some(4.0));
29/// assert_eq!(levels.latest(), Some(41.0));
30/// ```
31#[derive(Clone, Copy, Debug)]
32pub struct Window<const N: usize> {
33    samples: [f32; N],
34    len: usize,
35    next: usize,
36}
37
38impl<const N: usize> Window<N> {
39    /// Creates an empty window with capacity `N`.
40    ///
41    /// # Returns
42    ///
43    /// A window holding no readings yet.
44    pub fn new() -> Self {
45        Self {
46            samples: [0.0; N],
47            len: 0,
48            next: 0,
49        }
50    }
51
52    /// Adds a reading, evicting the oldest once the window is full.
53    ///
54    /// # Arguments
55    ///
56    /// * `reading` - the value to record.
57    pub fn push(&mut self, reading: f32) {
58        if N == 0 {
59            return;
60        }
61        self.samples[self.next] = reading;
62        self.next = (self.next + 1) % N;
63        if self.len < N {
64            self.len += 1;
65        }
66    }
67
68    /// Returns the number of readings currently held, at most `N`.
69    pub fn len(&self) -> usize {
70        self.len
71    }
72
73    /// Returns `true` if the window holds no readings.
74    pub fn is_empty(&self) -> bool {
75        self.len == 0
76    }
77
78    /// Returns `true` if the window holds its full capacity of `N` readings.
79    pub fn is_full(&self) -> bool {
80        self.len == N
81    }
82
83    /// Returns the window's capacity, `N`.
84    pub fn capacity(&self) -> usize {
85        N
86    }
87
88    /// Returns the most recent reading, or [`None`] if the window is empty.
89    pub fn latest(&self) -> Option<f32> {
90        if self.len == 0 {
91            return None;
92        }
93        Some(self.samples[(self.next + N - 1) % N])
94    }
95
96    /// Returns the oldest reading still held, or [`None`] if the window is empty.
97    pub fn oldest(&self) -> Option<f32> {
98        if self.len == 0 {
99            return None;
100        }
101        let index = if self.is_full() { self.next } else { 0 };
102        Some(self.samples[index])
103    }
104
105    /// Returns the smallest reading in the window, or [`None`] if it is empty.
106    pub fn min(&self) -> Option<f32> {
107        self.samples[..self.len].iter().copied().reduce(f32::min)
108    }
109
110    /// Returns the largest reading in the window, or [`None`] if it is empty.
111    pub fn max(&self) -> Option<f32> {
112        self.samples[..self.len].iter().copied().reduce(f32::max)
113    }
114
115    /// Returns the spread (largest minus smallest), or [`None`] if the window is empty.
116    pub fn range(&self) -> Option<f32> {
117        Some(self.max()? - self.min()?)
118    }
119
120    /// Returns the mean of the readings, or [`None`] if the window is empty.
121    pub fn mean(&self) -> Option<f32> {
122        if self.len == 0 {
123            return None;
124        }
125        let sum: f32 = self.samples[..self.len].iter().sum();
126        Some(sum / self.len as f32)
127    }
128
129    /// Returns the population variance of the readings, or [`None`] if the window is empty.
130    ///
131    /// # Returns
132    ///
133    /// The mean squared deviation from the mean. The standard deviation is its square root.
134    pub fn variance(&self) -> Option<f32> {
135        let mean = self.mean()?;
136        let sum_squared: f32 = self.samples[..self.len]
137            .iter()
138            .map(|reading| {
139                let deviation = reading - mean;
140                deviation * deviation
141            })
142            .sum();
143        Some(sum_squared / self.len as f32)
144    }
145}
146
147impl<const N: usize> Default for Window<N> {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn approx(a: f32, b: f32) -> bool {
158        (a - b).abs() < 1e-4
159    }
160
161    #[test]
162    fn an_empty_window_has_no_statistics() {
163        let window = Window::<3>::new();
164        assert!(window.is_empty());
165        assert_eq!(window.len(), 0);
166        assert_eq!(window.min(), None);
167        assert_eq!(window.max(), None);
168        assert_eq!(window.mean(), None);
169        assert_eq!(window.range(), None);
170        assert_eq!(window.variance(), None);
171        assert_eq!(window.latest(), None);
172        assert_eq!(window.oldest(), None);
173    }
174
175    #[test]
176    fn fills_to_capacity_then_stays_full() {
177        let mut window = Window::<3>::new();
178        window.push(1.0);
179        assert_eq!(window.len(), 1);
180        assert!(!window.is_full());
181        window.push(2.0);
182        window.push(3.0);
183        assert!(window.is_full());
184        window.push(4.0);
185        assert_eq!(window.len(), 3);
186        assert_eq!(window.capacity(), 3);
187    }
188
189    #[test]
190    fn reports_spread_over_the_held_readings() {
191        let mut window = Window::<4>::new();
192        for reading in [40.0, 42.0, 38.0, 41.0] {
193            window.push(reading);
194        }
195        assert_eq!(window.min(), Some(38.0));
196        assert_eq!(window.max(), Some(42.0));
197        assert_eq!(window.range(), Some(4.0));
198        assert!(approx(window.mean().unwrap(), 40.25));
199    }
200
201    #[test]
202    fn variance_matches_the_hand_computed_value() {
203        // Readings 2, 4, 6: mean 4, squared deviations 4 + 0 + 4 = 8, over 3 samples.
204        let mut window = Window::<3>::new();
205        for reading in [2.0, 4.0, 6.0] {
206            window.push(reading);
207        }
208        assert!(approx(window.variance().unwrap(), 8.0 / 3.0));
209    }
210
211    #[test]
212    fn the_oldest_reading_is_evicted_when_full() {
213        let mut window = Window::<3>::new();
214        for reading in [10.0, 20.0, 30.0, 40.0] {
215            window.push(reading);
216        }
217        // 10 has been pushed out; the window holds 20, 30, 40.
218        assert_eq!(window.oldest(), Some(20.0));
219        assert_eq!(window.latest(), Some(40.0));
220        assert_eq!(window.min(), Some(20.0));
221        assert_eq!(window.max(), Some(40.0));
222    }
223
224    #[test]
225    fn latest_and_oldest_track_before_the_window_fills() {
226        let mut window = Window::<5>::new();
227        window.push(7.0);
228        assert_eq!(window.latest(), Some(7.0));
229        assert_eq!(window.oldest(), Some(7.0));
230        window.push(9.0);
231        assert_eq!(window.latest(), Some(9.0));
232        assert_eq!(window.oldest(), Some(7.0));
233    }
234}