1use std::collections::{BTreeMap, BTreeSet};
22
23use crate::diagnostics::Diagnostic;
24use num_complex::Complex64;
25use powerio_dist::{Configuration, MulticonductorNetwork};
26use sprs::CsMat;
27
28use crate::diagnostics::codes;
29use crate::matrix::triplet::CooBuilder;
30use crate::{Error, Result};
31
32#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub struct DistNode {
35 pub bus: String,
36 pub terminal: String,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum NodeRef {
42 Node(usize),
44 Ground,
46}
47
48#[derive(Clone, Debug)]
50pub struct MulticonductorNodeIndex {
51 nodes: Vec<DistNode>,
52 position: BTreeMap<(String, String), usize>,
53 grounded: BTreeSet<(String, String)>,
54}
55
56fn find(parent: &mut [usize], node: usize) -> usize {
57 let mut root = node;
58 while parent[root] != root {
59 root = parent[root];
60 }
61 let mut walk = node;
62 while parent[walk] != root {
63 let next = parent[walk];
64 parent[walk] = root;
65 walk = next;
66 }
67 root
68}
69
70impl MulticonductorNodeIndex {
71 pub fn build(network: &MulticonductorNetwork) -> Result<Self> {
76 let mut provisional: Vec<(String, String)> = Vec::new();
78 let mut slot: BTreeMap<(String, String), usize> = BTreeMap::new();
79 let mut grounded: BTreeSet<(String, String)> = BTreeSet::new();
80 for bus in network.buses() {
81 for terminal in &bus.terminals {
82 let key = (bus.id.clone(), terminal.clone());
83 if terminal == "0" || bus.grounded.contains(terminal) {
84 grounded.insert(key);
85 continue;
86 }
87 slot.insert(key.clone(), provisional.len());
88 provisional.push(key);
89 }
90 }
91
92 let mut parent: Vec<usize> = (0..provisional.len()).collect();
96 let mut ground_touched: Vec<usize> = Vec::new();
97 for switch in network.switches().iter().filter(|switch| !switch.open) {
98 for (from, to) in switch
99 .terminal_map_from
100 .iter()
101 .zip(switch.terminal_map_to.iter())
102 {
103 let from_key = (switch.bus_from.clone(), from.clone());
104 let to_key = (switch.bus_to.clone(), to.clone());
105 match (slot.get(&from_key), slot.get(&to_key)) {
106 (Some(&a), Some(&b)) => {
107 let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
108 if ra != rb {
109 parent[ra.max(rb)] = ra.min(rb);
110 }
111 }
112 (Some(&a), None) if grounded.contains(&to_key) => {
115 ground_touched.push(a);
116 }
117 (None, Some(&b)) if grounded.contains(&from_key) => {
118 ground_touched.push(b);
119 }
120 _ => {
121 return Err(Error::Mtx(format!(
122 "switch `{}` names a terminal its buses do not declare",
123 switch.name
124 )));
125 }
126 }
127 }
128 }
129 let grounded_roots: BTreeSet<usize> = ground_touched
130 .into_iter()
131 .map(|slot| find(&mut parent, slot))
132 .collect();
133
134 let mut dense_of_root: BTreeMap<usize, usize> = BTreeMap::new();
136 let mut nodes = Vec::new();
137 let mut position = BTreeMap::new();
138 for index in 0..provisional.len() {
139 let root = find(&mut parent, index);
140 if grounded_roots.contains(&root) {
143 grounded.insert(provisional[index].clone());
144 continue;
145 }
146 let dense = *dense_of_root.entry(root).or_insert_with(|| {
147 let (bus, terminal) = provisional[root].clone();
148 nodes.push(DistNode { bus, terminal });
149 nodes.len() - 1
150 });
151 position.insert(provisional[index].clone(), dense);
152 }
153 Ok(Self {
154 nodes,
155 position,
156 grounded,
157 })
158 }
159
160 #[must_use]
162 pub fn nodes(&self) -> &[DistNode] {
163 &self.nodes
164 }
165
166 #[must_use]
168 pub fn len(&self) -> usize {
169 self.nodes.len()
170 }
171
172 #[must_use]
173 pub fn is_empty(&self) -> bool {
174 self.nodes.is_empty()
175 }
176
177 #[must_use]
181 pub fn resolve(&self, bus: &str, terminal: &str) -> Option<NodeRef> {
182 let key = (bus.to_owned(), terminal.to_owned());
183 if let Some(&dense) = self.position.get(&key) {
184 return Some(NodeRef::Node(dense));
185 }
186 if self.grounded.contains(&key) {
187 return Some(NodeRef::Ground);
188 }
189 None
190 }
191}
192
193#[derive(Clone, Debug)]
197pub struct AugmentedSystem {
198 pub constraint_re: CsMat<f64>,
201 pub constraint_im: CsMat<f64>,
202 pub rhs_re: Vec<f64>,
203 pub rhs_im: Vec<f64>,
204 pub labels: Vec<String>,
206}
207
208#[derive(Clone, Debug)]
210pub struct MulticonductorAdmittance {
211 index: MulticonductorNodeIndex,
212 conductance: CsMat<f64>,
213 susceptance: CsMat<f64>,
214 augmented: AugmentedSystem,
215 diagnostics: Vec<Diagnostic>,
216}
217
218impl MulticonductorAdmittance {
219 #[must_use]
221 pub const fn index(&self) -> &MulticonductorNodeIndex {
222 &self.index
223 }
224
225 #[must_use]
227 pub const fn conductance(&self) -> &CsMat<f64> {
228 &self.conductance
229 }
230
231 #[must_use]
233 pub const fn susceptance(&self) -> &CsMat<f64> {
234 &self.susceptance
235 }
236
237 #[must_use]
239 pub const fn augmented(&self) -> &AugmentedSystem {
240 &self.augmented
241 }
242
243 #[must_use]
245 pub fn diagnostics(&self) -> &[Diagnostic] {
246 &self.diagnostics
247 }
248}
249
250fn invert(matrix: &[Vec<Complex64>]) -> Option<Vec<Vec<Complex64>>> {
253 let n = matrix.len();
254 let mut work: Vec<Vec<Complex64>> = matrix.to_vec();
255 let mut inverse: Vec<Vec<Complex64>> = (0..n)
256 .map(|row| {
257 (0..n)
258 .map(|column| {
259 if row == column {
260 Complex64::new(1.0, 0.0)
261 } else {
262 Complex64::new(0.0, 0.0)
263 }
264 })
265 .collect()
266 })
267 .collect();
268 for pivot in 0..n {
269 let best = (pivot..n).max_by(|&a, &b| {
270 work[a][pivot]
271 .norm()
272 .partial_cmp(&work[b][pivot].norm())
273 .unwrap_or(std::cmp::Ordering::Equal)
274 })?;
275 if work[best][pivot].norm() == 0.0 {
276 return None;
277 }
278 work.swap(pivot, best);
279 inverse.swap(pivot, best);
280 let lead = work[pivot][pivot];
281 for column in 0..n {
282 work[pivot][column] /= lead;
283 inverse[pivot][column] /= lead;
284 }
285 for row in 0..n {
286 if row == pivot {
287 continue;
288 }
289 let factor = work[row][pivot];
290 if factor.norm() == 0.0 {
291 continue;
292 }
293 for column in 0..n {
294 let w = work[pivot][column];
295 let i = inverse[pivot][column];
296 work[row][column] -= factor * w;
297 inverse[row][column] -= factor * i;
298 }
299 }
300 }
301 Some(inverse)
302}
303
304struct Stamper {
305 conductance: CooBuilder,
306 susceptance: CooBuilder,
307}
308
309impl Stamper {
310 fn new(n: usize) -> Self {
311 Self {
312 conductance: CooBuilder::new(n),
313 susceptance: CooBuilder::new(n),
314 }
315 }
316
317 fn add(&mut self, from: NodeRef, to: NodeRef, value: Complex64) {
320 if let (NodeRef::Node(i), NodeRef::Node(j)) = (from, to) {
321 self.conductance.add(i, j, value.re);
322 self.susceptance.add(i, j, value.im);
323 }
324 }
325}
326
327#[allow(clippy::too_many_lines)] #[allow(clippy::many_single_char_names)] pub fn calc_multiconductor_admittance_matrix(
337 network: &MulticonductorNetwork,
338) -> Result<MulticonductorAdmittance> {
339 powerio_dist::require_electrical_readiness(network)?;
340 let index = MulticonductorNodeIndex::build(network)?;
341 for transformer in network.transformers() {
342 let supported = transformer.windings.len() == 2
343 && transformer.xsc_pct.iter().all(|&x| x == 0.0)
344 && transformer.windings.iter().all(|w| {
345 w.conn == powerio_dist::DistWindingConn::Wye
346 && w.r_pct == 0.0
347 && w.r_neutral.is_none_or(|r| r == 0.0)
348 && w.x_neutral.is_none_or(|x| x == 0.0)
349 && (w.terminal_map.len() == 1
350 || w.terminal_map.last().is_some_and(|terminal| {
351 index.resolve(&w.bus, terminal) == Some(NodeRef::Ground)
352 }))
353 })
354 && transformer.windings[0].terminal_map.len()
355 == transformer.windings[1].terminal_map.len()
356 && transformer.extras.get("no_load_shunt").is_none_or(|shunt| {
357 ["g", "b"]
358 .iter()
359 .all(|key| shunt.get(key).and_then(serde_json::Value::as_f64) == Some(0.0))
360 })
361 && ["g_no_load", "b_no_load", "%noloadloss", "%imag"]
362 .iter()
363 .all(|key| {
364 transformer
365 .extras
366 .get(*key)
367 .and_then(serde_json::Value::as_f64)
368 .is_none_or(|v| v == 0.0)
369 })
370 && !["tap_min", "tap_max", "tap_ratio_min", "tap_ratio_max"]
371 .iter()
372 .any(|key| transformer.extras.contains_key(*key));
373 if !supported {
374 return Err(powerio_core::Error::new(&codes::BUILD_MULTI_PHYSICS_UNSUPPORTED,
375 format!("transformer `{}` requires leakage, winding connection, neutral, core-loss, or tap-control equations outside the ideal grounded-WYE admittance profile", transformer.name)).into());
376 }
377 }
378 let n = index.len();
379 let mut stamper = Stamper::new(n);
380 let mut diagnostics = Vec::new();
381
382 let resolve = |bus: &str, terminal: &str, element: &str| -> Result<NodeRef> {
383 index.resolve(bus, terminal).ok_or_else(|| {
384 Error::Mtx(format!(
385 "{element} names terminal `{terminal}` bus `{bus}` does not declare"
386 ))
387 })
388 };
389
390 let linecode_of = |name: &str| {
391 network
392 .line_codes()
393 .iter()
394 .find(|linecode| linecode.name == name)
395 };
396
397 for line in network.lines() {
400 let Some(code) = linecode_of(&line.linecode) else {
401 return Err(Error::Mtx(format!(
402 "line `{}` names linecode `{}` the network does not declare",
403 line.name, line.linecode
404 )));
405 };
406 let conductors = code.n_conductors;
407 if line.terminal_map_from.len() != conductors || line.terminal_map_to.len() != conductors {
408 return Err(Error::Mtx(format!(
409 "line `{}` maps {}/{} terminals over a {conductors} conductor linecode",
410 line.name,
411 line.terminal_map_from.len(),
412 line.terminal_map_to.len()
413 )));
414 }
415 if !line.length.is_finite() || line.length <= 0.0 {
416 diagnostics.push(Diagnostic::of(
417 &codes::BUILD_MULTI_UNSUPPORTED_STAMP,
418 format!(
419 "line `{}` states no usable length; its stamp is omitted",
420 line.name
421 ),
422 ));
423 continue;
424 }
425 let z: Vec<Vec<Complex64>> = (0..conductors)
426 .map(|row| {
427 (0..conductors)
428 .map(|column| {
429 Complex64::new(code.r_series[row][column], code.x_series[row][column])
430 * line.length
431 })
432 .collect()
433 })
434 .collect();
435 let Some(y_series) = invert(&z) else {
436 return Err(Error::Mtx(format!(
437 "line `{}` has a singular series impedance matrix",
438 line.name
439 )));
440 };
441 let from: Vec<NodeRef> = line
442 .terminal_map_from
443 .iter()
444 .map(|terminal| resolve(&line.bus_from, terminal, &format!("line `{}`", line.name)))
445 .collect::<Result<_>>()?;
446 let to: Vec<NodeRef> = line
447 .terminal_map_to
448 .iter()
449 .map(|terminal| resolve(&line.bus_to, terminal, &format!("line `{}`", line.name)))
450 .collect::<Result<_>>()?;
451 for row in 0..conductors {
452 for column in 0..conductors {
453 let y = y_series[row][column];
454 stamper.add(from[row], from[column], y);
455 stamper.add(to[row], to[column], y);
456 stamper.add(from[row], to[column], -y);
457 stamper.add(to[row], from[column], -y);
458 let shunt_from = Complex64::new(code.g_from[row][column], code.b_from[row][column])
459 * line.length;
460 let shunt_to =
461 Complex64::new(code.g_to[row][column], code.b_to[row][column]) * line.length;
462 stamper.add(from[row], from[column], shunt_from);
463 stamper.add(to[row], to[column], shunt_to);
464 }
465 }
466 }
467
468 for shunt in network.shunts() {
470 let terminals: Vec<NodeRef> = shunt
471 .terminal_map
472 .iter()
473 .map(|terminal| resolve(&shunt.bus, terminal, &format!("shunt `{}`", shunt.name)))
474 .collect::<Result<_>>()?;
475 for row in 0..terminals.len() {
476 for column in 0..terminals.len() {
477 let value = Complex64::new(shunt.g[row][column], shunt.b[row][column]);
478 stamper.add(terminals[row], terminals[column], value);
479 }
480 }
481 }
482
483 for capacitor in network.capacitors() {
485 let terminals: Vec<NodeRef> = capacitor
486 .terminal_map
487 .iter()
488 .map(|terminal| {
489 resolve(
490 &capacitor.bus,
491 terminal,
492 &format!("capacitor `{}`", capacitor.name),
493 )
494 })
495 .collect::<Result<_>>()?;
496 match capacitor.configuration {
497 Configuration::SinglePhase => {
498 if terminals.len() != 2 {
499 return Err(Error::Mtx(format!(
500 "single phase capacitor `{}` maps {} terminals",
501 capacitor.name,
502 terminals.len()
503 )));
504 }
505 let b = capacitor.q_rated / (capacitor.v_nom * capacitor.v_nom);
506 let y = Complex64::new(0.0, b);
507 stamper.add(terminals[0], terminals[0], y);
508 stamper.add(terminals[1], terminals[1], y);
509 stamper.add(terminals[0], terminals[1], -y);
510 stamper.add(terminals[1], terminals[0], -y);
511 }
512 Configuration::Wye => {
513 let phases = terminals.len().saturating_sub(1);
516 if phases == 0 {
517 return Err(Error::Mtx(format!(
518 "wye capacitor `{}` maps no phase terminal",
519 capacitor.name
520 )));
521 }
522 let v_ln = capacitor.v_nom / 3f64.sqrt();
523 let b = capacitor.q_rated / (phases as f64) / (v_ln * v_ln);
524 let neutral = terminals[phases];
525 for &phase in &terminals[..phases] {
526 let y = Complex64::new(0.0, b);
527 stamper.add(phase, phase, y);
528 stamper.add(neutral, neutral, y);
529 stamper.add(phase, neutral, -y);
530 stamper.add(neutral, phase, -y);
531 }
532 }
533 Configuration::Delta => {
534 let phases = terminals.len();
535 if phases < 2 {
536 return Err(Error::Mtx(format!(
537 "delta capacitor `{}` maps {} terminals",
538 capacitor.name, phases
539 )));
540 }
541 let b = capacitor.q_rated / (phases as f64) / (capacitor.v_nom * capacitor.v_nom);
542 let loops = if phases == 2 { 1 } else { phases };
544 for pair in 0..loops {
545 let a = terminals[pair];
546 let c = terminals[(pair + 1) % phases];
547 let y = Complex64::new(0.0, b);
548 stamper.add(a, a, y);
549 stamper.add(c, c, y);
550 stamper.add(a, c, -y);
551 stamper.add(c, a, -y);
552 }
553 }
554 _ => {
555 diagnostics.push(Diagnostic::of(
556 &codes::BUILD_MULTI_UNSUPPORTED_STAMP,
557 format!(
558 "capacitor `{}` states a configuration this builder does not stamp",
559 capacitor.name
560 ),
561 ));
562 }
563 }
564 }
565
566 let mut constraint_re: Vec<(usize, usize, f64)> = Vec::new();
569 let constraint_im: Vec<(usize, usize, f64)> = Vec::new();
570 let mut rhs_re = Vec::new();
571 let mut rhs_im = Vec::new();
572 let mut labels = Vec::new();
573 let mut rows = 0usize;
574
575 for source in network.sources() {
578 for (position, terminal) in source.terminal_map.iter().enumerate() {
579 let node = resolve(&source.bus, terminal, &format!("source `{}`", source.name))?;
580 let NodeRef::Node(dense) = node else {
581 continue;
582 };
583 constraint_re.push((rows, dense, 1.0));
584 let magnitude = source.v_magnitude.get(position).copied().unwrap_or(0.0);
585 let angle = source.v_angle.get(position).copied().unwrap_or(0.0);
586 rhs_re.push(magnitude * angle.cos());
587 rhs_im.push(magnitude * angle.sin());
588 labels.push(format!("source:{}:{terminal}", source.name));
589 rows += 1;
590 }
591 }
592
593 for transformer in network.transformers() {
596 if transformer.windings.len() != 2 {
597 diagnostics.push(
598 Diagnostic::of(
599 &codes::BUILD_MULTI_UNSUPPORTED_STAMP,
600 format!(
601 "transformer `{}` has {} windings; only the two winding ideal plus leakage stamp is supported",
602 transformer.name,
603 transformer.windings.len()
604 ),
605 )
606 ,
607 );
608 continue;
609 }
610 let primary = &transformer.windings[0];
611 let secondary = &transformer.windings[1];
612 let pairs = primary.terminal_map.len().min(secondary.terminal_map.len());
613 let ratio = if secondary.v_ref == 0.0 {
614 0.0
615 } else {
616 (primary.v_ref * primary.tap) / (secondary.v_ref * secondary.tap)
617 };
618 if !ratio.is_finite() || ratio == 0.0 {
619 diagnostics.push(Diagnostic::of(
620 &codes::BUILD_MULTI_UNSUPPORTED_STAMP,
621 format!(
622 "transformer `{}` states no finite winding ratio; its stamp is omitted",
623 transformer.name
624 ),
625 ));
626 continue;
627 }
628 for pair in 0..pairs {
629 let p = resolve(
630 &primary.bus,
631 &primary.terminal_map[pair],
632 &format!("transformer `{}`", transformer.name),
633 )?;
634 let s = resolve(
635 &secondary.bus,
636 &secondary.terminal_map[pair],
637 &format!("transformer `{}`", transformer.name),
638 )?;
639 match (p, s) {
640 (NodeRef::Node(dense_p), NodeRef::Node(dense_s)) => {
641 constraint_re.push((rows, dense_p, 1.0));
642 constraint_re.push((rows, dense_s, -ratio));
643 rhs_re.push(0.0);
644 rhs_im.push(0.0);
645 labels.push(format!("transformer:{}:{pair}", transformer.name));
646 rows += 1;
647 }
648 (NodeRef::Node(dense_p), NodeRef::Ground) => {
650 constraint_re.push((rows, dense_p, 1.0));
651 rhs_re.push(0.0);
652 rhs_im.push(0.0);
653 labels.push(format!("transformer:{}:{pair}", transformer.name));
654 rows += 1;
655 }
656 (NodeRef::Ground, NodeRef::Node(dense_s)) => {
657 constraint_re.push((rows, dense_s, 1.0));
658 rhs_re.push(0.0);
659 rhs_im.push(0.0);
660 labels.push(format!("transformer:{}:{pair}", transformer.name));
661 rows += 1;
662 }
663 (NodeRef::Ground, NodeRef::Ground) => {}
664 }
665 }
666 }
667
668 for ibr in network.ibrs() {
671 diagnostics.push(
672 Diagnostic::of(
673 &codes::BUILD_MULTI_UNSUPPORTED_STAMP,
674 format!(
675 "inverter based resource `{}` has no passive admittance stamp; it is an injection, not an admittance",
676 ibr.name
677 ),
678 )
679 ,
680 );
681 }
682
683 let build_constraints = |triplets: &[(usize, usize, f64)]| {
684 let mut builder = CooBuilder::new_rect(rows.max(1), n.max(1));
685 for &(row, column, value) in triplets {
686 builder.add(row, column, value);
687 }
688 let mut matrix = builder.finish_csr();
689 if rows == 0 || n == 0 {
690 matrix = CooBuilder::new_rect(rows.max(1), n.max(1)).finish_csr();
691 }
692 matrix
693 };
694 let augmented = AugmentedSystem {
695 constraint_re: build_constraints(&constraint_re),
696 constraint_im: build_constraints(&constraint_im),
697 rhs_re,
698 rhs_im,
699 labels,
700 };
701 Ok(MulticonductorAdmittance {
702 index,
703 conductance: stamper.conductance.finish_csr(),
704 susceptance: stamper.susceptance.finish_csr(),
705 augmented,
706 diagnostics,
707 })
708}