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]`.
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}