Joular Core :zap:
August 11, 2026 · View on GitHub

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.
The library is used by two multi-OS applications:
- Joular Core CLI — a command-line program.
- Joular Core 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
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. |
# 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 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.
env_logger::init(); // honours RUST_LOG, e.g. RUST_LOG=joularcore=debug
💡 Quickstart
A runnable version lives in examples/monitor.rs:
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
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
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.
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:
# 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 |
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, used through the Scaphandre driver interface. The easiest way to install a signed version is through the Scaphandre installer. 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.
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:
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:
- Load
headwith acquire ordering.head == 0means nothing has been written yet. - Read slot
(head - 1) % 5. - Load
headagain. 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) which accompanies this distribution.
Author: Prof. Adel Noureddine