use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrProgramType {
DirectLoadControl,
Interruptible,
TimeOfUse,
CriticalPeakPricing,
RealTimePricing,
EmergencyDr,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrOffer {
pub load_id: usize,
pub bus_id: usize,
pub program_type: DrProgramType,
pub curtailment_mw: f64,
pub offer_price: f64,
pub voll: f64,
pub notice_minutes: f64,
pub max_duration_h: f64,
pub rebound_fraction: f64,
}
impl DrOffer {
pub fn simple(load_id: usize, bus_id: usize, curtailment_mw: f64, offer_price: f64) -> Self {
Self {
load_id,
bus_id,
program_type: DrProgramType::Interruptible,
curtailment_mw,
offer_price,
voll: 10_000.0,
notice_minutes: 10.0,
max_duration_h: 4.0,
rebound_fraction: 0.0,
}
}
pub fn rebound_mw(&self) -> f64 {
self.curtailment_mw * self.rebound_fraction
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrPortfolio {
pub offers: Vec<DrOffer>,
}
impl DrPortfolio {
pub fn new() -> Self {
Self { offers: Vec::new() }
}
pub fn add_offer(&mut self, offer: DrOffer) {
self.offers.push(offer);
}
pub fn total_curtailment_mw(&self) -> f64 {
self.offers.iter().map(|o| o.curtailment_mw).sum()
}
pub fn merit_order(&self) -> Vec<&DrOffer> {
let mut sorted: Vec<&DrOffer> = self.offers.iter().collect();
sorted.sort_by(|a, b| {
a.offer_price
.partial_cmp(&b.offer_price)
.unwrap_or(std::cmp::Ordering::Equal)
});
sorted
}
pub fn portfolio_voll(&self) -> f64 {
let total_mw = self.total_curtailment_mw();
if total_mw < 1e-9 {
return 0.0;
}
self.offers
.iter()
.map(|o| o.voll * o.curtailment_mw)
.sum::<f64>()
/ total_mw
}
pub fn clear_dr(&self, curtailment_target_mw: f64, max_price: f64) -> Result<DrClearingResult> {
if curtailment_target_mw < 0.0 {
return Err(OxiGridError::InvalidParameter(
"Curtailment target must be non-negative".to_string(),
));
}
let mut cleared = Vec::new();
let mut total_curtailed = 0.0;
let mut clearing_price = 0.0;
for offer in self.merit_order() {
if offer.offer_price > max_price {
break;
}
if total_curtailed >= curtailment_target_mw - 1e-9 {
break;
}
let available = curtailment_target_mw - total_curtailed;
let cleared_mw = offer.curtailment_mw.min(available);
cleared.push(DrClearedOffer {
load_id: offer.load_id,
bus_id: offer.bus_id,
cleared_mw,
offer_price: offer.offer_price,
rebound_mw: cleared_mw * offer.rebound_fraction,
});
total_curtailed += cleared_mw;
clearing_price = offer.offer_price;
}
let cost = cleared.iter().map(|c| c.cleared_mw * c.offer_price).sum();
Ok(DrClearingResult {
cleared_offers: cleared,
clearing_price,
total_curtailment_mw: total_curtailed,
total_cost: cost,
})
}
pub fn price_elasticity_response(
baseline_load_mw: f64,
baseline_price: f64,
new_price: f64,
elasticity: f64,
) -> f64 {
if baseline_price < 1e-9 || baseline_load_mw < 0.0 {
return 0.0;
}
let price_ratio = (new_price - baseline_price) / baseline_price;
let load_change = baseline_load_mw * elasticity * price_ratio;
(-load_change).max(0.0).min(baseline_load_mw)
}
}
impl Default for DrPortfolio {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrClearedOffer {
pub load_id: usize,
pub bus_id: usize,
pub cleared_mw: f64,
pub offer_price: f64,
pub rebound_mw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrClearingResult {
pub cleared_offers: Vec<DrClearedOffer>,
pub clearing_price: f64,
pub total_curtailment_mw: f64,
pub total_cost: f64,
}
impl DrClearingResult {
pub fn cost_benefit_ratio(&self, voll: f64, duration_h: f64) -> f64 {
let benefit = self.total_curtailment_mw * voll * duration_h;
if benefit < 1e-9 {
return f64::INFINITY;
}
self.total_cost * duration_h / benefit
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dr_portfolio_merit_order() {
let mut portfolio = DrPortfolio::new();
portfolio.add_offer(DrOffer::simple(0, 0, 10.0, 50.0));
portfolio.add_offer(DrOffer::simple(1, 0, 20.0, 30.0));
portfolio.add_offer(DrOffer::simple(2, 0, 15.0, 40.0));
let merit = portfolio.merit_order();
assert!((merit[0].offer_price - 30.0).abs() < 1e-9);
assert!((merit[1].offer_price - 40.0).abs() < 1e-9);
assert!((merit[2].offer_price - 50.0).abs() < 1e-9);
}
#[test]
fn test_dr_clearing_partial() {
let mut portfolio = DrPortfolio::new();
portfolio.add_offer(DrOffer::simple(0, 0, 10.0, 30.0));
portfolio.add_offer(DrOffer::simple(1, 0, 20.0, 50.0));
let result = portfolio.clear_dr(15.0, 100.0).unwrap();
assert!((result.total_curtailment_mw - 15.0).abs() < 1e-6);
assert_eq!(result.cleared_offers.len(), 2);
}
#[test]
fn test_dr_clearing_price_cap() {
let mut portfolio = DrPortfolio::new();
portfolio.add_offer(DrOffer::simple(0, 0, 10.0, 30.0));
portfolio.add_offer(DrOffer::simple(1, 0, 20.0, 80.0));
let result = portfolio.clear_dr(25.0, 60.0).unwrap();
assert!((result.total_curtailment_mw - 10.0).abs() < 1e-6);
}
#[test]
fn test_price_elasticity_response() {
let reduction = DrPortfolio::price_elasticity_response(100.0, 50.0, 55.0, -0.3);
assert!(
(reduction - 3.0).abs() < 0.01,
"Expected 3 MW reduction, got {reduction:.4}"
);
}
#[test]
fn test_portfolio_voll() {
let mut portfolio = DrPortfolio::new();
let mut offer1 = DrOffer::simple(0, 0, 10.0, 30.0);
offer1.voll = 5000.0;
let mut offer2 = DrOffer::simple(1, 0, 10.0, 40.0);
offer2.voll = 15000.0;
portfolio.add_offer(offer1);
portfolio.add_offer(offer2);
assert!((portfolio.portfolio_voll() - 10000.0).abs() < 1e-6);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum DrProgramKind {
DirectLoadControl,
InterruptibleLoad,
TimeOfUse,
CriticalPeakPricing,
RealTimePricing,
EmergencyDemandResponse,
EconomicDemandResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DrEventTrigger {
Price { threshold_per_mwh: f64 },
Reliability { reserve_shortage_mw: f64 },
Congestion { branch_idx: usize, loading_pct: f64 },
Operator,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrCustomer {
pub customer_id: usize,
pub bus: usize,
pub program: DrProgramKind,
pub baseline_mw: f64,
pub max_curtailment_mw: f64,
pub min_curtailment_mw: f64,
pub max_events_per_year: usize,
pub max_duration_h: f64,
pub notice_time_min: f64,
pub recovery_time_h: f64,
pub incentive_rate: f64,
pub elasticity: f64,
pub last_event_time: Option<f64>,
pub events_this_year: usize,
}
impl DrCustomer {
pub fn is_eligible(&self, current_time_h: f64) -> bool {
if self.events_this_year >= self.max_events_per_year {
return false;
}
if let Some(last_t) = self.last_event_time {
if current_time_h - last_t < self.recovery_time_h {
return false;
}
}
self.max_curtailment_mw > 1e-9
}
}
#[derive(Debug, Clone)]
pub struct DrProgramDispatchResult {
pub dispatched_customers: Vec<usize>,
pub curtailment_per_customer: Vec<f64>,
pub total_curtailment_mw: f64,
pub total_cost: f64,
pub n_customers_called: usize,
pub response_rate: f64,
}
pub struct DrProgramPortfolio {
pub customers: Vec<DrCustomer>,
pub program_type: DrProgramKind,
pub max_simultaneous_mw: f64,
pub event_counter: usize,
}
impl DrProgramPortfolio {
pub fn new(customers: Vec<DrCustomer>, program: DrProgramKind, max_mw: f64) -> Self {
Self {
customers,
program_type: program,
max_simultaneous_mw: max_mw,
event_counter: 0,
}
}
pub fn dispatch(
&mut self,
target_mw: f64,
current_time_h: f64,
_trigger: DrEventTrigger,
) -> Result<DrProgramDispatchResult> {
if target_mw < 0.0 {
return Err(OxiGridError::InvalidParameter(
"DR dispatch target cannot be negative".to_string(),
));
}
let effective_target = target_mw.min(self.max_simultaneous_mw);
let mut eligible_idx: Vec<usize> = self
.customers
.iter()
.enumerate()
.filter(|(_, c)| c.is_eligible(current_time_h))
.map(|(i, _)| i)
.collect();
eligible_idx.sort_by(|&a, &b| {
self.customers[a]
.incentive_rate
.partial_cmp(&self.customers[b].incentive_rate)
.unwrap_or(std::cmp::Ordering::Equal)
});
const RESPONSE_FACTOR: f64 = 0.85;
let mut dispatched_customers: Vec<usize> = Vec::new();
let mut curtailment_per_customer: Vec<f64> = Vec::new();
let mut remaining = effective_target;
let mut n_called = 0_usize;
let mut total_curtailment = 0.0_f64;
let mut total_cost = 0.0_f64;
for idx in &eligible_idx {
if remaining <= 1e-9 {
break;
}
n_called += 1;
let customer = &self.customers[*idx];
let raw_curtailment = customer.max_curtailment_mw.min(remaining);
let actual_curtailment = raw_curtailment * RESPONSE_FACTOR;
if actual_curtailment > 1e-9 {
dispatched_customers.push(customer.customer_id);
curtailment_per_customer.push(actual_curtailment);
total_cost += actual_curtailment * customer.incentive_rate;
total_curtailment += actual_curtailment;
remaining -= actual_curtailment;
}
}
for cust_id in &dispatched_customers {
if let Some(c) = self
.customers
.iter_mut()
.find(|c| c.customer_id == *cust_id)
{
c.last_event_time = Some(current_time_h);
c.events_this_year = c.events_this_year.saturating_add(1);
}
}
let response_rate = if n_called > 0 {
dispatched_customers.len() as f64 / n_called as f64
} else {
0.0
};
self.event_counter = self.event_counter.saturating_add(1);
Ok(DrProgramDispatchResult {
dispatched_customers,
curtailment_per_customer,
total_curtailment_mw: total_curtailment,
total_cost,
n_customers_called: n_called,
response_rate,
})
}
pub fn price_responsive_demand(
&self,
price_mwh: f64,
baseline_mw: f64,
price_elasticity: f64,
reference_price_mwh: f64,
) -> f64 {
let ref_price = reference_price_mwh.max(1e-12);
let relative_change = (price_mwh - ref_price) / ref_price;
let demand = baseline_mw * (1.0 + price_elasticity * relative_change);
demand.clamp(0.0, baseline_mw)
}
pub fn flexibility_supply_curve(&self, current_time_h: f64) -> Vec<(f64, f64)> {
let mut eligible: Vec<&DrCustomer> = self
.customers
.iter()
.filter(|c| c.is_eligible(current_time_h))
.collect();
eligible.sort_by(|a, b| {
a.incentive_rate
.partial_cmp(&b.incentive_rate)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut cumulative = 0.0_f64;
eligible
.into_iter()
.map(|c| {
cumulative += c.max_curtailment_mw;
(cumulative, c.incentive_rate)
})
.collect()
}
pub fn compute_baseline(historical_demand: &[f64], n_days: usize, hour: usize) -> f64 {
if n_days == 0 || hour >= 24 {
return 0.0;
}
let n_available = historical_demand.len() / 24;
let days_used = n_days.min(n_available);
if days_used == 0 {
return 0.0;
}
let sum: f64 = (0..days_used)
.filter_map(|day| {
let idx = day * 24 + hour;
historical_demand.get(idx).copied()
})
.sum();
let count = (0..days_used)
.filter(|&day| day * 24 + hour < historical_demand.len())
.count();
if count == 0 {
0.0
} else {
sum / count as f64
}
}
pub fn rebound_load(
curtailment_mw: f64,
rebound_factor: f64,
rebound_duration_h: f64,
) -> Vec<f64> {
let duration = rebound_duration_h.max(1.0);
let n_periods = duration.ceil() as usize;
let total_rebound_mw = curtailment_mw * rebound_factor.clamp(0.0, 1.0);
let per_hour = total_rebound_mw / duration;
vec![per_hour; n_periods]
}
}
pub fn compute_voll(sector: &str, duration_h: f64) -> f64 {
let base_voll: f64 = match sector {
"residential" => 10_000.0,
"commercial" => 15_000.0,
"industrial" => 25_000.0,
_ => 10_000.0,
};
let duration_factor = (1.0 + 0.1 * duration_h.max(0.0)).min(3.0);
base_voll * duration_factor
}
pub fn dr_cost_benefit(
curtailment_mwh: f64,
avoided_cost_per_mwh: f64,
incentive_cost_per_mwh: f64,
admin_cost: f64,
) -> (f64, f64, f64) {
let benefit = curtailment_mwh * avoided_cost_per_mwh;
let cost = curtailment_mwh * incentive_cost_per_mwh + admin_cost;
let net_benefit = benefit - cost;
(benefit, cost, net_benefit)
}
#[cfg(test)]
mod program_tests {
use super::*;
fn make_customer(id: usize, max_mw: f64, rate: f64, recovery_h: f64) -> DrCustomer {
DrCustomer {
customer_id: id,
bus: 1,
program: DrProgramKind::InterruptibleLoad,
baseline_mw: max_mw * 2.0,
max_curtailment_mw: max_mw,
min_curtailment_mw: 0.0,
max_events_per_year: 30,
max_duration_h: 4.0,
notice_time_min: 10.0,
recovery_time_h: recovery_h,
incentive_rate: rate,
elasticity: -0.2,
last_event_time: None,
events_this_year: 0,
}
}
#[test]
fn test_dr_dispatch_meets_target() {
let customers = vec![
make_customer(0, 50.0, 10.0, 2.0),
make_customer(1, 50.0, 15.0, 2.0),
make_customer(2, 50.0, 20.0, 2.0),
];
let mut portfolio =
DrProgramPortfolio::new(customers, DrProgramKind::InterruptibleLoad, 200.0);
let result = portfolio
.dispatch(100.0, 10.0, DrEventTrigger::Operator)
.expect("Dispatch should succeed");
assert!(
result.total_curtailment_mw > 0.0,
"Should curtail some load: {:.2}",
result.total_curtailment_mw
);
assert!(result.n_customers_called > 0);
}
#[test]
fn test_dr_price_response_elasticity() {
let customers = vec![make_customer(0, 50.0, 10.0, 2.0)];
let portfolio = DrProgramPortfolio::new(customers, DrProgramKind::RealTimePricing, 100.0);
let baseline = 100.0;
let reference = 50.0;
let high_price = 100.0;
let elasticity = -0.3;
let demand = portfolio.price_responsive_demand(high_price, baseline, elasticity, reference);
assert!(
demand < baseline,
"Demand should decrease at higher price: {demand:.2}"
);
assert!(demand > 0.0, "Demand should remain positive: {demand:.2}");
assert!(
(demand - 70.0).abs() < 1e-6,
"Expected 70 MW, got {demand:.2}"
);
}
#[test]
fn test_dr_eligibility_recovery_time() {
let mut c = make_customer(0, 50.0, 10.0, 4.0);
c.last_event_time = Some(0.0);
assert!(!c.is_eligible(1.0), "Not eligible before recovery");
assert!(c.is_eligible(5.0), "Eligible after recovery");
let customers = vec![{
let mut c2 = make_customer(0, 50.0, 10.0, 4.0);
c2.last_event_time = Some(0.0);
c2
}];
let mut portfolio =
DrProgramPortfolio::new(customers, DrProgramKind::InterruptibleLoad, 100.0);
let result = portfolio
.dispatch(50.0, 1.0, DrEventTrigger::Operator)
.expect("Dispatch should not error");
assert_eq!(
result.total_curtailment_mw, 0.0,
"No eligible customers → zero curtailment"
);
}
#[test]
fn test_dr_supply_curve_sorted() {
let customers = vec![
make_customer(0, 30.0, 25.0, 2.0),
make_customer(1, 20.0, 10.0, 2.0),
make_customer(2, 40.0, 15.0, 2.0),
];
let portfolio = DrProgramPortfolio::new(customers, DrProgramKind::InterruptibleLoad, 200.0);
let curve = portfolio.flexibility_supply_curve(0.0);
assert!(!curve.is_empty(), "Supply curve should not be empty");
for i in 1..curve.len() {
assert!(
curve[i - 1].1 <= curve[i].1,
"Prices must be sorted ascending"
);
assert!(
curve[i - 1].0 <= curve[i].0,
"Cumulative volume must be non-decreasing"
);
}
}
#[test]
fn test_dr_baseline_computation() {
let mut hist = vec![100.0_f64; 72]; hist[12] = 150.0;
hist[24 + 12] = 160.0;
hist[48 + 12] = 170.0;
let baseline = DrProgramPortfolio::compute_baseline(&hist, 3, 12);
let expected = (150.0 + 160.0 + 170.0) / 3.0;
assert!(
(baseline - expected).abs() < 1e-6,
"Baseline should be {expected:.2}, got {baseline:.2}"
);
}
#[test]
fn test_voll_positive() {
let voll = compute_voll("residential", 1.0);
assert!(voll > 0.0, "VOLL should be positive: {voll:.2}");
assert!(voll >= 10_000.0, "Residential VOLL >= 10,000 $/MWh");
}
#[test]
fn test_dr_cost_benefit() {
let (benefit, cost, net) = dr_cost_benefit(10.0, 100.0, 20.0, 50.0);
assert!((benefit - 1000.0).abs() < 1e-6, "Benefit={benefit:.2}");
assert!((cost - 250.0).abs() < 1e-6, "Cost={cost:.2}");
assert!((net - 750.0).abs() < 1e-6, "Net={net:.2}");
}
}