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, DistNetwork, DistTransformer, VoltageSource, Winding, 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 DistNetwork {
84    /// Project this network into a render ready bus and terminal graph.
85    #[must_use]
86    pub fn 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: &DistNetwork) -> 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        // `DistNetwork` can also arrive without those caps (the model JSON
248        // C entry point deserializes one unchecked). No physical
249        // transformer comes near it.
250        let n_windings = transformer.windings.len().min(MAX_WINDING_PAIRS_DIM);
251        for (from_idx, to_idx) in pair_keys(n_windings) {
252            let Some(from_winding) = transformer.windings.get(from_idx) else {
253                continue;
254            };
255            let Some(to_winding) = transformer.windings.get(to_idx) else {
256                continue;
257            };
258            let from = self.canonical_bus_id(&from_winding.bus);
259            let to = self.canonical_bus_id(&to_winding.bus);
260            self.edges.push(transformer_edge(
261                transformer,
262                from,
263                to,
264                from_winding,
265                to_winding,
266            ));
267        }
268    }
269
270    fn add_load(&mut self, bus: &str, terminals: &[String], id: &str, load_kw: f64) {
271        let index = self.bus_index(bus);
272        self.buses[index].load_kw += load_kw;
273        self.add_attachment(bus, terminals, DistGraphAttachmentKind::Load, id);
274    }
275
276    fn add_generator(&mut self, bus: &str, terminals: &[String], id: &str, gen_kw: f64) {
277        let index = self.bus_index(bus);
278        self.buses[index].gen_kw += gen_kw;
279        self.add_attachment(bus, terminals, DistGraphAttachmentKind::Generator, id);
280    }
281
282    fn add_ibr(&mut self, ibr: &DistIbr) {
283        let index = self.bus_index(&ibr.bus);
284        self.buses[index].gen_kw += ibr_kw(ibr);
285        self.add_attachment(
286            &ibr.bus,
287            &ibr.terminal_map,
288            DistGraphAttachmentKind::Ibr,
289            &ibr.name,
290        );
291    }
292
293    fn add_source(&mut self, source: &VoltageSource) {
294        let index = self.bus_index(&source.bus);
295        self.buses[index].has_source = true;
296        self.add_attachment(
297            &source.bus,
298            &source.terminal_map,
299            DistGraphAttachmentKind::Source,
300            &source.name,
301        );
302    }
303
304    fn add_attachment(
305        &mut self,
306        bus: &str,
307        terminals: &[String],
308        kind: DistGraphAttachmentKind,
309        id: &str,
310    ) {
311        let index = self.bus_index(bus);
312        let attachment = DistGraphAttachment {
313            kind,
314            id: id.to_owned(),
315        };
316        if terminals.is_empty() {
317            self.buses[index]
318                .terminal_attachments
319                .entry(String::new())
320                .or_default()
321                .push(attachment);
322            return;
323        }
324        for terminal in terminals {
325            self.buses[index]
326                .terminal_attachments
327                .entry(terminal.clone())
328                .or_default()
329                .push(attachment.clone());
330        }
331    }
332}
333
334fn transformer_edge(
335    transformer: &DistTransformer,
336    from: String,
337    to: String,
338    from_winding: &Winding,
339    to_winding: &Winding,
340) -> DistGraphEdge {
341    DistGraphEdge {
342        kind: DistGraphEdgeKind::Transformer,
343        id: transformer.name.clone(),
344        from,
345        to,
346        conductors: conductor_pairs(&from_winding.terminal_map, &to_winding.terminal_map),
347        closed: true,
348        n_phases: transformer.phases,
349    }
350}
351
352fn conductor_pairs(from: &[String], to: &[String]) -> Vec<(String, String)> {
353    from.iter().cloned().zip(to.iter().cloned()).collect()
354}
355
356fn watts_to_kw(values: &[f64]) -> f64 {
357    values.iter().sum::<f64>() / 1000.0
358}
359
360fn ibr_kw(ibr: &DistIbr) -> f64 {
361    ibr.p_avail
362        .or_else(|| ibr.p_max.as_ref().map(|p| p.iter().sum()))
363        .unwrap_or(0.0)
364        / 1000.0
365}
366
367fn bus_key(id: &str) -> String {
368    id.to_ascii_lowercase()
369}
370
371fn bus_xy(bus: &DistBus) -> Option<[f64; 2]> {
372    if let Some(location) = bus.location
373        && location.x.is_finite()
374        && location.y.is_finite()
375    {
376        return Some([location.x, location.y]);
377    }
378    let x = number_extra(&bus.extras, &["x", "lon", "lng", "longitude"])?;
379    let y = number_extra(&bus.extras, &["y", "lat", "latitude"])?;
380    Some([x, y])
381}
382
383fn number_extra(extras: &BTreeMap<String, serde_json::Value>, names: &[&str]) -> Option<f64> {
384    names.iter().find_map(|name| {
385        extras
386            .get(*name)
387            .and_then(serde_json::Value::as_f64)
388            .filter(|value| value.is_finite())
389    })
390}
391
392#[cfg(test)]
393mod tests {
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 = crate::parse_file(fixture("micro/switch.dss"), None).expect("parse switch");
432        let graph = net.graph();
433
434        let open = edge(&graph, DistGraphEdgeKind::Switch, "sw_open");
435        assert!(!open.closed);
436        assert_eq!(open.from, "mid");
437        assert_eq!(open.to, "stub");
438        assert_eq!(
439            open.conductors,
440            vec![
441                ("1".to_owned(), "1".to_owned()),
442                ("2".to_owned(), "2".to_owned()),
443                ("3".to_owned(), "3".to_owned())
444            ]
445        );
446        assert_eq!(open.n_phases, 3);
447
448        let closed = edge(&graph, DistGraphEdgeKind::Switch, "sw_closed");
449        assert!(closed.closed);
450
451        let sourcebus = bus(&graph, "sourcebus");
452        assert!(sourcebus.has_source);
453        let loadbus = bus(&graph, "loadbus");
454        assert_close(loadbus.load_kw, 500.0);
455        assert!(
456            loadbus
457                .terminal_attachments
458                .get("1")
459                .expect("terminal attachment")
460                .iter()
461                .any(
462                    |attachment| attachment.kind == DistGraphAttachmentKind::Load
463                        && attachment.id == "l1"
464                )
465        );
466    }
467
468    #[test]
469    fn graph_projects_one_edge_per_transformer_winding_pair() {
470        let net =
471            crate::parse_file(fixture("micro/xfmr_center_tap.dss"), None).expect("parse xfmr");
472        let graph = net.graph();
473        let transformer_edges: Vec<_> = graph
474            .edges
475            .iter()
476            .filter(|edge| edge.kind == DistGraphEdgeKind::Transformer && edge.id == "t1")
477            .collect();
478
479        assert_eq!(transformer_edges.len(), 3);
480        assert!(
481            transformer_edges
482                .iter()
483                .any(|edge| edge.from == "sourcebus" && edge.to == "secondary")
484        );
485        assert!(
486            transformer_edges
487                .iter()
488                .any(|edge| edge.from == "secondary" && edge.to == "secondary")
489        );
490        assert!(
491            transformer_edges
492                .iter()
493                .all(|edge| edge.closed && edge.n_phases == 1)
494        );
495
496        let secondary = bus(&graph, "secondary");
497        assert_close(secondary.load_kw, 15.0);
498    }
499
500    #[test]
501    fn graph_projects_bmopf_fixture() {
502        let net =
503            crate::parse_file(fixture("bmopf/example_ieee13.json"), None).expect("parse bmopf");
504        let graph = net.graph();
505
506        assert!(graph.buses.len() >= net.buses.len());
507        assert!(
508            graph
509                .edges
510                .iter()
511                .any(|edge| edge.kind == DistGraphEdgeKind::Line)
512        );
513        assert!(
514            graph
515                .edges
516                .iter()
517                .any(|edge| edge.kind == DistGraphEdgeKind::Transformer)
518        );
519    }
520
521    #[test]
522    fn graph_accumulates_terminal_attachments_and_generation() {
523        let mut net = DistNetwork::new();
524        net.buses
525            .push(DistBus::new("b1", strings(&["a", "b", "n"])));
526        net.loads.push(DistLoad::new(
527            "load",
528            "b1",
529            strings(&["a", "n"]),
530            Configuration::Wye,
531            vec![1000.0],
532            vec![0.0],
533        ));
534        net.generators.push(DistGenerator::new(
535            "gen",
536            "b1",
537            strings(&["b", "n"]),
538            Configuration::Wye,
539            vec![2000.0],
540            vec![0.0],
541        ));
542        net.sources.push(VoltageSource::new(
543            "source",
544            "b1",
545            strings(&["a", "b", "n"]),
546            vec![1.0, 1.0, 0.0],
547            vec![0.0, 0.0, 0.0],
548        ));
549
550        let graph = net.graph();
551        let b1 = bus(&graph, "b1");
552
553        assert_close(b1.load_kw, 1.0);
554        assert_close(b1.gen_kw, 2.0);
555        assert!(b1.has_source);
556        assert_eq!(
557            b1.terminal_attachments
558                .get("n")
559                .expect("neutral attachments")
560                .len(),
561            3
562        );
563    }
564
565    #[test]
566    fn transformer_edge_expansion_is_bounded() {
567        // One edge per winding pair. Every reader caps the winding count,
568        // but the model JSON C entry point deserializes a `DistNetwork`
569        // unchecked, so a linear-size model must not force quadratic work.
570        let n = 4000;
571        let windings: Vec<Winding> = (0..n)
572            .map(|i| {
573                Winding::new(
574                    format!("b{i}"),
575                    strings(&["1", "2", "3", "n"]),
576                    crate::model::WindingConn::Wye,
577                    12470.0,
578                    5e6,
579                )
580            })
581            .collect();
582        let net = DistNetwork {
583            transformers: vec![DistTransformer::new("t1", windings, Vec::new(), 3)],
584            ..DistNetwork::new()
585        };
586
587        let graph = net.graph();
588        let pairs = MAX_WINDING_PAIRS_DIM * (MAX_WINDING_PAIRS_DIM - 1) / 2;
589        assert_eq!(graph.edges.len(), pairs);
590        // Uncapped this would be n(n-1)/2 edges, three orders of magnitude
591        // more than the winding array that produced them.
592        assert!(pairs < n);
593    }
594
595    #[test]
596    fn graph_uses_extra_coordinates_when_present() {
597        let mut bus = DistBus::new("b1", strings(&["1"]));
598        bus.extras
599            .insert("longitude".into(), serde_json::json!(-80.0));
600        bus.extras
601            .insert("latitude".into(), serde_json::json!(35.0));
602        let net = DistNetwork {
603            buses: vec![bus],
604            ..DistNetwork::new()
605        };
606
607        assert_eq!(net.graph().buses[0].xy, Some([-80.0, 35.0]));
608    }
609}