Skip to main content

gridoxide_core/
network.rs

1use super::types::{Bus, BusType, Line, Line3Ph, Transformer, Transformer3PhSeq, ZipKind};
2use gridoxide_sparse::sparse;
3use num_complex::Complex;
4
5/// A lumped shunt admittance to be added to the Y-bus diagonal at bus `at`.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct ShuntAdm {
8    pub at: usize,
9    pub y: Complex<f64>,
10}
11
12/// A three-phase shunt admittance, given as positive- and zero-sequence values.
13pub struct ShuntAdm3Ph {
14    pub at: usize,
15    pub y1: Complex<f64>,
16    pub y0: Complex<f64>,
17}
18
19/// Converts sequence-domain admittance (y1, y0) to the 3×3 phase-domain shunt
20/// tensor: diagonal `(2·y1+y0)/3`, off-diagonal `(y0−y1)/3`.
21pub(crate) fn seq_to_phase_shunt(y1: Complex<f64>, y0: Complex<f64>) -> [[Complex<f64>; 3]; 3] {
22    let d = (y0 + 2.0 * y1) / 3.0;
23    let o = (y0 - y1) / 3.0;
24    let mut m = [[o; 3]; 3];
25    for i in 0..3 {
26        m[i][i] = d;
27    }
28    m
29}
30
31/// A mutable Y-bus under construction: a COO (triplet) accumulator. Entries
32/// at the same `(row, col)` are summed once finalized via `finish()` — this
33/// gives the same accumulation semantics as the old dense `ybus[(i,j)] +=
34/// val`, including for parallel branches between the same two buses (e.g.
35/// `symmetric/distribution-case`'s two parallel transformers).
36pub struct YBus {
37    n: usize,
38    entries: Vec<(usize, usize, Complex<f64>)>,
39}
40
41impl YBus {
42    pub fn new(n: usize) -> Self {
43        Self {
44            n,
45            entries: Vec::new(),
46        }
47    }
48
49    pub fn add(&mut self, i: usize, j: usize, val: Complex<f64>) {
50        self.entries.push((i, j, val));
51    }
52
53    /// The raw, unconsolidated COO triplets, for callers that need to modify
54    /// the matrix *by column* before solving.
55    ///
56    /// [`finish`](Self::finish) is the normal exit and gives a row-major
57    /// structure ([`YBusSparse::row`]) that cannot serve a column-wise
58    /// rewrite. `shortcircuit` needs exactly that: a bolted fault zeroes the
59    /// faulted bus's whole column, which is a filter over these triplets.
60    ///
61    /// **Zeroing a column means removing its entries, not adding a
62    /// compensating one.** Duplicate `(row, col)` triplets are *summed*, both
63    /// here in `finish` and in `sparse::solve_complex`'s own
64    /// `try_new_from_triplets`, so an added negative would cancel only the
65    /// entries it was computed against and silently leave anything else.
66    pub fn into_entries(self) -> Vec<(usize, usize, Complex<f64>)> {
67        self.entries
68    }
69
70    /// Consolidates the accumulated triplets into the frozen, sparse form
71    /// used for the actual power-flow solve. Call once all `build_ybus*`/
72    /// `stamp_*` contributions have been added.
73    pub fn finish(self) -> YBusSparse {
74        // Consolidate duplicate (i, j) entries and group by row, giving each
75        // row's actual admittance neighbors (including its own diagonal) —
76        // used by `linear_initial_guess` and `build_jacobian` to walk only
77        // real neighbors instead of every other bus.
78        let mut merged: std::collections::HashMap<(usize, usize), Complex<f64>> =
79            std::collections::HashMap::new();
80        for &(i, j, v) in &self.entries {
81            *merged.entry((i, j)).or_insert(Complex::new(0.0, 0.0)) += v;
82        }
83        let mut adjacency: Vec<Vec<(usize, Complex<f64>)>> = vec![Vec::new(); self.n];
84        for (&(i, j), &v) in &merged {
85            adjacency[i].push((j, v));
86        }
87        for row in &mut adjacency {
88            row.sort_unstable_by_key(|&(j, _)| j);
89        }
90        let matrix = sparse::SparseMatrix::build(self.n, &self.entries)
91            .expect("Y-bus triplet set should always form a valid sparse matrix");
92        YBusSparse {
93            n: self.n,
94            adjacency,
95            matrix,
96        }
97    }
98}
99
100/// A finalized, frozen Y-bus: consolidated per-row admittance neighbors (for
101/// sparse-aware assembly of the linear initial guess and the Jacobian) plus
102/// a ready-to-use sparse matrix (for `power_injections`'s mat-vec, needed
103/// every Newton-Raphson iteration). Built once via `YBus::finish`.
104#[derive(Clone)]
105pub struct YBusSparse {
106    n: usize,
107    adjacency: Vec<Vec<(usize, Complex<f64>)>>,
108    matrix: sparse::SparseMatrix,
109}
110
111impl YBusSparse {
112    pub fn n(&self) -> usize {
113        self.n
114    }
115
116    /// The `(col, value)` pairs for row `i`'s actual admittance neighbors
117    /// (including the diagonal), sorted by column index.
118    pub fn row(&self, i: usize) -> &[(usize, Complex<f64>)] {
119        &self.adjacency[i]
120    }
121
122    /// The value at `(i, j)`, or zero if there's no entry there.
123    pub fn get(&self, i: usize, j: usize) -> Complex<f64> {
124        self.adjacency[i]
125            .binary_search_by_key(&j, |&(col, _)| col)
126            .map(|idx| self.adjacency[i][idx].1)
127            .unwrap_or(Complex::new(0.0, 0.0))
128    }
129
130    pub fn mul_vec(&self, v: &[Complex<f64>]) -> Vec<Complex<f64>> {
131        self.matrix.mul_vec(v)
132    }
133}
134
135/// Groups buses into connected components via the Y-bus's actual admittance
136/// graph — two buses are in the same component iff there's a path of
137/// nonzero off-diagonal Y-bus entries between them (`row(i)`'s own diagonal
138/// self-entry, `j == i`, is skipped, since it's a shunt term, not a branch
139/// to another bus). Each returned `Vec<usize>` is one component's member
140/// bus indices, sorted ascending; components are returned in ascending
141/// order of their first (lowest-index) member.
142///
143/// Generic over the *finished* Y-bus rather than any particular input
144/// format's own `Line`/`Transformer`/branch-status representation, so it
145/// applies uniformly to native JSON, PGM-JSON, and CGMES input with no
146/// format-specific code — see `classify`/`mark_unreferenced_islands` for
147/// what this partition is used for.
148pub fn connected_components(ybus: &YBusSparse) -> Vec<Vec<usize>> {
149    let n = ybus.n();
150    let mut visited = vec![false; n];
151    let mut components = Vec::new();
152    for start in 0..n {
153        if visited[start] {
154            continue;
155        }
156        let mut members = Vec::new();
157        let mut stack = vec![start];
158        visited[start] = true;
159        while let Some(i) = stack.pop() {
160            members.push(i);
161            for &(j, y) in ybus.row(i) {
162                // A structurally-present but numerically *zero* entry does not
163                // connect anything. `build_ybus` stamps one for every
164                // transformer regardless of terminal status, and
165                // `build_ybus_with_outages` does the same deliberately, so
166                // ignoring the value here would call an out-of-service branch a
167                // connection — and leave the bus behind it with an all-zero
168                // Jacobian row inside somebody else's island.
169                if j != i && y != Complex::new(0.0, 0.0) && !visited[j] {
170                    visited[j] = true;
171                    stack.push(j);
172                }
173            }
174        }
175        members.sort_unstable();
176        components.push(members);
177    }
178    components
179}
180
181/// A single connected component's slack-bus classification, computed once
182/// up front and trusted unconditionally thereafter — see
183/// `mark_unreferenced_islands`'s doc comment for why an `AmbiguousReferenceBus`
184/// verdict is never later overwritten by a numerically-convergent-looking
185/// post-hoc mismatch check. `pub(crate)`, not part of the public API: it's
186/// an intermediate value `lib.rs`'s orchestration passes to
187/// `solver::finish_island_reports`, not something downstream users are
188/// meant to construct or match on directly — they get `solver::IslandReport`
189/// instead.
190pub(crate) enum Verdict {
191    NoReferenceBus,
192    AmbiguousReferenceBus,
193    Solvable,
194}
195
196pub(crate) struct Classified {
197    pub(crate) bus_indices: Vec<usize>,
198    /// The `Slack` bus(es) found in this component, captured *before*
199    /// `mark_unreferenced_islands` runs: empty for `NoReferenceBus`, exactly
200    /// one for `Solvable`, two-or-more for `AmbiguousReferenceBus`.
201    pub(crate) slack_indices: Vec<usize>,
202    pub(crate) verdict: Verdict,
203}
204
205/// Classifies each connected component (from `connected_components`) by how
206/// many `Slack` buses it already contains. Exactly one is the normal,
207/// solvable case; the other two counts are the situations
208/// `mark_unreferenced_islands`/`solver::finish_island_reports` need to
209/// handle specially.
210pub(crate) fn classify(buses: &[Bus], components: &[Vec<usize>]) -> Vec<Classified> {
211    components
212        .iter()
213        .map(|members| {
214            // A de-energized bus is `Slack` at `V = 0` — a placeholder, not a
215            // reference (see `mark_unreferenced_islands`). Counting one as a
216            // reference leaves the component "solvable" with nothing to solve
217            // against, and a neighbouring `PQ` bus then gets an identically
218            // zero angle equation: `H_ii = −Q_i − V_i²B_ii`, whose two terms
219            // cancel exactly when the only neighbour sits at zero volts. That
220            // is a structurally singular row, and it is how this surfaced —
221            // Svedala's node-breaker import produces such pairs, because the
222            // finer partition stops merging a dead node into a live one.
223            let slack_indices: Vec<usize> = members
224                .iter()
225                .copied()
226                .filter(|&i| buses[i].bus_type == BusType::Slack && buses[i].voltage_mag != 0.0)
227                .collect();
228            let verdict = match slack_indices.len() {
229                0 => Verdict::NoReferenceBus,
230                1 => Verdict::Solvable,
231                _ => Verdict::AmbiguousReferenceBus,
232            };
233            Classified {
234                bus_indices: members.clone(),
235                slack_indices,
236                verdict,
237            }
238        })
239        .collect()
240}
241
242/// For every component with no `Slack` bus of its own, pins every member
243/// bus to a fixed, zero-injection placeholder (`V = 0`, no P/Q) rather than
244/// solving it as an ordinary PQ region — mirrors `cgmes.rs`'s existing
245/// de-energized-bus handling exactly. Deliberately does **not** auto-promote
246/// any PV/PQ bus in such a component to `Slack`: there is no principled way
247/// to fabricate a reference voltage/angle for a genuinely sourceless
248/// island, and every unit/sign-convention bug this project has actually
249/// fixed got fixed by matching verified physical or reference-implementation
250/// behavior, never by guessing — inventing a slack here would repeat that
251/// same mistake class.
252///
253/// `AmbiguousReferenceBus` components are deliberately left untouched here
254/// (not mutated at all): their non-slack buses stay fully live in the
255/// shared Newton-Raphson system, since there's no safe placeholder value for
256/// them either. See `solver::IslandStatus::AmbiguousReferenceBus`'s doc
257/// comment for the resulting caveat.
258pub(crate) fn mark_unreferenced_islands(buses: &mut [Bus], classified: &[Classified]) {
259    for c in classified {
260        if matches!(c.verdict, Verdict::NoReferenceBus) {
261            for &i in &c.bus_indices {
262                buses[i].bus_type = BusType::Slack;
263                buses[i].voltage_mag = 0.0;
264                buses[i].voltage_ang = 0.0;
265                buses[i].p_spec = 0.0;
266                buses[i].q_spec = 0.0;
267            }
268        }
269    }
270}
271
272/// Builds a Y-bus with the branches flagged in `outaged` taken out of service,
273/// **keeping their structural entries** so the sparsity pattern is identical to
274/// the intact network's.
275///
276/// `outaged` is indexed by the crate's flat branch index — lines first, then
277/// transformers, the same space `branch_flow::branch_params` uses — and may be
278/// shorter than the branch list, in which case the missing tail is in service.
279///
280/// The preserved zeros are the whole point. `jacobian::JacobianPattern`
281/// derives its pattern from `YBusSparse::row`, and `YBus::finish` keeps every
282/// `(i, j)` that was ever added regardless of value, so a contingency built
283/// this way factorizes against the *base* symbolic factorization — no
284/// re-analysis, which is most of what an N-1 sweep would otherwise pay per
285/// scenario. It is also exactly how gridoxide already represents an open
286/// transformer terminal: `branch_calc_param` returns zeros for a `(1,0)`
287/// status and `stamp_transformers` stamps them anyway.
288///
289/// **This is only safe when the outage does not disconnect anything.**
290/// `network::connected_components` walks the same structural entries and
291/// cannot see that a zero-valued one carries nothing, so an outage that splits
292/// the network would be classified as still-connected and its now-referenceless
293/// buses would come back as a singular solve rather than an honest
294/// `NoReferenceBus`. Callers must check first —
295/// [`structural_component_count`] is the cheap test, and
296/// `batch::BatchSolver::solve_contingencies` uses it to fall back to a full
297/// rebuild for the scenarios that need one.
298pub fn build_ybus_with_outages(
299    n: usize,
300    lines: &[Line],
301    transformers: &[Transformer],
302    outaged: &[bool],
303) -> YBus {
304    let out = |idx: usize| outaged.get(idx).copied().unwrap_or(false);
305
306    // Stamp the in-service network through the ordinary builder, so the
307    // π-model, tap and open-terminal rules stay defined in exactly one place.
308    let in_service_lines: Vec<Line> = lines
309        .iter()
310        .enumerate()
311        .filter(|(i, _)| !out(*i))
312        .map(|(_, l)| l.clone())
313        .collect();
314    let in_service_transformers: Vec<Transformer> = transformers
315        .iter()
316        .enumerate()
317        .filter(|(j, _)| !out(lines.len() + j))
318        .map(|(_, t)| t.clone())
319        .collect();
320    let mut y = build_ybus(n, &in_service_lines, &in_service_transformers);
321
322    // Then re-add what the outaged branches *would* have touched, at zero.
323    let zero = Complex::new(0.0, 0.0);
324    let mut stamp_pattern = |from: usize, to: usize| {
325        y.add(from, from, zero);
326        if from != to {
327            y.add(from, to, zero);
328            y.add(to, from, zero);
329            y.add(to, to, zero);
330        }
331    };
332    for (i, ln) in lines.iter().enumerate() {
333        if out(i) {
334            stamp_pattern(ln.from, ln.to);
335        }
336    }
337    for (j, t) in transformers.iter().enumerate() {
338        if out(lines.len() + j) {
339            stamp_pattern(t.from, t.to);
340        }
341    }
342    y
343}
344
345/// How many components the branch list forms, counting a branch as connecting
346/// exactly when [`build_ybus`] would give it a structural off-diagonal entry.
347///
348/// Deliberately mirrors what [`connected_components`] sees rather than what is
349/// physically connected: a half-open transformer counts here, because it
350/// counts there too. That makes the comparison between an intact count and an
351/// outaged one a sound test of "did this outage split anything", which is what
352/// [`build_ybus_with_outages`]'s fast path needs.
353pub fn structural_component_count(
354    n: usize,
355    lines: &[Line],
356    transformers: &[Transformer],
357    outaged: &[bool],
358) -> usize {
359    let out = |idx: usize| outaged.get(idx).copied().unwrap_or(false);
360    let mut uf = crate::topology::UnionFind::new(n);
361    for (i, ln) in lines.iter().enumerate() {
362        if !out(i) && ln.from != ln.to {
363            uf.union(ln.from, ln.to);
364        }
365    }
366    for (j, t) in transformers.iter().enumerate() {
367        if !out(lines.len() + j) && t.from != t.to {
368            uf.union(t.from, t.to);
369        }
370    }
371    let mut roots: Vec<usize> = (0..n).map(|i| uf.find(i)).collect();
372    roots.sort_unstable();
373    roots.dedup();
374    roots.len()
375}
376
377pub fn build_ybus(n: usize, lines: &[Line], transformers: &[Transformer]) -> YBus {
378    let mut y = YBus::new(n);
379    for ln in lines {
380        // Self-loop: pure shunt element (no series branch).
381        if ln.from == ln.to {
382            y.add(ln.from, ln.from, Complex::new(ln.g_shunt, ln.b_shunt));
383            continue;
384        }
385        let z = Complex::new(ln.r, ln.x);
386        // series admittance
387        let y_line = Complex::new(1.0, 0.0) / z;
388        // split shunt admittance (conductance + susceptance) equally to both ends of line
389        let y_shunt_half = Complex::new(ln.g_shunt / 2.0, ln.b_shunt / 2.0);
390        // diagonal elements
391        y.add(ln.from, ln.from, y_line + y_shunt_half);
392        y.add(ln.to, ln.to, y_line + y_shunt_half);
393        // off-diagonal elements
394        y.add(ln.from, ln.to, -y_line);
395        y.add(ln.to, ln.from, -y_line);
396    }
397    stamp_transformers(&mut y, transformers);
398    y
399}
400
401/// The four 3×3 phase-domain π-model blocks of a three-phase line,
402/// `[Yff, Yft, Ytf, Ytt]` — the same order and meaning as
403/// [`branch_calc_param`]'s four scalars.
404///
405/// Extracted so that [`build_ybus_3ph`] and the state estimator's own branch
406/// functionals read one description rather than two. A Y-bus and a measurement
407/// model that disagree about a branch produce an estimate that converges
408/// confidently to the wrong answer, which is the failure mode
409/// `tests/measurement_residual_test.rs` exists to catch on the symmetric side.
410pub fn line3ph_blocks(ln: &Line3Ph) -> [[[Complex<f64>; 3]; 3]; 4] {
411    let y_c1 = Complex::new(ln.g1, ln.b1);
412    let y_c0 = Complex::new(ln.g0, ln.b0);
413    let zero = [[Complex::new(0.0, 0.0); 3]; 3];
414
415    // A `from == to` line is gridoxide's half-open branch: the whole equivalent
416    // admittance sits on one diagonal block and there is no mutual term.
417    if ln.from == ln.to {
418        let m = seq_to_phase_shunt(y_c1, y_c0);
419        return [m, zero, zero, zero];
420    }
421
422    let y1 = Complex::new(1.0, 0.0) / Complex::new(ln.r1, ln.x1);
423    let y0 = Complex::new(1.0, 0.0) / Complex::new(ln.r0, ln.x0);
424    let d_s = (y0 + 2.0 * y1) / 3.0;
425    let o_s = (y0 - y1) / 3.0;
426    let d_sh = (y_c0 + 2.0 * y_c1) / 6.0;
427    let o_sh = (y_c0 - y_c1) / 6.0;
428
429    let mut diag = zero;
430    let mut mutual = zero;
431    for p in 0..3 {
432        for q in 0..3 {
433            let ys = if p == q { d_s } else { o_s };
434            let ysh = if p == q { d_sh } else { o_sh };
435            diag[p][q] = ys + ysh;
436            mutual[p][q] = -ys;
437        }
438    }
439    [diag, mutual, mutual, diag]
440}
441
442/// The four 3×3 phase-domain blocks of a three-phase transformer, in the same
443/// order as [`line3ph_blocks`].
444///
445/// Unlike a line, a transformer's positive and negative sequences differ once
446/// its clock phase shift is in, so this needs the full Fortescue product rather
447/// than the closed form a passive branch admits.
448pub fn transformer3ph_blocks(t: &Transformer3PhSeq) -> [[[Complex<f64>; 3]; 3]; 4] {
449    [0, 1, 2, 3].map(|i| fortescue_to_phase(t.y0[i], t.y1[i], t.y2[i]))
450}
451
452/// Builds a 3N×3N phase-domain Y-bus from a list of three-phase lines.
453///
454/// Physical node `k` maps to rows/columns `3k`, `3k+1`, `3k+2` (phases a, b, c).
455/// Sequence parameters are converted to the 3×3 primitive admittance matrix via
456/// the symmetrical-components transform; off-diagonal terms couple phases when
457/// r0≠r1 or x0≠x1.
458pub fn build_ybus_3ph(n: usize, lines: &[Line3Ph]) -> YBus {
459    let mut y = YBus::new(3 * n);
460    for ln in lines {
461        let [yff, yft, ytf, ytt] = line3ph_blocks(ln);
462        // A half-open branch collapses to `from == to`, where all four blocks
463        // land on one diagonal and the three zero ones add nothing.
464        for (block, (bi, bj)) in [yff, yft, ytf, ytt].iter().zip([
465            (ln.from, ln.from),
466            (ln.from, ln.to),
467            (ln.to, ln.from),
468            (ln.to, ln.to),
469        ]) {
470            for p in 0..3 {
471                for q in 0..3 {
472                    y.add(3 * bi + p, 3 * bj + q, block[p][q]);
473                }
474            }
475        }
476    }
477    y
478}
479
480/// Computes the complex off-nominal tap ratio `k · exp(j·clock·π/6)` from a
481/// numerator/denominator voltage pair. `u_num`/`u_denom` are the (possibly
482/// tap-adjusted) nameplate voltages on the two sides; the magnitude ratio
483/// `u_num/u_denom` gives `k`. Used both by `transformer_tap` (2-winding) and
484/// the three-winding/asymmetric-transformer paths.
485pub fn tap_ratio_from_voltages(u_num: f64, u_denom: f64, clock: i32) -> Complex<f64> {
486    Complex::from_polar(u_num / u_denom, clock as f64 * std::f64::consts::PI / 6.0)
487}
488
489/// The effective shunt admittance seen at a half-open transformer's
490/// connected end: `y_shunt/2 + 1/(1/y_series + 2/y_shunt)`. Mathematically,
491/// this expression's limit as `y_shunt → 0` is exactly `0` (with no
492/// magnetizing branch at all, opening one end truly isolates the connected
493/// side — there's no other path for current to flow), but literal complex
494/// division by `y_shunt = 0+0j` hits a `0/0` pattern and evaluates to `NaN`
495/// instead of that limit. `y_shunt == 0` exactly is common in practice
496/// (magnetizing admittance is often left unspecified/zero, e.g. in real
497/// CGMES transformer data far more often than gridoxide's own PGM test
498/// fixtures happen to combine with a half-open status), so this needs an
499/// explicit guard, not just trusting the formula.
500pub fn half_open_branch_shunt(y_series: Complex<f64>, y_shunt: Complex<f64>) -> Complex<f64> {
501    if y_shunt == Complex::new(0.0, 0.0) {
502        return Complex::new(0.0, 0.0);
503    }
504    let one = Complex::new(1.0, 0.0);
505    y_shunt * 0.5 + one / (one / y_series + Complex::new(2.0, 0.0) / y_shunt)
506}
507
508/// Computes the four branch admittance entries `[yff, yft, ytf, ytt]` for a
509/// two-winding transformer branch. Implements PGM's π-equivalent model:
510/// `y_shunt` is split equally between both terminals. The complex tap ratio
511/// `tap = k·exp(jθ)` carries both the off-nominal magnitude `k` and the
512/// vector-group phase shift `θ`.
513///
514/// Status rules (mirrors PGM's `calc_param_y_sym`):
515///   (1,1): yff = (y_s+y_sh/2)/k², ytt = y_s+y_sh/2,
516///          yft = -y_s/conj(a), ytf = -y_s/a
517///   (1,0)/(0,1): effective shunt = y_sh/2 + 1/(1/y_s + 2/y_sh) at connected end
518///   (0,0): all zero
519pub fn branch_calc_param(
520    y_series: Complex<f64>,
521    y_shunt: Complex<f64>,
522    tap: Complex<f64>,
523    from_status: u8,
524    to_status: u8,
525) -> [Complex<f64>; 4] {
526    let zero = Complex::new(0.0, 0.0);
527    let k = tap.norm();
528    match (from_status, to_status) {
529        (1, 1) => {
530            let y_diag = y_series + y_shunt * 0.5;
531            [
532                y_diag / (k * k),
533                -y_series / tap.conj(),
534                -y_series / tap,
535                y_diag,
536            ]
537        }
538        (1, 0) => {
539            let branch_shunt = half_open_branch_shunt(y_series, y_shunt);
540            [branch_shunt / (k * k), zero, zero, zero]
541        }
542        (0, 1) => {
543            let branch_shunt = half_open_branch_shunt(y_series, y_shunt);
544            [zero, zero, zero, branch_shunt]
545        }
546        _ => [zero, zero, zero, zero],
547    }
548}
549
550/// Stamps two-winding transformer contributions into an existing Y-bus, via
551/// `branch_calc_param` for the per-branch [yff, yft, ytf, ytt] entries.
552fn stamp_transformers(ybus: &mut YBus, transformers: &[Transformer]) {
553    for t in transformers {
554        let [yff, yft, ytf, ytt] =
555            branch_calc_param(t.y_series, t.y_shunt, t.tap, t.from_status, t.to_status);
556        ybus.add(t.from, t.from, yff);
557        ybus.add(t.from, t.to, yft);
558        ybus.add(t.to, t.from, ytf);
559        ybus.add(t.to, t.to, ytt);
560    }
561}
562
563/// Converts sequence-domain admittances `(y0, y1, y2)` to the 3×3 phase-domain
564/// tensor via the Fortescue transform `Yabc = A · diag(y0,y1,y2) · A⁻¹`, where
565/// `A = [[1,1,1],[1,a²,a],[1,a,a²]]`, `a = exp(j·2π/3)`. Degenerates to
566/// `seq_to_phase_shunt`'s `(2y1+y0)/3` / `(y0−y1)/3` formula when `y1 == y2`.
567fn fortescue_to_phase(
568    y0: Complex<f64>,
569    y1: Complex<f64>,
570    y2: Complex<f64>,
571) -> [[Complex<f64>; 3]; 3] {
572    let one = Complex::new(1.0, 0.0);
573    let a = Complex::from_polar(1.0, 2.0 * std::f64::consts::PI / 3.0);
574    let a2 = a * a;
575    let big_a = [[one, one, one], [one, a2, a], [one, a, a2]];
576    let a_inv = [[one, one, one], [one, a, a2], [one, a2, a]];
577    let y_seq = [y0, y1, y2];
578    let mut out = [[Complex::new(0.0, 0.0); 3]; 3];
579    for p in 0..3 {
580        for q in 0..3 {
581            let mut sum = Complex::new(0.0, 0.0);
582            for k in 0..3 {
583                sum += big_a[p][k] * y_seq[k] * a_inv[k][q] / 3.0;
584            }
585            out[p][q] = sum;
586        }
587    }
588    out
589}
590
591/// PGM's `WindingType` codes, as they appear on `PgmTransformer`'s
592/// `winding_from`/`winding_to` (`common/enum.hpp`).
593pub const WYE: u8 = 0;
594pub const WYE_N: u8 = 1;
595pub const DELTA: u8 = 2;
596pub const ZIGZAG: u8 = 3;
597pub const ZIGZAG_N: u8 = 4;
598
599/// Whether a winding offers the zero sequence a path of its own, given what
600/// sits on the other side — PGM's `zero_seq_available` lambda
601/// (`component/transformer.hpp`). A wye_n winding needs the other side to be
602/// wye_n or delta to have somewhere for zero-sequence current to go; a
603/// zigzag_n winding always does; nothing else ever does.
604///
605/// The sides where this is false are exactly the ones that pick up the
606/// artificial low-susceptance term, which exists to keep such a side from
607/// floating free of ground in the zero-sequence network (an all-zero row is
608/// a singular matrix, not a physical answer).
609fn zero_seq_available(this_side: u8, other_side: u8) -> bool {
610    match this_side {
611        WYE_N => other_side == WYE_N || other_side == DELTA,
612        ZIGZAG_N => true,
613        _ => false,
614    }
615}
616
617/// Computes sequence-domain `(y0, y1, y2)` branch admittance parameters
618/// (each `[yff, yft, ytf, ytt]`) for a two-winding transformer, needed for
619/// asymmetric (3-phase) power flow and for short-circuit calculation.
620///
621/// Positive/negative sequence reuse `branch_calc_param` with the tap rotated
622/// by ±clock (mirrors PGM's `asym_calc_param`).
623///
624/// Zero sequence follows PGM's own general algorithm
625/// (`component/transformer.hpp`, the `sc_calc_param`/`calc_param_asym` zero-
626/// sequence block) rather than enumerating winding pairs. Four cases, in
627/// PGM's own order — the first three are mutually exclusive, the zigzag pair
628/// is additive on top:
629///
630/// - **YNyn** — the full two-port zero-sequence branch, `z0_series =
631///   1/y_series + 3·(z_grounding_to + z_grounding_from/k²)`, with the tap's
632///   phase picking up a 180° flip for clock ∈ {2, 6, 10} (reverse-connected
633///   variants).
634/// - **YN\*** (from side wye_n, in service) — a ground path via the
635///   magnetizing branch, plus a path via the series impedance when the *other*
636///   side is delta; a one-port term on `yff`, scaled by `1/k²`.
637/// - **\*yn** (to side wye_n, in service) — the mirror, a one-port term on
638///   `ytt`. Dyn is this case, and was the only form of it gridoxide
639///   previously implemented.
640/// - **ZN\*/\*zn** — a zigzag_n winding's zero-sequence impedance is taken as
641///   10% of its positive-sequence value.
642///
643/// Then every side without a zero-sequence path of its own
644/// (`zero_seq_available`, private) and in service picks up
645/// `−j·1e-8·sn/s_base/uk`.
646///
647/// # Two modelling substitutions
648///
649/// gridoxide has no `i0_zero_sequence`/`p0_zero_sequence` fields on
650/// `PgmTransformer`, so the zero-sequence magnetizing admittance is taken as
651/// the positive-sequence one, `y0_shunt = y_shunt`. That is not an
652/// approximation this crate invents: it is exactly what PGM computes when
653/// those two fields are left unset, since it defaults `i0_zero_sequence` to
654/// `i0` and `p0_zero_sequence` to `p0` in that case.
655///
656/// Likewise there are no grounding-impedance fields, so `z_grounding_from =
657/// z_grounding_to = 0` throughout (PGM defaults them to zero too). That is
658/// what collapses YNyn's `z0_series` to `1/y_series` — i.e. `y0_series =
659/// y_series` — and drops the `3·z_grounding` term from the one-port cases,
660/// where it would otherwise sit in series with `1/y0`.
661pub fn transformer_seq_params(
662    y_series: Complex<f64>,
663    y_shunt: Complex<f64>,
664    tap: Complex<f64>,
665    from_status: u8,
666    to_status: u8,
667    winding_from: u8,
668    winding_to: u8,
669    sn: f64,
670    uk: f64,
671    s_base_va: f64,
672    clock: i32,
673) -> ([Complex<f64>; 4], [Complex<f64>; 4], [Complex<f64>; 4]) {
674    let zero = Complex::new(0.0, 0.0);
675    let y1 = branch_calc_param(y_series, y_shunt, tap, from_status, to_status);
676    let y2 = branch_calc_param(y_series, y_shunt, tap.conj(), from_status, to_status);
677
678    let k = tap.norm();
679    // See the doc comment: PGM's own default when the zero-sequence
680    // magnetizing fields are unset, which is the only case gridoxide models.
681    let y0_shunt = y_shunt;
682
683    let mut y0 = [zero; 4];
684    if winding_from == WYE_N && winding_to == WYE_N {
685        // YNyn. With both grounding impedances zero, `z0_series` collapses to
686        // `1/y_series`, so `y0_series` is just `y_series`.
687        let phase_shift_0 = if matches!(clock, 2 | 6 | 10) {
688            std::f64::consts::PI
689        } else {
690            0.0
691        };
692        let tap0 = Complex::from_polar(k, phase_shift_0);
693        y0 = branch_calc_param(y_series, y0_shunt, tap0, from_status, to_status);
694    } else if winding_from == WYE_N && from_status == 1 {
695        // YN*: ground path via the magnetizing branch, plus one via zk when
696        // the other side is delta.
697        let mut y = y0_shunt;
698        if winding_to == DELTA {
699            y += y_series;
700        }
701        // Guarded exactly as PGM guards it: with no magnetizing branch and no
702        // delta on the far side there is no zero-sequence path at all, and
703        // `1/y` would be a division by zero rather than the zero it means.
704        if y != zero {
705            y0[0] = y / (k * k);
706        }
707    } else if winding_to == WYE_N && to_status == 1 {
708        // *yn — the mirror of the above. Dyn lands here.
709        let mut y = y0_shunt;
710        if winding_from == DELTA {
711            y += y_series;
712        }
713        if y != zero {
714            y0[3] = y;
715        }
716    }
717
718    // Zigzag windings are additive on top of the above, not an alternative to
719    // it — PGM writes these as separate `if`s, not `else if`s. A zigzag_n
720    // winding's zero-sequence series impedance is approximated as 10% of its
721    // positive-sequence value, so its admittance is 10x.
722    if winding_from == ZIGZAG_N && from_status == 1 {
723        y0[0] = y_series * 10.0 / (k * k);
724    }
725    if winding_to == ZIGZAG_N && to_status == 1 {
726        y0[3] = y_series * 10.0;
727    }
728
729    let low_susceptance = -1e-8 * sn / s_base_va / uk;
730    if !zero_seq_available(winding_from, winding_to) && from_status == 1 {
731        y0[0] += Complex::new(0.0, low_susceptance);
732    }
733    if !zero_seq_available(winding_to, winding_from) && to_status == 1 {
734        y0[3] += Complex::new(0.0, low_susceptance);
735    }
736
737    (y0, y1, y2)
738}
739
740/// Stamps asymmetric transformer contributions into a 3N×3N phase-domain Y-bus.
741pub fn stamp_transformers_3ph(ybus: &mut YBus, transformers: &[Transformer3PhSeq]) {
742    for t in transformers {
743        let blocks = transformer3ph_blocks(t);
744        let ends = [
745            (t.from, t.from),
746            (t.from, t.to),
747            (t.to, t.from),
748            (t.to, t.to),
749        ];
750        for (block, (bi, bj)) in blocks.iter().zip(ends) {
751            for p in 0..3 {
752                for q in 0..3 {
753                    ybus.add(3 * bi + p, 3 * bj + q, block[p][q]);
754                }
755            }
756        }
757    }
758}
759
760/// Adds a set of lumped shunt admittances to the Y-bus diagonal.
761pub fn stamp_shunts(ybus: &mut YBus, shunts: &[ShuntAdm]) {
762    for s in shunts {
763        ybus.add(s.at, s.at, s.y);
764    }
765}
766
767/// Adds a set of three-phase shunt admittances to the phase-domain Y-bus diagonal blocks.
768pub fn stamp_shunts_3ph(ybus: &mut YBus, shunts: &[ShuntAdm3Ph]) {
769    for s in shunts {
770        let m = seq_to_phase_shunt(s.y1, s.y0);
771        for p in 0..3 {
772            for q in 0..3 {
773                ybus.add(3 * s.at + p, 3 * s.at + q, m[p][q]);
774            }
775        }
776    }
777}
778
779/// Computes per-unit source impedance (r, x) from short-circuit power and R/X
780/// ratio. Matches PGM's `Source::math_param` (`z_abs = base_power_3p / sk`,
781/// with no dependence on `u_ref` — the source's reference voltage only sets
782/// the fixed voltage at the virtual slack bus, not the branch impedance).
783pub fn source_impedance_pu(sk: f64, rx_ratio: f64, s_base_va: f64) -> (f64, f64) {
784    let z_s_pu = s_base_va / sk;
785    let x_s = z_s_pu / (rx_ratio * rx_ratio + 1.0_f64).sqrt();
786    (rx_ratio * x_s, x_s)
787}
788
789/// Computes per-unit positive- and zero-sequence source impedance
790/// `(r1, x1, r0, x0)`, where `z01_ratio = z0 / z1` (PGM convention), so the
791/// zero-sequence impedance is `z1 * z01_ratio`.
792pub fn source_impedance_pu_seq(
793    sk: f64,
794    rx_ratio: f64,
795    z01_ratio: f64,
796    s_base_va: f64,
797) -> (f64, f64, f64, f64) {
798    let (r1, x1) = source_impedance_pu(sk, rx_ratio, s_base_va);
799    let (r0, x0) = (r1 * z01_ratio, x1 * z01_ratio);
800    (r1, x1, r0, x0)
801}
802
803/// Computes the complex off-nominal tap ratio k·exp(j·clock·π/6) from transformer nameplate data.
804///
805/// `tap_pos` is clamped to `[min(tap_min, tap_max), max(tap_min, tap_max)]` before use, mirroring
806/// PGM's own `Transformer::tap_limit` — real-world converted data (e.g. pandapower's MATPOWER-derived
807/// test cases) can carry a `tap_pos` outside that range for transformers with no adjustable tap
808/// changer (`tap_min == tap_max`), and PGM's reference implementation silently clamps rather than
809/// applying the out-of-range offset.
810pub fn transformer_tap(
811    u1: f64,
812    u2: f64,
813    tap_side: u8,
814    tap_pos: i32,
815    tap_min: i32,
816    tap_max: i32,
817    tap_nom: i32,
818    tap_size: f64,
819    clock: i32,
820    u1_rated: f64,
821    u2_rated: f64,
822) -> Complex<f64> {
823    let tap_pos = tap_pos.clamp(tap_min.min(tap_max), tap_min.max(tap_max));
824    let delta = (tap_pos - tap_nom) as f64 * tap_size;
825    let (u1_tapped, u2_tapped) = if tap_side == 0 {
826        (u1 + delta, u2)
827    } else {
828        (u1, u2 + delta)
829    };
830    // `k = (u1/u2) / (u1_rated/u2_rated)`, matching power-grid-model's
831    // `transformer.hpp:194` exactly. Both halves matter:
832    //
833    // - the *nameplate* ratio `u1/u2` is what the windings actually do, tap
834    //   included;
835    // - the *node* ratings are the per-unit bases the Y-bus is expressed in.
836    //
837    // Their quotient is the off-nominal ratio. Taking only `(u1 + delta)/u1` —
838    // as this did until the `vision-validation-network` fixture exposed it —
839    // cancels the nameplate against itself and drops the node bases entirely,
840    // so a transformer whose nameplate ratio differs from its nodes' rating
841    // ratio is modelled as though it did not. That is ordinary data, not an
842    // edge case: 20 of that fixture's 22 transformers are 10750/420 units on a
843    // 10500 V node, a 2.4% ratio silently lost.
844    tap_ratio_from_voltages(u1_tapped * u2_rated, u2_tapped * u1_rated, clock)
845}
846
847/// Computes per-unit series and shunt admittances from transformer nameplate data.
848/// Both are referenced to the to-side (u2) voltage base.
849/// Like `transformer_admittances`, but with separately specified nameplate
850/// ("to"-field, used for absolute impedance/shunt magnitude scaling) and rated
851/// ("to"-base, used for the per-unit base) to-side voltages — needed when the
852/// two differ, as for a three-winding transformer's internal star leg, whose
853/// nameplate voltage tracks the tapped side while its per-unit base stays
854/// pinned to the physical node's rated voltage. `uk`/`pk` may be negative (as
855/// PGM's three-winding delta→wye conversion can produce); the sign of `uk` is
856/// carried onto the series reactance, matching PGM's `transformer_params()`.
857pub fn transformer_admittances_ex(
858    u_field: f64,
859    u_base: f64,
860    sn: f64,
861    uk: f64,
862    pk: f64,
863    i0: f64,
864    p0: f64,
865    s_base_va: f64,
866) -> (Complex<f64>, Complex<f64>) {
867    let base_y_to = s_base_va / (u_base * u_base);
868    let uk_sign = if uk >= 0.0 { 1.0 } else { -1.0 };
869    let z_abs = uk.abs() * u_field * u_field / sn;
870    let r_ohm = pk * u_field * u_field / (sn * sn);
871    let x_sq = z_abs * z_abs - r_ohm * r_ohm;
872    let x_ohm = uk_sign * if x_sq > 0.0 { x_sq.sqrt() } else { 0.0 };
873    let y_series = Complex::new(1.0, 0.0) / Complex::new(r_ohm, x_ohm) / base_y_to;
874    let g_fe = p0 / (u_field * u_field);
875    let y_sh_abs = i0 * sn / (u_field * u_field);
876    let b_sq = y_sh_abs * y_sh_abs - g_fe * g_fe;
877    let b_m = if b_sq > 0.0 { -b_sq.sqrt() } else { 0.0 };
878    let y_shunt = Complex::new(g_fe, b_m) / base_y_to;
879    (y_series, y_shunt)
880}
881
882pub fn transformer_admittances(
883    u2: f64,
884    sn: f64,
885    uk: f64,
886    pk: f64,
887    i0: f64,
888    p0: f64,
889    s_base_va: f64,
890) -> (Complex<f64>, Complex<f64>) {
891    transformer_admittances_ex(u2, u2, sn, uk, pk, i0, p0, s_base_va)
892}
893
894/// Computes the three-winding transformer's delta→wye (star-equivalent)
895/// short-circuit voltage and loss parameters `(uk_T1, uk_T2, uk_T3)` and
896/// `(pk_T1, pk_T2, pk_T3)`, referenced to side 1's power base. Mirrors PGM's
897/// `ThreeWindingTransformer::calculate_uk`/`calculate_pk` (tap-dependent
898/// `uk_min`/`uk_max` adjustment is not implemented — this three-winding
899/// support only covers fixtures with no such tap-dependent impedance range).
900pub fn three_winding_star_params(
901    sn_1: f64,
902    sn_2: f64,
903    sn_3: f64,
904    uk_12: f64,
905    uk_13: f64,
906    uk_23: f64,
907    pk_12: f64,
908    pk_13: f64,
909    pk_23: f64,
910) -> ((f64, f64, f64), (f64, f64, f64)) {
911    let uk_12r = uk_12 * sn_1 / sn_1.min(sn_2);
912    let uk_13r = uk_13 * sn_1 / sn_1.min(sn_3);
913    let uk_23r = uk_23 * sn_1 / sn_2.min(sn_3);
914    let uk_t1p = 0.5 * (uk_12r + uk_13r - uk_23r);
915    let uk_t2p = 0.5 * (uk_12r + uk_23r - uk_13r);
916    let uk_t3p = 0.5 * (uk_13r + uk_23r - uk_12r);
917    let uk = (uk_t1p, uk_t2p * (sn_2 / sn_1), uk_t3p * (sn_3 / sn_1));
918
919    let pk_12r = pk_12 * (sn_1 / sn_1.min(sn_2)).powi(2);
920    let pk_13r = pk_13 * (sn_1 / sn_1.min(sn_3)).powi(2);
921    let pk_23r = pk_23 * (sn_1 / sn_2.min(sn_3)).powi(2);
922    let pk_t1p = 0.5 * (pk_12r + pk_13r - pk_23r);
923    let pk_t2p = 0.5 * (pk_12r + pk_23r - pk_13r);
924    let pk_t3p = 0.5 * (pk_13r + pk_23r - pk_12r);
925    let pk = (
926        pk_t1p,
927        pk_t2p * (sn_2 / sn_1).powi(2),
928        pk_t3p * (sn_3 / sn_1).powi(2),
929    );
930
931    (uk, pk)
932}
933
934/// Computes the effective (P, Q) injection at a bus at its current voltage
935/// magnitude: `p_spec`/`q_spec` (constant-power) plus any ZIP terms evaluated
936/// at `bus.voltage_mag`. For a bus with no ZIP terms this is exactly
937/// `(bus.p_spec, bus.q_spec)`.
938pub fn effective_injection(bus: &Bus) -> (f64, f64) {
939    let mut s = Complex::new(bus.p_spec, bus.q_spec);
940    let vmag = bus.voltage_mag;
941    for zt in &bus.zip_terms {
942        s += match zt.kind {
943            ZipKind::ConstPower => zt.s_const,
944            ZipKind::ConstImpedance => zt.s_const * vmag * vmag,
945            ZipKind::ConstCurrent => zt.s_const * vmag,
946        };
947    }
948    (s.re, s.im)
949}
950
951/// Solves one reduced constant-admittance system over `unknown_idx`, writing
952/// `voltage_mag`/`voltage_ang` back for those buses only.
953///
954/// Every bus not in `unknown_idx` is treated as a fixed voltage source at its
955/// present `voltage_mag`/`voltage_ang` and moves to the right-hand side.
956/// Returns `false` if the reduced system is singular, in which case no bus is
957/// modified — leaving the caller's starting values intact is what makes this
958/// safe to use as a warm start.
959///
960/// Walks each unknown bus's actual admittance neighbours from the sparse
961/// Y-bus's row structure rather than the full unknown×unknown cross product,
962/// so the assembly cost is proportional to the number of branches, not to the
963/// square of the number of buses.
964pub fn solve_constant_admittance(
965    buses: &mut [Bus],
966    ybus: &YBusSparse,
967    unknown_idx: &[usize],
968) -> bool {
969    let m = unknown_idx.len();
970    if m == 0 {
971        return true;
972    }
973
974    let mut reduced_pos: Vec<Option<usize>> = vec![None; buses.len()];
975    for (r, &i) in unknown_idx.iter().enumerate() {
976        reduced_pos[i] = Some(r);
977    }
978
979    let mut triplets: Vec<(usize, usize, Complex<f64>)> = Vec::new();
980    let mut rhs = vec![Complex::new(0.0, 0.0); m];
981    for (r, &i) in unknown_idx.iter().enumerate() {
982        let (p, q) = effective_injection(&buses[i]);
983        let y_load = -Complex::new(p, q).conj();
984        let mut diag_seen = false;
985        for &(j, y_ij) in ybus.row(i) {
986            let y_ij = if j == i {
987                diag_seen = true;
988                y_ij + y_load
989            } else {
990                y_ij
991            };
992            match reduced_pos[j] {
993                Some(c) => triplets.push((r, c, y_ij)),
994                None => {
995                    let u_j = Complex::from_polar(buses[j].voltage_mag, buses[j].voltage_ang);
996                    rhs[r] -= y_ij * u_j;
997                }
998            }
999        }
1000        if !diag_seen {
1001            triplets.push((r, r, y_load));
1002        }
1003    }
1004
1005    match sparse::solve_complex(m, &triplets, &rhs) {
1006        Some(sol) => {
1007            for (r, &i) in unknown_idx.iter().enumerate() {
1008                buses[i].voltage_mag = sol[r].norm();
1009                buses[i].voltage_ang = sol[r].arg();
1010            }
1011            true
1012        }
1013        None => false,
1014    }
1015}
1016
1017/// Computes a smarter-than-flat-start initial voltage guess for Newton-Raphson
1018/// by solving one linearized constant-admittance system, mirroring PGM's
1019/// `NewtonRaphsonPFSolver::initialize_derived_solver`. Each energized (`PQ`)
1020/// bus's injection is approximated as a constant admittance `y = -conj(S)`
1021/// evaluated at the flat-start `|V|=1` assumption and added to the Y-bus
1022/// diagonal; `Slack` buses (both real sources and de-energized nodes, which
1023/// gridoxide models as fixed `Slack` buses at V=0) keep their already-set
1024/// voltage. The resulting linear system `Y'_nn·U_n = -Y'_ns·U_s` is solved for
1025/// the unknown `PQ` bus voltages via LU decomposition, and `voltage_mag`/
1026/// `voltage_ang` are overwritten with the result. If the reduced system is
1027/// singular, buses are left at their prior (flat-start) values. PGM applies no
1028/// step damping or voltage clamping beyond this — a better initial guess is
1029/// its only robustness mechanism, needed on networks combining weak sources
1030/// with large transformer phase shifts where plain flat-start NR diverges.
1031///
1032/// [`linear::impedance`](crate::linear::impedance) offers the same
1033/// linearization, built on the same [`solve_constant_admittance`], as a
1034/// standalone power-flow method
1035/// ([`linear::linear_power_flow`](crate::linear::linear_power_flow)). This
1036/// entry point stays deliberately island-unaware: it runs *before* the
1037/// solver's own `classify`/`mark_unreferenced_islands` pass, so it must not
1038/// mutate bus types, and a singular result here is simply a warm start that
1039/// did not happen rather than a failure to report.
1040pub fn linear_initial_guess(buses: &mut [Bus], ybus: &YBusSparse) {
1041    let n = buses.len();
1042    let unknown_idx: Vec<usize> = (0..n)
1043        .filter(|&i| matches!(buses[i].bus_type, BusType::PQ))
1044        .collect();
1045    solve_constant_admittance(buses, ybus, &unknown_idx);
1046}
1047
1048/// Every bus's voltage magnitude and the sine and cosine of its angle, taken
1049/// once per Newton iteration and read by both the power injections and the
1050/// Jacobian fill — which used to evaluate the same sines and cosines each.
1051#[derive(Clone, Debug, Default)]
1052pub struct BusVoltages {
1053    pub vm: Vec<f64>,
1054    pub sin: Vec<f64>,
1055    pub cos: Vec<f64>,
1056}
1057
1058impl BusVoltages {
1059    pub fn of(buses: &[Bus]) -> Self {
1060        let mut v = Self::default();
1061        v.update(buses);
1062        v
1063    }
1064
1065    /// Refills from `buses`, reusing the allocations.
1066    pub fn update(&mut self, buses: &[Bus]) {
1067        self.vm.clear();
1068        self.sin.clear();
1069        self.cos.clear();
1070        for b in buses {
1071            let (s, c) = b.voltage_ang.sin_cos();
1072            self.vm.push(b.voltage_mag);
1073            self.sin.push(s);
1074            self.cos.push(c);
1075        }
1076    }
1077}
1078
1079pub fn power_injections(buses: &[Bus], ybus: &YBusSparse) -> (Vec<f64>, Vec<f64>) {
1080    power_injections_from(&BusVoltages::of(buses), ybus)
1081}
1082
1083/// [`power_injections`] from voltages already taken.
1084pub fn power_injections_from(v: &BusVoltages, ybus: &YBusSparse) -> (Vec<f64>, Vec<f64>) {
1085    // Calculates the complex power injection into each bus.
1086    // S = V .* conj(I) where I = Ybus * V
1087    // S_k = V_k * I_k^*
1088    let n = v.vm.len();
1089    let mut p = vec![0.0; n];
1090    let mut q = vec![0.0; n];
1091
1092    let v: Vec<Complex<f64>> = (0..n)
1093        .map(|k| Complex::new(v.vm[k] * v.cos[k], v.vm[k] * v.sin[k]))
1094        .collect();
1095    let i = ybus.mul_vec(&v);
1096
1097    for k in 0..n {
1098        let s = v[k] * i[k].conj();
1099        p[k] = s.re;
1100        q[k] = s.im;
1101    }
1102
1103    (p, q)
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109
1110    /// Nameplate numbers shared by the `transformer_seq_params` cases below,
1111    /// picked so `y_shunt` is nonzero (the magnetizing branch is the only
1112    /// zero-sequence path a YN winding has when the far side is not delta,
1113    /// so a zero one would hide the difference between the arms).
1114    fn seq_fixture() -> (Complex<f64>, Complex<f64>, Complex<f64>) {
1115        let y_series = Complex::new(2.0, -6.0);
1116        let y_shunt = Complex::new(1e-4, -5e-4);
1117        let tap = Complex::from_polar(1.05, std::f64::consts::PI / 6.0);
1118        (y_series, y_shunt, tap)
1119    }
1120
1121    fn seq_params_for(winding_from: u8, winding_to: u8, clock: i32) -> [Complex<f64>; 4] {
1122        let (y_series, y_shunt, tap) = seq_fixture();
1123        let (y0, _, _) = transformer_seq_params(
1124            y_series,
1125            y_shunt,
1126            tap,
1127            1,
1128            1,
1129            winding_from,
1130            winding_to,
1131            2e7,
1132            0.05,
1133            1e6,
1134            clock,
1135        );
1136        y0
1137    }
1138
1139    /// YNd is the pair 11 of power-grid-model's 15 short-circuit fixtures use,
1140    /// and the one the old two-arm implementation panicked on. Its
1141    /// zero-sequence branch is a one-port term on the *from* side: the
1142    /// magnetizing branch in parallel with the series impedance (the delta on
1143    /// the far side gives zero-sequence current a circulating path), scaled by
1144    /// `1/k²`.
1145    #[test]
1146    fn ynd_gets_a_from_side_zero_sequence_path() {
1147        let (y_series, y_shunt, tap) = seq_fixture();
1148        let k = tap.norm();
1149        let y0 = seq_params_for(WYE_N, DELTA, 1);
1150
1151        let expected_yff = (y_shunt + y_series) / (k * k);
1152        assert!(
1153            (y0[0] - expected_yff).norm() < 1e-15,
1154            "yff = {}, want {expected_yff}",
1155            y0[0]
1156        );
1157        // A delta winding has no zero-sequence path of its own, so the to side
1158        // carries only the artificial low-susceptance term.
1159        let low = -1e-8 * 2e7 / 1e6 / 0.05;
1160        assert!(
1161            (y0[3] - Complex::new(0.0, low)).norm() < 1e-18,
1162            "ytt = {}",
1163            y0[3]
1164        );
1165        // A one-port term couples nothing across the branch.
1166        assert_eq!(y0[1], Complex::new(0.0, 0.0));
1167        assert_eq!(y0[2], Complex::new(0.0, 0.0));
1168    }
1169
1170    /// Dyn is YNd's mirror, and was the one arm the old implementation had.
1171    /// Pinning both together is what shows the generalization did not quietly
1172    /// transpose the sides.
1173    #[test]
1174    fn dyn_is_the_mirror_of_ynd() {
1175        let (y_series, y_shunt, _) = seq_fixture();
1176        let y0 = seq_params_for(DELTA, WYE_N, 5);
1177
1178        // The `*yn` arm carries no `1/k²` — the to side is the per-unit base.
1179        assert!(
1180            (y0[3] - (y_shunt + y_series)).norm() < 1e-15,
1181            "ytt = {}",
1182            y0[3]
1183        );
1184        let low = -1e-8 * 2e7 / 1e6 / 0.05;
1185        assert!(
1186            (y0[0] - Complex::new(0.0, low)).norm() < 1e-18,
1187            "yff = {}",
1188            y0[0]
1189        );
1190    }
1191
1192    /// The reverse-connected clock positions flip the zero-sequence tap by
1193    /// 180°, which the two-port YNyn arm is the only one to see.
1194    #[test]
1195    fn ynyn_flips_the_zero_sequence_tap_when_reverse_connected() {
1196        let straight = seq_params_for(WYE_N, WYE_N, 0);
1197        let reversed = seq_params_for(WYE_N, WYE_N, 6);
1198
1199        // yff/ytt are magnitude-only and so unmoved; the coupling terms carry
1200        // the flip, and a 180° rotation is exactly a sign change.
1201        assert!((straight[0] - reversed[0]).norm() < 1e-15);
1202        assert!((straight[3] - reversed[3]).norm() < 1e-15);
1203        assert!(
1204            (straight[1] + reversed[1]).norm() < 1e-15,
1205            "{} vs {}",
1206            straight[1],
1207            reversed[1]
1208        );
1209        assert!(
1210            (straight[2] + reversed[2]).norm() < 1e-15,
1211            "{} vs {}",
1212            straight[2],
1213            reversed[2]
1214        );
1215        // Both sides are wye_n, so neither picks up a low-susceptance term.
1216        assert!(straight[1].norm() > 0.0);
1217    }
1218
1219    /// A winding pair with no zero-sequence path at either end must not come
1220    /// back all-zero: an all-zero row is a singular matrix, not an answer.
1221    /// Both sides get the artificial low susceptance instead.
1222    #[test]
1223    fn a_pair_with_no_zero_sequence_path_is_not_left_floating() {
1224        let y0 = seq_params_for(WYE, DELTA, 1);
1225        let low = -1e-8 * 2e7 / 1e6 / 0.05;
1226        assert!(
1227            (y0[0] - Complex::new(0.0, low)).norm() < 1e-18,
1228            "yff = {}",
1229            y0[0]
1230        );
1231        assert!(
1232            (y0[3] - Complex::new(0.0, low)).norm() < 1e-18,
1233            "ytt = {}",
1234            y0[3]
1235        );
1236    }
1237
1238    /// A zigzag_n winding's zero-sequence impedance is taken as 10% of its
1239    /// positive-sequence value, so its admittance is 10x — and it always has a
1240    /// path of its own, so it never picks up the low-susceptance term.
1241    #[test]
1242    fn zigzag_n_carries_ten_times_the_series_admittance() {
1243        let (y_series, _, _) = seq_fixture();
1244        let y0 = seq_params_for(WYE, ZIGZAG_N, 1);
1245        assert!((y0[3] - y_series * 10.0).norm() < 1e-15, "ytt = {}", y0[3]);
1246    }
1247
1248    /// An out-of-service terminal contributes nothing at all — not even the
1249    /// low-susceptance term, which exists to ground a *connected* winding.
1250    #[test]
1251    fn an_open_terminal_contributes_no_zero_sequence_term() {
1252        let (y_series, y_shunt, tap) = seq_fixture();
1253        let (y0, _, _) = transformer_seq_params(
1254            y_series, y_shunt, tap, 1, 0, WYE_N, DELTA, 2e7, 0.05, 1e6, 1,
1255        );
1256        assert_eq!(y0[3], Complex::new(0.0, 0.0));
1257    }
1258
1259    fn bus_at(idx: usize, bus_type: BusType, voltage_mag: f64) -> Bus {
1260        Bus {
1261            idx,
1262            bus_type,
1263            voltage_mag,
1264            voltage_ang: 0.0,
1265            p_spec: 0.0,
1266            q_spec: 0.0,
1267            q_min: 0.0,
1268            q_max: 0.0,
1269            u_rated: 0.0,
1270            zip_terms: Vec::new(),
1271        }
1272    }
1273
1274    /// A de-energized bus is `Slack` at `V = 0` — a placeholder, not a
1275    /// reference. Counting one as a reference leaves a component nominally
1276    /// solvable with nothing to solve against, and the neighbouring `PQ` bus
1277    /// gets an identically zero angle row: `H_ii = −Q_i − V_i²B_ii`, whose
1278    /// terms cancel exactly when the only neighbour sits at zero volts.
1279    ///
1280    /// This is not hypothetical. It made Svedala's node-breaker import report
1281    /// `Singular`, and SmallGrid's fail under `RetainAll` — which looked like
1282    /// a switch-count ceiling, and was not.
1283    #[test]
1284    fn a_de_energized_placeholder_is_not_a_reference_bus() {
1285        let buses = vec![
1286            bus_at(0, BusType::Slack, 0.0), // de-energized placeholder
1287            bus_at(1, BusType::PQ, 1.0),
1288            bus_at(2, BusType::Slack, 1.05), // a real reference
1289            bus_at(3, BusType::PQ, 1.0),
1290        ];
1291        let components = vec![vec![0, 1], vec![2, 3]];
1292        let classified = classify(&buses, &components);
1293
1294        assert!(
1295            matches!(classified[0].verdict, Verdict::NoReferenceBus),
1296            "a component whose only slack sits at V = 0 has no reference"
1297        );
1298        assert!(classified[0].slack_indices.is_empty());
1299        assert!(matches!(classified[1].verdict, Verdict::Solvable));
1300        assert_eq!(classified[1].slack_indices, vec![2]);
1301
1302        // ...and the unreferenced one is then pinned, so its PQ bus stops
1303        // being an unknown at all.
1304        let mut buses = buses;
1305        mark_unreferenced_islands(&mut buses, &classified);
1306        assert_eq!(buses[1].bus_type, BusType::Slack);
1307        assert_eq!(buses[1].voltage_mag, 0.0);
1308        assert_eq!(
1309            buses[3].bus_type,
1310            BusType::PQ,
1311            "the referenced island is untouched"
1312        );
1313    }
1314
1315    /// A real reference alongside a placeholder still counts: the component is
1316    /// solvable, and only the placeholder is disregarded.
1317    #[test]
1318    fn a_real_reference_beside_a_placeholder_still_counts() {
1319        let buses = vec![
1320            bus_at(0, BusType::Slack, 0.0),
1321            bus_at(1, BusType::Slack, 1.05),
1322            bus_at(2, BusType::PQ, 1.0),
1323        ];
1324        let classified = classify(&buses, &[vec![0, 1, 2]]);
1325        assert!(matches!(classified[0].verdict, Verdict::Solvable));
1326        assert_eq!(classified[0].slack_indices, vec![1]);
1327    }
1328
1329    /// A transformer whose nameplate ratio differs from its nodes' rating
1330    /// ratio has a real off-nominal ratio, even at nominal tap.
1331    ///
1332    /// This is the case that went wrong: the old formula computed
1333    /// `(u1 + delta)/u1`, which cancels the nameplate against itself and never
1334    /// looks at the node ratings, so a 10750/420 unit on a 10500 V node came out
1335    /// as 1:1. Twenty of `vision-validation-network`'s twenty-two transformers
1336    /// are exactly that, and the resulting node voltages were 10.7 V out against
1337    /// a 0.5 V tolerance.
1338    #[test]
1339    fn tap_ratio_accounts_for_nameplate_versus_node_rating() {
1340        // Nominal tap: pos == nom, so the tap contributes nothing and the whole
1341        // ratio comes from nameplate against rating.
1342        let k = transformer_tap(10750.0, 420.0, 0, 3, 5, 1, 3, 250.0, 0, 10500.0, 420.0);
1343        assert!(
1344            (k.norm() - 10750.0 / 10500.0).abs() < 1e-12,
1345            "expected the 2.4% off-nominal ratio, got {}",
1346            k.norm()
1347        );
1348    }
1349
1350    /// The reassuring half: when nameplate and rating agree, nominal tap really
1351    /// is 1:1, so the fix does not disturb the ordinary case.
1352    #[test]
1353    fn a_matched_transformer_at_nominal_tap_is_unity() {
1354        let k = transformer_tap(
1355            110000.0, 10500.0, 0, 0, -12, 12, 0, 1320.0, 0, 110000.0, 10500.0,
1356        );
1357        assert!((k.norm() - 1.0).abs() < 1e-12, "got {}", k.norm());
1358    }
1359
1360    /// The tap itself still moves the ratio, on whichever side it sits.
1361    #[test]
1362    fn the_tap_position_shifts_the_tapped_side() {
1363        // tap_side 0: -3 steps of 1320 V off a 110 kV winding.
1364        let k = transformer_tap(
1365            110000.0, 10500.0, 0, -3, -12, 12, 0, 1320.0, 0, 110000.0, 10500.0,
1366        );
1367        assert!(
1368            (k.norm() - (110000.0 - 3960.0) / 110000.0).abs() < 1e-12,
1369            "got {}",
1370            k.norm()
1371        );
1372
1373        // tap_side 1 moves u2 instead, which inverts the direction.
1374        let k = transformer_tap(
1375            110000.0, 10500.0, 1, -3, -12, 12, 0, 100.0, 0, 110000.0, 10500.0,
1376        );
1377        assert!(
1378            (k.norm() - 10500.0 / (10500.0 - 300.0)).abs() < 1e-12,
1379            "got {}",
1380            k.norm()
1381        );
1382    }
1383
1384    /// `clock` is a vector-group phase shift of 30 degrees per unit, and must
1385    /// survive the ratio change untouched.
1386    #[test]
1387    fn the_clock_still_sets_the_phase() {
1388        let k = transformer_tap(10750.0, 420.0, 0, 3, 5, 1, 3, 250.0, 5, 10500.0, 420.0);
1389        assert!(
1390            (k.arg() - 5.0 * std::f64::consts::PI / 6.0).abs() < 1e-12,
1391            "got {}",
1392            k.arg()
1393        );
1394    }
1395
1396    #[test]
1397    fn connected_components_single_cluster() {
1398        // 0-1-2 all tied together by ordinary series branches.
1399        let mut y = YBus::new(3);
1400        y.add(0, 1, Complex::new(1.0, -2.0));
1401        y.add(1, 0, Complex::new(1.0, -2.0));
1402        y.add(1, 2, Complex::new(1.0, -2.0));
1403        y.add(2, 1, Complex::new(1.0, -2.0));
1404        let ybus = y.finish();
1405        let components = connected_components(&ybus);
1406        assert_eq!(components, vec![vec![0, 1, 2]]);
1407    }
1408
1409    #[test]
1410    fn connected_components_multiple_clusters() {
1411        // {0,1} tied together, {2} a singleton (shunt-only diagonal entry,
1412        // no off-diagonal edge — must not be treated as connected to
1413        // anything), {3,4} tied together.
1414        let mut y = YBus::new(5);
1415        y.add(0, 1, Complex::new(1.0, -2.0));
1416        y.add(1, 0, Complex::new(1.0, -2.0));
1417        y.add(2, 2, Complex::new(0.0, 1e-6)); // pure shunt, no branch
1418        y.add(3, 4, Complex::new(1.0, -2.0));
1419        y.add(4, 3, Complex::new(1.0, -2.0));
1420        let ybus = y.finish();
1421        let components = connected_components(&ybus);
1422        assert_eq!(components, vec![vec![0, 1], vec![2], vec![3, 4]]);
1423    }
1424
1425    #[test]
1426    fn connected_components_bus_with_no_ybus_entries_at_all() {
1427        // A bus that never got any `y.add(i, ...)` call at all (not even a
1428        // self-shunt) must still surface as its own singleton, not panic.
1429        let mut y = YBus::new(2);
1430        y.add(0, 0, Complex::new(0.0, 1e-6));
1431        let ybus = y.finish();
1432        let components = connected_components(&ybus);
1433        assert_eq!(components, vec![vec![0], vec![1]]);
1434    }
1435
1436    fn test_bus(idx: usize, bus_type: BusType) -> Bus {
1437        Bus {
1438            idx,
1439            bus_type,
1440            voltage_mag: 1.0,
1441            voltage_ang: 0.0,
1442            p_spec: 0.3,
1443            q_spec: 0.1,
1444            q_min: -f64::INFINITY,
1445            q_max: f64::INFINITY,
1446            u_rated: 0.0,
1447            zip_terms: Vec::new(),
1448        }
1449    }
1450
1451    #[test]
1452    fn classify_exactly_one_slack_is_solvable() {
1453        let buses = vec![test_bus(0, BusType::Slack), test_bus(1, BusType::PQ)];
1454        let classified = classify(&buses, &[vec![0, 1]]);
1455        assert_eq!(classified.len(), 1);
1456        assert!(matches!(classified[0].verdict, Verdict::Solvable));
1457        assert_eq!(classified[0].slack_indices, vec![0]);
1458    }
1459
1460    #[test]
1461    fn classify_zero_slack_is_no_reference_bus() {
1462        let buses = vec![test_bus(0, BusType::PQ), test_bus(1, BusType::PV)];
1463        let classified = classify(&buses, &[vec![0, 1]]);
1464        assert!(matches!(classified[0].verdict, Verdict::NoReferenceBus));
1465        assert!(classified[0].slack_indices.is_empty());
1466    }
1467
1468    #[test]
1469    fn classify_two_slack_is_ambiguous() {
1470        let buses = vec![test_bus(0, BusType::Slack), test_bus(1, BusType::Slack)];
1471        let classified = classify(&buses, &[vec![0, 1]]);
1472        assert!(matches!(
1473            classified[0].verdict,
1474            Verdict::AmbiguousReferenceBus
1475        ));
1476        assert_eq!(classified[0].slack_indices, vec![0, 1]);
1477    }
1478
1479    #[test]
1480    fn mark_unreferenced_islands_zeroes_no_reference_component_only() {
1481        let mut buses = vec![
1482            test_bus(0, BusType::PQ),    // component 0: no slack
1483            test_bus(1, BusType::PV),    // component 0: no slack
1484            test_bus(2, BusType::Slack), // component 1: solvable, untouched
1485            test_bus(3, BusType::PQ),    // component 1: solvable, untouched
1486        ];
1487        let components = vec![vec![0, 1], vec![2, 3]];
1488        let classified = classify(&buses, &components);
1489        mark_unreferenced_islands(&mut buses, &classified);
1490
1491        for &i in &[0, 1] {
1492            assert_eq!(buses[i].bus_type, BusType::Slack);
1493            assert_eq!(buses[i].voltage_mag, 0.0);
1494            assert_eq!(buses[i].voltage_ang, 0.0);
1495            assert_eq!(buses[i].p_spec, 0.0);
1496            assert_eq!(buses[i].q_spec, 0.0);
1497        }
1498        // Component 1 (already solvable) must be untouched.
1499        assert_eq!(buses[2].bus_type, BusType::Slack);
1500        assert_eq!(buses[3].bus_type, BusType::PQ);
1501        assert_eq!(buses[3].p_spec, 0.3);
1502        assert_eq!(buses[3].q_spec, 0.1);
1503    }
1504
1505    #[test]
1506    fn mark_unreferenced_islands_leaves_ambiguous_component_untouched() {
1507        let mut buses = vec![
1508            test_bus(0, BusType::Slack),
1509            test_bus(1, BusType::Slack),
1510            test_bus(2, BusType::PQ),
1511        ];
1512        let components = vec![vec![0, 1, 2]];
1513        let classified = classify(&buses, &components);
1514        mark_unreferenced_islands(&mut buses, &classified);
1515        // Ambiguous components aren't mutated at all — bus 2 keeps its
1516        // original PQ spec rather than being zeroed like a NoReferenceBus one.
1517        assert_eq!(buses[2].bus_type, BusType::PQ);
1518        assert_eq!(buses[2].p_spec, 0.3);
1519    }
1520}