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_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 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 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}