Skip to main content

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}