Skip to main content

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]`. A fraction that is not a number keeps the node asleep for the
52    ///   whole period, since a budget worked out from a failed reading is no budget.
53    ///
54    /// # Returns
55    ///
56    /// A duty cycle whose awake time is `fraction` of `period`.
57    pub fn from_fraction(period: Duration, fraction: f32) -> Self {
58        let active = if fraction >= 1.0 {
59            period
60        } else if fraction > 0.0 {
61            period.mul_f64(f64::from(fraction))
62        } else {
63            Duration::ZERO
64        };
65        Self {
66            active,
67            sleep: period - active,
68        }
69    }
70
71    /// Returns the awake portion of each period.
72    pub fn active(&self) -> Duration {
73        self.active
74    }
75
76    /// Returns the asleep portion of each period.
77    pub fn sleep(&self) -> Duration {
78        self.sleep
79    }
80
81    /// Returns the full period, awake plus asleep, held at [`Duration::MAX`] rather
82    /// than overflowing.
83    pub fn period(&self) -> Duration {
84        self.active.saturating_add(self.sleep)
85    }
86
87    /// Returns the share of each period spent awake, in `[0.0, 1.0]`.
88    ///
89    /// # Returns
90    ///
91    /// The duty fraction, or `0.0` for a zero-length period.
92    pub fn fraction(&self) -> f32 {
93        let period = self.period();
94        if period.is_zero() {
95            0.0
96        } else {
97            self.active.as_secs_f32() / period.as_secs_f32()
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn period_is_awake_plus_asleep() {
108        let cycle = DutyCycle::new(Duration::from_secs(2), Duration::from_secs(8));
109        assert_eq!(cycle.period(), Duration::from_secs(10));
110    }
111
112    #[test]
113    fn fraction_is_the_awake_share() {
114        let cycle = DutyCycle::new(Duration::from_secs(2), Duration::from_secs(8));
115        assert!((cycle.fraction() - 0.2).abs() < 1e-6);
116    }
117
118    #[test]
119    fn a_zero_period_has_zero_fraction() {
120        let cycle = DutyCycle::new(Duration::ZERO, Duration::ZERO);
121        assert_eq!(cycle.fraction(), 0.0);
122    }
123
124    #[test]
125    fn from_fraction_splits_the_period() {
126        let cycle = DutyCycle::from_fraction(Duration::from_secs(10), 0.25);
127        assert!((cycle.active().as_secs_f32() - 2.5).abs() < 1e-3);
128        assert!((cycle.fraction() - 0.25).abs() < 1e-6);
129    }
130
131    #[test]
132    fn from_fraction_clamps_out_of_range_values() {
133        let all_awake = DutyCycle::from_fraction(Duration::from_secs(10), 5.0);
134        assert_eq!(all_awake.active(), Duration::from_secs(10));
135        assert_eq!(all_awake.sleep(), Duration::ZERO);
136
137        let all_asleep = DutyCycle::from_fraction(Duration::from_secs(10), -1.0);
138        assert_eq!(all_asleep.active(), Duration::ZERO);
139    }
140
141    #[test]
142    fn a_fraction_that_is_not_a_number_keeps_the_node_asleep() {
143        let cycle = DutyCycle::from_fraction(Duration::from_secs(10), f32::NAN);
144        assert_eq!(cycle.active(), Duration::ZERO);
145        assert_eq!(cycle.sleep(), Duration::from_secs(10));
146    }
147
148    #[test]
149    fn the_longest_period_splits_without_overflowing() {
150        let whole = DutyCycle::from_fraction(Duration::MAX, 1.0);
151        assert_eq!(whole.active(), Duration::MAX);
152        let nearly = DutyCycle::from_fraction(Duration::MAX, 1.0 - f32::EPSILON);
153        assert!(nearly.active() < Duration::MAX);
154    }
155
156    #[test]
157    fn the_longest_halves_report_their_period_without_overflowing() {
158        let cycle = DutyCycle::new(Duration::MAX, Duration::from_secs(1));
159        assert_eq!(cycle.period(), Duration::MAX);
160        assert!((cycle.fraction() - 1.0).abs() < 1e-6);
161    }
162}