Skip to main content

powerio_dist/
graph.rs

1//! Render ready bus and terminal graph projection.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::{
8    DistBus, DistIbr, DistTransformer, DistWinding, MulticonductorNetwork, VoltageSource, pair_keys,
9};
10
11/// Upper bound on the winding count the transformer edge expansion is
12/// materialized at, matching the readers' own winding cap.
13const MAX_WINDING_PAIRS_DIM: usize = 64;
14
15/// A collapsed bus graph with terminal level attachments and conductor maps.
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct DistGraph {
19    pub buses: Vec<DistGraphBus>,
20    pub edges: Vec<DistGraphEdge>,
21}
22
23/// One bus node in the collapsed graph.
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25#[non_exhaustive]
26pub struct DistGraphBus {
27    pub id: String,
28    pub terminals: Vec<String>,
29    pub grounded: Vec<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub xy: Option<[f64; 2]>,
32    pub load_kw: f64,
33    pub gen_kw: f64,
34    pub has_source: bool,
35    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
36    pub terminal_attachments: BTreeMap<String, Vec<DistGraphAttachment>>,
37}
38
39/// An element connected to a bus terminal.
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[non_exhaustive]
42pub struct DistGraphAttachment {
43    pub kind: DistGraphAttachmentKind,
44    pub id: String,
45}
46
47/// Element family for terminal attachments.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum DistGraphAttachmentKind {
52    Load,
53    Generator,
54    Ibr,
55    Shunt,
56    Source,
57}
58
59/// One edge in the collapsed graph.
60#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
61#[non_exhaustive]
62pub struct DistGraphEdge {
63    pub kind: DistGraphEdgeKind,
64    pub id: String,
65    pub from: String,
66    pub to: String,
67    /// `(from_terminal, to_terminal)` pairs in conductor order.
68    pub conductors: Vec<(String, String)>,
69    pub closed: bool,
70    pub n_phases: usize,
71}
72
73/// Edge family in the collapsed graph.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76#[non_exhaustive]
77pub enum DistGraphEdgeKind {
78    Line,
79    Switch,
80    Transformer,
81}
82
83impl MulticonductorNetwork {
84    /// Transform this network into a render ready bus and terminal graph.
85    #[must_use]
86    pub fn to_graph(&self) -> DistGraph {
87        DistGraph::from_network(self)
88    }
89}
90
91impl DistGraph {
92    /// Build the graph projection for one distribution network.
93    #[must_use]
94    pub fn from_network(net: &MulticonductorNetwork) -> Self {
95        let mut builder = GraphBuilder::new(net.buses());
96
97        for line in net.lines() {
98            let from = builder.canonical_bus_id(&line.bus_from);
99            let to = builder.canonical_bus_id(&line.bus_to);
100            builder.edges.push(DistGraphEdge {
101                kind: DistGraphEdgeKind::Line,
102                id: line.name.clone(),
103                from,
104                to,
105                conductors: conductor_pairs(&line.terminal_map_from, &line.terminal_map_to),
106                closed: true,
107                n_phases: line.terminal_map_from.len().min(line.terminal_map_to.len()),
108            });
109        }
110
111        for switch in net.switches() {
112            let from = builder.canonical_bus_id(&switch.bus_from);
113            let to = builder.canonical_bus_id(&switch.bus_to);
114            builder.edges.push(DistGraphEdge {
115                kind: DistGraphEdgeKind::Switch,
116                id: switch.name.clone(),
117                from,
118                to,
119                conductors: conductor_pairs(&switch.terminal_map_from, &switch.terminal_map_to),
120                closed: !switch.open,
121                n_phases: switch
122                    .terminal_map_from
123                    .len()
124                    .min(switch.terminal_map_to.len()),
125            });
126        }
127
128        for transformer in net.transformers() {
129            builder.add_transformer_edges(transformer);
130        }
131
132        for load in net.loads() {
133            builder.add_load(
134                &load.bus,
135                &load.terminal_map,
136                &load.name,
137                watts_to_kw(&load.p_nom),
138            );
139        }
140        for generator in net.generators() {
141            builder.add_generator(
142                &generator.bus,
143                &generator.terminal_map,
144                &generator.name,
145                watts_to_kw(&generator.p_nom),
146            );
147        }
148        for ibr in net.ibrs() {
149            builder.add_ibr(ibr);
150        }
151        for shunt in net.shunts() {
152            builder.add_attachment(
153                &shunt.bus,
154                &shunt.terminal_map,
155                DistGraphAttachmentKind::Shunt,
156                &shunt.name,
157            );
158        }
159        // A rated capacitor bank attaches to a bus exactly like a raw
160        // admittance shunt. It projects as one, so connectivity and the geo
161        // layer see the bank.
162        for capacitor in net.capacitors() {
163            builder.add_attachment(
164                &capacitor.bus,
165                &capacitor.terminal_map,
166                DistGraphAttachmentKind::Shunt,
167                &capacitor.name,
168            );
169        }
170        for source in net.sources() {
171            builder.add_source(source);
172        }
173
174        DistGraph {
175            buses: builder.buses,
176            edges: builder.edges,
177        }
178    }
179}
180
181struct GraphBuilder {
182    buses: Vec<DistGraphBus>,
183    bus_index: BTreeMap<String, usize>,
184    edges: Vec<DistGraphEdge>,
185}
186
187impl GraphBuilder {
188    fn new(buses: &[DistBus]) -> Self {
189        let mut builder = Self {
190            buses: Vec::new(),
191            bus_index: BTreeMap::new(),
192            edges: Vec::new(),
193        };
194        for bus in buses {
195            builder.push_bus(bus);
196        }
197        builder
198    }
199
200    fn push_bus(&mut self, bus: &DistBus) {
201        let index = self.buses.len();
202        self.bus_index.insert(bus_key(&bus.id), index);
203        self.buses.push(DistGraphBus {
204            id: bus.id.clone(),
205            terminals: bus.terminals.clone(),
206            grounded: bus.grounded.clone(),
207            xy: bus_xy(bus),
208            load_kw: 0.0,
209            gen_kw: 0.0,
210            has_source: false,
211            terminal_attachments: bus
212                .terminals
213                .iter()
214                .map(|terminal| (terminal.clone(), Vec::new()))
215                .collect(),
216        });
217    }
218
219    fn bus_index(&mut self, id: &str) -> usize {
220        let key = bus_key(id);
221        if let Some(index) = self.bus_index.get(&key) {
222            return *index;
223        }
224        let index = self.buses.len();
225        self.bus_index.insert(key, index);
226        self.buses.push(DistGraphBus {
227            id: id.to_owned(),
228            terminals: Vec::new(),
229            grounded: Vec::new(),
230            xy: None,
231            load_kw: 0.0,
232            gen_kw: 0.0,
233            has_source: false,
234            terminal_attachments: BTreeMap::new(),
235        });
236        index
237    }
238
239    fn canonical_bus_id(&mut self, id: &str) -> String {
240        let index = self.bus_index(id);
241        self.buses[index].id.clone()
242    }
243
244    fn add_transformer_edges(&mut self, transformer: &DistTransformer) {
245        // One edge per winding pair, so the winding count expands
246        // quadratically. Every reader caps it at the same bound, but a
247        // callers can construct a `MulticonductorNetwork` without those caps.
248        // No physical transformer comes near it.
249        let n_windings = transformer.windings.len().min(MAX_WINDING_PAIRS_DIM);
250        for (from_idx, to_idx) in pair_keys(n_windings) {
251            let Some(from_winding) = transformer.windings.get(from_idx) else {
252                continue;
253            };
254            let Some(to_winding) = transformer.windings.get(to_idx) else {
255                continue;
256            };
257            let from = self.canonical_bus_id(&from_winding.bus);
258            let to = self.canonical_bus_id(&to_winding.bus);
259            self.edges.push(transformer_edge(
260                transformer,
261                from,
262                to,
263                from_winding,
264                to_winding,
265            ));
266        }
267    }
268
269    fn add_load(&mut self, bus: &str, terminals: &[String], id: &str, load_kw: f64) {
270        let index = self.bus_index(bus);
271        self.buses[index].load_kw += load_kw;
272        self.add_attachment(bus, terminals, DistGraphAttachmentKind::Load, id);
273    }
274
275    fn add_generator(&mut self, bus: &str, terminals: &[String], id: &str, gen_kw: f64) {
276        let index = self.bus_index(bus);
277        self.buses[index].gen_kw += gen_kw;
278        self.add_attachment(bus, terminals, DistGraphAttachmentKind::Generator, id);
279    }
280
281    fn add_ibr(&mut self, ibr: &DistIbr) {
282        let index = self.bus_index(&ibr.bus);
283        self.buses[index].gen_kw += ibr_kw(ibr);
284        self.add_attachment(
285            &ibr.bus,
286            &ibr.terminal_map,
287            DistGraphAttachmentKind::Ibr,
288            &ibr.name,
289        );
290    }
291
292    fn add_source(&mut self, source: &VoltageSource) {
293        let index = self.bus_index(&source.bus);
294        self.buses[index].has_source = true;
295        self.add_attachment(
296            &source.bus,
297            &source.terminal_map,
298            DistGraphAttachmentKind::Source,
299            &source.name,
300        );
301    }
302
303    fn add_attachment(
304        &mut self,
305        bus: &str,
306        terminals: &[String],
307        kind: DistGraphAttachmentKind,
308        id: &str,
309    ) {
310        let index = self.bus_index(bus);
311        let attachment = DistGraphAttachment {
312            kind,
313            id: id.to_owned(),
314        };
315        if terminals.is_empty() {
316            self.buses[index]
317                .terminal_attachments
318                .entry(String::new())
319                .or_default()
320                .push(attachment);
321            return;
322        }
323        for terminal in terminals {
324            self.buses[index]
325                .terminal_attachments
326                .entry(terminal.clone())
327                .or_default()
328                .push(attachment.clone());
329        }
330    }
331}
332
333fn transformer_edge(
334    transformer: &DistTransformer,
335    from: String,
336    to: String,
337    from_winding: &DistWinding,
338    to_winding: &DistWinding,
339) -> DistGraphEdge {
340    DistGraphEdge {
341        kind: DistGraphEdgeKind::Transformer,
342        id: transformer.name.clone(),
343        from,
344        to,
345        conductors: conductor_pairs(&from_winding.terminal_map, &to_winding.terminal_map),
346        closed: true,
347        n_phases: transformer.phases,
348    }
349}
350
351fn conductor_pairs(from: &[String], to: &[String]) -> Vec<(String, String)> {
352    from.iter().cloned().zip(to.iter().cloned()).collect()
353}
354
355fn watts_to_kw(values: &[f64]) -> f64 {
356    values.iter().sum::<f64>() / 1000.0
357}
358
359fn ibr_kw(ibr: &DistIbr) -> f64 {
360    ibr.p_avail
361        .or_else(|| ibr.p_max.as_ref().map(|p| p.iter().sum()))
362        .unwrap_or(0.0)
363        / 1000.0
364}
365
366fn bus_key(id: &str) -> String {
367    id.to_ascii_lowercase()
368}
369
370fn bus_xy(bus: &DistBus) -> Option<[f64; 2]> {
371    if let Some(location) = bus.location
372        && location.x.is_finite()
373        && location.y.is_finite()
374    {
375        return Some([location.x, location.y]);
376    }
377    let x = number_extra(&bus.extras, &["x", "lon", "lng", "longitude"])?;
378    let y = number_extra(&bus.extras, &["y", "lat", "latitude"])?;
379    Some([x, y])
380}
381
382fn number_extra(extras: &BTreeMap<String, serde_json::Value>, names: &[&str]) -> Option<f64> {
383    names.iter().find_map(|name| {
384        extras
385            .get(*name)
386            .and_then(serde_json::Value::as_f64)
387            .filter(|value| value.is_finite())
388    })
389}
390
391#[cfg(test)]
392mod tests {
393    use crate::model::MulticonductorNetworkTables;
394    use std::path::Path;
395
396    use super::*;
397    use crate::model::{Configuration, DistGenerator, DistLoad};
398
399    fn strings(values: &[&str]) -> Vec<String> {
400        values.iter().map(|value| (*value).to_owned()).collect()
401    }
402
403    fn assert_close(actual: f64, expected: f64) {
404        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
405    }
406
407    fn fixture(path: &str) -> std::path::PathBuf {
408        Path::new(env!("CARGO_MANIFEST_DIR"))
409            .join("../tests/data/dist")
410            .join(path)
411    }
412
413    fn bus<'a>(graph: &'a DistGraph, id: &str) -> &'a DistGraphBus {
414        graph
415            .buses
416            .iter()
417            .find(|bus| bus.id.eq_ignore_ascii_case(id))
418            .expect("graph bus exists")
419    }
420
421    fn edge<'a>(graph: &'a DistGraph, kind: DistGraphEdgeKind, id: &str) -> &'a DistGraphEdge {
422        graph
423            .edges
424            .iter()
425            .find(|edge| edge.kind == kind && edge.id.eq_ignore_ascii_case(id))
426            .expect("graph edge exists")
427    }
428
429    #[test]
430    fn graph_projects_open_switch_fixture() {
431        let net =
432            crate::testkit::parse_file(fixture("micro/switch.dss"), None).expect("parse switch");
433        let graph = net.to_graph();
434
435        let open = edge(&graph, DistGraphEdgeKind::Switch, "sw_open");
436        assert!(!open.closed);
437        assert_eq!(open.from, "mid");
438        assert_eq!(open.to, "stub");
439        assert_eq!(
440            open.conductors,
441            vec![
442                ("1".to_owned(), "1".to_owned()),
443                ("2".to_owned(), "2".to_owned()),
444                ("3".to_owned(), "3".to_owned())
445            ]
446        );
447        assert_eq!(open.n_phases, 3);
448
449        let closed = edge(&graph, DistGraphEdgeKind::Switch, "sw_closed");
450        assert!(closed.closed);
451
452        let sourcebus = bus(&graph, "sourcebus");
453        assert!(sourcebus.has_source);
454        let loadbus = bus(&graph, "loadbus");
455        assert_close(loadbus.load_kw, 500.0);
456        assert!(
457            loadbus
458                .terminal_attachments
459                .get("1")
460                .expect("terminal attachment")
461                .iter()
462                .any(
463                    |attachment| attachment.kind == DistGraphAttachmentKind::Load
464                        && attachment.id == "l1"
465                )
466        );
467    }
468
469    #[test]
470    fn graph_projects_one_edge_per_transformer_winding_pair() {
471        let net = crate::testkit::parse_file(fixture("micro/xfmr_center_tap.dss"), None)
472            .expect("parse xfmr");
473        let graph = net.to_graph();
474        let transformer_edges: Vec<_> = graph
475            .edges
476            .iter()
477            .filter(|edge| edge.kind == DistGraphEdgeKind::Transformer && edge.id == "t1")
478            .collect();
479
480        assert_eq!(transformer_edges.len(), 3);
481        assert!(
482            transformer_edges
483                .iter()
484                .any(|edge| edge.from == "sourcebus" && edge.to == "secondary")
485        );
486        assert!(
487            transformer_edges
488                .iter()
489                .any(|edge| edge.from == "secondary" && edge.to == "secondary")
490        );
491        assert!(
492            transformer_edges
493                .iter()
494                .all(|edge| edge.closed && edge.n_phases == 1)
495        );
496
497        let secondary = bus(&graph, "secondary");
498        assert_close(secondary.load_kw, 15.0);
499    }
500
501    #[test]
502    fn graph_projects_bmopf_fixture() {
503        let net = crate::testkit::parse_file(fixture("bmopf/example_ieee13.json"), None)
504            .expect("parse bmopf");
505        let graph = net.to_graph();
506
507        assert!(graph.buses.len() >= net.buses().len());
508        assert!(
509            graph
510                .edges
511                .iter()
512                .any(|edge| edge.kind == DistGraphEdgeKind::Line)
513        );
514        assert!(
515            graph
516                .edges
517                .iter()
518                .any(|edge| edge.kind == DistGraphEdgeKind::Transformer)
519        );
520    }
521
522    #[test]
523    fn graph_accumulates_terminal_attachments_and_generation() {
524        let mut net = MulticonductorNetwork::new();
525        net.buses_mut()
526            .push(DistBus::new("b1", strings(&["a", "b", "n"])));
527        net.loads_mut().push(DistLoad::new(
528            "load",
529            "b1",
530            strings(&["a", "n"]),
531            Configuration::Wye,
532            vec![1000.0],
533            vec![0.0],
534        ));
535        net.generators_mut().push(DistGenerator::new(
536            "gen",
537            "b1",
538            strings(&["b", "n"]),
539            Configuration::Wye,
540            vec![2000.0],
541            vec![0.0],
542        ));
543        net.sources_mut().push(VoltageSource::new(
544            "source",
545            "b1",
546            strings(&["a", "b", "n"]),
547            vec![1.0, 1.0, 0.0],
548            vec![0.0, 0.0, 0.0],
549        ));
550
551        let graph = net.to_graph();
552        let b1 = bus(&graph, "b1");
553
554        assert_close(b1.load_kw, 1.0);
555        assert_close(b1.gen_kw, 2.0);
556        assert!(b1.has_source);
557        assert_eq!(
558            b1.terminal_attachments
559                .get("n")
560                .expect("neutral attachments")
561                .len(),
562            3
563        );
564    }
565
566    #[test]
567    fn transformer_edge_expansion_is_bounded() {
568        // One edge per winding pair. Every reader caps the winding count,
569        // but callers can construct a `MulticonductorNetwork` without those
570        // caps, so a linear-size model must not force quadratic work.
571        let n = 4000;
572        let windings: Vec<DistWinding> = (0..n)
573            .map(|i| {
574                DistWinding::new(
575                    format!("b{i}"),
576                    strings(&["1", "2", "3", "n"]),
577                    crate::model::DistWindingConn::Wye,
578                    12470.0,
579                    5e6,
580                )
581            })
582            .collect();
583        let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
584            transformers: vec![DistTransformer::new("t1", windings, Vec::new(), 3)],
585            ..MulticonductorNetworkTables::default()
586        });
587
588        let graph = net.to_graph();
589        let pairs = MAX_WINDING_PAIRS_DIM * (MAX_WINDING_PAIRS_DIM - 1) / 2;
590        assert_eq!(graph.edges.len(), pairs);
591        // Uncapped this would be n(n-1)/2 edges, three orders of magnitude
592        // more than the winding array that produced them.
593        assert!(pairs < n);
594    }
595
596    #[test]
597    fn graph_uses_extra_coordinates_when_present() {
598        let mut bus = DistBus::new("b1", strings(&["1"]));
599        bus.extras
600            .insert("longitude".into(), serde_json::json!(-80.0));
601        bus.extras
602            .insert("latitude".into(), serde_json::json!(35.0));
603        let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
604            buses: vec![bus],
605            ..MulticonductorNetworkTables::default()
606        });
607
608        assert_eq!(net.to_graph().buses[0].xy, Some([-80.0, 35.0]));
609    }
610}