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

//! Reading power figures out of `powermetrics` output.
//!
//! The output is plain text laid out for humans, and its shape differs between
//! Apple Silicon and Intel — and between chip generations. This is where a
//! macOS or vendor update is most likely to break the backend, so every
//! supported shape has a test against captured real output.

/// One parsed `powermetrics` sample.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PowerReading {
    pub(crate) cpu_power: f64,
    /// `None` when `powermetrics` does not report a separate GPU figure, such
    /// as on Intel Macs.
    pub(crate) gpu_power: Option<f64>,
}

/// Parse one `powermetrics` sample block, or `None` if it carries no power
/// figures (headers and separators do not).
pub(crate) fn parse_block(block: &str, is_apple_silicon: bool) -> Option<PowerReading> {
    if is_apple_silicon {
        Some(PowerReading {
            cpu_power: extract_cpu_power(block)?,
            gpu_power: extract_labelled_power(block, "GPU Power:"),
        })
    } else {
        Some(PowerReading {
            cpu_power: extract_intel_package_power(block)?,
            gpu_power: None,
        })
    }
}

/// CPU power from an Apple Silicon block, in watts.
///
/// Newer chips report a single `CPU Power:`; older ones report one line per
/// efficiency and performance cluster, which have to be summed.
fn extract_cpu_power(block: &str) -> Option<f64> {
    if let Some(power) = extract_labelled_power(block, "CPU Power:") {
        return Some(power);
    }

    let clusters: f64 = block
        .lines()
        .map(str::trim)
        .filter(|line| line.starts_with("E-Cluster Power:") || line.starts_with("P-Cluster Power:"))
        .filter_map(milliwatts_after_colon)
        .sum();

    (clusters > 0.0).then_some(clusters)
}

/// The value of a `<label> <n> mW` line, converted to watts.
fn extract_labelled_power(block: &str, label: &str) -> Option<f64> {
    block
        .lines()
        .map(str::trim)
        .find(|line| line.starts_with(label))
        .and_then(milliwatts_after_colon)
}

/// Parse `... : 1234 mW` as watts.
fn milliwatts_after_colon(line: &str) -> Option<f64> {
    let value = line.split(':').nth(1)?.split_whitespace().next()?;
    Some(value.parse::<f64>().ok()? / 1000.0)
}

/// Package power from an Intel block, in watts.
fn extract_intel_package_power(block: &str) -> Option<f64> {
    let line = block
        .lines()
        .map(str::trim)
        .find(|line| line.contains("package power") || line.contains("Package Power"))?;

    let parts: Vec<&str> = line.split_whitespace().collect();
    for (index, part) in parts.iter().enumerate() {
        if let Some(value) = part.strip_suffix("mW")
            && let Ok(milliwatts) = value.parse::<f64>()
        {
            return Some(milliwatts / 1000.0);
        }
        if let Some(value) = part.strip_suffix('W')
            && let Ok(watts) = value.parse::<f64>()
        {
            return Some(watts);
        }
        // A unit separated from its value: "12.5 W".
        if *part == "W"
            && index > 0
            && let Ok(watts) = parts[index - 1].parse::<f64>()
        {
            return Some(watts);
        }
        if *part == "mW"
            && index > 0
            && let Ok(milliwatts) = parts[index - 1].parse::<f64>()
        {
            return Some(milliwatts / 1000.0);
        }
    }

    None
}

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

    const APPLE_SILICON_BLOCK: &str = "\
*** Sampled system activity (Mon Jan  1 12:00:00 2026) ***

**** Processor usage ****

E-Cluster Power: 120 mW
P-Cluster Power: 2380 mW
GPU Power: 415 mW
ANE Power: 0 mW
Combined Power (CPU + GPU + ANE): 2915 mW
";

    const APPLE_SILICON_SINGLE_CPU_BLOCK: &str = "\
*** Sampled system activity ***
CPU Power: 3500 mW
GPU Power: 200 mW
";

    const INTEL_BLOCK: &str = "\
*** Sampled system activity ***
Intel energy model derived package power (CPUs+GT+SA): 14.52W
";

    #[test]
    fn apple_silicon_cpu_power_sums_the_clusters() {
        let reading = parse_block(APPLE_SILICON_BLOCK, true).unwrap();
        assert!((reading.cpu_power - 2.5).abs() < 1e-9);
        assert_eq!(reading.gpu_power, Some(0.415));
    }

    #[test]
    fn apple_silicon_prefers_a_single_cpu_power_line() {
        let reading = parse_block(APPLE_SILICON_SINGLE_CPU_BLOCK, true).unwrap();
        assert_eq!(reading.cpu_power, 3.5);
        assert_eq!(reading.gpu_power, Some(0.2));
    }

    #[test]
    fn intel_package_power_is_read_in_watts() {
        let reading = parse_block(INTEL_BLOCK, false).unwrap();
        assert_eq!(reading.cpu_power, 14.52);
        assert_eq!(reading.gpu_power, None);
    }

    #[test]
    fn intel_package_power_handles_a_separated_unit() {
        let block = "package power: 12.5 W\n";
        assert_eq!(extract_intel_package_power(block), Some(12.5));
        let block = "Package Power: 900 mW\n";
        assert_eq!(extract_intel_package_power(block), Some(0.9));
    }

    #[test]
    fn blocks_without_power_figures_are_skipped() {
        let header = "*** Sampled system activity ***\n\n**** Processor usage ****\n";
        assert_eq!(parse_block(header, true), None);
        assert_eq!(parse_block(header, false), None);
        assert_eq!(parse_block("", true), None);
    }

    #[test]
    fn locale_specific_decimal_commas_are_not_misread() {
        // If the C locale were ever lost, powermetrics would emit "14,52W".
        // Reading that as 14 W would be worse than reporting nothing.
        assert_eq!(extract_intel_package_power("package power: 14,52W\n"), None);
    }
}