pamoja_kit/anomaly.rs
1//! Flagging a reading that departs from its recent history.
2
3use crate::Window;
4
5/// Flags a reading that lies far from the recent norm.
6///
7/// "Tell me when something is off" needs a baseline: a reading is suspicious only relative to
8/// what is usual. An [`Anomaly`] keeps a rolling window of recent readings and flags one that
9/// sits more than a chosen number of standard deviations from their mean - the three-sigma
10/// rule, the standard z-score test, with the threshold left to the caller (`3.0` is the
11/// usual choice). It is dependency-free: instead of taking a square root it compares the
12/// squared deviation against the squared threshold, which is the same test.
13///
14/// With a perfectly flat baseline the spread is zero, so any change at all reads as
15/// anomalous; real sensor noise gives a non-zero baseline, where this is not an issue.
16///
17/// A reading that is not a finite number, such as the NaN a failed sensor reports, is always
18/// flagged, and is kept out of the baseline so it cannot blind the detector to the readings
19/// after it.
20///
21/// # Examples
22///
23/// ```
24/// use pamoja_kit::Anomaly;
25///
26/// let mut watch = Anomaly::<8>::new(3.0);
27/// // Establish a steady baseline.
28/// for reading in [10.0, 10.2, 9.8, 10.1, 9.9, 10.0, 10.2, 9.8] {
29/// watch.check(reading);
30/// }
31/// assert!(!watch.check(10.1)); // close to the norm: fine
32/// assert!(watch.check(20.0)); // a far jump: flagged
33/// ```
34#[derive(Clone, Copy, Debug)]
35pub struct Anomaly<const N: usize> {
36 window: Window<N>,
37 sigmas: f32,
38}
39
40impl<const N: usize> Anomaly<N> {
41 /// Creates a detector that flags readings beyond `sigmas` standard deviations.
42 ///
43 /// # Arguments
44 ///
45 /// * `sigmas` - the threshold in standard deviations; `3.0` is the common three-sigma
46 /// rule. Its magnitude is used, and one that is not a number is taken as zero, so the
47 /// mistake shows as alarms rather than as silence.
48 ///
49 /// # Returns
50 ///
51 /// A detector with an empty history.
52 pub fn new(sigmas: f32) -> Self {
53 Self::with_capacity(sigmas, N)
54 }
55
56 /// Creates a detector whose baseline keeps at most `capacity` readings.
57 ///
58 /// # Arguments
59 ///
60 /// * `sigmas` - the threshold in standard deviations, as for [`new`](Anomaly::new).
61 /// * `capacity` - the most readings the baseline keeps. One above `N` is taken as `N`.
62 ///
63 /// # Returns
64 ///
65 /// A detector with an empty history.
66 pub fn with_capacity(sigmas: f32, capacity: usize) -> Self {
67 let sigmas = if sigmas.is_nan() {
68 0.0
69 } else if sigmas < 0.0 {
70 -sigmas
71 } else {
72 sigmas
73 };
74 Self {
75 window: Window::with_capacity(capacity),
76 sigmas,
77 }
78 }
79
80 /// Tests a reading against the recent norm, then folds it into the history.
81 ///
82 /// The reading is judged against the window of earlier readings, so the value being
83 /// tested does not inflate its own baseline. Until at least two readings have been seen
84 /// there is no spread to judge against, so nothing is flagged: from the third reading
85 /// on, a reading can be. A reading that is not a finite number is always flagged and is
86 /// not kept.
87 ///
88 /// # Arguments
89 ///
90 /// * `reading` - the latest reading.
91 ///
92 /// # Returns
93 ///
94 /// `true` if `reading` lies more than the configured standard deviations from the mean
95 /// of the recent window.
96 pub fn check(&mut self, reading: f32) -> bool {
97 if !reading.is_finite() {
98 return true;
99 }
100 let anomalous = match self.window.mean() {
101 Some(mean) if self.window.len() >= 2 => {
102 let variance = self.window.variance().unwrap_or(0.0);
103 let deviation = reading - mean;
104 deviation * deviation > self.sigmas * self.sigmas * variance
105 }
106 _ => false,
107 };
108 self.window.push(reading);
109 anomalous
110 }
111
112 /// Returns the number of readings in the baseline window so far.
113 pub fn len(&self) -> usize {
114 self.window.len()
115 }
116
117 /// Returns `true` if no readings have been recorded yet.
118 pub fn is_empty(&self) -> bool {
119 self.window.is_empty()
120 }
121
122 /// Returns the most readings the baseline keeps: `N`, or less when made with
123 /// [`with_capacity`](Anomaly::with_capacity).
124 pub fn capacity(&self) -> usize {
125 self.window.capacity()
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn nothing_is_flagged_until_there_is_a_baseline() {
135 let mut watch = Anomaly::<5>::new(3.0);
136 assert!(!watch.check(100.0)); // first reading: no baseline
137 assert!(!watch.check(0.0)); // only one prior reading: still none
138 assert_eq!(watch.len(), 2);
139 }
140
141 #[test]
142 fn flags_beyond_three_sigma_but_not_within() {
143 // A window of {1, -1, 1, -1} has mean 0 and population variance 1, so sigma = 1.
144 let baseline = [1.0, -1.0, 1.0, -1.0];
145
146 let mut inside = Anomaly::<4>::new(3.0);
147 for reading in baseline {
148 inside.check(reading);
149 }
150 assert!(!inside.check(2.9)); // 2.9 sigma: within
151
152 let mut outside = Anomaly::<4>::new(3.0);
153 for reading in baseline {
154 outside.check(reading);
155 }
156 assert!(outside.check(3.1)); // 3.1 sigma: beyond
157 }
158
159 #[test]
160 fn a_clear_outlier_is_flagged_after_a_noisy_baseline() {
161 let mut watch = Anomaly::<6>::new(3.0);
162 for reading in [50.0, 51.0, 49.0, 50.5, 49.5, 50.0] {
163 watch.check(reading);
164 }
165 assert!(!watch.check(50.5)); // an ordinary reading
166 assert!(watch.check(80.0)); // a far outlier
167 }
168
169 #[test]
170 fn a_reading_that_is_not_a_number_is_flagged_and_kept_out_of_the_baseline() {
171 let mut watch = Anomaly::<6>::new(3.0);
172 for reading in [50.0, 51.0, 49.0, 50.5, 49.5] {
173 watch.check(reading);
174 }
175 assert!(watch.check(f32::NAN));
176 assert!(watch.check(f32::INFINITY));
177 assert_eq!(watch.len(), 5);
178 assert!(!watch.check(50.5));
179 assert!(watch.check(80.0));
180 }
181
182 #[test]
183 fn a_smaller_capacity_forgets_sooner() {
184 let mut watch = Anomaly::<32>::with_capacity(3.0, 3);
185 for reading in [1.0, -1.0, 1.0, -1.0, 100.0, 101.0, 100.0] {
186 watch.check(reading);
187 }
188 assert_eq!(watch.capacity(), 3);
189 assert!(!watch.check(100.5));
190 }
191
192 #[test]
193 fn any_change_from_a_flat_baseline_is_flagged() {
194 let mut watch = Anomaly::<4>::new(3.0);
195 watch.check(5.0);
196 watch.check(5.0); // the baseline has zero spread
197 assert!(watch.check(6.0)); // so anything different stands out
198 }
199}