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/// # Examples
18///
19/// ```
20/// use pamoja_kit::Anomaly;
21///
22/// let mut watch = Anomaly::<8>::new(3.0);
23/// // Establish a steady baseline.
24/// for reading in [10.0, 10.2, 9.8, 10.1, 9.9, 10.0, 10.2, 9.8] {
25/// watch.check(reading);
26/// }
27/// assert!(!watch.check(10.1)); // close to the norm: fine
28/// assert!(watch.check(20.0)); // a far jump: flagged
29/// ```
30#[derive(Clone, Copy, Debug)]
31pub struct Anomaly<const N: usize> {
32 window: Window<N>,
33 sigmas: f32,
34}
35
36impl<const N: usize> Anomaly<N> {
37 /// Creates a detector that flags readings beyond `sigmas` standard deviations.
38 ///
39 /// # Arguments
40 ///
41 /// * `sigmas` - the threshold in standard deviations; `3.0` is the common three-sigma
42 /// rule. Its magnitude is used.
43 ///
44 /// # Returns
45 ///
46 /// A detector with an empty history.
47 pub fn new(sigmas: f32) -> Self {
48 Self {
49 window: Window::new(),
50 sigmas: if sigmas < 0.0 { -sigmas } else { sigmas },
51 }
52 }
53
54 /// Tests a reading against the recent norm, then folds it into the history.
55 ///
56 /// The reading is judged against the window of earlier readings, so the value being
57 /// tested does not inflate its own baseline. Until at least two readings have been seen
58 /// there is no spread to judge against, so nothing is flagged.
59 ///
60 /// # Arguments
61 ///
62 /// * `reading` - the latest reading.
63 ///
64 /// # Returns
65 ///
66 /// `true` if `reading` lies more than the configured standard deviations from the mean
67 /// of the recent window.
68 pub fn check(&mut self, reading: f32) -> bool {
69 let anomalous = match self.window.mean() {
70 Some(mean) if self.window.len() >= 2 => {
71 let variance = self.window.variance().unwrap_or(0.0);
72 let deviation = reading - mean;
73 deviation * deviation > self.sigmas * self.sigmas * variance
74 }
75 _ => false,
76 };
77 self.window.push(reading);
78 anomalous
79 }
80
81 /// Returns the number of readings in the baseline window so far.
82 pub fn len(&self) -> usize {
83 self.window.len()
84 }
85
86 /// Returns `true` if no readings have been recorded yet.
87 pub fn is_empty(&self) -> bool {
88 self.window.is_empty()
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn nothing_is_flagged_until_there_is_a_baseline() {
98 let mut watch = Anomaly::<5>::new(3.0);
99 assert!(!watch.check(100.0)); // first reading: no baseline
100 assert!(!watch.check(0.0)); // only one prior reading: still none
101 assert_eq!(watch.len(), 2);
102 }
103
104 #[test]
105 fn flags_beyond_three_sigma_but_not_within() {
106 // A window of {1, -1, 1, -1} has mean 0 and population variance 1, so sigma = 1.
107 let baseline = [1.0, -1.0, 1.0, -1.0];
108
109 let mut inside = Anomaly::<4>::new(3.0);
110 for reading in baseline {
111 inside.check(reading);
112 }
113 assert!(!inside.check(2.9)); // 2.9 sigma: within
114
115 let mut outside = Anomaly::<4>::new(3.0);
116 for reading in baseline {
117 outside.check(reading);
118 }
119 assert!(outside.check(3.1)); // 3.1 sigma: beyond
120 }
121
122 #[test]
123 fn a_clear_outlier_is_flagged_after_a_noisy_baseline() {
124 let mut watch = Anomaly::<6>::new(3.0);
125 for reading in [50.0, 51.0, 49.0, 50.5, 49.5, 50.0] {
126 watch.check(reading);
127 }
128 assert!(!watch.check(50.5)); // an ordinary reading
129 assert!(watch.check(80.0)); // a far outlier
130 }
131
132 #[test]
133 fn any_change_from_a_flat_baseline_is_flagged() {
134 let mut watch = Anomaly::<4>::new(3.0);
135 watch.check(5.0);
136 watch.check(5.0); // the baseline has zero spread
137 assert!(watch.check(6.0)); // so anything different stands out
138 }
139}