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}