use serde::{Deserialize, Serialize};
use crate::market::EnergyOffer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DlPeriodParams {
pub p_sched_pu: f64,
pub p_max_pu: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q_sched_pu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q_min_pu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q_max_pu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pq_linear_equality: Option<crate::network::generator::PqLinearLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pq_linear_upper: Option<crate::network::generator::PqLinearLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pq_linear_lower: Option<crate::network::generator::PqLinearLink>,
pub cost_model: LoadCostModel,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DlOfferSchedule {
pub periods: Vec<Option<DlPeriodParams>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoadArchetype {
Curtailable,
Elastic,
Interruptible,
IndependentPQ,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LoadCostModel {
LinearCurtailment {
cost_per_mw: f64,
},
QuadraticUtility {
a: f64,
b: f64,
},
PiecewiseLinear {
points: Vec<(f64, f64)>,
},
InterruptPenalty {
cost_per_mw: f64,
},
}
impl LoadCostModel {
pub fn objective_contribution(&self, p_served_pu: f64, p_sched_pu: f64, base_mva: f64) -> f64 {
match self {
LoadCostModel::LinearCurtailment { cost_per_mw }
| LoadCostModel::InterruptPenalty { cost_per_mw } => {
cost_per_mw * (p_sched_pu - p_served_pu) * base_mva
}
LoadCostModel::QuadraticUtility { a, b } => {
let p_mw = p_served_pu * base_mva;
-(a * p_mw - b * p_mw * p_mw / 2.0)
}
LoadCostModel::PiecewiseLinear { points } => {
if points.len() < 2 {
return 0.0;
}
let p_mw = p_served_pu * base_mva;
let utility = integrate_pwl_utility(points, p_mw);
-utility
}
}
}
pub fn d_obj_d_p(&self, p_served_pu: f64, base_mva: f64) -> f64 {
match self {
LoadCostModel::LinearCurtailment { cost_per_mw }
| LoadCostModel::InterruptPenalty { cost_per_mw } => {
-cost_per_mw * base_mva
}
LoadCostModel::QuadraticUtility { a, b } => {
let p_mw = p_served_pu * base_mva;
(b * p_mw - a) * base_mva
}
LoadCostModel::PiecewiseLinear { points } => {
if points.len() < 2 {
return 0.0;
}
let p_mw = p_served_pu * base_mva;
let mu = interpolate_marginal_utility(points, p_mw);
-mu * base_mva
}
}
}
pub fn d2_obj_d_p2(&self, base_mva: f64) -> f64 {
match self {
LoadCostModel::QuadraticUtility { b, .. } => b * base_mva * base_mva,
_ => 0.0,
}
}
pub fn has_quadratic_term(&self) -> bool {
matches!(self, LoadCostModel::QuadraticUtility { .. })
}
pub fn dc_linear_obj_coeff(&self, base_mva: f64) -> f64 {
match self {
LoadCostModel::LinearCurtailment { cost_per_mw }
| LoadCostModel::InterruptPenalty { cost_per_mw } => {
-cost_per_mw * base_mva
}
LoadCostModel::QuadraticUtility { a, .. } => {
-a * base_mva
}
LoadCostModel::PiecewiseLinear { .. } => 0.0,
}
}
pub fn dc_quadratic_obj_coeff(&self, base_mva: f64) -> f64 {
match self {
LoadCostModel::QuadraticUtility { b, .. } => b * base_mva * base_mva,
_ => 0.0,
}
}
}
fn integrate_pwl_utility(points: &[(f64, f64)], p_mw: f64) -> f64 {
let mut utility = 0.0;
let mut prev_p = 0.0;
let mut prev_mu = if !points.is_empty() { points[0].1 } else { 0.0 };
if !points.is_empty() && points[0].0 > 0.0 {
let end = p_mw.min(points[0].0);
utility += prev_mu * end;
if p_mw <= points[0].0 {
return utility;
}
prev_p = points[0].0;
}
for &(bp, mu) in points {
if bp <= prev_p {
prev_mu = mu;
continue;
}
let end = p_mw.min(bp);
let mid_mu = prev_mu + (mu - prev_mu) * (end - prev_p) / (bp - prev_p);
utility += (prev_mu + mid_mu) / 2.0 * (end - prev_p);
if p_mw <= bp {
return utility;
}
prev_p = bp;
prev_mu = mu;
}
if p_mw > prev_p {
utility += prev_mu * (p_mw - prev_p);
}
utility
}
fn interpolate_marginal_utility(points: &[(f64, f64)], p_mw: f64) -> f64 {
if points.is_empty() {
return 0.0;
}
if p_mw <= points[0].0 {
return points[0].1;
}
for i in 1..points.len() {
let (p0, mu0) = points[i - 1];
let (p1, mu1) = points[i];
if p_mw <= p1 {
let t = (p_mw - p0) / (p1 - p0);
return mu0 + t * (mu1 - mu0);
}
}
points.last().map(|&(_, mu)| mu).unwrap_or(0.0)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DispatchableLoad {
pub bus: u32,
pub p_sched_pu: f64,
pub q_sched_pu: f64,
pub p_min_pu: f64,
pub p_max_pu: f64,
pub q_min_pu: f64,
pub q_max_pu: f64,
pub archetype: LoadArchetype,
pub cost_model: LoadCostModel,
pub fixed_power_factor: bool,
pub in_service: bool,
#[serde(default)]
pub resource_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_type: Option<String>,
#[serde(default)]
pub dispatch_notification_minutes: f64,
#[serde(default)]
pub min_duration_hours: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline_mw: Option<f64>,
#[serde(default)]
pub rebound_fraction: f64,
#[serde(default)]
pub rebound_periods: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ramp_up_pu_per_hr: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ramp_down_pu_per_hr: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_p_pu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pq_linear_equality: Option<crate::network::generator::PqLinearLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pq_linear_upper: Option<crate::network::generator::PqLinearLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pq_linear_lower: Option<crate::network::generator::PqLinearLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ramp_group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energy_offer: Option<EnergyOffer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reserve_offers: Vec<crate::market::reserve::ReserveOffer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reserve_group: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub qualifications: crate::market::reserve::QualificationMap,
}
impl DispatchableLoad {
pub fn curtailable(
bus: u32,
p_sched_mw: f64,
q_sched_mvar: f64,
p_min_mw: f64,
cost_per_mwh: f64,
base_mva: f64,
) -> Self {
let pf_ratio = if p_sched_mw.abs() > 1e-10 {
q_sched_mvar / p_sched_mw
} else {
0.0
};
let p_sched_pu = p_sched_mw / base_mva;
let q_sched_pu = q_sched_mvar / base_mva;
let p_min_pu = p_min_mw / base_mva;
let q_min_pu = p_min_pu * pf_ratio;
Self {
bus,
p_sched_pu,
q_sched_pu,
p_min_pu,
p_max_pu: p_sched_pu,
q_min_pu,
q_max_pu: q_sched_pu,
archetype: LoadArchetype::Curtailable,
cost_model: LoadCostModel::LinearCurtailment {
cost_per_mw: cost_per_mwh,
},
fixed_power_factor: true,
in_service: true,
resource_id: String::new(),
product_type: None,
dispatch_notification_minutes: 0.0,
min_duration_hours: 0.0,
baseline_mw: None,
rebound_fraction: 0.0,
rebound_periods: 0,
energy_offer: None,
reserve_offers: Vec::new(),
reserve_group: None,
qualifications: std::collections::HashMap::new(),
ramp_up_pu_per_hr: None,
ramp_down_pu_per_hr: None,
initial_p_pu: None,
ramp_group: None,
pq_linear_equality: None,
pq_linear_upper: None,
pq_linear_lower: None,
}
}
pub fn elastic(
bus: u32,
p_min_mw: f64,
p_max_mw: f64,
a_choke_price: f64,
b_slope: f64,
base_mva: f64,
) -> Self {
let p_min_pu = p_min_mw / base_mva;
let p_max_pu = p_max_mw / base_mva;
Self {
bus,
p_sched_pu: p_max_pu,
q_sched_pu: 0.0,
p_min_pu,
p_max_pu,
q_min_pu: 0.0,
q_max_pu: 0.0,
archetype: LoadArchetype::Elastic,
cost_model: LoadCostModel::QuadraticUtility {
a: a_choke_price,
b: b_slope,
},
fixed_power_factor: false,
in_service: true,
resource_id: String::new(),
product_type: None,
dispatch_notification_minutes: 0.0,
min_duration_hours: 0.0,
baseline_mw: None,
rebound_fraction: 0.0,
rebound_periods: 0,
energy_offer: None,
reserve_offers: Vec::new(),
reserve_group: None,
qualifications: std::collections::HashMap::new(),
ramp_up_pu_per_hr: None,
ramp_down_pu_per_hr: None,
initial_p_pu: None,
ramp_group: None,
pq_linear_equality: None,
pq_linear_upper: None,
pq_linear_lower: None,
}
}
pub fn interruptible(
bus: u32,
p_sched_mw: f64,
q_sched_mvar: f64,
cost_per_mwh: f64,
base_mva: f64,
) -> Self {
let p_sched_pu = p_sched_mw / base_mva;
let q_sched_pu = q_sched_mvar / base_mva;
Self {
bus,
p_sched_pu,
q_sched_pu,
p_min_pu: 0.0,
p_max_pu: p_sched_pu,
q_min_pu: 0.0,
q_max_pu: q_sched_pu,
archetype: LoadArchetype::Interruptible,
cost_model: LoadCostModel::InterruptPenalty {
cost_per_mw: cost_per_mwh,
},
fixed_power_factor: true,
in_service: true,
resource_id: String::new(),
product_type: None,
dispatch_notification_minutes: 0.0,
min_duration_hours: 0.0,
baseline_mw: None,
rebound_fraction: 0.0,
rebound_periods: 0,
energy_offer: None,
reserve_offers: Vec::new(),
reserve_group: None,
qualifications: std::collections::HashMap::new(),
ramp_up_pu_per_hr: None,
ramp_down_pu_per_hr: None,
initial_p_pu: None,
ramp_group: None,
pq_linear_equality: None,
pq_linear_upper: None,
pq_linear_lower: None,
}
}
pub fn reserve_offer(&self, product_id: &str) -> Option<&crate::market::reserve::ReserveOffer> {
self.reserve_offers
.iter()
.find(|o| o.product_id == product_id)
}
#[inline]
pub fn p_injection_sched(&self) -> f64 {
-self.p_sched_pu
}
#[inline]
pub fn q_injection_sched(&self) -> f64 {
-self.q_sched_pu
}
#[inline]
pub fn pf_ratio(&self) -> f64 {
if self.p_sched_pu.abs() > 1e-10 {
self.q_sched_pu / self.p_sched_pu
} else {
0.0
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadDispatchResult {
pub bus: u32,
pub p_served_pu: f64,
pub q_served_pu: f64,
pub p_curtailed_pu: f64,
pub curtailment_pct: f64,
pub cost_contribution: f64,
pub lmp_at_bus: f64,
pub net_curtailment_benefit: f64,
}
impl LoadDispatchResult {
pub fn from_solution(
dl: &DispatchableLoad,
p_served_pu: f64,
q_served_pu: f64,
lmp: f64,
base_mva: f64,
) -> Self {
let baseline_pu = dl
.baseline_mw
.map(|mw| mw / base_mva)
.unwrap_or(dl.p_sched_pu);
let p_curtailed_pu = (baseline_pu - p_served_pu).max(0.0);
let curtailment_pct = if baseline_pu.abs() > 1e-12 {
p_curtailed_pu / baseline_pu * 100.0
} else {
0.0
};
let cost_contribution =
dl.cost_model
.objective_contribution(p_served_pu, dl.p_sched_pu, base_mva);
let curtailment_cost = match &dl.cost_model {
LoadCostModel::LinearCurtailment { cost_per_mw }
| LoadCostModel::InterruptPenalty { cost_per_mw } => *cost_per_mw,
LoadCostModel::QuadraticUtility { a, b } => {
let p_served_mw = p_served_pu * base_mva;
let p_sched_mw = dl.p_sched_pu * base_mva;
let mu_served = a - b * p_served_mw;
let mu_sched = a - b * p_sched_mw;
(mu_served + mu_sched) / 2.0
}
LoadCostModel::PiecewiseLinear { .. } => lmp, };
let p_curtailed_mw = p_curtailed_pu * base_mva;
let net_curtailment_benefit = (lmp - curtailment_cost) * p_curtailed_mw;
Self {
bus: dl.bus,
p_served_pu,
q_served_pu,
p_curtailed_pu,
curtailment_pct,
cost_contribution,
lmp_at_bus: lmp,
net_curtailment_benefit,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DemandResponseResults {
pub loads: Vec<LoadDispatchResult>,
pub total_served_mw: f64,
pub total_curtailed_mw: f64,
pub total_cost: f64,
pub consumer_surplus: f64,
}
impl DemandResponseResults {
pub fn from_load_results(loads: Vec<LoadDispatchResult>, base_mva: f64) -> Self {
let total_served_mw = loads.iter().map(|r| r.p_served_pu * base_mva).sum();
let total_curtailed_mw = loads.iter().map(|r| r.p_curtailed_pu * base_mva).sum();
let total_cost = loads.iter().map(|r| r.cost_contribution).sum();
let consumer_surplus = loads
.iter()
.map(|r| {
r.net_curtailment_benefit.max(0.0)
})
.sum();
Self {
loads,
total_served_mw,
total_curtailed_mw,
total_cost,
consumer_surplus,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_curtailment_objective() {
let model = LoadCostModel::LinearCurtailment { cost_per_mw: 50.0 };
let obj = model.objective_contribution(0.10, 0.15, 100.0);
assert!((obj - 250.0).abs() < 1e-10, "got {obj}");
}
#[test]
fn test_quadratic_utility_objective() {
let _model = LoadCostModel::QuadraticUtility { a: 60.0, b: 100.0 };
let model2 = LoadCostModel::QuadraticUtility { a: 60.0, b: 1.0 };
let p = 0.10; let base = 100.0;
let p_mw = p * base;
let expected = -(60.0 * p_mw - 1.0 * p_mw * p_mw / 2.0);
let obj = model2.objective_contribution(p, p, base);
assert!(
(obj - expected).abs() < 1e-6,
"expected {expected}, got {obj}"
);
}
#[test]
fn test_quadratic_utility_gradient() {
let model = LoadCostModel::QuadraticUtility { a: 50.0, b: 100.0 };
let p = 0.002; let base = 100.0;
let p_mw = p * base;
let expected = (100.0 * p_mw - 50.0) * base;
let grad = model.d_obj_d_p(p, base);
assert!(
(grad - expected).abs() < 1e-6,
"expected {expected}, got {grad}"
);
}
#[test]
fn test_quadratic_hessian() {
let model = LoadCostModel::QuadraticUtility { a: 50.0, b: 100.0 };
let base = 100.0;
let h = model.d2_obj_d_p2(base);
assert!((h - 100.0 * 100.0 * 100.0).abs() < 1e-6, "got {h}");
}
#[test]
fn test_linear_curtailment_gradient() {
let model = LoadCostModel::LinearCurtailment { cost_per_mw: 50.0 };
let grad = model.d_obj_d_p(0.15, 100.0);
assert!((grad - (-5000.0)).abs() < 1e-10, "got {grad}");
}
#[test]
fn test_curtailable_constructor() {
let dl = DispatchableLoad::curtailable(3, 150.0, 30.0, 50.0, 50.0, 100.0);
assert_eq!(dl.bus, 3);
assert!((dl.p_sched_pu - 1.5).abs() < 1e-10);
assert!((dl.p_min_pu - 0.5).abs() < 1e-10);
assert!((dl.q_sched_pu - 0.3).abs() < 1e-10);
assert!(dl.fixed_power_factor);
assert!(matches!(dl.archetype, LoadArchetype::Curtailable));
}
#[test]
fn test_pf_ratio() {
let dl = DispatchableLoad::curtailable(0, 100.0, 50.0, 0.0, 50.0, 100.0);
assert!((dl.pf_ratio() - 0.5).abs() < 1e-10);
}
#[test]
fn test_dispatch_result_net_benefit() {
let dl = DispatchableLoad::curtailable(5, 150.0, 0.0, 50.0, 40.0, 100.0);
let result = LoadDispatchResult::from_solution(&dl, 0.5, 0.0, 60.0, 100.0);
assert!((result.p_curtailed_pu - 1.0).abs() < 1e-10);
assert!((result.curtailment_pct - (100.0 / 150.0 * 100.0)).abs() < 1e-6);
assert!(result.net_curtailment_benefit > 0.0);
}
}