Skip to main content

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}