joularcore 0.1.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
 */

use crate::cpu::{
    AppCPUUtilization, AppMonitor, CPUUtilization, ProcStatCpuUsage, ProcStatProcessUtil,
    ProcessCPUUtilization,
};
use crate::energy::{CPUEnergy, GPUEnergy, PlatformEnergy};
use crate::platform::amdgpu;
use crate::platform::nvidia;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;

pub struct LinuxCpu {
    rapl: Mutex<Option<IntelRapl>>,
    warned_read_failure: AtomicBool,
}

impl LinuxCpu {
    pub fn new() -> Self {
        match IntelRapl::new() {
            Ok(rapl) => Self {
                rapl: std::sync::Mutex::new(Some(rapl)),
                warned_read_failure: AtomicBool::new(false),
            },
            Err(e) => {
                crate::logging::print_warning(&format!(
                    "RAPL is not available: {}. Continuing without CPU power data",
                    e
                ));
                Self {
                    rapl: std::sync::Mutex::new(None),
                    warned_read_failure: AtomicBool::new(false),
                }
            }
        }
    }
}

impl CPUEnergy for LinuxCpu {
    fn get_power(&self) -> f64 {
        let mut rapl = self.rapl.lock().unwrap_or_else(|e| e.into_inner());
        match rapl.as_mut() {
            Some(rapl) => match rapl.calculate_power() {
                Ok(power) => power,
                Err(e) => {
                    // Warn the first time a read fails so the user knows the
                    // 0 W readings aren't real idle. Don't spam at 1 Hz.
                    if !self.warned_read_failure.swap(true, Ordering::Relaxed) {
                        tracing::warn!(error = %e, "RAPL read failed; reporting 0 W until it recovers");
                    }
                    0.0
                }
            },
            None => 0.0,
        }
    }
}

pub struct LinuxGpu;
impl GPUEnergy for LinuxGpu {
    fn get_power(&self) -> f64 {
        let mut gpu_energy = 0.0;
        if nvidia::is_nvidia_supported() {
            gpu_energy += nvidia::get_nvidia_power();
        }
        if amdgpu::is_amdgpu_supported() {
            gpu_energy += amdgpu::get_amdgpu_power();
        }
        gpu_energy
    }
}

pub struct LinuxPlatform;

impl LinuxPlatform {
    pub fn new() -> Self {
        LinuxPlatform
    }
}

impl PlatformEnergy for LinuxPlatform {
    fn cpu(&self) -> Box<dyn CPUEnergy> {
        Box::new(LinuxCpu::new())
    }

    fn gpu(&self) -> Box<dyn GPUEnergy> {
        Box::new(LinuxGpu)
    }

    fn cpu_usage(&self) -> Box<dyn CPUUtilization> {
        Box::new(ProcStatCpuUsage::new())
    }

    fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCPUUtilization>> {
        Some(Box::new(ProcStatProcessUtil::new()))
    }

    fn app_cpu_usage(
        &self,
        refresh_interval: std::time::Duration,
    ) -> Option<Box<dyn AppCPUUtilization>> {
        Some(Box::new(AppMonitor::new(
            ProcStatProcessUtil::new,
            refresh_interval,
        )))
    }
}

const RAPL_PATH: &str = "/sys/class/powercap/intel-rapl";

/// Intel RAPL energy reader
pub struct IntelRapl {
    energy_path: PathBuf,
    max_energy_range: f64,
    last_energy: f64,
    last_read_at: Option<Instant>,
}

impl IntelRapl {
    /// Create a new Intel RAPL reader
    ///
    /// Initializes the PKG (package-0) energy source.
    /// Returns an error if PKG is not supported.
    pub fn new() -> Result<Self, String> {
        let pkg_dir = PathBuf::from(format!("{}/intel-rapl:0", RAPL_PATH));
        let name_path = pkg_dir.join("name");
        let energy_path = pkg_dir.join("energy_uj");
        let max_energy_range_path = pkg_dir.join("max_energy_range_uj");

        let name_ok = fs::read_to_string(&name_path)
            .map(|name| name.trim() == "package-0")
            .unwrap_or(false);
        if !name_ok {
            return Err("No supported RAPL package found (expected pkg/package-0)".to_string());
        }

        // Read max energy range
        let max_energy_range = fs::read_to_string(&max_energy_range_path)
            .map_err(|e| format!("Failed to read max_energy_range: {}", e))?
            .trim()
            .parse::<f64>()
            .map_err(|e| format!("Failed to parse max_energy_range: {}", e))?
            / 1_000_000.0; // Convert from microjoules to joules

        Ok(Self {
            energy_path,
            max_energy_range,
            last_energy: 0.0,
            last_read_at: None,
        })
    }

    /// Get current energy reading in joules
    ///
    /// Reads the energy counter from the detected RAPL package.
    pub fn read_energy(&self) -> Result<f64, io::Error> {
        let microjoules = fs::read_to_string(&self.energy_path)?
            .trim()
            .parse::<f64>()
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

        Ok(microjoules / 1_000_000.0)
    }

    /// Get the maximum energy range in joules
    pub fn max_energy_range(&self) -> f64 {
        self.max_energy_range
    }

    /// Calculate power since last time
    pub fn calculate_power(&mut self) -> io::Result<f64> {
        let current_energy: f64 = self.read_energy()?;
        let current_read_at = Instant::now();

        let Some(last_read_at) = self.last_read_at else {
            self.last_energy = current_energy;
            self.last_read_at = Some(current_read_at);
            return Ok(0.0);
        };

        let energy_delta = if current_energy >= self.last_energy {
            current_energy - self.last_energy
        } else {
            // PKG has wrapped
            current_energy - self.last_energy + self.max_energy_range()
        };
        let elapsed_secs = current_read_at.duration_since(last_read_at).as_secs_f64();

        self.last_energy = current_energy;
        self.last_read_at = Some(current_read_at);

        if elapsed_secs <= 0.0 || !elapsed_secs.is_finite() {
            return Ok(0.0);
        }

        Ok(energy_delta / elapsed_secs)
    }
}