#[derive(Debug, Clone, PartialEq)]
pub struct FrequencyMeasurement {
pub freq_hz: f64,
pub deviation_hz: f64,
pub rocof_hz_per_s: f64,
pub nadir_detected: bool,
pub timestamp_s: f64,
}
#[derive(Debug)]
pub struct FrequencyMeter {
pub nominal_hz: f64,
pub window_size: usize,
pub deadband_hz: f64,
sample_buffer: Vec<(f64, f64)>,
write_ptr: usize,
count: usize,
time_s: f64,
dt_s: f64,
prev_freq: f64,
prev_prev_freq: f64,
}
impl FrequencyMeter {
pub fn new(nominal_hz: f64, window_size: usize) -> Self {
let window_size = window_size.max(2);
Self {
nominal_hz,
window_size,
deadband_hz: 1e-3,
sample_buffer: Vec::with_capacity(window_size),
write_ptr: 0,
count: 0,
time_s: 0.0,
dt_s: 0.02, prev_freq: nominal_hz,
prev_prev_freq: nominal_hz,
}
}
pub fn update(&mut self, freq_sample: f64) -> FrequencyMeasurement {
if self.count > 0 {
self.time_s += self.dt_s;
}
let ts = self.time_s;
let entry = (ts, freq_sample);
if self.sample_buffer.len() < self.window_size {
self.sample_buffer.push(entry);
} else {
self.sample_buffer[self.write_ptr] = entry;
}
self.write_ptr = (self.write_ptr + 1) % self.window_size;
self.count = self.count.saturating_add(1);
let rocof = self.compute_rocof();
let raw_dev = freq_sample - self.nominal_hz;
let deviation_hz = if raw_dev.abs() < self.deadband_hz {
0.0
} else {
raw_dev
};
let nadir_detected =
self.count >= 3 && self.prev_freq < self.prev_prev_freq && freq_sample > self.prev_freq;
self.prev_prev_freq = self.prev_freq;
self.prev_freq = freq_sample;
FrequencyMeasurement {
freq_hz: freq_sample,
deviation_hz,
rocof_hz_per_s: rocof,
nadir_detected,
timestamp_s: ts,
}
}
fn compute_rocof(&self) -> f64 {
let n = self.sample_buffer.len();
if n < 2 {
return 0.0;
}
let n_f = n as f64;
let sum_t: f64 = self.sample_buffer.iter().map(|(t, _)| t).sum();
let sum_f: f64 = self.sample_buffer.iter().map(|(_, f)| f).sum();
let sum_t2: f64 = self.sample_buffer.iter().map(|(t, _)| t * t).sum();
let sum_tf: f64 = self.sample_buffer.iter().map(|(t, f)| t * f).sum();
let denom = n_f * sum_t2 - sum_t * sum_t;
if denom.abs() < f64::EPSILON {
return 0.0;
}
(n_f * sum_tf - sum_t * sum_f) / denom
}
pub fn set_dt(&mut self, dt_s: f64) {
self.dt_s = dt_s.max(1e-6);
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RelayAction {
NoAction,
Alarm,
Trip,
LoadShed(f64),
}
#[derive(Debug)]
pub struct RocofRelay {
pub threshold_hz_per_s: f64,
pub measurement_window_s: f64,
pub time_delay_s: f64,
pub enabled: bool,
accumulated_time: f64,
alarm_issued: bool,
}
impl RocofRelay {
pub fn new(threshold_hz_per_s: f64, time_delay_s: f64) -> Self {
Self {
threshold_hz_per_s: threshold_hz_per_s.abs(),
measurement_window_s: 0.1,
time_delay_s,
enabled: true,
accumulated_time: 0.0,
alarm_issued: false,
}
}
pub fn evaluate(&mut self, measurement: &FrequencyMeasurement, dt: f64) -> RelayAction {
if !self.enabled {
return RelayAction::NoAction;
}
if measurement.rocof_hz_per_s.abs() >= self.threshold_hz_per_s {
self.accumulated_time += dt;
} else {
self.accumulated_time = 0.0;
self.alarm_issued = false;
}
if self.accumulated_time >= self.time_delay_s {
RelayAction::Trip
} else if self.accumulated_time > self.time_delay_s * 0.5 && !self.alarm_issued {
self.alarm_issued = true;
RelayAction::Alarm
} else {
RelayAction::NoAction
}
}
pub fn reset(&mut self) {
self.accumulated_time = 0.0;
self.alarm_issued = false;
}
}
#[derive(Debug)]
pub struct FrequencyRelay {
pub under_freq_threshold: f64,
pub over_freq_threshold: f64,
pub time_delay_s: f64,
pub intentional_delay_s: f64,
timer: f64,
}
impl FrequencyRelay {
pub fn new(under_freq_threshold: f64, over_freq_threshold: f64, time_delay_s: f64) -> Self {
Self {
under_freq_threshold,
over_freq_threshold,
time_delay_s,
intentional_delay_s: 0.0,
timer: 0.0,
}
}
pub fn evaluate(&mut self, freq_hz: f64, dt: f64) -> RelayAction {
let total_delay = self.time_delay_s + self.intentional_delay_s;
if freq_hz < self.under_freq_threshold || freq_hz > self.over_freq_threshold {
self.timer += dt;
if self.timer >= total_delay {
return RelayAction::Trip;
}
if self.timer > total_delay * 0.5 {
return RelayAction::Alarm;
}
} else {
self.timer = 0.0;
}
RelayAction::NoAction
}
pub fn reset(&mut self) {
self.timer = 0.0;
}
}
#[derive(Debug, Clone)]
pub struct UflsStep {
pub freq_threshold_hz: f64,
pub time_delay_s: f64,
pub shed_fraction: f64,
pub label: String,
triggered: bool,
timer: f64,
}
impl UflsStep {
pub fn new(
freq_threshold_hz: f64,
time_delay_s: f64,
shed_fraction: f64,
label: impl Into<String>,
) -> Self {
Self {
freq_threshold_hz,
time_delay_s,
shed_fraction,
label: label.into(),
triggered: false,
timer: 0.0,
}
}
}
#[derive(Debug)]
pub struct UflsScheme {
pub steps: Vec<UflsStep>,
pub total_load_mw: f64,
pub shed_history: Vec<(f64, f64)>,
time_s: f64,
}
impl UflsScheme {
pub fn new_entso_e(total_load_mw: f64) -> Self {
let steps = vec![
UflsStep::new(49.0, 0.20, 0.05, "ENTSO-E Step 1 (49.0 Hz)"),
UflsStep::new(48.8, 0.20, 0.05, "ENTSO-E Step 2 (48.8 Hz)"),
UflsStep::new(48.6, 0.20, 0.05, "ENTSO-E Step 3 (48.6 Hz)"),
UflsStep::new(48.4, 0.20, 0.05, "ENTSO-E Step 4 (48.4 Hz)"),
UflsStep::new(48.2, 0.20, 0.05, "ENTSO-E Step 5 (48.2 Hz)"),
UflsStep::new(47.5, 0.20, 0.10, "ENTSO-E Step 6 (47.5 Hz)"),
];
Self {
steps,
total_load_mw,
shed_history: Vec::new(),
time_s: 0.0,
}
}
pub fn new_nerc(total_load_mw: f64) -> Self {
let steps = vec![
UflsStep::new(59.3, 0.28, 0.05, "NERC Step 1 (59.3 Hz)"),
UflsStep::new(58.9, 0.28, 0.05, "NERC Step 2 (58.9 Hz)"),
UflsStep::new(58.5, 0.28, 0.05, "NERC Step 3 (58.5 Hz)"),
UflsStep::new(58.1, 0.28, 0.05, "NERC Step 4 (58.1 Hz)"),
UflsStep::new(57.7, 0.28, 0.05, "NERC Step 5 (57.7 Hz)"),
];
Self {
steps,
total_load_mw,
shed_history: Vec::new(),
time_s: 0.0,
}
}
pub fn step(&mut self, freq_hz: f64, dt: f64) -> f64 {
self.time_s += dt;
let mut shed_this_step = 0.0;
for ufls_step in &mut self.steps {
if ufls_step.triggered {
continue;
}
if freq_hz < ufls_step.freq_threshold_hz {
ufls_step.timer += dt;
if ufls_step.timer >= ufls_step.time_delay_s {
ufls_step.triggered = true;
let mw = ufls_step.shed_fraction * self.total_load_mw;
shed_this_step += mw;
}
} else {
ufls_step.timer = 0.0;
}
}
if shed_this_step > 0.0 {
self.shed_history.push((self.time_s, shed_this_step));
}
shed_this_step
}
pub fn total_shed_mw(&self) -> f64 {
self.shed_history.iter().map(|(_, mw)| mw).sum()
}
pub fn reset(&mut self) {
for s in &mut self.steps {
s.triggered = false;
s.timer = 0.0;
}
self.shed_history.clear();
self.time_s = 0.0;
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NadirEstimate {
pub nadir_freq_hz: f64,
pub time_to_nadir_s: f64,
pub quasi_steady_state_hz: f64,
pub rocof_initial_hz_per_s: f64,
}
#[derive(Debug, Clone)]
pub struct NadirEstimator {
pub total_inertia_mj_per_mva: f64,
pub system_mva: f64,
pub droop_response_mw_per_hz: f64,
pub governor_time_const_s: f64,
}
impl NadirEstimator {
pub fn new(
total_inertia_mj_per_mva: f64,
system_mva: f64,
droop_response_mw_per_hz: f64,
governor_time_const_s: f64,
) -> Self {
Self {
total_inertia_mj_per_mva,
system_mva,
droop_response_mw_per_hz,
governor_time_const_s,
}
}
pub fn estimate_nadir(&self, initial_freq_hz: f64, power_imbalance_mw: f64) -> NadirEstimate {
let h = self.total_inertia_mj_per_mva.max(f64::EPSILON);
let s_base = self.system_mva.max(f64::EPSILON);
let f0 = initial_freq_hz;
let dp = power_imbalance_mw;
let rocof0 = -dp * f0 / (2.0 * h * s_base);
let d_total = self.droop_response_mw_per_hz.max(f64::EPSILON);
let df_qss = -dp / d_total;
let f_qss = f0 + df_qss;
let dt = 0.01_f64; let t_gov = self.governor_time_const_s.max(f64::EPSILON);
let mut freq = f0;
let mut gov_output = 0.0_f64; let mut t = 0.0_f64;
let mut nadir = f0;
let mut t_nadir = 0.0_f64;
let mut prev_rocof = rocof0;
for _i in 0..2000 {
let gov_ss = (f0 - freq) * d_total; let d_gov = (gov_ss - gov_output) / t_gov;
gov_output += d_gov * dt;
let net_imbalance = dp - gov_output;
let rocof = -net_imbalance * f0 / (2.0 * h * s_base);
freq += rocof * dt;
t += dt;
if freq < nadir {
nadir = freq;
t_nadir = t;
}
if prev_rocof < 0.0 && rocof >= 0.0 {
break;
}
prev_rocof = rocof;
}
NadirEstimate {
nadir_freq_hz: nadir,
time_to_nadir_s: t_nadir,
quasi_steady_state_hz: f_qss,
rocof_initial_hz_per_s: rocof0,
}
}
}
#[derive(Debug, Default)]
pub struct InertiaEstimator {
measurements: Vec<FrequencyMeasurement>,
}
impl InertiaEstimator {
pub fn new() -> Self {
Self {
measurements: Vec::new(),
}
}
pub fn add_measurement(&mut self, m: FrequencyMeasurement) {
self.measurements.push(m);
}
pub fn estimate_inertia(
&self,
power_imbalance_mw: f64,
system_mva: f64,
) -> Result<f64, String> {
if self.measurements.is_empty() {
return Err("No measurements available".into());
}
let s_base = system_mva.max(f64::EPSILON);
let avg_rocof = self
.measurements
.iter()
.map(|m| m.rocof_hz_per_s)
.sum::<f64>()
/ self.measurements.len() as f64;
let avg_freq = self.measurements.iter().map(|m| m.freq_hz).sum::<f64>()
/ self.measurements.len() as f64;
if avg_rocof.abs() < 1e-6 {
return Err("ROCOF too small for inertia estimation".into());
}
let h = -power_imbalance_mw * avg_freq / (2.0 * s_base * avg_rocof);
if h <= 0.0 {
return Err(format!(
"Inertia estimate non-physical: H = {h:.4} s (check sign of imbalance/ROCOF)"
));
}
Ok(h)
}
pub fn estimate_from_event_window(
&self,
start_s: f64,
end_s: f64,
power_imbalance_mw: f64,
system_mva: f64,
) -> Result<f64, String> {
let window: Vec<&FrequencyMeasurement> = self
.measurements
.iter()
.filter(|m| m.timestamp_s >= start_s && m.timestamp_s <= end_s)
.collect();
if window.is_empty() {
return Err(format!(
"No measurements in window [{start_s:.2}, {end_s:.2}] s"
));
}
let s_base = system_mva.max(f64::EPSILON);
let n = window.len() as f64;
let avg_rocof = window.iter().map(|m| m.rocof_hz_per_s).sum::<f64>() / n;
let avg_freq = window.iter().map(|m| m.freq_hz).sum::<f64>() / n;
if avg_rocof.abs() < 1e-6 {
return Err("ROCOF too small for inertia estimation in window".into());
}
let h = -power_imbalance_mw * avg_freq / (2.0 * s_base * avg_rocof);
if h <= 0.0 {
return Err(format!(
"Inertia estimate non-physical (window): H = {h:.4} s"
));
}
Ok(h)
}
pub fn clear(&mut self) {
self.measurements.clear();
}
pub fn len(&self) -> usize {
self.measurements.len()
}
pub fn is_empty(&self) -> bool {
self.measurements.is_empty()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FrequencyComplianceReport {
pub time_in_normal_band_pct: f64,
pub time_in_alert_band_pct: f64,
pub time_in_emergency_pct: f64,
pub max_rocof_hz_per_s: f64,
pub nadir_count: usize,
pub ufls_activations: usize,
pub compliant: bool,
}
impl FrequencyComplianceReport {
pub fn compute(measurements: &[FrequencyMeasurement]) -> Self {
if measurements.is_empty() {
return Self {
time_in_normal_band_pct: 100.0,
time_in_alert_band_pct: 0.0,
time_in_emergency_pct: 0.0,
max_rocof_hz_per_s: 0.0,
nadir_count: 0,
ufls_activations: 0,
compliant: true,
};
}
let n = measurements.len() as f64;
let mut normal = 0usize;
let mut alert = 0usize;
let mut emergency = 0usize;
let mut max_rocof: f64 = 0.0;
let mut nadir_count = 0usize;
let mut ufls_activations = 0usize;
let mut in_emergency = false;
for m in measurements {
let abs_dev = m.deviation_hz.abs();
if abs_dev < 0.05 {
normal += 1;
} else if abs_dev < 0.2 {
alert += 1;
} else {
emergency += 1;
if !in_emergency {
ufls_activations += 1;
in_emergency = true;
}
}
if abs_dev < 0.2 {
in_emergency = false;
}
let abs_rocof = m.rocof_hz_per_s.abs();
if abs_rocof > max_rocof {
max_rocof = abs_rocof;
}
if m.nadir_detected {
nadir_count += 1;
}
}
let time_in_normal_band_pct = (normal as f64 / n) * 100.0;
let time_in_alert_band_pct = (alert as f64 / n) * 100.0;
let time_in_emergency_pct = (emergency as f64 / n) * 100.0;
let compliant = time_in_emergency_pct < 0.1 && max_rocof < 2.0;
Self {
time_in_normal_band_pct,
time_in_alert_band_pct,
time_in_emergency_pct,
max_rocof_hz_per_s: max_rocof,
nadir_count,
ufls_activations,
compliant,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frequency_meter_steady_rocof_near_zero() {
let mut meter = FrequencyMeter::new(50.0, 20);
meter.set_dt(0.02);
let mut last = FrequencyMeasurement {
freq_hz: 0.0,
deviation_hz: 0.0,
rocof_hz_per_s: 0.0,
nadir_detected: false,
timestamp_s: 0.0,
};
for _ in 0..20 {
last = meter.update(50.0);
}
assert!(
last.rocof_hz_per_s.abs() < 0.05,
"steady ROCOF should be ≈ 0, got {}",
last.rocof_hz_per_s
);
}
#[test]
fn test_frequency_meter_linear_decline_rocof() {
let mut meter = FrequencyMeter::new(50.0, 30);
meter.set_dt(0.02);
let rate = -1.0_f64; let dt = 0.02_f64;
let mut last = FrequencyMeasurement {
freq_hz: 50.0,
deviation_hz: 0.0,
rocof_hz_per_s: 0.0,
nadir_detected: false,
timestamp_s: 0.0,
};
for k in 0..30usize {
let f = 50.0 + rate * (k as f64) * dt;
last = meter.update(f);
}
let error = (last.rocof_hz_per_s - rate).abs();
assert!(
error < 0.15,
"ROCOF should be ≈ -1.0 Hz/s, got {:.4}",
last.rocof_hz_per_s
);
}
#[test]
fn test_frequency_meter_deviation_deadband() {
let mut meter = FrequencyMeter::new(50.0, 5);
let m = meter.update(50.0 + 5e-4);
assert_eq!(
m.deviation_hz, 0.0,
"inside deadband: deviation should be 0"
);
}
#[test]
fn test_rocof_relay_no_trip_below_threshold() {
let mut relay = RocofRelay::new(1.0, 0.1);
let meas = FrequencyMeasurement {
freq_hz: 49.8,
deviation_hz: -0.2,
rocof_hz_per_s: 0.4, nadir_detected: false,
timestamp_s: 0.0,
};
let mut action = RelayAction::NoAction;
for _ in 0..10 {
action = relay.evaluate(&meas, 0.1);
}
assert_ne!(action, RelayAction::Trip, "ROCOF below threshold → no trip");
}
#[test]
fn test_rocof_relay_trip_after_delay() {
let mut relay = RocofRelay::new(1.0, 0.2); let meas = FrequencyMeasurement {
freq_hz: 49.5,
deviation_hz: -0.5,
rocof_hz_per_s: 2.0, nadir_detected: false,
timestamp_s: 0.0,
};
let mut action = RelayAction::NoAction;
for _ in 0..25 {
action = relay.evaluate(&meas, 0.02);
}
assert_eq!(action, RelayAction::Trip, "ROCOF above threshold → trip");
}
#[test]
fn test_rocof_relay_reset_clears_accumulation() {
let mut relay = RocofRelay::new(1.0, 0.1);
let meas = FrequencyMeasurement {
freq_hz: 49.5,
deviation_hz: -0.5,
rocof_hz_per_s: 2.5,
nadir_detected: false,
timestamp_s: 0.0,
};
for _ in 0..5 {
relay.evaluate(&meas, 0.02);
}
relay.reset();
assert!(
relay.accumulated_time == 0.0,
"reset should clear accumulated_time"
);
}
#[test]
fn test_frequency_relay_no_trip_at_nominal() {
let mut relay = FrequencyRelay::new(49.0, 51.0, 0.3);
let mut action = RelayAction::NoAction;
for _ in 0..50 {
action = relay.evaluate(50.0, 0.02);
}
assert_eq!(
action,
RelayAction::NoAction,
"nominal frequency → no action"
);
}
#[test]
fn test_frequency_relay_trip_at_under_freq() {
let mut relay = FrequencyRelay::new(49.0, 51.0, 0.2);
let mut action = RelayAction::NoAction;
for _ in 0..20 {
action = relay.evaluate(48.5, 0.02);
}
assert_eq!(action, RelayAction::Trip, "under-frequency → trip");
}
#[test]
fn test_frequency_relay_trip_at_over_freq() {
let mut relay = FrequencyRelay::new(49.0, 51.0, 0.2);
let mut action = RelayAction::NoAction;
for _ in 0..20 {
action = relay.evaluate(51.5, 0.02);
}
assert_eq!(action, RelayAction::Trip, "over-frequency → trip");
}
#[test]
fn test_ufls_entso_e_has_six_steps() {
let scheme = UflsScheme::new_entso_e(1000.0);
assert_eq!(scheme.steps.len(), 6, "ENTSO-E scheme must have 6 steps");
}
#[test]
fn test_ufls_nerc_has_five_steps() {
let scheme = UflsScheme::new_nerc(1000.0);
assert_eq!(scheme.steps.len(), 5, "NERC scheme must have 5 steps");
}
#[test]
fn test_ufls_shed_fraction_at_49hz() {
let mut scheme = UflsScheme::new_entso_e(1000.0);
let mut total_shed = 0.0;
for _ in 0..30 {
total_shed += scheme.step(48.9, 0.02); }
assert!(
(total_shed - 50.0).abs() < 1.0,
"Expected ≈ 50 MW shed (step 1), got {total_shed:.1} MW"
);
}
#[test]
fn test_ufls_reset_clears_triggered() {
let mut scheme = UflsScheme::new_entso_e(1000.0);
for _ in 0..100 {
scheme.step(47.0, 0.02);
}
let before = scheme.total_shed_mw();
assert!(before > 0.0, "steps should have been triggered");
scheme.reset();
assert_eq!(scheme.total_shed_mw(), 0.0, "reset clears shed history");
for s in &scheme.steps {
assert!(!s.triggered, "reset clears triggered flags");
}
}
#[test]
fn test_nadir_below_initial_for_generation_loss() {
let estimator = NadirEstimator::new(5.0, 1000.0, 100.0, 8.0);
let result = estimator.estimate_nadir(50.0, 100.0); assert!(
result.nadir_freq_hz < 50.0,
"nadir must be below initial frequency"
);
}
#[test]
fn test_nadir_larger_imbalance_lower_nadir() {
let estimator = NadirEstimator::new(5.0, 1000.0, 100.0, 8.0);
let small = estimator.estimate_nadir(50.0, 50.0);
let large = estimator.estimate_nadir(50.0, 200.0);
assert!(
large.nadir_freq_hz < small.nadir_freq_hz,
"larger disturbance → lower nadir"
);
}
#[test]
fn test_nadir_rocof_proportional_to_imbalance() {
let estimator = NadirEstimator::new(5.0, 1000.0, 100.0, 8.0);
let r1 = estimator.estimate_nadir(50.0, 100.0);
let r2 = estimator.estimate_nadir(50.0, 200.0);
assert!(
r2.rocof_initial_hz_per_s.abs() > r1.rocof_initial_hz_per_s.abs(),
"larger imbalance → larger initial ROCOF"
);
}
#[test]
fn test_inertia_estimator_physically_reasonable() {
let mut est = InertiaEstimator::new();
for k in 0..20usize {
est.add_measurement(FrequencyMeasurement {
freq_hz: 49.9,
deviation_hz: -0.1,
rocof_hz_per_s: -1.0,
nadir_detected: false,
timestamp_s: k as f64 * 0.02,
});
}
let h = est
.estimate_inertia(100.0, 1000.0)
.expect("inertia estimate");
assert!(
h > 0.0 && h < 20.0,
"inertia H should be in [0, 20] s, got {h:.3}"
);
}
#[test]
fn test_inertia_estimator_no_data_returns_err() {
let est = InertiaEstimator::new();
let result = est.estimate_inertia(100.0, 1000.0);
assert!(result.is_err(), "no data should return Err");
}
#[test]
fn test_inertia_estimator_window_subset() {
let mut est = InertiaEstimator::new();
for k in 0..50usize {
let ts = k as f64 * 0.02;
est.add_measurement(FrequencyMeasurement {
freq_hz: 49.9,
deviation_hz: -0.1,
rocof_hz_per_s: if ts < 0.5 { -1.0 } else { -0.1 },
nadir_detected: false,
timestamp_s: ts,
});
}
let h = est
.estimate_from_event_window(0.0, 0.4, 100.0, 1000.0)
.expect("window estimate");
assert!(h > 0.0, "window H should be positive");
}
#[test]
fn test_compliance_all_normal_is_compliant() {
let measurements: Vec<FrequencyMeasurement> = (0..100)
.map(|k| FrequencyMeasurement {
freq_hz: 50.0,
deviation_hz: 0.0,
rocof_hz_per_s: 0.0,
nadir_detected: false,
timestamp_s: k as f64 * 0.02,
})
.collect();
let report = FrequencyComplianceReport::compute(&measurements);
assert!(report.compliant, "all nominal → compliant");
assert!((report.time_in_normal_band_pct - 100.0).abs() < 1e-9);
assert_eq!(report.time_in_emergency_pct as u64, 0);
}
#[test]
fn test_compliance_emergency_band_triggers_non_compliant() {
let measurements: Vec<FrequencyMeasurement> = (0..100)
.map(|k| FrequencyMeasurement {
freq_hz: 49.5,
deviation_hz: -0.5,
rocof_hz_per_s: 0.0,
nadir_detected: false,
timestamp_s: k as f64 * 0.02,
})
.collect();
let report = FrequencyComplianceReport::compute(&measurements);
assert!(!report.compliant, "emergency samples → non-compliant");
assert!(report.time_in_emergency_pct > 0.0);
}
#[test]
fn test_compliance_empty_measurements() {
let report = FrequencyComplianceReport::compute(&[]);
assert!(report.compliant, "empty → compliant by default");
assert_eq!(report.nadir_count, 0);
assert_eq!(report.ufls_activations, 0);
}
}