Skip to main content

powerio_matrix/matrix/
multiconductor.rs

1//! Native multiconductor nodal admittance over [`MulticonductorNetwork`]
2//! (#232): terminal and conductor indexed, in the network's actual units
3//! (volts, amperes, siemens), with no implicit positive sequence
4//! transformation anywhere.
5//!
6//! The voltage unknown rows are the ungrounded bus terminals: terminal `0`
7//! and every explicitly grounded terminal are excluded, and buses joined by
8//! an exact unity connection (a closed switch) merge into one electrical
9//! node rather than receiving an arbitrary small impedance. Every axis
10//! carries the stable [`DistNode`] mapping — bus identity plus terminal — so
11//! mappings remain valid after source row reordering.
12//!
13//! The passive admittance carries lines (series and both shunt halves from
14//! the linecode), shunts, and capacitor banks. Ideal equipment — two winding
15//! transformer coupling and voltage sources — enters the augmented system as
16//! exact constraint rows over the node voltages with their coupled ideal
17//! currents. Transformer leakage, non-WYE connections, floating winding
18//! neutrals, core shunts and tap decisions require a different augmented
19//! formulation and return an unsupported-physics error before assembly.
20
21use 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/// One voltage unknown: a bus terminal, by stable identity.
33#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub struct DistNode {
35    pub bus: String,
36    pub terminal: String,
37}
38
39/// Where a bus terminal lands in the nodal system.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum NodeRef {
42    /// A voltage unknown at this dense row.
43    Node(usize),
44    /// Ground: terminal `0` or an explicitly grounded terminal.
45    Ground,
46}
47
48/// The dense node indexing of one multiconductor network.
49#[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    /// Build the index: bus table order, each bus's stated terminal order,
72    /// with terminal `0` and explicitly grounded terminals excluded from the
73    /// unknowns. Closed switches merge their paired terminals into one
74    /// node — the first spelling encountered names the merged node.
75    pub fn build(network: &MulticonductorNetwork) -> Result<Self> {
76        // Union-find over provisional slots, one per ungrounded terminal.
77        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        // Every union runs before any group is marked grounded, so a
93        // grounding is a property of the finished group and the index is
94        // identical under any declaration order of closed switches.
95        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                    // A closed switch onto ground grounds the other side's
113                    // whole finished group.
114                    (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        // Dense rows: one per surviving root, in provisional (table) order.
135        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            // A root whose group touched ground through a switch grounds the
141            // whole group.
142            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    /// The voltage unknowns, in dense row order.
161    #[must_use]
162    pub fn nodes(&self) -> &[DistNode] {
163        &self.nodes
164    }
165
166    /// The number of voltage unknowns.
167    #[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    /// Where one bus terminal lands: a dense unknown (closed switch groups
178    /// share one), ground, or `None` for a terminal the network does not
179    /// declare.
180    #[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/// One ideal constraint row of the augmented system: exact relations over
194/// the node voltages, with the row's coupled ideal current entering the
195/// nodal balance through the transpose.
196#[derive(Clone, Debug)]
197pub struct AugmentedSystem {
198    /// Real and imaginary parts of the constraint matrix `A`
199    /// (`rows × nodes`): `A v = rhs`.
200    pub constraint_re: CsMat<f64>,
201    pub constraint_im: CsMat<f64>,
202    pub rhs_re: Vec<f64>,
203    pub rhs_im: Vec<f64>,
204    /// The element behind each constraint row, by stable identity.
205    pub labels: Vec<String>,
206}
207
208/// The assembled multiconductor nodal system.
209#[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    /// The node indexing behind every axis.
220    #[must_use]
221    pub const fn index(&self) -> &MulticonductorNodeIndex {
222        &self.index
223    }
224
225    /// The passive nodal conductance `G`, siemens, over the unknowns.
226    #[must_use]
227    pub const fn conductance(&self) -> &CsMat<f64> {
228        &self.conductance
229    }
230
231    /// The passive nodal susceptance `B`, siemens: `Y = G + jB`.
232    #[must_use]
233    pub const fn susceptance(&self) -> &CsMat<f64> {
234        &self.susceptance
235    }
236
237    /// The ideal equipment constraint rows.
238    #[must_use]
239    pub const fn augmented(&self) -> &AugmentedSystem {
240        &self.augmented
241    }
242
243    /// The builder's findings: every stamp it does not support, by element.
244    #[must_use]
245    pub fn diagnostics(&self) -> &[Diagnostic] {
246        &self.diagnostics
247    }
248}
249
250/// Small dense complex inverse by Gaussian elimination with partial
251/// pivoting, for conductor count sized matrices.
252fn 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    /// Accumulate one admittance between two node references: ground rows
318    /// and columns vanish, which is exactly the grounded reduction.
319    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/// Build the passive nodal admittance and the ideal equipment constraints.
328///
329/// # Errors
330/// A structurally broken network: an element naming an undeclared bus or
331/// terminal, or a line whose terminal maps disagree with its linecode shape.
332/// Everything the builder cannot stamp exactly is a structured diagnostic,
333/// never a silent omission or a fabricated impedance.
334#[allow(clippy::too_many_lines)] // one stamp block per element family
335#[allow(clippy::many_single_char_names)] // n and per conductor y/z/b follow the textbook stamps
336pub 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    // Lines: series inverse of the linecode impedance over the length, and
398    // both shunt halves.
399    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    // Shunt elements: their stated admittance matrix across their terminals.
469    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    // Capacitor banks: nameplate reactive power at nameplate voltage.
484    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                // Phase terminals to the last terminal (neutral); nameplate
514                // voltage is line to line.
515                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                // Two terminals close one delta loop; more close `phases`.
543                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    // Ideal equipment: constraint rows over the node voltages, collected as
567    // triplets until the row count is known.
568    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    // Voltage sources: v(terminal) = stated complex voltage, one row per
576    // ungrounded source terminal.
577    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    // Grounded WYE winding pairs use an exact voltage-ratio constraint.
594    // Unsupported transformer physics is rejected before assembly.
595    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                // A grounded terminal fixes that side of the relation.
649                (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    // Injections (loads, generators) are boundary data, never admittance;
669    // families with no exact stamp are reported.
670    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}