use num_complex::Complex;
use gridoxide::dynamics::models::machine::{self, GenCls, GenClsParams};
use gridoxide::dynamics::models::{DynamicModel, GeneratingUnit, ModelJacobian, finite_difference};
use gridoxide::dynamics::{
DeviceSpec, DynamicSystem, DynamicsOptions, DynamicsStatus, SystemSpec, build, run_dynamics,
settle,
};
use gridoxide::network::{build_ybus, power_injections};
use gridoxide::solver::JacobianBackend;
use gridoxide::types::{Bus, BusType, Line};
const S_BASE: f64 = 100.0;
const F_NOM: f64 = 50.0;
const H: f64 = 5.0;
const XDP: f64 = 0.3;
const X_LINE: f64 = 0.2;
const P_GEN: f64 = 0.8;
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 params(d: f64) -> GenClsParams {
GenClsParams {
h: H,
d,
ra: 0.0,
xdp: XDP,
mbase: S_BASE,
}
}
fn smib(damping: f64) -> (DynamicSystem, f64, f64) {
let buses = vec![
bus(0, BusType::PV, 1.0, P_GEN, 0.0),
bus(1, BusType::Slack, 1.0, 0.0, 0.0),
];
let lines = vec![Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE,
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,
"the SMIB base case must solve"
);
let buses = report.buses;
let ybus = build_ybus(buses.len(), &lines, &[]).finish();
let (p_calc, q_calc) = power_injections(&buses, &ybus);
let s_dev = Complex::new(p_calc[0], q_calc[0]);
let model = GenCls::new(params(damping), S_BASE, F_NOM).expect("machine parameters are valid");
let system = build(SystemSpec {
buses: &buses,
lines: &lines,
transformers: &[],
shunts: &[],
devices: vec![DeviceSpec {
id: "G1".to_string(),
bus: 0,
s: s_dev,
model: machine::bare(Box::new(model)),
}],
fixed_buses: vec![1],
})
.expect("the solved case initializes to an equilibrium");
let e_mag = {
let v0 = system.voltages()[0];
let i = (s_dev / v0).conj();
(v0 + Complex::new(0.0, XDP) * i).norm()
};
let p_max = e_mag * 1.0 / (XDP + X_LINE);
let delta_0 = system.state()[0];
(system, p_max, delta_0)
}
fn options(step: f64, end: f64) -> DynamicsOptions {
DynamicsOptions {
end_time: end,
step,
damping_steps: 0,
..Default::default()
}
}
#[test]
fn equilibrium_is_invariant() {
let (mut system, _, delta_0) = smib(0.0);
assert!(
system.max_derivative() < 1e-12,
"initial derivative should be zero, was {:e}",
system.max_derivative()
);
assert!(
system.network_residual_norm() < 1e-12,
"initial network residual should be zero, was {:e}",
system.network_residual_norm()
);
let report = run_dynamics(&mut system, &options(0.01, 10.0));
assert_eq!(report.status, DynamicsStatus::Completed);
let delta = report
.trajectory
.series("G1.delta")
.expect("delta is recorded");
let omega = report
.trajectory
.series("G1.omega")
.expect("omega is recorded");
let vmag = report
.trajectory
.series("bus0.vmag")
.expect("bus 0 voltage is recorded");
let d_drift = delta.iter().fold(0.0f64, |m, d| m.max((d - delta_0).abs()));
let w_drift = omega.iter().fold(0.0f64, |m, w| m.max((w - 1.0).abs()));
let v_drift = vmag.iter().fold(0.0f64, |m, v| m.max((v - vmag[0]).abs()));
assert!(
d_drift < 1e-12,
"rotor angle drifted by {d_drift:e} over 10 s with no disturbance"
);
assert!(w_drift < 1e-12, "rotor speed drifted by {w_drift:e}");
assert!(v_drift < 1e-12, "terminal voltage drifted by {v_drift:e}");
}
#[test]
fn analytic_jacobian_matches_finite_difference() {
let mut model =
GeneratingUnit::machine_only(Box::new(GenCls::new(params(2.0), S_BASE, F_NOM).unwrap()));
model
.initialize(Complex::from_polar(1.02, 0.15), Complex::new(0.8, 0.3))
.expect("initializes");
let probes = [
(0.35, 1.000, Complex::new(1.00, 0.00)),
(0.35, 1.002, Complex::new(0.98, 0.12)),
(-0.60, 0.995, Complex::new(1.05, -0.20)),
(2.10, 1.010, Complex::new(0.60, 0.35)),
(0.00, 1.000, Complex::new(0.30, 0.00)),
];
for (delta, omega, v) in probes {
let x = [delta, omega];
let mut analytic = ModelJacobian::zeros(model.n_states());
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}] at delta={delta}, omega={omega}, v={v}: \
analytic {av:e} vs numeric {bv:e}"
);
}
};
compare("dfdx", &analytic.dfdx, &numeric.dfdx);
compare("dfdv", &analytic.dfdv, &numeric.dfdv);
compare("didx", &analytic.didx, &numeric.didx);
compare("didv", &analytic.didv, &numeric.didv);
}
}
fn crossings(time: &[f64], series: &[f64], about: f64) -> Vec<f64> {
let mut out = Vec::new();
for k in 1..series.len() {
let (a, b) = (series[k - 1] - about, series[k] - about);
if (a < 0.0) != (b < 0.0) {
let frac = a / (a - b);
out.push(time[k - 1] + frac * (time[k] - time[k - 1]));
}
}
out
}
#[test]
fn small_signal_oscillation_matches_the_closed_form() {
let (mut system, p_max, delta_0) = smib(0.0);
let opts = options(0.001, 6.0);
system.state_mut()[0] += 1e-3;
settle(&mut system, &opts);
assert!(
system.network_residual_norm() < 1e-9,
"the algebraic re-solve must restore the constraint"
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let delta = report.trajectory.series("G1.delta").unwrap();
let zeros = crossings(&report.trajectory.time, &delta, delta_0);
assert!(
zeros.len() >= 5,
"expected several oscillations, found {} crossings",
zeros.len()
);
let measured = 2.0 * (zeros[zeros.len() - 1] - zeros[0]) / (zeros.len() - 1) as f64;
let omega_b = std::f64::consts::TAU * F_NOM;
let k_s = p_max * delta_0.cos();
let expected = std::f64::consts::TAU / (omega_b * k_s / (2.0 * H)).sqrt();
let rel = (measured - expected).abs() / expected;
assert!(
rel < 2e-3,
"oscillation period {measured:.6} s differs from the closed form {expected:.6} s by {:.3}%",
rel * 100.0
);
}
#[test]
fn damping_decays_and_its_absence_does_not() {
let run = |d: f64| {
let (mut system, _, delta_0) = smib(d);
let opts = options(0.001, 6.0);
system.state_mut()[0] += 1e-2;
settle(&mut system, &opts);
let report = run_dynamics(&mut system, &opts);
let delta = report.trajectory.series("G1.delta").unwrap();
let n = delta.len();
let early = delta[..n / 6]
.iter()
.fold(0.0f64, |m, d| m.max((d - delta_0).abs()));
let late = delta[5 * n / 6..]
.iter()
.fold(0.0f64, |m, d| m.max((d - delta_0).abs()));
(early, late)
};
let (early, late) = run(0.0);
assert!(
(late - early).abs() / early < 5e-3,
"an undamped swing should keep its amplitude: {early:e} then {late:e}"
);
let (early, late) = run(10.0);
assert!(
late < 0.5 * early,
"a damped swing should decay: {early:e} then {late:e}"
);
}
#[test]
fn trapezoidal_is_second_order() {
let final_delta = |step: f64| {
let (mut system, _, _) = smib(1.0);
let opts = options(step, 1.0);
system.state_mut()[0] += 0.2;
settle(&mut system, &opts);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
*report
.trajectory
.series("G1.delta")
.unwrap()
.last()
.unwrap()
};
let reference = final_delta(0.02 / 16.0);
let coarse = (final_delta(0.02) - reference).abs();
let fine = (final_delta(0.01) - reference).abs();
let order = (coarse / fine).log2();
assert!(
(order - 2.0).abs() < 0.2,
"observed order of accuracy {order:.3} (errors {coarse:e} then {fine:e})"
);
}
#[test]
fn backends_agree() {
let trajectory = |backend: JacobianBackend| {
let (mut system, _, _) = smib(1.0);
let opts = DynamicsOptions {
backend,
..options(0.005, 2.0)
};
system.state_mut()[0] += 0.1;
settle(&mut system, &opts);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
report.trajectory.series("G1.delta").unwrap()
};
let scalar = trajectory(JacobianBackend::Scalar);
let klu = trajectory(JacobianBackend::KluNative);
assert_eq!(scalar.len(), klu.len());
let worst = scalar
.iter()
.zip(klu.iter())
.fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
assert!(worst < 1e-9, "Scalar and KluNative disagree by {worst:e}");
}
#[test]
fn a_mis_declared_split_is_silent_and_changes_the_machine() {
let split = |s_dev: Complex<f64>| {
let buses = vec![
bus(0, BusType::PV, 1.0, P_GEN, 0.0),
bus(1, BusType::Slack, 1.0, 0.0, 0.0),
];
let lines = vec![Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE,
b_shunt: 0.0,
g_shunt: 0.0,
}];
let report = gridoxide::run_power_flow_analysis(gridoxide::json::NetworkData {
buses,
lines: lines.clone(),
});
let model = GenCls::new(params(0.0), S_BASE, F_NOM).unwrap();
build(SystemSpec {
buses: &report.buses,
lines: &lines,
transformers: &[],
shunts: &[],
devices: vec![DeviceSpec {
id: "G1".to_string(),
bus: 0,
s: s_dev,
model: machine::bare(Box::new(model)),
}],
fixed_buses: vec![1],
})
.expect("a self-consistent split always builds")
};
let ybus = build_ybus(
2,
&[Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE,
b_shunt: 0.0,
g_shunt: 0.0,
}],
&[],
)
.finish();
let honest = {
let buses = vec![
bus(0, BusType::PV, 1.0, P_GEN, 0.0),
bus(1, BusType::Slack, 1.0, 0.0, 0.0),
];
let lines = vec![Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE,
b_shunt: 0.0,
g_shunt: 0.0,
}];
let report =
gridoxide::run_power_flow_analysis(gridoxide::json::NetworkData { buses, lines });
let (p, q) = power_injections(&report.buses, &ybus);
Complex::new(p[0], q[0])
};
let correct = split(honest);
let halved = split(Complex::new(honest.re / 2.0, honest.im / 2.0));
for system in [&correct, &halved] {
assert!(system.max_derivative() < 1e-12);
assert!(system.network_residual_norm() < 1e-12);
}
let (d_correct, d_halved) = (correct.state()[0], halved.state()[0]);
assert!(
d_halved < d_correct - 0.05,
"the mis-declared machine should sit at a visibly smaller angle: \
{d_correct:.4} rad vs {d_halved:.4} rad"
);
}