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/// The margin a charge must climb past a threshold before a plan leaves the lower mode.
17pub const DEFAULT_HYSTERESIS: f32 = 0.05;
18
19/// Maps a battery state of charge onto a [`PowerMode`] and a work interval.
20///
21/// As the battery drains, a node should do less: sample and transmit less often so
22/// it survives the night or a cloudy week. A [`PowerPlan`] encodes that policy as
23/// three intervals and two thresholds. Feed it a state of charge in `[0.0, 1.0]`
24/// and it returns the mode to run in and how long to wait before the next cycle.
25/// When the panel is charging it eases off by one mode, since incoming energy buys
26/// back some headroom.
27///
28/// A charge read from a fuel gauge wanders by a percent or two from one reading to the
29/// next, so a node whose charge sits at a threshold would change mode on every cycle.
30/// [`next_mode`](PowerPlan::next_mode) takes the mode the node is in and applies
31/// hysteresis: the node drops to a lower mode as soon as the charge falls below its
32/// threshold, and climbs back only once the charge reaches the threshold plus a margin,
33/// [`DEFAULT_HYSTERESIS`] unless [`with_hysteresis`](PowerPlan::with_hysteresis) sets
34/// another.
35///
36/// # Examples
37///
38/// ```
39/// use core::time::Duration;
40/// use pamoja_power::{PowerMode, PowerPlan};
41///
42/// let plan = PowerPlan::new(
43///     Duration::from_secs(60),
44///     Duration::from_secs(600),
45///     Duration::from_secs(3600),
46/// );
47///
48/// // Low battery means the saver cadence...
49/// assert_eq!(plan.mode(0.3), PowerMode::Saver);
50/// // ...unless the panel is charging, which buys back the active cadence.
51/// assert_eq!(plan.mode_while_charging(0.3, true), PowerMode::Active);
52///
53/// // A charge wandering around the 50% threshold settles in saver mode...
54/// let mut mode = PowerMode::Active;
55/// for soc in [0.49, 0.51, 0.50, 0.53, 0.48] {
56///     mode = plan.next_mode(mode, soc);
57///     assert_eq!(mode, PowerMode::Saver);
58/// }
59/// // ...until it reaches 55%.
60/// assert_eq!(plan.next_mode(mode, 0.55), PowerMode::Active);
61/// ```
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct PowerPlan {
64    active_interval: Duration,
65    saver_interval: Duration,
66    critical_interval: Duration,
67    saver_below: f32,
68    critical_below: f32,
69    hysteresis: f32,
70}
71
72impl PowerPlan {
73    /// Creates a plan from its three work intervals, with default thresholds.
74    ///
75    /// The defaults enter [`PowerMode::Saver`] below 50% charge and
76    /// [`PowerMode::Critical`] below 20%, and leave each lower mode once the charge is
77    /// [`DEFAULT_HYSTERESIS`] above the threshold that brought it on.
78    ///
79    /// # Arguments
80    ///
81    /// * `active` - the interval at a healthy charge.
82    /// * `saver` - the longer interval used to conserve, normally larger than
83    ///   `active`.
84    /// * `critical` - the longest interval, used when charge is critically low.
85    ///
86    /// # Returns
87    ///
88    /// The power plan.
89    pub fn new(active: Duration, saver: Duration, critical: Duration) -> Self {
90        Self {
91            active_interval: active,
92            saver_interval: saver,
93            critical_interval: critical,
94            saver_below: 0.5,
95            critical_below: 0.2,
96            hysteresis: DEFAULT_HYSTERESIS,
97        }
98    }
99
100    /// Sets the state-of-charge thresholds for entering each lower mode.
101    ///
102    /// # Arguments
103    ///
104    /// * `saver_below` - enter [`PowerMode::Saver`] when charge is below this.
105    /// * `critical_below` - enter [`PowerMode::Critical`] when charge is below this,
106    ///   normally lower than `saver_below`.
107    ///
108    /// # Returns
109    ///
110    /// The updated plan, for chaining.
111    pub fn thresholds(mut self, saver_below: f32, critical_below: f32) -> Self {
112        self.saver_below = saver_below;
113        self.critical_below = critical_below;
114        self
115    }
116
117    /// Sets how far above a threshold the charge must climb before the plan leaves the
118    /// lower mode.
119    ///
120    /// # Arguments
121    ///
122    /// * `margin` - the state of charge added to each threshold on the way back up. `0.0`
123    ///   turns hysteresis off, so [`next_mode`](PowerPlan::next_mode) agrees with
124    ///   [`mode`](PowerPlan::mode) at every charge. A margin that is negative or not a
125    ///   number is taken as `0.0`.
126    ///
127    /// # Returns
128    ///
129    /// The updated plan, for chaining.
130    pub fn with_hysteresis(mut self, margin: f32) -> Self {
131        self.hysteresis = if margin > 0.0 { margin } else { 0.0 };
132        self
133    }
134
135    /// Returns how far above a threshold the charge must climb to leave the lower mode.
136    ///
137    /// # Returns
138    ///
139    /// The margin as a state of charge.
140    pub fn hysteresis(&self) -> f32 {
141        self.hysteresis
142    }
143
144    /// Returns the charge below which the plan enters [`PowerMode::Saver`].
145    ///
146    /// # Returns
147    ///
148    /// The saver threshold as a state of charge in `[0.0, 1.0]`.
149    pub fn saver_below(&self) -> f32 {
150        self.saver_below
151    }
152
153    /// Returns the charge below which the plan enters [`PowerMode::Critical`].
154    ///
155    /// # Returns
156    ///
157    /// The critical threshold as a state of charge in `[0.0, 1.0]`.
158    pub fn critical_below(&self) -> f32 {
159        self.critical_below
160    }
161
162    /// Returns the mode for the given state of charge.
163    ///
164    /// A charge that is not a number, such as a fuel gauge that failed to answer, is
165    /// taken as critical: a node that cannot tell how much it has left does the least
166    /// until it can.
167    ///
168    /// # Arguments
169    ///
170    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
171    ///
172    /// # Returns
173    ///
174    /// The [`PowerMode`] the node should run in.
175    pub fn mode(&self, soc: f32) -> PowerMode {
176        Self::mode_at(soc, self.saver_below, self.critical_below)
177    }
178
179    /// Returns the mode a node in `current` moves to at a new state of charge.
180    ///
181    /// The node drops to a lower mode as soon as the charge falls below that mode's
182    /// threshold, as [`mode`](PowerPlan::mode) does. It climbs to a higher mode only once
183    /// the charge reaches the threshold plus the plan's
184    /// [`hysteresis`](PowerPlan::hysteresis), so a charge wandering around a threshold
185    /// keeps the node where it is. A charge that is not a number is taken as critical.
186    ///
187    /// # Arguments
188    ///
189    /// * `current` - the mode the node is running in.
190    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
191    ///
192    /// # Returns
193    ///
194    /// The [`PowerMode`] the node should run in next.
195    pub fn next_mode(&self, current: PowerMode, soc: f32) -> PowerMode {
196        let target = self.mode(soc);
197        if rank(target) <= rank(current) {
198            return target;
199        }
200        let reached = Self::mode_at(
201            soc,
202            self.saver_below + self.hysteresis,
203            self.critical_below + self.hysteresis,
204        );
205        if rank(reached) > rank(current) {
206            reached
207        } else {
208            current
209        }
210    }
211
212    /// Returns the mode a node in `current` moves to, easing off by one step when
213    /// charging.
214    ///
215    /// The hysteresis of [`next_mode`](PowerPlan::next_mode) applies to the mode the
216    /// node runs in, so `current` is the mode this returned last time. Easing up because
217    /// the panel started charging counts as a climb, so it too waits until the charge is
218    /// the margin clear of the threshold below it.
219    ///
220    /// # Arguments
221    ///
222    /// * `current` - the mode the node is running in.
223    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
224    /// * `charging` - whether the panel is currently delivering charge.
225    ///
226    /// # Returns
227    ///
228    /// The [`PowerMode`] the node should run in next.
229    pub fn next_mode_while_charging(
230        &self,
231        current: PowerMode,
232        soc: f32,
233        charging: bool,
234    ) -> PowerMode {
235        if !charging {
236            return self.next_mode(current, soc);
237        }
238        let target = eased(self.mode(soc));
239        if rank(target) <= rank(current) {
240            return target;
241        }
242        let reached = eased(Self::mode_at(
243            soc,
244            self.saver_below + self.hysteresis,
245            self.critical_below + self.hysteresis,
246        ));
247        if rank(reached) > rank(current) {
248            reached
249        } else {
250            current
251        }
252    }
253
254    fn mode_at(soc: f32, saver_below: f32, critical_below: f32) -> PowerMode {
255        if soc.is_nan() || soc < critical_below {
256            PowerMode::Critical
257        } else if soc < saver_below {
258            PowerMode::Saver
259        } else {
260            PowerMode::Active
261        }
262    }
263
264    /// Returns the mode for the given charge, easing off by one step when charging.
265    ///
266    /// # Arguments
267    ///
268    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
269    /// * `charging` - whether the panel is currently delivering charge.
270    ///
271    /// # Returns
272    ///
273    /// The [`PowerMode`], promoted one step toward [`PowerMode::Active`] while
274    /// `charging` is `true`.
275    pub fn mode_while_charging(&self, soc: f32, charging: bool) -> PowerMode {
276        let mode = self.mode(soc);
277        if charging {
278            eased(mode)
279        } else {
280            mode
281        }
282    }
283
284    /// Returns the work interval for a given mode.
285    ///
286    /// # Arguments
287    ///
288    /// * `mode` - the mode to look up.
289    ///
290    /// # Returns
291    ///
292    /// The interval to wait before the next work cycle in that mode.
293    pub fn interval_for(&self, mode: PowerMode) -> Duration {
294        match mode {
295            PowerMode::Active => self.active_interval,
296            PowerMode::Saver => self.saver_interval,
297            PowerMode::Critical => self.critical_interval,
298        }
299    }
300
301    /// Returns the work interval for the given state of charge.
302    ///
303    /// # Arguments
304    ///
305    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
306    ///
307    /// # Returns
308    ///
309    /// The interval to wait before the next work cycle.
310    pub fn interval(&self, soc: f32) -> Duration {
311        self.interval_for(self.mode(soc))
312    }
313}
314
315fn eased(mode: PowerMode) -> PowerMode {
316    match mode {
317        PowerMode::Critical => PowerMode::Saver,
318        PowerMode::Saver | PowerMode::Active => PowerMode::Active,
319    }
320}
321
322fn rank(mode: PowerMode) -> u8 {
323    match mode {
324        PowerMode::Critical => 0,
325        PowerMode::Saver => 1,
326        PowerMode::Active => 2,
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn plan() -> PowerPlan {
335        PowerPlan::new(
336            Duration::from_secs(60),
337            Duration::from_secs(600),
338            Duration::from_secs(3600),
339        )
340    }
341
342    #[test]
343    fn mode_steps_down_as_charge_falls() {
344        let plan = plan();
345        assert_eq!(plan.mode(0.9), PowerMode::Active);
346        assert_eq!(plan.mode(0.4), PowerMode::Saver);
347        assert_eq!(plan.mode(0.1), PowerMode::Critical);
348    }
349
350    #[test]
351    fn thresholds_are_the_lower_bound_of_each_mode() {
352        let plan = plan();
353        // Exactly at a threshold stays in the higher mode.
354        assert_eq!(plan.mode(0.5), PowerMode::Active);
355        assert_eq!(plan.mode(0.2), PowerMode::Saver);
356    }
357
358    #[test]
359    fn interval_follows_the_mode() {
360        let plan = plan();
361        assert_eq!(plan.interval(0.9), Duration::from_secs(60));
362        assert_eq!(plan.interval(0.4), Duration::from_secs(600));
363        assert_eq!(plan.interval(0.1), Duration::from_secs(3600));
364    }
365
366    #[test]
367    fn charging_eases_off_by_one_mode() {
368        let plan = plan();
369        assert_eq!(plan.mode_while_charging(0.1, true), PowerMode::Saver);
370        assert_eq!(plan.mode_while_charging(0.4, true), PowerMode::Active);
371        assert_eq!(plan.mode_while_charging(0.9, true), PowerMode::Active);
372        // Not charging is unchanged.
373        assert_eq!(plan.mode_while_charging(0.1, false), PowerMode::Critical);
374    }
375
376    #[test]
377    fn custom_thresholds_apply() {
378        let plan = plan().thresholds(0.7, 0.3);
379        assert_eq!(plan.mode(0.65), PowerMode::Saver);
380        assert_eq!(plan.mode(0.25), PowerMode::Critical);
381    }
382
383    #[test]
384    fn a_charge_wandering_at_a_threshold_holds_the_mode() {
385        let plan = plan();
386        let mut mode = PowerMode::Active;
387        let mut changes = 0;
388        for soc in [0.52, 0.49, 0.51, 0.495, 0.505, 0.53, 0.5, 0.54] {
389            let next = plan.next_mode(mode, soc);
390            changes += usize::from(next != mode);
391            mode = next;
392        }
393        assert_eq!(mode, PowerMode::Saver);
394        assert_eq!(changes, 1, "one drop, and no climb back inside the margin");
395        assert_eq!(plan.next_mode(mode, 0.56), PowerMode::Active);
396    }
397
398    #[test]
399    fn a_fall_takes_effect_at_the_threshold() {
400        let plan = plan();
401        assert_eq!(plan.next_mode(PowerMode::Active, 0.49), PowerMode::Saver);
402        assert_eq!(plan.next_mode(PowerMode::Active, 0.1), PowerMode::Critical);
403        assert_eq!(
404            plan.next_mode(PowerMode::Saver, f32::NAN),
405            PowerMode::Critical
406        );
407    }
408
409    #[test]
410    fn a_climb_stops_at_the_highest_mode_the_margin_allows() {
411        let plan = plan();
412        assert_eq!(
413            plan.next_mode(PowerMode::Critical, 0.22),
414            PowerMode::Critical
415        );
416        assert_eq!(plan.next_mode(PowerMode::Critical, 0.3), PowerMode::Saver);
417        assert_eq!(plan.next_mode(PowerMode::Critical, 0.52), PowerMode::Saver);
418        assert_eq!(plan.next_mode(PowerMode::Critical, 0.9), PowerMode::Active);
419    }
420
421    #[test]
422    fn no_margin_follows_the_plain_mode() {
423        let plan = plan().with_hysteresis(0.0);
424        for soc in [0.0, 0.19, 0.2, 0.3, 0.49, 0.5, 0.9, 1.0] {
425            for current in [PowerMode::Active, PowerMode::Saver, PowerMode::Critical] {
426                assert_eq!(plan.next_mode(current, soc), plan.mode(soc));
427            }
428        }
429        assert_eq!(plan.with_hysteresis(-1.0).hysteresis(), 0.0);
430        assert_eq!(plan.with_hysteresis(f32::NAN).hysteresis(), 0.0);
431    }
432
433    #[test]
434    fn charging_eases_the_mode_the_margin_settles_on() {
435        let plan = plan();
436        assert_eq!(
437            plan.next_mode_while_charging(PowerMode::Saver, 0.21, true),
438            PowerMode::Saver,
439            "a node that fell to critical and eased to saver stays there inside the margin"
440        );
441        assert_eq!(
442            plan.next_mode_while_charging(PowerMode::Saver, 0.26, true),
443            PowerMode::Active
444        );
445        assert_eq!(
446            plan.next_mode_while_charging(PowerMode::Active, 0.19, true),
447            PowerMode::Saver
448        );
449        assert_eq!(
450            plan.next_mode_while_charging(PowerMode::Active, 0.19, false),
451            PowerMode::Critical
452        );
453    }
454
455    #[test]
456    fn a_charge_that_is_not_a_number_is_taken_as_critical() {
457        let plan = plan();
458        assert_eq!(plan.mode(f32::NAN), PowerMode::Critical);
459        assert_eq!(plan.interval(f32::NAN), Duration::from_secs(3600));
460        assert_eq!(
461            plan.mode_while_charging(f32::NAN, true),
462            PowerMode::Saver,
463            "a delivering panel still buys back one mode"
464        );
465    }
466}