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;
32const DEFAULT_AUTO_DENSE_THRESHOLD: usize = 512;
33const LODF_ISLAND_TOLERANCE: f64 = 1e-9;
34
35/// Solver selection for option based DC sensitivity builds.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum SensitivitySolver {
40    /// Dense below [`SensitivityOptions::auto_dense_threshold`], iterative above it.
41    #[default]
42    Auto,
43    /// Dense grounded inverse. This is the historical builder path.
44    Dense,
45    /// Preconditioned conjugate gradient, one right hand side at a time.
46    Iterative,
47}
48
49/// Solver path actually used for a sensitivity build.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "snake_case")]
52#[non_exhaustive]
53pub enum SensitivitySolverPath {
54    DenseCholesky,
55    DenseInverse,
56    IterativeCg,
57}
58
59impl SensitivitySolverPath {
60    #[inline]
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::DenseCholesky => "dense_cholesky",
64            Self::DenseInverse => "dense_inverse",
65            Self::IterativeCg => "iterative_cg",
66        }
67    }
68}
69
70/// Options for PTDF/LODF builders that expose solver choice and output pruning.
71#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
72pub struct SensitivityOptions {
73    /// DC branch susceptance convention.
74    pub convention: DcConvention,
75    /// Solver selection policy.
76    pub solver: SensitivitySolver,
77    /// Entries with absolute value at or below this value are omitted from the
78    /// returned sparse matrices. LODF diagonal entries are structural and kept.
79    pub drop_tolerance: f64,
80    /// Relative residual tolerance for the iterative solver.
81    pub cg_tolerance: f64,
82    /// Maximum conjugate gradient iterations per right hand side.
83    pub cg_max_iterations: usize,
84    /// Reduced dimension above which [`SensitivitySolver::Auto`] selects the
85    /// iterative path.
86    pub auto_dense_threshold: usize,
87}
88
89impl Default for SensitivityOptions {
90    fn default() -> Self {
91        Self {
92            convention: DcConvention::PaperPure,
93            solver: SensitivitySolver::Auto,
94            drop_tolerance: PRUNE,
95            cg_tolerance: DEFAULT_CG_TOLERANCE,
96            cg_max_iterations: DEFAULT_CG_MAX_ITERATIONS,
97            auto_dense_threshold: DEFAULT_AUTO_DENSE_THRESHOLD,
98        }
99    }
100}
101
102impl SensitivityOptions {
103    fn validate(&self) -> Result<()> {
104        if !self.drop_tolerance.is_finite() || self.drop_tolerance < 0.0 {
105            return Err(Error::InvalidSensitivityOptions {
106                reason: format!(
107                    "drop_tolerance must be finite and nonnegative, got {}",
108                    self.drop_tolerance
109                ),
110            });
111        }
112        if !self.cg_tolerance.is_finite() || self.cg_tolerance <= 0.0 {
113            return Err(Error::InvalidSensitivityOptions {
114                reason: format!(
115                    "cg_tolerance must be finite and positive, got {}",
116                    self.cg_tolerance
117                ),
118            });
119        }
120        if self.cg_max_iterations == 0 {
121            return Err(Error::InvalidSensitivityOptions {
122                reason: "cg_max_iterations must be positive".into(),
123            });
124        }
125        Ok(())
126    }
127
128    /// Return the concrete solver selected for a reduced grounded dimension.
129    pub fn selected_solver_for_reduced_dimension(
130        &self,
131        reduced_dimension: usize,
132    ) -> SensitivitySolver {
133        match self.solver {
134            SensitivitySolver::Auto if reduced_dimension > self.auto_dense_threshold => {
135                SensitivitySolver::Iterative
136            }
137            SensitivitySolver::Auto => SensitivitySolver::Dense,
138            other => other,
139        }
140    }
141}
142
143/// PTDF/LODF matrices plus metadata for serialized outputs.
144#[derive(Debug, Clone)]
145pub struct SensitivityMatrices {
146    pub ptdf: CsMat<f64>,
147    pub lodf: CsMat<f64>,
148    pub metadata: SensitivityMetadata,
149}
150
151/// Metadata describing a sensitivity build.
152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
153pub struct SensitivityMetadata {
154    pub requested_solver: SensitivitySolver,
155    pub solver_path: SensitivitySolverPath,
156    pub drop_tolerance: f64,
157    pub cg_tolerance: Option<f64>,
158    pub cg_max_iterations: Option<usize>,
159    pub auto_dense_threshold: usize,
160    pub reduced_dimension: usize,
161    pub ptdf: SensitivityMatrixMetadata,
162    pub lodf: SensitivityMatrixMetadata,
163}
164
165/// Shape and pruning metadata for one sensitivity matrix.
166#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
167pub struct SensitivityMatrixMetadata {
168    pub rows: usize,
169    pub cols: usize,
170    pub nnz: usize,
171    pub dropped_entries: usize,
172}
173
174/// PTDF (`m × n`): branch flows from nodal injections, `f = PTDF · p`. Every
175/// reference bus column is zero. The DC bus susceptance matrix is grounded at
176/// the whole reference set (`reference_bus_indices`), one row/column per slack.
177/// One reference per island handles disconnected networks; several references
178/// within one island fixes all of those bus angles to zero.
179pub fn build_ptdf(case: &IndexedNetwork, conv: DcConvention) -> Result<CsMat<f64>> {
180    case.check_reference_coverage()?;
181    let refs = case.reference_bus_indices();
182    let inc = build_incidence(case, conv, &BuildOptions::default())?;
183    let (dense, m, n) = ptdf_dense(&inc, &refs)?;
184    Ok(dense_to_csr(&dense, m, n))
185}
186
187/// LODF (`m × m`): pre-outage flow on branch `k` redistributes onto branch `l`
188/// with factor `LODF[l, k]`. Diagonal is `−1`. A branch whose outage islands
189/// the network (denominator `≈ 0`) gets a zero column.
190pub fn build_lodf(case: &IndexedNetwork, conv: DcConvention) -> Result<CsMat<f64>> {
191    case.check_reference_coverage()?;
192    let refs = case.reference_bus_indices();
193    let inc = build_incidence(case, conv, &BuildOptions::default())?;
194    let (ptdf, m, n) = ptdf_dense(&inc, &refs)?;
195    Ok(lodf_from_dense(&ptdf, &inc.a, m, n))
196}
197
198/// Both DC sensitivity matrices `(PTDF, LODF)` from one DC bus susceptance matrix
199/// factorization. When a caller needs both for the same case (the
200/// `sensitivities` bundle), this factors and inverts the grounded DC bus
201/// susceptance matrix once instead of paying the O(n³) twice across separate
202/// [`build_ptdf`]/[`build_lodf`] calls.
203pub fn build_ptdf_lodf(
204    case: &IndexedNetwork,
205    conv: DcConvention,
206) -> Result<(CsMat<f64>, CsMat<f64>)> {
207    case.check_reference_coverage()?;
208    let refs = case.reference_bus_indices();
209    let inc = build_incidence(case, conv, &BuildOptions::default())?;
210    let (dense, m, n) = ptdf_dense(&inc, &refs)?;
211    let ptdf = dense_to_csr(&dense, m, n);
212    let lodf = lodf_from_dense(&dense, &inc.a, m, n);
213    Ok((ptdf, lodf))
214}
215
216/// PTDF and LODF with solver selection, drop tolerance, and output metadata.
217pub fn build_ptdf_lodf_with_options(
218    case: &IndexedNetwork,
219    options: &SensitivityOptions,
220) -> Result<SensitivityMatrices> {
221    options.validate()?;
222    case.check_reference_coverage()?;
223    let refs = case.reference_bus_indices();
224    let inc = build_incidence(case, options.convention, &BuildOptions::default())?;
225    let reduced_dimension = inc.n().saturating_sub(Grounding::new(&refs).len());
226
227    let (ptdf, lodf, solver_path, ptdf_dropped, lodf_dropped) = match options
228        .selected_solver_for_reduced_dimension(reduced_dimension)
229    {
230        SensitivitySolver::Dense => {
231            let (dense, m, n, solver_path) = ptdf_dense_with_path(&inc, &refs)?;
232            let (ptdf, ptdf_dropped) = dense_to_csr_with_drop(&dense, m, n, options.drop_tolerance);
233            let (lodf, lodf_dropped) =
234                lodf_from_dense_with_drop(&dense, &inc.a, m, n, options.drop_tolerance);
235            (ptdf, lodf, solver_path, ptdf_dropped, lodf_dropped)
236        }
237        SensitivitySolver::Iterative => {
238            ensure_iterative_solver_eligible(&inc)?;
239            let (ptdf, ptdf_dropped, lodf, lodf_dropped) =
240                iterative_ptdf_lodf(&inc, &refs, options)?;
241            (
242                ptdf,
243                lodf,
244                SensitivitySolverPath::IterativeCg,
245                ptdf_dropped,
246                lodf_dropped,
247            )
248        }
249        SensitivitySolver::Auto => unreachable!("selected_solver resolves Auto"),
250    };
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
267pub(crate) fn for_each_ptdf_lodf_entry(
268    case: &IndexedNetwork,
269    options: &SensitivityOptions,
270    mut ptdf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
271    mut lodf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
272) -> Result<SensitivityMetadata> {
273    options.validate()?;
274    case.check_reference_coverage()?;
275    let refs = case.reference_bus_indices();
276    let inc = build_incidence(case, options.convention, &BuildOptions::default())?;
277    let reduced_dimension = inc.n().saturating_sub(Grounding::new(&refs).len());
278
279    let (solver_path, ptdf, lodf) = match options
280        .selected_solver_for_reduced_dimension(reduced_dimension)
281    {
282        SensitivitySolver::Dense => {
283            let (dense, m, n, solver_path) = ptdf_dense_with_path(&inc, &refs)?;
284            let (ptdf, ptdf_dropped) = dense_to_csr_with_drop(&dense, m, n, options.drop_tolerance);
285            let (lodf, lodf_dropped) =
286                lodf_from_dense_with_drop(&dense, &inc.a, m, n, options.drop_tolerance);
287            let ptdf_meta = matrix_metadata(&ptdf, ptdf_dropped);
288            let lodf_meta = matrix_metadata(&lodf, lodf_dropped);
289            for (&v, (row, col)) in &ptdf {
290                ptdf_entry(row, col, v)?;
291            }
292            for (&v, (row, col)) in &lodf {
293                lodf_entry(row, col, v)?;
294            }
295            (solver_path, ptdf_meta, lodf_meta)
296        }
297        SensitivitySolver::Iterative => {
298            ensure_iterative_solver_eligible(&inc)?;
299            let (ptdf, lodf) =
300                iterative_ptdf_lodf_entries(&inc, &refs, options, ptdf_entry, lodf_entry)?;
301            (SensitivitySolverPath::IterativeCg, ptdf, lodf)
302        }
303        SensitivitySolver::Auto => {
304            unreachable!("selected_solver_for_reduced_dimension resolves Auto")
305        }
306    };
307
308    Ok(sensitivity_metadata(
309        options,
310        solver_path,
311        reduced_dimension,
312        ptdf,
313        lodf,
314    ))
315}
316
317fn sensitivity_metadata(
318    options: &SensitivityOptions,
319    solver_path: SensitivitySolverPath,
320    reduced_dimension: usize,
321    ptdf: SensitivityMatrixMetadata,
322    lodf: SensitivityMatrixMetadata,
323) -> SensitivityMetadata {
324    SensitivityMetadata {
325        requested_solver: options.solver,
326        solver_path,
327        drop_tolerance: options.drop_tolerance,
328        cg_tolerance: matches!(solver_path, SensitivitySolverPath::IterativeCg)
329            .then_some(options.cg_tolerance),
330        cg_max_iterations: matches!(solver_path, SensitivitySolverPath::IterativeCg)
331            .then_some(options.cg_max_iterations),
332        auto_dense_threshold: options.auto_dense_threshold,
333        reduced_dimension,
334        ptdf,
335        lodf,
336    }
337}
338
339fn matrix_metadata(matrix: &CsMat<f64>, dropped_entries: usize) -> SensitivityMatrixMetadata {
340    SensitivityMatrixMetadata {
341        rows: matrix.rows(),
342        cols: matrix.cols(),
343        nnz: matrix.nnz(),
344        dropped_entries,
345    }
346}
347
348/// LODF from a dense PTDF and the signed incidence (the shared tail of
349/// [`build_lodf`] and [`build_ptdf_lodf`]).
350fn lodf_from_dense(ptdf: &[f64], a: &CsMat<f64>, m: usize, n: usize) -> CsMat<f64> {
351    lodf_from_dense_with_drop(ptdf, a, m, n, PRUNE).0
352}
353
354fn lodf_from_dense_with_drop(
355    ptdf: &[f64],
356    a: &CsMat<f64>,
357    m: usize,
358    n: usize,
359    drop_tolerance: f64,
360) -> (CsMat<f64>, usize) {
361    // Branch endpoints (dense bus indices), recovered from the incidence.
362    let (from, to) = endpoints(a, m);
363
364    // δ[l,k] = PTDF[l, from_k] − PTDF[l, to_k]: flow on l from a unit transfer
365    // along branch k.
366    let delta = |l: usize, k: usize| ptdf[l * n + from[k]] - ptdf[l * n + to[k]];
367
368    let mut lodf = CooBuilder::new(m); // m × m
369    let mut dropped = 0usize;
370    for k in 0..m {
371        let denom = 1.0 - delta(k, k);
372        let islands = denom.abs() < LODF_ISLAND_TOLERANCE;
373        for l in 0..m {
374            let v = if l == k {
375                -1.0
376            } else if islands {
377                0.0
378            } else {
379                delta(l, k) / denom
380            };
381            if l == k || v.abs() > drop_tolerance {
382                lodf.add(l, k, v);
383            } else if v != 0.0 {
384                dropped += 1;
385            }
386        }
387    }
388    (lodf.finish_csr(), dropped)
389}
390
391/// Dense PTDF (`m × n`, row-major) plus its shape. `refs` is the reference set;
392/// the DC bus susceptance matrix is grounded at every entry (one row/column each).
393fn ptdf_dense(inc: &IncidenceParts, refs: &[usize]) -> Result<(Vec<f64>, usize, usize)> {
394    let (ptdf, m, n, _) = ptdf_dense_with_path(inc, refs)?;
395    Ok((ptdf, m, n))
396}
397
398fn ptdf_dense_with_path(
399    inc: &IncidenceParts,
400    refs: &[usize],
401) -> Result<(Vec<f64>, usize, usize, SensitivitySolverPath)> {
402    let n = inc.n();
403    let m = inc.m();
404    let g = Grounding::new(refs);
405    let nr = n - g.len();
406
407    // Reduced inverse of the grounded DC bus susceptance matrix: Rinv = (ABA_refs)^{-1}.
408    let lr = ground_with(&build_weighted_laplacian(&inc.a, &inc.b), &g);
409    let dense_lr = densify(&lr, nr);
410    let (rinv, solver_path) = DenseCholesky::factor(&dense_lr, nr).map_or_else(
411        || {
412            dense_inverse(&dense_lr, nr)
413                .map(|rinv| (rinv, SensitivitySolverPath::DenseInverse))
414                .ok_or(Error::SingularNetwork)
415        },
416        |chol| Ok((chol.inverse(), SensitivitySolverPath::DenseCholesky)),
417    )?; // nr × nr, row-major
418
419    // Minv (n × n) is Rinv padded with a zero row/col at every grounded bus, so
420    // each reference's PTDF column comes out zero. PTDF = (B Aᵀ) · Minv, computed
421    // sparse-times-dense: each nonzero of the flow map scatters a scaled Minv row
422    // into a PTDF row.
423    let flow = build_flow_map(&inc.a, &inc.b); // m × n
424    let mut ptdf = vec![0.0; m * n];
425    for (&w, (l, c)) in flow.iter() {
426        let Some(rc) = g.reduced(c) else { continue }; // Minv row at a slack is 0
427        for k in 0..n {
428            if let Some(rk) = g.reduced(k) {
429                ptdf[l * n + k] += w * rinv[rc * nr + rk];
430            }
431        }
432    }
433    Ok((ptdf, m, n, solver_path))
434}
435
436fn iterative_ptdf_lodf(
437    inc: &IncidenceParts,
438    refs: &[usize],
439    options: &SensitivityOptions,
440) -> Result<(CsMat<f64>, usize, CsMat<f64>, usize)> {
441    ensure_iterative_solver_eligible(inc)?;
442    let mut ptdf = CooBuilder::new_rect(inc.m(), inc.n());
443    let mut lodf = CooBuilder::new(inc.m());
444    let (ptdf_meta, lodf_meta) = iterative_ptdf_lodf_entries(
445        inc,
446        refs,
447        options,
448        |row, col, value| {
449            ptdf.add(row, col, value);
450            Ok(())
451        },
452        |row, col, value| {
453            lodf.add(row, col, value);
454            Ok(())
455        },
456    )?;
457    Ok((
458        ptdf.finish_csr(),
459        ptdf_meta.dropped_entries,
460        lodf.finish_csr(),
461        lodf_meta.dropped_entries,
462    ))
463}
464
465fn iterative_ptdf_lodf_entries(
466    inc: &IncidenceParts,
467    refs: &[usize],
468    options: &SensitivityOptions,
469    mut ptdf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
470    mut lodf_entry: impl FnMut(usize, usize, f64) -> Result<()>,
471) -> Result<(SensitivityMatrixMetadata, SensitivityMatrixMetadata)> {
472    let n = inc.n();
473    let m = inc.m();
474    let g = Grounding::new(refs);
475    let nr = n - g.len();
476    let lr = ground_with(&build_weighted_laplacian(&inc.a, &inc.b), &g);
477    let solver = CgSolver::new(&lr, options.cg_tolerance, options.cg_max_iterations)?;
478    let (from, to) = endpoints(&inc.a, m);
479
480    let mut rhs = vec![0.0; nr];
481    let mut ptdf_nnz = 0usize;
482    let mut ptdf_dropped = 0usize;
483    for bus in 0..n {
484        let Some(rb) = g.reduced(bus) else {
485            continue;
486        };
487        rhs.fill(0.0);
488        rhs[rb] = 1.0;
489        let theta = solver.solve(&rhs)?;
490        for branch in 0..m {
491            let v = branch_flow(branch, &from, &to, &inc.b, &g, &theta);
492            if v.abs() > options.drop_tolerance {
493                ptdf_entry(branch, bus, v)?;
494                ptdf_nnz += 1;
495            } else if v != 0.0 {
496                ptdf_dropped += 1;
497            }
498        }
499    }
500
501    let mut lodf_nnz = 0usize;
502    let mut lodf_dropped = 0usize;
503    for outage in 0..m {
504        rhs.fill(0.0);
505        if let Some(rf) = g.reduced(from[outage]) {
506            rhs[rf] += 1.0;
507        }
508        if let Some(rt) = g.reduced(to[outage]) {
509            rhs[rt] -= 1.0;
510        }
511        let theta = solver.solve(&rhs)?;
512        let outage_delta = branch_flow(outage, &from, &to, &inc.b, &g, &theta);
513        let denom = 1.0 - outage_delta;
514        let islands = denom.abs() < LODF_ISLAND_TOLERANCE;
515        for branch in 0..m {
516            let v = if branch == outage {
517                -1.0
518            } else if islands {
519                0.0
520            } else {
521                branch_flow(branch, &from, &to, &inc.b, &g, &theta) / denom
522            };
523            if branch == outage || v.abs() > options.drop_tolerance {
524                lodf_entry(branch, outage, v)?;
525                lodf_nnz += 1;
526            } else if v != 0.0 {
527                lodf_dropped += 1;
528            }
529        }
530    }
531
532    Ok((
533        SensitivityMatrixMetadata {
534            rows: m,
535            cols: n,
536            nnz: ptdf_nnz,
537            dropped_entries: ptdf_dropped,
538        },
539        SensitivityMatrixMetadata {
540            rows: m,
541            cols: m,
542            nnz: lodf_nnz,
543            dropped_entries: lodf_dropped,
544        },
545    ))
546}
547
548fn ensure_iterative_solver_eligible(inc: &IncidenceParts) -> Result<()> {
549    for (branch, &b) in inc.b.iter().enumerate() {
550        if !b.is_finite() || b <= 0.0 {
551            return Err(Error::InvalidSensitivityOptions {
552                reason: format!(
553                    "iterative sensitivity solver requires positive finite branch susceptances; \
554                     branch {branch} has {b}; use solver=dense for nonsingular indefinite cases"
555                ),
556            });
557        }
558    }
559    Ok(())
560}
561
562fn branch_flow(
563    branch: usize,
564    from: &[usize],
565    to: &[usize],
566    b: &[f64],
567    g: &Grounding,
568    theta: &[f64],
569) -> f64 {
570    let theta_from = g.reduced(from[branch]).map_or(0.0, |i| theta[i]);
571    let theta_to = g.reduced(to[branch]).map_or(0.0, |i| theta[i]);
572    b[branch] * (theta_from - theta_to)
573}
574
575/// Branch endpoints from the signed incidence: `+1` row is from, `−1` is to.
576fn endpoints(a: &CsMat<f64>, m: usize) -> (Vec<usize>, Vec<usize>) {
577    let mut from = vec![0usize; m];
578    let mut to = vec![0usize; m];
579    for (&v, (bus, branch)) in a.iter() {
580        if v > 0.0 {
581            from[branch] = bus;
582        } else {
583            to[branch] = bus;
584        }
585    }
586    (from, to)
587}
588
589fn densify(a: &CsMat<f64>, n: usize) -> Vec<f64> {
590    let mut d = vec![0.0; n * n];
591    for (&v, (i, j)) in a.iter() {
592        d[i * n + j] = v;
593    }
594    d
595}
596
597fn dense_to_csr(dense: &[f64], rows: usize, cols: usize) -> CsMat<f64> {
598    dense_to_csr_with_drop(dense, rows, cols, PRUNE).0
599}
600
601fn dense_to_csr_with_drop(
602    dense: &[f64],
603    rows: usize,
604    cols: usize,
605    drop_tolerance: f64,
606) -> (CsMat<f64>, usize) {
607    let mut coo = CooBuilder::with_capacity_rect(rows, cols, dense.len() / 2);
608    let mut dropped = 0usize;
609    for i in 0..rows {
610        for j in 0..cols {
611            let v = dense[i * cols + j];
612            if v.abs() > drop_tolerance {
613                coo.add(i, j, v);
614            } else if v != 0.0 {
615                dropped += 1;
616            }
617        }
618    }
619    (coo.finish_csr(), dropped)
620}
621
622fn dense_inverse(a: &[f64], n: usize) -> Option<Vec<f64>> {
623    let mut a = a.to_vec();
624    let mut inv = vec![0.0; n * n];
625    for i in 0..n {
626        inv[i * n + i] = 1.0;
627    }
628
629    for col in 0..n {
630        let mut pivot_row = col;
631        let mut pivot_abs = a[col * n + col].abs();
632        for r in (col + 1)..n {
633            let v = a[r * n + col].abs();
634            if v > pivot_abs {
635                pivot_abs = v;
636                pivot_row = r;
637            }
638        }
639        if !pivot_abs.is_finite() || pivot_abs <= 1e-12 {
640            return None;
641        }
642        if pivot_row != col {
643            swap_dense_rows(&mut a, n, pivot_row, col);
644            swap_dense_rows(&mut inv, n, pivot_row, col);
645        }
646
647        let pivot = a[col * n + col];
648        for c in 0..n {
649            a[col * n + c] /= pivot;
650            inv[col * n + c] /= pivot;
651        }
652        for r in 0..n {
653            if r == col {
654                continue;
655            }
656            let factor = a[r * n + col];
657            if factor == 0.0 {
658                continue;
659            }
660            for c in 0..n {
661                a[r * n + c] -= factor * a[col * n + c];
662                inv[r * n + c] -= factor * inv[col * n + c];
663            }
664        }
665    }
666    Some(inv)
667}
668
669fn swap_dense_rows(a: &mut [f64], n: usize, r1: usize, r2: usize) {
670    for c in 0..n {
671        a.swap(r1 * n + c, r2 * n + c);
672    }
673}
674
675struct CgSolver<'a> {
676    a: &'a CsMat<f64>,
677    diag: Vec<f64>,
678    tolerance: f64,
679    max_iterations: usize,
680}
681
682impl<'a> CgSolver<'a> {
683    fn new(a: &'a CsMat<f64>, tolerance: f64, max_iterations: usize) -> Result<Self> {
684        let n = a.rows();
685        if a.cols() != n {
686            return Err(Error::ShapeMismatch {
687                what: "grounded DC bus susceptance matrix columns",
688                expected: n,
689                got: a.cols(),
690            });
691        }
692        let mut diag = vec![0.0; n];
693        for (i, slot) in diag.iter_mut().enumerate() {
694            *slot = a.get(i, i).copied().unwrap_or(0.0);
695            if !slot.is_finite() || *slot <= 0.0 {
696                return Err(Error::SingularNetwork);
697            }
698        }
699        Ok(Self {
700            a,
701            diag,
702            tolerance,
703            max_iterations,
704        })
705    }
706
707    fn solve(&self, rhs: &[f64]) -> Result<Vec<f64>> {
708        let n = self.a.rows();
709        if rhs.len() != n {
710            return Err(Error::DimensionMismatch {
711                n,
712                b_len: rhs.len(),
713            });
714        }
715        if n == 0 {
716            return Ok(Vec::new());
717        }
718
719        let rhs_norm = norm2(rhs);
720        if rhs_norm == 0.0 {
721            return Ok(vec![0.0; n]);
722        }
723        let target = self.tolerance * rhs_norm;
724        let mut solution = vec![0.0; n];
725        let mut residual_vec = rhs.to_vec();
726        let mut preconditioned = self.precondition(&residual_vec);
727        let mut direction = preconditioned.clone();
728        let mut residual_dot = dot(&residual_vec, &preconditioned);
729        if !residual_dot.is_finite() || residual_dot <= 0.0 {
730            return Err(Error::SingularNetwork);
731        }
732        let mut matvec_out = vec![0.0; n];
733
734        for iter in 1..=self.max_iterations {
735            matvec(self.a, &direction, &mut matvec_out);
736            let denom = dot(&direction, &matvec_out);
737            if !denom.is_finite() || denom <= 0.0 {
738                return Err(Error::SingularNetwork);
739            }
740            let alpha = residual_dot / denom;
741            for i in 0..n {
742                solution[i] += alpha * direction[i];
743                residual_vec[i] -= alpha * matvec_out[i];
744            }
745            let residual = norm2(&residual_vec);
746            if residual <= target {
747                return Ok(solution);
748            }
749            preconditioned = self.precondition(&residual_vec);
750            let next_residual_dot = dot(&residual_vec, &preconditioned);
751            if !next_residual_dot.is_finite() || next_residual_dot <= 0.0 {
752                return Err(Error::SingularNetwork);
753            }
754            let beta = next_residual_dot / residual_dot;
755            for i in 0..n {
756                direction[i] = preconditioned[i] + beta * direction[i];
757            }
758            residual_dot = next_residual_dot;
759
760            if iter == self.max_iterations {
761                return Err(Error::SensitivitySolveDidNotConverge {
762                    iterations: iter,
763                    relative_residual: residual / rhs_norm,
764                });
765            }
766        }
767        unreachable!("positive max_iterations loop returns")
768    }
769
770    fn precondition(&self, r: &[f64]) -> Vec<f64> {
771        r.iter().zip(&self.diag).map(|(&ri, &di)| ri / di).collect()
772    }
773}
774
775fn matvec(a: &CsMat<f64>, x: &[f64], out: &mut [f64]) {
776    out.fill(0.0);
777    for (i, row) in a.outer_iterator().enumerate() {
778        let mut sum = 0.0;
779        for (j, &v) in row.iter() {
780            sum += v * x[j];
781        }
782        out[i] = sum;
783    }
784}
785
786fn dot(a: &[f64], b: &[f64]) -> f64 {
787    a.iter().zip(b).map(|(&x, &y)| x * y).sum()
788}
789
790fn norm2(a: &[f64]) -> f64 {
791    dot(a, a).sqrt()
792}
793
794/// Dense lower-triangular Cholesky `A = L Lᵀ` for a small SPD matrix.
795struct DenseCholesky {
796    n: usize,
797    l: Vec<f64>, // row-major lower triangle
798}
799
800impl DenseCholesky {
801    fn factor(a: &[f64], n: usize) -> Option<Self> {
802        let mut l = vec![0.0; n * n];
803        for i in 0..n {
804            for j in 0..=i {
805                let mut s = a[i * n + j];
806                for k in 0..j {
807                    s -= l[i * n + k] * l[j * n + k];
808                }
809                if i == j {
810                    // `!(s > 0.0)` rejects negative, zero, AND NaN pivots:
811                    // `NaN <= 0.0` is false, so `s <= 0.0` would let a
812                    // NaN-poisoned matrix factor "successfully" into all-NaN.
813                    // The negated comparison is the point (NaN incomparability),
814                    // so the partial_cmp rewrite clippy suggests would obscure it.
815                    #[allow(clippy::neg_cmp_op_on_partial_ord)]
816                    if !(s > 0.0) {
817                        return None;
818                    }
819                    l[i * n + i] = s.sqrt();
820                } else {
821                    l[i * n + j] = s / l[j * n + j];
822                }
823            }
824        }
825        Some(Self { n, l })
826    }
827
828    /// Solve `A x = b` in place.
829    fn solve(&self, b: &mut [f64]) {
830        let n = self.n;
831        for i in 0..n {
832            let mut s = b[i];
833            for k in 0..i {
834                s -= self.l[i * n + k] * b[k];
835            }
836            b[i] = s / self.l[i * n + i];
837        }
838        for i in (0..n).rev() {
839            let mut s = b[i];
840            for k in (i + 1)..n {
841                s -= self.l[k * n + i] * b[k];
842            }
843            b[i] = s / self.l[i * n + i];
844        }
845    }
846
847    /// Full inverse, row-major. The matrix is symmetric, so rows = columns.
848    fn inverse(&self) -> Vec<f64> {
849        let n = self.n;
850        let mut inv = vec![0.0; n * n];
851        let mut e = vec![0.0; n];
852        for j in 0..n {
853            e.fill(0.0);
854            e[j] = 1.0;
855            self.solve(&mut e);
856            for (i, &x) in e.iter().enumerate() {
857                inv[i * n + j] = x;
858            }
859        }
860        inv
861    }
862}