Skip to main content

gridoxide_core/continuation/
augmented.rs

1//! The bordered `(n+1) × (n+1)` system, and why it is the one that works.
2//!
3//! # Why not a bordered *solve*
4//!
5//! The tempting cheap route is to keep the plain `n × n` Jacobian, reuse its
6//! factorization, and recover `Δλ` by block elimination — two solves against
7//! `J` plus a scalar. It is rejected on a mathematical basis, not a performance
8//! one: `J` is *exactly* the matrix that goes singular at the nose, so the
9//! Schur complement `d − bᵀJ⁻¹c` is the quotient of two quantities that both
10//! vanish there. Every backend in this crate reports singularity only as a
11//! non-finite result vector (`sparse::RealSparseSystem::solve_values`) and none
12//! offers a condition estimate, so there is no way to detect the transition
13//! either. The augmented matrix, by contrast, stays non-singular *through* the
14//! nose.
15//!
16//! # Why it stays non-singular
17//!
18//! At a simple fold `J` has a one-dimensional null space `span(v)`, and
19//! transversality gives `−Δs ∉ range(J)`. The bordered matrix
20//!
21//! ```text
22//! [ J    -Δs ]
23//! [ bᵀ     d ]
24//! ```
25//!
26//! is then non-singular **iff `bᵀv ≠ 0`**. For [`Parametrization::PseudoArcLength`]
27//! `b = t_x`, and at the nose `t = (v, 0)`, so `bᵀv = ‖v‖² ≠ 0`. For
28//! [`Parametrization::Local`] `b = e_k` with `k = argmax|t_i| = argmax|v_i|`, so
29//! `bᵀv = v_k ≠ 0`. That is the entire reason this form exists.
30//!
31//! # The border row must stay sparse, and this is measured
32//!
33//! The λ **column** is emitted at every row regardless of whether `Δs` is zero
34//! there, so the pattern does not depend on which buses carry load. That is
35//! free: COLAMD's `dense_col` threshold trips on it, the column is ordered last,
36//! and the factors are then exactly the bordered factorization. Measured on
37//! `case1354pegase` (n = 2449), a bordered solve with a sparse border row costs
38//! **1.4×** a plain Newton solve.
39//!
40//! The border **row** is a different story, and the naive choice is a disaster.
41//! Emitting it densely — which [`Parametrization::PseudoArcLength`] genuinely
42//! needs, since its row *is* the previous tangent — costs **92×** the plain
43//! solve at the same size, while the symbolic analysis stays at 1.0×. So this is
44//! numeric fill during factorization, not a bad ordering, and no amount of
45//! reasoning about COLAMD's dense-row handling makes it go away. At 119 buses
46//! the same comparison is 1.7×, which is why the problem is invisible on small
47//! fixtures.
48//!
49//! Hence [`Parametrization::Local`] is the default: its row is a single entry
50//! `e_k`, and it carries the *same* transversality guarantee at the nose
51//! (`bᵀv = v_k ≠ 0`, because `k` is chosen as the tangent's largest component).
52//! `k` changes rarely — far from the nose the tangent is dominated by λ, so
53//! `k = n` and this degenerates to `Natural`; near the nose a voltage component
54//! takes over — and a change costs one re-analysis, the same price a bus-type
55//! change already pays.
56//!
57//! So the pattern is fixed for as long as the *continuation index* is, rather
58//! than for the whole segment. [`border_columns`] says which columns the row
59//! occupies, and `corrector::Segment` re-analyzes when they change.
60
61use crate::jacobian::JacobianPattern;
62use crate::network::YBusSparse;
63use crate::types::{Bus, BusType};
64
65/// Which equation closes the `n` power-flow equations in `n+1` unknowns.
66///
67/// All three share one sparsity pattern, so switching between them costs
68/// nothing and they can be mixed along a single trace — which the driver does:
69/// [`Natural`](Self::Natural) is what lands exactly on a target λ and what the
70/// event locator corrects with, whatever the trace's nominal choice.
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
72pub enum Parametrization {
73    /// `λ − λ_pred = 0`. Cheapest, and the only one whose continuation
74    /// parameter is meaningful to a caller — but it **cannot pass the nose**,
75    /// where λ stops being a valid parameter for the curve.
76    Natural,
77    /// `z_k − z_k_pred = 0`, with `k` the largest component of the tangent.
78    /// The classical Ajjarapu–Christy choice, and the default: it passes the
79    /// nose, and its border row is one entry rather than `n+1`. See the module
80    /// docs for why that second property decides the matter at any real size.
81    #[default]
82    Local,
83    /// `tᵀ(z − z₀) − σ = 0`. The most robust in principle — its bordering row
84    /// is non-orthogonal to the null vector by construction rather than by a
85    /// search — and what MATPOWER defaults to. **But its row is dense**, which
86    /// costs 92× a plain solve at n = 2449 against `Local`'s 1.4×. Worth having
87    /// for small networks and for cross-checking `Local`; not the default.
88    PseudoArcLength,
89}
90
91/// The Newton unknown layout, derived once per segment.
92///
93/// Reproduces `solver::newton_raphson_cached`'s ordering exactly — every
94/// non-slack bus contributes an angle unknown in bus order, then every PQ bus a
95/// magnitude unknown — because the corrector's mismatch vector and the
96/// augmented Jacobian's rows must agree with it term for term.
97pub struct Layout {
98    pub non_slack_idx: Vec<usize>,
99    pub pq_idx: Vec<usize>,
100    pub n_angle: usize,
101    pub n_unknowns: usize,
102    pub n_buses: usize,
103}
104
105impl Layout {
106    pub fn analyze(buses: &[Bus]) -> Self {
107        let non_slack_idx: Vec<usize> = buses
108            .iter()
109            .filter(|b| b.bus_type != BusType::Slack)
110            .map(|b| b.idx)
111            .collect();
112        let pq_idx: Vec<usize> = buses
113            .iter()
114            .filter(|b| b.bus_type == BusType::PQ)
115            .map(|b| b.idx)
116            .collect();
117        let n_angle = non_slack_idx.len();
118        let n_unknowns = n_angle + pq_idx.len();
119        Self {
120            non_slack_idx,
121            pq_idx,
122            n_angle,
123            n_unknowns,
124            n_buses: buses.len(),
125        }
126    }
127
128    /// Re-expresses a tangent written in `from`'s layout in this one.
129    ///
130    /// A `PV → PQ` switch grows the unknown count by one, so the tangent that
131    /// was steering the walk no longer even has the right length — but it still
132    /// carries the direction of travel, which is the part that matters. Mapping
133    /// it bus by bus (the newly-freed magnitude gets a zero component) gives the
134    /// next tangent solve a bordering row that points the right way, so the walk
135    /// continues forward across the event instead of doubling back.
136    pub fn embed(&self, from: &Layout, t: &[f64]) -> Vec<f64> {
137        let mut ang_of = vec![usize::MAX; self.n_buses];
138        for (row, &i) in from.non_slack_idx.iter().enumerate() {
139            ang_of[i] = row;
140        }
141        let mut vm_of = vec![usize::MAX; self.n_buses];
142        for (row, &i) in from.pq_idx.iter().enumerate() {
143            vm_of[i] = from.n_angle + row;
144        }
145        let mut out = vec![0.0; self.n_unknowns + 1];
146        for (row, &i) in self.non_slack_idx.iter().enumerate() {
147            if ang_of[i] != usize::MAX {
148                out[row] = t[ang_of[i]];
149            }
150        }
151        for (row, &i) in self.pq_idx.iter().enumerate() {
152            if vm_of[i] != usize::MAX {
153                out[self.n_angle + row] = t[vm_of[i]];
154            }
155        }
156        out[self.n_unknowns] = t[from.n_unknowns];
157        out
158    }
159
160    /// The value of unknown `k`, with `k == n_unknowns` meaning λ itself.
161    pub fn get(&self, buses: &[Bus], lambda: f64, k: usize) -> f64 {
162        if k < self.n_angle {
163            buses[self.non_slack_idx[k]].voltage_ang
164        } else if k < self.n_unknowns {
165            buses[self.pq_idx[k - self.n_angle]].voltage_mag
166        } else {
167            lambda
168        }
169    }
170
171    /// Adds `delta` to unknown `k`. λ is the caller's own scalar, so `k ==
172    /// n_unknowns` is handled by the caller rather than here.
173    pub fn add(&self, buses: &mut [Bus], k: usize, delta: f64) {
174        if k < self.n_angle {
175            buses[self.non_slack_idx[k]].voltage_ang += delta;
176        } else if k < self.n_unknowns {
177            buses[self.pq_idx[k - self.n_angle]].voltage_mag += delta;
178        }
179    }
180
181    /// `Δs` in Newton row order: `Δp` on every non-slack P row, `Δq` on every
182    /// PQ Q row. The same layout as the mismatch vector, which is what lets the
183    /// λ column be appended row-for-row.
184    pub fn direction_rows(&self, d_p: &[f64], d_q: &[f64]) -> Vec<f64> {
185        let mut ds = vec![0.0; self.n_unknowns];
186        for (row, &i) in self.non_slack_idx.iter().enumerate() {
187            ds[row] = d_p[i];
188        }
189        for (row, &i) in self.pq_idx.iter().enumerate() {
190            ds[self.n_angle + row] = d_q[i];
191        }
192        ds
193    }
194}
195
196/// Which columns the border row structurally occupies, for a parametrization
197/// whose continuation index is `k` in a system of `n` unknowns.
198///
199/// One entry for the two sparse parametrizations, all `n+1` for
200/// pseudo-arclength. See the module docs for why this distinction is worth
201/// making rather than always emitting the dense row.
202pub fn border_columns(param: Parametrization, k: usize, n: usize) -> Vec<usize> {
203    match param {
204        Parametrization::Natural => vec![n],
205        Parametrization::Local => vec![k],
206        Parametrization::PseudoArcLength => (0..=n).collect(),
207    }
208}
209
210/// A [`JacobianPattern`] plus the border, in one fixed emission order:
211/// the Jacobian's own entries, then the λ column top to bottom, then the
212/// border row's own columns in order.
213pub struct AugmentedPattern {
214    jac: JacobianPattern,
215    n: usize,
216    border_cols: Vec<usize>,
217}
218
219impl AugmentedPattern {
220    pub fn analyze(buses: &[Bus], ybus: &YBusSparse, border_cols: Vec<usize>) -> Self {
221        let jac = JacobianPattern::analyze(buses, ybus);
222        let n = jac.n_unknowns;
223        Self {
224            jac,
225            n,
226            border_cols,
227        }
228    }
229
230    /// The columns this pattern's border row occupies. A caller whose
231    /// parametrization now wants different ones must re-analyze.
232    pub fn border_cols(&self) -> &[usize] {
233        &self.border_cols
234    }
235
236    /// The augmented dimension, `n + 1`.
237    pub fn dim(&self) -> usize {
238        self.n + 1
239    }
240
241    pub fn len(&self) -> usize {
242        self.jac.len() + self.n + self.border_cols.len()
243    }
244
245    pub fn is_empty(&self) -> bool {
246        self.n == 0
247    }
248
249    /// `(row, col)` pairs in emission order, with the values supplied — the
250    /// shape every [`LinearSolver`](crate::solver::LinearSolver) backend
251    /// analyzes once.
252    pub fn to_triplets(&self, values: &[f64]) -> Vec<(usize, usize, f64)> {
253        let mut out = Vec::with_capacity(self.len());
254        for (k, ((&r, &c), &v)) in self
255            .jac
256            .rows()
257            .iter()
258            .zip(self.jac.cols())
259            .zip(values)
260            .enumerate()
261        {
262            let _ = k;
263            out.push((r as usize, c as usize, v));
264        }
265        let mut at = self.jac.len();
266        for r in 0..self.n {
267            out.push((r, self.n, values[at]));
268            at += 1;
269        }
270        for &c in &self.border_cols {
271            out.push((self.n, c, values[at]));
272            at += 1;
273        }
274        out
275    }
276
277    /// Refills every value at its fixed offset.
278    ///
279    /// `ds` is the direction in Newton row order (see
280    /// [`Layout::direction_rows`]) and `border` holds the row's values at
281    /// [`border_cols`](Self::border_cols), in that order.
282    /// The λ column carries `∂g/∂λ = −Δs`; getting that sign wrong is the
283    /// single easiest way to produce a curve that looks plausible and is wrong,
284    /// which is why the test suite re-checks every returned point against an
285    /// independently-built power flow.
286    pub fn fill(
287        &self,
288        buses: &[Bus],
289        p_calc: &[f64],
290        q_calc: &[f64],
291        ds: &[f64],
292        border: &[f64],
293        values: &mut Vec<f64>,
294    ) {
295        values.clear();
296        self.jac.fill_into(buses, p_calc, q_calc, values);
297        values.extend(ds.iter().map(|d| -d));
298        values.extend_from_slice(border);
299        debug_assert_eq!(values.len(), self.len());
300    }
301}
302
303/// The index of the tangent's largest component — the unknown that is locally
304/// the best continuation parameter, and the one [`Parametrization::Local`]
305/// pins.
306///
307/// Choosing the *largest* component is what makes the bordered matrix
308/// non-singular at the nose: there `b = e_k` and the tangent is the Jacobian's
309/// null vector `v`, so `bᵀv = v_k`, which is the largest entry of `v` and
310/// therefore certainly not zero.
311pub fn dominant_index(tangent: &[f64]) -> usize {
312    let mut best = 0;
313    let mut best_abs = 0.0;
314    for (i, t) in tangent.iter().enumerate() {
315        if t.abs() > best_abs {
316            best_abs = t.abs();
317            best = i;
318        }
319    }
320    best
321}