gridoxide_core/topology/bus_view.rs
1//! Deriving an electrical bus view from a node-breaker topology.
2//!
3//! One rule, with one clause more than the importer used to have:
4//!
5//! > Union two nodes iff the switch between them is **closed**, **in service**,
6//! > and **not retained**.
7//!
8//! The first two clauses are what `cgmes::merge_closed_switches` already did.
9//! The third is the whole feature: a retained switch survives into the solver
10//! as an element with an identity, a flow, and a state that can be changed
11//! without re-importing.
12//!
13//! [`RetentionPolicy::MergeAll`] retains nothing, which makes the third clause
14//! vacuous and reproduces the historical behavior exactly. It is the default
15//! everywhere, and every CGMES fixture asserts against it.
16
17use std::collections::HashSet;
18
19use super::model::{BusIdx, NodeBreakerTopology, NodeIdx, SwitchIdx, SwitchKind};
20use super::reduction::{UnionFind, merge_groups};
21
22/// Which switches survive into the bus view as elements rather than being
23/// merged away.
24///
25/// This is the knob that keeps node-breaker support from being a performance
26/// regression. On SmallGrid, [`MergeAll`](Self::MergeAll) gives 167 buses;
27/// [`RetainAll`](Self::RetainAll) gives 1,366 nodes plus 1,266 retained edges,
28/// which for an AC solve is roughly a 16x larger system answering the same
29/// question. Nobody wants that by default, and most people never want it at
30/// all — they want the twenty switches they intend to operate.
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub enum RetentionPolicy {
33 /// Merge every conducting switch. The bus-branch view, and gridoxide's
34 /// historical and default behavior.
35 #[default]
36 MergeAll,
37 /// Retain every operable switch — the full node-breaker view. A
38 /// [`Junction`](SwitchKind::Junction) is still merged, since it has no
39 /// position to retain.
40 RetainAll,
41 /// Retain only these kinds. `RetainKinds(vec![Breaker])` is the usual way
42 /// to get a bus-breaker view on data whose disconnectors are noise.
43 RetainKinds(Vec<SwitchKind>),
44 /// Retain operable switches with a busbar section on at least one side —
45 /// the classic bus-breaker view.
46 ///
47 /// Retains nothing when the source has no busbar concept, which is the
48 /// honest answer rather than a silent fallback to something else.
49 RetainAdjacentToBusbar,
50 /// Retain exactly these, and merge everything else. What a contingency
51 /// campaign uses: name the switches you intend to operate and pay for
52 /// nothing more.
53 Explicit(HashSet<SwitchIdx>),
54}
55
56impl RetentionPolicy {
57 /// Whether `switch` survives into the bus view.
58 ///
59 /// A non-operable switch (a junction) is never retained under any policy:
60 /// there is no state to give it, so retaining it would add an unknown and
61 /// a constraint that could never do anything.
62 fn retains(&self, idx: SwitchIdx, topo: &NodeBreakerTopology) -> bool {
63 let switch = &topo.switches[idx.0];
64 if !switch.kind.is_operable() {
65 return false;
66 }
67 match self {
68 RetentionPolicy::MergeAll => false,
69 RetentionPolicy::RetainAll => true,
70 RetentionPolicy::RetainKinds(kinds) => kinds.contains(&switch.kind),
71 RetentionPolicy::RetainAdjacentToBusbar => {
72 switch.nodes.iter().any(|n| topo.busbars.contains(n))
73 }
74 RetentionPolicy::Explicit(set) => set.contains(&idx),
75 }
76 }
77}
78
79/// A switch that survived into the bus view, with its endpoints resolved to
80/// buses.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct RetainedSwitch {
83 pub switch: SwitchIdx,
84 /// The buses its two nodes landed on.
85 ///
86 /// These can be **equal**: a retained switch whose endpoints are tied
87 /// together by some *other* conducting path merges into one bus anyway. Its
88 /// state then cannot affect connectivity, and a solver formulation must
89 /// treat it as a self-loop rather than as a constraint between two
90 /// distinct buses. See [`BusView::is_degenerate`].
91 pub buses: [BusIdx; 2],
92 /// The position the view was derived *with*.
93 ///
94 /// A snapshot, not a live value: once a switch has been stamped as a
95 /// branch, its position lives there, and
96 /// [`NodeBreakerNetwork::set_switch_open`](crate::switches::NodeBreakerNetwork::set_switch_open)
97 /// changes that rather than this. Ask
98 /// [`is_switch_open`](crate::switches::NodeBreakerNetwork::is_switch_open)
99 /// for the current one.
100 pub open: bool,
101}
102
103/// The mapping from nodes to electrical buses, plus what survived.
104#[derive(Clone, Debug)]
105pub struct BusView {
106 /// Node -> bus, indexed by [`NodeIdx`].
107 bus_of: Vec<BusIdx>,
108 /// Bus -> its nodes, in ascending node order. The inverse of `bus_of`,
109 /// which reporting needs — "which connectivity nodes is this bus?" is the
110 /// question a substation operator actually asks.
111 nodes_of: Vec<Vec<NodeIdx>>,
112 /// Bus -> the union-find root of its group.
113 ///
114 /// Arbitrary but deterministic, and *not* generally the lowest member.
115 /// It exists because a merger has to adopt one member's attributes and
116 /// this is the choice `cgmes::merge_closed_switches` has always made — see
117 /// [`BusView::representative`].
118 representative: Vec<NodeIdx>,
119 retained: Vec<RetainedSwitch>,
120}
121
122impl BusView {
123 pub fn n_buses(&self) -> usize {
124 self.nodes_of.len()
125 }
126
127 pub fn n_nodes(&self) -> usize {
128 self.bus_of.len()
129 }
130
131 /// The bus a node landed on.
132 pub fn bus_of(&self, node: NodeIdx) -> BusIdx {
133 self.bus_of[node.0]
134 }
135
136 /// The nodes that merged into a bus, ascending.
137 pub fn nodes_of(&self, bus: BusIdx) -> &[NodeIdx] {
138 &self.nodes_of[bus.0]
139 }
140
141 /// One node standing for the whole group, for a caller that must collapse
142 /// per-node records into one per-bus record and has to pick whose.
143 ///
144 /// **Arbitrary but deterministic**, and deliberately *not* documented as
145 /// the lowest member — it is the union-find root, which depends on the
146 /// order switches appear in [`NodeBreakerTopology::switches`]. Callers that
147 /// need a stable, order-independent choice should use `nodes_of(bus)[0]`
148 /// instead and accept that it may differ from this.
149 ///
150 /// The distinction is not hypothetical. `cgmes::merge_closed_switches`
151 /// clones this node's `Bus` record — `u_rated`, `bus_type` — into the
152 /// merged bus, and its own comment records that the root was chosen over
153 /// the first-encountered index to make that extraction "provably
154 /// behaviour-preserving rather than merely equivalent-looking". Exposing
155 /// the same choice here is what keeps rerouting through this layer
156 /// bit-identical. The fields that matter do agree across a group in
157 /// practice, since a conducting switch ties nodes at one nominal voltage —
158 /// but "in practice" is not the same as "provably".
159 pub fn representative(&self, bus: BusIdx) -> NodeIdx {
160 self.representative[bus.0]
161 }
162
163 pub fn retained(&self) -> &[RetainedSwitch] {
164 &self.retained
165 }
166
167 /// The raw node -> bus map, for callers that need to remap a parallel
168 /// array in one pass.
169 pub fn bus_of_slice(&self) -> &[BusIdx] {
170 &self.bus_of
171 }
172
173 /// Whether a retained switch's two endpoints landed on the same bus, so
174 /// that its position cannot affect connectivity.
175 ///
176 /// Happens when some other conducting path already ties the two ends —
177 /// a bus-coupler in a ring, or a switch retained in parallel with a merged
178 /// one. Worth knowing about rather than discovering as a singular
179 /// constraint row later.
180 pub fn is_degenerate(&self, retained: &RetainedSwitch) -> bool {
181 retained.buses[0] == retained.buses[1]
182 }
183}
184
185/// Derives the bus view under `policy`.
186///
187/// # Determinism, and why the switch order matters
188///
189/// Nodes are unioned in [`NodeBreakerTopology::switches`] order, and
190/// [`merge_groups`] then numbers the groups by lowest member. Union-find's root
191/// — and therefore which node a caller may treat as a merged group's
192/// representative — depends on that order. This is not an incidental detail:
193/// `cgmes::merge_closed_switches` clones the *root* node's bus record into the
194/// merged bus, so a different order would produce a different (still correct,
195/// but different) `u_rated`, and the fixture tests compare exact values.
196/// Preserving the historical class-by-class order is what makes rerouting the
197/// importer through here bit-identical.
198pub fn bus_view(topo: &NodeBreakerTopology, policy: &RetentionPolicy) -> BusView {
199 let mut uf = UnionFind::new(topo.n_nodes);
200 let mut retained_idx: Vec<SwitchIdx> = Vec::new();
201
202 for (i, switch) in topo.switches.iter().enumerate() {
203 let idx = SwitchIdx(i);
204 if policy.retains(idx, topo) {
205 retained_idx.push(idx);
206 continue;
207 }
208 if switch.conducting() {
209 uf.union(switch.nodes[0].0, switch.nodes[1].0);
210 }
211 }
212
213 let (remap, n_buses) = merge_groups(topo.n_nodes, &mut uf);
214 let bus_of: Vec<BusIdx> = remap.into_iter().map(BusIdx).collect();
215
216 let mut nodes_of: Vec<Vec<NodeIdx>> = vec![Vec::new(); n_buses];
217 // `merge_groups` has already path-compressed every node, so `find` is a
218 // stable lookup here and every member of a group yields the same root.
219 let mut representative: Vec<NodeIdx> = vec![NodeIdx(0); n_buses];
220 for (node, bus) in bus_of.iter().enumerate() {
221 nodes_of[bus.0].push(NodeIdx(node));
222 representative[bus.0] = NodeIdx(uf.find(node));
223 }
224
225 let retained = retained_idx
226 .into_iter()
227 .map(|idx| {
228 let switch = &topo.switches[idx.0];
229 RetainedSwitch {
230 switch: idx,
231 buses: [bus_of[switch.nodes[0].0], bus_of[switch.nodes[1].0]],
232 open: switch.open,
233 }
234 })
235 .collect();
236
237 BusView {
238 bus_of,
239 nodes_of,
240 representative,
241 retained,
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::topology::model::Switch;
249
250 fn sw(kind: SwitchKind, a: usize, b: usize, open: bool) -> Switch {
251 Switch {
252 kind,
253 nodes: [NodeIdx(a), NodeIdx(b)],
254 open,
255 in_service: true,
256 }
257 }
258
259 /// 0 —[closed]— 1 —[open]— 2, plus an isolated node 3.
260 fn fixture() -> NodeBreakerTopology {
261 let mut topo = NodeBreakerTopology::new(4);
262 topo.add_switch(sw(SwitchKind::Breaker, 0, 1, false));
263 topo.add_switch(sw(SwitchKind::Disconnector, 1, 2, true));
264 topo
265 }
266
267 #[test]
268 fn merge_all_collapses_only_conducting_switches() {
269 let topo = fixture();
270 let view = bus_view(&topo, &RetentionPolicy::MergeAll);
271
272 assert_eq!(view.n_nodes(), 4);
273 assert_eq!(view.n_buses(), 3, "0 and 1 merge; 2 and 3 stay their own");
274 assert_eq!(view.bus_of(NodeIdx(0)), view.bus_of(NodeIdx(1)));
275 assert_ne!(view.bus_of(NodeIdx(1)), view.bus_of(NodeIdx(2)));
276 assert!(view.retained().is_empty());
277 assert_eq!(
278 view.nodes_of(view.bus_of(NodeIdx(0))),
279 &[NodeIdx(0), NodeIdx(1)]
280 );
281 }
282
283 /// The defining property of retention: a retained switch does *not* merge
284 /// its ends, even when closed.
285 #[test]
286 fn retain_all_keeps_every_operable_switch_and_merges_nothing() {
287 let topo = fixture();
288 let view = bus_view(&topo, &RetentionPolicy::RetainAll);
289
290 assert_eq!(view.n_buses(), 4, "nothing merged");
291 assert_eq!(view.retained().len(), 2);
292 let closed = view.retained().iter().find(|r| !r.open).unwrap();
293 assert_eq!(closed.buses, [BusIdx(0), BusIdx(1)]);
294 assert!(!view.is_degenerate(closed));
295 }
296
297 #[test]
298 fn retain_kinds_selects_by_class() {
299 let topo = fixture();
300 let view = bus_view(
301 &topo,
302 &RetentionPolicy::RetainKinds(vec![SwitchKind::Breaker]),
303 );
304
305 // The breaker is retained (so 0 and 1 stay apart); the disconnector is
306 // not retained but is open, so it merges nothing either.
307 assert_eq!(view.n_buses(), 4);
308 assert_eq!(view.retained().len(), 1);
309 assert_eq!(view.retained()[0].switch, SwitchIdx(0));
310 }
311
312 #[test]
313 fn explicit_retains_exactly_what_it_names() {
314 let topo = fixture();
315 let policy = RetentionPolicy::Explicit(HashSet::from([SwitchIdx(1)]));
316 let view = bus_view(&topo, &policy);
317
318 assert_eq!(view.retained().len(), 1);
319 assert_eq!(view.retained()[0].switch, SwitchIdx(1));
320 // Switch 0 was not retained and is closed, so it still merges.
321 assert_eq!(view.bus_of(NodeIdx(0)), view.bus_of(NodeIdx(1)));
322 }
323
324 #[test]
325 fn retain_adjacent_to_busbar_needs_busbars_and_says_so_when_absent() {
326 let mut topo = fixture();
327 assert!(
328 bus_view(&topo, &RetentionPolicy::RetainAdjacentToBusbar)
329 .retained()
330 .is_empty(),
331 "with no busbars declared, this policy retains nothing"
332 );
333
334 topo.busbars.push(NodeIdx(2));
335 let view = bus_view(&topo, &RetentionPolicy::RetainAdjacentToBusbar);
336 assert_eq!(view.retained().len(), 1, "only the switch touching node 2");
337 assert_eq!(view.retained()[0].switch, SwitchIdx(1));
338 }
339
340 /// A junction has no position, so no policy retains it — retaining one
341 /// would add an unknown and a constraint that could never do anything.
342 #[test]
343 fn a_junction_is_never_retained() {
344 let mut topo = NodeBreakerTopology::new(2);
345 topo.add_switch(sw(SwitchKind::Junction, 0, 1, false));
346 topo.busbars.push(NodeIdx(0));
347
348 for policy in [
349 RetentionPolicy::RetainAll,
350 RetentionPolicy::RetainKinds(vec![SwitchKind::Junction]),
351 RetentionPolicy::RetainAdjacentToBusbar,
352 RetentionPolicy::Explicit(HashSet::from([SwitchIdx(0)])),
353 ] {
354 let view = bus_view(&topo, &policy);
355 assert!(view.retained().is_empty(), "{policy:?} retained a junction");
356 assert_eq!(view.n_buses(), 1, "{policy:?} failed to merge a junction");
357 }
358 }
359
360 /// An out-of-service switch conducts nothing whatever its position.
361 #[test]
362 fn an_out_of_service_switch_merges_nothing() {
363 let mut topo = NodeBreakerTopology::new(2);
364 topo.add_switch(Switch {
365 kind: SwitchKind::Breaker,
366 nodes: [NodeIdx(0), NodeIdx(1)],
367 open: false,
368 in_service: false,
369 });
370 assert_eq!(bus_view(&topo, &RetentionPolicy::MergeAll).n_buses(), 2);
371 }
372
373 /// A retained switch in parallel with a merged path lands both ends on one
374 /// bus. Its position then cannot change connectivity, and a formulation
375 /// has to know that rather than emit a constraint between a bus and itself.
376 #[test]
377 fn a_retained_switch_parallel_to_a_merged_path_is_degenerate() {
378 let mut topo = NodeBreakerTopology::new(2);
379 topo.add_switch(sw(SwitchKind::Breaker, 0, 1, false)); // retained
380 topo.add_switch(sw(SwitchKind::Junction, 0, 1, false)); // always merged
381
382 let view = bus_view(&topo, &RetentionPolicy::RetainAll);
383 assert_eq!(view.n_buses(), 1);
384 assert_eq!(view.retained().len(), 1);
385 assert!(view.is_degenerate(&view.retained()[0]));
386 }
387
388 /// The representative is a member of its own group and is consistent for
389 /// every member — the property `cgmes::merge_closed_switches` relies on
390 /// when it clones that node's bus record.
391 #[test]
392 fn the_representative_is_a_consistent_member_of_its_group() {
393 let mut topo = NodeBreakerTopology::new(5);
394 topo.add_switch(sw(SwitchKind::Breaker, 3, 4, false));
395 topo.add_switch(sw(SwitchKind::Breaker, 1, 2, false));
396 topo.add_switch(sw(SwitchKind::Breaker, 2, 3, false));
397
398 let view = bus_view(&topo, &RetentionPolicy::MergeAll);
399 for bus in 0..view.n_buses() {
400 let rep = view.representative(BusIdx(bus));
401 assert!(
402 view.nodes_of(BusIdx(bus)).contains(&rep),
403 "bus {bus}'s representative {rep:?} is not one of its nodes"
404 );
405 for &node in view.nodes_of(BusIdx(bus)) {
406 assert_eq!(view.representative(view.bus_of(node)), rep);
407 }
408 }
409 }
410
411 /// Bus numbering follows lowest member, and `nodes_of` inverts `bus_of`
412 /// exactly — both are contracts callers rely on.
413 #[test]
414 fn bus_numbering_and_the_inverse_map_are_consistent() {
415 let mut topo = NodeBreakerTopology::new(5);
416 // Wire the higher nodes together first, to prove numbering does not
417 // follow switch order.
418 topo.add_switch(sw(SwitchKind::Breaker, 3, 4, false));
419 topo.add_switch(sw(SwitchKind::Breaker, 1, 2, false));
420
421 let view = bus_view(&topo, &RetentionPolicy::MergeAll);
422 assert_eq!(view.n_buses(), 3);
423 assert_eq!(view.bus_of(NodeIdx(0)), BusIdx(0));
424 assert_eq!(view.bus_of(NodeIdx(1)), BusIdx(1));
425 assert_eq!(view.bus_of(NodeIdx(3)), BusIdx(2));
426
427 for bus in 0..view.n_buses() {
428 for &node in view.nodes_of(BusIdx(bus)) {
429 assert_eq!(view.bus_of(node), BusIdx(bus));
430 }
431 }
432 let total: usize = (0..view.n_buses())
433 .map(|b| view.nodes_of(BusIdx(b)).len())
434 .sum();
435 assert_eq!(total, view.n_nodes(), "every node lands on exactly one bus");
436 }
437}