Skip to main content

oxigrid/stability/
grid_forming_stability.rs

1//! Grid-Forming Inverter Stability Assessment for Inverter-Dominated Grids.
2//!
3//! Provides small-signal stability analysis of multi-inverter power systems using
4//! linearised state-space models for Virtual Synchronous Generators (VSG) and
5//! droop-controlled inverters.
6//!
7//! ## Analytical Framework
8//!
9//! For a VSG the swing-equation state matrix per inverter is:
10//! ```text
11//! A_vsg = [[0,          ω0       ],
12//!           [-Ks/(2H), -Kd/(2H)  ]]
13//! ```
14//! where `Ks` = synchronising torque, `Kd` = damping torque, `H` = virtual inertia [s].
15//!
16//! Stability criterion: all eigenvalues `λ` satisfy `Re(λ) < 0`.
17//! Damping ratio: `ζ = -Re(λ) / |λ|`.
18//!
19//! ## References
20//! - Kundur, "Power System Stability and Control", McGraw-Hill, 1994.
21//! - Dörfler & Bullo, "Synchronization in complex oscillator networks", SIAM, 2014.
22//! - D'Arco & Suul, "Virtual synchronous machines", IEEE TPWRD, 2014.
23
24use serde::{Deserialize, Serialize};
25use std::f64::consts::PI;
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Enums
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// Grid-forming inverter control strategy.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum GfmControlType {
34    /// Virtual synchronous machine — emulates swing equation with virtual inertia.
35    VirtualSynchronousMachine,
36    /// P-f / Q-V droop control (static frequency and voltage regulation).
37    Droop,
38    /// Energy-based matching control (port-Hamiltonian framework).
39    MatchingControl,
40    /// Dispatchable virtual oscillator control (dVOC).
41    DispatchableVirtualOscillatorControl,
42    /// Phase-locked-loop based grid-forming (GFL boundary).
43    PllBased,
44    /// Current-source mode (grid-following, for comparison).
45    CurrentSourceMode,
46}
47
48/// Small-signal stability classification of the system.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum StabilityMode {
51    /// All eigenvalues strictly in the left half-plane with damping > 5 %.
52    Stable,
53    /// All eigenvalues in left half-plane, but some modes have damping 1–5 %.
54    OscillatoryStable,
55    /// At least one eigenvalue on the imaginary axis (zero real part).
56    MarginallyStable,
57    /// At least one eigenvalue in the right half-plane.
58    Unstable,
59    /// Multiple eigenvalues with positive real parts — system diverges rapidly.
60    DivergentlyUnstable,
61}
62
63/// Domain of stability analysis.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum DomainOfAnalysis {
66    /// Linearised small-signal (eigenvalue) analysis.
67    SmallSignal,
68    /// Large-signal (Lyapunov / time-domain) analysis.
69    LargeSignal,
70    /// Bifurcation analysis (saddle-node, Hopf).
71    Bifurcation,
72    /// Passivity-based stability assessment.
73    PassivityBased,
74}
75
76// ─────────────────────────────────────────────────────────────────────────────
77// Structs
78// ─────────────────────────────────────────────────────────────────────────────
79
80/// Model of a single grid-forming inverter for stability analysis.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct GfmInverterModel {
83    /// Unique inverter index.
84    pub id: usize,
85    /// Human-readable label.
86    pub name: String,
87    /// Control strategy implemented by this inverter.
88    pub control_type: GfmControlType,
89    /// Rated active power \[MW\].
90    pub rated_power_mw: f64,
91    /// Rated line-to-line voltage \[kV\].
92    pub rated_voltage_kv: f64,
93    /// Virtual inertia constant H \[s\]. Zero means no virtual inertia (pure droop).
94    pub virtual_inertia_h_s: f64,
95    /// Damping coefficient D_p \[pu\]. Default 0.05.
96    pub damping_ratio: f64,
97    /// Active-power droop \[%\] (e.g. 5 means 5 % droop).
98    pub droop_p_pct: f64,
99    /// Reactive-power droop \[%\].
100    pub droop_q_pct: f64,
101    /// Voltage magnitude setpoint \[pu\].
102    pub voltage_setpoint_pu: f64,
103    /// Frequency setpoint \[Hz\].
104    pub frequency_setpoint_hz: f64,
105    /// LC-filter inductance \[pu\].
106    pub lc_filter_l_pu: f64,
107    /// LC-filter capacitance \[pu\].
108    pub lc_filter_c_pu: f64,
109    /// Inner voltage/current control-loop bandwidth \[Hz\].
110    pub bandwidth_hz: f64,
111    /// Index of the bus this inverter is connected to.
112    pub bus_id: usize,
113}
114
115impl Default for GfmInverterModel {
116    fn default() -> Self {
117        Self {
118            id: 0,
119            name: "GFM".to_string(),
120            control_type: GfmControlType::VirtualSynchronousMachine,
121            rated_power_mw: 10.0,
122            rated_voltage_kv: 11.0,
123            virtual_inertia_h_s: 5.0,
124            damping_ratio: 0.05,
125            droop_p_pct: 5.0,
126            droop_q_pct: 5.0,
127            voltage_setpoint_pu: 1.0,
128            frequency_setpoint_hz: 50.0,
129            lc_filter_l_pu: 0.1,
130            lc_filter_c_pu: 0.05,
131            bandwidth_hz: 100.0,
132            bus_id: 0,
133        }
134    }
135}
136
137/// A complex eigenvalue of the system state matrix with physical interpretation.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SystemEigenvalue {
140    /// Real part σ (negative = stable mode).
141    pub real_part: f64,
142    /// Imaginary part ω_d \[rad/s\].
143    pub imag_part: f64,
144    /// Damping ratio ζ = −Re(λ) / |λ|. Positive in left half-plane.
145    pub damping_ratio: f64,
146    /// Oscillation frequency f = |Im(λ)| / (2π) \[Hz\].
147    pub frequency_hz: f64,
148    /// Physical mode label (e.g. "power_sharing", "voltage_control").
149    pub associated_mode: String,
150    /// Normalised participation factor ∈ [0, 1].
151    pub participation_factor: f64,
152}
153
154/// Full stability assessment result for a multi-inverter grid.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct StabilityAssessment {
157    /// All system eigenvalues.
158    pub eigenvalues: Vec<SystemEigenvalue>,
159    /// Overall stability classification.
160    pub mode: StabilityMode,
161    /// Minimum damping ratio across all modes.
162    pub minimum_damping_ratio: f64,
163    /// Least-stable eigenvalue (smallest damping ratio).
164    pub critical_eigenvalue: Option<SystemEigenvalue>,
165    /// Stability margin = minimum damping ratio (positive ⟹ stable).
166    pub stability_margin: f64,
167    /// Oscillation frequencies of poorly-damped modes (ζ < 5 %) \[Hz\].
168    pub oscillation_frequencies: Vec<f64>,
169    /// Participation matrix (n_states × n_modes).
170    pub participation_matrix: Vec<Vec<f64>>,
171    /// Synchronising torque coefficient K_s (positive ⟹ synchronism).
172    pub synchronizing_torque: f64,
173    /// Damping torque coefficient K_d (positive ⟹ damping).
174    pub damping_torque: f64,
175}
176
177/// Multi-inverter grid model for stability analysis.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct GfmGridModel {
180    /// List of grid-forming inverter models.
181    pub inverters: Vec<GfmInverterModel>,
182    /// Grid short-circuit ratio (SCR = S_sc / P_inv).
183    pub grid_strength_scr: f64,
184    /// Grid impedance magnitude \[pu\].
185    pub grid_impedance_z_pu: f64,
186    /// Grid X/R ratio.
187    pub grid_x_r_ratio: f64,
188    /// Total load \[MW\].
189    pub load_mw: f64,
190    /// Renewable penetration \[%\] (0–100).
191    pub renewable_penetration_pct: f64,
192}
193
194/// Main analyser for grid-forming inverter stability.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct GfmStabilityAnalyzer {
197    /// Grid model containing all inverters and network parameters.
198    pub grid_model: GfmGridModel,
199    /// Analysis domain (small-signal, large-signal, etc.).
200    pub domain: DomainOfAnalysis,
201    /// Perturbation magnitude for numerical Jacobian computation.
202    pub perturbation_magnitude: f64,
203}
204
205// ─────────────────────────────────────────────────────────────────────────────
206// Internal helpers — QR eigenvalue decomposition (pure Rust, no external deps)
207// ─────────────────────────────────────────────────────────────────────────────
208
209/// Reduce matrix to upper Hessenberg form via Householder reflections.
210#[allow(clippy::needless_range_loop)]
211fn hessenberg_form(a: &[Vec<f64>]) -> Vec<Vec<f64>> {
212    let n = a.len();
213    let mut h: Vec<Vec<f64>> = a.to_vec();
214    for k in 0..n.saturating_sub(2) {
215        // Build Householder vector from column k, rows k+1..n
216        let mut x: Vec<f64> = (k + 1..n).map(|i| h[i][k]).collect();
217        let norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
218        if norm < 1e-14 {
219            continue;
220        }
221        if x[0] >= 0.0 {
222            x[0] += norm;
223        } else {
224            x[0] -= norm;
225        }
226        let norm2: f64 = x.iter().map(|v| v * v).sum::<f64>();
227        if norm2 < 1e-28 {
228            continue;
229        }
230        // Left multiply: H = H - 2 x (x^T H) / norm2
231        for j in 0..n {
232            let dot: f64 = x
233                .iter()
234                .enumerate()
235                .map(|(i, xi)| xi * h[k + 1 + i][j])
236                .sum();
237            for (i, xi) in x.iter().enumerate() {
238                h[k + 1 + i][j] -= 2.0 * xi * dot / norm2;
239            }
240        }
241        // Right multiply: H = H - 2 (H x) x^T / norm2
242        for i in 0..n {
243            let dot: f64 = x
244                .iter()
245                .enumerate()
246                .map(|(j, xj)| h[i][k + 1 + j] * xj)
247                .sum();
248            for (j, xj) in x.iter().enumerate() {
249                h[i][k + 1 + j] -= 2.0 * dot * xj / norm2;
250            }
251        }
252    }
253    h
254}
255
256/// Wilkinson shift from trailing 2×2 of `h[0..size][0..size]`.
257fn wilkinson_shift(h: &[Vec<f64>], size: usize) -> (f64, f64) {
258    if size < 2 {
259        return (h[0][0], 0.0);
260    }
261    let a = h[size - 2][size - 2];
262    let b = h[size - 2][size - 1];
263    let c = h[size - 1][size - 2];
264    let d = h[size - 1][size - 1];
265    let tr = a + d;
266    let det = a * d - b * c;
267    let disc = tr * tr - 4.0 * det;
268    if disc >= 0.0 {
269        let s = disc.sqrt();
270        let l1 = (tr + s) / 2.0;
271        let l2 = (tr - s) / 2.0;
272        // Pick eigenvalue closer to h[size-1][size-1]
273        if (l1 - d).abs() < (l2 - d).abs() {
274            (l1, 0.0)
275        } else {
276            (l2, 0.0)
277        }
278    } else {
279        (tr / 2.0, (-disc).sqrt() / 2.0)
280    }
281}
282
283/// Apply one Francis double-shift QR step to `h[0..size][0..size]`.
284#[allow(clippy::ptr_arg, clippy::needless_range_loop)]
285fn double_qr_step(h: &mut Vec<Vec<f64>>, size: usize, mu_re: f64, mu_im: f64) {
286    if size < 2 {
287        return;
288    }
289    // Compute shift polynomial: (H - mu1*I)(H - mu2*I) first column
290    let s = 2.0 * mu_re; // trace of 2x2 shift matrix
291    let t = mu_re * mu_re + mu_im * mu_im; // determinant
292                                           // First column of (H^2 - s*H + t*I)
293    let h00 = h[0][0];
294    let h10 = h[1][0];
295    let mut x0 = h00 * h00 + h[0][1] * h10 - s * h00 + t;
296    let mut x1 = h10 * (h00 + h[1][1] - s);
297    let mut x2 = if size > 2 { h[2][1] * h10 } else { 0.0 };
298
299    for k in 0..size.saturating_sub(1) {
300        // Build Householder reflector for [x0, x1, x2] (or [x0, x1] at last step)
301        let m = if k + 3 <= size {
302            3
303        } else if k + 2 <= size {
304            2
305        } else {
306            1
307        };
308        let vec: Vec<f64> = match m {
309            3 => vec![x0, x1, x2],
310            2 => vec![x0, x1],
311            _ => vec![x0],
312        };
313        let norm = vec.iter().map(|v| v * v).sum::<f64>().sqrt();
314        if norm < 1e-14 {
315            if k + 1 < size {
316                x0 = h[k + 1][k];
317                x1 = if k + 2 < size { h[k + 2][k] } else { 0.0 };
318                x2 = if k + 3 < size { h[k + 3][k] } else { 0.0 };
319            }
320            continue;
321        }
322        let mut v = vec.clone();
323        if v[0] >= 0.0 {
324            v[0] += norm;
325        } else {
326            v[0] -= norm;
327        }
328        let norm2: f64 = v.iter().map(|vi| vi * vi).sum();
329        if norm2 < 1e-28 {
330            if k + 1 < size {
331                x0 = h[k + 1][k];
332                x1 = if k + 2 < size { h[k + 2][k] } else { 0.0 };
333                x2 = if k + 3 < size { h[k + 3][k] } else { 0.0 };
334            }
335            continue;
336        }
337        let r = k; // starting row/col of this reflector in h
338                   // Left apply: h[r..r+m][r..n] = h - 2 v (v^T h) / norm2
339        let n = h.len();
340        for j in 0..n {
341            let dot: f64 = (0..m)
342                .map(|i| if r + i < n { v[i] * h[r + i][j] } else { 0.0 })
343                .sum();
344            for i in 0..m {
345                if r + i < n {
346                    h[r + i][j] -= 2.0 * v[i] * dot / norm2;
347                }
348            }
349        }
350        // Right apply: h[0..n][r..r+m] = h - 2 (h v) v^T / norm2
351        for i in 0..n {
352            let dot: f64 = (0..m)
353                .map(|j| if r + j < n { h[i][r + j] * v[j] } else { 0.0 })
354                .sum();
355            for j in 0..m {
356                if r + j < n {
357                    h[i][r + j] -= 2.0 * dot * v[j] / norm2;
358                }
359            }
360        }
361        // Prepare next bulge
362        if k + 1 < size {
363            x0 = h[k + 1][k];
364            x1 = if k + 2 < size { h[k + 2][k] } else { 0.0 };
365            x2 = if k + 3 < size { h[k + 3][k] } else { 0.0 };
366        }
367    }
368}
369
370/// Compute eigenvalues of a real square matrix via Francis double-shift QR iteration.
371/// Returns a `Vec<(real, imag)>` of complex eigenvalues.
372fn qr_eigenvalues(a: &[Vec<f64>]) -> Vec<(f64, f64)> {
373    let n = a.len();
374    if n == 0 {
375        return vec![];
376    }
377    if n == 1 {
378        return vec![(a[0][0], 0.0)];
379    }
380    if n == 2 {
381        return exact_2x2_eigenvalues(a[0][0], a[0][1], a[1][0], a[1][1]);
382    }
383    let mut h = hessenberg_form(a);
384    let mut result: Vec<(f64, f64)> = Vec::with_capacity(n);
385    let mut size = n;
386    let max_iter = 300;
387
388    while size > 2 {
389        let mut deflated = false;
390        for _ in 0..max_iter {
391            let eps = 1e-10;
392            // Check for deflation at bottom
393            if h[size - 1][size - 2].abs()
394                < eps * (h[size - 2][size - 2].abs() + h[size - 1][size - 1].abs())
395            {
396                result.push((h[size - 1][size - 1], 0.0));
397                h[size - 1][size - 2] = 0.0;
398                size -= 1;
399                deflated = true;
400                break;
401            }
402            // Check for 2x2 block deflation
403            if size >= 3
404                && h[size - 2][size - 3].abs()
405                    < eps * (h[size - 3][size - 3].abs() + h[size - 2][size - 2].abs())
406            {
407                let ev2 = exact_2x2_eigenvalues(
408                    h[size - 2][size - 2],
409                    h[size - 2][size - 1],
410                    h[size - 1][size - 2],
411                    h[size - 1][size - 1],
412                );
413                result.extend(ev2);
414                h[size - 2][size - 3] = 0.0;
415                size -= 2;
416                deflated = true;
417                break;
418            }
419            let (mu_re, mu_im) = wilkinson_shift(&h, size);
420            double_qr_step(&mut h, size, mu_re, mu_im);
421        }
422        if !deflated {
423            // Force deflation of trailing 2x2
424            let ev2 = exact_2x2_eigenvalues(
425                h[size - 2][size - 2],
426                h[size - 2][size - 1],
427                h[size - 1][size - 2],
428                h[size - 1][size - 1],
429            );
430            result.extend(ev2);
431            size = size.saturating_sub(2);
432        }
433    }
434    if size == 2 {
435        let ev2 = exact_2x2_eigenvalues(h[0][0], h[0][1], h[1][0], h[1][1]);
436        result.extend(ev2);
437    } else if size == 1 {
438        result.push((h[0][0], 0.0));
439    }
440    result
441}
442
443/// Exact eigenvalues of a 2×2 real matrix.
444fn exact_2x2_eigenvalues(a: f64, b: f64, c: f64, d: f64) -> Vec<(f64, f64)> {
445    let tr = a + d;
446    let det = a * d - b * c;
447    let disc = tr * tr - 4.0 * det;
448    if disc >= 0.0 {
449        let s = disc.sqrt();
450        vec![((tr + s) / 2.0, 0.0), ((tr - s) / 2.0, 0.0)]
451    } else {
452        let s = (-disc).sqrt() / 2.0;
453        vec![(tr / 2.0, s), (tr / 2.0, -s)]
454    }
455}
456
457/// Classify oscillation mode from frequency in Hz.
458fn classify_mode(freq_hz: f64) -> String {
459    if freq_hz < 0.1 {
460        "inter_area".to_string()
461    } else if freq_hz < 2.0 {
462        "local_oscillation".to_string()
463    } else if freq_hz < 10.0 {
464        "power_sharing".to_string()
465    } else if freq_hz < 100.0 {
466        "voltage_control".to_string()
467    } else {
468        "current_control".to_string()
469    }
470}
471
472// ─────────────────────────────────────────────────────────────────────────────
473// GfmStabilityAnalyzer implementation
474// ─────────────────────────────────────────────────────────────────────────────
475
476impl GfmStabilityAnalyzer {
477    /// Create a new analyser with default small-signal domain and perturbation 1e-5.
478    pub fn new(grid_model: GfmGridModel) -> Self {
479        Self {
480            grid_model,
481            domain: DomainOfAnalysis::SmallSignal,
482            perturbation_magnitude: 1e-5,
483        }
484    }
485
486    /// Build the linearised state matrix A for the multi-inverter system.
487    ///
488    /// Each inverter contributes a 2×2 sub-block on the block diagonal.
489    /// VSG inverters use the swing-equation linearisation; others use droop dynamics.
490    /// Off-diagonal coupling terms scale with 1 / SCR.
491    pub fn compute_state_matrix(&self) -> Vec<Vec<f64>> {
492        let n_inv = self.grid_model.inverters.len();
493        if n_inv == 0 {
494            return vec![];
495        }
496        let size = 2 * n_inv;
497        let mut a = vec![vec![0.0f64; size]; size];
498        let omega0 = 2.0 * PI * 50.0_f64;
499        let scr = self.grid_model.grid_strength_scr.max(0.1);
500        let coupling = 0.01 / scr;
501
502        for (idx, inv) in self.grid_model.inverters.iter().enumerate() {
503            let r = 2 * idx; // top-left corner of this inverter's block
504            match inv.control_type {
505                GfmControlType::VirtualSynchronousMachine => {
506                    let h = inv.virtual_inertia_h_s;
507                    if h > 1e-9 {
508                        let ks = self.compute_synchronizing_torque(idx);
509                        let kd = inv.damping_ratio * inv.rated_power_mw.max(1.0);
510                        // [dδ/dt]   [0,          ω0      ] [δ]
511                        // [dω/dt] = [-Ks/(2H), -Kd/(2H) ] [ω]
512                        a[r][r] = 0.0;
513                        a[r][r + 1] = omega0;
514                        a[r + 1][r] = -ks / (2.0 * h);
515                        a[r + 1][r + 1] = -kd / (2.0 * h);
516                    } else {
517                        // Zero inertia — degenerate to proportional droop
518                        let dp = inv.droop_p_pct / 100.0;
519                        let dq = inv.droop_q_pct / 100.0;
520                        a[r][r] = -dp.max(1e-3);
521                        a[r][r + 1] = 0.0;
522                        a[r + 1][r] = 0.0;
523                        a[r + 1][r + 1] = -dq.max(1e-3);
524                    }
525                }
526                _ => {
527                    // Droop / matching / dVOC / PLL / current-source
528                    let dp = inv.droop_p_pct / 100.0;
529                    let dq = inv.droop_q_pct / 100.0;
530                    a[r][r] = -dp;
531                    a[r][r + 1] = 0.0;
532                    a[r + 1][r] = 0.0;
533                    a[r + 1][r + 1] = -dq;
534                }
535            }
536            // Add coupling to all other inverter sub-blocks
537            for (jdx, _) in self.grid_model.inverters.iter().enumerate() {
538                if jdx == idx {
539                    continue;
540                }
541                let c = 2 * jdx;
542                a[r][c] += coupling;
543                a[r + 1][c + 1] += coupling;
544            }
545        }
546        a
547    }
548
549    /// Compute system eigenvalues from state matrix `a` using QR iteration.
550    ///
551    /// Returns eigenvalues sorted by damping ratio (least stable first).
552    pub fn compute_eigenvalues(&self, a_matrix: &[Vec<f64>]) -> Vec<SystemEigenvalue> {
553        let raw = qr_eigenvalues(a_matrix);
554        let mut evs: Vec<SystemEigenvalue> = raw
555            .into_iter()
556            .map(|(re, im)| {
557                let magnitude = (re * re + im * im).sqrt();
558                let zeta = if magnitude < 1e-12 {
559                    0.0
560                } else {
561                    -re / magnitude
562                };
563                let freq_hz = im.abs() / (2.0 * PI);
564                let mode_label = classify_mode(freq_hz);
565                let pf = (0.5 + 0.5 * zeta).clamp(0.0, 1.0);
566                SystemEigenvalue {
567                    real_part: re,
568                    imag_part: im,
569                    damping_ratio: zeta,
570                    frequency_hz: freq_hz,
571                    associated_mode: mode_label,
572                    participation_factor: pf,
573                }
574            })
575            .collect();
576        // Sort least stable first (ascending damping ratio)
577        evs.sort_by(|a, b| {
578            a.damping_ratio
579                .partial_cmp(&b.damping_ratio)
580                .unwrap_or(std::cmp::Ordering::Equal)
581        });
582        evs
583    }
584
585    /// Perform a full stability assessment of the multi-inverter system.
586    ///
587    /// Computes state matrix, eigenvalues, stability mode, synchronising/damping
588    /// torques, and participation matrix.
589    pub fn assess_stability(&self) -> StabilityAssessment {
590        let a = self.compute_state_matrix();
591        let eigenvalues = self.compute_eigenvalues(&a);
592
593        let max_real = eigenvalues
594            .iter()
595            .map(|e| e.real_part)
596            .fold(f64::NEG_INFINITY, f64::max);
597        let min_damping = eigenvalues
598            .iter()
599            .map(|e| e.damping_ratio)
600            .fold(f64::INFINITY, f64::min);
601        let min_damping = if min_damping.is_infinite() {
602            0.0
603        } else {
604            min_damping
605        };
606
607        let unstable_count = eigenvalues.iter().filter(|e| e.real_part > 1e-6).count();
608        let mode = if unstable_count > 1 {
609            StabilityMode::DivergentlyUnstable
610        } else if max_real > 1e-6 {
611            StabilityMode::Unstable
612        } else if max_real > -1e-6 {
613            StabilityMode::MarginallyStable
614        } else if min_damping < 0.05 {
615            StabilityMode::OscillatoryStable
616        } else {
617            StabilityMode::Stable
618        };
619
620        // Critical = least stable eigenvalue (first after sort)
621        let critical_eigenvalue = eigenvalues.first().cloned();
622
623        // Poorly-damped oscillation frequencies
624        let oscillation_frequencies: Vec<f64> = eigenvalues
625            .iter()
626            .filter(|e| e.damping_ratio < 0.05 && e.frequency_hz > 0.01)
627            .map(|e| e.frequency_hz)
628            .collect();
629
630        // Approximate participation matrix as identity (n_states × n_modes)
631        let n = a.len();
632        let participation_matrix: Vec<Vec<f64>> = (0..n)
633            .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
634            .collect();
635
636        let synchronizing_torque = self.compute_synchronizing_torque(0);
637        let damping_torque = self.compute_damping_torque(0);
638
639        StabilityAssessment {
640            eigenvalues,
641            mode,
642            minimum_damping_ratio: min_damping,
643            critical_eigenvalue,
644            stability_margin: min_damping,
645            oscillation_frequencies,
646            participation_matrix,
647            synchronizing_torque,
648            damping_torque,
649        }
650    }
651
652    /// Compute the synchronising torque coefficient K_s for inverter `inverter_id`.
653    ///
654    /// `K_s = V_g * V * sin(δ) / (X_inv + X_grid)` from the swing-equation
655    /// linearisation. A positive K_s is required for synchronism.
656    pub fn compute_synchronizing_torque(&self, inverter_id: usize) -> f64 {
657        let inv = match self.grid_model.inverters.get(inverter_id) {
658            Some(i) => i,
659            None => return 0.0,
660        };
661        let v = inv.voltage_setpoint_pu;
662        let vg = 1.0_f64; // infinite-bus voltage in pu
663        let x_inv = inv.lc_filter_l_pu.max(0.01);
664        // Decompose grid impedance into X and R via X/R ratio
665        let xr = self.grid_model.grid_x_r_ratio;
666        let z = self.grid_model.grid_impedance_z_pu;
667        let x_grid = z * xr / (1.0 + xr * xr).sqrt();
668        let x_total = x_inv + x_grid;
669        // Power angle: sin(δ) ≈ P_rated * X_total / (V * Vg)
670        let sin_delta = (inv.rated_power_mw * x_total / (v * vg)).clamp(-1.0, 1.0);
671        let delta = sin_delta.asin();
672        vg * v * delta.sin() / x_total
673    }
674
675    /// Compute the damping torque coefficient K_d for inverter `inverter_id`.
676    ///
677    /// Approximated as `K_d ≈ D_p * V² / X_total`, where D_p is the damping
678    /// coefficient. Positive K_d ensures oscillations are attenuated.
679    pub fn compute_damping_torque(&self, inverter_id: usize) -> f64 {
680        let inv = match self.grid_model.inverters.get(inverter_id) {
681            Some(i) => i,
682            None => return 0.0,
683        };
684        let dp = inv.damping_ratio;
685        let v = inv.voltage_setpoint_pu;
686        let xr = self.grid_model.grid_x_r_ratio;
687        let z = self.grid_model.grid_impedance_z_pu;
688        let x_grid = z * xr / (1.0 + xr * xr).sqrt();
689        let x_inv = inv.lc_filter_l_pu.max(0.01);
690        let x_total = x_inv + x_grid;
691        dp * v * v / x_total
692    }
693
694    /// Check whether the system is passive (positive-definite conductance matrix).
695    ///
696    /// A passive system cannot generate energy internally, which is a sufficient
697    /// condition for input-output stability. Returns `true` if the system is passive.
698    pub fn check_passivity(&self) -> bool {
699        let total_damping: f64 = self
700            .grid_model
701            .inverters
702            .iter()
703            .map(|inv| inv.damping_ratio * inv.rated_power_mw)
704            .sum();
705        let grid_conductance = 1.0 / self.grid_model.grid_impedance_z_pu.max(1e-9);
706        total_damping + grid_conductance > 0.0
707    }
708
709    /// Estimate the minimum short-circuit ratio (SCR) required for stable operation.
710    ///
711    /// Higher total virtual inertia allows operation at lower SCR. Rule of thumb:
712    /// SCR < 1.5 risks instability for grid-following inverters; SCR < 0.5 even
713    /// for grid-forming inverters.
714    pub fn compute_minimum_scr(&self) -> f64 {
715        let total_inertia: f64 = self
716            .grid_model
717            .inverters
718            .iter()
719            .map(|inv| inv.virtual_inertia_h_s * inv.rated_power_mw)
720            .sum::<f64>();
721        let total_power: f64 = self
722            .grid_model
723            .inverters
724            .iter()
725            .map(|inv| inv.rated_power_mw)
726            .sum::<f64>();
727        if total_inertia < 1e-9 {
728            // No virtual inertia — apply grid-following limit
729            return 1.5;
730        }
731        let omega0 = 2.0 * PI * 50.0_f64;
732        // Approximate minimum SCR = P_rated / (H_total * ω0² / scale)
733        let scr_min = total_power / (total_inertia * omega0 * omega0 / 1e4);
734        scr_min.max(0.1)
735    }
736
737    /// Sweep a named parameter and return (parameter_value, stability_margin) pairs.
738    ///
739    /// Supported parameter names: `"droop_p_pct"`, `"virtual_inertia_h_s"`,
740    /// `"damping_ratio"`, `"grid_strength_scr"`, `"bandwidth_hz"`.
741    /// Returns 10 evenly-spaced sample points across a pre-defined range.
742    pub fn analyze_sensitivity(&self, parameter: &str) -> Vec<(f64, f64)> {
743        let (low, high) = match parameter {
744            "droop_p_pct" => (1.0_f64, 20.0_f64),
745            "virtual_inertia_h_s" => (0.1, 10.0),
746            "damping_ratio" => (0.01, 0.3),
747            "grid_strength_scr" => (0.5, 10.0),
748            "bandwidth_hz" => (10.0, 1000.0),
749            _ => (0.1, 10.0),
750        };
751        (0..10)
752            .map(|i| {
753                let val = low + (high - low) * (i as f64) / 9.0;
754                let mut modified = self.grid_model.clone();
755                for inv in &mut modified.inverters {
756                    match parameter {
757                        "droop_p_pct" => inv.droop_p_pct = val,
758                        "virtual_inertia_h_s" => inv.virtual_inertia_h_s = val,
759                        "damping_ratio" => inv.damping_ratio = val,
760                        "bandwidth_hz" => inv.bandwidth_hz = val,
761                        _ => {}
762                    }
763                }
764                if parameter == "grid_strength_scr" {
765                    modified.grid_strength_scr = val;
766                }
767                let analyzer = GfmStabilityAnalyzer {
768                    grid_model: modified,
769                    domain: DomainOfAnalysis::SmallSignal,
770                    perturbation_magnitude: self.perturbation_magnitude,
771                };
772                let assessment = analyzer.assess_stability();
773                (val, assessment.stability_margin)
774            })
775            .collect()
776    }
777
778    /// Identify the dominant oscillatory mode (eigenvalue with highest frequency).
779    ///
780    /// Filters out non-oscillatory modes (frequency < 0.01 Hz) and returns the
781    /// mode with the largest oscillation frequency, which is typically the most
782    /// observable in measurements.
783    pub fn identify_dominant_mode<'a>(
784        &self,
785        eigenvalues: &'a [SystemEigenvalue],
786    ) -> Option<&'a SystemEigenvalue> {
787        eigenvalues
788            .iter()
789            .filter(|e| e.frequency_hz > 0.01)
790            .max_by(|a, b| {
791                a.frequency_hz
792                    .partial_cmp(&b.frequency_hz)
793                    .unwrap_or(std::cmp::Ordering::Equal)
794            })
795    }
796}
797
798// ─────────────────────────────────────────────────────────────────────────────
799// Tests
800// ─────────────────────────────────────────────────────────────────────────────
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    /// Build a default stable VSG-based single-inverter grid model.
807    fn make_vsg_model() -> GfmGridModel {
808        GfmGridModel {
809            inverters: vec![GfmInverterModel {
810                id: 0,
811                name: "VSG1".into(),
812                control_type: GfmControlType::VirtualSynchronousMachine,
813                rated_power_mw: 10.0,
814                rated_voltage_kv: 11.0,
815                virtual_inertia_h_s: 5.0,
816                damping_ratio: 0.05,
817                droop_p_pct: 5.0,
818                droop_q_pct: 5.0,
819                voltage_setpoint_pu: 1.0,
820                frequency_setpoint_hz: 50.0,
821                lc_filter_l_pu: 0.1,
822                lc_filter_c_pu: 0.05,
823                bandwidth_hz: 100.0,
824                bus_id: 1,
825            }],
826            grid_strength_scr: 5.0,
827            grid_impedance_z_pu: 0.1,
828            grid_x_r_ratio: 5.0,
829            load_mw: 8.0,
830            renewable_penetration_pct: 60.0,
831        }
832    }
833
834    /// Build a droop-controlled inverter grid model.
835    fn make_droop_model() -> GfmGridModel {
836        GfmGridModel {
837            inverters: vec![GfmInverterModel {
838                id: 0,
839                name: "Droop1".into(),
840                control_type: GfmControlType::Droop,
841                rated_power_mw: 5.0,
842                rated_voltage_kv: 0.4,
843                virtual_inertia_h_s: 0.0,
844                damping_ratio: 0.1,
845                droop_p_pct: 4.0,
846                droop_q_pct: 4.0,
847                voltage_setpoint_pu: 1.0,
848                frequency_setpoint_hz: 50.0,
849                lc_filter_l_pu: 0.05,
850                lc_filter_c_pu: 0.02,
851                bandwidth_hz: 200.0,
852                bus_id: 2,
853            }],
854            grid_strength_scr: 3.0,
855            grid_impedance_z_pu: 0.05,
856            grid_x_r_ratio: 3.0,
857            load_mw: 4.0,
858            renewable_penetration_pct: 40.0,
859        }
860    }
861
862    #[test]
863    fn test_vsg_synchronizing_torque_positive() {
864        let model = make_vsg_model();
865        let analyzer = GfmStabilityAnalyzer::new(model);
866        let ks = analyzer.compute_synchronizing_torque(0);
867        assert!(ks > 0.0, "Synchronising torque must be positive: got {ks}");
868    }
869
870    #[test]
871    fn test_droop_damping_positive() {
872        let model = make_droop_model();
873        let analyzer = GfmStabilityAnalyzer::new(model);
874        let kd = analyzer.compute_damping_torque(0);
875        assert!(kd > 0.0, "Damping torque must be positive: got {kd}");
876    }
877
878    #[test]
879    fn test_eigenvalue_damping_ratio() {
880        // λ = -1 + 0j  →  |λ| = 1  →  ζ = 1.0
881        let a = vec![vec![-1.0_f64]];
882        let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
883        let evs = analyzer.compute_eigenvalues(&a);
884        assert_eq!(evs.len(), 1);
885        let zeta = evs[0].damping_ratio;
886        assert!(
887            (zeta - 1.0).abs() < 1e-9,
888            "Expected ζ=1.0 for λ=-1, got {zeta}"
889        );
890    }
891
892    #[test]
893    fn test_stable_classification() {
894        let model = make_vsg_model();
895        let analyzer = GfmStabilityAnalyzer::new(model);
896        let assessment = analyzer.assess_stability();
897        matches!(
898            assessment.mode,
899            StabilityMode::Stable | StabilityMode::OscillatoryStable
900        );
901        // All eigenvalues must have negative real part
902        for ev in &assessment.eigenvalues {
903            assert!(
904                ev.real_part < 1e-6,
905                "Eigenvalue real part should be negative: {}",
906                ev.real_part
907            );
908        }
909    }
910
911    #[test]
912    fn test_unstable_classification() {
913        // Negative droop creates positive diagonal → positive eigenvalue
914        let mut model = make_droop_model();
915        model.inverters[0].droop_p_pct = -50.0;
916        model.inverters[0].droop_q_pct = -50.0;
917        let analyzer = GfmStabilityAnalyzer::new(model);
918        let assessment = analyzer.assess_stability();
919        assert!(
920            matches!(
921                assessment.mode,
922                StabilityMode::Unstable | StabilityMode::DivergentlyUnstable
923            ),
924            "Expected Unstable, got {:?}",
925            assessment.mode
926        );
927    }
928
929    #[test]
930    fn test_oscillatory_stable_classification() {
931        // Very small droop → very low damping but stable
932        let mut model = make_droop_model();
933        model.inverters[0].droop_p_pct = 0.001;
934        model.inverters[0].droop_q_pct = 0.001;
935        model.inverters[0].damping_ratio = 0.001;
936        let analyzer = GfmStabilityAnalyzer::new(model);
937        let assessment = analyzer.assess_stability();
938        // Should be OscillatoryStable or Stable — not Unstable
939        assert!(
940            !matches!(
941                assessment.mode,
942                StabilityMode::Unstable | StabilityMode::DivergentlyUnstable
943            ),
944            "Should be stable variant, got {:?}",
945            assessment.mode
946        );
947    }
948
949    #[test]
950    fn test_state_matrix_2x2_eigenvalue() {
951        // A = [[0,1],[-1,-0.1]] — underdamped oscillator
952        let a = vec![vec![0.0_f64, 1.0], vec![-1.0, -0.1]];
953        let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
954        let evs = analyzer.compute_eigenvalues(&a);
955        assert_eq!(evs.len(), 2);
956        // Both eigenvalues should have negative real part
957        for ev in &evs {
958            assert!(ev.real_part < 0.0, "Expected Re < 0, got {}", ev.real_part);
959        }
960        // Should be complex conjugate pair
961        let has_imag = evs.iter().any(|e| e.imag_part.abs() > 0.01);
962        assert!(
963            has_imag,
964            "Expected complex eigenvalues for underdamped system"
965        );
966    }
967
968    #[test]
969    fn test_state_matrix_diagonal() {
970        // Diagonal matrix → eigenvalues = diagonal entries
971        let a = vec![
972            vec![-2.0_f64, 0.0, 0.0],
973            vec![0.0, -3.0, 0.0],
974            vec![0.0, 0.0, -5.0],
975        ];
976        let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
977        let evs = analyzer.compute_eigenvalues(&a);
978        assert_eq!(evs.len(), 3);
979        let mut re_parts: Vec<f64> = evs.iter().map(|e| e.real_part).collect();
980        re_parts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
981        let expected = [-5.0, -3.0, -2.0];
982        for (got, exp) in re_parts.iter().zip(expected.iter()) {
983            assert!((got - exp).abs() < 1e-6, "Expected {exp}, got {got}");
984        }
985    }
986
987    #[test]
988    fn test_minimum_scr_positive() {
989        let model = make_vsg_model();
990        let analyzer = GfmStabilityAnalyzer::new(model);
991        let scr = analyzer.compute_minimum_scr();
992        assert!(scr > 0.0, "Minimum SCR must be positive: {scr}");
993    }
994
995    #[test]
996    fn test_minimum_scr_decreases_with_inertia() {
997        // Higher virtual inertia → lower required SCR
998        let mut model_low = make_vsg_model();
999        let mut model_high = make_vsg_model();
1000        model_low.inverters[0].virtual_inertia_h_s = 1.0;
1001        model_high.inverters[0].virtual_inertia_h_s = 10.0;
1002        let scr_low = GfmStabilityAnalyzer::new(model_low).compute_minimum_scr();
1003        let scr_high = GfmStabilityAnalyzer::new(model_high).compute_minimum_scr();
1004        assert!(
1005            scr_high < scr_low,
1006            "Higher inertia should require lower SCR: scr_low={scr_low}, scr_high={scr_high}"
1007        );
1008    }
1009
1010    #[test]
1011    fn test_passivity_check_lossy_system() {
1012        let model = make_vsg_model();
1013        let analyzer = GfmStabilityAnalyzer::new(model);
1014        assert!(
1015            analyzer.check_passivity(),
1016            "Lossy system with positive damping should be passive"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_stability_margin_positive_stable() {
1022        let model = make_vsg_model();
1023        let analyzer = GfmStabilityAnalyzer::new(model);
1024        let assessment = analyzer.assess_stability();
1025        assert!(
1026            assessment.stability_margin > 0.0,
1027            "Stability margin must be positive for stable system: {}",
1028            assessment.stability_margin
1029        );
1030    }
1031
1032    #[test]
1033    fn test_stability_margin_negative_unstable() {
1034        let mut model = make_droop_model();
1035        model.inverters[0].droop_p_pct = -20.0;
1036        model.inverters[0].droop_q_pct = -20.0;
1037        let analyzer = GfmStabilityAnalyzer::new(model);
1038        let assessment = analyzer.assess_stability();
1039        assert!(
1040            assessment.stability_margin < 0.0,
1041            "Stability margin should be negative for unstable system: {}",
1042            assessment.stability_margin
1043        );
1044    }
1045
1046    #[test]
1047    fn test_critical_eigenvalue_least_stable() {
1048        let model = make_vsg_model();
1049        let analyzer = GfmStabilityAnalyzer::new(model);
1050        let assessment = analyzer.assess_stability();
1051        if let Some(crit) = &assessment.critical_eigenvalue {
1052            // Critical eigenvalue must have the minimum damping ratio
1053            let min_zeta = assessment
1054                .eigenvalues
1055                .iter()
1056                .map(|e| e.damping_ratio)
1057                .fold(f64::INFINITY, f64::min);
1058            assert!(
1059                (crit.damping_ratio - min_zeta).abs() < 1e-9,
1060                "Critical eigenvalue should have minimum damping ratio"
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn test_sensitivity_analysis_returns_10_points() {
1067        let model = make_vsg_model();
1068        let analyzer = GfmStabilityAnalyzer::new(model);
1069        let pairs = analyzer.analyze_sensitivity("droop_p_pct");
1070        assert_eq!(
1071            pairs.len(),
1072            10,
1073            "Sensitivity analysis should return 10 pairs"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_frequency_oscillation_detection() {
1079        // Build eigenvalues with a low-frequency oscillatory mode directly
1080        // and verify that assess_stability populates oscillation_frequencies.
1081        // Use a VSG model where the 2×2 swing block produces complex eigenvalues.
1082        let mut model = make_vsg_model();
1083        // Large virtual inertia + small damping → oscillatory mode
1084        model.inverters[0].virtual_inertia_h_s = 50.0;
1085        model.inverters[0].damping_ratio = 0.001;
1086        let analyzer = GfmStabilityAnalyzer::new(model);
1087        let a = analyzer.compute_state_matrix();
1088        let evs = analyzer.compute_eigenvalues(&a);
1089        // At least one eigenvalue should have a non-zero imaginary part
1090        let has_oscillatory = evs.iter().any(|e| e.imag_part.abs() > 1e-3);
1091        assert!(
1092            has_oscillatory,
1093            "Large inertia + small damping should produce oscillatory eigenvalues"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_multiple_inverters_interaction() {
1099        let mut model = make_vsg_model();
1100        model.inverters.push(GfmInverterModel {
1101            id: 1,
1102            name: "VSG2".into(),
1103            control_type: GfmControlType::VirtualSynchronousMachine,
1104            rated_power_mw: 8.0,
1105            rated_voltage_kv: 11.0,
1106            virtual_inertia_h_s: 4.0,
1107            damping_ratio: 0.06,
1108            droop_p_pct: 4.0,
1109            droop_q_pct: 4.0,
1110            voltage_setpoint_pu: 1.0,
1111            frequency_setpoint_hz: 50.0,
1112            lc_filter_l_pu: 0.08,
1113            lc_filter_c_pu: 0.04,
1114            bandwidth_hz: 120.0,
1115            bus_id: 2,
1116        });
1117        let analyzer = GfmStabilityAnalyzer::new(model);
1118        let a = analyzer.compute_state_matrix();
1119        // 2 inverters × 2 states = 4×4 matrix
1120        assert_eq!(a.len(), 4, "State matrix should be 4×4 for 2 inverters");
1121        assert_eq!(a[0].len(), 4);
1122        // Off-diagonal coupling should be non-zero
1123        let has_coupling = a[0][2] != 0.0 || a[1][3] != 0.0;
1124        assert!(has_coupling, "Should have cross-inverter coupling terms");
1125    }
1126
1127    #[test]
1128    fn test_high_renewable_penetration_stability() {
1129        let mut model = make_vsg_model();
1130        model.renewable_penetration_pct = 90.0;
1131        let analyzer = GfmStabilityAnalyzer::new(model);
1132        let assessment = analyzer.assess_stability();
1133        // Should return a valid (non-panicking) result
1134        assert!(!assessment.eigenvalues.is_empty());
1135    }
1136
1137    #[test]
1138    fn test_assess_stability_returns_complete() {
1139        let model = make_vsg_model();
1140        let analyzer = GfmStabilityAnalyzer::new(model);
1141        let assessment = analyzer.assess_stability();
1142        assert!(!assessment.eigenvalues.is_empty(), "Must have eigenvalues");
1143        assert!(
1144            !assessment.participation_matrix.is_empty(),
1145            "Must have participation matrix"
1146        );
1147        assert!(
1148            assessment.synchronizing_torque.is_finite(),
1149            "Ks must be finite"
1150        );
1151        assert!(assessment.damping_torque.is_finite(), "Kd must be finite");
1152        assert!(
1153            assessment.minimum_damping_ratio.is_finite(),
1154            "Min damping must be finite"
1155        );
1156    }
1157
1158    #[test]
1159    fn test_identify_dominant_mode() {
1160        // Create eigenvalues with known frequencies
1161        let evs = vec![
1162            SystemEigenvalue {
1163                real_part: -0.5,
1164                imag_part: 2.0 * PI * 1.5,
1165                damping_ratio: 0.1,
1166                frequency_hz: 1.5,
1167                associated_mode: "local_oscillation".into(),
1168                participation_factor: 0.8,
1169            },
1170            SystemEigenvalue {
1171                real_part: -1.0,
1172                imag_part: 2.0 * PI * 10.0,
1173                damping_ratio: 0.3,
1174                frequency_hz: 10.0,
1175                associated_mode: "voltage_control".into(),
1176                participation_factor: 0.7,
1177            },
1178            SystemEigenvalue {
1179                real_part: -2.0,
1180                imag_part: 0.0,
1181                damping_ratio: 1.0,
1182                frequency_hz: 0.0,
1183                associated_mode: "inter_area".into(),
1184                participation_factor: 0.5,
1185            },
1186        ];
1187        let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
1188        let dominant = analyzer.identify_dominant_mode(&evs);
1189        assert!(dominant.is_some());
1190        let dom = dominant.unwrap();
1191        assert!(
1192            (dom.frequency_hz - 10.0).abs() < 1e-9,
1193            "Dominant mode should have highest frequency: {}",
1194            dom.frequency_hz
1195        );
1196    }
1197
1198    #[test]
1199    fn test_control_type_droop_model() {
1200        let model = make_droop_model();
1201        let analyzer = GfmStabilityAnalyzer::new(model);
1202        let a = analyzer.compute_state_matrix();
1203        assert!(!a.is_empty(), "State matrix should be non-empty");
1204        // Droop model: diagonal should be -droop_p_pct/100 = -0.04
1205        let expected_diag = -0.04_f64;
1206        assert!(
1207            (a[0][0] - expected_diag).abs() < 1e-9,
1208            "Droop diagonal entry: expected {expected_diag}, got {}",
1209            a[0][0]
1210        );
1211    }
1212
1213    #[test]
1214    fn test_matching_control_type() {
1215        let mut model = make_vsg_model();
1216        model.inverters[0].control_type = GfmControlType::MatchingControl;
1217        model.inverters[0].droop_p_pct = 6.0;
1218        model.inverters[0].droop_q_pct = 6.0;
1219        let analyzer = GfmStabilityAnalyzer::new(model);
1220        let assessment = analyzer.assess_stability();
1221        assert!(!assessment.eigenvalues.is_empty());
1222        // MatchingControl uses droop dynamics → diagonal = -0.06
1223        let a = analyzer.compute_state_matrix();
1224        assert!(
1225            (a[0][0] - (-0.06)).abs() < 1e-9,
1226            "MatchingControl diagonal: {}",
1227            a[0][0]
1228        );
1229    }
1230
1231    #[test]
1232    fn test_sensitivity_analysis_grid_scr() {
1233        let model = make_vsg_model();
1234        let analyzer = GfmStabilityAnalyzer::new(model);
1235        let pairs = analyzer.analyze_sensitivity("grid_strength_scr");
1236        assert_eq!(pairs.len(), 10);
1237        // All parameter values should be increasing
1238        for w in pairs.windows(2) {
1239            assert!(
1240                w[1].0 >= w[0].0,
1241                "Parameter values should be non-decreasing"
1242            );
1243        }
1244    }
1245
1246    #[test]
1247    fn test_identify_dominant_mode_empty() {
1248        let analyzer = GfmStabilityAnalyzer::new(make_vsg_model());
1249        // No oscillatory eigenvalues
1250        let evs: Vec<SystemEigenvalue> = vec![];
1251        assert!(analyzer.identify_dominant_mode(&evs).is_none());
1252    }
1253
1254    #[test]
1255    fn test_vsg_zero_inertia_uses_droop() {
1256        let mut model = make_vsg_model();
1257        model.inverters[0].virtual_inertia_h_s = 0.0;
1258        model.inverters[0].droop_p_pct = 5.0;
1259        let analyzer = GfmStabilityAnalyzer::new(model);
1260        let a = analyzer.compute_state_matrix();
1261        // Should use droop block with positive magnitude diagonal entries
1262        assert!(
1263            a[0][0] < 0.0,
1264            "Zero-inertia VSG should use negative droop diagonal"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_passivity_zero_grid_impedance_handled() {
1270        let mut model = make_vsg_model();
1271        model.grid_impedance_z_pu = 0.0; // edge case
1272        let analyzer = GfmStabilityAnalyzer::new(model);
1273        // Should not panic (uses .max(1e-9) guard)
1274        let passive = analyzer.check_passivity();
1275        assert!(passive);
1276    }
1277}