use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use gridoxide::cgmes::{CgmesNodeBreaker, cgmes_node_breaker_topology, load_profiles};
use gridoxide::topology::model::{BusIdx, NodeIdx};
use gridoxide::topology::{BusView, RetentionPolicy, bus_view};
struct Fixture {
name: &'static str,
dir: &'static str,
prefix: &'static str,
}
const FIXTURES: &[Fixture] = &[
Fixture {
name: "MiniGrid",
dir: "MiniGrid/MiniGrid-Merged",
prefix: "MiniGrid",
},
Fixture {
name: "FullGrid",
dir: "FullGrid/FullGrid-Merged",
prefix: "FullGrid",
},
Fixture {
name: "SmallGrid",
dir: "SmallGrid/SmallGrid-Merged",
prefix: "SmallGrid",
},
Fixture {
name: "Svedala",
dir: "Svedala/Svedala-Merged",
prefix: "Svedala",
},
];
fn profile_dir(fixture: &Fixture) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/CGMES-Test-Configurations/v3.0")
.join(fixture.dir)
}
fn available(fixture: &Fixture) -> Option<PathBuf> {
let dir = profile_dir(fixture);
if dir.exists() {
return Some(dir);
}
eprintln!(
"skipping {}: {} not found — run `git submodule update --init \
tests/data/CGMES-Test-Configurations`",
fixture.name,
dir.display()
);
None
}
fn read(fixture: &Fixture, with_tp: bool) -> Option<CgmesNodeBreaker> {
let dir = available(fixture)?;
let eq_bd = dir.join(format!("{}_EQBD.xml", fixture.prefix));
let eq = dir.join(format!("{}_EQ.xml", fixture.prefix));
let ssh = dir.join(format!("{}_SSH.xml", fixture.prefix));
let tp = dir.join(format!("{}_TP.xml", fixture.prefix));
let mut paths: Vec<&Path> = vec![&eq_bd, &eq, &ssh];
if with_tp {
paths.push(&tp);
}
let ds = load_profiles(&paths).expect("failed to decode CGMES profiles");
Some(cgmes_node_breaker_topology(&ds).expect("node-breaker read failed"))
}
fn nodes_by_topological_node(nb: &CgmesNodeBreaker) -> HashMap<&str, Vec<usize>> {
let mut out: HashMap<&str, Vec<usize>> = HashMap::new();
for (node, tn) in nb.tn_of_node.iter().enumerate() {
if let Some(tn) = tn {
out.entry(tn.as_str()).or_default().push(node);
}
}
out
}
fn crossing_switches<'a>(
nb: &'a CgmesNodeBreaker,
view: &BusView,
nodes: &[usize],
) -> Vec<&'a gridoxide::topology::Switch> {
let set: HashSet<usize> = nodes.iter().copied().collect();
nb.topology
.switches
.iter()
.filter(|s| {
set.contains(&s.nodes[0].0)
&& set.contains(&s.nodes[1].0)
&& view.bus_of(s.nodes[0]) != view.bus_of(s.nodes[1])
})
.collect()
}
#[test]
fn every_disagreement_with_the_exporter_is_explained_by_an_open_switch() {
let mut checked = 0;
for fixture in FIXTURES {
let Some(nb) = read(fixture, true) else {
continue;
};
let view = bus_view(&nb.topology, &RetentionPolicy::MergeAll);
let by_tn = nodes_by_topological_node(&nb);
checked += 1;
assert!(
!nb.topology.switches.is_empty(),
"{}: no switches read",
fixture.name
);
assert!(
nb.topology.n_nodes > by_tn.len(),
"{}: {} CN is not more than {} TN — not a node-breaker export?",
fixture.name,
nb.topology.n_nodes,
by_tn.len()
);
let mut splits = 0;
let mut tns_of_bus: HashMap<usize, HashSet<&str>> = HashMap::new();
for (tn, nodes) in &by_tn {
let buses: HashSet<BusIdx> = nodes.iter().map(|&n| view.bus_of(NodeIdx(n))).collect();
for b in &buses {
tns_of_bus.entry(b.0).or_default().insert(tn);
}
if buses.len() == 1 {
continue;
}
splits += 1;
let crossing = crossing_switches(&nb, &view, nodes);
assert!(
!crossing.is_empty(),
"{}: TopologicalNode {tn} is split across {} gridoxide buses with no switch \
between the parts at all — the reader missed an edge",
fixture.name,
buses.len()
);
for s in &crossing {
assert!(
!s.conducting(),
"{}: TopologicalNode {tn} is split, but {:?} between its parts is \
conducting — gridoxide should have merged it",
fixture.name,
s.kind
);
}
}
let multi_tn = tns_of_bus.values().filter(|s| s.len() > 1).count();
eprintln!(
"{:<10} CN={:<5} sw={:<5} ({:<5} conducting) bb={:<4} TN={:<4} buses={:<5} \
splits={splits} multi-TN buses={multi_tn}",
fixture.name,
nb.topology.n_nodes,
nb.topology.switches.len(),
nb.topology.conducting_count(),
nb.topology.busbars.len(),
by_tn.len(),
tns_of_bus.len(),
);
}
assert!(
checked > 0,
"no CGMES fixture was available; the gate proved nothing"
);
}
#[test]
fn minigrid_reproduces_the_exporter_partition_exactly() {
let fixture = &FIXTURES[0];
assert_eq!(fixture.name, "MiniGrid");
let Some(nb) = read(fixture, true) else {
return;
};
let view = bus_view(&nb.topology, &RetentionPolicy::MergeAll);
let by_tn = nodes_by_topological_node(&nb);
assert_eq!(nb.topology.n_nodes, 103);
assert_eq!(nb.topology.switches.len(), 90);
assert_eq!(
nb.topology.conducting_count(),
90,
"every MiniGrid switch is closed"
);
assert_eq!(by_tn.len(), 13);
let mut buses_seen: HashSet<BusIdx> = HashSet::new();
for (tn, nodes) in &by_tn {
let buses: HashSet<BusIdx> = nodes.iter().map(|&n| view.bus_of(NodeIdx(n))).collect();
assert_eq!(
buses.len(),
1,
"TopologicalNode {tn} split across {} buses",
buses.len()
);
assert!(
buses_seen.insert(*buses.iter().next().unwrap()),
"two TopologicalNodes landed on one bus — gridoxide merged further than the exporter"
);
}
assert_eq!(buses_seen.len(), 13);
}
#[test]
fn fullgrid_still_needs_merging_the_exporter_did_not_do() {
let fixture = &FIXTURES[1];
assert_eq!(fixture.name, "FullGrid");
let Some(nb) = read(fixture, true) else {
return;
};
let view = bus_view(&nb.topology, &RetentionPolicy::MergeAll);
let mut tns_of_bus: HashMap<BusIdx, HashSet<&str>> = HashMap::new();
for (tn, nodes) in &nodes_by_topological_node(&nb) {
for &n in nodes {
tns_of_bus
.entry(view.bus_of(NodeIdx(n)))
.or_default()
.insert(tn);
}
}
assert!(
tns_of_bus.values().any(|s| s.len() > 1),
"FullGrid no longer has a bus spanning several TopologicalNodes"
);
}
#[test]
fn busbars_are_read_and_the_busbar_policy_selects_a_strict_subset() {
let mut checked = 0;
for fixture in FIXTURES {
let Some(nb) = read(fixture, false) else {
continue;
};
checked += 1;
assert!(
!nb.topology.busbars.is_empty(),
"{}: no busbar sections read",
fixture.name
);
let all = bus_view(&nb.topology, &RetentionPolicy::RetainAll);
let busbar = bus_view(&nb.topology, &RetentionPolicy::RetainAdjacentToBusbar);
let merged = bus_view(&nb.topology, &RetentionPolicy::MergeAll);
assert!(
!busbar.retained().is_empty(),
"{}: the busbar-adjacent policy retained nothing despite {} busbars",
fixture.name,
nb.topology.busbars.len()
);
assert!(
busbar.retained().len() < all.retained().len(),
"{}: the busbar-adjacent policy retained everything, so it is not a subset",
fixture.name
);
assert!(merged.n_buses() <= busbar.n_buses());
assert!(busbar.n_buses() <= all.n_buses());
assert_eq!(
all.n_buses(),
nb.topology.n_nodes,
"{}: RetainAll must merge nothing but junctions or nothing at all",
fixture.name
);
eprintln!(
"{:<10} retained: all={:<5} busbar-adjacent={:<5} buses: merged={:<5} \
busbar={:<5} all={}",
fixture.name,
all.retained().len(),
busbar.retained().len(),
merged.n_buses(),
busbar.n_buses(),
all.n_buses(),
);
}
assert!(checked > 0, "no CGMES fixture was available");
}
#[test]
fn the_bus_partition_does_not_depend_on_the_tp_profile() {
let mut checked = 0;
for fixture in FIXTURES {
let (Some(without), Some(with)) = (read(fixture, false), read(fixture, true)) else {
continue;
};
checked += 1;
assert_eq!(
without.topology.n_nodes, with.topology.n_nodes,
"{}",
fixture.name
);
assert_eq!(
without.topology.switches.len(),
with.topology.switches.len(),
"{}",
fixture.name
);
assert!(
without.tn_of_node.iter().all(|t| t.is_none()),
"{}: TopologicalNode references appeared without the TP profile",
fixture.name
);
assert!(
with.tn_of_node.iter().any(|t| t.is_some()),
"{}: no TopologicalNode references even with TP loaded",
fixture.name
);
let group = |nb: &CgmesNodeBreaker| -> HashMap<String, usize> {
let view = bus_view(&nb.topology, &RetentionPolicy::MergeAll);
nb.node_mrids
.iter()
.enumerate()
.map(|(n, mrid)| (mrid.clone(), view.bus_of(NodeIdx(n)).0))
.collect()
};
let (a, b) = (group(&without), group(&with));
assert_eq!(a.len(), b.len(), "{}", fixture.name);
let mut canonical: HashMap<usize, usize> = HashMap::new();
for (mrid, bus_a) in &a {
let bus_b = b[mrid];
match canonical.get(bus_a) {
None => {
canonical.insert(*bus_a, bus_b);
}
Some(&expected) => assert_eq!(
bus_b, expected,
"{}: node {mrid} groups differently with and without TP",
fixture.name
),
}
}
assert_eq!(
canonical.values().collect::<HashSet<_>>().len(),
canonical.len(),
"{}: two buses without TP collapsed into one with TP",
fixture.name
);
}
assert!(checked > 0, "no CGMES fixture was available");
}
#[test]
fn the_returned_indices_are_internally_consistent() {
for fixture in FIXTURES {
let Some(nb) = read(fixture, false) else {
continue;
};
assert_eq!(nb.node_mrids.len(), nb.topology.n_nodes, "{}", fixture.name);
assert_eq!(nb.tn_of_node.len(), nb.topology.n_nodes, "{}", fixture.name);
assert_eq!(
nb.switch_mrids.len(),
nb.topology.switches.len(),
"{}",
fixture.name
);
for s in &nb.topology.switches {
for n in s.nodes {
assert!(
n.0 < nb.topology.n_nodes,
"{}: node {n:?} out of range",
fixture.name
);
}
}
for b in &nb.topology.busbars {
assert!(
b.0 < nb.topology.n_nodes,
"{}: busbar {b:?} out of range",
fixture.name
);
}
let unique: HashSet<&String> = nb.node_mrids.iter().collect();
assert_eq!(
unique.len(),
nb.node_mrids.len(),
"{}: duplicate CN mrid",
fixture.name
);
let view = bus_view(&nb.topology, &RetentionPolicy::MergeAll);
let total: usize = (0..view.n_buses())
.map(|b| view.nodes_of(BusIdx(b)).len())
.sum();
assert_eq!(total, nb.topology.n_nodes, "{}", fixture.name);
}
}