Skip to main content

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}