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", ¶ms).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", ¶ms)),
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", ¶ms)),
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}