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}