use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GfmControlType {
VirtualSynchronousMachine,
Droop,
MatchingControl,
DispatchableVirtualOscillatorControl,
PllBased,
CurrentSourceMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StabilityMode {
Stable,
OscillatoryStable,
MarginallyStable,
Unstable,
DivergentlyUnstable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DomainOfAnalysis {
SmallSignal,
LargeSignal,
Bifurcation,
PassivityBased,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GfmInverterModel {
pub id: usize,
pub name: String,
pub control_type: GfmControlType,
pub rated_power_mw: f64,
pub rated_voltage_kv: f64,
pub virtual_inertia_h_s: f64,
pub damping_ratio: f64,
pub droop_p_pct: f64,
pub droop_q_pct: f64,
pub voltage_setpoint_pu: f64,
pub frequency_setpoint_hz: f64,
pub lc_filter_l_pu: f64,
pub lc_filter_c_pu: f64,
pub bandwidth_hz: f64,
pub bus_id: usize,
}
impl Default for GfmInverterModel {
fn default() -> Self {
Self {
id: 0,
name: "GFM".to_string(),
control_type: GfmControlType::VirtualSynchronousMachine,
rated_power_mw: 10.0,
rated_voltage_kv: 11.0,
virtual_inertia_h_s: 5.0,
damping_ratio: 0.05,
droop_p_pct: 5.0,
droop_q_pct: 5.0,
voltage_setpoint_pu: 1.0,
frequency_setpoint_hz: 50.0,
lc_filter_l_pu: 0.1,
lc_filter_c_pu: 0.05,
bandwidth_hz: 100.0,
bus_id: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEigenvalue {
pub real_part: f64,
pub imag_part: f64,
pub damping_ratio: f64,
pub frequency_hz: f64,
pub associated_mode: String,
pub participation_factor: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StabilityAssessment {
pub eigenvalues: Vec<SystemEigenvalue>,
pub mode: StabilityMode,
pub minimum_damping_ratio: f64,
pub critical_eigenvalue: Option<SystemEigenvalue>,
pub stability_margin: f64,
pub oscillation_frequencies: Vec<f64>,
pub participation_matrix: Vec<Vec<f64>>,
pub synchronizing_torque: f64,
pub damping_torque: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GfmGridModel {
pub inverters: Vec<GfmInverterModel>,
pub grid_strength_scr: f64,
pub grid_impedance_z_pu: f64,
pub grid_x_r_ratio: f64,
pub load_mw: f64,
pub renewable_penetration_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GfmStabilityAnalyzer {
pub grid_model: GfmGridModel,
pub domain: DomainOfAnalysis,
pub perturbation_magnitude: f64,
}
#[allow(clippy::needless_range_loop)]
fn hessenberg_form(a: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = a.len();
let mut h: Vec<Vec<f64>> = a.to_vec();
for k in 0..n.saturating_sub(2) {
let mut x: Vec<f64> = (k + 1..n).map(|i| h[i][k]).collect();
let norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm < 1e-14 {
continue;
}
if x[0] >= 0.0 {
x[0] += norm;
} else {
x[0] -= norm;
}
let norm2: f64 = x.iter().map(|v| v * v).sum::<f64>();
if norm2 < 1e-28 {
continue;
}
for j in 0..n {
let dot: f64 = x
.iter()
.enumerate()
.map(|(i, xi)| xi * h[k + 1 + i][j])
.sum();
for (i, xi) in x.iter().enumerate() {
h[k + 1 + i][j] -= 2.0 * xi * dot / norm2;
}
}
for i in 0..n {
let dot: f64 = x
.iter()
.enumerate()
.map(|(j, xj)| h[i][k + 1 + j] * xj)
.sum();
for (j, xj) in x.iter().enumerate() {
h[i][k + 1 + j] -= 2.0 * dot * xj / norm2;
}
}
}
h
}
fn wilkinson_shift(h: &[Vec<f64>], size: usize) -> (f64, f64) {
if size < 2 {
return (h[0][0], 0.0);
}
let a = h[size - 2][size - 2];
let b = h[size - 2][size - 1];
let c = h[size - 1][size - 2];
let d = h[size - 1][size - 1];
let tr = a + d;
let det = a * d - b * c;
let disc = tr * tr - 4.0 * det;
if disc >= 0.0 {
let s = disc.sqrt();
let l1 = (tr + s) / 2.0;
let l2 = (tr - s) / 2.0;
if (l1 - d).abs() < (l2 - d).abs() {
(l1, 0.0)
} else {
(l2, 0.0)
}
} else {
(tr / 2.0, (-disc).sqrt() / 2.0)
}
}
#[allow(clippy::ptr_arg, clippy::needless_range_loop)]
fn double_qr_step(h: &mut Vec<Vec<f64>>, size: usize, mu_re: f64, mu_im: f64) {
if size < 2 {
return;
}
let s = 2.0 * mu_re; let t = mu_re * mu_re + mu_im * mu_im; let h00 = h[0][0];
let h10 = h[1][0];
let mut x0 = h00 * h00 + h[0][1] * h10 - s * h00 + t;
let mut x1 = h10 * (h00 + h[1][1] - s);
let mut x2 = if size > 2 { h[2][1] * h10 } else { 0.0 };
for k in 0..size.saturating_sub(1) {
let m = if k + 3 <= size {
3
} else if k + 2 <= size {
2
} else {
1
};
let vec: Vec<f64> = match m {
3 => vec![x0, x1, x2],
2 => vec![x0, x1],
_ => vec![x0],
};
let norm = vec.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm < 1e-14 {
if k + 1 < size {
x0 = h[k + 1][k];
x1 = if k + 2 < size { h[k + 2][k] } else { 0.0 };
x2 = if k + 3 < size { h[k + 3][k] } else { 0.0 };
}
continue;
}
let mut v = vec.clone();
if v[0] >= 0.0 {
v[0] += norm;
} else {
v[0] -= norm;
}
let norm2: f64 = v.iter().map(|vi| vi * vi).sum();
if norm2 < 1e-28 {
if k + 1 < size {
x0 = h[k + 1][k];
x1 = if k + 2 < size { h[k + 2][k] } else { 0.0 };
x2 = if k + 3 < size { h[k + 3][k] } else { 0.0 };
}
continue;
}
let r = k; let n = h.len();
for j in 0..n {
let dot: f64 = (0..m)
.map(|i| if r + i < n { v[i] * h[r + i][j] } else { 0.0 })
.sum();
for i in 0..m {
if r + i < n {
h[r + i][j] -= 2.0 * v[i] * dot / norm2;
}
}
}
for i in 0..n {
let dot: f64 = (0..m)
.map(|j| if r + j < n { h[i][r + j] * v[j] } else { 0.0 })
.sum();
for j in 0..m {
if r + j < n {
h[i][r + j] -= 2.0 * dot * v[j] / norm2;
}
}
}
if k + 1 < size {
x0 = h[k + 1][k];
x1 = if k + 2 < size { h[k + 2][k] } else { 0.0 };
x2 = if k + 3 < size { h[k + 3][k] } else { 0.0 };
}
}
}
fn qr_eigenvalues(a: &[Vec<f64>]) -> Vec<(f64, f64)> {
let n = a.len();
if n == 0 {
return vec![];
}
if n == 1 {
return vec![(a[0][0], 0.0)];
}
if n == 2 {
return exact_2x2_eigenvalues(a[0][0], a[0][1], a[1][0], a[1][1]);
}
let mut h = hessenberg_form(a);
let mut result: Vec<(f64, f64)> = Vec::with_capacity(n);
let mut size = n;
let max_iter = 300;
while size > 2 {
let mut deflated = false;
for _ in 0..max_iter {
let eps = 1e-10;
if h[size - 1][size - 2].abs()
< eps * (h[size - 2][size - 2].abs() + h[size - 1][size - 1].abs())
{
result.push((h[size - 1][size - 1], 0.0));
h[size - 1][size - 2] = 0.0;
size -= 1;
deflated = true;
break;
}
if size >= 3
&& h[size - 2][size - 3].abs()
< eps * (h[size - 3][size - 3].abs() + h[size - 2][size - 2].abs())
{
let ev2 = exact_2x2_eigenvalues(
h[size - 2][size - 2],
h[size - 2][size - 1],
h[size - 1][size - 2],
h[size - 1][size - 1],
);
result.extend(ev2);
h[size - 2][size - 3] = 0.0;
size -= 2;
deflated = true;
break;
}
let (mu_re, mu_im) = wilkinson_shift(&h, size);
double_qr_step(&mut h, size, mu_re, mu_im);
}
if !deflated {
let ev2 = exact_2x2_eigenvalues(
h[size - 2][size - 2],
h[size - 2][size - 1],
h[size - 1][size - 2],
h[size - 1][size - 1],
);
result.extend(ev2);
size = size.saturating_sub(2);
}
}
if size == 2 {
let ev2 = exact_2x2_eigenvalues(h[0][0], h[0][1], h[1][0], h[1][1]);
result.extend(ev2);
} else if size == 1 {
result.push((h[0][0], 0.0));
}
result
}
fn exact_2x2_eigenvalues(a: f64, b: f64, c: f64, d: f64) -> Vec<(f64, f64)> {
let tr = a + d;
let det = a * d - b * c;
let disc = tr * tr - 4.0 * det;
if disc >= 0.0 {
let s = disc.sqrt();
vec![((tr + s) / 2.0, 0.0), ((tr - s) / 2.0, 0.0)]
} else {
let s = (-disc).sqrt() / 2.0;
vec![(tr / 2.0, s), (tr / 2.0, -s)]
}
}
fn classify_mode(freq_hz: f64) -> String {
if freq_hz < 0.1 {
"inter_area".to_string()
} else if freq_hz < 2.0 {
"local_oscillation".to_string()
} else if freq_hz < 10.0 {
"power_sharing".to_string()
} else if freq_hz < 100.0 {
"voltage_control".to_string()
} else {
"current_control".to_string()
}
}
impl GfmStabilityAnalyzer {
pub fn new(grid_model: GfmGridModel) -> Self {
Self {
grid_model,
domain: DomainOfAnalysis::SmallSignal,
perturbation_magnitude: 1e-5,
}
}
pub fn compute_state_matrix(&self) -> Vec<Vec<f64>> {
let n_inv = self.grid_model.inverters.len();
if n_inv == 0 {
return vec![];
}
let size = 2 * n_inv;
let mut a = vec![vec![0.0f64; size]; size];
let omega0 = 2.0 * PI * 50.0_f64;
let scr = self.grid_model.grid_strength_scr.max(0.1);
let coupling = 0.01 / scr;
for (idx, inv) in self.grid_model.inverters.iter().enumerate() {
let r = 2 * idx; match inv.control_type {
GfmControlType::VirtualSynchronousMachine => {
let h = inv.virtual_inertia_h_s;
if h > 1e-9 {
let ks = self.compute_synchronizing_torque(idx);
let kd = inv.damping_ratio * inv.rated_power_mw.max(1.0);
a[r][r] = 0.0;
a[r][r + 1] = omega0;
a[r + 1][r] = -ks / (2.0 * h);
a[r + 1][r + 1] = -kd / (2.0 * h);
} else {
let dp = inv.droop_p_pct / 100.0;
let dq = inv.droop_q_pct / 100.0;
a[r][r] = -dp.max(1e-3);
a[r][r + 1] = 0.0;
a[r + 1][r] = 0.0;
a[r + 1][r + 1] = -dq.max(1e-3);
}
}
_ => {
let dp = inv.droop_p_pct / 100.0;
let dq = inv.droop_q_pct / 100.0;
a[r][r] = -dp;
a[r][r + 1] = 0.0;
a[r + 1][r] = 0.0;
a[r + 1][r + 1] = -dq;
}
}
for (jdx, _) in self.grid_model.inverters.iter().enumerate() {
if jdx == idx {
continue;
}
let c = 2 * jdx;
a[r][c] += coupling;
a[r + 1][c + 1] += coupling;
}
}
a
}
pub fn compute_eigenvalues(&self, a_matrix: &[Vec<f64>]) -> Vec<SystemEigenvalue> {
let raw = qr_eigenvalues(a_matrix);
let mut evs: Vec<SystemEigenvalue> = raw
.into_iter()
.map(|(re, im)| {
let magnitude = (re * re + im * im).sqrt();
let zeta = if magnitude < 1e-12 {
0.0
} else {
-re / magnitude
};
let freq_hz = im.abs() / (2.0 * PI);
let mode_label = classify_mode(freq_hz);
let pf = (0.5 + 0.5 * zeta).clamp(0.0, 1.0);
SystemEigenvalue {
real_part: re,
imag_part: im,
damping_ratio: zeta,
frequency_hz: freq_hz,
associated_mode: mode_label,
participation_factor: pf,
}
})
.collect();
evs.sort_by(|a, b| {
a.damping_ratio
.partial_cmp(&b.damping_ratio)
.unwrap_or(std::cmp::Ordering::Equal)
});
evs
}
pub fn assess_stability(&self) -> StabilityAssessment {
let a = self.compute_state_matrix();
let eigenvalues = self.compute_eigenvalues(&a);
let max_real = eigenvalues
.iter()
.map(|e| e.real_part)
.fold(f64::NEG_INFINITY, f64::max);
let min_damping = eigenvalues
.iter()
.map(|e| e.damping_ratio)
.fold(f64::INFINITY, f64::min);
let min_damping = if min_damping.is_infinite() {
0.0
} else {
min_damping
};
let unstable_count = eigenvalues.iter().filter(|e| e.real_part > 1e-6).count();
let mode = if unstable_count > 1 {
StabilityMode::DivergentlyUnstable
} else if max_real > 1e-6 {
StabilityMode::Unstable
} else if max_real > -1e-6 {
StabilityMode::MarginallyStable
} else if min_damping < 0.05 {
StabilityMode::OscillatoryStable
} else {
StabilityMode::Stable
};
let critical_eigenvalue = eigenvalues.first().cloned();
let oscillation_frequencies: Vec<f64> = eigenvalues
.iter()
.filter(|e| e.damping_ratio < 0.05 && e.frequency_hz > 0.01)
.map(|e| e.frequency_hz)
.collect();
let n = a.len();
let participation_matrix: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
.collect();
let synchronizing_torque = self.compute_synchronizing_torque(0);
let damping_torque = self.compute_damping_torque(0);
StabilityAssessment {
eigenvalues,
mode,
minimum_damping_ratio: min_damping,
critical_eigenvalue,
stability_margin: min_damping,
oscillation_frequencies,
participation_matrix,
synchronizing_torque,
damping_torque,
}
}
pub fn compute_synchronizing_torque(&self, inverter_id: usize) -> f64 {
let inv = match self.grid_model.inverters.get(inverter_id) {
Some(i) => i,
None => return 0.0,
};
let v = inv.voltage_setpoint_pu;
let vg = 1.0_f64; let x_inv = inv.lc_filter_l_pu.max(0.01);
let xr = self.grid_model.grid_x_r_ratio;
let z = self.grid_model.grid_impedance_z_pu;
let x_grid = z * xr / (1.0 + xr * xr).sqrt();
let x_total = x_inv + x_grid;
let sin_delta = (inv.rated_power_mw * x_total / (v * vg)).clamp(-1.0, 1.0);
let delta = sin_delta.asin();
vg * v * delta.sin() / x_total
}
pub fn compute_damping_torque(&self, inverter_id: usize) -> f64 {
let inv = match self.grid_model.inverters.get(inverter_id) {
Some(i) => i,
None => return 0.0,
};
let dp = inv.damping_ratio;
let v = inv.voltage_setpoint_pu;
let xr = self.grid_model.grid_x_r_ratio;
let z = self.grid_model.grid_impedance_z_pu;
let x_grid = z * xr / (1.0 + xr * xr).sqrt();
let x_inv = inv.lc_filter_l_pu.max(0.01);
let x_total = x_inv + x_grid;
dp * v * v / x_total
}
pub fn check_passivity(&self) -> bool {
let total_damping: f64 = self
.grid_model
.inverters
.iter()
.map(|inv| inv.damping_ratio * inv.rated_power_mw)
.sum();
let grid_conductance = 1.0 / self.grid_model.grid_impedance_z_pu.max(1e-9);
total_damping + grid_conductance > 0.0
}
pub fn compute_minimum_scr(&self) -> f64 {
let total_inertia: f64 = self
.grid_model
.inverters
.iter()
.map(|inv| inv.virtual_inertia_h_s * inv.rated_power_mw)
.sum::<f64>();
let total_power: f64 = self
.grid_model
.inverters
.iter()
.map(|inv| inv.rated_power_mw)
.sum::<f64>();
if total_inertia < 1e-9 {
return 1.5;
}
let omega0 = 2.0 * PI * 50.0_f64;
let scr_min = total_power / (total_inertia * omega0 * omega0 / 1e4);
scr_min.max(0.1)
}
pub fn analyze_sensitivity(&self, parameter: &str) -> Vec<(f64, f64)> {
let (low, high) = match parameter {
"droop_p_pct" => (1.0_f64, 20.0_f64),
"virtual_inertia_h_s" => (0.1, 10.0),
"damping_ratio" => (0.01, 0.3),
"grid_strength_scr" => (0.5, 10.0),
"bandwidth_hz" => (10.0, 1000.0),
_ => (0.1, 10.0),
};
(0..10)
.map(|i| {
let val = low + (high - low) * (i as f64) / 9.0;
let mut modified = self.grid_model.clone();
for inv in &mut modified.inverters {
match parameter {
"droop_p_pct" => inv.droop_p_pct = val,
"virtual_inertia_h_s" => inv.virtual_inertia_h_s = val,
"damping_ratio" => inv.damping_ratio = val,
"bandwidth_hz" => inv.bandwidth_hz = val,
_ => {}
}
}
if parameter == "grid_strength_scr" {
modified.grid_strength_scr = val;
}
let analyzer = GfmStabilityAnalyzer {
grid_model: modified,
domain: DomainOfAnalysis::SmallSignal,
perturbation_magnitude: self.perturbation_magnitude,
};
let assessment = analyzer.assess_stability();
(val, assessment.stability_margin)
})
.collect()
}
pub fn identify_dominant_mode<'a>(
&self,
eigenvalues: &'a [SystemEigenvalue],
) -> Option<&'a SystemEigenvalue> {
eigenvalues
.iter()
.filter(|e| e.frequency_hz > 0.01)
.max_by(|a, b| {
a.frequency_hz
.partial_cmp(&b.frequency_hz)
.unwrap_or(std::cmp::Ordering::Equal)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_vsg_model() -> GfmGridModel {
GfmGridModel {
inverters: vec![GfmInverterModel {
id: 0,
name: "VSG1".into(),
control_type: GfmControlType::VirtualSynchronousMachine,
rated_power_mw: 10.0,
rated_voltage_kv: 11.0,
virtual_inertia_h_s: 5.0,
damping_ratio: 0.05,
droop_p_pct: 5.0,
droop_q_pct: 5.0,
voltage_setpoint_pu: 1.0,
frequency_setpoint_hz: 50.0,
lc_filter_l_pu: 0.1,
lc_filter_c_pu: 0.05,
bandwidth_hz: 100.0,
bus_id: 1,
}],
grid_strength_scr: 5.0,
grid_impedance_z_pu: 0.1,
grid_x_r_ratio: 5.0,
load_mw: 8.0,
renewable_penetration_pct: 60.0,
}
}
fn make_droop_model() -> GfmGridModel {
GfmGridModel {
inverters: vec![GfmInverterModel {
id: 0,
name: "Droop1".into(),
control_type: GfmControlType::Droop,
rated_power_mw: 5.0,
rated_voltage_kv: 0.4,
virtual_inertia_h_s: 0.0,
damping_ratio: 0.1,
droop_p_pct: 4.0,
droop_q_pct: 4.0,
voltage_setpoint_pu: 1.0,
frequency_setpoint_hz: 50.0,
lc_filter_l_pu: 0.05,
lc_filter_c_pu: 0.02,
bandwidth_hz: 200.0,
bus_id: 2,
}],
grid_strength_scr: 3.0,
grid_impedance_z_pu: 0.05,
grid_x_r_ratio: 3.0,
load_mw: 4.0,
renewable_penetration_pct: 40.0,
}
}
#[test]
fn test_vsg_synchronizing_torque_positive() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let ks = analyzer.compute_synchronizing_torque(0);
assert!(ks > 0.0, "Synchronising torque must be positive: got {ks}");
}
#[test]
fn test_droop_damping_positive() {
let model = make_droop_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let kd = analyzer.compute_damping_torque(0);
assert!(kd > 0.0, "Damping torque must be positive: got {kd}");
}
#[test]
fn test_eigenvalue_damping_ratio() {
let a = vec![vec![-1.0_f64]];
let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
let evs = analyzer.compute_eigenvalues(&a);
assert_eq!(evs.len(), 1);
let zeta = evs[0].damping_ratio;
assert!(
(zeta - 1.0).abs() < 1e-9,
"Expected ζ=1.0 for λ=-1, got {zeta}"
);
}
#[test]
fn test_stable_classification() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
matches!(
assessment.mode,
StabilityMode::Stable | StabilityMode::OscillatoryStable
);
for ev in &assessment.eigenvalues {
assert!(
ev.real_part < 1e-6,
"Eigenvalue real part should be negative: {}",
ev.real_part
);
}
}
#[test]
fn test_unstable_classification() {
let mut model = make_droop_model();
model.inverters[0].droop_p_pct = -50.0;
model.inverters[0].droop_q_pct = -50.0;
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(
matches!(
assessment.mode,
StabilityMode::Unstable | StabilityMode::DivergentlyUnstable
),
"Expected Unstable, got {:?}",
assessment.mode
);
}
#[test]
fn test_oscillatory_stable_classification() {
let mut model = make_droop_model();
model.inverters[0].droop_p_pct = 0.001;
model.inverters[0].droop_q_pct = 0.001;
model.inverters[0].damping_ratio = 0.001;
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(
!matches!(
assessment.mode,
StabilityMode::Unstable | StabilityMode::DivergentlyUnstable
),
"Should be stable variant, got {:?}",
assessment.mode
);
}
#[test]
fn test_state_matrix_2x2_eigenvalue() {
let a = vec![vec![0.0_f64, 1.0], vec![-1.0, -0.1]];
let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
let evs = analyzer.compute_eigenvalues(&a);
assert_eq!(evs.len(), 2);
for ev in &evs {
assert!(ev.real_part < 0.0, "Expected Re < 0, got {}", ev.real_part);
}
let has_imag = evs.iter().any(|e| e.imag_part.abs() > 0.01);
assert!(
has_imag,
"Expected complex eigenvalues for underdamped system"
);
}
#[test]
fn test_state_matrix_diagonal() {
let a = vec![
vec![-2.0_f64, 0.0, 0.0],
vec![0.0, -3.0, 0.0],
vec![0.0, 0.0, -5.0],
];
let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
let evs = analyzer.compute_eigenvalues(&a);
assert_eq!(evs.len(), 3);
let mut re_parts: Vec<f64> = evs.iter().map(|e| e.real_part).collect();
re_parts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let expected = [-5.0, -3.0, -2.0];
for (got, exp) in re_parts.iter().zip(expected.iter()) {
assert!((got - exp).abs() < 1e-6, "Expected {exp}, got {got}");
}
}
#[test]
fn test_minimum_scr_positive() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let scr = analyzer.compute_minimum_scr();
assert!(scr > 0.0, "Minimum SCR must be positive: {scr}");
}
#[test]
fn test_minimum_scr_decreases_with_inertia() {
let mut model_low = make_vsg_model();
let mut model_high = make_vsg_model();
model_low.inverters[0].virtual_inertia_h_s = 1.0;
model_high.inverters[0].virtual_inertia_h_s = 10.0;
let scr_low = GfmStabilityAnalyzer::new(model_low).compute_minimum_scr();
let scr_high = GfmStabilityAnalyzer::new(model_high).compute_minimum_scr();
assert!(
scr_high < scr_low,
"Higher inertia should require lower SCR: scr_low={scr_low}, scr_high={scr_high}"
);
}
#[test]
fn test_passivity_check_lossy_system() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
assert!(
analyzer.check_passivity(),
"Lossy system with positive damping should be passive"
);
}
#[test]
fn test_stability_margin_positive_stable() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(
assessment.stability_margin > 0.0,
"Stability margin must be positive for stable system: {}",
assessment.stability_margin
);
}
#[test]
fn test_stability_margin_negative_unstable() {
let mut model = make_droop_model();
model.inverters[0].droop_p_pct = -20.0;
model.inverters[0].droop_q_pct = -20.0;
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(
assessment.stability_margin < 0.0,
"Stability margin should be negative for unstable system: {}",
assessment.stability_margin
);
}
#[test]
fn test_critical_eigenvalue_least_stable() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
if let Some(crit) = &assessment.critical_eigenvalue {
let min_zeta = assessment
.eigenvalues
.iter()
.map(|e| e.damping_ratio)
.fold(f64::INFINITY, f64::min);
assert!(
(crit.damping_ratio - min_zeta).abs() < 1e-9,
"Critical eigenvalue should have minimum damping ratio"
);
}
}
#[test]
fn test_sensitivity_analysis_returns_10_points() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let pairs = analyzer.analyze_sensitivity("droop_p_pct");
assert_eq!(
pairs.len(),
10,
"Sensitivity analysis should return 10 pairs"
);
}
#[test]
fn test_frequency_oscillation_detection() {
let mut model = make_vsg_model();
model.inverters[0].virtual_inertia_h_s = 50.0;
model.inverters[0].damping_ratio = 0.001;
let analyzer = GfmStabilityAnalyzer::new(model);
let a = analyzer.compute_state_matrix();
let evs = analyzer.compute_eigenvalues(&a);
let has_oscillatory = evs.iter().any(|e| e.imag_part.abs() > 1e-3);
assert!(
has_oscillatory,
"Large inertia + small damping should produce oscillatory eigenvalues"
);
}
#[test]
fn test_multiple_inverters_interaction() {
let mut model = make_vsg_model();
model.inverters.push(GfmInverterModel {
id: 1,
name: "VSG2".into(),
control_type: GfmControlType::VirtualSynchronousMachine,
rated_power_mw: 8.0,
rated_voltage_kv: 11.0,
virtual_inertia_h_s: 4.0,
damping_ratio: 0.06,
droop_p_pct: 4.0,
droop_q_pct: 4.0,
voltage_setpoint_pu: 1.0,
frequency_setpoint_hz: 50.0,
lc_filter_l_pu: 0.08,
lc_filter_c_pu: 0.04,
bandwidth_hz: 120.0,
bus_id: 2,
});
let analyzer = GfmStabilityAnalyzer::new(model);
let a = analyzer.compute_state_matrix();
assert_eq!(a.len(), 4, "State matrix should be 4×4 for 2 inverters");
assert_eq!(a[0].len(), 4);
let has_coupling = a[0][2] != 0.0 || a[1][3] != 0.0;
assert!(has_coupling, "Should have cross-inverter coupling terms");
}
#[test]
fn test_high_renewable_penetration_stability() {
let mut model = make_vsg_model();
model.renewable_penetration_pct = 90.0;
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(!assessment.eigenvalues.is_empty());
}
#[test]
fn test_assess_stability_returns_complete() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(!assessment.eigenvalues.is_empty(), "Must have eigenvalues");
assert!(
!assessment.participation_matrix.is_empty(),
"Must have participation matrix"
);
assert!(
assessment.synchronizing_torque.is_finite(),
"Ks must be finite"
);
assert!(assessment.damping_torque.is_finite(), "Kd must be finite");
assert!(
assessment.minimum_damping_ratio.is_finite(),
"Min damping must be finite"
);
}
#[test]
fn test_identify_dominant_mode() {
let evs = vec![
SystemEigenvalue {
real_part: -0.5,
imag_part: 2.0 * PI * 1.5,
damping_ratio: 0.1,
frequency_hz: 1.5,
associated_mode: "local_oscillation".into(),
participation_factor: 0.8,
},
SystemEigenvalue {
real_part: -1.0,
imag_part: 2.0 * PI * 10.0,
damping_ratio: 0.3,
frequency_hz: 10.0,
associated_mode: "voltage_control".into(),
participation_factor: 0.7,
},
SystemEigenvalue {
real_part: -2.0,
imag_part: 0.0,
damping_ratio: 1.0,
frequency_hz: 0.0,
associated_mode: "inter_area".into(),
participation_factor: 0.5,
},
];
let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
let dominant = analyzer.identify_dominant_mode(&evs);
assert!(dominant.is_some());
let dom = dominant.unwrap();
assert!(
(dom.frequency_hz - 10.0).abs() < 1e-9,
"Dominant mode should have highest frequency: {}",
dom.frequency_hz
);
}
#[test]
fn test_control_type_droop_model() {
let model = make_droop_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let a = analyzer.compute_state_matrix();
assert!(!a.is_empty(), "State matrix should be non-empty");
let expected_diag = -0.04_f64;
assert!(
(a[0][0] - expected_diag).abs() < 1e-9,
"Droop diagonal entry: expected {expected_diag}, got {}",
a[0][0]
);
}
#[test]
fn test_matching_control_type() {
let mut model = make_vsg_model();
model.inverters[0].control_type = GfmControlType::MatchingControl;
model.inverters[0].droop_p_pct = 6.0;
model.inverters[0].droop_q_pct = 6.0;
let analyzer = GfmStabilityAnalyzer::new(model);
let assessment = analyzer.assess_stability();
assert!(!assessment.eigenvalues.is_empty());
let a = analyzer.compute_state_matrix();
assert!(
(a[0][0] - (-0.06)).abs() < 1e-9,
"MatchingControl diagonal: {}",
a[0][0]
);
}
#[test]
fn test_sensitivity_analysis_grid_scr() {
let model = make_vsg_model();
let analyzer = GfmStabilityAnalyzer::new(model);
let pairs = analyzer.analyze_sensitivity("grid_strength_scr");
assert_eq!(pairs.len(), 10);
for w in pairs.windows(2) {
assert!(
w[1].0 >= w[0].0,
"Parameter values should be non-decreasing"
);
}
}
#[test]
fn test_identify_dominant_mode_empty() {
let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
let evs: Vec<SystemEigenvalue> = vec![];
assert!(analyzer.identify_dominant_mode(&evs).is_none());
}
#[test]
fn test_vsg_zero_inertia_uses_droop() {
let mut model = make_vsg_model();
model.inverters[0].virtual_inertia_h_s = 0.0;
model.inverters[0].droop_p_pct = 5.0;
let analyzer = GfmStabilityAnalyzer::new(model);
let a = analyzer.compute_state_matrix();
assert!(
a[0][0] < 0.0,
"Zero-inertia VSG should use negative droop diagonal"
);
}
#[test]
fn test_passivity_zero_grid_impedance_handled() {
let mut model = make_vsg_model();
model.grid_impedance_z_pu = 0.0; let analyzer = GfmStabilityAnalyzer::new(model);
let passive = analyzer.check_passivity();
assert!(passive);
}
}