Skip to main content

powerio_matrix/matrix/
sensitivity.rs

1//! DC sensitivity matrices.
2//!
3//! PTDF maps nodal injections to branch flows (`f = PTDF · p`); LODF maps a
4//! branch outage to the flow it redistributes onto the others. Both come from
5//! the reference grounded DC bus susceptance matrix
6//! `ABA = ground_with(L, refs)`: one row/column removed per reference bus.
7//! Every builder routes through the same solver selection: a dense Cholesky
8//! (with dense Gaussian elimination as the nonsingular indefinite fallback)
9//! below the `Auto` ceilings, and a sparse Cholesky factored once and reused
10//! across every right hand side above them. Disconnected networks with one
11//! reference per island are supported.
12//! Several references in one island are fixed angle buses; this is not a
13//! participation factor based distributed slack model.
14
15// Dense linear algebra: indexed triangular-solve loops and the `.iter()`
16// sparse traversal read clearer than the iterator rewrites clippy suggests.
17#![allow(clippy::needless_range_loop, clippy::explicit_iter_loop)]
18
19use sprs::CsMat;
20
21use crate::indexed::IndexedNetwork;
22use crate::matrix::laplacian::{Grounding, calc_weighted_laplacian, ground_with};
23use crate::matrix::triplet::CooBuilder;
24use crate::matrix::{
25    BranchSusceptanceFormula, BuildOptions, IncidenceParts, build_incidence,
26    calc_solver_branch_flow_matrix,
27};
28use crate::{Error, Result};
29
30/// Entries below this magnitude are dropped from the emitted sparse matrices.
31const PRUNE: f64 = 1e-12;
32/// Right hand sides per sparse block solve. Bounds the block buffer to
33/// `nr × 32` doubles while amortizing each triangular sweep across columns.
34const SPARSE_SOLVE_BLOCK: usize = 32;
35/// Reduced-dimension ceiling for the `Auto` dense path. Against the retired
36/// conjugate gradient solver the dense crossover sat in the thousands; against
37/// a sparse direct factorization, whose fill stays near linear on network
38/// graphs, the dense path wins only where its constant factors do, on small
39/// cases.
40const DEFAULT_AUTO_DENSE_THRESHOLD: usize = 512;
41
42/// Memory ceiling for the `Auto` dense path. The dimension alone does not
43/// bound the cost: the dense path also materializes an m x n PTDF and an
44/// m x m LODF, so a case with few buses and many parallel branches could ask
45/// for tens of GB while passing any nr test.
46const AUTO_DENSE_MEMORY_BUDGET: usize = 2 << 30;
47
48/// Peak bytes the dense path holds: the reduced Laplacian and its packed
49/// factorization (one and a half nr x nr buffers alive together, rounded up),
50/// plus the dense PTDF and the LODF built from it.
51fn dense_footprint_bytes(reduced_dimension: usize, branches: usize, buses: usize) -> usize {
52    let f = size_of::<f64>();
53    let sq = |a: usize, b: usize| a.saturating_mul(b).saturating_mul(f);
54    sq(reduced_dimension, reduced_dimension)
55        .saturating_mul(2)
56        .saturating_add(sq(branches, buses))
57        .saturating_add(sq(branches, branches))
58}
59const LODF_ISLAND_TOLERANCE: f64 = 1e-9;
60
61/// Solver selection for option based DC sensitivity builds.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
63#[serde(rename_all = "snake_case")]
64#[non_exhaustive]
65pub enum SensitivitySolver {
66    /// Dense below [`SensitivityOptions::auto_dense_threshold`], sparse above it.
67    #[default]
68    Auto,
69    /// Dense grounded factorization. Handles nonsingular indefinite cases.
70    Dense,
71    /// Sparse Cholesky, factored once and reused across every right hand side.
72    Sparse,
73}
74
75/// Solver path actually used for a sensitivity build.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "snake_case")]
78#[non_exhaustive]
79pub enum SensitivitySolverPath {
80    DenseCholesky,
81    DenseInverse,
82    SparseCholesky,
83}
84
85impl SensitivitySolverPath {
86    #[inline]
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Self::DenseCholesky => "dense_cholesky",
90            Self::DenseInverse => "dense_inverse",
91            Self::SparseCholesky => "sparse_cholesky",
92        }
93    }
94}
95
96/// Options for PTDF/LODF builders that expose solver choice and output pruning.
97#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
98pub struct SensitivityOptions {
99    /// Formula used to calculate each DC branch susceptance.
100    pub formula: BranchSusceptanceFormula,
101    /// Solver selection policy.
102    pub solver: SensitivitySolver,
103    /// Entries with absolute value at or below this value are omitted from the
104    /// returned sparse matrices. LODF diagonal entries are structural and kept.
105    pub drop_tolerance: f64,
106    /// Reduced dimension above which [`SensitivitySolver::Auto`] selects the
107    /// sparse path.
108    pub auto_dense_threshold: usize,
109}
110
111impl Default for SensitivityOptions {
112    fn default() -> Self {
113        Self {
114            formula: BranchSusceptanceFormula::default(),
115            solver: SensitivitySolver::Auto,
116            drop_tolerance: PRUNE,
117            auto_dense_threshold: DEFAULT_AUTO_DENSE_THRESHOLD,
118        }
119    }
120}
121
122impl SensitivityOptions {
123    fn validate(&self) -> Result<()> {
124        if !self.drop_tolerance.is_finite() || self.drop_tolerance < 0.0 {
125            return Err(Error::InvalidSensitivityOptions {
126                reason: format!(
127                    "drop_tolerance must be finite and nonnegative, got {}",
128                    self.drop_tolerance
129                ),
130            });
131        }
132        Ok(())
133    }
134
135    /// Return the concrete solver selected for a reduced grounded dimension,
136    /// assuming a square problem. Prefer
137    /// [`Self::select_solver_for_shape`], which also sees the branch count
138    /// the dense PTDF and LODF are sized by.
139    pub fn select_solver_for_reduced_dimension(
140        &self,
141        reduced_dimension: usize,
142    ) -> SensitivitySolver {
143        self.select_solver_for_shape(reduced_dimension, reduced_dimension, reduced_dimension)
144    }
145
146    /// Return the concrete solver selected for a problem shape. `Auto` takes
147    /// the dense path while both the reduced dimension and the predicted
148    /// dense footprint stay within their ceilings, so a wide case (few buses,
149    /// many branches) no longer picks a path that would ask for tens of GB.
150    pub fn select_solver_for_shape(
151        &self,
152        reduced_dimension: usize,
153        branches: usize,
154        buses: usize,
155    ) -> SensitivitySolver {
156        match self.solver {
157            SensitivitySolver::Auto => {
158                let fits = reduced_dimension <= self.auto_dense_threshold
159                    && dense_footprint_bytes(reduced_dimension, branches, buses)
160                        <= AUTO_DENSE_MEMORY_BUDGET;
161                if fits {
162                    SensitivitySolver::Dense
163                } else {
164                    SensitivitySolver::Sparse
165                }
166            }
167            other => other,
168        }
169    }
170}
171
172/// PTDF/LODF matrices plus metadata for serialized outputs.
173#[derive(Debug, Clone)]
174pub struct SensitivityMatrices {
175    pub ptdf: CsMat<f64>,
176    pub lodf: CsMat<f64>,
177    pub metadata: SensitivityMetadata,
178}
179
180/// Metadata describing a sensitivity build.
181#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
182pub struct SensitivityMetadata {
183    pub requested_solver: SensitivitySolver,
184    pub solver_path: SensitivitySolverPath,
185    pub drop_tolerance: f64,
186    pub auto_dense_threshold: usize,
187    pub reduced_dimension: usize,
188    pub ptdf: SensitivityMatrixMetadata,
189    pub lodf: SensitivityMatrixMetadata,
190}
191
192/// Shape and pruning metadata for one sensitivity matrix.
193#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
194pub struct SensitivityMatrixMetadata {
195    pub rows: usize,
196    pub cols: usize,
197    pub nnz: usize,
198    pub dropped_entries: usize,
199}
200
201/// PTDF (`m × n`): branch flows from nodal injections, `f = PTDF · p`. Every
202/// reference bus column is zero. The DC bus susceptance matrix is grounded at
203/// the whole reference set (`reference_bus_indices`), one row/column per slack.
204/// One reference per island handles disconnected networks; several references
205/// within one island fixes all of those bus angles to zero.
206pub fn calc_ptdf(case: &IndexedNetwork, formula: BranchSusceptanceFormula) -> Result<CsMat<f64>> {
207    let options = SensitivityOptions {
208        formula,
209        ..SensitivityOptions::default()
210    };
211    Ok(build_parts(case, &options, Want::Ptdf)?.into_ptdf().0)
212}
213
214/// LODF (`m × m`): pre-outage flow on branch `k` redistributes onto branch `l`
215/// with factor `LODF[l, k]`. Diagonal is `−1`. A branch whose outage islands
216/// the network (denominator `≈ 0`) gets a zero column.
217pub fn calc_lodf(case: &IndexedNetwork, formula: BranchSusceptanceFormula) -> Result<CsMat<f64>> {
218    let options = SensitivityOptions {
219        formula,
220        ..SensitivityOptions::default()
221    };
222    Ok(build_parts(case, &options, Want::Lodf)?.into_lodf().0)
223}
224
225/// Both DC sensitivity matrices `(PTDF, LODF)` from one DC bus susceptance
226/// matrix factorization. When a caller needs both for the same case (the
227/// `sensitivities` bundle), this factors the grounded DC bus susceptance
228/// matrix once instead of paying the factorization twice across separate
229/// [`calc_ptdf`]/[`calc_lodf`] calls.
230pub fn calc_ptdf_lodf(
231    case: &IndexedNetwork,
232    formula: BranchSusceptanceFormula,
233) -> Result<(CsMat<f64>, CsMat<f64>)> {
234    let options = SensitivityOptions {
235        formula,
236        ..SensitivityOptions::default()
237    };
238    let ((ptdf, _), (lodf, _)) = build_parts(case, &options, Want::Both)?.into_both();
239    Ok((ptdf, lodf))
240}
241
242/// PTDF and LODF with solver selection, drop tolerance, and output metadata.
243pub fn calc_ptdf_lodf_with_options(
244    case: &IndexedNetwork,
245    options: &SensitivityOptions,
246) -> Result<SensitivityMatrices> {
247    let parts = build_parts(case, options, Want::Both)?;
248    let solver_path = parts.solver_path;
249    let reduced_dimension = parts.reduced_dimension;
250    let ((ptdf, ptdf_dropped), (lodf, lodf_dropped)) = parts.into_both();
251
252    let metadata = sensitivity_metadata(
253        options,
254        solver_path,
255        reduced_dimension,
256        matrix_metadata(&ptdf, ptdf_dropped),
257        matrix_metadata(&lodf, lodf_dropped),
258    );
259
260    Ok(SensitivityMatrices {
261        ptdf,
262        lodf,
263        metadata,
264    })
265}
266
267/// Which matrices a [`build_parts`] call materializes. The dense path always
268/// forms the dense PTDF (the LODF is built from it); the sparse path runs only
269/// the requested halves.
270#[derive(Clone, Copy, PartialEq, Eq)]
271enum Want {
272    Ptdf,
273    Lodf,
274    Both,
275}
276
277struct BuiltParts {
278    /// Matrix plus its dropped-entry count, present when requested.
279    ptdf: Option<(CsMat<f64>, usize)>,
280    lodf: Option<(CsMat<f64>, usize)>,
281    solver_path: SensitivitySolverPath,
282    reduced_dimension: usize,
283}
284
285impl BuiltParts {
286    fn into_ptdf(self) -> (CsMat<f64>, usize) {
287        self.ptdf.expect("the requested PTDF was built")
288    }
289
290    fn into_lodf(self) -> (CsMat<f64>, usize) {
291        self.lodf.expect("the requested LODF was built")
292    }
293
294    fn into_both(self) -> ((CsMat<f64>, usize), (CsMat<f64>, usize)) {
295        (
296            self.ptdf.expect("Want::Both builds the PTDF"),
297            self.lodf.expect("Want::Both builds the LODF"),
298        )
299    }
300}
301
302/// Shared body of every in-memory builder: validate, select a solver for the
303/// problem shape, and build the requested matrices on that path.
304fn build_parts(
305    case: &IndexedNetwork,
306    options: &SensitivityOptions,
307    want: Want,
308) -> Result<BuiltParts> {
309    options.validate()?;
310    case.check_reference_coverage()?;
311    let refs = case.reference_bus_indices();
312    let inc = build_incidence(case, options.formula, &BuildOptions::default())?;
313    let g = Grounding::new(&refs);
314    let reduced_dimension = inc.n().saturating_sub(g.len());
315
316    match options.select_solver_for_shape(reduced_dimension, inc.m(), inc.n()) {
317        SensitivitySolver::Dense => {
318            let (dense, m, n, solver_path) = ptdf_dense_with_path(&inc, &refs)?;
319            let ptdf = (want != Want::Lodf)
320                .then(|| dense_to_csr_with_drop(&dense, m, n, options.drop_tolerance));
321            let lodf = (want != Want::Ptdf)
322                .then(|| lodf_from_dense_with_drop(&dense, &inc.a, m, n, options.drop_tolerance));
323            Ok(BuiltParts {
324                ptdf,
325                lodf,
326                solver_path,
327                reduced_dimension,
328            })
329        }
330        SensitivitySolver::Sparse => {
331            ensure_sparse_solver_eligible(&inc)?;
332            let lr = ground_with(&calc_weighted_laplacian(&inc.a, &inc.b), &g);
333            let llt = SparseLlt::factor(&lr)?;
334            let ptdf = if want == Want::Lodf {
335                None
336            } else {
337                let mut builder = CooBuilder::new_rect(inc.m(), inc.n());
338                let meta = sparse_ptdf_entries(&inc, &g, &llt, options, |row, col, value| {
339                    builder.add(row, col, value);
340                    Ok(())
341                })?;
342                Some((builder.finish_csr(), meta.dropped_entries))
343            };
344            let lodf = if want == Want::Ptdf {
345                None
346            } else {
347                let mut builder = CooBuilder::new(inc.m());
348                let meta = sparse_lodf_entries(&inc, &g, &llt, options, |row, col, value| {
349                    builder.add(row, col, value);
350                    Ok(())
351                })?;
352                Some((builder.finish_csr(), meta.dropped_entries))
353            };
354            Ok(BuiltParts {
355                ptdf,
356                lodf,
357                solver_path: SensitivitySolverPath::SparseCholesky,
358                reduced_dimension,
359            })
360        }
361        SensitivitySolver::Auto => unreachable!("select_solver_for_shape resolves Auto"),
362    }
363}
364
365pub(crate) fn for_each_ptdf_lodf_entry(
366    case: &IndexedNetwork,
367    options: &SensitivityOptions,
368    mut ptdf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
369    mut lodf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
370) -> Result<SensitivityMetadata> {
371    options.validate()?;
372    case.check_reference_coverage()?;
373    let refs = case.reference_bus_indices();
374    let inc = build_incidence(case, options.formula, &BuildOptions::default())?;
375    let reduced_dimension = inc.n().saturating_sub(Grounding::new(&refs).len());
376
377    let (solver_path, ptdf, lodf) =
378        match options.select_solver_for_shape(reduced_dimension, inc.m(), inc.n()) {
379            SensitivitySolver::Dense => {
380                let (dense, m, n, solver_path) = ptdf_dense_with_path(&inc, &refs)?;
381                let (ptdf, ptdf_dropped) =
382                    dense_to_csr_with_drop(&dense, m, n, options.drop_tolerance);
383                let (lodf, lodf_dropped) =
384                    lodf_from_dense_with_drop(&dense, &inc.a, m, n, options.drop_tolerance);
385                let ptdf_meta = matrix_metadata(&ptdf, ptdf_dropped);
386                let lodf_meta = matrix_metadata(&lodf, lodf_dropped);
387                for (&v, (row, col)) in &ptdf {
388                    ptdf_entry(row, col, v)?;
389                }
390                for (&v, (row, col)) in &lodf {
391                    lodf_entry(row, col, v)?;
392                }
393                (solver_path, ptdf_meta, lodf_meta)
394            }
395            SensitivitySolver::Sparse => {
396                ensure_sparse_solver_eligible(&inc)?;
397                let g = Grounding::new(&refs);
398                let lr = ground_with(&calc_weighted_laplacian(&inc.a, &inc.b), &g);
399                let llt = SparseLlt::factor(&lr)?;
400                let ptdf = sparse_ptdf_entries(&inc, &g, &llt, options, ptdf_entry)?;
401                let lodf = sparse_lodf_entries(&inc, &g, &llt, options, lodf_entry)?;
402                (SensitivitySolverPath::SparseCholesky, ptdf, lodf)
403            }
404            SensitivitySolver::Auto => {
405                unreachable!("select_solver_for_shape resolves Auto")
406            }
407        };
408
409    Ok(sensitivity_metadata(
410        options,
411        solver_path,
412        reduced_dimension,
413        ptdf,
414        lodf,
415    ))
416}
417
418fn sensitivity_metadata(
419    options: &SensitivityOptions,
420    solver_path: SensitivitySolverPath,
421    reduced_dimension: usize,
422    ptdf: SensitivityMatrixMetadata,
423    lodf: SensitivityMatrixMetadata,
424) -> SensitivityMetadata {
425    SensitivityMetadata {
426        requested_solver: options.solver,
427        solver_path,
428        drop_tolerance: options.drop_tolerance,
429        auto_dense_threshold: options.auto_dense_threshold,
430        reduced_dimension,
431        ptdf,
432        lodf,
433    }
434}
435
436fn matrix_metadata(matrix: &CsMat<f64>, dropped_entries: usize) -> SensitivityMatrixMetadata {
437    SensitivityMatrixMetadata {
438        rows: matrix.rows(),
439        cols: matrix.cols(),
440        nnz: matrix.nnz(),
441        dropped_entries,
442    }
443}
444
445fn lodf_from_dense_with_drop(
446    ptdf: &[f64],
447    a: &CsMat<f64>,
448    m: usize,
449    n: usize,
450    drop_tolerance: f64,
451) -> (CsMat<f64>, usize) {
452    // Branch endpoints (dense bus indices), recovered from the incidence.
453    let (from, to) = endpoints(a, m);
454
455    // Outaging a bridge redistributes nothing, so its column is structurally
456    // zero. The magnitude test this replaces let a near bridge at
457    // `delta(k,k) = 1 - 1.1e-9` through, amplifying its column to ~1e9 with
458    // about seven digits gone.
459    let is_bridge = bridges(&from, &to, n);
460
461    // Denominator `1 − (PTDF[k, from_k] − PTDF[k, to_k])` and the islanding
462    // decision, once per outage column instead of once per entry.
463    let mut denoms = vec![0.0; m];
464    let mut islands = vec![false; m];
465    for k in 0..m {
466        denoms[k] = 1.0 - (ptdf[k * n + from[k]] - ptdf[k * n + to[k]]);
467        islands[k] = is_bridge[k] || denoms[k].abs() < LODF_ISLAND_TOLERANCE;
468    }
469
470    // Row l of the LODF reads only PTDF row l, so walking l in the outer loop
471    // visits both matrices in row order and emits finished CSR rows directly.
472    // The old k-outer form re-read a full strided PTDF column per outage and
473    // scattered into a triplet map.
474    let mut indptr = Vec::with_capacity(m + 1);
475    indptr.push(0usize);
476    let mut indices = Vec::new();
477    let mut data = Vec::new();
478    let mut dropped = 0usize;
479    for l in 0..m {
480        let row = &ptdf[l * n..l * n + n];
481        for k in 0..m {
482            let v = if l == k {
483                -1.0
484            } else if islands[k] {
485                0.0
486            } else {
487                (row[from[k]] - row[to[k]]) / denoms[k]
488            };
489            if l == k || v.abs() > drop_tolerance {
490                indices.push(k);
491                data.push(v);
492            } else if v != 0.0 {
493                dropped += 1;
494            }
495        }
496        indptr.push(indices.len());
497    }
498    (CsMat::new((m, m), indptr, indices, data), dropped)
499}
500
501fn ptdf_dense_with_path(
502    inc: &IncidenceParts,
503    refs: &[usize],
504) -> Result<(Vec<f64>, usize, usize, SensitivitySolverPath)> {
505    let n = inc.n();
506    let m = inc.m();
507    let g = Grounding::new(refs);
508    let nr = n - g.len();
509
510    // Reduced grounded DC bus susceptance matrix: ABA_refs.
511    let lr = ground_with(&calc_weighted_laplacian(&inc.a, &inc.b), &g);
512    let dense_lr = densify(&lr, nr);
513
514    // Minv (n × n) is the reduced inverse padded with a zero row/col at every
515    // grounded bus, so each reference's PTDF column comes out zero.
516    // PTDF = (B Aᵀ) · Minv: each nonzero of the branch flow matrix scatters a scaled
517    // Minv row into a PTDF row. Grouping the flow nonzeros by reduced column
518    // up front names exactly which Minv rows the scatter reads, so the
519    // factored path below produces each of those rows once by a back-solve
520    // instead of materializing the whole inverse.
521    let flow = calc_solver_branch_flow_matrix(&inc.a, &inc.b); // m × n
522    let mut rows_used: Vec<Vec<(usize, f64)>> = vec![Vec::new(); nr];
523    for (&w, (l, c)) in flow.iter() {
524        // Minv row at a slack is 0.
525        if let Some(rc) = g.reduced(c) {
526            rows_used[rc].push((l, w));
527        }
528    }
529    // Reduced → full column map, built once. The scatter walks each Minv row
530    // contiguously and skips grounded columns instead of testing every one of
531    // them; reduced order is ascending full order, so the accumulation order
532    // is unchanged.
533    let full_of = g.full_of_reduced(n);
534    let mut ptdf = vec![0.0; m * n];
535
536    if let Some(chol) = DenseCholesky::factor(&dense_lr, nr) {
537        // The reduced matrix is symmetric, so solving `ABA_refs · x = e_rc`
538        // yields Minv row rc directly. Peak memory is the packed triangle
539        // plus one row buffer; the retired form held the dense matrix, a full
540        // triangular factor, and the explicit inverse at once.
541        drop(dense_lr);
542        let mut row = vec![0.0; nr];
543        for (rc, uses) in rows_used.iter().enumerate() {
544            if uses.is_empty() {
545                continue;
546            }
547            row.fill(0.0);
548            row[rc] = 1.0;
549            chol.solve(&mut row);
550            scatter_minv_row(&row, uses, &full_of, n, &mut ptdf);
551        }
552        return Ok((ptdf, m, n, SensitivitySolverPath::DenseCholesky));
553    }
554
555    // Nonsingular indefinite fallback: explicit inverse by Gaussian
556    // elimination with partial pivoting.
557    let rinv = dense_inverse(dense_lr, nr).ok_or(Error::SingularNetwork)?;
558    for (rc, uses) in rows_used.iter().enumerate() {
559        scatter_minv_row(&rinv[rc * nr..rc * nr + nr], uses, &full_of, n, &mut ptdf);
560    }
561    Ok((ptdf, m, n, SensitivitySolverPath::DenseInverse))
562}
563
564/// Scatter one Minv row into every PTDF row that reads it: `uses` holds the
565/// `(branch row, weight)` pairs grouped by [`ptdf_dense_with_path`].
566fn scatter_minv_row(
567    row: &[f64],
568    uses: &[(usize, f64)],
569    full_of: &[usize],
570    n: usize,
571    ptdf: &mut [f64],
572) {
573    for &(l, w) in uses {
574        let out = &mut ptdf[l * n..l * n + n];
575        for (rk, &k) in full_of.iter().enumerate() {
576            out[k] += w * row[rk];
577        }
578    }
579}
580
581/// Sparse Cholesky of the grounded DC bus susceptance matrix, factored once
582/// and reused across every right hand side.
583struct SparseLlt {
584    llt: faer::sparse::linalg::solvers::Llt<usize, f64>,
585}
586
587impl SparseLlt {
588    fn factor(lr: &CsMat<f64>) -> Result<Self> {
589        let nr = lr.rows();
590        if lr.cols() != nr {
591            return Err(Error::ShapeMismatch {
592                what: "grounded DC bus susceptance matrix columns",
593                expected: nr,
594                got: lr.cols(),
595            });
596        }
597        // An absent, nonpositive, or nonfinite diagonal is a structural
598        // problem — an ungrounded row, or NaN poisoning — reported as
599        // singularity before the numerical factorization runs, so it is not
600        // mistaken for a factorization that merely broke down.
601        for i in 0..nr {
602            let d = lr.get(i, i).copied().unwrap_or(0.0);
603            if !d.is_finite() || d <= 0.0 {
604                return Err(Error::SingularNetwork);
605            }
606        }
607        let mut triplets = Vec::with_capacity(lr.nnz());
608        for (i, row) in lr.outer_iterator().enumerate() {
609            for (j, &v) in row.iter() {
610                triplets.push(faer::sparse::Triplet::new(i, j, v));
611            }
612        }
613        let mat = faer::sparse::SparseColMat::try_new_from_triplets(nr, nr, &triplets)
614            .map_err(|_| Error::SingularNetwork)?;
615        let llt = mat
616            .as_ref()
617            .sp_cholesky(faer::Side::Lower)
618            .map_err(|_| Error::SingularNetwork)?;
619        Ok(Self { llt })
620    }
621
622    /// Solve in place, one right hand side per column.
623    fn solve_block(&self, rhs: faer::MatMut<'_, f64>) {
624        use faer::linalg::solvers::Solve;
625        self.llt.solve_in_place(rhs);
626    }
627}
628
629fn sparse_ptdf_entries(
630    inc: &IncidenceParts,
631    g: &Grounding,
632    llt: &SparseLlt,
633    options: &SensitivityOptions,
634    mut ptdf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
635) -> Result<SensitivityMatrixMetadata> {
636    let n = inc.n();
637    let m = inc.m();
638    let nr = n - g.len();
639    let (from, to) = endpoints(&inc.a, m);
640
641    let mut nnz = 0usize;
642    let mut dropped = 0usize;
643    let reduced_buses: Vec<usize> = (0..n).filter(|&bus| g.reduced(bus).is_some()).collect();
644    let mut theta = vec![0.0; nr];
645    for chunk in reduced_buses.chunks(SPARSE_SOLVE_BLOCK) {
646        let mut block = faer::Mat::<f64>::zeros(nr, chunk.len());
647        for (col, &bus) in chunk.iter().enumerate() {
648            block[(g.reduced(bus).expect("chunk holds reduced buses"), col)] = 1.0;
649        }
650        llt.solve_block(block.as_mut());
651        for (col, &bus) in chunk.iter().enumerate() {
652            for (r, slot) in theta.iter_mut().enumerate() {
653                *slot = block[(r, col)];
654            }
655            for branch in 0..m {
656                let v = branch_flow(branch, &from, &to, &inc.b, g, &theta);
657                if v.abs() > options.drop_tolerance {
658                    ptdf_entry(branch, bus, v)?;
659                    nnz += 1;
660                } else if v != 0.0 {
661                    dropped += 1;
662                }
663            }
664        }
665    }
666
667    Ok(SensitivityMatrixMetadata {
668        rows: m,
669        cols: n,
670        nnz,
671        dropped_entries: dropped,
672    })
673}
674
675fn sparse_lodf_entries(
676    inc: &IncidenceParts,
677    g: &Grounding,
678    llt: &SparseLlt,
679    options: &SensitivityOptions,
680    mut lodf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
681) -> Result<SensitivityMatrixMetadata> {
682    let n = inc.n();
683    let m = inc.m();
684    let nr = n - g.len();
685    let (from, to) = endpoints(&inc.a, m);
686
687    // Same rule as the dense path: a bridge redistributes nothing, decided on
688    // the topology rather than on how close the denominator came to zero.
689    let is_bridge = bridges(&from, &to, n);
690
691    let mut nnz = 0usize;
692    let mut dropped = 0usize;
693    let mut theta = vec![0.0; nr];
694    let mut start = 0usize;
695    while start < m {
696        let end = (start + SPARSE_SOLVE_BLOCK).min(m);
697        // A bridge's column is its diagonal alone, so only the other outages
698        // of this block get a right hand side and a solve. Every branch of a
699        // radial feeder is a bridge, which is every solve skipped here.
700        let solved: Vec<usize> = (start..end).filter(|&k| !is_bridge[k]).collect();
701        if solved.is_empty() {
702            for outage in start..end {
703                lodf_entry(outage, outage, -1.0)?;
704                nnz += 1;
705            }
706        } else {
707            let mut block = faer::Mat::<f64>::zeros(nr, solved.len());
708            for (col, &outage) in solved.iter().enumerate() {
709                if let Some(rf) = g.reduced(from[outage]) {
710                    block[(rf, col)] += 1.0;
711                }
712                if let Some(rt) = g.reduced(to[outage]) {
713                    block[(rt, col)] -= 1.0;
714                }
715            }
716            llt.solve_block(block.as_mut());
717            let mut next = 0usize;
718            for outage in start..end {
719                // Neither the solve that would have produced the rest of a
720                // bridge's column nor the scan that would emit it runs: every
721                // other entry is an exact zero, which is neither above the
722                // drop tolerance nor counted as dropped.
723                if is_bridge[outage] {
724                    lodf_entry(outage, outage, -1.0)?;
725                    nnz += 1;
726                    continue;
727                }
728                for (r, slot) in theta.iter_mut().enumerate() {
729                    *slot = block[(r, next)];
730                }
731                next += 1;
732                let denom = 1.0 - branch_flow(outage, &from, &to, &inc.b, g, &theta);
733                let islands = denom.abs() < LODF_ISLAND_TOLERANCE;
734                for branch in 0..m {
735                    let v = if branch == outage {
736                        -1.0
737                    } else if islands {
738                        0.0
739                    } else {
740                        branch_flow(branch, &from, &to, &inc.b, g, &theta) / denom
741                    };
742                    if branch == outage || v.abs() > options.drop_tolerance {
743                        lodf_entry(branch, outage, v)?;
744                        nnz += 1;
745                    } else if v != 0.0 {
746                        dropped += 1;
747                    }
748                }
749            }
750        }
751        start = end;
752    }
753
754    Ok(SensitivityMatrixMetadata {
755        rows: m,
756        cols: m,
757        nnz,
758        dropped_entries: dropped,
759    })
760}
761
762fn ensure_sparse_solver_eligible(inc: &IncidenceParts) -> Result<()> {
763    for (branch, &b) in inc.b.iter().enumerate() {
764        if !b.is_finite() || b <= 0.0 {
765            return Err(Error::InvalidSensitivityOptions {
766                reason: format!(
767                    "the sparse sensitivity solver requires positive finite branch susceptances; \
768                     branch {branch} has {b}; use solver=dense for nonsingular indefinite cases"
769                ),
770            });
771        }
772    }
773    Ok(())
774}
775
776fn branch_flow(
777    branch: usize,
778    from: &[usize],
779    to: &[usize],
780    b: &[f64],
781    g: &Grounding,
782    theta: &[f64],
783) -> f64 {
784    let theta_from = g.reduced(from[branch]).map_or(0.0, |i| theta[i]);
785    let theta_to = g.reduced(to[branch]).map_or(0.0, |i| theta[i]);
786    b[branch] * (theta_from - theta_to)
787}
788
789/// Branch endpoints from the signed incidence: `+1` row is from, `−1` is to.
790/// Which branches are bridges of the graph the columns describe: an edge whose
791/// removal disconnects its endpoints.
792///
793/// Outaging a bridge moves no flow anywhere, which is the condition the LODF
794/// denominator `1 - delta(k,k)` approaches. Deciding it topologically is exact,
795/// where a magnitude test on the denominator cannot separate a true bridge from
796/// a branch that merely carries almost everything.
797///
798/// Iterative Tarjan, O(n + m); the textbook recursion overflows the stack on a
799/// real feeder. Entry is tracked by arc rather than by parent node, so parallel
800/// branches leave neither of them a bridge.
801fn bridges(from: &[usize], to: &[usize], n: usize) -> Vec<bool> {
802    let m = from.len();
803    // Forward star: arc `2k` runs from[k] -> to[k], arc `2k+1` its reverse, so
804    // `arc ^ 1` is the other direction of the same branch and `arc / 2` is the
805    // branch itself.
806    let mut head = vec![usize::MAX; n];
807    let mut next = vec![usize::MAX; 2 * m];
808    let mut dest = vec![0usize; 2 * m];
809    for k in 0..m {
810        for (arc, tail, other) in [(2 * k, from[k], to[k]), (2 * k + 1, to[k], from[k])] {
811            dest[arc] = other;
812            next[arc] = head[tail];
813            head[tail] = arc;
814        }
815    }
816
817    let mut disc = vec![usize::MAX; n];
818    let mut low = vec![0usize; n];
819    let mut is_bridge = vec![false; m];
820    let mut timer = 0usize;
821    // (node, the arc it was entered by, the next arc to examine)
822    let mut stack: Vec<(usize, usize, usize)> = Vec::new();
823
824    for root in 0..n {
825        if disc[root] != usize::MAX {
826            continue;
827        }
828        disc[root] = timer;
829        low[root] = timer;
830        timer += 1;
831        stack.push((root, usize::MAX, head[root]));
832        while let Some(top) = stack.last_mut() {
833            let (v, in_arc) = (top.0, top.1);
834            if top.2 == usize::MAX {
835                stack.pop();
836                if let Some(parent) = stack.last_mut() {
837                    let p = parent.0;
838                    low[p] = low[p].min(low[v]);
839                    // The subtree under v reaches nothing at or above p, so
840                    // the edge into v is the only way back.
841                    if low[v] > disc[p] {
842                        is_bridge[in_arc / 2] = true;
843                    }
844                }
845                continue;
846            }
847            let arc = top.2;
848            top.2 = next[arc];
849            // Skip the branch we arrived on, but not a parallel one beside it.
850            if arc == in_arc ^ 1 {
851                continue;
852            }
853            let w = dest[arc];
854            if disc[w] == usize::MAX {
855                disc[w] = timer;
856                low[w] = timer;
857                timer += 1;
858                stack.push((w, arc, head[w]));
859            } else {
860                low[v] = low[v].min(disc[w]);
861            }
862        }
863    }
864    is_bridge
865}
866
867fn endpoints(a: &CsMat<f64>, m: usize) -> (Vec<usize>, Vec<usize>) {
868    let mut from = vec![0usize; m];
869    let mut to = vec![0usize; m];
870    for (&v, (bus, branch)) in a.iter() {
871        if v > 0.0 {
872            from[branch] = bus;
873        } else {
874            to[branch] = bus;
875        }
876    }
877    (from, to)
878}
879
880fn densify(a: &CsMat<f64>, n: usize) -> Vec<f64> {
881    let mut d = vec![0.0; n * n];
882    for (&v, (i, j)) in a.iter() {
883        d[i * n + j] = v;
884    }
885    d
886}
887
888fn dense_to_csr_with_drop(
889    dense: &[f64],
890    rows: usize,
891    cols: usize,
892    drop_tolerance: f64,
893) -> (CsMat<f64>, usize) {
894    // The scan is row major and every coordinate is unique, so the CSR
895    // arrays fill directly. Routing it through a hash map bought a dedup
896    // that cannot fire, then copied the entries into a triplet matrix and
897    // sorted them: on a 10k-bus PTDF that was several GB of intermediates
898    // and an O(nnz log nnz) sort to emit an already-ordered matrix. One
899    // counting pass sizes the buffers exactly instead.
900    let mut dropped = 0usize;
901    let mut nnz = 0usize;
902    for &v in dense {
903        if v.abs() > drop_tolerance {
904            nnz += 1;
905        } else if v != 0.0 {
906            dropped += 1;
907        }
908    }
909    let mut indptr = Vec::with_capacity(rows + 1);
910    let mut indices = Vec::with_capacity(nnz);
911    let mut data = Vec::with_capacity(nnz);
912    indptr.push(0usize);
913    for i in 0..rows {
914        for j in 0..cols {
915            let v = dense[i * cols + j];
916            if v.abs() > drop_tolerance {
917                indices.push(j);
918                data.push(v);
919            }
920        }
921        indptr.push(data.len());
922    }
923    (CsMat::new((rows, cols), indptr, indices, data), dropped)
924}
925
926/// The smallest pivot a dense factorization of `a` accepts.
927///
928/// It tracks the matrix's own scale. A fixed 1e-12 is at once too strict for a
929/// legitimately small scaled matrix and far too loose for one whose entries run
930/// to 1e12. Accepting 1e-300 instead lets a square root divide a column by
931/// 1e-150 twice and return entries near 1e300 with no error, which is the shape
932/// a near disconnected island joined by one very high impedance branch takes,
933/// and which `check_reference_coverage` passes.
934#[allow(clippy::cast_precision_loss)]
935fn dense_inverse(mut a: Vec<f64>, n: usize) -> Option<Vec<f64>> {
936    // Each elimination pivot is judged against its own column's original
937    // magnitude, so the floor measures cancellation within that column. A
938    // single floor scaled from the largest entry anywhere in the matrix
939    // refused wide but valid magnitude spreads as singular (#324).
940    let mut floors = vec![0.0; n];
941    for (c, floor) in floors.iter_mut().enumerate() {
942        let scale = (0..n).fold(0.0_f64, |mx, r| mx.max(a[r * n + c].abs()));
943        *floor = n as f64 * f64::EPSILON * scale;
944    }
945    let mut inv = vec![0.0; n * n];
946    for i in 0..n {
947        inv[i * n + i] = 1.0;
948    }
949
950    for col in 0..n {
951        let mut pivot_row = col;
952        let mut pivot_abs = a[col * n + col].abs();
953        for r in (col + 1)..n {
954            let v = a[r * n + col].abs();
955            if v > pivot_abs {
956                pivot_abs = v;
957                pivot_row = r;
958            }
959        }
960        if !pivot_abs.is_finite() || pivot_abs <= floors[col] {
961            return None;
962        }
963        if pivot_row != col {
964            swap_dense_rows(&mut a, n, pivot_row, col);
965            swap_dense_rows(&mut inv, n, pivot_row, col);
966        }
967
968        let pivot = a[col * n + col];
969        for c in 0..n {
970            a[col * n + c] /= pivot;
971            inv[col * n + c] /= pivot;
972        }
973        for r in 0..n {
974            if r == col {
975                continue;
976            }
977            let factor = a[r * n + col];
978            if factor == 0.0 {
979                continue;
980            }
981            for c in 0..n {
982                a[r * n + c] -= factor * a[col * n + c];
983                inv[r * n + c] -= factor * inv[col * n + c];
984            }
985        }
986    }
987    Some(inv)
988}
989
990fn swap_dense_rows(a: &mut [f64], n: usize, r1: usize, r2: usize) {
991    for c in 0..n {
992        a.swap(r1 * n + c, r2 * n + c);
993    }
994}
995
996/// Dense lower-triangular Cholesky `A = L Lᵀ` for a small SPD matrix, stored
997/// packed: row `i` of the lower triangle starts at `i·(i+1)/2`.
998struct DenseCholesky {
999    n: usize,
1000    l: Vec<f64>,
1001}
1002
1003impl DenseCholesky {
1004    fn factor(a: &[f64], n: usize) -> Option<Self> {
1005        let mut l = vec![0.0; n * (n + 1) / 2];
1006        for i in 0..n {
1007            // Each pivot is judged against its own original diagonal entry:
1008            // the floor measures how much elimination has cancelled it, so a
1009            // wide but valid spread of magnitudes factors while `s > 0.0`
1010            // alone would still accept a pivot ground down to noise (#324).
1011            let floor = n as f64 * f64::EPSILON * a[i * n + i].abs();
1012            let row = i * (i + 1) / 2;
1013            for j in 0..=i {
1014                let mut s = a[i * n + j];
1015                let jrow = j * (j + 1) / 2;
1016                for k in 0..j {
1017                    s -= l[row + k] * l[jrow + k];
1018                }
1019                if i == j {
1020                    // `!(s > floor)` rejects negative, too small, AND NaN
1021                    // pivots: `NaN <= x` is false, so `s <= floor` would let a
1022                    // NaN-poisoned matrix factor "successfully" into all-NaN.
1023                    // The negated comparison is the point (NaN incomparability),
1024                    // so the partial_cmp rewrite clippy suggests would obscure it.
1025                    #[allow(clippy::neg_cmp_op_on_partial_ord)]
1026                    if !(s > floor) {
1027                        return None;
1028                    }
1029                    l[row + i] = s.sqrt();
1030                } else {
1031                    l[row + j] = s / l[jrow + j];
1032                }
1033            }
1034        }
1035        Some(Self { n, l })
1036    }
1037
1038    /// Solve `A x = b` in place.
1039    fn solve(&self, b: &mut [f64]) {
1040        let n = self.n;
1041        for i in 0..n {
1042            let row = i * (i + 1) / 2;
1043            let mut s = b[i];
1044            for k in 0..i {
1045                s -= self.l[row + k] * b[k];
1046            }
1047            b[i] = s / self.l[row + i];
1048        }
1049        for i in (0..n).rev() {
1050            let mut s = b[i];
1051            for k in (i + 1)..n {
1052                s -= self.l[k * (k + 1) / 2 + i] * b[k];
1053            }
1054            b[i] = s / self.l[i * (i + 1) / 2 + i];
1055        }
1056    }
1057}
1058
1059#[cfg(test)]
1060mod bridge_tests {
1061    use super::bridges;
1062
1063    #[test]
1064    fn every_edge_of_a_path_is_a_bridge() {
1065        // 0 - 1 - 2 - 3
1066        let b = bridges(&[0, 1, 2], &[1, 2, 3], 4);
1067        assert_eq!(b, vec![true, true, true]);
1068    }
1069
1070    #[test]
1071    fn no_edge_of_a_cycle_is_a_bridge() {
1072        // 0 - 1 - 2 - 0
1073        let b = bridges(&[0, 1, 2], &[1, 2, 0], 3);
1074        assert_eq!(b, vec![false, false, false]);
1075    }
1076
1077    #[test]
1078    fn parallel_branches_leave_neither_a_bridge() {
1079        // Two circuits on the same corridor: outaging one still leaves a path,
1080        // so neither is a bridge. Tracking entry by node rather than by arc
1081        // would call both of them bridges.
1082        let b = bridges(&[0, 0], &[1, 1], 2);
1083        assert_eq!(b, vec![false, false]);
1084    }
1085
1086    #[test]
1087    fn only_the_tie_between_two_loops_is_a_bridge() {
1088        // Two triangles joined by one tie line: 0-1-2-0, tie 2-3, 3-4-5-3.
1089        let from = [0, 1, 2, 2, 3, 4, 5];
1090        let to = [1, 2, 0, 3, 4, 5, 3];
1091        let b = bridges(&from, &to, 6);
1092        assert_eq!(b, vec![false, false, false, true, false, false, false]);
1093    }
1094
1095    #[test]
1096    fn a_self_loop_is_not_a_bridge() {
1097        let b = bridges(&[0, 1], &[1, 1], 2);
1098        assert_eq!(b, vec![true, false]);
1099    }
1100
1101    #[test]
1102    fn separate_components_are_each_walked() {
1103        // 0-1 and 2-3, no tie. Both edges are bridges of their own component,
1104        // and the root loop must reach the second one.
1105        let b = bridges(&[0, 2], &[1, 3], 4);
1106        assert_eq!(b, vec![true, true]);
1107    }
1108
1109    #[test]
1110    fn a_long_path_does_not_overflow_the_stack() {
1111        // The recursion a textbook writes dies here.
1112        let n = 200_000;
1113        let from: Vec<usize> = (0..n - 1).collect();
1114        let to: Vec<usize> = (1..n).collect();
1115        let b = bridges(&from, &to, n);
1116        assert_eq!(b.len(), n - 1);
1117        assert!(b.iter().all(|&x| x));
1118    }
1119}
1120
1121#[cfg(test)]
1122mod pivot_tests {
1123    use super::{DenseCholesky, dense_inverse};
1124
1125    /// #324. A pivot is judged against its own column (inverse) or its own
1126    /// diagonal entry (Cholesky), so a wide but valid spread of magnitudes
1127    /// factors, while cancellation — a pivot that elimination has erased
1128    /// relative to where its column started — is refused at any scale.
1129    #[test]
1130    fn a_wide_but_valid_magnitude_spread_factors() {
1131        // diag(1e12, 1e-6): condition 1e18, yet every pivot keeps its full
1132        // original magnitude. The old floor scaled from the matrix-wide
1133        // maximum refused this as singular.
1134        let wide = [1e12, 0.0, 0.0, 1e-6];
1135        let inv = dense_inverse(wide.to_vec(), 2).expect("a diagonal matrix inverts");
1136        assert!((inv[3] - 1e6).abs() < 1e-4, "{inv:?}");
1137        assert!(DenseCholesky::factor(&wide, 2).is_some());
1138    }
1139
1140    #[test]
1141    fn a_cancelled_pivot_is_refused_at_any_scale() {
1142        // Elimination reduces the second pivot from order 1 to machine
1143        // epsilon: no significant digits survive.
1144        let close = [1.0, 1.0, 1.0, 1.0 + f64::EPSILON];
1145        assert!(dense_inverse(close.to_vec(), 2).is_none());
1146        assert!(DenseCholesky::factor(&close, 2).is_none());
1147
1148        // The same cancellation scaled down by 1e14 is caught by the same
1149        // relative floor.
1150        let small = [1e-14, 1e-14, 1e-14, 1e-14 * (1.0 + f64::EPSILON)];
1151        assert!(dense_inverse(small.to_vec(), 2).is_none());
1152        assert!(DenseCholesky::factor(&small, 2).is_none());
1153
1154        // A genuinely singular matrix is refused outright.
1155        assert!(dense_inverse(vec![1.0, 1.0, 1.0, 1.0], 2).is_none());
1156        assert!(DenseCholesky::factor(&[1.0, 1.0, 1.0, 1.0], 2).is_none());
1157    }
1158
1159    #[test]
1160    fn a_small_well_conditioned_matrix_factors() {
1161        // Every entry is tiny but the matrix is perfectly conditioned; an
1162        // absolute floor refused it outright.
1163        let small = [1e-14, 0.0, 0.0, 1e-14];
1164        let inv =
1165            dense_inverse(small.to_vec(), 2).expect("a well conditioned small matrix inverts");
1166        assert!((inv[0] - 1e14).abs() < 1.0, "{inv:?}");
1167        assert!(DenseCholesky::factor(&small, 2).is_some());
1168    }
1169
1170    /// The `!(s > floor)` idiom must still reject a NaN pivot; `NaN > x` is
1171    /// false, which is the whole reason the comparison is negated.
1172    #[test]
1173    fn a_nan_pivot_does_not_factor() {
1174        assert!(DenseCholesky::factor(&[f64::NAN, 0.0, 0.0, 1.0], 2).is_none());
1175        assert!(dense_inverse(vec![f64::NAN, 0.0, 0.0, 1.0], 2).is_none());
1176    }
1177}
1178
1179#[cfg(test)]
1180mod auto_policy_tests {
1181    use super::{
1182        AUTO_DENSE_MEMORY_BUDGET, SensitivityOptions, SensitivitySolver, dense_footprint_bytes,
1183    };
1184
1185    #[test]
1186    fn auto_takes_the_dense_path_for_a_small_case() {
1187        // Below the crossover the dense factorization's constant factors win.
1188        let o = SensitivityOptions::default();
1189        assert_eq!(
1190            o.select_solver_for_shape(118, 186, 118),
1191            SensitivitySolver::Dense
1192        );
1193        assert_eq!(
1194            o.select_solver_for_shape(118, 186, 118),
1195            o.select_solver_for_shape(118, 186, 118)
1196        );
1197    }
1198
1199    #[test]
1200    fn auto_takes_the_sparse_path_for_a_mid_size_case() {
1201        // 2869 buses: the sparse factorization's fill stays near linear on a
1202        // network graph, while the dense path would factor a 2868² matrix.
1203        let o = SensitivityOptions::default();
1204        assert_eq!(
1205            o.select_solver_for_shape(2868, 4582, 2869),
1206            SensitivitySolver::Sparse
1207        );
1208    }
1209
1210    #[test]
1211    fn auto_refuses_the_dense_path_for_a_wide_case() {
1212        // Few buses, very many parallel branches: the reduced dimension is
1213        // small but the dense LODF alone is m x m, so the footprint veto has
1214        // to fire even though the dimension test passes.
1215        let o = SensitivityOptions::default();
1216        let (nr, m, n) = (400usize, 40_000usize, 401usize);
1217        assert!(nr <= o.auto_dense_threshold);
1218        assert!(dense_footprint_bytes(nr, m, n) > AUTO_DENSE_MEMORY_BUDGET);
1219        assert_eq!(
1220            o.select_solver_for_shape(nr, m, n),
1221            SensitivitySolver::Sparse
1222        );
1223    }
1224
1225    #[test]
1226    fn an_explicit_solver_choice_ignores_both_ceilings() {
1227        for solver in [SensitivitySolver::Dense, SensitivitySolver::Sparse] {
1228            let o = SensitivityOptions {
1229                solver,
1230                ..SensitivityOptions::default()
1231            };
1232            assert_eq!(o.select_solver_for_shape(1, 1, 1), solver);
1233            assert_eq!(o.select_solver_for_shape(99_999, 99_999, 99_999), solver);
1234        }
1235    }
1236
1237    #[test]
1238    fn serialized_options_use_formula_and_no_retired_solver_aliases() {
1239        let value = serde_json::to_value(SensitivityOptions::default()).unwrap();
1240        assert!(value.get("formula").is_some());
1241        assert!(value.get("convention").is_none());
1242        assert!(serde_json::from_str::<SensitivitySolver>(r#""iterative""#).is_err());
1243        assert!(serde_json::from_str::<super::SensitivitySolverPath>(r#""iterative_cg""#).is_err());
1244    }
1245
1246    #[test]
1247    fn the_footprint_saturates_instead_of_overflowing() {
1248        assert_eq!(
1249            SensitivityOptions::default().select_solver_for_shape(
1250                usize::MAX,
1251                usize::MAX,
1252                usize::MAX
1253            ),
1254            SensitivitySolver::Sparse
1255        );
1256    }
1257}