Skip to main content

pamoja_profile/
control.rs

1//! The decision logic a profile assembles: turning a reading into a reaction.
2
3use std::collections::BTreeMap;
4
5use pamoja_core::{Error, Result};
6use pamoja_kit::{Depletion, Surge, Thermostat};
7
8use crate::{ControlSpec, Params};
9
10/// An alert raised when a reading crosses a profile's safety threshold.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub enum Alert {
13    /// A controlled reading drifted outside its safe band.
14    ///
15    /// For a cold-chain fridge this is a spoilage excursion: the cooler may be
16    /// running, but the contents are no longer within the safe temperature range.
17    OutOfRange {
18        /// The reading that triggered the alert.
19        reading: f32,
20    },
21    /// A falling level will reach its empty mark within this many more samples.
22    RunningOut {
23        /// The estimated number of samples until the level reaches empty.
24        samples: u32,
25    },
26    /// A reading is changing faster than its safe rate.
27    ///
28    /// For a river gauge this is a flash-flood warning: the level jumped further in
29    /// one sample than the profile allows.
30    ChangingFast {
31        /// The change since the previous sample, as a positive number.
32        rate: f32,
33    },
34    /// A reading that is not a finite number, such as the NaN a failed probe or a
35    /// garbled payload produces.
36    ///
37    /// Every built-in kind but a monitor raises it, since a node that goes quiet when its
38    /// sensor fails looks exactly like one whose readings are fine. The reading changes
39    /// nothing: a setpoint's output holds its last state, and a level or a surge carries
40    /// on from the last good reading.
41    InvalidReading {
42        /// The reading as it arrived.
43        reading: f32,
44    },
45    /// A condition a policy of your own raised, named by a code it chose.
46    ///
47    /// The code is a fixed identifier the policy's author picks, such as
48    /// `"FrostRisk"`, and the value is the measurement behind it. The bindings carry
49    /// it as they carry the built-in kinds, as the code and a number, so a custom
50    /// condition reads the same wherever the node's code is written.
51    Custom {
52        /// The condition's name, chosen by the policy that raises it.
53        code: &'static str,
54        /// The measurement behind the condition.
55        value: f32,
56    },
57}
58
59impl Alert {
60    /// Returns the name of the condition, without the measurement that triggered it.
61    ///
62    /// The bindings carry an alert as a kind and a value, so this is the same word in
63    /// every language, which is what lets one reading be logged or compared the same way
64    /// wherever the node's code is written.
65    ///
66    /// # Returns
67    ///
68    /// One of `"OutOfRange"`, `"RunningOut"`, `"ChangingFast"`, or `"InvalidReading"`,
69    /// or the code a custom alert was raised with.
70    pub fn kind(self) -> &'static str {
71        match self {
72            Alert::OutOfRange { .. } => "OutOfRange",
73            Alert::RunningOut { .. } => "RunningOut",
74            Alert::ChangingFast { .. } => "ChangingFast",
75            Alert::InvalidReading { .. } => "InvalidReading",
76            Alert::Custom { code, .. } => code,
77        }
78    }
79}
80
81/// The outcome of evaluating one reading against a policy.
82///
83/// A [`Controller`] issues an on/off command, so a reaction is `Reaction<bool>` by
84/// default; a policy of your own issues whatever its actuator takes.
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct Reaction<C = bool> {
87    /// The actuator setting this reading calls for, if the policy drives one.
88    ///
89    /// For a controller, `Some(true)` switches the output on, `Some(false)` switches
90    /// it off, and `None` means the profile observes without driving an output.
91    pub actuator: Option<C>,
92    /// An alert, if the reading crossed a threshold; `None` otherwise.
93    pub alert: Option<Alert>,
94}
95
96impl<C> Default for Reaction<C> {
97    fn default() -> Self {
98        Self {
99            actuator: None,
100            alert: None,
101        }
102    }
103}
104
105/// The decision half of a node: what one reading calls for.
106///
107/// A [`Controller`] is the policy behind every built-in kind, deciding an `f32`
108/// reading into an on/off command. A policy of your own decides whatever a driver
109/// reads, a whole measurement struct included, into whatever an actuator takes, and
110/// [`Node::with_policy`](crate::Node::with_policy) runs it in the same read, decide,
111/// act, publish loop. Evaluation is synchronous and hardware-free, so a policy is
112/// unit-testable with no devices and no network.
113///
114/// # Examples
115///
116/// ```
117/// use pamoja_profile::{Alert, Policy, Reaction};
118///
119/// // Frost settles when the air is cold and damp, so this reads both at once and
120/// // switches a heater, raising its own condition when the risk is on.
121/// struct FrostGuard {
122///     warn_below: f32,
123/// }
124///
125/// impl Policy for FrostGuard {
126///     type Reading = (f32, f32);
127///     type Command = bool;
128///
129///     fn evaluate(&mut self, &(temperature, humidity): &(f32, f32)) -> Reaction {
130///         let risk = temperature < self.warn_below && humidity > 80.0;
131///         Reaction {
132///             actuator: Some(risk),
133///             alert: risk.then_some(Alert::Custom { code: "FrostRisk", value: temperature }),
134///         }
135///     }
136/// }
137///
138/// let mut guard = FrostGuard { warn_below: 2.0 };
139/// let cold = guard.evaluate(&(1.0, 92.0));
140/// assert_eq!(cold.actuator, Some(true));
141/// assert_eq!(cold.alert.map(Alert::kind), Some("FrostRisk"));
142/// assert_eq!(guard.evaluate(&(1.0, 40.0)).alert, None);
143/// ```
144pub trait Policy {
145    /// What the policy decides on: a number, or a driver's whole measurement.
146    type Reading;
147    /// What the policy issues: an on/off setting, a duty, a setpoint.
148    type Command;
149
150    /// Decides what one reading calls for.
151    ///
152    /// # Arguments
153    ///
154    /// * `reading` - the latest reading, borrowed so the node can still publish it.
155    ///
156    /// # Returns
157    ///
158    /// The command to apply, if any, and any alert the reading raised.
159    fn evaluate(&mut self, reading: &Self::Reading) -> Reaction<Self::Command>;
160}
161
162impl<P: Policy + ?Sized> Policy for Box<P> {
163    type Reading = P::Reading;
164    type Command = P::Command;
165
166    fn evaluate(&mut self, reading: &Self::Reading) -> Reaction<Self::Command> {
167        (**self).evaluate(reading)
168    }
169}
170
171impl Policy for Controller {
172    type Reading = f32;
173    type Command = bool;
174
175    fn evaluate(&mut self, reading: &f32) -> Reaction {
176        Controller::evaluate(self, *reading)
177    }
178}
179
180/// A policy behind a trait object, as a [`PolicyRegistry`] hands one out.
181pub type BoxedPolicy<R = f32, C = bool> = Box<dyn Policy<Reading = R, Command = C> + Send>;
182
183/// The code that builds a custom kind's policy from the parameters its manifest carries.
184type Factory<R, C> = Box<dyn Fn(&Params) -> Result<BoxedPolicy<R, C>> + Send + Sync>;
185
186/// What resolves a manifest's control kind to the code that decides it.
187///
188/// The four built-in kinds resolve to a [`Controller`]. A kind pamoja never shipped
189/// resolves to the factory registered under its name, which reads the parameters the
190/// manifest carried beside the kind and builds the policy, so a fleet can share a
191/// manifest for a policy of its own the same way it shares one for a setpoint. A
192/// registry is generic over the reading and command its policies work in;
193/// [`resolve`](PolicyRegistry::resolve) exists for the `f32` reading and `bool` command
194/// the built-in kinds share, and [`custom`](PolicyRegistry::custom) for any other pair.
195///
196/// # Examples
197///
198/// ```
199/// use pamoja_core::Error;
200/// use pamoja_profile::{Alert, BoxedPolicy, Policy, PolicyRegistry, Profile, Reaction};
201///
202/// struct FrostGuard {
203///     warn_below: f32,
204/// }
205///
206/// impl Policy for FrostGuard {
207///     type Reading = f32;
208///     type Command = bool;
209///
210///     fn evaluate(&mut self, reading: &f32) -> Reaction {
211///         let cold = *reading < self.warn_below;
212///         Reaction {
213///             actuator: Some(cold),
214///             alert: cold.then_some(Alert::Custom { code: "FrostRisk", value: *reading }),
215///         }
216///     }
217/// }
218///
219/// // The manifest names a kind the library has never heard of, with its parameter
220/// // beside it, exactly as a built-in kind carries its own.
221/// let manifest = r#"{
222///     "name": "orchard-frost",
223///     "topic": "orchard/air/temperature",
224///     "control": { "kind": "frost_guard", "warn_below": 2.0 },
225///     "power": { "active_secs": 60, "saver_secs": 300, "critical_secs": 900 }
226/// }"#;
227/// let profile = Profile::from_json(manifest)?;
228///
229/// let registry = PolicyRegistry::new().register("frost_guard", |params| {
230///     let warn_below = params
231///         .number("warn_below")
232///         .ok_or(Error::Unsupported("frost_guard needs a `warn_below` parameter"))?;
233///     Ok(Box::new(FrostGuard { warn_below: warn_below as f32 }) as BoxedPolicy)
234/// });
235/// let mut policy = registry.resolve(&profile.control)?;
236/// assert_eq!(policy.evaluate(&1.0).alert.map(Alert::kind), Some("FrostRisk"));
237/// assert_eq!(policy.evaluate(&5.0).actuator, Some(false));
238///
239/// // A built-in kind resolves through the same registry.
240/// let mut fridge = registry.resolve(&Profile::vaccine_fridge_monitor().control)?;
241/// assert_eq!(fridge.evaluate(&9.0).actuator, Some(true));
242/// # Ok::<(), Error>(())
243/// ```
244pub struct PolicyRegistry<R = f32, C = bool> {
245    factories: BTreeMap<String, Factory<R, C>>,
246}
247
248impl<R, C> PolicyRegistry<R, C> {
249    /// Starts a registry with no custom kinds.
250    ///
251    /// # Returns
252    ///
253    /// A registry that resolves only what is registered on it.
254    pub fn new() -> Self {
255        Self {
256            factories: BTreeMap::new(),
257        }
258    }
259
260    /// Registers the factory for a custom kind, replacing one of the same name.
261    ///
262    /// # Arguments
263    ///
264    /// * `kind` - the kind as a manifest names it, such as `"frost_guard"`.
265    /// * `factory` - builds the policy from the parameters beside the kind, or says
266    ///   which parameter it needed.
267    ///
268    /// # Returns
269    ///
270    /// The registry, for chaining.
271    pub fn register<F>(mut self, kind: impl Into<String>, factory: F) -> Self
272    where
273        F: Fn(&Params) -> Result<BoxedPolicy<R, C>> + Send + Sync + 'static,
274    {
275        self.factories.insert(kind.into(), Box::new(factory));
276        self
277    }
278
279    /// Lists the custom kinds registered, in name order.
280    ///
281    /// # Returns
282    ///
283    /// Each kind's name.
284    pub fn kinds(&self) -> impl Iterator<Item = &str> {
285        self.factories.keys().map(String::as_str)
286    }
287
288    /// Builds the policy for a custom kind from its parameters.
289    ///
290    /// # Arguments
291    ///
292    /// * `kind` - the kind as a manifest names it.
293    /// * `params` - the parameters the manifest carried beside it.
294    ///
295    /// # Returns
296    ///
297    /// The policy, ready to evaluate readings.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`Error::Codec`] naming the kind when no factory is registered for it, or
302    /// whatever the factory returns when the parameters are not what it needs.
303    pub fn custom(&self, kind: &str, params: &Params) -> Result<BoxedPolicy<R, C>> {
304        match self.factories.get(kind) {
305            Some(factory) => factory(params),
306            None => Err(unresolved(kind, &self.kinds().collect::<Vec<_>>())),
307        }
308    }
309}
310
311/// Explains that a custom kind has no policy behind it, naming what it may have meant.
312fn unresolved(kind: &str, registered: &[&str]) -> Error {
313    let known = ControlSpec::BUILT_IN
314        .iter()
315        .copied()
316        .chain(registered.iter().copied());
317    let hint = match crate::format::nearest(kind, known) {
318        Some(nearest) => format!("; did you mean `{nearest}`?"),
319        None if registered.is_empty() => {
320            "; resolve the profile through a PolicyRegistry that registers it".to_owned()
321        }
322        None => format!("; the registry knows `{}`", registered.join("`, `")),
323    };
324    Error::Codec(format!(
325        "no policy decides the control kind `{kind}`, which is not built in{hint}"
326    ))
327}
328
329impl PolicyRegistry<f32, bool> {
330    /// Resolves a manifest's control kind: a built-in kind to its [`Controller`], a
331    /// custom kind to the policy its factory builds.
332    ///
333    /// # Arguments
334    ///
335    /// * `spec` - the control policy a profile carries.
336    ///
337    /// # Returns
338    ///
339    /// The policy, ready to evaluate readings.
340    ///
341    /// # Errors
342    ///
343    /// Returns [`Error::Codec`] naming the kind when it is custom and no factory is
344    /// registered for it, or whatever the factory returns.
345    pub fn resolve(&self, spec: &ControlSpec) -> Result<BoxedPolicy<f32, bool>> {
346        match spec {
347            ControlSpec::Custom { kind, params } => self.custom(kind, params),
348            built_in => Ok(Box::new(Controller::from_spec(built_in)?)),
349        }
350    }
351}
352
353impl<R, C> Default for PolicyRegistry<R, C> {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359impl<R, C> core::fmt::Debug for PolicyRegistry<R, C> {
360    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
361        f.debug_struct("PolicyRegistry")
362            .field("kinds", &self.factories.keys().collect::<Vec<_>>())
363            .finish()
364    }
365}
366
367// The live policy behind a `Controller`. It is private so the controller's public
368// surface stays its constructors and `evaluate`, not the kit helpers it wraps.
369#[derive(Clone, Copy, Debug)]
370enum Builtin {
371    Setpoint {
372        thermostat: Thermostat,
373        setpoint: f32,
374        safe_band: f32,
375    },
376    Level {
377        depletion: Depletion,
378        warn_within: u32,
379    },
380    Surge {
381        surge: Surge,
382    },
383    Monitor,
384}
385
386/// The assembled, stateful decision logic of a profile's built-in kind.
387///
388/// A controller is what a [`Profile`](crate::Profile) turns its
389/// [`ControlSpec`](crate::ControlSpec) into: the live loop that maps each reading to
390/// a [`Reaction`]. It composes the `pamoja-kit` helpers - a
391/// [`Thermostat`](pamoja_kit::Thermostat) for on/off control, a
392/// [`Depletion`](pamoja_kit::Depletion) predictor for level alerts, and a
393/// [`Surge`](pamoja_kit::Surge) alarm for rapid change - so the same field-tested
394/// math drives every profile. The logic is synchronous and
395/// hardware-free, so a profile's whole control policy is unit-testable with no
396/// devices and no network. It implements [`Policy`] over an `f32` reading and a
397/// `bool` command, which is what lets a policy of your own stand in its place.
398///
399/// # Examples
400///
401/// ```
402/// use pamoja_profile::{Alert, Controller};
403///
404/// // Hold a fridge near 5 C, alerting if it strays more than 3 C from target.
405/// let mut control = Controller::setpoint(5.0, 0.5, true, 3.0);
406///
407/// let reaction = control.evaluate(9.0); // warm and out of the safe band
408/// assert_eq!(reaction.actuator, Some(true));
409/// assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
410/// ```
411#[derive(Clone, Copy, Debug)]
412pub struct Controller {
413    policy: Builtin,
414}
415
416impl Controller {
417    /// Builds a controller that holds a reading near a setpoint.
418    ///
419    /// This is the policy behind "keep a temperature" and "keep the soil watered":
420    /// it switches an output on and off around the setpoint and raises an
421    /// [`Alert::OutOfRange`] when the reading strays beyond `safe_band`.
422    ///
423    /// # Arguments
424    ///
425    /// * `setpoint` - the target reading.
426    /// * `hysteresis` - half the deadband width around the setpoint, which stops the
427    ///   output chattering at the threshold.
428    /// * `cooling` - `true` for an output that switches on above the band (a cooler),
429    ///   `false` for one that switches on below it (a heater or an irrigation valve).
430    /// * `safe_band` - how far the reading may stray from the setpoint before an
431    ///   alert fires.
432    ///
433    /// # Returns
434    ///
435    /// A controller whose output starts off.
436    pub fn setpoint(setpoint: f32, hysteresis: f32, cooling: bool, safe_band: f32) -> Self {
437        let thermostat = if cooling {
438            Thermostat::cooling(setpoint, hysteresis)
439        } else {
440            Thermostat::heating(setpoint, hysteresis)
441        };
442        Self {
443            policy: Builtin::Setpoint {
444                thermostat,
445                setpoint,
446                safe_band,
447            },
448        }
449    }
450
451    /// Builds a controller that warns before a falling level runs out.
452    ///
453    /// This is the policy behind "warn before a tank runs dry": it watches a level
454    /// fall and raises an [`Alert::RunningOut`] once it is estimated to reach `empty`
455    /// within `warn_within` more samples.
456    ///
457    /// # Arguments
458    ///
459    /// * `empty` - the level treated as empty, such as a dry tank.
460    /// * `warn_within` - warn once empty is this many samples away or nearer.
461    ///
462    /// # Returns
463    ///
464    /// A controller awaiting its first two readings.
465    pub fn level(empty: f32, warn_within: u32) -> Self {
466        Self {
467            policy: Builtin::Level {
468                depletion: Depletion::new(empty),
469                warn_within,
470            },
471        }
472    }
473
474    /// Builds a controller that warns when a reading changes too fast.
475    ///
476    /// This is the policy behind "warn me before it is too late": it watches the
477    /// change between samples and raises an [`Alert::ChangingFast`] when a reading
478    /// moves more than `limit` per sample in the watched direction, such as a river
479    /// level rising into a flash flood.
480    ///
481    /// # Arguments
482    ///
483    /// * `rising` - watch a rapid rise (`true`) or a rapid fall (`false`).
484    /// * `limit` - the largest safe change per sample.
485    ///
486    /// # Returns
487    ///
488    /// A controller awaiting its first reading.
489    pub fn surge(rising: bool, limit: f32) -> Self {
490        let surge = if rising {
491            Surge::rising(limit)
492        } else {
493            Surge::falling(limit)
494        };
495        Self {
496            policy: Builtin::Surge { surge },
497        }
498    }
499
500    /// Builds a controller that reports readings without driving an output.
501    ///
502    /// # Returns
503    ///
504    /// A controller that never commands an actuator and never alerts.
505    pub fn monitor() -> Self {
506        Self {
507            policy: Builtin::Monitor,
508        }
509    }
510
511    /// Assembles the controller a manifest's control kind describes.
512    ///
513    /// # Arguments
514    ///
515    /// * `spec` - the control policy a profile carries.
516    ///
517    /// # Returns
518    ///
519    /// A fresh controller with its control state reset.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`Error::Codec`] for a custom kind, which has no built-in controller: its
524    /// policy is the code a [`PolicyRegistry`] resolves it to. Running one as a monitor
525    /// instead would leave a node that reports readings and never drives the output its
526    /// manifest promised.
527    pub fn from_spec(spec: &ControlSpec) -> Result<Self> {
528        Ok(match spec {
529            ControlSpec::Setpoint {
530                setpoint,
531                hysteresis,
532                cooling,
533                safe_band,
534            } => Controller::setpoint(*setpoint, *hysteresis, *cooling, *safe_band),
535            ControlSpec::Level { empty, warn_within } => Controller::level(*empty, *warn_within),
536            ControlSpec::Surge { rising, limit } => Controller::surge(*rising, *limit),
537            ControlSpec::Monitor => Controller::monitor(),
538            ControlSpec::Custom { kind, .. } => return Err(unresolved(kind, &[])),
539        })
540    }
541
542    /// Evaluates one reading and returns the action and any alert it calls for.
543    ///
544    /// A controller remembers what it has seen: a setpoint whether its output is on, a
545    /// level and a surge the previous reading. Keep one controller for the life of the
546    /// node and hand it every reading in turn.
547    ///
548    /// # Arguments
549    ///
550    /// * `reading` - the latest measured value, in real-world units. One that is not a
551    ///   finite number raises [`Alert::InvalidReading`] under every kind but a monitor,
552    ///   and changes nothing.
553    ///
554    /// # Returns
555    ///
556    /// The [`Reaction`] for this reading: the actuator setting (if the profile drives
557    /// one) and any alert the reading raised.
558    pub fn evaluate(&mut self, reading: f32) -> Reaction {
559        let invalid = (!reading.is_finite()).then_some(Alert::InvalidReading { reading });
560        match &mut self.policy {
561            Builtin::Setpoint {
562                thermostat,
563                setpoint,
564                safe_band,
565            } => {
566                let on = thermostat.update(reading);
567                let alert = invalid.or_else(|| {
568                    ((reading - *setpoint).abs() > *safe_band)
569                        .then_some(Alert::OutOfRange { reading })
570                });
571                Reaction {
572                    actuator: Some(on),
573                    alert,
574                }
575            }
576            Builtin::Level {
577                depletion,
578                warn_within,
579            } => {
580                let warn_within = *warn_within;
581                let alert = invalid.or_else(|| {
582                    depletion
583                        .update(reading)
584                        .filter(|samples| *samples <= warn_within)
585                        .map(|samples| Alert::RunningOut { samples })
586                });
587                Reaction {
588                    actuator: None,
589                    alert,
590                }
591            }
592            Builtin::Surge { surge } => {
593                let alert = invalid.or_else(|| {
594                    surge
595                        .update(reading)
596                        .map(|rate| Alert::ChangingFast { rate })
597                });
598                Reaction {
599                    actuator: None,
600                    alert,
601                }
602            }
603            Builtin::Monitor => Reaction::default(),
604        }
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn setpoint_switches_the_output_and_flags_excursions() {
614        let mut control = Controller::setpoint(5.0, 0.5, true, 3.0);
615
616        // Warm and beyond the safe band: cooler on, excursion flagged.
617        let hot = control.evaluate(9.0);
618        assert_eq!(hot.actuator, Some(true));
619        assert_eq!(hot.alert, Some(Alert::OutOfRange { reading: 9.0 }));
620
621        // Back in range: cooler still on (above the deadband), no alert.
622        let warm = control.evaluate(6.0);
623        assert_eq!(warm.actuator, Some(true));
624        assert_eq!(warm.alert, None);
625
626        // Below the deadband: cooler off, no alert.
627        let cold = control.evaluate(4.0);
628        assert_eq!(cold.actuator, Some(false));
629        assert_eq!(cold.alert, None);
630    }
631
632    #[test]
633    fn heating_setpoint_switches_on_below_the_band() {
634        // An irrigation valve adds water, so it is a "heater" for soil moisture.
635        let mut control = Controller::setpoint(35.0, 5.0, false, 25.0);
636        assert_eq!(control.evaluate(28.0).actuator, Some(true)); // dry: valve opens
637        assert_eq!(control.evaluate(42.0).actuator, Some(false)); // wet: valve closes
638    }
639
640    #[test]
641    fn level_warns_only_inside_the_window() {
642        let mut control = Controller::level(0.0, 3);
643        assert_eq!(control.evaluate(10.0).alert, None); // first reading: no rate yet
644        assert_eq!(control.evaluate(8.0).alert, None); // 4 samples out: outside window
645        assert_eq!(
646            control.evaluate(6.0).alert,
647            Some(Alert::RunningOut { samples: 3 })
648        ); // now within the window
649        assert_eq!(control.evaluate(6.0).actuator, None); // never drives an output
650    }
651
652    #[test]
653    fn a_reading_that_is_not_a_number_is_raised_and_changes_nothing() {
654        let mut heater = Controller::setpoint(32.0, 0.5, false, 4.0);
655        assert_eq!(heater.evaluate(27.5).actuator, Some(true));
656        let failed = heater.evaluate(f32::NAN);
657        assert_eq!(
658            failed.actuator,
659            Some(true),
660            "the output holds its last state"
661        );
662        assert!(
663            matches!(failed.alert, Some(Alert::InvalidReading { reading }) if reading.is_nan())
664        );
665        assert_eq!(
666            heater.evaluate(f32::INFINITY).alert,
667            Some(Alert::InvalidReading {
668                reading: f32::INFINITY
669            })
670        );
671
672        let mut well = Controller::level(0.0, 3);
673        well.evaluate(10.0);
674        assert_eq!(
675            well.evaluate(f32::NAN).alert.map(Alert::kind),
676            Some("InvalidReading")
677        );
678        assert_eq!(
679            well.evaluate(8.0).alert,
680            None,
681            "the fall is measured from 10"
682        );
683        assert_eq!(
684            well.evaluate(6.0).alert,
685            Some(Alert::RunningOut { samples: 3 })
686        );
687
688        let mut river = Controller::surge(true, 0.5);
689        river.evaluate(1.0);
690        assert_eq!(
691            river.evaluate(f32::NEG_INFINITY).alert.map(Alert::kind),
692            Some("InvalidReading")
693        );
694        assert_eq!(
695            river.evaluate(1.25).alert,
696            None,
697            "the rise is measured from 1"
698        );
699
700        assert_eq!(Controller::monitor().evaluate(f32::NAN).alert, None);
701    }
702
703    #[test]
704    fn surge_warns_on_a_rapid_rise_without_an_output() {
705        let mut control = Controller::surge(true, 0.5);
706        assert_eq!(control.evaluate(1.0).alert, None); // first reading: no rate yet
707        assert_eq!(control.evaluate(1.25).alert, None); // a gentle rise is fine
708        let flood = control.evaluate(2.0); // a 0.75 jump: too fast
709        assert_eq!(flood.alert, Some(Alert::ChangingFast { rate: 0.75 }));
710        assert_eq!(flood.actuator, None); // never drives an output
711    }
712
713    #[test]
714    fn monitor_is_inert() {
715        let mut control = Controller::monitor();
716        let reaction = control.evaluate(42.0);
717        assert_eq!(reaction, Reaction::default());
718    }
719
720    #[test]
721    fn a_controller_is_a_policy_over_a_number() {
722        let mut policy: BoxedPolicy = Box::new(Controller::setpoint(5.0, 0.5, true, 3.0));
723        assert_eq!(policy.evaluate(&9.0).actuator, Some(true));
724        assert_eq!(
725            Alert::Custom {
726                code: "FrostRisk",
727                value: 1.0
728            }
729            .kind(),
730            "FrostRisk"
731        );
732        assert_eq!(Reaction::<u8>::default().actuator, None);
733    }
734
735    // A policy over a whole measurement, deciding a duty rather than an on/off.
736    struct Fan {
737        above: f32,
738    }
739
740    impl Policy for Fan {
741        type Reading = (f32, f32);
742        type Command = u8;
743
744        fn evaluate(&mut self, &(temperature, humidity): &(f32, f32)) -> Reaction<u8> {
745            let hot = temperature > self.above;
746            Reaction {
747                actuator: Some(if hot { 100 } else { 0 }),
748                alert: (hot && humidity > 90.0).then_some(Alert::Custom {
749                    code: "Muggy",
750                    value: humidity,
751                }),
752            }
753        }
754    }
755
756    #[test]
757    fn a_registry_resolves_built_in_and_custom_kinds() {
758        let registry = PolicyRegistry::<(f32, f32), u8>::new().register("fan", |params| {
759            let above = params
760                .number("above")
761                .ok_or(Error::Unsupported("fan needs `above`"))?;
762            Ok(Box::new(Fan {
763                above: above as f32,
764            }) as BoxedPolicy<(f32, f32), u8>)
765        });
766        assert_eq!(registry.kinds().collect::<Vec<_>>(), ["fan"]);
767
768        let params = Params::new().with("above", 30.0);
769        let mut fan = registry.custom("fan", &params).expect("registered");
770        assert_eq!(fan.evaluate(&(35.0, 95.0)).actuator, Some(100));
771        assert_eq!(
772            fan.evaluate(&(35.0, 95.0)).alert.map(Alert::kind),
773            Some("Muggy")
774        );
775        assert!(matches!(
776            registry.custom("fan", &Params::new()),
777            Err(Error::Unsupported(_))
778        ));
779        let reason = |result: Result<BoxedPolicy<(f32, f32), u8>>| match result {
780            Err(Error::Codec(reason)) => reason,
781            Err(other) => panic!("expected a codec error, got {other}"),
782            Ok(_) => panic!("expected the kind to be refused"),
783        };
784        assert_eq!(
785            reason(registry.custom("heater", &params)),
786            "no policy decides the control kind `heater`, which is not built in; the registry knows `fan`"
787        );
788        assert_eq!(
789            reason(registry.custom("fam", &params)),
790            "no policy decides the control kind `fam`, which is not built in; did you mean `fan`?"
791        );
792
793        let scalar = PolicyRegistry::new();
794        let mut monitor = scalar
795            .resolve(&ControlSpec::Monitor)
796            .expect("a built-in kind resolves without a factory");
797        assert_eq!(monitor.evaluate(&1.0), Reaction::default());
798        let custom = ControlSpec::Custom {
799            kind: "heater".to_owned(),
800            params: Params::new(),
801        };
802        assert!(matches!(scalar.resolve(&custom), Err(Error::Codec(_))));
803        assert_eq!(format!("{scalar:?}"), "PolicyRegistry { kinds: [] }");
804    }
805
806    #[test]
807    fn a_custom_kind_is_refused_rather_than_run_as_a_monitor() {
808        let refused = |kind: &str| {
809            let spec = ControlSpec::Custom {
810                kind: kind.to_owned(),
811                params: Params::new(),
812            };
813            match Controller::from_spec(&spec) {
814                Err(Error::Codec(reason)) => reason,
815                other => panic!("expected {kind} to be refused, got {other:?}"),
816            }
817        };
818        assert_eq!(
819            refused("brooder_guard"),
820            "no policy decides the control kind `brooder_guard`, which is not built in; resolve the profile through a PolicyRegistry that registers it"
821        );
822        assert!(refused("levle").ends_with("did you mean `level`?"));
823    }
824}