joularcore 0.2.0

Joular Core is a platform to measure power and energy across all systems, OSes and devices
Documentation
/*
 * Copyright (c) 2025-2026, Adel Noureddine.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the
 * GNU Lesser General Public License v3.0 only (LGPL-3.0-only)
 * which accompanies this distribution, and is available at
 * https://www.gnu.org/licenses/lgpl-3.0.en.html
 *
 * Author : Adel Noureddine
 */

//! The traits a sensor backend implements, and the formula that splits CPU
//! power between workloads.
//!
//! Everything here is an extension point. A [`Platform`] is the factory for one
//! machine's sensors; [`crate::platform::current`] returns the one for the
//! running system, and [`crate::monitor::MonitorBuilder::with_platform`] accepts
//! one you wrote yourself. Individual sensors can be replaced without writing a
//! whole backend — that is how `vm::VmSensor`, under the `vm` feature, reads power
//! from a file.

use crate::Result;
use crate::config::AppMatch;
use std::time::Duration;
use sysinfo::System;

/// A source of power readings, in watts.
///
/// The same contract covers CPU and GPU sensors; which one a given sensor
/// reports is decided by where it is installed on the monitor, not by its type.
pub trait PowerSensor: Send {
    /// Power drawn since the previous call, in watts.
    ///
    /// For counter-based sensors such as RAPL, the first call after
    /// construction establishes the baseline and returns `0.0`; see
    /// [`crate::monitor::JoularCoreMonitor::prime`].
    ///
    /// Takes `&mut self` because most sensors are counter differences and so
    /// have to remember the previous reading. The monitor owns its sensors and
    /// samples them one at a time, so that state needs no lock around it.
    ///
    /// # Errors
    ///
    /// Returns an error when the sensor cannot be read, so callers can tell an
    /// idle component from an unreadable one.
    fn power(&mut self) -> Result<f64>;
}

/// A sensor backend: the factory for one platform's sensors.
pub trait Platform: Send {
    /// A human-readable name for the running operating system.
    fn name(&self) -> String {
        let name = System::name().unwrap_or_else(|| "Unknown OS".to_string());
        let version = System::os_version().unwrap_or_default();
        if version.is_empty() {
            name
        } else {
            format!("{name} {version}")
        }
    }

    /// Create the CPU power sensor.
    fn cpu(&self) -> Box<dyn PowerSensor>;

    /// Create the GPU power sensor.
    fn gpu(&self) -> Box<dyn PowerSensor>;

    /// Create the whole-system CPU utilization tracker.
    fn cpu_usage(&self) -> Box<dyn CpuUtilization>;

    /// Create a per-process CPU utilization tracker.
    ///
    /// Returns `None` on platforms without per-process accounting.
    fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCpuUtilization>> {
        None
    }

    /// Create a per-application CPU utilization tracker.
    ///
    /// Returns `None` on platforms without per-process accounting.
    fn app_cpu_usage(
        &self,
        _refresh_interval: Duration,
        _app_match: AppMatch,
    ) -> Option<Box<dyn AppCpuUtilization>> {
        None
    }
}

/// Cumulative CPU time counters, in whatever unit the platform reports.
///
/// Only meaningful as a difference between two snapshots.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CpuTotals {
    /// Time spent in every state, including idle.
    pub total: u64,
    /// Time spent idle (including I/O wait).
    pub idle: u64,
}

impl CpuTotals {
    /// Create a snapshot from raw counters.
    #[must_use]
    pub fn new(total: u64, idle: u64) -> Self {
        Self { total, idle }
    }

    /// Busy fraction of the interval between `previous` and `self`, in
    /// `0.0..=1.0`.
    #[must_use]
    pub fn utilization_since(&self, previous: &CpuTotals) -> f64 {
        let total_delta = self.total.saturating_sub(previous.total);
        let idle_delta = self.idle.saturating_sub(previous.idle);

        if total_delta == 0 {
            return 0.0;
        }
        // `saturating_sub` guards against counters that move backwards, which
        // some kernels allow for `iowait`.
        total_delta.saturating_sub(idle_delta) as f64 / total_delta as f64
    }
}

/// Whole-system CPU utilization.
pub trait CpuUtilization: Send {
    /// Busy fraction since the previous call, in `0.0..=1.0`.
    ///
    /// The first call establishes the baseline and returns `0.0`.
    fn cpu_utilization(&mut self) -> f64;

    /// The counters behind the most recent [`Self::cpu_utilization`] call, on
    /// platforms that expose them.
    ///
    /// [`crate::monitor::JoularCoreMonitor`] forwards `total` to the
    /// per-process trackers so system and per-process figures cover the same
    /// interval. Platforms without such counters return `None`, and their
    /// trackers measure against wall-clock time instead.
    fn last_totals(&self) -> Option<CpuTotals> {
        None
    }
}

/// CPU utilization of a single process.
pub trait ProcessCpuUtilization: Send {
    /// Fraction of total system CPU capacity used by `pid` since the previous
    /// call, in `0.0..=1.0`.
    ///
    /// `cpu_total` is the system-wide counter from [`CpuUtilization::last_totals`]
    /// for the same interval; pass `None` to let the tracker obtain its own.
    /// Returns `0.0` if the process is gone or unreadable.
    fn process_cpu_utilization(&mut self, pid: u32, cpu_total: Option<u64>) -> f64;
}

/// One sample of an application's CPU usage across all of its processes.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct AppSample {
    /// Summed fraction of total system CPU capacity used by every matching
    /// process.
    pub utilization: f64,
    /// The processes that matched at sample time.
    pub pids: Vec<u32>,
}

/// CPU utilization of every process belonging to one application.
pub trait AppCpuUtilization: Send {
    /// Sample the application named `app_name`.
    ///
    /// `cpu_total` has the same meaning as in
    /// [`ProcessCpuUtilization::process_cpu_utilization`].
    fn app_snapshot(&mut self, app_name: &str, cpu_total: Option<u64>) -> AppSample;
}

/// Split `cpu_power` between a workload and the rest of the machine, in
/// proportion to the CPU time each used.
///
/// * `utilization` — the workload's share of total CPU capacity, `0.0..=1.0`
///   (or higher across several cores).
/// * `attributable_cpu_power` — CPU power available for attribution, in watts.
///   Callers pass power with any idle baseline already removed.
/// * `cpu_utilization` — whole-system CPU usage, as a percentage.
///
/// Returns watts, clamped to `0.0..=attributable_cpu_power`: no single workload
/// can draw more than the whole CPU budget.
#[must_use]
pub fn attribute_power(utilization: f64, attributable_cpu_power: f64, cpu_utilization: f64) -> f64 {
    // Epsilon (0.01% of CPU) avoids dividing by near-zero noise on a nearly
    // idle machine, which would otherwise blow up the attribution.
    if cpu_utilization < 0.01 || !cpu_utilization.is_finite() {
        return 0.0;
    }
    let attributed = 100.0 * ((utilization * attributable_cpu_power) / cpu_utilization);
    if !attributed.is_finite() {
        return 0.0;
    }
    attributed.clamp(0.0, attributable_cpu_power.max(0.0))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn idle_machine_attributes_nothing() {
        assert_eq!(attribute_power(0.5, 40.0, 0.0), 0.0);
        assert_eq!(attribute_power(0.5, 40.0, 0.009), 0.0);
    }

    #[test]
    fn half_the_busy_cpu_gets_half_the_power() {
        // The workload used 25% of total capacity while the machine was 50%
        // busy, so it is responsible for half of the 40 W.
        assert_eq!(attribute_power(0.25, 40.0, 50.0), 20.0);
    }

    #[test]
    fn attribution_never_exceeds_the_cpu_budget() {
        // Sampling skew can make a process look busier than the whole machine.
        assert_eq!(attribute_power(0.9, 40.0, 10.0), 40.0);
    }

    #[test]
    fn negative_and_non_finite_inputs_are_clamped() {
        assert_eq!(attribute_power(0.5, -5.0, 50.0), 0.0);
        assert_eq!(attribute_power(f64::NAN, 40.0, 50.0), 0.0);
        assert_eq!(attribute_power(0.5, 40.0, f64::NAN), 0.0);
    }

    #[test]
    fn utilization_is_busy_over_total() {
        let previous = CpuTotals::new(1_000, 800);
        let current = CpuTotals::new(1_100, 850);
        // 100 ticks elapsed, 50 of them idle.
        assert_eq!(current.utilization_since(&previous), 0.5);
    }

    #[test]
    fn no_elapsed_time_means_no_usage() {
        let totals = CpuTotals::new(1_000, 800);
        assert_eq!(totals.utilization_since(&totals), 0.0);
    }

    #[test]
    fn counters_moving_backwards_do_not_underflow() {
        // `iowait` is documented as able to decrease, which would otherwise
        // make the idle delta exceed the total delta.
        let previous = CpuTotals::new(1_000, 800);
        let current = CpuTotals::new(900, 900);
        assert_eq!(current.utilization_since(&previous), 0.0);
    }
}