Skip to main content

pamoja_power/
plan.rs

1//! An energy-aware governor that adapts the work cadence to the battery.
2
3use core::time::Duration;
4
5/// How hard a node should work, chosen from its battery state of charge.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum PowerMode {
8    /// Healthy charge: run at the normal cadence.
9    Active,
10    /// Low charge: stretch the cadence to conserve.
11    Saver,
12    /// Critically low charge: do the bare minimum to survive.
13    Critical,
14}
15
16/// Maps a battery state of charge onto a [`PowerMode`] and a work interval.
17///
18/// As the battery drains, a node should do less: sample and transmit less often so
19/// it survives the night or a cloudy week. A [`PowerPlan`] encodes that policy as
20/// three intervals and two thresholds. Feed it a state of charge in `[0.0, 1.0]`
21/// and it returns the mode to run in and how long to wait before the next cycle.
22/// When the panel is charging it eases off by one mode, since incoming energy buys
23/// back some headroom.
24///
25/// # Examples
26///
27/// ```
28/// use core::time::Duration;
29/// use pamoja_power::{PowerMode, PowerPlan};
30///
31/// let plan = PowerPlan::new(
32///     Duration::from_secs(60),
33///     Duration::from_secs(600),
34///     Duration::from_secs(3600),
35/// );
36///
37/// // Low battery means the saver cadence...
38/// assert_eq!(plan.mode(0.3), PowerMode::Saver);
39/// // ...unless the panel is charging, which buys back the active cadence.
40/// assert_eq!(plan.mode_while_charging(0.3, true), PowerMode::Active);
41/// ```
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct PowerPlan {
44    active_interval: Duration,
45    saver_interval: Duration,
46    critical_interval: Duration,
47    saver_below: f32,
48    critical_below: f32,
49}
50
51impl PowerPlan {
52    /// Creates a plan from its three work intervals, with default thresholds.
53    ///
54    /// The defaults enter [`PowerMode::Saver`] below 50% charge and
55    /// [`PowerMode::Critical`] below 20%.
56    ///
57    /// # Arguments
58    ///
59    /// * `active` - the interval at a healthy charge.
60    /// * `saver` - the longer interval used to conserve, normally larger than
61    ///   `active`.
62    /// * `critical` - the longest interval, used when charge is critically low.
63    ///
64    /// # Returns
65    ///
66    /// The power plan.
67    pub fn new(active: Duration, saver: Duration, critical: Duration) -> Self {
68        Self {
69            active_interval: active,
70            saver_interval: saver,
71            critical_interval: critical,
72            saver_below: 0.5,
73            critical_below: 0.2,
74        }
75    }
76
77    /// Sets the state-of-charge thresholds for entering each lower mode.
78    ///
79    /// # Arguments
80    ///
81    /// * `saver_below` - enter [`PowerMode::Saver`] when charge is below this.
82    /// * `critical_below` - enter [`PowerMode::Critical`] when charge is below this,
83    ///   normally lower than `saver_below`.
84    ///
85    /// # Returns
86    ///
87    /// The updated plan, for chaining.
88    pub fn thresholds(mut self, saver_below: f32, critical_below: f32) -> Self {
89        self.saver_below = saver_below;
90        self.critical_below = critical_below;
91        self
92    }
93
94    /// Returns the charge below which the plan enters [`PowerMode::Saver`].
95    ///
96    /// # Returns
97    ///
98    /// The saver threshold as a state of charge in `[0.0, 1.0]`.
99    pub fn saver_below(&self) -> f32 {
100        self.saver_below
101    }
102
103    /// Returns the charge below which the plan enters [`PowerMode::Critical`].
104    ///
105    /// # Returns
106    ///
107    /// The critical threshold as a state of charge in `[0.0, 1.0]`.
108    pub fn critical_below(&self) -> f32 {
109        self.critical_below
110    }
111
112    /// Returns the mode for the given state of charge.
113    ///
114    /// # Arguments
115    ///
116    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
117    ///
118    /// # Returns
119    ///
120    /// The [`PowerMode`] the node should run in.
121    pub fn mode(&self, soc: f32) -> PowerMode {
122        if soc < self.critical_below {
123            PowerMode::Critical
124        } else if soc < self.saver_below {
125            PowerMode::Saver
126        } else {
127            PowerMode::Active
128        }
129    }
130
131    /// Returns the mode for the given charge, easing off by one step when charging.
132    ///
133    /// # Arguments
134    ///
135    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
136    /// * `charging` - whether the panel is currently delivering charge.
137    ///
138    /// # Returns
139    ///
140    /// The [`PowerMode`], promoted one step toward [`PowerMode::Active`] while
141    /// `charging` is `true`.
142    pub fn mode_while_charging(&self, soc: f32, charging: bool) -> PowerMode {
143        let mode = self.mode(soc);
144        if charging {
145            match mode {
146                PowerMode::Critical => PowerMode::Saver,
147                PowerMode::Saver | PowerMode::Active => PowerMode::Active,
148            }
149        } else {
150            mode
151        }
152    }
153
154    /// Returns the work interval for a given mode.
155    ///
156    /// # Arguments
157    ///
158    /// * `mode` - the mode to look up.
159    ///
160    /// # Returns
161    ///
162    /// The interval to wait before the next work cycle in that mode.
163    pub fn interval_for(&self, mode: PowerMode) -> Duration {
164        match mode {
165            PowerMode::Active => self.active_interval,
166            PowerMode::Saver => self.saver_interval,
167            PowerMode::Critical => self.critical_interval,
168        }
169    }
170
171    /// Returns the work interval for the given state of charge.
172    ///
173    /// # Arguments
174    ///
175    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
176    ///
177    /// # Returns
178    ///
179    /// The interval to wait before the next work cycle.
180    pub fn interval(&self, soc: f32) -> Duration {
181        self.interval_for(self.mode(soc))
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn plan() -> PowerPlan {
190        PowerPlan::new(
191            Duration::from_secs(60),
192            Duration::from_secs(600),
193            Duration::from_secs(3600),
194        )
195    }
196
197    #[test]
198    fn mode_steps_down_as_charge_falls() {
199        let plan = plan();
200        assert_eq!(plan.mode(0.9), PowerMode::Active);
201        assert_eq!(plan.mode(0.4), PowerMode::Saver);
202        assert_eq!(plan.mode(0.1), PowerMode::Critical);
203    }
204
205    #[test]
206    fn thresholds_are_the_lower_bound_of_each_mode() {
207        let plan = plan();
208        // Exactly at a threshold stays in the higher mode.
209        assert_eq!(plan.mode(0.5), PowerMode::Active);
210        assert_eq!(plan.mode(0.2), PowerMode::Saver);
211    }
212
213    #[test]
214    fn interval_follows_the_mode() {
215        let plan = plan();
216        assert_eq!(plan.interval(0.9), Duration::from_secs(60));
217        assert_eq!(plan.interval(0.4), Duration::from_secs(600));
218        assert_eq!(plan.interval(0.1), Duration::from_secs(3600));
219    }
220
221    #[test]
222    fn charging_eases_off_by_one_mode() {
223        let plan = plan();
224        assert_eq!(plan.mode_while_charging(0.1, true), PowerMode::Saver);
225        assert_eq!(plan.mode_while_charging(0.4, true), PowerMode::Active);
226        assert_eq!(plan.mode_while_charging(0.9, true), PowerMode::Active);
227        // Not charging is unchanged.
228        assert_eq!(plan.mode_while_charging(0.1, false), PowerMode::Critical);
229    }
230
231    #[test]
232    fn custom_thresholds_apply() {
233        let plan = plan().thresholds(0.7, 0.3);
234        assert_eq!(plan.mode(0.65), PowerMode::Saver);
235        assert_eq!(plan.mode(0.25), PowerMode::Critical);
236    }
237}