Skip to main content

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}