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}