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,
FrequencyResponsive,
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EnergyCoupling {
Headroom,
Footroom,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReserveProduct {
pub id: String,
pub name: String,
pub direction: ReserveDirection,
pub deploy_secs: f64,
pub qualification: QualificationRule,
pub energy_coupling: EnergyCoupling,
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,
deploy_secs: 300.0,
qualification: QualificationRule::Committed,
energy_coupling: EnergyCoupling::Headroom,
demand_curve: default_curve.clone(),
},
ReserveProduct {
id: "reg_dn".into(),
name: "Regulation Down".into(),
direction: ReserveDirection::Down,
deploy_secs: 300.0,
qualification: QualificationRule::Committed,
energy_coupling: EnergyCoupling::Footroom,
demand_curve: default_curve.clone(),
},
ReserveProduct {
id: "spin".into(),
name: "Spinning Reserve".into(),
direction: ReserveDirection::Up,
deploy_secs: 600.0,
qualification: QualificationRule::Synchronized,
energy_coupling: EnergyCoupling::Headroom,
demand_curve: default_curve.clone(),
},
ReserveProduct {
id: "nspin".into(),
name: "Non-Spinning Reserve".into(),
direction: ReserveDirection::Up,
deploy_secs: 1800.0,
qualification: QualificationRule::QuickStart,
energy_coupling: EnergyCoupling::None,
demand_curve: default_curve.clone(),
},
ReserveProduct {
id: "ecrs".into(),
name: "ERCOT Contingency Reserve".into(),
direction: ReserveDirection::Up,
deploy_secs: 600.0,
qualification: QualificationRule::Committed,
energy_coupling: EnergyCoupling::Headroom,
demand_curve: default_curve.clone(),
},
ReserveProduct {
id: "rrs".into(),
name: "Responsive Reserve".into(),
direction: ReserveDirection::Up,
deploy_secs: 600.0,
qualification: QualificationRule::FrequencyResponsive,
energy_coupling: EnergyCoupling::Headroom,
demand_curve: default_curve,
},
]
}
}
#[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,
}
#[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::FrequencyResponsive => {
is_committed
&& qualifications
.get("freq_responsive")
.copied()
.unwrap_or(false)
}
QualificationRule::Custom(flag) => {
is_committed && qualifications.get(flag).copied().unwrap_or(false)
}
}
}
#[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_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_ramp_sharing_default() {
let cfg = RampSharingConfig::default();
assert_eq!(cfg.sharing_ratio, 0.0);
}
}