use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FrpError {
#[error("schedule length {got} does not match n_hours {expected}")]
ScheduleLengthMismatch { got: usize, expected: usize },
#[error("invalid FRP config: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrpConfig {
pub n_hours: usize,
pub n_generators: usize,
pub ramping_requirement_mw_per_h: Vec<f64>,
pub down_ramp_requirement_mw_per_h: Vec<f64>,
pub frp_price_usd_per_mw: f64,
pub reserve_price_usd_per_mw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RampingGenerator {
pub id: usize,
pub p_min_mw: f64,
pub p_max_mw: f64,
pub ramp_up_mw_per_h: f64,
pub ramp_down_mw_per_h: f64,
pub cost_per_mwh: f64,
pub commitment: Vec<bool>,
pub dispatch_mw: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrpResult {
pub upward_frp_mw: Vec<Vec<f64>>,
pub downward_frp_mw: Vec<Vec<f64>>,
pub frp_requirement_met: Vec<bool>,
pub frp_cost_usd: f64,
pub unserved_frp_mw: Vec<f64>,
pub binding_hours: Vec<usize>,
}
pub struct FrpOptimizer {
config: FrpConfig,
generators: Vec<RampingGenerator>,
}
impl FrpOptimizer {
pub fn new(config: FrpConfig) -> Self {
Self {
config,
generators: Vec::new(),
}
}
pub fn add_generator(&mut self, gen: RampingGenerator) {
self.generators.push(gen);
}
pub fn optimize(&self) -> Result<FrpResult, FrpError> {
let n_h = self.config.n_hours;
if self.config.ramping_requirement_mw_per_h.len() != n_h {
return Err(FrpError::InvalidConfig(
"ramping_requirement_mw_per_h length != n_hours".into(),
));
}
if self.config.down_ramp_requirement_mw_per_h.len() != n_h {
return Err(FrpError::InvalidConfig(
"down_ramp_requirement_mw_per_h length != n_hours".into(),
));
}
for gen in &self.generators {
if gen.commitment.len() != n_h {
return Err(FrpError::ScheduleLengthMismatch {
got: gen.commitment.len(),
expected: n_h,
});
}
if gen.dispatch_mw.len() != n_h {
return Err(FrpError::ScheduleLengthMismatch {
got: gen.dispatch_mw.len(),
expected: n_h,
});
}
}
let n_gen = self.generators.len();
let mut upward_frp_mw: Vec<Vec<f64>> = vec![vec![0.0; n_gen]; n_h];
let mut downward_frp_mw: Vec<Vec<f64>> = vec![vec![0.0; n_gen]; n_h];
let mut frp_requirement_met: Vec<bool> = vec![false; n_h];
let mut unserved_frp_mw: Vec<f64> = vec![0.0; n_h];
let mut binding_hours: Vec<usize> = Vec::new();
let mut total_frp_cost: f64 = 0.0;
let mut merit_order: Vec<usize> = (0..n_gen).collect();
merit_order.sort_by(|&a, &b| {
self.generators[a]
.cost_per_mwh
.partial_cmp(&self.generators[b].cost_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
for h in 0..n_h {
let req_up = self.config.ramping_requirement_mw_per_h[h];
let req_dn = self.config.down_ramp_requirement_mw_per_h[h];
let mut remaining_up = req_up;
let mut remaining_dn = req_dn;
for &gi in &merit_order {
let gen = &self.generators[gi];
if !gen.commitment[h] {
continue; }
let dispatch = gen.dispatch_mw[h];
let headroom = (gen.p_max_mw - dispatch).max(0.0);
let avail_up = headroom.min(gen.ramp_up_mw_per_h);
let alloc_up = avail_up.min(remaining_up);
upward_frp_mw[h][gi] = alloc_up;
remaining_up -= alloc_up;
total_frp_cost += alloc_up * self.config.frp_price_usd_per_mw;
let footroom = (dispatch - gen.p_min_mw).max(0.0);
let avail_dn = footroom.min(gen.ramp_down_mw_per_h);
let alloc_dn = avail_dn.min(remaining_dn);
downward_frp_mw[h][gi] = alloc_dn;
remaining_dn -= alloc_dn;
}
unserved_frp_mw[h] = remaining_up.max(0.0);
frp_requirement_met[h] = remaining_up <= 1e-6;
let served_up = req_up - remaining_up.max(0.0);
if (served_up - req_up).abs() <= 1.0 && req_up > 0.0 {
binding_hours.push(h);
}
}
Ok(FrpResult {
upward_frp_mw,
downward_frp_mw,
frp_requirement_met,
frp_cost_usd: total_frp_cost,
unserved_frp_mw,
binding_hours,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn single_gen_config(n_hours: usize, req_up: f64) -> FrpConfig {
FrpConfig {
n_hours,
n_generators: 1,
ramping_requirement_mw_per_h: vec![req_up; n_hours],
down_ramp_requirement_mw_per_h: vec![req_up * 0.5; n_hours],
frp_price_usd_per_mw: 5.0,
reserve_price_usd_per_mw: 3.0,
}
}
fn gen_online(n_h: usize, pmax: f64, ramp: f64, dispatch: f64, cost: f64) -> RampingGenerator {
RampingGenerator {
id: 0,
p_min_mw: 0.0,
p_max_mw: pmax,
ramp_up_mw_per_h: ramp,
ramp_down_mw_per_h: ramp,
cost_per_mwh: cost,
commitment: vec![true; n_h],
dispatch_mw: vec![dispatch; n_h],
}
}
#[test]
fn test_sufficient_ramp_capacity_all_met() {
let mut opt = FrpOptimizer::new(single_gen_config(3, 50.0));
opt.add_generator(gen_online(3, 200.0, 120.0, 100.0, 30.0));
let res = opt.optimize().expect("optimize failed");
for h in 0..3 {
assert!(
res.frp_requirement_met[h],
"hour {h} requirement must be met"
);
assert_eq!(res.unserved_frp_mw[h], 0.0, "no unserved FRP at hour {h}");
}
}
#[test]
fn test_insufficient_capacity_unserved() {
let mut opt = FrpOptimizer::new(single_gen_config(2, 50.0));
opt.add_generator(gen_online(2, 100.0, 100.0, 100.0, 30.0));
let res = opt.optimize().expect("optimize failed");
for h in 0..2 {
assert!(
!res.frp_requirement_met[h],
"requirement must be unmet when no headroom"
);
assert!(
(res.unserved_frp_mw[h] - 50.0).abs() < 1e-9,
"unserved must equal requirement when headroom=0"
);
}
}
#[test]
fn test_merit_order_cheapest_first() {
let n_h = 1;
let config = FrpConfig {
n_hours: n_h,
n_generators: 2,
ramping_requirement_mw_per_h: vec![30.0],
down_ramp_requirement_mw_per_h: vec![20.0],
frp_price_usd_per_mw: 10.0,
reserve_price_usd_per_mw: 5.0,
};
let mut opt = FrpOptimizer::new(config);
opt.add_generator(RampingGenerator {
id: 0,
p_min_mw: 0.0,
p_max_mw: 200.0,
ramp_up_mw_per_h: 100.0,
ramp_down_mw_per_h: 80.0,
cost_per_mwh: 80.0,
commitment: vec![true],
dispatch_mw: vec![100.0],
});
opt.add_generator(RampingGenerator {
id: 1,
p_min_mw: 0.0,
p_max_mw: 200.0,
ramp_up_mw_per_h: 100.0,
ramp_down_mw_per_h: 80.0,
cost_per_mwh: 20.0,
commitment: vec![true],
dispatch_mw: vec![100.0],
});
let res = opt.optimize().expect("optimize failed");
assert!(
res.upward_frp_mw[0][1] >= 30.0 - 1e-9,
"cheap generator (index 1) should provide 30 MW FRP"
);
assert!(
res.upward_frp_mw[0][0] < 1e-9,
"expensive generator should not be dispatched for FRP"
);
}
#[test]
fn test_ramp_rate_constraint_limits_frp() {
let mut opt = FrpOptimizer::new(single_gen_config(1, 100.0));
opt.add_generator(gen_online(1, 200.0, 40.0, 0.0, 30.0));
let res = opt.optimize().expect("optimize failed");
assert!(
!res.frp_requirement_met[0],
"ramp-limited: requirement of 100 MW should not be met with ramp=40"
);
assert!(
(res.unserved_frp_mw[0] - 60.0).abs() < 1e-9,
"unserved = 100 - 40 = 60"
);
assert!(
(res.upward_frp_mw[0][0] - 40.0).abs() < 1e-9,
"allocated FRP = ramp limit = 40 MW"
);
}
#[test]
fn test_binding_hours_identified() {
let n_h = 3;
let config = FrpConfig {
n_hours: n_h,
n_generators: 1,
ramping_requirement_mw_per_h: vec![50.0, 80.0, 50.0],
down_ramp_requirement_mw_per_h: vec![20.0; n_h],
frp_price_usd_per_mw: 5.0,
reserve_price_usd_per_mw: 3.0,
};
let mut opt = FrpOptimizer::new(config);
opt.add_generator(gen_online(n_h, 200.0, 50.0, 100.0, 30.0));
let res = opt.optimize().expect("optimize failed");
assert!(
res.binding_hours.contains(&0),
"hour 0 (req=50, avail=50) should be binding"
);
assert!(
res.binding_hours.contains(&2),
"hour 2 (req=50, avail=50) should be binding"
);
}
#[test]
fn test_frp_cost_non_negative() {
let mut opt = FrpOptimizer::new(single_gen_config(4, 20.0));
opt.add_generator(gen_online(4, 150.0, 80.0, 80.0, 40.0));
let res = opt.optimize().expect("optimize failed");
assert!(res.frp_cost_usd >= 0.0);
}
}