1use serde::{Deserialize, Serialize};
6use std::time::{Duration, Instant};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct EnergyMetrics {
11 pub watts_avg: f64,
13 pub joules_total: f64,
15 pub carbon_kg: f64,
17 pub efficiency_samples_per_joule: f64,
19}
20
21impl EnergyMetrics {
22 pub fn new(watts_avg: f64, joules_total: f64, samples: u64) -> Self {
24 let efficiency = if joules_total > 0.0 { samples as f64 / joules_total } else { 0.0 };
25
26 Self { watts_avg, joules_total, carbon_kg: 0.0, efficiency_samples_per_joule: efficiency }
27 }
28
29 pub fn from_power_readings(readings: &[(Instant, f64)], samples: u64) -> Self {
36 if readings.len() < 2 {
37 return Self::new(0.0, 0.0, samples);
38 }
39
40 let mut total_joules = 0.0;
42 let mut total_watts = 0.0;
43
44 for i in 1..readings.len() {
45 let (t1, w1) = readings[i - 1];
46 let (t2, w2) = readings[i];
47
48 let duration_secs = t2.duration_since(t1).as_secs_f64();
49 let avg_watts = f64::midpoint(w1, w2);
50
51 total_joules += avg_watts * duration_secs;
52 total_watts += avg_watts;
53 }
54
55 let watts_avg = total_watts / (readings.len() - 1).max(1) as f64;
56 Self::new(watts_avg, total_joules, samples)
57 }
58
59 pub fn with_carbon_intensity(mut self, kg_co2_per_kwh: f64) -> Self {
69 let kwh = self.joules_total / 3_600_000.0; self.carbon_kg = kwh * kg_co2_per_kwh;
71 self
72 }
73
74 pub fn kwh(&self) -> f64 {
76 self.joules_total / 3_600_000.0
77 }
78
79 pub fn wh(&self) -> f64 {
81 self.joules_total / 3_600.0
82 }
83
84 pub fn estimated_cost_usd(&self, usd_per_kwh: f64) -> f64 {
86 self.kwh() * usd_per_kwh
87 }
88
89 pub fn zero() -> Self {
91 Self {
92 watts_avg: 0.0,
93 joules_total: 0.0,
94 carbon_kg: 0.0,
95 efficiency_samples_per_joule: 0.0,
96 }
97 }
98
99 pub fn add(&self, other: &Self) -> Self {
101 let total_joules = self.joules_total + other.joules_total;
102 let weighted_watts = if total_joules > 0.0 {
103 (self.watts_avg * self.joules_total + other.watts_avg * other.joules_total)
104 / total_joules
105 } else {
106 0.0
107 };
108
109 Self {
110 watts_avg: weighted_watts,
111 joules_total: total_joules,
112 carbon_kg: self.carbon_kg + other.carbon_kg,
113 efficiency_samples_per_joule: f64::midpoint(
114 self.efficiency_samples_per_joule,
115 other.efficiency_samples_per_joule,
116 ),
117 }
118 }
119}
120
121impl Default for EnergyMetrics {
122 fn default() -> Self {
123 Self::zero()
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct CostMetrics {
130 pub cost_per_sample_usd: f64,
132 pub cost_per_epoch_usd: f64,
134 pub total_cost_usd: f64,
136 pub device_hours: f64,
138 pub rate_per_hour_usd: f64,
140}
141
142pub mod pricing {
144 pub const A100_SPOT: f64 = 1.00;
146 pub const A100_ONDEMAND: f64 = 3.00;
148 pub const V100_SPOT: f64 = 0.50;
150 pub const T4_SPOT: f64 = 0.15;
152 pub const CPU_8CORE: f64 = 0.20;
154 pub const M2_AMORTIZED: f64 = 0.05;
156}
157
158impl CostMetrics {
159 pub fn new(device_hours: f64, rate_per_hour_usd: f64, samples: u64, epochs: u32) -> Self {
168 let total_cost = device_hours * rate_per_hour_usd;
169 let cost_per_sample = if samples > 0 { total_cost / samples as f64 } else { 0.0 };
170 let cost_per_epoch = if epochs > 0 { total_cost / f64::from(epochs) } else { 0.0 };
171
172 Self {
173 cost_per_sample_usd: cost_per_sample,
174 cost_per_epoch_usd: cost_per_epoch,
175 total_cost_usd: total_cost,
176 device_hours,
177 rate_per_hour_usd,
178 }
179 }
180
181 pub fn from_duration(
183 duration: Duration,
184 rate_per_hour_usd: f64,
185 samples: u64,
186 epochs: u32,
187 ) -> Self {
188 let device_hours = duration.as_secs_f64() / 3600.0;
189 Self::new(device_hours, rate_per_hour_usd, samples, epochs)
190 }
191
192 pub fn zero() -> Self {
194 Self {
195 cost_per_sample_usd: 0.0,
196 cost_per_epoch_usd: 0.0,
197 total_cost_usd: 0.0,
198 device_hours: 0.0,
199 rate_per_hour_usd: 0.0,
200 }
201 }
202
203 pub fn add(&self, other: &Self) -> Self {
205 let total_hours = self.device_hours + other.device_hours;
206 let weighted_rate = if total_hours > 0.0 {
207 (self.rate_per_hour_usd * self.device_hours
208 + other.rate_per_hour_usd * other.device_hours)
209 / total_hours
210 } else {
211 0.0
212 };
213
214 Self {
215 cost_per_sample_usd: self.cost_per_sample_usd + other.cost_per_sample_usd,
216 cost_per_epoch_usd: self.cost_per_epoch_usd + other.cost_per_epoch_usd,
217 total_cost_usd: self.total_cost_usd + other.total_cost_usd,
218 device_hours: total_hours,
219 rate_per_hour_usd: weighted_rate,
220 }
221 }
222
223 pub fn samples_per_dollar(&self, samples: u64) -> f64 {
225 if self.total_cost_usd > 0.0 {
226 samples as f64 / self.total_cost_usd
227 } else {
228 0.0
229 }
230 }
231
232 pub fn estimate_additional(&self, additional_hours: f64) -> f64 {
234 additional_hours * self.rate_per_hour_usd
235 }
236}
237
238impl Default for CostMetrics {
239 fn default() -> Self {
240 Self::zero()
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246pub struct EfficiencyMetrics {
247 pub energy: EnergyMetrics,
249 pub cost: CostMetrics,
251 pub quality_score: f64,
253}
254
255impl EfficiencyMetrics {
256 pub fn new(energy: EnergyMetrics, cost: CostMetrics, quality_score: f64) -> Self {
258 Self { energy, cost, quality_score }
259 }
260
261 pub fn quality_per_dollar(&self) -> f64 {
263 if self.cost.total_cost_usd > 0.0 {
264 self.quality_score / self.cost.total_cost_usd
265 } else {
266 0.0
267 }
268 }
269
270 pub fn quality_per_kwh(&self) -> f64 {
272 let kwh = self.energy.kwh();
273 if kwh > 0.0 {
274 self.quality_score / kwh
275 } else {
276 0.0
277 }
278 }
279
280 pub fn quality_per_carbon(&self) -> f64 {
282 if self.energy.carbon_kg > 0.0 {
283 self.quality_score / self.energy.carbon_kg
284 } else {
285 0.0
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_energy_metrics_new() {
296 let metrics = EnergyMetrics::new(200.0, 720_000.0, 1000);
297
298 assert!((metrics.watts_avg - 200.0).abs() < f64::EPSILON);
299 assert!((metrics.joules_total - 720_000.0).abs() < f64::EPSILON);
300 assert!((metrics.efficiency_samples_per_joule - 1000.0 / 720_000.0).abs() < 0.0001);
301 }
302
303 #[test]
304 fn test_energy_metrics_from_power_readings() {
305 let start = Instant::now();
306 let readings = vec![
307 (start, 100.0),
308 (start + Duration::from_secs(1), 150.0),
309 (start + Duration::from_secs(2), 200.0),
310 ];
311
312 let metrics = EnergyMetrics::from_power_readings(&readings, 100);
313
314 assert!((metrics.watts_avg - 150.0).abs() < 1.0);
316 assert!((metrics.joules_total - 300.0).abs() < 1.0);
318 }
319
320 #[test]
321 fn test_energy_metrics_with_carbon() {
322 let metrics = EnergyMetrics::new(200.0, 3_600_000.0, 1000) .with_carbon_intensity(0.4); assert!((metrics.kwh() - 1.0).abs() < 0.01);
326 assert!((metrics.carbon_kg - 0.4).abs() < 0.01);
327 }
328
329 #[test]
330 fn test_energy_metrics_kwh() {
331 let metrics = EnergyMetrics::new(200.0, 7_200_000.0, 1000); assert!((metrics.kwh() - 2.0).abs() < 0.01);
333 assert!((metrics.wh() - 2000.0).abs() < 0.1);
334 }
335
336 #[test]
337 fn test_energy_metrics_cost() {
338 let metrics = EnergyMetrics::new(200.0, 3_600_000.0, 1000); let cost = metrics.estimated_cost_usd(0.15); assert!((cost - 0.15).abs() < 0.01);
341 }
342
343 #[test]
344 fn test_energy_metrics_add() {
345 let m1 = EnergyMetrics::new(100.0, 1000.0, 100);
346 let m2 = EnergyMetrics::new(200.0, 2000.0, 200);
347
348 let combined = m1.add(&m2);
349 assert!((combined.joules_total - 3000.0).abs() < f64::EPSILON);
350 }
351
352 #[test]
353 fn test_energy_metrics_zero() {
354 let zero = EnergyMetrics::zero();
355 assert!((zero.watts_avg - 0.0).abs() < f64::EPSILON);
356 assert!((zero.joules_total - 0.0).abs() < f64::EPSILON);
357 }
358
359 #[test]
360 fn test_cost_metrics_new() {
361 let metrics = CostMetrics::new(2.0, 1.50, 10000, 5);
362
363 assert!((metrics.device_hours - 2.0).abs() < f64::EPSILON);
364 assert!((metrics.rate_per_hour_usd - 1.50).abs() < f64::EPSILON);
365 assert!((metrics.total_cost_usd - 3.0).abs() < 0.01);
366 assert!((metrics.cost_per_sample_usd - 0.0003).abs() < 0.0001);
367 assert!((metrics.cost_per_epoch_usd - 0.6).abs() < 0.01);
368 }
369
370 #[test]
371 fn test_cost_metrics_from_duration() {
372 let duration = Duration::from_secs(7200); let metrics = CostMetrics::from_duration(duration, 1.0, 1000, 10);
374
375 assert!((metrics.device_hours - 2.0).abs() < 0.01);
376 assert!((metrics.total_cost_usd - 2.0).abs() < 0.01);
377 }
378
379 #[test]
380 fn test_cost_metrics_samples_per_dollar() {
381 let metrics = CostMetrics::new(1.0, 1.0, 1000, 1);
382 assert!((metrics.samples_per_dollar(1000) - 1000.0).abs() < 0.01);
383 }
384
385 #[test]
386 fn test_cost_metrics_estimate_additional() {
387 let metrics = CostMetrics::new(1.0, 2.50, 1000, 1);
388 let additional = metrics.estimate_additional(4.0);
389 assert!((additional - 10.0).abs() < 0.01);
390 }
391
392 #[test]
393 fn test_cost_metrics_add() {
394 let m1 = CostMetrics::new(1.0, 1.0, 500, 1);
395 let m2 = CostMetrics::new(2.0, 2.0, 1000, 2);
396
397 let combined = m1.add(&m2);
398 assert!((combined.device_hours - 3.0).abs() < f64::EPSILON);
399 assert!((combined.total_cost_usd - 5.0).abs() < 0.01);
400 }
401
402 #[test]
403 fn test_cost_metrics_pricing_constants() {
404 assert!(pricing::A100_SPOT > 0.0);
405 assert!(pricing::A100_ONDEMAND > pricing::A100_SPOT);
406 assert!(pricing::T4_SPOT < pricing::V100_SPOT);
407 }
408
409 #[test]
410 fn test_efficiency_metrics() {
411 let energy = EnergyMetrics::new(200.0, 3_600_000.0, 1000);
412 let cost = CostMetrics::new(1.0, 2.0, 1000, 10);
413 let efficiency = EfficiencyMetrics::new(energy, cost, 0.95);
414
415 assert!((efficiency.quality_score - 0.95).abs() < f64::EPSILON);
416 assert!(efficiency.quality_per_dollar() > 0.0);
417 assert!(efficiency.quality_per_kwh() > 0.0);
418 }
419
420 #[test]
421 fn test_efficiency_metrics_quality_per_carbon() {
422 let energy = EnergyMetrics::new(200.0, 3_600_000.0, 1000).with_carbon_intensity(0.4);
423 let cost = CostMetrics::new(1.0, 2.0, 1000, 10);
424 let efficiency = EfficiencyMetrics::new(energy, cost, 0.95);
425
426 assert!(efficiency.quality_per_carbon() > 0.0);
427 }
428
429 #[test]
430 fn test_energy_metrics_serialization() {
431 let metrics = EnergyMetrics::new(200.0, 720_000.0, 1000);
432 let json = serde_json::to_string(&metrics).expect("JSON serialization should succeed");
433 let parsed: EnergyMetrics =
434 serde_json::from_str(&json).expect("JSON deserialization should succeed");
435
436 assert!((parsed.watts_avg - metrics.watts_avg).abs() < f64::EPSILON);
437 }
438
439 #[test]
440 fn test_cost_metrics_serialization() {
441 let metrics = CostMetrics::new(2.0, 1.50, 10000, 5);
442 let json = serde_json::to_string(&metrics).expect("JSON serialization should succeed");
443 let parsed: CostMetrics =
444 serde_json::from_str(&json).expect("JSON deserialization should succeed");
445
446 assert!((parsed.total_cost_usd - metrics.total_cost_usd).abs() < f64::EPSILON);
447 }
448}