Skip to main content

gridoxide_core/shortcircuit/
solver.rs

1//! Matrix assembly, fault stamping and the solve.
2//!
3//! Transcribed from power-grid-model's
4//! `math_solver/short_circuit_solver.hpp`, whose structure this follows
5//! closely on purpose: the fault boundary conditions are fiddly, every one of
6//! its cases is exercised by a fixture in `tests/data/pgm/short_circuit/`, and
7//! a formulation that merely looks reasonable will produce plausible numbers
8//! that are wrong.
9
10use std::collections::HashMap;
11
12use num_complex::Complex;
13
14use super::ScNetwork3Ph;
15use crate::network::{
16    build_ybus_3ph, connected_components, line3ph_blocks, seq_to_phase_shunt, stamp_shunts_3ph,
17    stamp_transformers_3ph, transformer3ph_blocks,
18};
19use gridoxide_sparse::sparse;
20
21use super::{Fault, FaultAdmittance, FaultType, PHASE_ANG, ShortCircuitOptions, c_factor};
22
23const ZERO: Complex<f64> = Complex::new(0.0, 0.0);
24
25/// Why a short-circuit calculation could not be carried out.
26#[derive(Clone, Debug, PartialEq)]
27pub enum ShortCircuitError {
28    /// The faults in one calculation disagree about type or phase.
29    ///
30    /// power-grid-model imposes the same restriction, and it is not
31    /// arbitrary: the boundary conditions of different fault types rewrite the
32    /// same matrix entries in incompatible ways, so "one of each, at once" is
33    /// not a well-posed single linear system. Several faults of the *same*
34    /// type, at the same or different buses, are fine.
35    MixedFaultTypes,
36    /// A `fault` names a `fault_object` that is not a node in this document.
37    UnknownFaultObject(u64),
38    UnknownFaultType(i8),
39    UnknownFaultPhase(i8),
40    /// The assembled system has no unique solution. In the phase domain the
41    /// usual cause is a network with no path to ground — see the module docs.
42    Singular,
43}
44
45impl std::fmt::Display for ShortCircuitError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::MixedFaultTypes => write!(
49                f,
50                "all faults in one short-circuit calculation must share a fault type and phase"
51            ),
52            Self::UnknownFaultObject(id) => {
53                write!(f, "fault_object {id} is not a node in this document")
54            }
55            Self::UnknownFaultType(c) => write!(f, "unknown fault_type {c}"),
56            Self::UnknownFaultPhase(c) => write!(f, "unknown fault_phase {c}"),
57            Self::Singular => write!(
58                f,
59                "the short-circuit system is singular — in the phase domain this usually \
60                 means the network has no path to ground"
61            ),
62        }
63    }
64}
65
66impl std::error::Error for ShortCircuitError {}
67
68/// One node's short-circuit voltages.
69#[derive(Clone, Debug)]
70pub struct NodeResult {
71    /// Per-phase voltage magnitude, per unit.
72    pub u_pu: [f64; 3],
73    /// Per-phase voltage angle, radians.
74    pub u_angle: [f64; 3],
75    /// Per-phase voltage magnitude in volts, **line-to-neutral** — the
76    /// asymmetric convention, `u_pu · u_rated / √3`.
77    pub u: [f64; 3],
78    /// Whether this node has a path to any active source.
79    pub energized: bool,
80}
81
82/// One fault's current.
83#[derive(Clone, Debug)]
84pub struct FaultResult {
85    pub id: u64,
86    /// Per-phase fault-current magnitude, amperes.
87    pub i_f: [f64; 3],
88    /// Per-phase fault-current angle, radians.
89    pub i_f_angle: [f64; 3],
90}
91
92/// One branch's terminal currents.
93#[derive(Clone, Debug)]
94pub struct BranchResult {
95    /// The PGM `line`, `transformer` or `link` id.
96    pub id: u64,
97    /// Whether a closed terminal connects the branch to an energized node —
98    /// power-grid-model's definition, under which a de-energized branch still
99    /// has a record, with zero currents.
100    pub energized: bool,
101    pub i_from: [f64; 3],
102    pub i_from_angle: [f64; 3],
103    pub i_to: [f64; 3],
104    pub i_to_angle: [f64; 3],
105}
106
107/// One source's current contribution.
108#[derive(Clone, Debug)]
109pub struct SourceResult {
110    pub id: u64,
111    pub i: [f64; 3],
112    pub i_angle: [f64; 3],
113}
114
115/// One active shunt's current, flowing from its node into the shunt.
116#[derive(Clone, Debug)]
117pub struct ShuntResult {
118    pub id: u64,
119    pub i: [f64; 3],
120    pub i_angle: [f64; 3],
121}
122
123/// Everything a short-circuit calculation produces.
124#[derive(Clone, Debug)]
125pub struct ShortCircuitReport {
126    /// Indexed by physical node.
127    pub nodes: Vec<NodeResult>,
128    /// In the order the faults were supplied.
129    pub faults: Vec<FaultResult>,
130    /// Lines first, then transformers, then links — the flat order of
131    /// [`ScNetwork3Ph::branch_ids`]. Fully open branches are absent.
132    pub branches: Vec<BranchResult>,
133    pub sources: Vec<SourceResult>,
134    /// Active shunts only, in the order of [`ScNetwork3Ph::shunt_ids`].
135    pub shunts: Vec<ShuntResult>,
136    /// Complex per-phase node voltages, per unit, kept so callers can derive
137    /// their own quantities (and so [`super::fortescue`] can project them)
138    /// without re-deriving them from magnitude and angle.
139    pub u_bus: Vec<[Complex<f64>; 3]>,
140}
141
142/// Runs a short-circuit calculation.
143///
144/// `faults` may be empty, in which case this is simply the network's
145/// pre-fault state under the scaled source EMFs.
146pub fn short_circuit(
147    net: &ScNetwork3Ph,
148    faults: &[Fault],
149    opts: ShortCircuitOptions,
150) -> Result<ShortCircuitReport, ShortCircuitError> {
151    check_faults_agree(faults)?;
152
153    let n = net.n_nodes;
154    let dim = 3 * n;
155
156    // ── Assembly ─────────────────────────────────────────────────────────
157    // The passive network first, reusing the same Y-bus builders the
158    // asymmetric power flow uses — a short circuit sees the same lines,
159    // transformers and shunts, which is the whole reason this is cheap.
160    let mut ybus = build_ybus_3ph(n, &net.lines);
161    stamp_transformers_3ph(&mut ybus, &net.transformers);
162    stamp_shunts_3ph(&mut ybus, &net.shunts);
163    let mut entries = ybus.into_entries();
164    let mut rhs = vec![ZERO; dim];
165
166    // Then the sources, as Norton equivalents: internal admittance onto the
167    // diagonal, EMF into the right-hand side.
168    //
169    // **Before the faults, not after.** A bolted fault zeroes the faulted
170    // bus's whole column, and it must zero the source's contribution to that
171    // column along with everything else. Reversing these two loops would
172    // leave a source stamped into a column that is supposed to be empty.
173    let source_y: Vec<[[Complex<f64>; 3]; 3]> = net
174        .sources
175        .iter()
176        .map(|src| seq_to_phase_shunt(src.y1, src.y0))
177        .collect();
178    let source_emf: Vec<[Complex<f64>; 3]> = net
179        .sources
180        .iter()
181        .map(|src| {
182            // IEC 60909's source EMF is `c · e^{jθ}`. It is **not** scaled by
183            // the source's own `u_ref`: the voltage factor replaces the
184            // setpoint rather than multiplying it, which is what isolates the
185            // result from the operating point. Every reference fixture has
186            // `u_ref = 1.0`, so this distinction is invisible to them and
187            // shows up only on real data — see this module's tests.
188            let c = c_factor(src.u_rated, opts.scaling);
189            [0, 1, 2].map(|p| Complex::from_polar(c, src.u_ref_angle + PHASE_ANG[p]))
190        })
191        .collect();
192
193    for (s, src) in net.sources.iter().enumerate() {
194        let y = &source_y[s];
195        let u = &source_emf[s];
196        for p in 0..3 {
197            for q in 0..3 {
198                entries.push((3 * src.node + p, 3 * src.node + q, y[p][q]));
199                rhs[3 * src.node + p] += y[p][q] * u[q];
200            }
201        }
202    }
203
204    // ── Fault stamping ───────────────────────────────────────────────────
205    //
206    // A fault on a de-energized bus is not a fault: with no source feeding it
207    // there is nothing to drive a current, and power-grid-model says the same
208    // thing by handing such a fault a zero admittance
209    // (`Fault::calc_param`'s `energized` guard). Skipping it here keeps it out
210    // of the matrix entirely, and it comes back with zero current below.
211    let energized = energized_nodes(net);
212    let mut faults_by_bus: HashMap<usize, Vec<usize>> = HashMap::new();
213    for (i, f) in faults.iter().enumerate() {
214        if energized[f.bus] {
215            faults_by_bus.entry(f.bus).or_default().push(i);
216        }
217    }
218    // How many bolted faults sit on each bus. A bolted fault short-circuits
219    // the bus outright, so any further fault there — bolted or not — is
220    // shorted out by it; the count is what splits the resulting current
221    // between several bolted faults that share a bus.
222    let mut bolted_count: HashMap<usize, usize> = HashMap::new();
223
224    for (&bus, fault_idx) in &faults_by_bus {
225        for &i in fault_idx {
226            let f = &faults[i];
227            match f.y_fault {
228                FaultAdmittance::Bolted => {
229                    *bolted_count.entry(bus).or_insert(0) += 1;
230                    stamp_bolted(&mut entries, &mut rhs, bus, f);
231                    // Once a bus is dead-shorted there is nothing further to
232                    // stamp there — every other fault on it is bypassed.
233                    break;
234                }
235                FaultAdmittance::Finite(y) => {
236                    stamp_finite(&mut entries, &mut rhs, bus, f, y);
237                }
238            }
239        }
240    }
241
242    // ── Solve ────────────────────────────────────────────────────────────
243    //
244    // Only over the energized rows. A component with no source has neither a
245    // source admittance nor any other reference to ground, so including it
246    // would make the whole system singular and lose the answer for the parts
247    // that *are* solvable — the same reason every other solver in this crate
248    // partitions before solving (see `network::mark_unreferenced_islands`).
249    // De-energized buses are pinned at zero rather than invented, which is
250    // this crate's established contract for them.
251    //
252    // Energization follows the same connectivity the Y-bus does, so the
253    // matrix is block-diagonal across the split and dropping the other block
254    // changes nothing about this one.
255    let mut reduced_pos: Vec<Option<usize>> = vec![None; dim];
256    let mut n_reduced = 0;
257    for k in 0..n {
258        if energized[k] {
259            for p in 0..3 {
260                reduced_pos[3 * k + p] = Some(n_reduced);
261                n_reduced += 1;
262            }
263        }
264    }
265
266    let reduced_entries: Vec<(usize, usize, Complex<f64>)> = entries
267        .iter()
268        .filter_map(|&(r, c, v)| Some((reduced_pos[r]?, reduced_pos[c]?, v)))
269        .collect();
270    let reduced_rhs: Vec<Complex<f64>> = (0..dim)
271        .filter(|&i| reduced_pos[i].is_some())
272        .map(|i| rhs[i])
273        .collect();
274
275    let mut u_bus = vec![[ZERO; 3]; n];
276    if n_reduced > 0 {
277        let sol = sparse::solve_complex(n_reduced, &reduced_entries, &reduced_rhs)
278            .ok_or(ShortCircuitError::Singular)?;
279        for k in 0..n {
280            for p in 0..3 {
281                if let Some(r) = reduced_pos[3 * k + p] {
282                    u_bus[k][p] = sol[r];
283                }
284            }
285        }
286    }
287
288    // ── Results ──────────────────────────────────────────────────────────
289    let mut fault_current = vec![[ZERO; 3]; faults.len()];
290    resolve_fault_currents(
291        net,
292        faults,
293        &faults_by_bus,
294        &bolted_count,
295        &source_y,
296        &source_emf,
297        &mut u_bus,
298        &mut fault_current,
299    );
300
301    Ok(assemble_report(
302        net,
303        faults,
304        &fault_current,
305        &source_y,
306        &source_emf,
307        u_bus,
308        &energized,
309    ))
310}
311
312/// power-grid-model's `check_input_valid`: one calculation, one fault type and
313/// phase.
314fn check_faults_agree(faults: &[Fault]) -> Result<(), ShortCircuitError> {
315    let Some(first) = faults.first() else {
316        return Ok(());
317    };
318    if faults
319        .iter()
320        .any(|f| f.fault_type != first.fault_type || f.fault_phase != first.fault_phase)
321    {
322        return Err(ShortCircuitError::MixedFaultTypes);
323    }
324    Ok(())
325}
326
327/// Removes every triplet in global column `col`.
328///
329/// This is why the matrix is carried as triplets rather than as a finished
330/// `YBusSparse`: zeroing a column is a *filter*, and it has to be one.
331/// Duplicate `(row, col)` triplets are summed downstream, so "adding a
332/// compensating negative entry" would cancel only the entries it was computed
333/// against and silently leave any other contribution to that column standing.
334fn zero_column(entries: &mut Vec<(usize, usize, Complex<f64>)>, col: usize) {
335    entries.retain(|&(_, c, _)| c != col);
336}
337
338/// Adds column `from` into column `into`, then empties column `from`.
339fn fold_column(entries: &mut Vec<(usize, usize, Complex<f64>)>, into: usize, from: usize) {
340    let folded: Vec<(usize, usize, Complex<f64>)> = entries
341        .iter()
342        .filter(|&&(_, c, _)| c == from)
343        .map(|&(r, _, v)| (r, into, v))
344        .collect();
345    entries.extend(folded);
346    zero_column(entries, from);
347}
348
349/// Stamps a bolted (zero-impedance) fault.
350///
351/// The faulted bus's own equations are *replaced* rather than added to: its
352/// voltage is known to be zero, so the unknown at that position becomes the
353/// injected current instead, which is what the `-1` diagonal expresses.
354fn stamp_bolted(
355    entries: &mut Vec<(usize, usize, Complex<f64>)>,
356    rhs: &mut [Complex<f64>],
357    bus: usize,
358    f: &Fault,
359) {
360    let (p1, p2) = f.fault_phase.indices();
361    match f.fault_type {
362        FaultType::ThreePhase => {
363            for p in 0..3 {
364                zero_column(entries, 3 * bus + p);
365                entries.push((3 * bus + p, 3 * bus + p, -Complex::new(1.0, 0.0)));
366                rhs[3 * bus + p] = ZERO;
367            }
368        }
369        FaultType::SinglePhaseToGround => {
370            let p1 = p1.expect("a single-phase fault names its phase");
371            zero_column(entries, 3 * bus + p1);
372            entries.push((3 * bus + p1, 3 * bus + p1, -Complex::new(1.0, 0.0)));
373            rhs[3 * bus + p1] = ZERO;
374        }
375        FaultType::TwoPhase => {
376            // The two phases are tied together but not to ground, so the
377            // constraint is `u_p1 = u_p2` rather than either being zero —
378            // hence a fold rather than two independent column erasures.
379            let (p1, p2) = two_phase_indices(p1, p2);
380            fold_column(entries, 3 * bus + p1, 3 * bus + p2);
381            entries.push((3 * bus + p1, 3 * bus + p2, -Complex::new(1.0, 0.0)));
382            entries.push((3 * bus + p2, 3 * bus + p2, Complex::new(1.0, 0.0)));
383            let carried = rhs[3 * bus + p1];
384            rhs[3 * bus + p2] += carried;
385            rhs[3 * bus + p1] = ZERO;
386        }
387        FaultType::TwoPhaseToGround => {
388            let (p1, p2) = two_phase_indices(p1, p2);
389            for p in [p1, p2] {
390                zero_column(entries, 3 * bus + p);
391                entries.push((3 * bus + p, 3 * bus + p, -Complex::new(1.0, 0.0)));
392                rhs[3 * bus + p] = ZERO;
393            }
394        }
395    }
396}
397
398/// Stamps a fault through a finite admittance. Except for the two-phase-to-
399/// ground case these are pure diagonal-block additions — the fault is just
400/// another admittance to ground.
401fn stamp_finite(
402    entries: &mut Vec<(usize, usize, Complex<f64>)>,
403    rhs: &mut [Complex<f64>],
404    bus: usize,
405    f: &Fault,
406    y: Complex<f64>,
407) {
408    let (p1, p2) = f.fault_phase.indices();
409    match f.fault_type {
410        FaultType::ThreePhase => {
411            for p in 0..3 {
412                entries.push((3 * bus + p, 3 * bus + p, y));
413            }
414        }
415        FaultType::SinglePhaseToGround => {
416            let p1 = p1.expect("a single-phase fault names its phase");
417            entries.push((3 * bus + p1, 3 * bus + p1, y));
418        }
419        FaultType::TwoPhase => {
420            let (p1, p2) = two_phase_indices(p1, p2);
421            entries.push((3 * bus + p1, 3 * bus + p1, y));
422            entries.push((3 * bus + p2, 3 * bus + p2, y));
423            entries.push((3 * bus + p1, 3 * bus + p2, -y));
424            entries.push((3 * bus + p2, 3 * bus + p1, -y));
425        }
426        FaultType::TwoPhaseToGround => {
427            // Two constraints at once — the phases are tied to each other
428            // (the fold) *and* the pair is tied to ground through `y` — so
429            // this one needs the column surgery even with finite impedance.
430            let (p1, p2) = two_phase_indices(p1, p2);
431            fold_column(entries, 3 * bus + p1, 3 * bus + p2);
432            entries.push((3 * bus + p1, 3 * bus + p2, -Complex::new(1.0, 0.0)));
433            entries.push((3 * bus + p2, 3 * bus + p2, Complex::new(1.0, 0.0)));
434            // Column p1 was *not* zeroed, so this is a genuine addition.
435            entries.push((3 * bus + p2, 3 * bus + p1, y));
436            let carried = rhs[3 * bus + p1];
437            rhs[3 * bus + p2] += carried;
438            rhs[3 * bus + p1] = ZERO;
439        }
440    }
441}
442
443fn two_phase_indices(p1: Option<usize>, p2: Option<usize>) -> (usize, usize) {
444    (
445        p1.expect("a two-phase fault names its first phase"),
446        p2.expect("a two-phase fault names its second phase"),
447    )
448}
449
450/// Recovers fault currents and restores the true voltages at faulted buses.
451///
452/// At a faulted bus the solved vector does not hold a voltage in the phases
453/// the fault constrains — the bolted stamping swapped that unknown for an
454/// injected current. This reads the current back out and writes the known
455/// voltage (zero, or the tied partner's) into its place, which is why
456/// `u_bus` is taken by `&mut`.
457#[allow(clippy::too_many_arguments)]
458fn resolve_fault_currents(
459    net: &ScNetwork3Ph,
460    faults: &[Fault],
461    faults_by_bus: &HashMap<usize, Vec<usize>>,
462    bolted_count: &HashMap<usize, usize>,
463    source_y: &[[[Complex<f64>; 3]; 3]],
464    source_emf: &[[Complex<f64>; 3]],
465    u_bus: &mut [[Complex<f64>; 3]],
466    fault_current: &mut [[Complex<f64>; 3]],
467) {
468    for (&bus, fault_idx) in faults_by_bus {
469        // Snapshot before the loop below starts rewriting the bus, exactly as
470        // the reference does: every fault at this bus reads the *same*
471        // pre-restoration values.
472        let subtotal = u_bus[bus];
473        let n_bolted = *bolted_count.get(&bus).unwrap_or(&0);
474        let split = n_bolted as f64;
475
476        for &i in fault_idx {
477            let f = &faults[i];
478            let (p1, p2) = f.fault_phase.indices();
479            let i_f = &mut fault_current[i];
480
481            match f.y_fault {
482                FaultAdmittance::Bolted => match f.fault_type {
483                    FaultType::ThreePhase => {
484                        for p in 0..3 {
485                            i_f[p] = -subtotal[p] / split;
486                            u_bus[bus][p] = ZERO;
487                        }
488                    }
489                    FaultType::SinglePhaseToGround => {
490                        let p1 = p1.unwrap();
491                        i_f[p1] = -subtotal[p1] / split;
492                        u_bus[bus][p1] = ZERO;
493                    }
494                    FaultType::TwoPhase => {
495                        let (p1, p2) = two_phase_indices(p1, p2);
496                        i_f[p1] = -subtotal[p2] / split;
497                        i_f[p2] = -i_f[p1];
498                        u_bus[bus][p2] = u_bus[bus][p1];
499                    }
500                    FaultType::TwoPhaseToGround => {
501                        let (p1, p2) = two_phase_indices(p1, p2);
502                        i_f[p1] = -subtotal[p1] / split;
503                        i_f[p2] = -subtotal[p2] / split;
504                        u_bus[bus][p1] = ZERO;
505                        u_bus[bus][p2] = ZERO;
506                    }
507                },
508                FaultAdmittance::Finite(y) => {
509                    // A bolted fault on the same bus shorts this one out
510                    // entirely: it sits across zero volts and carries nothing.
511                    if n_bolted > 0 {
512                        continue;
513                    }
514                    match f.fault_type {
515                        FaultType::ThreePhase => {
516                            for p in 0..3 {
517                                i_f[p] = y * subtotal[p];
518                            }
519                        }
520                        FaultType::SinglePhaseToGround => {
521                            let p1 = p1.unwrap();
522                            i_f[p1] = y * subtotal[p1];
523                        }
524                        FaultType::TwoPhase => {
525                            let (p1, p2) = two_phase_indices(p1, p2);
526                            i_f[p1] = y * subtotal[p1] - y * subtotal[p2];
527                            i_f[p2] = y * subtotal[p2] - y * subtotal[p1];
528                        }
529                        FaultType::TwoPhaseToGround => {
530                            let (p1, p2) = two_phase_indices(p1, p2);
531                            i_f[p1] = -subtotal[p2];
532                            i_f[p2] = -i_f[p1] + y * subtotal[p1];
533                            u_bus[bus][p2] = u_bus[bus][p1];
534                        }
535                    }
536                }
537            }
538        }
539
540        // Source currents at this bus, read against the *restored* voltages.
541        let mut i_source_bus = [ZERO; 3];
542        let mut i_source_inject = [ZERO; 3];
543        for (s, src) in net.sources.iter().enumerate() {
544            if src.node != bus {
545                continue;
546            }
547            let (inject, out) = source_currents(&source_y[s], &source_emf[s], &u_bus[bus]);
548            for p in 0..3 {
549                i_source_bus[p] += out[p];
550                i_source_inject[p] += inject[p];
551            }
552        }
553
554        // A source sitting on the faulted bus feeds the fault directly,
555        // without passing through any branch — so its contribution never
556        // appeared in the solve above and has to be added back here.
557        for &i in fault_idx {
558            let f = &faults[i];
559            let (p1, p2) = f.fault_phase.indices();
560            let i_f = &mut fault_current[i];
561            match f.y_fault {
562                FaultAdmittance::Bolted => match f.fault_type {
563                    FaultType::ThreePhase => {
564                        for p in 0..3 {
565                            i_f[p] += i_source_bus[p] / split;
566                        }
567                    }
568                    FaultType::SinglePhaseToGround => {
569                        let p1 = p1.unwrap();
570                        i_f[p1] += i_source_bus[p1] / split;
571                    }
572                    FaultType::TwoPhase => {
573                        let (p1, p2) = two_phase_indices(p1, p2);
574                        i_f[p1] += i_source_inject[p1] / split;
575                        i_f[p2] -= i_source_inject[p1] / split;
576                    }
577                    FaultType::TwoPhaseToGround => {
578                        let (p1, p2) = two_phase_indices(p1, p2);
579                        i_f[p1] += i_source_bus[p1];
580                        i_f[p2] += i_source_bus[p2];
581                    }
582                },
583                FaultAdmittance::Finite(_) => {
584                    if f.fault_type == FaultType::TwoPhaseToGround && n_bolted == 0 {
585                        let (p1, p2) = two_phase_indices(p1, p2);
586                        let finite = fault_idx.len() as f64;
587                        i_f[p1] += i_source_inject[p1] / finite;
588                        i_f[p2] -= i_source_inject[p1] / finite;
589                    }
590                }
591            }
592        }
593    }
594
595    // Sources on unfaulted buses still have a current worth reporting; those
596    // are computed in `assemble_report`, which sees every source.
597}
598
599/// A source's raw Norton injection and its net current into the bus.
600fn source_currents(
601    y: &[[Complex<f64>; 3]; 3],
602    emf: &[Complex<f64>; 3],
603    u: &[Complex<f64>; 3],
604) -> ([Complex<f64>; 3], [Complex<f64>; 3]) {
605    let mut inject = [ZERO; 3];
606    let mut out = [ZERO; 3];
607    for p in 0..3 {
608        for q in 0..3 {
609            inject[p] += y[p][q] * emf[q];
610            out[p] += y[p][q] * (emf[q] - u[q]);
611        }
612    }
613    (inject, out)
614}
615
616fn assemble_report(
617    net: &ScNetwork3Ph,
618    faults: &[Fault],
619    fault_current: &[[Complex<f64>; 3]],
620    source_y: &[[[Complex<f64>; 3]; 3]],
621    source_emf: &[[Complex<f64>; 3]],
622    u_bus: Vec<[Complex<f64>; 3]>,
623    energized: &[bool],
624) -> ShortCircuitReport {
625    let sqrt3 = 3.0_f64.sqrt();
626
627    let nodes = (0..net.n_nodes)
628        .map(|k| {
629            let u_rated = net.u_rated[k];
630            NodeResult {
631                u_pu: [0, 1, 2].map(|p| u_bus[k][p].norm()),
632                u_angle: [0, 1, 2].map(|p| u_bus[k][p].arg()),
633                u: [0, 1, 2].map(|p| u_bus[k][p].norm() * u_rated / sqrt3),
634                energized: energized[k],
635            }
636        })
637        .collect();
638
639    // Per-unit currents become amperes against the faulted node's own base.
640    // The asymmetric base power is `s_base/3` against a line-to-neutral
641    // voltage base of `u_rated/√3`, which is the same as `s_base/(√3·u_rated)`.
642    let i_base = |u_rated: f64| net.s_base_va / (sqrt3 * u_rated);
643
644    let fault_results = faults
645        .iter()
646        .zip(fault_current)
647        .map(|(f, i)| {
648            let base = i_base(net.u_rated[f.bus]);
649            FaultResult {
650                id: f.id,
651                i_f: [0, 1, 2].map(|p| i[p].norm() * base),
652                i_f_angle: [0, 1, 2].map(|p| i[p].arg()),
653            }
654        })
655        .collect();
656
657    let sources = net
658        .sources
659        .iter()
660        .enumerate()
661        .map(|(s, src)| {
662            let (_, out) = source_currents(&source_y[s], &source_emf[s], &u_bus[src.node]);
663            let base = i_base(src.u_rated);
664            SourceResult {
665                id: src.id,
666                i: [0, 1, 2].map(|p| out[p].norm() * base),
667                i_angle: [0, 1, 2].map(|p| out[p].arg()),
668            }
669        })
670        .collect();
671
672    let branches = branch_currents(net, &u_bus, energized, sqrt3);
673
674    // power-grid-model reports the current *into* the shunt, `Y·u` — its
675    // solver computes the injection `-Y·u` and flips it back on output.
676    let shunts = net
677        .shunts
678        .iter()
679        .zip(&net.shunt_ids)
680        .map(|(sh, &id)| {
681            let y = seq_to_phase_shunt(sh.y1, sh.y0);
682            let u = &u_bus[sh.at];
683            let i: [Complex<f64>; 3] = [0, 1, 2].map(|p| (0..3).map(|q| y[p][q] * u[q]).sum());
684            let base = i_base(net.u_rated[sh.at]);
685            ShuntResult {
686                id,
687                i: [0, 1, 2].map(|p| i[p].norm() * base),
688                i_angle: [0, 1, 2].map(|p| i[p].arg()),
689            }
690        })
691        .collect();
692
693    ShortCircuitReport {
694        nodes,
695        faults: fault_results,
696        branches,
697        sources,
698        shunts,
699        u_bus,
700    }
701}
702
703/// Terminal currents for every branch, in [`ScNetwork3Ph::branch_ids`] order.
704fn branch_currents(
705    net: &ScNetwork3Ph,
706    u_bus: &[[Complex<f64>; 3]],
707    energized: &[bool],
708    sqrt3: f64,
709) -> Vec<BranchResult> {
710    let mut out = Vec::with_capacity(net.lines.len() + net.transformers.len());
711
712    let push = |from: usize,
713                to: usize,
714                blocks: [[[Complex<f64>; 3]; 3]; 4],
715                out: &mut Vec<BranchResult>| {
716        let id = net.branch_ids[out.len()];
717        let [from_closed, to_closed] = net.branch_closed[out.len()];
718        let [yff, yft, ytf, ytt] = blocks;
719        let mut i_from = [ZERO; 3];
720        let mut i_to = [ZERO; 3];
721        for p in 0..3 {
722            for q in 0..3 {
723                i_from[p] += yff[p][q] * u_bus[from][q] + yft[p][q] * u_bus[to][q];
724                i_to[p] += ytf[p][q] * u_bus[from][q] + ytt[p][q] * u_bus[to][q];
725            }
726        }
727        // A self-loop puts its whole shunt on the `from` block. That is right
728        // for the Y-bus either way, but a line genuinely wired into itself with
729        // both terminals closed carries no series current (both ends sit at one
730        // voltage), so each terminal draws half the shunt current — which is
731        // what power-grid-model reports.
732        if from == to && from_closed && to_closed {
733            i_from = i_from.map(|i| i / 2.0);
734            i_to = i_from;
735        }
736        // Each terminal is referred to its own node's base.
737        let base_from = net.s_base_va / (sqrt3 * net.u_rated[from]);
738        let base_to = net.s_base_va / (sqrt3 * net.u_rated[to]);
739        let mut r = BranchResult {
740            id,
741            energized: (from_closed && energized[from]) || (to_closed && energized[to]),
742            i_from: [0, 1, 2].map(|p| i_from[p].norm() * base_from),
743            i_from_angle: [0, 1, 2].map(|p| i_from[p].arg()),
744            i_to: [0, 1, 2].map(|p| i_to[p].norm() * base_to),
745            i_to_angle: [0, 1, 2].map(|p| i_to[p].arg()),
746        };
747        // A half-open line's self-loop carries its current at `from`, but
748        // power-grid-model reports it at whichever terminal is the live one.
749        if from == to && !from_closed {
750            std::mem::swap(&mut r.i_from, &mut r.i_to);
751            std::mem::swap(&mut r.i_from_angle, &mut r.i_to_angle);
752        }
753        out.push(r);
754    };
755
756    for ln in &net.lines {
757        push(ln.from, ln.to, line3ph_blocks(ln), &mut out);
758    }
759    for t in &net.transformers {
760        push(t.from, t.to, transformer3ph_blocks(t), &mut out);
761    }
762    out
763}
764
765/// Which nodes have a path to an active source, over the passive network's own
766/// connectivity.
767fn energized_nodes(net: &ScNetwork3Ph) -> Vec<bool> {
768    let mut ybus = build_ybus_3ph(net.n_nodes, &net.lines);
769    stamp_transformers_3ph(&mut ybus, &net.transformers);
770    let finished = ybus.finish();
771    let components = connected_components(&finished);
772
773    let mut energized = vec![false; net.n_nodes];
774    for comp in &components {
775        // A component is a set of *phase* rows; a physical node is energized
776        // if any of its phases sits in a component holding a source.
777        let has_source = comp
778            .iter()
779            .any(|&row| net.sources.iter().any(|s| s.node == row / 3));
780        if has_source {
781            for &row in comp {
782                energized[row / 3] = true;
783            }
784        }
785    }
786    energized
787}