use thiserror::Error;
#[derive(Debug, Error)]
pub enum CurtailmentError {
#[error("Data length {got} does not match n_hours {expected}")]
LengthMismatch { got: usize, expected: usize },
#[error("No renewable units registered")]
NoUnits,
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenewableTech {
Solar,
Wind,
RunOfRiver,
Tidal,
Geothermal,
}
#[derive(Debug, Clone)]
pub struct CurtailmentConfig {
pub n_hours: usize,
pub system_voltage_kv: f64,
pub base_mva: f64,
pub curtailment_cost_usd_per_mwh: f64,
pub compensation_rate_usd_per_mwh: f64,
}
#[derive(Debug, Clone)]
pub struct RenewableUnit {
pub id: usize,
pub bus: usize,
pub technology: RenewableTech,
pub rated_mw: f64,
pub available_mw: Vec<f64>,
pub marginal_cost: f64,
}
#[derive(Debug, Clone, Default)]
pub struct CurtailmentReason {
pub technical_curtailment: bool,
pub economic_curtailment: bool,
pub frequency_curtailment: bool,
pub voltage_curtailment: bool,
pub congestion_curtailment: bool,
}
#[derive(Debug, Clone)]
pub struct HourlyCurtailment {
pub hour: usize,
pub unit_id: usize,
pub available_mw: f64,
pub dispatched_mw: f64,
pub curtailed_mw: f64,
pub curtailment_pct: f64,
pub reason: CurtailmentReason,
pub curtailment_cost_usd: f64,
}
#[derive(Debug, Clone, Default)]
pub struct CurtailmentByReason {
pub technical_gwh: f64,
pub economic_gwh: f64,
pub frequency_gwh: f64,
pub voltage_gwh: f64,
pub congestion_gwh: f64,
}
#[derive(Debug, Clone)]
pub struct CurtailmentAnalysisResult {
pub hourly: Vec<HourlyCurtailment>,
pub total_available_gwh: f64,
pub total_dispatched_gwh: f64,
pub total_curtailed_gwh: f64,
pub curtailment_rate_pct: f64,
pub peak_curtailment_mw: f64,
pub peak_curtailment_hour: usize,
pub by_reason: CurtailmentByReason,
pub total_cost_usd: f64,
pub co2_avoided_less_t: f64,
pub grid_upgrade_benefit_mw: f64,
}
pub struct CurtailmentAnalyzer {
config: CurtailmentConfig,
units: Vec<RenewableUnit>,
load_mw: Vec<f64>,
line_ratings: Vec<f64>,
freq_limits: (f64, f64),
voltage_limits: (f64, f64),
spot_prices: Vec<f64>,
}
impl CurtailmentAnalyzer {
pub fn new(config: CurtailmentConfig) -> Self {
let n = config.n_hours;
Self {
config,
units: Vec::new(),
load_mw: vec![0.0; n],
line_ratings: Vec::new(),
freq_limits: (49.0, 51.0),
voltage_limits: (0.9, 1.1),
spot_prices: vec![0.0; n],
}
}
pub fn add_unit(&mut self, unit: RenewableUnit) {
self.units.push(unit);
}
pub fn set_load(&mut self, load_mw: Vec<f64>) {
self.load_mw = load_mw;
}
pub fn set_spot_prices(&mut self, prices: Vec<f64>) {
self.spot_prices = prices;
}
pub fn set_line_ratings(&mut self, ratings: Vec<f64>) {
self.line_ratings = ratings;
}
pub fn set_freq_limits(&mut self, min_hz: f64, max_hz: f64) {
self.freq_limits = (min_hz, max_hz);
}
pub fn set_voltage_limits(&mut self, min_pu: f64, max_pu: f64) {
self.voltage_limits = (min_pu, max_pu);
}
pub fn analyze(&self) -> Result<CurtailmentAnalysisResult, CurtailmentError> {
if self.units.is_empty() {
return Err(CurtailmentError::NoUnits);
}
let n = self.config.n_hours;
let min_line_rating = self
.line_ratings
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
let effective_line_cap = if self.line_ratings.is_empty() {
f64::INFINITY
} else {
min_line_rating
};
let mut hourly: Vec<HourlyCurtailment> = Vec::new();
let mut total_available = 0.0f64;
let mut total_dispatched = 0.0f64;
let mut total_curtailed = 0.0f64;
let mut peak_curt_mw = 0.0f64;
let mut peak_curt_hour = 0usize;
let mut total_cost = 0.0f64;
let mut by_reason = CurtailmentByReason::default();
const CO2_INTENSITY: f64 = 0.45;
for h in 0..n {
let load = *self.load_mw.get(h).unwrap_or(&0.0);
let price = *self.spot_prices.get(h).unwrap_or(&50.0);
let total_avail_h: f64 = self
.units
.iter()
.map(|u| *u.available_mw.get(h).unwrap_or(&0.0))
.sum();
let mut max_dispatch = total_avail_h;
let mut reason = CurtailmentReason::default();
if price < 0.0 {
max_dispatch = 0.0;
reason.economic_curtailment = true;
}
if total_avail_h > load * 1.05 && load > 0.0 {
let freq_limited = load * 1.05;
if freq_limited < max_dispatch {
max_dispatch = freq_limited;
reason.frequency_curtailment = true;
}
}
if total_avail_h > effective_line_cap && effective_line_cap < max_dispatch {
max_dispatch = effective_line_cap;
reason.congestion_curtailment = true;
reason.technical_curtailment = true;
}
let vmax = self.voltage_limits.1;
if total_avail_h > 0.9 * load && vmax < 1.05 && load > 0.0 {
let volt_limited = 0.9 * load;
if volt_limited < max_dispatch {
max_dispatch = volt_limited;
reason.voltage_curtailment = true;
reason.technical_curtailment = true;
}
}
max_dispatch = max_dispatch.max(0.0);
let total_curtailed_h = (total_avail_h - max_dispatch).max(0.0);
for unit in &self.units {
let avail = *unit.available_mw.get(h).unwrap_or(&0.0);
let unit_curt = if total_avail_h > 1e-9 {
total_curtailed_h * (avail / total_avail_h)
} else {
0.0
};
let dispatched = (avail - unit_curt).max(0.0);
let curt_pct = if avail > 1e-9 {
unit_curt / avail * 100.0
} else {
0.0
};
let cost = unit_curt * self.config.compensation_rate_usd_per_mwh;
total_available += avail;
total_dispatched += dispatched;
total_curtailed += unit_curt;
total_cost += cost;
if unit_curt > peak_curt_mw {
peak_curt_mw = unit_curt;
peak_curt_hour = h;
}
hourly.push(HourlyCurtailment {
hour: h,
unit_id: unit.id,
available_mw: avail,
dispatched_mw: dispatched,
curtailed_mw: unit_curt,
curtailment_pct: curt_pct,
reason: reason.clone(),
curtailment_cost_usd: cost,
});
}
let curt_gwh = total_curtailed_h / 1000.0;
if reason.technical_curtailment {
by_reason.technical_gwh += curt_gwh;
}
if reason.economic_curtailment {
by_reason.economic_gwh += curt_gwh;
}
if reason.frequency_curtailment {
by_reason.frequency_gwh += curt_gwh;
}
if reason.voltage_curtailment {
by_reason.voltage_gwh += curt_gwh;
}
if reason.congestion_curtailment {
by_reason.congestion_gwh += curt_gwh;
}
}
let avail_gwh = total_available / 1000.0;
let disp_gwh = total_dispatched / 1000.0;
let curt_gwh = total_curtailed / 1000.0;
let curt_rate = if avail_gwh > 1e-9 {
curt_gwh / avail_gwh * 100.0
} else {
0.0
};
let grid_upgrade_benefit = self.estimate_upgrade_benefit(&hourly);
Ok(CurtailmentAnalysisResult {
hourly,
total_available_gwh: avail_gwh,
total_dispatched_gwh: disp_gwh,
total_curtailed_gwh: curt_gwh,
curtailment_rate_pct: curt_rate,
peak_curtailment_mw: peak_curt_mw,
peak_curtailment_hour: peak_curt_hour,
by_reason,
total_cost_usd: total_cost,
co2_avoided_less_t: curt_gwh * 1000.0 * CO2_INTENSITY, grid_upgrade_benefit_mw: grid_upgrade_benefit,
})
}
fn estimate_upgrade_benefit(&self, results: &[HourlyCurtailment]) -> f64 {
let congestion_only: Vec<f64> = results
.iter()
.filter(|r| r.reason.congestion_curtailment && r.curtailed_mw > 0.0)
.map(|r| r.curtailed_mw)
.collect();
if congestion_only.is_empty() {
return 0.0;
}
let mut sorted = congestion_only;
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let idx = ((sorted.len() as f64 * 0.95) as usize).min(sorted.len() - 1);
sorted[idx]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_unit(id: usize, available: Vec<f64>) -> RenewableUnit {
RenewableUnit {
id,
bus: id,
technology: RenewableTech::Wind,
rated_mw: 100.0,
available_mw: available,
marginal_cost: 0.0,
}
}
fn make_config(n: usize) -> CurtailmentConfig {
CurtailmentConfig {
n_hours: n,
system_voltage_kv: 110.0,
base_mva: 100.0,
curtailment_cost_usd_per_mwh: 60.0,
compensation_rate_usd_per_mwh: 50.0,
}
}
#[test]
fn test_no_curtailment_when_renewable_lt_load() {
let n = 24;
let mut analyzer = CurtailmentAnalyzer::new(make_config(n));
analyzer.add_unit(make_unit(0, vec![50.0; n]));
analyzer.set_load(vec![200.0; n]);
analyzer.set_spot_prices(vec![50.0; n]);
let result = analyzer.analyze().expect("ok");
assert!(
result.total_curtailed_gwh < 1e-9,
"No curtailment when renewable < load: {}",
result.total_curtailed_gwh
);
assert!(result.curtailment_rate_pct < 1e-9);
}
#[test]
fn test_economic_curtailment_negative_prices() {
let n = 24;
let mut analyzer = CurtailmentAnalyzer::new(make_config(n));
analyzer.add_unit(make_unit(0, vec![100.0; n]));
analyzer.set_load(vec![200.0; n]);
let mut prices = vec![-10.0; 12];
prices.extend(vec![50.0; 12]);
analyzer.set_spot_prices(prices);
let result = analyzer.analyze().expect("ok");
assert!(
result.total_curtailed_gwh > 0.0,
"Economic curtailment expected"
);
let econ_hours: Vec<_> = result
.hourly
.iter()
.filter(|r| r.reason.economic_curtailment && r.curtailed_mw > 0.0)
.collect();
assert!(
!econ_hours.is_empty(),
"Some hours should show economic curtailment"
);
}
#[test]
fn test_congestion_curtailment_line_limit_hit() {
let n = 4;
let mut analyzer = CurtailmentAnalyzer::new(make_config(n));
analyzer.add_unit(make_unit(0, vec![200.0; n]));
analyzer.set_load(vec![300.0; n]);
analyzer.set_spot_prices(vec![50.0; n]);
analyzer.set_line_ratings(vec![80.0]);
let result = analyzer.analyze().expect("ok");
assert!(
result.total_curtailed_gwh > 0.0,
"Congestion curtailment expected: {:?}",
result.total_curtailed_gwh
);
let congestion_hours: Vec<_> = result
.hourly
.iter()
.filter(|r| r.reason.congestion_curtailment)
.collect();
assert!(
!congestion_hours.is_empty(),
"Congestion reason should be set"
);
}
#[test]
fn test_cost_calculated_correctly() {
let n = 1;
let mut analyzer = CurtailmentAnalyzer::new(CurtailmentConfig {
n_hours: n,
system_voltage_kv: 110.0,
base_mva: 100.0,
curtailment_cost_usd_per_mwh: 60.0,
compensation_rate_usd_per_mwh: 50.0,
});
analyzer.add_unit(make_unit(0, vec![100.0]));
analyzer.set_load(vec![200.0]);
analyzer.set_spot_prices(vec![-5.0]);
let result = analyzer.analyze().expect("ok");
assert!(
(result.total_cost_usd - 5000.0).abs() < 1e-6,
"Cost should be $5000, got ${:.2}",
result.total_cost_usd
);
}
#[test]
fn test_co2_impact_from_curtailment() {
let n = 1;
let mut analyzer = CurtailmentAnalyzer::new(make_config(n));
analyzer.add_unit(make_unit(0, vec![100.0]));
analyzer.set_load(vec![200.0]);
analyzer.set_spot_prices(vec![-1.0]);
let result = analyzer.analyze().expect("ok");
assert!(result.total_curtailed_gwh > 0.0, "Should have curtailment");
let expected_co2 = result.total_curtailed_gwh * 1000.0 * 0.45;
assert!(
(result.co2_avoided_less_t - expected_co2).abs() < 1e-6,
"CO2 {:.2} vs expected {:.2}",
result.co2_avoided_less_t,
expected_co2
);
}
#[test]
fn test_peak_curtailment_tracked_correctly() {
let n = 4;
let mut analyzer = CurtailmentAnalyzer::new(make_config(n));
let available = vec![50.0, 50.0, 200.0, 200.0];
analyzer.add_unit(make_unit(0, available));
analyzer.set_load(vec![100.0; n]);
analyzer.set_spot_prices(vec![50.0; n]);
let result = analyzer.analyze().expect("ok");
if result.peak_curtailment_mw > 0.0 {
assert!(
result.peak_curtailment_hour >= 2,
"Peak should be at h=2 or h=3, got {}",
result.peak_curtailment_hour
);
}
}
#[test]
fn test_multiple_units_proportional_curtailment() {
let n = 2;
let mut analyzer = CurtailmentAnalyzer::new(make_config(n));
analyzer.add_unit(make_unit(0, vec![60.0; n]));
analyzer.add_unit(make_unit(1, vec![40.0; n]));
analyzer.set_load(vec![200.0; n]);
analyzer.set_spot_prices(vec![50.0; n]);
analyzer.set_line_ratings(vec![50.0]);
let result = analyzer.analyze().expect("ok");
let unit0_h0 = result
.hourly
.iter()
.find(|r| r.hour == 0 && r.unit_id == 0)
.map(|r| r.curtailed_mw)
.unwrap_or(0.0);
let unit1_h0 = result
.hourly
.iter()
.find(|r| r.hour == 0 && r.unit_id == 1)
.map(|r| r.curtailed_mw)
.unwrap_or(0.0);
assert!(
(unit0_h0 - 30.0).abs() < 1e-6,
"Unit 0 curtailment should be 30 MW, got {unit0_h0:.4}"
);
assert!(
(unit1_h0 - 20.0).abs() < 1e-6,
"Unit 1 curtailment should be 20 MW, got {unit1_h0:.4}"
);
}
}