use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, thiserror::Error)]
pub enum UflsError {
#[error("invalid UFLS parameter: {0}")]
InvalidParameter(String),
#[error("simulation diverged: {0}")]
SimulationDiverged(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UflsConfig {
pub nominal_frequency_hz: f64,
pub stages: Vec<UflsStage>,
pub rocof_blocking: f64,
pub inertia_constant_s: f64,
pub load_restoration_delay_s: f64,
}
impl Default for UflsConfig {
fn default() -> Self {
Self {
nominal_frequency_hz: 50.0,
stages: vec![
UflsStage {
stage_id: 1,
frequency_threshold_hz: 49.0,
load_shedding_pct: 10.0,
time_delay_ms: 200.0,
adaptive: false,
priority_buses: vec![],
},
UflsStage {
stage_id: 2,
frequency_threshold_hz: 48.5,
load_shedding_pct: 15.0,
time_delay_ms: 200.0,
adaptive: true,
priority_buses: vec![],
},
UflsStage {
stage_id: 3,
frequency_threshold_hz: 48.0,
load_shedding_pct: 20.0,
time_delay_ms: 200.0,
adaptive: true,
priority_buses: vec![],
},
],
rocof_blocking: 2.5,
inertia_constant_s: 6.0,
load_restoration_delay_s: 30.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UflsStage {
pub stage_id: usize,
pub frequency_threshold_hz: f64,
pub load_shedding_pct: f64,
pub time_delay_ms: f64,
pub adaptive: bool,
pub priority_buses: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdaptiveUflsResult {
pub stages_triggered: Vec<usize>,
pub total_load_shed_mw: f64,
pub total_load_shed_pct: f64,
pub frequency_trajectory: Vec<(f64, f64)>,
pub frequency_nadir_hz: f64,
pub nadir_time_s: f64,
pub frequency_recovered: bool,
pub recovery_time_s: Option<f64>,
pub shedding_events: Vec<SheddingEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SheddingEvent {
pub stage: usize,
pub time_s: f64,
pub load_shed_mw: f64,
pub frequency_at_trigger_hz: f64,
pub buses_affected: Vec<usize>,
}
pub struct AdaptiveUflsController {
config: UflsConfig,
}
impl AdaptiveUflsController {
pub fn new(config: UflsConfig) -> Self {
Self { config }
}
pub fn simulate(
&self,
power_imbalance_mw: f64,
total_load_mw: f64,
simulation_time_s: f64,
dt_s: f64,
) -> Result<AdaptiveUflsResult, UflsError> {
if total_load_mw <= 0.0 {
return Err(UflsError::InvalidParameter(
"total_load_mw must be positive".into(),
));
}
if dt_s <= 0.0 || dt_s > 1.0 {
return Err(UflsError::InvalidParameter(
"dt_s must be in (0, 1] seconds".into(),
));
}
if simulation_time_s <= 0.0 {
return Err(UflsError::InvalidParameter(
"simulation_time_s must be positive".into(),
));
}
if self.config.inertia_constant_s <= 0.0 {
return Err(UflsError::InvalidParameter(
"inertia_constant_s must be positive".into(),
));
}
let f0 = self.config.nominal_frequency_hz;
let h = self.config.inertia_constant_s;
let d_coeff = 0.01_f64;
let n_steps = (simulation_time_s / dt_s).ceil() as usize + 1;
let mut freq = f0;
let mut p_load = total_load_mw; let p_mech = total_load_mw + power_imbalance_mw;
let n_stages = self.config.stages.len();
let mut stage_trigger_time: Vec<Option<f64>> = vec![None; n_stages];
let mut stage_fired: Vec<bool> = vec![false; n_stages];
let mut pending: Vec<(f64, f64, usize, f64, f64)> = Vec::new();
let mut trajectory: Vec<(f64, f64)> = Vec::with_capacity(n_steps.min(100_000));
let mut shedding_events: Vec<SheddingEvent> = Vec::new();
let mut stages_triggered: Vec<usize> = Vec::new();
let mut freq_nadir = freq;
let mut nadir_time = 0.0_f64;
let recovery_band_hz = 0.5_f64;
let mut recovery_time: Option<f64> = None;
let mut post_shed_time: Option<f64> = None;
let rocof_window = (0.1 / dt_s).max(1.0) as usize; let mut freq_history: Vec<f64> = Vec::with_capacity(rocof_window + 1);
freq_history.push(freq);
let mut total_shed_mw = 0.0_f64;
for step in 0..n_steps {
let time = step as f64 * dt_s;
let mut newly_shed = 0.0_f64;
for &(fire_time, shed_mw, stage_idx, trigger_freq, _rocof) in &pending {
if time >= fire_time {
let stage = &self.config.stages[stage_idx];
if !stage_fired[stage_idx] {
newly_shed += shed_mw;
total_shed_mw += shed_mw;
p_load -= shed_mw;
stage_fired[stage_idx] = true;
shedding_events.push(SheddingEvent {
stage: stage.stage_id,
time_s: time,
load_shed_mw: shed_mw,
frequency_at_trigger_hz: trigger_freq,
buses_affected: stage.priority_buses.clone(),
});
if post_shed_time.is_none() {
post_shed_time = Some(time + self.config.load_restoration_delay_s);
}
}
}
}
if newly_shed > 0.0 {
pending.retain(|(fire_time, _shed, stage_idx, _tf, _r)| {
!stage_fired[*stage_idx] || time < *fire_time
});
}
trajectory.push((time, freq));
if freq < freq_nadir {
freq_nadir = freq;
nadir_time = time;
}
let rocof = self.estimate_rocof(&freq_history, dt_s);
for (idx, stage) in self.config.stages.iter().enumerate() {
if stage_fired[idx] {
continue;
}
if freq < stage.frequency_threshold_hz {
if stage_trigger_time[idx].is_none() {
stage_trigger_time[idx] = Some(time);
if !self.should_block(rocof) {
let shed_pct = if stage.adaptive {
self.adaptive_shed_pct(stage, rocof)
} else {
stage.load_shedding_pct
};
let shed_mw = total_load_mw * shed_pct / 100.0;
let delay_s = stage.time_delay_ms / 1000.0;
let fire_time = time + delay_s;
pending.push((fire_time, shed_mw, idx, freq, rocof));
stages_triggered.push(stage.stage_id);
}
}
} else {
stage_trigger_time[idx] = None;
}
}
if recovery_time.is_none()
&& (freq - f0).abs() < recovery_band_hz
&& total_shed_mw > 0.0
&& time > 1.0
{
let ok = post_shed_time.map_or(true, |t| time >= t);
if ok {
recovery_time = Some(time);
}
}
let p_imbalance = p_mech - p_load;
let s_base = total_load_mw.max(1.0); let df_dt = (p_imbalance / s_base) * f0 / (2.0 * h);
freq += df_dt * dt_s;
if !freq.is_finite() {
return Err(UflsError::SimulationDiverged(format!(
"frequency diverged at t={time:.3} s"
)));
}
let load_base = total_load_mw - total_shed_mw;
let freq_dev_pu = (freq - f0) / f0;
p_load = (load_base * (1.0 + d_coeff * freq_dev_pu)).max(0.0);
freq_history.push(freq);
if freq_history.len() > rocof_window + 1 {
freq_history.remove(0);
}
}
let freq_recovered =
recovery_time.is_some() || (freq - f0).abs() < recovery_band_hz && total_shed_mw == 0.0;
stages_triggered.dedup();
Ok(AdaptiveUflsResult {
stages_triggered,
total_load_shed_mw: total_shed_mw,
total_load_shed_pct: total_shed_mw / total_load_mw * 100.0,
frequency_trajectory: trajectory,
frequency_nadir_hz: freq_nadir,
nadir_time_s: nadir_time,
frequency_recovered: freq_recovered,
recovery_time_s: recovery_time,
shedding_events,
})
}
fn adaptive_shed_pct(&self, stage: &UflsStage, rocof: f64) -> f64 {
let rocof_ref = (self.config.rocof_blocking / 2.0).max(0.1);
let multiplier = (1.0 + rocof.abs() / rocof_ref).min(2.0);
(stage.load_shedding_pct * multiplier).min(100.0)
}
fn should_block(&self, rocof: f64) -> bool {
rocof.abs() > self.config.rocof_blocking
}
fn estimate_rocof(&self, freq_history: &[f64], dt_s: f64) -> f64 {
let n = freq_history.len();
if n < 2 {
return 0.0;
}
let df = freq_history[n - 1] - freq_history[0];
let dt = (n - 1) as f64 * dt_s;
if dt < 1e-12 {
return 0.0;
}
df / dt
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> UflsConfig {
UflsConfig::default()
}
#[test]
fn test_small_disturbance_no_ufls() {
let config = UflsConfig {
nominal_frequency_hz: 50.0,
stages: vec![UflsStage {
stage_id: 1,
frequency_threshold_hz: 47.5, load_shedding_pct: 10.0,
time_delay_ms: 200.0,
adaptive: false,
priority_buses: vec![],
}],
rocof_blocking: 2.5,
inertia_constant_s: 6.0,
load_restoration_delay_s: 30.0,
};
let ctrl = AdaptiveUflsController::new(config);
let result = ctrl.simulate(-10.0, 1000.0, 20.0, 0.02).unwrap();
assert!(
result.stages_triggered.is_empty(),
"No stages should trigger for small disturbance, nadir={:.3} Hz",
result.frequency_nadir_hz
);
assert!(
result.total_load_shed_mw < 1e-9,
"No load should be shed for small disturbance"
);
assert!(
result.frequency_nadir_hz > 47.5,
"Nadir {:.3} should be above 47.5 Hz",
result.frequency_nadir_hz
);
}
#[test]
fn test_large_disturbance_multiple_stages() {
let config = default_config();
let ctrl = AdaptiveUflsController::new(config);
let result = ctrl.simulate(-200.0, 1000.0, 30.0, 0.01).unwrap();
assert!(
!result.stages_triggered.is_empty(),
"At least one stage should fire for large disturbance"
);
assert!(
result.total_load_shed_mw > 0.0,
"Load must be shed: {:.2} MW",
result.total_load_shed_mw
);
assert!(
result.frequency_nadir_hz > 47.5,
"Nadir {:.3} Hz must stay above 47.5 Hz safety limit",
result.frequency_nadir_hz
);
}
#[test]
fn test_adaptive_higher_rocof_sheds_more() {
let config = default_config();
let stage = config.stages.iter().find(|s| s.adaptive).cloned().unwrap();
let ctrl = AdaptiveUflsController::new(config);
let shed_low = ctrl.adaptive_shed_pct(&stage, 0.3);
let shed_high = ctrl.adaptive_shed_pct(&stage, 2.0);
assert!(
shed_high > shed_low,
"Higher ROCOF ({:.1}) should shed more ({:.2}%) than low ROCOF ({:.1}) -> ({:.2}%)",
2.0,
shed_high,
0.3,
shed_low
);
}
#[test]
fn test_stage_timing_delay_respected() {
let config = default_config();
let stage_delays: Vec<(usize, f64)> = config
.stages
.iter()
.map(|s| (s.stage_id, s.time_delay_ms / 1000.0))
.collect();
let ctrl = AdaptiveUflsController::new(config);
let result = ctrl.simulate(-200.0, 1000.0, 30.0, 0.01).unwrap();
for event in &result.shedding_events {
let min_delay_s = stage_delays
.iter()
.find(|&&(id, _)| id == event.stage)
.map(|&(_, d)| d)
.unwrap_or(0.0);
assert!(
event.time_s >= min_delay_s - 1e-6,
"Stage {} event at {:.3} s violates min delay {:.3} s",
event.stage,
event.time_s,
min_delay_s
);
}
}
#[test]
fn test_frequency_recovery_after_shedding() {
let config = default_config();
let ctrl = AdaptiveUflsController::new(config);
let result = ctrl.simulate(-150.0, 1000.0, 60.0, 0.02).unwrap();
assert!(
result.frequency_nadir_hz > 47.0,
"Frequency should not collapse: nadir = {:.3} Hz",
result.frequency_nadir_hz
);
for (t, f) in &result.frequency_trajectory {
assert!(
t.is_finite() && f.is_finite(),
"Non-finite point in trajectory: t={t}, f={f}"
);
}
}
#[test]
fn test_rocof_blocking_suppresses_stage() {
let mut config = default_config();
config.rocof_blocking = 0.01; let ctrl = AdaptiveUflsController::new(config);
let result = ctrl.simulate(-200.0, 1000.0, 5.0, 0.01).unwrap();
assert!(
result.shedding_events.is_empty(),
"All stages should be blocked by ROCOF blocking: {:?}",
result.shedding_events
);
}
#[test]
fn test_invalid_parameter_returns_error() {
let config = default_config();
let ctrl = AdaptiveUflsController::new(config);
assert!(ctrl.simulate(-100.0, -1.0, 10.0, 0.01).is_err());
assert!(ctrl.simulate(-100.0, 1000.0, 10.0, -0.01).is_err());
assert!(ctrl.simulate(-100.0, 1000.0, -1.0, 0.01).is_err());
}
}