use std::collections::HashMap;
use std::path::PathBuf;
use gridoxide::branch_flow::{Terminal, branch_params, bus_voltages, terminal_flow};
use gridoxide::solver::SolveStatus;
use gridoxide::types::BusType;
use gridoxide::ucte;
use gridoxide::{PowerFlowMethod, PowerFlowOptions};
mod openrao;
fn fixture(name: &str) -> PathBuf {
openrao::fixture(name)
}
fn reference_file(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/ucte")
.join(name)
}
struct Reference {
buses: HashMap<String, (f64, f64)>,
branches: HashMap<String, [f64; 4]>,
}
fn reference(name: &str) -> Reference {
let text = std::fs::read_to_string(reference_file(name)).expect("reference fixture");
let doc: serde_json::Value = serde_json::from_str(&text).expect("reference json");
let mut buses = HashMap::new();
for (k, v) in doc["buses"].as_object().expect("buses") {
buses.insert(
k.clone(),
(
v["v_pu"].as_f64().unwrap(),
v["angle_deg"].as_f64().unwrap(),
),
);
}
let mut branches = HashMap::new();
for (k, v) in doc["branches"].as_object().expect("branches") {
let flows: Option<Vec<f64>> = ["p1", "q1", "p2", "q2"]
.iter()
.map(|f| v[*f].as_f64())
.collect();
if let Some(f) = flows {
branches.insert(k.clone(), [f[0], f[1], f[2], f[3]]);
}
}
Reference { buses, branches }
}
fn solve(
net: &ucte::UcteImport,
) -> (
HashMap<String, (f64, f64)>,
HashMap<String, [f64; 4]>,
SolveStatus,
) {
let opts = PowerFlowOptions {
method: PowerFlowMethod::NewtonRaphson,
..Default::default()
};
let (lines, transformers) = net.as_switched();
let report = gridoxide::run_power_flow(
net.buses.clone(),
&lines,
&transformers,
&net.shunts,
gridoxide::TapData::none(),
opts,
);
let v = bus_voltages(&report.buses);
let params = branch_params(&lines, &transformers);
let mva = net.base_mva;
let buses = net
.node_codes
.iter()
.enumerate()
.map(|(i, code)| {
(
code.trim().to_string(),
(
report.buses[i].voltage_mag,
report.buses[i].voltage_ang.to_degrees(),
),
)
})
.collect();
let branches = net
.branch_ids
.iter()
.enumerate()
.map(|(b, id)| {
let (p1, q1) = terminal_flow(¶ms[b], Terminal::From, &v);
let (p2, q2) = terminal_flow(¶ms[b], Terminal::To, &v);
(id.clone(), [p1 * mva, q1 * mva, p2 * mva, q2 * mva])
})
.collect();
(buses, branches, report.stats.status)
}
fn worst_deviation(net: &ucte::UcteImport, reference_name: &str) -> (f64, f64, f64) {
let reference = reference(reference_name);
let (buses, branches, status) = solve(net);
assert_eq!(status, SolveStatus::Converged, "gridoxide did not converge");
let datum = {
let mut shared: Vec<&String> = buses
.keys()
.filter(|c| reference.buses.contains_key(*c))
.collect();
shared.sort();
shared.first().copied().cloned().expect("no shared bus")
};
let shift = buses[&datum].1 - reference.buses[&datum].1;
let mut dv: f64 = 0.0;
let mut da: f64 = 0.0;
let mut compared = 0;
for (code, (v, a)) in &buses {
if let Some((rv, ra)) = reference.buses.get(code) {
dv = dv.max((v - rv).abs());
da = da.max((a - shift - ra).abs());
compared += 1;
}
}
assert!(
compared >= 5,
"only {compared} buses matched the reference by name"
);
let mut df: f64 = 0.0;
let mut branches_compared = 0;
for (id, flows) in &branches {
if let Some(r) = reference.branches.get(id) {
for k in 0..4 {
df = df.max((flows[k] - r[k]).abs());
}
branches_compared += 1;
}
}
assert!(
branches_compared >= 5,
"only {branches_compared} branches matched by id"
);
(dv, da, df)
}
#[test]
fn the_twelve_node_case_matches_pypowsybl_to_solver_tolerance() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let (dv, da, df) = worst_deviation(&net, "TestCase12Nodes.pypowsybl.json");
assert!(dv < 1e-9, "worst |V| deviation {dv}");
assert!(da < 1e-6, "worst angle deviation {da} deg");
assert!(df < 1e-3, "worst branch flow deviation {df} MW/MVar");
}
#[test]
fn transformers_across_voltage_levels_and_x_nodes_match_pypowsybl() {
let net = ucte::read(fixture("TestCase_severalVoltageLevels_Xnodes.uct")).expect("import");
let (dv, da, df) = worst_deviation(&net, "TestCase_severalVoltageLevels_Xnodes.pypowsybl.json");
assert!(dv < 1e-9, "worst |V| deviation {dv}");
assert!(da < 1e-3, "worst angle deviation {da} deg");
assert!(df < 0.5, "worst branch flow deviation {df} MW/MVar");
}
#[test]
fn a_case_with_a_busbar_coupler_matches_pypowsybl() {
let net = ucte::read(fixture("TestCase16Nodes_with_different_imax.uct")).expect("import");
let (dv, da, df) = worst_deviation(&net, "TestCase16Nodes_with_different_imax.pypowsybl.json");
assert!(dv < 1e-9, "worst |V| deviation {dv}");
assert!(da < 1e-3, "worst angle deviation {da} deg");
assert!(df < 0.5, "worst branch flow deviation {df} MW/MVar");
}
#[test]
fn the_sixteen_node_case_matches_pypowsybl() {
let net = ucte::read(fixture("TestCase16Nodes.uct")).expect("import");
let (dv, da, df) = worst_deviation(&net, "TestCase16Nodes.pypowsybl.json");
assert!(dv < 1e-9, "worst |V| deviation {dv}");
assert!(da < 1e-3, "worst angle deviation {da} deg");
assert!(df < 0.5, "worst branch flow deviation {df} MW/MVar");
}
#[test]
fn generation_is_negative_in_the_file_and_positive_in_the_model() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let i = net.node_index["BBE1AA1 "];
assert!(
(net.buses[i].p_spec - (-10.0)).abs() < 1e-12,
"p_spec {}",
net.buses[i].p_spec
);
}
#[test]
fn nominal_voltage_comes_from_the_node_code_not_the_record() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let i = net.node_index["BBE1AA1 "];
assert_eq!(net.buses[i].u_rated, 380_000.0);
assert!((net.buses[i].voltage_mag - 400.0 / 380.0).abs() < 1e-12);
assert_eq!(net.buses[i].bus_type, BusType::PV);
}
#[test]
fn the_two_two_zero_kv_class_is_read_as_two_twenty() {
let net = ucte::read(fixture("TestCase_severalVoltageLevels_Xnodes.uct")).expect("import");
let i = net.node_index["BBE1AA2 "];
assert_eq!(net.buses[i].u_rated, 220_000.0);
}
#[test]
fn a_transformer_is_reversed_relative_to_the_file() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
assert_eq!(net.transformers.len(), 1);
let t = &net.transformers[0];
assert_eq!(net.node_codes[t.from].trim(), "BBE3AA1");
assert_eq!(net.node_codes[t.to].trim(), "BBE2AA1");
}
#[test]
fn a_line_carries_its_current_rating_as_a_permanent_limit() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let b = net
.branch_ids
.iter()
.position(|id| id.starts_with("BBE1AA1 BBE2AA1"))
.unwrap();
assert_eq!(net.limits[b].patl_a, Some(5000.0));
assert!(
net.limits[b].tatl.is_empty(),
"UCTE states no temporary ratings"
);
}
#[test]
fn every_branch_has_a_limit_entry_even_when_the_file_gave_none() {
let net = ucte::read(fixture("TestCase_severalVoltageLevels_Xnodes.uct")).expect("import");
assert_eq!(net.limits.len(), net.n_branches());
assert_eq!(net.branch_ids.len(), net.n_branches());
}
#[test]
fn a_symmetrical_phase_shifter_yields_a_full_tap_table() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let changer = net.tap_changers[0].as_ref().expect("tap changer");
assert_eq!(changer.low, -16);
assert_eq!(changer.high(), 16);
assert_eq!(changer.len(), 33);
assert_eq!(changer.position, 0);
for pos in changer.low..=changer.high() {
let ratio = changer.ratio(pos).unwrap();
assert!(
(ratio - 1.0).abs() < 1e-12,
"tap {pos} changed |ratio| to {ratio}"
);
}
assert!(changer.angle_deg(0).unwrap().abs() < 1e-12);
let hi = changer.angle_deg(16).unwrap();
let lo = changer.angle_deg(-16).unwrap();
assert!(
(hi + lo).abs() < 1e-12,
"angles {hi} and {lo} are not symmetric"
);
assert!(
hi.abs() > 5.0,
"16 taps of 0.68% should be several degrees, got {hi}"
);
}
#[test]
fn a_phase_shifter_at_its_extreme_tap_matches_pypowsybl() {
let net = ucte::read(fixture("TestCase12NodesDifferentPstTap.uct")).expect("import");
let changer = net.tap_changers[0].as_ref().expect("tap changer");
assert_eq!(changer.position, 16, "fixture should sit at the top tap");
assert!(changer.angle_deg(16).unwrap().abs() > 5.0);
let (dv, da, df) = worst_deviation(&net, "TestCase12NodesDifferentPstTap.pypowsybl.json");
assert!(dv < 1e-9, "worst |V| deviation {dv}");
assert!(da < 1e-6, "worst angle deviation {da} deg");
assert!(df < 1e-3, "worst branch flow deviation {df} MW/MVar");
}
#[test]
fn a_shifted_pst_puts_its_current_step_on_the_transformer() {
let net = ucte::read(fixture("TestCase16Nodes_with_different_imax.uct")).expect("import");
let (i, changer) = net
.tap_changers
.iter()
.enumerate()
.find_map(|(i, c)| c.as_ref().filter(|c| c.position != 0).map(|c| (i, c)))
.expect("a shifted tap changer");
assert_eq!(changer.position, 5);
let tap = net.transformers[i].tap;
assert!((tap - changer.current().unwrap()).norm() < 1e-15);
assert!(
tap.arg().abs() > 1e-6,
"a tap-5 PST should have a nonzero shift"
);
}
#[test]
fn a_negative_tap_position_shifts_the_other_way() {
let net = ucte::read(fixture("3nodes_pst.uct")).expect("import");
let changer = net
.tap_changers
.iter()
.flatten()
.next()
.expect("tap changer");
assert_eq!(changer.position, -8);
let here = changer.angle_deg(-8).unwrap();
let mirrored = changer.angle_deg(8).unwrap();
assert!(
here * mirrored < 0.0,
"taps -8 and +8 should shift opposite ways"
);
assert!((here + mirrored).abs() < 1e-12, "and by equal amounts");
}
#[test]
fn setting_a_tap_position_moves_the_transformer_and_refuses_to_leave_the_range() {
let mut net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let mut changer = net.tap_changers[0].take().expect("tap changer");
let before = net.transformers[0].tap;
assert!(changer.set_position(&mut net.transformers[0], 8));
assert_eq!(changer.position, 8);
assert!(
(net.transformers[0].tap - before).norm() > 1e-6,
"tap did not move"
);
let held = net.transformers[0].tap;
assert!(!changer.set_position(&mut net.transformers[0], 99));
assert_eq!(changer.position, 8);
assert_eq!(net.transformers[0].tap, held);
}
#[test]
fn rounding_an_angle_to_a_tap_searches_rather_than_dividing() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let changer = net.tap_changers[0].as_ref().expect("tap changer");
for pos in [-16, -7, 0, 5, 16] {
let angle = changer.angle_deg(pos).unwrap();
assert_eq!(changer.nearest_to_angle(angle), Some(pos));
}
let extreme = changer.angle_deg(16).unwrap() * 10.0;
let rounded = changer.nearest_to_angle(extreme).unwrap();
assert!(rounded == 16 || rounded == -16, "got {rounded}");
}
#[test]
fn out_of_service_branches_are_kept_open_rather_than_dropped() {
let net = ucte::read(fixture("TestCase16Nodes_with_different_imax.uct")).expect("import");
assert_eq!(net.out_of_service.len(), net.initially_open.len());
for (id, &branch) in net.out_of_service.iter().zip(&net.initially_open) {
assert!(net.branch_ids.contains(id), "`{id}` was dropped");
assert_eq!(
&net.branch_ids[branch], id,
"initially_open does not line up with the ids"
);
}
if !net.out_of_service.is_empty() {
assert!(net.notes.iter().any(|n| n.contains("out of operation")));
let (lines, transformers) = net.as_switched();
let open = net.initially_open[0];
if open < lines.len() {
assert!(lines[open].x > 1e6, "an open line is still conducting");
} else {
assert_eq!(transformers[open - lines.len()].from_status, 0);
}
}
}
#[test]
fn a_closed_busbar_coupler_becomes_a_low_impedance_branch() {
let net = ucte::read(fixture("TestCase16Nodes_with_different_imax.uct")).expect("import");
let b = net
.branch_ids
.iter()
.position(|id| id.starts_with("BBE1AA1 BBE4AA1"))
.unwrap();
let line = &net.lines[b];
assert!(
line.x > 0.0 && line.x < 1e-3,
"coupler reactance {}",
line.x
);
assert!(net.notes.iter().any(|n| n.contains("busbar coupler")));
}
#[test]
fn x_nodes_are_kept_as_buses_and_said_so() {
let net = ucte::read(fixture("TestCase_severalVoltageLevels_Xnodes.uct")).expect("import");
let x = net.node_codes.iter().filter(|c| c.starts_with('X')).count();
assert!(x > 0);
assert!(net.notes.iter().any(|n| n.contains("X-node")));
}
#[test]
fn the_slack_is_chosen_deterministically_and_announced() {
let a = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let b = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
assert_eq!(a.slack, b.slack);
assert_eq!(a.buses[a.slack].bus_type, BusType::Slack);
assert_eq!(a.node_codes[a.slack].trim(), "BBE2AA1");
assert!(a.notes.iter().any(|n| n.contains("slack chosen")));
assert_eq!(
a.buses
.iter()
.filter(|b| b.bus_type == BusType::Slack)
.count(),
1,
"exactly one slack"
);
}
#[test]
fn an_explicit_slack_policy_is_honoured() {
let options = ucte::UcteOptions {
slack: ucte::SlackPolicy::Node("FFR3AA1 ".to_string()),
..Default::default()
};
let net = ucte::read_with(fixture("TestCase12Nodes.uct"), &options).expect("import");
assert_eq!(net.node_codes[net.slack].trim(), "FFR3AA1");
let bad = ucte::UcteOptions {
slack: ucte::SlackPolicy::Node("NOPE".to_string()),
..Default::default()
};
assert!(ucte::read_with(fixture("TestCase12Nodes.uct"), &bad).is_err());
}
#[test]
fn data_before_any_block_header_is_rejected() {
let err = ucte::read(fixture("TestCase12Nodes_wrong.uct")).unwrap_err();
assert!(
matches!(err, ucte::UcteError::DataOutsideBlock { .. }),
"expected DataOutsideBlock, got {err}"
);
}
#[test]
fn a_short_record_is_tolerated_when_only_trailing_fields_are_missing() {
let doc = b"##N\n##ZBE\nBBE1AA1 BE1 0 2 400.00 2500.00\n";
let net = ucte::parse(doc).expect("import");
assert_eq!(net.buses.len(), 1);
assert!((net.buses[0].p_spec - (-25.0)).abs() < 1e-12);
assert!(
net.buses[0].q_min.is_infinite(),
"absent limits are unbounded, not zero"
);
}
#[test]
fn latin_one_names_do_not_shift_the_columns() {
let good = std::fs::read(fixture("TestCase12Nodes.uct")).expect("fixture");
let plain = ucte::parse(&good).expect("import");
let reference = plain.buses[0].p_spec;
let mut accented = good.clone();
let start = accented
.windows(7)
.position(|w| w == b"BBE1AA1")
.expect("first node record");
accented[start + 9] = 0xC9; let net = ucte::parse(&accented).expect("import");
assert_eq!(net.buses.len(), plain.buses.len());
assert!(
(net.buses[0].p_spec - reference).abs() < 1e-12,
"p_spec moved from {reference} to {} when a name gained an accent",
net.buses[0].p_spec
);
}
fn regulated(r_record: &str) -> Vec<u8> {
let mut doc = String::new();
doc.push_str("##N\n##ZBE\n");
doc.push_str("BBE1AA1 BE1 0 2 400.00 0.00 0.00 0.00 0.00\n");
doc.push_str("BBE2AA1 BE2 0 0 225.00 50.00 10.00\n");
doc.push_str("##T\n");
doc.push_str(
"BBE1AA1 BBE2AA1 1 0 400.00 225.00 1000.00 0.5000 10.000 0.0000 0.0000 5000\n",
);
doc.push_str("##R\n");
doc.push_str(r_record);
doc.push('\n');
doc.into_bytes()
}
#[test]
fn a_ratio_regulations_held_voltage_is_read() {
let doc = regulated("BBE1AA1 BBE2AA1 1 1.50 10 2225.00");
let net = ucte::parse(&doc).expect("import");
assert_eq!(net.regulation.len(), 1, "notes: {:?}", net.notes);
let r = &net.regulation[0];
assert_eq!(r.mode, gridoxide::outerloop::RegulationMode::Voltage);
assert!(r.enabled);
assert!(
(r.target - 225_000.0 / net.buses[r.controlled_bus].u_rated).abs() < 1e-12,
"target {} against a {} V bus",
r.target,
net.buses[r.controlled_bus].u_rated
);
}
#[test]
fn an_angle_regulations_held_power_is_read() {
let doc = regulated("BBE1AA1 BBE2AA1 1 -0.68 90.00 16 0-65.00SYMM");
let net = ucte::parse(&doc).expect("import");
assert_eq!(net.regulation.len(), 1, "notes: {:?}", net.notes);
let r = &net.regulation[0];
match r.mode {
gridoxide::outerloop::RegulationMode::ActivePower { branch, .. } => {
assert_eq!(
branch,
net.lines.len(),
"a UCTE shifter holds its own branch's flow"
);
}
m => panic!("expected an active-power control, got {m:?}"),
}
assert!((r.target - -0.65).abs() < 1e-12, "{}", r.target);
}
#[test]
fn no_vendored_file_declares_a_regulation_target() {
let names = openrao::names_with_extension("uct");
assert!(
!names.is_empty(),
"tests/data/openrao-fixtures.txt lists no .uct files"
);
for name in names
.into_iter()
.filter(|n| *n != "TestCase12Nodes_wrong.uct")
{
let net = ucte::read(fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(
net.regulation.is_empty(),
"{name} now declares a regulation target — the note in src/ucte.rs is out of date"
);
}
}
#[test]
fn country_codes_become_control_areas() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let (of_bus, names) = net.country_areas();
assert_eq!(
names,
["BE", "DE", "FR", "NL"],
"sorted, so the indices are stable across runs"
);
assert_eq!(of_bus.len(), net.buses.len());
assert!(
of_bus.iter().all(|a| a.is_some()),
"every node in this file states its country"
);
for (i, name) in names.iter().enumerate() {
assert_eq!(
of_bus.iter().filter(|a| **a == Some(i)).count(),
3,
"{name} should hold three nodes"
);
}
}
#[test]
fn the_measured_positions_sum_to_the_tie_losses() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let (of_bus, names) = net.country_areas();
let (lines, transformers) = net.as_switched();
let report = gridoxide::run_power_flow(
net.buses.clone(),
&lines,
&transformers,
&net.shunts,
gridoxide::TapData::none(),
gridoxide::PowerFlowOptions {
tol: 1e-10,
max_iter: 40,
..Default::default()
},
);
assert_eq!(
report.stats.status,
gridoxide::solver::SolveStatus::Converged
);
let x = gridoxide::outerloop::AreaInterchange::measure(
&report.buses,
&lines,
&transformers,
&of_bus,
names.len(),
);
let mw: Vec<f64> = x.iter().map(|v| v * net.base_mva).collect();
let losses: f64 = mw.iter().sum();
assert!(
losses.abs() < 1e-6,
"lossless ties here, so the positions cancel: {mw:?}"
);
assert!(mw.iter().any(|v| *v > 500.0), "{mw:?}");
assert!(mw.iter().any(|v| *v < -500.0), "{mw:?}");
}
#[test]
fn ucte_supplies_membership_but_never_a_schedule() {
let net = ucte::read(fixture("TestCase12Nodes.uct")).expect("import");
let (of_bus, names) = net.country_areas();
let areas = gridoxide::outerloop::AreaDefinition::uniform(&net.buses, of_bus, names.len());
assert_eq!(areas.targets, vec![0.0; 4]);
}