243 lines
7.7 KiB
Rust
243 lines
7.7 KiB
Rust
//! Power management for battery-operated ESP32 sensor nodes.
|
|
//!
|
|
//! Provides duty-cycle estimation, sleep scheduling, and automatic duty-cycle
|
|
//! optimization to hit a target runtime.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Operating power mode.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum PowerMode {
|
|
/// Full speed — all peripherals active.
|
|
Active,
|
|
/// Reduced clock, WiFi power save.
|
|
LowPower,
|
|
/// Minimal peripherals, deep sleep between samples.
|
|
UltraLowPower,
|
|
/// Full deep sleep — wakes only on timer or external interrupt.
|
|
Sleep,
|
|
}
|
|
|
|
impl PowerMode {
|
|
/// Estimated current draw in milliamps for this mode on an ESP32-S3.
|
|
pub fn estimated_current_ma(&self) -> f64 {
|
|
match self {
|
|
PowerMode::Active => 240.0,
|
|
PowerMode::LowPower => 80.0,
|
|
PowerMode::UltraLowPower => 20.0,
|
|
PowerMode::Sleep => 0.01,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Power management configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PowerConfig {
|
|
/// Base operating mode.
|
|
pub mode: PowerMode,
|
|
/// Whether to enter light sleep between sample bursts.
|
|
pub sleep_between_samples: bool,
|
|
/// Fraction of time spent actively sampling (0.0-1.0).
|
|
pub sample_duty_cycle: f64,
|
|
/// Fraction of time WiFi is enabled (0.0-1.0).
|
|
pub wifi_duty_cycle: f64,
|
|
}
|
|
|
|
impl Default for PowerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
mode: PowerMode::Active,
|
|
sleep_between_samples: false,
|
|
sample_duty_cycle: 1.0,
|
|
wifi_duty_cycle: 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Power manager that tracks battery state and optimizes duty cycles.
|
|
pub struct PowerManager {
|
|
config: PowerConfig,
|
|
battery_mv: u32,
|
|
estimated_runtime_hours: f64,
|
|
}
|
|
|
|
impl PowerManager {
|
|
/// Create a new power manager with the given configuration.
|
|
pub fn new(config: PowerConfig) -> Self {
|
|
Self {
|
|
config,
|
|
battery_mv: 4200, // Fully charged LiPo
|
|
estimated_runtime_hours: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Estimate runtime in hours given a battery capacity in mAh.
|
|
///
|
|
/// The effective current draw is a weighted average of active and sleep
|
|
/// currents based on the configured duty cycles.
|
|
pub fn estimate_runtime(&self, battery_capacity_mah: u32) -> f64 {
|
|
let active_current = self.config.mode.estimated_current_ma();
|
|
let sleep_current = PowerMode::Sleep.estimated_current_ma();
|
|
|
|
let sample_active = self.config.sample_duty_cycle.clamp(0.0, 1.0);
|
|
let wifi_active = self.config.wifi_duty_cycle.clamp(0.0, 1.0);
|
|
|
|
// WiFi adds roughly 80 mA when active
|
|
let wifi_overhead = 80.0 * wifi_active;
|
|
|
|
let effective_current =
|
|
active_current * sample_active + sleep_current * (1.0 - sample_active) + wifi_overhead;
|
|
|
|
if effective_current <= 0.0 {
|
|
return f64::INFINITY;
|
|
}
|
|
|
|
battery_capacity_mah as f64 / effective_current
|
|
}
|
|
|
|
/// Returns `true` if the node should sleep at the given time based on
|
|
/// the configured duty cycle.
|
|
///
|
|
/// Uses a simple periodic pattern: active for `duty * period`, then sleep
|
|
/// for the remainder. The period is fixed at 1 second (1_000_000 us).
|
|
pub fn should_sleep(&self, current_time_us: u64) -> bool {
|
|
if !self.config.sleep_between_samples {
|
|
return false;
|
|
}
|
|
let period_us: u64 = 1_000_000;
|
|
let active_us = (self.config.sample_duty_cycle * period_us as f64) as u64;
|
|
let position = current_time_us % period_us;
|
|
position >= active_us
|
|
}
|
|
|
|
/// Adjust the sample and WiFi duty cycles to reach the target runtime.
|
|
pub fn optimize_duty_cycle(&mut self, target_runtime_hours: f64) {
|
|
// Binary search for the duty cycle that achieves the target runtime
|
|
// with a 2000 mAh reference battery.
|
|
let battery_mah = 2000u32;
|
|
let mut low = 0.01_f64;
|
|
let mut high = 1.0_f64;
|
|
|
|
for _ in 0..50 {
|
|
let mid = (low + high) / 2.0;
|
|
self.config.sample_duty_cycle = mid;
|
|
self.config.wifi_duty_cycle = mid;
|
|
let runtime = self.estimate_runtime(battery_mah);
|
|
if runtime < target_runtime_hours {
|
|
high = mid;
|
|
} else {
|
|
low = mid;
|
|
}
|
|
}
|
|
|
|
self.config.sample_duty_cycle = low;
|
|
self.config.wifi_duty_cycle = low;
|
|
self.estimated_runtime_hours = self.estimate_runtime(battery_mah);
|
|
}
|
|
|
|
/// Update the battery voltage reading.
|
|
pub fn set_battery_mv(&mut self, mv: u32) {
|
|
self.battery_mv = mv;
|
|
}
|
|
|
|
/// Current battery voltage in millivolts.
|
|
pub fn battery_mv(&self) -> u32 {
|
|
self.battery_mv
|
|
}
|
|
|
|
/// Estimated remaining runtime in hours (after calling
|
|
/// `optimize_duty_cycle`).
|
|
pub fn estimated_runtime_hours(&self) -> f64 {
|
|
self.estimated_runtime_hours
|
|
}
|
|
|
|
/// Returns a reference to the current power configuration.
|
|
pub fn config(&self) -> &PowerConfig {
|
|
&self.config
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_estimate_runtime_active() {
|
|
let config = PowerConfig {
|
|
mode: PowerMode::Active,
|
|
sleep_between_samples: false,
|
|
sample_duty_cycle: 1.0,
|
|
wifi_duty_cycle: 1.0,
|
|
};
|
|
let pm = PowerManager::new(config);
|
|
let hours = pm.estimate_runtime(2000);
|
|
// 2000 mAh / (240 + 80) = 6.25 hours
|
|
assert!((hours - 6.25).abs() < 0.1, "got {hours}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_estimate_runtime_low_duty() {
|
|
let config = PowerConfig {
|
|
mode: PowerMode::Active,
|
|
sleep_between_samples: true,
|
|
sample_duty_cycle: 0.1,
|
|
wifi_duty_cycle: 0.1,
|
|
};
|
|
let pm = PowerManager::new(config);
|
|
let hours = pm.estimate_runtime(2000);
|
|
// Much longer than 6.25 hours
|
|
assert!(hours > 20.0, "expected >20h, got {hours}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_sleep() {
|
|
let config = PowerConfig {
|
|
mode: PowerMode::Active,
|
|
sleep_between_samples: true,
|
|
sample_duty_cycle: 0.5,
|
|
wifi_duty_cycle: 1.0,
|
|
};
|
|
let pm = PowerManager::new(config);
|
|
// Active window: 0..500_000 us, sleep: 500_000..1_000_000 us
|
|
assert!(!pm.should_sleep(0));
|
|
assert!(!pm.should_sleep(499_999));
|
|
assert!(pm.should_sleep(500_000));
|
|
assert!(pm.should_sleep(999_999));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_sleep_disabled() {
|
|
let config = PowerConfig {
|
|
mode: PowerMode::Active,
|
|
sleep_between_samples: false,
|
|
sample_duty_cycle: 0.1,
|
|
wifi_duty_cycle: 0.1,
|
|
};
|
|
let pm = PowerManager::new(config);
|
|
assert!(!pm.should_sleep(999_999));
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimize_duty_cycle() {
|
|
let config = PowerConfig {
|
|
mode: PowerMode::Active,
|
|
sleep_between_samples: true,
|
|
sample_duty_cycle: 1.0,
|
|
wifi_duty_cycle: 1.0,
|
|
};
|
|
let mut pm = PowerManager::new(config);
|
|
pm.optimize_duty_cycle(48.0); // Target 48 hours
|
|
|
|
// Duty cycles should have been reduced
|
|
assert!(pm.config().sample_duty_cycle < 1.0);
|
|
assert!(pm.config().sample_duty_cycle > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_power_mode_current() {
|
|
assert!(PowerMode::Active.estimated_current_ma() > PowerMode::LowPower.estimated_current_ma());
|
|
assert!(PowerMode::LowPower.estimated_current_ma() > PowerMode::UltraLowPower.estimated_current_ma());
|
|
assert!(PowerMode::UltraLowPower.estimated_current_ma() > PowerMode::Sleep.estimated_current_ma());
|
|
}
|
|
}
|