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}