gridoxide_core/bde.rs
1//! Block-diagonal embedding: solve B scenarios as one sparse system.
2//!
3//! Every scenario in a batch shares one topology, so every scenario's Jacobian
4//! shares one sparsity pattern. Rather than B separate sparse solves, stack
5//! them:
6//!
7//! ```text
8//! ┌ J₁ ┐ ┌ Δx₁ ┐ ┌ f₁ ┐
9//! │ J₂ │ │ Δx₂ │ │ f₂ │
10//! J = │ ⋱ │ , │ ⋮ │ = │ ⋮ │
11//! └ J_B ┘ └ Δx_B┘ └ f_B┘
12//! ```
13//!
14//! This is the design a GPU path would adopt, and the reason it matters is
15//! that **it needs no batched solver API**. You hand the library
16//! one ordinary sparse matrix. That is what makes the AMD path viable even
17//! though rocSOLVER's `csrrf_*` refactorization routines are not batched, and
18//! it is why the same code can later target cuDSS, rocSOLVER, or anything else
19//! that factors a sparse matrix.
20//!
21//! The equivalence is exact, not approximate:
22//!
23//! - **No fill crosses a block.** Fill at `(i, j)` needs a path `i → k → j`
24//! through eliminated vertices; the graph is B disconnected components, so
25//! no such path exists between blocks. Total fill is the sum of per-block
26//! fills.
27//! - **Pivoting stays inside its block.** A column in block `s` has nonzeros
28//! only in block `s`'s rows, so partial-pivot search can never reach another
29//! scenario.
30//! - **The elimination tree is a forest** of B independent trees, which is
31//! precisely the wide parallelism a GPU refactorization needs — and the
32//! reason a single 18k-unknown Jacobian cannot fill a GPU but 256 stacked
33//! ones can.
34//!
35//! This module exists to prove all of that on **real sparse code, on the CPU,
36//! before any GPU spend**. `scripts/bench/jax_oracle.py` already established it
37//! with dense linear algebra; this extends the result to the actual sparse
38//! backends the GPU path will mirror.
39//!
40//! It is deliberately *not* faster than [`crate::batch::BatchSolver`] on a CPU
41//! — one big factorization beats B small ones only when the hardware wants
42//! wide independent work, which is a GPU property. Treat this as an
43//! architecture validator and the host-side half of a GPU path.
44
45use crate::batch::Scenario;
46use crate::jacobian::JacobianPattern;
47use crate::network::{YBusSparse, effective_injection, linear_initial_guess, power_injections};
48use crate::solver::{LinearSolver, SolveStats, SolveStatus};
49use crate::types::{Bus, BusType};
50
51/// The stacked sparsity pattern for `n_scenarios` copies of one topology's
52/// Jacobian, plus the per-block recipe needed to refill it.
53pub struct BlockDiagonal {
54 block: JacobianPattern,
55 n_scenarios: usize,
56 /// Unknowns per scenario — the block stride.
57 block_size: usize,
58 rows: Vec<u32>,
59 cols: Vec<u32>,
60}
61
62impl BlockDiagonal {
63 /// Derives the stacked pattern by replicating a single-scenario
64 /// [`JacobianPattern`] `n_scenarios` times, offsetting block `s`'s rows
65 /// and columns by `s * block_size`.
66 ///
67 /// All scenarios must share `buses`' bus-type assignment and `ybus` —
68 /// that shared pattern is the entire premise. `batch::Scenario` cannot
69 /// override `bus_type` precisely so this stays true.
70 pub fn analyze(buses: &[Bus], ybus: &YBusSparse, n_scenarios: usize) -> Self {
71 let block = JacobianPattern::analyze(buses, ybus);
72 let block_size = block.n_unknowns;
73 let nnz = block.len();
74
75 let mut rows = Vec::with_capacity(nnz * n_scenarios);
76 let mut cols = Vec::with_capacity(nnz * n_scenarios);
77 for s in 0..n_scenarios {
78 let off = (s * block_size) as u32;
79 rows.extend(block.rows().iter().map(|&r| r + off));
80 cols.extend(block.cols().iter().map(|&c| c + off));
81 }
82
83 Self {
84 block,
85 n_scenarios,
86 block_size,
87 rows,
88 cols,
89 }
90 }
91
92 /// Total unknowns across the batch — `n_scenarios * block_size`.
93 pub fn n_unknowns(&self) -> usize {
94 self.n_scenarios * self.block_size
95 }
96
97 /// Unknowns per scenario.
98 pub fn block_size(&self) -> usize {
99 self.block_size
100 }
101
102 /// Nonzeros in the stacked matrix.
103 pub fn len(&self) -> usize {
104 self.rows.len()
105 }
106
107 pub fn is_empty(&self) -> bool {
108 self.rows.is_empty()
109 }
110
111 /// The stacked pattern as `(row, col, value)` triplets — the shape every
112 /// [`LinearSolver`] consumes for its one-time symbolic analysis.
113 pub fn to_triplets(&self, values: &[f64]) -> Vec<(usize, usize, f64)> {
114 self.rows
115 .iter()
116 .zip(&self.cols)
117 .zip(values)
118 .map(|((&r, &c), &v)| (r as usize, c as usize, v))
119 .collect()
120 }
121
122 /// Fills the stacked value array from each scenario's own state.
123 ///
124 /// `active[s] == false` writes an identity block instead of scenario
125 /// `s`'s Jacobian (see [`JacobianPattern::fill_identity_into`]), which —
126 /// paired with a zero right-hand side — makes that scenario's update
127 /// exactly zero while leaving the sparsity pattern untouched.
128 pub fn fill(
129 &self,
130 states: &[Vec<Bus>],
131 p_calc: &[Vec<f64>],
132 q_calc: &[Vec<f64>],
133 active: &[bool],
134 values: &mut Vec<f64>,
135 ) {
136 values.clear();
137 for s in 0..self.n_scenarios {
138 if active[s] {
139 self.block
140 .fill_into(&states[s], &p_calc[s], &q_calc[s], values);
141 } else {
142 self.block.fill_identity_into(values);
143 }
144 }
145 }
146}
147
148/// One scenario's outcome from [`solve_batch_block_diagonal`].
149pub struct BdeScenarioResult {
150 pub buses: Vec<Bus>,
151 pub stats: SolveStats,
152}
153
154/// Runs Newton-Raphson on **all** scenarios at once through a single stacked
155/// sparse factorization per iteration, with per-scenario convergence masking.
156///
157/// Scenarios converge at different iteration counts and some contingency
158/// scenarios never converge at all, which makes masking a hard requirement
159/// rather than an optimization. A scenario that has converged
160/// (or gone singular, or hit `max_iter`) is masked out by writing an identity
161/// into its block — its `Δx` is then exactly zero and it stops moving, while
162/// every other scenario continues undisturbed and the stacked matrix keeps the
163/// pattern its cached symbolic factorization was built for.
164///
165/// Returns one result per scenario, in scenario order.
166///
167/// Unlike [`crate::batch::BatchSolver`] this does **not** partition islands —
168/// it is an architecture validator for the GPU path, not a production entry
169/// point. Use `BatchSolver` for real work on a CPU.
170pub fn solve_batch_block_diagonal<S: LinearSolver>(
171 buses_template: &[Bus],
172 ybus: &YBusSparse,
173 scenarios: &[Scenario],
174 tol: f64,
175 max_iter: usize,
176) -> Vec<BdeScenarioResult> {
177 let nb = scenarios.len();
178 if nb == 0 {
179 return Vec::new();
180 }
181
182 let non_slack_idx: Vec<usize> = buses_template
183 .iter()
184 .filter(|b| !matches!(b.bus_type, BusType::Slack))
185 .map(|b| b.idx)
186 .collect();
187 let pq_idx: Vec<usize> = buses_template
188 .iter()
189 .filter(|b| matches!(b.bus_type, BusType::PQ))
190 .map(|b| b.idx)
191 .collect();
192 let n_angle = non_slack_idx.len();
193 let blk = n_angle + pq_idx.len();
194
195 // Per-scenario starting state, built exactly the way `BatchSolver` builds
196 // it, so the two are comparable scenario for scenario.
197 let mut states: Vec<Vec<Bus>> = scenarios
198 .iter()
199 .map(|sc| {
200 let mut buses = buses_template.to_vec();
201 for ov in &sc.bus_overrides {
202 let b = &mut buses[ov.bus];
203 if let Some(p) = ov.p_spec {
204 b.p_spec = p;
205 }
206 if let Some(q) = ov.q_spec {
207 b.q_spec = q;
208 }
209 if let Some(vm) = ov.voltage_mag {
210 b.voltage_mag = vm;
211 }
212 }
213 linear_initial_guess(&mut buses, ybus);
214 buses
215 })
216 .collect();
217
218 let pattern = BlockDiagonal::analyze(buses_template, ybus, nb);
219 let mut cache: Option<S> = None;
220
221 let mut active = vec![true; nb];
222 let mut history: Vec<Vec<f64>> = vec![Vec::new(); nb];
223 let mut status = vec![SolveStatus::MaxIterationsReached; nb];
224
225 let mut values: Vec<f64> = Vec::with_capacity(pattern.len());
226 let mut rhs: Vec<f64> = vec![0.0; pattern.n_unknowns()];
227 let mut p_all: Vec<Vec<f64>> = vec![Vec::new(); nb];
228 let mut q_all: Vec<Vec<f64>> = vec![Vec::new(); nb];
229
230 for _ in 0..max_iter {
231 rhs.iter_mut().for_each(|v| *v = 0.0);
232
233 for s in 0..nb {
234 // Injections are recomputed even for masked scenarios: `fill`
235 // ignores them, but keeping the arrays sized and valid avoids a
236 // second code path, and the cost is bounded by how quickly
237 // scenarios drop out.
238 let (p_calc, q_calc) = power_injections(&states[s], ybus);
239 if !active[s] {
240 p_all[s] = p_calc;
241 q_all[s] = q_calc;
242 continue;
243 }
244
245 let base = s * blk;
246 let mut max_mis = 0.0f64;
247 for (r, &i) in non_slack_idx.iter().enumerate() {
248 let (p_eff, _) = effective_injection(&states[s][i]);
249 let v = p_eff - p_calc[i];
250 rhs[base + r] = v;
251 max_mis = max_mis.max(v.abs());
252 }
253 for (r, &i) in pq_idx.iter().enumerate() {
254 let (_, q_eff) = effective_injection(&states[s][i]);
255 let v = q_eff - q_calc[i];
256 rhs[base + n_angle + r] = v;
257 max_mis = max_mis.max(v.abs());
258 }
259
260 history[s].push(max_mis);
261 if max_mis < tol {
262 status[s] = SolveStatus::Converged;
263 active[s] = false;
264 // Zero this block's rhs so the masked identity yields Δx = 0.
265 rhs[base..base + blk].iter_mut().for_each(|v| *v = 0.0);
266 }
267
268 p_all[s] = p_calc;
269 q_all[s] = q_calc;
270 }
271
272 if active.iter().all(|&a| !a) {
273 break;
274 }
275
276 pattern.fill(&states, &p_all, &q_all, &active, &mut values);
277
278 if cache.is_none() {
279 cache = S::new(pattern.n_unknowns(), &pattern.to_triplets(&values));
280 }
281 let Some(system) = cache.as_mut() else {
282 for s in 0..nb {
283 if active[s] {
284 status[s] = SolveStatus::Singular;
285 }
286 }
287 break;
288 };
289 let Some(dx) = system.factor_and_solve_values(&values, &rhs) else {
290 for s in 0..nb {
291 if active[s] {
292 status[s] = SolveStatus::Singular;
293 }
294 }
295 break;
296 };
297
298 for s in 0..nb {
299 if !active[s] {
300 continue;
301 }
302 let base = s * blk;
303 for (r, &i) in non_slack_idx.iter().enumerate() {
304 states[s][i].voltage_ang += dx[base + r];
305 }
306 for (r, &i) in pq_idx.iter().enumerate() {
307 states[s][i].voltage_mag += dx[base + n_angle + r];
308 }
309 }
310 }
311
312 states
313 .into_iter()
314 .zip(history)
315 .zip(status)
316 .map(|((buses, mismatch_history), status)| BdeScenarioResult {
317 buses,
318 stats: SolveStats {
319 status,
320 mismatch_history,
321 },
322 })
323 .collect()
324}