use crate::error::OxiGridError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GeneratorTechnology {
SteamTurbine,
GasTurbine,
HydroPower {
penstock_water_mass: f64,
},
CombinedCycle,
NuclearSteam,
WindTurbineType3 {
virtual_inertia: f64,
},
WindTurbineType4,
PhotovoltaicPlant,
BessWithVirtualInertia {
kvi: f64,
},
}
impl GeneratorTechnology {
pub fn effective_inertia_s(&self, rated_h_s: f64) -> f64 {
match self {
Self::SteamTurbine => rated_h_s,
Self::GasTurbine => rated_h_s,
Self::HydroPower { .. } => rated_h_s,
Self::CombinedCycle => rated_h_s,
Self::NuclearSteam => rated_h_s,
Self::WindTurbineType3 { virtual_inertia } => {
rated_h_s + virtual_inertia
}
Self::WindTurbineType4 => 0.0,
Self::PhotovoltaicPlant => 0.0,
Self::BessWithVirtualInertia { kvi: _ } => {
0.0
}
}
}
pub fn is_synchronous(&self) -> bool {
matches!(
self,
Self::SteamTurbine
| Self::GasTurbine
| Self::HydroPower { .. }
| Self::CombinedCycle
| Self::NuclearSteam
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratorInertia {
pub id: usize,
pub bus: usize,
pub rated_mva: f64,
pub h_constant_s: f64,
pub d_damping: f64,
pub droop_percent: f64,
pub response_time_s: f64,
pub is_online: bool,
pub technology: GeneratorTechnology,
}
impl GeneratorInertia {
pub fn effective_h_s(&self) -> f64 {
self.technology.effective_inertia_s(self.h_constant_s)
}
pub fn stored_energy_mws(&self) -> f64 {
self.effective_h_s() * self.rated_mva
}
pub fn droop_pu(&self) -> f64 {
self.droop_percent / 100.0
}
fn governor_ss_mw(&self, delta_f_hz: f64, f0_hz: f64) -> f64 {
if self.droop_pu() < 1e-12 {
return 0.0;
}
let delta_f_pu = delta_f_hz / f0_hz;
-delta_f_pu / self.droop_pu() * self.rated_mva
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RocofRiskLevel {
Low,
Medium,
High,
Critical,
}
impl RocofRiskLevel {
fn from_adequacy(adequacy: f64) -> Self {
if adequacy >= 1.5 {
Self::Low
} else if adequacy >= 1.0 {
Self::Medium
} else if adequacy >= 0.7 {
Self::High
} else {
Self::Critical
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInertia {
pub total_h_mws: f64,
pub system_h_s: f64,
pub weighted_average_h_s: f64,
pub synchronous_mva: f64,
pub converter_mva: f64,
pub inertia_adequacy: f64,
pub rocof_risk: RocofRiskLevel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrequencyNadirResult {
pub time_to_nadir_s: f64,
pub nadir_frequency_hz: f64,
pub nadir_deviation_hz: f64,
pub rocof_initial_hz_per_s: f64,
pub quasi_steady_state_hz: f64,
pub ufls_triggered: bool,
pub rocof_limit_violated: bool,
pub trajectory: Vec<(f64, f64)>,
pub governor_response: Vec<(f64, f64)>,
}
#[derive(Debug, Clone)]
pub struct FrequencyNadirPredictor {
pub generators: Vec<GeneratorInertia>,
pub system_mva: f64,
pub frequency_hz: f64,
pub minimum_frequency_hz: f64,
pub rocof_limit_hz_per_s: f64,
virtual_inertia_resources: Vec<(f64, f64)>,
}
impl FrequencyNadirPredictor {
pub fn new(
generators: Vec<GeneratorInertia>,
system_mva: f64,
frequency_hz: f64,
minimum_frequency_hz: f64,
rocof_limit_hz_per_s: f64,
) -> Self {
Self {
generators,
system_mva,
frequency_hz,
minimum_frequency_hz,
rocof_limit_hz_per_s,
virtual_inertia_resources: Vec::new(),
}
}
pub fn compute_system_inertia(&self) -> SystemInertia {
let mut total_h_mws = 0.0_f64;
let mut synchronous_mva = 0.0_f64;
let mut converter_mva = 0.0_f64;
let mut weighted_sum = 0.0_f64;
for gen in &self.generators {
if !gen.is_online {
continue;
}
let h_eff = gen.effective_h_s();
let mva = gen.rated_mva;
total_h_mws += h_eff * mva;
weighted_sum += h_eff * mva;
if gen.technology.is_synchronous() {
synchronous_mva += mva;
} else {
converter_mva += mva;
}
}
let s_base = if self.system_mva > 0.0 {
self.system_mva
} else {
1.0
};
let system_h_s = total_h_mws / s_base;
let online_mva = synchronous_mva + converter_mva;
let weighted_average_h_s = if online_mva > 0.0 {
weighted_sum / online_mva
} else {
0.0
};
let default_loss_mw = 0.05 * s_base;
let h_min = self.minimum_inertia_for_rocof(default_loss_mw);
let inertia_adequacy = if h_min > 0.0 {
system_h_s / h_min
} else {
f64::INFINITY
};
let rocof_risk = RocofRiskLevel::from_adequacy(inertia_adequacy);
SystemInertia {
total_h_mws,
system_h_s,
weighted_average_h_s,
synchronous_mva,
converter_mva,
inertia_adequacy,
rocof_risk,
}
}
pub fn predict_nadir(
&self,
power_imbalance_mw: f64,
t_max_s: f64,
) -> Result<FrequencyNadirResult, OxiGridError> {
if self.system_mva <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"system_mva must be positive".into(),
));
}
let inertia = self.compute_system_inertia();
if inertia.system_h_s <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"no online generators with positive inertia found".into(),
));
}
let f0 = self.frequency_hz;
let fn_ = self.frequency_hz; let h_sys = inertia.system_h_s;
let s_base = self.system_mva;
let online_gens: Vec<&GeneratorInertia> = self
.generators
.iter()
.filter(|g| g.is_online && g.droop_percent > 0.0)
.collect();
let n_gov = online_gens.len();
let n_vi = self.virtual_inertia_resources.len();
let n_states = 1 + n_gov + n_vi;
let mut state = vec![0.0_f64; n_states];
state[0] = f0;
let dt = 0.01_f64; let n_steps = ((t_max_s / dt).ceil() as usize).max(1);
let mut trajectory: Vec<(f64, f64)> = Vec::with_capacity(n_steps + 1);
let mut gov_trace: Vec<(f64, f64)> = Vec::with_capacity(n_steps + 1);
trajectory.push((0.0, f0));
gov_trace.push((0.0, 0.0));
let mut nadir_freq = f0;
let mut time_to_nadir = 0.0_f64;
let mut ufls_triggered = false;
let mut rocof_limit_violated = false;
let rocof_0 = -power_imbalance_mw * fn_ / (2.0 * h_sys * s_base);
if rocof_0.abs() > self.rocof_limit_hz_per_s {
rocof_limit_violated = true;
}
let deriv = |t: f64, s: &[f64]| -> Vec<f64> {
let _ = t;
let f_now = s[0];
let delta_f = f_now - fn_;
let mut p_gov_total = 0.0_f64;
for i in 0..n_gov {
p_gov_total += s[1 + i];
}
for i in 0..n_vi {
p_gov_total += s[1 + n_gov + i];
}
let p_imbalance_pu = power_imbalance_mw - p_gov_total;
let df_dt = -p_imbalance_pu * fn_ / (2.0 * h_sys * s_base);
let mut d = vec![0.0_f64; n_states];
d[0] = df_dt;
for (i, gen) in online_gens.iter().enumerate() {
let p_ss = gen.governor_ss_mw(delta_f, fn_);
let p_gov_i = s[1 + i];
let t_gov = gen.response_time_s.max(1e-6);
d[1 + i] = (p_ss - p_gov_i) / t_gov;
}
for (j, &(k_vi, t_vi)) in self.virtual_inertia_resources.iter().enumerate() {
let p_vi_ss = -k_vi * (delta_f / fn_);
let p_vi_j = s[1 + n_gov + j];
let t_resp = t_vi.max(1e-6);
d[1 + n_gov + j] = (p_vi_ss - p_vi_j) / t_resp;
}
d
};
for step in 0..n_steps {
let t = step as f64 * dt;
let s = state.clone();
let k1 = deriv(t, &s);
let s2: Vec<f64> = s
.iter()
.zip(k1.iter())
.map(|(x, d)| x + 0.5 * dt * d)
.collect();
let k2 = deriv(t + 0.5 * dt, &s2);
let s3: Vec<f64> = s
.iter()
.zip(k2.iter())
.map(|(x, d)| x + 0.5 * dt * d)
.collect();
let k3 = deriv(t + 0.5 * dt, &s3);
let s4: Vec<f64> = s.iter().zip(k3.iter()).map(|(x, d)| x + dt * d).collect();
let k4 = deriv(t + dt, &s4);
for i in 0..n_states {
state[i] += dt / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]);
}
let t_new = (step + 1) as f64 * dt;
let f_new = state[0];
if step == 0 {
let rocof_now = (f_new - f0) / dt;
if rocof_now.abs() > self.rocof_limit_hz_per_s {
rocof_limit_violated = true;
}
}
if f_new < nadir_freq {
nadir_freq = f_new;
time_to_nadir = t_new;
}
if f_new < self.minimum_frequency_hz {
ufls_triggered = true;
}
let gov_total: f64 = (0..n_gov + n_vi).map(|i| state[1 + i]).sum();
trajectory.push((t_new, f_new));
gov_trace.push((t_new, gov_total));
}
let qss_start = (n_steps as f64 * 0.9) as usize;
let qss_slice = &trajectory[qss_start.min(trajectory.len().saturating_sub(1))..];
let qss_freq = if qss_slice.is_empty() {
nadir_freq
} else {
qss_slice.iter().map(|(_, f)| f).sum::<f64>() / qss_slice.len() as f64
};
Ok(FrequencyNadirResult {
time_to_nadir_s: time_to_nadir,
nadir_frequency_hz: nadir_freq,
nadir_deviation_hz: f0 - nadir_freq,
rocof_initial_hz_per_s: rocof_0,
quasi_steady_state_hz: qss_freq,
ufls_triggered,
rocof_limit_violated,
trajectory,
governor_response: gov_trace,
})
}
pub fn minimum_inertia_for_rocof(&self, power_imbalance_mw: f64) -> f64 {
let denom = 2.0 * self.rocof_limit_hz_per_s * self.system_mva;
if denom <= 0.0 {
return 0.0;
}
power_imbalance_mw * self.frequency_hz / denom
}
pub fn maximum_credible_loss(&self, t_max_s: f64) -> Result<f64, OxiGridError> {
let tiny = 0.1_f64;
let result = self.predict_nadir(tiny, t_max_s)?;
if result.ufls_triggered {
return Ok(0.0);
}
let mut lo = 0.0_f64;
let mut hi = self.system_mva;
for _ in 0..50 {
let mid = (lo + hi) / 2.0;
if mid < 0.1 {
break;
}
let res = self.predict_nadir(mid, t_max_s)?;
if res.ufls_triggered {
hi = mid;
} else {
lo = mid;
}
if hi - lo < 0.1 {
break;
}
}
Ok(lo)
}
pub fn add_virtual_inertia(&mut self, k_vi_mw_s: f64, t_response_s: f64) {
self.virtual_inertia_resources
.push((k_vi_mw_s, t_response_s.max(0.001)));
}
pub fn fcr_requirement(&self, t_delivery_s: f64) -> f64 {
let default_loss_mw = 0.05 * self.system_mva;
let h_min = self.minimum_inertia_for_rocof(default_loss_mw);
if t_delivery_s <= 0.0 {
return 0.0;
}
h_min * 2.0 * self.system_mva / t_delivery_s
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InertiaEmulationControl {
pub k_inertia: f64,
pub k_droop: f64,
pub deadband_hz: f64,
pub max_power_mw: f64,
pub ramp_limit_mw_per_s: f64,
pub df_dt: f64,
pub delta_f: f64,
pub p_output_mw: f64,
pub p_prev_mw: f64,
}
impl InertiaEmulationControl {
pub fn new(
k_inertia: f64,
k_droop: f64,
deadband_hz: f64,
max_power_mw: f64,
ramp_limit_mw_per_s: f64,
) -> Self {
Self {
k_inertia,
k_droop,
deadband_hz,
max_power_mw,
ramp_limit_mw_per_s,
df_dt: 0.0,
delta_f: 0.0,
p_output_mw: 0.0,
p_prev_mw: 0.0,
}
}
pub fn compute_response(&mut self, frequency_hz: f64, rocof_hz_per_s: f64, dt: f64) -> f64 {
self.df_dt = rocof_hz_per_s;
self.delta_f = frequency_hz - self.nominal_frequency_estimate();
let effective_delta_f = if self.delta_f.abs() < self.deadband_hz {
0.0
} else {
self.delta_f
};
let effective_rocof = rocof_hz_per_s;
let p_desired = self.k_inertia * (-effective_rocof) + self.k_droop * (-effective_delta_f);
let max_delta = self.ramp_limit_mw_per_s * dt.max(1e-9);
let p_ramped = p_desired.clamp(self.p_prev_mw - max_delta, self.p_prev_mw + max_delta);
let p_final = p_ramped.clamp(0.0, self.max_power_mw);
self.p_prev_mw = p_final;
self.p_output_mw = p_final;
p_final
}
fn nominal_frequency_estimate(&self) -> f64 {
50.0
}
pub fn virtual_h_constant(&self, s_resource_mva: f64) -> f64 {
if s_resource_mva <= 0.0 {
return 0.0;
}
self.k_inertia / (2.0 * s_resource_mva)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EstimationMethod {
LinearRegression,
KalmanFilter,
WallisMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InertiaEstimate {
pub timestamp: f64,
pub estimated_h_mws: f64,
pub confidence: f64,
pub rocof_measured_hz_per_s: f64,
pub power_imbalance_estimate_mw: f64,
pub method: EstimationMethod,
}
pub struct InertiaMonitor {
pub window_size: usize,
pub sampling_rate_hz: f64,
pub frequency_buffer: Vec<f64>,
pub time_buffer: Vec<f64>,
}
impl InertiaMonitor {
pub fn new(window_size: usize, sampling_rate_hz: f64) -> Self {
Self {
window_size: window_size.max(3),
sampling_rate_hz,
frequency_buffer: Vec::with_capacity(window_size),
time_buffer: Vec::with_capacity(window_size),
}
}
pub fn update(&mut self, timestamp: f64, frequency_hz: f64) {
self.frequency_buffer.push(frequency_hz);
self.time_buffer.push(timestamp);
if self.frequency_buffer.len() > self.window_size {
self.frequency_buffer.remove(0);
self.time_buffer.remove(0);
}
}
pub fn estimate_rocof(&self) -> f64 {
let n = self.frequency_buffer.len();
if n < 2 {
return 0.0;
}
let n_f = n as f64;
let sum_t: f64 = self.time_buffer.iter().sum();
let sum_f: f64 = self.frequency_buffer.iter().sum();
let sum_tf: f64 = self
.time_buffer
.iter()
.zip(self.frequency_buffer.iter())
.map(|(t, f)| t * f)
.sum();
let sum_t2: f64 = self.time_buffer.iter().map(|t| t * t).sum();
let denom = n_f * sum_t2 - sum_t * sum_t;
if denom.abs() < 1e-30 {
return 0.0;
}
(n_f * sum_tf - sum_t * sum_f) / denom
}
pub fn estimate_inertia(&self, power_imbalance_mw: f64, system_mva: f64) -> InertiaEstimate {
let rocof = self.estimate_rocof();
let timestamp = self.time_buffer.last().copied().unwrap_or(0.0);
let fn_ = 50.0_f64;
let h_mws = if rocof.abs() < 1e-9 || system_mva <= 0.0 {
0.0
} else {
power_imbalance_mw * fn_ / (2.0 * rocof.abs() * system_mva) * system_mva
};
let n = self.frequency_buffer.len() as f64;
let event_magnitude = power_imbalance_mw.abs() / system_mva.max(1.0);
let window_fill = (n / self.window_size as f64).min(1.0);
let confidence = (event_magnitude * 10.0).min(1.0) * window_fill;
InertiaEstimate {
timestamp,
estimated_h_mws: h_mws,
confidence,
rocof_measured_hz_per_s: rocof,
power_imbalance_estimate_mw: power_imbalance_mw,
method: EstimationMethod::LinearRegression,
}
}
pub fn detect_event(&self, rocof_threshold_hz_per_s: f64) -> bool {
self.estimate_rocof().abs() > rocof_threshold_hz_per_s
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_steam_gen(id: usize, mva: f64, h: f64) -> GeneratorInertia {
GeneratorInertia {
id,
bus: id,
rated_mva: mva,
h_constant_s: h,
d_damping: 1.0,
droop_percent: 5.0,
response_time_s: 5.0,
is_online: true,
technology: GeneratorTechnology::SteamTurbine,
}
}
fn make_predictor_homogeneous() -> FrequencyNadirPredictor {
let gens: Vec<GeneratorInertia> = (0..10).map(|i| make_steam_gen(i, 100.0, 6.0)).collect();
FrequencyNadirPredictor::new(gens, 1000.0, 50.0, 47.5, 1.0)
}
#[test]
fn test_generator_inertia_creation() {
let gen = make_steam_gen(1, 200.0, 5.0);
assert_eq!(gen.id, 1);
assert!((gen.rated_mva - 200.0).abs() < 1e-9);
assert!((gen.h_constant_s - 5.0).abs() < 1e-9);
assert!((gen.effective_h_s() - 5.0).abs() < 1e-9);
assert!((gen.stored_energy_mws() - 1000.0).abs() < 1e-9);
assert!(gen.technology.is_synchronous());
}
#[test]
fn test_system_inertia_calculation_homogeneous() {
let p = make_predictor_homogeneous();
let si = p.compute_system_inertia();
assert!(
(si.system_h_s - 6.0).abs() < 1e-6,
"H_sys={}",
si.system_h_s
);
assert!((si.total_h_mws - 6000.0).abs() < 1e-6);
assert!((si.synchronous_mva - 1000.0).abs() < 1e-6);
assert!((si.converter_mva).abs() < 1e-6);
}
#[test]
fn test_system_inertia_with_renewables_zero() {
let mut gens: Vec<GeneratorInertia> =
(0..5).map(|i| make_steam_gen(i, 100.0, 5.0)).collect();
gens.push(GeneratorInertia {
id: 10,
bus: 10,
rated_mva: 200.0,
h_constant_s: 0.0,
d_damping: 0.0,
droop_percent: 0.0,
response_time_s: 0.0,
is_online: true,
technology: GeneratorTechnology::WindTurbineType4,
});
gens.push(GeneratorInertia {
id: 11,
bus: 11,
rated_mva: 100.0,
h_constant_s: 0.0,
d_damping: 0.0,
droop_percent: 0.0,
response_time_s: 0.0,
is_online: true,
technology: GeneratorTechnology::PhotovoltaicPlant,
});
let p = FrequencyNadirPredictor::new(gens, 800.0, 50.0, 47.5, 1.0);
let si = p.compute_system_inertia();
assert!(
(si.total_h_mws - 2500.0).abs() < 1e-6,
"h_mws={}",
si.total_h_mws
);
assert!(si.converter_mva > 0.0, "converter_mva should be > 0");
}
#[test]
fn test_rocof_risk_assessment_low() {
let gens: Vec<GeneratorInertia> = (0..20).map(|i| make_steam_gen(i, 500.0, 10.0)).collect();
let p = FrequencyNadirPredictor::new(gens, 1000.0, 50.0, 47.5, 1.0);
let si = p.compute_system_inertia();
assert_eq!(si.rocof_risk, RocofRiskLevel::Low);
}
#[test]
fn test_rocof_risk_assessment_critical() {
let gens: Vec<GeneratorInertia> = vec![make_steam_gen(0, 10.0, 0.1)];
let p = FrequencyNadirPredictor::new(gens, 10000.0, 50.0, 47.5, 1.0);
let si = p.compute_system_inertia();
assert!(
si.rocof_risk == RocofRiskLevel::Critical || si.rocof_risk == RocofRiskLevel::High,
"risk={:?}",
si.rocof_risk
);
}
#[test]
fn test_frequency_nadir_basic() {
let p = make_predictor_homogeneous();
let res = p.predict_nadir(100.0, 30.0).expect("predict_nadir failed");
assert!(
res.nadir_frequency_hz < 50.0,
"nadir={}",
res.nadir_frequency_hz
);
assert!(
res.nadir_frequency_hz > 40.0,
"nadir too low: {}",
res.nadir_frequency_hz
);
assert!(res.rocof_initial_hz_per_s < 0.0);
assert!(!res.trajectory.is_empty());
}
#[test]
fn test_frequency_nadir_small_inertia() {
let gens_low: Vec<GeneratorInertia> =
(0..3).map(|i| make_steam_gen(i, 100.0, 2.0)).collect();
let gens_high: Vec<GeneratorInertia> =
(0..3).map(|i| make_steam_gen(i, 100.0, 8.0)).collect();
let p_low = FrequencyNadirPredictor::new(gens_low, 300.0, 50.0, 47.5, 2.0);
let p_high = FrequencyNadirPredictor::new(gens_high, 300.0, 50.0, 47.5, 2.0);
let r_low = p_low.predict_nadir(50.0, 30.0).expect("low inertia failed");
let r_high = p_high
.predict_nadir(50.0, 30.0)
.expect("high inertia failed");
assert!(
r_low.nadir_frequency_hz < r_high.nadir_frequency_hz,
"low H nadir {} >= high H nadir {}",
r_low.nadir_frequency_hz,
r_high.nadir_frequency_hz
);
}
#[test]
fn test_frequency_nadir_trajectory_length() {
let p = make_predictor_homogeneous();
let t_max = 20.0_f64;
let dt = 0.01_f64;
let res = p.predict_nadir(50.0, t_max).expect("predict failed");
let expected = (t_max / dt).ceil() as usize + 1;
assert_eq!(
res.trajectory.len(),
expected,
"trajectory length {} != expected {}",
res.trajectory.len(),
expected
);
}
#[test]
fn test_nadir_above_minimum() {
let p = make_predictor_homogeneous();
let res = p.predict_nadir(30.0, 30.0).expect("predict failed");
assert!(
!res.ufls_triggered,
"UFLS incorrectly triggered, nadir={}",
res.nadir_frequency_hz
);
}
#[test]
fn test_nadir_below_minimum_triggers_ufls() {
let gens = vec![GeneratorInertia {
id: 0,
bus: 0,
rated_mva: 50.0,
h_constant_s: 1.0,
d_damping: 0.0,
droop_percent: 0.0, response_time_s: 100.0,
is_online: true,
technology: GeneratorTechnology::SteamTurbine,
}];
let p = FrequencyNadirPredictor::new(gens, 50.0, 50.0, 48.0, 5.0);
let res = p.predict_nadir(40.0, 10.0).expect("predict failed");
assert!(
res.ufls_triggered,
"UFLS should have triggered, nadir={}",
res.nadir_frequency_hz
);
}
#[test]
fn test_minimum_inertia_formula() {
let p = make_predictor_homogeneous();
let h_min = p.minimum_inertia_for_rocof(100.0);
assert!((h_min - 2.5).abs() < 1e-6, "H_min={}", h_min);
}
#[test]
fn test_maximum_credible_loss() {
let p = make_predictor_homogeneous();
let max_loss = p.maximum_credible_loss(30.0).expect("max_loss failed");
assert!(max_loss > 0.0, "max_loss should be positive");
assert!(max_loss <= p.system_mva, "max_loss > system_mva");
}
#[test]
fn test_virtual_inertia_augmentation() {
let p_base = make_predictor_homogeneous();
let res_base = p_base.predict_nadir(200.0, 30.0).expect("base failed");
let mut p_vi = make_predictor_homogeneous();
p_vi.add_virtual_inertia(500.0, 0.05); let res_vi = p_vi.predict_nadir(200.0, 30.0).expect("vi failed");
assert!(
res_vi.nadir_frequency_hz >= res_base.nadir_frequency_hz,
"VI nadir {} < base nadir {}",
res_vi.nadir_frequency_hz,
res_base.nadir_frequency_hz
);
}
#[test]
fn test_fcr_requirement() {
let p = make_predictor_homogeneous();
let fcr_30 = p.fcr_requirement(30.0);
let fcr_15 = p.fcr_requirement(15.0);
assert!(fcr_30 > 0.0, "FCR should be positive");
assert!(
fcr_15 > fcr_30,
"shorter t_delivery should need more FCR: fcr_15={} fcr_30={}",
fcr_15,
fcr_30
);
}
#[test]
fn test_inertia_emulation_droop_response() {
let mut ctrl = InertiaEmulationControl::new(
0.0, 10.0, 0.0, 100.0, 50.0, );
let p = ctrl.compute_response(49.0, 0.0, 0.1);
assert!(p > 0.0, "droop response should be positive, got {}", p);
assert!(p <= 100.0);
}
#[test]
fn test_inertia_emulation_rocof_response() {
let mut ctrl = InertiaEmulationControl::new(
50.0, 0.0, 0.0, 200.0, 200.0, );
let p = ctrl.compute_response(50.0, -2.0, 1.0);
assert!(
(p - 100.0).abs() < 1.0,
"ROCOF response should be ~100 MW, got {}",
p
);
}
#[test]
fn test_inertia_emulation_deadband() {
let mut ctrl = InertiaEmulationControl::new(
0.0, 20.0, 0.1, 100.0, 100.0,
);
let p_inside = ctrl.compute_response(49.95, 0.0, 0.1);
assert!(
(p_inside).abs() < 1e-6,
"inside deadband should give 0, got {}",
p_inside
);
let mut ctrl2 = InertiaEmulationControl::new(0.0, 20.0, 0.1, 100.0, 100.0);
let p_outside = ctrl2.compute_response(49.8, 0.0, 0.1);
assert!(
p_outside > 0.0,
"outside deadband should give positive P, got {}",
p_outside
);
}
#[test]
fn test_virtual_h_constant() {
let ctrl = InertiaEmulationControl::new(100.0, 0.0, 0.0, 50.0, 50.0);
let h = ctrl.virtual_h_constant(50.0);
assert!((h - 1.0).abs() < 1e-9, "H_virtual={}", h);
}
#[test]
fn test_inertia_monitor_rocof_estimation() {
let mut mon = InertiaMonitor::new(20, 100.0);
for i in 0..20 {
let t = i as f64 * 0.01;
let f = 50.0 - 0.5 * t;
mon.update(t, f);
}
let rocof = mon.estimate_rocof();
assert!((rocof - (-0.5)).abs() < 0.01, "ROCOF estimate={:.4}", rocof);
}
#[test]
fn test_inertia_monitor_event_detection() {
let mut mon = InertiaMonitor::new(10, 50.0);
for i in 0..10 {
mon.update(i as f64 * 0.02, 50.0);
}
assert!(
!mon.detect_event(0.1),
"stable system should not trigger event"
);
let mut mon2 = InertiaMonitor::new(10, 50.0);
for i in 0..10 {
let t = i as f64 * 0.02;
mon2.update(t, 50.0 - 2.0 * t);
}
assert!(
mon2.detect_event(0.1),
"fast frequency drop should trigger event"
);
}
#[test]
fn test_inertia_estimate_from_disturbance() {
let mut mon = InertiaMonitor::new(50, 100.0);
for i in 0..50 {
let t = i as f64 * 0.01;
mon.update(t, 50.0 - 1.0 * t);
}
let est = mon.estimate_inertia(100.0, 1000.0);
assert!(
(est.estimated_h_mws - 2500.0).abs() < 50.0,
"H_mws estimate={:.1}",
est.estimated_h_mws
);
assert!(est.confidence > 0.0);
assert_eq!(est.method, EstimationMethod::LinearRegression);
assert!((est.rocof_measured_hz_per_s - (-1.0)).abs() < 0.02);
}
#[test]
fn test_wind_type4_zero_inertia() {
let tech = GeneratorTechnology::WindTurbineType4;
assert_eq!(tech.effective_inertia_s(5.0), 0.0);
assert!(!tech.is_synchronous());
}
#[test]
fn test_wind_type3_virtual_inertia() {
let tech = GeneratorTechnology::WindTurbineType3 {
virtual_inertia: 1.5,
};
assert!((tech.effective_inertia_s(3.0) - 4.5).abs() < 1e-9);
}
#[test]
fn test_bess_virtual_inertia_zero_h() {
let tech = GeneratorTechnology::BessWithVirtualInertia { kvi: 200.0 };
assert_eq!(tech.effective_inertia_s(5.0), 0.0);
assert!(!tech.is_synchronous());
}
}