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}