diff --git a/README.md b/README.md index eb110c8..0488b8f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@
Synopsis
Features | Usage
- Contributing + Contributing
@@ -176,7 +176,8 @@ max_freq_mhz = 2500 # Global battery charging thresholds (applied to both profiles unless overridden) # Start charging at 40%, stop at 80% - extends battery lifespan -# NOTE: Profile-specific thresholds (in [charger] or [battery] sections) take precedence over this global setting +# NOTE: Profile-specific thresholds (in [charger] or [battery] sections) +# take precedence over this global setting battery_charge_thresholds = [40, 80] # Daemon configuration @@ -210,11 +211,28 @@ create an issue. ### Adaptive Polling -The daemon mode uses adaptive polling to balance responsiveness with efficiency: +Superfreq includes a "sophisticated" (euphemism for complicated) adaptive +polling system to try and maximize power efficiency -- Increases polling frequency during system changes -- Decreases polling frequency during stable periods -- Reduces polling when on battery to save power +- **Battery Discharge Analysis** - Automatically adjusts polling frequency based + on the battery discharge rate, reducing system activity when battery is + draining quickly +- **System Activity Pattern Recognition** - Monitors CPU usage and temperature + patterns to identify system stability +- **Dynamic Interval Calculation** - Uses multiple factors to determine optimal + polling intervals - up to 3x longer on battery with minimal user impact +- **Idle Detection** - Significantly reduces polling frequency during extended + idle periods to minimize power consumption +- **Gradual Transition** - Smooth transitions between polling rates to avoid + performance spikes +- **Progressive Back-off** - Implements logarithmic back-off during idle periods + (1min -> 1.5x, 2min -> 2x, 4min -> 3x, 8min -> 4x, 16min -> 5x) +- **Battery Discharge Protection** - Includes safeguards against measurement + noise to prevent erratic polling behavior + +When enabled, this intelligent polling system provides substantial power savings +over conventional fixed-interval approaches, especially during low-activity or +idle periods, while maintaining responsiveness when needed. ### Power Supply Filtering @@ -257,11 +275,25 @@ the codebase as they stand. ### Setup -You will need Cargo and Rust installed on your system. For Nix users, using -Direnv is encouraged. +You will need Cargo and Rust installed on your system. Rust 1.80 or later is required. -Non-Nix users may get the appropriate Cargo andn Rust versions from their -package manager. +A `.envrc` is provided, and it's usage is encouraged for Nix users. +Alternatively, you may use Nix for a reproducible developer environment + +```bash +nix develop +``` + +Non-Nix users may get the appropriate Cargo and Rust versions from their package +manager. + +### Formatting + +Please make sure to run _at least_ `cargo fmt` inside the repository to make +sure all of your code is properly formatted. For Nix code, please use Alejandra. + +Clippy lints are not _required_ as of now, but a good rule of thumb to run them +before committing to catch possible code smell early. ## License diff --git a/src/daemon.rs b/src/daemon.rs index dfa4ebb..82349cb 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -4,12 +4,343 @@ use crate::core::SystemReport; use crate::engine; use crate::monitor; use log::{LevelFilter, debug, error, info, warn}; +use std::collections::VecDeque; use std::fs::File; use std::io::Write; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; +/// Parameters for computing optimal polling interval +struct IntervalParams { + /// Base polling interval in seconds + base_interval: u64, + /// Minimum allowed polling interval in seconds + min_interval: u64, + /// Maximum allowed polling interval in seconds + max_interval: u64, + /// How rapidly CPU usage is changing + cpu_volatility: f32, + /// How rapidly temperature is changing + temp_volatility: f32, + /// Battery discharge rate in %/hour if available + battery_discharge_rate: Option, + /// Time since last detected user activity + last_user_activity: Duration, + /// Whether the system appears to be idle + is_system_idle: bool, + /// Whether the system is running on battery power + on_battery: bool, +} + +/// Calculate the idle time multiplier based on system idle duration +/// +/// Returns a multiplier between 1.0 and 5.0 (capped): +/// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0 +/// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes)) +fn idle_multiplier(idle_secs: u64) -> f32 { + if idle_secs == 0 { + return 1.0; // No idle time, no multiplier effect + } + + let idle_factor = if idle_secs < 120 { + // Less than 2 minutes (0 to 119 seconds) + // Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s) + 1.0 + (idle_secs as f32) / 120.0 + } else { + // 2 minutes (120 seconds) or more + let idle_time_minutes = idle_secs / 60; + // Logarithmic scaling: 1.0 + log2(minutes) + 1.0 + (idle_time_minutes as f32).log2().max(0.5) + }; + + // Cap the multiplier to avoid excessive intervals + idle_factor.min(5.0) // max factor of 5x +} + +/// Calculate optimal polling interval based on system conditions and history +fn compute_new(params: &IntervalParams) -> u64 { + // Start with base interval + let mut adjusted_interval = params.base_interval; + + // If we're on battery, we want to be more aggressive about saving power + if params.on_battery { + // Apply a multiplier based on battery discharge rate + if let Some(discharge_rate) = params.battery_discharge_rate { + if discharge_rate > 20.0 { + // High discharge rate - increase polling interval significantly (3x) + adjusted_interval = adjusted_interval.saturating_mul(3); + } else if discharge_rate > 10.0 { + // Moderate discharge - double polling interval (2x) + adjusted_interval = adjusted_interval.saturating_mul(2); + } else { + // Low discharge rate - increase by 50% (multiply by 3/2) + adjusted_interval = adjusted_interval.saturating_mul(3).saturating_div(2); + } + } else { + // If we don't know discharge rate, use a conservative multiplier (2x) + adjusted_interval = adjusted_interval.saturating_mul(2); + } + } + + // Adjust for system idleness + if params.is_system_idle { + let idle_time_seconds = params.last_user_activity.as_secs(); + + // Apply adjustment only if the system has been idle for a non-zero duration + if idle_time_seconds > 0 { + let idle_factor = idle_multiplier(idle_time_seconds); + + debug!( + "System idle for {} seconds (approx. {} minutes), applying idle factor: {:.2}x", + idle_time_seconds, + (idle_time_seconds as f32 / 60.0).round(), + idle_factor + ); + + // Convert f32 multiplier to integer-safe math + // Multiply by a large number first, then divide to maintain precision + // Use 1000 as the scaling factor to preserve up to 3 decimal places + let scaling_factor = 1000; + let scaled_factor = (idle_factor * scaling_factor as f32) as u64; + adjusted_interval = adjusted_interval + .saturating_mul(scaled_factor) + .saturating_div(scaling_factor); + } + // If idle_time_seconds is 0, no factor is applied by this block + } + + // Adjust for CPU/temperature volatility + if params.cpu_volatility > 10.0 || params.temp_volatility > 2.0 { + // For division by 2 (halving the interval), we can safely use integer division + adjusted_interval = (adjusted_interval / 2).max(1); + } + + // Ensure interval stays within configured bounds + adjusted_interval.clamp(params.min_interval, params.max_interval) +} + +/// Tracks historical system data for "advanced" adaptive polling +#[derive(Debug)] +struct SystemHistory { + /// Last several CPU usage measurements + cpu_usage_history: VecDeque, + /// Last several temperature readings + temperature_history: VecDeque, + /// Time of last detected user activity + last_user_activity: Instant, + /// Previous battery percentage (to calculate discharge rate) + last_battery_percentage: Option, + /// Timestamp of last battery reading + last_battery_timestamp: Option, + /// Battery discharge rate (%/hour) + battery_discharge_rate: Option, + /// Time spent in each system state + state_durations: std::collections::HashMap, + /// Last time a state transition happened + last_state_change: Instant, + /// Current system state + current_state: SystemState, +} + +impl Default for SystemHistory { + fn default() -> Self { + Self { + cpu_usage_history: VecDeque::new(), + temperature_history: VecDeque::new(), + last_user_activity: Instant::now(), + last_battery_percentage: None, + last_battery_timestamp: None, + battery_discharge_rate: None, + state_durations: std::collections::HashMap::new(), + last_state_change: Instant::now(), + current_state: SystemState::default(), + } + } +} + +impl SystemHistory { + /// Update system history with new report data + fn update(&mut self, report: &SystemReport) { + // Update CPU usage history + if !report.cpu_cores.is_empty() { + let mut total_usage: f32 = 0.0; + let mut core_count: usize = 0; + + for core in &report.cpu_cores { + if let Some(usage) = core.usage_percent { + total_usage += usage; + core_count += 1; + } + } + + if core_count > 0 { + let avg_usage = total_usage / core_count as f32; + + // Keep only the last 5 measurements + if self.cpu_usage_history.len() >= 5 { + self.cpu_usage_history.pop_front(); + } + self.cpu_usage_history.push_back(avg_usage); + + // Update last_user_activity if CPU usage indicates activity + // Consider significant CPU usage or sudden change as user activity + if avg_usage > 20.0 + || (self.cpu_usage_history.len() > 1 + && (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2]) + .abs() + > 15.0) + { + self.last_user_activity = Instant::now(); + debug!("User activity detected based on CPU usage"); + } + } + } + + // Update temperature history + if let Some(temp) = report.cpu_global.average_temperature_celsius { + if self.temperature_history.len() >= 5 { + self.temperature_history.pop_front(); + } + self.temperature_history.push_back(temp); + + // Significant temperature increase can indicate user activity + if self.temperature_history.len() > 1 { + let temp_change = + temp - self.temperature_history[self.temperature_history.len() - 2]; + if temp_change > 5.0 { + // 5°C rise in temperature + self.last_user_activity = Instant::now(); + debug!("User activity detected based on temperature change"); + } + } + } + + // Update battery discharge rate + if let Some(battery) = report.batteries.first() { + // Reset when we are charging or have just connected AC + if battery.ac_connected { + // Reset discharge tracking but continue updating the rest of + // the history so we still detect activity/load changes on AC. + self.battery_discharge_rate = None; + self.last_battery_percentage = None; + self.last_battery_timestamp = None; + } + + if let Some(current_percentage) = battery.capacity_percent { + let current_percent = f32::from(current_percentage); + + if let (Some(last_percentage), Some(last_timestamp)) = + (self.last_battery_percentage, self.last_battery_timestamp) + { + let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0; + // Only calculate discharge rate if at least 30 seconds have passed + // and we're not on AC power + if elapsed_hours > 0.0083 && !battery.ac_connected { + // 0.0083 hours = 30 seconds + // Calculate discharge rate in percent per hour + let percent_change = last_percentage - current_percent; + if percent_change > 0.0 { + // Only if battery is discharging + let hourly_rate = percent_change / elapsed_hours; + // Clamp the discharge rate to a reasonable maximum value (100%/hour) + let clamped_rate = hourly_rate.min(100.0); + self.battery_discharge_rate = Some(clamped_rate); + } + } + } + + self.last_battery_percentage = Some(current_percent); + self.last_battery_timestamp = Some(Instant::now()); + } + } + + // Update system state tracking + let new_state = determine_system_state(report, self); + if new_state != self.current_state { + // Record time spent in previous state + let time_in_state = self.last_state_change.elapsed(); + *self + .state_durations + .entry(self.current_state.clone()) + .or_insert(Duration::ZERO) += time_in_state; + + // State changes (except to Idle) likely indicate user activity + if new_state != SystemState::Idle && new_state != SystemState::LowLoad { + self.last_user_activity = Instant::now(); + debug!("User activity detected based on system state change to {new_state:?}"); + } + + // Update state + self.current_state = new_state; + self.last_state_change = Instant::now(); + } + + // Check for significant load changes + if report.system_load.load_avg_1min > 1.0 { + self.last_user_activity = Instant::now(); + debug!("User activity detected based on system load"); + } + } + + /// Calculate CPU usage volatility (how much it's changing) + fn get_cpu_volatility(&self) -> f32 { + if self.cpu_usage_history.len() < 2 { + return 0.0; + } + + let mut sum_of_changes = 0.0; + for i in 1..self.cpu_usage_history.len() { + sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs(); + } + + sum_of_changes / (self.cpu_usage_history.len() - 1) as f32 + } + + /// Calculate temperature volatility + fn get_temperature_volatility(&self) -> f32 { + if self.temperature_history.len() < 2 { + return 0.0; + } + + let mut sum_of_changes = 0.0; + for i in 1..self.temperature_history.len() { + sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs(); + } + + sum_of_changes / (self.temperature_history.len() - 1) as f32 + } + + /// Determine if the system appears to be idle + fn is_system_idle(&self) -> bool { + if self.cpu_usage_history.is_empty() { + return false; + } + + // System considered idle if the average CPU usage of last readings is below 10% + let recent_avg = + self.cpu_usage_history.iter().sum::() / self.cpu_usage_history.len() as f32; + recent_avg < 10.0 && self.get_cpu_volatility() < 5.0 + } + + /// Calculate optimal polling interval based on system conditions + fn calculate_optimal_interval(&self, config: &AppConfig, on_battery: bool) -> u64 { + let params = IntervalParams { + base_interval: config.daemon.poll_interval_sec, + min_interval: config.daemon.min_poll_interval_sec, + max_interval: config.daemon.max_poll_interval_sec, + cpu_volatility: self.get_cpu_volatility(), + temp_volatility: self.get_temperature_volatility(), + battery_discharge_rate: self.battery_discharge_rate, + last_user_activity: self.last_user_activity.elapsed(), + is_system_idle: self.is_system_idle(), + on_battery, + }; + + compute_new(¶ms) + } +} + /// Run the daemon pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box> { // Set effective log level based on config and verbose flag @@ -83,9 +414,12 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box Result<(), Box { error!("Error loading new configuration: {e}"); @@ -115,8 +454,11 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box { debug!("Collected system report, applying settings..."); - // Determine current system state - let current_state = determine_system_state(&report); + // Store the current state before updating history + let previous_state = system_history.current_state.clone(); + + // Update system history with new data + system_history.update(&report); // Update the stats file if configured if let Some(stats_path) = &config.daemon.stats_file_path { @@ -129,12 +471,12 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box { debug!("Successfully applied system settings"); - // If system state changed or settings were applied differently, record the time - if current_state != last_system_state { - last_settings_change = Instant::now(); - last_system_state = current_state.clone(); - - info!("System state changed to: {current_state:?}"); + // If system state changed, log the new state + if system_history.current_state != previous_state { + info!( + "System state changed to: {:?}", + system_history.current_state + ); } } Err(e) => { @@ -142,38 +484,51 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box 60 { - current_poll_interval = - (current_poll_interval * 2).min(config.daemon.max_poll_interval_sec); - - debug!("Adaptive polling: increasing interval to {current_poll_interval}s"); - } else if time_since_change < 10 { - // If we've had recent changes, decrease the interval (down to min) - current_poll_interval = - (current_poll_interval / 2).max(config.daemon.min_poll_interval_sec); - - debug!("Adaptive polling: decreasing interval to {current_poll_interval}s"); + // Don't change the interval too dramatically at once + if optimal_interval > current_poll_interval { + current_poll_interval = (current_poll_interval + optimal_interval) / 2; + } else if optimal_interval < current_poll_interval { + current_poll_interval = current_poll_interval + - ((current_poll_interval - optimal_interval) / 2).max(1); } + + // Make sure that we respect the (user) configured min and max limits + current_poll_interval = current_poll_interval.clamp( + config.daemon.min_poll_interval_sec, + config.daemon.max_poll_interval_sec, + ); + + debug!("Adaptive polling: set interval to {current_poll_interval}s"); } else { - // If not adaptive, use the configured poll interval - current_poll_interval = config.daemon.poll_interval_sec; - } + // If adaptive polling is disabled, still apply battery-saving adjustment + if config.daemon.throttle_on_battery && on_battery { + let battery_multiplier = 2; // poll half as often on battery - // If on battery and throttling is enabled, lengthen the poll interval to save power - if config.daemon.throttle_on_battery - && !report.batteries.is_empty() - && report.batteries.first().is_some_and(|b| !b.ac_connected) - { - let battery_multiplier = 2; // Poll half as often on battery - current_poll_interval = (current_poll_interval * battery_multiplier) - .min(config.daemon.max_poll_interval_sec); + // We need to make sure `poll_interval_sec` is *at least* 1 + // before multiplying. + let safe_interval = config.daemon.poll_interval_sec.max(1); + current_poll_interval = (safe_interval * battery_multiplier) + .min(config.daemon.max_poll_interval_sec); - debug!("On battery power, increasing poll interval to save energy"); + debug!( + "On battery power, increased poll interval to {current_poll_interval}s" + ); + } else { + // Use the configured poll interval + current_poll_interval = config.daemon.poll_interval_sec.max(1); + if config.daemon.poll_interval_sec == 0 { + debug!("Using minimum poll interval of 1s instead of configured 0s"); + } + } } } Err(e) => { @@ -227,18 +582,20 @@ fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Er } /// Simplified system state used for determining when to adjust polling interval -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)] enum SystemState { + #[default] Unknown, OnAC, OnBattery, HighLoad, LowLoad, HighTemp, + Idle, } /// Determine the current system state for adaptive polling -fn determine_system_state(report: &SystemReport) -> SystemState { +fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState { // Check power state first if !report.batteries.is_empty() { if let Some(battery) = report.batteries.first() { @@ -261,11 +618,18 @@ fn determine_system_state(report: &SystemReport) -> SystemState { } } - // Check load + // Check load first, as high load should take precedence over idle state let avg_load = report.system_load.load_avg_1min; if avg_load > 3.0 { return SystemState::HighLoad; } + + // Check idle state only if we don't have high load + if history.is_system_idle() { + return SystemState::Idle; + } + + // Check for low load if avg_load < 0.5 { return SystemState::LowLoad; }