Skip to main content

pamoja_profile/
control.rs

1//! The decision logic a profile assembles: turning a reading into a reaction.
2
3use pamoja_kit::{Depletion, Surge, Thermostat};
4
5/// An alert raised when a reading crosses a profile's safety threshold.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub enum Alert {
8    /// A controlled reading drifted outside its safe band.
9    ///
10    /// For a cold-chain fridge this is a spoilage excursion: the cooler may be
11    /// running, but the contents are no longer within the safe temperature range.
12    OutOfRange {
13        /// The reading that triggered the alert.
14        reading: f32,
15    },
16    /// A falling level will reach its empty mark within this many more samples.
17    RunningOut {
18        /// The estimated number of samples until the level reaches empty.
19        samples: u32,
20    },
21    /// A reading is changing faster than its safe rate.
22    ///
23    /// For a river gauge this is a flash-flood warning: the level jumped further in
24    /// one sample than the profile allows.
25    ChangingFast {
26        /// The change since the previous sample, as a positive number.
27        rate: f32,
28    },
29}
30
31impl Alert {
32    /// Returns the name of the condition, without the measurement that triggered it.
33    ///
34    /// The bindings carry an alert as a kind and a value, so this is the same word in
35    /// every language, which is what lets one reading be logged or compared the same way
36    /// wherever the node's code is written.
37    ///
38    /// # Returns
39    ///
40    /// One of `"OutOfRange"`, `"RunningOut"`, or `"ChangingFast"`.
41    pub fn kind(self) -> &'static str {
42        match self {
43            Alert::OutOfRange { .. } => "OutOfRange",
44            Alert::RunningOut { .. } => "RunningOut",
45            Alert::ChangingFast { .. } => "ChangingFast",
46        }
47    }
48}
49
50/// The outcome of evaluating one reading against a profile's control policy.
51#[derive(Clone, Copy, Debug, Default, PartialEq)]
52pub struct Reaction {
53    /// The actuator setting this reading calls for, if the profile drives one.
54    ///
55    /// `Some(true)` switches the output on, `Some(false)` switches it off, and
56    /// `None` means the profile observes without driving an output.
57    pub actuator: Option<bool>,
58    /// An alert, if the reading crossed a profile threshold; `None` otherwise.
59    pub alert: Option<Alert>,
60}
61
62// The live policy behind a `Controller`. It is private so the controller's public
63// surface stays its constructors and `evaluate`, not the kit helpers it wraps.
64#[derive(Clone, Copy, Debug)]
65enum Policy {
66    Setpoint {
67        thermostat: Thermostat,
68        setpoint: f32,
69        safe_band: f32,
70    },
71    Level {
72        depletion: Depletion,
73        warn_within: u32,
74    },
75    Surge {
76        surge: Surge,
77    },
78    Monitor,
79}
80
81/// The assembled, stateful decision logic of a profile.
82///
83/// A controller is what a [`Profile`](crate::Profile) turns its
84/// [`ControlSpec`](crate::ControlSpec) into: the live loop that maps each reading to
85/// a [`Reaction`]. It composes the `pamoja-kit` helpers - a
86/// [`Thermostat`](pamoja_kit::Thermostat) for on/off control, a
87/// [`Depletion`](pamoja_kit::Depletion) predictor for level alerts, and a
88/// [`Surge`](pamoja_kit::Surge) alarm for rapid change - so the same field-tested
89/// math drives every profile. The logic is synchronous and
90/// hardware-free, so a profile's whole control policy is unit-testable with no
91/// devices and no network.
92///
93/// # Examples
94///
95/// ```
96/// use pamoja_profile::{Alert, Controller};
97///
98/// // Hold a fridge near 5 C, alerting if it strays more than 3 C from target.
99/// let mut control = Controller::setpoint(5.0, 0.5, true, 3.0);
100///
101/// let reaction = control.evaluate(9.0); // warm and out of the safe band
102/// assert_eq!(reaction.actuator, Some(true));
103/// assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
104/// ```
105#[derive(Clone, Copy, Debug)]
106pub struct Controller {
107    policy: Policy,
108}
109
110impl Controller {
111    /// Builds a controller that holds a reading near a setpoint.
112    ///
113    /// This is the policy behind "keep a temperature" and "keep the soil watered":
114    /// it switches an output on and off around the setpoint and raises an
115    /// [`Alert::OutOfRange`] when the reading strays beyond `safe_band`.
116    ///
117    /// # Arguments
118    ///
119    /// * `setpoint` - the target reading.
120    /// * `hysteresis` - half the deadband width around the setpoint, which stops the
121    ///   output chattering at the threshold.
122    /// * `cooling` - `true` for an output that switches on above the band (a cooler),
123    ///   `false` for one that switches on below it (a heater or an irrigation valve).
124    /// * `safe_band` - how far the reading may stray from the setpoint before an
125    ///   alert fires.
126    ///
127    /// # Returns
128    ///
129    /// A controller whose output starts off.
130    pub fn setpoint(setpoint: f32, hysteresis: f32, cooling: bool, safe_band: f32) -> Self {
131        let thermostat = if cooling {
132            Thermostat::cooling(setpoint, hysteresis)
133        } else {
134            Thermostat::heating(setpoint, hysteresis)
135        };
136        Self {
137            policy: Policy::Setpoint {
138                thermostat,
139                setpoint,
140                safe_band,
141            },
142        }
143    }
144
145    /// Builds a controller that warns before a falling level runs out.
146    ///
147    /// This is the policy behind "warn before a tank runs dry": it watches a level
148    /// fall and raises an [`Alert::RunningOut`] once it is estimated to reach `empty`
149    /// within `warn_within` more samples.
150    ///
151    /// # Arguments
152    ///
153    /// * `empty` - the level treated as empty, such as a dry tank.
154    /// * `warn_within` - warn once empty is this many samples away or nearer.
155    ///
156    /// # Returns
157    ///
158    /// A controller awaiting its first two readings.
159    pub fn level(empty: f32, warn_within: u32) -> Self {
160        Self {
161            policy: Policy::Level {
162                depletion: Depletion::new(empty),
163                warn_within,
164            },
165        }
166    }
167
168    /// Builds a controller that warns when a reading changes too fast.
169    ///
170    /// This is the policy behind "warn me before it is too late": it watches the
171    /// change between samples and raises an [`Alert::ChangingFast`] when a reading
172    /// moves more than `limit` per sample in the watched direction, such as a river
173    /// level rising into a flash flood.
174    ///
175    /// # Arguments
176    ///
177    /// * `rising` - watch a rapid rise (`true`) or a rapid fall (`false`).
178    /// * `limit` - the largest safe change per sample.
179    ///
180    /// # Returns
181    ///
182    /// A controller awaiting its first reading.
183    pub fn surge(rising: bool, limit: f32) -> Self {
184        let surge = if rising {
185            Surge::rising(limit)
186        } else {
187            Surge::falling(limit)
188        };
189        Self {
190            policy: Policy::Surge { surge },
191        }
192    }
193
194    /// Builds a controller that reports readings without driving an output.
195    ///
196    /// # Returns
197    ///
198    /// A controller that never commands an actuator and never alerts.
199    pub fn monitor() -> Self {
200        Self {
201            policy: Policy::Monitor,
202        }
203    }
204
205    /// Evaluates one reading and returns the action and any alert it calls for.
206    ///
207    /// # Arguments
208    ///
209    /// * `reading` - the latest measured value, in real-world units.
210    ///
211    /// # Returns
212    ///
213    /// The [`Reaction`] for this reading: the actuator setting (if the profile drives
214    /// one) and any alert the reading raised.
215    pub fn evaluate(&mut self, reading: f32) -> Reaction {
216        match &mut self.policy {
217            Policy::Setpoint {
218                thermostat,
219                setpoint,
220                safe_band,
221            } => {
222                let on = thermostat.update(reading);
223                let alert = if (reading - *setpoint).abs() > *safe_band {
224                    Some(Alert::OutOfRange { reading })
225                } else {
226                    None
227                };
228                Reaction {
229                    actuator: Some(on),
230                    alert,
231                }
232            }
233            Policy::Level {
234                depletion,
235                warn_within,
236            } => {
237                let warn_within = *warn_within;
238                let alert = depletion
239                    .update(reading)
240                    .filter(|samples| *samples <= warn_within)
241                    .map(|samples| Alert::RunningOut { samples });
242                Reaction {
243                    actuator: None,
244                    alert,
245                }
246            }
247            Policy::Surge { surge } => {
248                let alert = surge
249                    .update(reading)
250                    .map(|rate| Alert::ChangingFast { rate });
251                Reaction {
252                    actuator: None,
253                    alert,
254                }
255            }
256            Policy::Monitor => Reaction::default(),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn setpoint_switches_the_output_and_flags_excursions() {
267        let mut control = Controller::setpoint(5.0, 0.5, true, 3.0);
268
269        // Warm and beyond the safe band: cooler on, excursion flagged.
270        let hot = control.evaluate(9.0);
271        assert_eq!(hot.actuator, Some(true));
272        assert_eq!(hot.alert, Some(Alert::OutOfRange { reading: 9.0 }));
273
274        // Back in range: cooler still on (above the deadband), no alert.
275        let warm = control.evaluate(6.0);
276        assert_eq!(warm.actuator, Some(true));
277        assert_eq!(warm.alert, None);
278
279        // Below the deadband: cooler off, no alert.
280        let cold = control.evaluate(4.0);
281        assert_eq!(cold.actuator, Some(false));
282        assert_eq!(cold.alert, None);
283    }
284
285    #[test]
286    fn heating_setpoint_switches_on_below_the_band() {
287        // An irrigation valve adds water, so it is a "heater" for soil moisture.
288        let mut control = Controller::setpoint(35.0, 5.0, false, 25.0);
289        assert_eq!(control.evaluate(28.0).actuator, Some(true)); // dry: valve opens
290        assert_eq!(control.evaluate(42.0).actuator, Some(false)); // wet: valve closes
291    }
292
293    #[test]
294    fn level_warns_only_inside_the_window() {
295        let mut control = Controller::level(0.0, 3);
296        assert_eq!(control.evaluate(10.0).alert, None); // first reading: no rate yet
297        assert_eq!(control.evaluate(8.0).alert, None); // 4 samples out: outside window
298        assert_eq!(
299            control.evaluate(6.0).alert,
300            Some(Alert::RunningOut { samples: 3 })
301        ); // now within the window
302        assert_eq!(control.evaluate(6.0).actuator, None); // never drives an output
303    }
304
305    #[test]
306    fn surge_warns_on_a_rapid_rise_without_an_output() {
307        let mut control = Controller::surge(true, 0.5);
308        assert_eq!(control.evaluate(1.0).alert, None); // first reading: no rate yet
309        assert_eq!(control.evaluate(1.25).alert, None); // a gentle rise is fine
310        let flood = control.evaluate(2.0); // a 0.75 jump: too fast
311        assert_eq!(flood.alert, Some(Alert::ChangingFast { rate: 0.75 }));
312        assert_eq!(flood.actuator, None); // never drives an output
313    }
314
315    #[test]
316    fn monitor_is_inert() {
317        let mut control = Controller::monitor();
318        let reaction = control.evaluate(42.0);
319        assert_eq!(reaction, Reaction::default());
320    }
321}