use core::time::Duration;
use pamoja_power::PowerPlan;
use serde::{Deserialize, Serialize};
use crate::{Controller, Presentation};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ControlSpec {
Setpoint {
setpoint: f32,
hysteresis: f32,
cooling: bool,
safe_band: f32,
},
Level {
empty: f32,
warn_within: u32,
},
Surge {
rising: bool,
limit: f32,
},
Monitor,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PowerSchedule {
pub active_secs: u64,
pub saver_secs: u64,
pub critical_secs: u64,
#[serde(default = "PowerSchedule::default_saver_below")]
pub saver_below: f32,
#[serde(default = "PowerSchedule::default_critical_below")]
pub critical_below: f32,
}
impl PowerSchedule {
fn default_saver_below() -> f32 {
0.5
}
fn default_critical_below() -> f32 {
0.2
}
pub fn new(active_secs: u64, saver_secs: u64, critical_secs: u64) -> Self {
Self {
active_secs,
saver_secs,
critical_secs,
saver_below: Self::default_saver_below(),
critical_below: Self::default_critical_below(),
}
}
pub fn with_thresholds(mut self, saver_below: f32, critical_below: f32) -> Self {
self.saver_below = saver_below;
self.critical_below = critical_below;
self
}
pub fn plan(&self) -> PowerPlan {
PowerPlan::new(
Duration::from_secs(self.active_secs),
Duration::from_secs(self.saver_secs),
Duration::from_secs(self.critical_secs),
)
.thresholds(self.saver_below, self.critical_below)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
pub topic: String,
pub control: ControlSpec,
pub power: PowerSchedule,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presentation: Option<Presentation>,
}
impl Profile {
pub fn vaccine_fridge_monitor() -> Self {
Self {
name: "vaccine-fridge-monitor".to_owned(),
topic: "cold-chain/fridge/temperature".to_owned(),
control: ControlSpec::Setpoint {
setpoint: 5.0,
hysteresis: 0.5,
cooling: true,
safe_band: 3.0,
},
power: PowerSchedule::new(60, 300, 900),
presentation: None,
}
}
pub fn irrigation_node() -> Self {
Self {
name: "irrigation-node".to_owned(),
topic: "farm/irrigation/soil-moisture".to_owned(),
control: ControlSpec::Setpoint {
setpoint: 35.0,
hysteresis: 5.0,
cooling: false,
safe_band: 25.0,
},
power: PowerSchedule::new(300, 1800, 3600),
presentation: None,
}
}
pub fn well_level() -> Self {
Self {
name: "well-level".to_owned(),
topic: "water/well/level".to_owned(),
control: ControlSpec::Level {
empty: 0.5,
warn_within: 6,
},
power: PowerSchedule::new(600, 1800, 3600),
presentation: None,
}
}
pub fn flood_sensor() -> Self {
Self {
name: "flood-sensor".to_owned(),
topic: "water/river/level".to_owned(),
control: ControlSpec::Surge {
rising: true,
limit: 0.3,
},
power: PowerSchedule::new(60, 300, 900),
presentation: None,
}
}
pub fn controller(&self) -> Controller {
match self.control {
ControlSpec::Setpoint {
setpoint,
hysteresis,
cooling,
safe_band,
} => Controller::setpoint(setpoint, hysteresis, cooling, safe_band),
ControlSpec::Level { empty, warn_within } => Controller::level(empty, warn_within),
ControlSpec::Surge { rising, limit } => Controller::surge(rising, limit),
ControlSpec::Monitor => Controller::monitor(),
}
}
pub fn with_presentation(mut self, presentation: Presentation) -> Self {
self.presentation = Some(presentation);
self
}
}
#[cfg(feature = "json")]
impl Profile {
pub fn from_json(manifest: &str) -> pamoja_core::Result<Self> {
serde_json::from_str(manifest).map_err(|error| pamoja_core::Error::Codec(error.to_string()))
}
pub fn to_json(&self) -> pamoja_core::Result<String> {
serde_json::to_string_pretty(self)
.map_err(|error| pamoja_core::Error::Codec(error.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Alert;
#[test]
fn presets_have_stable_names_and_topics() {
assert_eq!(
Profile::vaccine_fridge_monitor().name,
"vaccine-fridge-monitor"
);
assert_eq!(
Profile::vaccine_fridge_monitor().topic,
"cold-chain/fridge/temperature"
);
assert_eq!(Profile::irrigation_node().name, "irrigation-node");
assert_eq!(Profile::well_level().name, "well-level");
}
#[test]
fn the_fridge_controller_cools_and_flags_a_spoilage_excursion() {
let mut control = Profile::vaccine_fridge_monitor().controller();
let reaction = control.evaluate(9.0);
assert_eq!(reaction.actuator, Some(true));
assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
}
#[test]
fn the_well_controller_observes_without_an_output() {
let mut control = Profile::well_level().controller();
control.evaluate(3.0);
assert_eq!(control.evaluate(2.0).actuator, None);
}
#[test]
fn the_flood_controller_warns_on_a_rapid_rise() {
let mut control = Profile::flood_sensor().controller();
control.evaluate(1.0);
let reaction = control.evaluate(1.5); assert!(matches!(reaction.alert, Some(Alert::ChangingFast { .. })));
}
#[test]
fn the_schedule_builds_the_documented_power_plan() {
use pamoja_power::PowerMode;
let plan = Profile::vaccine_fridge_monitor().power.plan();
assert_eq!(plan.mode(0.9), PowerMode::Active);
assert_eq!(plan.mode(0.1), PowerMode::Critical);
assert_eq!(plan.interval(0.9), Duration::from_secs(60));
}
#[cfg(feature = "json")]
#[test]
fn a_profile_round_trips_through_json() {
for profile in [Profile::irrigation_node(), Profile::flood_sensor()] {
let json = profile.to_json().expect("serialize");
let restored = Profile::from_json(&json).expect("deserialize");
assert_eq!(profile, restored);
}
}
#[cfg(feature = "json")]
#[test]
fn a_manifest_may_omit_the_power_thresholds() {
let manifest = r#"{
"name": "tank",
"topic": "water/tank/level",
"control": { "kind": "level", "empty": 0.0, "warn_within": 4 },
"power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
}"#;
let profile = Profile::from_json(manifest).expect("valid manifest");
assert_eq!(profile.power.saver_below, 0.5);
assert_eq!(profile.power.critical_below, 0.2);
assert!(matches!(
profile.control,
ControlSpec::Level { warn_within: 4, .. }
));
}
}