Skip to main content

pamoja_profile/
profile.rs

1//! The profile manifest and its named, ready-to-run presets.
2//!
3//! A profile is data: a [`Profile`] serializes to and from a manifest a community
4//! can write by hand, store in a file, and share. The presets here are convenience
5//! constructors for the same data, not a closed set - any manifest that names a
6//! [`ControlSpec`] and a [`PowerSchedule`] is a valid profile.
7
8use core::time::Duration;
9
10use pamoja_power::PowerPlan;
11use serde::{Deserialize, Serialize};
12
13use crate::{Controller, Presentation};
14
15/// How a profile turns each reading into control output and alerts.
16///
17/// This is the policy half of a profile's manifest: the tunable rule a community can
18/// publish and share, with no code to write. [`Profile::controller`] assembles it
19/// into a live [`Controller`]. In a manifest it is tagged by `kind`:
20///
21/// ```json
22/// { "kind": "setpoint", "setpoint": 5.0, "hysteresis": 0.5, "cooling": true, "safe_band": 3.0 }
23/// ```
24#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "kind", rename_all = "snake_case")]
26pub enum ControlSpec {
27    /// Hold a reading near `setpoint` by switching an output on and off.
28    Setpoint {
29        /// The target reading, such as 5 C for a vaccine fridge.
30        setpoint: f32,
31        /// Half the deadband width around the setpoint, which stops the output
32        /// chattering at the threshold.
33        hysteresis: f32,
34        /// Whether the output cools (switches on above the band) or heats (switches
35        /// on below it). An irrigation valve that adds water is a "heater".
36        cooling: bool,
37        /// How far the reading may stray from the setpoint before an
38        /// [`Alert::OutOfRange`](crate::Alert::OutOfRange) fires.
39        safe_band: f32,
40    },
41    /// Watch a falling level and warn before it reaches `empty`.
42    Level {
43        /// The level treated as empty, such as a dry tank.
44        empty: f32,
45        /// Warn once the level is estimated to reach `empty` within this many more
46        /// samples.
47        warn_within: u32,
48    },
49    /// Warn when a reading changes faster than `limit` per sample.
50    Surge {
51        /// Watch a rapid rise (`true`) or a rapid fall (`false`).
52        rising: bool,
53        /// The largest safe change per sample.
54        limit: f32,
55    },
56    /// Report readings only, with no control output and no alerts.
57    Monitor,
58}
59
60/// How often a node samples as its battery drains, in plain seconds.
61///
62/// This is the serializable form of a [`PowerPlan`](pamoja_power::PowerPlan): a
63/// manifest carries the three work intervals as whole seconds and the two
64/// state-of-charge thresholds, and [`plan`](PowerSchedule::plan) assembles the
65/// `pamoja-power` governor from them. The thresholds may be omitted from a manifest,
66/// in which case they default to entering the saver cadence below 50% charge and the
67/// critical cadence below 20%.
68#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
69pub struct PowerSchedule {
70    /// Seconds between samples at a healthy charge.
71    pub active_secs: u64,
72    /// Seconds between samples while conserving.
73    pub saver_secs: u64,
74    /// Seconds between samples when critically low.
75    pub critical_secs: u64,
76    /// Enter the saver cadence below this state of charge.
77    #[serde(default = "PowerSchedule::default_saver_below")]
78    pub saver_below: f32,
79    /// Enter the critical cadence below this state of charge.
80    #[serde(default = "PowerSchedule::default_critical_below")]
81    pub critical_below: f32,
82}
83
84impl PowerSchedule {
85    fn default_saver_below() -> f32 {
86        0.5
87    }
88
89    fn default_critical_below() -> f32 {
90        0.2
91    }
92
93    /// Creates a schedule from its three work intervals, with default thresholds.
94    ///
95    /// # Arguments
96    ///
97    /// * `active_secs` - seconds between samples at a healthy charge.
98    /// * `saver_secs` - seconds between samples while conserving.
99    /// * `critical_secs` - seconds between samples when critically low.
100    ///
101    /// # Returns
102    ///
103    /// A schedule that enters the saver cadence below 50% charge and the critical
104    /// cadence below 20%.
105    pub fn new(active_secs: u64, saver_secs: u64, critical_secs: u64) -> Self {
106        Self {
107            active_secs,
108            saver_secs,
109            critical_secs,
110            saver_below: Self::default_saver_below(),
111            critical_below: Self::default_critical_below(),
112        }
113    }
114
115    /// Sets the state-of-charge thresholds for entering each lower cadence.
116    ///
117    /// # Arguments
118    ///
119    /// * `saver_below` - enter the saver cadence when charge is below this.
120    /// * `critical_below` - enter the critical cadence when charge is below this,
121    ///   normally lower than `saver_below`.
122    ///
123    /// # Returns
124    ///
125    /// The updated schedule, for chaining.
126    pub fn with_thresholds(mut self, saver_below: f32, critical_below: f32) -> Self {
127        self.saver_below = saver_below;
128        self.critical_below = critical_below;
129        self
130    }
131
132    /// Assembles the `pamoja-power` governor this schedule describes.
133    ///
134    /// # Returns
135    ///
136    /// A [`PowerPlan`](pamoja_power::PowerPlan) with this schedule's intervals and
137    /// thresholds.
138    pub fn plan(&self) -> PowerPlan {
139        PowerPlan::new(
140            Duration::from_secs(self.active_secs),
141            Duration::from_secs(self.saver_secs),
142            Duration::from_secs(self.critical_secs),
143        )
144        .thresholds(self.saver_below, self.critical_below)
145    }
146}
147
148/// A named, pre-wired bundle of control policy, publish topic, and power schedule.
149///
150/// A profile is the unit a builder instantiates instead of wiring pins and tuning
151/// constants, and it is plain data: it serializes to and from a manifest a community
152/// can write, store in a file, and share. Pick a preset such as
153/// [`vaccine_fridge_monitor`](Profile::vaccine_fridge_monitor) or load one with
154/// [`from_json`](Profile::from_json), hand it a sensor, an actuator, a transport, and
155/// a codec, and the resulting [`Node`](crate::Node) reads, decides, drives the
156/// output, and publishes on its own. Every field is public, so a deployment can
157/// adjust the policy, topic, or power schedule in place.
158///
159/// # Examples
160///
161/// ```
162/// use pamoja_profile::{ControlSpec, Profile};
163///
164/// let profile = Profile::vaccine_fridge_monitor();
165/// assert_eq!(profile.name, "vaccine-fridge-monitor");
166/// assert!(matches!(profile.control, ControlSpec::Setpoint { .. }));
167/// ```
168#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
169pub struct Profile {
170    /// A stable, human-readable name, such as `"vaccine-fridge-monitor"`.
171    pub name: String,
172    /// The topic each reading is published to.
173    pub topic: String,
174    /// The control policy applied to each reading.
175    pub control: ControlSpec,
176    /// The power schedule that sets how often the node samples as the battery drains.
177    pub power: PowerSchedule,
178    /// How this profile presents itself on the dashboard - its custom sensors, node
179    /// stats, and theme. A profile that introduces no element beyond the dashboard's
180    /// built-in set leaves this `None`.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub presentation: Option<Presentation>,
183}
184
185impl Profile {
186    /// A cold-chain fridge monitor: hold 5 C and alert on a spoilage excursion.
187    ///
188    /// Switches a cooler to hold the contents near 5 C and raises an
189    /// [`Alert::OutOfRange`](crate::Alert::OutOfRange) the moment the temperature
190    /// leaves the 2-8 C safe range. Data integrity outweighs power here, so it keeps
191    /// sampling often even as the battery drains.
192    ///
193    /// # Returns
194    ///
195    /// The cold-chain monitoring profile.
196    pub fn vaccine_fridge_monitor() -> Self {
197        Self {
198            name: "vaccine-fridge-monitor".to_owned(),
199            topic: "cold-chain/fridge/temperature".to_owned(),
200            control: ControlSpec::Setpoint {
201                setpoint: 5.0,
202                hysteresis: 0.5,
203                cooling: true,
204                safe_band: 3.0,
205            },
206            power: PowerSchedule::new(60, 300, 900),
207            presentation: None,
208        }
209    }
210
211    /// An irrigation node: hold soil moisture near a target by opening a valve.
212    ///
213    /// Treats the valve as a "heater" for soil moisture, opening it when the soil
214    /// dries below the band and closing it once it is wet enough, and alerts if the
215    /// soil falls critically dry. Samples less often than the fridge, since soil
216    /// changes slowly and battery life matters more.
217    ///
218    /// # Returns
219    ///
220    /// The irrigation profile.
221    pub fn irrigation_node() -> Self {
222        Self {
223            name: "irrigation-node".to_owned(),
224            topic: "farm/irrigation/soil-moisture".to_owned(),
225            control: ControlSpec::Setpoint {
226                setpoint: 35.0,
227                hysteresis: 5.0,
228                cooling: false,
229                safe_band: 25.0,
230            },
231            power: PowerSchedule::new(300, 1800, 3600),
232            presentation: None,
233        }
234    }
235
236    /// A well-level monitor: report depth and warn before the well runs dry.
237    ///
238    /// Observes the water level without driving an output and raises an
239    /// [`Alert::RunningOut`](crate::Alert::RunningOut) once the level is on course to
240    /// reach the dry mark within a few more samples.
241    ///
242    /// # Returns
243    ///
244    /// The well-level monitoring profile.
245    pub fn well_level() -> Self {
246        Self {
247            name: "well-level".to_owned(),
248            topic: "water/well/level".to_owned(),
249            control: ControlSpec::Level {
250                empty: 0.5,
251                warn_within: 6,
252            },
253            power: PowerSchedule::new(600, 1800, 3600),
254            presentation: None,
255        }
256    }
257
258    /// A flash-flood sensor: warn when a river level rises dangerously fast.
259    ///
260    /// Watches a river or stream gauge and raises an
261    /// [`Alert::ChangingFast`](crate::Alert::ChangingFast) when the level rises more
262    /// than 0.3 m in a single sample, the signature of a flash flood. It samples
263    /// often, because a flood gives little warning.
264    ///
265    /// # Returns
266    ///
267    /// The flash-flood monitoring profile.
268    ///
269    /// # Examples
270    ///
271    /// ```
272    /// use pamoja_profile::{Alert, Profile};
273    ///
274    /// let mut control = Profile::flood_sensor().controller();
275    /// control.evaluate(1.0); // first fix establishes the level
276    /// let reaction = control.evaluate(1.5); // the river jumped 0.5 m
277    /// assert!(matches!(reaction.alert, Some(Alert::ChangingFast { .. })));
278    /// ```
279    pub fn flood_sensor() -> Self {
280        Self {
281            name: "flood-sensor".to_owned(),
282            topic: "water/river/level".to_owned(),
283            control: ControlSpec::Surge {
284                rising: true,
285                limit: 0.3,
286            },
287            power: PowerSchedule::new(60, 300, 900),
288            presentation: None,
289        }
290    }
291
292    /// Assembles this profile's [`ControlSpec`] into a live [`Controller`].
293    ///
294    /// # Returns
295    ///
296    /// A fresh controller implementing the profile's policy, with its control state
297    /// reset.
298    pub fn controller(&self) -> Controller {
299        match self.control {
300            ControlSpec::Setpoint {
301                setpoint,
302                hysteresis,
303                cooling,
304                safe_band,
305            } => Controller::setpoint(setpoint, hysteresis, cooling, safe_band),
306            ControlSpec::Level { empty, warn_within } => Controller::level(empty, warn_within),
307            ControlSpec::Surge { rising, limit } => Controller::surge(rising, limit),
308            ControlSpec::Monitor => Controller::monitor(),
309        }
310    }
311
312    /// Attaches a dashboard [`Presentation`] declaring this profile's custom elements.
313    ///
314    /// A profile that measures something the dashboard does not draw out of the box - a
315    /// turbidity probe, a custom node stat - carries the graphic, band, and label for it
316    /// here, so the dashboard offers and renders it with no code.
317    ///
318    /// # Arguments
319    ///
320    /// * `presentation` - how this profile presents itself on the dashboard.
321    ///
322    /// # Returns
323    ///
324    /// The profile, for chaining.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use pamoja_profile::{ElementSpec, Presentation, Profile, Viz};
330    ///
331    /// // A water-monitoring profile that adds a turbidity gauge the dashboard would not
332    /// // otherwise know how to draw.
333    /// let profile = Profile::well_level().with_presentation(
334    ///     Presentation::new().with_element(
335    ///         ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
336    ///             .with_band(0.0, 5.0),
337    ///     ),
338    /// );
339    /// let elements = &profile.presentation.unwrap().elements;
340    /// assert_eq!(elements[0].viz.kind(), "radial");
341    /// ```
342    pub fn with_presentation(mut self, presentation: Presentation) -> Self {
343        self.presentation = Some(presentation);
344        self
345    }
346}
347
348#[cfg(feature = "json")]
349impl Profile {
350    /// Loads a profile from a JSON manifest.
351    ///
352    /// This is how a shared profile reaches a device: a community publishes a manifest
353    /// file, and the runtime loads it into a profile to assemble a node from.
354    ///
355    /// # Arguments
356    ///
357    /// * `manifest` - the JSON text of the profile.
358    ///
359    /// # Returns
360    ///
361    /// The profile described by `manifest`.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if `manifest` is not valid
366    /// JSON or does not describe a profile.
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use pamoja_profile::Profile;
372    ///
373    /// // A well-level monitor, shared as a manifest. The power thresholds are
374    /// // optional and default when omitted.
375    /// let manifest = r#"{
376    ///     "name": "tank-level",
377    ///     "topic": "water/tank/level",
378    ///     "control": { "kind": "level", "empty": 0.0, "warn_within": 5 },
379    ///     "power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
380    /// }"#;
381    ///
382    /// let profile = Profile::from_json(manifest).expect("valid manifest");
383    /// assert_eq!(profile.name, "tank-level");
384    ///
385    /// let mut control = profile.controller();
386    /// control.evaluate(10.0); // first reading establishes a level
387    /// assert!(control.evaluate(2.0).alert.is_some()); // falling fast toward empty
388    /// ```
389    pub fn from_json(manifest: &str) -> pamoja_core::Result<Self> {
390        serde_json::from_str(manifest).map_err(|error| pamoja_core::Error::Codec(error.to_string()))
391    }
392
393    /// Serializes this profile to a JSON manifest a community can share.
394    ///
395    /// # Returns
396    ///
397    /// The pretty-printed JSON text of the profile.
398    ///
399    /// # Errors
400    ///
401    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the profile cannot be
402    /// serialized.
403    pub fn to_json(&self) -> pamoja_core::Result<String> {
404        serde_json::to_string_pretty(self)
405            .map_err(|error| pamoja_core::Error::Codec(error.to_string()))
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::Alert;
413
414    #[test]
415    fn presets_have_stable_names_and_topics() {
416        assert_eq!(
417            Profile::vaccine_fridge_monitor().name,
418            "vaccine-fridge-monitor"
419        );
420        assert_eq!(
421            Profile::vaccine_fridge_monitor().topic,
422            "cold-chain/fridge/temperature"
423        );
424        assert_eq!(Profile::irrigation_node().name, "irrigation-node");
425        assert_eq!(Profile::well_level().name, "well-level");
426    }
427
428    #[test]
429    fn the_fridge_controller_cools_and_flags_a_spoilage_excursion() {
430        let mut control = Profile::vaccine_fridge_monitor().controller();
431        let reaction = control.evaluate(9.0);
432        assert_eq!(reaction.actuator, Some(true));
433        assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
434    }
435
436    #[test]
437    fn the_well_controller_observes_without_an_output() {
438        let mut control = Profile::well_level().controller();
439        control.evaluate(3.0);
440        assert_eq!(control.evaluate(2.0).actuator, None);
441    }
442
443    #[test]
444    fn the_flood_controller_warns_on_a_rapid_rise() {
445        let mut control = Profile::flood_sensor().controller();
446        control.evaluate(1.0);
447        let reaction = control.evaluate(1.5); // a 0.5 m jump in one sample
448        assert!(matches!(reaction.alert, Some(Alert::ChangingFast { .. })));
449    }
450
451    #[test]
452    fn the_schedule_builds_the_documented_power_plan() {
453        use pamoja_power::PowerMode;
454
455        let plan = Profile::vaccine_fridge_monitor().power.plan();
456        assert_eq!(plan.mode(0.9), PowerMode::Active);
457        assert_eq!(plan.mode(0.1), PowerMode::Critical);
458        assert_eq!(plan.interval(0.9), Duration::from_secs(60));
459    }
460
461    #[cfg(feature = "json")]
462    #[test]
463    fn a_profile_round_trips_through_json() {
464        // Cover a setpoint profile and a surge profile, the two manifest shapes that
465        // carry the most fields.
466        for profile in [Profile::irrigation_node(), Profile::flood_sensor()] {
467            let json = profile.to_json().expect("serialize");
468            let restored = Profile::from_json(&json).expect("deserialize");
469            assert_eq!(profile, restored);
470        }
471    }
472
473    #[cfg(feature = "json")]
474    #[test]
475    fn a_manifest_may_omit_the_power_thresholds() {
476        let manifest = r#"{
477            "name": "tank",
478            "topic": "water/tank/level",
479            "control": { "kind": "level", "empty": 0.0, "warn_within": 4 },
480            "power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
481        }"#;
482        let profile = Profile::from_json(manifest).expect("valid manifest");
483        assert_eq!(profile.power.saver_below, 0.5);
484        assert_eq!(profile.power.critical_below, 0.2);
485        assert!(matches!(
486            profile.control,
487            ControlSpec::Level { warn_within: 4, .. }
488        ));
489    }
490}