use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RampRateRequirement {
pub max_ramp_up_pct_per_min: f64,
pub max_ramp_down_pct_per_min: f64,
pub emergency_ramp_pct_per_min: f64,
pub gradient_protection: bool,
}
impl RampRateRequirement {
pub fn standard() -> Self {
Self {
max_ramp_up_pct_per_min: 10.0,
max_ramp_down_pct_per_min: 10.0,
emergency_ramp_pct_per_min: 20.0,
gradient_protection: true,
}
}
pub fn strict() -> Self {
Self {
max_ramp_up_pct_per_min: 3.0,
max_ramp_down_pct_per_min: 3.0,
emergency_ramp_pct_per_min: 10.0,
gradient_protection: true,
}
}
fn pct_per_min_to_pu_per_s(pct_per_min: f64) -> f64 {
pct_per_min / 100.0 / 60.0
}
pub fn check_trajectory(&self, power_pu: &[f64], dt_s: f64) -> Vec<bool> {
if power_pu.is_empty() {
return Vec::new();
}
let max_up_pu_s = Self::pct_per_min_to_pu_per_s(self.max_ramp_up_pct_per_min);
let max_down_pu_s = Self::pct_per_min_to_pu_per_s(self.max_ramp_down_pct_per_min);
let mut result = Vec::with_capacity(power_pu.len());
result.push(true);
for window in power_pu.windows(2) {
let delta = window[1] - window[0];
let ramp_pu_s = if dt_s > 1e-12 { delta / dt_s } else { 0.0 };
let ok = if ramp_pu_s > 0.0 {
ramp_pu_s <= max_up_pu_s
} else {
ramp_pu_s.abs() <= max_down_pu_s
};
result.push(ok);
}
result
}
pub fn enforce_ramp_limits(&self, power_pu: &[f64], dt_s: f64) -> Vec<f64> {
if power_pu.is_empty() {
return Vec::new();
}
let max_up_pu_s = Self::pct_per_min_to_pu_per_s(self.max_ramp_up_pct_per_min);
let max_down_pu_s = Self::pct_per_min_to_pu_per_s(self.max_ramp_down_pct_per_min);
let max_up_step = max_up_pu_s * dt_s;
let max_down_step = max_down_pu_s * dt_s;
let mut enforced = Vec::with_capacity(power_pu.len());
enforced.push(power_pu[0]);
for &p_desired in &power_pu[1..] {
let p_prev = *enforced.last().unwrap_or(&0.0);
let delta = p_desired - p_prev;
let clipped_delta = if delta > 0.0 {
delta.min(max_up_step)
} else {
delta.max(-max_down_step)
};
enforced.push(p_prev + clipped_delta);
}
enforced
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ramp_rate_enforcement() {
let req = RampRateRequirement::standard();
let trajectory = vec![0.0, 0.5, 1.0];
let enforced = req.enforce_ramp_limits(&trajectory, 60.0);
assert_eq!(enforced.len(), 3);
assert!((enforced[0] - 0.0).abs() < 1e-9);
assert!(
(enforced[1] - enforced[0]).abs() <= 0.10 + 1e-9,
"Ramp should be clipped: enforced[1]={:.4}",
enforced[1]
);
}
#[test]
fn test_ramp_rate_enforcement_gradual_is_unchanged() {
let req = RampRateRequirement::standard();
let trajectory: Vec<f64> = (0..=10).map(|i| i as f64 * 0.01).collect(); let enforced = req.enforce_ramp_limits(&trajectory, 60.0);
for (orig, enf) in trajectory.iter().zip(enforced.iter()) {
assert!(
(orig - enf).abs() < 1e-9,
"Gradual ramp should be unchanged: orig={}, enf={}",
orig,
enf
);
}
}
#[test]
fn test_check_trajectory_detects_violation() {
let req = RampRateRequirement::standard(); let traj = vec![0.0, 0.5, 0.6];
let ok = req.check_trajectory(&traj, 1.0);
assert_eq!(ok.len(), 3);
assert!(ok[0], "First step should always be true");
assert!(!ok[1], "0.5 pu in 1 s should fail 10%/min ramp limit");
}
#[test]
fn test_check_trajectory_gradual_all_ok() {
let req = RampRateRequirement::strict(); let traj: Vec<f64> = (0..5).map(|i| i as f64 * 0.001).collect();
let ok = req.check_trajectory(&traj, 60.0);
assert!(
ok.iter().all(|&b| b),
"All steps within strict limit should be OK"
);
}
#[test]
fn test_ramp_down_enforced() {
let req = RampRateRequirement::standard(); let traj = vec![1.0, 0.0];
let enforced = req.enforce_ramp_limits(&traj, 60.0);
assert_eq!(enforced.len(), 2);
let ramp = enforced[0] - enforced[1];
assert!(
ramp <= 0.10 + 1e-9,
"Ramp down should be clipped to 0.10 pu/step: got {:.4}",
ramp
);
}
#[test]
fn test_empty_trajectory_returns_empty_vecs() {
let req = RampRateRequirement::standard();
let empty: Vec<f64> = Vec::new();
let checked = req.check_trajectory(&empty, 60.0);
assert!(
checked.is_empty(),
"check_trajectory on empty slice must return empty Vec"
);
let enforced = req.enforce_ramp_limits(&empty, 60.0);
assert!(
enforced.is_empty(),
"enforce_ramp_limits on empty slice must return empty Vec"
);
}
#[test]
fn test_single_element_trajectory() {
let req = RampRateRequirement::standard();
let single = vec![0.42_f64];
let checked = req.check_trajectory(&single, 60.0);
assert_eq!(checked.len(), 1, "check result length must be 1");
assert!(checked[0], "single-element check must always be true");
let enforced = req.enforce_ramp_limits(&single, 60.0);
assert_eq!(enforced.len(), 1, "enforce result length must be 1");
assert!(
(enforced[0] - 0.42).abs() < 1e-12,
"single element must be returned unchanged: got {:.9}",
enforced[0]
);
}
#[test]
fn test_strict_preset_fields() {
let req = RampRateRequirement::strict();
assert!(
(req.max_ramp_up_pct_per_min - 3.0).abs() < 1e-12,
"strict() up rate must be 3.0 %/min, got {}",
req.max_ramp_up_pct_per_min
);
assert!(
(req.max_ramp_down_pct_per_min - 3.0).abs() < 1e-12,
"strict() down rate must be 3.0 %/min, got {}",
req.max_ramp_down_pct_per_min
);
assert!(
(req.emergency_ramp_pct_per_min - 10.0).abs() < 1e-12,
"strict() emergency rate must be 10.0 %/min, got {}",
req.emergency_ramp_pct_per_min
);
assert!(
req.gradient_protection,
"strict() must have gradient_protection = true"
);
}
#[test]
fn test_ramp_down_violation_detected_strict() {
let req = RampRateRequirement::strict();
let traj = vec![0.50_f64, 0.40]; let ok = req.check_trajectory(&traj, 60.0);
assert_eq!(ok.len(), 2, "result length must match trajectory length");
assert!(ok[0], "first element is always true");
assert!(
!ok[1],
"−0.10 pu drop in 60 s must violate the 3 %/min down limit"
);
}
#[test]
fn test_enforce_ramp_up_monotone() {
let req = RampRateRequirement::standard(); let desired = vec![0.0_f64, 0.5, 1.0, 1.5, 2.0];
let enforced = req.enforce_ramp_limits(&desired, 60.0);
assert_eq!(enforced.len(), desired.len(), "length must be preserved");
for i in 1..enforced.len() {
let step = enforced[i] - enforced[i - 1];
assert!(
step >= -1e-12,
"enforced trajectory must be non-decreasing at index {}: step={:.9}",
i,
step
);
assert!(
step <= 0.10 + 1e-9,
"enforced step must not exceed 0.10 pu at index {}: step={:.9}",
i,
step
);
}
}
#[test]
fn test_asymmetric_limits_downward_violation() {
let req = RampRateRequirement {
max_ramp_up_pct_per_min: 5.0,
max_ramp_down_pct_per_min: 2.0,
emergency_ramp_pct_per_min: 15.0,
gradient_protection: true,
};
let traj = vec![0.50_f64, 0.46]; let ok = req.check_trajectory(&traj, 60.0);
assert_eq!(ok.len(), 2, "result length must match trajectory length");
assert!(ok[0], "first element is always true");
assert!(
!ok[1],
"−0.04 pu step in 60 s must violate the 2 %/min down limit (limit is 0.02 pu)"
);
}
#[test]
fn test_enforced_trajectory_passes_check() {
let req = RampRateRequirement::standard(); let desired = vec![0.0_f64, 1.0, 0.0, 1.0, 0.5];
let enforced = req.enforce_ramp_limits(&desired, 60.0);
let ok = req.check_trajectory(&enforced, 60.0);
assert_eq!(
ok.len(),
enforced.len(),
"check result length must match enforced length"
);
for (i, &pass) in ok.iter().enumerate() {
assert!(
pass,
"enforced trajectory must pass check at index {}: value={:.9}",
i, enforced[i]
);
}
}
}