Skip to main content

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}