Skip to main content

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}