pamoja_power/duty.rs
1//! Duty cycling: trading wakefulness for battery life.
2
3use core::time::Duration;
4
5/// A repeating wake/sleep schedule.
6///
7/// Duty cycling is the simplest way to make a battery or solar node last: stay
8/// awake just long enough to do the work, then sleep through the rest of the
9/// period. The duty fraction - the share of each period spent awake - is a good
10/// first proxy for average power draw, so roughly halving it halves the energy the
11/// cycle costs.
12///
13/// # Examples
14///
15/// ```
16/// use core::time::Duration;
17/// use pamoja_power::DutyCycle;
18///
19/// // Wake for one second every minute.
20/// let cycle = DutyCycle::new(Duration::from_secs(1), Duration::from_secs(59));
21/// assert_eq!(cycle.period(), Duration::from_secs(60));
22/// assert!((cycle.fraction() - 1.0 / 60.0).abs() < 1e-6);
23/// ```
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct DutyCycle {
26 active: Duration,
27 sleep: Duration,
28}
29
30impl DutyCycle {
31 /// Creates a duty cycle from its awake and asleep durations.
32 ///
33 /// # Arguments
34 ///
35 /// * `active` - how long to stay awake each period.
36 /// * `sleep` - how long to sleep each period.
37 ///
38 /// # Returns
39 ///
40 /// The duty cycle.
41 pub fn new(active: Duration, sleep: Duration) -> Self {
42 Self { active, sleep }
43 }
44
45 /// Creates a duty cycle from a period and the fraction of it to stay awake.
46 ///
47 /// # Arguments
48 ///
49 /// * `period` - the full wake-plus-sleep period.
50 /// * `fraction` - the share of the period to stay awake, clamped to
51 /// `[0.0, 1.0]`.
52 ///
53 /// # Returns
54 ///
55 /// A duty cycle whose awake time is `fraction` of `period`.
56 pub fn from_fraction(period: Duration, fraction: f32) -> Self {
57 let active = period.mul_f32(unit_interval(fraction));
58 Self {
59 active,
60 sleep: period - active,
61 }
62 }
63
64 /// Returns the awake portion of each period.
65 pub fn active(&self) -> Duration {
66 self.active
67 }
68
69 /// Returns the asleep portion of each period.
70 pub fn sleep(&self) -> Duration {
71 self.sleep
72 }
73
74 /// Returns the full period, awake plus asleep.
75 pub fn period(&self) -> Duration {
76 self.active + self.sleep
77 }
78
79 /// Returns the share of each period spent awake, in `[0.0, 1.0]`.
80 ///
81 /// # Returns
82 ///
83 /// The duty fraction, or `0.0` for a zero-length period.
84 pub fn fraction(&self) -> f32 {
85 let period = self.period();
86 if period.is_zero() {
87 0.0
88 } else {
89 self.active.as_secs_f32() / period.as_secs_f32()
90 }
91 }
92}
93
94// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
95#[allow(clippy::manual_clamp)]
96fn unit_interval(value: f32) -> f32 {
97 if value < 0.0 {
98 0.0
99 } else if value > 1.0 {
100 1.0
101 } else {
102 value
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn period_is_awake_plus_asleep() {
112 let cycle = DutyCycle::new(Duration::from_secs(2), Duration::from_secs(8));
113 assert_eq!(cycle.period(), Duration::from_secs(10));
114 }
115
116 #[test]
117 fn fraction_is_the_awake_share() {
118 let cycle = DutyCycle::new(Duration::from_secs(2), Duration::from_secs(8));
119 assert!((cycle.fraction() - 0.2).abs() < 1e-6);
120 }
121
122 #[test]
123 fn a_zero_period_has_zero_fraction() {
124 let cycle = DutyCycle::new(Duration::ZERO, Duration::ZERO);
125 assert_eq!(cycle.fraction(), 0.0);
126 }
127
128 #[test]
129 fn from_fraction_splits_the_period() {
130 let cycle = DutyCycle::from_fraction(Duration::from_secs(10), 0.25);
131 assert!((cycle.active().as_secs_f32() - 2.5).abs() < 1e-3);
132 assert!((cycle.fraction() - 0.25).abs() < 1e-6);
133 }
134
135 #[test]
136 fn from_fraction_clamps_out_of_range_values() {
137 let all_awake = DutyCycle::from_fraction(Duration::from_secs(10), 5.0);
138 assert_eq!(all_awake.active(), Duration::from_secs(10));
139 assert_eq!(all_awake.sleep(), Duration::ZERO);
140
141 let all_asleep = DutyCycle::from_fraction(Duration::from_secs(10), -1.0);
142 assert_eq!(all_asleep.active(), Duration::ZERO);
143 }
144}