use std::path::{Path, PathBuf};
use gridoxide::PowerFlowOptions;
use gridoxide::cgmes::{CgmesNetwork, cgmes_to_network, load_profiles};
use gridoxide::outerloop::{ControllerOutcome, RegulationMode, TapRegulation};
use gridoxide::solver::SolveStatus;
use gridoxide::types::{Bus, BusType, Line, TapChanger, Transformer};
use gridoxide::{PowerFlowReport, TapData, run_power_flow};
use num_complex::Complex;
fn two_bus(
position: i32,
) -> (
Vec<Bus>,
Vec<Line>,
Vec<Transformer>,
Vec<Option<TapChanger>>,
) {
let buses = vec![
Bus {
idx: 0,
bus_type: BusType::Slack,
voltage_mag: 1.0,
voltage_ang: 0.0,
p_spec: 0.0,
q_spec: 0.0,
q_min: f64::NEG_INFINITY,
q_max: f64::INFINITY,
u_rated: 1.0,
zip_terms: Vec::new(),
},
Bus {
idx: 1,
bus_type: BusType::PQ,
voltage_mag: 1.0,
voltage_ang: 0.0,
p_spec: -0.8,
q_spec: -0.3,
q_min: f64::NEG_INFINITY,
q_max: f64::INFINITY,
u_rated: 1.0,
zip_terms: Vec::new(),
},
];
let steps: Vec<Complex<f64>> = (0..11)
.map(|i| Complex::new(0.90 + 0.02 * i as f64, 0.0))
.collect();
let changer = TapChanger {
low: 0,
position,
neutral: 5,
steps,
series: None,
};
let mut transformer = Transformer {
from: 0,
to: 1,
from_status: 1,
to_status: 1,
y_series: Complex::new(1.0, 0.0) / Complex::new(0.01, 0.10),
y_shunt: Complex::new(0.0, 0.0),
tap: Complex::new(1.0, 0.0),
};
transformer.tap = changer.at(position).unwrap();
(buses, Vec::new(), vec![transformer], vec![Some(changer)])
}
fn voltage_control(target: f64, deadband: f64) -> Vec<TapRegulation> {
vec![TapRegulation {
transformer: 0,
controlled_bus: 1,
mode: RegulationMode::Voltage,
target,
deadband,
enabled: true,
id: "analytic".into(),
}]
}
fn solve(
buses: Vec<Bus>,
lines: &[Line],
transformers: &[Transformer],
changers: &[Option<TapChanger>],
regulation: &[TapRegulation],
control_taps: bool,
) -> PowerFlowReport {
run_power_flow(
buses,
lines,
transformers,
&[],
TapData {
changers,
regulation,
},
PowerFlowOptions {
control_taps,
max_outer_iter: 60,
tol: 1e-10,
max_iter: 40,
..Default::default()
},
)
}
fn sweep(
buses: &[Bus],
lines: &[Line],
transformers: &[Transformer],
changers: &[Option<TapChanger>],
which: usize,
controlled_bus: usize,
) -> Vec<(i32, f64)> {
let changer = changers[which].as_ref().expect("a changer to sweep");
let mut out = Vec::new();
for p in changer.low..=changer.high() {
let mut t = transformers.to_vec();
let mut c = changer.clone();
assert!(c.set_position(&mut t[which], p));
let report = solve(buses.to_vec(), lines, &t, &[], &[], false);
if report.stats.status == SolveStatus::Converged {
out.push((p, report.buses[controlled_bus].voltage_mag));
}
}
out
}
#[test]
fn the_loop_finds_the_position_a_sweep_names() {
let (buses, lines, transformers, changers) = two_bus(5);
let table = sweep(&buses, &lines, &transformers, &changers, 0, 1);
assert_eq!(table.len(), 11, "every position should solve");
let target = 1.0;
let best = table
.iter()
.min_by(|a, b| (a.1 - target).abs().total_cmp(&(b.1 - target).abs()))
.expect("a best position")
.0;
for start in 0..=10 {
let (buses, lines, transformers, changers) = two_bus(start);
let report = solve(
buses,
&lines,
&transformers,
&changers,
&voltage_control(target, 1e-6),
true,
);
let outer = report.outer.as_ref().expect("loops ran");
let landed = outer.changers[0].as_ref().unwrap().position;
assert_eq!(
landed, best,
"started at {start}, landed at {landed}, sweep says {best}; table {table:?}"
);
}
}
#[test]
fn a_bus_inside_its_deadband_does_not_move() {
let (buses, lines, transformers, changers) = two_bus(5);
let table = sweep(&buses, &lines, &transformers, &changers, 0, 1);
let at_five = table.iter().find(|(p, _)| *p == 5).unwrap().1;
let report = solve(
buses,
&lines,
&transformers,
&changers,
&voltage_control(at_five + 0.01, 0.05),
true,
);
let outer = report.outer.as_ref().expect("loops ran");
assert_eq!(
outer.changers[0].as_ref().unwrap().position,
5,
"nothing to do"
);
assert_eq!(outer.taps[0].outcome, ControllerOutcome::InDeadband);
assert_eq!(outer.taps[0].steps_moved, 0);
}
#[test]
fn an_unreachable_target_stops_at_the_limit_and_reports_it() {
let (buses, lines, transformers, changers) = two_bus(5);
let table = sweep(&buses, &lines, &transformers, &changers, 0, 1);
let highest = table.iter().max_by(|a, b| a.1.total_cmp(&b.1)).unwrap().0;
let report = solve(
buses,
&lines,
&transformers,
&changers,
&voltage_control(2.0, 1e-6),
true,
);
let outer = report.outer.as_ref().expect("loops ran");
let changer = outer.changers[0].as_ref().unwrap();
assert_eq!(
changer.position, highest,
"it should have gone as far as it can: {table:?}"
);
assert!(
changer.position == changer.low || changer.position == changer.high(),
"and that is one end of the range"
);
assert_eq!(outer.taps[0].outcome, ControllerOutcome::AtLimit);
assert!(
outer.report.as_ref().unwrap().converged,
"stopping at a limit is a settled answer"
);
}
#[test]
fn control_taps_off_changes_nothing() {
let (buses, lines, transformers, changers) = two_bus(3);
let regulation = voltage_control(1.0, 1e-6);
let plain = solve(
buses.clone(),
&lines,
&transformers,
&changers,
®ulation,
false,
);
let bare = run_power_flow(
buses,
&lines,
&transformers,
&[],
TapData::none(),
PowerFlowOptions {
tol: 1e-10,
max_iter: 40,
..Default::default()
},
);
assert!(
plain.outer.is_none(),
"no loop was configured, so none should be reported"
);
for (a, b) in plain.buses.iter().zip(&bare.buses) {
assert_eq!(a.voltage_mag.to_bits(), b.voltage_mag.to_bits());
assert_eq!(a.voltage_ang.to_bits(), b.voltage_ang.to_bits());
}
}
fn fixture(dir: &str) -> Option<CgmesNetwork> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/CGMES-Test-Configurations/v3.0")
.join(dir);
if !dir.exists() {
return None;
}
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("configuration directory")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "xml"))
.collect();
paths.sort();
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let ds = load_profiles(&refs).expect("failed to decode CGMES profiles");
Some(cgmes_to_network(&ds, 100e6).expect("conversion failed"))
}
fn solve_fixture(net: &CgmesNetwork, control_taps: bool) -> PowerFlowReport {
run_power_flow(
net.buses.clone(),
&net.lines,
&net.transformers,
&net.shunts,
TapData {
changers: &net.tap_changers,
regulation: &net.regulation,
},
PowerFlowOptions {
control_taps,
max_outer_iter: 60,
tol: 1e-8,
max_iter: 30,
..Default::default()
},
)
}
#[test]
fn svedalas_published_solution_violates_its_own_deadbands() {
let Some(net) = fixture("Svedala/Svedala-Merged") else {
eprintln!("skipping: Svedala fixture not checked out");
return;
};
let base = solve_fixture(&net, false);
assert_eq!(base.stats.status, SolveStatus::Converged);
let mut outside = 0;
for r in &net.regulation {
let v = base.buses[r.controlled_bus].voltage_mag;
if (v - r.target).abs() > r.deadband / 2.0 {
outside += 1;
}
}
assert_eq!(
outside,
net.regulation.len(),
"every one of Svedala's controls should be violated at the published positions"
);
}
#[test]
fn svedala_brings_every_controller_into_its_deadband() {
let Some(net) = fixture("Svedala/Svedala-Merged") else {
eprintln!("skipping: Svedala fixture not checked out");
return;
};
let report = solve_fixture(&net, true);
assert_eq!(report.stats.status, SolveStatus::Converged);
let outer = report.outer.as_ref().expect("loops ran");
assert!(
outer.report.as_ref().unwrap().converged,
"{:?}",
outer.report
);
assert_eq!(outer.taps.len(), 11);
for c in &outer.taps {
assert_eq!(
c.outcome,
ControllerOutcome::InDeadband,
"controller {} on {}",
c.id,
c.transformer
);
}
for r in &net.regulation {
let v = report.buses[r.controlled_bus].voltage_mag;
assert!(
(v - r.target).abs() <= r.deadband / 2.0,
"bus {} at {v} against target {} ± {}",
r.controlled_bus,
r.target,
r.deadband / 2.0
);
}
for (i, c) in outer.changers.iter().enumerate() {
let Some(c) = c else { continue };
assert!(
c.position >= c.low && c.position <= c.high(),
"transformer {i}"
);
}
}
#[test]
fn every_chosen_position_is_confirmed_by_a_sweep() {
let Some(net) = fixture("Svedala/Svedala-Merged") else {
eprintln!("skipping: Svedala fixture not checked out");
return;
};
let report = solve_fixture(&net, true);
let outer = report.outer.as_ref().expect("loops ran");
let settled = &outer.transformers;
let changers = &outer.changers;
for r in &net.regulation {
let chosen = changers[r.transformer]
.as_ref()
.expect("a controlled transformer has a table");
let mut acceptable = Vec::new();
for p in chosen.low..=chosen.high() {
let mut t = settled.clone();
let mut c = chosen.clone();
assert!(c.set_position(&mut t[r.transformer], p));
let probe = run_power_flow(
net.buses.clone(),
&net.lines,
&t,
&net.shunts,
TapData::none(),
PowerFlowOptions {
tol: 1e-8,
max_iter: 30,
..Default::default()
},
);
if probe.stats.status != SolveStatus::Converged {
continue;
}
let v = probe.buses[r.controlled_bus].voltage_mag;
if (v - r.target).abs() <= r.deadband / 2.0 {
acceptable.push(p);
}
}
assert!(
acceptable.contains(&chosen.position),
"transformer {} landed on {}, but a sweep says only {acceptable:?} satisfy the control",
r.transformer,
chosen.position
);
}
}
#[test]
fn no_control_means_no_movement() {
for name in [
"SmallGrid/SmallGrid-Merged",
"PowerFlow/PowerFlow",
"MiniGrid/MiniGrid-Merged",
] {
let Some(net) = fixture(name) else { continue };
assert!(net.regulation.is_empty(), "{name}: fixture assumption");
let report = solve_fixture(&net, true);
assert!(
report.outer.is_none(),
"{name}: with nothing to control, no outer loop should be configured at all"
);
}
let (buses, lines, transformers, changers) = two_bus(3);
let mut regulation = voltage_control(1.0, 1e-6);
regulation[0].enabled = false;
let report = solve(buses, &lines, &transformers, &changers, ®ulation, true);
let outer = report
.outer
.as_ref()
.expect("control_taps was on with regulation present");
assert!(
outer.taps.is_empty(),
"a disabled control must produce no controller"
);
assert_eq!(
outer.changers[0].as_ref().unwrap().position,
3,
"a disabled control must not move its tap"
);
}
#[test]
fn the_pst_fixtures_reach_the_best_position_their_table_offers() {
for (name, expect_move, expected) in [
(
"PST/PST_PhaseTapChangerLinear_Type1",
false,
ControllerOutcome::InDeadband,
),
(
"PST/PST_PhaseTapChangerLinear_Type2",
true,
ControllerOutcome::Closest,
),
(
"PST/PST_PhaseTapChangerTable_Type3",
true,
ControllerOutcome::Closest,
),
] {
let Some(net) = fixture(name) else { continue };
assert_eq!(net.regulation.len(), 1, "{name}");
let before = net.tap_changers[0].as_ref().unwrap().position;
let report = solve_fixture(&net, true);
assert_eq!(report.stats.status, SolveStatus::Converged, "{name}");
let outer = report.outer.as_ref().expect("loops ran");
let after = outer.changers[0].as_ref().unwrap().position;
assert_eq!(outer.taps.len(), 1, "{name}");
assert_eq!(outer.taps[0].outcome, expected, "{name}");
assert_eq!(after != before, expect_move, "{name}: {before} -> {after}");
let r = &net.regulation[0];
let RegulationMode::ActivePower { branch, terminal } = r.mode else {
panic!("{name}: expected an active-power control");
};
let flow_at = |p: i32| -> Option<f64> {
let mut t = net.transformers.clone();
let mut c = net.tap_changers[0].clone().unwrap();
c.set_position(&mut t[0], p);
let probe = run_power_flow(
net.buses.clone(),
&net.lines,
&t,
&net.shunts,
TapData::none(),
PowerFlowOptions {
tol: 1e-10,
max_iter: 40,
..Default::default()
},
);
(probe.stats.status == SolveStatus::Converged).then(|| {
let params = gridoxide::branch_flow::branch_params(&net.lines, &t);
let v = gridoxide::branch_flow::bus_voltages(&probe.buses);
gridoxide::branch_flow::terminal_flow(¶ms[branch], terminal, &v).0
})
};
let changer = net.tap_changers[0].as_ref().unwrap();
let best = (changer.low..=changer.high())
.filter_map(|p| flow_at(p).map(|f| (p, (f - r.target).abs())))
.min_by(|a, b| a.1.total_cmp(&b.1))
.expect("some position solves")
.0;
assert_eq!(after, best, "{name}: landed on {after}, sweep says {best}");
}
}