1use serde::{Deserialize, Serialize};
25use std::f64::consts::PI;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum GfmControlType {
34 VirtualSynchronousMachine,
36 Droop,
38 MatchingControl,
40 DispatchableVirtualOscillatorControl,
42 PllBased,
44 CurrentSourceMode,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum StabilityMode {
51 Stable,
53 OscillatoryStable,
55 MarginallyStable,
57 Unstable,
59 DivergentlyUnstable,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum DomainOfAnalysis {
66 SmallSignal,
68 LargeSignal,
70 Bifurcation,
72 PassivityBased,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct GfmInverterModel {
83 pub id: usize,
85 pub name: String,
87 pub control_type: GfmControlType,
89 pub rated_power_mw: f64,
91 pub rated_voltage_kv: f64,
93 pub virtual_inertia_h_s: f64,
95 pub damping_ratio: f64,
97 pub droop_p_pct: f64,
99 pub droop_q_pct: f64,
101 pub voltage_setpoint_pu: f64,
103 pub frequency_setpoint_hz: f64,
105 pub lc_filter_l_pu: f64,
107 pub lc_filter_c_pu: f64,
109 pub bandwidth_hz: f64,
111 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#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SystemEigenvalue {
140 pub real_part: f64,
142 pub imag_part: f64,
144 pub damping_ratio: f64,
146 pub frequency_hz: f64,
148 pub associated_mode: String,
150 pub participation_factor: f64,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct StabilityAssessment {
157 pub eigenvalues: Vec<SystemEigenvalue>,
159 pub mode: StabilityMode,
161 pub minimum_damping_ratio: f64,
163 pub critical_eigenvalue: Option<SystemEigenvalue>,
165 pub stability_margin: f64,
167 pub oscillation_frequencies: Vec<f64>,
169 pub participation_matrix: Vec<Vec<f64>>,
171 pub synchronizing_torque: f64,
173 pub damping_torque: f64,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct GfmGridModel {
180 pub inverters: Vec<GfmInverterModel>,
182 pub grid_strength_scr: f64,
184 pub grid_impedance_z_pu: f64,
186 pub grid_x_r_ratio: f64,
188 pub load_mw: f64,
190 pub renewable_penetration_pct: f64,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct GfmStabilityAnalyzer {
197 pub grid_model: GfmGridModel,
199 pub domain: DomainOfAnalysis,
201 pub perturbation_magnitude: f64,
203}
204
205#[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 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 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 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
256fn 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 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#[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 let s = 2.0 * mu_re; let t = mu_re * mu_re + mu_im * mu_im; 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 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; 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 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 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
370fn 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 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 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 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
443fn 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
457fn 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
472impl GfmStabilityAnalyzer {
477 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 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; 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 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 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 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 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 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 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 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 let critical_eigenvalue = eigenvalues.first().cloned();
622
623 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 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 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; let x_inv = inv.lc_filter_l_pu.max(0.01);
664 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 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 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 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 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 return 1.5;
730 }
731 let omega0 = 2.0 * PI * 50.0_f64;
732 let scr_min = total_power / (total_inertia * omega0 * omega0 / 1e4);
734 scr_min.max(0.1)
735 }
736
737 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 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#[cfg(test)]
803mod tests {
804 use super::*;
805
806 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 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 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 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 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 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 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 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 for ev in &evs {
958 assert!(ev.real_part < 0.0, "Expected Re < 0, got {}", ev.real_part);
959 }
960 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 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 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 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 let mut model = make_vsg_model();
1083 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 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 assert_eq!(a.len(), 4, "State matrix should be 4×4 for 2 inverters");
1121 assert_eq!(a[0].len(), 4);
1122 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 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 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 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 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 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 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 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; let analyzer = GfmStabilityAnalyzer::new(model);
1273 let passive = analyzer.check_passivity();
1275 assert!(passive);
1276 }
1277}