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
 */

//! Turning running energy counters into average power.
//!
//! RAPL reports accumulated joules in a register that wraps, not watts. The
//! Linux and Windows backends therefore do the same three things, using the
//! three helpers here: difference the counter across the wrap point
//! ([`WrappingCounter`]), divide by how long the interval actually lasted
//! ([`Interval`]), and keep reporting *why* power is missing when the counter
//! could not be opened at all ([`repeat_failure`]).

use crate::{Error, Result};
use std::time::Instant;

/// An energy counter that wraps back to zero, in joules.
///
/// A machine with several CPU packages has one of these per package.
#[derive(Debug)]
pub(super) struct WrappingCounter {
    /// The value at which the counter returns to zero, in joules.
    modulus: f64,
    /// The previous reading, in joules.
    last: f64,
}

impl WrappingCounter {
    /// Start from `initial` joules, wrapping at `modulus` joules.
    pub(super) fn new(modulus: f64, initial: f64) -> Self {
        Self {
            modulus,
            last: initial,
        }
    }

    /// Joules consumed between the previous reading and `current`.
    pub(super) fn delta(&mut self, current: f64) -> f64 {
        let previous = std::mem::replace(&mut self.last, current);

        if current >= previous {
            current - previous
        } else {
            // The counter wrapped, so the interval spans the wrap point.
            current - previous + self.modulus
        }
    }
}

/// The clock a sensor divides its joules by.
///
/// Power is energy over time, so a reading is only meaningful once a previous
/// one exists to measure against.
#[derive(Debug, Default)]
pub(super) struct Interval {
    last_read_at: Option<Instant>,
}

impl Interval {
    /// Seconds since the previous call, or `None` when there is nothing to
    /// measure against yet.
    ///
    /// `None` on the first call, which only establishes the baseline, and also
    /// when no measurable time has passed — dividing by that would produce an
    /// absurd wattage rather than a missing one.
    pub(super) fn tick(&mut self) -> Option<f64> {
        let now = Instant::now();
        let previous = self.last_read_at.replace(now)?;

        let elapsed = now.duration_since(previous).as_secs_f64();
        (elapsed > 0.0).then_some(elapsed)
    }
}

/// Rebuild the error that stopped a counter from opening, so every read can
/// report it again.
///
/// Backends never fail to construct: when the counter is unavailable the reason
/// is kept and returned from each read, so a caller always learns *why* power is
/// missing rather than seeing a stream of plausible-looking zeroes. [`Error`] is
/// not `Clone` — `io::Error` is not — so the stored one is rebuilt rather than
/// copied.
///
/// `PermissionDenied` keeps its variant: "needs elevated privileges" is
/// actionable in a way "unavailable" is not, and it is how a Linux user learns
/// that RAPL has been root-only since kernel 5.10.
pub(super) fn repeat_failure(sensor: &'static str, error: &Error) -> Error {
    match error {
        Error::PermissionDenied {
            sensor: original,
            detail,
        } => Error::permission(original, detail.clone()),
        other => Error::sensor(sensor, other.to_string()),
    }
}

/// Report a counter that could not be opened, once, when the backend is built.
pub(super) fn warn_unavailable<T>(sensor: &'static str, opened: &Result<T>) {
    if let Err(e) = opened {
        log::warn!(
            "{sensor} could not be opened, so CPU power will be reported as unavailable: {e}"
        );
    }
}

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

    #[test]
    fn a_rising_counter_reports_the_plain_difference() {
        let mut counter = WrappingCounter::new(100.0, 10.0);
        assert_eq!(counter.delta(30.0), 20.0);
        assert_eq!(counter.delta(45.0), 15.0);
    }

    #[test]
    fn a_wrapped_counter_spans_the_wrap_point() {
        // Wraps at 100 J: from 90 J up past 100 and round to 5 J is 15 J, not
        // the -85 J a plain subtraction would give.
        let mut counter = WrappingCounter::new(100.0, 90.0);
        assert_eq!(counter.delta(5.0), 15.0);
    }

    #[test]
    fn an_unchanged_counter_reports_no_energy() {
        let mut counter = WrappingCounter::new(100.0, 42.0);
        assert_eq!(counter.delta(42.0), 0.0);
    }

    #[test]
    fn the_first_tick_has_nothing_to_measure_against() {
        let mut interval = Interval::default();
        assert_eq!(interval.tick(), None);
        assert!(interval.tick().is_some_and(|elapsed| elapsed > 0.0));
    }

    #[test]
    fn a_permission_failure_keeps_its_variant() {
        // A caller branches on this to tell "run me as root" from "this machine
        // has no such sensor".
        let stored = Error::permission("Intel RAPL", "not readable");
        assert!(matches!(
            repeat_failure("Intel RAPL", &stored),
            Error::PermissionDenied { .. }
        ));
    }

    #[test]
    fn any_other_failure_repeats_its_reason() {
        // The alternative — reporting 0 W — would make an unreadable sensor
        // indistinguishable from an idle machine.
        let stored = Error::sensor("Intel RAPL", "no powercap domains");
        let repeated = repeat_failure("Intel RAPL", &stored);
        assert!(
            repeated.to_string().contains("no powercap domains"),
            "unexpected error: {repeated}"
        );
    }
}