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///
13/// # Examples
14///
15/// ```
16/// use pamoja_kit::Trend;
17///
18/// // A tank level falling two units per reading.
19/// let mut level = Trend::<4>::new();
20/// for reading in [40.0, 38.0, 36.0, 34.0] {
21/// level.push(reading);
22/// }
23/// assert!((level.slope().unwrap() + 2.0).abs() < 1e-4);
24/// ```
25#[derive(Clone, Copy, Debug)]
26pub struct Trend<const N: usize> {
27 samples: [f32; N],
28 len: usize,
29 next: usize,
30}
31
32impl<const N: usize> Trend<N> {
33 /// Creates an empty trend tracker over a window of `N` readings.
34 ///
35 /// # Returns
36 ///
37 /// A tracker holding no readings yet.
38 pub fn new() -> Self {
39 Self {
40 samples: [0.0; N],
41 len: 0,
42 next: 0,
43 }
44 }
45
46 /// Adds a reading, evicting the oldest once the window is full.
47 ///
48 /// # Arguments
49 ///
50 /// * `reading` - the latest reading, taken one sample interval after the previous one.
51 pub fn push(&mut self, reading: f32) {
52 if N == 0 {
53 return;
54 }
55 self.samples[self.next] = reading;
56 self.next = (self.next + 1) % N;
57 if self.len < N {
58 self.len += 1;
59 }
60 }
61
62 /// Returns the trend slope in units per sample, or [`None`] with fewer than two readings.
63 ///
64 /// # Returns
65 ///
66 /// The least-squares slope of the window: positive when rising, negative when falling.
67 /// [`None`] until at least two readings are present, since a single point has no slope.
68 pub fn slope(&self) -> Option<f32> {
69 if self.len < 2 {
70 return None;
71 }
72 let count = self.len as f32;
73 let mean_x = (count - 1.0) / 2.0;
74 let mut mean_y = 0.0;
75 for i in 0..self.len {
76 mean_y += self.ordered(i);
77 }
78 mean_y /= count;
79 let mut covariance = 0.0;
80 let mut variance_x = 0.0;
81 for i in 0..self.len {
82 let dx = i as f32 - mean_x;
83 covariance += dx * (self.ordered(i) - mean_y);
84 variance_x += dx * dx;
85 }
86 if variance_x == 0.0 {
87 return None;
88 }
89 Some(covariance / variance_x)
90 }
91
92 /// Returns the number of readings currently held, at most `N`.
93 pub fn len(&self) -> usize {
94 self.len
95 }
96
97 /// Returns `true` if the tracker holds no readings.
98 pub fn is_empty(&self) -> bool {
99 self.len == 0
100 }
101
102 /// Returns the reading at time position `index`, where `0` is the oldest still held.
103 fn ordered(&self, index: usize) -> f32 {
104 let physical = if self.len == N {
105 (self.next + index) % N
106 } else {
107 index
108 };
109 self.samples[physical]
110 }
111}
112
113impl<const N: usize> Default for Trend<N> {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn approx(a: f32, b: f32) -> bool {
124 (a - b).abs() < 1e-4
125 }
126
127 #[test]
128 fn a_perfect_rising_line_has_its_exact_slope() {
129 // y = 2x + 1 at x = 0..=4.
130 let mut trend = Trend::<5>::new();
131 for reading in [1.0, 3.0, 5.0, 7.0, 9.0] {
132 trend.push(reading);
133 }
134 assert!(approx(trend.slope().unwrap(), 2.0));
135 }
136
137 #[test]
138 fn a_falling_line_has_a_negative_slope() {
139 let mut trend = Trend::<3>::new();
140 for reading in [10.0, 8.0, 6.0] {
141 trend.push(reading);
142 }
143 assert!(approx(trend.slope().unwrap(), -2.0));
144 }
145
146 #[test]
147 fn a_flat_signal_has_zero_slope() {
148 let mut trend = Trend::<4>::new();
149 for reading in [5.0, 5.0, 5.0, 5.0] {
150 trend.push(reading);
151 }
152 assert!(approx(trend.slope().unwrap(), 0.0));
153 }
154
155 #[test]
156 fn slope_matches_a_hand_computed_least_squares_fit() {
157 // y = [4, 5, 7, 10, 15] at x = 0..=4. mean_x = 2, mean_y = 8.2.
158 // sum dx*dy = 8.4 + 3.2 + 0 + 1.8 + 13.6 = 27; sum dx^2 = 10; slope = 2.7.
159 let mut trend = Trend::<5>::new();
160 for reading in [4.0, 5.0, 7.0, 10.0, 15.0] {
161 trend.push(reading);
162 }
163 assert!(approx(trend.slope().unwrap(), 2.7));
164 }
165
166 #[test]
167 fn fewer_than_two_readings_has_no_slope() {
168 let mut trend = Trend::<3>::new();
169 assert_eq!(trend.slope(), None);
170 trend.push(5.0);
171 assert_eq!(trend.slope(), None);
172 }
173
174 #[test]
175 fn the_slope_follows_the_window_as_it_slides() {
176 // Once it fills, only the last three readings count.
177 let mut trend = Trend::<3>::new();
178 for reading in [0.0, 0.0, 0.0, 10.0, 20.0, 30.0] {
179 trend.push(reading);
180 }
181 // The window holds 10, 20, 30 in order: slope 10 per sample.
182 assert!(approx(trend.slope().unwrap(), 10.0));
183 }
184}