Skip to main content

surge_network/network/
topology.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Physical node-breaker topology model for transmission networks.
3//!
4//! Captures the IEC 61970 (CIM) hierarchy:
5//! **Substation → VoltageLevel → Bay → ConnectivityNode**
6//!
7//! This model lives alongside [`crate::Network`] (which is bus-branch).
8//! Solvers never see `NodeBreakerTopology` directly — it is reduced to bus-branch
9//! by the topology engine in `surge-topology`.  After solving, results are
10//! mapped back to physical elements via [`TopologyMapping`].
11
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16// ---------------------------------------------------------------------------
17// Top-level container
18// ---------------------------------------------------------------------------
19
20/// Physical node-breaker topology model.
21///
22/// When present on [`crate::Network::topology`], the network was
23/// imported from a node-breaker source (CGMES, XIIDM node-breaker).  The model
24/// retains the full physical hierarchy and the mapping from connectivity nodes
25/// to bus-branch buses produced by topology mapping.
26///
27/// When absent, the network is purely bus-branch (MATPOWER, PSS/E, etc.) and
28/// all existing workflows are unaffected.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct NodeBreakerTopology {
31    /// Physical substations.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub substations: Vec<Substation>,
34
35    /// Voltage levels within substations.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub voltage_levels: Vec<VoltageLevel>,
38
39    /// Equipment bays within voltage levels.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub bays: Vec<Bay>,
42
43    /// Physical junction points (connectivity nodes).
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub connectivity_nodes: Vec<ConnectivityNode>,
46
47    /// Physical busbars.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub busbar_sections: Vec<BusbarSection>,
50
51    /// Switching devices (breakers, disconnectors, fuses, …).
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub switches: Vec<SwitchDevice>,
54
55    /// Equipment terminal ↔ connectivity-node associations.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub terminal_connections: Vec<TerminalConnection>,
58
59    /// Reduction produced by the last topology rebuild (connectivity node → bus).
60    /// `None` until topology mapping has been computed.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    mapping: Option<TopologyMapping>,
63
64    /// Whether the retained topology mapping is stale relative to the current
65    /// switch states.
66    ///
67    /// The previous mapping is intentionally kept when switches change so the
68    /// topology engine can reassign existing bus-branch equipment safely during
69    /// `rebuild_topology()`. User-facing lookup helpers treat stale reductions as
70    /// unavailable until a fresh reduction is performed.
71    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
72    mapping_stale: bool,
73}
74
75/// Freshness state for the retained topology mapping.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub enum TopologyMappingState {
78    Missing,
79    Current,
80    Stale,
81}
82
83impl NodeBreakerTopology {
84    /// Build a retained physical topology with no reduction installed yet.
85    #[allow(clippy::too_many_arguments)]
86    pub fn new(
87        substations: Vec<Substation>,
88        voltage_levels: Vec<VoltageLevel>,
89        bays: Vec<Bay>,
90        connectivity_nodes: Vec<ConnectivityNode>,
91        busbar_sections: Vec<BusbarSection>,
92        switches: Vec<SwitchDevice>,
93        terminal_connections: Vec<TerminalConnection>,
94    ) -> Self {
95        Self {
96            substations,
97            voltage_levels,
98            bays,
99            connectivity_nodes,
100            busbar_sections,
101            switches,
102            terminal_connections,
103            mapping: None,
104            mapping_stale: false,
105        }
106    }
107
108    /// Attach a fresh topology mapping and return the updated topology.
109    pub fn with_mapping(mut self, reduction: TopologyMapping) -> Self {
110        self.install_mapping(reduction);
111        self
112    }
113
114    /// Replace the retained mapping with a fresh one.
115    #[doc(hidden)]
116    pub fn install_mapping(&mut self, reduction: TopologyMapping) {
117        self.mapping = Some(reduction);
118        self.mapping_stale = false;
119    }
120
121    /// Remove any retained reduction and reset freshness state.
122    #[doc(hidden)]
123    pub fn clear_mapping(&mut self) {
124        self.mapping = None;
125        self.mapping_stale = false;
126    }
127
128    /// Access the retained mapping even if it is stale.
129    #[doc(hidden)]
130    pub fn retained_mapping(&self) -> Option<&TopologyMapping> {
131        self.mapping.as_ref()
132    }
133
134    /// Set a switch to open (`true`) or closed (`false`).
135    ///
136    /// Returns `true` if the switch was found and its state changed,
137    /// `false` if the switch was not found or state was already equal.
138    pub fn set_switch_state(&mut self, switch_id: &str, open: bool) -> bool {
139        if let Some(sw) = self.switches.iter_mut().find(|s| s.id == switch_id)
140            && sw.open != open
141        {
142            sw.open = open;
143            // Retain the previous mapping so rebuild_topology() can safely remap
144            // existing equipment, but mark it stale for user-facing lookups.
145            self.mapping_stale = true;
146            return true;
147        }
148        false
149    }
150
151    /// Whether the currently stored topology mapping is fresh.
152    pub fn is_current(&self) -> bool {
153        self.mapping.is_some() && !self.mapping_stale
154    }
155
156    /// Freshness state for the retained topology mapping.
157    pub fn status(&self) -> TopologyMappingState {
158        match (self.mapping.is_some(), self.mapping_stale) {
159            (false, _) => TopologyMappingState::Missing,
160            (true, false) => TopologyMappingState::Current,
161            (true, true) => TopologyMappingState::Stale,
162        }
163    }
164
165    /// The current topology mapping, if one is available and fresh.
166    pub fn current_mapping(&self) -> Option<&TopologyMapping> {
167        self.mapping.as_ref().filter(|_| self.is_current())
168    }
169
170    /// Query the current open/closed state of a switch.
171    ///
172    /// Returns `Some(true)` if open, `Some(false)` if closed, `None` if not found.
173    pub fn switch_state(&self, switch_id: &str) -> Option<bool> {
174        self.switches
175            .iter()
176            .find(|s| s.id == switch_id)
177            .map(|s| s.open)
178    }
179
180    /// Return all switches of a given type.
181    pub fn switches_of_kind(&self, sw_type: SwitchType) -> Vec<&SwitchDevice> {
182        self.switches
183            .iter()
184            .filter(|s| s.switch_type == sw_type)
185            .collect()
186    }
187
188    /// Look up which bus a connectivity node is currently mapped to.
189    ///
190    /// Returns `None` if there is no current topology mapping or the node is not
191    /// mapped.
192    pub fn bus_for_connectivity_node(&self, cn_id: &str) -> Option<u32> {
193        self.current_mapping()
194            .and_then(|m| m.connectivity_node_to_bus.get(cn_id).copied())
195    }
196
197    /// Look up which connectivity nodes were merged into a given bus.
198    ///
199    /// Returns `None` if there is no current topology mapping or the bus is not
200    /// found.
201    pub fn connectivity_nodes_for_bus(&self, bus_num: u32) -> Option<&Vec<String>> {
202        self.current_mapping()
203            .and_then(|m| m.bus_to_connectivity_nodes.get(&bus_num))
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Hierarchy elements
209// ---------------------------------------------------------------------------
210
211/// A physical substation (CIM `Substation`).
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Substation {
214    /// Unique identifier (CIM mRID).
215    pub id: String,
216    /// Human-readable name.
217    pub name: String,
218    /// Parent sub-geographical region mRID (optional).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub region: Option<String>,
221}
222
223/// A voltage level within a substation (CIM `VoltageLevel`).
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct VoltageLevel {
226    /// Unique identifier (CIM mRID).
227    pub id: String,
228    /// Human-readable name.
229    pub name: String,
230    /// Parent substation mRID.
231    pub substation_id: String,
232    /// Nominal base voltage in kV.
233    pub base_kv: f64,
234}
235
236/// An equipment bay within a voltage level (CIM `Bay`).
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Bay {
239    /// Unique identifier (CIM mRID).
240    pub id: String,
241    /// Human-readable name.
242    pub name: String,
243    /// Parent voltage-level mRID.
244    pub voltage_level_id: String,
245}
246
247/// A physical junction point in the substation (CIM `ConnectivityNode`).
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ConnectivityNode {
250    /// Unique identifier (CIM mRID).
251    pub id: String,
252    /// Human-readable name.
253    pub name: String,
254    /// Parent voltage-level mRID (via `ConnectivityNodeContainer`).
255    pub voltage_level_id: String,
256}
257
258/// A physical busbar section (CIM `BusbarSection`).
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct BusbarSection {
261    /// Unique identifier (CIM mRID).
262    pub id: String,
263    /// Human-readable name.
264    pub name: String,
265    /// The connectivity node this busbar is connected to.
266    pub connectivity_node_id: String,
267    /// Rated peak withstand current (kA), if known.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub ip_max: Option<f64>,
270}
271
272// ---------------------------------------------------------------------------
273// Switching devices
274// ---------------------------------------------------------------------------
275
276/// Classification of a switching device.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
278pub enum SwitchType {
279    Breaker,
280    Disconnector,
281    LoadBreakSwitch,
282    Fuse,
283    GroundDisconnector,
284    /// Generic CIM `Switch` (unspecified subtype).
285    Switch,
286}
287
288/// A switching device connecting two connectivity nodes.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct SwitchDevice {
291    /// Unique identifier (CIM mRID).
292    pub id: String,
293    /// Human-readable name.
294    pub name: String,
295    /// Device classification.
296    pub switch_type: SwitchType,
297    /// "From" connectivity-node mRID.
298    pub cn1_id: String,
299    /// "To" connectivity-node mRID.
300    pub cn2_id: String,
301    /// `true` = open (no current flow), `false` = closed.
302    pub open: bool,
303    /// Normal (design) open state from the EQ profile.
304    pub normal_open: bool,
305    /// Whether this switch is "retained" — i.e. it defines a topology boundary
306    /// even when closed (CIM `Switch.retained`).
307    #[serde(default)]
308    pub retained: bool,
309    /// Rated continuous current in amperes, if known.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub rated_current: Option<f64>,
312}
313
314// ---------------------------------------------------------------------------
315// Terminal connections
316// ---------------------------------------------------------------------------
317
318/// An equipment terminal's connection to a connectivity node.
319///
320/// This captures the CIM `Terminal → ConnectivityNode` association so that
321/// equipment can be resolved to buses through the topology mapping.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct TerminalConnection {
324    /// CIM Terminal mRID.
325    pub terminal_id: String,
326    /// CIM ConductingEquipment mRID.
327    pub equipment_id: String,
328    /// CIM class name (e.g. `"ACLineSegment"`, `"PowerTransformer"`).
329    pub equipment_class: String,
330    /// Terminal sequence number (1-based, as in CIM).
331    pub sequence_number: u32,
332    /// The connectivity node this terminal connects to.
333    pub connectivity_node_id: String,
334}
335
336// ---------------------------------------------------------------------------
337// Topology reduction (output of reduction)
338// ---------------------------------------------------------------------------
339
340/// The result of reducing a node-breaker model to bus-branch.
341///
342/// Maps connectivity nodes to bus numbers and vice versa, tracking which
343/// switches were "consumed" (closed, merging their CNs) and which CNs ended
344/// up isolated in the current topology mapping.
345#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346pub struct TopologyMapping {
347    /// Connectivity-node mRID → bus number in the reduced `Network`.
348    pub connectivity_node_to_bus: HashMap<String, u32>,
349
350    /// Bus number → list of CN mRIDs that merged into this bus.
351    pub bus_to_connectivity_nodes: HashMap<u32, Vec<String>>,
352
353    /// Switch mRIDs that were consumed (closed, their two CNs share a bus).
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub consumed_switch_ids: Vec<String>,
356
357    /// CN mRIDs that are electrically isolated (no energized equipment path).
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub isolated_connectivity_node_ids: Vec<String>,
360}
361
362// ---------------------------------------------------------------------------
363// Tests
364// ---------------------------------------------------------------------------
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn substation_topology_default_is_empty() {
372        let sm = NodeBreakerTopology::default();
373        assert!(sm.substations.is_empty());
374        assert!(sm.switches.is_empty());
375        assert!(sm.retained_mapping().is_none());
376        assert_eq!(sm.status(), TopologyMappingState::Missing);
377    }
378
379    #[test]
380    fn set_switch_state_toggle() {
381        let mut sm = NodeBreakerTopology::new(
382            Vec::new(),
383            Vec::new(),
384            Vec::new(),
385            Vec::new(),
386            Vec::new(),
387            vec![SwitchDevice {
388                id: "BRK_1".into(),
389                name: "Breaker 1".into(),
390                switch_type: SwitchType::Breaker,
391                cn1_id: "CN_A".into(),
392                cn2_id: "CN_B".into(),
393                open: false,
394                normal_open: false,
395                retained: false,
396                rated_current: None,
397            }],
398            Vec::new(),
399        )
400        .with_mapping(TopologyMapping::default());
401
402        // Open the breaker.
403        assert!(sm.set_switch_state("BRK_1", true));
404        assert_eq!(sm.switch_state("BRK_1"), Some(true));
405        // Previous mapping is retained for retopology, but hidden from lookups.
406        assert!(sm.retained_mapping().is_some());
407        assert_eq!(sm.status(), TopologyMappingState::Stale);
408        assert_eq!(sm.bus_for_connectivity_node("CN_A"), None);
409
410        // No-op (already open).
411        assert!(!sm.set_switch_state("BRK_1", true));
412
413        // Unknown switch.
414        assert!(!sm.set_switch_state("BRK_UNKNOWN", false));
415    }
416
417    #[test]
418    fn switches_of_type_filter() {
419        let sm = NodeBreakerTopology::new(
420            Vec::new(),
421            Vec::new(),
422            Vec::new(),
423            Vec::new(),
424            Vec::new(),
425            vec![
426                SwitchDevice {
427                    id: "BRK_1".into(),
428                    name: "B1".into(),
429                    switch_type: SwitchType::Breaker,
430                    cn1_id: "A".into(),
431                    cn2_id: "B".into(),
432                    open: false,
433                    normal_open: false,
434                    retained: false,
435                    rated_current: None,
436                },
437                SwitchDevice {
438                    id: "DIS_1".into(),
439                    name: "D1".into(),
440                    switch_type: SwitchType::Disconnector,
441                    cn1_id: "B".into(),
442                    cn2_id: "C".into(),
443                    open: false,
444                    normal_open: false,
445                    retained: false,
446                    rated_current: None,
447                },
448            ],
449            Vec::new(),
450        );
451
452        assert_eq!(sm.switches_of_kind(SwitchType::Breaker).len(), 1);
453        assert_eq!(sm.switches_of_kind(SwitchType::Disconnector).len(), 1);
454        assert_eq!(sm.switches_of_kind(SwitchType::Fuse).len(), 0);
455    }
456
457    #[test]
458    fn serde_roundtrip() {
459        let sm = NodeBreakerTopology::new(
460            vec![Substation {
461                id: "SUB_1".into(),
462                name: "Station Alpha".into(),
463                region: Some("RGN_1".into()),
464            }],
465            vec![VoltageLevel {
466                id: "VL_220".into(),
467                name: "220 kV".into(),
468                substation_id: "SUB_1".into(),
469                base_kv: 220.0,
470            }],
471            Vec::new(),
472            vec![ConnectivityNode {
473                id: "CN_A".into(),
474                name: "Node A".into(),
475                voltage_level_id: "VL_220".into(),
476            }],
477            Vec::new(),
478            vec![SwitchDevice {
479                id: "BRK_1".into(),
480                name: "Breaker 1".into(),
481                switch_type: SwitchType::Breaker,
482                cn1_id: "CN_A".into(),
483                cn2_id: "CN_B".into(),
484                open: false,
485                normal_open: false,
486                retained: false,
487                rated_current: Some(2000.0),
488            }],
489            Vec::new(),
490        )
491        .with_mapping(TopologyMapping {
492            connectivity_node_to_bus: [("CN_A".into(), 1), ("CN_B".into(), 1)]
493                .into_iter()
494                .collect(),
495            bus_to_connectivity_nodes: [(1, vec!["CN_A".into(), "CN_B".into()])]
496                .into_iter()
497                .collect(),
498            consumed_switch_ids: vec!["BRK_1".into()],
499            isolated_connectivity_node_ids: vec![],
500        });
501
502        let json = serde_json::to_string(&sm).unwrap();
503        let deser: NodeBreakerTopology = serde_json::from_str(&json).unwrap();
504        assert_eq!(deser.substations.len(), 1);
505        assert_eq!(deser.switches.len(), 1);
506        assert_eq!(deser.switches[0].switch_type, SwitchType::Breaker);
507        assert_eq!(deser.bus_for_connectivity_node("CN_A"), Some(1));
508        assert_eq!(deser.connectivity_nodes_for_bus(1).unwrap().len(), 2);
509        assert!(deser.is_current());
510        assert_eq!(deser.status(), TopologyMappingState::Current);
511    }
512
513    #[test]
514    fn network_serde_without_substation_topology() {
515        // Existing JSON without topology field should deserialize fine.
516        let json = r#"{"name":"test","base_mva":100.0,"buses":[],"branches":[],"generators":[],"loads":[]}"#;
517        let net: crate::Network = serde_json::from_str(json).unwrap();
518        assert!(net.topology.is_none());
519    }
520}