use num_complex::Complex;
use gridoxide::dynamics::models::InitError;
use gridoxide::dynamics::models::avr::{Sexs, SexsParams};
use gridoxide::dynamics::models::gov::{Tgov1, Tgov1Params};
use gridoxide::dynamics::models::load::ZipLoad;
use gridoxide::dynamics::models::machine::{
GenRound, GenRoundParams, GenSalient, GenSalientParams, GenTransient, GenTransientParams,
Machine,
};
use gridoxide::dynamics::models::pss::{Stab1, Stab1Params};
use gridoxide::dynamics::models::{
Control, DynamicModel, GeneratingUnit, Limits, ModelJacobian, finite_difference,
};
use gridoxide::dynamics::{
DeviceSpec, DynamicSystem, DynamicsOptions, DynamicsStatus, Event, EventKind, SystemSpec,
build, run_dynamics,
};
use gridoxide::network::{build_ybus, power_injections};
use gridoxide::types::{Bus, BusType, Line};
const S_BASE: f64 = 100.0;
const F_NOM: f64 = 50.0;
fn bus(idx: usize, bus_type: BusType, vmag: f64, p: f64, q: f64) -> Bus {
Bus {
idx,
bus_type,
voltage_mag: vmag,
voltage_ang: 0.0,
p_spec: p,
q_spec: q,
q_min: f64::NEG_INFINITY,
q_max: f64::INFINITY,
u_rated: 1.0,
zip_terms: Vec::new(),
}
}
fn machine_params(d: f64) -> GenTransientParams {
GenTransientParams {
h: 5.0,
d,
ra: 0.003,
xd: 1.8,
xq: 1.7,
xdp: 0.30,
xqp: 0.55,
td0p: 8.0,
tq0p: 0.4,
mbase: S_BASE,
}
}
fn new_machine(d: f64) -> GenTransient {
GenTransient::new(machine_params(d), S_BASE, F_NOM).expect("valid machine parameters")
}
fn new_avr() -> Sexs {
Sexs::new(SexsParams {
k: 200.0,
ta: 0.1,
tb: 1.0,
te: 0.05,
limits: Limits::NONE,
})
.unwrap()
}
fn new_gov(r: f64) -> Tgov1 {
Tgov1::new(Tgov1Params {
r,
t1: 0.5,
t2: 1.0,
t3: 5.0,
dt: 0.0,
limits: Limits::NONE,
rate: Limits::NONE,
})
.unwrap()
}
fn new_pss() -> Stab1 {
Stab1::new(Stab1Params {
k: 5.0,
tw: 10.0,
t1: 0.15,
t2: 0.03,
t3: 0.15,
t4: 0.03,
limits: Limits::NONE,
})
.unwrap()
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Fit {
avr: bool,
gov: bool,
pss: bool,
}
impl Fit {
fn label(self) -> String {
let mut parts = vec!["machine"];
if self.avr {
parts.push("avr");
}
if self.gov {
parts.push("gov");
}
if self.pss {
parts.push("pss");
}
parts.join("+")
}
}
const FITS: [Fit; 8] = [
Fit {
avr: false,
gov: false,
pss: false,
},
Fit {
avr: true,
gov: false,
pss: false,
},
Fit {
avr: false,
gov: true,
pss: false,
},
Fit {
avr: false,
gov: false,
pss: true,
},
Fit {
avr: true,
gov: true,
pss: false,
},
Fit {
avr: true,
gov: false,
pss: true,
},
Fit {
avr: false,
gov: true,
pss: true,
},
Fit {
avr: true,
gov: true,
pss: true,
},
];
fn unit(fit: Fit, damping: f64, droop: f64) -> GeneratingUnit {
GeneratingUnit::new(
Box::new(new_machine(damping)),
fit.avr.then(|| Box::new(new_avr()) as Box<dyn Control>),
fit.gov
.then(|| Box::new(new_gov(droop)) as Box<dyn Control>),
fit.pss.then(|| Box::new(new_pss()) as Box<dyn Control>),
)
}
#[test]
fn every_combination_matches_the_finite_difference_oracle() {
for fit in FITS {
let mut model = unit(fit, 2.0, 0.05);
model
.initialize(Complex::from_polar(1.02, 0.15), Complex::new(0.8, 0.3))
.unwrap_or_else(|e| panic!("{}: {e}", fit.label()));
let n = model.n_states();
let probes: [(Vec<f64>, Complex<f64>); 3] = [
(
(0..n).map(|k| 0.4 + 0.13 * k as f64).collect(),
Complex::new(1.00, 0.05),
),
(
(0..n).map(|k| -0.3 + 0.21 * k as f64).collect(),
Complex::new(0.87, -0.24),
),
(
(0..n).map(|k| 1.1 - 0.17 * k as f64).collect(),
Complex::new(1.06, 0.31),
),
];
for (x, v) in probes {
let mut analytic = ModelJacobian::zeros(n);
analytic.clear();
model.jacobian(&x, v, &mut analytic);
let numeric = finite_difference(&model, &x, v, 1e-6);
let compare = |name: &str, a: &[f64], b: &[f64]| {
for (k, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
let scale = av.abs().max(bv.abs()).max(1.0);
assert!(
(av - bv).abs() / scale < 1e-6,
"{} {name}[{k}]: analytic {av:e} vs numeric {bv:e} (x = {x:?}, v = {v})",
fit.label()
);
}
};
compare("dfdx", &analytic.dfdx, &numeric.dfdx);
compare("dfdv", &analytic.dfdv, &numeric.dfdv);
compare("didx", &analytic.didx, &numeric.didx);
compare("didv", &analytic.didv, &numeric.didv);
}
}
}
#[test]
fn initialization_reproduces_the_terminal_current_and_power() {
for (v, s) in [
(Complex::from_polar(1.0, 0.0), Complex::new(0.8, 0.3)),
(Complex::from_polar(1.05, 0.2), Complex::new(0.5, -0.15)),
(Complex::from_polar(0.95, -0.1), Complex::new(1.0, 0.6)),
] {
let mut m = new_machine(0.0);
let init = m.initialize(v, s).unwrap();
let i_model = m.injection(&init.states, v) - m.norton_admittance() * v;
let i_expected = (s / v).conj();
assert!(
(i_model - i_expected).norm() < 1e-12,
"terminal current {i_model} should be {i_expected} at v = {v}, s = {s}"
);
let copper = i_expected.norm_sqr() * machine_params(0.0).ra;
assert!(
(init.p_m - (s.re + copper)).abs() < 1e-12,
"air-gap power {} should be terminal {} plus copper loss {copper:e}",
init.p_m,
s.re
);
}
}
fn islanded(
fits: &[(Fit, f64, f64)],
gen_p: &[f64],
load: (f64, f64),
) -> (DynamicSystem, Vec<String>) {
let models: Vec<Box<dyn DynamicModel>> = fits
.iter()
.map(|(fit, d, r)| Box::new(unit(*fit, *d, *r)) as Box<dyn DynamicModel>)
.collect();
islanded_with(models, gen_p, load)
}
fn islanded_with(
models: Vec<Box<dyn DynamicModel>>,
gen_p: &[f64],
load: (f64, f64),
) -> (DynamicSystem, Vec<String>) {
let n_gen = models.len();
let load_bus = n_gen;
let mut buses = Vec::new();
for (i, p) in gen_p.iter().enumerate() {
let kind = if i == 0 { BusType::Slack } else { BusType::PV };
buses.push(bus(i, kind, 1.0, *p, 0.0));
}
buses.push(bus(load_bus, BusType::PQ, 1.0, load.0, load.1));
let mut lines = Vec::new();
for i in 0..n_gen {
lines.push(Line {
from: i,
to: load_bus,
r: 0.005,
x: 0.05,
b_shunt: 0.0,
g_shunt: 0.0,
});
}
let report = gridoxide::run_power_flow_analysis(gridoxide::json::NetworkData {
buses,
lines: lines.clone(),
});
assert_eq!(
report.stats.status,
gridoxide::solver::SolveStatus::Converged
);
let buses = report.buses;
let ybus = build_ybus(buses.len(), &lines, &[]).finish();
let (p_calc, q_calc) = power_injections(&buses, &ybus);
let mut devices = Vec::new();
let mut ids = Vec::new();
for (i, model) in models.into_iter().enumerate() {
let id = format!("G{}", i + 1);
ids.push(id.clone());
devices.push(DeviceSpec {
id,
bus: i,
s: Complex::new(p_calc[i], q_calc[i]),
model,
});
}
let system = build(SystemSpec {
buses: &buses,
lines: &lines,
transformers: &[],
shunts: &[],
devices,
fixed_buses: Vec::new(),
})
.expect("the solved case initializes to an equilibrium");
(system, ids)
}
fn options(step: f64, end: f64, events: Vec<Event>) -> DynamicsOptions {
DynamicsOptions {
end_time: end,
step,
events,
..Default::default()
}
}
#[test]
fn every_combination_is_an_equilibrium() {
for fit in FITS {
let (mut system, _) = islanded(&[(fit, 2.0, 0.05)], &[0.8], (-0.8, -0.2));
assert!(
system.max_derivative() < 1e-11,
"{}: initial derivative {:e}",
fit.label(),
system.max_derivative()
);
assert!(
system.network_residual_norm() < 1e-11,
"{}: initial residual {:e}",
fit.label(),
system.network_residual_norm()
);
let report = run_dynamics(&mut system, &options(0.01, 20.0, Vec::new()));
assert_eq!(report.status, DynamicsStatus::Completed, "{}", fit.label());
for name in report.trajectory.names.clone() {
let series = report.trajectory.series(&name).unwrap();
if name.ends_with(".delta") || name.ends_with(".vang") {
continue;
}
let drift = series
.iter()
.fold(0.0f64, |m, s| m.max((s - series[0]).abs()));
assert!(
drift < 1e-9,
"{}: {name} drifted by {drift:e} with no disturbance",
fit.label()
);
}
}
}
#[test]
fn an_exciter_returns_the_terminal_voltage() {
let recovery = |avr: bool| {
let fit = Fit {
avr,
gov: false,
pss: false,
};
let (mut system, _) = islanded(&[(fit, 2.0, 0.05)], &[0.8], (-0.8, -0.2));
let opts = options(
0.005,
30.0,
vec![Event::new(
1.0,
EventKind::LoadStep {
bus: 1,
ds: Complex::new(-0.25, -0.1),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let v = report.trajectory.series("bus0.vmag").unwrap();
(v[0], *v.last().unwrap())
};
let (v0_open, v_open) = recovery(false);
let (v0_avr, v_avr) = recovery(true);
let sag_open = (v0_open - v_open).abs();
let sag_avr = (v0_avr - v_avr).abs();
assert!(
sag_open > 0.01,
"the load step should move the voltage without an exciter; it moved {sag_open:e}"
);
assert!(
sag_avr < 0.25 * sag_open,
"an exciter should recover most of the sag: {sag_avr:.5} left of {sag_open:.5}"
);
}
#[test]
fn droop_shares_a_disturbance_in_inverse_proportion() {
let fit = Fit {
avr: true,
gov: true,
pss: false,
};
let (r_a, r_b) = (0.05, 0.10);
let (mut system, _) = islanded(
&[(fit, 2.0, r_a), (fit, 2.0, r_b)],
&[0.8, 0.8],
(-1.6, -0.4),
);
let opts = options(
0.01,
120.0,
vec![Event::new(
1.0,
EventKind::LoadStep {
bus: 2,
ds: Complex::new(-0.2, 0.0),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let settled = |name: &str| {
let s = report.trajectory.series(name).unwrap();
(s[0], *s.last().unwrap())
};
let (wa0, wa) = settled("G1.omega");
let (wb0, wb) = settled("G2.omega");
let (pa0, pa) = settled("G1.gov_turbine");
let (pb0, pb) = settled("G2.gov_turbine");
assert!(
(wa - wb).abs() < 1e-7,
"two synchronized machines must settle together: {wa:.9} and {wb:.9}"
);
assert!(wa < wa0 && wb < wb0, "more load should lower the frequency");
let (d_omega, dpa, dpb) = (wa - wa0, pa - pa0, pb - pb0);
for (label, dp, r) in [("G1", dpa, r_a), ("G2", dpb, r_b)] {
let predicted = -d_omega / r;
assert!(
(dp - predicted).abs() < 0.02 * predicted.abs().max(1e-3),
"{label} should pick up −Δω/R = {predicted:.6}, picked up {dp:.6}"
);
}
let ratio = dpa / dpb;
assert!(
(ratio - r_b / r_a).abs() < 0.02,
"the machine with half the droop should pick up twice as much; ratio was {ratio:.4}"
);
}
#[test]
fn a_stabilizer_leaves_no_steady_signal() {
let fit = Fit {
avr: true,
gov: false,
pss: true,
};
let (mut system, _) = islanded(&[(fit, 2.0, 0.05)], &[0.8], (-0.8, -0.2));
let opts = options(
0.02,
400.0,
vec![Event::new(
1.0,
EventKind::LoadStep {
bus: 1,
ds: Complex::new(-0.2, 0.0),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let washout = report.trajectory.series("G1.pss_washout").unwrap();
let lead1 = report.trajectory.series("G1.pss_lead1").unwrap();
let lead2 = report.trajectory.series("G1.pss_lead2").unwrap();
let omega = report.trajectory.series("G1.omega").unwrap();
let peak = lead2.iter().fold(0.0f64, |m, s| m.max(s.abs()));
assert!(
peak > 1e-6,
"the stabilizer should respond to the disturbance at all, peaked {peak:e}"
);
let d_omega = omega.last().unwrap() - 1.0;
assert!(
(d_omega - -0.1).abs() < 5e-3,
"with no governor the frequency should settle at (P_m − P_e)/D = −0.1, \
ended at Δω = {d_omega:e}"
);
let t = &report.trajectory.time;
let last = omega.len() - 1;
let window = last - last / 20;
let rate = (omega[last] - omega[window]) / (t[last] - t[window]);
let expected_lag = 10.0 * rate.abs(); let gap = (washout[last] - d_omega).abs();
assert!(
gap < 2.0 * expected_lag + 1e-7,
"the washout should trail the input by about T_w·dΔω/dt = {expected_lag:e}; \
it trailed by {gap:e}"
);
for (name, series) in [("lead1", &lead1), ("lead2", &lead2)] {
let stage_peak = series.iter().fold(0.0f64, |m, s| m.max(s.abs()));
let end = series.last().unwrap().abs();
assert!(
end < 0.02 * stage_peak,
"pss_{name} should decay toward zero: ended at {end:e} against a peak of {stage_peak:e}"
);
}
let y1 = d_omega - washout[last];
let predicted = 5.0 * (1.0 - 0.15 / 0.03) * y1;
assert!(
(lead1[last] - predicted).abs() < 0.05 * predicted.abs() + 1e-9,
"the first lead-lag's residual should be K(1 − T₁/T₂)·y₁ = {predicted:e}, \
it was {:e}",
lead1[last]
);
}
#[test]
fn a_zip_load_follows_its_own_exponents() {
let v0 = Complex::new(1.0, 0.0);
let s0 = Complex::new(-0.8, -0.3);
let cutoff = 0.5;
let ratio = |z: f64, i: f64, p: f64, v_mag: f64| {
let mut load = ZipLoad::new(z, i, p, cutoff).unwrap();
load.initialize(v0, s0).unwrap();
let v = Complex::from_polar(v_mag, 0.35);
let s = v * load.injection(&[], v).conj();
s.norm() / s0.norm()
};
for v_mag in [0.7, 0.9, 1.1] {
let (z, i, p) = (
ratio(1.0, 0.0, 0.0, v_mag),
ratio(0.0, 1.0, 0.0, v_mag),
ratio(0.0, 0.0, 1.0, v_mag),
);
assert!(
(z - v_mag * v_mag).abs() < 1e-12,
"constant impedance at |V| = {v_mag}: {z}"
);
assert!(
(i - v_mag).abs() < 1e-12,
"constant current at |V| = {v_mag}: {i}"
);
assert!(
(p - 1.0).abs() < 1e-12,
"constant power at |V| = {v_mag}: {p}"
);
}
for v_mag in [0.1, 0.3, 0.49] {
let scale = (v_mag / cutoff).powi(2);
for (z, i, p, at_cutoff) in [
(1.0, 0.0, 0.0, cutoff * cutoff),
(0.0, 1.0, 0.0, cutoff),
(0.0, 0.0, 1.0, 1.0),
] {
let got = ratio(z, i, p, v_mag);
assert!(
(got - at_cutoff * scale).abs() < 1e-12,
"below the cutoff, ({z},{i},{p}) at |V| = {v_mag} should be \
{} but was {got}",
at_cutoff * scale
);
}
}
let mut load = ZipLoad::constant_power(cutoff).unwrap();
load.initialize(v0, s0).unwrap();
let eps = 1e-9;
let below = load.injection(&[], Complex::from_polar(cutoff - eps, 0.2));
let above = load.injection(&[], Complex::from_polar(cutoff + eps, 0.2));
assert!(
(below - above).norm() < 1e-7,
"the current should be continuous at the cutoff"
);
}
#[test]
fn a_zip_load_matches_the_finite_difference_oracle() {
let mut load = ZipLoad::new(0.3, 0.3, 0.4, 0.5).unwrap();
load.initialize(Complex::new(1.0, 0.0), Complex::new(-0.8, -0.3))
.unwrap();
for v in [
Complex::new(1.00, 0.00),
Complex::new(0.87, -0.24),
Complex::new(0.62, 0.10),
Complex::new(0.20, 0.05),
Complex::new(0.10, -0.03),
] {
let mut analytic = ModelJacobian::zeros(0);
analytic.clear();
load.jacobian(&[], v, &mut analytic);
let numeric = finite_difference(&load, &[], v, 1e-7);
for k in 0..4 {
let (a, b) = (analytic.didv[k], numeric.didv[k]);
let scale = a.abs().max(b.abs()).max(1.0);
assert!(
(a - b).abs() / scale < 1e-6,
"didv[{k}] at v = {v}: analytic {a:e} vs numeric {b:e}"
);
}
}
}
#[test]
fn a_constant_power_load_deepens_a_sag() {
let sag = |constant_power: bool| {
let fit = Fit {
avr: false,
gov: false,
pss: false,
};
let buses = vec![
bus(0, BusType::Slack, 1.0, 0.8, 0.0),
bus(1, BusType::PQ, 1.0, -0.8, -0.3),
];
let lines = vec![Line {
from: 0,
to: 1,
r: 0.01,
x: 0.10,
b_shunt: 0.0,
g_shunt: 0.0,
}];
let report = gridoxide::run_power_flow_analysis(gridoxide::json::NetworkData {
buses,
lines: lines.clone(),
});
let buses = report.buses;
let ybus = build_ybus(buses.len(), &lines, &[]).finish();
let (p_calc, q_calc) = power_injections(&buses, &ybus);
let mut devices = vec![DeviceSpec {
id: "G1".to_string(),
bus: 0,
s: Complex::new(p_calc[0], q_calc[0]),
model: Box::new(unit(fit, 2.0, 0.05)),
}];
if constant_power {
devices.push(DeviceSpec {
id: "L1".to_string(),
bus: 1,
s: Complex::new(p_calc[1], q_calc[1]),
model: Box::new(ZipLoad::constant_power(0.5).unwrap()),
});
}
let mut system = build(SystemSpec {
buses: &buses,
lines: &lines,
transformers: &[],
shunts: &[],
devices,
fixed_buses: Vec::new(),
})
.expect("initializes to an equilibrium either way");
assert!(system.max_derivative() < 1e-11);
assert!(system.network_residual_norm() < 1e-11);
let opts = options(
0.005,
10.0,
vec![Event::new(
1.0,
EventKind::BusFault {
bus: 1,
y: Complex::new(1.5, 0.0),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let v = report.trajectory.series("bus1.vmag").unwrap();
v[0] - v.iter().cloned().fold(f64::INFINITY, f64::min)
};
let impedance = sag(false);
let power = sag(true);
assert!(
power > impedance,
"a constant-power load should sag further than a constant-impedance one: \
{power:.5} against {impedance:.5}"
);
}
fn round_params(xdpp: f64, xqpp: f64) -> GenRoundParams {
GenRoundParams {
h: 5.0,
d: 2.0,
ra: 0.003,
xd: 1.8,
xq: 1.7,
xdp: 0.30,
xqp: 0.55,
xdpp,
xqpp,
xl: 0.15,
td0p: 8.0,
tq0p: 0.4,
td0pp: 0.03,
tq0pp: 0.05,
mbase: S_BASE,
}
}
#[test]
fn a_non_salient_machine_cancels_its_own_norton_stamp() {
let v = Complex::from_polar(1.02, 0.17);
let s = Complex::new(0.7, 0.25);
let mut transient = GenTransient::new(
GenTransientParams {
xqp: 0.30,
..machine_params(0.0)
},
S_BASE,
F_NOM,
)
.unwrap();
let init = transient.initialize(v, s).unwrap();
let mut jac = gridoxide::dynamics::models::MachineJacobian::zeros(4);
jac.clear();
transient.jacobian(&init.states, v, init.e_fd, init.p_m, &mut jac);
for (k, value) in jac.didv.iter().enumerate() {
assert!(
value.abs() < 1e-12,
"transient didv[{k}] = {value:e}, should be exactly zero"
);
}
let mut round = GenRound::new(round_params(0.22, 0.22), S_BASE, F_NOM).unwrap();
let init = round.initialize(v, s).unwrap();
let mut jac = gridoxide::dynamics::models::MachineJacobian::zeros(6);
jac.clear();
round.jacobian(&init.states, v, init.e_fd, init.p_m, &mut jac);
for (k, value) in jac.didv.iter().enumerate() {
assert!(
value.abs() < 1e-12,
"round didv[{k}] = {value:e}, should be exactly zero"
);
}
}
#[test]
fn the_subtransient_machine_is_self_consistent() {
for (v, s) in [
(Complex::from_polar(1.0, 0.0), Complex::new(0.8, 0.3)),
(Complex::from_polar(1.05, 0.2), Complex::new(0.5, -0.15)),
(Complex::from_polar(0.95, -0.1), Complex::new(1.0, 0.6)),
] {
let mut m = GenRound::new(round_params(0.22, 0.25), S_BASE, F_NOM).unwrap();
let init = m.initialize(v, s).unwrap();
let i_model = m.injection(&init.states, v) - m.norton_admittance() * v;
let i_expected = (s / v).conj();
assert!(
(i_model - i_expected).norm() < 1e-12,
"terminal current {i_model} should be {i_expected}"
);
let copper = i_expected.norm_sqr() * round_params(0.22, 0.25).ra;
assert!((init.p_m - (s.re + copper)).abs() < 1e-12);
}
let mut model = GeneratingUnit::new(
Box::new(GenRound::new(round_params(0.22, 0.25), S_BASE, F_NOM).unwrap()),
Some(Box::new(new_avr())),
Some(Box::new(new_gov(0.05))),
Some(Box::new(new_pss())),
);
model
.initialize(Complex::from_polar(1.02, 0.15), Complex::new(0.8, 0.3))
.unwrap();
let n = model.n_states();
for scale in [0.13, -0.21, 0.17] {
let x: Vec<f64> = (0..n).map(|k| 0.4 + scale * k as f64).collect();
let v = Complex::new(0.97, 0.11);
let mut analytic = ModelJacobian::zeros(n);
analytic.clear();
model.jacobian(&x, v, &mut analytic);
let numeric = finite_difference(&model, &x, v, 1e-6);
for (name, a, b) in [
("dfdx", &analytic.dfdx, &numeric.dfdx),
("dfdv", &analytic.dfdv, &numeric.dfdv),
("didx", &analytic.didx, &numeric.didx),
] {
for (k, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
let sc = av.abs().max(bv.abs()).max(1.0);
assert!(
(av - bv).abs() / sc < 1e-6,
"{name}[{k}]: analytic {av:e} vs numeric {bv:e}"
);
}
}
}
}
#[test]
fn the_subtransient_machine_reduces_to_the_transient_one() {
let trajectory = |sixth: bool| {
let model: Box<dyn DynamicModel> = if sixth {
Box::new(GeneratingUnit::machine_only(Box::new(
GenRound::new(round_params(0.30 - 1e-7, 0.55 - 1e-7), S_BASE, F_NOM).unwrap(),
)))
} else {
Box::new(GeneratingUnit::machine_only(Box::new(new_machine(2.0))))
};
let (mut system, _) = islanded_with(vec![model], &[0.8], (-0.8, -0.2));
let opts = options(
0.002,
8.0,
vec![
Event::bolted_fault(1.0, 1),
Event::new(1.08, EventKind::ClearFault { bus: 1 }),
],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
(
report.trajectory.series("G1.delta").unwrap(),
report.trajectory.series("G1.omega").unwrap(),
)
};
let (d4, w4) = trajectory(false);
let (d6, w6) = trajectory(true);
assert_eq!(d4.len(), d6.len());
let worst_d = d4
.iter()
.zip(d6.iter())
.fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
let worst_w = w4
.iter()
.zip(w6.iter())
.fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
assert!(
worst_d < 1e-5,
"with the dampers made ineffective the two orders should agree; \
the rotor angles differ by {worst_d:e} rad"
);
assert!(worst_w < 1e-7, "the speeds differ by {worst_w:e} pu");
}
#[test]
fn dampers_stiffen_the_instantaneous_response() {
let fault_current = |sixth: bool| {
let model: Box<dyn DynamicModel> = if sixth {
Box::new(GeneratingUnit::machine_only(Box::new(
GenRound::new(round_params(0.20, 0.22), S_BASE, F_NOM).unwrap(),
)))
} else {
Box::new(GeneratingUnit::machine_only(Box::new(new_machine(2.0))))
};
let (mut system, _) = islanded_with(vec![model], &[0.8], (-0.8, -0.2));
let opts = options(
0.002,
1.2,
vec![Event::new(
1.0,
EventKind::BusFault {
bus: 1,
y: Complex::new(4.0, 0.0),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let at: Vec<usize> = report
.trajectory
.time
.iter()
.enumerate()
.filter(|(_, t)| (**t - 1.0).abs() < 1e-12)
.map(|(k, _)| k)
.collect();
assert_eq!(at.len(), 2);
let col = report.trajectory.column("bus0.vmag").unwrap();
let (before, after) = (
report.trajectory.rows[at[0]][col],
report.trajectory.rows[at[1]][col],
);
(before, after)
};
let (v4_before, v4_after) = fault_current(false);
let (v6_before, v6_after) = fault_current(true);
assert!(
(v4_before - v6_before).abs() < 1e-6,
"the two should start from the same point"
);
assert!(
v6_after > v4_after,
"a stiffer machine should hold its terminal voltage up better through the \
first instant: {v6_after:.6} against {v4_after:.6}"
);
}
#[test]
fn a_tripped_unit_freezes_and_the_rest_picks_up_the_load() {
let fit = Fit {
avr: true,
gov: true,
pss: false,
};
let (mut system, _) = islanded(
&[(fit, 2.0, 0.05), (fit, 2.0, 0.05)],
&[0.8, 0.8],
(-1.6, -0.4),
);
let opts = options(
0.005,
40.0,
vec![Event::new(1.0, EventKind::UnitTrip { unit: 1 })],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
assert_eq!(report.events_applied, 1);
let after_trip: Vec<usize> = report
.trajectory
.time
.iter()
.enumerate()
.filter(|(_, t)| **t >= 1.0)
.map(|(k, _)| k)
.collect();
for name in report.trajectory.names.clone() {
if !name.starts_with("G2.") {
continue;
}
let col = report.trajectory.column(&name).unwrap();
let frozen = report.trajectory.rows[after_trip[1]][col];
for &k in &after_trip[1..] {
assert_eq!(
report.trajectory.rows[k][col].to_bits(),
frozen.to_bits(),
"{name} should be frozen after the trip"
);
}
}
let w = report.trajectory.series("G1.omega").unwrap();
let p = report.trajectory.series("G1.gov_turbine").unwrap();
assert!(
*w.last().unwrap() < w[0] - 1e-3,
"losing half the generation should depress the frequency, Δω = {:e}",
w.last().unwrap() - w[0]
);
assert!(
*p.last().unwrap() > p[0] + 0.1,
"the survivor should pick up load: {:.4} to {:.4}",
p[0],
p.last().unwrap()
);
let predicted = -(w.last().unwrap() - w[0]) / 0.05;
let actual = p.last().unwrap() - p[0];
assert!(
(actual - predicted).abs() < 0.03 * predicted.abs(),
"the survivor should pick up −Δω/R = {predicted:.5}, picked up {actual:.5}"
);
}
#[test]
fn the_full_form_is_as_well_founded_as_the_approximate_one() {
let machines: Vec<Box<dyn Machine>> = vec![
Box::new(new_machine(2.0).with_speed_voltages(true)),
Box::new(
GenRound::new(round_params(0.22, 0.25), S_BASE, F_NOM)
.unwrap()
.with_speed_voltages(true),
),
];
for machine in machines {
let mut model = GeneratingUnit::new(
machine,
Some(Box::new(new_avr())),
Some(Box::new(new_gov(0.05))),
None,
);
model
.initialize(Complex::from_polar(1.02, 0.15), Complex::new(0.8, 0.3))
.unwrap();
let n = model.n_states();
for scale in [0.13, -0.19] {
let mut x: Vec<f64> = (0..n).map(|k| 0.4 + scale * k as f64).collect();
x[1] = 1.03;
let v = Complex::new(0.97, 0.11);
let mut analytic = ModelJacobian::zeros(n);
analytic.clear();
model.jacobian(&x, v, &mut analytic);
let numeric = finite_difference(&model, &x, v, 1e-6);
for (name, a, b) in [
("dfdx", &analytic.dfdx, &numeric.dfdx),
("dfdv", &analytic.dfdv, &numeric.dfdv),
("didx", &analytic.didx, &numeric.didx),
("didv", &analytic.didv.to_vec(), &numeric.didv.to_vec()),
] {
for (k, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
let sc = av.abs().max(bv.abs()).max(1.0);
assert!(
(av - bv).abs() / sc < 1e-6,
"{name}[{k}]: analytic {av:e} vs numeric {bv:e}"
);
}
}
}
}
let model: Box<dyn DynamicModel> = Box::new(GeneratingUnit::new(
Box::new(
GenRound::new(round_params(0.22, 0.25), S_BASE, F_NOM)
.unwrap()
.with_speed_voltages(true),
),
Some(Box::new(new_avr())),
Some(Box::new(new_gov(0.05))),
None,
));
let (mut system, _) = islanded_with(vec![model], &[0.8], (-0.8, -0.2));
assert!(
system.max_derivative() < 1e-11,
"the full form must initialize to an equilibrium too, drift {:e}",
system.max_derivative()
);
let report = run_dynamics(&mut system, &options(0.01, 20.0, Vec::new()));
assert_eq!(report.status, DynamicsStatus::Completed);
let omega = report.trajectory.series("G1.omega").unwrap();
let drift = omega
.iter()
.fold(0.0f64, |m, w| m.max((w - omega[0]).abs()));
assert!(drift < 1e-9, "undisturbed speed drifted by {drift:e}");
}
#[test]
fn the_two_forms_agree_at_synchronous_speed_and_part_in_proportion_to_deviation() {
let run = |speed_voltages: bool, events: Vec<Event>| {
let buses = vec![
bus(0, BusType::PV, 1.0, 0.8, 0.0),
bus(1, BusType::Slack, 1.0, 0.0, 0.0),
];
let lines = vec![Line {
from: 0,
to: 1,
r: 0.0,
x: 0.20,
b_shunt: 0.0,
g_shunt: 0.0,
}];
let report = gridoxide::run_power_flow_analysis(gridoxide::json::NetworkData {
buses,
lines: lines.clone(),
});
let buses = report.buses;
let ybus = build_ybus(buses.len(), &lines, &[]).finish();
let (p_calc, q_calc) = power_injections(&buses, &ybus);
let machine = GenRound::new(round_params(0.22, 0.25), S_BASE, F_NOM)
.unwrap()
.with_speed_voltages(speed_voltages);
let mut system = build(SystemSpec {
buses: &buses,
lines: &lines,
transformers: &[],
shunts: &[],
devices: vec![DeviceSpec {
id: "G1".to_string(),
bus: 0,
s: Complex::new(p_calc[0], q_calc[0]),
model: Box::new(GeneratingUnit::machine_only(Box::new(machine))),
}],
fixed_buses: vec![1],
})
.expect("both forms initialize to the same equilibrium");
let report = run_dynamics(&mut system, &options(0.002, 6.0, events));
assert_eq!(report.status, DynamicsStatus::Completed);
(
report.trajectory.series("G1.delta").unwrap(),
report.trajectory.series("G1.omega").unwrap(),
)
};
let (still_approx, _) = run(false, Vec::new());
let (still_full, _) = run(true, Vec::new());
for (a, b) in still_approx.iter().zip(still_full.iter()) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"at synchronous speed the two forms must be the same equations"
);
}
let fault = vec![
Event::bolted_fault(1.0, 0),
Event::new(1.06, EventKind::ClearFault { bus: 0 }),
];
let (d_approx, w_approx) = run(false, fault.clone());
let (d_full, _) = run(true, fault);
let deviation = w_approx.iter().fold(0.0f64, |m, w| m.max((w - 1.0).abs()));
let gap = d_approx
.iter()
.zip(d_full.iter())
.fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
let swing = d_approx.iter().fold(0.0f64, |m, d| m.max(*d))
- d_approx.iter().fold(f64::MAX, |m, d| m.min(*d));
assert!(
deviation > 1e-3,
"the case must actually leave synchronous speed"
);
assert!(
swing < std::f64::consts::TAU,
"the machine must stay in step, swing {swing:e}"
);
assert!(
gap > 1e-5,
"the flag must reach the equations, but the gap was {gap:e}"
);
assert!(
gap < 5.0 * deviation * swing,
"the two forms should differ by the order of the speed deviation: gap {gap:e} \
against a deviation of {deviation:e} on a swing of {swing:e}"
);
}
fn limited_avr(limits: Limits) -> Sexs {
Sexs::new(SexsParams {
k: 200.0,
ta: 0.1,
tb: 1.0,
te: 0.05,
limits,
})
.unwrap()
}
#[test]
fn an_unreachable_equilibrium_is_refused() {
let mut avr = limited_avr(Limits::new(-1.0, 1.0));
let err = avr
.initialize(2.3, -1.0)
.expect_err("a 1.0 ceiling cannot hold 2.3");
assert!(
matches!(err, InitError::OutsideLimits { name, value } if name.contains("field") && value == 2.3),
"got {err}"
);
assert!(err.to_string().contains("outside its limits"), "{err}");
let mut limited = limited_avr(Limits::new(-6.0, 6.0));
let mut free = limited_avr(Limits::NONE);
assert_eq!(
limited.initialize(2.3, -1.0).unwrap(),
free.initialize(2.3, -1.0).unwrap()
);
}
#[test]
fn a_limit_is_non_windup() {
let ceiling = 2.6;
let fit = Fit {
avr: true,
gov: false,
pss: false,
};
let unit = GeneratingUnit::new(
Box::new(new_machine(2.0)),
Some(Box::new(limited_avr(Limits::new(-6.0, ceiling)))),
None,
None,
);
let (mut system, _) = islanded_with(vec![Box::new(unit)], &[0.8], (-0.8, -0.2));
let _ = fit;
let clear = 1.6;
let opts = options(
0.002,
30.0,
vec![
Event::new(
1.0,
EventKind::BusFault {
bus: 1,
y: Complex::new(3.0, 0.0),
},
),
Event::new(clear, EventKind::ClearFault { bus: 1 }),
],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let time = &report.trajectory.time;
let efd = report.trajectory.series("G1.efd").unwrap();
let peak = efd.iter().fold(0.0f64, |m, e| m.max(*e));
assert!(
peak > ceiling - 1e-6,
"the fault should drive the exciter to its ceiling; it reached {peak}"
);
assert!(
peak <= ceiling + 1e-12,
"a non-windup limit holds the state at the boundary; it reached {peak}"
);
let settled = *efd.last().unwrap();
assert!(
settled < ceiling - 1e-3,
"the field voltage should settle back below the ceiling, but ended at {settled}"
);
let _ = time;
}
#[test]
fn a_ceiling_holds_the_voltage_lower() {
let recovery = |limits: Limits| {
let unit = GeneratingUnit::new(
Box::new(new_machine(2.0)),
Some(Box::new(limited_avr(limits))),
None,
None,
);
let (mut system, _) = islanded_with(vec![Box::new(unit)], &[0.8], (-0.8, -0.2));
let opts = options(
0.005,
20.0,
vec![Event::new(
1.0,
EventKind::LoadStep {
bus: 1,
ds: Complex::new(-0.35, -0.15),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let v = report.trajectory.series("bus0.vmag").unwrap();
*v.last().unwrap()
};
let free = recovery(Limits::NONE);
let capped = recovery(Limits::new(-6.0, 2.35));
assert!(
capped < free - 1e-3,
"a capped exciter should leave the voltage lower: {capped:.5} against {free:.5}"
);
}
#[test]
fn a_limited_control_matches_the_oracle_on_both_sides() {
let mut model = GeneratingUnit::new(
Box::new(new_machine(2.0)),
Some(Box::new(limited_avr(Limits::new(-6.0, 2.6)))),
Some(Box::new(
Tgov1::new(Tgov1Params {
r: 0.05,
t1: 0.5,
t2: 1.0,
t3: 5.0,
dt: 0.0,
limits: Limits::new(0.0, 0.9),
rate: Limits::NONE,
})
.unwrap(),
)),
None,
);
model
.initialize(Complex::from_polar(1.02, 0.15), Complex::new(0.8, 0.3))
.unwrap();
let n = model.n_states();
for (efd, valve) in [(1.0, 0.5), (4.0, 1.6), (-9.0, -0.6)] {
let mut x: Vec<f64> = (0..n).map(|k| 0.3 + 0.11 * k as f64).collect();
x[1] = 1.004;
x[5] = efd;
x[6] = valve;
let v = Complex::new(0.98, 0.09);
let mut analytic = ModelJacobian::zeros(n);
analytic.clear();
model.jacobian(&x, v, &mut analytic);
let numeric = finite_difference(&model, &x, v, 1e-7);
for (name, a, b) in [
("dfdx", &analytic.dfdx, &numeric.dfdx),
("dfdv", &analytic.dfdv, &numeric.dfdv),
("didx", &analytic.didx, &numeric.didx),
] {
for (k, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
let sc = av.abs().max(bv.abs()).max(1.0);
assert!(
(av - bv).abs() / sc < 1e-6,
"{name}[{k}] at efd = {efd}, valve = {valve}: {av:e} vs {bv:e}"
);
}
}
}
}
fn salient_params() -> GenSalientParams {
GenSalientParams {
h: 5.0,
d: 1.0,
ra: 0.003,
xd: 1.8,
xq: 1.7,
xdp: 0.30,
xdpp: 0.22,
xqpp: 0.25,
xl: 0.15,
td0p: 8.0,
td0pp: 0.03,
tq0pp: 0.05,
mbase: S_BASE,
}
}
#[test]
fn the_salient_machine_is_self_consistent() {
for (v, s) in [
(Complex::from_polar(1.0, 0.0), Complex::new(0.8, 0.3)),
(Complex::from_polar(1.05, 0.2), Complex::new(0.5, -0.15)),
(Complex::from_polar(0.95, -0.1), Complex::new(1.0, 0.6)),
] {
let mut m = GenSalient::new(salient_params(), S_BASE, F_NOM).unwrap();
let init = m.initialize(v, s).unwrap();
assert_eq!(init.states.len(), 5, "δ, ω, e'_q, ψ_1d and one q-axis flux");
let i_model = m.injection(&init.states, v) - m.norton_admittance() * v;
let i_expected = (s / v).conj();
assert!(
(i_model - i_expected).norm() < 1e-12,
"terminal current {i_model} should be {i_expected}"
);
let copper = i_expected.norm_sqr() * salient_params().ra;
assert!((init.p_m - (s.re + copper)).abs() < 1e-12);
}
let mut model = GeneratingUnit::new(
Box::new(GenSalient::new(salient_params(), S_BASE, F_NOM).unwrap()),
Some(Box::new(new_avr())),
Some(Box::new(new_gov(0.05))),
None,
);
model
.initialize(Complex::from_polar(1.02, 0.15), Complex::new(0.8, 0.3))
.unwrap();
let n = model.n_states();
for scale in [0.13, -0.21] {
let x: Vec<f64> = (0..n).map(|k| 0.4 + scale * k as f64).collect();
let v = Complex::new(0.97, 0.11);
let mut analytic = ModelJacobian::zeros(n);
analytic.clear();
model.jacobian(&x, v, &mut analytic);
let numeric = finite_difference(&model, &x, v, 1e-6);
for (name, a, b) in [
("dfdx", &analytic.dfdx, &numeric.dfdx),
("dfdv", &analytic.dfdv, &numeric.dfdv),
("didx", &analytic.didx, &numeric.didx),
] {
for (k, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
let sc = av.abs().max(bv.abs()).max(1.0);
assert!(
(av - bv).abs() / sc < 1e-6,
"{name}[{k}]: analytic {av:e} vs numeric {bv:e}"
);
}
}
}
}
#[test]
fn the_round_machine_reduces_to_the_salient_one() {
let trajectory = |salient: bool| {
let machine: Box<dyn Machine> = if salient {
Box::new(GenSalient::new(salient_params(), S_BASE, F_NOM).unwrap())
} else {
let p = salient_params();
Box::new(
GenRound::new(
GenRoundParams {
h: p.h,
d: p.d,
ra: p.ra,
xd: p.xd,
xq: p.xq,
xdp: p.xdp,
xqp: p.xqpp + 1e-7,
xdpp: p.xdpp,
xqpp: p.xqpp,
xl: p.xl,
td0p: p.td0p,
tq0p: p.tq0pp,
td0pp: p.td0pp,
tq0pp: p.tq0pp,
mbase: p.mbase,
},
S_BASE,
F_NOM,
)
.unwrap(),
)
};
let model: Box<dyn DynamicModel> = Box::new(GeneratingUnit::machine_only(machine));
let (mut system, _) = islanded_with(vec![model], &[0.8], (-0.8, -0.2));
let opts = options(
0.002,
8.0,
vec![
Event::bolted_fault(1.0, 1),
Event::new(1.08, EventKind::ClearFault { bus: 1 }),
],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
report.trajectory.series("G1.delta").unwrap()
};
let (round, salient) = (trajectory(false), trajectory(true));
assert_eq!(round.len(), salient.len());
let worst = round
.iter()
.zip(salient.iter())
.fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
assert!(
worst < 1e-5,
"with one effective q winding the two orders should agree; \
the rotor angles differ by {worst:e} rad"
);
}
#[test]
fn a_rate_limit_makes_the_valve_travel_at_exactly_its_rate() {
let rate = 0.01;
let travel = |limited: bool| {
let gov = Tgov1::new(Tgov1Params {
r: 0.05,
t1: 0.5,
t2: 1.0,
t3: 5.0,
dt: 0.0,
limits: Limits::NONE,
rate: if limited {
Limits::new(-rate, rate)
} else {
Limits::NONE
},
})
.unwrap();
let unit = GeneratingUnit::new(Box::new(new_machine(2.0)), None, Some(Box::new(gov)), None);
let (mut system, _) = islanded_with(vec![Box::new(unit)], &[0.8], (-0.8, -0.2));
let opts = options(
0.002,
8.0,
vec![Event::new(
1.0,
EventKind::LoadStep {
bus: 1,
ds: Complex::new(-0.4, 0.0),
},
)],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
(
report.trajectory.time.clone(),
report.trajectory.series("G1.gov_valve").unwrap(),
)
};
let (time, free) = travel(false);
let (_, capped) = travel(true);
let slope = |series: &[f64], from: f64, to: f64| {
let a = time.iter().position(|t| *t >= from).unwrap();
let b = time.iter().position(|t| *t >= to).unwrap();
(series[b] - series[a]) / (time[b] - time[a])
};
assert!(
slope(&free, 1.01, 1.05) > 1.5 * rate,
"the unlimited valve should want to move faster than {rate}, it moved at {}",
slope(&free, 1.01, 1.05)
);
let measured = slope(&capped, 1.05, 1.30);
assert!(
(measured - rate).abs() < 1e-6,
"a rate-limited valve should travel at exactly {rate} per second, not {measured}"
);
let a = time.iter().position(|t| *t >= 1.05).unwrap();
let b = time.iter().position(|t| *t >= 1.30).unwrap();
let (t0, v0) = (time[a], capped[a]);
for k in a..=b {
let expected = v0 + rate * (time[k] - t0);
assert!(
(capped[k] - expected).abs() < 1e-6,
"at t = {}: {} against a straight line at {expected}",
time[k],
capped[k]
);
}
for k in 1..capped.len() {
let dt = time[k] - time[k - 1];
if dt <= 0.0 {
continue;
}
let travelled = (capped[k] - capped[k - 1]).abs() / dt;
assert!(
travelled <= rate + 1e-6,
"the valve travelled at {travelled} at t = {}, past its {rate} limit",
time[k]
);
}
let fastest = (1..free.len())
.filter(|&k| time[k] > time[k - 1])
.map(|k| (free[k] - free[k - 1]).abs() / (time[k] - time[k - 1]))
.fold(0.0f64, f64::max);
assert!(
fastest > 2.0 * rate,
"the unlimited valve peaked at {fastest}"
);
}