use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DecommitError {
#[error("no units added to the decommitter")]
NoUnits,
#[error("load vector is empty")]
EmptyLoad,
#[error("load vector length {got} does not match n_hours {expected}")]
LoadLengthMismatch { got: usize, expected: usize },
#[error("infeasible: total capacity {capacity_mw:.1} MW < peak load {peak_mw:.1} MW")]
Infeasible { capacity_mw: f64, peak_mw: f64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecommitmentConfig {
pub n_hours: usize,
pub base_load_mw: Vec<f64>,
pub reserve_margin_pct: f64,
pub startup_shutdown_cost: bool,
pub min_online_units: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecommitUnit {
pub id: usize,
pub p_min_mw: f64,
pub p_max_mw: f64,
pub variable_cost_usd_per_mwh: f64,
pub no_load_cost_usd_per_h: f64,
pub shutdown_cost_usd: f64,
pub startup_cost_usd: f64,
pub min_down_hours: usize,
pub initially_online: bool,
pub is_must_run: bool,
}
impl DecommitUnit {
pub fn full_load_cost_per_h(&self) -> f64 {
self.no_load_cost_usd_per_h + self.p_max_mw * self.variable_cost_usd_per_mwh
}
pub fn dispatch_cost_per_h(&self, p_mw: f64) -> f64 {
self.no_load_cost_usd_per_h + p_mw * self.variable_cost_usd_per_mwh
}
pub fn average_cost_per_mwh(&self) -> f64 {
if self.p_max_mw <= 0.0 {
return f64::MAX;
}
(self.no_load_cost_usd_per_h + self.p_max_mw * self.variable_cost_usd_per_mwh)
/ self.p_max_mw
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecommitSchedule {
pub unit_id: usize,
pub status: Vec<bool>,
pub dispatch_mw: Vec<f64>,
pub hours_offline: usize,
pub savings_usd: f64,
pub shutdown_cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecommitResult {
pub schedules: Vec<DecommitSchedule>,
pub total_cost_usd: f64,
pub total_savings_usd: f64,
pub savings_pct: f64,
pub load_always_served: bool,
pub reserve_always_met: bool,
pub n_units_decommitted: usize,
}
fn dispatch_units(units: &[DecommitUnit], online_mask: &[bool], load_mw: f64) -> Vec<f64> {
let n = units.len();
let mut dispatch = vec![0.0_f64; n];
let mut order: Vec<usize> = (0..n).filter(|&i| online_mask[i]).collect();
order.sort_by(|&a, &b| {
units[a]
.variable_cost_usd_per_mwh
.partial_cmp(&units[b].variable_cost_usd_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut remaining = load_mw;
for &i in &order {
let unit = &units[i];
if remaining <= 0.0 {
break;
}
let p = remaining
.min(unit.p_max_mw)
.max(unit.p_min_mw.min(remaining));
dispatch[i] = p;
remaining -= p;
}
dispatch
}
pub struct UnitDecommitter {
config: DecommitmentConfig,
units: Vec<DecommitUnit>,
}
impl UnitDecommitter {
pub fn new(config: DecommitmentConfig) -> Self {
Self {
config,
units: Vec::new(),
}
}
pub fn add_unit(&mut self, unit: DecommitUnit) {
self.units.push(unit);
}
fn available_capacity(units: &[DecommitUnit], mask: &[bool]) -> f64 {
units
.iter()
.zip(mask.iter())
.filter(|(_, &on)| on)
.map(|(u, _)| u.p_max_mw)
.sum()
}
fn min_generation(units: &[DecommitUnit], mask: &[bool]) -> f64 {
units
.iter()
.zip(mask.iter())
.filter(|(_, &on)| on)
.map(|(u, _)| u.p_min_mw)
.sum()
}
fn commitment_feasible(
units: &[DecommitUnit],
mask: &[bool],
loads_mw: &[f64],
reserve_frac: f64,
min_online: usize,
) -> bool {
let n_online: usize = mask.iter().filter(|&&x| x).count();
if n_online < min_online {
return false;
}
let cap = Self::available_capacity(units, mask);
let gen_min = Self::min_generation(units, mask);
for &load in loads_mw {
let required_with_reserve = load * (1.0 + reserve_frac);
if cap < required_with_reserve {
return false;
}
if gen_min > load {
return false;
}
}
true
}
pub fn operating_cost(&self, commitment: &[bool], dispatch: &[Vec<f64>]) -> f64 {
let mut total = 0.0;
for h in 0..self.config.n_hours {
for (i, unit) in self.units.iter().enumerate() {
if commitment[i] && h < dispatch.len() && i < dispatch[h].len() {
total += unit.dispatch_cost_per_h(dispatch[h][i]);
}
}
}
total
}
fn baseline_cost(&self, loads_mw: &[f64]) -> f64 {
let n = self.units.len();
let mask: Vec<bool> = self.units.iter().map(|u| u.initially_online).collect();
let mut total = 0.0_f64;
for &load in loads_mw {
let disp = dispatch_units(&self.units, &mask, load);
for i in 0..n {
if mask[i] {
total += self.units[i].dispatch_cost_per_h(disp[i]);
}
}
}
total
}
pub fn optimize(&self) -> Result<DecommitResult, DecommitError> {
if self.units.is_empty() {
return Err(DecommitError::NoUnits);
}
if self.config.base_load_mw.is_empty() {
return Err(DecommitError::EmptyLoad);
}
if self.config.base_load_mw.len() != self.config.n_hours {
return Err(DecommitError::LoadLengthMismatch {
got: self.config.base_load_mw.len(),
expected: self.config.n_hours,
});
}
let loads_mw = &self.config.base_load_mw;
let peak_mw = loads_mw.iter().cloned().fold(0.0_f64, f64::max);
let n = self.units.len();
let mut commitment: Vec<bool> = self.units.iter().map(|u| u.initially_online).collect();
let total_cap: f64 = self.units.iter().map(|u| u.p_max_mw).sum();
if total_cap < peak_mw * (1.0 + self.config.reserve_margin_pct) {
return Err(DecommitError::Infeasible {
capacity_mw: total_cap,
peak_mw,
});
}
let baseline_cost = self.baseline_cost(loads_mw);
let mut candidates: Vec<usize> = (0..n)
.filter(|&i| self.units[i].initially_online && !self.units[i].is_must_run)
.collect();
candidates.sort_by(|&a, &b| {
self.units[b]
.average_cost_per_mwh()
.partial_cmp(&self.units[a].average_cost_per_mwh())
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut decommitted: Vec<bool> = vec![false; n];
let mut changed = true;
while changed {
changed = false;
for &unit_idx in &candidates {
if decommitted[unit_idx] || !commitment[unit_idx] {
continue;
}
commitment[unit_idx] = false;
if Self::commitment_feasible(
&self.units,
&commitment,
loads_mw,
self.config.reserve_margin_pct,
self.config.min_online_units,
) {
decommitted[unit_idx] = true;
changed = true;
} else {
commitment[unit_idx] = true;
}
}
}
let mut hourly_dispatch: Vec<Vec<f64>> = Vec::with_capacity(self.config.n_hours);
for &load in loads_mw {
hourly_dispatch.push(dispatch_units(&self.units, &commitment, load));
}
let mut total_cost_usd = 0.0_f64;
let mut schedules: Vec<DecommitSchedule> = Vec::with_capacity(n);
let mut n_units_decommitted = 0_usize;
for (i, unit) in self.units.iter().enumerate() {
let on = commitment[i];
let status = vec![on; self.config.n_hours];
let dispatch: Vec<f64> = (0..self.config.n_hours)
.map(|h| if on { hourly_dispatch[h][i] } else { 0.0 })
.collect();
let unit_cost: f64 = (0..self.config.n_hours)
.map(|h| {
if on {
unit.dispatch_cost_per_h(dispatch[h])
} else {
0.0
}
})
.sum();
total_cost_usd += unit_cost;
let unit_baseline: f64 = if unit.initially_online {
loads_mw
.iter()
.map(|&load| {
let d = dispatch_units(
&self.units,
&vec![true; n], load,
);
unit.dispatch_cost_per_h(d[i])
})
.sum()
} else {
0.0
};
let raw_savings = unit_baseline - unit_cost;
let sd_cost = if !on && self.config.startup_shutdown_cost {
unit.shutdown_cost_usd
} else {
0.0
};
let savings_usd = raw_savings - sd_cost;
let hours_offline = if on { 0 } else { self.config.n_hours };
if !on && unit.initially_online {
n_units_decommitted += 1;
}
schedules.push(DecommitSchedule {
unit_id: unit.id,
status,
dispatch_mw: dispatch,
hours_offline,
savings_usd,
shutdown_cost_usd: sd_cost,
});
}
let mut load_always_served = true;
let mut reserve_always_met = true;
for &load in loads_mw {
let cap = Self::available_capacity(&self.units, &commitment);
if cap < load {
load_always_served = false;
}
if cap < load * (1.0 + self.config.reserve_margin_pct) {
reserve_always_met = false;
}
}
let total_savings_usd = baseline_cost - total_cost_usd;
let savings_pct = if baseline_cost > 0.0 {
total_savings_usd / baseline_cost * 100.0
} else {
0.0
};
Ok(DecommitResult {
schedules,
total_cost_usd,
total_savings_usd,
savings_pct,
load_always_served,
reserve_always_met,
n_units_decommitted,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cheap_unit(id: usize) -> DecommitUnit {
DecommitUnit {
id,
p_min_mw: 20.0,
p_max_mw: 100.0,
variable_cost_usd_per_mwh: 10.0,
no_load_cost_usd_per_h: 50.0,
shutdown_cost_usd: 500.0,
startup_cost_usd: 1000.0,
min_down_hours: 2,
initially_online: true,
is_must_run: false,
}
}
fn expensive_unit(id: usize) -> DecommitUnit {
DecommitUnit {
id,
p_min_mw: 10.0,
p_max_mw: 80.0,
variable_cost_usd_per_mwh: 50.0,
no_load_cost_usd_per_h: 200.0,
shutdown_cost_usd: 100.0,
startup_cost_usd: 200.0,
min_down_hours: 1,
initially_online: true,
is_must_run: false,
}
}
fn must_run_unit(id: usize) -> DecommitUnit {
DecommitUnit {
id,
p_min_mw: 30.0,
p_max_mw: 60.0,
variable_cost_usd_per_mwh: 80.0, no_load_cost_usd_per_h: 300.0,
shutdown_cost_usd: 0.0,
startup_cost_usd: 0.0,
min_down_hours: 0,
initially_online: true,
is_must_run: true,
}
}
fn config_4h(load_mw: f64) -> DecommitmentConfig {
DecommitmentConfig {
n_hours: 4,
base_load_mw: vec![load_mw; 4],
reserve_margin_pct: 0.15,
startup_shutdown_cost: true,
min_online_units: 1,
}
}
#[test]
fn test_expensive_unit_decommitted_at_low_load() {
let mut dc = UnitDecommitter::new(config_4h(50.0));
dc.add_unit(cheap_unit(0)); dc.add_unit(expensive_unit(1));
let result = dc.optimize().expect("optimize failed");
let exp_sched = result
.schedules
.iter()
.find(|s| s.unit_id == 1)
.expect("missing unit 1");
assert!(
!exp_sched.status[0],
"Expensive unit should be decommitted at 50 MW load"
);
assert!(
result.n_units_decommitted >= 1,
"At least one unit should be decommitted"
);
}
#[test]
fn test_must_run_never_decommitted() {
let mut dc = UnitDecommitter::new(config_4h(30.0));
dc.add_unit(cheap_unit(0)); dc.add_unit(must_run_unit(1));
let result = dc.optimize().expect("optimize failed");
let mr_sched = result
.schedules
.iter()
.find(|s| s.unit_id == 1)
.expect("missing must-run");
for &status in &mr_sched.status {
assert!(status, "Must-run unit should never be decommitted");
}
}
#[test]
fn test_reserve_constraint_maintained() {
let config = DecommitmentConfig {
n_hours: 2,
base_load_mw: vec![200.0, 200.0],
reserve_margin_pct: 0.15,
startup_shutdown_cost: false,
min_online_units: 1,
};
let mut dc = UnitDecommitter::new(config);
dc.add_unit(DecommitUnit {
id: 0,
p_min_mw: 0.0,
p_max_mw: 100.0,
variable_cost_usd_per_mwh: 10.0,
no_load_cost_usd_per_h: 0.0,
shutdown_cost_usd: 0.0,
startup_cost_usd: 0.0,
min_down_hours: 0,
initially_online: true,
is_must_run: false,
});
dc.add_unit(DecommitUnit {
id: 1,
p_min_mw: 0.0,
p_max_mw: 100.0,
variable_cost_usd_per_mwh: 20.0,
no_load_cost_usd_per_h: 0.0,
shutdown_cost_usd: 0.0,
startup_cost_usd: 0.0,
min_down_hours: 0,
initially_online: true,
is_must_run: false,
});
dc.add_unit(DecommitUnit {
id: 2,
p_min_mw: 0.0,
p_max_mw: 100.0,
variable_cost_usd_per_mwh: 50.0,
no_load_cost_usd_per_h: 0.0,
shutdown_cost_usd: 0.0,
startup_cost_usd: 0.0,
min_down_hours: 0,
initially_online: true,
is_must_run: false,
});
let result = dc.optimize().expect("optimize failed");
assert!(
result.reserve_always_met,
"Reserve constraint should always be met"
);
}
#[test]
fn test_savings_positive_without_marginal_unit() {
let mut dc = UnitDecommitter::new(config_4h(60.0));
dc.add_unit(DecommitUnit {
id: 0,
p_min_mw: 0.0,
p_max_mw: 100.0,
variable_cost_usd_per_mwh: 10.0,
no_load_cost_usd_per_h: 0.0,
shutdown_cost_usd: 0.0,
startup_cost_usd: 0.0,
min_down_hours: 0,
initially_online: true,
is_must_run: false,
});
dc.add_unit(DecommitUnit {
id: 1,
p_min_mw: 0.0,
p_max_mw: 100.0,
variable_cost_usd_per_mwh: 100.0,
no_load_cost_usd_per_h: 500.0,
shutdown_cost_usd: 0.0,
startup_cost_usd: 0.0,
min_down_hours: 0,
initially_online: true,
is_must_run: false,
});
let result = dc.optimize().expect("optimize failed");
let sched1 = result
.schedules
.iter()
.find(|s| s.unit_id == 1)
.expect("missing unit 1");
assert!(!sched1.status[0], "Expensive unit should be decommitted");
assert!(
result.total_savings_usd > 0.0,
"Total savings should be positive, got {:.2}",
result.total_savings_usd
);
}
#[test]
fn test_infeasible_when_capacity_insufficient() {
let config = DecommitmentConfig {
n_hours: 1,
base_load_mw: vec![500.0], reserve_margin_pct: 0.10,
startup_shutdown_cost: false,
min_online_units: 1,
};
let mut dc = UnitDecommitter::new(config);
dc.add_unit(cheap_unit(0));
let err = dc.optimize().expect_err("should fail with Infeasible");
assert!(
matches!(err, DecommitError::Infeasible { .. }),
"Expected Infeasible error, got {err}"
);
}
#[test]
fn test_min_online_units_respected() {
let config = DecommitmentConfig {
n_hours: 2,
base_load_mw: vec![1.0, 1.0], reserve_margin_pct: 0.0,
startup_shutdown_cost: false,
min_online_units: 2, };
let mut dc = UnitDecommitter::new(config);
dc.add_unit(cheap_unit(0));
dc.add_unit(expensive_unit(1));
dc.add_unit(must_run_unit(2));
let result = dc.optimize().expect("optimize failed");
let n_on: usize = result.schedules.iter().filter(|s| s.status[0]).count();
assert!(
n_on >= 2,
"At least 2 units should remain online, got {n_on}"
);
}
}