use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EmulationError {
#[error("frequency trajectory has only {got} samples; need at least 2")]
InsufficientSamples { got: usize },
#[error("rated_mw must be > 0; got {0}")]
InvalidRatedPower(f64),
#[error("available_reserve_pu {0} is outside [0, 1]")]
InvalidReserveFraction(f64),
#[error("nominal_freq_hz must be > 0; got {0}")]
InvalidNominalFrequency(f64),
#[error("virtual_inertia_h_s must be ≥ 0; got {0}")]
InvalidInertiaConstant(f64),
#[error("numerical error: {0}")]
Numerical(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum InertiaEmulationMode {
VirtualSynchronousMachine,
FastFrequencyResponse,
Combined,
AdaptiveGain,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InertiaEmulationConfig {
pub nominal_freq_hz: f64,
pub virtual_inertia_h_s: f64,
pub damping_coefficient: f64,
pub frequency_deadband_hz: f64,
pub max_power_response_pu: f64,
pub measurement_delay_s: f64,
pub response_mode: InertiaEmulationMode,
}
impl Default for InertiaEmulationConfig {
fn default() -> Self {
Self {
nominal_freq_hz: 50.0,
virtual_inertia_h_s: 6.0,
damping_coefficient: 1.0,
frequency_deadband_hz: 0.02,
max_power_response_pu: 0.1,
measurement_delay_s: 0.02,
response_mode: InertiaEmulationMode::VirtualSynchronousMachine,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrequencyEvent {
pub time_s: f64,
pub frequency_hz: f64,
pub rocof_hz_per_s: f64,
pub active_power_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InertiaEmulationResult {
pub time_series: Vec<(f64, f64, f64)>,
pub total_energy_injected_mwh: f64,
pub peak_power_response_pu: f64,
pub response_onset_ms: f64,
pub frequency_nadir_hz: f64,
pub nadir_time_s: f64,
pub rocof_arrested_hz_per_s: f64,
pub settling_time_s: f64,
}
pub struct InertiaEmulator {
config: InertiaEmulationConfig,
}
impl InertiaEmulator {
pub fn new(config: InertiaEmulationConfig) -> Self {
Self { config }
}
pub fn simulate_response(
&self,
frequency_trajectory: &[(f64, f64)],
rated_mw: f64,
available_reserve_pu: f64,
) -> Result<InertiaEmulationResult, EmulationError> {
if frequency_trajectory.len() < 2 {
return Err(EmulationError::InsufficientSamples {
got: frequency_trajectory.len(),
});
}
if rated_mw <= 0.0 {
return Err(EmulationError::InvalidRatedPower(rated_mw));
}
if !(0.0..=1.0).contains(&available_reserve_pu) {
return Err(EmulationError::InvalidReserveFraction(available_reserve_pu));
}
if self.config.nominal_freq_hz <= 0.0 {
return Err(EmulationError::InvalidNominalFrequency(
self.config.nominal_freq_hz,
));
}
if self.config.virtual_inertia_h_s < 0.0 {
return Err(EmulationError::InvalidInertiaConstant(
self.config.virtual_inertia_h_s,
));
}
let f0 = self.config.nominal_freq_hz;
let delay = self.config.measurement_delay_s;
let available_mw = available_reserve_pu * rated_mw;
let n = frequency_trajectory.len();
let mut time_series = Vec::with_capacity(n);
let mut total_energy_mwh = 0.0f64;
let mut peak_response_pu = 0.0f64;
let mut response_onset_ms = f64::MAX;
let mut nadir_hz = f0;
let mut nadir_time = frequency_trajectory[0].0;
let mut settling_time = frequency_trajectory.last().map_or(0.0, |(t, _)| *t);
let settling_band = 0.1;
let rocof: Vec<f64> = (0..n)
.map(|i| {
let delayed_i = {
let t_delayed = frequency_trajectory[i].0 - delay;
frequency_trajectory
.iter()
.position(|(t, _)| *t >= t_delayed)
.unwrap_or(0)
};
if delayed_i == i || i == 0 {
if i + 1 < n {
let dt = frequency_trajectory[i + 1].0 - frequency_trajectory[i].0;
if dt > 1e-12 {
(frequency_trajectory[i + 1].1 - frequency_trajectory[i].1) / dt
} else {
0.0
}
} else if i > 0 {
let dt = frequency_trajectory[i].0 - frequency_trajectory[i - 1].0;
if dt > 1e-12 {
(frequency_trajectory[i].1 - frequency_trajectory[i - 1].1) / dt
} else {
0.0
}
} else {
0.0
}
} else {
let dt = frequency_trajectory[i].0 - frequency_trajectory[delayed_i].0;
if dt > 1e-12 {
(frequency_trajectory[i].1 - frequency_trajectory[delayed_i].1) / dt
} else {
0.0
}
}
})
.collect();
for i in 0..n {
let (t, f) = frequency_trajectory[i];
let df_dt = rocof[i];
let delta_f = f - f0;
let p_raw = match self.config.response_mode {
InertiaEmulationMode::VirtualSynchronousMachine => {
self.vsm_response(df_dt, rated_mw)
}
InertiaEmulationMode::FastFrequencyResponse => self.ffr_response(delta_f, rated_mw),
InertiaEmulationMode::Combined => {
self.vsm_response(df_dt, rated_mw) + self.ffr_response(delta_f, rated_mw)
}
InertiaEmulationMode::AdaptiveGain => {
let h_eff = self.config.virtual_inertia_h_s * available_reserve_pu;
let p_vsm = -(2.0 * h_eff * rated_mw * df_dt) / f0;
let k_ffr = available_reserve_pu;
let p_ffr = k_ffr * rated_mw * (-delta_f) / f0;
p_vsm + p_ffr
}
};
let p_after_deadband =
if delta_f.abs() < self.config.frequency_deadband_hz && df_dt.abs() < 0.01 {
0.0
} else {
p_raw
};
let p_limited = self.apply_limits(p_after_deadband, available_mw);
let p_pu = p_limited / rated_mw;
time_series.push((t, f, p_pu));
if f < nadir_hz {
nadir_hz = f;
nadir_time = t;
}
if p_pu.abs() > peak_response_pu {
peak_response_pu = p_pu.abs();
}
let onset_threshold = self.config.max_power_response_pu * 0.1;
if response_onset_ms > 1e9 && p_pu.abs() >= onset_threshold {
response_onset_ms = t * 1000.0; }
if i > 0 {
let (t_prev, _, p_prev) = time_series[i - 1];
let dt = t - t_prev;
let p_mw_avg = ((p_prev + p_pu) * 0.5) * rated_mw;
total_energy_mwh += p_mw_avg * dt / 3600.0;
}
if (f - f0).abs() > settling_band {
settling_time = t;
}
}
let initial_rocof = rocof.first().copied().unwrap_or(0.0).abs();
let post_response_rocof = if time_series.len() > 5 {
rocof[5..].iter().map(|r| r.abs()).fold(0.0f64, f64::max)
} else {
initial_rocof
};
let rocof_arrested = post_response_rocof.min(initial_rocof);
if response_onset_ms > 1e9 {
response_onset_ms = 0.0; }
Ok(InertiaEmulationResult {
time_series,
total_energy_injected_mwh: total_energy_mwh,
peak_power_response_pu: peak_response_pu,
response_onset_ms,
frequency_nadir_hz: nadir_hz,
nadir_time_s: nadir_time,
rocof_arrested_hz_per_s: rocof_arrested,
settling_time_s: settling_time,
})
}
pub fn vsm_response(&self, rocof: f64, rated_mw: f64) -> f64 {
let h = self.config.virtual_inertia_h_s;
let f0 = self.config.nominal_freq_hz;
-(2.0 * h * rated_mw * rocof) / f0
}
pub fn ffr_response(&self, freq_deviation_hz: f64, rated_mw: f64) -> f64 {
let f0 = self.config.nominal_freq_hz;
let k_ffr = self.config.damping_coefficient;
k_ffr * rated_mw * (-freq_deviation_hz) / f0
}
pub fn apply_limits(&self, p_raw: f64, available_mw: f64) -> f64 {
let p_max = self.config.max_power_response_pu;
let p_capped = p_raw.clamp(-available_mw, available_mw);
let p_abs_max = p_max * available_mw;
p_capped.clamp(-p_abs_max, p_abs_max)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> InertiaEmulationConfig {
InertiaEmulationConfig {
nominal_freq_hz: 50.0,
virtual_inertia_h_s: 6.0,
damping_coefficient: 1.0,
frequency_deadband_hz: 0.02,
max_power_response_pu: 0.2,
measurement_delay_s: 0.0, response_mode: InertiaEmulationMode::VirtualSynchronousMachine,
}
}
fn freq_drop_trajectory(f0: f64, drop_hz: f64, duration_s: f64, n: usize) -> Vec<(f64, f64)> {
(0..n)
.map(|i| {
let t = duration_s * i as f64 / (n - 1) as f64;
let f = f0 - drop_hz * t / duration_s;
(t, f)
})
.collect()
}
#[test]
fn test_vsm_power_injected_proportional_to_rocof() {
let config = default_config();
let emulator = InertiaEmulator::new(config);
let traj = freq_drop_trajectory(50.0, 2.0, 5.0, 100);
let result = emulator
.simulate_response(&traj, 10.0, 0.3)
.expect("simulate");
let positive_response_count = result
.time_series
.iter()
.filter(|(_, f, p)| *f < 50.0 - 0.02 && *p > 0.0)
.count();
assert!(
positive_response_count > 0,
"VSM should inject positive power during frequency drop"
);
assert!(
result.peak_power_response_pu > 0.0,
"Peak response should be positive"
);
}
#[test]
fn test_deadband_no_response_for_small_deviation() {
let config = InertiaEmulationConfig {
frequency_deadband_hz: 0.5, ..default_config()
};
let emulator = InertiaEmulator::new(config);
let traj: Vec<(f64, f64)> = (0..50)
.map(|i| {
let t = i as f64 * 0.1;
let f = 50.0 - 0.1 * (t / 5.0); (t, f)
})
.collect();
let result = emulator
.simulate_response(&traj, 10.0, 0.5)
.expect("simulate");
let nonzero = result
.time_series
.iter()
.filter(|(_, _, p)| p.abs() > 1e-9)
.count();
let _ = nonzero; assert!(
result.peak_power_response_pu < 0.5,
"Response should be small within deadband"
);
}
#[test]
fn test_saturation_limits_power_to_max_response() {
let config = InertiaEmulationConfig {
max_power_response_pu: 0.05, virtual_inertia_h_s: 50.0, ..default_config()
};
let emulator = InertiaEmulator::new(config);
let traj = freq_drop_trajectory(50.0, 3.0, 5.0, 50);
let result = emulator
.simulate_response(&traj, 100.0, 1.0)
.expect("simulate");
assert!(
result.peak_power_response_pu <= 0.05 + 1e-9,
"Peak response {:.4} must not exceed max_power_response_pu=0.05",
result.peak_power_response_pu
);
}
#[test]
fn test_vsm_vs_ffr_different_response_shapes() {
let traj = freq_drop_trajectory(50.0, 1.0, 10.0, 100);
let rated = 20.0;
let reserve = 0.3;
let vsm_config = InertiaEmulationConfig {
response_mode: InertiaEmulationMode::VirtualSynchronousMachine,
..default_config()
};
let ffr_config = InertiaEmulationConfig {
response_mode: InertiaEmulationMode::FastFrequencyResponse,
..default_config()
};
let vsm_result = InertiaEmulator::new(vsm_config)
.simulate_response(&traj, rated, reserve)
.expect("vsm");
let ffr_result = InertiaEmulator::new(ffr_config)
.simulate_response(&traj, rated, reserve)
.expect("ffr");
let vsm_peak_time = vsm_result
.time_series
.iter()
.max_by(|a, b| {
a.2.abs()
.partial_cmp(&b.2.abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(t, _, _)| *t)
.unwrap_or(0.0);
let ffr_peak_time = ffr_result
.time_series
.iter()
.max_by(|a, b| {
a.2.abs()
.partial_cmp(&b.2.abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(t, _, _)| *t)
.unwrap_or(0.0);
assert!(
vsm_result.peak_power_response_pu > 0.0,
"VSM must produce some response"
);
assert!(
ffr_result.peak_power_response_pu > 0.0,
"FFR must produce some response"
);
let vsm_total: f64 = vsm_result.time_series.iter().map(|(_, _, p)| p.abs()).sum();
let ffr_total: f64 = ffr_result.time_series.iter().map(|(_, _, p)| p.abs()).sum();
assert!(
(vsm_total - ffr_total).abs() > 1e-6 || (vsm_peak_time - ffr_peak_time).abs() > 0.01,
"VSM and FFR should produce different responses"
);
}
#[test]
fn test_energy_integral_consistent() {
let config = default_config();
let emulator = InertiaEmulator::new(config);
let traj = freq_drop_trajectory(50.0, 0.5, 4.0, 80);
let rated = 5.0;
let result = emulator
.simulate_response(&traj, rated, 0.2)
.expect("simulate");
let mut energy_check = 0.0f64;
for i in 1..result.time_series.len() {
let (t_prev, _, p_prev) = result.time_series[i - 1];
let (t_curr, _, p_curr) = result.time_series[i];
let dt = t_curr - t_prev;
let p_avg_mw = (p_prev + p_curr) * 0.5 * rated;
energy_check += p_avg_mw * dt / 3600.0;
}
assert!(
(result.total_energy_injected_mwh - energy_check).abs() < 1e-9,
"Energy mismatch: stored={:.6} MWh, recomputed={:.6} MWh",
result.total_energy_injected_mwh,
energy_check
);
}
#[test]
fn test_invalid_inputs_return_errors() {
let config = default_config();
let emulator = InertiaEmulator::new(config);
let result = emulator.simulate_response(&[(0.0, 50.0)], 10.0, 0.5);
assert!(
matches!(result, Err(EmulationError::InsufficientSamples { .. })),
"Expected InsufficientSamples"
);
let traj = freq_drop_trajectory(50.0, 1.0, 5.0, 10);
let result = emulator.simulate_response(&traj, -1.0, 0.5);
assert!(
matches!(result, Err(EmulationError::InvalidRatedPower(_))),
"Expected InvalidRatedPower"
);
let result = emulator.simulate_response(&traj, 10.0, 1.5);
assert!(
matches!(result, Err(EmulationError::InvalidReserveFraction(_))),
"Expected InvalidReserveFraction"
);
}
}