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}