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/// # 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}