use crate::error::{OxiGridError, Result};
#[derive(Debug, Clone)]
pub enum AncillaryService {
SpinningReserve {
capacity_mw: f64,
bid_mwh: f64,
},
NonSpinningReserve {
capacity_mw: f64,
bid_mwh: f64,
},
RegulationUp {
capacity_mw: f64,
bid_mwh: f64,
},
RegulationDown {
capacity_mw: f64,
bid_mwh: f64,
},
Voltage {
reactive_mvar: f64,
bid_mvar: f64,
},
}
impl AncillaryService {
pub fn capacity_and_price(&self) -> (f64, f64) {
match self {
AncillaryService::SpinningReserve {
capacity_mw,
bid_mwh,
} => (*capacity_mw, *bid_mwh),
AncillaryService::NonSpinningReserve {
capacity_mw,
bid_mwh,
} => (*capacity_mw, *bid_mwh),
AncillaryService::RegulationUp {
capacity_mw,
bid_mwh,
} => (*capacity_mw, *bid_mwh),
AncillaryService::RegulationDown {
capacity_mw,
bid_mwh,
} => (*capacity_mw, *bid_mwh),
AncillaryService::Voltage {
reactive_mvar,
bid_mvar,
} => (*reactive_mvar, *bid_mvar),
}
}
pub fn service_type(&self) -> &'static str {
match self {
AncillaryService::SpinningReserve { .. } => "spinning",
AncillaryService::NonSpinningReserve { .. } => "non_spinning",
AncillaryService::RegulationUp { .. } => "regulation_up",
AncillaryService::RegulationDown { .. } => "regulation_down",
AncillaryService::Voltage { .. } => "voltage",
}
}
}
#[derive(Debug, Clone)]
pub struct AncillaryOffer {
pub unit_id: usize,
pub service: AncillaryService,
pub availability_price: f64,
}
#[derive(Debug, Clone)]
pub struct AncillaryResult {
pub cleared_offers: Vec<(usize, f64)>,
pub clearing_price: f64,
pub requirement_met: bool,
pub shortage_mw: f64,
}
pub fn clear_ancillary_market(
offers: &[AncillaryOffer],
requirement_mw: f64,
service_type: &str,
) -> Result<AncillaryResult> {
if requirement_mw < 0.0 {
return Err(OxiGridError::InvalidParameter(
"Ancillary service requirement cannot be negative".to_string(),
));
}
let mut eligible: Vec<&AncillaryOffer> = offers
.iter()
.filter(|o| service_type == "all" || o.service.service_type() == service_type)
.collect();
eligible.sort_by(|a, b| {
let (_, pa) = a.service.capacity_and_price();
let (_, pb) = b.service.capacity_and_price();
pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
});
let mut cleared_offers: Vec<(usize, f64)> = Vec::new();
let mut remaining = requirement_mw;
let mut clearing_price = 0.0;
for offer in &eligible {
if remaining <= 1e-9 {
break;
}
let (cap, price) = offer.service.capacity_and_price();
let cleared_cap = cap.min(remaining);
if cleared_cap > 0.0 {
cleared_offers.push((offer.unit_id, cleared_cap));
remaining -= cleared_cap;
clearing_price = price;
}
}
let shortage = remaining.max(0.0);
let requirement_met = shortage < 1e-6;
Ok(AncillaryResult {
cleared_offers,
clearing_price,
requirement_met,
shortage_mw: shortage,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ancillary_merit_order() {
let offers = vec![
AncillaryOffer {
unit_id: 0,
service: AncillaryService::SpinningReserve {
capacity_mw: 30.0,
bid_mwh: 5.0,
},
availability_price: 1.0,
},
AncillaryOffer {
unit_id: 1,
service: AncillaryService::SpinningReserve {
capacity_mw: 40.0,
bid_mwh: 8.0,
},
availability_price: 2.0,
},
];
let result = clear_ancillary_market(&offers, 50.0, "spinning")
.expect("Ancillary market should clear");
assert!(
result.requirement_met,
"50 MW should be met with 70 MW available"
);
assert_eq!(result.shortage_mw, 0.0);
let first_unit = result
.cleared_offers
.first()
.expect("At least one cleared offer");
assert_eq!(first_unit.0, 0, "Cheapest unit (id=0) should clear first");
}
#[test]
fn test_ancillary_negative_requirement_error() {
let offers: Vec<AncillaryOffer> = vec![];
let result = clear_ancillary_market(&offers, -10.0, "spinning");
assert!(result.is_err());
}
#[test]
fn test_ancillary_shortage() {
let offers = vec![AncillaryOffer {
unit_id: 0,
service: AncillaryService::SpinningReserve {
capacity_mw: 20.0,
bid_mwh: 5.0,
},
availability_price: 1.0,
}];
let result = clear_ancillary_market(&offers, 50.0, "spinning").expect("Should not error");
assert!(!result.requirement_met);
assert!((result.shortage_mw - 30.0).abs() < 1e-6);
}
}