use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhillipsHeffronModel {
pub k1: f64,
pub k2: f64,
pub k3: f64,
pub k4: f64,
pub k5: f64,
pub k6: f64,
pub d: f64,
pub m: f64,
pub t_d0: f64,
pub ka: f64,
pub ta: f64,
}
impl Default for PhillipsHeffronModel {
fn default() -> Self {
Self {
k1: 0.7640,
k2: 0.8649,
k3: 0.3231,
k4: 1.4190,
k5: -0.1110,
k6: 0.4477,
d: 0.0,
m: 10.0, t_d0: 7.32,
ka: 200.0,
ta: 0.05,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PssParameters {
pub k_s: f64,
pub t_w: f64,
pub t1: f64,
pub t2: f64,
pub t3: f64,
pub t4: f64,
pub v_smax: f64,
pub v_smin: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PssTuner {
pub target_mode_freq_hz: f64,
pub target_damping_ratio: f64,
pub washout_time_const: f64,
pub gain_range: (f64, f64),
pub lead_lag_range: (f64, f64),
pub gain_steps: usize,
}
impl Default for PssTuner {
fn default() -> Self {
Self {
target_mode_freq_hz: 1.0,
target_damping_ratio: 0.05,
washout_time_const: 10.0,
gain_range: (0.1, 50.0),
lead_lag_range: (0.05, 20.0),
gain_steps: 200,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PssTuningResult {
pub parameters: PssParameters,
pub achieved_damping: f64,
pub achieved_freq_hz: f64,
pub phase_margin_deg: f64,
pub gain_margin_db: f64,
pub iterations: usize,
}
impl PssTuner {
pub fn tune(&self, model: &PhillipsHeffronModel) -> Result<PssTuningResult> {
if self.target_mode_freq_hz <= 0.0 || self.target_mode_freq_hz > 10.0 {
return Err(OxiGridError::InvalidParameter(format!(
"target_mode_freq_hz={:.3} must be in (0, 10] Hz",
self.target_mode_freq_hz
)));
}
if !(0.0..1.0).contains(&self.target_damping_ratio) {
return Err(OxiGridError::InvalidParameter(format!(
"target_damping_ratio={:.3} must be in [0, 1)",
self.target_damping_ratio
)));
}
let omega = 2.0 * PI * self.target_mode_freq_hz;
let ge_phase_rad = -(omega * model.k3 * model.t_d0).atan();
let avr_phase_rad = if model.ka > 0.0 {
-(omega * model.ta).atan()
} else {
0.0
};
let total_lag_rad = ge_phase_rad + avr_phase_rad;
let washout_phase_rad = PI / 2.0 - (1.0 / (self.washout_time_const * omega)).atan();
let required_lead_rad = -total_lag_rad - washout_phase_rad;
let required_lead_clamped = required_lead_rad.clamp(0.0, PI / 2.0 - 1e-6);
let phase_per_stage = required_lead_clamped / 2.0;
let (t1, t2, t3, t4) = design_lead_lag(phase_per_stage, omega);
let (k_s, achieved_damping, achieved_freq_hz, iters) =
self.tune_gain(model, t1, t2, t3, t4, omega)?;
let (phase_margin_deg, gain_margin_db) = compute_stability_margins(
model,
&PssParameters {
k_s,
t_w: self.washout_time_const,
t1,
t2,
t3,
t4,
v_smax: 0.1,
v_smin: -0.1,
},
);
Ok(PssTuningResult {
parameters: PssParameters {
k_s,
t_w: self.washout_time_const,
t1,
t2,
t3,
t4,
v_smax: 0.1,
v_smin: -0.1,
},
achieved_damping,
achieved_freq_hz,
phase_margin_deg,
gain_margin_db,
iterations: iters,
})
}
fn tune_gain(
&self,
model: &PhillipsHeffronModel,
t1: f64,
t2: f64,
t3: f64,
t4: f64,
omega0: f64,
) -> Result<(f64, f64, f64, usize)> {
let (k_min, k_max) = self.gain_range;
let n = self.gain_steps.max(10);
let step = (k_max - k_min) / n as f64;
let mut best_k = k_min;
let mut best_zeta = f64::NEG_INFINITY;
let mut best_freq_hz = self.target_mode_freq_hz;
let mut iterations = 0;
for i in 0..=n {
let k_s = k_min + i as f64 * step;
let pss = PssParameters {
k_s,
t_w: self.washout_time_const,
t1,
t2,
t3,
t4,
v_smax: 0.1,
v_smin: -0.1,
};
iterations += 1;
if let Some((zeta, freq_hz)) = closed_loop_damping(model, &pss, omega0) {
if zeta > best_zeta {
best_zeta = zeta;
best_k = k_s;
best_freq_hz = freq_hz;
}
if zeta >= self.target_damping_ratio + 0.05 {
break;
}
}
}
if best_zeta == f64::NEG_INFINITY {
best_k = k_min;
best_zeta = 0.0;
}
Ok((best_k, best_zeta, best_freq_hz, iterations))
}
}
fn design_lead_lag(phase_per_stage_rad: f64, omega: f64) -> (f64, f64, f64, f64) {
let phi = phase_per_stage_rad.clamp(0.0, PI / 2.0 - 1e-6);
let sin_phi = phi.sin().clamp(-0.9999, 0.9999);
let alpha = (1.0 + sin_phi) / (1.0 - sin_phi).max(1e-10);
let alpha_clamped = alpha.clamp(1.0, 100.0);
let t2 = 1.0 / (omega * alpha_clamped.sqrt()).max(1e-10);
let t1 = alpha_clamped * t2;
(t1, t2, t1, t2) }
fn closed_loop_damping(
model: &PhillipsHeffronModel,
pss: &PssParameters,
omega0: f64,
) -> Option<(f64, f64)> {
let s = num_complex::Complex64::new(0.0, omega0);
let washout = (pss.t_w * s) / (1.0 + pss.t_w * s);
let stage1 = (1.0 + pss.t1 * s) / (1.0 + pss.t2 * s);
let stage2 = (1.0 + pss.t3 * s) / (1.0 + pss.t4 * s);
let g_pss = pss.k_s * washout * stage1 * stage2;
let g_e = model.k2 * model.k3 / (1.0 + model.k3 * model.t_d0 * s);
let g_avr = model.ka / (1.0 + model.ta * s);
let g_v = num_complex::Complex64::new(model.k5, 0.0) + model.k6 * g_e;
let loop_gain = g_pss * g_avr * g_e;
let delta_td = loop_gain.re;
let d_eff = model.d + delta_td;
let omega_n_sq = model.k1 / model.m.max(1e-10);
if omega_n_sq <= 0.0 {
return None;
}
let omega_n = omega_n_sq.sqrt();
let delta_ks = -(g_pss * g_avr * g_e * num_complex::Complex64::new(model.k2, 0.0) * g_v).re;
let k_eff = (model.k1 + delta_ks).max(0.001);
let omega_n_cl = (k_eff / model.m.max(1e-10)).sqrt();
let zeta = d_eff / (2.0 * omega_n_cl * model.m.max(1e-10));
let freq_hz = omega_n / (2.0 * PI);
let _ = omega_n;
if zeta.is_finite() {
Some((zeta, freq_hz))
} else {
None
}
}
fn compute_stability_margins(model: &PhillipsHeffronModel, pss: &PssParameters) -> (f64, f64) {
let n_pts = 500;
let omega_min = 0.01_f64;
let omega_max = 100.0_f64;
let mut phase_margin_deg = 90.0_f64; let mut gain_margin_db = 20.0_f64;
let mut prev_mag = 0.0_f64;
let mut prev_phase = 0.0_f64;
let mut found_gain_crossover = false;
let mut found_phase_crossover = false;
for i in 0..n_pts {
let omega = omega_min * (omega_max / omega_min).powf(i as f64 / (n_pts - 1) as f64);
let s = num_complex::Complex64::new(0.0, omega);
let washout = (pss.t_w * s) / (1.0 + pss.t_w * s);
let stage1 = (1.0 + pss.t1 * s) / (1.0 + pss.t2 * s);
let stage2 = (1.0 + pss.t3 * s) / (1.0 + pss.t4 * s);
let g_pss = pss.k_s * washout * stage1 * stage2;
let g_e = model.k2 * model.k3 / (1.0 + model.k3 * model.t_d0 * s);
let g_avr = model.ka / (1.0 + model.ta * s);
let g_ol = g_pss * g_avr * g_e;
let mag = g_ol.norm();
let phase_deg = g_ol.arg().to_degrees();
if !found_gain_crossover && i > 0 && (prev_mag - 1.0) * (mag - 1.0) < 0.0 {
let t = (1.0 - prev_mag) / (mag - prev_mag).max(1e-30);
let phase_at_cross = prev_phase + t * (phase_deg - prev_phase);
phase_margin_deg = 180.0 + phase_at_cross;
found_gain_crossover = true;
}
if !found_phase_crossover && i > 0 && (prev_phase + 180.0) * (phase_deg + 180.0) < 0.0 {
let t = -(prev_phase + 180.0) / (phase_deg - prev_phase).max(1e-30);
let mag_at_cross = prev_mag + t * (mag - prev_mag);
gain_margin_db = if mag_at_cross > 1e-10 {
-20.0 * mag_at_cross.log10()
} else {
60.0 };
found_phase_crossover = true;
}
prev_mag = mag;
prev_phase = phase_deg;
if found_gain_crossover && found_phase_crossover {
break;
}
}
(phase_margin_deg, gain_margin_db)
}
#[cfg(test)]
mod tests {
use super::*;
fn smib_model() -> PhillipsHeffronModel {
PhillipsHeffronModel::default()
}
fn default_tuner() -> PssTuner {
PssTuner {
target_mode_freq_hz: 1.0,
target_damping_ratio: 0.05,
washout_time_const: 10.0,
gain_range: (0.1, 30.0),
lead_lag_range: (0.05, 20.0),
gain_steps: 100,
}
}
#[test]
fn test_pss_tuning_converges() {
let model = smib_model();
let tuner = default_tuner();
let result = tuner.tune(&model).expect("PSS tuning should succeed");
assert!(
result.parameters.k_s > 0.0,
"PSS gain should be positive: {:.4}",
result.parameters.k_s
);
assert!(
result.parameters.t1 >= result.parameters.t2 - 1e-9,
"T1={:.4} should be >= T2={:.4} (lead-lag)",
result.parameters.t1,
result.parameters.t2
);
}
#[test]
fn test_pss_achieves_positive_damping() {
let model = smib_model();
let tuner = default_tuner();
let result = tuner.tune(&model).expect("PSS tuning");
assert!(
result.achieved_damping.is_finite(),
"Achieved damping should be finite"
);
}
#[test]
fn test_phase_margin_ge_30_deg() {
let model = smib_model();
let tuner = default_tuner();
let result = tuner.tune(&model).expect("PSS tuning");
assert!(
result.phase_margin_deg >= 30.0,
"Phase margin {:.2}° should be >= 30°",
result.phase_margin_deg
);
}
#[test]
fn test_lead_lag_provides_phase_compensation() {
let omega = 2.0 * PI * 1.0; let phi = 30.0_f64.to_radians();
let (t1, t2, t3, t4) = design_lead_lag(phi, omega);
let alpha = t1 / t2.max(1e-10);
let sin_phi_computed = (alpha - 1.0) / (alpha + 1.0);
assert!(
(sin_phi_computed - phi.sin()).abs() < 0.01,
"Lead-lag ratio mismatch: sin(phi)={:.4}, (α-1)/(α+1)={:.4}",
phi.sin(),
sin_phi_computed
);
assert!(
(t3 - t1).abs() < 1e-10,
"T3={:.6} should equal T1={:.6}",
t3,
t1
);
assert!(
(t4 - t2).abs() < 1e-10,
"T4={:.6} should equal T2={:.6}",
t4,
t2
);
}
#[test]
fn test_invalid_frequency_returns_error() {
let model = smib_model();
let tuner = PssTuner {
target_mode_freq_hz: -1.0,
..default_tuner()
};
assert!(
tuner.tune(&model).is_err(),
"Negative frequency should error"
);
}
#[test]
fn test_invalid_damping_ratio_returns_error() {
let model = smib_model();
let tuner = PssTuner {
target_damping_ratio: 1.5,
..default_tuner()
};
assert!(
tuner.tune(&model).is_err(),
"Damping ratio > 1 should error"
);
}
#[test]
fn test_washout_time_constant_set_correctly() {
let model = smib_model();
let tuner = PssTuner {
washout_time_const: 7.5,
..default_tuner()
};
let result = tuner.tune(&model).expect("PSS tuning");
assert!(
(result.parameters.t_w - 7.5).abs() < 1e-10,
"Washout time constant should match config"
);
}
#[test]
fn test_different_target_frequencies_yield_different_time_constants() {
let model = smib_model();
let tuner_05 = PssTuner {
target_mode_freq_hz: 0.5,
..default_tuner()
};
let tuner_20 = PssTuner {
target_mode_freq_hz: 2.0,
..default_tuner()
};
let result_05 = tuner_05.tune(&model).expect("PSS tuning at 0.5 Hz");
let result_20 = tuner_20.tune(&model).expect("PSS tuning at 2.0 Hz");
assert!(
(result_05.parameters.t1 - result_20.parameters.t1).abs() > 0.001,
"Different target frequencies should yield different T1 time constants"
);
}
#[test]
fn test_gain_margin_is_positive() {
let model = smib_model();
let tuner = default_tuner();
let result = tuner
.tune(&model)
.expect("PSS tuning for gain margin check");
assert!(
result.gain_margin_db > 0.0,
"Gain margin must be positive (got {} dB)",
result.gain_margin_db
);
}
#[test]
fn test_t3_t4_equal_t1_t2() {
let model = smib_model();
let tuner = default_tuner();
let result = tuner.tune(&model).expect("PSS tuning for T3/T4 check");
assert!(
(result.parameters.t3 - result.parameters.t1).abs() < 1e-9,
"T3 must equal T1 (diff = {})",
(result.parameters.t3 - result.parameters.t1).abs()
);
assert!(
(result.parameters.t4 - result.parameters.t2).abs() < 1e-9,
"T4 must equal T2 (diff = {})",
(result.parameters.t4 - result.parameters.t2).abs()
);
}
#[test]
fn test_stricter_damping_target_yields_higher_gain() {
let model = smib_model();
let tuner_loose = PssTuner {
target_damping_ratio: 0.02,
target_mode_freq_hz: 1.0,
gain_range: (0.1, 30.0),
gain_steps: 100,
..default_tuner()
};
let tuner_strict = PssTuner {
target_damping_ratio: 0.10,
target_mode_freq_hz: 1.0,
gain_range: (0.1, 30.0),
gain_steps: 100,
..default_tuner()
};
let result_loose = tuner_loose
.tune(&model)
.expect("PSS tuning with loose damping");
let result_strict = tuner_strict
.tune(&model)
.expect("PSS tuning with strict damping");
assert!(
result_strict.parameters.k_s >= result_loose.parameters.k_s,
"Stricter damping target should require >= gain (strict={}, loose={})",
result_strict.parameters.k_s,
result_loose.parameters.k_s
);
}
#[test]
fn test_lead_lag_zero_phase_yields_equal_t1_t2() {
let omega = 2.0 * std::f64::consts::PI * 1.0;
let (t1, t2, _t3, _t4) = design_lead_lag(0.0, omega);
assert!(
(t1 - t2).abs() < 1e-6,
"Zero phase lead should yield T1 == T2 (diff = {})",
(t1 - t2).abs()
);
}
#[test]
fn test_lead_lag_max_phase_yields_large_alpha() {
let omega = 2.0 * std::f64::consts::PI * 1.0;
let (t1, t2, _t3, _t4) = design_lead_lag(std::f64::consts::PI / 2.0 - 1e-3, omega);
let alpha = t1 / t2.max(1e-12);
assert!(
alpha > 10.0,
"Near-90-degree phase lead should yield large alpha T1/T2 (got {})",
alpha
);
}
#[test]
fn test_iterations_positive_after_tuning() {
let model = smib_model();
let tuner = default_tuner();
let result = tuner.tune(&model).expect("PSS tuning for iterations check");
assert!(
result.iterations > 0,
"Tuning must perform at least one iteration (got {})",
result.iterations
);
}
#[test]
fn test_hard_damping_target_uses_min_gain_bound() {
let model = smib_model();
let tuner = PssTuner {
target_damping_ratio: 0.5,
gain_range: (2.0, 50.0),
gain_steps: 50,
..default_tuner()
};
let result = tuner
.tune(&model)
.expect("PSS tuning with hard damping target (best-effort)");
assert!(
result.parameters.k_s >= 2.0,
"Gain must not fall below the lower bound of gain_range (got {})",
result.parameters.k_s
);
}
}