use std::collections::HashMap;
use gridoxide::dynamics::dyd;
use gridoxide::dynamics::json::{DynamicsData, DynamicsDocument, EventSpec, MachineSpec};
use gridoxide::dynamics::{DynamicsOptions, DynamicsStatus, Trajectory, run_dynamics};
use gridoxide::json::NetworkData;
use gridoxide::types::{Bus, BusType, Line};
use num_complex::Complex;
const DIR: &str = "tests/data/benchmark-grids/dynawo/kundur13";
const S_BASE: f64 = 100.0;
const F_NOM: f64 = 50.0;
const V_INFINITE: f64 = 0.90081;
const X_LINE_1: f64 = 0.022522;
const X_LINE_2: f64 = 0.04189;
const X_TRANSFORMER: f64 = 0.00675;
const P0_PU: f64 = -19.98;
const FAULT_AT: f64 = 1.0;
const CLEAR_AT: f64 = 1.07;
struct Reference {
time: Vec<f64>,
columns: HashMap<String, Vec<f64>>,
}
impl Reference {
fn read() -> Self {
let text = std::fs::read_to_string(format!("{DIR}/reference_setpoint.csv"))
.expect("the reference is committed beside the test");
let mut lines = text.lines();
let header: Vec<&str> = lines.next().expect("a header").split(';').collect();
let mut columns: HashMap<String, Vec<f64>> = header
.iter()
.map(|h| (h.trim().to_string(), Vec::new()))
.collect();
let mut time = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
}
let fields: Vec<&str> = line.split(';').collect();
for (k, name) in header.iter().enumerate() {
if let Some(value) = fields.get(k).and_then(|f| f.trim().parse::<f64>().ok()) {
columns.get_mut(name.trim()).unwrap().push(value);
}
}
time.push(fields[0].trim().parse().expect("a time"));
}
Self { time, columns }
}
fn column(&self, name: &str) -> &[f64] {
self.columns
.get(name)
.unwrap_or_else(|| panic!("no column `{name}`"))
}
}
fn sample(trajectory: &Trajectory, column: usize, t: f64) -> f64 {
let time = &trajectory.time;
let rows = &trajectory.rows;
let mut lo = 0usize;
for i in 0..time.len() - 1 {
if time[i] <= t {
lo = i;
}
}
let (t0, t1) = (time[lo], time[lo + 1]);
let (v0, v1) = (rows[lo][column], rows[lo + 1][column]);
if t1 <= t0 {
return v1;
}
v0 + (v1 - v0) * ((t - t0) / (t1 - t0))
}
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 case() -> DynamicsDocument {
case_with(false)
}
fn case_with(speed_voltages: bool) -> DynamicsDocument {
let dyd = dyd::read_dyd(format!("{DIR}/KundurExample13_SetPoint.dyd")).expect("parses");
let par = dyd::read_par(format!("{DIR}/KundurExample13.par")).expect("parses");
let bus_of = HashMap::from([("SM".to_string(), 2usize)]);
let (mut units, warnings) = dyd::to_units(&dyd, &par, &bus_of, S_BASE).expect("converts");
assert_eq!(units.len(), 1, "the SetPoint case has exactly one machine");
assert!(
units[0].avr.is_none() && units[0].gov.is_none(),
"`GeneratorSynchronousFourWindings` carries no regulators — that is why this case \
was chosen"
);
assert!(
warnings.is_empty(),
"nothing should need excusing here: {warnings:?}"
);
units[0].id = "SM".to_string();
let network = NetworkData {
buses: vec![
bus(0, BusType::Slack, V_INFINITE, 0.0, 0.0),
bus(1, BusType::PQ, 1.0, 0.0, 0.0),
bus(2, BusType::PV, 1.0, -P0_PU, 0.0),
],
lines: vec![
Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE_1,
b_shunt: 0.0,
g_shunt: 0.0,
},
Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE_2,
b_shunt: 0.0,
g_shunt: 0.0,
},
Line {
from: 1,
to: 2,
r: 0.0,
x: X_TRANSFORMER,
b_shunt: 0.0,
g_shunt: 0.0,
},
],
};
DynamicsDocument {
network,
dynamics: DynamicsData {
s_base: S_BASE,
f_nom: F_NOM,
speed_voltages,
units,
loads: Vec::new(),
fixed_buses: vec![0],
events: vec![
EventSpec::BusFault {
t: FAULT_AT,
bus: 1,
y: None,
},
EventSpec::ClearFault {
t: CLEAR_AT,
bus: 1,
},
EventSpec::BranchTrip {
t: CLEAR_AT,
branch: 1,
},
],
relays: Vec::new(),
},
}
}
#[test]
fn the_machine_is_the_one_the_book_describes() {
let document = case();
match document.dynamics.units[0].machine {
MachineSpec::GenRound(p) => {
assert_eq!(p.h, 3.5);
assert_eq!(p.d, 0.0);
assert_eq!(p.ra, 0.003);
assert_eq!(p.xd, 1.81);
assert_eq!(p.xq, 1.76);
assert_eq!(p.xdp, 0.30);
assert_eq!(p.xqp, 0.65);
assert_eq!(p.xdpp, 0.23);
assert_eq!(p.xqpp, 0.25);
assert_eq!(p.xl, 0.15);
assert_eq!(p.mbase, 2220.0);
}
ref other => panic!("expected a subtransient machine, got {other:?}"),
}
}
#[test]
fn the_operating_point_and_the_initial_rotor_angle_agree() {
let reference = Reference::read();
let (system, _) = case().build().expect("builds");
let v_terminal = system.voltages()[2];
assert!(
(v_terminal.norm() - 1.0).abs() < 1e-9,
"the machine terminal should sit at 1.0 pu, found {}",
v_terminal.norm()
);
assert!(
(v_terminal.arg() - 0.49445).abs() < 1e-4,
"the terminal angle should be the file's UPhase0 = 0.49445, found {}",
v_terminal.arg()
);
let delta_0 = system.state()[0];
let dynawo_theta_0 = reference.column("SM_generator_theta")[0];
assert!(
(delta_0 - dynawo_theta_0).abs() < 1e-4,
"δ₀ = {delta_0:.6} against Dynawo's theta₀ = {dynawo_theta_0:.6}"
);
assert!(
system.max_derivative() < 1e-10,
"drift {:e}",
system.max_derivative()
);
assert!(system.network_residual_norm() < 1e-10);
}
#[test]
fn the_trajectory_matches_dynawo_through_the_fault() {
let reference = Reference::read();
let (mut system, events) = case().build().expect("builds");
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: 5.0,
step: 0.00025,
events,
..Default::default()
},
);
assert_eq!(report.status, DynamicsStatus::Completed);
assert_eq!(report.events_applied, 3);
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let worst = |ours: &str, theirs: &str, until: f64| {
let column = report.trajectory.column(ours).unwrap();
let expected = reference.column(theirs);
let mut worst = 0.0f64;
for (k, &t) in reference.time.iter().enumerate() {
if t > until {
break;
}
if (t - FAULT_AT).abs() < 2e-3 || (t - CLEAR_AT).abs() < 2e-3 {
continue;
}
worst = worst.max((sample(&report.trajectory, column, t) - expected[k]).abs());
}
worst
};
let delta_fault = worst("SM.delta", "SM_generator_theta", CLEAR_AT + 0.04);
let omega_fault = worst("SM.omega", "SM_generator_omegaPu", CLEAR_AT + 0.04);
assert!(
delta_fault < 1e-3,
"rotor angle through the fault: {delta_fault:e} rad"
);
assert!(
omega_fault < 1e-4,
"rotor speed through the fault: {omega_fault:e} pu"
);
let voltage_during = worst("bus2.vmag", "SM_generator_UPu", CLEAR_AT);
let voltage_after = worst("bus2.vmag", "SM_generator_UPu", CLEAR_AT + 0.04);
assert!(
voltage_during < 2e-3,
"terminal voltage during the fault: {voltage_during:e} pu"
);
assert!(
voltage_after < 8e-3,
"terminal voltage just after clearing: {voltage_after:e} pu"
);
assert!(
voltage_after > 3.0 * voltage_during,
"the voltage error should step up at clearing, not grow smoothly: \
{voltage_during:e} during, {voltage_after:e} after"
);
let delta_swing = worst("SM.delta", "SM_generator_theta", 1.5);
assert!(
delta_swing < 0.03,
"rotor angle over the first swing: {delta_swing:e} rad"
);
println!(
"through-fault worst — delta {delta_fault:.3e} rad, omega {omega_fault:.3e} pu, \
|V| {voltage_during:.3e} pu during / {voltage_after:.3e} after clearing; \
first swing delta {delta_swing:.3e} rad"
);
}
#[test]
fn the_speed_voltage_approximation_costs_six_tenths_of_a_percent() {
let reference = Reference::read();
let expected_delta = reference.column("SM_generator_theta");
let expected_power = reference.column("SM_generator_PGenPu");
let (mut system, events) = case().build().unwrap();
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: 2.0,
step: 0.00025,
events,
..Default::default()
},
);
assert_eq!(report.status, DynamicsStatus::Completed);
let (dm, da) = (
report.trajectory.column("SM.delta").unwrap(),
report.trajectory.column("bus1.vang").unwrap(),
);
let (m1, m2, a2) = (
report.trajectory.column("bus1.vmag").unwrap(),
report.trajectory.column("bus2.vmag").unwrap(),
report.trajectory.column("bus2.vang").unwrap(),
);
let mut offsets = Vec::new();
for (k, &t) in reference.time.iter().enumerate() {
if !(CLEAR_AT + 0.01..CLEAR_AT + 0.06).contains(&t) {
continue;
}
let delta = sample(&report.trajectory, dm, t);
if (delta - expected_delta[k]).abs() > 1e-3 {
continue;
}
let (v1, ang1) = (
sample(&report.trajectory, m1, t),
sample(&report.trajectory, da, t),
);
let (v2, ang2) = (
sample(&report.trajectory, m2, t),
sample(&report.trajectory, a2, t),
);
let ours = v1 * v2 * (ang2 - ang1).sin() / X_TRANSFORMER;
offsets.push((ours - expected_power[k]) / expected_power[k]);
}
assert!(
offsets.len() >= 5,
"not enough matched samples: {}",
offsets.len()
);
let mean = offsets.iter().sum::<f64>() / offsets.len() as f64;
println!(
"post-fault power offset at matched angle: {:.4}%",
mean * 100.0
);
assert!(
(-0.008..-0.004).contains(&mean),
"the ω ≈ 1 approximation is expected to cost about −0.6% here; it cost \
{:.4}%. If it moved, something changed the power-angle relation — which \
is the whole point of pinning it.",
mean * 100.0
);
}
#[test]
fn the_published_clearing_time_is_survivable_and_a_longer_one_is_not() {
let stays_in_step = |clear_at: f64| {
let mut document = case();
document.dynamics.events = vec![
EventSpec::BusFault {
t: FAULT_AT,
bus: 1,
y: None,
},
EventSpec::ClearFault {
t: clear_at,
bus: 1,
},
EventSpec::BranchTrip {
t: clear_at,
branch: 1,
},
];
let (mut system, events) = document.build().unwrap();
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: 5.0,
step: 0.001,
events,
..Default::default()
},
);
assert_eq!(report.status, DynamicsStatus::Completed);
let delta = report.trajectory.series("SM.delta").unwrap();
delta.iter().fold(0.0f64, |m, d| m.max(*d)) < std::f64::consts::TAU
};
assert!(
stays_in_step(CLEAR_AT),
"the published 70 ms clearing must be survivable"
);
assert!(
!stays_in_step(1.0 + 0.5),
"half a second of fault must not be"
);
}
#[test]
fn the_full_form_removes_the_offset_against_dynawo() {
let offset = |speed_voltages: bool| {
let reference = Reference::read();
let expected_delta = reference.column("SM_generator_theta");
let expected_power = reference.column("SM_generator_PGenPu");
let (mut system, events) = case_with(speed_voltages).build().unwrap();
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: 2.0,
step: 0.00025,
events,
..Default::default()
},
);
assert_eq!(report.status, DynamicsStatus::Completed);
let (dm, da) = (
report.trajectory.column("SM.delta").unwrap(),
report.trajectory.column("bus1.vang").unwrap(),
);
let (m1, m2, a2) = (
report.trajectory.column("bus1.vmag").unwrap(),
report.trajectory.column("bus2.vmag").unwrap(),
report.trajectory.column("bus2.vang").unwrap(),
);
let mut offsets = Vec::new();
for (k, &t) in reference.time.iter().enumerate() {
if !(CLEAR_AT + 0.01..CLEAR_AT + 0.06).contains(&t) {
continue;
}
let delta = sample(&report.trajectory, dm, t);
if (delta - expected_delta[k]).abs() > 1e-3 {
continue;
}
let (v1, ang1) = (
sample(&report.trajectory, m1, t),
sample(&report.trajectory, da, t),
);
let (v2, ang2) = (
sample(&report.trajectory, m2, t),
sample(&report.trajectory, a2, t),
);
let ours = v1 * v2 * (ang2 - ang1).sin() / X_TRANSFORMER;
offsets.push((ours - expected_power[k]) / expected_power[k]);
}
assert!(
offsets.len() >= 5,
"not enough matched samples: {}",
offsets.len()
);
offsets.iter().sum::<f64>() / offsets.len() as f64
};
let approximate = offset(false);
let full = offset(true);
println!(
"post-fault power offset: {:.4}% with the approximation, {:.4}% without",
approximate * 100.0,
full * 100.0
);
assert!(
full.abs() < 0.1 * approximate.abs(),
"adopting Dynawo's form should remove most of the offset: {:.4}% became {:.4}%",
approximate * 100.0,
full * 100.0
);
assert!(
full.abs() < 1e-3,
"what remains after the switch should be under 0.1%; it was {:.4}%",
full * 100.0
);
}
#[test]
fn the_full_form_tracks_dynawo_through_the_whole_first_swing() {
let reference = Reference::read();
let worst_over = |speed_voltages: bool, until: f64| {
let (mut system, events) = case_with(speed_voltages).build().unwrap();
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: 5.0,
step: 0.00025,
events,
..Default::default()
},
);
assert_eq!(report.status, DynamicsStatus::Completed);
let column = report.trajectory.column("SM.delta").unwrap();
let expected = reference.column("SM_generator_theta");
let mut worst = 0.0f64;
for (k, &t) in reference.time.iter().enumerate() {
if t > until {
break;
}
if (t - FAULT_AT).abs() < 2e-3 || (t - CLEAR_AT).abs() < 2e-3 {
continue;
}
worst = worst.max((sample(&report.trajectory, column, t) - expected[k]).abs());
}
worst
};
let (approx_swing, full_swing) = (worst_over(false, 1.5), worst_over(true, 1.5));
let (approx_run, full_run) = (worst_over(false, 5.0), worst_over(true, 5.0));
println!(
"rotor angle vs Dynawo — first swing: {approx_swing:.3e} → {full_swing:.3e} rad; \
whole 5 s: {approx_run:.3e} → {full_run:.3e} rad"
);
assert!(
full_swing < 0.2 * approx_swing,
"over the first swing the full form should agree far better: \
{approx_swing:e} against {full_swing:e}"
);
assert!(
full_run < approx_run,
"and it should not be worse over the whole run: {approx_run:e} against {full_run:e}"
);
}
#[test]
fn the_field_voltage_base_agrees_with_dynawos() {
use gridoxide::dynamics::models::machine::{GenRound, Machine};
let params = match case().dynamics.units[0].machine {
MachineSpec::GenRound(p) => p,
ref other => panic!("expected a subtransient machine, got {other:?}"),
};
let mut machine = GenRound::new(params, S_BASE, F_NOM).unwrap();
let init = machine
.initialize(
Complex::from_polar(1.0, 0.49445),
Complex::new(-P0_PU, 9.68),
)
.expect("initializes");
let expected = Reference::read().column("SM_generator_efdPu")[0];
assert!(
(init.e_fd - expected).abs() < 1e-5,
"field voltage {:.6} against Dynawo's {expected:.6}",
init.e_fd
);
}