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