use num_complex::Complex;
use gridoxide::dynamics::models::machine::{self, GenCls, GenClsParams};
use gridoxide::dynamics::{
DeviceSpec, DynamicSystem, DynamicsOptions, DynamicsStatus, DynamicsWarning, 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;
const OMEGA_B: f64 = std::f64::consts::TAU * F_NOM;
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, p: f64, q: f64) -> Bus {
Bus {
idx,
bus_type,
voltage_mag: 1.0,
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(),
}
}
struct Analytic {
p_max: f64,
p_m: f64,
delta_0: f64,
t_cc: f64,
}
fn smib() -> (DynamicSystem, Analytic) {
let buses = vec![
bus(0, BusType::PV, P_GEN, 0.0),
bus(1, BusType::Slack, 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 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(
GenClsParams {
h: H,
d: 0.0,
ra: 0.0,
xdp: XDP,
mbase: S_BASE,
},
S_BASE,
F_NOM,
)
.unwrap();
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],
})
.unwrap();
let p_m = p_calc[0];
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 / (XDP + X_LINE);
let delta_0 = system.state()[0];
let delta_max = std::f64::consts::PI - delta_0;
let cos_cc = delta_max.cos() + (p_m / p_max) * (delta_max - delta_0);
let delta_cc = cos_cc.acos();
let t_cc = (4.0 * H * (delta_cc - delta_0) / (OMEGA_B * p_m)).sqrt();
(
system,
Analytic {
p_max,
p_m,
delta_0,
t_cc,
},
)
}
fn options(step: f64, end: f64, events: Vec<Event>) -> DynamicsOptions {
DynamicsOptions {
end_time: end,
step,
events,
..Default::default()
}
}
const FAULT_AT: f64 = 1.0;
fn stays_in_step(clearing: f64, step: f64) -> bool {
let (mut system, _) = smib();
let opts = options(
step,
FAULT_AT + clearing + 4.0,
vec![
Event::bolted_fault(FAULT_AT, 0),
Event::new(FAULT_AT + clearing, EventKind::ClearFault { bus: 0 }),
],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(
report.status,
DynamicsStatus::Completed,
"clearing at {clearing}"
);
assert_eq!(report.events_applied, 2);
let delta = report.trajectory.series("G1.delta").unwrap();
delta.iter().fold(0.0f64, |m, d| m.max(*d)) < std::f64::consts::TAU
}
#[test]
fn critical_clearing_time_matches_the_equal_area_criterion() {
let (_, analytic) = smib();
let on_curve = analytic.p_max * analytic.delta_0.sin();
assert!(
(on_curve - analytic.p_m).abs() < 1e-9,
"P_max·sin δ₀ = {on_curve:.9} should be P_m = {:.9}",
analytic.p_m
);
assert!(
analytic.t_cc > 0.2 && analytic.t_cc < 0.5,
"the closed form should land in a plausible band, got {:.4} s",
analytic.t_cc
);
let step = 0.002;
let (mut lo, mut hi) = (0.15, 0.45);
assert!(
stays_in_step(lo, step),
"the machine must survive a short fault"
);
assert!(
!stays_in_step(hi, step),
"the machine must not survive a long one"
);
for _ in 0..14 {
let mid = 0.5 * (lo + hi);
if stays_in_step(mid, step) {
lo = mid;
} else {
hi = mid;
}
}
let measured = 0.5 * (lo + hi);
assert!(
(measured - analytic.t_cc).abs() < 1e-3,
"simulated critical clearing time {measured:.5} s differs from the \
equal-area criterion's {:.5} s by {:.2} ms",
analytic.t_cc,
(measured - analytic.t_cc).abs() * 1e3
);
}
#[test]
fn state_is_continuous_across_an_event_and_voltage_is_not() {
let (mut system, _) = smib();
let opts = options(0.005, 2.0, vec![Event::bolted_fault(FAULT_AT, 0)]);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let at_fault: Vec<usize> = report
.trajectory
.time
.iter()
.enumerate()
.filter(|(_, t)| (**t - FAULT_AT).abs() < 1e-12)
.map(|(k, _)| k)
.collect();
assert_eq!(
at_fault.len(),
2,
"the fault instant should be recorded twice"
);
let (before, after) = (at_fault[0], at_fault[1]);
let col = |name: &str| report.trajectory.column(name).unwrap();
for state in ["G1.delta", "G1.omega"] {
let c = col(state);
assert_eq!(
report.trajectory.rows[before][c].to_bits(),
report.trajectory.rows[after][c].to_bits(),
"{state} must be bit-identical across the jump"
);
}
let vmag = col("bus0.vmag");
let (v_before, v_after) = (
report.trajectory.rows[before][vmag],
report.trajectory.rows[after][vmag],
);
assert!(
v_before > 0.9,
"terminal voltage before the fault was {v_before}"
);
assert!(
v_after < 1e-4,
"a bolted fault should collapse the terminal voltage, got {v_after}"
);
assert!(
system.network_residual_norm() < 1e-9,
"residual after the run: {:e}",
system.network_residual_norm()
);
}
#[test]
fn a_terminal_fault_removes_the_electrical_power() {
let departure = |damping_steps: usize, step: f64, y_fault: f64| {
let (mut system, analytic) = smib();
let opts = DynamicsOptions {
damping_steps,
..options(
step,
FAULT_AT + 0.25,
vec![Event::new(
FAULT_AT,
EventKind::BusFault {
bus: 0,
y: Complex::new(y_fault, 0.0),
},
)],
)
};
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
let time = &report.trajectory.time;
let delta = report.trajectory.series("G1.delta").unwrap();
let accel = OMEGA_B * analytic.p_m / (4.0 * H);
let mut worst: f64 = 0.0;
for (k, &t) in time.iter().enumerate() {
if t <= FAULT_AT + 1e-12 {
continue;
}
let dt = t - FAULT_AT;
worst = worst.max((delta[k] - (analytic.delta_0 + accel * dt * dt)).abs());
}
(worst, analytic.p_m)
};
let step = 0.001;
let (coarse, _) = departure(0, step, 1e6);
let (stiff, _) = departure(0, step, 1e8);
let ratio = coarse / stiff;
assert!(
(50.0..200.0).contains(&ratio),
"a hundred-fold stiffer fault should shrink the departure a hundred-fold; \
got {coarse:e} then {stiff:e}, a ratio of {ratio:.1}"
);
assert!(
stiff < 1e-6,
"with a stiff fault the trapezoidal rule should be exact here, departed by {stiff:e}"
);
let (damped, p_m) = departure(2, step, 1e8);
let predicted = OMEGA_B * (p_m / (2.0 * H)) * step * step;
assert!(
(damped - predicted).abs() / predicted < 0.01,
"the damping offset should be Ω_b·(P_m/2H)·h² = {predicted:e} rad, measured {damped:e}"
);
let (finer, _) = departure(2, step / 2.0, 1e8);
let order = (damped / finer).log2();
assert!(
(order - 2.0).abs() < 0.05,
"the damping offset should fall as h², observed order {order:.3}"
);
}
#[test]
fn damping_steps_do_not_change_the_answer_yet() {
let clearing = 0.30;
let peak = |damping_steps: usize| {
let (mut system, _) = smib();
let opts = DynamicsOptions {
damping_steps,
..options(
0.002,
FAULT_AT + clearing + 2.0,
vec![
Event::bolted_fault(FAULT_AT, 0),
Event::new(FAULT_AT + clearing, EventKind::ClearFault { bus: 0 }),
],
)
};
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
report
.trajectory
.series("G1.delta")
.unwrap()
.iter()
.fold(0.0f64, |m, d| m.max(*d))
};
let (none, damped) = (peak(0), peak(2));
assert!(
(none - damped).abs() < 5e-4,
"peak angle {none:.6} with no damping vs {damped:.6} with two steps"
);
}
#[test]
fn an_event_lands_exactly_on_its_own_time() {
let (mut system, _) = smib();
let odd = 0.7071;
let opts = options(0.005, 1.0, vec![Event::bolted_fault(odd, 0)]);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.events_applied, 1);
let hits = report.trajectory.time.iter().filter(|t| **t == odd).count();
assert_eq!(
hits, 2,
"the event time should appear exactly twice, bit-exact"
);
}
#[test]
fn a_switched_off_island_is_reported() {
let buses = vec![
bus(0, BusType::PV, P_GEN, 0.0),
bus(1, BusType::Slack, 0.0, 0.0),
bus(2, BusType::PQ, -0.3, -0.1),
];
let lines = vec![
Line {
from: 0,
to: 1,
r: 0.0,
x: X_LINE,
b_shunt: 0.0,
g_shunt: 0.0,
},
Line {
from: 1,
to: 2,
r: 0.01,
x: 0.1,
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 model = GenCls::new(
GenClsParams {
h: H,
d: 0.0,
ra: 0.0,
xdp: XDP,
mbase: S_BASE,
},
S_BASE,
F_NOM,
)
.unwrap();
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: machine::bare(Box::new(model)),
}],
fixed_buses: vec![1],
})
.unwrap();
let opts = options(
0.005,
1.0,
vec![Event::new(0.5, EventKind::BranchTrip { branch: 1 })],
);
let out = run_dynamics(&mut system, &opts);
assert_eq!(out.status, DynamicsStatus::Completed);
assert_eq!(out.events_applied, 1);
let dead: Vec<&DynamicsWarning> = out
.warnings
.iter()
.filter(|w| matches!(w, DynamicsWarning::DeadIsland { .. }))
.collect();
assert_eq!(
dead.len(),
1,
"expected one dead island, got {:?}",
out.warnings
);
match dead[0] {
DynamicsWarning::DeadIsland { buses, time } => {
assert_eq!(buses, &vec![2]);
assert_eq!(*time, 0.5);
}
other => panic!("unexpected warning {other:?}"),
}
let delta = out.trajectory.series("G1.delta").unwrap();
let drift = delta
.iter()
.fold(0.0f64, |m, d| m.max((d - delta[0]).abs()));
assert!(
drift < 1e-9,
"the machine should not notice, but moved {drift:e} rad"
);
}
#[test]
fn an_impossible_event_is_skipped_and_named() {
let (mut system, _) = smib();
let opts = options(
0.01,
0.5,
vec![
Event::bolted_fault(0.1, 7),
Event::new(0.2, EventKind::BranchTrip { branch: 9 }),
],
);
let report = run_dynamics(&mut system, &opts);
assert_eq!(report.status, DynamicsStatus::Completed);
assert_eq!(report.events_applied, 0);
assert_eq!(report.warnings.len(), 2);
for warning in &report.warnings {
assert!(
matches!(warning, DynamicsWarning::Skipped { .. }),
"got {warning:?}"
);
}
}