1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::{
8 DistBus, DistIbr, DistNetwork, DistTransformer, VoltageSource, Winding, 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 DistNetwork {
84 #[must_use]
86 pub fn graph(&self) -> DistGraph {
87 DistGraph::from_network(self)
88 }
89}
90
91impl DistGraph {
92 #[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 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);
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 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 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}