use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::market::PenaltyCurve;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ReserveDirection {
Up,
Down,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum QualificationRule {
Committed,
Synchronized,
QuickStart,
OfflineQuickStart,
FrequencyResponsive,
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EnergyCoupling {
Headroom,
Footroom,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ReserveKind {
#[default]
Real,
Reactive,
ReactiveHeadroom,
}
fn default_apply_deploy_ramp_limit() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReserveProduct {
pub id: String,
pub name: String,
#[serde(default)]
pub kind: ReserveKind,
#[serde(
default = "default_apply_deploy_ramp_limit",
skip_serializing_if = "is_true"
)]
pub apply_deploy_ramp_limit: bool,
pub direction: ReserveDirection,
pub deploy_secs: f64,
pub qualification: QualificationRule,
pub energy_coupling: EnergyCoupling,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatchable_load_energy_coupling: Option<EnergyCoupling>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shared_limit_products: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub balance_products: Vec<String>,
pub demand_curve: PenaltyCurve,
}
impl ReserveProduct {
pub fn ercot_defaults() -> Vec<ReserveProduct> {
let default_curve = PenaltyCurve::Linear {
cost_per_unit: 1000.0,
};
vec![
ReserveProduct {
id: "reg_up".into(),
name: "Regulation Up".into(),
direction: ReserveDirection::Up,
apply_deploy_ramp_limit: true,
deploy_secs: 300.0,
qualification: QualificationRule::Committed,
energy_coupling: EnergyCoupling::Headroom,
dispatchable_load_energy_coupling: None,
shared_limit_products: Vec::new(),
balance_products: Vec::new(),
demand_curve: default_curve.clone(),
kind: ReserveKind::Real,
},
ReserveProduct {
id: "reg_dn".into(),
name: "Regulation Down".into(),
direction: ReserveDirection::Down,
apply_deploy_ramp_limit: true,
deploy_secs: 300.0,
qualification: QualificationRule::Committed,
energy_coupling: EnergyCoupling::Footroom,
dispatchable_load_energy_coupling: None,
shared_limit_products: Vec::new(),
balance_products: Vec::new(),
demand_curve: default_curve.clone(),
kind: ReserveKind::Real,
},
ReserveProduct {
id: "spin".into(),
name: "Spinning Reserve".into(),
direction: ReserveDirection::Up,
apply_deploy_ramp_limit: true,
deploy_secs: 600.0,
qualification: QualificationRule::Synchronized,
energy_coupling: EnergyCoupling::Headroom,
dispatchable_load_energy_coupling: None,
shared_limit_products: Vec::new(),
balance_products: Vec::new(),
demand_curve: default_curve.clone(),
kind: ReserveKind::Real,
},
ReserveProduct {
id: "nspin".into(),
name: "Non-Spinning Reserve".into(),
direction: ReserveDirection::Up,
apply_deploy_ramp_limit: true,
deploy_secs: 1800.0,
qualification: QualificationRule::QuickStart,
energy_coupling: EnergyCoupling::None,
dispatchable_load_energy_coupling: None,
shared_limit_products: Vec::new(),
balance_products: Vec::new(),
demand_curve: default_curve.clone(),
kind: ReserveKind::Real,
},
ReserveProduct {
id: "ecrs".into(),
name: "ERCOT Contingency Reserve".into(),
direction: ReserveDirection::Up,
apply_deploy_ramp_limit: true,
deploy_secs: 600.0,
qualification: QualificationRule::Committed,
energy_coupling: EnergyCoupling::Headroom,
dispatchable_load_energy_coupling: None,
shared_limit_products: Vec::new(),
balance_products: Vec::new(),
demand_curve: default_curve.clone(),
kind: ReserveKind::Real,
},
ReserveProduct {
id: "rrs".into(),
name: "Responsive Reserve".into(),
direction: ReserveDirection::Up,
apply_deploy_ramp_limit: true,
deploy_secs: 600.0,
qualification: QualificationRule::FrequencyResponsive,
energy_coupling: EnergyCoupling::Headroom,
dispatchable_load_energy_coupling: None,
shared_limit_products: Vec::new(),
balance_products: Vec::new(),
demand_curve: default_curve,
kind: ReserveKind::Real,
},
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReserveOffer {
pub product_id: String,
pub capacity_mw: f64,
pub cost_per_mwh: f64,
}
pub type QualificationMap = HashMap<String, bool>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemReserveRequirement {
pub product_id: String,
pub requirement_mw: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub per_period_mw: Option<Vec<f64>>,
}
impl SystemReserveRequirement {
pub fn requirement_mw_for_period(&self, period: usize) -> f64 {
self.per_period_mw
.as_ref()
.and_then(|v| v.get(period).or_else(|| v.last()).copied())
.unwrap_or(self.requirement_mw)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZonalReserveRequirement {
pub zone_id: usize,
pub product_id: String,
pub requirement_mw: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub per_period_mw: Option<Vec<f64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortfall_cost_per_unit: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub served_dispatchable_load_coefficient: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub largest_generator_dispatch_coefficient: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub participant_bus_numbers: Option<Vec<u32>>,
}
impl ZonalReserveRequirement {
pub fn requirement_mw_for_period(&self, period: usize) -> f64 {
self.per_period_mw
.as_ref()
.and_then(|v| v.get(period).or_else(|| v.last()).copied())
.unwrap_or(self.requirement_mw)
}
pub fn has_explicit_participant_buses(&self) -> bool {
self.participant_bus_numbers.is_some()
}
pub fn includes_participant_bus_number(&self, bus_number: u32) -> bool {
self.participant_bus_numbers
.as_ref()
.is_some_and(|buses| buses.contains(&bus_number))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RampSharingConfig {
pub sharing_ratio: f64,
}
impl Default for RampSharingConfig {
fn default() -> Self {
Self { sharing_ratio: 0.0 }
}
}
pub fn qualifies_for(
rule: &QualificationRule,
is_committed: bool,
is_quick_start: bool,
qualifications: &QualificationMap,
) -> bool {
match rule {
QualificationRule::Committed => is_committed,
QualificationRule::Synchronized => is_committed,
QualificationRule::QuickStart => is_quick_start || is_committed,
QualificationRule::OfflineQuickStart => is_quick_start && !is_committed,
QualificationRule::FrequencyResponsive => {
is_committed
&& qualifications
.get("freq_responsive")
.copied()
.unwrap_or(false)
}
QualificationRule::Custom(flag) => {
is_committed && qualifications.get(flag).copied().unwrap_or(false)
}
}
}
fn qualification_states(rule: &QualificationRule) -> (bool, bool) {
match rule {
QualificationRule::Committed
| QualificationRule::Synchronized
| QualificationRule::FrequencyResponsive
| QualificationRule::Custom(_) => (true, false),
QualificationRule::QuickStart => (true, true),
QualificationRule::OfflineQuickStart => (false, true),
}
}
pub fn qualifications_can_overlap(lhs: &QualificationRule, rhs: &QualificationRule) -> bool {
let (lhs_committed, lhs_offline) = qualification_states(lhs);
let (rhs_committed, rhs_offline) = qualification_states(rhs);
(lhs_committed && rhs_committed) || (lhs_offline && rhs_offline)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ercot_defaults_has_6_products() {
let products = ReserveProduct::ercot_defaults();
assert_eq!(products.len(), 6);
let ids: Vec<&str> = products.iter().map(|p| p.id.as_str()).collect();
assert!(ids.contains(&"reg_up"));
assert!(ids.contains(&"reg_dn"));
assert!(ids.contains(&"spin"));
assert!(ids.contains(&"nspin"));
assert!(ids.contains(&"ecrs"));
assert!(ids.contains(&"rrs"));
}
#[test]
fn test_ercot_defaults_directions() {
let products = ReserveProduct::ercot_defaults();
for p in &products {
match p.id.as_str() {
"reg_dn" => assert_eq!(p.direction, ReserveDirection::Down),
_ => assert_eq!(p.direction, ReserveDirection::Up),
}
}
}
#[test]
fn test_ercot_defaults_energy_coupling() {
let products = ReserveProduct::ercot_defaults();
for p in &products {
match p.id.as_str() {
"reg_dn" => assert_eq!(p.energy_coupling, EnergyCoupling::Footroom),
"nspin" => assert_eq!(p.energy_coupling, EnergyCoupling::None),
_ => assert_eq!(p.energy_coupling, EnergyCoupling::Headroom),
}
}
}
#[test]
fn test_qualifies_committed() {
let q = HashMap::new();
assert!(qualifies_for(
&QualificationRule::Committed,
true,
false,
&q
));
assert!(!qualifies_for(
&QualificationRule::Committed,
false,
false,
&q
));
}
#[test]
fn test_qualifies_offline_quick_start() {
let q = HashMap::new();
assert!(qualifies_for(
&QualificationRule::OfflineQuickStart,
false,
true,
&q
));
assert!(!qualifies_for(
&QualificationRule::OfflineQuickStart,
true,
true,
&q
));
assert!(!qualifies_for(
&QualificationRule::OfflineQuickStart,
false,
false,
&q
));
}
#[test]
fn test_qualifies_quick_start() {
let q = HashMap::new();
assert!(qualifies_for(
&QualificationRule::QuickStart,
false,
true,
&q
));
assert!(qualifies_for(
&QualificationRule::QuickStart,
true,
false,
&q
));
assert!(!qualifies_for(
&QualificationRule::QuickStart,
false,
false,
&q
));
}
#[test]
fn test_qualifies_freq_responsive() {
let mut q = HashMap::new();
assert!(!qualifies_for(
&QualificationRule::FrequencyResponsive,
true,
false,
&q
));
q.insert("freq_responsive".to_string(), true);
assert!(qualifies_for(
&QualificationRule::FrequencyResponsive,
true,
false,
&q
));
assert!(!qualifies_for(
&QualificationRule::FrequencyResponsive,
false,
false,
&q
));
}
#[test]
fn test_qualifies_custom() {
let mut q = HashMap::new();
q.insert("my_custom_flag".to_string(), true);
assert!(qualifies_for(
&QualificationRule::Custom("my_custom_flag".to_string()),
true,
false,
&q
));
assert!(!qualifies_for(
&QualificationRule::Custom("other_flag".to_string()),
true,
false,
&q
));
}
#[test]
fn test_qualification_overlap_filters_mutually_exclusive_states() {
assert!(qualifications_can_overlap(
&QualificationRule::Committed,
&QualificationRule::Synchronized,
));
assert!(qualifications_can_overlap(
&QualificationRule::OfflineQuickStart,
&QualificationRule::QuickStart,
));
assert!(!qualifications_can_overlap(
&QualificationRule::OfflineQuickStart,
&QualificationRule::Committed,
));
assert!(!qualifications_can_overlap(
&QualificationRule::OfflineQuickStart,
&QualificationRule::FrequencyResponsive,
));
}
#[test]
fn test_ramp_sharing_default() {
let cfg = RampSharingConfig::default();
assert_eq!(cfg.sharing_ratio, 0.0);
}
}