Skip to main content

datasynth_core/models/
esg.rs

1//! ESG (Environmental, Social, Governance) and Sustainability Reporting Models.
2//!
3//! Covers the GHG Protocol Scope 1/2/3 emissions, energy/water/waste tracking,
4//! workforce diversity, pay equity, safety, governance metrics, supply chain ESG
5//! assessments, and disclosure/assurance records.
6
7use std::collections::HashMap;
8
9use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12use serde::{Deserialize, Serialize};
13
14use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
15
16// ===========================================================================
17// Environmental — Emissions
18// ===========================================================================
19
20/// GHG Protocol emission scope.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum EmissionScope {
24    /// Direct emissions from owned/controlled sources
25    #[default]
26    Scope1,
27    /// Indirect emissions from purchased energy
28    Scope2,
29    /// All other indirect emissions in the value chain
30    Scope3,
31}
32
33/// GHG Protocol Scope 3 categories (15 categories).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
35#[serde(rename_all = "snake_case")]
36pub enum Scope3Category {
37    #[default]
38    PurchasedGoods,
39    CapitalGoods,
40    FuelAndEnergy,
41    UpstreamTransport,
42    WasteGenerated,
43    BusinessTravel,
44    EmployeeCommuting,
45    UpstreamLeased,
46    DownstreamTransport,
47    ProcessingOfSoldProducts,
48    UseOfSoldProducts,
49    EndOfLifeTreatment,
50    DownstreamLeased,
51    Franchises,
52    Investments,
53}
54
55/// Method used to estimate emissions.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
57#[serde(rename_all = "snake_case")]
58pub enum EstimationMethod {
59    /// Activity-based (consumption × emission factor)
60    #[default]
61    ActivityBased,
62    /// Spend-based (procurement spend × EEIO factor)
63    SpendBased,
64    /// Supplier-specific (primary data from supply chain)
65    SupplierSpecific,
66    /// Average-data approach
67    AverageData,
68}
69
70/// A single GHG emission record.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EmissionRecord {
73    pub id: String,
74    pub entity_id: String,
75    pub scope: EmissionScope,
76    pub scope3_category: Option<Scope3Category>,
77    pub facility_id: Option<String>,
78    pub period: NaiveDate,
79    pub activity_data: Option<String>,
80    pub activity_unit: Option<String>,
81    #[serde(with = "rust_decimal::serde::str_option")]
82    pub emission_factor: Option<Decimal>,
83    #[serde(with = "rust_decimal::serde::str")]
84    pub co2e_tonnes: Decimal,
85    pub estimation_method: EstimationMethod,
86    pub source: Option<String>,
87}
88
89// ===========================================================================
90// Environmental — Energy
91// ===========================================================================
92
93/// Type of energy source.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum EnergySourceType {
97    #[default]
98    Electricity,
99    NaturalGas,
100    Diesel,
101    Coal,
102    SolarPv,
103    WindOnshore,
104    Biomass,
105    Geothermal,
106}
107
108impl EnergySourceType {
109    /// Whether this energy source is renewable.
110    pub fn is_renewable(&self) -> bool {
111        matches!(
112            self,
113            Self::SolarPv | Self::WindOnshore | Self::Biomass | Self::Geothermal
114        )
115    }
116}
117
118/// Energy consumption record for a facility in a period.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct EnergyConsumption {
121    pub id: String,
122    pub entity_id: String,
123    pub facility_id: String,
124    pub period: NaiveDate,
125    pub energy_source: EnergySourceType,
126    #[serde(with = "rust_decimal::serde::str")]
127    pub consumption_kwh: Decimal,
128    #[serde(with = "rust_decimal::serde::str")]
129    pub cost: Decimal,
130    pub currency: String,
131    pub is_renewable: bool,
132}
133
134// ===========================================================================
135// Environmental — Water
136// ===========================================================================
137
138/// Water source type.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
140#[serde(rename_all = "snake_case")]
141pub enum WaterSource {
142    #[default]
143    Municipal,
144    Groundwater,
145    SurfaceWater,
146    Rainwater,
147    Recycled,
148}
149
150/// Water usage record for a facility.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct WaterUsage {
153    pub id: String,
154    pub entity_id: String,
155    pub facility_id: String,
156    pub period: NaiveDate,
157    pub source: WaterSource,
158    #[serde(with = "rust_decimal::serde::str")]
159    pub withdrawal_m3: Decimal,
160    #[serde(with = "rust_decimal::serde::str")]
161    pub discharge_m3: Decimal,
162    #[serde(with = "rust_decimal::serde::str")]
163    pub consumption_m3: Decimal,
164    pub is_water_stressed_area: bool,
165}
166
167impl WaterUsage {
168    /// Consumption = withdrawal − discharge.
169    pub fn computed_consumption(&self) -> Decimal {
170        (self.withdrawal_m3 - self.discharge_m3).max(Decimal::ZERO)
171    }
172}
173
174// ===========================================================================
175// Environmental — Waste
176// ===========================================================================
177
178/// Waste type.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
180#[serde(rename_all = "snake_case")]
181pub enum WasteType {
182    #[default]
183    General,
184    Hazardous,
185    Electronic,
186    Organic,
187    Construction,
188}
189
190/// Waste disposal method.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
192#[serde(rename_all = "snake_case")]
193pub enum DisposalMethod {
194    #[default]
195    Landfill,
196    Recycled,
197    Composted,
198    Incinerated,
199    Reused,
200}
201
202/// Waste generation record for a facility.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct WasteRecord {
205    pub id: String,
206    pub entity_id: String,
207    pub facility_id: String,
208    pub period: NaiveDate,
209    pub waste_type: WasteType,
210    pub disposal_method: DisposalMethod,
211    #[serde(with = "rust_decimal::serde::str")]
212    pub quantity_tonnes: Decimal,
213    pub is_diverted_from_landfill: bool,
214}
215
216impl WasteRecord {
217    /// Whether the waste was diverted from landfill.
218    pub fn computed_diversion(&self) -> bool {
219        !matches!(
220            self.disposal_method,
221            DisposalMethod::Landfill | DisposalMethod::Incinerated
222        )
223    }
224}
225
226// ===========================================================================
227// Social — Diversity
228// ===========================================================================
229
230/// Dimension of workforce diversity.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
232#[serde(rename_all = "snake_case")]
233pub enum DiversityDimension {
234    #[default]
235    Gender,
236    Ethnicity,
237    Age,
238    Disability,
239    VeteranStatus,
240}
241
242/// Organization level for metrics.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
244#[serde(rename_all = "snake_case")]
245pub enum OrganizationLevel {
246    #[default]
247    Corporate,
248    Department,
249    Team,
250    Executive,
251    Board,
252}
253
254/// Workforce diversity metric for a reporting period.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct WorkforceDiversityMetric {
257    pub id: String,
258    pub entity_id: String,
259    pub period: NaiveDate,
260    pub dimension: DiversityDimension,
261    pub level: OrganizationLevel,
262    pub category: String,
263    pub headcount: u32,
264    pub total_headcount: u32,
265    #[serde(with = "rust_decimal::serde::str")]
266    pub percentage: Decimal,
267}
268
269impl WorkforceDiversityMetric {
270    /// Computed percentage = headcount / total_headcount.
271    pub fn computed_percentage(&self) -> Decimal {
272        if self.total_headcount == 0 {
273            return Decimal::ZERO;
274        }
275        (Decimal::from(self.headcount) / Decimal::from(self.total_headcount)).round_dp(4)
276    }
277}
278
279// ===========================================================================
280// Social — Pay Equity
281// ===========================================================================
282
283/// Pay equity metric comparing compensation across groups.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct PayEquityMetric {
286    pub id: String,
287    pub entity_id: String,
288    pub period: NaiveDate,
289    pub dimension: DiversityDimension,
290    pub reference_group: String,
291    pub comparison_group: String,
292    #[serde(with = "rust_decimal::serde::str")]
293    pub reference_median_salary: Decimal,
294    #[serde(with = "rust_decimal::serde::str")]
295    pub comparison_median_salary: Decimal,
296    #[serde(with = "rust_decimal::serde::str")]
297    pub pay_gap_ratio: Decimal,
298    pub sample_size: u32,
299}
300
301impl PayEquityMetric {
302    /// Computed pay gap ratio = comparison / reference.
303    pub fn computed_pay_gap_ratio(&self) -> Decimal {
304        if self.reference_median_salary.is_zero() {
305            return dec!(1.00);
306        }
307        (self.comparison_median_salary / self.reference_median_salary).round_dp(4)
308    }
309}
310
311// ===========================================================================
312// Social — Safety
313// ===========================================================================
314
315/// Type of safety incident.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
317#[serde(rename_all = "snake_case")]
318pub enum IncidentType {
319    #[default]
320    Injury,
321    Illness,
322    NearMiss,
323    Fatality,
324    PropertyDamage,
325}
326
327/// Individual safety incident record.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SafetyIncident {
330    pub id: String,
331    pub entity_id: String,
332    pub facility_id: String,
333    pub date: NaiveDate,
334    pub incident_type: IncidentType,
335    pub days_away: u32,
336    pub is_recordable: bool,
337    pub description: String,
338}
339
340/// Aggregate safety metrics for a period.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct SafetyMetric {
343    pub id: String,
344    pub entity_id: String,
345    pub period: NaiveDate,
346    pub total_hours_worked: u64,
347    pub recordable_incidents: u32,
348    pub lost_time_incidents: u32,
349    pub days_away: u32,
350    pub near_misses: u32,
351    pub fatalities: u32,
352    #[serde(with = "rust_decimal::serde::str")]
353    pub trir: Decimal,
354    #[serde(with = "rust_decimal::serde::str")]
355    pub ltir: Decimal,
356    #[serde(with = "rust_decimal::serde::str")]
357    pub dart_rate: Decimal,
358}
359
360impl SafetyMetric {
361    /// TRIR (Total Recordable Incident Rate) = recordable × 200,000 / hours.
362    pub fn computed_trir(&self) -> Decimal {
363        if self.total_hours_worked == 0 {
364            return Decimal::ZERO;
365        }
366        (Decimal::from(self.recordable_incidents) * dec!(200000)
367            / Decimal::from(self.total_hours_worked))
368        .round_dp(4)
369    }
370
371    /// LTIR (Lost Time Incident Rate) = lost_time × 200,000 / hours.
372    pub fn computed_ltir(&self) -> Decimal {
373        if self.total_hours_worked == 0 {
374            return Decimal::ZERO;
375        }
376        (Decimal::from(self.lost_time_incidents) * dec!(200000)
377            / Decimal::from(self.total_hours_worked))
378        .round_dp(4)
379    }
380
381    /// DART (Days Away, Restricted, or Transferred) rate.
382    pub fn computed_dart_rate(&self) -> Decimal {
383        if self.total_hours_worked == 0 {
384            return Decimal::ZERO;
385        }
386        (Decimal::from(self.days_away) * dec!(200000) / Decimal::from(self.total_hours_worked))
387            .round_dp(4)
388    }
389}
390
391// ===========================================================================
392// Governance
393// ===========================================================================
394
395/// Governance metric for a reporting period.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct GovernanceMetric {
398    pub id: String,
399    pub entity_id: String,
400    pub period: NaiveDate,
401    pub board_size: u32,
402    pub independent_directors: u32,
403    pub female_directors: u32,
404    #[serde(with = "rust_decimal::serde::str")]
405    pub board_independence_ratio: Decimal,
406    #[serde(with = "rust_decimal::serde::str")]
407    pub board_gender_diversity_ratio: Decimal,
408    pub ethics_training_completion_pct: f64,
409    pub whistleblower_reports: u32,
410    pub anti_corruption_violations: u32,
411}
412
413impl GovernanceMetric {
414    /// Computed board independence = independent / total.
415    pub fn computed_independence_ratio(&self) -> Decimal {
416        if self.board_size == 0 {
417            return Decimal::ZERO;
418        }
419        (Decimal::from(self.independent_directors) / Decimal::from(self.board_size)).round_dp(4)
420    }
421}
422
423// ===========================================================================
424// Supply Chain ESG
425// ===========================================================================
426
427/// ESG risk flag for a supplier.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
429#[serde(rename_all = "snake_case")]
430pub enum EsgRiskFlag {
431    #[default]
432    Low,
433    Medium,
434    High,
435    Critical,
436}
437
438/// Method used for supplier ESG assessment.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
440#[serde(rename_all = "snake_case")]
441pub enum AssessmentMethod {
442    #[default]
443    SelfAssessment,
444    ThirdPartyAudit,
445    OnSiteAssessment,
446    DocumentReview,
447}
448
449/// Supplier ESG assessment record.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct SupplierEsgAssessment {
452    pub id: String,
453    pub entity_id: String,
454    pub vendor_id: String,
455    pub assessment_date: NaiveDate,
456    pub method: AssessmentMethod,
457    #[serde(with = "rust_decimal::serde::str")]
458    pub environmental_score: Decimal,
459    #[serde(with = "rust_decimal::serde::str")]
460    pub social_score: Decimal,
461    #[serde(with = "rust_decimal::serde::str")]
462    pub governance_score: Decimal,
463    #[serde(with = "rust_decimal::serde::str")]
464    pub overall_score: Decimal,
465    pub risk_flag: EsgRiskFlag,
466    pub corrective_actions_required: u32,
467}
468
469impl SupplierEsgAssessment {
470    /// Computed overall score = average of E, S, G.
471    pub fn computed_overall_score(&self) -> Decimal {
472        ((self.environmental_score + self.social_score + self.governance_score) / dec!(3))
473            .round_dp(2)
474    }
475}
476
477// ===========================================================================
478// Reporting & Disclosure
479// ===========================================================================
480
481/// ESG reporting framework.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum EsgFramework {
485    /// Global Reporting Initiative
486    #[default]
487    Gri,
488    /// European Sustainability Reporting Standards
489    Esrs,
490    /// Sustainability Accounting Standards Board
491    Sasb,
492    /// Task Force on Climate-related Financial Disclosures
493    Tcfd,
494    /// International Sustainability Standards Board
495    Issb,
496}
497
498/// Level of assurance for ESG data.
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
500#[serde(rename_all = "snake_case")]
501pub enum AssuranceLevel {
502    /// No external assurance
503    #[default]
504    None,
505    /// Limited assurance
506    Limited,
507    /// Reasonable assurance
508    Reasonable,
509}
510
511/// An ESG disclosure record.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct EsgDisclosure {
514    pub id: String,
515    pub entity_id: String,
516    pub reporting_period_start: NaiveDate,
517    pub reporting_period_end: NaiveDate,
518    pub framework: EsgFramework,
519    pub assurance_level: AssuranceLevel,
520    pub disclosure_topic: String,
521    pub metric_value: String,
522    pub metric_unit: String,
523    pub is_assured: bool,
524}
525
526/// Materiality assessment with double materiality.
527#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct MaterialityAssessment {
529    pub id: String,
530    pub entity_id: String,
531    pub period: NaiveDate,
532    pub topic: String,
533    /// Impact materiality (outward impact on environment/society)
534    #[serde(with = "rust_decimal::serde::str")]
535    pub impact_score: Decimal,
536    /// Financial materiality (inward impact on the enterprise)
537    #[serde(with = "rust_decimal::serde::str")]
538    pub financial_score: Decimal,
539    /// Combined score
540    #[serde(with = "rust_decimal::serde::str")]
541    pub combined_score: Decimal,
542    pub is_material: bool,
543}
544
545impl MaterialityAssessment {
546    /// Double materiality: material if either dimension exceeds threshold.
547    pub fn is_material_at_threshold(&self, threshold: Decimal) -> bool {
548        self.impact_score >= threshold || self.financial_score >= threshold
549    }
550}
551
552/// TCFD climate scenario type.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
554#[serde(rename_all = "snake_case")]
555pub enum ScenarioType {
556    /// Well-below 2°C (Paris-aligned)
557    #[default]
558    WellBelow2C,
559    /// Orderly transition
560    Orderly,
561    /// Disorderly transition
562    Disorderly,
563    /// Hot house world
564    HotHouse,
565}
566
567/// Time horizon for scenario analysis.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
569#[serde(rename_all = "snake_case")]
570pub enum TimeHorizon {
571    /// Short-term (1–3 years)
572    Short,
573    /// Medium-term (3–10 years)
574    #[default]
575    Medium,
576    /// Long-term (10–30 years)
577    Long,
578}
579
580/// A TCFD climate scenario analysis record.
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct ClimateScenario {
583    pub id: String,
584    pub entity_id: String,
585    pub scenario_type: ScenarioType,
586    pub time_horizon: TimeHorizon,
587    pub description: String,
588    #[serde(with = "rust_decimal::serde::str")]
589    pub temperature_rise_c: Decimal,
590    #[serde(with = "rust_decimal::serde::str")]
591    pub transition_risk_impact: Decimal,
592    #[serde(with = "rust_decimal::serde::str")]
593    pub physical_risk_impact: Decimal,
594    #[serde(with = "rust_decimal::serde::str")]
595    pub financial_impact: Decimal,
596}
597
598// ===========================================================================
599// ToNodeProperties implementations
600// ===========================================================================
601
602impl ToNodeProperties for EmissionRecord {
603    fn node_type_name(&self) -> &'static str {
604        "emission_record"
605    }
606    fn node_type_code(&self) -> u16 {
607        430
608    }
609    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
610        let mut p = HashMap::new();
611        p.insert(
612            "entityCode".into(),
613            GraphPropertyValue::String(self.entity_id.clone()),
614        );
615        p.insert(
616            "scope".into(),
617            GraphPropertyValue::String(format!("{:?}", self.scope)),
618        );
619        if let Some(ref cat) = self.scope3_category {
620            p.insert(
621                "scope3Category".into(),
622                GraphPropertyValue::String(format!("{:?}", cat)),
623            );
624        }
625        if let Some(ref fid) = self.facility_id {
626            p.insert("facilityId".into(), GraphPropertyValue::String(fid.clone()));
627        }
628        p.insert("period".into(), GraphPropertyValue::Date(self.period));
629        if let Some(ref ad) = self.activity_data {
630            p.insert(
631                "activityData".into(),
632                GraphPropertyValue::String(ad.clone()),
633            );
634        }
635        if let Some(ref au) = self.activity_unit {
636            p.insert(
637                "activityUnit".into(),
638                GraphPropertyValue::String(au.clone()),
639            );
640        }
641        if let Some(ef) = self.emission_factor {
642            p.insert("emissionFactor".into(), GraphPropertyValue::Decimal(ef));
643        }
644        p.insert(
645            "amount".into(),
646            GraphPropertyValue::Decimal(self.co2e_tonnes),
647        );
648        p.insert(
649            "dataQuality".into(),
650            GraphPropertyValue::String(format!("{:?}", self.estimation_method)),
651        );
652        if let Some(ref src) = self.source {
653            p.insert("source".into(), GraphPropertyValue::String(src.clone()));
654        }
655        p
656    }
657}
658
659impl ToNodeProperties for EnergyConsumption {
660    fn node_type_name(&self) -> &'static str {
661        "energy_consumption"
662    }
663    fn node_type_code(&self) -> u16 {
664        431
665    }
666    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
667        let mut p = HashMap::new();
668        p.insert(
669            "entityCode".into(),
670            GraphPropertyValue::String(self.entity_id.clone()),
671        );
672        p.insert(
673            "facilityId".into(),
674            GraphPropertyValue::String(self.facility_id.clone()),
675        );
676        p.insert("period".into(), GraphPropertyValue::Date(self.period));
677        p.insert(
678            "energySource".into(),
679            GraphPropertyValue::String(format!("{:?}", self.energy_source)),
680        );
681        p.insert(
682            "consumptionKwh".into(),
683            GraphPropertyValue::Decimal(self.consumption_kwh),
684        );
685        p.insert("cost".into(), GraphPropertyValue::Decimal(self.cost));
686        p.insert(
687            "currency".into(),
688            GraphPropertyValue::String(self.currency.clone()),
689        );
690        p.insert(
691            "isRenewable".into(),
692            GraphPropertyValue::Bool(self.is_renewable),
693        );
694        p
695    }
696}
697
698impl ToNodeProperties for WaterUsage {
699    fn node_type_name(&self) -> &'static str {
700        "water_usage"
701    }
702    fn node_type_code(&self) -> u16 {
703        432
704    }
705    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
706        let mut p = HashMap::new();
707        p.insert(
708            "entityCode".into(),
709            GraphPropertyValue::String(self.entity_id.clone()),
710        );
711        p.insert(
712            "facilityId".into(),
713            GraphPropertyValue::String(self.facility_id.clone()),
714        );
715        p.insert("period".into(), GraphPropertyValue::Date(self.period));
716        p.insert(
717            "source".into(),
718            GraphPropertyValue::String(format!("{:?}", self.source)),
719        );
720        p.insert(
721            "withdrawalM3".into(),
722            GraphPropertyValue::Decimal(self.withdrawal_m3),
723        );
724        p.insert(
725            "dischargeM3".into(),
726            GraphPropertyValue::Decimal(self.discharge_m3),
727        );
728        p.insert(
729            "consumptionM3".into(),
730            GraphPropertyValue::Decimal(self.consumption_m3),
731        );
732        p.insert(
733            "isWaterStressed".into(),
734            GraphPropertyValue::Bool(self.is_water_stressed_area),
735        );
736        p
737    }
738}
739
740impl ToNodeProperties for WasteRecord {
741    fn node_type_name(&self) -> &'static str {
742        "waste_record"
743    }
744    fn node_type_code(&self) -> u16 {
745        433
746    }
747    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
748        let mut p = HashMap::new();
749        p.insert(
750            "entityCode".into(),
751            GraphPropertyValue::String(self.entity_id.clone()),
752        );
753        p.insert(
754            "facilityId".into(),
755            GraphPropertyValue::String(self.facility_id.clone()),
756        );
757        p.insert("period".into(), GraphPropertyValue::Date(self.period));
758        p.insert(
759            "wasteType".into(),
760            GraphPropertyValue::String(format!("{:?}", self.waste_type)),
761        );
762        p.insert(
763            "disposalMethod".into(),
764            GraphPropertyValue::String(format!("{:?}", self.disposal_method)),
765        );
766        p.insert(
767            "quantityTonnes".into(),
768            GraphPropertyValue::Decimal(self.quantity_tonnes),
769        );
770        p.insert(
771            "isDivertedFromLandfill".into(),
772            GraphPropertyValue::Bool(self.is_diverted_from_landfill),
773        );
774        p
775    }
776}
777
778impl ToNodeProperties for WorkforceDiversityMetric {
779    fn node_type_name(&self) -> &'static str {
780        "workforce_diversity_metric"
781    }
782    fn node_type_code(&self) -> u16 {
783        434
784    }
785    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
786        let mut p = HashMap::new();
787        p.insert(
788            "entityCode".into(),
789            GraphPropertyValue::String(self.entity_id.clone()),
790        );
791        p.insert("period".into(), GraphPropertyValue::Date(self.period));
792        p.insert(
793            "dimension".into(),
794            GraphPropertyValue::String(format!("{:?}", self.dimension)),
795        );
796        p.insert(
797            "level".into(),
798            GraphPropertyValue::String(format!("{:?}", self.level)),
799        );
800        p.insert(
801            "category".into(),
802            GraphPropertyValue::String(self.category.clone()),
803        );
804        p.insert(
805            "headcount".into(),
806            GraphPropertyValue::Int(self.headcount as i64),
807        );
808        p.insert(
809            "totalHeadcount".into(),
810            GraphPropertyValue::Int(self.total_headcount as i64),
811        );
812        p.insert(
813            "percentage".into(),
814            GraphPropertyValue::Decimal(self.percentage),
815        );
816        p
817    }
818}
819
820impl ToNodeProperties for PayEquityMetric {
821    fn node_type_name(&self) -> &'static str {
822        "pay_equity_metric"
823    }
824    fn node_type_code(&self) -> u16 {
825        435
826    }
827    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
828        let mut p = HashMap::new();
829        p.insert(
830            "entityCode".into(),
831            GraphPropertyValue::String(self.entity_id.clone()),
832        );
833        p.insert("period".into(), GraphPropertyValue::Date(self.period));
834        p.insert(
835            "dimension".into(),
836            GraphPropertyValue::String(format!("{:?}", self.dimension)),
837        );
838        p.insert(
839            "referenceGroup".into(),
840            GraphPropertyValue::String(self.reference_group.clone()),
841        );
842        p.insert(
843            "comparisonGroup".into(),
844            GraphPropertyValue::String(self.comparison_group.clone()),
845        );
846        p.insert(
847            "referenceSalary".into(),
848            GraphPropertyValue::Decimal(self.reference_median_salary),
849        );
850        p.insert(
851            "comparisonSalary".into(),
852            GraphPropertyValue::Decimal(self.comparison_median_salary),
853        );
854        p.insert(
855            "payGapRatio".into(),
856            GraphPropertyValue::Decimal(self.pay_gap_ratio),
857        );
858        p.insert(
859            "sampleSize".into(),
860            GraphPropertyValue::Int(self.sample_size as i64),
861        );
862        p
863    }
864}
865
866impl ToNodeProperties for SafetyIncident {
867    fn node_type_name(&self) -> &'static str {
868        "safety_incident"
869    }
870    fn node_type_code(&self) -> u16 {
871        436
872    }
873    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
874        let mut p = HashMap::new();
875        p.insert(
876            "entityCode".into(),
877            GraphPropertyValue::String(self.entity_id.clone()),
878        );
879        p.insert(
880            "facilityId".into(),
881            GraphPropertyValue::String(self.facility_id.clone()),
882        );
883        p.insert("date".into(), GraphPropertyValue::Date(self.date));
884        p.insert(
885            "incidentType".into(),
886            GraphPropertyValue::String(format!("{:?}", self.incident_type)),
887        );
888        p.insert(
889            "daysAway".into(),
890            GraphPropertyValue::Int(self.days_away as i64),
891        );
892        p.insert(
893            "isRecordable".into(),
894            GraphPropertyValue::Bool(self.is_recordable),
895        );
896        p.insert(
897            "description".into(),
898            GraphPropertyValue::String(self.description.clone()),
899        );
900        p
901    }
902}
903
904impl ToNodeProperties for SafetyMetric {
905    fn node_type_name(&self) -> &'static str {
906        "safety_metric"
907    }
908    fn node_type_code(&self) -> u16 {
909        437
910    }
911    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
912        let mut p = HashMap::new();
913        p.insert(
914            "entityCode".into(),
915            GraphPropertyValue::String(self.entity_id.clone()),
916        );
917        p.insert("period".into(), GraphPropertyValue::Date(self.period));
918        p.insert(
919            "totalHoursWorked".into(),
920            GraphPropertyValue::Int(self.total_hours_worked as i64),
921        );
922        p.insert(
923            "recordableIncidents".into(),
924            GraphPropertyValue::Int(self.recordable_incidents as i64),
925        );
926        p.insert(
927            "lostTimeIncidents".into(),
928            GraphPropertyValue::Int(self.lost_time_incidents as i64),
929        );
930        p.insert(
931            "daysAway".into(),
932            GraphPropertyValue::Int(self.days_away as i64),
933        );
934        p.insert(
935            "nearMisses".into(),
936            GraphPropertyValue::Int(self.near_misses as i64),
937        );
938        p.insert(
939            "fatalities".into(),
940            GraphPropertyValue::Int(self.fatalities as i64),
941        );
942        p.insert("trir".into(), GraphPropertyValue::Decimal(self.trir));
943        p.insert("ltir".into(), GraphPropertyValue::Decimal(self.ltir));
944        p.insert(
945            "dartRate".into(),
946            GraphPropertyValue::Decimal(self.dart_rate),
947        );
948        p
949    }
950}
951
952impl ToNodeProperties for GovernanceMetric {
953    fn node_type_name(&self) -> &'static str {
954        "governance_metric"
955    }
956    fn node_type_code(&self) -> u16 {
957        438
958    }
959    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
960        let mut p = HashMap::new();
961        p.insert(
962            "entityCode".into(),
963            GraphPropertyValue::String(self.entity_id.clone()),
964        );
965        p.insert("period".into(), GraphPropertyValue::Date(self.period));
966        p.insert(
967            "boardSize".into(),
968            GraphPropertyValue::Int(self.board_size as i64),
969        );
970        p.insert(
971            "independentDirectors".into(),
972            GraphPropertyValue::Int(self.independent_directors as i64),
973        );
974        p.insert(
975            "femaleDirectors".into(),
976            GraphPropertyValue::Int(self.female_directors as i64),
977        );
978        p.insert(
979            "independenceRatio".into(),
980            GraphPropertyValue::Decimal(self.board_independence_ratio),
981        );
982        p.insert(
983            "genderDiversityRatio".into(),
984            GraphPropertyValue::Decimal(self.board_gender_diversity_ratio),
985        );
986        p.insert(
987            "ethicsTrainingPct".into(),
988            GraphPropertyValue::Float(self.ethics_training_completion_pct),
989        );
990        p.insert(
991            "whistleblowerReports".into(),
992            GraphPropertyValue::Int(self.whistleblower_reports as i64),
993        );
994        p.insert(
995            "antiCorruptionViolations".into(),
996            GraphPropertyValue::Int(self.anti_corruption_violations as i64),
997        );
998        p
999    }
1000}
1001
1002impl ToNodeProperties for SupplierEsgAssessment {
1003    fn node_type_name(&self) -> &'static str {
1004        "supplier_esg_assessment"
1005    }
1006    fn node_type_code(&self) -> u16 {
1007        439
1008    }
1009    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1010        let mut p = HashMap::new();
1011        p.insert(
1012            "entityCode".into(),
1013            GraphPropertyValue::String(self.entity_id.clone()),
1014        );
1015        p.insert(
1016            "vendorId".into(),
1017            GraphPropertyValue::String(self.vendor_id.clone()),
1018        );
1019        p.insert(
1020            "assessmentDate".into(),
1021            GraphPropertyValue::Date(self.assessment_date),
1022        );
1023        p.insert(
1024            "method".into(),
1025            GraphPropertyValue::String(format!("{:?}", self.method)),
1026        );
1027        p.insert(
1028            "environmentalScore".into(),
1029            GraphPropertyValue::Decimal(self.environmental_score),
1030        );
1031        p.insert(
1032            "socialScore".into(),
1033            GraphPropertyValue::Decimal(self.social_score),
1034        );
1035        p.insert(
1036            "governanceScore".into(),
1037            GraphPropertyValue::Decimal(self.governance_score),
1038        );
1039        p.insert(
1040            "overallScore".into(),
1041            GraphPropertyValue::Decimal(self.overall_score),
1042        );
1043        p.insert(
1044            "riskTier".into(),
1045            GraphPropertyValue::String(format!("{:?}", self.risk_flag)),
1046        );
1047        p.insert(
1048            "hasCorrectiveAction".into(),
1049            GraphPropertyValue::Bool(self.corrective_actions_required > 0),
1050        );
1051        p
1052    }
1053}
1054
1055impl ToNodeProperties for MaterialityAssessment {
1056    fn node_type_name(&self) -> &'static str {
1057        "materiality_assessment"
1058    }
1059    fn node_type_code(&self) -> u16 {
1060        440
1061    }
1062    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1063        let mut p = HashMap::new();
1064        p.insert(
1065            "entityCode".into(),
1066            GraphPropertyValue::String(self.entity_id.clone()),
1067        );
1068        p.insert("period".into(), GraphPropertyValue::Date(self.period));
1069        p.insert(
1070            "topic".into(),
1071            GraphPropertyValue::String(self.topic.clone()),
1072        );
1073        p.insert(
1074            "impactScore".into(),
1075            GraphPropertyValue::Decimal(self.impact_score),
1076        );
1077        p.insert(
1078            "financialScore".into(),
1079            GraphPropertyValue::Decimal(self.financial_score),
1080        );
1081        p.insert(
1082            "combinedScore".into(),
1083            GraphPropertyValue::Decimal(self.combined_score),
1084        );
1085        p.insert(
1086            "isMaterial".into(),
1087            GraphPropertyValue::Bool(self.is_material),
1088        );
1089        p
1090    }
1091}
1092
1093impl ToNodeProperties for EsgDisclosure {
1094    fn node_type_name(&self) -> &'static str {
1095        "esg_disclosure"
1096    }
1097    fn node_type_code(&self) -> u16 {
1098        441
1099    }
1100    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1101        let mut p = HashMap::new();
1102        p.insert(
1103            "entityCode".into(),
1104            GraphPropertyValue::String(self.entity_id.clone()),
1105        );
1106        p.insert(
1107            "framework".into(),
1108            GraphPropertyValue::String(format!("{:?}", self.framework)),
1109        );
1110        p.insert(
1111            "topic".into(),
1112            GraphPropertyValue::String(self.disclosure_topic.clone()),
1113        );
1114        p.insert(
1115            "periodStart".into(),
1116            GraphPropertyValue::Date(self.reporting_period_start),
1117        );
1118        p.insert(
1119            "periodEnd".into(),
1120            GraphPropertyValue::Date(self.reporting_period_end),
1121        );
1122        p.insert(
1123            "assuranceLevel".into(),
1124            GraphPropertyValue::String(format!("{:?}", self.assurance_level)),
1125        );
1126        p.insert(
1127            "metricValue".into(),
1128            GraphPropertyValue::String(self.metric_value.clone()),
1129        );
1130        p.insert(
1131            "metricUnit".into(),
1132            GraphPropertyValue::String(self.metric_unit.clone()),
1133        );
1134        p.insert(
1135            "isAssured".into(),
1136            GraphPropertyValue::Bool(self.is_assured),
1137        );
1138        p
1139    }
1140}
1141
1142impl ToNodeProperties for ClimateScenario {
1143    fn node_type_name(&self) -> &'static str {
1144        "climate_scenario"
1145    }
1146    fn node_type_code(&self) -> u16 {
1147        442
1148    }
1149    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1150        let mut p = HashMap::new();
1151        p.insert(
1152            "entityCode".into(),
1153            GraphPropertyValue::String(self.entity_id.clone()),
1154        );
1155        p.insert(
1156            "scenario".into(),
1157            GraphPropertyValue::String(format!("{:?}", self.scenario_type)),
1158        );
1159        p.insert(
1160            "timeHorizon".into(),
1161            GraphPropertyValue::String(format!("{:?}", self.time_horizon)),
1162        );
1163        p.insert(
1164            "description".into(),
1165            GraphPropertyValue::String(self.description.clone()),
1166        );
1167        p.insert(
1168            "warmingPathway".into(),
1169            GraphPropertyValue::Decimal(self.temperature_rise_c),
1170        );
1171        p.insert(
1172            "transitionRisk".into(),
1173            GraphPropertyValue::Decimal(self.transition_risk_impact),
1174        );
1175        p.insert(
1176            "physicalRisk".into(),
1177            GraphPropertyValue::Decimal(self.physical_risk_impact),
1178        );
1179        p.insert(
1180            "financialImpact".into(),
1181            GraphPropertyValue::Decimal(self.financial_impact),
1182        );
1183        p
1184    }
1185}
1186
1187// ===========================================================================
1188// Tests
1189// ===========================================================================
1190
1191#[cfg(test)]
1192#[allow(clippy::unwrap_used)]
1193mod tests {
1194    use super::*;
1195
1196    fn d(s: &str) -> NaiveDate {
1197        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
1198    }
1199
1200    // -- Emissions --
1201
1202    #[test]
1203    fn test_emission_record_serde_roundtrip() {
1204        let record = EmissionRecord {
1205            id: "EM-001".to_string(),
1206            entity_id: "C001".to_string(),
1207            scope: EmissionScope::Scope1,
1208            scope3_category: None,
1209            facility_id: Some("F-001".to_string()),
1210            period: d("2025-01-01"),
1211            activity_data: Some("100000 kWh".to_string()),
1212            activity_unit: Some("kWh".to_string()),
1213            emission_factor: Some(dec!(0.18)),
1214            co2e_tonnes: dec!(18),
1215            estimation_method: EstimationMethod::ActivityBased,
1216            source: Some("Natural gas combustion".to_string()),
1217        };
1218
1219        let json = serde_json::to_string(&record).unwrap();
1220        let deserialized: EmissionRecord = serde_json::from_str(&json).unwrap();
1221        assert_eq!(deserialized.co2e_tonnes, dec!(18));
1222        assert_eq!(deserialized.scope, EmissionScope::Scope1);
1223    }
1224
1225    #[test]
1226    fn test_emission_factor_calculation() {
1227        // activity_data * emission_factor = co2e
1228        let consumption_kwh = dec!(100000);
1229        let factor = dec!(0.18); // natural gas kg CO2e per kWh
1230        let co2e_kg = consumption_kwh * factor;
1231        let co2e_tonnes = co2e_kg / dec!(1000);
1232        assert_eq!(co2e_tonnes, dec!(18));
1233    }
1234
1235    // -- Energy --
1236
1237    #[test]
1238    fn test_energy_source_renewable() {
1239        assert!(EnergySourceType::SolarPv.is_renewable());
1240        assert!(EnergySourceType::WindOnshore.is_renewable());
1241        assert!(!EnergySourceType::NaturalGas.is_renewable());
1242        assert!(!EnergySourceType::Electricity.is_renewable());
1243    }
1244
1245    // -- Water --
1246
1247    #[test]
1248    fn test_water_consumption_formula() {
1249        let usage = WaterUsage {
1250            id: "W-001".to_string(),
1251            entity_id: "C001".to_string(),
1252            facility_id: "F-001".to_string(),
1253            period: d("2025-01-01"),
1254            source: WaterSource::Municipal,
1255            withdrawal_m3: dec!(5000),
1256            discharge_m3: dec!(3500),
1257            consumption_m3: dec!(1500),
1258            is_water_stressed_area: false,
1259        };
1260
1261        assert_eq!(usage.computed_consumption(), dec!(1500));
1262    }
1263
1264    // -- Waste --
1265
1266    #[test]
1267    fn test_waste_diversion() {
1268        let recycled = WasteRecord {
1269            id: "WS-001".to_string(),
1270            entity_id: "C001".to_string(),
1271            facility_id: "F-001".to_string(),
1272            period: d("2025-01-01"),
1273            waste_type: WasteType::General,
1274            disposal_method: DisposalMethod::Recycled,
1275            quantity_tonnes: dec!(100),
1276            is_diverted_from_landfill: true,
1277        };
1278        assert!(recycled.computed_diversion());
1279
1280        let landfill = WasteRecord {
1281            disposal_method: DisposalMethod::Landfill,
1282            ..recycled.clone()
1283        };
1284        assert!(!landfill.computed_diversion());
1285    }
1286
1287    // -- Safety --
1288
1289    #[test]
1290    fn test_trir_formula() {
1291        let metric = SafetyMetric {
1292            id: "SM-001".to_string(),
1293            entity_id: "C001".to_string(),
1294            period: d("2025-01-01"),
1295            total_hours_worked: 1_000_000,
1296            recordable_incidents: 5,
1297            lost_time_incidents: 2,
1298            days_away: 30,
1299            near_misses: 15,
1300            fatalities: 0,
1301            trir: dec!(1.0000),
1302            ltir: dec!(0.4000),
1303            dart_rate: dec!(6.0000),
1304        };
1305
1306        // TRIR = 5 * 200,000 / 1,000,000 = 1.0
1307        assert_eq!(metric.computed_trir(), dec!(1.0000));
1308        // LTIR = 2 * 200,000 / 1,000,000 = 0.4
1309        assert_eq!(metric.computed_ltir(), dec!(0.4000));
1310        // DART = 30 * 200,000 / 1,000,000 = 6.0
1311        assert_eq!(metric.computed_dart_rate(), dec!(6.0000));
1312    }
1313
1314    #[test]
1315    fn test_trir_zero_hours() {
1316        let metric = SafetyMetric {
1317            id: "SM-002".to_string(),
1318            entity_id: "C001".to_string(),
1319            period: d("2025-01-01"),
1320            total_hours_worked: 0,
1321            recordable_incidents: 0,
1322            lost_time_incidents: 0,
1323            days_away: 0,
1324            near_misses: 0,
1325            fatalities: 0,
1326            trir: Decimal::ZERO,
1327            ltir: Decimal::ZERO,
1328            dart_rate: Decimal::ZERO,
1329        };
1330        assert_eq!(metric.computed_trir(), Decimal::ZERO);
1331    }
1332
1333    // -- Diversity --
1334
1335    #[test]
1336    fn test_diversity_percentage() {
1337        let metric = WorkforceDiversityMetric {
1338            id: "WD-001".to_string(),
1339            entity_id: "C001".to_string(),
1340            period: d("2025-01-01"),
1341            dimension: DiversityDimension::Gender,
1342            level: OrganizationLevel::Corporate,
1343            category: "Female".to_string(),
1344            headcount: 450,
1345            total_headcount: 1000,
1346            percentage: dec!(0.4500),
1347        };
1348
1349        assert_eq!(metric.computed_percentage(), dec!(0.4500));
1350    }
1351
1352    // -- Pay Equity --
1353
1354    #[test]
1355    fn test_pay_gap_ratio() {
1356        let metric = PayEquityMetric {
1357            id: "PE-001".to_string(),
1358            entity_id: "C001".to_string(),
1359            period: d("2025-01-01"),
1360            dimension: DiversityDimension::Gender,
1361            reference_group: "Male".to_string(),
1362            comparison_group: "Female".to_string(),
1363            reference_median_salary: dec!(85000),
1364            comparison_median_salary: dec!(78000),
1365            pay_gap_ratio: dec!(0.9176),
1366            sample_size: 500,
1367        };
1368
1369        // 78000 / 85000 ≈ 0.9176
1370        assert_eq!(metric.computed_pay_gap_ratio(), dec!(0.9176));
1371    }
1372
1373    // -- Governance --
1374
1375    #[test]
1376    fn test_board_independence() {
1377        let metric = GovernanceMetric {
1378            id: "GOV-001".to_string(),
1379            entity_id: "C001".to_string(),
1380            period: d("2025-01-01"),
1381            board_size: 12,
1382            independent_directors: 8,
1383            female_directors: 4,
1384            board_independence_ratio: dec!(0.6667),
1385            board_gender_diversity_ratio: dec!(0.3333),
1386            ethics_training_completion_pct: 0.95,
1387            whistleblower_reports: 3,
1388            anti_corruption_violations: 0,
1389        };
1390
1391        assert_eq!(metric.computed_independence_ratio(), dec!(0.6667));
1392    }
1393
1394    // -- Supplier ESG --
1395
1396    #[test]
1397    fn test_supplier_esg_overall_score() {
1398        let assessment = SupplierEsgAssessment {
1399            id: "SEA-001".to_string(),
1400            entity_id: "C001".to_string(),
1401            vendor_id: "V-001".to_string(),
1402            assessment_date: d("2025-06-15"),
1403            method: AssessmentMethod::ThirdPartyAudit,
1404            environmental_score: dec!(75),
1405            social_score: dec!(80),
1406            governance_score: dec!(85),
1407            overall_score: dec!(80),
1408            risk_flag: EsgRiskFlag::Low,
1409            corrective_actions_required: 0,
1410        };
1411
1412        assert_eq!(assessment.computed_overall_score(), dec!(80.00));
1413    }
1414
1415    // -- Materiality --
1416
1417    #[test]
1418    fn test_materiality_double_threshold() {
1419        let assessment = MaterialityAssessment {
1420            id: "MA-001".to_string(),
1421            entity_id: "C001".to_string(),
1422            period: d("2025-01-01"),
1423            topic: "Climate Change".to_string(),
1424            impact_score: dec!(8.5),
1425            financial_score: dec!(6.0),
1426            combined_score: dec!(7.25),
1427            is_material: true,
1428        };
1429
1430        // Material if either dimension ≥ 7.0
1431        assert!(assessment.is_material_at_threshold(dec!(7.0)));
1432        // Not material if both need to be ≥ 9.0
1433        assert!(!assessment.is_material_at_threshold(dec!(9.0)));
1434    }
1435
1436    // -- Serde --
1437
1438    #[test]
1439    fn test_safety_metric_serde_roundtrip() {
1440        let metric = SafetyMetric {
1441            id: "SM-100".to_string(),
1442            entity_id: "C001".to_string(),
1443            period: d("2025-01-01"),
1444            total_hours_worked: 500_000,
1445            recordable_incidents: 3,
1446            lost_time_incidents: 1,
1447            days_away: 10,
1448            near_misses: 8,
1449            fatalities: 0,
1450            trir: dec!(1.2000),
1451            ltir: dec!(0.4000),
1452            dart_rate: dec!(4.0000),
1453        };
1454
1455        let json = serde_json::to_string(&metric).unwrap();
1456        let deserialized: SafetyMetric = serde_json::from_str(&json).unwrap();
1457        assert_eq!(deserialized.trir, dec!(1.2000));
1458        assert_eq!(deserialized.recordable_incidents, 3);
1459    }
1460
1461    #[test]
1462    fn test_climate_scenario_serde() {
1463        let scenario = ClimateScenario {
1464            id: "CS-001".to_string(),
1465            entity_id: "C001".to_string(),
1466            scenario_type: ScenarioType::WellBelow2C,
1467            time_horizon: TimeHorizon::Long,
1468            description: "Paris-aligned scenario".to_string(),
1469            temperature_rise_c: dec!(1.5),
1470            transition_risk_impact: dec!(-50000),
1471            physical_risk_impact: dec!(-10000),
1472            financial_impact: dec!(-60000),
1473        };
1474
1475        let json = serde_json::to_string(&scenario).unwrap();
1476        let deserialized: ClimateScenario = serde_json::from_str(&json).unwrap();
1477        assert_eq!(deserialized.scenario_type, ScenarioType::WellBelow2C);
1478        assert_eq!(deserialized.temperature_rise_c, dec!(1.5));
1479    }
1480}