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}