Skip to main content

pamoja_ffi/
power.rs

1//! The C ABI for power-aware scheduling.
2//!
3//! These functions wrap [`pamoja_power`] for callers that reach the SDK through
4//! the flat C boundary: the split between working and sleeping that a duty cycle
5//! describes, and the plan that stretches a work interval as a battery falls.
6//!
7//! Everything here is arithmetic over scalars, so both types cross by value and
8//! nothing allocates. Durations cross as microseconds, which covers intervals
9//! from a radio burst to weeks of deep sleep in a 64-bit unsigned integer.
10
11use core::time::Duration;
12
13use pamoja_power::{DutyCycle, PowerMode, PowerPlan};
14
15/// The split between the time a node works and the time it sleeps.
16#[repr(C)]
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct PamojaDutyCycle {
19    /// How long the node stays awake each period, in microseconds.
20    pub active_us: u64,
21    /// How long it sleeps each period, in microseconds.
22    pub sleep_us: u64,
23}
24
25/// What a node should be doing at the current state of charge.
26#[repr(C)]
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum PamojaPowerMode {
29    /// Full duty, because the charge is healthy.
30    Active = 0,
31    /// Reduced duty, to conserve charge.
32    Saver = 1,
33    /// Minimum duty, to stay alive as long as possible.
34    Critical = 2,
35}
36
37/// The work intervals a node uses in each mode, and where the modes change.
38///
39/// Build one with [`pamoja_power_plan_new`], which applies the default
40/// thresholds, then move them with [`pamoja_power_plan_with_thresholds`].
41#[repr(C)]
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct PamojaPowerPlan {
44    /// The interval between work at a healthy charge, in microseconds.
45    pub active_us: u64,
46    /// The interval used to conserve charge, in microseconds.
47    pub saver_us: u64,
48    /// The interval used at a critically low charge, in microseconds.
49    pub critical_us: u64,
50    /// Enter [`PamojaPowerMode::Saver`] below this state of charge.
51    pub saver_below: f32,
52    /// Enter [`PamojaPowerMode::Critical`] below this state of charge.
53    pub critical_below: f32,
54}
55
56/// Creates a duty cycle from the time awake and the time asleep.
57///
58/// # Arguments
59///
60/// * `active_us` - how long the node works each period, in microseconds.
61/// * `sleep_us` - how long it sleeps each period, in microseconds.
62///
63/// # Returns
64///
65/// The duty cycle.
66#[no_mangle]
67pub extern "C" fn pamoja_duty_cycle_new(active_us: u64, sleep_us: u64) -> PamojaDutyCycle {
68    let duty = DutyCycle::new(
69        Duration::from_micros(active_us),
70        Duration::from_micros(sleep_us),
71    );
72    PamojaDutyCycle {
73        active_us: micros(duty.active()),
74        sleep_us: micros(duty.sleep()),
75    }
76}
77
78/// Creates a duty cycle that spends a fraction of each period awake.
79///
80/// # Arguments
81///
82/// * `period_us` - the whole period, in microseconds.
83/// * `fraction` - the share of the period spent awake, clamped to 0.0 through 1.0.
84///
85/// # Returns
86///
87/// The duty cycle.
88#[no_mangle]
89pub extern "C" fn pamoja_duty_cycle_from_fraction(
90    period_us: u64,
91    fraction: f32,
92) -> PamojaDutyCycle {
93    let duty = DutyCycle::from_fraction(Duration::from_micros(period_us), fraction);
94    PamojaDutyCycle {
95        active_us: micros(duty.active()),
96        sleep_us: micros(duty.sleep()),
97    }
98}
99
100/// Returns the whole period of a duty cycle, awake plus asleep.
101///
102/// # Arguments
103///
104/// * `duty` - the duty cycle.
105///
106/// # Returns
107///
108/// The period in microseconds.
109#[no_mangle]
110pub extern "C" fn pamoja_duty_cycle_period_us(duty: PamojaDutyCycle) -> u64 {
111    micros(cycle(duty).period())
112}
113
114/// Returns the share of a period a duty cycle spends awake.
115///
116/// # Arguments
117///
118/// * `duty` - the duty cycle.
119///
120/// # Returns
121///
122/// The fraction from 0.0 through 1.0, or `0.0` if the period is zero.
123#[no_mangle]
124pub extern "C" fn pamoja_duty_cycle_fraction(duty: PamojaDutyCycle) -> f32 {
125    cycle(duty).fraction()
126}
127
128/// Creates a power plan from its three work intervals, with default thresholds.
129///
130/// The defaults enter [`PamojaPowerMode::Saver`] below 50% charge and
131/// [`PamojaPowerMode::Critical`] below 20%.
132///
133/// # Arguments
134///
135/// * `active_us` - the interval at a healthy charge, in microseconds.
136/// * `saver_us` - the longer interval used to conserve, in microseconds.
137/// * `critical_us` - the longest interval, in microseconds.
138///
139/// # Returns
140///
141/// The power plan.
142#[no_mangle]
143pub extern "C" fn pamoja_power_plan_new(
144    active_us: u64,
145    saver_us: u64,
146    critical_us: u64,
147) -> PamojaPowerPlan {
148    PamojaPowerPlan {
149        active_us,
150        saver_us,
151        critical_us,
152        saver_below: 0.5,
153        critical_below: 0.2,
154    }
155}
156
157/// Returns a plan with the state-of-charge thresholds moved.
158///
159/// # Arguments
160///
161/// * `plan` - the plan to adjust.
162/// * `saver_below` - enter [`PamojaPowerMode::Saver`] below this charge.
163/// * `critical_below` - enter [`PamojaPowerMode::Critical`] below this charge.
164///
165/// # Returns
166///
167/// The adjusted plan.
168#[no_mangle]
169pub extern "C" fn pamoja_power_plan_with_thresholds(
170    plan: PamojaPowerPlan,
171    saver_below: f32,
172    critical_below: f32,
173) -> PamojaPowerPlan {
174    PamojaPowerPlan {
175        saver_below,
176        critical_below,
177        ..plan
178    }
179}
180
181/// Returns the mode a plan calls for at a state of charge.
182///
183/// # Arguments
184///
185/// * `plan` - the power plan.
186/// * `soc` - the battery state of charge, from 0.0 through 1.0.
187///
188/// # Returns
189///
190/// The mode the node should run in.
191#[no_mangle]
192pub extern "C" fn pamoja_power_plan_mode(plan: PamojaPowerPlan, soc: f32) -> PamojaPowerMode {
193    mode(rust_plan(plan).mode(soc))
194}
195
196/// Returns the mode a plan calls for, easing off one step while charging.
197///
198/// A node taking charge is heading the right way, so it moves one step toward
199/// full duty rather than holding at what the charge alone would call for.
200///
201/// # Arguments
202///
203/// * `plan` - the power plan.
204/// * `soc` - the battery state of charge, from 0.0 through 1.0.
205/// * `charging` - `1` if the node is charging, `0` if it is not.
206///
207/// # Returns
208///
209/// The mode the node should run in.
210#[no_mangle]
211pub extern "C" fn pamoja_power_plan_mode_while_charging(
212    plan: PamojaPowerPlan,
213    soc: f32,
214    charging: u8,
215) -> PamojaPowerMode {
216    mode(rust_plan(plan).mode_while_charging(soc, charging != 0))
217}
218
219/// Returns the work interval a plan uses in a mode.
220///
221/// # Arguments
222///
223/// * `plan` - the power plan.
224/// * `mode` - the mode to look up.
225///
226/// # Returns
227///
228/// The interval in microseconds.
229#[no_mangle]
230pub extern "C" fn pamoja_power_plan_interval_for_us(
231    plan: PamojaPowerPlan,
232    mode: PamojaPowerMode,
233) -> u64 {
234    micros(rust_plan(plan).interval_for(rust_mode(mode)))
235}
236
237/// Returns the work interval a plan calls for at a state of charge.
238///
239/// # Arguments
240///
241/// * `plan` - the power plan.
242/// * `soc` - the battery state of charge, from 0.0 through 1.0.
243///
244/// # Returns
245///
246/// The interval in microseconds.
247#[no_mangle]
248pub extern "C" fn pamoja_power_plan_interval_us(plan: PamojaPowerPlan, soc: f32) -> u64 {
249    micros(rust_plan(plan).interval(soc))
250}
251
252/// Rebuilds the Rust duty cycle from the fields that crossed the boundary.
253fn cycle(duty: PamojaDutyCycle) -> DutyCycle {
254    DutyCycle::new(
255        Duration::from_micros(duty.active_us),
256        Duration::from_micros(duty.sleep_us),
257    )
258}
259
260/// Rebuilds the Rust power plan from the fields that crossed the boundary.
261fn rust_plan(plan: PamojaPowerPlan) -> PowerPlan {
262    PowerPlan::new(
263        Duration::from_micros(plan.active_us),
264        Duration::from_micros(plan.saver_us),
265        Duration::from_micros(plan.critical_us),
266    )
267    .thresholds(plan.saver_below, plan.critical_below)
268}
269
270/// Narrows a duration to the microseconds the boundary carries.
271fn micros(duration: Duration) -> u64 {
272    duration.as_micros().min(u128::from(u64::MAX)) as u64
273}
274
275/// Maps a Rust power mode onto the value that crosses the boundary.
276fn mode(mode: PowerMode) -> PamojaPowerMode {
277    match mode {
278        PowerMode::Active => PamojaPowerMode::Active,
279        PowerMode::Saver => PamojaPowerMode::Saver,
280        PowerMode::Critical => PamojaPowerMode::Critical,
281    }
282}
283
284/// Maps a boundary power mode back onto the Rust one.
285fn rust_mode(mode: PamojaPowerMode) -> PowerMode {
286    match mode {
287        PamojaPowerMode::Active => PowerMode::Active,
288        PamojaPowerMode::Saver => PowerMode::Saver,
289        PamojaPowerMode::Critical => PowerMode::Critical,
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn a_fraction_splits_the_period() {
299        let duty = pamoja_duty_cycle_from_fraction(1_000_000, 0.25);
300        assert_eq!(duty.active_us, 250_000);
301        assert_eq!(duty.sleep_us, 750_000);
302        assert_eq!(pamoja_duty_cycle_period_us(duty), 1_000_000);
303        assert!((pamoja_duty_cycle_fraction(duty) - 0.25).abs() < 1e-6);
304    }
305
306    #[test]
307    fn a_falling_charge_stretches_the_interval() {
308        let plan = pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000);
309
310        assert_eq!(pamoja_power_plan_mode(plan, 0.9), PamojaPowerMode::Active);
311        assert_eq!(pamoja_power_plan_mode(plan, 0.3), PamojaPowerMode::Saver);
312        assert_eq!(pamoja_power_plan_mode(plan, 0.1), PamojaPowerMode::Critical);
313        assert_eq!(pamoja_power_plan_interval_us(plan, 0.1), 3_600_000_000);
314    }
315
316    #[test]
317    fn charging_eases_a_low_node_up_one_step() {
318        let plan = pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000);
319
320        assert_eq!(
321            pamoja_power_plan_mode_while_charging(plan, 0.1, 1),
322            PamojaPowerMode::Saver
323        );
324        assert_eq!(
325            pamoja_power_plan_mode_while_charging(plan, 0.3, 1),
326            PamojaPowerMode::Active
327        );
328        assert_eq!(
329            pamoja_power_plan_mode_while_charging(plan, 0.1, 0),
330            PamojaPowerMode::Critical
331        );
332    }
333
334    #[test]
335    fn moved_thresholds_change_where_the_modes_meet() {
336        let plan = pamoja_power_plan_with_thresholds(
337            pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000),
338            0.8,
339            0.4,
340        );
341
342        assert_eq!(pamoja_power_plan_mode(plan, 0.7), PamojaPowerMode::Saver);
343        assert_eq!(pamoja_power_plan_mode(plan, 0.3), PamojaPowerMode::Critical);
344    }
345}