1use std::collections::HashMap;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::event::SpanEvent;
15use crate::score::electricity_maps::config::{
16 ApiVersion, ElectricityMapsConfig, EmissionFactorType, TemporalGranularity,
17};
18
19pub use super::carbon_profiles::HourlyProfile;
20pub(crate) use super::carbon_profiles::HourlyProfileRef;
21
22pub const ENERGY_PER_IO_OP_KWH: f64 = 0.000_000_1;
30
31const SQL_SELECT_COEFF: f64 = 0.5; const SQL_INSERT_COEFF: f64 = 1.5; const SQL_UPDATE_COEFF: f64 = 1.5; const SQL_DELETE_COEFF: f64 = 1.2; const SQL_OTHER_COEFF: f64 = 1.0; const HTTP_SMALL_COEFF: f64 = 0.8; const HTTP_MEDIUM_COEFF: f64 = 1.2; const HTTP_LARGE_COEFF: f64 = 2.0; const HTTP_SMALL_THRESHOLD: u64 = 10 * 1024; const HTTP_LARGE_THRESHOLD: u64 = 1024 * 1024; pub const DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH: f64 = 0.000_000_000_04;
53
54pub const CO2_LOW_FACTOR: f64 = 0.5;
57
58pub const CO2_HIGH_FACTOR: f64 = 2.0;
60
61pub const CO2_MODEL: &str = "io_proxy_v1";
63
64pub const CO2_MODEL_V2: &str = "io_proxy_v2";
66
67pub const CO2_MODEL_V3: &str = "io_proxy_v3";
70
71pub const CO2_MODEL_ALUMET: &str = "alumet_rapl";
79
80pub const CO2_MODEL_SCAPHANDRE: &str = "scaphandre_rapl";
84
85pub const CO2_MODEL_KEPLER: &str = "kepler_ebpf";
89
90pub const CO2_MODEL_REDFISH: &str = "redfish_bmc";
94
95pub const CO2_MODEL_CLOUD_SPECPOWER: &str = "cloud_specpower";
98
99pub const CO2_MODEL_EMAPS: &str = "electricity_maps_api";
103
104pub const CO2_MODEL_CAL_SUFFIX: &str = "+cal";
106
107pub const CO2_MODEL_V1_CAL: &str = "io_proxy_v1+cal";
109pub const CO2_MODEL_V2_CAL: &str = "io_proxy_v2+cal";
110pub const CO2_MODEL_V3_CAL: &str = "io_proxy_v3+cal";
111
112pub const METHODOLOGY_SCI_NUMERATOR: &str = "sci_v1_numerator";
115
116pub const METHODOLOGY_SCI_NUMERATOR_TRANSPORT: &str = "sci_v1_numerator+transport";
120
121pub const METHODOLOGY_OPERATIONAL_RATIO: &str = "sci_v1_operational_ratio";
124
125pub const METHODOLOGY_SCI_INTENSITY: &str = "sci_v1_intensity";
128
129pub const DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2: f64 = 0.001;
133
134pub const GENERIC_PUE: f64 = 1.5;
141
142#[allow(dead_code)]
147pub(crate) const PUE_VINTAGE: &str = "2026 refresh (AWS 2024 global, GCP 2024 fleet, Azure FY25)";
148
149pub const UNKNOWN_REGION: &str = "unknown";
151
152pub const REGION_STATUS_KNOWN: &str = "known";
154
155pub const REGION_STATUS_OUT_OF_TABLE: &str = "out_of_table";
157
158pub const REGION_STATUS_UNRESOLVED: &str = "unresolved";
160
161#[derive(Debug, Clone, Copy)]
163pub struct EnergyEntry {
164 pub energy_per_op_kwh: f64,
166 pub model_tag: &'static str,
171}
172
173impl EnergyEntry {
174 #[must_use]
176 pub const fn alumet(energy_per_op_kwh: f64) -> Self {
177 Self {
178 energy_per_op_kwh,
179 model_tag: CO2_MODEL_ALUMET,
180 }
181 }
182
183 #[must_use]
185 pub const fn scaphandre(energy_per_op_kwh: f64) -> Self {
186 Self {
187 energy_per_op_kwh,
188 model_tag: CO2_MODEL_SCAPHANDRE,
189 }
190 }
191
192 #[must_use]
194 pub const fn kepler(energy_per_op_kwh: f64) -> Self {
195 Self {
196 energy_per_op_kwh,
197 model_tag: CO2_MODEL_KEPLER,
198 }
199 }
200
201 #[must_use]
203 pub const fn redfish(energy_per_op_kwh: f64) -> Self {
204 Self {
205 energy_per_op_kwh,
206 model_tag: CO2_MODEL_REDFISH,
207 }
208 }
209
210 #[must_use]
212 pub const fn cloud(energy_per_op_kwh: f64) -> Self {
213 Self {
214 energy_per_op_kwh,
215 model_tag: CO2_MODEL_CLOUD_SPECPOWER,
216 }
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct CarbonEstimate {
228 pub low: f64,
229 pub mid: f64,
230 pub high: f64,
231 pub model: String,
232 pub methodology: String,
233}
234
235impl CarbonEstimate {
236 pub(crate) fn new_with_model(mid: f64, model: &'static str, methodology: &'static str) -> Self {
238 Self {
239 low: mid * CO2_LOW_FACTOR,
240 mid,
241 high: mid * CO2_HIGH_FACTOR,
242 model: model.to_string(),
243 methodology: methodology.to_string(),
244 }
245 }
246
247 #[must_use]
249 pub fn sci_numerator(mid: f64) -> Self {
250 Self::new_with_model(mid, CO2_MODEL, METHODOLOGY_SCI_NUMERATOR)
251 }
252
253 #[must_use]
255 pub fn operational_ratio(mid: f64) -> Self {
256 Self::new_with_model(mid, CO2_MODEL, METHODOLOGY_OPERATIONAL_RATIO)
257 }
258
259 #[must_use]
261 pub fn sci_numerator_with_model(mid: f64, model: &'static str) -> Self {
262 Self::new_with_model(mid, model, METHODOLOGY_SCI_NUMERATOR)
263 }
264
265 #[must_use]
267 pub fn operational_ratio_with_model(mid: f64, model: &'static str) -> Self {
268 Self::new_with_model(mid, model, METHODOLOGY_OPERATIONAL_RATIO)
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct CarbonReport {
280 pub total: CarbonEstimate,
282 pub avoidable: CarbonEstimate,
286 pub operational_gco2: f64,
288 pub embodied_gco2: f64,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub transport_gco2: Option<f64>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub sci_per_trace: Option<CarbonEstimate>,
302 #[serde(default, skip_serializing_if = "String::is_empty")]
305 pub functional_unit: String,
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
312#[serde(rename_all = "snake_case")]
313pub enum IntensitySource {
314 #[default]
315 Annual,
316 Hourly,
317 MonthlyHourly,
318 RealTime,
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328pub struct RegionBreakdown {
329 pub status: String,
331 pub region: String,
332 pub grid_intensity_gco2_kwh: f64,
334 pub pue: f64,
335 pub io_ops: usize,
336 pub co2_gco2: f64,
337 #[serde(default)]
338 pub intensity_source: IntensitySource,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub intensity_estimated: Option<bool>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub intensity_estimation_method: Option<String>,
351}
352
353#[derive(Debug, Clone)]
356pub struct CarbonContext {
357 pub default_region: Option<String>,
358 pub service_regions: HashMap<String, String>,
360 pub embodied_per_request_gco2: f64,
361 pub use_hourly_profiles: bool,
362 pub energy_snapshot: Option<HashMap<String, EnergyEntry>>,
364 pub per_operation_coefficients: bool,
366 pub include_network_transport: bool,
367 pub network_energy_per_byte_kwh: f64,
368 pub custom_hourly_profiles: Option<Arc<HashMap<String, HourlyProfile>>>,
373 pub calibration: Option<crate::calibrate::CalibrationData>,
376 pub real_time_intensity: Option<HashMap<String, RealTimeIntensityEntry>>,
381 pub scoring_config: Option<ScoringConfig>,
388 pub db_energy: Option<DbEnergyContext>,
395}
396
397#[derive(Debug, Clone, Default, PartialEq)]
400pub struct DbEnergyContext {
401 pub window_kwh: f64,
403 pub region: Option<String>,
405}
406
407#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
414pub struct ScoringConfig {
415 pub api_version: ApiVersion,
416 pub emission_factor_type: EmissionFactorType,
417 pub temporal_granularity: TemporalGranularity,
418}
419
420impl ScoringConfig {
421 #[must_use]
426 pub fn from_electricity_maps(cfg: &ElectricityMapsConfig) -> Self {
427 Self {
428 api_version: ApiVersion::from_endpoint(&cfg.api_endpoint),
429 emission_factor_type: cfg.emission_factor_type,
430 temporal_granularity: cfg.temporal_granularity,
431 }
432 }
433}
434
435#[derive(Debug, Clone)]
441#[must_use]
442pub struct RealTimeIntensityEntry {
443 pub gco2_per_kwh: f64,
445 pub is_estimated: Option<bool>,
450 pub estimation_method: Option<String>,
454}
455
456impl RealTimeIntensityEntry {
457 pub fn measured(gco2_per_kwh: f64) -> Self {
460 Self {
461 gco2_per_kwh,
462 is_estimated: None,
463 estimation_method: None,
464 }
465 }
466}
467
468impl Default for CarbonContext {
469 fn default() -> Self {
470 Self {
471 default_region: None,
472 service_regions: HashMap::new(),
473 embodied_per_request_gco2: 0.0,
474 use_hourly_profiles: true,
475 energy_snapshot: None,
476 per_operation_coefficients: true,
477 include_network_transport: false,
478 network_energy_per_byte_kwh: DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH,
479 custom_hourly_profiles: None,
480 calibration: None,
481 real_time_intensity: None,
482 scoring_config: None,
483 db_energy: None,
484 }
485 }
486}
487
488#[must_use]
494pub(crate) fn db_waste_gco2(waste_kwh: f64, region: &str, ctx: &CarbonContext) -> Option<f64> {
495 let region_lower = region.to_ascii_lowercase();
496 let real_time = ctx
497 .real_time_intensity
498 .as_ref()
499 .and_then(|m| m.get(®ion_lower))
500 .map(|e| e.gco2_per_kwh);
501 let (intensity, pue) = match (lookup_region_lower(®ion_lower), real_time) {
502 (Some((_, pue)), Some(rt)) => (rt, pue),
503 (Some((annual, pue)), None) => (annual, pue),
504 (None, Some(rt)) => (rt, GENERIC_PUE),
505 (None, None) => return None,
506 };
507 Some(per_op_gco2(waste_kwh, intensity, pue))
508}
509
510#[must_use]
512pub fn resolve_region<'a>(event: &'a SpanEvent, ctx: &'a CarbonContext) -> Option<&'a str> {
513 if let Some(region) = event.cloud_region.as_deref() {
514 return Some(region);
515 }
516 if !ctx.service_regions.is_empty() {
518 let lookup = if event.service.bytes().any(|b| b.is_ascii_uppercase()) {
519 ctx.service_regions.get(&event.service.to_ascii_lowercase())
520 } else {
521 ctx.service_regions.get(event.service.as_ref())
522 };
523 if let Some(region) = lookup {
524 return Some(region.as_str());
525 }
526 }
527 ctx.default_region.as_deref()
528}
529
530#[must_use]
544pub(crate) fn is_valid_region_id(s: &str) -> bool {
545 !s.is_empty()
546 && s.len() <= 64
547 && s.bytes()
548 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub(super) enum Provider {
554 Aws,
555 Gcp,
556 Azure,
557 Generic,
558}
559
560impl Provider {
561 const fn pue(self) -> f64 {
563 match self {
564 Self::Aws => 1.15,
565 Self::Gcp => 1.09,
566 Self::Azure => 1.17,
567 Self::Generic => GENERIC_PUE,
568 }
569 }
570}
571
572static MANUAL_CARBON_ROWS: &[(&str, f64, Provider)] = &[
585 ("us-east-1", 379.0, Provider::Aws),
587 ("us-east-2", 410.0, Provider::Aws),
588 ("us-west-1", 200.0, Provider::Aws),
589 ("us-west-2", 89.0, Provider::Aws),
590 ("ca-central-1", 13.0, Provider::Aws), ("sa-east-1", 96.0, Provider::Aws), ("us-central1", 426.0, Provider::Gcp),
594 ("us-east1", 379.0, Provider::Gcp),
595 ("us-west1", 89.0, Provider::Gcp),
596 ("eastus", 379.0, Provider::Azure),
598 ("westus2", 89.0, Provider::Azure),
599 ("ca", 13.0, Provider::Generic),
601 ("br", 96.0, Provider::Generic), ];
603
604static REGION_MAP: std::sync::LazyLock<HashMap<&'static str, (f64, Provider)>> =
608 std::sync::LazyLock::new(|| {
609 super::carbon_data::GENERATED_CARBON_ROWS
610 .iter()
611 .chain(MANUAL_CARBON_ROWS)
612 .map(|&(key, intensity, provider)| (key, (intensity, provider)))
613 .collect()
614 });
615
616static HOURLY_REGION_MAP: std::sync::LazyLock<HashMap<&'static str, HourlyProfileRef<'static>>> =
619 std::sync::LazyLock::new(|| {
620 use super::carbon_profiles::{FLAT_YEAR_PROFILES, MONTHLY_PROFILES, PROFILE_ALIASES};
621
622 let cap = FLAT_YEAR_PROFILES.len() + MONTHLY_PROFILES.len() + PROFILE_ALIASES.len();
623 let mut map = HashMap::with_capacity(cap);
624 for (key, profile) in FLAT_YEAR_PROFILES {
625 map.insert(*key, HourlyProfileRef::FlatYear(profile));
626 }
627 for (key, profile) in MONTHLY_PROFILES {
628 map.insert(*key, HourlyProfileRef::Monthly(profile));
629 }
630 for &(alias, canonical) in PROFILE_ALIASES {
633 if let Some(&profile_ref) = map.get(canonical) {
634 map.insert(alias, profile_ref);
635 }
636 }
637 map
638 });
639
640#[cfg(test)]
644#[must_use]
645pub(crate) fn lookup_hourly_intensity_lower(
646 region: &str,
647 hour: u8,
648 month: Option<u8>,
649) -> Option<f64> {
650 if hour >= 24 {
651 return None;
652 }
653 if let Some(m) = month
654 && m >= 12
655 {
656 return None;
657 }
658 HOURLY_REGION_MAP
659 .get(region)
660 .map(|profile_ref: &HourlyProfileRef<'_>| profile_ref.intensity_at(hour, month))
661}
662
663#[must_use]
666pub(crate) fn hourly_profile_for_region_lower(region: &str) -> Option<HourlyProfileRef<'static>> {
667 HOURLY_REGION_MAP.get(region).copied()
668}
669
670#[cfg(test)]
676#[must_use]
677pub(crate) fn resolve_hourly_intensity(
678 region: &str,
679 hour: u8,
680 month: Option<u8>,
681 custom: Option<&HashMap<String, HourlyProfile>>,
682) -> Option<(f64, IntensitySource)> {
683 if hour >= 24 {
684 return None;
685 }
686 if let Some(m) = month
687 && m >= 12
688 {
689 return None;
690 }
691 if let Some(custom_map) = custom
693 && let Some(profile) = custom_map.get(region)
694 {
695 let val = profile.intensity_at(hour, month);
696 let src = if profile.is_monthly() {
697 IntensitySource::MonthlyHourly
698 } else {
699 IntensitySource::Hourly
700 };
701 return Some((val, src));
702 }
703 HOURLY_REGION_MAP
705 .get(region)
706 .map(|profile_ref: &HourlyProfileRef<'_>| {
707 let val = profile_ref.intensity_at(hour, month);
708 let src = if profile_ref.is_monthly() {
709 IntensitySource::MonthlyHourly
710 } else {
711 IntensitySource::Hourly
712 };
713 (val, src)
714 })
715}
716
717const MAX_PROFILE_FILE_BYTES: u64 = 2 * 1024 * 1024;
720
721const MAX_PLAUSIBLE_INTENSITY: f64 = 1000.0;
725
726const MAX_CUSTOM_PROFILES: usize = 256;
728
729pub fn load_custom_profiles(
750 path: &std::path::Path,
751) -> Result<HashMap<String, HourlyProfile>, String> {
752 let content = read_custom_profiles_file(path)?;
753 let raw: serde_json::Value = serde_json::from_str(&content)
754 .map_err(|e| format!("invalid JSON in '{}': {e}", path.display()))?;
755 let profiles_obj = raw
756 .get("profiles")
757 .and_then(|v| v.as_object())
758 .ok_or_else(|| format!("'{}' missing 'profiles' object", path.display()))?;
759 if profiles_obj.len() > MAX_CUSTOM_PROFILES {
760 return Err(format!(
761 "'{}' contains {} profiles, exceeding the {} limit",
762 path.display(),
763 profiles_obj.len(),
764 MAX_CUSTOM_PROFILES
765 ));
766 }
767
768 let mut result = HashMap::with_capacity(profiles_obj.len());
769 for (region, value) in profiles_obj {
770 let region_lower = region.to_ascii_lowercase();
771 if !is_valid_region_id(®ion_lower) {
772 return Err(
773 "invalid region key (expected ASCII alphanumeric + '-'/'_', length 1-64)"
774 .to_string(),
775 );
776 }
777 let profile = parse_single_custom_profile(region, value)?;
778 warn_on_profile_anomalies(®ion_lower, &profile);
779 result.insert(region_lower, profile);
780 }
781 Ok(result)
782}
783
784fn read_custom_profiles_file(path: &std::path::Path) -> Result<String, String> {
787 let metadata =
788 std::fs::metadata(path).map_err(|e| format!("failed to stat '{}': {e}", path.display()))?;
789 if metadata.len() > MAX_PROFILE_FILE_BYTES {
790 return Err(format!(
791 "'{}' is {} bytes, exceeding the {} byte limit",
792 path.display(),
793 metadata.len(),
794 MAX_PROFILE_FILE_BYTES
795 ));
796 }
797 std::fs::read_to_string(path).map_err(|e| format!("failed to read '{}': {e}", path.display()))
798}
799
800fn parse_single_custom_profile(
803 region: &str,
804 value: &serde_json::Value,
805) -> Result<HourlyProfile, String> {
806 let profile_type = value
807 .get("type")
808 .and_then(|t| t.as_str())
809 .ok_or_else(|| format!("region '{region}': missing 'type' field"))?;
810 match profile_type {
811 "flat_year" => parse_flat_year_profile(region, value),
812 "monthly" => parse_monthly_profile(region, value),
813 _ => Err(format!(
814 "region '{region}': unknown profile type (expected 'flat_year' or 'monthly')"
815 )),
816 }
817}
818
819fn parse_flat_year_profile(
821 region: &str,
822 value: &serde_json::Value,
823) -> Result<HourlyProfile, String> {
824 let hours = value
825 .get("hours")
826 .and_then(|h| h.as_array())
827 .ok_or_else(|| format!("region '{region}': missing 'hours' array"))?;
828 if hours.len() != 24 {
829 return Err(format!(
830 "region '{region}': flat_year profile must have exactly 24 values, got {}",
831 hours.len()
832 ));
833 }
834 let mut arr = [0.0_f64; 24];
835 for (i, v) in hours.iter().enumerate() {
836 arr[i] = parse_profile_f64(v, &format!("region '{region}' hour {i}"))?;
837 }
838 Ok(HourlyProfile::FlatYear(arr))
839}
840
841fn parse_monthly_profile(region: &str, value: &serde_json::Value) -> Result<HourlyProfile, String> {
844 let months = value
845 .get("months")
846 .and_then(|m| m.as_array())
847 .ok_or_else(|| format!("region '{region}': missing 'months' array"))?;
848 if months.len() != 12 {
849 return Err(format!(
850 "region '{region}': monthly profile must have exactly 12 months, got {}",
851 months.len()
852 ));
853 }
854 let mut arr = [[0.0_f64; 24]; 12];
855 for (m, month_val) in months.iter().enumerate() {
856 let month_arr = month_val
857 .as_array()
858 .ok_or_else(|| format!("region '{region}' month {m}: expected an array"))?;
859 if month_arr.len() != 24 {
860 return Err(format!(
861 "region '{region}' month {m}: must have exactly 24 values, got {}",
862 month_arr.len()
863 ));
864 }
865 for (h, v) in month_arr.iter().enumerate() {
866 arr[m][h] = parse_profile_f64(v, &format!("region '{region}' month {m} hour {h}"))?;
867 }
868 }
869 Ok(HourlyProfile::Monthly(Box::new(arr)))
870}
871
872fn parse_profile_f64(v: &serde_json::Value, context: &str) -> Result<f64, String> {
876 let val = v
877 .as_f64()
878 .ok_or_else(|| format!("{context}: expected a number"))?;
879 if !val.is_finite() || val < 0.0 {
880 return Err(format!(
881 "{context}: value must be finite and non-negative, got {val}"
882 ));
883 }
884 Ok(val)
885}
886
887fn warn_on_profile_anomalies(region_lower: &str, profile: &HourlyProfile) {
893 let mean = profile.mean();
894 if let Some(&(annual, _)) = REGION_MAP.get(region_lower)
895 && annual > 0.0
896 {
897 let deviation = (mean - annual).abs() / annual;
898 if deviation > 0.05 {
899 tracing::warn!(
900 region = %region_lower,
901 profile_mean = mean,
902 annual_value = annual,
903 deviation_pct = deviation * 100.0,
904 "Custom hourly profile mean deviates from embedded annual value. \
905 The profile will be used as-is.",
906 );
907 }
908 }
909 if mean > MAX_PLAUSIBLE_INTENSITY {
910 tracing::warn!(
911 region = %region_lower,
912 profile_mean = mean,
913 "Custom hourly profile has an unusually high mean intensity. \
914 Verify the values are in gCO2/kWh, not mg or another unit.",
915 );
916 }
917}
918
919#[must_use]
921pub fn lookup_region(region: &str) -> Option<(f64, f64)> {
922 if region.bytes().any(|b| b.is_ascii_uppercase()) {
923 lookup_region_lower(®ion.to_ascii_lowercase())
924 } else {
925 lookup_region_lower(region)
926 }
927}
928
929#[must_use]
931pub(crate) fn lookup_region_lower(region: &str) -> Option<(f64, f64)> {
932 REGION_MAP
933 .get(region)
934 .map(|(intensity, provider)| (*intensity, provider.pue()))
935}
936
937#[inline]
939#[must_use]
940pub(crate) fn per_op_gco2(energy_kwh: f64, intensity: f64, pue: f64) -> f64 {
941 energy_kwh * intensity * pue
942}
943
944#[inline]
953#[must_use]
954pub(crate) fn energy_coefficient(event: &SpanEvent) -> f64 {
955 match event.event_type {
956 crate::event::EventType::Sql => {
957 let verb = event.target.split_ascii_whitespace().next().unwrap_or("");
958 if verb.eq_ignore_ascii_case("SELECT") {
959 SQL_SELECT_COEFF
960 } else if verb.eq_ignore_ascii_case("INSERT") {
961 SQL_INSERT_COEFF
962 } else if verb.eq_ignore_ascii_case("UPDATE") {
963 SQL_UPDATE_COEFF
964 } else if verb.eq_ignore_ascii_case("DELETE") {
965 SQL_DELETE_COEFF
966 } else {
967 SQL_OTHER_COEFF
968 }
969 }
970 crate::event::EventType::HttpOut => match event.response_size_bytes {
971 Some(size) if size > HTTP_LARGE_THRESHOLD => HTTP_LARGE_COEFF,
972 Some(size) if size >= HTTP_SMALL_THRESHOLD => HTTP_MEDIUM_COEFF,
973 Some(_) => HTTP_SMALL_COEFF,
974 None => 1.0,
975 },
976 }
977}
978
979#[must_use]
985pub(crate) fn extract_hostname(url: &str) -> Option<&str> {
986 let after_scheme = url
987 .strip_prefix("http://")
988 .or_else(|| url.strip_prefix("https://"))?;
989 let host_port = after_scheme.split('/').next()?;
990 let authority = host_port.rsplit('@').next().unwrap_or(host_port);
992 let host = authority.split(':').next()?;
993 if host.is_empty() { None } else { Some(host) }
994}
995
996#[must_use]
1007pub(crate) fn compute_operational_gco2(io_ops: usize, intensity: f64, pue: f64) -> f64 {
1008 io_ops as f64 * per_op_gco2(ENERGY_PER_IO_OP_KWH, intensity, pue)
1009}
1010
1011#[must_use]
1018pub(crate) fn io_ops_to_co2_grams(io_ops: usize, region: &str) -> Option<f64> {
1019 let (intensity, pue) = lookup_region_lower(region)?;
1020 Some(compute_operational_gco2(io_ops, intensity, pue))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026
1027 #[test]
1030 fn hourly_profile_present_for_key_regions() {
1031 assert!(hourly_profile_for_region_lower("eu-west-3").is_some());
1033 assert!(hourly_profile_for_region_lower("eu-central-1").is_some());
1034 assert!(hourly_profile_for_region_lower("eu-west-2").is_some());
1035 assert!(hourly_profile_for_region_lower("us-east-1").is_some());
1036 assert!(hourly_profile_for_region_lower("eu-west-1").is_some());
1038 assert!(hourly_profile_for_region_lower("eu-west-4").is_some());
1039 assert!(hourly_profile_for_region_lower("eu-north-1").is_some());
1040 assert!(hourly_profile_for_region_lower("europe-west1").is_some());
1041 assert!(hourly_profile_for_region_lower("europe-north1").is_some());
1042 assert!(hourly_profile_for_region_lower("us-east-2").is_some());
1043 assert!(hourly_profile_for_region_lower("us-west-1").is_some());
1044 assert!(hourly_profile_for_region_lower("us-west-2").is_some());
1045 assert!(hourly_profile_for_region_lower("ca-central-1").is_some());
1046 assert!(hourly_profile_for_region_lower("ap-southeast-2").is_some());
1047 assert!(hourly_profile_for_region_lower("ap-northeast-1").is_some());
1048 assert!(hourly_profile_for_region_lower("ap-southeast-1").is_some());
1049 assert!(hourly_profile_for_region_lower("ap-south-1").is_some());
1050 assert!(hourly_profile_for_region_lower("sa-east-1").is_some());
1051 }
1052
1053 #[test]
1054 fn hourly_profile_absent_for_unknown_region() {
1055 assert!(hourly_profile_for_region_lower("mars-1").is_none());
1056 assert!(hourly_profile_for_region_lower("unknown-region").is_none());
1057 }
1058
1059 #[test]
1060 fn hourly_profile_aliases_resolve() {
1061 assert!(hourly_profile_for_region_lower("fr").is_some());
1063 assert!(hourly_profile_for_region_lower("de").is_some());
1064 assert!(hourly_profile_for_region_lower("gb").is_some());
1065 assert!(hourly_profile_for_region_lower("ie").is_some());
1066 assert!(hourly_profile_for_region_lower("nl").is_some());
1067 assert!(hourly_profile_for_region_lower("se").is_some());
1068 assert!(hourly_profile_for_region_lower("no").is_some());
1069 assert!(hourly_profile_for_region_lower("jp").is_some());
1070 assert!(hourly_profile_for_region_lower("br").is_some());
1071 assert!(hourly_profile_for_region_lower("westeurope").is_some());
1073 assert!(hourly_profile_for_region_lower("northeurope").is_some());
1074 assert!(hourly_profile_for_region_lower("uksouth").is_some());
1075 assert!(hourly_profile_for_region_lower("francecentral").is_some());
1076 }
1077
1078 #[test]
1079 fn hourly_profile_original_4_are_monthly() {
1080 assert!(
1082 hourly_profile_for_region_lower("eu-west-3")
1083 .unwrap()
1084 .is_monthly()
1085 );
1086 assert!(
1087 hourly_profile_for_region_lower("eu-central-1")
1088 .unwrap()
1089 .is_monthly()
1090 );
1091 assert!(
1092 hourly_profile_for_region_lower("eu-west-2")
1093 .unwrap()
1094 .is_monthly()
1095 );
1096 assert!(
1097 hourly_profile_for_region_lower("us-east-1")
1098 .unwrap()
1099 .is_monthly()
1100 );
1101 }
1102
1103 #[test]
1104 fn hourly_profile_new_regions_are_flat_year() {
1105 assert!(
1106 !hourly_profile_for_region_lower("eu-west-1")
1107 .unwrap()
1108 .is_monthly()
1109 );
1110 assert!(
1111 !hourly_profile_for_region_lower("us-east-2")
1112 .unwrap()
1113 .is_monthly()
1114 );
1115 assert!(
1116 !hourly_profile_for_region_lower("ca-central-1")
1117 .unwrap()
1118 .is_monthly()
1119 );
1120 }
1121
1122 #[test]
1123 fn hourly_intensity_lookup_returns_hour_value() {
1124 let night_fr = lookup_hourly_intensity_lower("eu-west-3", 3, Some(6)).unwrap();
1126 let evening_fr = lookup_hourly_intensity_lower("eu-west-3", 18, Some(6)).unwrap();
1127 assert!(
1128 night_fr < evening_fr,
1129 "expected night ({night_fr}) < evening peak ({evening_fr}) in eu-west-3 (July)"
1130 );
1131 }
1132
1133 #[test]
1134 fn hourly_intensity_unknown_region_returns_none() {
1135 assert!(lookup_hourly_intensity_lower("mars-1", 10, None).is_none());
1136 }
1137
1138 #[test]
1139 fn hourly_intensity_invalid_hour_returns_none() {
1140 assert!(lookup_hourly_intensity_lower("eu-west-3", 24, None).is_none());
1141 assert!(lookup_hourly_intensity_lower("eu-west-3", 99, None).is_none());
1142 }
1143
1144 #[test]
1145 fn hourly_intensity_invalid_month_returns_none() {
1146 assert!(lookup_hourly_intensity_lower("eu-west-3", 12, Some(12)).is_none());
1147 assert!(lookup_hourly_intensity_lower("eu-west-3", 12, Some(99)).is_none());
1148 }
1149
1150 fn profile_grand_mean(pr: HourlyProfileRef<'_>) -> f64 {
1152 match pr {
1153 HourlyProfileRef::FlatYear(profile) => profile.iter().sum::<f64>() / 24.0,
1154 HourlyProfileRef::Monthly(profiles) => {
1155 let total: f64 = profiles.iter().flat_map(|m| m.iter()).sum();
1156 total / (12.0 * 24.0)
1157 }
1158 }
1159 }
1160
1161 #[test]
1162 fn hourly_profile_mean_close_to_annual_for_fr() {
1163 let pr = hourly_profile_for_region_lower("eu-west-3").unwrap();
1164 let mean = profile_grand_mean(pr);
1165 let annual = lookup_region_lower("eu-west-3").unwrap().0;
1166 let deviation = (mean - annual).abs() / annual;
1167 assert!(
1168 deviation < 0.05,
1169 "fr grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1170 );
1171 }
1172
1173 #[test]
1174 fn hourly_profile_mean_close_to_annual_for_us_east() {
1175 let pr = hourly_profile_for_region_lower("us-east-1").unwrap();
1176 let mean = profile_grand_mean(pr);
1177 let annual = lookup_region_lower("us-east-1").unwrap().0;
1178 let deviation = (mean - annual).abs() / annual;
1179 assert!(
1180 deviation < 0.05,
1181 "us-east-1 grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1182 );
1183 }
1184
1185 #[test]
1186 fn hourly_profile_mean_close_to_annual_for_gb() {
1187 let pr = hourly_profile_for_region_lower("eu-west-2").unwrap();
1188 let mean = profile_grand_mean(pr);
1189 let annual = lookup_region_lower("eu-west-2").unwrap().0;
1190 let deviation = (mean - annual).abs() / annual;
1191 assert!(
1192 deviation < 0.05,
1193 "gb grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1194 );
1195 }
1196
1197 #[test]
1198 fn hourly_profile_de_mean_close_to_annual() {
1199 let pr = hourly_profile_for_region_lower("eu-central-1").unwrap();
1203 let mean = profile_grand_mean(pr);
1204 let annual = lookup_region_lower("eu-central-1").unwrap().0;
1205 let deviation = (mean - annual).abs() / annual;
1206 assert!(
1207 deviation < 0.05,
1208 "eu-central-1 grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1209 );
1210 }
1211
1212 #[test]
1214 fn hourly_profile_mean_close_to_annual_for_all_flat_year_regions() {
1215 for &(key, ref profile) in crate::score::carbon_profiles::FLAT_YEAR_PROFILES {
1216 let vals: &[f64; 24] = profile;
1217 let mean: f64 = vals.iter().sum::<f64>() / 24.0;
1218 let (annual, _) = lookup_region_lower(key).unwrap_or_else(|| {
1219 panic!("{key} is a canonical profile key but is missing from the carbon intensity table")
1220 });
1221 let deviation = (mean - annual).abs() / annual;
1222 assert!(
1223 deviation < 0.05,
1224 "{key} hourly mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1225 );
1226 }
1227 }
1228
1229 #[test]
1230 fn hourly_profile_mean_close_to_annual_for_all_monthly_regions() {
1231 for &(key, ref months) in crate::score::carbon_profiles::MONTHLY_PROFILES {
1232 let total: f64 = months.iter().flat_map(|m| m.iter()).sum();
1233 let mean = total / (12.0 * 24.0);
1234 let (annual, _) = lookup_region_lower(key).unwrap_or_else(|| {
1235 panic!(
1236 "{key} is a canonical monthly profile key but is missing from the carbon intensity table"
1237 )
1238 });
1239 let deviation = (mean - annual).abs() / annual;
1240 assert!(
1241 deviation < 0.05,
1242 "{key} monthly grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1243 );
1244 }
1245 }
1246
1247 #[test]
1248 fn monthly_profile_seasonal_variation_fr() {
1249 let pr = hourly_profile_for_region_lower("eu-west-3").unwrap();
1251 let jan_mean = (0..24).map(|h| pr.intensity_at(h, Some(0))).sum::<f64>() / 24.0;
1252 let jul_mean = (0..24).map(|h| pr.intensity_at(h, Some(6))).sum::<f64>() / 24.0;
1253 assert!(
1254 jan_mean > jul_mean,
1255 "FR January mean ({jan_mean:.1}) should be higher than July ({jul_mean:.1})"
1256 );
1257 }
1258
1259 #[test]
1260 fn monthly_profile_seasonal_variation_de() {
1261 let pr = hourly_profile_for_region_lower("eu-central-1").unwrap();
1262 let jan_mean = (0..24).map(|h| pr.intensity_at(h, Some(0))).sum::<f64>() / 24.0;
1263 let jun_mean = (0..24).map(|h| pr.intensity_at(h, Some(5))).sum::<f64>() / 24.0;
1264 assert!(
1265 jan_mean > jun_mean,
1266 "DE January mean ({jan_mean:.1}) should be higher than June ({jun_mean:.1})"
1267 );
1268 }
1269
1270 #[test]
1273 fn caiso_profile_has_midday_solar_dip() {
1274 let pr = hourly_profile_for_region_lower("us-west-1").unwrap();
1277 let solar_min = (18..=21)
1278 .map(|h| pr.intensity_at(h, None))
1279 .fold(f64::INFINITY, f64::min);
1280 let evening_max = (2..=4)
1281 .map(|h| pr.intensity_at(h, None))
1282 .fold(f64::NEG_INFINITY, f64::max);
1283 assert!(
1284 solar_min < evening_max * 0.80,
1285 "CAISO solar dip ({solar_min:.0}) should be well below evening peak ({evening_max:.0})"
1286 );
1287 }
1288
1289 #[test]
1290 fn spain_profile_has_midday_solar_dip() {
1291 let pr = hourly_profile_for_region_lower("europe-southwest1").unwrap();
1293 let solar_min = (10..=13)
1294 .map(|h| pr.intensity_at(h, None))
1295 .fold(f64::INFINITY, f64::min);
1296 let evening_max = (17..=19)
1297 .map(|h| pr.intensity_at(h, None))
1298 .fold(f64::NEG_INFINITY, f64::max);
1299 assert!(
1300 solar_min < evening_max * 0.85,
1301 "Spain solar dip ({solar_min:.0}) should be below evening peak ({evening_max:.0})"
1302 );
1303 }
1304
1305 #[test]
1306 fn hydro_profiles_are_nearly_flat() {
1307 for region in ["eu-north-1", "europe-north2", "ca-central-1"] {
1309 let pr = hourly_profile_for_region_lower(region).unwrap();
1310 let min = (0..24)
1311 .map(|h| pr.intensity_at(h, None))
1312 .fold(f64::INFINITY, f64::min);
1313 let max = (0..24)
1314 .map(|h| pr.intensity_at(h, None))
1315 .fold(f64::NEG_INFINITY, f64::max);
1316 assert!(
1317 max <= min * 2.5,
1318 "{region} hydro profile should be nearly flat (min={min:.0}, max={max:.0})"
1319 );
1320 }
1321 }
1322
1323 #[test]
1326 fn resolve_hourly_intensity_custom_takes_precedence() {
1327 let mut custom = HashMap::new();
1328 custom.insert(
1329 "eu-west-3".to_string(),
1330 HourlyProfile::FlatYear([999.0; 24]),
1331 );
1332 let (val, src) = resolve_hourly_intensity("eu-west-3", 12, None, Some(&custom)).unwrap();
1333 assert!((val - 999.0).abs() < f64::EPSILON);
1334 assert_eq!(src, IntensitySource::Hourly);
1335 }
1336
1337 #[test]
1338 fn resolve_hourly_intensity_falls_through_to_embedded() {
1339 let (val, src) = resolve_hourly_intensity("eu-west-1", 12, None, None).unwrap();
1340 assert!(val > 0.0);
1341 assert_eq!(src, IntensitySource::Hourly); }
1343
1344 #[test]
1345 fn resolve_hourly_intensity_monthly_embedded() {
1346 let (val, src) = resolve_hourly_intensity("eu-west-3", 12, Some(6), None).unwrap();
1347 assert!(val > 0.0);
1348 assert_eq!(src, IntensitySource::MonthlyHourly);
1349 }
1350
1351 #[test]
1352 fn resolve_hourly_intensity_unknown_region_returns_none() {
1353 assert!(resolve_hourly_intensity("mars-1", 12, None, None).is_none());
1354 }
1355
1356 #[test]
1357 fn resolve_hourly_intensity_rejects_invalid_month() {
1358 assert!(resolve_hourly_intensity("eu-west-3", 12, Some(12), None).is_none());
1359 assert!(resolve_hourly_intensity("eu-west-3", 12, Some(99), None).is_none());
1360 }
1361
1362 #[test]
1363 fn resolve_hourly_intensity_rejects_invalid_hour() {
1364 assert!(resolve_hourly_intensity("eu-west-3", 24, None, None).is_none());
1365 assert!(resolve_hourly_intensity("eu-west-3", 99, None, None).is_none());
1366 }
1367
1368 #[test]
1371 fn load_custom_profiles_flat_year() {
1372 let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1373 let _ = std::fs::create_dir_all(&dir);
1374 let path = dir.join("test_flat.json");
1375 let hours: Vec<f64> = (0..24).map(|h| 50.0 + f64::from(h)).collect();
1376 let json =
1377 format!(r#"{{"profiles": {{"my-dc": {{"type": "flat_year", "hours": {hours:?}}}}}}}"#);
1378 std::fs::write(&path, &json).unwrap();
1379 let result = load_custom_profiles(&path).unwrap();
1380 assert!(result.contains_key("my-dc"));
1381 assert!(!result["my-dc"].is_monthly());
1382 let _ = std::fs::remove_file(&path);
1383 }
1384
1385 #[test]
1386 fn load_custom_profiles_monthly() {
1387 let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1388 let _ = std::fs::create_dir_all(&dir);
1389 let path = dir.join("test_monthly.json");
1390 let month: Vec<f64> = vec![100.0; 24];
1391 let months: Vec<Vec<f64>> = vec![month; 12];
1392 let json =
1393 format!(r#"{{"profiles": {{"my-dc": {{"type": "monthly", "months": {months:?}}}}}}}"#);
1394 std::fs::write(&path, &json).unwrap();
1395 let result = load_custom_profiles(&path).unwrap();
1396 assert!(result["my-dc"].is_monthly());
1397 let _ = std::fs::remove_file(&path);
1398 }
1399
1400 #[test]
1401 fn load_custom_profiles_rejects_wrong_dimensions() {
1402 let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1403 let _ = std::fs::create_dir_all(&dir);
1404 let path = dir.join("test_bad_dim.json");
1405 let json = r#"{"profiles": {"my-dc": {"type": "flat_year", "hours": [1.0, 2.0]}}}"#;
1406 std::fs::write(&path, json).unwrap();
1407 assert!(load_custom_profiles(&path).is_err());
1408 let _ = std::fs::remove_file(&path);
1409 }
1410
1411 #[test]
1412 fn load_custom_profiles_rejects_negative() {
1413 let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1414 let _ = std::fs::create_dir_all(&dir);
1415 let path = dir.join("test_neg.json");
1416 let mut hours = vec![50.0; 24];
1417 hours[5] = -1.0;
1418 let json =
1419 format!(r#"{{"profiles": {{"my-dc": {{"type": "flat_year", "hours": {hours:?}}}}}}}"#);
1420 std::fs::write(&path, &json).unwrap();
1421 assert!(load_custom_profiles(&path).is_err());
1422 let _ = std::fs::remove_file(&path);
1423 }
1424
1425 #[test]
1426 fn load_custom_profiles_rejects_nan() {
1427 let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1428 let _ = std::fs::create_dir_all(&dir);
1429 let path = dir.join("test_nan.json");
1430 let json = r#"{"profiles": {"my-dc": {"type": "flat_year", "hours": [null, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0]}}}"#;
1432 std::fs::write(&path, json).unwrap();
1433 assert!(load_custom_profiles(&path).is_err());
1434 let _ = std::fs::remove_file(&path);
1435 }
1436
1437 #[test]
1438 fn per_op_gco2_single_source() {
1439 let per_op = per_op_gco2(ENERGY_PER_IO_OP_KWH, 100.0, 1.2);
1442 let bulk = compute_operational_gco2(1, 100.0, 1.2);
1443 assert!((per_op - bulk).abs() < 1e-18);
1444 let bulk10 = compute_operational_gco2(10, 100.0, 1.2);
1445 assert!((per_op * 10.0 - bulk10).abs() < 1e-18);
1446 }
1447
1448 #[test]
1449 fn carbon_estimate_with_model_tags() {
1450 let e = CarbonEstimate::sci_numerator_with_model(0.001, CO2_MODEL_V2);
1453 assert_eq!(e.model, "io_proxy_v2");
1454 assert_eq!(e.methodology, "sci_v1_numerator");
1455 let e = CarbonEstimate::operational_ratio_with_model(0.001, CO2_MODEL_SCAPHANDRE);
1456 assert_eq!(e.model, "scaphandre_rapl");
1457 assert_eq!(e.methodology, "sci_v1_operational_ratio");
1458 }
1459
1460 #[test]
1464 fn lookup_known_aws_region() {
1465 let (intensity, pue) = lookup_region("eu-west-3").expect("eu-west-3");
1466 let (fr_intensity, _) = lookup_region("fr").expect("fr");
1467 assert!((intensity - fr_intensity).abs() < f64::EPSILON);
1468 assert!((pue - 1.15).abs() < f64::EPSILON);
1469 }
1470
1471 #[test]
1472 fn lookup_known_gcp_region() {
1473 let (intensity, pue) = lookup_region("europe-west9").expect("europe-west9");
1474 let (fr_intensity, _) = lookup_region("fr").expect("fr");
1475 assert!((intensity - fr_intensity).abs() < f64::EPSILON);
1476 assert!((pue - 1.09).abs() < f64::EPSILON);
1477 }
1478
1479 #[test]
1480 fn lookup_country_code() {
1481 let (intensity, pue) = lookup_region("FR").expect("FR");
1482 assert!(intensity > 0.0);
1483 assert!((pue - 1.5).abs() < f64::EPSILON);
1484 }
1485
1486 #[test]
1487 fn lookup_case_insensitive() {
1488 assert!(lookup_region("EU-WEST-3").is_some());
1489 assert!(lookup_region("Us-East-1").is_some());
1490 assert!(lookup_region("fr").is_some());
1491 assert!(lookup_region("FR").is_some());
1492 }
1493
1494 #[test]
1495 fn lookup_unknown_region_returns_none() {
1496 assert!(lookup_region("unknown-region").is_none());
1497 assert!(lookup_region("").is_none());
1498 }
1499
1500 #[test]
1501 fn io_ops_to_co2_known_region() {
1502 let val = io_ops_to_co2_grams(1000, "eu-west-3").expect("eu-west-3");
1503 let (intensity, pue) = lookup_region("eu-west-3").expect("eu-west-3");
1504 let expected = 1000.0 * ENERGY_PER_IO_OP_KWH * intensity * pue;
1505 assert!((val - expected).abs() < 1e-9);
1506 }
1507
1508 #[test]
1509 fn io_ops_to_co2_unknown_region() {
1510 assert!(io_ops_to_co2_grams(1000, "mars-1").is_none());
1511 }
1512
1513 #[test]
1514 fn io_ops_to_co2_zero_ops() {
1515 let co2 = io_ops_to_co2_grams(0, "eu-west-3");
1516 assert!(co2.is_some());
1517 assert!((co2.unwrap() - 0.0).abs() < f64::EPSILON);
1518 }
1519
1520 #[test]
1521 fn high_carbon_region_vs_low() {
1522 let high = io_ops_to_co2_grams(1000, "ap-south-1").unwrap(); let low = io_ops_to_co2_grams(1000, "eu-north-1").unwrap(); assert!(high > low * 5.0, "India should be much higher than Sweden");
1525 }
1526
1527 #[test]
1531 fn generated_and_manual_carbon_keys_are_disjoint() {
1532 assert_eq!(
1533 REGION_MAP.len(),
1534 super::super::carbon_data::GENERATED_CARBON_ROWS.len() + MANUAL_CARBON_ROWS.len(),
1535 "a manual carbon row shadows a generated one"
1536 );
1537 }
1538
1539 #[test]
1542 fn all_carbon_rows_are_plausible() {
1543 for &(key, intensity, _) in super::super::carbon_data::GENERATED_CARBON_ROWS
1544 .iter()
1545 .chain(MANUAL_CARBON_ROWS)
1546 {
1547 assert!(
1548 intensity > 0.0 && intensity <= 2000.0,
1549 "{key}: implausible carbon intensity {intensity}"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn lookup_azure_region() {
1556 let result = lookup_region("eastus");
1557 assert!(result.is_some());
1558 let (_, pue) = result.unwrap();
1559 assert!(
1560 (pue - 1.17).abs() < f64::EPSILON,
1561 "Azure PUE should be 1.17"
1562 );
1563 }
1564
1565 use std::sync::Arc;
1568
1569 use crate::event::{EventSource, EventType, SpanEvent};
1570
1571 fn make_event(service: &str, cloud_region: Option<&str>) -> SpanEvent {
1572 SpanEvent {
1573 timestamp: "2025-07-10T14:32:01.000Z".to_string(),
1574 trace_id: "trace-1".to_string(),
1575 span_id: "span-1".to_string(),
1576 parent_span_id: None,
1577 service: Arc::from(service),
1578 cloud_region: cloud_region.map(Arc::from),
1579 event_type: EventType::Sql,
1580 operation: "SELECT".to_string(),
1581 target: "SELECT 1".to_string(),
1582 duration_us: 1000,
1583 source: EventSource {
1584 endpoint: "GET /test".to_string(),
1585 method: "Test::method".to_string(),
1586 },
1587 status_code: None,
1588 response_size_bytes: None,
1589 code_function: None,
1590 code_filepath: None,
1591 code_lineno: None,
1592 code_namespace: None,
1593 instrumentation_scopes: Vec::new(),
1594 }
1595 }
1596
1597 #[test]
1598 fn carbon_estimate_sci_numerator_labels() {
1599 let est = CarbonEstimate::sci_numerator(0.000_100);
1600 assert!((est.mid - 0.000_100).abs() < f64::EPSILON);
1601 assert!((est.low - 0.000_050).abs() < f64::EPSILON);
1602 assert!((est.high - 0.000_200).abs() < f64::EPSILON);
1603 assert_eq!(est.model, "io_proxy_v1");
1604 assert_eq!(est.methodology, "sci_v1_numerator");
1605 }
1606
1607 #[test]
1608 fn carbon_estimate_operational_ratio_labels() {
1609 let est = CarbonEstimate::operational_ratio(0.000_050);
1610 assert!((est.mid - 0.000_050).abs() < f64::EPSILON);
1611 assert!((est.low - 0.000_025).abs() < f64::EPSILON);
1612 assert!((est.high - 0.000_100).abs() < f64::EPSILON);
1613 assert_eq!(est.model, "io_proxy_v1");
1614 assert_eq!(est.methodology, "sci_v1_operational_ratio");
1615 }
1616
1617 #[test]
1618 fn carbon_estimate_methodology_constants_are_distinct() {
1619 assert_ne!(METHODOLOGY_SCI_NUMERATOR, METHODOLOGY_OPERATIONAL_RATIO);
1620 assert_eq!(METHODOLOGY_SCI_NUMERATOR, "sci_v1_numerator");
1621 assert_eq!(METHODOLOGY_OPERATIONAL_RATIO, "sci_v1_operational_ratio");
1622 }
1623
1624 #[test]
1625 fn intensity_source_ordering_by_fidelity() {
1626 assert!(IntensitySource::Annual < IntensitySource::Hourly);
1628 assert!(IntensitySource::Hourly < IntensitySource::MonthlyHourly);
1629 }
1630
1631 #[test]
1632 fn carbon_estimate_from_zero_midpoint() {
1633 let est = CarbonEstimate::sci_numerator(0.0);
1634 assert!((est.low - 0.0).abs() < f64::EPSILON);
1635 assert!((est.mid - 0.0).abs() < f64::EPSILON);
1636 assert!((est.high - 0.0).abs() < f64::EPSILON);
1637 }
1638
1639 #[test]
1640 fn confidence_interval_factors_are_2x_multiplicative() {
1641 let mid = 12.34_f64;
1646 let est = CarbonEstimate::sci_numerator(mid);
1647 assert!((est.low - mid * CO2_LOW_FACTOR).abs() < f64::EPSILON);
1648 assert!((est.high - mid * CO2_HIGH_FACTOR).abs() < f64::EPSILON);
1649 assert!((CO2_LOW_FACTOR - 0.5).abs() < f64::EPSILON);
1650 assert!((CO2_HIGH_FACTOR - 2.0).abs() < f64::EPSILON);
1651 let geo_mean = (est.low * est.high).sqrt();
1653 assert!((geo_mean - mid).abs() < 1e-9);
1654 }
1655
1656 #[test]
1657 fn compute_operational_gco2_matches_expected() {
1658 let result = compute_operational_gco2(1000, 56.0, 1.15);
1660 assert!((result - 0.006_440).abs() < 1e-9);
1661 }
1662
1663 #[test]
1664 fn compute_operational_gco2_zero_ops() {
1665 assert!((compute_operational_gco2(0, 56.0, 1.15) - 0.0).abs() < f64::EPSILON);
1666 }
1667
1668 #[test]
1669 fn io_ops_to_co2_grams_delegates_to_helper() {
1670 let scalar = io_ops_to_co2_grams(1000, "eu-west-3").unwrap();
1673 let (intensity, pue) = lookup_region_lower("eu-west-3").unwrap();
1674 let helper = compute_operational_gco2(1000, intensity, pue);
1675 assert!((scalar - helper).abs() < f64::EPSILON);
1676 }
1677
1678 #[test]
1679 fn is_valid_region_id_accepts_valid() {
1680 assert!(is_valid_region_id("eu-west-3"));
1681 assert!(is_valid_region_id("us-east-1"));
1682 assert!(is_valid_region_id("europe-west9"));
1683 assert!(is_valid_region_id("francecentral"));
1684 assert!(is_valid_region_id("fr"));
1685 assert!(is_valid_region_id("unknown"));
1686 assert!(is_valid_region_id("mars-1"));
1687 assert!(is_valid_region_id("my_region_42"));
1688 }
1689
1690 #[test]
1691 fn is_valid_region_id_rejects_invalid() {
1692 assert!(!is_valid_region_id(""), "empty string");
1693 assert!(!is_valid_region_id(&"a".repeat(65)), "too long");
1694 assert!(!is_valid_region_id("eu west 3"), "space");
1695 assert!(!is_valid_region_id("eu.west.3"), "dot");
1696 assert!(!is_valid_region_id("eu/west/3"), "slash");
1697 assert!(!is_valid_region_id("eu-west-3\n"), "newline");
1698 assert!(!is_valid_region_id("eu-west-3\0"), "null byte");
1699 assert!(!is_valid_region_id("région"), "non-ASCII");
1700 }
1701
1702 #[test]
1703 fn is_valid_region_id_accepts_exact_64_chars() {
1704 let max_len = "a".repeat(64);
1705 assert!(is_valid_region_id(&max_len));
1706 }
1707
1708 #[test]
1709 fn resolve_region_prefers_event_attribute() {
1710 let mut service_regions = HashMap::new();
1711 service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1712 let ctx = CarbonContext {
1713 default_region: Some("eu-west-3".to_string()),
1714 service_regions,
1715 embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1716 use_hourly_profiles: true,
1717 energy_snapshot: None,
1718 ..CarbonContext::default()
1719 };
1720 let event = make_event("order-svc", Some("ap-south-1"));
1721 assert_eq!(resolve_region(&event, &ctx), Some("ap-south-1"));
1722 }
1723
1724 #[test]
1725 fn resolve_region_falls_back_to_service_map() {
1726 let mut service_regions = HashMap::new();
1727 service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1728 let ctx = CarbonContext {
1729 default_region: Some("eu-west-3".to_string()),
1730 service_regions,
1731 embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1732 use_hourly_profiles: true,
1733 energy_snapshot: None,
1734 ..CarbonContext::default()
1735 };
1736 let event = make_event("order-svc", None);
1737 assert_eq!(resolve_region(&event, &ctx), Some("us-east-1"));
1738 }
1739
1740 #[test]
1741 fn resolve_region_falls_back_to_default() {
1742 let ctx = CarbonContext {
1743 default_region: Some("eu-west-3".to_string()),
1744 service_regions: HashMap::new(),
1745 embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1746 use_hourly_profiles: true,
1747 energy_snapshot: None,
1748 ..CarbonContext::default()
1749 };
1750 let event = make_event("unknown-svc", None);
1751 assert_eq!(resolve_region(&event, &ctx), Some("eu-west-3"));
1752 }
1753
1754 #[test]
1755 fn resolve_region_returns_none_when_all_unset() {
1756 let ctx = CarbonContext::default();
1757 let event = make_event("any-svc", None);
1758 assert_eq!(resolve_region(&event, &ctx), None);
1759 }
1760
1761 #[test]
1762 fn resolve_region_service_map_does_not_shadow_event_attribute() {
1763 let mut service_regions = HashMap::new();
1766 service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1767 let ctx = CarbonContext {
1768 default_region: None,
1769 service_regions,
1770 embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1771 use_hourly_profiles: true,
1772 energy_snapshot: None,
1773 ..CarbonContext::default()
1774 };
1775 let event = make_event("order-svc", Some("eu-north-1"));
1776 assert_eq!(resolve_region(&event, &ctx), Some("eu-north-1"));
1777 }
1778
1779 #[test]
1780 fn resolve_region_service_map_is_case_insensitive() {
1781 let mut service_regions = HashMap::new();
1786 service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1787 let ctx = CarbonContext {
1788 default_region: None,
1789 service_regions,
1790 embodied_per_request_gco2: 0.0,
1791 use_hourly_profiles: true,
1792 energy_snapshot: None,
1793 ..CarbonContext::default()
1794 };
1795 let event = make_event("Order-Svc", None);
1797 assert_eq!(resolve_region(&event, &ctx), Some("us-east-1"));
1798 let event_upper = make_event("ORDER-SVC", None);
1800 assert_eq!(resolve_region(&event_upper, &ctx), Some("us-east-1"));
1801 }
1802
1803 fn make_sql_target_event(target: &str) -> SpanEvent {
1806 SpanEvent {
1807 timestamp: "2025-07-10T14:32:01.000Z".to_string(),
1808 trace_id: "trace-1".to_string(),
1809 span_id: "span-1".to_string(),
1810 parent_span_id: None,
1811 service: Arc::from("test"),
1812 cloud_region: None,
1813 event_type: EventType::Sql,
1814 operation: "postgresql".to_string(),
1815 target: target.to_string(),
1816 duration_us: 1000,
1817 source: EventSource {
1818 endpoint: "GET /test".to_string(),
1819 method: "Test::method".to_string(),
1820 },
1821 status_code: None,
1822 response_size_bytes: None,
1823 code_function: None,
1824 code_filepath: None,
1825 code_lineno: None,
1826 code_namespace: None,
1827 instrumentation_scopes: Vec::new(),
1828 }
1829 }
1830
1831 fn make_http_size_event(response_size_bytes: Option<u64>) -> SpanEvent {
1832 SpanEvent {
1833 timestamp: "2025-07-10T14:32:01.000Z".to_string(),
1834 trace_id: "trace-1".to_string(),
1835 span_id: "span-1".to_string(),
1836 parent_span_id: None,
1837 service: Arc::from("test"),
1838 cloud_region: None,
1839 event_type: EventType::HttpOut,
1840 operation: "GET".to_string(),
1841 target: "http://user-svc:5000/api/users/123".to_string(),
1842 duration_us: 1000,
1843 source: EventSource {
1844 endpoint: "GET /test".to_string(),
1845 method: "Test::method".to_string(),
1846 },
1847 status_code: Some(200),
1848 response_size_bytes,
1849 code_function: None,
1850 code_filepath: None,
1851 code_lineno: None,
1852 code_namespace: None,
1853 instrumentation_scopes: Vec::new(),
1854 }
1855 }
1856
1857 #[test]
1858 fn energy_coefficient_sql_select() {
1859 let event = make_sql_target_event("SELECT * FROM users WHERE id = 1");
1860 assert!((energy_coefficient(&event) - SQL_SELECT_COEFF).abs() < f64::EPSILON);
1861 }
1862
1863 #[test]
1864 fn energy_coefficient_sql_insert() {
1865 let event = make_sql_target_event("INSERT INTO users (name) VALUES ('Alice')");
1866 assert!((energy_coefficient(&event) - SQL_INSERT_COEFF).abs() < f64::EPSILON);
1867 }
1868
1869 #[test]
1870 fn energy_coefficient_sql_update() {
1871 let event = make_sql_target_event("UPDATE users SET name = 'Bob' WHERE id = 1");
1872 assert!((energy_coefficient(&event) - SQL_UPDATE_COEFF).abs() < f64::EPSILON);
1873 }
1874
1875 #[test]
1876 fn energy_coefficient_sql_delete() {
1877 let event = make_sql_target_event("DELETE FROM users WHERE id = 1");
1878 assert!((energy_coefficient(&event) - SQL_DELETE_COEFF).abs() < f64::EPSILON);
1879 }
1880
1881 #[test]
1882 fn energy_coefficient_sql_other() {
1883 let event = make_sql_target_event("CREATE TABLE users (id INT)");
1884 assert!((energy_coefficient(&event) - SQL_OTHER_COEFF).abs() < f64::EPSILON);
1885 }
1886
1887 #[test]
1888 fn energy_coefficient_sql_case_insensitive() {
1889 let event = make_sql_target_event("select * from users");
1890 assert!((energy_coefficient(&event) - SQL_SELECT_COEFF).abs() < f64::EPSILON);
1891 }
1892
1893 #[test]
1894 fn energy_coefficient_http_small() {
1895 let event = make_http_size_event(Some(1024)); assert!((energy_coefficient(&event) - HTTP_SMALL_COEFF).abs() < f64::EPSILON);
1897 }
1898
1899 #[test]
1900 fn energy_coefficient_http_medium() {
1901 let event = make_http_size_event(Some(100 * 1024)); assert!((energy_coefficient(&event) - HTTP_MEDIUM_COEFF).abs() < f64::EPSILON);
1903 }
1904
1905 #[test]
1906 fn energy_coefficient_http_large() {
1907 let event = make_http_size_event(Some(2 * 1024 * 1024)); assert!((energy_coefficient(&event) - HTTP_LARGE_COEFF).abs() < f64::EPSILON);
1909 }
1910
1911 #[test]
1912 fn energy_coefficient_http_no_size() {
1913 let event = make_http_size_event(None);
1914 assert!((energy_coefficient(&event) - 1.0).abs() < f64::EPSILON);
1915 }
1916
1917 #[test]
1918 fn energy_coefficient_http_boundary_small_threshold() {
1919 let event = make_http_size_event(Some(HTTP_SMALL_THRESHOLD));
1921 assert!((energy_coefficient(&event) - HTTP_MEDIUM_COEFF).abs() < f64::EPSILON);
1922 }
1923
1924 #[test]
1925 fn energy_coefficient_http_boundary_large_threshold() {
1926 let event = make_http_size_event(Some(HTTP_LARGE_THRESHOLD));
1928 assert!((energy_coefficient(&event) - HTTP_MEDIUM_COEFF).abs() < f64::EPSILON);
1929 let event_over = make_http_size_event(Some(HTTP_LARGE_THRESHOLD + 1));
1930 assert!((energy_coefficient(&event_over) - HTTP_LARGE_COEFF).abs() < f64::EPSILON);
1931 }
1932
1933 #[test]
1936 fn extract_hostname_http_with_port() {
1937 assert_eq!(
1938 extract_hostname("http://user-svc:5000/api/users"),
1939 Some("user-svc")
1940 );
1941 }
1942
1943 #[test]
1944 fn extract_hostname_http_no_port() {
1945 assert_eq!(
1946 extract_hostname("http://user-svc/api/users"),
1947 Some("user-svc")
1948 );
1949 }
1950
1951 #[test]
1952 fn extract_hostname_https() {
1953 assert_eq!(
1954 extract_hostname("https://api.example.com/path"),
1955 Some("api.example.com")
1956 );
1957 }
1958
1959 #[test]
1960 fn extract_hostname_empty() {
1961 assert_eq!(extract_hostname(""), None);
1962 }
1963
1964 #[test]
1965 fn extract_hostname_no_scheme() {
1966 assert_eq!(extract_hostname("/api/users"), None);
1967 }
1968
1969 #[test]
1970 fn extract_hostname_empty_host() {
1971 assert_eq!(extract_hostname("http:///path"), None);
1972 }
1973
1974 #[test]
1975 fn extract_hostname_with_userinfo() {
1976 assert_eq!(
1978 extract_hostname("http://user:pass@order-api:8080/api/orders"),
1979 Some("order-api")
1980 );
1981 }
1982
1983 #[test]
1984 fn extract_hostname_with_user_only() {
1985 assert_eq!(
1986 extract_hostname("http://admin@order-api/api"),
1987 Some("order-api")
1988 );
1989 }
1990
1991 #[test]
1992 fn energy_coefficient_http_zero_bytes() {
1993 let event = make_http_size_event(Some(0));
1994 assert!((energy_coefficient(&event) - HTTP_SMALL_COEFF).abs() < f64::EPSILON);
1995 }
1996
1997 #[test]
1998 fn energy_coefficient_sql_empty_target() {
1999 let event = make_sql_target_event("");
2000 assert!((energy_coefficient(&event) - SQL_OTHER_COEFF).abs() < f64::EPSILON);
2001 }
2002
2003 #[test]
2006 fn scoring_config_default_is_v4_lifecycle_hourly() {
2007 let cfg = ScoringConfig::default();
2008 assert_eq!(cfg.api_version, ApiVersion::V4);
2009 assert_eq!(cfg.emission_factor_type, EmissionFactorType::Lifecycle);
2010 assert_eq!(cfg.temporal_granularity, TemporalGranularity::Hourly);
2011 }
2012
2013 #[test]
2014 fn scoring_config_round_trip_json_all_defaults() {
2015 let cfg = ScoringConfig::default();
2016 let json = serde_json::to_string(&cfg).unwrap();
2017 let back: ScoringConfig = serde_json::from_str(&json).unwrap();
2018 assert_eq!(cfg, back);
2019 assert!(json.contains("\"v4\""));
2020 assert!(json.contains("\"lifecycle\""));
2021 assert!(json.contains("\"hourly\""));
2022 }
2023
2024 #[test]
2025 fn scoring_config_round_trip_json_all_optins() {
2026 let cfg = ScoringConfig {
2027 api_version: ApiVersion::V3,
2028 emission_factor_type: EmissionFactorType::Direct,
2029 temporal_granularity: TemporalGranularity::FiveMinutes,
2030 };
2031 let json = serde_json::to_string(&cfg).unwrap();
2032 let back: ScoringConfig = serde_json::from_str(&json).unwrap();
2033 assert_eq!(cfg, back);
2034 assert!(json.contains("\"v3\""));
2035 assert!(json.contains("\"direct\""));
2036 assert!(json.contains("\"5_minutes\""));
2037 }
2038
2039 #[test]
2040 fn scoring_config_from_electricity_maps_derives_api_version_from_endpoint() {
2041 let cfg = ElectricityMapsConfig {
2046 api_endpoint: "https://api.electricitymaps.com/v3".to_string(),
2047 auth_token: "test-token".to_string(),
2048 poll_interval: std::time::Duration::from_mins(5),
2049 region_map: HashMap::new(),
2050 emission_factor_type: EmissionFactorType::Direct,
2051 temporal_granularity: TemporalGranularity::FifteenMinutes,
2052 };
2053 let scoring = ScoringConfig::from_electricity_maps(&cfg);
2054 assert_eq!(scoring.api_version, ApiVersion::V3);
2055 assert_eq!(scoring.emission_factor_type, EmissionFactorType::Direct);
2056 assert_eq!(
2057 scoring.temporal_granularity,
2058 TemporalGranularity::FifteenMinutes
2059 );
2060 }
2061
2062 #[test]
2063 fn scoring_config_from_electricity_maps_v4_default_endpoint() {
2064 let cfg = ElectricityMapsConfig {
2067 api_endpoint: "https://api.electricitymaps.com/v4".to_string(),
2068 auth_token: "test-token".to_string(),
2069 poll_interval: std::time::Duration::from_mins(5),
2070 region_map: HashMap::new(),
2071 emission_factor_type: EmissionFactorType::Lifecycle,
2072 temporal_granularity: TemporalGranularity::Hourly,
2073 };
2074 let scoring = ScoringConfig::from_electricity_maps(&cfg);
2075 assert_eq!(scoring.api_version, ApiVersion::V4);
2076 assert_eq!(scoring.emission_factor_type, EmissionFactorType::Lifecycle);
2077 assert_eq!(scoring.temporal_granularity, TemporalGranularity::Hourly);
2078 }
2079
2080 #[test]
2081 fn scoring_config_from_electricity_maps_custom_endpoint() {
2082 let cfg = ElectricityMapsConfig {
2086 api_endpoint: "https://corp-proxy.acme.internal/electricity-maps".to_string(),
2087 auth_token: "test-token".to_string(),
2088 poll_interval: std::time::Duration::from_mins(5),
2089 region_map: HashMap::new(),
2090 emission_factor_type: EmissionFactorType::Lifecycle,
2091 temporal_granularity: TemporalGranularity::Hourly,
2092 };
2093 let scoring = ScoringConfig::from_electricity_maps(&cfg);
2094 assert_eq!(scoring.api_version, ApiVersion::Custom);
2095 }
2096}