1pub mod cfb;
34pub mod gwp_factors;
35pub mod parameters;
36pub mod thresholds;
37
38#[cfg(test)]
39mod golden_vectors;
40
41use serde::{Deserialize, Serialize};
42
43use crate::error::CalcError;
44use crate::receipt::{CalculationReceipt, input_hash, jcs_hash};
45
46pub use parameters::{Co2eInputs, MaterialFootprint};
47pub use thresholds::{Co2eRuleset, CradleToGateRuleset};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum LifecycleStage {
60 RawMaterials,
62 Production,
64 Distribution,
66 Use,
68 EndOfLife,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct MaterialLineResult {
79 pub mass_kg: f64,
81 pub emission_factor_kg_co2e_per_kg: f64,
83 pub co2e_kg: f64,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Co2eResult {
90 pub material_co2e_kg: f64,
92 pub energy_co2e_kg: f64,
94 pub total_co2e_kg: f64,
96 pub material_breakdown: Vec<MaterialLineResult>,
99 pub declared_stages: Vec<LifecycleStage>,
101 pub receipt: CalculationReceipt,
103}
104
105pub fn calculate(inputs: &Co2eInputs, ruleset: &dyn Co2eRuleset) -> Result<Co2eResult, CalcError> {
114 validate_inputs(inputs)?;
115
116 let material_breakdown: Vec<MaterialLineResult> = inputs
117 .materials
118 .iter()
119 .map(|m| MaterialLineResult {
120 mass_kg: m.mass_kg,
121 emission_factor_kg_co2e_per_kg: m.emission_factor_kg_co2e_per_kg,
122 co2e_kg: m.mass_kg * m.emission_factor_kg_co2e_per_kg,
123 })
124 .collect();
125
126 let material_co2e_kg: f64 = material_breakdown.iter().map(|l| l.co2e_kg).sum();
127 let energy_co2e_kg = inputs.energy_kwh * inputs.grid_factor_kg_co2e_per_kwh;
128 let total_co2e_kg = material_co2e_kg + energy_co2e_kg;
129
130 let output_hash = jcs_hash(&(total_co2e_kg, material_co2e_kg, energy_co2e_kg))?;
132
133 let receipt = CalculationReceipt::new(
134 input_hash(inputs)?,
135 ruleset.id().0.as_str(),
136 ruleset.version().0.as_str(),
137 )
138 .with_output_hash(output_hash);
139
140 Ok(Co2eResult {
141 material_co2e_kg,
142 energy_co2e_kg,
143 total_co2e_kg,
144 material_breakdown,
145 declared_stages: ruleset.declared_stages().to_vec(),
146 receipt,
147 })
148}
149
150fn validate_inputs(inputs: &Co2eInputs) -> Result<(), CalcError> {
151 if !inputs.energy_kwh.is_finite() || inputs.energy_kwh < 0.0 {
152 return Err(CalcError::InvalidInput(format!(
153 "energy_kwh must be finite and ≥ 0; got {}",
154 inputs.energy_kwh
155 )));
156 }
157 if !inputs.grid_factor_kg_co2e_per_kwh.is_finite() || inputs.grid_factor_kg_co2e_per_kwh < 0.0 {
158 return Err(CalcError::InvalidInput(format!(
159 "grid_factor_kg_co2e_per_kwh must be finite and ≥ 0; got {}",
160 inputs.grid_factor_kg_co2e_per_kwh
161 )));
162 }
163 for (i, m) in inputs.materials.iter().enumerate() {
164 if !m.mass_kg.is_finite() || m.mass_kg < 0.0 {
165 return Err(CalcError::InvalidInput(format!(
166 "materials[{i}].mass_kg must be finite and ≥ 0; got {}",
167 m.mass_kg
168 )));
169 }
170 if !m.emission_factor_kg_co2e_per_kg.is_finite() || m.emission_factor_kg_co2e_per_kg < 0.0 {
171 return Err(CalcError::InvalidInput(format!(
172 "materials[{i}].emission_factor must be finite and ≥ 0; got {}",
173 m.emission_factor_kg_co2e_per_kg
174 )));
175 }
176 }
177 Ok(())
178}
179
180#[cfg(test)]
181mod input_validation_tests {
182 use super::*;
183
184 fn material(mass: f64, factor: f64) -> MaterialFootprint {
185 MaterialFootprint {
186 mass_kg: mass,
187 emission_factor_kg_co2e_per_kg: factor,
188 }
189 }
190
191 #[test]
192 fn rejects_negative_energy() {
193 let inputs = Co2eInputs {
194 materials: vec![material(1.0, 2.0)],
195 energy_kwh: -1.0,
196 grid_factor_kg_co2e_per_kwh: 0.4,
197 };
198 assert!(matches!(
199 calculate(&inputs, &CradleToGateRuleset),
200 Err(CalcError::InvalidInput(_))
201 ));
202 }
203
204 #[test]
205 fn rejects_non_finite_grid_factor() {
206 let inputs = Co2eInputs {
207 materials: vec![material(1.0, 2.0)],
208 energy_kwh: 1.0,
209 grid_factor_kg_co2e_per_kwh: f64::NAN,
210 };
211 assert!(matches!(
212 calculate(&inputs, &CradleToGateRuleset),
213 Err(CalcError::InvalidInput(_))
214 ));
215 }
216
217 #[test]
218 fn rejects_negative_material_mass() {
219 let inputs = Co2eInputs {
220 materials: vec![material(-0.5, 2.0)],
221 energy_kwh: 1.0,
222 grid_factor_kg_co2e_per_kwh: 0.4,
223 };
224 assert!(matches!(
225 calculate(&inputs, &CradleToGateRuleset),
226 Err(CalcError::InvalidInput(_))
227 ));
228 }
229
230 #[test]
231 fn rejects_negative_emission_factor() {
232 let inputs = Co2eInputs {
233 materials: vec![material(0.5, -2.0)],
234 energy_kwh: 1.0,
235 grid_factor_kg_co2e_per_kwh: 0.4,
236 };
237 assert!(matches!(
238 calculate(&inputs, &CradleToGateRuleset),
239 Err(CalcError::InvalidInput(_))
240 ));
241 }
242
243 #[test]
244 fn accepts_valid_inputs_and_sums_correctly() {
245 let inputs = Co2eInputs {
246 materials: vec![material(2.0, 3.0), material(1.0, 4.0)],
247 energy_kwh: 10.0,
248 grid_factor_kg_co2e_per_kwh: 0.5,
249 };
250 let result = calculate(&inputs, &CradleToGateRuleset).unwrap();
251 assert!((result.material_co2e_kg - 10.0).abs() < 1e-9);
253 assert!((result.energy_co2e_kg - 5.0).abs() < 1e-9);
254 assert!((result.total_co2e_kg - 15.0).abs() < 1e-9);
255 assert_eq!(result.material_breakdown.len(), 2);
256 }
257}