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, DEFAULT_HYSTERESIS};
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 and hysteresis, then move them with
41/// [`pamoja_power_plan_with_thresholds`] and [`pamoja_power_plan_with_hysteresis`].
42#[repr(C)]
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct PamojaPowerPlan {
45    /// The interval between work at a healthy charge, in microseconds.
46    pub active_us: u64,
47    /// The interval used to conserve charge, in microseconds.
48    pub saver_us: u64,
49    /// The interval used at a critically low charge, in microseconds.
50    pub critical_us: u64,
51    /// Enter [`PamojaPowerMode::Saver`] below this state of charge.
52    pub saver_below: f32,
53    /// Enter [`PamojaPowerMode::Critical`] below this state of charge.
54    pub critical_below: f32,
55    /// How far above a threshold the charge must climb before the plan leaves the lower
56    /// mode.
57    pub hysteresis: f32,
58}
59
60/// Creates a duty cycle from the time awake and the time asleep.
61///
62/// # Arguments
63///
64/// * `active_us` - how long the node works each period, in microseconds.
65/// * `sleep_us` - how long it sleeps each period, in microseconds.
66///
67/// # Returns
68///
69/// The duty cycle.
70#[no_mangle]
71pub extern "C" fn pamoja_duty_cycle_new(active_us: u64, sleep_us: u64) -> PamojaDutyCycle {
72    let duty = DutyCycle::new(
73        Duration::from_micros(active_us),
74        Duration::from_micros(sleep_us),
75    );
76    PamojaDutyCycle {
77        active_us: micros(duty.active()),
78        sleep_us: micros(duty.sleep()),
79    }
80}
81
82/// Creates a duty cycle that spends a fraction of each period awake.
83///
84/// The time awake is rounded down to a whole microsecond and the rest of the period is
85/// spent asleep, so the two always add up to the period and the awake share never runs
86/// over the fraction.
87///
88/// # Arguments
89///
90/// * `period_us` - the whole period, in microseconds.
91/// * `fraction` - the share of the period spent awake, clamped to 0.0 through 1.0. A
92///   fraction that is not a number keeps the node asleep for the whole period.
93///
94/// # Returns
95///
96/// The duty cycle.
97#[no_mangle]
98pub extern "C" fn pamoja_duty_cycle_from_fraction(
99    period_us: u64,
100    fraction: f32,
101) -> PamojaDutyCycle {
102    let duty = DutyCycle::from_fraction(Duration::from_micros(period_us), fraction);
103    let active_us = micros(duty.active());
104    PamojaDutyCycle {
105        active_us,
106        sleep_us: period_us - active_us,
107    }
108}
109
110/// Returns the whole period of a duty cycle, awake plus asleep.
111///
112/// # Arguments
113///
114/// * `duty` - the duty cycle.
115///
116/// # Returns
117///
118/// The period in microseconds.
119#[no_mangle]
120pub extern "C" fn pamoja_duty_cycle_period_us(duty: PamojaDutyCycle) -> u64 {
121    micros(cycle(duty).period())
122}
123
124/// Returns the share of a period a duty cycle spends awake.
125///
126/// # Arguments
127///
128/// * `duty` - the duty cycle.
129///
130/// # Returns
131///
132/// The fraction from 0.0 through 1.0, or `0.0` if the period is zero.
133#[no_mangle]
134pub extern "C" fn pamoja_duty_cycle_fraction(duty: PamojaDutyCycle) -> f32 {
135    cycle(duty).fraction()
136}
137
138/// Creates a power plan from its three work intervals, with default thresholds.
139///
140/// The defaults enter [`PamojaPowerMode::Saver`] below 50% charge and
141/// [`PamojaPowerMode::Critical`] below 20%, and leave each lower mode once the charge is
142/// five points above the threshold that brought it on.
143///
144/// # Arguments
145///
146/// * `active_us` - the interval at a healthy charge, in microseconds.
147/// * `saver_us` - the longer interval used to conserve, in microseconds.
148/// * `critical_us` - the longest interval, in microseconds.
149///
150/// # Returns
151///
152/// The power plan.
153#[no_mangle]
154pub extern "C" fn pamoja_power_plan_new(
155    active_us: u64,
156    saver_us: u64,
157    critical_us: u64,
158) -> PamojaPowerPlan {
159    PamojaPowerPlan {
160        active_us,
161        saver_us,
162        critical_us,
163        saver_below: 0.5,
164        critical_below: 0.2,
165        hysteresis: DEFAULT_HYSTERESIS,
166    }
167}
168
169/// Returns a plan with the state-of-charge thresholds moved.
170///
171/// # Arguments
172///
173/// * `plan` - the plan to adjust.
174/// * `saver_below` - enter [`PamojaPowerMode::Saver`] below this charge.
175/// * `critical_below` - enter [`PamojaPowerMode::Critical`] below this charge.
176///
177/// # Returns
178///
179/// The adjusted plan.
180#[no_mangle]
181pub extern "C" fn pamoja_power_plan_with_thresholds(
182    plan: PamojaPowerPlan,
183    saver_below: f32,
184    critical_below: f32,
185) -> PamojaPowerPlan {
186    PamojaPowerPlan {
187        saver_below,
188        critical_below,
189        ..plan
190    }
191}
192
193/// Returns a plan with the hysteresis margin moved.
194///
195/// # Arguments
196///
197/// * `plan` - the plan to adjust.
198/// * `margin` - how far above a threshold the charge must climb before the plan leaves
199///   the lower mode; `0.0` switches at the thresholds themselves, and a margin that is
200///   negative or not a number is taken as `0.0`.
201///
202/// # Returns
203///
204/// The adjusted plan.
205#[no_mangle]
206pub extern "C" fn pamoja_power_plan_with_hysteresis(
207    plan: PamojaPowerPlan,
208    margin: f32,
209) -> PamojaPowerPlan {
210    PamojaPowerPlan {
211        hysteresis: rust_plan(plan).with_hysteresis(margin).hysteresis(),
212        ..plan
213    }
214}
215
216/// Returns the mode a node in a given mode moves to at a new state of charge.
217///
218/// The node drops to a lower mode as soon as the charge falls below its threshold, and
219/// climbs to a higher one only once the charge reaches the threshold plus the plan's
220/// hysteresis, so a charge wandering around a threshold does not switch the mode on
221/// every reading.
222///
223/// # Arguments
224///
225/// * `plan` - the power plan.
226/// * `current` - the mode the node is running in.
227/// * `soc` - the battery state of charge, from 0.0 through 1.0.
228///
229/// # Returns
230///
231/// The mode the node should run in next.
232#[no_mangle]
233pub extern "C" fn pamoja_power_plan_next_mode(
234    plan: PamojaPowerPlan,
235    current: PamojaPowerMode,
236    soc: f32,
237) -> PamojaPowerMode {
238    mode(rust_plan(plan).next_mode(rust_mode(current), soc))
239}
240
241/// Returns the mode a node in a given mode moves to, easing off one step while
242/// charging.
243///
244/// # Arguments
245///
246/// * `plan` - the power plan.
247/// * `current` - the mode the node is running in, as this returned it last time.
248/// * `soc` - the battery state of charge, from 0.0 through 1.0.
249/// * `charging` - `1` if the node is charging, `0` if it is not.
250///
251/// # Returns
252///
253/// The mode the node should run in next.
254#[no_mangle]
255pub extern "C" fn pamoja_power_plan_next_mode_while_charging(
256    plan: PamojaPowerPlan,
257    current: PamojaPowerMode,
258    soc: f32,
259    charging: u8,
260) -> PamojaPowerMode {
261    mode(rust_plan(plan).next_mode_while_charging(rust_mode(current), soc, charging != 0))
262}
263
264/// Returns the mode a plan calls for at a state of charge.
265///
266/// A charge that is not a number, such as a fuel gauge that failed to answer, is taken
267/// as critical.
268///
269/// # Arguments
270///
271/// * `plan` - the power plan.
272/// * `soc` - the battery state of charge, from 0.0 through 1.0.
273///
274/// # Returns
275///
276/// The mode the node should run in.
277#[no_mangle]
278pub extern "C" fn pamoja_power_plan_mode(plan: PamojaPowerPlan, soc: f32) -> PamojaPowerMode {
279    mode(rust_plan(plan).mode(soc))
280}
281
282/// Returns the mode a plan calls for, easing off one step while charging.
283///
284/// A node taking charge is heading the right way, so it moves one step toward
285/// full duty rather than holding at what the charge alone would call for.
286///
287/// # Arguments
288///
289/// * `plan` - the power plan.
290/// * `soc` - the battery state of charge, from 0.0 through 1.0.
291/// * `charging` - `1` if the node is charging, `0` if it is not.
292///
293/// # Returns
294///
295/// The mode the node should run in.
296#[no_mangle]
297pub extern "C" fn pamoja_power_plan_mode_while_charging(
298    plan: PamojaPowerPlan,
299    soc: f32,
300    charging: u8,
301) -> PamojaPowerMode {
302    mode(rust_plan(plan).mode_while_charging(soc, charging != 0))
303}
304
305/// Returns the work interval a plan uses in a mode.
306///
307/// # Arguments
308///
309/// * `plan` - the power plan.
310/// * `mode` - the mode to look up.
311///
312/// # Returns
313///
314/// The interval in microseconds.
315#[no_mangle]
316pub extern "C" fn pamoja_power_plan_interval_for_us(
317    plan: PamojaPowerPlan,
318    mode: PamojaPowerMode,
319) -> u64 {
320    micros(rust_plan(plan).interval_for(rust_mode(mode)))
321}
322
323/// Returns the work interval a plan calls for at a state of charge.
324///
325/// # Arguments
326///
327/// * `plan` - the power plan.
328/// * `soc` - the battery state of charge, from 0.0 through 1.0.
329///
330/// # Returns
331///
332/// The interval in microseconds.
333#[no_mangle]
334pub extern "C" fn pamoja_power_plan_interval_us(plan: PamojaPowerPlan, soc: f32) -> u64 {
335    micros(rust_plan(plan).interval(soc))
336}
337
338/// Rebuilds the Rust duty cycle from the fields that crossed the boundary.
339fn cycle(duty: PamojaDutyCycle) -> DutyCycle {
340    DutyCycle::new(
341        Duration::from_micros(duty.active_us),
342        Duration::from_micros(duty.sleep_us),
343    )
344}
345
346/// Rebuilds the Rust power plan from the fields that crossed the boundary.
347fn rust_plan(plan: PamojaPowerPlan) -> PowerPlan {
348    PowerPlan::new(
349        Duration::from_micros(plan.active_us),
350        Duration::from_micros(plan.saver_us),
351        Duration::from_micros(plan.critical_us),
352    )
353    .thresholds(plan.saver_below, plan.critical_below)
354    .with_hysteresis(plan.hysteresis)
355}
356
357/// Narrows a duration to the microseconds the boundary carries.
358fn micros(duration: Duration) -> u64 {
359    duration.as_micros().min(u128::from(u64::MAX)) as u64
360}
361
362/// Maps a Rust power mode onto the value that crosses the boundary.
363fn mode(mode: PowerMode) -> PamojaPowerMode {
364    match mode {
365        PowerMode::Active => PamojaPowerMode::Active,
366        PowerMode::Saver => PamojaPowerMode::Saver,
367        PowerMode::Critical => PamojaPowerMode::Critical,
368    }
369}
370
371/// Maps a boundary power mode back onto the Rust one.
372fn rust_mode(mode: PamojaPowerMode) -> PowerMode {
373    match mode {
374        PamojaPowerMode::Active => PowerMode::Active,
375        PamojaPowerMode::Saver => PowerMode::Saver,
376        PamojaPowerMode::Critical => PowerMode::Critical,
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn a_split_that_is_not_whole_microseconds_still_adds_up_to_the_period() {
386        let duty = pamoja_duty_cycle_from_fraction(60_000_000, 0.1);
387        assert_eq!(duty.active_us, 6_000_000);
388        assert_eq!(duty.sleep_us, 54_000_000);
389        assert_eq!(pamoja_duty_cycle_period_us(duty), 60_000_000);
390    }
391
392    #[test]
393    fn a_fraction_splits_the_period() {
394        let duty = pamoja_duty_cycle_from_fraction(1_000_000, 0.25);
395        assert_eq!(duty.active_us, 250_000);
396        assert_eq!(duty.sleep_us, 750_000);
397        assert_eq!(pamoja_duty_cycle_period_us(duty), 1_000_000);
398        assert!((pamoja_duty_cycle_fraction(duty) - 0.25).abs() < 1e-6);
399    }
400
401    #[test]
402    fn a_falling_charge_stretches_the_interval() {
403        let plan = pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000);
404
405        assert_eq!(pamoja_power_plan_mode(plan, 0.9), PamojaPowerMode::Active);
406        assert_eq!(pamoja_power_plan_mode(plan, 0.3), PamojaPowerMode::Saver);
407        assert_eq!(pamoja_power_plan_mode(plan, 0.1), PamojaPowerMode::Critical);
408        assert_eq!(pamoja_power_plan_interval_us(plan, 0.1), 3_600_000_000);
409    }
410
411    #[test]
412    fn charging_eases_a_low_node_up_one_step() {
413        let plan = pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000);
414
415        assert_eq!(
416            pamoja_power_plan_mode_while_charging(plan, 0.1, 1),
417            PamojaPowerMode::Saver
418        );
419        assert_eq!(
420            pamoja_power_plan_mode_while_charging(plan, 0.3, 1),
421            PamojaPowerMode::Active
422        );
423        assert_eq!(
424            pamoja_power_plan_mode_while_charging(plan, 0.1, 0),
425            PamojaPowerMode::Critical
426        );
427    }
428
429    #[test]
430    fn moved_thresholds_change_where_the_modes_meet() {
431        let plan = pamoja_power_plan_with_thresholds(
432            pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000),
433            0.8,
434            0.4,
435        );
436
437        assert_eq!(pamoja_power_plan_mode(plan, 0.7), PamojaPowerMode::Saver);
438        assert_eq!(pamoja_power_plan_mode(plan, 0.3), PamojaPowerMode::Critical);
439    }
440
441    #[test]
442    fn a_charge_must_clear_the_margin_to_climb_back() {
443        let plan = pamoja_power_plan_new(60_000_000, 300_000_000, 3_600_000_000);
444        assert_eq!(plan.hysteresis, DEFAULT_HYSTERESIS);
445
446        let saver = PamojaPowerMode::Saver;
447        assert_eq!(pamoja_power_plan_next_mode(plan, saver, 0.52), saver);
448        assert_eq!(
449            pamoja_power_plan_next_mode(plan, saver, 0.56),
450            PamojaPowerMode::Active
451        );
452        assert_eq!(
453            pamoja_power_plan_next_mode_while_charging(plan, saver, 0.22, 1),
454            saver
455        );
456
457        let flat = pamoja_power_plan_with_hysteresis(plan, 0.0);
458        assert_eq!(
459            pamoja_power_plan_next_mode(flat, saver, 0.52),
460            PamojaPowerMode::Active
461        );
462        assert_eq!(
463            pamoja_power_plan_with_hysteresis(plan, -1.0).hysteresis,
464            0.0
465        );
466    }
467}