use std::collections::HashMap;
use std::path::PathBuf;
use gridoxide::branch_flow::{Terminal, branch_params, bus_voltages, terminal_flow};
use gridoxide::iidm;
use gridoxide::solver::SolveStatus;
use gridoxide::topology::bus_view::RetentionPolicy;
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/iidm")
.join(name)
}
fn ucte_fixture(name: &str) -> PathBuf {
openrao::fixture(name)
}
type Solved = (HashMap<String, (f64, f64)>, HashMap<String, [f64; 4]>);
fn solve(
buses: Vec<gridoxide::types::Bus>,
lines: &[gridoxide::types::Line],
transformers: &[gridoxide::types::Transformer],
shunts: &[gridoxide::network::ShuntAdm],
labels: &[String],
ids: &[String],
base_mva: f64,
) -> (Solved, SolveStatus) {
let opts = PowerFlowOptions {
method: PowerFlowMethod::NewtonRaphson,
..Default::default()
};
let report = gridoxide::run_power_flow(
buses,
lines,
transformers,
shunts,
gridoxide::TapData::none(),
opts,
);
let v = bus_voltages(&report.buses);
let params = branch_params(lines, transformers);
let voltages = labels
.iter()
.enumerate()
.map(|(i, l)| {
(
l.trim().to_string(),
(
report.buses[i].voltage_mag,
report.buses[i].voltage_ang.to_degrees(),
),
)
})
.collect();
let flows = 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.trim().to_string(),
[p1 * base_mva, q1 * base_mva, p2 * base_mva, q2 * base_mva],
)
})
.collect();
((voltages, flows), report.stats.status)
}
fn solve_iidm(net: &iidm::IidmImport) -> (Solved, SolveStatus) {
solve(
net.buses.clone(),
&net.lines,
&net.transformers,
&net.shunts,
&net.bus_labels,
&net.branch_ids,
net.base_mva,
)
}
fn worst_flow(a: &HashMap<String, [f64; 4]>, b: &HashMap<String, [f64; 4]>) -> (f64, usize, usize) {
let mut worst: f64 = 0.0;
let mut shared = 0;
let mut reversed_count = 0;
for (id, x) in a {
let Some(y) = b.get(id) else { continue };
shared += 1;
let direct = (0..4).map(|k| (x[k] - y[k]).abs()).fold(0.0f64, f64::max);
let swapped = [y[2], y[3], y[0], y[1]];
let reversed = (0..4)
.map(|k| (x[k] - swapped[k]).abs())
.fold(0.0f64, f64::max);
if reversed < direct {
reversed_count += 1;
}
worst = worst.max(direct.min(reversed));
}
(worst, shared, reversed_count)
}
fn cross_check(stem: &str, tolerance: f64) -> (f64, usize) {
let u = gridoxide::ucte::read(ucte_fixture(&format!("{stem}.uct"))).expect("ucte import");
let i = iidm::read(fixture(&format!("{stem}.xiidm"))).expect("iidm import");
assert_eq!(
u.buses.len(),
i.buses.len(),
"{stem}: bus counts differ ({} UCTE vs {} IIDM)",
u.buses.len(),
i.buses.len()
);
assert_eq!(
u.n_branches(),
i.n_branches(),
"{stem}: branch counts differ"
);
let ((_, fu), su) = solve(
u.buses.clone(),
&u.lines,
&u.transformers,
&u.shunts,
&u.node_codes,
&u.branch_ids,
u.base_mva,
);
let ((_, fi), si) = solve_iidm(&i);
assert_eq!(
su,
SolveStatus::Converged,
"{stem}: UCTE side did not converge"
);
assert_eq!(
si,
SolveStatus::Converged,
"{stem}: IIDM side did not converge"
);
let (worst, shared, reversed) = worst_flow(&fu, &fi);
assert_eq!(
shared,
u.n_branches(),
"{stem}: only {shared} branch ids matched"
);
assert!(
worst < tolerance,
"{stem}: worst flow deviation {worst} MW/MVar"
);
(worst, reversed)
}
#[test]
fn ucte_and_iidm_agree_exactly_on_the_twelve_node_case() {
let (worst, reversed) = cross_check("TestCase12Nodes", 1e-9);
assert_eq!(worst, 0.0, "expected bit-identical flows, got {worst}");
assert_eq!(reversed, 0, "no terminal should need reversing here");
}
#[test]
fn ucte_and_iidm_agree_exactly_with_the_phase_shifter_at_its_limit() {
let (worst, _) = cross_check("TestCase12NodesDifferentPstTap", 1e-9);
assert_eq!(worst, 0.0, "expected bit-identical flows, got {worst}");
}
#[test]
fn ucte_and_iidm_agree_across_voltage_levels_and_boundary_nodes() {
let (worst, reversed) = cross_check("TestCase_severalVoltageLevels_Xnodes", 1e-9);
assert!(worst < 1e-9, "worst deviation {worst}");
assert!(
reversed > 0,
"boundary half-lines should differ in terminal order"
);
}
fn reference(name: &str) -> Solved {
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 buses = doc["buses"]
.as_object()
.expect("buses")
.iter()
.filter_map(|(k, v)| {
Some((
k.trim().to_string(),
(v["v_pu"].as_f64()?, v["angle_deg"].as_f64()?),
))
})
.collect();
let branches = doc["branches"]
.as_object()
.expect("branches")
.iter()
.filter_map(|(k, v)| {
let f: Option<Vec<f64>> = ["p1", "q1", "p2", "q2"]
.iter()
.map(|n| v[*n].as_f64())
.collect();
Some((k.trim().to_string(), {
let f = f?;
[f[0], f[1], f[2], f[3]]
}))
})
.collect();
(buses, branches)
}
fn against_pypowsybl(stem: &str, min_branches: usize) -> f64 {
let net = iidm::read(fixture(&format!("{stem}.xiidm"))).expect("import");
let ((_, flows), status) = solve_iidm(&net);
assert_eq!(status, SolveStatus::Converged, "{stem} did not converge");
let (_, expected) = reference(&format!("{stem}.pypowsybl.json"));
let (worst, shared, _) = worst_flow(&flows, &expected);
assert!(
shared >= min_branches,
"{stem}: only {shared} branches matched by id"
);
worst
}
#[test]
fn a_larger_native_case_matches_pypowsybl() {
let worst = against_pypowsybl("nordic32", 75);
assert!(worst < 0.05, "worst flow deviation {worst} MW/MVar");
}
#[test]
fn a_node_breaker_case_matches_pypowsybl() {
let worst = against_pypowsybl("voltage_monitoring", 4);
assert!(worst < 0.05, "worst flow deviation {worst} MW/MVar");
}
#[test]
fn the_oldest_schema_version_still_reads() {
let net = iidm::read(fixture("TestCase2Nodes.xiidm")).expect("import");
assert_eq!(net.version.as_deref(), Some("1_0"));
let worst = against_pypowsybl("TestCase2Nodes", 2);
assert!(worst < 1e-3, "worst flow deviation {worst}");
}
#[test]
fn every_vendored_version_parses() {
let mut seen: Vec<String> = Vec::new();
for name in openrao::names_with_extension("xiidm") {
let path = fixture(name);
let net = iidm::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert!(
!net.buses.is_empty(),
"{} produced no buses",
path.display()
);
if let Some(v) = net.version.clone() {
if !seen.contains(&v) {
seen.push(v);
}
}
}
assert!(
seen.len() >= 4,
"expected several schema versions, saw {seen:?}"
);
}
#[test]
fn an_unknown_element_is_skipped_and_named() {
let doc = r#"<?xml version='1.0'?>
<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_14" id="n">
<iidm:substation id="S">
<iidm:voltageLevel id="VL" nominalV="400.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B"/></iidm:busBreakerTopology>
<iidm:load id="L" p0="10.0" q0="1.0" bus="B" connectableBus="B"/>
<iidm:somethingNobodyHasHeardOf id="Z"/>
</iidm:voltageLevel>
</iidm:substation>
</iidm:network>"#;
let net = iidm::parse(doc).expect("import");
assert_eq!(net.version.as_deref(), Some("1_14"));
assert_eq!(net.buses.len(), 1);
assert!(
net.notes
.iter()
.any(|n| n.contains("somethingNobodyHasHeardOf")),
"unknown element was skipped without saying so: {:?}",
net.notes
);
}
#[test]
fn both_limit_spellings_are_read() {
let old = iidm::read(fixture("TestCase2Nodes.xiidm")).expect("import");
assert!(
old.limits
.iter()
.any(|[a, b]| a.patl_a.is_some() || b.patl_a.is_some()),
"no limit read from the currentLimits1 spelling"
);
let new = iidm::read(fixture("network_with_dangling_lines.xiidm")).expect("import");
assert!(
new.limits
.iter()
.any(|[a, b]| a.patl_a.is_some() || b.patl_a.is_some()),
"no limit read from the operationalLimitsGroup spelling"
);
}
#[test]
fn a_temporary_limit_keeps_its_duration() {
let net = iidm::read(fixture("network_one_voltage_level.xiidm")).expect("import");
let temporary: Vec<_> = net
.limits
.iter()
.flat_map(|[a, b]| a.tatl.iter().chain(b.tatl.iter()))
.collect();
assert!(
!temporary.is_empty(),
"fixture declares temporary limits but none were read"
);
assert!(
temporary
.iter()
.any(|t| t.acceptable_duration_s == Some(60.0)),
"the 60-second limit lost its duration: {temporary:?}"
);
assert!(temporary.iter().all(|t| t.value_a > 0.0));
}
#[test]
fn limits_are_kept_per_side() {
let net = iidm::read(fixture("network_with_dangling_lines.xiidm")).expect("import");
assert_eq!(net.limits.len(), net.n_branches());
let one_sided = net
.limits
.iter()
.filter(|[a, b]| a.patl_a.is_some() != b.patl_a.is_some())
.count();
assert!(
one_sided > 0,
"this fixture has a line rated on one side only"
);
}
#[test]
fn a_phase_tap_changer_becomes_a_tap_table() {
let net = iidm::read(fixture("TestCase12NodesDifferentPstTap.xiidm")).expect("import");
let changer = net
.tap_changers
.iter()
.flatten()
.next()
.expect("a tap changer");
assert!(
changer.len() > 1,
"a single-step table is not a tap changer"
);
assert_eq!(changer.position, changer.high());
let tap = net.transformers[0].tap;
assert!((tap - changer.current().unwrap()).norm() < 1e-15);
assert!(
tap.arg().abs() > 1e-6,
"a shifted PST should have a nonzero angle"
);
}
#[test]
fn switches_survive_import_as_first_class_objects() {
let net = iidm::read(fixture("voltage_monitoring.xiidm")).expect("import");
assert!(
!net.topology.switches.is_empty(),
"node-breaker file yielded no switches"
);
assert_eq!(net.switch_ids.len(), net.topology.switches.len());
assert!(
net.switch_ids.iter().any(|id| id.contains("Break")),
"{:?}",
net.switch_ids
);
assert!(net.view.n_buses() < net.view.n_nodes());
}
#[test]
fn retaining_switches_yields_more_buses_than_merging_them() {
let merged = iidm::read(fixture("voltage_monitoring.xiidm")).expect("import");
let options = iidm::IidmOptions {
retention: RetentionPolicy::RetainAll,
..Default::default()
};
let retained = iidm::read_with(fixture("voltage_monitoring.xiidm"), &options).expect("import");
assert!(
retained.buses.len() > merged.buses.len(),
"retaining switches ({}) should split buses that merging ({}) joins",
retained.buses.len(),
merged.buses.len()
);
}
#[test]
fn a_disconnected_terminal_omits_the_branch_and_names_it() {
let net = iidm::read(fixture("network_with_dangling_lines.xiidm")).expect("import");
for id in &net.disconnected {
assert!(
!net.branch_ids.contains(id),
"`{id}` was both omitted and kept"
);
}
}
#[test]
fn the_slack_is_chosen_deterministically() {
let a = iidm::read(fixture("nordic32.xiidm")).expect("import");
let b = iidm::read(fixture("nordic32.xiidm")).expect("import");
assert_eq!(a.slack, b.slack);
assert_eq!(a.buses[a.slack].bus_type, gridoxide::types::BusType::Slack);
assert_eq!(
a.buses
.iter()
.filter(|x| x.bus_type == gridoxide::types::BusType::Slack)
.count(),
1
);
assert!(a.notes.iter().any(|n| n.contains("slack chosen")));
}
#[test]
fn a_boundary_node_takes_the_voltage_of_what_it_attaches_to() {
let net = iidm::read(fixture("network_with_dangling_lines.xiidm")).expect("import");
for (i, bus) in net.buses.iter().enumerate() {
assert!(
bus.u_rated > 1000.0,
"bus `{}` has an implausible base of {} V",
net.bus_labels[i],
bus.u_rated
);
}
}
fn regulating_transformer(ratio: &str, phase: &str) -> String {
format!(
r#"<?xml version='1.0'?>
<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_14" id="n">
<iidm:substation id="S">
<iidm:voltageLevel id="VL1" nominalV="400.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B1"/></iidm:busBreakerTopology>
<iidm:generator id="G" energySource="OTHER" minP="0" maxP="100" voltageRegulatorOn="true"
targetP="10" targetV="400" targetQ="0" bus="B1" connectableBus="B1">
<iidm:minMaxReactiveLimits minQ="-100" maxQ="100"/>
</iidm:generator>
</iidm:voltageLevel>
<iidm:voltageLevel id="VL2" nominalV="225.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B2"/></iidm:busBreakerTopology>
<iidm:load id="L" p0="10.0" q0="1.0" bus="B2" connectableBus="B2"/>
</iidm:voltageLevel>
<iidm:twoWindingsTransformer id="T" r="0.5" x="10.0" g="0.0" b="0.0"
ratedU1="400.0" ratedU2="225.0" bus1="B1" connectableBus1="B1"
voltageLevelId1="VL1" bus2="B2" connectableBus2="B2" voltageLevelId2="VL2">
{ratio}
{phase}
</iidm:twoWindingsTransformer>
</iidm:substation>
</iidm:network>"#
)
}
const RATIO_STEPS: &str = r#" <iidm:step rho="0.98" r="0" x="0" g="0" b="0"/>
<iidm:step rho="1.00" r="0" x="0" g="0" b="0"/>
<iidm:step rho="1.02" r="0" x="0" g="0" b="0"/>"#;
const PHASE_STEPS: &str = r#" <iidm:step rho="1.0" alpha="-5.0" r="0" x="0" g="0" b="0"/>
<iidm:step rho="1.0" alpha="0.0" r="0" x="0" g="0" b="0"/>
<iidm:step rho="1.0" alpha="5.0" r="0" x="0" g="0" b="0"/>"#;
#[test]
fn a_regulating_ratio_tap_changer_becomes_a_voltage_control() {
let doc = regulating_transformer(
&format!(
r#" <iidm:ratioTapChanger lowTapPosition="0" tapPosition="1" regulating="true"
loadTapChangingCapabilities="true" targetV="220.5" targetDeadband="2.25">
{RATIO_STEPS}
</iidm:ratioTapChanger>"#
),
"",
);
let net = iidm::parse(&doc).expect("import");
assert_eq!(net.regulation.len(), 1, "{:?}", net.notes);
let r = &net.regulation[0];
assert_eq!(r.id, "T");
assert_eq!(r.mode, gridoxide::outerloop::RegulationMode::Voltage);
assert!((r.target - 220.5 / 225.0).abs() < 1e-12, "{}", r.target);
assert!((r.deadband - 2.25 / 225.0).abs() < 1e-12, "{}", r.deadband);
assert!(r.enabled);
assert!(
net.tap_changers[0].is_some(),
"the table is still there too"
);
}
#[test]
fn a_non_regulating_changer_yields_no_control() {
let doc = regulating_transformer(
&format!(
r#" <iidm:ratioTapChanger lowTapPosition="0" tapPosition="1" regulating="false"
loadTapChangingCapabilities="true" targetV="220.5">
{RATIO_STEPS}
</iidm:ratioTapChanger>"#
),
"",
);
let net = iidm::parse(&doc).expect("import");
assert!(net.regulation.is_empty());
assert!(net.tap_changers[0].is_some());
}
#[test]
fn a_phase_tap_changer_in_active_power_mode_becomes_a_flow_control() {
let doc = regulating_transformer(
"",
&format!(
r#" <iidm:phaseTapChanger lowTapPosition="0" tapPosition="1" regulating="true"
regulationMode="ACTIVE_POWER_CONTROL" regulationValue="-65.0" targetDeadband="35.0">
{PHASE_STEPS}
</iidm:phaseTapChanger>"#
),
);
let net = iidm::parse(&doc).expect("import");
assert_eq!(net.regulation.len(), 1, "{:?}", net.notes);
let r = &net.regulation[0];
match r.mode {
gridoxide::outerloop::RegulationMode::ActivePower { branch, .. } => {
assert_eq!(branch, net.lines.len(), "the shifter's own branch");
}
m => panic!("expected an active-power control, got {m:?}"),
}
assert!((r.target - -0.65).abs() < 1e-12, "{}", r.target);
assert!((r.deadband - 0.35).abs() < 1e-12, "{}", r.deadband);
}
#[test]
fn a_current_limiting_phase_changer_is_dropped_and_named() {
let doc = regulating_transformer(
"",
&format!(
r#" <iidm:phaseTapChanger lowTapPosition="0" tapPosition="1" regulating="true"
regulationMode="CURRENT_LIMITER" regulationValue="500.0">
{PHASE_STEPS}
</iidm:phaseTapChanger>"#
),
);
let net = iidm::parse(&doc).expect("import");
assert!(net.regulation.is_empty());
assert!(
net.notes.iter().any(|n| n.contains("hold a current")),
"a dropped control must be named: {:?}",
net.notes
);
}
#[test]
fn area_elements_become_a_bus_assignment() {
let net = iidm::read(fixture("TestCase12Nodes.xiidm")).expect("import");
let a = &net.areas;
assert_eq!(a.report.areas, 4);
assert_eq!(a.report.other_types, 0);
assert_eq!(a.report.unknown_voltage_levels, 0);
assert_eq!(a.report.contested_buses, 0);
assert_eq!(
a.report.unassigned_buses, 0,
"this file has no boundary nodes"
);
let ids: Vec<&str> = a.ids.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["BE", "DE", "FR", "NL"]);
for (i, id) in ids.iter().enumerate() {
assert_eq!(
a.of_bus.iter().filter(|z| **z == Some(i)).count(),
3,
"{id} should hold three buses"
);
}
assert_eq!(a.report.without_target, vec![0, 1, 2, 3]);
assert_eq!(a.targets, vec![0.0; 4]);
}
#[test]
fn ucte_country_areas_and_iidm_area_elements_agree() {
let u = gridoxide::ucte::read(ucte_fixture("TestCase12Nodes.uct")).expect("ucte import");
let i = iidm::read(fixture("TestCase12Nodes.xiidm")).expect("iidm import");
let (u_of_bus, u_names) = u.country_areas();
let i_names: Vec<String> = i.areas.ids.iter().map(|(id, _)| id.clone()).collect();
assert_eq!(
u_names, i_names,
"the same four countries, in the same order"
);
let position = |buses: &[gridoxide::types::Bus],
lines: &[gridoxide::types::Line],
transformers: &[gridoxide::types::Transformer],
shunts: &[gridoxide::network::ShuntAdm],
of_bus: &[Option<usize>]|
-> Vec<f64> {
let solved = gridoxide::run_power_flow(
buses.to_vec(),
lines,
transformers,
shunts,
gridoxide::TapData::none(),
gridoxide::PowerFlowOptions {
tol: 1e-10,
max_iter: 40,
..Default::default()
},
);
assert_eq!(
solved.stats.status,
gridoxide::solver::SolveStatus::Converged
);
gridoxide::outerloop::AreaInterchange::measure(
&solved.buses,
lines,
transformers,
of_bus,
4,
)
.iter()
.map(|v| v * 100.0)
.collect()
};
let (u_lines, u_transformers) = u.as_switched();
let from_ucte = position(&u.buses, &u_lines, &u_transformers, &u.shunts, &u_of_bus);
let from_iidm = position(
&i.buses,
&i.lines,
&i.transformers,
&i.shunts,
&i.areas.of_bus,
);
for k in 0..4 {
assert!(
(from_ucte[k] - from_iidm[k]).abs() < 1e-6,
"{}: UCTE says {} MW, IIDM says {}",
u_names[k],
from_ucte[k],
from_iidm[k]
);
}
assert!(
(from_ucte[0] - 2000.0).abs() < 1e-6,
"BE exports 2000 MW: {from_ucte:?}"
);
assert!(
(from_ucte[1] - -2500.0).abs() < 1e-6,
"DE imports 2500 MW: {from_ucte:?}"
);
}
#[test]
fn boundary_nodes_belong_to_no_area() {
let net = iidm::read(fixture("TestCase_severalVoltageLevels_Xnodes.xiidm")).expect("import");
let a = &net.areas;
assert_eq!(a.report.areas, 4);
assert!(a.report.unassigned_buses > 0, "the X-nodes are in no area");
assert!(
a.report.boundaries_ignored > 0,
"the file states boundaries too"
);
assert_eq!(a.report.contested_buses, 0);
}
#[test]
fn a_non_control_area_type_is_skipped_and_counted() {
let doc = r#"<?xml version='1.0'?>
<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_14" id="n">
<iidm:area id="Z1" areaType="BiddingZone" interchangeTarget="10.0">
<iidm:voltageLevelRef id="VL"/>
</iidm:area>
<iidm:area id="C1" areaType="ControlArea" interchangeTarget="-25.0">
<iidm:voltageLevelRef id="VL"/>
</iidm:area>
<iidm:substation id="S">
<iidm:voltageLevel id="VL" nominalV="400.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B"/></iidm:busBreakerTopology>
<iidm:load id="L" p0="10.0" q0="1.0" bus="B" connectableBus="B"/>
</iidm:voltageLevel>
</iidm:substation>
</iidm:network>"#;
let net = iidm::parse(doc).expect("import");
assert_eq!(net.areas.report.areas, 1);
assert_eq!(net.areas.report.other_types, 1);
assert_eq!(net.areas.ids[0].0, "C1");
assert!(
(net.areas.targets[0] - 0.25).abs() < 1e-12,
"{:?}",
net.areas.targets
);
assert!(net.areas.report.without_target.is_empty());
}
#[test]
fn an_unknown_voltage_level_reference_is_counted() {
let doc = r#"<?xml version='1.0'?>
<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_14" id="n">
<iidm:area id="C1" areaType="ControlArea">
<iidm:voltageLevelRef id="VL"/>
<iidm:voltageLevelRef id="NOT_A_LEVEL"/>
</iidm:area>
<iidm:substation id="S">
<iidm:voltageLevel id="VL" nominalV="400.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B"/></iidm:busBreakerTopology>
<iidm:load id="L" p0="10.0" q0="1.0" bus="B" connectableBus="B"/>
</iidm:voltageLevel>
</iidm:substation>
</iidm:network>"#;
let net = iidm::parse(doc).expect("import");
assert_eq!(net.areas.report.unknown_voltage_levels, 1);
assert_eq!(
net.areas.of_bus,
vec![Some(0)],
"the level that does exist still claims its bus"
);
}
#[test]
fn a_stated_interchange_target_is_read_and_reached() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("references/powsybl-core/psse/psse-converter/src/test/resources/two_area_case.xiidm");
if !path.exists() {
eprintln!("skipping: references/powsybl-core not checked out");
return;
}
let net = iidm::read(&path).expect("import");
assert_eq!(net.areas.report.areas, 2);
assert!(
net.areas.report.without_target.is_empty(),
"both areas state a schedule"
);
let mw: Vec<f64> = net.areas.targets.iter().map(|t| t * net.base_mva).collect();
assert!((mw[0] - 400.0).abs() < 1e-9, "{mw:?}");
assert!((mw[1] - -400.0).abs() < 1e-9, "{mw:?}");
let mut buses = net.buses.clone();
let mut ybus = {
let mut y = gridoxide::network::build_ybus(buses.len(), &net.lines, &net.transformers);
gridoxide::network::stamp_shunts(&mut y, &net.shunts);
y.finish()
};
gridoxide::network::linear_initial_guess(&mut buses, &ybus);
let definition = gridoxide::outerloop::AreaDefinition {
targets: net.areas.targets.clone(),
tolerance: 1e-9,
..gridoxide::outerloop::AreaDefinition::by_generation(&buses, net.areas.of_bus.clone(), 2)
};
let mut transformers = net.transformers.clone();
let mut changers = Vec::new();
let mut area_loop = gridoxide::outerloop::AreaInterchange::new(definition);
let (_, report) = {
let mut ctx = gridoxide::outerloop::SolveContext::new(&mut buses, &mut ybus)
.with_branches(&net.lines, &mut transformers, &net.shunts)
.with_taps(&mut changers, &[]);
let mut list: Vec<&mut dyn gridoxide::outerloop::OuterLoop> = vec![&mut area_loop];
gridoxide::outerloop::solve_with_loops(
&mut ctx,
1e-9,
40,
gridoxide::solver::JacobianBackend::Scalar,
&mut list,
60,
)
};
let area_report = area_loop.into_report();
assert!(report.converged, "{report:?}");
assert!(
area_report.unbalanced.is_empty(),
"{:?}",
area_report.unbalanced
);
let measured = gridoxide::outerloop::AreaInterchange::measure(
&buses,
&net.lines,
&transformers,
&net.areas.of_bus,
2,
);
let dependent = area_report.dependent.expect("one area holds the slack");
let driven = 1 - dependent;
assert!(
(measured[driven] * net.base_mva - net.areas.targets[driven] * net.base_mva).abs() < 1e-6,
"area {driven} exports {} MW against a stated {} MW",
measured[driven] * net.base_mva,
net.areas.targets[driven] * net.base_mva
);
}
#[test]
fn the_two_formats_agree_about_reactive_limits() {
let u = gridoxide::ucte::read(ucte_fixture("TestCase12Nodes.uct")).expect("ucte import");
let i = iidm::read(fixture("TestCase12Nodes.xiidm")).expect("iidm import");
assert_eq!(u.buses.len(), i.buses.len());
let mut bounded = 0;
for (n, (ub, ib)) in u.buses.iter().zip(&i.buses).enumerate() {
assert!(
(ub.q_min - ib.q_min).abs() < 1e-9,
"bus {n}: q_min {} (UCTE) against {} (IIDM)",
ub.q_min,
ib.q_min
);
assert!(
(ub.q_max - ib.q_max).abs() < 1e-9,
"bus {n}: q_max {} (UCTE) against {} (IIDM)",
ub.q_max,
ib.q_max
);
if ib.q_min.is_finite() && ib.q_max.is_finite() {
bounded += 1;
}
}
assert!(bounded >= 5, "expected bounded machines, found {bounded}");
}
#[test]
fn a_capability_curve_becomes_its_envelope_and_is_reported() {
let net = iidm::read(fixture("network_one_voltage_level.xiidm")).expect("iidm import");
assert!(
net.notes.iter().any(|n| n.contains("capability curve")),
"the envelope approximation must be reported, notes were {:?}",
net.notes
);
let bounded: Vec<(f64, f64)> = net
.buses
.iter()
.filter(|b| b.q_min.is_finite() && b.q_max.is_finite())
.map(|b| (b.q_min, b.q_max))
.collect();
assert!(!bounded.is_empty(), "a curve should bound at least one bus");
for (lo, hi) in bounded {
assert!(lo < hi, "an envelope must not be inverted: {lo} .. {hi}");
}
}
#[test]
fn a_shunt_is_scaled_by_its_section_count() {
let doc = |sections: u32| {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_5" id="n" caseDate="2020-01-01T00:00:00.000Z" forecastDistance="0" sourceFormat="test">
<iidm:substation id="S" country="FR">
<iidm:voltageLevel id="VL" nominalV="400.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B"/></iidm:busBreakerTopology>
<iidm:generator id="G" voltageRegulatorOn="true" targetP="10.0" targetV="400.0" targetQ="0.0" bus="B" connectableBus="B">
<iidm:minMaxReactiveLimits minQ="-100.0" maxQ="100.0"/>
</iidm:generator>
<iidm:load id="L" p0="10.0" q0="0.0" bus="B" connectableBus="B"/>
<iidm:shunt id="SH" sectionCount="{sections}" bus="B" connectableBus="B">
<iidm:shuntLinearModel bPerSection="0.001" gPerSection="0.0" maximumSectionCount="4"/>
</iidm:shunt>
</iidm:voltageLevel>
</iidm:substation>
</iidm:network>"#
)
};
let one = iidm::parse(&doc(1)).expect("one section");
let three = iidm::parse(&doc(3)).expect("three sections");
assert_eq!(one.shunts.len(), 1);
assert_eq!(three.shunts.len(), 1);
let ratio = three.shunts[0].y.im / one.shunts[0].y.im;
assert!(
(ratio - 3.0).abs() < 1e-9,
"three sections must be three times one, got {ratio}"
);
}
#[test]
fn an_hvdc_converter_station_is_counted_rather_than_mistaken_for_a_generator() {
let doc = r#"<?xml version="1.0" encoding="UTF-8"?>
<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_5" id="n" caseDate="2020-01-01T00:00:00.000Z" forecastDistance="0" sourceFormat="test">
<iidm:substation id="S" country="FR">
<iidm:voltageLevel id="VL" nominalV="400.0" topologyKind="BUS_BREAKER">
<iidm:busBreakerTopology><iidm:bus id="B"/></iidm:busBreakerTopology>
<iidm:generator id="G" voltageRegulatorOn="true" targetP="50.0" targetV="400.0" targetQ="0.0" bus="B" connectableBus="B">
<iidm:minMaxReactiveLimits minQ="-100.0" maxQ="100.0"/>
</iidm:generator>
<iidm:load id="L" p0="50.0" q0="0.0" bus="B" connectableBus="B"/>
<iidm:vscConverterStation id="VSC" voltageRegulatorOn="false" lossFactor="1.1" reactivePowerSetpoint="0.0" bus="B" connectableBus="B"/>
</iidm:voltageLevel>
</iidm:substation>
</iidm:network>"#;
let net = iidm::parse(doc).expect("iidm import");
assert!(
net.notes.iter().any(|n| n.contains("vscConverterStation")),
"the station must be named in notes, which were {:?}",
net.notes
);
assert!(
!net.injections.iter().any(|i| i.id == "VSC"),
"a converter station is not a generator"
);
}
#[test]
fn the_two_formats_agree_about_everything_a_solve_does_not_read() {
for stem in ["TestCase12Nodes", "TestCase12NodesDifferentPstTap"] {
let u = gridoxide::ucte::read(ucte_fixture(&format!("{stem}.uct"))).expect("ucte");
let i = iidm::read(fixture(&format!("{stem}.xiidm"))).expect("iidm");
assert_eq!(u.buses.len(), i.buses.len(), "{stem}");
assert_eq!(u.n_branches(), i.n_branches(), "{stem}");
for (n, (ub, ib)) in u.buses.iter().zip(&i.buses).enumerate() {
assert_eq!(ub.bus_type, ib.bus_type, "{stem} bus {n}: bus type");
}
let mut compared = 0;
for b in 0..u.n_branches() {
let (Some(un), Some(inn)) = (u.limits.get(b), i.limits.get(b)) else {
continue;
};
let Some(upatl) = un.patl_a else { continue };
for side in inn {
let Some(ipatl) = side.patl_a else { continue };
assert!(
(upatl - ipatl).abs() < 1e-6 * upatl.abs().max(1.0),
"{stem} branch {b} ({}): PATL {upatl} A (UCTE) against {ipatl} A (IIDM)",
u.branch_ids[b]
);
compared += 1;
}
}
assert!(
compared > 0,
"{stem}: no limits compared, the check is vacuous"
);
eprintln!(
"SWEEP {stem}: {} buses, {} branches, {compared} limits, {} tap changers",
u.buses.len(),
u.n_branches(),
u.tap_changers.iter().filter(|t| t.is_some()).count()
);
for (t, (ut, it)) in u.tap_changers.iter().zip(&i.tap_changers).enumerate() {
match (ut, it) {
(Some(a), Some(b)) => assert_eq!(
a.position, b.position,
"{stem} transformer {t}: tap position"
),
(None, None) => {}
_ => panic!(
"{stem} transformer {t}: one format has a tap changer and the other does not"
),
}
}
}
}