Skip to main content

entrenar/efficiency/
metrics.rs

1//! Energy and Cost Metrics (ENT-009)
2//!
3//! Provides tracking for energy consumption and training costs.
4
5use serde::{Deserialize, Serialize};
6use std::time::{Duration, Instant};
7
8/// Energy consumption metrics
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct EnergyMetrics {
11    /// Average power consumption in watts
12    pub watts_avg: f64,
13    /// Total energy consumed in joules
14    pub joules_total: f64,
15    /// Estimated carbon emissions in kg CO2
16    pub carbon_kg: f64,
17    /// Training efficiency: samples processed per joule
18    pub efficiency_samples_per_joule: f64,
19}
20
21impl EnergyMetrics {
22    /// Create new energy metrics
23    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    /// Create energy metrics from power readings over time
30    ///
31    /// # Arguments
32    ///
33    /// * `readings` - Slice of (timestamp, watts) readings
34    /// * `samples` - Total samples processed during this period
35    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        // Calculate average power and total energy
41        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    /// Set carbon emissions based on grid carbon intensity
60    ///
61    /// # Arguments
62    ///
63    /// * `kg_co2_per_kwh` - Carbon intensity (kg CO2 per kWh)
64    ///   - US average: ~0.4
65    ///   - EU average: ~0.3
66    ///   - France (nuclear): ~0.05
67    ///   - Coal-heavy: ~0.8
68    pub fn with_carbon_intensity(mut self, kg_co2_per_kwh: f64) -> Self {
69        let kwh = self.joules_total / 3_600_000.0; // Joules to kWh
70        self.carbon_kg = kwh * kg_co2_per_kwh;
71        self
72    }
73
74    /// Get energy in kWh
75    pub fn kwh(&self) -> f64 {
76        self.joules_total / 3_600_000.0
77    }
78
79    /// Get energy in Wh
80    pub fn wh(&self) -> f64 {
81        self.joules_total / 3_600.0
82    }
83
84    /// Estimate cost at given electricity rate
85    pub fn estimated_cost_usd(&self, usd_per_kwh: f64) -> f64 {
86        self.kwh() * usd_per_kwh
87    }
88
89    /// Create zero energy metrics
90    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    /// Add two energy metrics (for aggregation)
100    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/// Cost metrics for training
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct CostMetrics {
130    /// Cost per sample in USD
131    pub cost_per_sample_usd: f64,
132    /// Cost per epoch in USD
133    pub cost_per_epoch_usd: f64,
134    /// Total cost in USD
135    pub total_cost_usd: f64,
136    /// Total device hours used
137    pub device_hours: f64,
138    /// Rate per hour in USD
139    pub rate_per_hour_usd: f64,
140}
141
142/// Common cloud GPU pricing constants (approximate hourly rates)
143pub mod pricing {
144    /// NVIDIA A100 (40GB) spot price ~$1.00/hr
145    pub const A100_SPOT: f64 = 1.00;
146    /// NVIDIA A100 on-demand ~$3.00/hr
147    pub const A100_ONDEMAND: f64 = 3.00;
148    /// NVIDIA V100 spot ~$0.50/hr
149    pub const V100_SPOT: f64 = 0.50;
150    /// NVIDIA T4 spot ~$0.15/hr
151    pub const T4_SPOT: f64 = 0.15;
152    /// CPU instance (8 cores) ~$0.20/hr
153    pub const CPU_8CORE: f64 = 0.20;
154    /// Apple M2 Mac Studio (amortized) ~$0.05/hr
155    pub const M2_AMORTIZED: f64 = 0.05;
156}
157
158impl CostMetrics {
159    /// Create new cost metrics
160    ///
161    /// # Arguments
162    ///
163    /// * `device_hours` - Total compute time in hours
164    /// * `rate_per_hour_usd` - Cost rate per device hour
165    /// * `samples` - Total samples processed
166    /// * `epochs` - Number of training epochs
167    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    /// Create cost metrics from duration
182    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    /// Create zero cost metrics
193    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    /// Add two cost metrics (for aggregation)
204    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    /// Get cost efficiency (samples per dollar)
224    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    /// Estimate cost for additional training
233    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/// Combined efficiency metrics
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246pub struct EfficiencyMetrics {
247    /// Energy metrics
248    pub energy: EnergyMetrics,
249    /// Cost metrics
250    pub cost: CostMetrics,
251    /// Quality score achieved (accuracy, F1, etc.)
252    pub quality_score: f64,
253}
254
255impl EfficiencyMetrics {
256    /// Create new efficiency metrics
257    pub fn new(energy: EnergyMetrics, cost: CostMetrics, quality_score: f64) -> Self {
258        Self { energy, cost, quality_score }
259    }
260
261    /// Calculate quality per dollar
262    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    /// Calculate quality per kWh
271    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    /// Calculate quality per kg CO2
281    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        // Average power: (100+150)/2 + (150+200)/2 = 125 + 175 = 300 / 2 = 150
315        assert!((metrics.watts_avg - 150.0).abs() < 1.0);
316        // Joules: 125 * 1 + 175 * 1 = 300
317        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) // 1 kWh
323            .with_carbon_intensity(0.4); // US average
324
325        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); // 2 kWh
332        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); // 1 kWh
339        let cost = metrics.estimated_cost_usd(0.15); // $0.15/kWh
340        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); // 2 hours
373        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}