use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrequencyResponseRequirement {
pub name: String,
pub dead_band_hz: f64,
pub droop_pct: f64,
pub f_min_hz: f64,
pub f_max_hz: f64,
pub lsip_required: bool,
pub fast_frequency_response: bool,
pub inertia_response_required: bool,
}
impl FrequencyResponseRequirement {
pub fn entso_e_rfg() -> Self {
Self {
name: "ENTSO-E RfG".to_string(),
dead_band_hz: 0.0,
droop_pct: 4.0,
f_min_hz: 47.0,
f_max_hz: 52.0,
lsip_required: false,
fast_frequency_response: false,
inertia_response_required: false,
}
}
pub fn nerc_bal() -> Self {
Self {
name: "NERC BAL-003-2".to_string(),
dead_band_hz: 0.036, droop_pct: 5.0,
f_min_hz: 59.0,
f_max_hz: 61.0,
lsip_required: false,
fast_frequency_response: false,
inertia_response_required: false,
}
}
pub fn required_response_pu(&self, freq_hz: f64, nominal_hz: f64) -> f64 {
let delta_f = freq_hz - nominal_hz;
if delta_f.abs() <= self.dead_band_hz {
return 0.0;
}
let droop_frac = self.droop_pct / 100.0;
if droop_frac < 1e-9 {
return 0.0;
}
let response = -(1.0 / droop_frac) * (delta_f / nominal_hz);
response.clamp(-1.0, 1.0)
}
pub fn check_inertia_response(&self, rocof: f64, p_response: f64) -> bool {
if !self.inertia_response_required {
return true; }
if rocof.abs() < 0.5 {
return true;
}
p_response >= 0.05
}
pub fn within_operating_range(&self, freq_hz: f64) -> bool {
freq_hz >= self.f_min_hz && freq_hz <= self.f_max_hz
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FfrRequirement {
pub response_time_ms: f64,
pub sustained_duration_s: f64,
pub activation_threshold_hz: f64,
pub required_response_pu: f64,
}
impl FfrRequirement {
pub fn standard() -> Self {
Self {
response_time_ms: 500.0,
sustained_duration_s: 30.0,
activation_threshold_hz: 0.5,
required_response_pu: 0.05,
}
}
pub fn enhanced() -> Self {
Self {
response_time_ms: 150.0,
sustained_duration_s: 10.0,
activation_threshold_hz: 0.2,
required_response_pu: 0.10,
}
}
pub fn is_compliant(
&self,
actual_response_time_ms: f64,
actual_duration_s: f64,
actual_response_pu: f64,
) -> bool {
actual_response_time_ms <= self.response_time_ms
&& actual_duration_s >= self.sustained_duration_s
&& actual_response_pu >= self.required_response_pu
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frequency_response_within_deadband() {
let req = FrequencyResponseRequirement::nerc_bal();
let resp = req.required_response_pu(60.02, 60.0);
assert_eq!(resp, 0.0, "Response within dead-band should be zero");
}
#[test]
fn test_frequency_response_droop() {
let req = FrequencyResponseRequirement::entso_e_rfg();
let resp = req.required_response_pu(49.0, 50.0);
assert!(
(resp - 0.5).abs() < 1e-9,
"Expected 0.5 pu response at 49 Hz with 4% droop, got {:.6}",
resp
);
}
#[test]
fn test_frequency_response_clamped() {
let req = FrequencyResponseRequirement::entso_e_rfg();
let resp = req.required_response_pu(43.0, 50.0);
assert!(
(resp - 1.0).abs() < 1e-9,
"Response should be clamped to 1.0"
);
}
#[test]
fn test_frequency_response_over_frequency() {
let req = FrequencyResponseRequirement::entso_e_rfg();
let resp = req.required_response_pu(51.0, 50.0);
assert!(
resp < 0.0,
"Over-frequency should produce negative (curtailment) response"
);
}
#[test]
fn test_frequency_within_operating_range() {
let req = FrequencyResponseRequirement::entso_e_rfg();
assert!(req.within_operating_range(50.0));
assert!(req.within_operating_range(49.0));
assert!(!req.within_operating_range(46.9));
assert!(!req.within_operating_range(52.1));
}
#[test]
fn test_inertia_response_not_required_always_passes() {
let mut req = FrequencyResponseRequirement::entso_e_rfg();
req.inertia_response_required = false;
assert!(req.check_inertia_response(5.0, 0.0));
}
#[test]
fn test_ffr_standard_compliance() {
let ffr = FfrRequirement::standard();
assert!(ffr.is_compliant(400.0, 35.0, 0.08));
assert!(!ffr.is_compliant(600.0, 35.0, 0.08)); assert!(!ffr.is_compliant(400.0, 20.0, 0.08)); }
}