gridoxide_core/topology/model.rs
1//! The node-breaker description: connectivity nodes, and the switches between
2//! them.
3//!
4//! This is what an importer produces *before* deciding what an electrical bus
5//! is. Turning it into buses is [`bus_view`](fn@super::bus_view)'s job, and the
6//! two are separate precisely so that the decision can be made per solve
7//! rather than baked in at import — which is what gridoxide did until now, and
8//! why switching state was frozen the moment a file was read.
9
10/// A connectivity node: a point where equipment terminals meet, before any
11/// merging.
12///
13/// Distinct from [`BusIdx`] in the type system on purpose — see this module's
14/// parent doc for the bug that motivates it.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct NodeIdx(pub usize);
17
18/// A resolved electrical bus: one or more nodes merged together, and what the
19/// solver actually indexes.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct BusIdx(pub usize);
22
23/// Position of a switch in [`NodeBreakerTopology::switches`].
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct SwitchIdx(pub usize);
26
27/// The switching-device classes gridoxide distinguishes.
28///
29/// These are the nine CIM switch classes `cgmes::merge_closed_switches`
30/// already reads, plus [`Junction`](Self::Junction). The distinction is not
31/// cosmetic: a retention policy that keeps breakers but merges disconnectors
32/// is the standard way to get a bus-breaker view, and that needs the kind to
33/// survive import.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub enum SwitchKind {
36 /// CIM `Switch` — the base class, used directly when nothing more
37 /// specific applies.
38 Generic,
39 Breaker,
40 Disconnector,
41 LoadBreakSwitch,
42 DisconnectingCircuitBreaker,
43 GroundDisconnector,
44 Jumper,
45 Cut,
46 Fuse,
47 /// CIM `Junction`: "a point where one or more conducting equipment are
48 /// connected with zero impedance".
49 ///
50 /// Not switchable at all — CIM gives it no `open` state — so it is a
51 /// permanent tie rather than a device with a position. It is represented
52 /// here anyway so that a node-breaker view can show *why* two nodes are
53 /// one, and [`Switch::open`] is always false for it.
54 Junction,
55}
56
57impl SwitchKind {
58 /// Whether this kind has an operable position at all.
59 ///
60 /// False only for [`Junction`](Self::Junction). A retention policy has no
61 /// reason to retain something that cannot be operated, and a switching
62 /// campaign has no reason to enumerate it.
63 pub fn is_operable(self) -> bool {
64 !matches!(self, SwitchKind::Junction)
65 }
66}
67
68/// One switching device, between exactly two nodes.
69///
70/// Deliberately has no `retained` field. Retention is a property of the
71/// *question being asked* — see [`RetentionPolicy`](super::RetentionPolicy) —
72/// not of the device, and giving a switch a stored flag as well would mean two
73/// mechanisms deciding one thing. If a source model turns out to carry its own
74/// retained flag (powsybl's node-breaker importer has the concept), the policy
75/// gains a variant that consults it rather than the struct gaining a field.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub struct Switch {
78 pub kind: SwitchKind,
79 pub nodes: [NodeIdx; 2],
80 /// Position. Always false for [`SwitchKind::Junction`], which has none.
81 pub open: bool,
82 /// Whether the device is in service at all. An out-of-service switch
83 /// conducts nothing regardless of position.
84 pub in_service: bool,
85}
86
87impl Switch {
88 /// Whether this device currently ties its two nodes together: in service,
89 /// and closed.
90 pub fn conducting(&self) -> bool {
91 self.in_service && !self.open
92 }
93}
94
95/// A node-breaker network: how many connectivity nodes there are, and what
96/// switches connect them.
97///
98/// # Why nodes are a count rather than records
99///
100/// There is nothing to *put* in a node record yet. The only producer today is
101/// `cgmes::merge_closed_switches`, for which a "node" is a pre-merge
102/// `TopologicalNode` that the importer already holds as a `Bus`; a parallel
103/// `Vec<Node>` would duplicate that and immediately go stale. When the
104/// node-breaker CGMES reader lands and nodes gain identity of their own —
105/// mRID, voltage level, substation, bay containment — this becomes
106/// `nodes: Vec<Node>` and `n_nodes` becomes `nodes.len()`. That is a small,
107/// mechanical change, and making it later costs less than carrying an empty
108/// struct now.
109#[derive(Clone, Debug, Default)]
110pub struct NodeBreakerTopology {
111 /// Number of connectivity nodes. Every [`NodeIdx`] must be below this.
112 pub n_nodes: usize,
113 /// Every switching device, **in a fixed order that callers may depend on**.
114 ///
115 /// [`bus_view`](super::bus_view::bus_view) unions in this order, and
116 /// union-find's root — and therefore which node represents a merged group
117 /// — depends on it. `cgmes::merge_closed_switches` preserves its historical
118 /// class-by-class order for exactly this reason, so that rerouting through
119 /// this layer is bit-identical.
120 pub switches: Vec<Switch>,
121 /// Nodes that are busbar sections, for
122 /// [`RetentionPolicy::RetainAdjacentToBusbar`](super::RetentionPolicy).
123 /// Empty when the source has no busbar concept, in which case that policy
124 /// retains nothing.
125 pub busbars: Vec<NodeIdx>,
126}
127
128impl NodeBreakerTopology {
129 /// A topology with `n_nodes` nodes and no switches — every node is its own
130 /// bus under any policy.
131 pub fn new(n_nodes: usize) -> Self {
132 Self {
133 n_nodes,
134 switches: Vec::new(),
135 busbars: Vec::new(),
136 }
137 }
138
139 /// Appends a switch, returning its index.
140 pub fn add_switch(&mut self, switch: Switch) -> SwitchIdx {
141 debug_assert!(switch.nodes[0].0 < self.n_nodes && switch.nodes[1].0 < self.n_nodes);
142 self.switches.push(switch);
143 SwitchIdx(self.switches.len() - 1)
144 }
145
146 /// How many switches are in service and closed — the ones that actually
147 /// tie nodes together right now.
148 pub fn conducting_count(&self) -> usize {
149 self.switches.iter().filter(|s| s.conducting()).count()
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn switch(kind: SwitchKind, a: usize, b: usize, open: bool, in_service: bool) -> Switch {
158 Switch {
159 kind,
160 nodes: [NodeIdx(a), NodeIdx(b)],
161 open,
162 in_service,
163 }
164 }
165
166 #[test]
167 fn conducting_requires_both_in_service_and_closed() {
168 assert!(switch(SwitchKind::Breaker, 0, 1, false, true).conducting());
169 assert!(!switch(SwitchKind::Breaker, 0, 1, true, true).conducting());
170 assert!(!switch(SwitchKind::Breaker, 0, 1, false, false).conducting());
171 assert!(!switch(SwitchKind::Breaker, 0, 1, true, false).conducting());
172 }
173
174 /// A junction is a permanent tie, not a device with a position — CIM gives
175 /// it no `open` at all.
176 #[test]
177 fn only_a_junction_is_inoperable() {
178 assert!(!SwitchKind::Junction.is_operable());
179 for kind in [
180 SwitchKind::Generic,
181 SwitchKind::Breaker,
182 SwitchKind::Disconnector,
183 SwitchKind::LoadBreakSwitch,
184 SwitchKind::DisconnectingCircuitBreaker,
185 SwitchKind::GroundDisconnector,
186 SwitchKind::Jumper,
187 SwitchKind::Cut,
188 SwitchKind::Fuse,
189 ] {
190 assert!(kind.is_operable(), "{kind:?}");
191 }
192 }
193
194 #[test]
195 fn add_switch_returns_a_usable_index_and_counts_conducting() {
196 let mut topo = NodeBreakerTopology::new(4);
197 let a = topo.add_switch(switch(SwitchKind::Breaker, 0, 1, false, true));
198 let b = topo.add_switch(switch(SwitchKind::Disconnector, 1, 2, true, true));
199 assert_eq!((a, b), (SwitchIdx(0), SwitchIdx(1)));
200 assert_eq!(topo.conducting_count(), 1);
201 }
202}