use crate::config::{Component, MonitorConfig, Target};
use crate::sensor::{
AppCpuUtilization, CpuUtilization, Platform, PowerSensor, ProcessCpuUtilization,
attribute_power,
};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct MonitorSample {
pub timestamp: u64,
pub cpu_power: Option<f64>,
pub gpu_power: Option<f64>,
pub cpu_usage: f64,
pub target_power: Option<f64>,
pub app_pid_count: Option<usize>,
}
impl MonitorSample {
#[must_use]
pub fn cpu_power_or_zero(&self) -> f64 {
self.cpu_power.unwrap_or(0.0)
}
#[must_use]
pub fn gpu_power_or_zero(&self) -> f64 {
self.gpu_power.unwrap_or(0.0)
}
#[must_use]
pub fn total_power(&self) -> f64 {
self.cpu_power_or_zero() + self.gpu_power_or_zero()
}
#[must_use]
pub fn target_power_or_zero(&self) -> f64 {
self.target_power.unwrap_or(0.0)
}
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PowerRecord {
pub timestamp: u64,
pub cpu_power: f64,
pub gpu_power: f64,
pub total_power: f64,
pub cpu_usage: f64,
pub pid_or_app_power: f64,
}
impl From<&MonitorSample> for PowerRecord {
fn from(sample: &MonitorSample) -> Self {
Self {
timestamp: sample.timestamp,
cpu_power: sample.cpu_power_or_zero(),
gpu_power: sample.gpu_power_or_zero(),
total_power: sample.total_power(),
cpu_usage: sample.cpu_usage,
pid_or_app_power: sample.target_power_or_zero(),
}
}
}
pub struct JoularCoreMonitor {
platform: Box<dyn Platform>,
cpu_sensor: Box<dyn PowerSensor>,
gpu_sensor: Box<dyn PowerSensor>,
cpu_usage: Box<dyn CpuUtilization>,
process_tracker: Option<Box<dyn ProcessCpuUtilization>>,
app_tracker: Option<Box<dyn AppCpuUtilization>>,
config: MonitorConfig,
cpu_idle_baseline: f64,
}
impl JoularCoreMonitor {
#[must_use]
pub fn new() -> Self {
Self::from_config(&MonitorConfig::default())
}
#[must_use]
pub fn for_target(target: Target) -> Self {
Self::from_config(&MonitorConfig {
target,
..Default::default()
})
}
#[must_use]
pub fn for_app(name: impl Into<String>) -> Self {
Self::for_target(Target::App(name.into()))
}
#[must_use]
pub fn for_pid(pid: u32) -> Self {
Self::for_target(Target::Pid(pid))
}
#[must_use]
pub fn builder(config: &MonitorConfig) -> MonitorBuilder {
MonitorBuilder::new(config)
}
#[must_use]
pub fn from_config(config: &MonitorConfig) -> Self {
MonitorBuilder::new(config).build()
}
#[must_use]
pub fn platform(&self) -> &dyn Platform {
self.platform.as_ref()
}
#[must_use]
pub fn config(&self) -> &MonitorConfig {
&self.config
}
pub fn set_target(&mut self, target: Target) {
if self.config.target == target {
return;
}
self.process_tracker = None;
self.app_tracker = None;
match &target {
Target::Pid(_) => {
self.process_tracker = self.platform.process_cpu_usage();
}
Target::App(_) => {
self.app_tracker = self
.platform
.app_cpu_usage(self.config.app_refresh_interval, self.config.app_match);
}
_ => {}
}
self.config.target = target;
}
pub fn set_component(&mut self, component: Option<Component>) {
self.config.component = component;
}
#[must_use]
pub fn cpu_idle_baseline(&self) -> f64 {
self.cpu_idle_baseline
}
pub fn set_cpu_idle_baseline(&mut self, baseline: f64) {
self.cpu_idle_baseline = if baseline.is_finite() {
baseline.max(0.0)
} else {
0.0
};
}
pub fn prime(&mut self) {
let _ = self.cpu_sensor.power();
let _ = self.gpu_sensor.power();
self.cpu_usage.cpu_utilization();
}
pub fn calibrate_cpu_idle_baseline(
&mut self,
samples: usize,
interval: Duration,
) -> crate::Result<f64> {
self.cpu_sensor.power()?;
let samples = samples.max(1);
let mut total = 0.0;
for _ in 0..samples {
thread::sleep(interval);
total += self.cpu_sensor.power()?;
}
let baseline = total / samples as f64;
self.set_cpu_idle_baseline(baseline);
Ok(baseline)
}
pub fn poll(&mut self) -> MonitorSample {
let component = self.config.component;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cpu_active = component != Some(Component::Gpu);
let gpu_active = component != Some(Component::Cpu);
let cpu_power = cpu_active
.then(|| read_sensor("CPU", self.cpu_sensor.power()))
.flatten();
let gpu_power = gpu_active
.then(|| read_sensor("GPU", self.gpu_sensor.power()))
.flatten();
let cpu_usage = if cpu_active {
self.cpu_usage.cpu_utilization() * 100.0
} else {
0.0
};
let cpu_total = self.cpu_usage.last_totals().map(|totals| totals.total);
let attributable = (cpu_power.unwrap_or(0.0) - self.cpu_idle_baseline).max(0.0);
let mut target_power = None;
let mut app_pid_count = None;
if cpu_active {
match &self.config.target {
Target::System => {}
Target::Pid(pid) => {
if let Some(tracker) = &mut self.process_tracker {
let utilization = tracker.process_cpu_utilization(*pid, cpu_total);
target_power = Some(attribute_power(utilization, attributable, cpu_usage));
}
}
Target::App(name) => {
if let Some(tracker) = &mut self.app_tracker {
let sample = tracker.app_snapshot(name, cpu_total);
target_power =
Some(attribute_power(sample.utilization, attributable, cpu_usage));
app_pid_count = Some(sample.pids.len());
}
}
}
}
MonitorSample {
timestamp,
cpu_power,
gpu_power,
cpu_usage,
target_power,
app_pid_count,
}
}
}
pub struct MonitorBuilder {
platform: Box<dyn Platform>,
config: MonitorConfig,
cpu_sensor: Option<Box<dyn PowerSensor>>,
gpu_sensor: Option<Box<dyn PowerSensor>>,
cpu_usage: Option<Box<dyn CpuUtilization>>,
process_tracker: Option<Box<dyn ProcessCpuUtilization>>,
app_tracker: Option<Box<dyn AppCpuUtilization>>,
}
impl MonitorBuilder {
fn new(config: &MonitorConfig) -> Self {
Self::with_platform(crate::platform::current(config.elevation), config)
}
#[must_use]
pub fn with_platform(platform: Box<dyn Platform>, config: &MonitorConfig) -> Self {
let process_tracker = matches!(config.target, Target::Pid(_))
.then(|| platform.process_cpu_usage())
.flatten();
let app_tracker = matches!(config.target, Target::App(_))
.then(|| platform.app_cpu_usage(config.app_refresh_interval, config.app_match))
.flatten();
Self {
platform,
config: config.clone(),
cpu_sensor: None,
gpu_sensor: None,
cpu_usage: None,
process_tracker,
app_tracker,
}
}
#[must_use]
pub fn cpu_sensor(mut self, sensor: Box<dyn PowerSensor>) -> Self {
self.cpu_sensor = Some(sensor);
self
}
#[must_use]
pub fn gpu_sensor(mut self, sensor: Box<dyn PowerSensor>) -> Self {
self.gpu_sensor = Some(sensor);
self
}
#[must_use]
pub fn cpu_usage(mut self, usage: Box<dyn CpuUtilization>) -> Self {
self.cpu_usage = Some(usage);
self
}
#[must_use]
pub fn process_tracker(mut self, tracker: Box<dyn ProcessCpuUtilization>) -> Self {
self.process_tracker = Some(tracker);
self
}
#[must_use]
pub fn app_tracker(mut self, tracker: Box<dyn AppCpuUtilization>) -> Self {
self.app_tracker = Some(tracker);
self
}
#[must_use]
pub fn build(self) -> JoularCoreMonitor {
let cpu_sensor = self.cpu_sensor.unwrap_or_else(|| self.platform.cpu());
let gpu_sensor = self.gpu_sensor.unwrap_or_else(|| self.platform.gpu());
let cpu_usage = self.cpu_usage.unwrap_or_else(|| self.platform.cpu_usage());
let mut monitor = JoularCoreMonitor {
platform: self.platform,
cpu_sensor,
gpu_sensor,
cpu_usage,
process_tracker: self.process_tracker,
app_tracker: self.app_tracker,
config: self.config,
cpu_idle_baseline: 0.0,
};
if let Some(watts) = monitor.config.cpu_idle_baseline {
monitor.set_cpu_idle_baseline(watts);
}
monitor.prime();
monitor
}
}
impl Default for JoularCoreMonitor {
fn default() -> Self {
Self::new()
}
}
fn read_sensor(which: &str, reading: crate::Result<f64>) -> Option<f64> {
match reading {
Ok(power) => Some(power),
Err(e) => {
log::debug!("{which} sensor read failed: {e}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppMatch;
use crate::sensor::{AppSample, CpuTotals};
use std::sync::{Arc, Mutex};
struct Fixed(Option<f64>);
impl PowerSensor for Fixed {
fn power(&mut self) -> crate::Result<f64> {
self.0
.ok_or_else(|| crate::Error::sensor("test", "unavailable"))
}
}
struct FixedUsage(f64);
impl CpuUtilization for FixedUsage {
fn cpu_utilization(&mut self) -> f64 {
self.0
}
fn last_totals(&self) -> Option<CpuTotals> {
Some(CpuTotals::new(1_000, 500))
}
}
struct FixedProcess(f64);
impl ProcessCpuUtilization for FixedProcess {
fn process_cpu_utilization(&mut self, _pid: u32, _cpu_total: Option<u64>) -> f64 {
self.0
}
}
struct FixedApp(f64, usize);
impl AppCpuUtilization for FixedApp {
fn app_snapshot(&mut self, _app_name: &str, _cpu_total: Option<u64>) -> AppSample {
AppSample {
utilization: self.0,
pids: (0..self.1 as u32).collect(),
}
}
}
type AppRequests = Arc<Mutex<Vec<(Duration, AppMatch)>>>;
type ProcessRequests = Arc<Mutex<usize>>;
#[derive(Default)]
struct FakePlatform {
app_requests: AppRequests,
process_requests: ProcessRequests,
}
impl Platform for FakePlatform {
fn cpu(&self) -> Box<dyn PowerSensor> {
Box::new(Fixed(Some(0.0)))
}
fn gpu(&self) -> Box<dyn PowerSensor> {
Box::new(Fixed(Some(0.0)))
}
fn cpu_usage(&self) -> Box<dyn CpuUtilization> {
Box::new(FixedUsage(0.0))
}
fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCpuUtilization>> {
*self.process_requests.lock().unwrap() += 1;
Some(Box::new(FixedProcess(0.25)))
}
fn app_cpu_usage(
&self,
refresh_interval: Duration,
app_match: AppMatch,
) -> Option<Box<dyn AppCpuUtilization>> {
self.app_requests
.lock()
.unwrap()
.push((refresh_interval, app_match));
Some(Box::new(FixedApp(0.25, 3)))
}
}
fn monitor(cpu: Option<f64>, gpu: Option<f64>, usage: f64) -> JoularCoreMonitor {
MonitorBuilder::with_platform(Box::<FakePlatform>::default(), &MonitorConfig::default())
.cpu_sensor(Box::new(Fixed(cpu)))
.gpu_sensor(Box::new(Fixed(gpu)))
.cpu_usage(Box::new(FixedUsage(usage)))
.process_tracker(Box::new(FixedProcess(0.25)))
.app_tracker(Box::new(FixedApp(0.25, 3)))
.build()
}
#[test]
fn flattening_a_sample_reports_unmeasured_components_as_zero() {
let record = PowerRecord::from(&MonitorSample {
timestamp: 7,
cpu_power: None,
gpu_power: Some(12.5),
cpu_usage: 40.0,
target_power: Some(3.0),
app_pid_count: None,
});
assert_eq!(record.cpu_power, 0.0);
assert_eq!(record.gpu_power, 12.5);
assert_eq!(record.total_power, 12.5);
assert_eq!(record.pid_or_app_power, 3.0);
}
#[test]
fn unreadable_sensor_reports_none_not_zero() {
let mut m = monitor(None, Some(5.0), 0.5);
let sample = m.poll();
assert_eq!(sample.cpu_power, None);
assert_eq!(sample.cpu_power_or_zero(), 0.0);
assert_eq!(sample.gpu_power, Some(5.0));
assert_eq!(sample.total_power(), 5.0);
}
#[test]
fn excluded_component_is_not_measured() {
let mut m = monitor(Some(40.0), Some(5.0), 0.5);
m.set_component(Some(Component::Cpu));
let cpu_only = m.poll();
assert_eq!(cpu_only.cpu_power, Some(40.0));
assert_eq!(cpu_only.gpu_power, None);
m.set_component(Some(Component::Gpu));
let gpu_only = m.poll();
assert_eq!(gpu_only.cpu_power, None);
assert_eq!(gpu_only.gpu_power, Some(5.0));
assert_eq!(gpu_only.cpu_usage, 0.0);
}
#[test]
fn process_and_app_power_are_attributed_from_cpu_power() {
let mut m = monitor(Some(40.0), Some(0.0), 0.5);
m.set_target(Target::Pid(1));
let pid = m.poll();
assert_eq!(pid.target_power, Some(20.0));
assert_eq!(pid.target_power_or_zero(), 20.0);
assert_eq!(pid.app_pid_count, None);
m.set_target(Target::App("firefox".into()));
let app = m.poll();
assert_eq!(app.target_power, Some(20.0));
assert_eq!(app.app_pid_count, Some(3));
}
#[test]
fn retargeting_at_an_app_keeps_the_configured_matching() {
let platform = FakePlatform::default();
let requests = platform.app_requests.clone();
let config = MonitorConfig {
app_match: AppMatch::Contains,
app_refresh_interval: Duration::from_secs(10),
..Default::default()
};
let mut m = MonitorBuilder::with_platform(Box::new(platform), &config).build();
assert!(requests.lock().unwrap().is_empty());
m.set_target(Target::App("firefox".into()));
assert_eq!(
requests.lock().unwrap().as_slice(),
[(Duration::from_secs(10), AppMatch::Contains)]
);
}
#[test]
fn retargeting_restarts_the_relevant_tracker() {
let platform = FakePlatform::default();
let app_requests = platform.app_requests.clone();
let process_requests = platform.process_requests.clone();
let mut m =
MonitorBuilder::with_platform(Box::new(platform), &MonitorConfig::default()).build();
m.set_target(Target::Pid(1));
m.set_target(Target::Pid(1));
m.set_target(Target::System);
m.set_target(Target::Pid(1));
assert_eq!(*process_requests.lock().unwrap(), 2);
m.set_target(Target::App("firefox".into()));
m.set_target(Target::System);
m.set_target(Target::App("firefox".into()));
assert_eq!(app_requests.lock().unwrap().len(), 2);
}
#[test]
fn idle_baseline_is_removed_before_attribution() {
let mut m = monitor(Some(40.0), Some(0.0), 0.5);
m.set_cpu_idle_baseline(20.0);
m.set_target(Target::Pid(1));
let sample = m.poll();
assert_eq!(sample.cpu_power, Some(40.0));
assert_eq!(sample.target_power, Some(10.0));
}
#[test]
fn baseline_rejects_negative_and_non_finite_values() {
let mut m = monitor(Some(40.0), Some(0.0), 0.5);
m.set_cpu_idle_baseline(-5.0);
assert_eq!(m.cpu_idle_baseline(), 0.0);
m.set_cpu_idle_baseline(f64::NAN);
assert_eq!(m.cpu_idle_baseline(), 0.0);
}
#[test]
fn calibration_adopts_the_average_of_successful_readings() {
let mut m = monitor(Some(40.0), Some(0.0), 0.5);
assert_eq!(
m.calibrate_cpu_idle_baseline(3, Duration::ZERO).unwrap(),
40.0
);
assert_eq!(m.cpu_idle_baseline(), 40.0);
}
#[test]
fn calibration_failure_preserves_the_existing_baseline() {
let mut m = monitor(None, Some(0.0), 0.5);
m.set_cpu_idle_baseline(12.0);
assert!(m.calibrate_cpu_idle_baseline(1, Duration::ZERO).is_err());
assert_eq!(m.cpu_idle_baseline(), 12.0);
}
#[test]
fn convenience_constructors_set_expected_targets() {
let default_m = JoularCoreMonitor::default();
assert_eq!(default_m.config().target, Target::System);
let new_m = JoularCoreMonitor::new();
assert_eq!(new_m.config().target, Target::System);
let app_m = JoularCoreMonitor::for_app("firefox");
assert_eq!(app_m.config().target, Target::App("firefox".into()));
let pid_m = JoularCoreMonitor::for_pid(42);
assert_eq!(pid_m.config().target, Target::Pid(42));
}
}