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 metres, 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.
38    ///
39    /// # Returns
40    ///
41    /// An alarm awaiting its first reading.
42    pub fn rising(limit: f32) -> Self {
43        Self {
44            limit: magnitude(limit),
45            rising: true,
46            last: None,
47        }
48    }
49
50    /// Creates an alarm for a value falling too fast.
51    ///
52    /// # Arguments
53    ///
54    /// * `limit` - the largest safe decrease per sample; its magnitude is used.
55    ///
56    /// # Returns
57    ///
58    /// An alarm awaiting its first reading.
59    pub fn falling(limit: f32) -> Self {
60        Self {
61            limit: magnitude(limit),
62            rising: false,
63            last: None,
64        }
65    }
66
67    /// Records a reading and reports the rate if it changed too fast.
68    ///
69    /// # Arguments
70    ///
71    /// * `value` - the latest reading.
72    ///
73    /// # Returns
74    ///
75    /// `Some(rate)` for the change since the previous sample when it exceeds the limit
76    /// in the watched direction, where `rate` is that change as a positive number;
77    /// `None` if the change is within the limit, is in the other direction, or this is
78    /// the first reading.
79    pub fn update(&mut self, value: f32) -> Option<f32> {
80        let exceeded = match self.last {
81            Some(previous) => {
82                let change = value - previous;
83                let watched = if self.rising { change } else { -change };
84                if watched > self.limit {
85                    Some(watched)
86                } else {
87                    None
88                }
89            }
90            None => None,
91        };
92        self.last = Some(value);
93        exceeded
94    }
95}
96
97// `f32::abs` lives in `std`, so this `no_std` crate takes the magnitude by hand.
98fn magnitude(value: f32) -> f32 {
99    if value < 0.0 {
100        -value
101    } else {
102        value
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn the_first_reading_has_no_rate() {
112        let mut surge = Surge::rising(1.0);
113        assert_eq!(surge.update(5.0), None);
114    }
115
116    #[test]
117    fn a_rapid_rise_reports_its_rate() {
118        let mut surge = Surge::rising(0.5);
119        surge.update(1.0);
120        assert_eq!(surge.update(1.25), None); // within the limit
121        assert_eq!(surge.update(2.0), Some(0.75)); // over the limit
122    }
123
124    #[test]
125    fn a_rising_alarm_ignores_a_fall() {
126        let mut surge = Surge::rising(0.5);
127        surge.update(5.0);
128        assert_eq!(surge.update(1.0), None); // a big drop is not a rise
129    }
130
131    #[test]
132    fn a_falling_alarm_reports_a_rapid_drop() {
133        let mut surge = Surge::falling(0.5);
134        surge.update(3.0);
135        assert_eq!(surge.update(2.75), None); // a small drop is fine
136        assert_eq!(surge.update(1.0), Some(1.75)); // a steep drop
137    }
138
139    #[test]
140    fn a_negative_limit_is_treated_as_its_magnitude() {
141        let mut surge = Surge::rising(-0.5);
142        surge.update(1.0);
143        assert_eq!(surge.update(2.0), Some(1.0));
144    }
145}