Skip to main content

pamoja_kit/
trend.rs

1//! Measuring whether a value is trending up or down.
2
3/// Tracks the linear trend of the most recent `N` readings.
4///
5/// Is a value rising or falling, and how fast? A [`Trend`] fits a least-squares straight
6/// line to the last `N` readings, taken as evenly spaced in time, and reports its slope in
7/// units per sample: positive when rising, negative when falling, near zero when flat.
8/// Because the fit uses the whole window, a single noisy reading does not masquerade as a
9/// trend the way a bare difference between two samples can. The slope is the ordinary
10/// least-squares estimate, the sum of `(x - mean_x) * (y - mean_y)` over the sum of
11/// `(x - mean_x)` squared, with `x` taken as the sample index 0, 1, 2, and so on.
12/// [`with_capacity`](Trend::with_capacity) keeps fewer than `N`.
13///
14/// A reading that is not a finite number, such as the NaN a failed sensor reports, is not
15/// kept, so it cannot turn the slope into NaN for as long as it stays in the window.
16///
17/// # Examples
18///
19/// ```
20/// use pamoja_kit::Trend;
21///
22/// // A tank level falling two units per reading.
23/// let mut level = Trend::<4>::new();
24/// for reading in [40.0, 38.0, 36.0, 34.0] {
25///     level.push(reading);
26/// }
27/// assert!((level.slope().unwrap() + 2.0).abs() < 1e-4);
28/// ```
29#[derive(Clone, Copy, Debug)]
30pub struct Trend<const N: usize> {
31    samples: [f32; N],
32    capacity: usize,
33    len: usize,
34    next: usize,
35}
36
37impl<const N: usize> Trend<N> {
38    /// Creates an empty trend tracker over a window of `N` readings.
39    ///
40    /// # Returns
41    ///
42    /// A tracker holding no readings yet.
43    pub fn new() -> Self {
44        Self::with_capacity(N)
45    }
46
47    /// Creates an empty trend tracker that keeps at most `capacity` readings.
48    ///
49    /// The storage is still `N` readings; this is how a tracker sized at run time keeps
50    /// fewer.
51    ///
52    /// # Arguments
53    ///
54    /// * `capacity` - the most readings to keep. One above `N` is taken as `N`.
55    ///
56    /// # Returns
57    ///
58    /// A tracker holding no readings yet.
59    pub fn with_capacity(capacity: usize) -> Self {
60        Self {
61            samples: [0.0; N],
62            capacity: capacity.min(N),
63            len: 0,
64            next: 0,
65        }
66    }
67
68    /// Adds a reading, evicting the oldest once the window is full.
69    ///
70    /// # Arguments
71    ///
72    /// * `reading` - the latest reading, taken one sample interval after the previous one.
73    ///   One that is not a finite number is not kept.
74    pub fn push(&mut self, reading: f32) {
75        if self.capacity == 0 || !reading.is_finite() {
76            return;
77        }
78        self.samples[self.next] = reading;
79        self.next = (self.next + 1) % self.capacity;
80        if self.len < self.capacity {
81            self.len += 1;
82        }
83    }
84
85    /// Returns the most readings the tracker keeps: `N`, or less when made with
86    /// [`with_capacity`](Trend::with_capacity).
87    pub fn capacity(&self) -> usize {
88        self.capacity
89    }
90
91    /// Returns the trend slope in units per sample, or [`None`] with fewer than two readings.
92    ///
93    /// # Returns
94    ///
95    /// The least-squares slope of the window: positive when rising, negative when falling.
96    /// [`None`] until at least two readings are present, since a single point has no slope.
97    pub fn slope(&self) -> Option<f32> {
98        if self.len < 2 {
99            return None;
100        }
101        let count = self.len as f32;
102        let mean_x = (count - 1.0) / 2.0;
103        let mut mean_y = 0.0;
104        for i in 0..self.len {
105            mean_y += self.ordered(i);
106        }
107        mean_y /= count;
108        let mut covariance = 0.0;
109        let mut variance_x = 0.0;
110        for i in 0..self.len {
111            let dx = i as f32 - mean_x;
112            covariance += dx * (self.ordered(i) - mean_y);
113            variance_x += dx * dx;
114        }
115        if variance_x == 0.0 {
116            return None;
117        }
118        Some(covariance / variance_x)
119    }
120
121    /// Returns the number of readings currently held, at most the capacity.
122    pub fn len(&self) -> usize {
123        self.len
124    }
125
126    /// Returns `true` if the tracker holds no readings.
127    pub fn is_empty(&self) -> bool {
128        self.len == 0
129    }
130
131    /// Returns the reading at time position `index`, where `0` is the oldest still held.
132    fn ordered(&self, index: usize) -> f32 {
133        let physical = if self.len == self.capacity {
134            (self.next + index) % self.capacity
135        } else {
136            index
137        };
138        self.samples[physical]
139    }
140}
141
142impl<const N: usize> Default for Trend<N> {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn approx(a: f32, b: f32) -> bool {
153        (a - b).abs() < 1e-4
154    }
155
156    #[test]
157    fn a_perfect_rising_line_has_its_exact_slope() {
158        // y = 2x + 1 at x = 0..=4.
159        let mut trend = Trend::<5>::new();
160        for reading in [1.0, 3.0, 5.0, 7.0, 9.0] {
161            trend.push(reading);
162        }
163        assert!(approx(trend.slope().unwrap(), 2.0));
164    }
165
166    #[test]
167    fn a_falling_line_has_a_negative_slope() {
168        let mut trend = Trend::<3>::new();
169        for reading in [10.0, 8.0, 6.0] {
170            trend.push(reading);
171        }
172        assert!(approx(trend.slope().unwrap(), -2.0));
173    }
174
175    #[test]
176    fn a_flat_signal_has_zero_slope() {
177        let mut trend = Trend::<4>::new();
178        for reading in [5.0, 5.0, 5.0, 5.0] {
179            trend.push(reading);
180        }
181        assert!(approx(trend.slope().unwrap(), 0.0));
182    }
183
184    #[test]
185    fn slope_matches_a_hand_computed_least_squares_fit() {
186        // y = [4, 5, 7, 10, 15] at x = 0..=4. mean_x = 2, mean_y = 8.2.
187        // sum dx*dy = 8.4 + 3.2 + 0 + 1.8 + 13.6 = 27; sum dx^2 = 10; slope = 2.7.
188        let mut trend = Trend::<5>::new();
189        for reading in [4.0, 5.0, 7.0, 10.0, 15.0] {
190            trend.push(reading);
191        }
192        assert!(approx(trend.slope().unwrap(), 2.7));
193    }
194
195    #[test]
196    fn fewer_than_two_readings_has_no_slope() {
197        let mut trend = Trend::<3>::new();
198        assert_eq!(trend.slope(), None);
199        trend.push(5.0);
200        assert_eq!(trend.slope(), None);
201    }
202
203    #[test]
204    fn the_slope_follows_the_window_as_it_slides() {
205        // Once it fills, only the last three readings count.
206        let mut trend = Trend::<3>::new();
207        for reading in [0.0, 0.0, 0.0, 10.0, 20.0, 30.0] {
208            trend.push(reading);
209        }
210        // The window holds 10, 20, 30 in order: slope 10 per sample.
211        assert!(approx(trend.slope().unwrap(), 10.0));
212    }
213
214    #[test]
215    fn a_smaller_capacity_slides_the_same_way() {
216        let mut trend = Trend::<32>::with_capacity(3);
217        for reading in [0.0, 0.0, 0.0, 10.0, 20.0, 30.0] {
218            trend.push(reading);
219        }
220        assert!(approx(trend.slope().unwrap(), 10.0));
221        assert_eq!(trend.capacity(), 3);
222    }
223
224    #[test]
225    fn a_reading_that_is_not_a_number_is_not_kept() {
226        let mut trend = Trend::<4>::new();
227        for reading in [10.0, f32::NAN, 8.0, f32::NEG_INFINITY, 6.0] {
228            trend.push(reading);
229        }
230        assert_eq!(trend.len(), 3);
231        assert!(approx(trend.slope().unwrap(), -2.0));
232    }
233}