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}