Skip to main content

datasynth_generators/esg/
emission_generator.rs

1//! Emission generator — derives GHG Protocol Scope 1/2/3 emission records
2//! from operational data (energy consumption, vendor spend, headcount).
3//!
4//! Uses EPA/DEFRA-style emission factors to convert activity data to CO2e tonnes.
5use chrono::NaiveDate;
6use datasynth_config::schema::EnvironmentalConfig;
7use datasynth_core::models::{
8    EmissionRecord, EmissionScope, EstimationMethod, ProductionOrder, ProductionOrderStatus,
9    Scope3Category,
10};
11use datasynth_core::utils::seeded_rng;
12use rand::prelude::*;
13use rand_chacha::ChaCha8Rng;
14use rust_decimal::prelude::FromPrimitive;
15use rust_decimal::Decimal;
16use rust_decimal_macros::dec;
17
18// ---------------------------------------------------------------------------
19// Input types — lightweight structs that upstream generators feed in
20// ---------------------------------------------------------------------------
21
22/// Energy consumption input for Scope 1 emission derivation.
23#[derive(Debug, Clone)]
24pub struct EnergyInput {
25    pub facility_id: String,
26    pub energy_type: EnergyInputType,
27    /// Consumption in kWh.
28    pub consumption_kwh: Decimal,
29    /// Period start date (first of month).
30    pub period: NaiveDate,
31}
32
33/// Energy input type for emission factor lookup.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EnergyInputType {
36    NaturalGas,
37    Diesel,
38    Coal,
39    Electricity,
40}
41
42/// Vendor spend input for Scope 3 emission derivation.
43#[derive(Debug, Clone)]
44pub struct VendorSpendInput {
45    pub vendor_id: String,
46    pub category: String,
47    pub spend: Decimal,
48    pub country: String,
49}
50
51// ---------------------------------------------------------------------------
52// Emission factors (kg CO2e per kWh, per USD, etc.)
53// ---------------------------------------------------------------------------
54
55/// Look up an activity-based emission factor (kg CO2e / kWh).
56fn emission_factor_kg_per_kwh(energy_type: EnergyInputType) -> Decimal {
57    match energy_type {
58        // EPA GHG factors (approximate)
59        EnergyInputType::NaturalGas => dec!(0.181), // kg CO2e / kWh
60        EnergyInputType::Diesel => dec!(0.253),
61        EnergyInputType::Coal => dec!(0.341),
62        EnergyInputType::Electricity => dec!(0.417), // US grid average
63    }
64}
65
66/// Look up a spend-based emission factor (kg CO2e / USD).
67fn spend_emission_factor(category: &str, country: &str) -> Decimal {
68    let base = match category {
69        "manufacturing" => dec!(0.80),
70        "construction" => dec!(0.65),
71        "transportation" => dec!(0.55),
72        "chemicals" => dec!(0.70),
73        "agriculture" => dec!(0.60),
74        "mining" => dec!(0.90),
75        "office_supplies" => dec!(0.20),
76        "professional_services" => dec!(0.15),
77        "technology" => dec!(0.25),
78        _ => dec!(0.40), // generic EEIO factor
79    };
80
81    // Country adjustment multiplier
82    let country_mult = match country {
83        "CN" => dec!(1.30),
84        "IN" => dec!(1.25),
85        "US" => dec!(1.00),
86        "DE" | "FR" | "GB" => dec!(0.85),
87        "JP" => dec!(0.90),
88        _ => dec!(1.00),
89    };
90
91    base * country_mult
92}
93
94// ---------------------------------------------------------------------------
95// EmissionGenerator
96// ---------------------------------------------------------------------------
97
98/// Generates [`EmissionRecord`] values from operational data.
99///
100/// Scope 1: fuel combustion (natural gas, diesel, coal) → activity-based
101/// Scope 2: purchased electricity → activity-based
102/// Scope 3: vendor spend → spend-based, business travel → average-data
103pub struct EmissionGenerator {
104    rng: ChaCha8Rng,
105    config: EnvironmentalConfig,
106    counter: u64,
107}
108
109impl EmissionGenerator {
110    /// Create a new emission generator.
111    pub fn new(config: EnvironmentalConfig, seed: u64) -> Self {
112        Self {
113            rng: seeded_rng(seed, 0),
114            config,
115            counter: 0,
116        }
117    }
118
119    // ----- Scope 1: Direct emissions from fuel combustion -----
120
121    /// Generate Scope 1 emission records from energy consumption data.
122    ///
123    /// Applies activity-based emission factors to fuel inputs
124    /// (natural gas, diesel, coal). Electricity is excluded (Scope 2).
125    pub fn generate_scope1(
126        &mut self,
127        entity_id: &str,
128        energy_data: &[EnergyInput],
129    ) -> Vec<EmissionRecord> {
130        if !self.config.scope1.enabled {
131            return Vec::new();
132        }
133
134        energy_data
135            .iter()
136            .filter(|e| e.energy_type != EnergyInputType::Electricity)
137            .map(|e| {
138                self.counter += 1;
139                let factor = emission_factor_kg_per_kwh(e.energy_type);
140                let co2e_kg = e.consumption_kwh * factor;
141                // Convert kg to tonnes (÷ 1000)
142                let co2e_tonnes = (co2e_kg / dec!(1000)).round_dp(4);
143
144                // Small random variance (±5%) to simulate measurement uncertainty
145                let variance = dec!(1) + self.random_variance();
146                let co2e_tonnes = (co2e_tonnes * variance).round_dp(4);
147
148                EmissionRecord {
149                    id: format!("EM-{:06}", self.counter),
150                    entity_id: entity_id.to_string(),
151                    scope: EmissionScope::Scope1,
152                    scope3_category: None,
153                    facility_id: Some(e.facility_id.clone()),
154                    period: e.period,
155                    activity_data: Some(format!("{} kWh", e.consumption_kwh)),
156                    activity_unit: Some("kWh".to_string()),
157                    emission_factor: Some(factor),
158                    co2e_tonnes,
159                    estimation_method: EstimationMethod::ActivityBased,
160                    source: Some(format!(
161                        "EPA GHG factors ({})",
162                        self.config.scope1.factor_region
163                    )),
164                }
165            })
166            .collect()
167    }
168
169    // ----- Scope 2: Indirect emissions from purchased electricity -----
170
171    /// Generate Scope 2 emission records from purchased electricity data.
172    pub fn generate_scope2(
173        &mut self,
174        entity_id: &str,
175        energy_data: &[EnergyInput],
176    ) -> Vec<EmissionRecord> {
177        if !self.config.scope2.enabled {
178            return Vec::new();
179        }
180
181        energy_data
182            .iter()
183            .filter(|e| e.energy_type == EnergyInputType::Electricity)
184            .map(|e| {
185                self.counter += 1;
186                let factor = emission_factor_kg_per_kwh(EnergyInputType::Electricity);
187                let co2e_kg = e.consumption_kwh * factor;
188                let co2e_tonnes = (co2e_kg / dec!(1000)).round_dp(4);
189
190                let variance = dec!(1) + self.random_variance();
191                let co2e_tonnes = (co2e_tonnes * variance).round_dp(4);
192
193                EmissionRecord {
194                    id: format!("EM-{:06}", self.counter),
195                    entity_id: entity_id.to_string(),
196                    scope: EmissionScope::Scope2,
197                    scope3_category: None,
198                    facility_id: Some(e.facility_id.clone()),
199                    period: e.period,
200                    activity_data: Some(format!("{} kWh", e.consumption_kwh)),
201                    activity_unit: Some("kWh".to_string()),
202                    emission_factor: Some(factor),
203                    co2e_tonnes,
204                    estimation_method: EstimationMethod::ActivityBased,
205                    source: Some(format!(
206                        "Grid average ({})",
207                        self.config.scope2.factor_region
208                    )),
209                }
210            })
211            .collect()
212    }
213
214    // ----- Scope 3: Value chain emissions -----
215
216    /// Generate Scope 3 (Category 1: Purchased Goods) emission records from vendor spend.
217    pub fn generate_scope3_purchased_goods(
218        &mut self,
219        entity_id: &str,
220        vendor_spend: &[VendorSpendInput],
221        start_date: NaiveDate,
222        _end_date: NaiveDate,
223    ) -> Vec<EmissionRecord> {
224        if !self.config.scope3.enabled {
225            return Vec::new();
226        }
227
228        vendor_spend
229            .iter()
230            .map(|vs| {
231                self.counter += 1;
232                let factor = spend_emission_factor(&vs.category, &vs.country);
233                let co2e_kg = vs.spend * factor;
234                let co2e_tonnes = (co2e_kg / dec!(1000)).round_dp(4);
235
236                EmissionRecord {
237                    id: format!("EM-{:06}", self.counter),
238                    entity_id: entity_id.to_string(),
239                    scope: EmissionScope::Scope3,
240                    scope3_category: Some(Scope3Category::PurchasedGoods),
241                    facility_id: None,
242                    period: start_date,
243                    activity_data: Some(format!("{} USD spend ({})", vs.spend, vs.category)),
244                    activity_unit: Some("USD".to_string()),
245                    emission_factor: Some(factor),
246                    co2e_tonnes,
247                    estimation_method: EstimationMethod::SpendBased,
248                    source: Some(format!("EEIO factors ({})", vs.country)),
249                }
250            })
251            .collect()
252    }
253
254    /// Generate Scope 3 (Category 6: Business Travel) from travel spend.
255    pub fn generate_scope3_business_travel(
256        &mut self,
257        entity_id: &str,
258        travel_spend: Decimal,
259        period: NaiveDate,
260    ) -> Vec<EmissionRecord> {
261        if !self.config.scope3.enabled || travel_spend <= Decimal::ZERO {
262            return Vec::new();
263        }
264
265        self.counter += 1;
266        // Average emission factor for business travel: ~0.25 kg CO2e / USD
267        let factor = dec!(0.25);
268        let co2e_kg = travel_spend * factor;
269        let co2e_tonnes = (co2e_kg / dec!(1000)).round_dp(4);
270
271        vec![EmissionRecord {
272            id: format!("EM-{:06}", self.counter),
273            entity_id: entity_id.to_string(),
274            scope: EmissionScope::Scope3,
275            scope3_category: Some(Scope3Category::BusinessTravel),
276            facility_id: None,
277            period,
278            activity_data: Some(format!("{travel_spend} USD travel spend")),
279            activity_unit: Some("USD".to_string()),
280            emission_factor: Some(factor),
281            co2e_tonnes,
282            estimation_method: EstimationMethod::AverageData,
283            source: Some("DEFRA business travel factors".to_string()),
284        }]
285    }
286
287    /// Generate Scope 3 (Category 7: Employee Commuting) from headcount.
288    pub fn generate_scope3_commuting(
289        &mut self,
290        entity_id: &str,
291        headcount: u32,
292        period: NaiveDate,
293    ) -> Vec<EmissionRecord> {
294        if !self.config.scope3.enabled || headcount == 0 {
295            return Vec::new();
296        }
297
298        self.counter += 1;
299        // Average commuting: ~2.5 tonnes CO2e / employee / year → per month
300        let annual_per_employee = dec!(2.5);
301        let monthly_per_employee = (annual_per_employee / dec!(12)).round_dp(4);
302        let co2e_tonnes = (monthly_per_employee * Decimal::from(headcount)).round_dp(4);
303
304        vec![EmissionRecord {
305            id: format!("EM-{:06}", self.counter),
306            entity_id: entity_id.to_string(),
307            scope: EmissionScope::Scope3,
308            scope3_category: Some(Scope3Category::EmployeeCommuting),
309            facility_id: None,
310            period,
311            activity_data: Some(format!("{headcount} employees")),
312            activity_unit: Some("headcount".to_string()),
313            emission_factor: None,
314            co2e_tonnes,
315            estimation_method: EstimationMethod::AverageData,
316            source: Some("EPA commuting average factors".to_string()),
317        }]
318    }
319
320    // ----- Manufacturing → Energy bridge -----
321
322    /// Convert production order routing operations into energy input records.
323    ///
324    /// Each order's machine_hours converted to electricity (Scope 2).
325    /// Each order's production quantity converted to natural gas consumption (Scope 1).
326    ///
327    /// Only `Completed` and `Closed` production orders are included.
328    pub fn energy_from_production(
329        production_orders: &[ProductionOrder],
330        kwh_per_machine_hour: Decimal,
331        gas_kwh_per_unit: Decimal,
332    ) -> Vec<EnergyInput> {
333        let mut inputs = Vec::new();
334
335        for order in production_orders {
336            // Only include completed / closed orders — in-flight orders
337            // have no settled activity data.
338            if !matches!(
339                order.status,
340                ProductionOrderStatus::Completed | ProductionOrderStatus::Closed
341            ) {
342                continue;
343            }
344
345            // Use work_center as the facility identifier; fall back to company_code
346            // when work_center is empty.
347            let facility_id = if order.work_center.is_empty() {
348                order.company_code.clone()
349            } else {
350                order.work_center.clone()
351            };
352
353            // Period: prefer actual_end; fall back to planned_end.
354            let period = order.actual_end.unwrap_or(order.planned_end);
355
356            // --- Scope 2: Electricity from machine hours ---
357            let machine_hours_dec = Decimal::from_f64(order.machine_hours).unwrap_or(Decimal::ZERO);
358            let electricity_kwh = machine_hours_dec * kwh_per_machine_hour;
359            if electricity_kwh > Decimal::ZERO {
360                inputs.push(EnergyInput {
361                    facility_id: facility_id.clone(),
362                    energy_type: EnergyInputType::Electricity,
363                    consumption_kwh: electricity_kwh,
364                    period,
365                });
366            }
367
368            // --- Scope 1: Natural gas from production quantity ---
369            let gas_kwh = order.actual_quantity * gas_kwh_per_unit;
370            if gas_kwh > Decimal::ZERO {
371                inputs.push(EnergyInput {
372                    facility_id,
373                    energy_type: EnergyInputType::NaturalGas,
374                    consumption_kwh: gas_kwh,
375                    period,
376                });
377            }
378        }
379
380        inputs
381    }
382
383    /// Small random variance ±5% for measurement uncertainty.
384    fn random_variance(&mut self) -> Decimal {
385        let v: f64 = self.rng.random_range(-0.05..0.05);
386        Decimal::from_f64_retain(v).unwrap_or(Decimal::ZERO)
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn d(s: &str) -> NaiveDate {
395        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
396    }
397
398    #[test]
399    fn test_scope1_emissions_from_energy() {
400        let energy_data = vec![EnergyInput {
401            facility_id: "F-001".into(),
402            energy_type: EnergyInputType::NaturalGas,
403            consumption_kwh: dec!(100000),
404            period: d("2025-01-01"),
405        }];
406
407        let config = EnvironmentalConfig::default();
408        let mut gen = EmissionGenerator::new(config, 42);
409        let records = gen.generate_scope1("C001", &energy_data);
410
411        assert_eq!(records.len(), 1);
412        assert_eq!(records[0].scope, EmissionScope::Scope1);
413        assert!(records[0].co2e_tonnes > Decimal::ZERO);
414        assert_eq!(
415            records[0].estimation_method,
416            EstimationMethod::ActivityBased
417        );
418        assert!(records[0].facility_id.is_some());
419    }
420
421    #[test]
422    fn test_scope1_excludes_electricity() {
423        let energy_data = vec![
424            EnergyInput {
425                facility_id: "F-001".into(),
426                energy_type: EnergyInputType::Electricity,
427                consumption_kwh: dec!(500000),
428                period: d("2025-01-01"),
429            },
430            EnergyInput {
431                facility_id: "F-001".into(),
432                energy_type: EnergyInputType::NaturalGas,
433                consumption_kwh: dec!(100000),
434                period: d("2025-01-01"),
435            },
436        ];
437
438        let config = EnvironmentalConfig::default();
439        let mut gen = EmissionGenerator::new(config, 42);
440        let records = gen.generate_scope1("C001", &energy_data);
441
442        assert_eq!(
443            records.len(),
444            1,
445            "Electricity should be excluded from Scope 1"
446        );
447        assert_eq!(records[0].scope, EmissionScope::Scope1);
448    }
449
450    #[test]
451    fn test_scope2_from_electricity() {
452        let energy_data = vec![EnergyInput {
453            facility_id: "F-001".into(),
454            energy_type: EnergyInputType::Electricity,
455            consumption_kwh: dec!(200000),
456            period: d("2025-01-01"),
457        }];
458
459        let config = EnvironmentalConfig::default();
460        let mut gen = EmissionGenerator::new(config, 42);
461        let records = gen.generate_scope2("C001", &energy_data);
462
463        assert_eq!(records.len(), 1);
464        assert_eq!(records[0].scope, EmissionScope::Scope2);
465        assert!(records[0].co2e_tonnes > Decimal::ZERO);
466    }
467
468    #[test]
469    fn test_scope3_from_vendor_spend() {
470        let vendor_spend = vec![
471            VendorSpendInput {
472                vendor_id: "V-001".into(),
473                category: "office_supplies".into(),
474                spend: dec!(50000),
475                country: "US".into(),
476            },
477            VendorSpendInput {
478                vendor_id: "V-002".into(),
479                category: "manufacturing".into(),
480                spend: dec!(200000),
481                country: "CN".into(),
482            },
483        ];
484
485        let config = EnvironmentalConfig::default();
486        let mut gen = EmissionGenerator::new(config, 42);
487        let records = gen.generate_scope3_purchased_goods(
488            "C001",
489            &vendor_spend,
490            d("2025-01-01"),
491            d("2025-12-31"),
492        );
493
494        assert_eq!(records.len(), 2);
495        assert!(records.iter().all(|r| r.scope == EmissionScope::Scope3));
496        assert!(records
497            .iter()
498            .all(|r| r.scope3_category == Some(Scope3Category::PurchasedGoods)));
499        // Higher spend + manufacturing + China multiplier → higher emissions
500        assert!(records[1].co2e_tonnes > records[0].co2e_tonnes);
501    }
502
503    #[test]
504    fn test_scope3_business_travel() {
505        let config = EnvironmentalConfig::default();
506        let mut gen = EmissionGenerator::new(config, 42);
507        let records = gen.generate_scope3_business_travel("C001", dec!(100000), d("2025-01-01"));
508
509        assert_eq!(records.len(), 1);
510        assert_eq!(
511            records[0].scope3_category,
512            Some(Scope3Category::BusinessTravel)
513        );
514        assert!(records[0].co2e_tonnes > Decimal::ZERO);
515    }
516
517    #[test]
518    fn test_scope3_commuting() {
519        let config = EnvironmentalConfig::default();
520        let mut gen = EmissionGenerator::new(config, 42);
521        let records = gen.generate_scope3_commuting("C001", 500, d("2025-06-01"));
522
523        assert_eq!(records.len(), 1);
524        assert_eq!(
525            records[0].scope3_category,
526            Some(Scope3Category::EmployeeCommuting)
527        );
528        // 500 employees × 2.5 t/yr / 12 ≈ 104 tonnes
529        assert!(records[0].co2e_tonnes > dec!(100));
530        assert!(records[0].co2e_tonnes < dec!(110));
531    }
532
533    #[test]
534    fn test_disabled_scope_produces_nothing() {
535        let mut config = EnvironmentalConfig::default();
536        config.scope1.enabled = false;
537
538        let energy_data = vec![EnergyInput {
539            facility_id: "F-001".into(),
540            energy_type: EnergyInputType::NaturalGas,
541            consumption_kwh: dec!(100000),
542            period: d("2025-01-01"),
543        }];
544
545        let mut gen = EmissionGenerator::new(config, 42);
546        let records = gen.generate_scope1("C001", &energy_data);
547        assert!(records.is_empty());
548    }
549
550    #[test]
551    fn test_deterministic_emissions() {
552        let energy_data = vec![EnergyInput {
553            facility_id: "F-001".into(),
554            energy_type: EnergyInputType::Diesel,
555            consumption_kwh: dec!(50000),
556            period: d("2025-01-01"),
557        }];
558
559        let config = EnvironmentalConfig::default();
560
561        let mut gen1 = EmissionGenerator::new(config.clone(), 42);
562        let r1 = gen1.generate_scope1("C001", &energy_data);
563
564        let mut gen2 = EmissionGenerator::new(config, 42);
565        let r2 = gen2.generate_scope1("C001", &energy_data);
566
567        assert_eq!(r1.len(), r2.len());
568        assert_eq!(r1[0].co2e_tonnes, r2[0].co2e_tonnes);
569    }
570
571    #[test]
572    fn test_zero_spend_scope3() {
573        let config = EnvironmentalConfig::default();
574        let mut gen = EmissionGenerator::new(config, 42);
575        let records = gen.generate_scope3_business_travel("C001", Decimal::ZERO, d("2025-01-01"));
576        assert!(records.is_empty());
577    }
578
579    #[test]
580    fn test_zero_headcount_commuting() {
581        let config = EnvironmentalConfig::default();
582        let mut gen = EmissionGenerator::new(config, 42);
583        let records = gen.generate_scope3_commuting("C001", 0, d("2025-01-01"));
584        assert!(records.is_empty());
585    }
586}