use thiserror::Error;
#[derive(Debug, Error)]
pub enum CarbonOpfError {
#[error("infeasible: {0}")]
Infeasible(String),
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("solver has no generators")]
NoGenerators,
}
#[derive(Debug, Clone)]
pub struct CarbonOpfConfig {
pub base_mva: f64,
pub n_buses: usize,
pub carbon_limit_t_per_h: Option<f64>,
pub carbon_price_usd_per_t: f64,
pub renewable_priority: bool,
pub dual_objective_weight: f64,
}
#[derive(Debug, Clone)]
pub struct GeneratorCarbon {
pub bus: usize,
pub p_max_mw: f64,
pub p_min_mw: f64,
pub energy_cost_usd_per_mwh: f64,
pub co2_rate_t_per_mwh: f64,
pub is_must_run: bool,
pub p_fixed_mw: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct CarbonOpfResult {
pub dispatch_mw: Vec<f64>,
pub total_cost_usd_per_h: f64,
pub total_emissions_t_per_h: f64,
pub emissions_budget_slack_t_per_h: f64,
pub renewable_curtailment_mw: f64,
pub carbon_price_shadow: f64,
pub pareto_point: (f64, f64),
pub green_lmp: Vec<f64>,
}
pub struct CarbonOpfSolver {
config: CarbonOpfConfig,
generators: Vec<GeneratorCarbon>,
network_b_matrix: Vec<Vec<f64>>,
load_mw: Vec<f64>,
}
impl CarbonOpfSolver {
pub fn new(config: CarbonOpfConfig) -> Self {
Self {
config,
generators: Vec::new(),
network_b_matrix: Vec::new(),
load_mw: Vec::new(),
}
}
pub fn set_network(&mut self, b_matrix: Vec<Vec<f64>>, load_mw: Vec<f64>) {
self.network_b_matrix = b_matrix;
self.load_mw = load_mw;
}
pub fn add_generator(&mut self, gen: GeneratorCarbon) {
self.generators.push(gen);
}
pub fn solve(&self) -> Result<CarbonOpfResult, CarbonOpfError> {
self.solve_with_weight(self.config.dual_objective_weight)
}
pub fn pareto_front(&self, n_points: usize) -> Result<Vec<CarbonOpfResult>, CarbonOpfError> {
if n_points == 0 {
return Err(CarbonOpfError::InvalidConfig(
"n_points must be ≥ 1".to_string(),
));
}
let mut results = Vec::with_capacity(n_points);
for i in 0..n_points {
let w = if n_points == 1 {
0.5
} else {
i as f64 / (n_points - 1) as f64
};
results.push(self.solve_with_weight(w)?);
}
Ok(results)
}
fn solve_with_weight(&self, w: f64) -> Result<CarbonOpfResult, CarbonOpfError> {
if self.generators.is_empty() {
return Err(CarbonOpfError::NoGenerators);
}
if self.load_mw.is_empty() {
return Err(CarbonOpfError::InvalidConfig(
"load_mw must be set before solving".to_string(),
));
}
let n_gen = self.generators.len();
let total_load: f64 = self.load_mw.iter().sum();
let mut dispatch = vec![0.0_f64; n_gen];
let mut remaining_load = total_load;
for (i, gen) in self.generators.iter().enumerate() {
if gen.is_must_run {
let p = gen
.p_fixed_mw
.unwrap_or(gen.p_min_mw)
.clamp(gen.p_min_mw, gen.p_max_mw);
dispatch[i] = p;
remaining_load -= p;
}
}
let mut merit: Vec<(usize, f64)> = self
.generators
.iter()
.enumerate()
.filter(|(_, g)| !g.is_must_run)
.map(|(i, g)| {
let mut c_aug = w * g.energy_cost_usd_per_mwh
+ self.config.carbon_price_usd_per_t * g.co2_rate_t_per_mwh;
if self.config.renewable_priority && g.co2_rate_t_per_mwh == 0.0 {
c_aug -= 1000.0; }
(i, c_aug)
})
.collect();
merit.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
for &(i, _) in &merit {
if remaining_load <= 0.0 {
break;
}
let gen = &self.generators[i];
let headroom = gen.p_max_mw - gen.p_min_mw;
let to_dispatch = remaining_load.min(headroom).max(0.0);
dispatch[i] = gen.p_min_mw + to_dispatch;
remaining_load -= to_dispatch;
}
let mut emissions: f64 = self
.generators
.iter()
.enumerate()
.map(|(i, g)| dispatch[i] * g.co2_rate_t_per_mwh)
.sum();
let mut shadow_price = 0.0_f64;
if let Some(cap) = self.config.carbon_limit_t_per_h {
let max_iter = n_gen * 2;
for _ in 0..max_iter {
if emissions <= cap + 1e-9 {
break;
}
let worst = self
.generators
.iter()
.enumerate()
.filter(|(i, g)| !g.is_must_run && dispatch[*i] > g.p_min_mw + 1e-9)
.max_by(|a, b| {
(a.1.co2_rate_t_per_mwh * dispatch[a.0])
.partial_cmp(&(b.1.co2_rate_t_per_mwh * dispatch[b.0]))
.unwrap_or(std::cmp::Ordering::Equal)
});
let best_green = self
.generators
.iter()
.enumerate()
.filter(|(i, g)| {
!g.is_must_run
&& dispatch[*i] < g.p_max_mw - 1e-9
&& g.co2_rate_t_per_mwh
< worst
.as_ref()
.map(|(_, g2)| g2.co2_rate_t_per_mwh)
.unwrap_or(f64::INFINITY)
})
.min_by(|a, b| {
a.1.energy_cost_usd_per_mwh
.partial_cmp(&b.1.energy_cost_usd_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
match (worst, best_green) {
(Some((wi, wgen)), Some((gi, ggen))) => {
let reduce = (dispatch[wi] - wgen.p_min_mw)
.min(ggen.p_max_mw - dispatch[gi])
.min(1.0); if reduce < 1e-9 {
break;
}
shadow_price = ggen.energy_cost_usd_per_mwh - wgen.energy_cost_usd_per_mwh;
dispatch[wi] -= reduce;
dispatch[gi] += reduce;
emissions -= reduce * (wgen.co2_rate_t_per_mwh - ggen.co2_rate_t_per_mwh);
}
_ => break, }
}
if emissions > cap + 1.0 {
return Err(CarbonOpfError::Infeasible(format!(
"cannot reduce emissions ({:.2} t/h) below cap ({:.2} t/h)",
emissions, cap
)));
}
}
let total_cost: f64 = self
.generators
.iter()
.enumerate()
.map(|(i, g)| dispatch[i] * g.energy_cost_usd_per_mwh)
.sum();
let slack = self
.config
.carbon_limit_t_per_h
.map(|cap| (cap - emissions).max(0.0))
.unwrap_or(0.0);
let green_lmp = self.compute_green_lmp(&dispatch, w);
Ok(CarbonOpfResult {
dispatch_mw: dispatch,
total_cost_usd_per_h: total_cost,
total_emissions_t_per_h: emissions,
emissions_budget_slack_t_per_h: slack,
renewable_curtailment_mw: 0.0,
carbon_price_shadow: shadow_price,
pareto_point: (total_cost, emissions),
green_lmp,
})
}
fn compute_green_lmp(&self, dispatch: &[f64], w: f64) -> Vec<f64> {
let n = self.config.n_buses;
if n == 0 {
return Vec::new();
}
let mut lmp = vec![0.0_f64; n];
for (i, gen) in self.generators.iter().enumerate() {
if dispatch[i] <= gen.p_min_mw + 1e-9 {
continue; }
let bus = gen.bus.min(n - 1);
let aug = w * gen.energy_cost_usd_per_mwh
+ self.config.carbon_price_usd_per_t * gen.co2_rate_t_per_mwh;
if aug > lmp[bus] {
lmp[bus] = aug;
}
}
let system_lmp: f64 = if lmp.iter().any(|&v| v > 0.0) {
lmp.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
} else {
0.0
};
for l in lmp.iter_mut() {
if *l < 1e-12 {
*l = system_lmp;
}
}
lmp
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_config() -> CarbonOpfConfig {
CarbonOpfConfig {
base_mva: 100.0,
n_buses: 3,
carbon_limit_t_per_h: None,
carbon_price_usd_per_t: 0.0,
renewable_priority: false,
dual_objective_weight: 1.0, }
}
fn coal_gen(bus: usize, p_min: f64, p_max: f64, cost: f64) -> GeneratorCarbon {
GeneratorCarbon {
bus,
p_max_mw: p_max,
p_min_mw: p_min,
energy_cost_usd_per_mwh: cost,
co2_rate_t_per_mwh: 0.9,
is_must_run: false,
p_fixed_mw: None,
}
}
fn solar_gen(bus: usize, p_max: f64) -> GeneratorCarbon {
GeneratorCarbon {
bus,
p_max_mw: p_max,
p_min_mw: 0.0,
energy_cost_usd_per_mwh: 5.0,
co2_rate_t_per_mwh: 0.0,
is_must_run: false,
p_fixed_mw: None,
}
}
#[test]
fn test_no_carbon_limit_matches_economic_dispatch() {
let mut solver = CarbonOpfSolver::new(base_config());
solver.set_network(vec![], vec![80.0, 0.0, 0.0]);
solver.add_generator(coal_gen(0, 0.0, 100.0, 30.0)); solver.add_generator(coal_gen(1, 0.0, 100.0, 80.0)); let res = solver.solve().expect("solve");
assert!(
res.dispatch_mw[0] >= res.dispatch_mw[1],
"cheap gen should dispatch ≥ expensive gen: {:?}",
res.dispatch_mw
);
}
#[test]
fn test_tight_carbon_cap_increases_renewable() {
let mut cfg = base_config();
cfg.carbon_limit_t_per_h = Some(20.0); let mut solver = CarbonOpfSolver::new(cfg);
solver.set_network(vec![], vec![80.0, 0.0, 0.0]);
solver.add_generator(coal_gen(0, 0.0, 100.0, 30.0));
solver.add_generator(solar_gen(1, 100.0));
let res = solver.solve().expect("solve with cap");
assert!(
res.dispatch_mw[1] > 0.0,
"renewable should be dispatched under tight cap"
);
assert!(
res.total_emissions_t_per_h <= 21.0,
"emissions {} should be near cap",
res.total_emissions_t_per_h
);
}
#[test]
fn test_higher_carbon_price_more_renewable() {
let make_solver = |carbon_price: f64| {
let mut cfg = base_config();
cfg.carbon_price_usd_per_t = carbon_price;
let mut solver = CarbonOpfSolver::new(cfg);
solver.set_network(vec![], vec![80.0, 0.0, 0.0]);
solver.add_generator(coal_gen(0, 0.0, 100.0, 30.0));
solver.add_generator(solar_gen(1, 100.0));
solver
};
let low = make_solver(0.0).solve().expect("low price solve");
let high = make_solver(500.0).solve().expect("high price solve");
assert!(
high.dispatch_mw[1] >= low.dispatch_mw[1],
"higher carbon price should increase solar dispatch"
);
}
#[test]
fn test_pareto_front_tradeoff() {
let mut solver = CarbonOpfSolver::new(base_config());
solver.set_network(vec![], vec![80.0, 0.0, 0.0]);
solver.add_generator(coal_gen(0, 0.0, 100.0, 30.0));
solver.add_generator(solar_gen(1, 100.0));
let front = solver.pareto_front(5).expect("pareto_front");
assert_eq!(front.len(), 5, "should return 5 Pareto points");
let first = &front[0];
let last = &front[4];
let cost_range = (last.total_cost_usd_per_h - first.total_cost_usd_per_h).abs();
let emit_range = (last.total_emissions_t_per_h - first.total_emissions_t_per_h).abs();
assert!(
cost_range + emit_range > 0.0,
"Pareto front should show cost-emission trade-off"
);
}
#[test]
fn test_green_lmp_nonempty() {
let mut solver = CarbonOpfSolver::new(base_config());
solver.set_network(vec![], vec![50.0, 0.0, 0.0]);
solver.add_generator(coal_gen(0, 0.0, 100.0, 40.0));
let res = solver.solve().expect("solve");
assert_eq!(
res.green_lmp.len(),
3,
"green_lmp length should equal n_buses"
);
}
}