joularcore 0.2.0

Joular Core is a platform to measure power and energy across all systems, OSes and devices
Documentation
# <a href="https://www.noureddine.org/research/joular/"><img src="https://raw.githubusercontent.com/joular/.github/main/profile/joular.png" alt="Joular Project" width="64" /></a> Joular Core :zap:

[![Crates.io](https://img.shields.io/crates/v/joularcore)](https://crates.io/crates/joularcore) [![Documentation](https://docs.rs/joularcore/badge.svg)](https://docs.rs/joularcore) [![License: LGPL v3](https://img.shields.io/badge/License-LGPLv3-blue)](https://www.gnu.org/licenses/lgpl-3.0) ![Made with Rust](https://img.shields.io/badge/Made%20with-Rust-2b2b2b?logo=rust&logoColor=white)

![Joular Core Logo](https://raw.githubusercontent.com/joular/joularcore/develop/joularcore.png)

Joular Core is a Rust library for measuring power and energy across systems and devices.

It measures CPU and GPU power in real time and attributes it to individual processes or applications, on Linux, Windows, macOS, Raspberry Pi, and inside virtual machines. Samples can be written to CSV files and mirrored into a shared-memory ring buffer that programs in other languages read.

Full documentation: [joular.github.io/joularcore](https://joular.github.io/joularcore/).

The library is used by two multi-OS applications:
- [Joular Core CLI](https://github.com/joular/joularcore-cli) — a command-line program.
- [Joular Core GUI](https://github.com/joular/joularcore-gui) — a graphical program.

> Joular Core is under active development and currently in beta quality. Expect rough edges and features still being worked on and polished.

## :satellite: Supported platforms

**CPU power:**

| OS / Architecture     | x86_64 | i686 | Apple Silicon | arm | armv7 | aarch64 |
|-----------------------|:------:|:----:|:-------------:|:---:|:-----:|:-------:|
| Linux                 | ✓      | ✓    |               |     |       |         |
| Windows               | ✓      | ✓    |               |     |       |         |
| macOS                 | ✓      |      | ✓             |     |       |         |
| SBC (Raspberry Pi)    |        |      |               | ✓   | ✓     | ✓       |
| Virtual Machines      | ✓      | ✓    | ✓             | ✓   | ✓     | ✓       |

**GPU power:**

| OS / Architecture    | Nvidia | AMD | Apple GPU |
|----------------------|:------:|:---:|:---------:|
| Linux                | ✓      | ✓   |           |
| Windows              | ✓      | ✓   |           |
| macOS                |        |     | ✓         |
| Virtual Machines     | ✓      | ✓   | ✓         |

**Supported SBC boards**: Raspberry Pi (Zero W, 1 B, 1 B+, 2 B, 3 B, 3 B+, 4 B, 400, 5 B), Asus Tinker Board S.

## :rocket: Features

- 📊 Real-time CPU and GPU power monitoring on PCs, servers, and single-board computers
- 🔍 Per-process and per-application power attribution
- 🌐 Monitor from inside a virtual machine, using data from the hypervisor or an external meter
- 📈 Export to CSV files, and to a shared-memory ring buffer for low-latency IPC
- ⚙️ Restrict output to CPU power, GPU power, or both
- ⚙️ Subtract an idle CPU baseline before attributing power
- 🔎 Unreadable sensors are reported as unavailable, never as a plausible-looking 0 W

## 📦 Installation

```bash
cargo add joularcore
```

### Cargo features

| Feature | Default | Description |
|---------|:-------:|-------------|
| `vm`    | on      | Read power from a file written by a hypervisor or external meter |
| `sbc`   | off     | Single-board computer power models. On Linux this replaces the RAPL backend. |

```toml
# Default: measurement, CSV and ring buffer output, and VM power files
joularcore = "0.3.0"

# Minimal core library, no VM file reading
joularcore = { version = "0.3.0", default-features = false }

# SBC build for a Raspberry Pi target
joularcore = { version = "0.3.0", default-features = false, features = ["sbc"] }
```

Features are additive: enabling one never changes the signature of anything the others expose.

## 📝 Logging

The library never writes to stdout or stderr on its own. It emits [`log`](https://docs.rs/log) records — install whatever logger your program already uses, and warnings such as "RAPL is not readable" will appear there. With no logger installed, the records are discarded and nothing is printed.

```rust,ignore
env_logger::init(); // honours RUST_LOG, e.g. RUST_LOG=joularcore=debug
```

## 💡 Quickstart

A runnable version lives in [`examples/monitor.rs`](examples/monitor.rs):

```bash
cargo run --example monitor            # whole system
cargo run --example monitor -- 1234    # plus the process with PID 1234
cargo run --example monitor -- firefox # plus every firefox process
```

### Sampling system power

```rust,no_run
use joularcore::JoularCoreMonitor;
use std::time::Duration;

let mut monitor = JoularCoreMonitor::new();

std::thread::sleep(Duration::from_secs(1));

let sample = monitor.poll();
println!("CPU Power:   {:.2} W", sample.cpu_power_or_zero());
println!("GPU Power:   {:.2} W", sample.gpu_power_or_zero());
println!("Total Power: {:.2} W", sample.total_power());
println!("CPU Usage:   {:.1} %", sample.cpu_usage);
```

There is no `Result` to handle: platform sensors never fail to *construct*. One that cannot be read reports that per reading, as `None`.

### Targeting a process or application

```rust,no_run
use joularcore::JoularCoreMonitor;
use std::time::Duration;

// JoularCoreMonitor::for_app("firefox") tracks every process of an application.
let mut monitor = JoularCoreMonitor::for_pid(1234);

std::thread::sleep(Duration::from_secs(1));

println!("Process power: {:.2} W", monitor.poll().target_power_or_zero());
```

### Writing output

Every destination implements `OutputSink`, and an `OutputBundle` forwards one sample to all of them. Each is constructed explicitly, so a failure to create it is reported where you asked for it — and you only pay for the ones you attach.

```rust,no_run
use joularcore::output::FileWriter;
use joularcore::ringbuffer::RingBufferWriter;
use joularcore::{JoularCoreMonitor, OutputBundle};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut monitor = JoularCoreMonitor::new();

    // FileWriter::csv opens the file and writes the header row automatically.
    let outputs = OutputBundle::new()
        .with(FileWriter::csv("power_log.csv", monitor.config())?)
        .with(RingBufferWriter::new()?);

    // Every sample now reaches the file and the ring buffer at once:
    // outputs.send(&monitor.poll())?;

    Ok(())
}
```

To send samples anywhere else — over HTTP, into a database — implement `OutputSink` for your own type and push it onto the same bundle.

### Handling unavailable sensors

Power interfaces are privileged on most systems. `cpu_power` and `gpu_power` are `Option<f64>`, so an unreadable sensor is never mistaken for an idle machine:

```rust
# use joularcore::MonitorSample;
# fn show(sample: &MonitorSample) {
match sample.cpu_power {
    Some(watts) => println!("CPU {watts:.2} W"),
    None => eprintln!("CPU power unavailable — check privileges (see the log)"),
}
# }
```

## :bulb: Configuration

`MonitorConfig` is plain data: start from `Default::default()` and set the fields you care about.

| Field | Default | Description |
|-------|---------|-------------|
| `target` | `Target::System` | What power is attributed to: `System`, `Pid(u32)`, or `App(String)` |
| `component` | `None` (both) | Restrict measurement to `Component::Cpu` or `Component::Gpu`. Skipping a component also skips its sensor read. |
| `app_match` | `AppMatch::Exact` | How an application name is matched: exact (ignoring case and a trailing `.exe`), or `Contains` |
| `app_refresh_interval` | 3 s | How often to rescan for an application's processes. `Duration::ZERO` rescans every sample. |
| `cpu_idle_baseline` | `None` | `Some(watts)` subtracts a known idle floor. To measure it instead, call `JoularCoreMonitor::calibrate_cpu_idle_baseline(samples, interval)` on the built monitor — it returns an error if CPU power cannot be read and blocks for `samples * interval`, so it is an explicit call rather than a setting. |
| `elevation` | `Never` | How far to go for privileged sensor access — see [Elevation](#elevation) |

### Platform requirements

**Linux (PC / servers)**
Reads CPU power via the Intel RAPL package interface (`/sys/class/powercap/intel-rapl:*`). Every `package-*` domain is summed, so multi-socket machines report the whole system rather than the first socket. Since kernel 5.10 these counters are readable only by root, so either run as root or grant read access to `energy_uj`. GPU power is read via `nvidia-smi` (Nvidia) or `amd-smi` / `rocm-smi` (AMD) if installed.

**Windows**
CPU power requires [Hubblo's RAPL driver](https://github.com/hubblo-org/windows-rapl-driver), used through the Scaphandre driver interface. The easiest way to install a signed version is through the [Scaphandre installer](https://github.com/hubblo-org/scaphandre/releases/download/v1.0.0/scaphandre_v1.0.0_installer.exe). Once the driver is installed, Joular Core runs without administrator rights. GPU power is read via `nvidia-smi` and `amd-smi`.

**macOS**
No additional dependencies. Uses `powermetrics`, which ships with macOS but must run as root. Apple Silicon GPU power is read through the same interface. If the sampler dies or stalls, readings older than five sample intervals are reported as unavailable rather than repeated forever.

**Raspberry Pi / SBC**
No dependencies and no `sudo` required when built with the `sbc` feature. CPU power is calculated using regression models tuned for each supported board. Unrecognised boards report an unavailable sensor rather than a wrong figure. GPU is not supported on SBC platforms.

To use your own model instead of the built-in ones, set `SBC_POWER_MODEL_JSON=/path/to/model.json`. The file must match the format used in the [Joular Power Models Database](https://github.com/joular/powermodels).

### Elevation

`powermetrics` needs root, and a library must not surprise its caller with a password prompt. `MonitorConfig::elevation` decides what may happen:

| Policy | Behaviour |
|--------|-----------|
| `ElevationPolicy::Never` (default) | Never elevates. An unprivileged process reports CPU and GPU power as unavailable. |
| `SudoNonInteractive` | Uses `sudo -n`; succeeds only if a credential is already cached. Never prompts. |

Neither policy ever prompts or blocks. If your program needs power readings on macOS, run it as root, or cache a `sudo` credential yourself — with your own prompt, which you control — and then use `SudoNonInteractive`.

Every program Joular Core runs with elevated privileges is addressed by absolute path, so a modified `PATH` cannot substitute it.

### Virtual machines

`VmSensor` reads power from a file written by the hypervisor or an external meter. It is an ordinary `PowerSensor`, so hand one to the monitor builder in place of the platform's own — everything you do not replace keeps coming from the platform:

```rust,no_run
use joularcore::vm::{PowerFormat, VmSensor};
use joularcore::{JoularCoreMonitor, MonitorConfig};

# fn main() -> joularcore::Result<()> {
let config = MonitorConfig::default();

let monitor = JoularCoreMonitor::builder(&config)
    .cpu_sensor(Box::new(VmSensor::cpu("/var/run/vm-power", PowerFormat::Watts)?))
    .build();
# Ok(())
# }
```

Two file formats are understood. `watts` is a plain text file holding a single number — what a `FileWriter` using `Schema::watts(..)` writes. `joularcore` is CSV with a header row matching Joular Core's own output, read from the last data row; for CPU power it prefers `App Power (W)`, then `Process Power (W)`, then `CPU Power (W)`, and for GPU power it reads `GPU Power (W)`.

`VmConfig::from_env()` reads `VM_CPU_POWER_FILE`, `VM_CPU_POWER_FORMAT`, `VM_GPU_POWER_FILE` and `VM_GPU_POWER_FORMAT`, returning `Ok(None)` when neither power-file variable is set; `VmSensor::cpu_from_config` turns one into a sensor. These variables are only read when you ask for them — the library does not consult the environment behind your back. The file is opened once and re-read by seeking back to the start, so replacing the file at that path afterwards does not redirect later reads. Reads are capped at 1 MiB.

## 🛠️ Shared memory ring buffer

`RingBufferWriter` streams readings into a zero-copy shared memory region that separate native binaries can read.

| OS      | Default path |
|---------|------|
| Linux   | `/dev/shm/joularcorering` |
| macOS   | `/tmp/joularcorering` |
| Windows | `Local\JoularCoreRing` |

The region starts with an 8-byte native-endian `u64` head counter, followed by 5 slots. Each slot is a C-compatible 48-byte `PowerRecord`: `timestamp` (u64, Unix seconds), then `CPU power`, `GPU power`, `Total power`, `CPU usage` (percent) and `PID or app power`, all f64. Fields that do not apply to the current mode are `0`. Consumers can compare `timestamp` against the current time to detect stale or paused samples.

**Reader protocol.** The writer stores the incremented head *after* the slot it describes, with release ordering. A reader should therefore:

1. Load `head` with acquire ordering. `head == 0` means nothing has been written yet.
2. Read slot `(head - 1) % 5`.
3. Load `head` again. If it advanced by 5 or more, the writer lapped the reader mid-copy — discard the value and retry.

At the usual 1 Hz sample rate a lap takes seconds, so step 3 effectively never fails, but skipping it makes torn reads possible.

**Ownership.** The default Unix paths live in world-writable directories, so Joular Core opens them with `O_NOFOLLOW` and refuses anything that is not a plain file, owned by the current user, with exactly one hard link. The file is created mode `0600`, so other local users cannot read live power telemetry. On Windows, an existing section object of the same name is refused rather than adopted. In all cases the failure is reported instead of worked around.

Pass your own location to `RingBufferWriter::with_path` if you need several instances side by side.

## 📜 License

Joular Core is licensed under the GNU Lesser General Public License 3 license only (LGPL-3.0-only).

Copyright © 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 (LGPL-3.0-only)](https://www.gnu.org/licenses/lgpl-3.0.en.html) which accompanies this distribution.

Author: Prof. Adel Noureddine