gridoxide_core/linear/btheta.rs
1//! The DC approximation to AC power flow: `B θ = P`.
2//!
3//! Three assumptions turn the AC power-flow equations into one linear system:
4//! every voltage magnitude is 1 p.u., every branch resistance is zero, and
5//! every angle difference is small enough that `sin δ ≈ δ` and `cos δ ≈ 1`.
6//! What survives is exact, direct and unconditionally solvable — no initial
7//! guess, no iteration, no convergence to fail. What is lost is every
8//! reactive quantity and every voltage magnitude, so DC answers exactly one
9//! question: how does active power divide between the available paths.
10//!
11//! # The sign conventions, derived from gridoxide's own AC code
12//!
13//! Rather than copy MATPOWER's formulas and hope the conventions line up, the
14//! ones below are derived from [`crate::network::branch_calc_param`], which is this
15//! crate's authority on branch orientation. With `tap = k·e^{jα}` that
16//! function produces `yft = −y_s/conj(tap)` and `yff = (y_s + y_sh/2)/k²`, so
17//! for `y_s = g + j·b_s`, `y_sh = 0`, `|V| = 1` and `δ = θ_f − θ_t − α`:
18//!
19//! \\[ P_{from} = \frac{g}{k^2} - \frac{1}{k}\left[g\cos\delta + b_s\sin\delta\right] \\]
20//!
21//! Dropping `g` and linearizing leaves the two formulas this module implements:
22//!
23//! \\[ P_{from} = b\,(\theta_{from} - \theta_{to} - \alpha), \qquad P_{to} = -P_{from} \\]
24//! \\[ b = \frac{-b_s}{k} = \frac{x}{k\,(r^2 + x^2)} \\]
25//!
26//! This agrees with MATPOWER `makeBdc` exactly (`b = 1./(x.*tap)`,
27//! `Pfinj = b .* (-shift·π/180)`). It appears to disagree with
28//! powsybl-open-loadflow, which writes `p1 = b·(θ1 − θ2 + a1)` — but powsybl's
29//! `a1` is the negation of gridoxide's `tap.arg()`, so the two agree on the
30//! physics. Do not "fix" the sign here to match powsybl's source without
31//! following that definition through.
32//!
33//! The phase shift never enters the matrix: it is a constant, so it moves to
34//! the right-hand side and `B` stays symmetric even on a network full of
35//! phase-shifting transformers.
36//!
37//! \\[ B\,\theta = P + \varphi, \qquad
38//! \varphi_i = \sum_{from_k = i} b_k \alpha_k - \sum_{to_k = i} b_k \alpha_k \\]
39//!
40//! (MATPOWER stores `−φ` and calls it `Pbusinj`.) The symmetry of `B` is
41//! relied on by [`sensitivity`](super::sensitivity), which solves against
42//! `Bᵀ` without ever forming it.
43//!
44//! # What this module deliberately does not do
45//!
46//! Shunt admittance is ignored entirely, both `b` and `g`, matching
47//! powsybl-open-loadflow (whose `DcEquationSystemCreator` has no shunt term).
48//! MATPOWER and pandapower additionally fold `Gs` in as a constant real load
49//! at `|V| = 1`; gridoxide does not, so on data where `g_shunt ≠ 0` its slack
50//! pickup differs from theirs by exactly `Σ g_ii`. Covering one of gridoxide's
51//! three shunt sources (`Line::g_shunt`, `Transformer::y_shunt`, and the
52//! separate `network::ShuntAdm` list this function is not even handed) and not
53//! the others would be worse than covering none.
54//!
55//! Bus type is ignored beyond `Slack`: `PV` and `PQ` are treated identically,
56//! because the distinction is entirely about reactive power.
57
58use num_complex::Complex;
59
60use crate::network::{Classified, Verdict, classify, mark_unreferenced_islands};
61use crate::topology::{IDEAL_CONNECTION_Y, clamp_branch_impedance, ideal_connection_z, union_all};
62use crate::types::{Bus, BusType, Line, Transformer};
63use gridoxide_sparse::sparse::RealFactorization;
64
65use super::{DcApproximation, DcOptions};
66
67/// One series branch reduced to the two numbers the DC model needs.
68///
69/// Branches with no series path at all — a half-open self-loop line, a
70/// transformer with an open terminal — produce no `DcBranch`: they contribute
71/// nothing to `B` and carry no DC flow. Their flat indices show up in
72/// [`DcSolution::ignored_branches`] instead.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct DcBranch {
75 /// Flat branch index — lines first, then transformers. This is the
76 /// crate-wide branch index space that [`branch_flow::branch_params`] and
77 /// `pgm::PgmNetwork::branch_idx` also use, so a `DcBranch` and a
78 /// `BranchParams` with the same `index` are the same physical branch.
79 ///
80 /// [`branch_flow::branch_params`]: crate::branch_flow::branch_params
81 pub index: usize,
82 pub from: usize,
83 pub to: usize,
84 /// Susceptance entering `B`, already divided by the off-nominal ratio
85 /// `k`. Positive for every ordinary inductive branch.
86 pub b: f64,
87 /// Phase shift α = `tap.arg()`, radians. Always 0 for a [`Line`].
88 pub shift: f64,
89}
90
91/// How one connected component's DC solve turned out.
92///
93/// Deliberately *not* [`solver::IslandStatus`](crate::solver::IslandStatus).
94/// A direct solve has no iterations, so that enum's `MaxIterationsReached`
95/// is unrepresentable here — and two of its cases genuinely come out better
96/// in DC, which a shared type could not express:
97///
98/// - `IslandStatus::Singular` documents that it cannot attribute a singular
99/// factorization to one component, because the AC path solves every
100/// component in one shared system. DC factorizes each island separately
101/// (the LU of a block-diagonal matrix *is* the sum of the block LUs, so
102/// this costs nothing), and so can name the island that failed.
103/// - `IslandStatus::AmbiguousReferenceBus` exists because two independently
104/// fixed complex voltages over-determine an AC island. DC fixes only
105/// angles, so the same island stays perfectly well posed —
106/// see [`SolvedMultipleReferences`](Self::SolvedMultipleReferences).
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum DcIslandStatus {
109 /// Reduced `B` factorized; this island's angles satisfy its equations.
110 Solved,
111 /// Two or more `Slack` buses, and solved anyway. Every non-slack bus's
112 /// injection is still met exactly — only the *split* of slack pickup
113 /// among the references is arbitrary, since DC pins their angles
114 /// independently and lets each supply whatever its own incident branches
115 /// demand. The per-island `slack_pickup` total is still meaningful; the
116 /// individual references' shares are not.
117 SolvedMultipleReferences,
118 /// The reduced `B` was singular, so this island's angles were left
119 /// untouched and its branch flows are zero. Attributable to exactly this
120 /// island, unlike the AC path's best-effort equivalent.
121 Singular,
122 /// No `Slack` bus, so there is no angle reference and nothing to solve
123 /// against. Pinned to `θ = 0` by
124 /// [`network::mark_unreferenced_islands`](crate::network), which never
125 /// fabricates a reference.
126 NoReferenceBus,
127}
128
129/// One connected component's DC outcome.
130#[derive(Clone, Debug)]
131pub struct DcIslandReport {
132 pub bus_indices: Vec<usize>,
133 /// The `Slack` bus(es) found in this component *before*
134 /// `mark_unreferenced_islands` ran — empty for
135 /// [`NoReferenceBus`](DcIslandStatus::NoReferenceBus).
136 pub slack_indices: Vec<usize>,
137 pub status: DcIslandStatus,
138 /// Total active power this island's reference bus(es) supply, per-unit.
139 /// Equal and opposite to the sum of every other bus's injection, since
140 /// DC is lossless — which is what
141 /// `slack_pickup_balances_injections_exactly` asserts.
142 pub slack_pickup: f64,
143}
144
145/// The result of a DC solve. Bus angles are written back into the `buses`
146/// slice; everything that has nowhere to live on a [`Bus`] is here.
147#[derive(Clone, Debug)]
148pub struct DcSolution {
149 pub islands: Vec<DcIslandReport>,
150 /// Active power entering each branch at its `from` terminal, per-unit,
151 /// indexed by the **flat** branch index (lines then transformers) — never
152 /// by position in [`dc_branches`]'s output. Branches that carry no DC
153 /// flow hold `0.0`.
154 ///
155 /// Sign convention matches
156 /// [`branch_flow::terminal_flow`](crate::branch_flow::terminal_flow):
157 /// positive means power entering the branch at `from`. DC is lossless, so
158 /// the `to`-terminal flow is always exactly the negation.
159 pub branch_p: Vec<f64>,
160 /// `max |Σ(flows out of i) − P_i|` over every solved non-slack bus.
161 ///
162 /// A factorization-quality check, **not** a convergence check — there is
163 /// nothing to converge. On a healthy network this is round-off, ~1e-15.
164 /// A large value means `B` was ill-conditioned, which on gridoxide data
165 /// most often means clamped zero-impedance branches (see
166 /// [`topology::ZERO_IMPEDANCE_THRESHOLD`](crate::topology::ZERO_IMPEDANCE_THRESHOLD)).
167 pub max_residual: f64,
168 /// Flat indices of branches with a negative susceptance — series
169 /// capacitors. Legitimate and kept, but they make `B` indefinite, so they
170 /// are reported rather than passed over in silence.
171 pub negative_reactance_branches: Vec<usize>,
172 /// Flat indices of branches that contributed nothing: self-loops
173 /// (gridoxide's half-open representation), transformers with an open
174 /// terminal, and any branch whose susceptance came out non-finite (a
175 /// purely resistive branch under
176 /// [`DcApproximation::IgnoreR`]).
177 pub ignored_branches: Vec<usize>,
178}
179
180/// Active-power injection under the DC assumption.
181///
182/// Every [`ZipKind`](crate::types::ZipKind) collapses to its own `s_const` at
183/// `|V| = 1`, so this is `network::effective_injection(bus).0` evaluated
184/// there. It is written out rather than delegated precisely because
185/// `effective_injection` reads the bus's *current* `voltage_mag` — which for
186/// a DC solve is whatever the caller happened to leave in the struct, and is
187/// not part of the DC model at all.
188fn dc_injection(bus: &Bus) -> f64 {
189 bus.p_spec + bus.zip_terms.iter().map(|z| z.s_const.re).sum::<f64>()
190}
191
192/// The branch susceptance entering `B`, before the transformer ratio.
193///
194/// Each approximation clamps against
195/// [`topology::ZERO_IMPEDANCE_THRESHOLD`](crate::topology::ZERO_IMPEDANCE_THRESHOLD)
196/// on *the impedance it actually models*, which is not the same quantity for
197/// the two of them:
198///
199/// - [`IgnoreR`](DcApproximation::IgnoreR) asserts `r = 0`, so a branch's
200/// impedance in its world is `|x|` alone. A line with `x = 0` and any `r`
201/// at all — power-grid-model's `link/dummy-test` fixture contains exactly
202/// one, at `r = 10 Ω, x = 0` — is therefore a *short circuit* as far as
203/// this approximation is concerned, and gets the crate's standard
204/// zero-impedance treatment rather than an infinite susceptance. Without
205/// this, `1/x` overflows, the branch has to be dropped, and dropping it
206/// silently splits the network into a live island and a sourceless one.
207/// - [`IgnoreG`](DcApproximation::IgnoreG) keeps `r`, so it clamps on the
208/// full `|z|` and that same line comes out at `b = 0`. That is not a bug
209/// but the correct answer for the model: with `x = 0` the exact AC flow
210/// `g/k² − g·cos δ` is second order in `δ`, so a purely resistive branch
211/// genuinely transmits no angle-driven active power. The two
212/// approximations disagree about such a branch by construction, and a
213/// network held together by one will island under `IgnoreG`.
214fn susceptance(r: f64, x: f64, approximation: DcApproximation) -> f64 {
215 match approximation {
216 DcApproximation::IgnoreR => 1.0 / clamp_branch_impedance(0.0, x).1,
217 DcApproximation::IgnoreG => {
218 let (r, x) = clamp_branch_impedance(r, x);
219 x / (r * r + x * x)
220 }
221 }
222}
223
224/// Reduces every branch to its DC parameters, in flat branch-index order.
225///
226/// See [`DcBranch::index`] for the index contract and the module doc for the
227/// susceptance and phase-shift conventions.
228pub fn dc_branches(lines: &[Line], transformers: &[Transformer], opts: DcOptions) -> Vec<DcBranch> {
229 let mut out = Vec::with_capacity(lines.len() + transformers.len());
230
231 for (i, ln) in lines.iter().enumerate() {
232 // A self-loop is gridoxide's half-open branch: pure shunt, no series
233 // path, so nothing for DC to carry. See `branch_flow::line_params`.
234 if ln.from == ln.to {
235 continue;
236 }
237 let b = susceptance(ln.r, ln.x, opts.approximation);
238 if !b.is_finite() {
239 continue;
240 }
241 out.push(DcBranch {
242 index: i,
243 from: ln.from,
244 to: ln.to,
245 b,
246 shift: 0.0,
247 });
248 }
249
250 for (j, t) in transformers.iter().enumerate() {
251 let index = lines.len() + j;
252 if t.from == t.to {
253 continue;
254 }
255 // `branch_calc_param` zeroes `yft`/`ytf` when either terminal is
256 // open, so such a transformer contributes only its shunt to the
257 // Y-bus — and shunts are outside the DC model entirely.
258 if t.from_status == 0 || t.to_status == 0 {
259 continue;
260 }
261
262 let k = t.tap.norm();
263 let mut b = if t.y_series == IDEAL_CONNECTION_Y {
264 // A PGM `link` (`pgm::LINK_Y`) carries `2e5 + j2e5`, whose
265 // *positive* imaginary part is inherited from power-grid-model's
266 // own `1e8 + j1e8` and is a regularization choice, not a claim
267 // that the element is capacitive. Inverting it gives
268 // `x = −2.5e-6`, and a negative susceptance here would flip this
269 // branch's coupling sign and destroy B's positive-definiteness.
270 // AC never noticed, because only `|y|` and the link's own
271 // reported Q depend on that sign — which is what
272 // `tests/link_test.rs` pins against PGM's fixtures.
273 //
274 // Match on the constant, never on `x < 0` in general: a genuinely
275 // negative reactance is a series capacitor and must pass through.
276 1.0 / ideal_connection_z()
277 } else {
278 let z = Complex::new(1.0, 0.0) / t.y_series;
279 susceptance(z.re, z.im, opts.approximation)
280 };
281 if opts.use_transformer_ratio && k.is_finite() && k > 0.0 {
282 b /= k;
283 }
284 if !b.is_finite() {
285 continue;
286 }
287 out.push(DcBranch {
288 index,
289 from: t.from,
290 to: t.to,
291 b,
292 shift: t.tap.arg(),
293 });
294 }
295
296 out
297}
298
299/// Partitions buses into connected components using only the DC branch list.
300///
301/// Returns the same shape as
302/// [`network::connected_components`](crate::network::connected_components):
303/// members sorted ascending, components ordered by lowest member. It does not
304/// call that function, which needs a `&YBusSparse` — building a full complex
305/// Y-bus purely to partition a branch-list-based solver would be the tail
306/// wagging the dog, and [`topology::UnionFind`](crate::topology::UnionFind)
307/// already exists for exactly this.
308///
309/// The two used to disagree about a **half-open transformer**, which
310/// `connected_components` counted as a connection because
311/// `network::stamp_transformers` inserts the structural `(from, to)` entry even
312/// when `branch_calc_param` has zeroed its value. That is fixed —
313/// `connected_components` now ignores numerically-zero off-diagonals — so the
314/// two partitions agree. This function still exists because building a complex
315/// Y-bus purely to partition a branch-list solver would be the tail wagging the
316/// dog, not because it answers differently.
317fn dc_components(n: usize, branches: &[DcBranch]) -> Vec<Vec<usize>> {
318 let mut uf = union_all(n, branches.iter().map(|b| (b.from, b.to)));
319 let mut comp_of_root = vec![usize::MAX; n];
320 let mut components: Vec<Vec<usize>> = Vec::new();
321 // Ascending `i` gives both halves of the contract for free: members land
322 // in each component in ascending order, and a component is created the
323 // first time its lowest-indexed member is seen.
324 for i in 0..n {
325 let root = uf.find(i);
326 if comp_of_root[root] == usize::MAX {
327 comp_of_root[root] = components.len();
328 components.push(Vec::new());
329 }
330 components[comp_of_root[root]].push(i);
331 }
332 components
333}
334
335/// One island partition, shared by the solver and by
336/// [`sensitivity::DcSensitivity`](super::sensitivity::DcSensitivity) so the
337/// two cannot drift apart in how they group buses or assign branches.
338///
339/// Deliberately does not mutate `buses`: the sensitivity factors are computed
340/// from a network the caller still owns, and only the solver has a reason to
341/// pin sourceless islands. Callers that want that must invoke
342/// [`network::mark_unreferenced_islands`](crate::network) themselves.
343pub(crate) struct Partition {
344 pub(crate) classified: Vec<Classified>,
345 /// Per island, the positions into the `branches` slice whose endpoints lie
346 /// in it. A branch's endpoints are always in the same island by
347 /// construction, so indexing by `from` assigns it unambiguously.
348 pub(crate) island_branches: Vec<Vec<usize>>,
349}
350
351pub(crate) fn partition(buses: &[Bus], branches: &[DcBranch]) -> Partition {
352 let n = buses.len();
353 let components = dc_components(n, branches);
354 let mut island_of = vec![usize::MAX; n];
355 for (ci, members) in components.iter().enumerate() {
356 for &i in members {
357 island_of[i] = ci;
358 }
359 }
360 let mut island_branches: Vec<Vec<usize>> = vec![Vec::new(); components.len()];
361 for (pos, br) in branches.iter().enumerate() {
362 island_branches[island_of[br.from]].push(pos);
363 }
364 Partition {
365 classified: classify(buses, &components),
366 island_branches,
367 }
368}
369
370/// Stamps one island's reduced susceptance matrix `B_NN`.
371///
372/// `reduced_pos` maps a bus index to its row in the reduced system, or
373/// [`NOT_UNKNOWN`] for a reference bus. Reference buses contribute to the
374/// diagonal of their unknown neighbours — a branch to a fixed angle still
375/// stiffens the bus it lands on — but produce no off-diagonal entry, since
376/// their angle is not a variable.
377///
378/// The result is symmetric even on a network full of phase shifters: the
379/// shift is constant and lives entirely on the right-hand side. Both callers
380/// depend on that, and `sensitivity` depends on it twice over, since it
381/// solves against `Bᵀ` without ever forming it.
382pub(crate) fn reduced_b_triplets(
383 branches: &[DcBranch],
384 positions: &[usize],
385 reduced_pos: &[usize],
386) -> Vec<(usize, usize, f64)> {
387 let mut triplets = Vec::with_capacity(positions.len() * 4);
388 for &pos in positions {
389 let br = &branches[pos];
390 let (rf, rt) = (reduced_pos[br.from], reduced_pos[br.to]);
391 if rf != NOT_UNKNOWN {
392 triplets.push((rf, rf, br.b));
393 if rt != NOT_UNKNOWN {
394 triplets.push((rf, rt, -br.b));
395 }
396 }
397 if rt != NOT_UNKNOWN {
398 triplets.push((rt, rt, br.b));
399 if rf != NOT_UNKNOWN {
400 triplets.push((rt, rf, -br.b));
401 }
402 }
403 }
404 triplets
405}
406
407/// Marker in a `reduced_pos` map for a bus that is a reference, and so has no
408/// row in the reduced system.
409pub(crate) const NOT_UNKNOWN: usize = usize::MAX;
410
411/// Solves the DC power flow, writing bus angles back into `buses`.
412///
413/// Each connected component is factorized and solved independently, so one
414/// island's singular `B` cannot spoil another's answer.
415///
416/// `voltage_ang` is overwritten on every solved non-slack bus. `voltage_mag`
417/// is set to 1 p.u. on solved **`PQ`** buses only — the DC model's own
418/// assumption, applied where nothing else specifies a magnitude. `Slack` and
419/// `PV` buses keep theirs: those are setpoints, inputs rather than results,
420/// and DC neither uses nor computes them. `Slack` buses likewise keep their
421/// angle, which is the reference this island is solved against and need not
422/// be zero.
423///
424/// Sourceless islands are handled by
425/// [`network::mark_unreferenced_islands`](crate::network), exactly as the AC
426/// path handles them: pinned to a placeholder, never given a fabricated
427/// reference bus.
428pub fn dc_power_flow(
429 buses: &mut [Bus],
430 lines: &[Line],
431 transformers: &[Transformer],
432 opts: DcOptions,
433) -> DcSolution {
434 let n = buses.len();
435 let n_branches = lines.len() + transformers.len();
436 let branches = dc_branches(lines, transformers, opts);
437
438 let Partition {
439 classified,
440 island_branches,
441 } = partition(buses, &branches);
442 mark_unreferenced_islands(buses, &classified);
443
444 let mut contributing = vec![false; n_branches];
445 let mut negative_reactance_branches = Vec::new();
446 for br in &branches {
447 contributing[br.index] = true;
448 if br.b < 0.0 {
449 negative_reactance_branches.push(br.index);
450 }
451 }
452 let ignored_branches: Vec<usize> = (0..n_branches).filter(|&i| !contributing[i]).collect();
453
454 let mut branch_p = vec![0.0; n_branches];
455 let mut max_residual: f64 = 0.0;
456 let mut islands = Vec::with_capacity(classified.len());
457 // Reused across islands so the reduced-index map costs one allocation
458 // rather than one per component; only each island's own members are read,
459 // and each writes its own entries before reading them.
460 let mut reduced_pos = vec![NOT_UNKNOWN; n];
461
462 for (ci, c) in classified.iter().enumerate() {
463 if matches!(c.verdict, Verdict::NoReferenceBus) {
464 islands.push(DcIslandReport {
465 bus_indices: c.bus_indices.clone(),
466 slack_indices: c.slack_indices.clone(),
467 status: DcIslandStatus::NoReferenceBus,
468 slack_pickup: 0.0,
469 });
470 continue;
471 }
472
473 let mut unknown: Vec<usize> = Vec::new();
474 for &i in &c.bus_indices {
475 if buses[i].bus_type != BusType::Slack {
476 reduced_pos[i] = unknown.len();
477 unknown.push(i);
478 }
479 }
480 let m = unknown.len();
481
482 let mut solved = true;
483 if m > 0 {
484 let triplets = reduced_b_triplets(&branches, &island_branches[ci], &reduced_pos);
485
486 let mut rhs = vec![0.0; m];
487 for (r, &i) in unknown.iter().enumerate() {
488 rhs[r] = dc_injection(&buses[i]);
489 }
490 for &pos in &island_branches[ci] {
491 let br = &branches[pos];
492 let (rf, rt) = (reduced_pos[br.from], reduced_pos[br.to]);
493 // φ: the phase shift is constant, so it lives on the RHS and
494 // `B` stays symmetric. See the module doc.
495 let shift_p = br.b * br.shift;
496 if rf != NOT_UNKNOWN {
497 rhs[rf] += shift_p;
498 // A reference's known angle also moves to the RHS:
499 // subtracting `B_ij θ_j = −b θ_j` adds `+b θ_j`.
500 if rt == NOT_UNKNOWN {
501 rhs[rf] += br.b * buses[br.to].voltage_ang;
502 }
503 }
504 if rt != NOT_UNKNOWN {
505 rhs[rt] -= shift_p;
506 if rf == NOT_UNKNOWN {
507 rhs[rt] += br.b * buses[br.from].voltage_ang;
508 }
509 }
510 }
511
512 match RealFactorization::new(m, &triplets).and_then(|sys| sys.solve(&rhs)) {
513 Some(theta) => {
514 for (r, &i) in unknown.iter().enumerate() {
515 buses[i].voltage_ang = theta[r];
516 // Only `PQ` magnitudes are normalized to the DC model's
517 // own |V| = 1 assumption. A `PV` bus's `voltage_mag` is
518 // its *setpoint* — an input the AC solver holds fixed
519 // (`jacobian` pins ΔVmag = 0 there and never updates
520 // it), not a quantity DC computed. Overwriting it would
521 // silently change the answer of any later AC solve, and
522 // DC has nothing to say about it either way.
523 if buses[i].bus_type == BusType::PQ {
524 buses[i].voltage_mag = 1.0;
525 }
526 }
527 }
528 None => solved = false,
529 }
530 }
531
532 if !solved {
533 islands.push(DcIslandReport {
534 bus_indices: c.bus_indices.clone(),
535 slack_indices: c.slack_indices.clone(),
536 status: DcIslandStatus::Singular,
537 slack_pickup: 0.0,
538 });
539 continue;
540 }
541
542 // Flows, and from them the two derived quantities. Deriving the slack
543 // pickup and the residual from the *flows* rather than from `B θ`
544 // makes them an end-to-end check: a sign error in φ that happened to
545 // be consistent between assembly and solve would still show up here,
546 // because these formulas go through `br.shift` a second time.
547 let mut net_out = vec![0.0; n];
548 for &pos in &island_branches[ci] {
549 let br = &branches[pos];
550 let p = br.b * (buses[br.from].voltage_ang - buses[br.to].voltage_ang - br.shift);
551 branch_p[br.index] = p;
552 net_out[br.from] += p;
553 net_out[br.to] -= p;
554 }
555
556 for &i in &c.bus_indices {
557 if buses[i].bus_type != BusType::Slack {
558 max_residual = max_residual.max((net_out[i] - dc_injection(&buses[i])).abs());
559 }
560 }
561 let slack_pickup: f64 = c.slack_indices.iter().map(|&s| net_out[s]).sum();
562
563 let status = if c.slack_indices.len() > 1 {
564 DcIslandStatus::SolvedMultipleReferences
565 } else {
566 DcIslandStatus::Solved
567 };
568 islands.push(DcIslandReport {
569 bus_indices: c.bus_indices.clone(),
570 slack_indices: c.slack_indices.clone(),
571 status,
572 slack_pickup,
573 });
574 }
575
576 DcSolution {
577 islands,
578 branch_p,
579 max_residual,
580 negative_reactance_branches,
581 ignored_branches,
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 fn bus(idx: usize, bus_type: BusType, p_spec: f64) -> Bus {
590 Bus {
591 idx,
592 bus_type,
593 voltage_mag: 1.0,
594 voltage_ang: 0.0,
595 p_spec,
596 q_spec: 0.0,
597 q_min: 0.0,
598 q_max: 0.0,
599 u_rated: 0.0,
600 zip_terms: Vec::new(),
601 }
602 }
603
604 fn line(from: usize, to: usize, x: f64) -> Line {
605 Line {
606 from,
607 to,
608 r: 0.0,
609 x,
610 b_shunt: 0.0,
611 g_shunt: 0.0,
612 }
613 }
614
615 /// Three buses in a line, slack at 0. With no meshing the flows are fixed
616 /// by continuity alone, so the angles follow in closed form:
617 ///
618 /// bus 2 draws 0.5, bus 1 draws 0.3 → branch 1→2 carries 0.5,
619 /// branch 0→1 carries 0.8.
620 /// θ1 = −0.8·x01, θ2 = θ1 − 0.5·x12.
621 #[test]
622 fn radial_three_bus_matches_closed_form() {
623 let mut buses = vec![
624 bus(0, BusType::Slack, 0.0),
625 bus(1, BusType::PQ, -0.3),
626 bus(2, BusType::PQ, -0.5),
627 ];
628 let lines = vec![line(0, 1, 0.1), line(1, 2, 0.2)];
629 let sol = dc_power_flow(&mut buses, &lines, &[], DcOptions::default());
630
631 assert_eq!(sol.islands.len(), 1);
632 assert_eq!(sol.islands[0].status, DcIslandStatus::Solved);
633
634 let theta1 = -0.8 * 0.1;
635 let theta2 = theta1 - 0.5 * 0.2;
636 assert!(
637 (buses[1].voltage_ang - theta1).abs() < 1e-12,
638 "{}",
639 buses[1].voltage_ang
640 );
641 assert!(
642 (buses[2].voltage_ang - theta2).abs() < 1e-12,
643 "{}",
644 buses[2].voltage_ang
645 );
646 assert!((sol.branch_p[0] - 0.8).abs() < 1e-12, "{}", sol.branch_p[0]);
647 assert!((sol.branch_p[1] - 0.5).abs() < 1e-12, "{}", sol.branch_p[1]);
648 assert!((sol.islands[0].slack_pickup - 0.8).abs() < 1e-12);
649 assert!(sol.max_residual < 1e-12, "{}", sol.max_residual);
650 }
651
652 /// A mesh divides flow in inverse proportion to reactance. Two parallel
653 /// paths from slack to bus 2, one of reactance 0.2 and one of 0.4, must
654 /// split a 0.6 load as 0.4 / 0.2.
655 #[test]
656 fn parallel_paths_split_inversely_with_reactance() {
657 let mut buses = vec![
658 bus(0, BusType::Slack, 0.0),
659 bus(1, BusType::PQ, 0.0),
660 bus(2, BusType::PQ, -0.6),
661 ];
662 // Path A: 0 → 2 directly, x = 0.2. Path B: 0 → 1 → 2, x = 0.2 + 0.2.
663 let lines = vec![line(0, 2, 0.2), line(0, 1, 0.2), line(1, 2, 0.2)];
664 let sol = dc_power_flow(&mut buses, &lines, &[], DcOptions::default());
665
666 assert!((sol.branch_p[0] - 0.4).abs() < 1e-12, "{}", sol.branch_p[0]);
667 assert!((sol.branch_p[1] - 0.2).abs() < 1e-12, "{}", sol.branch_p[1]);
668 assert!((sol.branch_p[2] - 0.2).abs() < 1e-12, "{}", sol.branch_p[2]);
669 assert!(sol.max_residual < 1e-12, "{}", sol.max_residual);
670 }
671
672 fn pst(from: usize, to: usize, x: f64, alpha: f64) -> Transformer {
673 Transformer {
674 from,
675 to,
676 from_status: 1,
677 to_status: 1,
678 y_series: Complex::new(1.0, 0.0) / Complex::new(0.0, x),
679 y_shunt: Complex::new(0.0, 0.0),
680 tap: Complex::from_polar(1.0, alpha),
681 }
682 }
683
684 /// A phase shifter on a *radial* branch carries no flow — there is
685 /// nowhere for power to go — but it does displace the far bus's angle by
686 /// exactly `−α`. That displacement is the cleanest available check that
687 /// φ enters the right-hand side with the right sign.
688 #[test]
689 fn radial_phase_shifter_displaces_the_angle_and_carries_nothing() {
690 let alpha = 0.05_f64;
691 let mut buses = vec![bus(0, BusType::Slack, 0.0), bus(1, BusType::PQ, 0.0)];
692 let sol = dc_power_flow(
693 &mut buses,
694 &[],
695 &[pst(0, 1, 0.25, alpha)],
696 DcOptions::default(),
697 );
698
699 assert!(
700 (buses[1].voltage_ang - (-alpha)).abs() < 1e-12,
701 "{}",
702 buses[1].voltage_ang
703 );
704 assert!(sol.branch_p[0].abs() < 1e-12, "{}", sol.branch_p[0]);
705 assert!(sol.islands[0].slack_pickup.abs() < 1e-12);
706 }
707
708 /// A phase shifter in a *loop* drives circulating power with no load
709 /// anywhere — which is the entire reason to install one, and the
710 /// tightest check on the φ convention because the answer depends on its
711 /// sign, not merely its magnitude.
712 ///
713 /// With a plain line (`b_l`) parallel to a PST (`b_t`, shift α) between
714 /// the same two buses and zero injection everywhere, bus 1's balance
715 /// gives `θ₁ = −b_t·α/(b_l + b_t)`, so the loop carries
716 /// `±b_l·b_t·α/(b_l + b_t)` — negative through the PST, positive through
717 /// the line, and nothing through the slack.
718 #[test]
719 fn phase_shifter_in_a_loop_drives_circulating_flow() {
720 let alpha = 0.05_f64;
721 let (x_line, x_pst) = (0.2_f64, 0.25_f64);
722 let (b_l, b_t) = (1.0 / x_line, 1.0 / x_pst);
723
724 let mut buses = vec![bus(0, BusType::Slack, 0.0), bus(1, BusType::PQ, 0.0)];
725 let lines = vec![line(0, 1, x_line)];
726 let sol = dc_power_flow(
727 &mut buses,
728 &lines,
729 &[pst(0, 1, x_pst, alpha)],
730 DcOptions::default(),
731 );
732
733 let circulating = b_l * b_t * alpha / (b_l + b_t);
734 assert!(
735 (sol.branch_p[0] - circulating).abs() < 1e-12,
736 "line: {}",
737 sol.branch_p[0]
738 );
739 assert!(
740 (sol.branch_p[1] + circulating).abs() < 1e-12,
741 "pst: {}",
742 sol.branch_p[1]
743 );
744 assert!(
745 (buses[1].voltage_ang - (-b_t * alpha / (b_l + b_t))).abs() < 1e-12,
746 "{}",
747 buses[1].voltage_ang
748 );
749 // No load and no losses, so the reference supplies nothing.
750 assert!(
751 sol.islands[0].slack_pickup.abs() < 1e-12,
752 "{}",
753 sol.islands[0].slack_pickup
754 );
755 assert!(sol.max_residual < 1e-12);
756 }
757
758 /// The off-nominal ratio divides `b` once, not twice — the `1/k` vs
759 /// `1/k²` distinction, checked against a hand-computed value.
760 #[test]
761 fn transformer_ratio_divides_susceptance_once() {
762 let x = 0.1_f64;
763 let k = 1.05_f64;
764 let t = Transformer {
765 from: 0,
766 to: 1,
767 from_status: 1,
768 to_status: 1,
769 y_series: Complex::new(1.0, 0.0) / Complex::new(0.0, x),
770 y_shunt: Complex::new(0.0, 0.0),
771 tap: Complex::from_polar(k, 0.0),
772 };
773 let got = dc_branches(&[], std::slice::from_ref(&t), DcOptions::default());
774 assert!((got[0].b - 1.0 / (x * k)).abs() < 1e-12, "{}", got[0].b);
775
776 let no_ratio = DcOptions {
777 use_transformer_ratio: false,
778 ..Default::default()
779 };
780 let got = dc_branches(&[], std::slice::from_ref(&t), no_ratio);
781 assert!((got[0].b - 1.0 / x).abs() < 1e-12, "{}", got[0].b);
782 }
783
784 /// `IgnoreG` keeps resistance in the denominator, and equals
785 /// `−Im(y_series)` exactly. On a branch with `r = x` the two
786 /// approximations differ by a factor of two, which is the point of
787 /// offering the choice.
788 #[test]
789 fn ignore_g_uses_the_true_series_susceptance() {
790 let ln = Line {
791 from: 0,
792 to: 1,
793 r: 0.1,
794 x: 0.1,
795 b_shunt: 0.0,
796 g_shunt: 0.0,
797 };
798 let opts = DcOptions {
799 approximation: DcApproximation::IgnoreG,
800 ..Default::default()
801 };
802 let got = dc_branches(std::slice::from_ref(&ln), &[], opts);
803 let y_series = Complex::new(1.0, 0.0) / Complex::new(ln.r, ln.x);
804 assert!((got[0].b - (-y_series.im)).abs() < 1e-12, "{}", got[0].b);
805 assert!((got[0].b - 5.0).abs() < 1e-12, "{}", got[0].b);
806
807 let got = dc_branches(std::slice::from_ref(&ln), &[], DcOptions::default());
808 assert!((got[0].b - 10.0).abs() < 1e-12, "{}", got[0].b);
809 }
810
811 /// Branches with no series path contribute nothing and are reported.
812 #[test]
813 fn self_loops_and_open_terminals_are_ignored() {
814 let lines = vec![line(0, 1, 0.1), line(1, 1, 0.2)];
815 let transformers = vec![Transformer {
816 from: 0,
817 to: 1,
818 from_status: 1,
819 to_status: 0,
820 y_series: Complex::new(1.0, 0.0) / Complex::new(0.0, 0.1),
821 y_shunt: Complex::new(0.0, 0.0),
822 tap: Complex::new(1.0, 0.0),
823 }];
824 let got = dc_branches(&lines, &transformers, DcOptions::default());
825 assert_eq!(got.len(), 1);
826 assert_eq!(got[0].index, 0);
827
828 let mut buses = vec![bus(0, BusType::Slack, 0.0), bus(1, BusType::PQ, -0.4)];
829 let sol = dc_power_flow(&mut buses, &lines, &transformers, DcOptions::default());
830 assert_eq!(sol.ignored_branches, vec![1, 2]);
831 assert_eq!(sol.branch_p[1], 0.0);
832 assert_eq!(sol.branch_p[2], 0.0);
833 }
834
835 /// A component with no slack gets no fabricated reference, matching the
836 /// AC path — and its neighbours still solve.
837 #[test]
838 fn sourceless_island_is_reported_not_invented() {
839 let mut buses = vec![
840 bus(0, BusType::Slack, 0.0),
841 bus(1, BusType::PQ, -0.4),
842 bus(2, BusType::PQ, -0.1),
843 bus(3, BusType::PQ, 0.1),
844 ];
845 let lines = vec![line(0, 1, 0.1), line(2, 3, 0.2)];
846 let sol = dc_power_flow(&mut buses, &lines, &[], DcOptions::default());
847
848 assert_eq!(sol.islands.len(), 2);
849 assert_eq!(sol.islands[0].status, DcIslandStatus::Solved);
850 assert_eq!(sol.islands[1].status, DcIslandStatus::NoReferenceBus);
851 assert_eq!(sol.islands[1].bus_indices, vec![2, 3]);
852 assert_eq!(buses[2].voltage_ang, 0.0);
853 assert_eq!(sol.branch_p[1], 0.0);
854 }
855
856 /// Two slacks in one component is over-determined for AC but well posed
857 /// for DC: the load's injection is still met exactly, and only the split
858 /// of pickup between the two references is arbitrary.
859 #[test]
860 fn two_references_in_one_island_still_solve() {
861 let mut buses = vec![
862 bus(0, BusType::Slack, 0.0),
863 bus(1, BusType::PQ, -0.6),
864 bus(2, BusType::Slack, 0.0),
865 ];
866 let lines = vec![line(0, 1, 0.2), line(1, 2, 0.2)];
867 let sol = dc_power_flow(&mut buses, &lines, &[], DcOptions::default());
868
869 assert_eq!(
870 sol.islands[0].status,
871 DcIslandStatus::SolvedMultipleReferences
872 );
873 assert!(sol.max_residual < 1e-12, "{}", sol.max_residual);
874 assert!((sol.islands[0].slack_pickup - 0.6).abs() < 1e-12);
875 // Equal reactances and equal reference angles ⇒ an even split here,
876 // but that is this fixture's symmetry, not a guarantee of the method.
877 assert!((sol.branch_p[0] - 0.3).abs() < 1e-12, "{}", sol.branch_p[0]);
878 }
879
880 /// A slack whose angle is not zero is a valid reference, and every other
881 /// angle must come out shifted by exactly that offset.
882 #[test]
883 fn nonzero_slack_angle_offsets_the_whole_island() {
884 let offset = 0.2_f64;
885 let lines = vec![line(0, 1, 0.1), line(1, 2, 0.2)];
886
887 let mut base = vec![
888 bus(0, BusType::Slack, 0.0),
889 bus(1, BusType::PQ, -0.3),
890 bus(2, BusType::PQ, -0.5),
891 ];
892 dc_power_flow(&mut base, &lines, &[], DcOptions::default());
893
894 let mut shifted = vec![
895 bus(0, BusType::Slack, 0.0),
896 bus(1, BusType::PQ, -0.3),
897 bus(2, BusType::PQ, -0.5),
898 ];
899 shifted[0].voltage_ang = offset;
900 let sol = dc_power_flow(&mut shifted, &lines, &[], DcOptions::default());
901
902 for i in 1..3 {
903 assert!(
904 (shifted[i].voltage_ang - (base[i].voltage_ang + offset)).abs() < 1e-12,
905 "bus {i}: {} vs {}",
906 shifted[i].voltage_ang,
907 base[i].voltage_ang
908 );
909 }
910 assert!(sol.max_residual < 1e-12);
911 }
912
913 /// A PGM `link` inverts to a *negative* reactance; DC must not let that
914 /// through as a negative susceptance. See the guard in `dc_branches`.
915 #[test]
916 fn link_admittance_yields_a_positive_susceptance() {
917 let t = Transformer {
918 from: 0,
919 to: 1,
920 from_status: 1,
921 to_status: 1,
922 y_series: IDEAL_CONNECTION_Y,
923 y_shunt: Complex::new(0.0, 0.0),
924 tap: Complex::new(1.0, 0.0),
925 };
926 // The hazard, spelled out: the naive route really does go negative.
927 let z = Complex::new(1.0, 0.0) / IDEAL_CONNECTION_Y;
928 assert!(
929 z.im < 0.0,
930 "fixture assumption broken: link reactance is {}",
931 z.im
932 );
933
934 let got = dc_branches(&[], std::slice::from_ref(&t), DcOptions::default());
935 assert!(got[0].b > 0.0, "link susceptance came out {}", got[0].b);
936 assert!(
937 (got[0].b - 1.0 / ideal_connection_z()).abs() < 1e-6,
938 "{}",
939 got[0].b
940 );
941 }
942
943 /// A series capacitor is legitimate and must pass through with its sign
944 /// intact — the link guard must not be a blanket `x < 0` clamp.
945 #[test]
946 fn series_capacitor_keeps_its_negative_susceptance() {
947 let lines = vec![line(0, 1, 0.2), line(0, 1, -0.1)];
948 let mut buses = vec![bus(0, BusType::Slack, 0.0), bus(1, BusType::PQ, -0.5)];
949 let sol = dc_power_flow(&mut buses, &lines, &[], DcOptions::default());
950
951 let got = dc_branches(&lines, &[], DcOptions::default());
952 assert!((got[1].b - (-10.0)).abs() < 1e-12, "{}", got[1].b);
953 assert_eq!(sol.negative_reactance_branches, vec![1]);
954 }
955
956 /// A `PV` bus's `voltage_mag` is a setpoint the AC solver holds fixed, not
957 /// a result. DC must leave it alone: overwriting it with the model's
958 /// |V| = 1 assumption silently changes the answer of any later AC solve
959 /// seeded from this state, and reports `Converged` while doing it.
960 #[test]
961 fn a_pv_bus_keeps_its_voltage_setpoint() {
962 let mut buses = vec![
963 bus(0, BusType::Slack, 0.0),
964 bus(1, BusType::PV, 0.2),
965 bus(2, BusType::PQ, -0.5),
966 ];
967 buses[0].voltage_mag = 1.06;
968 buses[1].voltage_mag = 1.04;
969 let lines = vec![line(0, 1, 0.1), line(1, 2, 0.2), line(0, 2, 0.3)];
970
971 dc_power_flow(&mut buses, &lines, &[], DcOptions::default());
972
973 assert_eq!(buses[0].voltage_mag, 1.06, "slack setpoint was overwritten");
974 assert_eq!(buses[1].voltage_mag, 1.04, "PV setpoint was overwritten");
975 // The PQ bus has no specified magnitude, so it takes the DC assumption.
976 assert_eq!(buses[2].voltage_mag, 1.0);
977 // A PV bus is still a solved unknown in *angle*, which is the whole
978 // point of including it.
979 assert!(buses[1].voltage_ang != 0.0);
980 }
981
982 /// Components must come back in the same order and shape
983 /// `network::connected_components` promises: members ascending,
984 /// components ordered by lowest member.
985 #[test]
986 fn components_match_the_documented_ordering_contract() {
987 // Deliberately wire the higher-numbered buses together first.
988 let branches = vec![
989 DcBranch {
990 index: 0,
991 from: 3,
992 to: 1,
993 b: 1.0,
994 shift: 0.0,
995 },
996 DcBranch {
997 index: 1,
998 from: 4,
999 to: 2,
1000 b: 1.0,
1001 shift: 0.0,
1002 },
1003 ];
1004 assert_eq!(
1005 dc_components(5, &branches),
1006 vec![vec![0], vec![1, 3], vec![2, 4]]
1007 );
1008 }
1009}