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

//! Single-board computer backend.
//!
//! Boards like the Raspberry Pi expose no energy counter, so power is estimated
//! from CPU utilization with a per-board polynomial fitted against physical
//! measurements. We empirically build these regression models from scientific
//! experimentations. Accuracy therefore depends on recognising the board: an
//! unrecognised one reports no power rather than a wrong figure.
//!
//! The board list and its coefficients live in `sbc_models`.

use crate::config::AppMatch;
use crate::platform::cpu_usage::{AppMonitor, TotalsSampler};
use crate::platform::procfs::{self, MAX_KERNEL_FILE_BYTES, read_capped};
use crate::platform::sbc_models::{BOARD_NAMES, builtin_coefficients};
use crate::sensor::{
    AppCpuUtilization, CpuUtilization, Platform, PowerSensor, ProcessCpuUtilization,
};
use crate::{Error, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;

/// Most a power-model file may hold, so a wrong path cannot exhaust memory.
const MAX_MODEL_BYTES: u64 = 1024 * 1024;

/// Where the kernel reports the board name.
const DEVICE_TREE_MODEL: &str = "/proc/device-tree/model";

/// Environment variable naming a custom power-model file.
const MODEL_JSON_VAR: &str = "SBC_POWER_MODEL_JSON";

/// How this sensor is named in errors.
const SENSOR: &str = "SBC power model";

/// The single-board computer backend.
#[derive(Debug, Default)]
pub(crate) struct SbcPlatform;

impl Platform for SbcPlatform {
    fn cpu(&self) -> Box<dyn PowerSensor> {
        Box::new(SbcCpu::new())
    }

    fn gpu(&self) -> Box<dyn PowerSensor> {
        Box::new(SbcGpu)
    }

    fn cpu_usage(&self) -> Box<dyn CpuUtilization> {
        Box::new(procfs::cpu_usage())
    }

    fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCpuUtilization>> {
        Some(procfs::process_tracker())
    }

    fn app_cpu_usage(
        &self,
        refresh_interval: Duration,
        app_match: AppMatch,
    ) -> Option<Box<dyn AppCpuUtilization>> {
        Some(AppMonitor::boxed(
            procfs::process_tracker,
            refresh_interval,
            app_match,
        ))
    }
}

/// Single-board computers have no separately measurable GPU.
struct SbcGpu;

impl PowerSensor for SbcGpu {
    fn power(&mut self) -> Result<f64> {
        Err(Error::sensor(
            "SBC GPU",
            "single-board computers expose no GPU power interface",
        ))
    }
}

/// CPU power estimated from utilization with a per-board model.
struct SbcCpu {
    device: Result<SbcDevice>,
    /// Utilization drives the model, and this sensor samples it over its own
    /// interval — independent of the monitor's own tracker, which has already
    /// been read by the time `poll` gets here.
    cpu_usage: TotalsSampler,
}

impl SbcCpu {
    /// Detect the board and load its power model.
    fn new() -> Self {
        let device = SbcDevice::detect();
        match &device {
            Ok(device) => log::info!("using the SBC power model for {}", device.model),
            Err(e) => log::warn!("CPU power will be reported as unavailable: {e}"),
        }

        Self {
            device,
            cpu_usage: procfs::cpu_usage(),
        }
    }
}

impl PowerSensor for SbcCpu {
    fn power(&mut self) -> Result<f64> {
        match &self.device {
            Ok(device) => Ok(device.power_at(self.cpu_usage.cpu_utilization())),
            Err(e) => Err(Error::sensor(SENSOR, e.to_string())),
        }
    }
}

/// A recognised board and the polynomial that models its power draw.
struct SbcDevice {
    model: String,
    /// Polynomial coefficients, constant term first.
    coefficients: Vec<f64>,
}

impl SbcDevice {
    /// Detect the board and pick its model, preferring a file named by
    /// `SBC_POWER_MODEL_JSON` over the built-in coefficients.
    fn detect() -> Result<Self> {
        let model = detect_model_name()?;

        let coefficients = match load_model_file(&model) {
            Ok(Some(coefficients)) => coefficients,
            Ok(None) => builtin_coefficients(&model)
                .ok_or_else(|| {
                    Error::sensor(SENSOR, format!("no power model for board {model:?}"))
                })?
                .to_vec(),
            Err(e) => {
                // A bad custom model should not silently fall back to a
                // different one; the caller asked for that file specifically.
                return Err(e);
            }
        };

        Ok(Self {
            model,
            coefficients,
        })
    }

    /// Estimated CPU power at `cpu_utilization` (a fraction in `0.0..=1.0`),
    /// in watts.
    fn power_at(&self, cpu_utilization: f64) -> f64 {
        if !cpu_utilization.is_finite() {
            return 0.0;
        }

        let power: f64 = self
            .coefficients
            .iter()
            .enumerate()
            .map(|(degree, coefficient)| coefficient * cpu_utilization.powi(degree as i32))
            .sum();

        // The fit can dip below zero just outside its sampled range.
        if power.is_finite() {
            power.max(0.0)
        } else {
            0.0
        }
    }
}

/// The board identifier, from the device tree.
fn detect_model_name() -> Result<String> {
    let raw = read_capped(Path::new(DEVICE_TREE_MODEL), MAX_KERNEL_FILE_BYTES)
        .map_err(|e| Error::sensor(SENSOR, format!("cannot read {DEVICE_TREE_MODEL}: {e}")))?;

    // 32- and 64-bit builds of the same board draw differently enough to need
    // their own fits.
    let is_64bit = cfg!(target_pointer_width = "64");

    BOARD_NAMES
        .iter()
        .find(|(device_tree_name, _, _)| raw.contains(device_tree_name))
        .map(|&(_, name_32, name_64)| if is_64bit { name_64 } else { name_32 }.to_string())
        .ok_or_else(|| {
            Error::sensor(
                SENSOR,
                format!("unrecognised board {:?}", raw.trim_end_matches('\0').trim()),
            )
        })
}

/// Coefficients from `SBC_POWER_MODEL_JSON`, if that variable is set.
fn load_model_file(model: &str) -> Result<Option<Vec<f64>>> {
    let Some(path) = std::env::var_os(MODEL_JSON_VAR).filter(|v| !v.is_empty()) else {
        return Ok(None);
    };
    let path = std::path::PathBuf::from(path);

    let bad_file = |detail: String| {
        Error::config(format!(
            "{MODEL_JSON_VAR} points to {}: {detail}",
            path.display()
        ))
    };

    let contents = read_capped(&path, MAX_MODEL_BYTES).map_err(|e| bad_file(e.to_string()))?;

    let models: HashMap<String, PlatformModel> =
        serde_json::from_str(&contents).map_err(|e| bad_file(format!("invalid JSON: {e}")))?;
    let platform = models
        .get(model)
        .ok_or_else(|| bad_file(format!("no entry for board {model:?}")))?;

    platform
        .polynomial
        .coefficients_with_intercept()
        .map(Some)
        .map_err(bad_file)
}

/// The on-disk power-model format, as published in the Joular power models
/// database, where coefficients are stored as strings.
#[derive(Deserialize)]
struct PlatformModel {
    polynomial: PolynomialModel,
}

#[derive(Deserialize)]
struct PolynomialModel {
    intercept: String,
    coefficients: Vec<String>,
}

impl PolynomialModel {
    /// Coefficients with the intercept first, matching [`SbcDevice::power_at`].
    fn coefficients_with_intercept(&self) -> std::result::Result<Vec<f64>, String> {
        let mut parsed = Vec::with_capacity(self.coefficients.len() + 1);
        parsed.push(
            self.intercept
                .trim()
                .parse()
                .map_err(|e| format!("intercept {:?} is not a number: {e}", self.intercept))?,
        );
        for coefficient in &self.coefficients {
            parsed.push(
                coefficient
                    .trim()
                    .parse()
                    .map_err(|e| format!("coefficient {coefficient:?} is not a number: {e}"))?,
            );
        }
        Ok(parsed)
    }
}

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

    #[test]
    fn idle_power_is_the_intercept() {
        let device = SbcDevice {
            model: "test".into(),
            coefficients: vec![2.5, 10.0, -4.0],
        };
        assert_eq!(device.power_at(0.0), 2.5);
        assert_eq!(device.power_at(1.0), 8.5);
    }

    #[test]
    fn power_is_never_negative_or_non_finite() {
        let device = SbcDevice {
            model: "test".into(),
            coefficients: vec![1.0, -100.0],
        };
        assert_eq!(device.power_at(1.0), 0.0);
        assert_eq!(device.power_at(f64::NAN), 0.0);
        assert_eq!(device.power_at(f64::INFINITY), 0.0);
    }

    #[test]
    fn model_files_are_parsed_with_the_intercept_first() {
        let json = r#"{
            "rbp4b1.2": {
                "polynomial": {"intercept": "2.5", "coefficients": ["1.5", "-0.5"]}
            }
        }"#;
        let models: HashMap<String, PlatformModel> = serde_json::from_str(json).unwrap();
        let coefficients = models["rbp4b1.2"]
            .polynomial
            .coefficients_with_intercept()
            .unwrap();
        assert_eq!(coefficients, vec![2.5, 1.5, -0.5]);
    }

    #[test]
    fn unparsable_model_coefficients_are_rejected() {
        let json = r#"{
            "board": {"polynomial": {"intercept": "2.5", "coefficients": ["oops"]}}
        }"#;
        let models: HashMap<String, PlatformModel> = serde_json::from_str(json).unwrap();
        assert!(
            models["board"]
                .polynomial
                .coefficients_with_intercept()
                .is_err()
        );
    }
}