use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::time::{Duration, Instant};
pub const JOULES_PER_KWH: f64 = 3_600_000.0;
pub const DEFAULT_GAP_INTERPOLATE_SECONDS: u64 = 10;
pub const MAX_POWER_WATTS: f64 = 100_000.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EnergyScope {
Gpu,
Cpu,
Chassis,
}
impl EnergyScope {
pub fn as_str(self) -> &'static str {
match self {
EnergyScope::Gpu => "gpu",
EnergyScope::Cpu => "cpu",
EnergyScope::Chassis => "chassis",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct EnergyKey {
pub scope: EnergyScope,
pub host: String,
pub device: String,
}
impl EnergyKey {
pub fn gpu(host: impl Into<String>, uuid: impl Into<String>) -> Self {
Self {
scope: EnergyScope::Gpu,
host: host.into(),
device: uuid.into(),
}
}
pub fn cpu(host: impl Into<String>) -> Self {
Self {
scope: EnergyScope::Cpu,
host: host.into(),
device: String::new(),
}
}
pub fn chassis(host: impl Into<String>) -> Self {
Self {
scope: EnergyScope::Chassis,
host: host.into(),
device: String::new(),
}
}
pub fn host_hash(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.host.hash(&mut hasher);
hasher.finish()
}
pub fn device_hash(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.scope.as_str().hash(&mut hasher);
self.device.hash(&mut hasher);
hasher.finish()
}
}
#[derive(Clone, Copy, Debug)]
struct DeviceState {
last_sample: Option<Sample>,
session_joules: f64,
lifetime_joules: f64,
wal_pending_joules: f64,
}
#[derive(Clone, Copy, Debug)]
struct Sample {
t: Instant,
p: f64,
}
impl Default for DeviceState {
fn default() -> Self {
Self {
last_sample: None,
session_joules: 0.0,
lifetime_joules: 0.0,
wal_pending_joules: 0.0,
}
}
}
pub const MAX_DEVICES: usize = 10_000;
#[derive(Clone, Debug)]
pub struct PowerIntegrator {
devices: HashMap<EnergyKey, DeviceState>,
gap_interpolate: Duration,
}
impl Default for PowerIntegrator {
fn default() -> Self {
Self::new(Duration::from_secs(DEFAULT_GAP_INTERPOLATE_SECONDS))
}
}
impl PowerIntegrator {
pub fn new(gap_interpolate: Duration) -> Self {
Self {
devices: HashMap::new(),
gap_interpolate,
}
}
pub fn record_sample(&mut self, key: EnergyKey, t: Instant, watts: f64) -> f64 {
let gap = self.gap_interpolate;
if self.devices.len() >= MAX_DEVICES && !self.devices.contains_key(&key) {
return 0.0;
}
let state = self.devices.entry(key).or_default();
let sanitized_now = sanitize_power(watts);
let Some(prev) = state.last_sample else {
state.last_sample = Some(Sample {
t,
p: sanitized_now,
});
return 0.0;
};
let dt = t.saturating_duration_since(prev.t);
if dt.is_zero() {
state.last_sample = Some(Sample {
t: prev.t,
p: sanitized_now,
});
return 0.0;
}
let dt_secs = dt.as_secs_f64();
let prev_power = sanitize_power(prev.p);
let delta = if sanitized_now == 0.0 && prev_power == 0.0 {
0.0
} else if dt > gap {
prev_power * dt_secs
} else {
0.5 * (prev_power + sanitized_now) * dt_secs
};
state.session_joules += delta;
state.lifetime_joules += delta;
state.wal_pending_joules += delta;
state.last_sample = Some(Sample {
t,
p: sanitized_now,
});
delta
}
#[allow(dead_code)] pub fn session_joules(&self, key: &EnergyKey) -> f64 {
self.devices
.get(key)
.map(|s| s.session_joules)
.unwrap_or(0.0)
}
#[allow(dead_code)] pub fn lifetime_joules(&self, key: &EnergyKey) -> f64 {
self.devices
.get(key)
.map(|s| s.lifetime_joules)
.unwrap_or(0.0)
}
pub fn has_samples(&self, key: &EnergyKey) -> bool {
self.devices
.get(key)
.map(|s| s.last_sample.is_some())
.unwrap_or(false)
}
pub fn reset_session(&mut self) {
for state in self.devices.values_mut() {
state.session_joules = 0.0;
}
}
pub fn seed_lifetime(&mut self, key: EnergyKey, joules: f64) {
if !joules.is_finite() || joules <= 0.0 {
return;
}
if self.devices.len() >= MAX_DEVICES && !self.devices.contains_key(&key) {
return;
}
let state = self.devices.entry(key).or_default();
state.lifetime_joules += joules;
}
pub fn drain_wal_deltas(&mut self) -> Vec<(EnergyKey, f64)> {
let mut out = Vec::new();
for (key, state) in self.devices.iter_mut() {
if state.wal_pending_joules > 0.0 {
out.push((key.clone(), state.wal_pending_joules));
state.wal_pending_joules = 0.0;
}
}
out
}
pub fn iter_stats(&self) -> impl Iterator<Item = EnergyStats<'_>> {
self.devices.iter().filter_map(|(key, state)| {
if state.last_sample.is_some() || state.lifetime_joules > 0.0 {
Some(EnergyStats {
key,
session_joules: state.session_joules,
lifetime_joules: state.lifetime_joules,
})
} else {
None
}
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct EnergyStats<'a> {
pub key: &'a EnergyKey,
pub session_joules: f64,
pub lifetime_joules: f64,
}
#[derive(Clone, Debug)]
pub struct EnergyAccountant {
integrator: PowerIntegrator,
session_started_at: Instant,
}
impl Default for EnergyAccountant {
fn default() -> Self {
Self::new(Duration::from_secs(DEFAULT_GAP_INTERPOLATE_SECONDS))
}
}
impl EnergyAccountant {
pub fn new(gap_interpolate: Duration) -> Self {
Self {
integrator: PowerIntegrator::new(gap_interpolate),
session_started_at: Instant::now(),
}
}
pub fn integrator_mut(&mut self) -> &mut PowerIntegrator {
&mut self.integrator
}
pub fn integrator(&self) -> &PowerIntegrator {
&self.integrator
}
#[allow(dead_code)] pub fn session_started_at(&self) -> Instant {
self.session_started_at
}
#[allow(dead_code)] pub fn session_elapsed(&self) -> Duration {
self.session_started_at.elapsed()
}
pub fn reset_session(&mut self) {
self.integrator.reset_session();
self.session_started_at = Instant::now();
}
#[allow(dead_code)] pub fn seed_lifetime(&mut self, key: EnergyKey, joules: f64) {
self.integrator.seed_lifetime(key, joules);
}
}
#[inline]
fn sanitize_power(watts: f64) -> f64 {
if !watts.is_finite() || watts < 0.0 {
0.0
} else if watts > MAX_POWER_WATTS {
MAX_POWER_WATTS
} else {
watts
}
}
#[inline]
pub fn joules_to_kwh(joules: f64) -> f64 {
joules / JOULES_PER_KWH
}
#[inline]
pub fn joules_to_cost(joules: f64, price_per_kwh: f64) -> f64 {
if !price_per_kwh.is_finite() || price_per_kwh <= 0.0 {
return 0.0;
}
joules_to_kwh(joules) * price_per_kwh
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn analytic_sine_joules(p_mid: f64, amp: f64, omega: f64, t: f64) -> f64 {
p_mid * t + amp * (1.0 - (omega * t).cos()) / omega
}
#[test]
fn sine_wave_trapezoidal_matches_analytic_within_0_1_percent() {
let p_mid = 200.0;
let amp = 100.0;
let period = 60.0_f64;
let omega = 2.0 * PI / period;
let samples = 1000;
let dt = 0.1_f64; let total_time = samples as f64 * dt;
let mut integ = PowerIntegrator::new(Duration::from_secs(10));
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
for i in 0..=samples {
let t = i as f64 * dt;
let watts = p_mid + amp * (omega * t).sin();
integ.record_sample(key.clone(), origin + Duration::from_secs_f64(t), watts);
}
let integrated = integ.lifetime_joules(&key);
let analytic = analytic_sine_joules(p_mid, amp, omega, total_time);
let rel_error = ((integrated - analytic).abs() / analytic).abs();
assert!(
rel_error < 0.001,
"trapezoidal sine integral off: analytic {analytic:.6}, integrated {integrated:.6}, rel_error {rel_error:.6}"
);
}
#[test]
fn constant_power_matches_rectangle_integral() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("dgx-01", "uuid-0");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, 300.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(600), 300.0);
let joules = integ.lifetime_joules(&key);
assert!(
(joules - 180_000.0).abs() < 1e-6,
"expected 180 000 J, got {joules}"
);
assert!((joules_to_kwh(joules) - 0.05).abs() < 1e-9);
}
#[test]
fn short_gap_interpolates_linearly() {
let mut integ = PowerIntegrator::new(Duration::from_secs(10));
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, 100.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(5), 200.0);
assert!((integ.lifetime_joules(&key) - 750.0).abs() < 1e-9);
}
#[test]
fn long_gap_holds_last_reading() {
let mut integ = PowerIntegrator::new(Duration::from_secs(10));
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, 100.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(30), 200.0);
assert!(
(integ.lifetime_joules(&key) - 3_000.0).abs() < 1e-9,
"got {}",
integ.lifetime_joules(&key)
);
}
#[test]
fn nan_and_negative_samples_linear_glide_to_zero() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, 100.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(1), f64::NAN);
integ.record_sample(key.clone(), origin + Duration::from_secs(2), -50.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(3), 100.0);
let joules = integ.lifetime_joules(&key);
assert!((joules - 100.0).abs() < 1e-9, "got {joules}");
}
#[test]
fn reset_session_preserves_lifetime() {
let mut acct = EnergyAccountant::default();
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
acct.integrator_mut()
.record_sample(key.clone(), origin, 100.0);
acct.integrator_mut()
.record_sample(key.clone(), origin + Duration::from_secs(10), 100.0);
let lifetime_before = acct.integrator().lifetime_joules(&key);
assert!(lifetime_before > 0.0);
assert!(acct.integrator().session_joules(&key) > 0.0);
acct.reset_session();
assert_eq!(acct.integrator().session_joules(&key), 0.0);
assert!(
(acct.integrator().lifetime_joules(&key) - lifetime_before).abs() < 1e-9,
"lifetime must survive reset"
);
}
#[test]
fn seed_lifetime_adds_to_counter_without_touching_session() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
integ.seed_lifetime(key.clone(), 1_000.0);
assert_eq!(integ.lifetime_joules(&key), 1_000.0);
assert_eq!(integ.session_joules(&key), 0.0);
integ.seed_lifetime(key.clone(), f64::NAN);
integ.seed_lifetime(key.clone(), -5.0);
assert_eq!(integ.lifetime_joules(&key), 1_000.0);
}
#[test]
fn drain_wal_deltas_zeros_pending() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, 100.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(10), 100.0);
let drained = integ.drain_wal_deltas();
assert_eq!(drained.len(), 1);
assert!((drained[0].1 - 1_000.0).abs() < 1e-9);
let drained2 = integ.drain_wal_deltas();
assert!(drained2.is_empty());
}
#[test]
fn first_sample_does_not_panic_and_returns_zero() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
let delta = integ.record_sample(key.clone(), Instant::now(), 250.0);
assert_eq!(delta, 0.0);
assert_eq!(integ.lifetime_joules(&key), 0.0);
}
#[test]
fn duplicate_timestamp_refreshes_power_without_accumulating() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, 100.0);
let delta = integ.record_sample(key.clone(), origin, 500.0);
assert_eq!(delta, 0.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(1), 500.0);
let joules = integ.lifetime_joules(&key);
assert!((joules - 500.0).abs() < 1e-9, "got {joules}");
}
#[test]
fn joules_to_cost_respects_non_positive_prices() {
assert_eq!(joules_to_cost(3_600_000.0, 0.12), 0.12);
assert_eq!(joules_to_cost(3_600_000.0, 0.0), 0.0);
assert_eq!(joules_to_cost(3_600_000.0, -0.5), 0.0);
assert_eq!(joules_to_cost(3_600_000.0, f64::NAN), 0.0);
}
#[test]
fn energy_key_hashes_are_stable_within_process() {
let k1 = EnergyKey::gpu("host-a", "uuid-0");
let k2 = EnergyKey::gpu("host-a", "uuid-0");
assert_eq!(k1.host_hash(), k2.host_hash());
assert_eq!(k1.device_hash(), k2.device_hash());
let chassis = EnergyKey::chassis("host-a");
let cpu = EnergyKey::cpu("host-a");
assert_ne!(chassis.device_hash(), cpu.device_hash());
}
#[test]
fn pathological_power_samples_do_not_overflow_lifetime() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
for i in 0..3600 {
integ.record_sample(
key.clone(),
origin + Duration::from_secs(i),
if i == 0 { 100.0 } else { f64::MAX },
);
}
integ.record_sample(
key.clone(),
origin + Duration::from_secs(3601),
f64::INFINITY,
);
let joules = integ.lifetime_joules(&key);
assert!(
joules.is_finite(),
"lifetime counter must remain finite under pathological input (got {joules})"
);
let upper_bound = MAX_POWER_WATTS * 3601.0 * 1.01;
assert!(
joules <= upper_bound,
"lifetime counter should stay under the MAX_POWER_WATTS envelope: got {joules}, bound {upper_bound}"
);
}
#[test]
fn max_power_watts_clamps_single_sample() {
let mut integ = PowerIntegrator::default();
let key = EnergyKey::gpu("host", "uuid");
let origin = Instant::now();
integ.record_sample(key.clone(), origin, f64::MAX);
integ.record_sample(key.clone(), origin + Duration::from_secs(1), 0.0);
let joules = integ.lifetime_joules(&key);
assert!(
(joules - MAX_POWER_WATTS / 2.0).abs() < 1e-3,
"expected ~MAX_POWER_WATTS/2 J, got {joules}"
);
}
#[test]
fn record_sample_enforces_device_cardinality_cap() {
let mut integ = PowerIntegrator::default();
let origin = Instant::now();
for i in 0..MAX_DEVICES {
integ.record_sample(EnergyKey::gpu("host", format!("uuid-{i}")), origin, 100.0);
}
assert_eq!(integ.devices.len(), MAX_DEVICES);
let overflow_key = EnergyKey::gpu("host", "uuid-overflow");
let delta = integ.record_sample(overflow_key.clone(), origin, 100.0);
assert_eq!(delta, 0.0);
assert_eq!(integ.devices.len(), MAX_DEVICES);
assert!(!integ.has_samples(&overflow_key));
let existing = EnergyKey::gpu("host", "uuid-0");
integ.record_sample(existing.clone(), origin + Duration::from_secs(1), 100.0);
assert!(integ.has_samples(&existing));
}
}