use thiserror::Error;
#[derive(Debug, Error)]
pub enum CarbonForecastError {
#[error("generator fleet is empty")]
EmptyFleet,
#[error("dispatch slice is empty — cannot compute average intensity")]
EmptyDispatch,
#[error("load profile length {load} ≠ renewable profile length {renewable}")]
ProfileLengthMismatch { load: usize, renewable: usize },
#[error("demand {demand_mw:.2} MW exceeds total fleet capacity {capacity_mw:.2} MW")]
DemandExceedsCapacity { demand_mw: f64, capacity_mw: f64 },
#[error("forecast/actual slice is empty — cannot compute skill score")]
EmptyForecastSlice,
#[error("forecast length {forecast} ≠ actual length {actual}")]
ForecastLengthMismatch { forecast: usize, actual: usize },
#[error("no historical CI observations available for climatology")]
NoHistory,
#[error("unit '{0}' not found in generator fleet")]
UnitNotFound(String),
#[error("total dispatched power is zero — cannot compute average intensity")]
ZeroTotalPower,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FuelType {
Coal,
NaturalGas,
Oil,
Nuclear,
Hydro,
Wind,
Solar,
Biomass,
Geothermal,
Storage,
}
impl FuelType {
pub fn is_zero_emission(&self) -> bool {
matches!(
self,
FuelType::Nuclear
| FuelType::Hydro
| FuelType::Wind
| FuelType::Solar
| FuelType::Geothermal
| FuelType::Storage
)
}
}
#[derive(Debug, Clone)]
pub struct GeneratorEmission {
pub unit_id: String,
pub fuel_type: FuelType,
pub capacity_mw: f64,
pub co2_kg_per_mwh: f64,
pub heat_rate_mmbtu_per_mwh: f64,
pub marginal: bool,
}
#[derive(Debug, Clone)]
pub struct CarbonForecastConfig {
pub horizon_h: usize,
pub confidence_level: f64,
pub include_upstream: bool,
pub region_name: String,
}
impl Default for CarbonForecastConfig {
fn default() -> Self {
Self {
horizon_h: 24,
confidence_level: 0.90,
include_upstream: false,
region_name: String::from("default"),
}
}
}
#[derive(Debug, Clone)]
pub struct MarginalEmissionResult {
pub marginal_unit_id: String,
pub mer_kg_co2_per_mwh: f64,
pub total_emissions_kg_co2: f64,
pub dispatch_mw: Vec<(String, f64)>,
pub clean_fraction: f64,
}
#[derive(Debug, Clone)]
pub struct CarbonForecastResult {
pub timestamps: Vec<f64>,
pub ci_kg_co2_per_mwh: Vec<f64>,
pub ci_lower: Vec<f64>,
pub ci_upper: Vec<f64>,
pub mer_kg_co2_per_mwh: Vec<f64>,
pub total_emissions_t_co2: f64,
}
#[derive(Debug, Clone)]
pub struct EmissionSavings {
pub co2_reduction_t: f64,
pub reduction_pct: f64,
pub clean_energy_added_mwh: f64,
pub dirty_energy_replaced_mwh: f64,
}
pub struct CarbonIntensityForecaster {
pub config: CarbonForecastConfig,
pub generators: Vec<GeneratorEmission>,
history: Vec<(f64, f64)>,
dispatch_stack: Vec<usize>,
}
impl CarbonIntensityForecaster {
pub fn new(config: CarbonForecastConfig, generators: Vec<GeneratorEmission>) -> Self {
let n = generators.len();
Self {
config,
generators,
history: Vec::new(),
dispatch_stack: (0..n).collect(),
}
}
pub fn build_merit_order(&mut self, energy_prices: &[f64]) {
let n = self.generators.len();
let mut indices: Vec<usize> = (0..n).collect();
if energy_prices.len() >= n && !energy_prices.is_empty() {
indices.sort_by(|&a, &b| {
energy_prices[a]
.partial_cmp(&energy_prices[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
} else {
indices.sort_by(|&a, &b| {
self.generators[a]
.heat_rate_mmbtu_per_mwh
.partial_cmp(&self.generators[b].heat_rate_mmbtu_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
self.dispatch_stack = indices;
}
pub fn calculate_average_intensity(
&self,
dispatch: &[(String, f64)],
) -> Result<f64, CarbonForecastError> {
if dispatch.is_empty() {
return Err(CarbonForecastError::EmptyDispatch);
}
let mut weighted_sum = 0.0_f64;
let mut total_mw = 0.0_f64;
for (unit_id, mw) in dispatch {
let emission_rate = self
.generators
.iter()
.find(|g| &g.unit_id == unit_id)
.map(|g| g.co2_kg_per_mwh)
.unwrap_or(0.0); weighted_sum += mw * emission_rate;
total_mw += mw;
}
if total_mw == 0.0 {
return Err(CarbonForecastError::ZeroTotalPower);
}
Ok(weighted_sum / total_mw)
}
pub fn calculate_marginal_emission_rate(
&self,
demand_mw: f64,
_fuel_price_per_mmbtu: f64,
) -> Result<MarginalEmissionResult, CarbonForecastError> {
if self.generators.is_empty() {
return Err(CarbonForecastError::EmptyFleet);
}
let total_capacity: f64 = self.generators.iter().map(|g| g.capacity_mw).sum();
if demand_mw > total_capacity {
return Err(CarbonForecastError::DemandExceedsCapacity {
demand_mw,
capacity_mw: total_capacity,
});
}
let mut remaining = demand_mw;
let mut dispatch_mw: Vec<(String, f64)> = Vec::new();
let mut total_emissions_kg = 0.0_f64;
let mut clean_mw = 0.0_f64;
let mut marginal_unit_id = String::new();
let mut mer = 0.0_f64;
for &idx in &self.dispatch_stack {
if remaining <= 0.0 {
break;
}
let gen = &self.generators[idx];
let dispatched = gen.capacity_mw.min(remaining);
dispatch_mw.push((gen.unit_id.clone(), dispatched));
total_emissions_kg += dispatched * gen.co2_kg_per_mwh;
if gen.fuel_type.is_zero_emission() {
clean_mw += dispatched;
}
marginal_unit_id = gen.unit_id.clone();
mer = gen.co2_kg_per_mwh;
remaining -= dispatched;
}
let clean_fraction = if demand_mw > 0.0 {
clean_mw / demand_mw
} else {
0.0
};
Ok(MarginalEmissionResult {
marginal_unit_id,
mer_kg_co2_per_mwh: mer,
total_emissions_kg_co2: total_emissions_kg,
dispatch_mw,
clean_fraction,
})
}
pub fn forecast_carbon_intensity(
&self,
load_profile: &[f64],
renewable_profile: &[f64],
) -> Result<CarbonForecastResult, CarbonForecastError> {
if load_profile.len() != renewable_profile.len() {
return Err(CarbonForecastError::ProfileLengthMismatch {
load: load_profile.len(),
renewable: renewable_profile.len(),
});
}
if self.generators.is_empty() {
return Err(CarbonForecastError::EmptyFleet);
}
let horizon = load_profile.len();
let mut timestamps = Vec::with_capacity(horizon);
let mut ci_point = Vec::with_capacity(horizon);
let mut ci_lower = Vec::with_capacity(horizon);
let mut ci_upper = Vec::with_capacity(horizon);
let mut mer_series = Vec::with_capacity(horizon);
let mut total_emissions_kg = 0.0_f64;
for h in 0..horizon {
let net_load = (load_profile[h] - renewable_profile[h]).max(0.0);
timestamps.push((h + 1) as f64);
let mer_result = self.calculate_marginal_emission_rate(net_load, 0.0);
let (mer, avg_ci, hour_emissions) = match mer_result {
Ok(ref r) => {
let avg = self
.calculate_average_intensity(&r.dispatch_mw)
.unwrap_or(0.0);
(r.mer_kg_co2_per_mwh, avg, r.total_emissions_kg_co2)
}
Err(_) => (0.0, 0.0, 0.0),
};
let frac = if horizon <= 1 {
0.0
} else {
h as f64 / (horizon - 1) as f64
};
let half_width_frac = 0.10 + 0.10 * frac; let half_width = avg_ci * half_width_frac;
ci_point.push(avg_ci);
ci_lower.push((avg_ci - half_width).max(0.0));
ci_upper.push(avg_ci + half_width);
mer_series.push(mer);
total_emissions_kg += hour_emissions;
}
Ok(CarbonForecastResult {
timestamps,
ci_kg_co2_per_mwh: ci_point,
ci_lower,
ci_upper,
mer_kg_co2_per_mwh: mer_series,
total_emissions_t_co2: total_emissions_kg / 1_000.0,
})
}
pub fn locational_marginal_emissions(&self, ptdf: &[Vec<f64>], mer: f64) -> Vec<f64> {
if ptdf.is_empty() {
return Vec::new();
}
let n_branches = ptdf[0].len();
let loss_sensitivity: Vec<f64> = vec![0.02; n_branches];
ptdf.iter()
.map(|row| {
let sensitivity_sum: f64 = row
.iter()
.zip(loss_sensitivity.iter())
.map(|(ptdf_val, ls)| ptdf_val * ls)
.sum();
mer * (1.0 + sensitivity_sum)
})
.collect()
}
pub fn carbon_optimal_dispatch(
&self,
generators: &[GeneratorEmission],
demand_mw: f64,
) -> Vec<f64> {
if generators.is_empty() || demand_mw <= 0.0 {
return vec![0.0; generators.len()];
}
let mut order: Vec<usize> = (0..generators.len()).collect();
order.sort_by(|&a, &b| {
generators[a]
.co2_kg_per_mwh
.partial_cmp(&generators[b].co2_kg_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut dispatch = vec![0.0_f64; generators.len()];
let mut remaining = demand_mw;
for idx in order {
if remaining <= 0.0 {
break;
}
let dispatched = generators[idx].capacity_mw.min(remaining);
dispatch[idx] = dispatched;
remaining -= dispatched;
}
dispatch
}
pub fn emission_savings_vs_baseline(
&self,
carbon_optimal: &[(String, f64)],
baseline: &[(String, f64)],
generators: &[GeneratorEmission],
) -> Result<EmissionSavings, CarbonForecastError> {
let lookup = |uid: &str| -> Result<&GeneratorEmission, CarbonForecastError> {
generators
.iter()
.find(|g| g.unit_id == uid)
.ok_or_else(|| CarbonForecastError::UnitNotFound(uid.to_string()))
};
let mut baseline_emissions = 0.0_f64;
let mut baseline_dirty_mwh = 0.0_f64;
for (uid, mw) in baseline {
let g = lookup(uid)?;
baseline_emissions += mw * g.co2_kg_per_mwh;
if !g.fuel_type.is_zero_emission() {
baseline_dirty_mwh += mw;
}
}
let mut optimal_emissions = 0.0_f64;
let mut optimal_clean_mwh = 0.0_f64;
let mut optimal_dirty_mwh = 0.0_f64;
for (uid, mw) in carbon_optimal {
let g = lookup(uid)?;
optimal_emissions += mw * g.co2_kg_per_mwh;
if g.fuel_type.is_zero_emission() {
optimal_clean_mwh += mw;
} else {
optimal_dirty_mwh += mw;
}
}
let delta_kg = baseline_emissions - optimal_emissions;
let reduction_pct = if baseline_emissions > 0.0 {
100.0 * delta_kg / baseline_emissions
} else {
0.0
};
let clean_energy_added_mwh = (optimal_clean_mwh
- baseline
.iter()
.map(|(uid, mw)| {
generators
.iter()
.find(|g| &g.unit_id == uid)
.map(|g| {
if g.fuel_type.is_zero_emission() {
*mw
} else {
0.0
}
})
.unwrap_or(0.0)
})
.sum::<f64>())
.max(0.0);
let dirty_energy_replaced_mwh = (baseline_dirty_mwh - optimal_dirty_mwh).max(0.0);
Ok(EmissionSavings {
co2_reduction_t: delta_kg / 1_000.0,
reduction_pct,
clean_energy_added_mwh,
dirty_energy_replaced_mwh,
})
}
pub fn update_history(&mut self, timestamp: f64, actual_ci: f64) {
self.history.push((timestamp, actual_ci));
}
pub fn skill_score_ci(
&self,
forecasts: &[f64],
actuals: &[f64],
) -> Result<f64, CarbonForecastError> {
if forecasts.is_empty() || actuals.is_empty() {
return Err(CarbonForecastError::EmptyForecastSlice);
}
if forecasts.len() != actuals.len() {
return Err(CarbonForecastError::ForecastLengthMismatch {
forecast: forecasts.len(),
actual: actuals.len(),
});
}
if self.history.is_empty() {
return Err(CarbonForecastError::NoHistory);
}
let n = forecasts.len() as f64;
let mse_forecast: f64 = forecasts
.iter()
.zip(actuals.iter())
.map(|(f, a)| (f - a).powi(2))
.sum::<f64>()
/ n;
let clim_mean: f64 =
self.history.iter().map(|(_, ci)| ci).sum::<f64>() / self.history.len() as f64;
let mse_clim: f64 = actuals.iter().map(|a| (a - clim_mean).powi(2)).sum::<f64>() / n;
if mse_clim == 0.0 {
return Ok(if mse_forecast == 0.0 { 1.0 } else { 0.0 });
}
Ok(1.0 - mse_forecast / mse_clim)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_fleet() -> Vec<GeneratorEmission> {
vec![
GeneratorEmission {
unit_id: "wind_1".to_string(),
fuel_type: FuelType::Wind,
capacity_mw: 100.0,
co2_kg_per_mwh: 0.0,
heat_rate_mmbtu_per_mwh: 0.0,
marginal: false,
},
GeneratorEmission {
unit_id: "solar_1".to_string(),
fuel_type: FuelType::Solar,
capacity_mw: 80.0,
co2_kg_per_mwh: 0.0,
heat_rate_mmbtu_per_mwh: 0.0,
marginal: false,
},
GeneratorEmission {
unit_id: "gas_1".to_string(),
fuel_type: FuelType::NaturalGas,
capacity_mw: 200.0,
co2_kg_per_mwh: 450.0,
heat_rate_mmbtu_per_mwh: 7.5,
marginal: false,
},
GeneratorEmission {
unit_id: "coal_1".to_string(),
fuel_type: FuelType::Coal,
capacity_mw: 300.0,
co2_kg_per_mwh: 950.0,
heat_rate_mmbtu_per_mwh: 10.5,
marginal: false,
},
]
}
fn default_forecaster() -> CarbonIntensityForecaster {
CarbonIntensityForecaster::new(CarbonForecastConfig::default(), test_fleet())
}
#[test]
fn test_merit_order_coal_after_gas() {
let forecaster = default_forecaster();
let fleet = test_fleet();
let dispatch = forecaster.carbon_optimal_dispatch(&fleet, 300.0);
let gas_idx = fleet.iter().position(|g| g.unit_id == "gas_1").unwrap();
let coal_idx = fleet.iter().position(|g| g.unit_id == "coal_1").unwrap();
assert!(
dispatch[gas_idx] > 0.0,
"Gas should be dispatched before coal"
);
assert_eq!(
dispatch[coal_idx], 0.0,
"Coal must not be dispatched when demand can be met cleanly + gas"
);
}
#[test]
fn test_mer_low_demand_cleanest_marginal() {
let mut forecaster = default_forecaster();
forecaster.build_merit_order(&[]);
let result = forecaster
.calculate_marginal_emission_rate(50.0, 3.0)
.expect("should succeed");
assert_eq!(
result.marginal_unit_id, "wind_1",
"Marginal unit must be wind at low demand"
);
assert_eq!(
result.mer_kg_co2_per_mwh, 0.0,
"MER must be 0 when wind is marginal"
);
assert!(
result.clean_fraction > 0.99,
"Clean fraction must be ~1.0 at low demand, got {}",
result.clean_fraction
);
}
#[test]
fn test_average_intensity_weighted() {
let forecaster = default_forecaster();
let dispatch = vec![
("wind_1".to_string(), 200.0_f64),
("gas_1".to_string(), 100.0_f64),
];
let ci = forecaster
.calculate_average_intensity(&dispatch)
.expect("should succeed");
let expected = (200.0 * 0.0 + 100.0 * 450.0) / 300.0;
assert!(
(ci - expected).abs() < 1e-9,
"Expected {:.4} kg/MWh, got {:.4}",
expected,
ci
);
}
#[test]
fn test_carbon_optimal_full_renewables() {
let forecaster = default_forecaster();
let fleet = test_fleet();
let dispatch = forecaster.carbon_optimal_dispatch(&fleet, 100.0);
let wind_idx = fleet.iter().position(|g| g.unit_id == "wind_1").unwrap();
assert!(
(dispatch[wind_idx] - 100.0).abs() < 1e-9,
"All demand should be met by wind"
);
let dispatch_pairs: Vec<(String, f64)> = fleet
.iter()
.zip(dispatch.iter())
.filter(|(_, &mw)| mw > 0.0)
.map(|(g, &mw)| (g.unit_id.clone(), mw))
.collect();
let ci = forecaster
.calculate_average_intensity(&dispatch_pairs)
.expect("should succeed");
assert!(
ci < 1e-9,
"CI must be near-zero with 100 % wind, got {:.4}",
ci
);
}
#[test]
fn test_forecast_uncertainty_grows_with_horizon() {
let mut forecaster = default_forecaster();
forecaster.build_merit_order(&[]);
let load: Vec<f64> = vec![300.0; 24];
let renew: Vec<f64> = vec![100.0; 24];
let result = forecaster
.forecast_carbon_intensity(&load, &renew)
.expect("forecast should succeed");
assert_eq!(result.timestamps.len(), 24);
let width_h1 = result.ci_upper[0] - result.ci_lower[0];
let width_h24 = result.ci_upper[23] - result.ci_lower[23];
assert!(
width_h24 > width_h1,
"PI at h=24 ({:.4}) must be wider than at h=1 ({:.4})",
width_h24,
width_h1
);
}
#[test]
fn test_emission_savings_positive() {
let forecaster = default_forecaster();
let fleet = test_fleet();
let baseline = vec![("coal_1".to_string(), 300.0_f64)];
let optimal = vec![
("wind_1".to_string(), 100.0_f64),
("solar_1".to_string(), 80.0_f64),
("gas_1".to_string(), 120.0_f64),
];
let savings = forecaster
.emission_savings_vs_baseline(&optimal, &baseline, &fleet)
.expect("should succeed");
assert!(
savings.co2_reduction_t > 0.0,
"CO₂ reduction must be positive, got {:.4}",
savings.co2_reduction_t
);
assert!(
savings.reduction_pct > 0.0,
"Reduction % must be positive, got {:.4}",
savings.reduction_pct
);
}
#[test]
fn test_lme_no_congestion_equals_mer() {
let forecaster = default_forecaster();
let mer = 450.0_f64;
let ptdf = vec![vec![0.0, 0.0], vec![0.0, 0.0], vec![0.0, 0.0]];
let lme = forecaster.locational_marginal_emissions(&ptdf, mer);
assert_eq!(lme.len(), 3, "Should return one LME per bus");
for (i, &l) in lme.iter().enumerate() {
assert!(
(l - mer).abs() < 1e-9,
"Bus {}: LME ({:.4}) must equal MER ({:.4}) when PTDF=0",
i,
l,
mer
);
}
}
#[test]
fn test_skill_score_perfect_forecast() {
let mut forecaster = default_forecaster();
forecaster.update_history(0.0, 400.0);
forecaster.update_history(1.0, 500.0);
forecaster.update_history(2.0, 450.0);
let actuals = vec![420.0, 480.0, 460.0, 440.0];
let forecasts = actuals.clone();
let ss = forecaster
.skill_score_ci(&forecasts, &actuals)
.expect("should succeed");
assert!(
(ss - 1.0).abs() < 1e-9,
"Perfect forecast must yield SS=1.0, got {:.6}",
ss
);
}
#[test]
fn test_build_merit_order_with_prices() {
let mut forecaster = default_forecaster();
let prices = vec![50.0, 55.0, 30.0, 20.0];
forecaster.build_merit_order(&prices);
let first_idx = forecaster.dispatch_stack[0];
assert_eq!(
forecaster.generators[first_idx].unit_id, "coal_1",
"Cheapest price = coal should be first in merit order"
);
}
#[test]
fn test_average_intensity_empty_dispatch_error() {
let forecaster = default_forecaster();
let result = forecaster.calculate_average_intensity(&[]);
assert!(
matches!(result, Err(CarbonForecastError::EmptyDispatch)),
"Expected EmptyDispatch error"
);
}
#[test]
fn test_mer_demand_exceeds_capacity() {
let forecaster = default_forecaster();
let result = forecaster.calculate_marginal_emission_rate(1000.0, 5.0);
assert!(
matches!(
result,
Err(CarbonForecastError::DemandExceedsCapacity { .. })
),
"Expected DemandExceedsCapacity error"
);
}
}