use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AncillaryProduct {
PrimaryFrequencyResponse,
SecondaryReserve,
TertiaryReserve,
SpinningReserve,
NonSpinningReserve,
CapacityMarket,
ReactiveReserve,
}
impl std::fmt::Display for AncillaryProduct {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AncillaryProduct::PrimaryFrequencyResponse => write!(f, "PrimaryFrequencyResponse"),
AncillaryProduct::SecondaryReserve => write!(f, "SecondaryReserve"),
AncillaryProduct::TertiaryReserve => write!(f, "TertiaryReserve"),
AncillaryProduct::SpinningReserve => write!(f, "SpinningReserve"),
AncillaryProduct::NonSpinningReserve => write!(f, "NonSpinningReserve"),
AncillaryProduct::CapacityMarket => write!(f, "CapacityMarket"),
AncillaryProduct::ReactiveReserve => write!(f, "ReactiveReserve"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AncillaryBid {
pub unit_id: String,
pub product: AncillaryProduct,
pub capacity_mw: f64,
pub availability_price_per_mw_h: f64,
pub activation_price_per_mwh: f64,
pub min_delivery_min: f64,
pub max_delivery_min: f64,
pub ramp_rate_mw_per_min: f64,
pub lead_time_min: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyBid {
pub unit_id: String,
pub capacity_mw: f64,
pub offer_price_per_mwh: f64,
pub p_min_mw: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProductRequirements {
pub primary_mw: f64,
pub secondary_mw: f64,
pub tertiary_mw: f64,
pub spinning_reserve_mw: f64,
pub non_spinning_reserve_mw: f64,
pub reactive_mvar: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcceptedBid {
pub unit_id: String,
pub capacity_accepted_mw: f64,
pub clearing_price_per_mw_h: f64,
pub offer_price_per_mw_h: f64,
pub producer_surplus_per_h: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClearingResult {
pub product: AncillaryProduct,
pub accepted_bids: Vec<AcceptedBid>,
pub clearing_price_per_mw_h: f64,
pub total_capacity_cleared_mw: f64,
pub total_cost_per_h: f64,
pub shortfall_mw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettlementReport {
pub unit_id: String,
pub availability_payment: f64,
pub activation_payment: f64,
pub total_payment: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationRecord {
pub unit_id: String,
pub energy_delivered_mwh: f64,
pub activation_price_per_mwh: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrequencyResponseMetrics {
pub rocof_hz_per_s: f64,
pub frequency_nadir_hz: f64,
pub time_to_nadir_s: f64,
pub recovery_time_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TsoMarket {
pub market_id: String,
pub interval_min: f64,
pub requirements: ProductRequirements,
pub nominal_frequency_hz: f64,
pub system_inertia_h: f64,
pub system_rated_mw: f64,
pub damping_coefficient: f64,
clearing_history: Vec<ClearingResult>,
}
impl TsoMarket {
pub fn new(
market_id: impl Into<String>,
interval_min: f64,
requirements: ProductRequirements,
nominal_frequency_hz: f64,
system_inertia_h: f64,
system_rated_mw: f64,
damping_coefficient: f64,
) -> Self {
Self {
market_id: market_id.into(),
interval_min,
requirements,
nominal_frequency_hz,
system_inertia_h,
system_rated_mw,
damping_coefficient,
clearing_history: Vec::new(),
}
}
fn clear_single_product(
bids: &[AncillaryBid],
product: &AncillaryProduct,
requirement_mw: f64,
) -> ClearingResult {
let mut eligible: Vec<&AncillaryBid> = bids
.iter()
.filter(|b| &b.product == product && b.capacity_mw > 0.0)
.collect();
eligible.sort_by(|a, b| {
a.availability_price_per_mw_h
.partial_cmp(&b.availability_price_per_mw_h)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut remaining = requirement_mw;
let mut accepted: Vec<AcceptedBid> = Vec::new();
let mut clearing_price = 0.0_f64;
let mut total_cleared = 0.0_f64;
for bid in &eligible {
if remaining <= 0.0 {
break;
}
let accepted_mw = bid.capacity_mw.min(remaining);
clearing_price = bid.availability_price_per_mw_h;
accepted.push(AcceptedBid {
unit_id: bid.unit_id.clone(),
capacity_accepted_mw: accepted_mw,
clearing_price_per_mw_h: clearing_price,
offer_price_per_mw_h: bid.availability_price_per_mw_h,
producer_surplus_per_h: 0.0,
});
total_cleared += accepted_mw;
remaining -= accepted_mw;
}
for ab in accepted.iter_mut() {
ab.clearing_price_per_mw_h = clearing_price;
ab.producer_surplus_per_h =
(clearing_price - ab.offer_price_per_mw_h) * ab.capacity_accepted_mw;
}
let total_cost = total_cleared * clearing_price;
let shortfall = requirement_mw - total_cleared;
ClearingResult {
product: product.clone(),
accepted_bids: accepted,
clearing_price_per_mw_h: clearing_price,
total_capacity_cleared_mw: total_cleared,
total_cost_per_h: total_cost,
shortfall_mw: shortfall.max(0.0),
}
}
pub fn clear_market(&mut self, bids: &[AncillaryBid]) -> Vec<ClearingResult> {
let req = self.requirements.clone();
let products_and_reqs: &[(AncillaryProduct, f64)] = &[
(AncillaryProduct::PrimaryFrequencyResponse, req.primary_mw),
(AncillaryProduct::SecondaryReserve, req.secondary_mw),
(AncillaryProduct::TertiaryReserve, req.tertiary_mw),
(AncillaryProduct::SpinningReserve, req.spinning_reserve_mw),
(
AncillaryProduct::NonSpinningReserve,
req.non_spinning_reserve_mw,
),
(AncillaryProduct::ReactiveReserve, req.reactive_mvar),
];
let results: Vec<ClearingResult> = products_and_reqs
.iter()
.filter(|(_, req_mw)| *req_mw > 0.0)
.map(|(product, req_mw)| Self::clear_single_product(bids, product, *req_mw))
.collect();
for r in &results {
self.clearing_history.push(r.clone());
}
results
}
pub fn co_optimize_energy_ancillary(
energy_bids: &[EnergyBid],
ancillary_bids: &[AncillaryBid],
demand_mw: f64,
requirements: &ProductRequirements,
) -> (HashMap<String, f64>, Vec<ClearingResult>) {
let mut sorted_energy: Vec<&EnergyBid> = energy_bids.iter().collect();
sorted_energy.sort_by(|a, b| {
a.offer_price_per_mwh
.partial_cmp(&b.offer_price_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut used_capacity: HashMap<String, f64> = HashMap::new();
let mut remaining_demand = demand_mw;
for eb in &sorted_energy {
if remaining_demand <= 0.0 {
break;
}
let available = (eb.capacity_mw - eb.p_min_mw).max(0.0);
let dispatched_extra = available.min(remaining_demand);
let dispatched = eb.p_min_mw + dispatched_extra;
*used_capacity.entry(eb.unit_id.clone()).or_insert(0.0) += dispatched;
remaining_demand -= dispatched_extra;
}
let reduced_bids: Vec<AncillaryBid> = ancillary_bids
.iter()
.filter_map(|ab| {
let unit_max = energy_bids
.iter()
.find(|eb| eb.unit_id == ab.unit_id)
.map(|eb| eb.capacity_mw)
.unwrap_or(ab.capacity_mw);
let energy_used = used_capacity.get(&ab.unit_id).copied().unwrap_or(0.0);
let residual = (unit_max - energy_used).max(0.0);
if residual < 1e-9 {
None
} else {
let mut reduced = ab.clone();
reduced.capacity_mw = reduced.capacity_mw.min(residual);
Some(reduced)
}
})
.collect();
let products_and_reqs: &[(AncillaryProduct, f64)] = &[
(
AncillaryProduct::PrimaryFrequencyResponse,
requirements.primary_mw,
),
(
AncillaryProduct::SecondaryReserve,
requirements.secondary_mw,
),
(AncillaryProduct::TertiaryReserve, requirements.tertiary_mw),
(
AncillaryProduct::SpinningReserve,
requirements.spinning_reserve_mw,
),
(
AncillaryProduct::NonSpinningReserve,
requirements.non_spinning_reserve_mw,
),
(
AncillaryProduct::ReactiveReserve,
requirements.reactive_mvar,
),
];
let ancillary_results: Vec<ClearingResult> = products_and_reqs
.iter()
.filter(|(_, req_mw)| *req_mw > 0.0)
.map(|(product, req_mw)| Self::clear_single_product(&reduced_bids, product, *req_mw))
.collect();
(used_capacity, ancillary_results)
}
pub fn calculate_hhi(clearing_result: &ClearingResult) -> f64 {
let total = clearing_result.total_capacity_cleared_mw;
if total < 1e-12 {
return 0.0;
}
clearing_result
.accepted_bids
.iter()
.map(|ab| {
let share_pct = 100.0 * ab.capacity_accepted_mw / total;
share_pct * share_pct
})
.sum()
}
pub fn settlement(
clearing: &ClearingResult,
actual_activations: &[ActivationRecord],
interval_h: f64,
) -> Vec<SettlementReport> {
let activation_map: HashMap<&str, &ActivationRecord> = actual_activations
.iter()
.map(|a| (a.unit_id.as_str(), a))
.collect();
clearing
.accepted_bids
.iter()
.map(|ab| {
let avail_payment =
ab.capacity_accepted_mw * clearing.clearing_price_per_mw_h * interval_h;
let act_payment = activation_map
.get(ab.unit_id.as_str())
.map(|rec| rec.energy_delivered_mwh * rec.activation_price_per_mwh)
.unwrap_or(0.0);
SettlementReport {
unit_id: ab.unit_id.clone(),
availability_payment: avail_payment,
activation_payment: act_payment,
total_payment: avail_payment + act_payment,
}
})
.collect()
}
pub fn frequency_response_assessment(
&self,
disturbance_mw: f64,
primary_cleared_mw: f64,
) -> FrequencyResponseMetrics {
let f0 = self.nominal_frequency_hz;
let h = self.system_inertia_h;
let s = self.system_rated_mw;
let d = self.damping_coefficient;
let dp_pu = if s > 1e-9 { disturbance_mw / s } else { 0.0 };
let rocof = if h > 1e-9 {
f0 * dp_pu / (2.0 * h)
} else {
0.0
};
let droop_gain = 25.0_f64; let r_pu = if s > 1e-9 {
(primary_cleared_mw / s) * droop_gain
} else {
0.0
};
let denominator = d + r_pu;
let delta_f_pu = if denominator > 1e-12 {
-dp_pu / denominator
} else {
-dp_pu };
let frequency_nadir = (f0 * (1.0 + delta_f_pu)).max(0.0);
let time_to_nadir = if disturbance_mw > 1e-9 && h > 1e-9 {
(2.0 * h * s / (f0 * disturbance_mw)).sqrt()
} else {
0.0
};
let recovery_time = 3.0 * time_to_nadir;
FrequencyResponseMetrics {
rocof_hz_per_s: rocof,
frequency_nadir_hz: frequency_nadir,
time_to_nadir_s: time_to_nadir,
recovery_time_s: recovery_time,
}
}
pub fn clearing_history(&self) -> &[ClearingResult] {
&self.clearing_history
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_market() -> TsoMarket {
TsoMarket::new(
"TEST-01",
60.0,
ProductRequirements {
primary_mw: 100.0,
secondary_mw: 200.0,
tertiary_mw: 150.0,
spinning_reserve_mw: 250.0,
non_spinning_reserve_mw: 100.0,
reactive_mvar: 50.0,
},
50.0, 5.0, 3000.0, 1.0, )
}
fn spinning_bid(unit: &str, mw: f64, price: f64) -> AncillaryBid {
AncillaryBid {
unit_id: unit.to_string(),
product: AncillaryProduct::SpinningReserve,
capacity_mw: mw,
availability_price_per_mw_h: price,
activation_price_per_mwh: price * 2.0,
min_delivery_min: 10.0,
max_delivery_min: 60.0,
ramp_rate_mw_per_min: 5.0,
lead_time_min: 0.0,
}
}
fn primary_bid(unit: &str, mw: f64, price: f64) -> AncillaryBid {
AncillaryBid {
unit_id: unit.to_string(),
product: AncillaryProduct::PrimaryFrequencyResponse,
capacity_mw: mw,
availability_price_per_mw_h: price,
activation_price_per_mwh: price * 1.5,
min_delivery_min: 0.0,
max_delivery_min: 30.0,
ramp_rate_mw_per_min: 20.0,
lead_time_min: 0.0,
}
}
#[test]
fn test_merit_order_cheapest_first() {
let bids = vec![
spinning_bid("G1", 100.0, 5.0),
spinning_bid("G2", 100.0, 2.0), spinning_bid("G3", 100.0, 8.0),
];
let result =
TsoMarket::clear_single_product(&bids, &AncillaryProduct::SpinningReserve, 150.0);
assert_eq!(result.accepted_bids[0].unit_id, "G2");
assert_eq!(result.accepted_bids[1].unit_id, "G1");
assert_eq!(result.accepted_bids.len(), 2);
}
#[test]
fn test_clearing_price_is_marginal_bid() {
let bids = vec![
spinning_bid("G1", 100.0, 2.0),
spinning_bid("G2", 100.0, 5.0),
];
let result =
TsoMarket::clear_single_product(&bids, &AncillaryProduct::SpinningReserve, 150.0);
assert!(
(result.clearing_price_per_mw_h - 5.0).abs() < 1e-9,
"Clearing price must equal marginal bid $5, got {}",
result.clearing_price_per_mw_h
);
assert!(
(result.total_capacity_cleared_mw - 150.0).abs() < 1e-9,
"Must clear exactly 150 MW"
);
}
#[test]
fn test_shortfall_when_supply_insufficient() {
let bids = vec![spinning_bid("G1", 50.0, 3.0), spinning_bid("G2", 30.0, 4.0)];
let result =
TsoMarket::clear_single_product(&bids, &AncillaryProduct::SpinningReserve, 150.0);
assert!(
result.shortfall_mw > 0.0,
"Shortfall must be positive when supply < requirement"
);
assert!(
(result.shortfall_mw - 70.0).abs() < 1e-9,
"Shortfall should be 70 MW, got {}",
result.shortfall_mw
);
assert!((result.total_capacity_cleared_mw - 80.0).abs() < 1e-9);
}
#[test]
fn test_hhi_three_equal_bidders() {
let bids = vec![
spinning_bid("G1", 100.0, 3.0),
spinning_bid("G2", 100.0, 3.0),
spinning_bid("G3", 100.0, 3.0),
];
let result =
TsoMarket::clear_single_product(&bids, &AncillaryProduct::SpinningReserve, 300.0);
let hhi = TsoMarket::calculate_hhi(&result);
assert!(
(hhi - 3333.33).abs() < 1.0,
"HHI should be ~3333 for three equal bidders, got {:.2}",
hhi
);
}
#[test]
fn test_settlement_payments() {
let bids = vec![spinning_bid("G1", 100.0, 4.0)];
let result =
TsoMarket::clear_single_product(&bids, &AncillaryProduct::SpinningReserve, 100.0);
let activations = vec![ActivationRecord {
unit_id: "G1".to_string(),
energy_delivered_mwh: 50.0,
activation_price_per_mwh: 8.0,
}];
let reports = TsoMarket::settlement(&result, &activations, 1.0);
assert_eq!(reports.len(), 1);
let rep = &reports[0];
assert!(
(rep.availability_payment - 400.0).abs() < 1e-9,
"Availability payment should be $400, got {}",
rep.availability_payment
);
assert!(
(rep.activation_payment - 400.0).abs() < 1e-9,
"Activation payment should be $400, got {}",
rep.activation_payment
);
assert!(
(rep.total_payment - 800.0).abs() < 1e-9,
"Total payment should be $800, got {}",
rep.total_payment
);
}
#[test]
fn test_frequency_nadir_estimation() {
let market = default_market();
let metrics = market.frequency_response_assessment(300.0, 150.0);
assert!(
(metrics.rocof_hz_per_s - 0.5).abs() < 1e-6,
"ROCOF should be 0.5 Hz/s, got {}",
metrics.rocof_hz_per_s
);
assert!(
metrics.frequency_nadir_hz > 47.0 && metrics.frequency_nadir_hz < 50.0,
"Nadir must be between 47 and 50 Hz, got {}",
metrics.frequency_nadir_hz
);
assert!(
metrics.time_to_nadir_s > 0.0,
"Time to nadir must be positive"
);
assert!((metrics.recovery_time_s - 3.0 * metrics.time_to_nadir_s).abs() < 1e-9);
}
#[test]
fn test_co_optimize_energy_constrains_ancillary() {
let energy_bids = vec![
EnergyBid {
unit_id: "G1".to_string(),
capacity_mw: 200.0,
offer_price_per_mwh: 30.0,
p_min_mw: 0.0,
},
EnergyBid {
unit_id: "G2".to_string(),
capacity_mw: 200.0,
offer_price_per_mwh: 50.0,
p_min_mw: 0.0,
},
];
let ancillary_bids = vec![
spinning_bid("G1", 200.0, 3.0), spinning_bid("G2", 200.0, 4.0),
];
let req = ProductRequirements {
spinning_reserve_mw: 250.0,
..Default::default()
};
let (energy_dispatch, anc_results) =
TsoMarket::co_optimize_energy_ancillary(&energy_bids, &ancillary_bids, 100.0, &req);
let g1_energy = energy_dispatch.get("G1").copied().unwrap_or(0.0);
assert!(
(g1_energy - 100.0).abs() < 1e-9,
"G1 should be dispatched 100 MW in energy, got {}",
g1_energy
);
let spin = anc_results
.iter()
.find(|r| r.product == AncillaryProduct::SpinningReserve)
.expect("Spinning reserve result must exist");
assert!(
spin.shortfall_mw < 1e-9,
"No shortfall expected when combined residual ≥ requirement"
);
let g1_spin = spin
.accepted_bids
.iter()
.find(|ab| ab.unit_id == "G1")
.map(|ab| ab.capacity_accepted_mw)
.unwrap_or(0.0);
assert!(
g1_spin <= 100.0 + 1e-9,
"G1 spinning reserve must not exceed residual capacity (100 MW), got {}",
g1_spin
);
}
#[test]
fn test_multi_product_clearing() {
let mut market = TsoMarket::new(
"MULTI-01",
60.0,
ProductRequirements {
primary_mw: 50.0,
secondary_mw: 80.0,
tertiary_mw: 0.0, spinning_reserve_mw: 100.0,
non_spinning_reserve_mw: 0.0,
reactive_mvar: 0.0,
},
50.0,
5.0,
2000.0,
1.0,
);
let bids = vec![
primary_bid("G1", 60.0, 3.0),
primary_bid("G2", 60.0, 5.0),
AncillaryBid {
unit_id: "G3".to_string(),
product: AncillaryProduct::SecondaryReserve,
capacity_mw: 100.0,
availability_price_per_mw_h: 4.0,
activation_price_per_mwh: 8.0,
min_delivery_min: 30.0,
max_delivery_min: 900.0,
ramp_rate_mw_per_min: 2.0,
lead_time_min: 2.0,
},
spinning_bid("G4", 120.0, 6.0),
];
let results = market.clear_market(&bids);
assert_eq!(results.len(), 3, "Expected 3 product results");
let primary = results
.iter()
.find(|r| r.product == AncillaryProduct::PrimaryFrequencyResponse)
.expect("Primary result missing");
assert!(primary.total_capacity_cleared_mw >= 50.0 - 1e-9);
let secondary = results
.iter()
.find(|r| r.product == AncillaryProduct::SecondaryReserve)
.expect("Secondary result missing");
assert!(secondary.total_capacity_cleared_mw >= 80.0 - 1e-9);
let spinning = results
.iter()
.find(|r| r.product == AncillaryProduct::SpinningReserve)
.expect("Spinning result missing");
assert!(spinning.total_capacity_cleared_mw >= 100.0 - 1e-9);
assert_eq!(market.clearing_history().len(), 3);
}
#[test]
fn test_zero_requirement_product_skipped() {
let mut market = TsoMarket::new(
"SKIP-01",
60.0,
ProductRequirements {
primary_mw: 50.0,
secondary_mw: 0.0, tertiary_mw: 0.0,
spinning_reserve_mw: 0.0,
non_spinning_reserve_mw: 0.0,
reactive_mvar: 0.0,
},
50.0,
5.0,
1000.0,
1.0,
);
let bids = vec![primary_bid("G1", 60.0, 3.0)];
let results = market.clear_market(&bids);
assert_eq!(results.len(), 1, "Only primary should be cleared");
assert_eq!(
results[0].product,
AncillaryProduct::PrimaryFrequencyResponse
);
}
#[test]
fn test_hhi_monopoly() {
let bids = vec![spinning_bid("G1", 100.0, 5.0)];
let result =
TsoMarket::clear_single_product(&bids, &AncillaryProduct::SpinningReserve, 100.0);
let hhi = TsoMarket::calculate_hhi(&result);
assert!(
(hhi - 10_000.0).abs() < 1e-6,
"Monopoly HHI must be 10 000, got {}",
hhi
);
}
}