1use 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#[derive(Debug, Clone)]
24pub struct EnergyInput {
25 pub facility_id: String,
26 pub energy_type: EnergyInputType,
27 pub consumption_kwh: Decimal,
29 pub period: NaiveDate,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EnergyInputType {
36 NaturalGas,
37 Diesel,
38 Coal,
39 Electricity,
40}
41
42#[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
51fn emission_factor_kg_per_kwh(energy_type: EnergyInputType) -> Decimal {
57 match energy_type {
58 EnergyInputType::NaturalGas => dec!(0.181), EnergyInputType::Diesel => dec!(0.253),
61 EnergyInputType::Coal => dec!(0.341),
62 EnergyInputType::Electricity => dec!(0.417), }
64}
65
66fn 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), };
80
81 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
94pub struct EmissionGenerator {
104 rng: ChaCha8Rng,
105 config: EnvironmentalConfig,
106 counter: u64,
107}
108
109impl EmissionGenerator {
110 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 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 let co2e_tonnes = (co2e_kg / dec!(1000)).round_dp(4);
143
144 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 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 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 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 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 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 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 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 if !matches!(
339 order.status,
340 ProductionOrderStatus::Completed | ProductionOrderStatus::Closed
341 ) {
342 continue;
343 }
344
345 let facility_id = if order.work_center.is_empty() {
348 order.company_code.clone()
349 } else {
350 order.work_center.clone()
351 };
352
353 let period = order.actual_end.unwrap_or(order.planned_end);
355
356 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 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 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 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 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}