1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::{
8 DistBus, DistIbr, DistTransformer, DistWinding, MulticonductorNetwork, VoltageSource, pair_keys,
9};
10
11const MAX_WINDING_PAIRS_DIM: usize = 64;
14
15#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[non_exhaustive]
42pub struct DistGraphAttachment {
43 pub kind: DistGraphAttachmentKind,
44 pub id: String,
45}
46
47#[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#[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 pub conductors: Vec<(String, String)>,
69 pub closed: bool,
70 pub n_phases: usize,
71}
72
73#[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 #[must_use]
86 pub fn to_graph(&self) -> DistGraph {
87 DistGraph::from_network(self)
88 }
89}
90
91impl DistGraph {
92 #[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 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 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_preserves_finite_switch_lines_without_executing_controls() {
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::Line, "sw_open");
436 assert!(open.closed);
438 assert!(
439 net.warnings
440 .iter()
441 .any(|w| w.contains("active control execution is excluded"))
442 );
443 assert_eq!(open.from, "mid");
444 assert_eq!(open.to, "stub");
445 assert_eq!(
446 open.conductors,
447 vec![
448 ("1".to_owned(), "1".to_owned()),
449 ("2".to_owned(), "2".to_owned()),
450 ("3".to_owned(), "3".to_owned())
451 ]
452 );
453 assert_eq!(open.n_phases, 3);
454
455 let closed = edge(&graph, DistGraphEdgeKind::Line, "sw_closed");
456 assert!(closed.closed);
457
458 let sourcebus = bus(&graph, "sourcebus");
459 assert!(!sourcebus.has_source);
460 let source = &net.sources()[0];
461 assert!(bus(&graph, &source.bus).has_source);
462 let impedance = edge(
463 &graph,
464 DistGraphEdgeKind::Line,
465 "_pio_vsource_source_impedance",
466 );
467 assert_eq!(impedance.from, source.bus);
468 assert_eq!(impedance.to, sourcebus.id);
469 let loadbus = bus(&graph, "loadbus");
470 assert_close(loadbus.load_kw, 500.0);
471 assert!(
472 loadbus
473 .terminal_attachments
474 .get("1")
475 .expect("terminal attachment")
476 .iter()
477 .any(
478 |attachment| attachment.kind == DistGraphAttachmentKind::Load
479 && attachment.id == "l1"
480 )
481 );
482 }
483
484 #[test]
485 fn graph_projects_one_edge_per_transformer_winding_pair() {
486 let net = crate::testkit::parse_file(fixture("micro/xfmr_center_tap.dss"), None)
487 .expect("parse xfmr");
488 let graph = net.to_graph();
489 let transformer_edges: Vec<_> = graph
490 .edges
491 .iter()
492 .filter(|edge| edge.kind == DistGraphEdgeKind::Transformer && edge.id == "t1")
493 .collect();
494
495 assert_eq!(transformer_edges.len(), 3);
496 assert!(
497 transformer_edges
498 .iter()
499 .any(|edge| edge.from == "sourcebus" && edge.to == "secondary")
500 );
501 assert!(
502 transformer_edges
503 .iter()
504 .any(|edge| edge.from == "secondary" && edge.to == "secondary")
505 );
506 assert!(
507 transformer_edges
508 .iter()
509 .all(|edge| edge.closed && edge.n_phases == 1)
510 );
511
512 let secondary = bus(&graph, "secondary");
513 assert_close(secondary.load_kw, 15.0);
514 }
515
516 #[test]
517 fn graph_projects_bmopf_fixture() {
518 let net = crate::testkit::parse_file(fixture("bmopf/example_ieee13.json"), None)
519 .expect("parse bmopf");
520 let graph = net.to_graph();
521
522 assert!(graph.buses.len() >= net.buses().len());
523 assert!(
524 graph
525 .edges
526 .iter()
527 .any(|edge| edge.kind == DistGraphEdgeKind::Line)
528 );
529 assert!(
530 graph
531 .edges
532 .iter()
533 .any(|edge| edge.kind == DistGraphEdgeKind::Transformer)
534 );
535 }
536
537 #[test]
538 fn graph_accumulates_terminal_attachments_and_generation() {
539 let mut net = MulticonductorNetwork::new();
540 net.buses_mut()
541 .push(DistBus::new("b1", strings(&["a", "b", "n"])));
542 net.loads_mut().push(DistLoad::new(
543 "load",
544 "b1",
545 strings(&["a", "n"]),
546 Configuration::Wye,
547 vec![1000.0],
548 vec![0.0],
549 ));
550 net.generators_mut().push(DistGenerator::new(
551 "gen",
552 "b1",
553 strings(&["b", "n"]),
554 Configuration::Wye,
555 vec![2000.0],
556 vec![0.0],
557 ));
558 net.sources_mut().push(VoltageSource::new(
559 "source",
560 "b1",
561 strings(&["a", "b", "n"]),
562 vec![1.0, 1.0, 0.0],
563 vec![0.0, 0.0, 0.0],
564 ));
565
566 let graph = net.to_graph();
567 let b1 = bus(&graph, "b1");
568
569 assert_close(b1.load_kw, 1.0);
570 assert_close(b1.gen_kw, 2.0);
571 assert!(b1.has_source);
572 assert_eq!(
573 b1.terminal_attachments
574 .get("n")
575 .expect("neutral attachments")
576 .len(),
577 3
578 );
579 }
580
581 #[test]
582 fn transformer_edge_expansion_is_bounded() {
583 let n = 4000;
587 let windings: Vec<DistWinding> = (0..n)
588 .map(|i| {
589 DistWinding::new(
590 format!("b{i}"),
591 strings(&["1", "2", "3", "n"]),
592 crate::model::DistWindingConn::Wye,
593 12470.0,
594 5e6,
595 )
596 })
597 .collect();
598 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
599 transformers: vec![DistTransformer::new("t1", windings, Vec::new(), 3)],
600 ..MulticonductorNetworkTables::default()
601 });
602
603 let graph = net.to_graph();
604 let pairs = MAX_WINDING_PAIRS_DIM * (MAX_WINDING_PAIRS_DIM - 1) / 2;
605 assert_eq!(graph.edges.len(), pairs);
606 assert!(pairs < n);
609 }
610
611 #[test]
612 fn graph_uses_extra_coordinates_when_present() {
613 let mut bus = DistBus::new("b1", strings(&["1"]));
614 bus.extras
615 .insert("longitude".into(), serde_json::json!(-80.0));
616 bus.extras
617 .insert("latitude".into(), serde_json::json!(35.0));
618 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
619 buses: vec![bus],
620 ..MulticonductorNetworkTables::default()
621 });
622
623 assert_eq!(net.to_graph().buses[0].xy, Some([-80.0, 35.0]));
624 }
625}