1#![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
30const PRUNE: f64 = 1e-12;
32const SPARSE_SOLVE_BLOCK: usize = 32;
35const DEFAULT_AUTO_DENSE_THRESHOLD: usize = 512;
41
42const AUTO_DENSE_MEMORY_BUDGET: usize = 2 << 30;
47
48fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
63#[serde(rename_all = "snake_case")]
64#[non_exhaustive]
65pub enum SensitivitySolver {
66 #[default]
68 Auto,
69 Dense,
71 Sparse,
73}
74
75#[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#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
98pub struct SensitivityOptions {
99 pub formula: BranchSusceptanceFormula,
101 pub solver: SensitivitySolver,
103 pub drop_tolerance: f64,
106 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 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 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#[derive(Debug, Clone)]
174pub struct SensitivityMatrices {
175 pub ptdf: CsMat<f64>,
176 pub lodf: CsMat<f64>,
177 pub metadata: SensitivityMetadata,
178}
179
180#[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#[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
201pub 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
214pub 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
225pub 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
242pub 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#[derive(Clone, Copy, PartialEq, Eq)]
271enum Want {
272 Ptdf,
273 Lodf,
274 Both,
275}
276
277struct BuiltParts {
278 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
302fn 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 let (from, to) = endpoints(a, m);
454
455 let is_bridge = bridges(&from, &to, n);
460
461 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 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 let lr = ground_with(&calc_weighted_laplacian(&inc.a, &inc.b), &g);
512 let dense_lr = densify(&lr, nr);
513
514 let flow = calc_solver_branch_flow_matrix(&inc.a, &inc.b); let mut rows_used: Vec<Vec<(usize, f64)>> = vec![Vec::new(); nr];
523 for (&w, (l, c)) in flow.iter() {
524 if let Some(rc) = g.reduced(c) {
526 rows_used[rc].push((l, w));
527 }
528 }
529 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 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 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
564fn 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
581struct 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 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 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 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 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 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
789fn bridges(from: &[usize], to: &[usize], n: usize) -> Vec<bool> {
802 let m = from.len();
803 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 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 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 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 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#[allow(clippy::cast_precision_loss)]
935fn dense_inverse(mut a: Vec<f64>, n: usize) -> Option<Vec<f64>> {
936 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
996struct 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 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 #[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 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 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 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 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 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 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 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 #[test]
1130 fn a_wide_but_valid_magnitude_spread_factors() {
1131 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 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 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 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 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 #[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 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 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 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}