Skip to main content

gridoxide_core/
solver.rs

1use super::jacobian::JacobianPattern;
2use super::network::{
3    BusVoltages, Classified, Verdict, YBusSparse, classify, connected_components,
4    effective_injection, mark_unreferenced_islands, power_injections, power_injections_from,
5};
6use gridoxide_sparse::block_sparse::{BlockLu, BlockMatrix, BlockSymbolic};
7// Defined beside the backends it abstracts over, so that they need nothing
8// from this module; re-exported here, where the Newton loop is generic over it.
9use super::types::{Bus, BusType};
10use gridoxide_sparse::lu::{LuBackend, SparseLu};
11pub use gridoxide_sparse::sparse::LinearSolver;
12
13/// Selects which sparse-LU backend `newton_raphson_with_backend` uses to
14/// solve the Jacobian system each iteration.
15///
16/// The default is [`KluNative`](Self::KluNative): the KLU algorithm power
17/// systems are usually solved with, in Rust, with no build requirement. It
18/// solves the power-flow Jacobians gridoxide is benchmarked on about six
19/// times faster than `Scalar` (case9241pegase: 15 ms against 87), within 15%
20/// of SuiteSparse's own KLU.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum JacobianBackend {
23    /// Scalar sparse triplets solved via `faer` (`sparse::RealSparseSystem`).
24    /// The default until KLU's native port caught up with SuiteSparse's.
25    Scalar,
26    /// Experimental, opt-in: groups each bus's own (angle, magnitude)
27    /// unknowns into one 2×2 block, mirroring power-grid-model's
28    /// block-per-bus matrix structure (`block_sparse::BlockLu`). Every
29    /// non-slack bus gets a block — including `PV` buses, which have no
30    /// real Q-mismatch equation. Rather than varying block size per bus
31    /// (which `block_sparse::BlockLu` doesn't support), a `PV` bus's second
32    /// row is replaced with a dummy `ΔVmag_i = 0` equation (coefficients
33    /// `[0, 1]`, target `0`) that pins its voltage-magnitude update to zero
34    /// every iteration — mathematically equivalent to the scalar backend's
35    /// actual dimension reduction, since that row is fully decoupled from
36    /// every other unknown. See `build_jacobian_blocks`.
37    Block,
38    /// Experimental, opt-in, only compiled with the `klu` Cargo feature:
39    /// the same scalar Jacobian as `Scalar` (one `jacobian::JacobianPattern`
40    /// serves both), solved via the **vendored SuiteSparse KLU C library**,
41    /// linked over FFI (`sparse_klu::KluRealSystem`) instead of `faer`. Needs
42    /// a C compiler and `libclang` at build time. See
43    /// `docs/src/solvers/backends.md` and
44    /// `crates/gridoxide-sparse/vendor/suitesparse/PROVENANCE.md`. For the
45    /// pure-Rust reimplementation of the same algorithm, see `KluNative`.
46    #[cfg(feature = "klu")]
47    Klu,
48    /// **The default**: the same scalar Jacobian as `Scalar`, solved via a
49    /// from-scratch Rust port of the same KLU algorithm `Klu` links over FFI
50    /// — no C toolchain needed, **always compiled** (unlike `Klu`, no
51    /// feature gate). See `klu_native`'s module doc comment for scope: BTF,
52    /// AMD, row scaling, partial pivoting, Eisenstat-Liu pruning and refactor
53    /// are faithful translations.
54    #[default]
55    KluNative,
56    /// Experimental, opt-in, only compiled with the `pardiso` Cargo feature:
57    /// the same scalar Jacobian as `Scalar`, solved via **Intel oneMKL's
58    /// PARDISO** sparse direct solver, linked dynamically against a
59    /// locally-installed oneMKL (`sparse_pardiso::PardisoRealSystem`) —
60    /// needs `MKLROOT` set at build time (see
61    /// `docs/src/solvers/backends.md`). Unlike `Klu`, nothing is vendored:
62    /// MKL is
63    /// proprietary, so this only links a system install rather than
64    /// compiling any bundled source. Not built or tested in CI (no MKL on
65    /// CI runners) — local/manual-verification-only.
66    #[cfg(feature = "pardiso")]
67    Pardiso,
68}
69
70impl JacobianBackend {
71    /// The sparse LU this backend factors with. `Block` has no general sparse
72    /// LU of its own, so wherever a Jacobian is not power flow's block-per-bus
73    /// shape it falls back to `faer`, as `Scalar` does.
74    pub fn lu_backend(self) -> LuBackend {
75        match self {
76            JacobianBackend::Scalar | JacobianBackend::Block => LuBackend::Faer,
77            JacobianBackend::KluNative => LuBackend::KluNative,
78            #[cfg(feature = "klu")]
79            JacobianBackend::Klu => LuBackend::Klu,
80            #[cfg(feature = "pardiso")]
81            JacobianBackend::Pardiso => LuBackend::Pardiso,
82        }
83    }
84}
85
86/// How a Newton-Raphson solve terminated, plus the per-iteration convergence
87/// trace it used to print to stdout.
88///
89/// The solve loop is silent: at batch scale (`crate::batch::BatchSolver`)
90/// per-iteration `println!` would serialize every worker on the stdout lock
91/// and dominate the measurement. Callers that want the old progress output
92/// reconstruct it from [`mismatch_history`](Self::mismatch_history) — see
93/// `src/main.rs`, which reproduces it verbatim.
94#[derive(Clone, Debug, PartialEq)]
95pub struct SolveStats {
96    pub status: SolveStatus,
97    /// Max |mismatch| measured at the top of each iteration, in order. The
98    /// last element is the value the convergence check accepted (for
99    /// `Converged`) or rejected. Length is the iteration count — see
100    /// [`iterations`](Self::iterations).
101    pub mismatch_history: Vec<f64>,
102}
103
104impl SolveStats {
105    pub fn from_loop(status: SolveStatus, mismatch_history: Vec<f64>) -> Self {
106        Self {
107            status,
108            mismatch_history,
109        }
110    }
111
112    /// Iterations actually run. Derived from `mismatch_history` rather than
113    /// stored separately so the two can't drift apart.
114    pub fn iterations(&self) -> usize {
115        self.mismatch_history.len()
116    }
117
118    /// Max |mismatch| at the final iteration, or `0.0` if none ran.
119    pub fn final_mismatch(&self) -> f64 {
120        self.mismatch_history.last().copied().unwrap_or(0.0)
121    }
122}
123
124/// Outcome of a single Newton-Raphson solve — see [`SolveStats`], which
125/// carries this alongside the convergence trace.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum SolveStatus {
128    /// Converged within `max_iter` iterations; `buses` holds the solution.
129    Converged,
130    /// Ran `max_iter` iterations without reaching `tol`; `buses` holds
131    /// whatever the last iteration produced, not a converged solution.
132    MaxIterationsReached,
133    /// The Jacobian was singular at some iteration; `buses` holds whatever
134    /// partial state existed at that point, not a valid solution.
135    Singular,
136}
137
138/// Per-connected-component outcome of a multi-island power-flow solve (see
139/// `network::connected_components`/`classify`) — one shared Newton-Raphson
140/// call across every solvable component's unknowns at once (the Y-bus has
141/// no coupling between disconnected components, so this is mathematically
142/// equivalent, iteration for iteration, to solving each independently), with
143/// per-component status recovered afterward rather than tracked live.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum IslandStatus {
146    /// This component's own mismatch is below `tol` in the final `buses`
147    /// state, regardless of the overall multi-component solve's status.
148    Converged,
149    /// The overall solve ran out of iterations, and this component's own
150    /// mismatch is still above `tol`.
151    MaxIterationsReached,
152    /// The overall solve hit a singular Jacobian, and this component's own
153    /// mismatch is still above `tol`. Best-effort, not precise: every
154    /// backend solves one combined sparse factorization and detects
155    /// singularity via a finiteness check on the *result* vector (see
156    /// `sparse.rs`), not a specific failing pivot/row — there is no way to
157    /// attribute a singular failure to one particular component in general,
158    /// so every still-unconverged component is marked `Singular` when the
159    /// overall result is `Singular`, whether or not it was the actual cause.
160    Singular,
161    /// This component had no `Slack` bus at all — every member bus was
162    /// pinned to a fixed `V = 0`, `P = Q = 0` placeholder
163    /// (`network::mark_unreferenced_islands`) instead of being solved; its
164    /// bus values are not a power-flow result, just the marker value.
165    /// There is no principled way to fabricate a reference bus for a
166    /// genuinely sourceless island, so this is never attempted.
167    NoReferenceBus,
168    /// This component had more than one `Slack` bus. Left in the shared
169    /// solve unmodified (its non-slack buses are still live unknowns): two
170    /// independently-fixed slack voltages in one connected component is
171    /// physically over-determined, not necessarily numerically singular, so
172    /// Newton-Raphson can converge to a result that satisfies neither
173    /// slack's true power balance. This verdict is decided once, before the
174    /// solve, and is never overwritten by a numerically-convergent-looking
175    /// post-hoc mismatch check.
176    AmbiguousReferenceBus,
177}
178
179/// One connected component's power-flow outcome — see `IslandStatus`.
180#[derive(Clone, Debug)]
181pub struct IslandReport {
182    pub bus_indices: Vec<usize>,
183    /// The `Slack` bus(es) originally found in this component: empty for
184    /// `NoReferenceBus`, exactly one for a normally-solved island,
185    /// two-or-more for `AmbiguousReferenceBus`.
186    pub slack_indices: Vec<usize>,
187    pub status: IslandStatus,
188}
189
190/// Computes each still-undecided (`Solvable`-verdict) component's own P/Q
191/// mismatch against `tol`, using the same `power_injections`/
192/// `effective_injection` primitives — restricted to that component's own
193/// bus indices — that `newton_raphson_scalar_cached`'s own convergence
194/// check uses. Safe to call even after an overall `Singular` result: every
195/// backend returns `Singular` *before* applying that iteration's update (see
196/// e.g. `newton_raphson_scalar_cached`/`newton_raphson_block_cached` below),
197/// so `buses` always holds the last fully-applied, fully-finite iterate.
198fn resolve_pending(
199    pending: Vec<(Vec<usize>, Vec<usize>)>,
200    buses: &[Bus],
201    ybus: &YBusSparse,
202    overall: SolveStatus,
203    tol: f64,
204) -> Vec<IslandReport> {
205    let (p_calc, q_calc) = power_injections(buses, ybus);
206    pending
207        .into_iter()
208        .map(|(bus_indices, slack_indices)| {
209            let mut max_mis = 0.0f64;
210            for &i in &bus_indices {
211                if buses[i].bus_type == BusType::Slack {
212                    continue;
213                }
214                let (p_eff, q_eff) = effective_injection(&buses[i]);
215                max_mis = max_mis.max((p_eff - p_calc[i]).abs());
216                if buses[i].bus_type == BusType::PQ {
217                    max_mis = max_mis.max((q_eff - q_calc[i]).abs());
218                }
219            }
220            let status = if max_mis < tol {
221                IslandStatus::Converged
222            } else if overall == SolveStatus::Singular {
223                IslandStatus::Singular
224            } else {
225                IslandStatus::MaxIterationsReached
226            };
227            IslandReport {
228                bus_indices,
229                slack_indices,
230                status,
231            }
232        })
233        .collect()
234}
235
236/// Turns each component's classify-time `Verdict` into a final
237/// `IslandReport`, in the same order `classify` produced them: `NoReferenceBus`/
238/// `AmbiguousReferenceBus` verdicts become their matching `IslandStatus`
239/// immediately (never touched by the post-hoc mismatch check — see
240/// `IslandStatus::AmbiguousReferenceBus`'s doc comment for why), while
241/// `Solvable` components are resolved via `resolve_pending` against the
242/// `buses`/`ybus` state left by the shared solve.
243pub(crate) fn finish_island_reports(
244    classified: Vec<Classified>,
245    buses: &[Bus],
246    ybus: &YBusSparse,
247    overall: SolveStatus,
248    tol: f64,
249) -> Vec<IslandReport> {
250    let mut reports: Vec<Option<IslandReport>> = Vec::with_capacity(classified.len());
251    let mut pending: Vec<(usize, Vec<usize>, Vec<usize>)> = Vec::new();
252    for (pos, c) in classified.into_iter().enumerate() {
253        match c.verdict {
254            Verdict::NoReferenceBus => reports.push(Some(IslandReport {
255                bus_indices: c.bus_indices,
256                slack_indices: c.slack_indices,
257                status: IslandStatus::NoReferenceBus,
258            })),
259            Verdict::AmbiguousReferenceBus => reports.push(Some(IslandReport {
260                bus_indices: c.bus_indices,
261                slack_indices: c.slack_indices,
262                status: IslandStatus::AmbiguousReferenceBus,
263            })),
264            Verdict::Solvable => {
265                pending.push((pos, c.bus_indices, c.slack_indices));
266                reports.push(None);
267            }
268        }
269    }
270
271    let (positions, pending): (Vec<usize>, Vec<(Vec<usize>, Vec<usize>)>) = pending
272        .into_iter()
273        .map(|(pos, bus_indices, slack_indices)| (pos, (bus_indices, slack_indices)))
274        .unzip();
275    let resolved = resolve_pending(pending, buses, ybus, overall, tol);
276    for (pos, report) in positions.into_iter().zip(resolved) {
277        reports[pos] = Some(report);
278    }
279
280    reports
281        .into_iter()
282        .map(|r| r.expect("every component classified exactly once"))
283        .collect()
284}
285
286pub fn newton_raphson(
287    buses: &mut [Bus],
288    ybus: &YBusSparse,
289    tol: f64,
290    max_iter: usize,
291) -> Vec<IslandReport> {
292    newton_raphson_with_backend(buses, ybus, tol, max_iter, JacobianBackend::default())
293}
294
295/// Partitions `buses` into connected components first (`network::
296/// connected_components`/`classify`/`mark_unreferenced_islands` — see
297/// `docs/src/powerflow/multi_island.md`), so a disconnected, sourceless
298/// region can never make the *whole* Jacobian singular, then solves every
299/// component in one shared Newton-Raphson call and returns each one's own
300/// outcome. This
301/// is gridoxide's one canonical way to run a solve — [`PersistentSolver::solve`]
302/// and [`crate::outerloop::solve_with_loops`] share this exact same
303/// partitioning step, so behavior is identical regardless of which entry
304/// point a caller uses.
305pub fn newton_raphson_with_backend(
306    buses: &mut [Bus],
307    ybus: &YBusSparse,
308    tol: f64,
309    max_iter: usize,
310    backend: JacobianBackend,
311) -> Vec<IslandReport> {
312    let components = connected_components(ybus);
313    let classified = classify(buses, &components);
314    mark_unreferenced_islands(buses, &classified);
315
316    let stats = match backend {
317        JacobianBackend::Block => {
318            newton_raphson_block_cached(buses, ybus, tol, max_iter, &mut None)
319        }
320        _ => newton_raphson_cached(
321            buses,
322            ybus,
323            tol,
324            max_iter,
325            &mut SparseLu::new(backend.lu_backend()),
326            &mut None,
327        ),
328    };
329
330    finish_island_reports(classified, buses, ybus, stats.status, tol)
331}
332
333/// A Newton-Raphson solver that reuses its symbolic factorization (fill-
334/// reducing ordering, elimination-graph structure — the expensive,
335/// topology-only part of a sparse LU factorization) across repeated
336/// [`solve`](Self::solve) calls, not just across the iterations *within*
337/// one call the way [`newton_raphson_with_backend`] already does.
338///
339/// This matters for any workload that solves the *same* topology
340/// repeatedly — a time series, a batch of scenarios, contingency analysis —
341/// where re-deriving the ordering from scratch on every call is pure
342/// overhead. Measured on a 9,241-bus case, reusing analysis across repeated
343/// solves cut per-solve time by ~45% (COLAMD/AMD/BTF ordering was ~1/3 of
344/// total solve time when redone from scratch every call).
345///
346/// ```
347/// # use gridoxide_core::solver::{PersistentSolver, JacobianBackend};
348/// # use gridoxide_core::network::YBusSparse;
349/// # use gridoxide_core::types::Bus;
350/// # fn example(mut buses: Vec<Bus>, ybus: &YBusSparse) {
351/// let mut solver = PersistentSolver::new(JacobianBackend::Scalar);
352/// for _ in 0..10 {
353///     // ... update buses' p_spec/q_spec for the next scenario ...
354///     solver.solve(&mut buses, ybus, 1e-6, 20);
355/// }
356/// # }
357/// ```
358///
359/// Call [`reset`](Self::reset) (or construct a new `PersistentSolver`) if
360/// the topology itself changes between solves — a different set of lines,
361/// transformers, or bus types — since the cached ordering would otherwise
362/// silently apply to the wrong sparsity pattern. Changing only bus
363/// *values* (`p_spec`, `q_spec`, `voltage_mag`/`voltage_ang` initial guess)
364/// between calls is exactly the case this is designed for and needs no
365/// reset.
366pub struct PersistentSolver {
367    backend: JacobianBackend,
368    /// Backend-independent, so one cache serves whichever backend is
369    /// selected.
370    ///
371    /// Its *sparsity pattern* depends only on topology and bus types — but the
372    /// per-nonzero recipe does not: [`JacobianPattern`]'s entries carry the
373    /// Y-bus admittance `y` they were analyzed against, and `fill` reads it
374    /// every iteration. So this is valid only while the Y-bus **values** are
375    /// unchanged too, which is automatic for an ordinary batch (every scenario
376    /// shares one Y-bus) and is not automatic for a contingency sweep (each
377    /// outage has its own). See [`invalidate_admittances`](Self::invalidate_admittances).
378    jacobian: Option<JacobianPattern>,
379    /// The Y-bus's connected components, which depend on its structure alone
380    /// — the same thing the symbolic factorization assumes fixed until
381    /// [`reset`](Self::reset), and kept by
382    /// [`invalidate_admittances`](Self::invalidate_admittances) for the same
383    /// reason. Finding them cost 4% of a warm solve on case9241pegase.
384    components: Option<Vec<Vec<usize>>>,
385    /// The general sparse LU, for every backend but `Block`.
386    lu: SparseLu,
387    block: Option<BlockSymbolic>,
388}
389
390impl PersistentSolver {
391    pub fn new(backend: JacobianBackend) -> Self {
392        Self {
393            backend,
394            jacobian: None,
395            components: None,
396            lu: SparseLu::new(backend.lu_backend()),
397            block: None,
398        }
399    }
400
401    /// Runs Newton-Raphson to convergence (or `max_iter`), reusing cached
402    /// symbolic factorization from a previous `solve()` call on this same
403    /// `PersistentSolver` when available. See the type-level doc comment
404    /// for when a cached factorization stays valid.
405    ///
406    /// Like [`newton_raphson_with_backend`], partitions `buses` into
407    /// connected components first (`network::connected_components`/
408    /// `classify`/`mark_unreferenced_islands`) so a disconnected, sourceless
409    /// region can't make the whole Jacobian singular, and returns each
410    /// component's own outcome rather than one flat status.
411    pub fn solve(
412        &mut self,
413        buses: &mut [Bus],
414        ybus: &YBusSparse,
415        tol: f64,
416        max_iter: usize,
417    ) -> Vec<IslandReport> {
418        self.solve_with_stats(buses, ybus, tol, max_iter).0
419    }
420
421    /// Exactly [`solve`](Self::solve), additionally returning the
422    /// [`SolveStats`] the solve produced — iteration count and the
423    /// per-iteration convergence trace the loop used to print to stdout.
424    /// `solve` is a wrapper that discards them.
425    pub fn solve_with_stats(
426        &mut self,
427        buses: &mut [Bus],
428        ybus: &YBusSparse,
429        tol: f64,
430        max_iter: usize,
431    ) -> (Vec<IslandReport>, SolveStats) {
432        // Recomputed if the bus count changed, the one structural change
433        // that is cheap to notice.
434        if self
435            .components
436            .as_ref()
437            .is_none_or(|c| c.iter().map(Vec::len).sum::<usize>() != buses.len())
438        {
439            self.components = Some(connected_components(ybus));
440        }
441        let components = self.components.as_ref().expect("just populated above");
442        let classified = classify(buses, components);
443        mark_unreferenced_islands(buses, &classified);
444
445        let stats = match self.backend {
446            JacobianBackend::Block => {
447                newton_raphson_block_cached(buses, ybus, tol, max_iter, &mut self.block)
448            }
449            _ => {
450                newton_raphson_cached(buses, ybus, tol, max_iter, &mut self.lu, &mut self.jacobian)
451            }
452        };
453
454        let reports = finish_island_reports(classified, buses, ybus, stats.status, tol);
455        (reports, stats)
456    }
457
458    /// Discards any cached symbolic factorization. Call this before the
459    /// next `solve()` if the topology (not just bus values) has changed
460    /// since the last call.
461    /// Invalidates the cached Jacobian recipe while **keeping** every
462    /// backend's symbolic factorization.
463    ///
464    /// The right call when the Y-bus's *values* changed but its sparsity
465    /// pattern did not — which is exactly a branch contingency built by
466    /// [`network::build_ybus_with_outages`](crate::network::build_ybus_with_outages),
467    /// where the outaged branch keeps its structural entries at zero. The
468    /// symbolic factorization depends only on the `(row, col)` pairs
469    /// `JacobianPattern::to_triplets` emits, and those are unchanged, so it
470    /// stays valid; the entries' cached admittances do not, and must be
471    /// re-analyzed.
472    ///
473    /// Re-analysis is O(nonzeros) against the symbolic factorization's
474    /// fill-reducing ordering and elimination-tree work, which is the
475    /// expensive half — measured at ~45% of solve time on a 9,241-bus case.
476    /// Keeping that is what makes an N-1 sweep cheaper than a sequence of
477    /// independent solves.
478    ///
479    /// Use [`reset`](Self::reset) instead when the pattern itself changed:
480    /// a different bus-type assignment, or a topology whose branches were
481    /// genuinely dropped rather than zeroed.
482    pub fn invalidate_admittances(&mut self) {
483        self.jacobian = None;
484    }
485
486    pub fn reset(&mut self) {
487        self.jacobian = None;
488        self.components = None;
489        self.lu.reset();
490        self.block = None;
491    }
492}
493
494/// The one Newton-Raphson loop, for every general sparse-LU backend: the
495/// [`SparseLu`] carries the choice. There were once four copies of this
496/// function, one per backend, and then one generic over [`LinearSolver`]
497/// with a `match` at every call site. `newton_raphson_block_cached` stays separate: it has a genuinely
498/// different matrix type and a `[f64; 2]`-shaped mismatch vector.
499///
500/// The cached symbolic factorization lives in a caller-supplied `lu`
501/// rather than a function-local variable — what lets `PersistentSolver`
502/// reuse it across repeated `solve()` calls on unchanged topology, not just
503/// across the iterations within a single call. `newton_raphson_with_backend`
504/// calls this directly too, with a function-local, throwaway cache, since it
505/// needs the returned status to build its own `IslandReport`s.
506///
507/// Returns [`SolveStats`] rather than printing progress. Per-iteration
508/// `println!` from inside this loop would serialize every worker of a
509/// [`crate::batch::BatchSolver`] run on the stdout lock; `src/main.rs`
510/// reproduces the old console output from `SolveStats::mismatch_history`
511/// instead.
512fn newton_raphson_cached(
513    buses: &mut [Bus],
514    ybus: &YBusSparse,
515    tol: f64,
516    max_iter: usize,
517    lu: &mut SparseLu,
518    pattern: &mut Option<JacobianPattern>,
519) -> SolveStats {
520    // Every non-slack bus carries an angle unknown; PQ buses additionally
521    // carry a voltage-magnitude unknown.
522    let non_slack_idx: Vec<usize> = buses
523        .iter()
524        .filter(|b| !matches!(b.bus_type, BusType::Slack))
525        .map(|b| b.idx)
526        .collect();
527    let pq_idx: Vec<usize> = buses
528        .iter()
529        .filter(|b| matches!(b.bus_type, BusType::PQ))
530        .map(|b| b.idx)
531        .collect();
532
533    let n_unknowns = non_slack_idx.len() + pq_idx.len();
534
535    // Both the Jacobian's sparsity pattern and the per-nonzero recipe depend
536    // only on the topology and the bus-type assignment, so they are derived
537    // once here (or reused from a previous solve) rather than per iteration.
538    if pattern.is_none() {
539        let mut fresh = JacobianPattern::analyze(buses, ybus);
540        // A factorization kept across `invalidate_admittances` still takes
541        // its values in the order it adopted below.
542        if let Some(order) = lu.storage_order() {
543            fresh.permute(order);
544        }
545        *pattern = Some(fresh);
546    }
547    let pattern = pattern.as_mut().expect("just populated above");
548
549    // Refilled in place every iteration — the allocation happens once.
550    let mut values: Vec<f64> = Vec::with_capacity(pattern.len());
551    let mut history: Vec<f64> = Vec::with_capacity(max_iter);
552
553    // The Jacobian's sparsity *pattern* is fixed across iterations (same
554    // bus topology every time, only numeric values change), so the
555    // symbolic factorization (ordering + fill-in) is computed once here and
556    // reused via `factor_and_solve` for every iteration's numeric-only
557    // refactorization, mirroring PGM's own prefactorization-reuse approach.
558    let mut voltages = BusVoltages::default();
559    for _ in 0..max_iter {
560        // compute injections, from voltages the Jacobian fill reads too
561        voltages.update(buses);
562        let (p_calc, q_calc) = power_injections_from(&voltages, ybus);
563
564        // Build mismatch vector
565        let mut mismatch = vec![0.0; n_unknowns];
566        let mut mis_idx = 0;
567        for &i in &non_slack_idx {
568            // P mismatch for PV and PQ buses
569            let (p_eff, _) = effective_injection(&buses[i]);
570            mismatch[mis_idx] = p_eff - p_calc[i];
571            mis_idx += 1;
572        }
573        for &i in &pq_idx {
574            // Q mismatch for PQ buses
575            let (_, q_eff) = effective_injection(&buses[i]);
576            mismatch[mis_idx] = q_eff - q_calc[i];
577            mis_idx += 1;
578        }
579
580        let max_mis = mismatch.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
581        history.push(max_mis);
582        if max_mis < tol {
583            return SolveStats::from_loop(SolveStatus::Converged, history);
584        }
585
586        // Refill the Jacobian's values at their precomputed offsets.
587        pattern.fill_with(&voltages, &p_calc, &q_calc, &mut values);
588
589        // The backend's symbolic analysis needs the `(row, col)` pairs, but
590        // only once per topology — every subsequent iteration hands over
591        // nothing but the values.
592        if !lu.is_analyzed() {
593            if !lu.analyze(n_unknowns, &pattern.to_triplets(&values)) {
594                return SolveStats::from_loop(SolveStatus::Singular, history);
595            }
596            // Where the backend has one layout (both KLUs), fill straight into
597            // it from here on rather than have it copy every iteration.
598            if let Some(order) = lu.adopt_storage_order() {
599                pattern.permute(&order);
600                values = order.iter().map(|&i| values[i]).collect();
601            }
602        }
603        let Some(dx) = lu.factor_and_solve_values(&values, &mismatch) else {
604            return SolveStats::from_loop(SolveStatus::Singular, history);
605        };
606
607        // Update state
608        let mut dx_idx = 0;
609        for &i in &non_slack_idx {
610            // update voltage angles
611            buses[i].voltage_ang += dx[dx_idx];
612            dx_idx += 1;
613        }
614        for &i in &pq_idx {
615            // update voltage magnitudes
616            buses[i].voltage_mag += dx[dx_idx];
617            dx_idx += 1;
618        }
619    }
620
621    SolveStats::from_loop(SolveStatus::MaxIterationsReached, history)
622}
623
624/// Experimental block-per-bus Newton-Raphson, backed by
625/// `block_sparse::BlockLu`. See `JacobianBackend::Block`'s doc comment for
626/// scope. Like `newton_raphson_scalar_cached`'s `RealSparseSystem` reuse,
627/// the `colamd` ordering and elimination-graph reachability
628/// (`BlockSymbolic`) are computed once and reused via `BlockLu::refactor`
629/// for cheap numeric-only refactorization on every subsequent iteration.
630/// See `newton_raphson_scalar_cached` — same caller-supplied-cache pattern,
631/// for the `Block` backend.
632fn newton_raphson_block_cached(
633    buses: &mut [Bus],
634    ybus: &YBusSparse,
635    tol: f64,
636    max_iter: usize,
637    symbolic: &mut Option<BlockSymbolic>,
638) -> SolveStats {
639    let non_slack_idx: Vec<usize> = buses
640        .iter()
641        .filter(|b| !matches!(b.bus_type, BusType::Slack))
642        .map(|b| b.idx)
643        .collect();
644
645    let mut block_pos: Vec<Option<usize>> = vec![None; buses.len()];
646    for (pos, &i) in non_slack_idx.iter().enumerate() {
647        block_pos[i] = Some(pos);
648    }
649
650    let mut history: Vec<f64> = Vec::with_capacity(max_iter);
651
652    for _ in 0..max_iter {
653        let (p_calc, q_calc) = power_injections(buses, ybus);
654
655        // PV buses get a dummy `ΔVmag = 0` target in the mismatch vector's
656        // second slot instead of a real Q mismatch — see
657        // `JacobianBackend::Block`'s doc comment. It's always exactly 0, so
658        // it never affects the `max_mis` convergence check below (matching
659        // the scalar backend, which has no Q-mismatch entry for PV buses at
660        // all).
661        let mismatch: Vec<[f64; 2]> = non_slack_idx
662            .iter()
663            .map(|&i| {
664                let (p_eff, q_eff) = effective_injection(&buses[i]);
665                let q_mismatch = match buses[i].bus_type {
666                    BusType::PV => 0.0,
667                    _ => q_eff - q_calc[i],
668                };
669                [p_eff - p_calc[i], q_mismatch]
670            })
671            .collect();
672
673        let max_mis = mismatch
674            .iter()
675            .flatten()
676            .fold(0.0f64, |a, &b| a.max(b.abs()));
677        history.push(max_mis);
678        if max_mis < tol {
679            return SolveStats::from_loop(SolveStatus::Converged, history);
680        }
681
682        let blocks =
683            build_jacobian_blocks(buses, ybus, &non_slack_idx, &block_pos, &p_calc, &q_calc)
684                .finish();
685        if symbolic.is_none() {
686            *symbolic = Some(BlockSymbolic::analyze(&blocks));
687        }
688        let Some(lu) = BlockLu::refactor(symbolic.as_ref().unwrap(), &blocks) else {
689            return SolveStats::from_loop(SolveStatus::Singular, history);
690        };
691        let dx = lu.solve(&mismatch);
692
693        for (pos, &i) in non_slack_idx.iter().enumerate() {
694            buses[i].voltage_ang += dx[pos][0];
695            buses[i].voltage_mag += dx[pos][1];
696        }
697    }
698
699    SolveStats::from_loop(SolveStatus::MaxIterationsReached, history)
700}
701
702/// Assembles the same H/N/M/L formulas as the scalar Jacobian
703/// (`jacobian::JacobianPattern`), but as one 2×2 block per (bus, bus) pair —
704/// `[[H, N], [M, L]]` on the diagonal, `[[H_ik, N_ik], [M_ik, L_ik]]`
705/// off-diagonal — instead of four separate scalar triplets.
706///
707/// A `PV` row bus has no real Q-mismatch equation, so its block's second row
708/// is replaced with the dummy `ΔVmag_i = 0` equation instead: `[0, 1]` on
709/// the diagonal block (pins this bus's own magnitude update to zero) and
710/// `[0, 0]` on every off-diagonal block in that row (so the dummy equation
711/// stays fully decoupled from every other bus's unknowns — required for it
712/// to pin exactly `0`, not something entangled with the rest of the solve).
713/// The H/N formulas themselves (first row) are unaffected — they're already
714/// correct for any non-slack bus, PV or PQ, matching
715/// the scalar Jacobian's H/N block.
716fn build_jacobian_blocks(
717    buses: &[Bus],
718    ybus: &YBusSparse,
719    non_slack_idx: &[usize],
720    block_pos: &[Option<usize>],
721    p_calc: &[f64],
722    q_calc: &[f64],
723) -> BlockMatrix {
724    let vm: Vec<f64> = buses.iter().map(|b| b.voltage_mag).collect();
725    let va: Vec<f64> = buses.iter().map(|b| b.voltage_ang).collect();
726    let mut blocks = BlockMatrix::new(non_slack_idx.len());
727
728    for (row, &i) in non_slack_idx.iter().enumerate() {
729        let is_pv = matches!(buses[i].bus_type, BusType::PV);
730        for &(k, y_ik) in ybus.row(i) {
731            if k == i {
732                let h_ii = -q_calc[i] - vm[i].powi(2) * y_ik.im;
733                let n_ii = p_calc[i] / vm[i] + vm[i] * y_ik.re;
734                let (m_ii, l_ii) = if is_pv {
735                    (0.0, 1.0)
736                } else {
737                    (
738                        p_calc[i] - vm[i].powi(2) * y_ik.re,
739                        q_calc[i] / vm[i] - vm[i] * y_ik.im,
740                    )
741                };
742                blocks.add(row, row, [[h_ii, n_ii], [m_ii, l_ii]]);
743                continue;
744            }
745            let Some(col) = block_pos[k] else { continue };
746            let angle_ik = va[i] - va[k];
747            let h_ik = vm[i] * vm[k] * (y_ik.re * angle_ik.sin() - y_ik.im * angle_ik.cos());
748            let n_ik = vm[i] * (y_ik.re * angle_ik.cos() + y_ik.im * angle_ik.sin());
749            let (m_ik, l_ik) = if is_pv {
750                (0.0, 0.0)
751            } else {
752                (
753                    -vm[i] * vm[k] * (y_ik.re * angle_ik.cos() + y_ik.im * angle_ik.sin()),
754                    vm[i] * (y_ik.re * angle_ik.sin() - y_ik.im * angle_ik.cos()),
755                )
756            };
757            blocks.add(row, col, [[h_ik, n_ik], [m_ik, l_ik]]);
758        }
759    }
760
761    blocks
762}