use thiserror::Error;
#[derive(Debug, Error)]
pub enum CarbonError {
#[error("dispatch sequence is empty")]
EmptyDispatch,
#[error("generation vector length {got} does not match generator count {expected}")]
LengthMismatch { got: usize, expected: usize },
#[error("negative total load {0} MW at hour {1}")]
NegativeLoad(f64, usize),
}
#[derive(Debug, Clone, PartialEq)]
pub enum GeneratorType {
Coal,
NaturalGasCc,
NaturalGasPeaker,
Nuclear,
Hydro,
Wind,
Solar,
Biomass,
OilPeaker,
Geothermal,
}
impl GeneratorType {
pub fn is_clean(&self) -> bool {
matches!(
self,
GeneratorType::Nuclear
| GeneratorType::Hydro
| GeneratorType::Wind
| GeneratorType::Solar
| GeneratorType::Geothermal
)
}
pub fn default_factor(&self) -> f64 {
match self {
GeneratorType::Coal => 0.95,
GeneratorType::NaturalGasCc => 0.40,
GeneratorType::NaturalGasPeaker => 0.65,
GeneratorType::Nuclear => 0.012,
GeneratorType::Hydro => 0.024,
GeneratorType::Wind => 0.011,
GeneratorType::Solar => 0.041,
GeneratorType::Biomass => 0.23,
GeneratorType::OilPeaker => 0.75,
GeneratorType::Geothermal => 0.038,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MarginalEmissionMethod {
SimpleAverageFactor,
MarginalGeneratingUnit,
LongRunMarginal,
OperatingMarginal,
BuildMarginal,
}
pub struct DispatchPoint {
pub hour: usize,
pub generation_mw: Vec<f64>,
pub generator_types: Vec<GeneratorType>,
pub emissions_factors: Vec<f64>,
pub total_load_mw: f64,
}
pub struct CarbonMetrics {
pub hour: usize,
pub total_emissions_t: f64,
pub average_emissions_factor: f64,
pub marginal_emissions_factor: f64,
pub clean_energy_pct: f64,
pub carbon_intensity_g_per_kwh: f64,
pub avoided_emissions_t: f64,
pub renewable_generation_mw: f64,
}
pub struct CarbonAnalysisResult {
pub hourly: Vec<CarbonMetrics>,
pub annual_emissions_mt: f64,
pub annual_avg_intensity_t_per_mwh: f64,
pub annual_marginal_intensity_t_per_mwh: f64,
pub peak_emissions_hour: usize,
pub cleanest_hour: usize,
pub carbon_savings_t: f64,
pub equivalent_cars_removed: f64,
}
pub struct CarbonAccountingConfig {
pub base_emissions_factors: Vec<(GeneratorType, f64)>,
pub marginal_emission_method: MarginalEmissionMethod,
pub time_resolution_hours: f64,
pub region: String,
}
impl Default for CarbonAccountingConfig {
fn default() -> Self {
Self {
base_emissions_factors: Vec::new(),
marginal_emission_method: MarginalEmissionMethod::MarginalGeneratingUnit,
time_resolution_hours: 1.0,
region: String::from("default"),
}
}
}
pub struct CarbonAccountant {
config: CarbonAccountingConfig,
}
impl CarbonAccountant {
pub fn new(config: CarbonAccountingConfig) -> Self {
Self { config }
}
pub fn analyze(&self, dispatch: &[DispatchPoint]) -> Result<CarbonAnalysisResult, CarbonError> {
if dispatch.is_empty() {
return Err(CarbonError::EmptyDispatch);
}
for dp in dispatch {
let n = dp.generator_types.len();
if dp.generation_mw.len() != n || dp.emissions_factors.len() != n {
return Err(CarbonError::LengthMismatch {
got: dp.generation_mw.len().min(dp.emissions_factors.len()),
expected: n,
});
}
if dp.total_load_mw < 0.0 {
return Err(CarbonError::NegativeLoad(dp.total_load_mw, dp.hour));
}
}
let mefs = self.time_varying_mef(dispatch);
let dt = self.config.time_resolution_hours;
const COAL_FACTOR: f64 = 0.95;
let mut hourly = Vec::with_capacity(dispatch.len());
for (idx, dp) in dispatch.iter().enumerate() {
let total_gen: f64 = dp.generation_mw.iter().sum();
let total_emissions_t: f64 = dp
.generation_mw
.iter()
.zip(dp.emissions_factors.iter())
.map(|(g, ef)| g * ef * dt)
.sum();
let average_emissions_factor = if total_gen > 0.0 {
total_emissions_t / (total_gen * dt)
} else {
0.0
};
let marginal_emissions_factor = mefs[idx];
let renewable_generation_mw: f64 = dp
.generation_mw
.iter()
.zip(dp.generator_types.iter())
.filter(|(_, gt)| gt.is_clean())
.map(|(g, _)| *g)
.sum();
let clean_energy_pct = if total_gen > 0.0 {
100.0 * renewable_generation_mw / total_gen
} else {
0.0
};
let carbon_intensity_g_per_kwh = average_emissions_factor * 1000.0;
let baseline_emissions = total_gen * COAL_FACTOR * dt;
let avoided_emissions_t = baseline_emissions - total_emissions_t;
hourly.push(CarbonMetrics {
hour: dp.hour,
total_emissions_t,
average_emissions_factor,
marginal_emissions_factor,
clean_energy_pct,
carbon_intensity_g_per_kwh,
avoided_emissions_t,
renewable_generation_mw,
});
}
let annual_emissions_t: f64 = hourly.iter().map(|m| m.total_emissions_t).sum();
let annual_emissions_mt = annual_emissions_t / 1_000_000.0;
let total_gen_all: f64 = dispatch
.iter()
.map(|dp| dp.generation_mw.iter().sum::<f64>() * dt)
.sum();
let annual_avg_intensity_t_per_mwh = if total_gen_all > 0.0 {
annual_emissions_t / total_gen_all
} else {
0.0
};
let annual_marginal_intensity_t_per_mwh = if hourly.is_empty() {
0.0
} else {
hourly
.iter()
.map(|m| m.marginal_emissions_factor)
.sum::<f64>()
/ hourly.len() as f64
};
let peak_emissions_hour = hourly
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
a.total_emissions_t
.partial_cmp(&b.total_emissions_t)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
let cleanest_hour = hourly
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
a.carbon_intensity_g_per_kwh
.partial_cmp(&b.carbon_intensity_g_per_kwh)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
let carbon_savings_t: f64 = hourly.iter().map(|m| m.avoided_emissions_t).sum();
let equivalent_cars_removed = Self::cars_equivalent(carbon_savings_t);
Ok(CarbonAnalysisResult {
hourly,
annual_emissions_mt,
annual_avg_intensity_t_per_mwh,
annual_marginal_intensity_t_per_mwh,
peak_emissions_hour,
cleanest_hour,
carbon_savings_t,
equivalent_cars_removed,
})
}
fn marginal_unit(&self, dp: &DispatchPoint) -> Option<usize> {
dp.generation_mw
.iter()
.zip(dp.emissions_factors.iter())
.enumerate()
.filter(|(_, (g, _))| **g > 1e-6)
.max_by(|(_, (_, a)), (_, (_, b))| {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
}
fn time_varying_mef(&self, dispatch: &[DispatchPoint]) -> Vec<f64> {
match self.config.marginal_emission_method {
MarginalEmissionMethod::SimpleAverageFactor => {
dispatch.iter().map(|dp| self.average_factor(dp)).collect()
}
MarginalEmissionMethod::MarginalGeneratingUnit => dispatch
.iter()
.map(|dp| {
self.marginal_unit(dp)
.map(|i| dp.emissions_factors[i])
.unwrap_or(0.0)
})
.collect(),
MarginalEmissionMethod::LongRunMarginal => {
let instant: Vec<f64> = dispatch
.iter()
.map(|dp| {
self.marginal_unit(dp)
.map(|i| dp.emissions_factors[i])
.unwrap_or(0.0)
})
.collect();
let mut mefs = Vec::with_capacity(instant.len());
let mut running_sum = 0.0;
for (k, &v) in instant.iter().enumerate() {
running_sum += v;
mefs.push(running_sum / (k + 1) as f64);
}
mefs
}
MarginalEmissionMethod::OperatingMarginal => {
dispatch
.iter()
.map(|dp| {
let mut active: Vec<(f64, f64)> = dp
.generation_mw
.iter()
.zip(dp.emissions_factors.iter())
.filter(|(g, _)| **g > 1e-6)
.map(|(g, ef)| (*g, *ef))
.collect();
active.sort_by(|(_, a), (_, b)| {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
});
active.last().map(|(_, ef)| *ef).unwrap_or(0.0)
})
.collect()
}
MarginalEmissionMethod::BuildMarginal => {
const NEW_BUILD_FACTOR: f64 = 0.40;
dispatch
.iter()
.map(|dp| {
let total_gen: f64 = dp.generation_mw.iter().sum();
if total_gen < dp.total_load_mw - 1e-3 {
NEW_BUILD_FACTOR
} else {
self.marginal_unit(dp)
.map(|i| dp.emissions_factors[i])
.unwrap_or(0.0)
}
})
.collect()
}
}
}
fn average_factor(&self, dp: &DispatchPoint) -> f64 {
let total_gen: f64 = dp.generation_mw.iter().sum();
if total_gen < 1e-9 {
return 0.0;
}
dp.generation_mw
.iter()
.zip(dp.emissions_factors.iter())
.map(|(g, ef)| g * ef)
.sum::<f64>()
/ total_gen
}
pub fn cars_equivalent(emissions_t: f64) -> f64 {
emissions_t / 4.6
}
}
#[cfg(test)]
mod tests {
use super::*;
fn coal_only_dispatch(hour: usize, mw: f64) -> DispatchPoint {
DispatchPoint {
hour,
generation_mw: vec![mw],
generator_types: vec![GeneratorType::Coal],
emissions_factors: vec![0.95],
total_load_mw: mw,
}
}
fn renewable_dispatch(hour: usize, mw: f64) -> DispatchPoint {
DispatchPoint {
hour,
generation_mw: vec![mw],
generator_types: vec![GeneratorType::Wind],
emissions_factors: vec![0.0],
total_load_mw: mw,
}
}
fn config(method: MarginalEmissionMethod) -> CarbonAccountingConfig {
CarbonAccountingConfig {
marginal_emission_method: method,
..Default::default()
}
}
#[test]
fn test_all_fossil_high_intensity() {
let dispatch: Vec<DispatchPoint> = (0..3).map(|h| coal_only_dispatch(h, 500.0)).collect();
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::SimpleAverageFactor));
let result = accountant
.analyze(&dispatch)
.expect("analysis should succeed");
for m in &result.hourly {
assert!(
m.average_emissions_factor > 0.9,
"Coal intensity must be > 0.9 t/MWh, got {:.4}",
m.average_emissions_factor
);
assert!(
m.clean_energy_pct < 1.0,
"No clean generation expected, got {:.2}%",
m.clean_energy_pct
);
}
}
#[test]
fn test_all_renewable_zero_emissions() {
let dispatch: Vec<DispatchPoint> = (0..4).map(|h| renewable_dispatch(h, 300.0)).collect();
let accountant =
CarbonAccountant::new(config(MarginalEmissionMethod::MarginalGeneratingUnit));
let result = accountant
.analyze(&dispatch)
.expect("analysis should succeed");
for m in &result.hourly {
assert!(
m.total_emissions_t.abs() < 1e-9,
"Renewable dispatch must emit zero, got {:.6}",
m.total_emissions_t
);
assert!(
(m.clean_energy_pct - 100.0).abs() < 1e-6,
"Clean % must be 100, got {:.2}",
m.clean_energy_pct
);
}
assert!(
result.annual_emissions_mt < 1e-9,
"Annual emissions must be zero"
);
}
#[test]
fn test_marginal_coal_peaker() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![200.0, 50.0],
generator_types: vec![GeneratorType::Wind, GeneratorType::Coal],
emissions_factors: vec![0.011, 0.95],
total_load_mw: 250.0,
};
let accountant =
CarbonAccountant::new(config(MarginalEmissionMethod::MarginalGeneratingUnit));
let result = accountant.analyze(&[dp]).expect("analysis should succeed");
assert!(
(result.hourly[0].marginal_emissions_factor - 0.95).abs() < 1e-9,
"Marginal unit must be coal peaker (0.95 t/MWh), got {:.4}",
result.hourly[0].marginal_emissions_factor
);
}
#[test]
fn test_annual_totals_consistent() {
let dispatch: Vec<DispatchPoint> = (0..24).map(|h| coal_only_dispatch(h, 100.0)).collect();
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::SimpleAverageFactor));
let result = accountant
.analyze(&dispatch)
.expect("analysis should succeed");
let sum_hourly: f64 = result.hourly.iter().map(|m| m.total_emissions_t).sum();
let from_mt = result.annual_emissions_mt * 1_000_000.0;
assert!(
(sum_hourly - from_mt).abs() < 1e-6,
"Hourly sum {:.4} must match annual Mt {:.6}",
sum_hourly,
from_mt
);
let expected = 24.0 * 100.0 * 0.95;
assert!(
(sum_hourly - expected).abs() < 1e-6,
"Expected {:.2} t, got {:.4} t",
expected,
sum_hourly
);
}
#[test]
fn test_cars_equivalent_formula() {
let cars = CarbonAccountant::cars_equivalent(4600.0);
assert!(
(cars - 1000.0).abs() < 1e-6,
"4600 t / 4.6 = 1000 cars, got {:.4}",
cars
);
}
#[test]
fn test_long_run_marginal_is_running_average() {
let dispatch = vec![
DispatchPoint {
hour: 0,
generation_mw: vec![100.0],
generator_types: vec![GeneratorType::Coal],
emissions_factors: vec![0.8],
total_load_mw: 100.0,
},
DispatchPoint {
hour: 1,
generation_mw: vec![100.0],
generator_types: vec![GeneratorType::NaturalGasCc],
emissions_factors: vec![0.4],
total_load_mw: 100.0,
},
];
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::LongRunMarginal));
let result = accountant
.analyze(&dispatch)
.expect("analysis should succeed");
assert!(
(result.hourly[0].marginal_emissions_factor - 0.8).abs() < 1e-9,
"Hour 0 LRM must be 0.8"
);
assert!(
(result.hourly[1].marginal_emissions_factor - 0.6).abs() < 1e-9,
"Hour 1 LRM must be 0.6, got {:.4}",
result.hourly[1].marginal_emissions_factor
);
}
#[test]
fn test_empty_dispatch_error() {
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::SimpleAverageFactor));
let result = accountant.analyze(&[]);
assert!(
matches!(result, Err(CarbonError::EmptyDispatch)),
"Expected EmptyDispatch error"
);
}
#[test]
fn test_operating_marginal_selects_highest_ef_unit() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![150.0, 50.0],
generator_types: vec![GeneratorType::Wind, GeneratorType::Coal],
emissions_factors: vec![0.0, 0.95],
total_load_mw: 200.0,
};
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::OperatingMarginal));
let result = accountant.analyze(&[dp]).expect("analysis should succeed");
assert!(
(result.hourly[0].marginal_emissions_factor - 0.95).abs() < 1e-9,
"OperatingMarginal must pick coal (0.95 t/MWh), got {:.6}",
result.hourly[0].marginal_emissions_factor
);
}
#[test]
fn test_build_marginal_uses_new_build_factor_when_short() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![80.0],
generator_types: vec![GeneratorType::NaturalGasCc],
emissions_factors: vec![0.40],
total_load_mw: 100.0,
};
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::BuildMarginal));
let result = accountant.analyze(&[dp]).expect("analysis should succeed");
assert!(
(result.hourly[0].marginal_emissions_factor - 0.40).abs() < 1e-9,
"BuildMarginal when short must return 0.40 (new-build CC), got {:.6}",
result.hourly[0].marginal_emissions_factor
);
}
#[test]
fn test_build_marginal_uses_marginal_unit_when_sufficient() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![100.0],
generator_types: vec![GeneratorType::Coal],
emissions_factors: vec![0.95],
total_load_mw: 100.0,
};
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::BuildMarginal));
let result = accountant.analyze(&[dp]).expect("analysis should succeed");
assert!(
(result.hourly[0].marginal_emissions_factor - 0.95).abs() < 1e-9,
"BuildMarginal when sufficient must return coal factor (0.95), got {:.6}",
result.hourly[0].marginal_emissions_factor
);
}
#[test]
fn test_length_mismatch_error() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![100.0], generator_types: vec![GeneratorType::Coal, GeneratorType::Wind], emissions_factors: vec![0.95, 0.0],
total_load_mw: 100.0,
};
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::SimpleAverageFactor));
let result = accountant.analyze(&[dp]);
assert!(
matches!(result, Err(CarbonError::LengthMismatch { .. })),
"Expected LengthMismatch error, got {:?}",
result.map(|_| ())
);
}
#[test]
fn test_negative_load_error() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![100.0],
generator_types: vec![GeneratorType::Coal],
emissions_factors: vec![0.95],
total_load_mw: -10.0,
};
let accountant = CarbonAccountant::new(config(MarginalEmissionMethod::SimpleAverageFactor));
let result = accountant.analyze(&[dp]);
assert!(
matches!(result, Err(CarbonError::NegativeLoad(..))),
"Expected NegativeLoad error, got {:?}",
result.map(|_| ())
);
}
#[test]
fn test_generator_type_is_clean_and_default_factor() {
assert!(GeneratorType::Wind.is_clean(), "Wind must be clean");
assert!(GeneratorType::Solar.is_clean(), "Solar must be clean");
assert!(!GeneratorType::Coal.is_clean(), "Coal must not be clean");
assert!(
!GeneratorType::NaturalGasCc.is_clean(),
"NaturalGasCc must not be clean"
);
assert!(
(GeneratorType::Coal.default_factor() - 0.95).abs() < 1e-12,
"Coal default factor must be 0.95, got {:.14}",
GeneratorType::Coal.default_factor()
);
assert!(
(GeneratorType::Wind.default_factor() - 0.011).abs() < 1e-12,
"Wind default factor must be 0.011, got {:.14}",
GeneratorType::Wind.default_factor()
);
}
#[test]
fn test_carbon_intensity_g_per_kwh_and_avoided_emissions() {
let dp = DispatchPoint {
hour: 0,
generation_mw: vec![100.0],
generator_types: vec![GeneratorType::Coal],
emissions_factors: vec![0.95],
total_load_mw: 100.0,
};
let accountant = CarbonAccountant::new(CarbonAccountingConfig {
marginal_emission_method: MarginalEmissionMethod::SimpleAverageFactor,
time_resolution_hours: 1.0,
..Default::default()
});
let result = accountant.analyze(&[dp]).expect("analysis should succeed");
let m = &result.hourly[0];
assert!(
(m.carbon_intensity_g_per_kwh - 950.0).abs() < 1e-9,
"Carbon intensity must be 950 g/kWh, got {:.6}",
m.carbon_intensity_g_per_kwh
);
assert!(
m.avoided_emissions_t.abs() < 1e-9,
"Avoided emissions must be 0.0 for all-coal dispatch, got {:.6}",
m.avoided_emissions_t
);
assert!(
m.clean_energy_pct.abs() < 1e-9,
"Clean energy pct must be 0.0, got {:.6}",
m.clean_energy_pct
);
}
}