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}