pamoja_kit/trigger.rs
1//! Firing once when a reading crosses a line, and not again until it has come back.
2
3/// What a [`Trigger`] reports when a reading changes its state.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum Edge {
6 /// The reading just crossed the line: the condition became true.
7 Set,
8 /// The reading just came back past the release band: the condition stopped holding.
9 Cleared,
10}
11
12/// A threshold with hysteresis that reports the moment it is crossed.
13///
14/// This is the helper behind "when the tank drops below 20 percent": it holds a line,
15/// says once when a reading crosses it, and says once more when the reading has come
16/// back far enough to count. The hysteresis is the release band on the far side of the
17/// line, so a reading hovering at the threshold cannot fire the trigger over and over.
18/// Between the line and the release band the trigger holds its state.
19///
20/// A [`Thermostat`](crate::Thermostat) answers the same question as a level to hold;
21/// a trigger answers it as an event to act on, which is what a rule wants.
22///
23/// # Examples
24///
25/// ```
26/// use pamoja_kit::{Edge, Trigger};
27///
28/// // Water when the bed is drier than 30, and stop once it is wetter than 35.
29/// let mut dry = Trigger::below(30.0, 5.0);
30/// assert_eq!(dry.update(42.0), None); // well above the line
31/// assert_eq!(dry.update(28.0), Some(Edge::Set)); // crossed it: fire once
32/// assert_eq!(dry.update(33.0), None); // inside the release band: hold
33/// assert!(dry.is_set());
34/// assert_eq!(dry.update(36.0), Some(Edge::Cleared)); // past the band: clear once
35/// assert_eq!(dry.update(20.0), Some(Edge::Set)); // and it can fire again
36/// ```
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct Trigger {
39 threshold: f32,
40 hysteresis: f32,
41 above: bool,
42 set: bool,
43}
44
45impl Trigger {
46 /// Creates a trigger that fires when a reading rises above a line.
47 ///
48 /// It sets when the reading exceeds `threshold` and clears once the reading has
49 /// fallen below `threshold` less `hysteresis`.
50 ///
51 /// # Arguments
52 ///
53 /// * `threshold` - the line a rising reading crosses. One that is not a number never
54 /// fires.
55 /// * `hysteresis` - how far below the line the reading must fall to clear; its
56 /// magnitude is used, and one that is not a number is taken as zero.
57 ///
58 /// # Returns
59 ///
60 /// A trigger that starts cleared.
61 pub fn above(threshold: f32, hysteresis: f32) -> Self {
62 Self {
63 threshold,
64 hysteresis: magnitude(hysteresis),
65 above: true,
66 set: false,
67 }
68 }
69
70 /// Creates a trigger that fires when a reading falls below a line.
71 ///
72 /// It sets when the reading drops under `threshold` and clears once the reading
73 /// has risen above `threshold` plus `hysteresis`.
74 ///
75 /// # Arguments
76 ///
77 /// * `threshold` - the line a falling reading crosses. One that is not a number never
78 /// fires.
79 /// * `hysteresis` - how far above the line the reading must rise to clear; its
80 /// magnitude is used, and one that is not a number is taken as zero.
81 ///
82 /// # Returns
83 ///
84 /// A trigger that starts cleared.
85 pub fn below(threshold: f32, hysteresis: f32) -> Self {
86 Self {
87 threshold,
88 hysteresis: magnitude(hysteresis),
89 above: false,
90 set: false,
91 }
92 }
93
94 /// Feeds a reading in and reports whether it changed the trigger's state.
95 ///
96 /// A reading that is not a finite number, such as the NaN a failed sensor reports, is
97 /// ignored: it neither sets nor clears the trigger.
98 ///
99 /// # Arguments
100 ///
101 /// * `reading` - the latest measured value.
102 ///
103 /// # Returns
104 ///
105 /// [`Edge::Set`] the moment the reading crosses the line, [`Edge::Cleared`] the
106 /// moment it comes back past the release band, and `None` while nothing changed.
107 pub fn update(&mut self, reading: f32) -> Option<Edge> {
108 if !reading.is_finite() {
109 return None;
110 }
111 let crossed = if self.above {
112 reading > self.threshold
113 } else {
114 reading < self.threshold
115 };
116 let released = if self.above {
117 reading < self.threshold - self.hysteresis
118 } else {
119 reading > self.threshold + self.hysteresis
120 };
121 if crossed && !self.set {
122 self.set = true;
123 Some(Edge::Set)
124 } else if released && self.set {
125 self.set = false;
126 Some(Edge::Cleared)
127 } else {
128 None
129 }
130 }
131
132 /// Returns whether the condition currently holds.
133 ///
134 /// # Returns
135 ///
136 /// `true` from the reading that set the trigger until the one that clears it.
137 pub fn is_set(&self) -> bool {
138 self.set
139 }
140
141 /// Returns the line the trigger watches.
142 ///
143 /// # Returns
144 ///
145 /// The threshold it was created with.
146 pub fn threshold(&self) -> f32 {
147 self.threshold
148 }
149
150 /// Returns the release band on the far side of the line.
151 ///
152 /// # Returns
153 ///
154 /// The hysteresis it was created with, as a magnitude.
155 pub fn hysteresis(&self) -> f32 {
156 self.hysteresis
157 }
158
159 /// Returns whether the trigger watches a rising reading.
160 ///
161 /// # Returns
162 ///
163 /// `true` for [`above`](Trigger::above), `false` for [`below`](Trigger::below).
164 pub fn watches_above(&self) -> bool {
165 self.above
166 }
167}
168
169// The magnitude of a hysteresis, taken by hand because `f32::abs` lives in `std`, with one
170// that is not a number read as zero.
171fn magnitude(value: f32) -> f32 {
172 if value.is_nan() {
173 0.0
174 } else if value < 0.0 {
175 -value
176 } else {
177 value
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn a_falling_trigger_fires_once_and_clears_past_the_band() {
187 let mut dry = Trigger::below(30.0, 5.0);
188 assert!(!dry.is_set());
189 assert_eq!(dry.update(42.0), None);
190 assert_eq!(dry.update(31.0), None); // above the line: nothing yet
191 assert_eq!(dry.update(28.0), Some(Edge::Set));
192 assert_eq!(dry.update(25.0), None); // still under: no second event
193 assert_eq!(dry.update(33.0), None); // in the band: holds
194 assert_eq!(dry.update(36.0), Some(Edge::Cleared));
195 assert_eq!(dry.update(36.0), None); // still clear: no second event
196 assert_eq!(dry.update(29.9), Some(Edge::Set));
197 }
198
199 #[test]
200 fn a_rising_trigger_watches_the_other_way() {
201 let mut hot = Trigger::above(80.0, 3.0);
202 assert_eq!(hot.update(80.0), None); // on the line: not over it
203 assert_eq!(hot.update(80.1), Some(Edge::Set));
204 assert_eq!(hot.update(78.0), None); // inside the band
205 assert_eq!(hot.update(76.9), Some(Edge::Cleared));
206 assert!(hot.watches_above());
207 assert_eq!(hot.threshold(), 80.0);
208 assert_eq!(hot.hysteresis(), 3.0);
209 }
210
211 #[test]
212 fn a_negative_hysteresis_is_taken_by_magnitude() {
213 let mut level = Trigger::below(20.0, -2.0);
214 assert_eq!(level.hysteresis(), 2.0);
215 assert_eq!(level.update(19.0), Some(Edge::Set));
216 assert_eq!(level.update(21.0), None);
217 assert_eq!(level.update(22.5), Some(Edge::Cleared));
218 }
219
220 #[test]
221 fn no_hysteresis_still_fires_once_per_crossing() {
222 let mut line = Trigger::above(1.0, 0.0);
223 assert_eq!(line.update(1.5), Some(Edge::Set));
224 assert_eq!(line.update(1.7), None);
225 assert_eq!(line.update(1.0), None); // on the line: holds
226 assert_eq!(line.update(0.5), Some(Edge::Cleared));
227 }
228
229 #[test]
230 fn a_reading_that_is_not_a_number_neither_sets_nor_clears() {
231 let mut hot = Trigger::above(30.0, 2.0);
232 assert_eq!(hot.update(f32::INFINITY), None);
233 assert!(!hot.is_set());
234 assert_eq!(hot.update(31.0), Some(Edge::Set));
235 assert_eq!(hot.update(f32::NAN), None);
236 assert_eq!(hot.update(f32::NEG_INFINITY), None);
237 assert!(hot.is_set());
238 }
239
240 #[test]
241 fn a_hysteresis_that_is_not_a_number_is_taken_as_zero() {
242 let mut hot = Trigger::above(30.0, f32::NAN);
243 assert_eq!(hot.hysteresis(), 0.0);
244 assert_eq!(hot.update(31.0), Some(Edge::Set));
245 assert_eq!(hot.update(29.0), Some(Edge::Cleared));
246 }
247}