Skip to main content

dpp_calc/co2e/
mod.rs

1//! Cradle-to-gate CO₂e calculator and Battery CFB stub.
2//!
3//! The primary entry point is [`calculate`], which computes the production-stage
4//! footprint from a bill of materials and manufacturing energy using a
5//! caller-supplied [`Co2eRuleset`]. [`cfb`] is a stub gated on Phase 2 data licensing.
6//!
7//! ## Module layout (four-file methodology convention)
8//!
9//! - [`parameters`] — typed inputs ([`Co2eInputs`], [`MaterialFootprint`]).
10//! - [`thresholds`] — the [`Co2eRuleset`] trait and [`CradleToGateRuleset`] impl.
11//! - this `mod.rs` — the [`calculate`] algorithm and its output types.
12//! - `golden_vectors` — `#[cfg(test)]` regression tests.
13//! - [`cfb`] — battery CFB stub; [`gwp_factors`] — embeddable GWP100 table.
14//!
15//! ## PEF lifecycle stages
16//!
17//! The EU PEF method covers the full product lifecycle (raw-material extraction,
18//! production, distribution, use, end-of-life). This calculator covers the
19//! **RawMaterials + Production** stages (cradle-to-gate), as declared in
20//! [`Co2eResult::declared_stages`]. Future calculators will extend to remaining
21//! stages without changing this module's contract.
22//!
23//! ## Methodology
24//!
25//! ```text
26//! co2e_kg = Σ (material.mass_kg × material.emission_factor) + energy_kwh × grid_factor
27//! ```
28//!
29//! Operator-supplied emission factors. The full PEF method (EU JRC LCA database,
30//! country-specific grid factors, allocation rules) refines those factors and is
31//! gated on a signed data license (`real-factors` feature).
32
33pub 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// ---------------------------------------------------------------------------
50// Lifecycle stage model (Gap 1 — PEF system boundary)
51// ---------------------------------------------------------------------------
52
53/// A single stage in the EU PEF product lifecycle.
54///
55/// Use [`Co2eResult::declared_stages`] to understand which stages a result covers.
56/// The cradle-to-gate [`calculate`] covers `RawMaterials` + `Production`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum LifecycleStage {
60    /// Extraction and processing of raw materials (cradle).
61    RawMaterials,
62    /// Manufacturing, assembly, and finishing (gate).
63    Production,
64    /// Packaging, transport, and distribution to point of sale.
65    Distribution,
66    /// Energy and resource consumption during product use.
67    Use,
68    /// Waste processing, recycling, and disposal.
69    EndOfLife,
70}
71
72// ---------------------------------------------------------------------------
73// Output types
74// ---------------------------------------------------------------------------
75
76/// CO₂e contribution for one material line — the audit breakdown that backs the total.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct MaterialLineResult {
79    /// Mass of this material, in kg (echoed from input for self-documenting receipts).
80    pub mass_kg: f64,
81    /// Emission factor used, in kg CO₂e/kg.
82    pub emission_factor_kg_co2e_per_kg: f64,
83    /// Embodied emissions for this line: `mass_kg × emission_factor`, in kg CO₂e.
84    pub co2e_kg: f64,
85}
86
87/// Transparent breakdown of the footprint plus a proof-of-calculation receipt.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Co2eResult {
90    /// Embodied emissions from materials, kg CO₂e.
91    pub material_co2e_kg: f64,
92    /// Emissions from manufacturing energy, kg CO₂e.
93    pub energy_co2e_kg: f64,
94    /// Total cradle-to-gate footprint, kg CO₂e.
95    pub total_co2e_kg: f64,
96    /// Per-material breakdown — each entry corresponds to `Co2eInputs::materials[i]`.
97    /// Enables a notified body to trace any line item without re-running the calculation.
98    pub material_breakdown: Vec<MaterialLineResult>,
99    /// Lifecycle stages this result covers (its declared PEF system boundary).
100    pub declared_stages: Vec<LifecycleStage>,
101    /// Proof-of-calculation receipt (input hash + output hash + ruleset citation).
102    pub receipt: CalculationReceipt,
103}
104
105// ---------------------------------------------------------------------------
106// Calculator
107// ---------------------------------------------------------------------------
108
109/// Calculate the cradle-to-gate CO₂e footprint for one unit.
110///
111/// Returns `Err(CalcError::InvalidInput)` for negative or non-finite inputs —
112/// silent clamping is not appropriate for a legally cited compliance figure.
113pub 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    // Hash outputs before building the result (avoids chicken-and-egg with receipt).
131    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        // materials: 2*3 + 1*4 = 10; energy: 10*0.5 = 5; total = 15
252        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}