1pub const J_PER_TOKEN: f64 = 0.4;
14
15pub const WH_PER_PHONE_CHARGE: f64 = 12.0;
17
18pub const G_CO2_PER_KWH: f64 = 475.0;
23
24#[must_use]
26pub fn wh_for_tokens(tokens_saved: u64) -> f64 {
27 tokens_saved as f64 * J_PER_TOKEN / 3600.0
28}
29
30#[must_use]
32pub fn phone_charges(tokens_saved: u64) -> f64 {
33 wh_for_tokens(tokens_saved) / WH_PER_PHONE_CHARGE
34}
35
36#[must_use]
39pub fn grid_co2_g_per_kwh() -> f64 {
40 std::env::var("LEAN_CTX_GRID_CO2_G_PER_KWH")
41 .ok()
42 .and_then(|v| v.trim().parse::<f64>().ok())
43 .filter(|v| v.is_finite() && *v > 0.0)
44 .unwrap_or(G_CO2_PER_KWH)
45}
46
47#[must_use]
50pub fn co2_grams_for_tokens(tokens_saved: u64) -> f64 {
51 wh_for_tokens(tokens_saved) / 1_000.0 * grid_co2_g_per_kwh()
52}
53
54#[must_use]
56pub fn format_co2(grams: f64) -> String {
57 if !grams.is_finite() || grams <= 0.0 {
58 "0 g".to_string()
59 } else if grams >= 1_000_000.0 {
60 format!("{:.1} t", grams / 1_000_000.0)
61 } else if grams >= 1_000.0 {
62 format!("{:.1} kg", grams / 1_000.0)
63 } else {
64 format!("{grams:.0} g")
65 }
66}
67
68#[must_use]
70pub fn format_co2_for_tokens(tokens_saved: u64) -> String {
71 format_co2(co2_grams_for_tokens(tokens_saved))
72}
73
74#[must_use]
76pub fn format_wh(wh: f64) -> String {
77 if !wh.is_finite() || wh <= 0.0 {
78 "0 Wh".to_string()
79 } else if wh >= 1_000_000.0 {
80 format!("{:.1} MWh", wh / 1_000_000.0)
81 } else if wh >= 1_000.0 {
82 format!("{:.1} kWh", wh / 1_000.0)
83 } else {
84 format!("{wh:.0} Wh")
85 }
86}
87
88#[must_use]
90pub fn format_for_tokens(tokens_saved: u64) -> String {
91 format_wh(wh_for_tokens(tokens_saved))
92}
93
94#[must_use]
97pub fn phone_charges_hint(tokens_saved: u64) -> Option<String> {
98 let charges = phone_charges(tokens_saved);
99 if charges < 0.5 {
100 return None;
101 }
102 Some(format!("≈ {} phone charges", charges.round() as u64))
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn wh_scales_linearly_with_tokens() {
111 assert!((wh_for_tokens(9_000) - 1.0).abs() < 1e-9);
113 assert!((wh_for_tokens(0)).abs() < 1e-9);
114 }
115
116 #[test]
117 fn format_picks_adaptive_units() {
118 assert_eq!(format_wh(0.0), "0 Wh");
119 assert_eq!(format_wh(-5.0), "0 Wh");
120 assert_eq!(format_wh(42.4), "42 Wh");
121 assert_eq!(format_wh(1_500.0), "1.5 kWh");
122 assert_eq!(format_wh(2_500_000.0), "2.5 MWh");
123 }
124
125 #[test]
126 fn format_for_tokens_matches_methodology() {
127 assert_eq!(format_for_tokens(12_800_000), "1.4 kWh");
129 }
130
131 #[test]
132 fn phone_charge_hint_suppressed_when_tiny() {
133 assert!(phone_charges_hint(0).is_none());
134 assert!(phone_charges_hint(1_000).is_none());
136 assert!(phone_charges_hint(12_800_000).is_some());
137 }
138
139 #[test]
140 fn co2_scales_with_energy_and_grid_intensity() {
141 let g = co2_grams_for_tokens(9_000);
143 assert!((g - 0.475).abs() < 1e-6, "got {g}");
144 assert!(co2_grams_for_tokens(0).abs() < 1e-12);
145 assert!((co2_grams_for_tokens(18_000) - 2.0 * g).abs() < 1e-6);
147 }
148
149 #[test]
150 fn format_co2_picks_adaptive_units() {
151 assert_eq!(format_co2(0.0), "0 g");
152 assert_eq!(format_co2(-3.0), "0 g");
153 assert_eq!(format_co2(42.4), "42 g");
154 assert_eq!(format_co2(1_500.0), "1.5 kg");
155 assert_eq!(format_co2(2_500_000.0), "2.5 t");
156 }
157
158 #[test]
159 fn grid_intensity_default_when_no_override() {
160 crate::test_env::remove_var("LEAN_CTX_GRID_CO2_G_PER_KWH");
163 assert!((grid_co2_g_per_kwh() - G_CO2_PER_KWH).abs() < 1e-9);
164 }
165}