oxigrid 0.1.1

Pure Rust Energy Systems Simulation & Optimization Library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
//! Carbon budget and emission trading market module.
//!
//! Implements:
//! - **Emission factors** by fuel type (CO₂, CH₄, N₂O, lifecycle CO₂e)
//! - **Emitting generators** with allowance positions and carbon cost
//! - **Carbon allowances** (EUA-like permits) across multiple ETS schemes
//! - **Carbon market** with supply/demand price dynamics, auctions, and forecasting
//! - **Carbon budget tracker** with optimal dispatch and scope 1/2/3 reporting
//! - **Grid emission intensity** (average and marginal rates)
//!
//! # Units
//! - Emissions: \[tonne CO₂e\] (metric tonnes)
//! - Carbon price: \[EUR/tonne\]
//! - Power: \[MW\], Energy: \[MWh\]
//! - Emission factors: \[kg CO₂e/MWh\]
//!
//! # References
//! - EU ETS Directive 2003/87/EC and amendments
//! - IPCC AR6 GWP100: CH₄ = 27.9, N₂O = 273 (we use classic AR5: 25/298 per spec)
//! - IEA Emission Factors 2023
//! - Ellerman, A.D. et al., "Pricing Carbon", Cambridge University Press, 2010
//! - ISO 14064-1:2018 Greenhouse Gas Accounting

use crate::error::OxiGridError;
use serde::{Deserialize, Serialize};

// ─────────────────────────────────────────────────────────────────────────────
// Re-export legacy types so they remain available from the market module
// ─────────────────────────────────────────────────────────────────────────────

pub use legacy::{
    AllocationMethod, AuctionBid, AuctionResult, CarbonBudgetConfig, CarbonDispatchResult,
    ComplianceStatus, GeneratorCarbonProfile, MultiYearCarbonPlan, ParetoPoint, PermitAllocation,
    PermitTransaction, TradingResult,
};

// ─────────────────────────────────────────────────────────────────────────────
// Legacy module — preserve existing public API surface
// ─────────────────────────────────────────────────────────────────────────────

pub mod legacy {
    use serde::{Deserialize, Serialize};

    /// Method used to allocate free emission permits to generators.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub enum AllocationMethod {
        /// Allocate based on historical emissions (grandfathering / free allocation).
        Grandfathering,
        /// Allocate based on emission intensity benchmark × capacity.
        Benchmarking,
        /// No free allocation — all permits sold at auction.
        Auctioning,
        /// Partial free allocation: `pct_free` fraction via grandfathering, rest auctioned.
        HybridAuction {
            /// Fraction of permits allocated for free (0.0–1.0).
            pct_free: f64,
        },
    }

    /// Carbon emission profile and cost data for a single generator.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct GeneratorCarbonProfile {
        pub unit_id: String,
        pub pmax_mw: f64,
        pub pmin_mw: f64,
        pub marginal_cost_per_mwh: f64,
        pub emission_rate_t_co2_per_mwh: f64,
        pub free_permits_t_co2: f64,
    }

    /// Configuration for the carbon budget / cap-and-trade system.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct CarbonBudgetConfig {
        pub total_budget_t_co2: f64,
        pub permit_price_per_t: f64,
        pub banking_allowed: bool,
        pub borrowing_allowed: bool,
        pub price_floor_per_t: f64,
        pub price_ceiling_per_t: f64,
        pub planning_horizon_years: usize,
    }

    impl Default for CarbonBudgetConfig {
        fn default() -> Self {
            Self {
                total_budget_t_co2: 1_000_000.0,
                permit_price_per_t: 50.0,
                banking_allowed: true,
                borrowing_allowed: false,
                price_floor_per_t: 20.0,
                price_ceiling_per_t: 200.0,
                planning_horizon_years: 10,
            }
        }
    }

    /// Record of a single permit transfer between entities.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct PermitTransaction {
        pub buyer: String,
        pub seller: String,
        pub quantity_t: f64,
        pub price_per_t: f64,
    }

    /// Permit allocation result for one generator.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct PermitAllocation {
        pub unit_id: String,
        pub allocated_t_co2: f64,
        pub method: String,
    }

    /// A bid submitted in a permit auction.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct AuctionBid {
        pub bidder_id: String,
        pub quantity_t: f64,
        pub price_per_t: f64,
    }

    /// Result of a uniform-price permit auction (legacy).
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct AuctionResult {
        pub clearing_price_per_t: f64,
        pub total_permits_sold: f64,
        pub revenue_usd: f64,
        pub unsold_permits: f64,
    }

    /// Result of carbon-constrained economic dispatch.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct CarbonDispatchResult {
        pub dispatch_mw: Vec<f64>,
        pub total_cost_usd: f64,
        pub total_emissions_t_co2: f64,
        pub permit_cost_usd: f64,
        pub permit_surplus_t: f64,
    }

    /// One point on the cost–emissions Pareto front.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct ParetoPoint {
        pub carbon_price: f64,
        pub total_cost_usd: f64,
        pub total_emissions_t: f64,
    }

    /// Multi-year carbon plan covering the planning horizon.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct MultiYearCarbonPlan {
        pub annual_dispatch: Vec<Vec<f64>>,
        pub annual_emissions: Vec<f64>,
        pub annual_permit_cost: Vec<f64>,
        pub banked_permits: Vec<f64>,
        pub total_npv_cost: f64,
    }

    /// Compliance status for a regulated entity.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct ComplianceStatus {
        pub net_permit_position_t: f64,
        pub compliant: bool,
        pub penalty_usd: f64,
        pub recommendation: String,
    }

    /// Result of a bilateral permit trading round.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct TradingResult {
        pub transactions: Vec<PermitTransaction>,
        pub total_volume_t: f64,
        pub clearing_price: f64,
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Carbon accounting period
// ─────────────────────────────────────────────────────────────────────────────

/// Carbon accounting period for budget tracking.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CarbonPeriod {
    /// Annual accounting period.
    Annual { year: u32 },
    /// Monthly accounting period.
    Monthly { year: u32, month: u8 },
    /// Daily accounting period.
    Daily { year: u32, month: u8, day: u8 },
}

impl CarbonPeriod {
    /// Returns a human-readable label for the period.
    pub fn label(&self) -> String {
        match self {
            CarbonPeriod::Annual { year } => format!("{}", year),
            CarbonPeriod::Monthly { year, month } => format!("{}-{:02}", year, month),
            CarbonPeriod::Daily { year, month, day } => {
                format!("{}-{:02}-{:02}", year, month, day)
            }
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Emission factor
// ─────────────────────────────────────────────────────────────────────────────

/// Emission intensity by fuel type.
///
/// All quantities in kg CO₂ (or equivalent) per MWh of electricity generated.
/// Global Warming Potentials (GWP100, AR5): CH₄ = 25, N₂O = 298.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmissionFactor {
    /// Fuel type label (e.g., "coal", "natural_gas").
    pub fuel_type: String,
    /// Direct CO₂ emissions \[kg CO₂/MWh\].
    pub co2_kg_per_mwh: f64,
    /// Direct methane (CH₄) emissions \[kg CH₄/MWh\].
    pub ch4_kg_per_mwh: f64,
    /// Direct nitrous oxide (N₂O) emissions \[kg N₂O/MWh\].
    pub n2o_kg_per_mwh: f64,
    /// Full lifecycle CO₂e including upstream activities \[kg CO₂e/MWh\].
    pub lifecycle_co2e_kg_per_mwh: f64,
}

/// GWP100 (AR5) for methane: 1 kg CH₄ = 25 kg CO₂e.
const GWP_CH4: f64 = 25.0;
/// GWP100 (AR5) for nitrous oxide: 1 kg N₂O = 298 kg CO₂e.
const GWP_N2O: f64 = 298.0;

impl EmissionFactor {
    /// Total CO₂ equivalent: CO₂ + 25·CH₄ + 298·N₂O \[kg CO₂e/MWh\].
    pub fn co2e_kg_per_mwh(&self) -> f64 {
        self.co2_kg_per_mwh + GWP_CH4 * self.ch4_kg_per_mwh + GWP_N2O * self.n2o_kg_per_mwh
    }

    /// Coal (hard coal / bituminous): ~820 kg CO₂e/MWh.
    ///
    /// High direct CO₂ due to high carbon content (~94 g C/MJ).
    pub fn coal() -> Self {
        Self {
            fuel_type: "coal".into(),
            co2_kg_per_mwh: 800.0,
            ch4_kg_per_mwh: 0.3,
            n2o_kg_per_mwh: 0.014,
            lifecycle_co2e_kg_per_mwh: 820.0,
        }
    }

    /// Natural gas (combined-cycle): ~490 kg CO₂e/MWh.
    ///
    /// Lower carbon content than coal; methane slip from upstream is significant.
    pub fn natural_gas() -> Self {
        Self {
            fuel_type: "natural_gas".into(),
            co2_kg_per_mwh: 400.0,
            ch4_kg_per_mwh: 3.5,
            n2o_kg_per_mwh: 0.002,
            lifecycle_co2e_kg_per_mwh: 490.0,
        }
    }

    /// Oil / diesel generation: ~650 kg CO₂e/MWh.
    pub fn oil() -> Self {
        Self {
            fuel_type: "oil".into(),
            co2_kg_per_mwh: 620.0,
            ch4_kg_per_mwh: 0.5,
            n2o_kg_per_mwh: 0.005,
            lifecycle_co2e_kg_per_mwh: 650.0,
        }
    }

    /// Nuclear (lifecycle): ~12 kg CO₂e/MWh.
    ///
    /// Near-zero operational emissions; lifecycle includes uranium enrichment.
    pub fn nuclear() -> Self {
        Self {
            fuel_type: "nuclear".into(),
            co2_kg_per_mwh: 0.0,
            ch4_kg_per_mwh: 0.0,
            n2o_kg_per_mwh: 0.0,
            lifecycle_co2e_kg_per_mwh: 12.0,
        }
    }

    /// Onshore wind (lifecycle): ~11 kg CO₂e/MWh.
    pub fn wind() -> Self {
        Self {
            fuel_type: "wind".into(),
            co2_kg_per_mwh: 0.0,
            ch4_kg_per_mwh: 0.0,
            n2o_kg_per_mwh: 0.0,
            lifecycle_co2e_kg_per_mwh: 11.0,
        }
    }

    /// Solar photovoltaic (lifecycle): ~45 kg CO₂e/MWh.
    ///
    /// Primarily from silicon purification and module manufacture.
    pub fn solar_pv() -> Self {
        Self {
            fuel_type: "solar_pv".into(),
            co2_kg_per_mwh: 0.0,
            ch4_kg_per_mwh: 0.0,
            n2o_kg_per_mwh: 0.0,
            lifecycle_co2e_kg_per_mwh: 45.0,
        }
    }

    /// Hydroelectric (reservoir): ~24 kg CO₂e/MWh.
    ///
    /// Includes methane from anaerobic decomposition in reservoir.
    pub fn hydro() -> Self {
        Self {
            fuel_type: "hydro".into(),
            co2_kg_per_mwh: 0.0,
            ch4_kg_per_mwh: 0.96, // ~24 kg CO₂e via GWP25
            n2o_kg_per_mwh: 0.0,
            lifecycle_co2e_kg_per_mwh: 24.0,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Emitting generator
// ─────────────────────────────────────────────────────────────────────────────

/// Generator with full emission characteristics for carbon market participation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmittingGenerator {
    /// Unique generator identifier.
    pub id: usize,
    /// Human-readable name.
    pub name: String,
    /// Installed capacity \[MW\].
    pub capacity_mw: f64,
    /// Emission factor for this generator.
    pub emission_factor: EmissionFactor,
    /// Free allowances allocated for the current compliance period \[tonne CO₂e\].
    pub allocated_allowances_ton: f64,
    /// Variable (fuel) cost \[EUR/MWh\].
    pub cost_per_mwh: f64,
    /// True if this generator is classified as renewable (zero direct emissions).
    pub is_renewable: bool,
}

impl EmittingGenerator {
    /// Emissions for given generation: `generation_mwh × kg/MWh / 1000` → \[tonne CO₂e\].
    pub fn compute_emissions_ton(&self, generation_mwh: f64) -> f64 {
        generation_mwh * self.emission_factor.co2e_kg_per_mwh() / 1_000.0
    }

    /// Net allowance position: allocated − emitted \[tonne CO₂e\].
    ///
    /// Positive = surplus (can sell). Negative = shortfall (must buy).
    pub fn allowance_position(&self, generation_mwh: f64) -> f64 {
        self.allocated_allowances_ton - self.compute_emissions_ton(generation_mwh)
    }

    /// Carbon cost (negative = revenue) at given carbon price \[EUR\].
    ///
    /// Shortfall × price = cost (positive). Surplus × price = revenue (negative).
    pub fn carbon_cost_eur(&self, generation_mwh: f64, carbon_price_eur_per_ton: f64) -> f64 {
        let position = self.allowance_position(generation_mwh);
        // Shortfall is positive cost; surplus is negative cost (= revenue)
        -position * carbon_price_eur_per_ton
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Emission scheme
// ─────────────────────────────────────────────────────────────────────────────

/// Emission trading scheme / regulatory context.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EmissionScheme {
    /// EU Emissions Trading System (ETS Phase IV).
    EuEts,
    /// UK Emissions Trading Scheme (post-Brexit).
    UkEts,
    /// China National ETS (power sector).
    ChinaEts,
    /// California Cap-and-Trade Program (AB 32 / SB 32).
    CaliforniaCap,
    /// Voluntary carbon offset market (Gold Standard, VCS, etc.).
    VoluntaryOffset {
        /// Certification standard name (e.g., "Gold Standard", "VCS").
        standard: String,
    },
    /// Internal carbon price set by a company for internal accounting.
    InternalPrice {
        /// Shadow price \[EUR/tonne\].
        company_price_eur_per_ton: f64,
    },
}

// ─────────────────────────────────────────────────────────────────────────────
// Carbon allowance (permit)
// ─────────────────────────────────────────────────────────────────────────────

/// A carbon allowance (EUA-like permit) granting the right to emit 1 tonne CO₂e.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarbonAllowance {
    /// Unique permit identifier (e.g., "EUA-2025-0001").
    pub permit_id: String,
    /// Year the allowance was issued.
    pub vintage_year: u32,
    /// Quantity of CO₂e covered \[tonne\].
    pub quantity_ton: f64,
    /// Emission trading scheme this allowance belongs to.
    pub scheme: EmissionScheme,
    /// True if independently certified / verified.
    pub is_certified: bool,
    /// Entity ID of the current owner.
    pub owner_id: String,
}

// ─────────────────────────────────────────────────────────────────────────────
// Carbon market
// ─────────────────────────────────────────────────────────────────────────────

/// Emission trading market with price dynamics, auctions, and forecasting.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarbonMarket {
    /// Regulatory / scheme context.
    pub scheme: EmissionScheme,
    /// Current spot price \[EUR/tonne\].
    pub current_price_eur_per_ton: f64,
    /// Minimum price floor (EU ETS MSR floor) \[EUR/tonne\].
    pub price_floor_eur_per_ton: f64,
    /// Maximum price ceiling (if price containment mechanism exists) \[EUR/tonne\].
    pub price_ceiling_eur_per_ton: f64,
    /// Total annual system cap \[million tonne CO₂e\].
    pub total_cap_million_ton: f64,
    /// Free allocations for the period \[million tonne CO₂e\].
    pub allocated_million_ton: f64,
    /// Allowances available for auction \[million tonne CO₂e\].
    pub auctioned_million_ton: f64,
    /// Maximum fraction of compliance obligation that offsets can cover (0–1).
    pub offset_limit_pct: f64,
    /// Historical price time-series: `(timestamp_unix, price_eur_per_ton)`.
    pub price_history: Vec<(f64, f64)>,
}

impl CarbonMarket {
    /// Create a new carbon market with sensible EU ETS defaults.
    pub fn new(scheme: EmissionScheme, initial_price: f64, total_cap_million_ton: f64) -> Self {
        let floor = match &scheme {
            EmissionScheme::EuEts => 20.0,
            EmissionScheme::UkEts => 22.0,
            _ => 0.0,
        };
        let ceiling = match &scheme {
            EmissionScheme::EuEts => 500.0,
            EmissionScheme::UkEts => 400.0,
            EmissionScheme::CaliforniaCap => 65.0,
            _ => f64::MAX,
        };
        let price = initial_price.max(floor);
        CarbonMarket {
            scheme,
            current_price_eur_per_ton: price.min(ceiling),
            price_floor_eur_per_ton: floor,
            price_ceiling_eur_per_ton: ceiling,
            total_cap_million_ton,
            allocated_million_ton: total_cap_million_ton * 0.43, // ~43% free allocation (EU ETS Phase IV)
            auctioned_million_ton: total_cap_million_ton * 0.57,
            offset_limit_pct: 0.10,
            price_history: vec![(0.0, price.min(ceiling))],
        }
    }

    /// Update market price using a simple supply–demand model.
    ///
    /// If emissions > cap: shortage fraction drives price up.
    /// If emissions < cap: surplus fraction drives price down.
    /// Price is clamped to `[price_floor, price_ceiling]`.
    ///
    /// # Arguments
    /// - `total_emissions_million_ton` — verified emissions for the period
    /// - `price_elasticity` — % price change per % imbalance (default 2.0)
    pub fn update_price(&mut self, total_emissions_million_ton: f64, price_elasticity: f64) {
        let cap = self.total_cap_million_ton;
        if cap <= 0.0 {
            return;
        }
        let imbalance_fraction = (total_emissions_million_ton - cap) / cap;
        let pct_change = price_elasticity * imbalance_fraction;
        let new_price = self.current_price_eur_per_ton * (1.0 + pct_change);
        self.current_price_eur_per_ton = new_price
            .max(self.price_floor_eur_per_ton)
            .min(self.price_ceiling_eur_per_ton);

        // Record in history with a simple sequential timestamp
        let next_ts = self
            .price_history
            .last()
            .map(|(t, _)| t + 1.0)
            .unwrap_or(0.0);
        self.price_history
            .push((next_ts, self.current_price_eur_per_ton));
    }

    /// Execute a bilateral trade: buyer receives `quantity_ton` allowances;
    /// seller receives payment at the current market price.
    ///
    /// Returns the total EUR cost of the trade.
    ///
    /// # Errors
    /// Returns `OxiGridError` if quantity is non-positive or buyer/seller are the same entity.
    pub fn execute_trade(
        &mut self,
        buyer_id: &str,
        seller_id: &str,
        quantity_ton: f64,
    ) -> Result<f64, OxiGridError> {
        if quantity_ton <= 0.0 {
            return Err(OxiGridError::InvalidParameter(
                "Trade quantity must be positive".into(),
            ));
        }
        if buyer_id == seller_id {
            return Err(OxiGridError::InvalidParameter(
                "Buyer and seller must be different entities".into(),
            ));
        }
        let total_eur = quantity_ton * self.current_price_eur_per_ton;
        Ok(total_eur)
    }

    /// Conduct a uniform-price permit auction.
    ///
    /// Bids sorted descending by max price; lowest accepted bid sets the clearing price.
    /// Returns `Vec<(winner_id, allocated_ton, paid_eur)>`.
    ///
    /// # Arguments
    /// - `bids` — `(bidder_id, quantity_ton, max_price_eur_per_ton)`
    pub fn conduct_auction(&self, bids: &[(String, f64, f64)]) -> Vec<(String, f64, f64)> {
        if bids.is_empty() {
            return vec![];
        }

        // Sort bids descending by max price
        let mut sorted: Vec<&(String, f64, f64)> = bids.iter().collect();
        sorted.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));

        // Total supply = auctioned amount (convert from million tonne to tonne)
        let supply_ton = self.auctioned_million_ton * 1_000_000.0;
        let mut remaining = supply_ton;
        let mut clearing_price = 0.0_f64;

        // Determine clearing price (lowest price that clears supply)
        let mut allocations: Vec<(String, f64)> = Vec::new();
        for bid in &sorted {
            if remaining <= 0.0 {
                break;
            }
            let allocated = bid.1.min(remaining);
            remaining -= allocated;
            clearing_price = bid.2;
            allocations.push((bid.0.clone(), allocated));
        }

        // All winners pay the uniform clearing price
        allocations
            .into_iter()
            .map(|(id, qty)| {
                let paid = qty * clearing_price;
                (id, qty, paid)
            })
            .collect()
    }

    /// Forecast carbon price using trend + mean-reversion model.
    ///
    /// `P(t+1) = P(t) × (1 + trend) + reversion × (long_run_mean − P(t))`
    ///
    /// Long-run mean is estimated as the midpoint of `[floor, min(ceiling, 3×current)]`.
    ///
    /// # Arguments
    /// - `horizon_years` — number of years to forecast
    /// - `annual_trend_pct` — annual drift (e.g., 0.05 = +5%/year)
    /// - `mean_reversion_speed` — Ornstein-Uhlenbeck κ (0 = no reversion, 1 = fast)
    ///
    /// # Returns
    /// `Vec<(year, forecast_price)>` with `year` counting from 1.
    pub fn forecast_price(
        &self,
        horizon_years: f64,
        annual_trend_pct: f64,
        mean_reversion_speed: f64,
    ) -> Vec<(f64, f64)> {
        let steps = horizon_years.ceil() as usize;
        if steps == 0 {
            return vec![];
        }

        // Long-run equilibrium price
        let ceiling_cap = self
            .price_ceiling_eur_per_ton
            .min(3.0 * self.current_price_eur_per_ton);
        let long_run_mean = 0.5 * (self.price_floor_eur_per_ton + ceiling_cap);

        let mut result = Vec::with_capacity(steps);
        let mut price = self.current_price_eur_per_ton;
        let kappa = mean_reversion_speed.clamp(0.0, 1.0);

        for step in 1..=steps {
            let year = step as f64;
            // Trend component
            let trend_component = price * annual_trend_pct;
            // Mean-reversion pull
            let reversion_component = kappa * (long_run_mean - price);
            price += trend_component + reversion_component;
            // Clamp to floor/ceiling
            price = price
                .max(self.price_floor_eur_per_ton)
                .min(self.price_ceiling_eur_per_ton);
            result.push((year, price));
        }
        result
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Budget action recommendation
// ─────────────────────────────────────────────────────────────────────────────

/// Recommended action based on carbon budget status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BudgetAction {
    /// On track — no action required.
    Continue,
    /// Curtail high-emission generators by the given amount \[MW\].
    ReduceProduction { mw: f64 },
    /// Purchase additional allowances to cover projected shortfall.
    PurchaseAllowances {
        /// Tonnes to purchase.
        ton: f64,
        /// Estimated EUR cost at current carbon price.
        estimated_cost_eur: f64,
    },
    /// Replace fossil generation with renewable capacity \[MW\].
    IncreaseRenewable { mw: f64 },
    /// Budget already exceeded — accept compliance penalty.
    NoAction,
}

// ─────────────────────────────────────────────────────────────────────────────
// Budget status
// ─────────────────────────────────────────────────────────────────────────────

/// Snapshot of the current carbon budget status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetStatus {
    /// Total budget for the period \[tonne CO₂e\].
    pub budget_ton: f64,
    /// Cumulative emissions to date \[tonne CO₂e\].
    pub emissions_ton: f64,
    /// Budget remaining \[tonne CO₂e\] = `budget - emissions`.
    pub remaining_budget_ton: f64,
    /// Fraction of budget consumed (0–1+).
    pub pct_budget_used: f64,
    /// Extrapolated end-of-period emissions at the current rate \[tonne CO₂e\].
    pub projected_end_of_period_ton: f64,
    /// True if the extrapolation exceeds the budget.
    pub will_exceed_budget: bool,
    /// Allowance surplus (positive) or deficit (negative) \[tonne CO₂e\].
    pub allowance_surplus_deficit_ton: f64,
    /// Recommended management action.
    pub recommended_action: BudgetAction,
}

// ─────────────────────────────────────────────────────────────────────────────
// Scope 1/2/3 emissions report
// ─────────────────────────────────────────────────────────────────────────────

/// ISO 14064-1 scope emissions breakdown.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeEmissionsReport {
    /// Scope 1 — direct combustion emissions \[tonne CO₂e\].
    pub scope1_ton: f64,
    /// Scope 2 — purchased electricity (zero for generators) \[tonne CO₂e\].
    pub scope2_ton: f64,
    /// Scope 3 — value-chain (upstream fuel, equipment manufacture) \[tonne CO₂e\].
    pub scope3_ton: f64,
    /// Total across all scopes \[tonne CO₂e\].
    pub total_ton: f64,
    /// Emission intensity \[kg CO₂e/MWh\].
    pub intensity_kg_per_mwh: f64,
    /// Renewable fraction of total generation \[%\].
    pub renewable_fraction_pct: f64,
    /// CO₂e avoided vs. a 100% coal baseline \[tonne CO₂e\].
    pub co2e_avoided_vs_baseline_ton: f64,
}

// ─────────────────────────────────────────────────────────────────────────────
// Carbon budget tracker
// ─────────────────────────────────────────────────────────────────────────────

/// Tracks carbon emissions, allowances, and budget for a fleet of generators.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarbonBudgetTracker {
    /// Registered generators in the fleet.
    pub generators: Vec<EmittingGenerator>,
    /// Accounting period for this budget.
    pub budget_period: CarbonPeriod,
    /// Total CO₂e budget for the period \[tonne CO₂e\].
    pub total_budget_ton: f64,
    /// Cumulative emissions recorded so far \[tonne CO₂e\].
    pub emissions_to_date_ton: f64,
    /// Current allowances held by the entity \[tonne CO₂e\].
    pub allowances_held_ton: f64,
    /// Cumulative generation per generator (indexed by `EmittingGenerator.id`) \[MWh\].
    generation_log: Vec<f64>,
}

impl CarbonBudgetTracker {
    /// Create a new tracker for a fleet of generators.
    ///
    /// # Arguments
    /// - `generators`       — fleet of emitting generators
    /// - `budget_period`    — accounting period
    /// - `total_budget_ton` — total CO₂e budget for the period \[tonne\]
    pub fn new(
        generators: Vec<EmittingGenerator>,
        budget_period: CarbonPeriod,
        total_budget_ton: f64,
    ) -> Self {
        let n = generators.len();
        let allowances_held: f64 = generators.iter().map(|g| g.allocated_allowances_ton).sum();
        Self {
            generators,
            budget_period,
            total_budget_ton,
            emissions_to_date_ton: 0.0,
            allowances_held_ton: allowances_held,
            generation_log: vec![0.0; n],
        }
    }

    /// Record actual generation and accumulate emissions.
    ///
    /// # Arguments
    /// - `generator_id`  — the `EmittingGenerator.id` field
    /// - `generation_mwh` — energy generated this interval \[MWh\]
    ///
    /// # Returns
    /// Emissions from this interval \[tonne CO₂e\].
    ///
    /// # Errors
    /// Returns `OxiGridError` if the generator ID is not found in the fleet.
    pub fn record_generation(
        &mut self,
        generator_id: usize,
        generation_mwh: f64,
    ) -> Result<f64, OxiGridError> {
        let idx = self
            .generators
            .iter()
            .position(|g| g.id == generator_id)
            .ok_or_else(|| {
                OxiGridError::InvalidParameter(format!(
                    "Generator id {} not found in fleet",
                    generator_id
                ))
            })?;

        let emissions = self.generators[idx].compute_emissions_ton(generation_mwh);
        self.emissions_to_date_ton += emissions;
        self.generation_log[idx] += generation_mwh;
        Ok(emissions)
    }

    /// Compute current carbon budget status and recommend an action.
    ///
    /// # Arguments
    /// - `fraction_of_period_elapsed` — how far through the accounting period (0–1)
    /// - `carbon_price`               — current carbon price \[EUR/tonne\]
    pub fn budget_status(
        &self,
        fraction_of_period_elapsed: f64,
        carbon_price: f64,
    ) -> BudgetStatus {
        let fraction = fraction_of_period_elapsed.clamp(1e-9, 1.0);
        let projected = if fraction > 0.0 {
            self.emissions_to_date_ton / fraction
        } else {
            self.emissions_to_date_ton
        };
        let remaining = self.total_budget_ton - self.emissions_to_date_ton;
        let pct_used = if self.total_budget_ton > 0.0 {
            self.emissions_to_date_ton / self.total_budget_ton
        } else {
            0.0
        };
        let will_exceed = projected > self.total_budget_ton;
        let surplus_deficit = self.allowances_held_ton - self.emissions_to_date_ton;

        // Shortfall of allowances vs projected total emissions
        let projected_deficit = (projected - self.allowances_held_ton).max(0.0);

        let action = if !will_exceed && surplus_deficit >= 0.0 {
            BudgetAction::Continue
        } else if projected_deficit > 0.0 && fraction < 0.9 {
            // There is still time to act: recommend purchasing allowances
            let cost = projected_deficit * carbon_price;
            BudgetAction::PurchaseAllowances {
                ton: projected_deficit,
                estimated_cost_eur: cost,
            }
        } else if will_exceed {
            // Already over: estimate how much fossil generation to curtail
            let excess = projected - self.total_budget_ton;
            // Find average emission intensity of fossil generators [tonne/MWh]
            let avg_fossil_intensity: f64 = {
                let fossils: Vec<f64> = self
                    .generators
                    .iter()
                    .filter(|g| !g.is_renewable)
                    .map(|g| g.emission_factor.co2e_kg_per_mwh() / 1_000.0)
                    .collect();
                if fossils.is_empty() {
                    1.0 // fallback
                } else {
                    fossils.iter().sum::<f64>() / fossils.len() as f64
                }
            };
            if avg_fossil_intensity > 0.0 {
                let curtail_mwh = excess / avg_fossil_intensity;
                // Scale from total-period MWh to average MW (assume 8760 h/yr)
                let curtail_mw = curtail_mwh / 8_760.0;
                BudgetAction::ReduceProduction { mw: curtail_mw }
            } else {
                BudgetAction::NoAction
            }
        } else {
            BudgetAction::Continue
        };

        BudgetStatus {
            budget_ton: self.total_budget_ton,
            emissions_ton: self.emissions_to_date_ton,
            remaining_budget_ton: remaining,
            pct_budget_used: pct_used,
            projected_end_of_period_ton: projected,
            will_exceed_budget: will_exceed,
            allowance_surplus_deficit_ton: surplus_deficit,
            recommended_action: action,
        }
    }

    /// Optimal dispatch considering carbon cost.
    ///
    /// Adds `carbon_price × emission_factor_tonne_per_mwh` to each generator's
    /// fuel cost, then ranks generators by total effective cost (merit order).
    ///
    /// # Returns
    /// `Vec<(generator_id, dispatch_mw)>` in dispatch order.
    pub fn carbon_adjusted_dispatch(
        &self,
        total_demand_mw: f64,
        carbon_price_eur_per_ton: f64,
    ) -> Vec<(usize, f64)> {
        // Build (index, effective_cost) pairs
        let mut order: Vec<(usize, f64)> = self
            .generators
            .iter()
            .enumerate()
            .map(|(idx, g)| {
                let carbon_cost_per_mwh =
                    carbon_price_eur_per_ton * g.emission_factor.co2e_kg_per_mwh() / 1_000.0;
                let effective_cost = g.cost_per_mwh + carbon_cost_per_mwh;
                (idx, effective_cost)
            })
            .collect();

        // Sort by ascending effective cost
        order.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));

        let mut remaining = total_demand_mw;
        let mut result = Vec::new();

        for (idx, _cost) in &order {
            if remaining <= 0.0 {
                break;
            }
            let gen = &self.generators[*idx];
            let dispatch = gen.capacity_mw.min(remaining);
            remaining -= dispatch;
            result.push((gen.id, dispatch));
        }

        result
    }

    /// Estimate the marginal abatement cost (MAC) of switching generation from one
    /// generator to another.
    ///
    /// `MAC = (cost_to - cost_from) / (emission_from - emission_to)` \[EUR/tonne CO₂e\].
    ///
    /// A positive MAC means abatement is costly; negative means it is profitable.
    ///
    /// # Arguments
    /// - `from_generator` — generator being displaced (higher emissions)
    /// - `to_generator`   — replacement generator (lower emissions)
    ///
    /// # Errors
    /// Returns `OxiGridError` if either generator ID is not found, or if the
    /// emission intensities are identical (no abatement, MAC undefined).
    pub fn marginal_abatement_cost(
        &self,
        from_generator: usize,
        to_generator: usize,
    ) -> Result<f64, OxiGridError> {
        let from = self
            .generators
            .iter()
            .find(|g| g.id == from_generator)
            .ok_or_else(|| {
                OxiGridError::InvalidParameter(format!(
                    "from_generator {} not found",
                    from_generator
                ))
            })?;

        let to = self
            .generators
            .iter()
            .find(|g| g.id == to_generator)
            .ok_or_else(|| {
                OxiGridError::InvalidParameter(format!("to_generator {} not found", to_generator))
            })?;

        // Convert kg/MWh → tonne/MWh
        let emission_from = from.emission_factor.co2e_kg_per_mwh() / 1_000.0;
        let emission_to = to.emission_factor.co2e_kg_per_mwh() / 1_000.0;
        let delta_emission = emission_from - emission_to; // [tonne CO₂e/MWh]

        if delta_emission.abs() < 1e-12 {
            return Err(OxiGridError::InvalidParameter(
                "Generators have identical emission intensities; MAC is undefined".into(),
            ));
        }

        let delta_cost = to.cost_per_mwh - from.cost_per_mwh; // [EUR/MWh]
                                                              // MAC [EUR/tonne] = delta_cost [EUR/MWh] / delta_emission [tonne/MWh]
        Ok(delta_cost / delta_emission)
    }

    /// Compute scope 1, 2, 3 emissions report for the fleet.
    ///
    /// # Arguments
    /// - `generation_mwh` — energy generated per generator (same order as `self.generators`)
    ///
    /// # Scope definitions (ISO 14064-1):
    /// - **Scope 1**: Direct CO₂/CH₄/N₂O from combustion (`co2e_kg_per_mwh`)
    /// - **Scope 2**: Purchased electricity — zero for electricity generators
    /// - **Scope 3**: Lifecycle upstream (module manufacture, fuel extraction)
    ///   = `lifecycle_co2e - direct_co2e`
    pub fn scope_emissions_report(&self, generation_mwh: &[f64]) -> ScopeEmissionsReport {
        let n = self.generators.len().min(generation_mwh.len());

        let mut scope1 = 0.0_f64;
        let mut scope3 = 0.0_f64;
        let mut total_gen = 0.0_f64;
        let mut renewable_gen = 0.0_f64;
        let mut coal_baseline_emissions = 0.0_f64;

        let coal_factor = EmissionFactor::coal();
        let coal_intensity = coal_factor.co2e_kg_per_mwh() / 1_000.0; // tonne/MWh

        for (gen, &mwh) in self.generators.iter().zip(generation_mwh.iter()).take(n) {
            let direct_tonne = gen.emission_factor.co2e_kg_per_mwh() * mwh / 1_000.0;
            let lifecycle_tonne = gen.emission_factor.lifecycle_co2e_kg_per_mwh * mwh / 1_000.0;

            scope1 += direct_tonne;
            // Scope 3 = lifecycle minus direct combustion
            scope3 += (lifecycle_tonne - direct_tonne).max(0.0);

            total_gen += mwh;
            if gen.is_renewable {
                renewable_gen += mwh;
            }
            // What the same MWh would have emitted from coal
            coal_baseline_emissions += mwh * coal_intensity;
        }

        let total_ton = scope1 + scope3; // scope2 = 0
        let intensity = if total_gen > 0.0 {
            total_ton * 1_000.0 / total_gen // kg/MWh
        } else {
            0.0
        };
        let renewable_pct = if total_gen > 0.0 {
            100.0 * renewable_gen / total_gen
        } else {
            0.0
        };
        let avoided = (coal_baseline_emissions - total_ton).max(0.0);

        ScopeEmissionsReport {
            scope1_ton: scope1,
            scope2_ton: 0.0,
            scope3_ton: scope3,
            total_ton,
            intensity_kg_per_mwh: intensity,
            renewable_fraction_pct: renewable_pct,
            co2e_avoided_vs_baseline_ton: avoided,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Grid emission intensity
// ─────────────────────────────────────────────────────────────────────────────

/// Instantaneous grid emission intensity (average and marginal).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridEmissionIntensity {
    /// Timestamp (e.g., Unix epoch seconds).
    pub timestamp: f64,
    /// Dispatch-weighted average emission intensity \[kg CO₂e/MWh\].
    pub average_intensity_kg_per_mwh: f64,
    /// Emission rate of the next (most expensive) marginal unit \[kg CO₂e/MWh\].
    pub marginal_intensity_kg_per_mwh: f64,
    /// Renewable fraction of total dispatched output \[%\].
    pub renewable_fraction_pct: f64,
    /// Dispatchable (non-renewable) fraction of dispatched output \[%\].
    pub dispatchable_fraction_pct: f64,
}

impl GridEmissionIntensity {
    /// Compute from a dispatch vector.
    ///
    /// # Arguments
    /// - `generators` — generator fleet (same order as `dispatch_mw`)
    /// - `dispatch_mw` — current dispatch \[MW\]
    pub fn from_dispatch(
        generators: &[EmittingGenerator],
        dispatch_mw: &[f64],
    ) -> GridEmissionIntensity {
        let n = generators.len().min(dispatch_mw.len());
        let mut total_mw = 0.0_f64;
        let mut weighted_intensity = 0.0_f64;
        let mut renewable_mw = 0.0_f64;

        for i in 0..n {
            let mw = dispatch_mw[i];
            if mw <= 0.0 {
                continue;
            }
            let intensity = generators[i].emission_factor.co2e_kg_per_mwh();
            weighted_intensity += mw * intensity;
            total_mw += mw;
            if generators[i].is_renewable {
                renewable_mw += mw;
            }
        }

        let avg_intensity = if total_mw > 0.0 {
            weighted_intensity / total_mw
        } else {
            0.0
        };

        let marginal = Self::marginal_rate(generators, dispatch_mw);

        let renewable_pct = if total_mw > 0.0 {
            100.0 * renewable_mw / total_mw
        } else {
            0.0
        };
        let dispatchable_pct = 100.0 - renewable_pct;

        GridEmissionIntensity {
            timestamp: 0.0,
            average_intensity_kg_per_mwh: avg_intensity,
            marginal_intensity_kg_per_mwh: marginal,
            renewable_fraction_pct: renewable_pct,
            dispatchable_fraction_pct: dispatchable_pct,
        }
    }

    /// Marginal emission rate: the emission factor of the last dispatched generator.
    ///
    /// "Last" is defined as the generator with the highest fuel cost among those
    /// with dispatch > 0. If no generators are dispatched, returns 0.
    pub fn marginal_rate(generators: &[EmittingGenerator], dispatch_mw: &[f64]) -> f64 {
        let n = generators.len().min(dispatch_mw.len());

        // Find the dispatched generator with the highest cost per MWh (the price-setter)
        let marginal_gen = (0..n).filter(|&i| dispatch_mw[i] > 1e-9).max_by(|&a, &b| {
            generators[a]
                .cost_per_mwh
                .partial_cmp(&generators[b].cost_per_mwh)
                .unwrap_or(std::cmp::Ordering::Equal)
        });

        marginal_gen
            .map(|i| generators[i].emission_factor.co2e_kg_per_mwh())
            .unwrap_or(0.0)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ─── Helpers ────────────────────────────────────────────────────────────

    fn make_coal_gen() -> EmittingGenerator {
        EmittingGenerator {
            id: 0,
            name: "Coal Plant".into(),
            capacity_mw: 200.0,
            emission_factor: EmissionFactor::coal(),
            allocated_allowances_ton: 50_000.0,
            cost_per_mwh: 35.0,
            is_renewable: false,
        }
    }

    fn make_gas_gen() -> EmittingGenerator {
        EmittingGenerator {
            id: 1,
            name: "CCGT".into(),
            capacity_mw: 150.0,
            emission_factor: EmissionFactor::natural_gas(),
            allocated_allowances_ton: 20_000.0,
            cost_per_mwh: 55.0,
            is_renewable: false,
        }
    }

    fn make_wind_gen() -> EmittingGenerator {
        EmittingGenerator {
            id: 2,
            name: "Wind Farm".into(),
            capacity_mw: 100.0,
            emission_factor: EmissionFactor::wind(),
            allocated_allowances_ton: 0.0,
            cost_per_mwh: 5.0,
            is_renewable: true,
        }
    }

    fn make_solar_gen() -> EmittingGenerator {
        EmittingGenerator {
            id: 3,
            name: "Solar PV".into(),
            capacity_mw: 80.0,
            emission_factor: EmissionFactor::solar_pv(),
            allocated_allowances_ton: 0.0,
            cost_per_mwh: 3.0,
            is_renewable: true,
        }
    }

    fn make_tracker() -> CarbonBudgetTracker {
        CarbonBudgetTracker::new(
            vec![make_coal_gen(), make_gas_gen(), make_wind_gen()],
            CarbonPeriod::Annual { year: 2025 },
            200_000.0, // 200 kt CO₂e annual budget
        )
    }

    fn make_market() -> CarbonMarket {
        CarbonMarket::new(EmissionScheme::EuEts, 65.0, 1_500.0)
    }

    // ─── EmissionFactor tests ────────────────────────────────────────────────

    #[test]
    fn test_emission_factor_coal() {
        let coal = EmissionFactor::coal();
        assert_eq!(coal.fuel_type, "coal");
        assert!(
            coal.co2_kg_per_mwh > 700.0,
            "Coal CO₂ should be >700 kg/MWh"
        );
        assert!(
            coal.lifecycle_co2e_kg_per_mwh >= 800.0,
            "Coal lifecycle should be >=800 kg CO₂e/MWh"
        );
    }

    #[test]
    fn test_emission_factor_co2e() {
        let gas = EmissionFactor::natural_gas();
        let co2e = gas.co2e_kg_per_mwh();
        // Must include CH₄ GWP contribution (3.5 × 25 = 87.5)
        assert!(
            co2e > gas.co2_kg_per_mwh,
            "CO₂e must exceed direct CO₂: {:.2} vs {:.2}",
            co2e,
            gas.co2_kg_per_mwh
        );
        // Verify formula: CO₂ + 25*CH₄ + 298*N₂O
        let expected = gas.co2_kg_per_mwh + 25.0 * gas.ch4_kg_per_mwh + 298.0 * gas.n2o_kg_per_mwh;
        assert!(
            (co2e - expected).abs() < 1e-9,
            "CO₂e formula mismatch: {:.4} vs {:.4}",
            co2e,
            expected
        );
    }

    #[test]
    fn test_emission_factor_wind_low() {
        let wind = EmissionFactor::wind();
        // Direct emissions are zero; lifecycle is ~11 kg CO₂e/MWh
        assert!(
            wind.co2e_kg_per_mwh() < 1.0,
            "Wind direct CO₂e should be near zero: {:.4}",
            wind.co2e_kg_per_mwh()
        );
        assert!(
            wind.lifecycle_co2e_kg_per_mwh < 20.0,
            "Wind lifecycle CO₂e should be <20 kg/MWh: {:.1}",
            wind.lifecycle_co2e_kg_per_mwh
        );
    }

    // ─── EmittingGenerator tests ─────────────────────────────────────────────

    #[test]
    fn test_emitting_generator_emissions() {
        let coal = make_coal_gen();
        // 1 MWh coal at ~800 kg CO₂e/MWh → ~0.8 tonne
        let emissions = coal.compute_emissions_ton(1.0);
        assert!(
            (emissions - coal.emission_factor.co2e_kg_per_mwh() / 1_000.0).abs() < 1e-9,
            "Emissions per MWh mismatch: {:.6}",
            emissions
        );
    }

    #[test]
    fn test_emitting_generator_allowance_surplus() {
        let coal = make_coal_gen();
        // Allocated = 50,000 t; generate 10,000 MWh → ~8,000 t emissions → surplus
        let surplus = coal.allowance_position(10_000.0);
        assert!(
            surplus > 0.0,
            "Should have allowance surplus for modest generation: {:.2}",
            surplus
        );
    }

    #[test]
    fn test_emitting_generator_allowance_deficit() {
        let coal = make_coal_gen();
        // Generate at full capacity for entire year: 200 MW × 8760 h = 1,752,000 MWh
        // Emissions ≈ 1,752,000 × 0.8/1000 = 1,401.6 kt >> 50 kt allocation
        let deficit = coal.allowance_position(1_752_000.0);
        assert!(
            deficit < 0.0,
            "Should have allowance deficit at full-year generation: {:.2}",
            deficit
        );
    }

    #[test]
    fn test_carbon_cost_with_price() {
        let coal = make_coal_gen();
        let price = 80.0; // EUR/t
        let mwh = 1_000.0;
        // With 50,000 t allocated and ~800 t emitted → surplus → negative cost (revenue)
        let cost = coal.carbon_cost_eur(mwh, price);
        let position = coal.allowance_position(mwh);
        let expected = -position * price;
        assert!(
            (cost - expected).abs() < 1e-6,
            "Carbon cost mismatch: {:.4} vs {:.4}",
            cost,
            expected
        );
    }

    // ─── CarbonMarket tests ──────────────────────────────────────────────────

    #[test]
    fn test_carbon_market_creation() {
        let market = make_market();
        assert_eq!(market.total_cap_million_ton, 1_500.0);
        assert!(
            market.current_price_eur_per_ton >= market.price_floor_eur_per_ton,
            "Price must be >= floor"
        );
        assert!(
            market.current_price_eur_per_ton <= market.price_ceiling_eur_per_ton,
            "Price must be <= ceiling"
        );
    }

    #[test]
    fn test_carbon_market_price_update_shortage() {
        let mut market = make_market();
        let initial_price = market.current_price_eur_per_ton;
        // Emit 10% more than cap → shortage → price should rise
        let excess_emissions = market.total_cap_million_ton * 1.10;
        market.update_price(excess_emissions, 2.0);
        assert!(
            market.current_price_eur_per_ton > initial_price,
            "Price should rise on shortage: {:.2} → {:.2}",
            initial_price,
            market.current_price_eur_per_ton
        );
    }

    #[test]
    fn test_carbon_market_price_update_surplus() {
        let mut market = make_market();
        let initial_price = market.current_price_eur_per_ton;
        // Emit 20% less than cap → surplus → price should fall
        let low_emissions = market.total_cap_million_ton * 0.80;
        market.update_price(low_emissions, 2.0);
        assert!(
            market.current_price_eur_per_ton < initial_price,
            "Price should fall on surplus: {:.2} → {:.2}",
            initial_price,
            market.current_price_eur_per_ton
        );
    }

    #[test]
    fn test_execute_trade() {
        let mut market = make_market();
        let cost = market
            .execute_trade("company_a", "company_b", 500.0)
            .expect("trade should succeed");
        let expected = 500.0 * market.current_price_eur_per_ton;
        assert!(
            (cost - expected).abs() < 1e-6,
            "Trade cost mismatch: {:.2} vs {:.2}",
            cost,
            expected
        );
    }

    #[test]
    fn test_conduct_auction_basic() {
        let market = make_market();
        let bids = vec![
            ("company_a".into(), 100_000.0, 70.0_f64),
            ("company_b".into(), 200_000.0, 60.0_f64),
            ("company_c".into(), 50_000.0, 80.0_f64),
        ];
        let winners = market.conduct_auction(&bids);
        // All winners should have been allocated something
        assert!(!winners.is_empty(), "Auction should produce winners");
        for (id, qty, paid) in &winners {
            assert!(*qty > 0.0, "Winner {} should get positive quantity", id);
            assert!(*paid > 0.0, "Winner {} should pay positive amount", id);
        }
    }

    #[test]
    fn test_price_forecast_trend() {
        let market = make_market();
        // Positive trend: price should increase over time
        let forecast = market.forecast_price(5.0, 0.05, 0.0);
        assert_eq!(forecast.len(), 5, "Should forecast 5 years");
        let (_, p1) = forecast[0];
        let (_, p5) = forecast[4];
        assert!(
            p5 > p1,
            "With positive trend, year-5 price should exceed year-1: {:.2} vs {:.2}",
            p5,
            p1
        );
        // All prices should be within floor/ceiling
        for (yr, price) in &forecast {
            assert!(
                *price >= market.price_floor_eur_per_ton,
                "Year {:.0} price {:.2} below floor",
                yr,
                price
            );
            assert!(
                *price <= market.price_ceiling_eur_per_ton,
                "Year {:.0} price {:.2} above ceiling",
                yr,
                price
            );
        }
    }

    // ─── CarbonBudgetTracker tests ───────────────────────────────────────────

    #[test]
    fn test_budget_tracker_creation() {
        let tracker = make_tracker();
        assert_eq!(tracker.generators.len(), 3);
        assert_eq!(tracker.total_budget_ton, 200_000.0);
        assert!(tracker.emissions_to_date_ton < 1e-9);
        // Allowances should sum allocated amounts from coal + gas + wind
        let expected_allowances: f64 = tracker
            .generators
            .iter()
            .map(|g| g.allocated_allowances_ton)
            .sum();
        assert!(
            (tracker.allowances_held_ton - expected_allowances).abs() < 1e-6,
            "Allowances held should equal sum of allocated: {:.2} vs {:.2}",
            tracker.allowances_held_ton,
            expected_allowances
        );
    }

    #[test]
    fn test_record_generation() {
        let mut tracker = make_tracker();
        // Record 1000 MWh from coal generator (id=0)
        let emissions = tracker
            .record_generation(0, 1_000.0)
            .expect("should succeed");
        // Emissions = 1000 × coal_co2e_kg / 1000 → tonnes
        let expected = make_coal_gen().compute_emissions_ton(1_000.0);
        assert!(
            (emissions - expected).abs() < 1e-9,
            "Recorded emissions mismatch: {:.4} vs {:.4}",
            emissions,
            expected
        );
        assert!(
            (tracker.emissions_to_date_ton - expected).abs() < 1e-9,
            "Cumulative emissions not updated correctly"
        );
    }

    #[test]
    fn test_budget_status_on_track() {
        let mut tracker = make_tracker();
        // Generate 5000 MWh from coal at half-way point → should be on track
        // 5000 MWh × 800 kg/MWh / 1000 = 4000 t → extrapolated 8000 t << 200,000 t budget
        tracker.record_generation(0, 5_000.0).expect("ok");
        let status = tracker.budget_status(0.5, 65.0);
        assert!(
            !status.will_exceed_budget,
            "Should be on track for modest generation"
        );
        assert!(status.pct_budget_used < 0.5, "Should use < 50% of budget");
    }

    #[test]
    fn test_budget_status_over_budget() {
        let mut tracker = make_tracker();
        // Record a massive amount of coal generation that will exceed the budget
        // Budget = 200,000 t; coal = ~800 kg/MWh; need > 250,000 MWh to exceed
        // Let's record 400,000 MWh at 10% elapsed → projected = 4M MWh → way over budget
        tracker.record_generation(0, 400_000.0).expect("ok");
        let status = tracker.budget_status(0.10, 65.0);
        assert!(
            status.will_exceed_budget,
            "Should flag budget exceedance: projected {:.0} t vs budget {:.0} t",
            status.projected_end_of_period_ton, status.budget_ton
        );
    }

    #[test]
    fn test_carbon_adjusted_dispatch() {
        let tracker = make_tracker();
        // At high carbon price, renewable (wind, id=2) should come first
        // Wind cost = 5 EUR/MWh + 0 carbon; Coal cost = 35 + high carbon
        let dispatch = tracker.carbon_adjusted_dispatch(200.0, 200.0);

        // First dispatched generator should be wind (lowest effective cost)
        let first_gen_id = dispatch.first().map(|(id, _)| *id).unwrap_or(99);
        assert_eq!(
            first_gen_id, 2,
            "At high carbon price, wind (id=2) should be dispatched first, got {}",
            first_gen_id
        );

        // Total dispatched should equal demand
        let total: f64 = dispatch.iter().map(|(_, mw)| mw).sum();
        assert!(
            (total - 200.0).abs() < 1e-6 || total <= 200.0 + 1e-6,
            "Dispatch total should match demand: {:.2}",
            total
        );
    }

    #[test]
    fn test_marginal_abatement_cost() {
        let tracker = make_tracker();
        // Switching from coal (id=0, high emission) to gas (id=1, lower emission)
        let mac = tracker
            .marginal_abatement_cost(0, 1)
            .expect("MAC should be computable");
        // Coal ~800 kg/MWh → 0.8 t/MWh; Gas ~490 kg/MWh direct + GWP
        // The switch costs more per MWh (gas costs 55 vs coal 35) but saves emissions
        // Positive MAC = it costs money to switch from coal to gas
        assert!(mac.is_finite(), "MAC should be a finite number: {:.4}", mac);
    }

    #[test]
    fn test_scope_emissions_report() {
        let tracker = make_tracker();
        // 1000 MWh from each generator
        let generation = vec![1_000.0, 1_000.0, 1_000.0];
        let report = tracker.scope_emissions_report(&generation);

        assert!(
            report.scope1_ton > 0.0,
            "Scope 1 should be positive (coal+gas)"
        );
        assert!(
            report.scope2_ton.abs() < 1e-9,
            "Scope 2 should be zero for generators"
        );
        assert!(report.scope3_ton >= 0.0, "Scope 3 should be non-negative");
        assert!(
            (report.total_ton - report.scope1_ton - report.scope2_ton - report.scope3_ton).abs()
                < 1e-6,
            "Total should equal sum of scopes"
        );
        // Renewable fraction: 1000 MWh wind out of 3000 MWh total = ~33%
        assert!(
            (report.renewable_fraction_pct - 100.0 / 3.0).abs() < 1.0,
            "Renewable fraction should be ~33%: {:.2}%",
            report.renewable_fraction_pct
        );
        // CO₂e avoided vs coal baseline should be positive (gas and wind are cleaner)
        assert!(
            report.co2e_avoided_vs_baseline_ton > 0.0,
            "CO₂e avoided should be positive: {:.2}",
            report.co2e_avoided_vs_baseline_ton
        );
    }

    // ─── GridEmissionIntensity tests ─────────────────────────────────────────

    #[test]
    fn test_grid_emission_intensity() {
        let generators = vec![make_coal_gen(), make_wind_gen()];
        // 100 MW coal, 50 MW wind
        let dispatch = vec![100.0, 50.0];
        let gei = GridEmissionIntensity::from_dispatch(&generators, &dispatch);

        // Average should be between wind (0) and coal (~800)
        assert!(
            gei.average_intensity_kg_per_mwh > 0.0,
            "Average intensity should be positive with coal in mix"
        );
        assert!(
            gei.average_intensity_kg_per_mwh < EmissionFactor::coal().co2e_kg_per_mwh(),
            "Average should be less than pure coal intensity"
        );

        // Renewable fraction: 50/(100+50) = 33.3%
        assert!(
            (gei.renewable_fraction_pct - 100.0 / 3.0).abs() < 1.0,
            "Renewable fraction {:.2}% should be ~33%",
            gei.renewable_fraction_pct
        );
        assert!(
            (gei.dispatchable_fraction_pct + gei.renewable_fraction_pct - 100.0).abs() < 1e-6,
            "Dispatchable + renewable should = 100%"
        );
    }

    #[test]
    fn test_marginal_emission_rate() {
        let generators = vec![make_coal_gen(), make_gas_gen(), make_wind_gen()];
        // Wind is cheapest (5 EUR/MWh); coal and gas also dispatched
        // Marginal unit = highest cost unit dispatched = gas (55 EUR/MWh)
        let dispatch = vec![100.0, 80.0, 50.0];
        let marginal = GridEmissionIntensity::marginal_rate(&generators, &dispatch);
        // Gas emission intensity
        let gas_intensity = EmissionFactor::natural_gas().co2e_kg_per_mwh();
        assert!(
            (marginal - gas_intensity).abs() < 1e-6,
            "Marginal rate should be gas intensity ({:.2}), got {:.2}",
            gas_intensity,
            marginal
        );
    }

    // ─── Additional edge-case tests ──────────────────────────────────────────

    #[test]
    fn test_execute_trade_invalid_same_entity() {
        let mut market = make_market();
        let result = market.execute_trade("same", "same", 100.0);
        assert!(
            result.is_err(),
            "Trade with same buyer and seller should fail"
        );
    }

    #[test]
    fn test_execute_trade_invalid_zero_quantity() {
        let mut market = make_market();
        let result = market.execute_trade("a", "b", 0.0);
        assert!(result.is_err(), "Trade with zero quantity should fail");
    }

    #[test]
    fn test_record_generation_invalid_id() {
        let mut tracker = make_tracker();
        let result = tracker.record_generation(999, 100.0);
        assert!(result.is_err(), "Unknown generator id should return error");
    }

    #[test]
    fn test_carbon_period_label() {
        assert_eq!(CarbonPeriod::Annual { year: 2025 }.label(), "2025");
        assert_eq!(
            CarbonPeriod::Monthly {
                year: 2025,
                month: 3
            }
            .label(),
            "2025-03"
        );
        assert_eq!(
            CarbonPeriod::Daily {
                year: 2025,
                month: 3,
                day: 9
            }
            .label(),
            "2025-03-09"
        );
    }

    #[test]
    fn test_mac_undefined_same_intensity() {
        // Two identical generators (same emission intensity)
        let tracker = CarbonBudgetTracker::new(
            vec![
                EmittingGenerator {
                    id: 10,
                    name: "A".into(),
                    capacity_mw: 100.0,
                    emission_factor: EmissionFactor::coal(),
                    allocated_allowances_ton: 0.0,
                    cost_per_mwh: 30.0,
                    is_renewable: false,
                },
                EmittingGenerator {
                    id: 11,
                    name: "B".into(),
                    capacity_mw: 100.0,
                    emission_factor: EmissionFactor::coal(),
                    allocated_allowances_ton: 0.0,
                    cost_per_mwh: 40.0,
                    is_renewable: false,
                },
            ],
            CarbonPeriod::Annual { year: 2025 },
            100_000.0,
        );
        let result = tracker.marginal_abatement_cost(10, 11);
        assert!(
            result.is_err(),
            "MAC should be undefined for identical emission intensities"
        );
    }

    #[test]
    fn test_emission_factors_ordering() {
        // Lifecycle CO₂e ordering: coal > oil > gas > solar > hydro > nuclear ≈ wind
        let coal = EmissionFactor::coal().lifecycle_co2e_kg_per_mwh;
        let oil = EmissionFactor::oil().lifecycle_co2e_kg_per_mwh;
        let gas = EmissionFactor::natural_gas().lifecycle_co2e_kg_per_mwh;
        let solar = EmissionFactor::solar_pv().lifecycle_co2e_kg_per_mwh;
        let hydro = EmissionFactor::hydro().lifecycle_co2e_kg_per_mwh;
        let nuclear = EmissionFactor::nuclear().lifecycle_co2e_kg_per_mwh;
        let wind = EmissionFactor::wind().lifecycle_co2e_kg_per_mwh;

        assert!(
            coal > oil,
            "Coal lifecycle > oil: {:.0} vs {:.0}",
            coal,
            oil
        );
        assert!(oil > gas, "Oil lifecycle > gas: {:.0} vs {:.0}", oil, gas);
        assert!(
            gas > solar,
            "Gas lifecycle > solar: {:.0} vs {:.0}",
            gas,
            solar
        );
        assert!(
            solar > hydro,
            "Solar lifecycle > hydro: {:.0} vs {:.0}",
            solar,
            hydro
        );
        assert!(
            hydro > nuclear,
            "Hydro lifecycle > nuclear: {:.0} vs {:.0}",
            hydro,
            nuclear
        );
        assert!(
            nuclear > wind || (nuclear - wind).abs() < 5.0,
            "Nuclear and wind lifecycle should be close: {:.0} vs {:.0}",
            nuclear,
            wind
        );
    }

    #[test]
    fn test_scope_report_all_renewable() {
        let tracker = CarbonBudgetTracker::new(
            vec![make_wind_gen(), make_solar_gen()],
            CarbonPeriod::Annual { year: 2025 },
            5_000.0,
        );
        let generation = vec![1_000.0, 1_000.0];
        let report = tracker.scope_emissions_report(&generation);
        assert!(
            report.scope1_ton.abs() < 1e-9,
            "All-renewable fleet has zero scope 1: {:.6}",
            report.scope1_ton
        );
        assert_eq!(
            report.renewable_fraction_pct as u32, 100,
            "All-renewable fraction should be 100%"
        );
        assert!(
            report.co2e_avoided_vs_baseline_ton > 0.0,
            "Renewables should avoid significant CO₂e vs coal baseline"
        );
    }
}