use serde::{Deserialize, Serialize};
use super::parameters::Co2eInputs;
use super::thresholds::Co2eRuleset;
use crate::clock::AssessmentClock;
use crate::error::CalcError;
use crate::receipt::{CalculationReceipt, jcs_hash};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleStage {
RawMaterials,
Production,
Distribution,
Use,
EndOfLife,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialLineResult {
pub mass_kg: f64,
pub emission_factor_kg_co2e_per_kg: f64,
pub co2e_kg: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Co2eResult {
pub material_co2e_kg: f64,
pub energy_co2e_kg: f64,
pub total_co2e_kg: f64,
pub material_breakdown: Vec<MaterialLineResult>,
pub declared_stages: Vec<LifecycleStage>,
pub receipt: CalculationReceipt,
}
pub fn calculate(
inputs: &Co2eInputs,
ruleset: &dyn Co2eRuleset,
clock: AssessmentClock,
) -> Result<Co2eResult, CalcError> {
validate_inputs(inputs)?;
ruleset
.effectivity()
.ensure_active_on(ruleset.id(), clock.law_in_force_on)?;
let material_breakdown: Vec<MaterialLineResult> = inputs
.materials
.iter()
.map(|m| MaterialLineResult {
mass_kg: m.mass_kg,
emission_factor_kg_co2e_per_kg: m.emission_factor_kg_co2e_per_kg,
co2e_kg: m.mass_kg * m.emission_factor_kg_co2e_per_kg,
})
.collect();
let material_co2e_kg: f64 = material_breakdown.iter().map(|l| l.co2e_kg).sum();
let energy_co2e_kg = inputs.energy_kwh * inputs.grid_factor_kg_co2e_per_kwh;
let total_co2e_kg = material_co2e_kg + energy_co2e_kg;
if !material_co2e_kg.is_finite() || !energy_co2e_kg.is_finite() || !total_co2e_kg.is_finite() {
return Err(CalcError::Overflow(format!(
"CO2e overflowed to a non-finite value \
(material={material_co2e_kg}, energy={energy_co2e_kg}, total={total_co2e_kg})"
)));
}
let output_hash = jcs_hash(&(total_co2e_kg, material_co2e_kg, energy_co2e_kg))?;
let receipt = CalculationReceipt::for_ruleset(inputs, ruleset, clock, output_hash)?;
Ok(Co2eResult {
material_co2e_kg,
energy_co2e_kg,
total_co2e_kg,
material_breakdown,
declared_stages: ruleset.declared_stages().to_vec(),
receipt,
})
}
fn validate_inputs(inputs: &Co2eInputs) -> Result<(), CalcError> {
if !inputs.energy_kwh.is_finite() || inputs.energy_kwh < 0.0 {
return Err(CalcError::InvalidInput(format!(
"energy_kwh must be finite and ≥ 0; got {}",
inputs.energy_kwh
)));
}
if !inputs.grid_factor_kg_co2e_per_kwh.is_finite() || inputs.grid_factor_kg_co2e_per_kwh < 0.0 {
return Err(CalcError::InvalidInput(format!(
"grid_factor_kg_co2e_per_kwh must be finite and ≥ 0; got {}",
inputs.grid_factor_kg_co2e_per_kwh
)));
}
for (i, m) in inputs.materials.iter().enumerate() {
if !m.mass_kg.is_finite() || m.mass_kg < 0.0 {
return Err(CalcError::InvalidInput(format!(
"materials[{i}].mass_kg must be finite and ≥ 0; got {}",
m.mass_kg
)));
}
if !m.emission_factor_kg_co2e_per_kg.is_finite() || m.emission_factor_kg_co2e_per_kg < 0.0 {
return Err(CalcError::InvalidInput(format!(
"materials[{i}].emission_factor must be finite and ≥ 0; got {}",
m.emission_factor_kg_co2e_per_kg
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::co2e::parameters::MaterialFootprint;
use crate::co2e::thresholds::CradleToGateRuleset;
use chrono::NaiveDate;
fn in_force() -> AssessmentClock {
AssessmentClock::placed_on(NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"))
}
fn material(mass: f64, factor: f64) -> MaterialFootprint {
MaterialFootprint {
mass_kg: mass,
emission_factor_kg_co2e_per_kg: factor,
}
}
#[test]
fn rejects_ruleset_not_yet_in_force() {
let inputs = Co2eInputs {
materials: vec![material(1.0, 2.0)],
energy_kwh: 1.0,
grid_factor_kg_co2e_per_kwh: 0.4,
};
let before_effective =
AssessmentClock::placed_on(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
assert!(matches!(
calculate(&inputs, &CradleToGateRuleset, before_effective),
Err(CalcError::RulesetNotYetEffective { .. })
));
}
#[test]
fn rejects_overflow_to_non_finite() {
let inputs = Co2eInputs {
materials: vec![material(1e200, 1e200)],
energy_kwh: 0.0,
grid_factor_kg_co2e_per_kwh: 0.0,
};
assert!(matches!(
calculate(&inputs, &CradleToGateRuleset, in_force()),
Err(CalcError::Overflow(_))
));
}
#[test]
fn rejects_negative_energy() {
let inputs = Co2eInputs {
materials: vec![material(1.0, 2.0)],
energy_kwh: -1.0,
grid_factor_kg_co2e_per_kwh: 0.4,
};
assert!(matches!(
calculate(&inputs, &CradleToGateRuleset, in_force()),
Err(CalcError::InvalidInput(_))
));
}
#[test]
fn rejects_non_finite_grid_factor() {
let inputs = Co2eInputs {
materials: vec![material(1.0, 2.0)],
energy_kwh: 1.0,
grid_factor_kg_co2e_per_kwh: f64::NAN,
};
assert!(matches!(
calculate(&inputs, &CradleToGateRuleset, in_force()),
Err(CalcError::InvalidInput(_))
));
}
#[test]
fn rejects_negative_material_mass() {
let inputs = Co2eInputs {
materials: vec![material(-0.5, 2.0)],
energy_kwh: 1.0,
grid_factor_kg_co2e_per_kwh: 0.4,
};
assert!(matches!(
calculate(&inputs, &CradleToGateRuleset, in_force()),
Err(CalcError::InvalidInput(_))
));
}
#[test]
fn rejects_negative_emission_factor() {
let inputs = Co2eInputs {
materials: vec![material(0.5, -2.0)],
energy_kwh: 1.0,
grid_factor_kg_co2e_per_kwh: 0.4,
};
assert!(matches!(
calculate(&inputs, &CradleToGateRuleset, in_force()),
Err(CalcError::InvalidInput(_))
));
}
#[test]
fn accepts_valid_inputs_and_sums_correctly() {
let inputs = Co2eInputs {
materials: vec![material(2.0, 3.0), material(1.0, 4.0)],
energy_kwh: 10.0,
grid_factor_kg_co2e_per_kwh: 0.5,
};
let result = calculate(&inputs, &CradleToGateRuleset, in_force()).unwrap();
assert!((result.material_co2e_kg - 10.0).abs() < 1e-9);
assert!((result.energy_co2e_kg - 5.0).abs() < 1e-9);
assert!((result.total_co2e_kg - 15.0).abs() < 1e-9);
assert_eq!(result.material_breakdown.len(), 2);
}
}