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::Component;
#[cfg(target_os = "linux")]
use crate::cpu::CpuState;
#[cfg(target_os = "linux")]
use crate::cpu::read_proc_stat;
use crate::cpu::{AppCPUUtilization, CPUUtilization, ProcessCPUUtilization};
use crate::energy::{CPUEnergy, GPUEnergy, PlatformEnergy};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

pub struct MonitorSample {
    pub timestamp: u64,
    pub cpu_power: f64,
    pub gpu_power: f64,
    pub total_power: f64,
    pub cpu_usage: f64,
    pub process_power: Option<f64>,
    pub app_power: Option<(f64, usize)>,
}

impl MonitorSample {
    pub fn pid_app_power(&self) -> f64 {
        self.process_power
            .or_else(|| self.app_power.map(|(p, _)| p))
            .unwrap_or(0.0)
    }
}

pub struct JoularCoreMonitor {
    pub platform: Box<dyn PlatformEnergy>,
    pub cpu_energy: Box<dyn CPUEnergy>,
    pub gpu_energy: Box<dyn GPUEnergy>,
    pub cpu_usage: Box<dyn CPUUtilization>,
    pub process_tracker: Option<Box<dyn ProcessCPUUtilization>>,
    pub app_tracker: Option<Box<dyn AppCPUUtilization>>,
    pub cpu_idle_baseline: f64,
    #[cfg(target_os = "linux")]
    prev_cpu_state: Option<(u64, u64)>,
}

impl JoularCoreMonitor {
    pub fn new(
        platform: Box<dyn PlatformEnergy>,
        cpu_energy: Box<dyn CPUEnergy>,
        gpu_energy: Box<dyn GPUEnergy>,
        cpu_usage: Box<dyn CPUUtilization>,
        process_tracker: Option<Box<dyn ProcessCPUUtilization>>,
        app_tracker: Option<Box<dyn AppCPUUtilization>>,
        cpu_idle_baseline: f64,
    ) -> Self {
        Self {
            platform,
            cpu_energy,
            gpu_energy,
            cpu_usage,
            process_tracker,
            app_tracker,
            cpu_idle_baseline,
            #[cfg(target_os = "linux")]
            prev_cpu_state: None,
        }
    }

    /// Read initial data to warm up sensors (e.g. discard first RAPL reading)
    pub fn loop_init(&mut self) {
        self.cpu_energy.get_power();
        self.gpu_energy.get_power();
        #[cfg(target_os = "linux")]
        {
            self.prev_cpu_state = read_proc_stat();
        }
        #[cfg(not(target_os = "linux"))]
        {
            self.cpu_usage.get_cpu_utilization();
        }
    }

    pub fn set_cpu_idle_baseline(&mut self, baseline: f64) {
        self.cpu_idle_baseline = baseline.max(0.0);
    }

    pub fn calibrate_cpu_idle_baseline(&mut self, samples: usize, interval: Duration) -> f64 {
        self.loop_init();

        let sample_count = samples.max(1);
        let mut total_power = 0.0;
        for _ in 0..sample_count {
            // Ensure every sample covers a real measurement interval instead of
            // averaging in an immediate near-zero post-init reading.
            thread::sleep(interval);
            total_power += self.cpu_energy.get_power();
        }

        let baseline = total_power / sample_count as f64;
        self.set_cpu_idle_baseline(baseline);
        baseline
    }

    pub fn poll(
        &mut self,
        pid: Option<u32>,
        app_name: Option<&str>,
        component: Option<&Component>,
    ) -> MonitorSample {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        // When the caller restricts monitoring to a single component, skip the
        // sensor read for the other side entirely. This avoids the cost of
        // shelling out to nvidia-smi / amd-smi or running the powermetrics
        // GPU sampler when the user only cares about CPU, and vice versa.
        // Per-process / per-app attribution is meaningless in GPU-only mode
        // because it's derived from CPU power, so we also skip it there.
        let cpu_active = !matches!(component, Some(Component::Gpu));
        let gpu_active = !matches!(component, Some(Component::Cpu));

        let cpu_power = if cpu_active {
            self.cpu_energy.get_power()
        } else {
            0.0
        };
        let gpu_power = if gpu_active {
            self.gpu_energy.get_power()
        } else {
            0.0
        };
        let total_power = cpu_power + gpu_power;
        let attributed_cpu_power = (cpu_power - self.cpu_idle_baseline).max(0.0);

        let cpu_usage_val;
        let cpu_snapshot: Option<(u64, u64)>;
        if cpu_active {
            #[cfg(target_os = "linux")]
            {
                let snapshot = read_proc_stat();
                cpu_snapshot = snapshot;
                cpu_usage_val = match (self.prev_cpu_state, snapshot) {
                    (Some((prev_total, prev_idle)), Some((curr_total, curr_idle))) => {
                        self.prev_cpu_state = Some((curr_total, curr_idle));
                        CpuState::new(curr_total, curr_idle)
                            .cpu_usage(&CpuState::new(prev_total, prev_idle))
                            * 100.0
                    }
                    (_, Some((curr_total, curr_idle))) => {
                        self.prev_cpu_state = Some((curr_total, curr_idle));
                        0.0
                    }
                    _ => 0.0,
                };
            }
            #[cfg(not(target_os = "linux"))]
            {
                cpu_snapshot = None;
                cpu_usage_val = self.cpu_usage.get_cpu_utilization() * 100.0;
            }
        } else {
            cpu_snapshot = None;
            cpu_usage_val = 0.0;
        }

        let mut process_power = None;
        let mut app_power = None;

        // Process monitoring (skipped when CPU is not active — attribution is
        // derived from CPU power, so it has no meaning in GPU-only mode).
        if cpu_active
            && let Some(pid_val) = pid
            && let Some(tracker) = &mut self.process_tracker
        {
            let util = tracker.get_process_cpu_utilization_with_total(
                pid_val,
                cpu_snapshot.map(|(total, _)| total),
            );
            let pwr = self
                .platform
                .process_energy(util, attributed_cpu_power, cpu_usage_val);
            process_power = Some(pwr);
        }

        // App monitoring (same rationale as process monitoring above).
        if cpu_active
            && let Some(name) = app_name
            && let Some(tracker) = &mut self.app_tracker
        {
            let (util, pids) =
                tracker.get_app_snapshot_with_total(name, cpu_snapshot.map(|(total, _)| total));
            let pwr = self
                .platform
                .app_energy(util, attributed_cpu_power, cpu_usage_val);
            app_power = Some((pwr, pids.len()));
        }

        MonitorSample {
            timestamp,
            cpu_power,
            gpu_power,
            total_power,
            cpu_usage: cpu_usage_val,
            process_power,
            app_power,
        }
    }
}