1use std::collections::HashMap;
9use std::fmt::Write as _;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::event::SpanEvent;
15use crate::score::carbon::ENERGY_PER_IO_OP_KWH;
16
17#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum CalibrationError {
27 #[error("CSV parse error at line {line}: {reason}")]
30 CsvParse { line: usize, reason: String },
31
32 #[error("failed to parse timestamp '{value}' at line {line}: {reason}")]
35 TimestampParse {
36 line: usize,
37 value: String,
38 reason: String,
39 },
40
41 #[error("empty energy CSV: no data rows found")]
44 EmptyData,
45
46 #[error("I/O error: {0}")]
49 Io(#[from] std::io::Error),
50
51 #[error("TOML parse error: {0}")]
54 TomlParse(#[from] toml::de::Error),
55
56 #[error("validation error: {0}")]
60 Validation(String),
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum CsvFormat {
70 PowerWatts,
71 EnergyKwh,
72}
73
74#[derive(Debug, Clone)]
76pub struct EnergyReading {
77 pub timestamp_ms: u64,
78 pub service: String,
79 pub energy_kwh: f64,
80}
81
82fn parse_timestamp_ms(s: &str) -> Result<u64, String> {
90 crate::time::parse_iso8601_utc_to_ms(s)
91}
92
93pub fn parse_energy_csv(content: &str) -> Result<Vec<EnergyReading>, CalibrationError> {
108 let mut lines = content.lines().enumerate();
109 let format = detect_csv_format(&mut lines)?;
110 let raw_rows = collect_csv_data_rows(lines)?;
111 match format {
112 CsvFormat::EnergyKwh => Ok(raw_rows
113 .into_iter()
114 .map(|(ts, service, energy_kwh)| EnergyReading {
115 timestamp_ms: ts,
116 service,
117 energy_kwh,
118 })
119 .collect()),
120 CsvFormat::PowerWatts => convert_power_to_energy(raw_rows),
121 }
122}
123
124fn detect_csv_format(
128 lines: &mut std::iter::Enumerate<std::str::Lines<'_>>,
129) -> Result<CsvFormat, CalibrationError> {
130 loop {
131 let (line_num, line) = lines.next().ok_or(CalibrationError::EmptyData)?;
132 let line = line.trim();
133 if line.is_empty() || line.starts_with('#') {
134 continue;
135 }
136 let lower = line.to_ascii_lowercase();
137 if lower.contains("power_watts") {
138 return Ok(CsvFormat::PowerWatts);
139 }
140 if lower.contains("energy_kwh") {
141 return Ok(CsvFormat::EnergyKwh);
142 }
143 return Err(CalibrationError::CsvParse {
144 line: line_num + 1,
145 reason: "header must contain 'power_watts' or 'energy_kwh'".to_string(),
146 });
147 }
148}
149
150fn collect_csv_data_rows(
154 lines: std::iter::Enumerate<std::str::Lines<'_>>,
155) -> Result<Vec<(u64, String, f64)>, CalibrationError> {
156 let mut raw_rows: Vec<(u64, String, f64)> = Vec::new();
157 for (line_num, line) in lines {
158 let line = line.trim();
159 if line.is_empty() || line.starts_with('#') {
160 continue;
161 }
162 raw_rows.push(parse_csv_data_row(line_num, line)?);
163 }
164 if raw_rows.is_empty() {
165 return Err(CalibrationError::EmptyData);
166 }
167 Ok(raw_rows)
168}
169
170fn parse_csv_data_row(line_num: usize, line: &str) -> Result<(u64, String, f64), CalibrationError> {
175 let parts: Vec<&str> = line.splitn(3, ',').collect();
176 if parts.len() != 3 {
177 return Err(CalibrationError::CsvParse {
178 line: line_num + 1,
179 reason: "expected 3 comma-separated columns".to_string(),
180 });
181 }
182 let ts =
183 parse_timestamp_ms(parts[0].trim()).map_err(|reason| CalibrationError::TimestampParse {
184 line: line_num + 1,
185 value: parts[0].trim().to_string(),
186 reason,
187 })?;
188 let service = parts[1].trim().to_string();
189 let value: f64 = parts[2]
190 .trim()
191 .parse()
192 .map_err(|_| CalibrationError::CsvParse {
193 line: line_num + 1,
194 reason: format!("invalid numeric value '{}'", parts[2].trim()),
195 })?;
196 if !value.is_finite() || value < 0.0 {
197 return Err(CalibrationError::CsvParse {
198 line: line_num + 1,
199 reason: format!("invalid value: {value} (must be finite and non-negative)"),
200 });
201 }
202 Ok((ts, service, value))
203}
204
205fn convert_power_to_energy(
208 mut rows: Vec<(u64, String, f64)>,
209) -> Result<Vec<EnergyReading>, CalibrationError> {
210 rows.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
212
213 let mut readings = Vec::new();
214 let mut prev: Option<(u64, &str, f64)> = None;
215
216 for (ts, service, watts) in &rows {
217 if let Some((prev_ts, prev_svc, prev_watts)) = prev
218 && prev_svc == service.as_str()
219 {
220 let interval_secs = ts.saturating_sub(prev_ts) as f64 / 1000.0;
221 if interval_secs > 0.0 {
222 let avg_watts = (prev_watts + watts) / 2.0;
224 let energy_kwh = avg_watts * interval_secs / 3_600_000.0;
225 readings.push(EnergyReading {
226 timestamp_ms: *ts,
227 service: service.clone(),
228 energy_kwh,
229 });
230 }
231 }
232 prev = Some((*ts, service, *watts));
233 }
234
235 if readings.is_empty() {
236 return Err(CalibrationError::EmptyData);
237 }
238
239 Ok(readings)
240}
241
242#[derive(Debug, Clone)]
248pub struct CalibrationResult {
249 pub service: String,
250 pub total_ops: u64,
251 pub total_energy_kwh: f64,
252 pub energy_per_op_kwh: f64,
253 pub default_energy_per_op_kwh: f64,
254 pub factor: f64,
255}
256
257pub fn calibrate(
271 events: &[SpanEvent],
272 readings: &[EnergyReading],
273) -> Result<Vec<CalibrationResult>, CalibrationError> {
274 if readings.is_empty() {
275 return Err(CalibrationError::EmptyData);
276 }
277
278 let window_start = readings.iter().map(|r| r.timestamp_ms).min().unwrap_or(0);
280 let window_end = readings.iter().map(|r| r.timestamp_ms).max().unwrap_or(0);
281
282 let mut ops_per_service: HashMap<Arc<str>, u64> = HashMap::new();
286 let mut skipped = 0usize;
287 for event in events {
288 let Ok(ts) = parse_timestamp_ms(&event.timestamp) else {
289 skipped += 1;
290 continue;
291 };
292 if ts >= window_start && ts <= window_end {
293 *ops_per_service
294 .entry(Arc::clone(&event.service))
295 .or_default() += 1;
296 }
297 }
298 if skipped > 0 {
299 tracing::debug!(
300 skipped,
301 "skipped events with unparsable timestamps during calibration"
302 );
303 }
304
305 let mut energy_per_service: HashMap<String, f64> = HashMap::new();
308 for reading in readings {
309 *energy_per_service
310 .entry(reading.service.clone())
311 .or_default() += reading.energy_kwh;
312 }
313
314 let mut results: Vec<CalibrationResult> = Vec::new();
316 for (service, total_energy_kwh) in &energy_per_service {
317 let total_ops = ops_per_service.get(service.as_str()).copied().unwrap_or(0);
318 if total_ops == 0 {
319 tracing::warn!(
320 service = %service,
321 "no I/O ops found for service in the observation window, skipping"
322 );
323 continue;
324 }
325 let energy_per_op_kwh = total_energy_kwh / total_ops as f64;
326 let factor = energy_per_op_kwh / ENERGY_PER_IO_OP_KWH;
327
328 results.push(CalibrationResult {
329 service: service.clone(),
330 total_ops,
331 total_energy_kwh: *total_energy_kwh,
332 energy_per_op_kwh,
333 default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
334 factor,
335 });
336 }
337
338 results.sort_by(|a, b| a.service.cmp(&b.service));
340
341 Ok(results)
342}
343
344#[must_use]
350pub fn write_calibration_toml(
351 results: &[CalibrationResult],
352 traces_path: &str,
353 energy_path: &str,
354) -> String {
355 let now = chrono_like_now();
356 let mut out = String::new();
357 out.push_str("# Auto-generated by perf-sentinel calibrate\n");
358 let _ = writeln!(out, "# Based on: {traces_path} + {energy_path}");
359 let _ = writeln!(out, "# Date: {now}");
360 out.push('\n');
361 out.push_str("[calibration]\n");
362 let _ = writeln!(out, "base_energy_per_io_op_kwh = {ENERGY_PER_IO_OP_KWH}");
363 out.push('\n');
364 out.push_str("[calibration.services]\n");
365 for r in results {
366 let escaped = r
369 .service
370 .replace('\\', "\\\\")
371 .replace('"', "\\\"")
372 .replace('\n', "\\n")
373 .replace('\r', "\\r");
374 let _ = writeln!(
375 out,
376 "\"{escaped}\" = {{ factor = {:.2}, measured_energy_per_op_kwh = {:.10} }}",
377 r.factor, r.energy_per_op_kwh
378 );
379 }
380 out
381}
382
383fn chrono_like_now() -> String {
385 let now = std::time::SystemTime::now()
386 .duration_since(std::time::UNIX_EPOCH)
387 .unwrap_or_default();
388 crate::time::nanos_to_iso8601(now.as_nanos() as u64)
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct ServiceCalibration {
398 pub factor: f64,
399 pub measured_energy_per_op_kwh: f64,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct CalibrationData {
405 pub calibration: CalibrationSection,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct CalibrationSection {
411 pub base_energy_per_io_op_kwh: f64,
412 #[serde(default)]
413 pub services: HashMap<String, ServiceCalibration>,
414}
415
416impl CalibrationData {
417 #[must_use]
421 pub fn factor_for(&self, service: &str) -> Option<f64> {
422 self.calibration.services.get(service).map(|s| s.factor)
423 }
424}
425
426pub fn load_calibration_file(path: &str) -> Result<CalibrationData, CalibrationError> {
434 let content = std::fs::read_to_string(path)?;
435 let data: CalibrationData = toml::from_str(&content)?;
436
437 for (service, cal) in &data.calibration.services {
439 if !cal.factor.is_finite() || cal.factor < 0.0 {
440 return Err(CalibrationError::Validation(format!(
441 "service '{service}' has invalid calibration factor: {}",
442 cal.factor
443 )));
444 }
445 if !cal.measured_energy_per_op_kwh.is_finite() || cal.measured_energy_per_op_kwh < 0.0 {
446 return Err(CalibrationError::Validation(format!(
447 "service '{service}' has invalid measured_energy_per_op_kwh: {}",
448 cal.measured_energy_per_op_kwh
449 )));
450 }
451 if cal.factor == 0.0 {
452 tracing::warn!(
453 service = %service,
454 "calibration factor is 0.0, service will have zero carbon impact"
455 );
456 }
457 if cal.factor > 10.0 {
458 tracing::warn!(
459 service = %service,
460 factor = cal.factor,
461 "calibration factor > 10x default, possible measurement error"
462 );
463 }
464 if cal.factor > 0.0 && cal.factor < 0.1 {
465 tracing::warn!(
466 service = %service,
467 factor = cal.factor,
468 "calibration factor < 0.1x default, possible measurement error"
469 );
470 }
471 }
472
473 Ok(data)
474}
475
476#[must_use]
478pub fn validate_results(results: &[CalibrationResult]) -> Vec<String> {
479 let mut warnings = Vec::new();
480 for r in results {
481 if r.factor > 10.0 {
482 warnings.push(format!(
483 "{}: factor {:.1}x is > 10x default, possible measurement error",
484 r.service, r.factor
485 ));
486 }
487 if r.factor > 0.0 && r.factor < 0.1 {
488 warnings.push(format!(
489 "{}: factor {:.1}x is < 0.1x default, possible measurement error",
490 r.service, r.factor
491 ));
492 }
493 }
494 warnings
495}
496
497#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::event::{EventSource, EventType};
505 use core::assert_matches;
506
507 fn make_event(service: &str, timestamp: &str) -> SpanEvent {
508 SpanEvent {
509 timestamp: timestamp.to_string(),
510 trace_id: "trace-1".to_string(),
511 span_id: "span-1".to_string(),
512 parent_span_id: None,
513 service: Arc::from(service),
514 cloud_region: None,
515 event_type: EventType::Sql,
516 operation: "SELECT".to_string(),
517 target: "SELECT * FROM t".to_string(),
518 duration_us: 100,
519 status_code: None,
520 response_size_bytes: None,
521 source: EventSource {
522 endpoint: "GET /api/test".to_string(),
523 method: "test".to_string(),
524 },
525 code_function: None,
526 code_filepath: None,
527 code_lineno: None,
528 code_namespace: None,
529 instrumentation_scopes: Vec::new(),
530 }
531 }
532
533 #[test]
536 fn parse_timestamp_basic() {
537 let ms = parse_timestamp_ms("2025-07-10T14:32:01.123Z").unwrap();
538 assert!(ms > 0);
539 }
540
541 #[test]
542 fn parse_timestamp_no_fraction() {
543 let ms = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
544 assert!(ms > 0);
545 }
546
547 #[test]
548 fn parse_timestamp_rejects_non_utc() {
549 assert!(parse_timestamp_ms("2025-07-10T14:32:01+02:00").is_err());
550 }
551
552 #[test]
553 fn parse_timestamp_rejects_invalid() {
554 assert!(parse_timestamp_ms("not-a-timestamp").is_err());
555 assert!(parse_timestamp_ms("").is_err());
556 }
557
558 #[test]
561 fn parse_csv_energy_format() {
562 let csv = "timestamp,service,energy_kwh\n\
563 2025-07-10T14:00:00Z,order-svc,0.0001\n\
564 2025-07-10T14:05:00Z,order-svc,0.0002\n";
565 let readings = parse_energy_csv(csv).unwrap();
566 assert_eq!(readings.len(), 2);
567 assert_eq!(readings[0].service, "order-svc");
568 assert!((readings[0].energy_kwh - 0.0001).abs() < 1e-10);
569 }
570
571 #[test]
572 fn parse_csv_power_format() {
573 let csv = "timestamp,service,power_watts\n\
574 2025-07-10T14:00:00Z,svc-a,10.0\n\
575 2025-07-10T14:00:05Z,svc-a,12.0\n";
576 let readings = parse_energy_csv(csv).unwrap();
577 assert_eq!(readings.len(), 1);
578 assert_eq!(readings[0].service, "svc-a");
579 let expected = 11.0 * 5.0 / 3_600_000.0;
582 assert!((readings[0].energy_kwh - expected).abs() < 1e-15);
583 }
584
585 #[test]
586 fn parse_csv_with_comments() {
587 let csv = "# Energy measurements\n\
588 timestamp,service,energy_kwh\n\
589 # First reading\n\
590 2025-07-10T14:00:00Z,svc-a,0.001\n";
591 let readings = parse_energy_csv(csv).unwrap();
592 assert_eq!(readings.len(), 1);
593 }
594
595 #[test]
596 fn parse_csv_empty_data() {
597 let csv = "timestamp,service,energy_kwh\n";
598 assert_matches!(parse_energy_csv(csv), Err(CalibrationError::EmptyData));
599 }
600
601 #[test]
602 fn parse_csv_rejects_negative() {
603 let csv = "timestamp,service,energy_kwh\n\
604 2025-07-10T14:00:00Z,svc-a,-0.001\n";
605 assert_matches!(
606 parse_energy_csv(csv),
607 Err(CalibrationError::CsvParse { .. })
608 );
609 }
610
611 #[test]
612 fn parse_csv_rejects_bad_header() {
613 let csv = "timestamp,service,something\n\
614 2025-07-10T14:00:00Z,svc-a,0.001\n";
615 assert_matches!(
616 parse_energy_csv(csv),
617 Err(CalibrationError::CsvParse { .. })
618 );
619 }
620
621 #[test]
622 fn parse_csv_rejects_malformed_row() {
623 let csv = "timestamp,service,energy_kwh\n\
624 2025-07-10T14:00:00Z,svc-a\n";
625 assert_matches!(
626 parse_energy_csv(csv),
627 Err(CalibrationError::CsvParse { .. })
628 );
629 }
630
631 #[test]
634 fn calibrate_basic() {
635 let events = vec![
636 make_event("svc-a", "2025-07-10T14:00:01Z"),
637 make_event("svc-a", "2025-07-10T14:00:02Z"),
638 make_event("svc-a", "2025-07-10T14:00:03Z"),
639 make_event("svc-a", "2025-07-10T14:00:04Z"),
640 ];
641 let readings = vec![
642 EnergyReading {
643 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
644 service: "svc-a".to_string(),
645 energy_kwh: 0.000_000_2, },
647 EnergyReading {
648 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:05Z").unwrap(),
649 service: "svc-a".to_string(),
650 energy_kwh: 0.000_000_2,
651 },
652 ];
653
654 let results = calibrate(&events, &readings).unwrap();
655 assert_eq!(results.len(), 1);
656 assert_eq!(results[0].service, "svc-a");
657 assert_eq!(results[0].total_ops, 4);
658 let expected_energy = 0.000_000_4; assert!((results[0].total_energy_kwh - expected_energy).abs() < 1e-15);
660 let expected_per_op = expected_energy / 4.0;
661 assert!((results[0].energy_per_op_kwh - expected_per_op).abs() < 1e-15);
662 assert!((results[0].factor - (expected_per_op / ENERGY_PER_IO_OP_KWH)).abs() < 1e-10);
663 }
664
665 #[test]
666 fn calibrate_multiple_services() {
667 let events = vec![
668 make_event("svc-a", "2025-07-10T14:00:01Z"),
669 make_event("svc-a", "2025-07-10T14:00:02Z"),
670 make_event("svc-b", "2025-07-10T14:00:01Z"),
671 ];
672 let readings = vec![
673 EnergyReading {
674 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
675 service: "svc-a".to_string(),
676 energy_kwh: 0.000_001,
677 },
678 EnergyReading {
679 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:05Z").unwrap(),
680 service: "svc-b".to_string(),
681 energy_kwh: 0.000_000_5,
682 },
683 ];
684
685 let results = calibrate(&events, &readings).unwrap();
686 assert_eq!(results.len(), 2);
687 assert_eq!(results[0].service, "svc-a");
688 assert_eq!(results[0].total_ops, 2);
689 assert_eq!(results[1].service, "svc-b");
690 assert_eq!(results[1].total_ops, 1);
691 }
692
693 #[test]
694 fn calibrate_skips_zero_ops_service() {
695 let events = vec![make_event("svc-a", "2025-07-10T14:00:01Z")];
696 let readings = vec![
697 EnergyReading {
698 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
699 service: "svc-a".to_string(),
700 energy_kwh: 0.0001,
701 },
702 EnergyReading {
703 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:05Z").unwrap(),
704 service: "svc-b".to_string(), energy_kwh: 0.0001,
706 },
707 ];
708
709 let results = calibrate(&events, &readings).unwrap();
710 assert_eq!(results.len(), 1);
711 assert_eq!(results[0].service, "svc-a");
712 }
713
714 #[test]
717 fn write_toml_round_trip() {
718 let results = vec![CalibrationResult {
719 service: "svc-a".to_string(),
720 total_ops: 100,
721 total_energy_kwh: 0.00001,
722 energy_per_op_kwh: 0.000_000_1,
723 default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
724 factor: 1.0,
725 }];
726
727 let toml_str = write_calibration_toml(&results, "traces.json", "energy.csv");
728 assert!(toml_str.contains("[calibration]"));
729 assert!(toml_str.contains("[calibration.services]"));
730 assert!(toml_str.contains("svc-a"));
731
732 let data: CalibrationData = toml::from_str(&toml_str).unwrap();
734 assert!(data.calibration.services.contains_key("svc-a"));
735 let cal = &data.calibration.services["svc-a"];
736 assert!((cal.factor - 1.0).abs() < 0.01);
737 }
738
739 #[test]
742 fn load_calibration_validates_negative_factor() {
743 let toml_str = r#"
744[calibration]
745base_energy_per_io_op_kwh = 0.000_000_1
746
747[calibration.services]
748"svc-a" = { factor = -1.0, measured_energy_per_op_kwh = 0.000_000_1 }
749"#;
750 let tmp = std::env::temp_dir().join("test-cal-negative.toml");
751 std::fs::write(&tmp, toml_str).unwrap();
752 let result = load_calibration_file(tmp.to_str().unwrap());
753 assert_matches!(result, Err(CalibrationError::Validation(_)));
754 let _ = std::fs::remove_file(tmp);
755 }
756
757 #[test]
760 fn validate_warns_extreme_factors() {
761 let results = vec![
762 CalibrationResult {
763 service: "normal".to_string(),
764 total_ops: 100,
765 total_energy_kwh: 0.00001,
766 energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
767 default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
768 factor: 1.0,
769 },
770 CalibrationResult {
771 service: "too-high".to_string(),
772 total_ops: 100,
773 total_energy_kwh: 0.001,
774 energy_per_op_kwh: ENERGY_PER_IO_OP_KWH * 15.0,
775 default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
776 factor: 15.0,
777 },
778 CalibrationResult {
779 service: "too-low".to_string(),
780 total_ops: 100,
781 total_energy_kwh: 0.000_000_1,
782 energy_per_op_kwh: ENERGY_PER_IO_OP_KWH * 0.05,
783 default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
784 factor: 0.05,
785 },
786 ];
787 let warnings = validate_results(&results);
788 assert_eq!(warnings.len(), 2);
789 assert!(warnings[0].contains("too-high"));
790 assert!(warnings[1].contains("too-low"));
791 }
792
793 #[test]
796 fn parse_timestamp_accepts_space_between_date_and_time() {
797 let ms = parse_timestamp_ms("2025-07-10 14:32:01Z").unwrap();
799 let t_form = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
800 assert_eq!(ms, t_form);
801 }
802
803 #[test]
804 fn parse_timestamp_rejects_missing_t_and_space() {
805 let err = parse_timestamp_ms("2025-07-1014:32:01Z").unwrap_err();
806 assert!(err.contains("'T' or space"));
807 }
808
809 #[test]
810 fn parse_timestamp_rejects_wrong_date_format() {
811 let err = parse_timestamp_ms("2025-07T14:32:01Z").unwrap_err();
812 assert!(err.contains("YYYY-MM-DD"));
813 }
814
815 #[test]
816 fn parse_timestamp_rejects_non_numeric_year_month_day() {
817 assert!(
818 parse_timestamp_ms("abcd-07-10T14:32:01Z")
819 .unwrap_err()
820 .contains("year")
821 );
822 assert!(
823 parse_timestamp_ms("2025-ab-10T14:32:01Z")
824 .unwrap_err()
825 .contains("month")
826 );
827 assert!(
828 parse_timestamp_ms("2025-07-abT14:32:01Z")
829 .unwrap_err()
830 .contains("day")
831 );
832 }
833
834 #[test]
835 fn parse_timestamp_rejects_pre_1970_year() {
836 let err = parse_timestamp_ms("1969-12-31T23:59:59Z").unwrap_err();
837 assert!(err.contains("1970"));
838 }
839
840 #[test]
841 fn parse_timestamp_rejects_month_day_out_of_range() {
842 assert!(
843 parse_timestamp_ms("2025-13-01T00:00:00Z")
844 .unwrap_err()
845 .contains("out of range")
846 );
847 assert!(
848 parse_timestamp_ms("2025-07-32T00:00:00Z")
849 .unwrap_err()
850 .contains("out of range")
851 );
852 }
853
854 #[test]
855 fn parse_timestamp_rejects_wrong_time_format() {
856 let err = parse_timestamp_ms("2025-07-10T14:32Z").unwrap_err();
857 assert!(err.contains("HH:MM:SS"));
858 }
859
860 #[test]
861 fn parse_timestamp_rejects_non_numeric_hours_minutes_seconds() {
862 assert!(
863 parse_timestamp_ms("2025-07-10Tab:32:01Z")
864 .unwrap_err()
865 .contains("hours")
866 );
867 assert!(
868 parse_timestamp_ms("2025-07-10T14:ab:01Z")
869 .unwrap_err()
870 .contains("minutes")
871 );
872 assert!(
873 parse_timestamp_ms("2025-07-10T14:32:abZ")
874 .unwrap_err()
875 .contains("seconds")
876 );
877 }
878
879 #[test]
880 fn parse_timestamp_rejects_time_out_of_range() {
881 let err = parse_timestamp_ms("2025-07-10T25:00:00Z").unwrap_err();
882 assert!(err.contains("out of range"));
883 }
884
885 #[test]
886 fn parse_timestamp_accepts_1_digit_fractional_seconds() {
887 let ms_1 = parse_timestamp_ms("2025-07-10T14:32:01.5Z").unwrap();
889 let ms_base = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
890 assert_eq!(ms_1 - ms_base, 500);
891 }
892
893 #[test]
894 fn parse_timestamp_accepts_2_digit_fractional_seconds() {
895 let ms_2 = parse_timestamp_ms("2025-07-10T14:32:01.25Z").unwrap();
897 let ms_base = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
898 assert_eq!(ms_2 - ms_base, 250);
899 }
900
901 #[test]
902 fn parse_timestamp_truncates_sub_millisecond_fractional_seconds() {
903 let ms = parse_timestamp_ms("2025-07-10T14:32:01.123456Z").unwrap();
905 let ms_base = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
906 assert_eq!(ms - ms_base, 123);
907 }
908
909 #[test]
910 fn parse_timestamp_rejects_non_numeric_fractional_seconds() {
911 let err = parse_timestamp_ms("2025-07-10T14:32:01.abZ").unwrap_err();
912 assert!(err.contains("fractional"));
913 }
914
915 #[test]
918 fn parse_energy_csv_reports_invalid_timestamp_with_line_number() {
919 let csv = "timestamp,service,energy_kwh\n\
920 not-a-timestamp,svc-a,0.001\n";
921 let err = parse_energy_csv(csv).unwrap_err();
922 match err {
923 CalibrationError::TimestampParse { line, value, .. } => {
924 assert_eq!(line, 2);
925 assert_eq!(value, "not-a-timestamp");
926 }
927 other => panic!("expected TimestampParse, got {other:?}"),
928 }
929 }
930
931 #[test]
932 fn parse_energy_csv_reports_invalid_numeric_value_with_line_number() {
933 let csv = "timestamp,service,energy_kwh\n\
934 2025-07-10T14:00:00Z,svc-a,not-a-number\n";
935 let err = parse_energy_csv(csv).unwrap_err();
936 match err {
937 CalibrationError::CsvParse { line, reason } => {
938 assert_eq!(line, 2);
939 assert!(reason.contains("not-a-number"));
940 }
941 other => panic!("expected CsvParse, got {other:?}"),
942 }
943 }
944
945 #[test]
946 fn parse_energy_csv_power_watts_empty_after_conversion_returns_empty_data() {
947 let csv = "timestamp,service,power_watts\n\
950 2025-07-10T14:00:00Z,svc-a,12.5\n";
951 let err = parse_energy_csv(csv).unwrap_err();
952 assert_matches!(err, CalibrationError::EmptyData);
953 }
954
955 #[test]
958 fn calibrate_rejects_empty_readings() {
959 let events = vec![make_event("svc-a", "2025-07-10T14:00:00Z")];
960 let err = calibrate(&events, &[]).unwrap_err();
961 assert_matches!(err, CalibrationError::EmptyData);
962 }
963
964 #[test]
965 fn calibrate_skips_events_with_unparsable_timestamp() {
966 let mut bad_event = make_event("svc-a", "2025-07-10T14:00:05Z");
970 bad_event.timestamp = "not-a-timestamp".to_string();
971 let events = vec![bad_event, make_event("svc-a", "2025-07-10T14:00:05Z")];
972 let readings = vec![
973 EnergyReading {
974 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
975 service: "svc-a".to_string(),
976 energy_kwh: 0.000_000_2,
977 },
978 EnergyReading {
979 timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:10Z").unwrap(),
980 service: "svc-a".to_string(),
981 energy_kwh: 0.000_000_2,
982 },
983 ];
984 let results = calibrate(&events, &readings).unwrap();
985 assert_eq!(results.len(), 1);
986 assert_eq!(results[0].total_ops, 1, "bad-timestamp event was skipped");
987 }
988
989 #[test]
992 fn load_calibration_file_rejects_missing_file() {
993 let err = load_calibration_file("/tmp/does-not-exist-abc123.toml").unwrap_err();
994 assert_matches!(err, CalibrationError::Io(_));
995 }
996
997 fn write_temp_toml(filename: &str, contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1002 let dir = tempfile::tempdir().expect("tempdir creation");
1003 let path = dir.path().join(filename);
1004 std::fs::write(&path, contents).unwrap();
1005 (dir, path)
1006 }
1007
1008 #[test]
1009 fn load_calibration_file_rejects_malformed_toml() {
1010 let (_dir, path) = write_temp_toml("malformed.toml", "not = valid [toml");
1011 let err = load_calibration_file(path.to_str().unwrap()).unwrap_err();
1012 assert_matches!(err, CalibrationError::TomlParse(_));
1013 }
1014
1015 #[test]
1016 fn load_calibration_file_rejects_negative_factor() {
1017 let (_dir, path) = write_temp_toml(
1018 "neg.toml",
1019 r#"
1020[calibration]
1021base_energy_per_io_op_kwh = 0.0000001
1022
1023[calibration.services]
1024"svc-a" = { factor = -1.0, measured_energy_per_op_kwh = 0.0000001 }
1025"#,
1026 );
1027 let err = load_calibration_file(path.to_str().unwrap()).unwrap_err();
1028 assert_matches!(err, CalibrationError::Validation(_));
1029 }
1030
1031 #[test]
1032 fn load_calibration_file_rejects_nonfinite_measured_energy() {
1033 let (_dir, path) = write_temp_toml(
1034 "inf.toml",
1035 r#"
1036[calibration]
1037base_energy_per_io_op_kwh = 0.0000001
1038
1039[calibration.services]
1040"svc-a" = { factor = 1.0, measured_energy_per_op_kwh = nan }
1041"#,
1042 );
1043 let err = load_calibration_file(path.to_str().unwrap()).unwrap_err();
1044 assert_matches!(err, CalibrationError::Validation(_));
1045 }
1046
1047 #[test]
1048 fn load_calibration_file_accepts_extreme_factors_with_warning() {
1049 let (_dir, path) = write_temp_toml(
1052 "warn.toml",
1053 r#"
1054[calibration]
1055base_energy_per_io_op_kwh = 0.0000001
1056
1057[calibration.services]
1058"zero" = { factor = 0.0, measured_energy_per_op_kwh = 0.0 }
1059"too-high" = { factor = 15.0, measured_energy_per_op_kwh = 0.0000015 }
1060"too-low" = { factor = 0.05, measured_energy_per_op_kwh = 0.000000005 }
1061"normal" = { factor = 1.0, measured_energy_per_op_kwh = 0.0000001 }
1062"#,
1063 );
1064 let data = load_calibration_file(path.to_str().unwrap()).unwrap();
1065 assert_eq!(data.calibration.services.len(), 4);
1066 }
1067}