Skip to main content

pamoja_kit/
surge.rs

1//! Catching a value that moves dangerously fast.
2
3/// Warns when a reading changes faster than a safe rate.
4///
5/// This is the primitive behind "warn me before it is too late": a river level
6/// rising fast enough to mean a flash flood, a gas reading spiking toward an
7/// explosive level, or a tank pressure collapsing. Feed it successive readings and
8/// it reports the rate whenever the change since the previous sample, in the
9/// direction being watched, exceeds a limit. The technique one layer down is a
10/// first difference between consecutive samples, so a noisy signal pairs well with a
11/// [`Smoother`](crate::Smoother) on the input to avoid false alarms.
12///
13/// # Examples
14///
15/// ```
16/// use pamoja_kit::Surge;
17///
18/// // A river gauge in meters, sampled each minute: alarm if it rises faster than
19/// // 0.5 m per sample.
20/// let mut flood = Surge::rising(0.5);
21/// assert_eq!(flood.update(1.0), None); // first reading: no rate yet
22/// assert_eq!(flood.update(1.25), None); // a gentle rise is fine
23/// assert_eq!(flood.update(2.0), Some(0.75)); // a 0.75 m jump: a flash flood
24/// ```
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct Surge {
27    limit: f32,
28    rising: bool,
29    last: Option<f32>,
30}
31
32impl Surge {
33    /// Creates an alarm for a value rising too fast.
34    ///
35    /// # Arguments
36    ///
37    /// * `limit` - the largest safe increase per sample; its magnitude is used, and one that
38    ///   is not a number is taken as zero, so every rise is reported.
39    ///
40    /// # Returns
41    ///
42    /// An alarm awaiting its first reading.
43    pub fn rising(limit: f32) -> Self {
44        Self {
45            limit: magnitude(limit),
46            rising: true,
47            last: None,
48        }
49    }
50
51    /// Creates an alarm for a value falling too fast.
52    ///
53    /// # Arguments
54    ///
55    /// * `limit` - the largest safe decrease per sample; its magnitude is used, and one that
56    ///   is not a number is taken as zero, so every fall is reported.
57    ///
58    /// # Returns
59    ///
60    /// An alarm awaiting its first reading.
61    pub fn falling(limit: f32) -> Self {
62        Self {
63            limit: magnitude(limit),
64            rising: false,
65            last: None,
66        }
67    }
68
69    /// Records a reading and reports the rate if it changed too fast.
70    ///
71    /// A reading that is not a finite number, such as the NaN a failed sensor reports, is
72    /// ignored: it reports nothing and is not the reading the next one is compared with.
73    ///
74    /// # Arguments
75    ///
76    /// * `value` - the latest reading.
77    ///
78    /// # Returns
79    ///
80    /// `Some(rate)` for the change since the previous sample when it exceeds the limit
81    /// in the watched direction, where `rate` is that change as a positive number;
82    /// `None` if the change is within the limit, is in the other direction, or this is
83    /// the first reading.
84    pub fn update(&mut self, value: f32) -> Option<f32> {
85        if !value.is_finite() {
86            return None;
87        }
88        let exceeded = match self.last {
89            Some(previous) => {
90                let change = value - previous;
91                let watched = if self.rising { change } else { -change };
92                if watched > self.limit {
93                    Some(watched)
94                } else {
95                    None
96                }
97            }
98            None => None,
99        };
100        self.last = Some(value);
101        exceeded
102    }
103}
104
105// The magnitude of a limit, taken by hand because `f32::abs` lives in `std`, with one that
106// is not a number read as zero.
107fn magnitude(value: f32) -> f32 {
108    if value.is_nan() {
109        0.0
110    } else if value < 0.0 {
111        -value
112    } else {
113        value
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn the_first_reading_has_no_rate() {
123        let mut surge = Surge::rising(1.0);
124        assert_eq!(surge.update(5.0), None);
125    }
126
127    #[test]
128    fn a_rapid_rise_reports_its_rate() {
129        let mut surge = Surge::rising(0.5);
130        surge.update(1.0);
131        assert_eq!(surge.update(1.25), None); // within the limit
132        assert_eq!(surge.update(2.0), Some(0.75)); // over the limit
133    }
134
135    #[test]
136    fn a_rising_alarm_ignores_a_fall() {
137        let mut surge = Surge::rising(0.5);
138        surge.update(5.0);
139        assert_eq!(surge.update(1.0), None); // a big drop is not a rise
140    }
141
142    #[test]
143    fn a_falling_alarm_reports_a_rapid_drop() {
144        let mut surge = Surge::falling(0.5);
145        surge.update(3.0);
146        assert_eq!(surge.update(2.75), None); // a small drop is fine
147        assert_eq!(surge.update(1.0), Some(1.75)); // a steep drop
148    }
149
150    #[test]
151    fn a_negative_limit_is_treated_as_its_magnitude() {
152        let mut surge = Surge::rising(-0.5);
153        surge.update(1.0);
154        assert_eq!(surge.update(2.0), Some(1.0));
155    }
156
157    #[test]
158    fn a_reading_that_is_not_a_number_is_ignored() {
159        let mut flood = Surge::rising(0.5);
160        flood.update(1.0);
161        assert_eq!(flood.update(f32::NAN), None);
162        assert_eq!(flood.update(f32::INFINITY), None);
163        assert_eq!(flood.update(1.25), None);
164        assert_eq!(flood.update(2.0), Some(0.75));
165    }
166
167    #[test]
168    fn a_limit_that_is_not_a_number_reports_every_rise() {
169        let mut flood = Surge::rising(f32::NAN);
170        flood.update(1.0);
171        assert_eq!(flood.update(1.5), Some(0.5));
172        assert_eq!(flood.update(1.0), None);
173    }
174
175    #[test]
176    fn a_change_equal_to_the_limit_is_not_reported() {
177        let mut flood = Surge::rising(0.5);
178        flood.update(1.0);
179        assert_eq!(flood.update(1.5), None);
180    }
181}