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}