Skip to main content

surge_dc/
sensitivity.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Power Transfer Distribution Factors (PTDF) and Line Outage Distribution
3//! Factors (LODF) computation.
4//!
5//! PTDF: How much of a power transfer between two buses flows on each line.
6//! LODF: How line flows redistribute when a line is outaged (for N-1 contingency).
7//!
8//! # Approach
9//!
10//! Both PTDF and LODF are computed via on-demand KLU column solves — no dense
11//! B'^-1 is ever materialized. One KLU solve per monitored branch gives the full
12//! PTDF row for that branch. This scales to the full US grid (82k buses, 121k
13//! branches) without memory issues.
14//!
15//! # Exactness Under DC Assumptions
16//!
17//! PTDF and LODF computed here are **exact under the DC power flow assumptions**
18//! (flat voltage, small angles, lossless branches). The LODF formula
19//! `LODF[l,k] = PTDF_lk / (1 - PTDF_kk)` is an analytical identity derived from
20//! the Sherman-Morrison matrix inverse update, not an approximation, within the DC
21//! model.
22//!
23//! # Limitations vs. AC Contingency Analysis
24//!
25//! - **Voltage-limited contingencies** — post-contingency voltage collapse not detected.
26//! - **Reactive power constraints** — generator Q limits invisible to DC PTDF/LODF.
27//! - **Nonlinear flow redistribution** — second-order voltage/angle effects ignored.
28//! - **Transformer tap effects** — post-contingency OLTC tap changes not modelled.
29//!
30//! For AC-accurate contingency analysis, use `surge-contingency` with full
31//! Newton-Raphson AC re-solve per contingency. DC PTDF/LODF is appropriate for
32//! fast screening before running expensive AC validation on the critical subset.
33
34use surge_network::Network;
35use tracing::{debug, info};
36
37use crate::solver::PreparedDcStudy;
38use crate::types::*;
39
40/// Threshold for bridge-line detection in LODF computation.
41///
42/// If `ptdf_kk >= 1.0 - BRIDGE_THRESHOLD` the line is treated as a bridge:
43/// outaging it disconnects the network and all LODF entries for that column are
44/// set to `f64::INFINITY`.
45pub(crate) const BRIDGE_THRESHOLD: f64 = 1e-6;
46
47/// Slack-balancing policy for PTDF and OTDF sensitivity wrappers.
48#[derive(Debug, Clone, PartialEq, Default)]
49pub enum DcSensitivitySlack {
50    /// Standard single-slack PTDF/LODF semantics.
51    #[default]
52    SingleSlack,
53    /// Fixed participation weights keyed by internal bus index.
54    SlackWeights(Vec<(usize, f64)>),
55    /// Headroom-limited balancing across the given internal bus indices.
56    HeadroomSlack(Vec<usize>),
57}
58
59/// Options for PTDF and OTDF sensitivity wrappers.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct DcSensitivityOptions {
62    /// Slack-balancing policy for sensitivity computation.
63    pub slack: DcSensitivitySlack,
64}
65
66impl DcSensitivityOptions {
67    /// Create default options (single-slack semantics).
68    #[inline]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Create options with fixed participation-weight slack balancing.
74    #[inline]
75    pub fn with_slack_weights(slack_weights: &[(usize, f64)]) -> Self {
76        Self {
77            slack: DcSensitivitySlack::SlackWeights(slack_weights.to_vec()),
78        }
79    }
80
81    /// Create options with headroom-limited slack balancing.
82    #[inline]
83    pub fn with_headroom_slack(participating_bus_indices: &[usize]) -> Self {
84        Self {
85            slack: DcSensitivitySlack::HeadroomSlack(participating_bus_indices.to_vec()),
86        }
87    }
88}
89
90/// Advanced request for one-shot PTDF computation.
91#[derive(Debug, Clone, PartialEq, Default)]
92pub struct PtdfRequest {
93    /// Branch indices to monitor. `None` monitors all branches.
94    pub monitored_branch_indices: Option<Vec<usize>>,
95    /// Bus indices for PTDF columns. `None` returns all buses.
96    pub bus_indices: Option<Vec<usize>>,
97    /// Sensitivity options (slack policy).
98    pub options: DcSensitivityOptions,
99}
100
101impl PtdfRequest {
102    /// Create a default request (all branches, all buses, single slack).
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Create a request for the given monitored branches.
108    pub fn for_branches(monitored_branch_indices: &[usize]) -> Self {
109        Self::new().with_monitored_branches(monitored_branch_indices)
110    }
111
112    /// Restrict to specific monitored branches.
113    pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
114        self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
115        self
116    }
117
118    /// Restrict PTDF columns to specific bus indices.
119    pub fn with_bus_indices(mut self, bus_indices: &[usize]) -> Self {
120        self.bus_indices = Some(bus_indices.to_vec());
121        self
122    }
123
124    /// Set sensitivity options (slack policy).
125    pub fn with_options(mut self, options: DcSensitivityOptions) -> Self {
126        self.options = options;
127        self
128    }
129}
130
131/// Advanced request for one-shot subset LODF computation.
132#[derive(Debug, Clone, PartialEq, Default)]
133pub struct LodfRequest {
134    /// Branch indices to monitor (rows). `None` monitors all branches.
135    pub monitored_branch_indices: Option<Vec<usize>>,
136    /// Branch indices to outage (columns). `None` uses the monitored set.
137    pub outage_branch_indices: Option<Vec<usize>>,
138}
139
140impl LodfRequest {
141    /// Create a default request (all branches for both monitored and outage sets).
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Create a request for explicit monitored and outage branch sets.
147    pub fn for_branches(
148        monitored_branch_indices: &[usize],
149        outage_branch_indices: &[usize],
150    ) -> Self {
151        Self::new()
152            .with_monitored_branches(monitored_branch_indices)
153            .with_outage_branches(outage_branch_indices)
154    }
155
156    /// Restrict to specific monitored branches.
157    pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
158        self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
159        self
160    }
161
162    /// Restrict to specific outage branches.
163    pub fn with_outage_branches(mut self, outage_branch_indices: &[usize]) -> Self {
164        self.outage_branch_indices = Some(outage_branch_indices.to_vec());
165        self
166    }
167}
168
169/// Advanced request for one-shot dense all-pairs LODF computation.
170#[derive(Debug, Clone, PartialEq, Default)]
171pub struct LodfMatrixRequest {
172    /// Branch indices for the square LODF matrix. `None` uses all branches.
173    pub branch_indices: Option<Vec<usize>>,
174}
175
176impl LodfMatrixRequest {
177    /// Create a default request (all branches).
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Create a request for the given branch set.
183    pub fn for_branches(branch_indices: &[usize]) -> Self {
184        Self::new().with_branches(branch_indices)
185    }
186
187    /// Restrict to specific branches.
188    pub fn with_branches(mut self, branch_indices: &[usize]) -> Self {
189        self.branch_indices = Some(branch_indices.to_vec());
190        self
191    }
192}
193
194/// Advanced request for one-shot OTDF computation.
195#[derive(Debug, Clone, PartialEq, Default)]
196pub struct OtdfRequest {
197    /// Branch indices to monitor.
198    pub monitored_branch_indices: Vec<usize>,
199    /// Branch indices to outage.
200    pub outage_branch_indices: Vec<usize>,
201    /// Bus indices for OTDF vectors. `None` returns all buses.
202    pub bus_indices: Option<Vec<usize>>,
203    /// Sensitivity options (slack policy).
204    pub options: DcSensitivityOptions,
205}
206
207impl OtdfRequest {
208    /// Create a request for the given monitored and outage branch sets.
209    pub fn new(monitored_branch_indices: &[usize], outage_branch_indices: &[usize]) -> Self {
210        Self {
211            monitored_branch_indices: monitored_branch_indices.to_vec(),
212            outage_branch_indices: outage_branch_indices.to_vec(),
213            bus_indices: None,
214            options: DcSensitivityOptions::default(),
215        }
216    }
217
218    /// Restrict OTDF vectors to specific bus indices.
219    pub fn with_bus_indices(mut self, bus_indices: &[usize]) -> Self {
220        self.bus_indices = Some(bus_indices.to_vec());
221        self
222    }
223
224    /// Set sensitivity options (slack policy).
225    pub fn with_options(mut self, options: DcSensitivityOptions) -> Self {
226        self.options = options;
227        self
228    }
229}
230
231/// Advanced request for one-shot N-2 computation.
232#[derive(Debug, Clone, PartialEq, Default)]
233pub struct N2LodfRequest {
234    /// The `(first, second)` outage branch pair.
235    pub outage_pair: (usize, usize),
236    /// Branch indices to monitor. `None` monitors all branches.
237    pub monitored_branch_indices: Option<Vec<usize>>,
238}
239
240impl N2LodfRequest {
241    /// Create a request for a single N-2 outage pair.
242    pub fn new(outage_pair: (usize, usize)) -> Self {
243        Self {
244            outage_pair,
245            monitored_branch_indices: None,
246        }
247    }
248
249    /// Restrict to specific monitored branches.
250    pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
251        self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
252        self
253    }
254}
255
256/// Advanced request for one-shot batched N-2 computation.
257#[derive(Debug, Clone, PartialEq, Default)]
258pub struct N2LodfBatchRequest {
259    /// The outage pairs to evaluate.
260    pub outage_pairs: Vec<(usize, usize)>,
261    /// Branch indices to monitor. `None` monitors all branches.
262    pub monitored_branch_indices: Option<Vec<usize>>,
263}
264
265impl N2LodfBatchRequest {
266    /// Create a request for the given outage pairs.
267    pub fn new(outage_pairs: &[(usize, usize)]) -> Self {
268        Self {
269            outage_pairs: outage_pairs.to_vec(),
270            monitored_branch_indices: None,
271        }
272    }
273
274    /// Restrict to specific monitored branches.
275    pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
276        self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
277        self
278    }
279}
280
281/// Compute PTDF rows for monitored branches.
282///
283/// Returns a [`PtdfRows`] matrix whose rows are ordered exactly like the
284/// request's `monitored_branch_indices`. Entry `(branch, bus)` is the
285/// sensitivity of flow on that branch to a 1 p.u. injection at `bus`
286/// (slack-bus column is always zero).
287///
288/// # Implementation
289///
290/// Factor B' once via KLU, then solve one linear system per monitored branch
291/// using the shift-factor RHS vector:
292///   `PTDF_row(k) = b_k × B'⁻¹ × e_{f_k, t_k}`
293/// where `e_{f,t}` is +1 at from-bus f, -1 at to-bus t (both in reduced space).
294///
295/// This costs O(n_monitored × nnz(B')) memory — no B'^-1 is ever formed.
296///
297/// # Errors
298///
299/// Returns `DcError` if the B' matrix cannot be factored (disconnected network)
300/// or if a branch index is out of range.
301pub fn compute_ptdf(network: &Network, request: &PtdfRequest) -> Result<PtdfRows, DcError> {
302    let monitored_branch_indices_storage;
303    let monitored_branch_indices =
304        if let Some(indices) = request.monitored_branch_indices.as_deref() {
305            indices
306        } else {
307            monitored_branch_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
308            &monitored_branch_indices_storage
309        };
310    info!(
311        buses = network.n_buses(),
312        monitored = monitored_branch_indices.len(),
313        "computing PTDF rows for monitored branches"
314    );
315    let mut model = PreparedDcStudy::new(network)?;
316    model.compute_ptdf_with_options(
317        monitored_branch_indices,
318        request.bus_indices.as_deref(),
319        &request.options,
320    )
321}
322
323/// Compute LODF values for monitored and outage branch sets.
324///
325/// Returns a [`LodfResult`] of dimensions `monitored.len() × outage.len()` in
326/// the requested branch order, where entry `[row, col]` is the Line Outage
327/// Distribution Factor for monitored branch `monitored[row]` under outage
328/// branch `outage[col]`.
329///
330/// # Implementation
331///
332/// Factors B' once via KLU, then computes each requested outage column lazily
333/// from cached endpoint solves. LODF is derived via the Sherman-Morrison
334/// identity `LODF[l,k] = PTDF_lk / (1 - PTDF_kk)` where `PTDF_lk` is the net
335/// sensitivity of branch `l` to the endpoints of branch `k`.
336///
337/// Bridge lines (where `PTDF_kk ≥ 1 - BRIDGE_THRESHOLD`) get column set to ±∞.
338///
339/// # Use
340///
341/// Use this for N-1 screening, ATC, and any workflow that only needs a subset
342/// of monitored branches and outage branches.
343pub fn compute_lodf(network: &Network, request: &LodfRequest) -> Result<LodfResult, DcError> {
344    let monitored_branch_indices_storage;
345    let monitored_branch_indices =
346        if let Some(indices) = request.monitored_branch_indices.as_deref() {
347            indices
348        } else {
349            monitored_branch_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
350            &monitored_branch_indices_storage
351        };
352    let outage_branch_indices = request
353        .outage_branch_indices
354        .as_deref()
355        .unwrap_or(monitored_branch_indices);
356    info!(
357        monitored = monitored_branch_indices.len(),
358        outage = outage_branch_indices.len(),
359        "computing LODF for monitored/outage branch sets"
360    );
361    let mut model = PreparedDcStudy::new(network)?;
362    let lodf = model.compute_lodf(monitored_branch_indices, outage_branch_indices)?;
363    debug!(
364        rows = lodf.n_rows(),
365        cols = lodf.n_cols(),
366        "LODF subset computed"
367    );
368    Ok(lodf)
369}
370
371/// Compute selected LODF entries as a sparse pair map.
372///
373/// The returned map is keyed as `(monitored_branch_idx, outage_branch_idx)`.
374pub fn compute_lodf_pairs(
375    network: &Network,
376    monitored_branch_indices: &[usize],
377    outage_branch_indices: &[usize],
378) -> Result<LodfPairs, DcError> {
379    info!(
380        monitored = monitored_branch_indices.len(),
381        outage = outage_branch_indices.len(),
382        "computing LODF pairs for monitored/outage branch sets"
383    );
384    let mut model = PreparedDcStudy::new(network)?;
385    model.compute_lodf_pairs(monitored_branch_indices, outage_branch_indices)
386}
387
388/// Compute a dense all-pairs LODF matrix.
389///
390/// The same branch set is used as both the monitored set and outage set.
391pub fn compute_lodf_matrix(
392    network: &Network,
393    request: &LodfMatrixRequest,
394) -> Result<LodfMatrixResult, DcError> {
395    let branch_indices_storage;
396    let branches = if let Some(indices) = request.branch_indices.as_deref() {
397        indices
398    } else {
399        branch_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
400        &branch_indices_storage
401    };
402    info!(
403        branches = branches.len(),
404        "computing LODF matrix via KLU column solves"
405    );
406    let mut model = PreparedDcStudy::new(network)?;
407    let lodf = model.compute_lodf_matrix(branches)?;
408    debug!(
409        rows = lodf.n_rows(),
410        cols = lodf.n_cols(),
411        "LODF matrix computed"
412    );
413    Ok(lodf)
414}
415
416/// Compute Outage Transfer Distribution Factors (OTDF).
417///
418/// `OTDF[(m, k)][bus] = PTDF[m][bus] + LODF[m, k] × PTDF[k][bus]`
419///
420/// This is the post-contingency sensitivity of flow on monitored branch `m`
421/// to a 1 p.u. injection at `bus` when outage branch `k` is tripped.
422///
423/// # Implementation
424///
425/// Factors B' once. Computes PTDF rows for the union of monitored and outage
426/// branch indices (one KLU solve per unique branch). LODF\[m,k\] derived via the
427/// Sherman-Morrison identity identical to `compute_lodf_matrix`.
428///
429/// Memory: O((n_monitored + n_outage) × n_buses) for the PTDF rows.
430///
431/// # Errors
432///
433/// Returns `DcError` if the B' matrix is singular (disconnected network) or
434/// any branch index is out of range.
435pub fn compute_otdf(network: &Network, request: &OtdfRequest) -> Result<OtdfResult, DcError> {
436    compute_otdf_with_options(
437        network,
438        &request.monitored_branch_indices,
439        &request.outage_branch_indices,
440        request.bus_indices.as_deref(),
441        &request.options,
442    )
443}
444
445/// Compute OTDF for explicit monitored and outage branch sets with
446/// explicit sensitivity options and an optional bus subset.
447fn compute_otdf_with_options(
448    network: &Network,
449    monitored_branch_indices: &[usize],
450    outage_branch_indices: &[usize],
451    bus_indices: Option<&[usize]>,
452    options: &DcSensitivityOptions,
453) -> Result<OtdfResult, DcError> {
454    info!(
455        monitored = monitored_branch_indices.len(),
456        outage = outage_branch_indices.len(),
457        buses = bus_indices
458            .map(|buses| buses.len())
459            .unwrap_or(network.n_buses()),
460        "computing OTDF for monitored/outage branch sets"
461    );
462    let mut model = PreparedDcStudy::new(network)?;
463    model.compute_otdf_with_options(
464        monitored_branch_indices,
465        outage_branch_indices,
466        bus_indices,
467        options,
468    )
469}
470
471pub(crate) fn collect_selected_bus_indices(
472    network: &Network,
473    bus_indices: Option<&[usize]>,
474) -> Result<Vec<usize>, DcError> {
475    let selected_bus_indices = bus_indices
476        .map(|indices| indices.to_vec())
477        .unwrap_or_else(|| (0..network.n_buses()).collect());
478    let mut seen = vec![false; network.n_buses()];
479    for &bus_idx in &selected_bus_indices {
480        if bus_idx >= network.n_buses() {
481            return Err(DcError::InvalidNetwork(format!(
482                "OTDF bus index {bus_idx} out of range (len={})",
483                network.n_buses()
484            )));
485        }
486        if std::mem::replace(&mut seen[bus_idx], true) {
487            return Err(DcError::InvalidNetwork(format!(
488                "OTDF bus index {bus_idx} requested more than once"
489            )));
490        }
491    }
492    Ok(selected_bus_indices)
493}
494
495pub(crate) fn compute_otdf_from_ptdf(
496    network: &Network,
497    monitored_branch_indices: &[usize],
498    outage_branch_indices: &[usize],
499    bus_indices: &[usize],
500    ptdf: &PtdfRows,
501) -> Result<OtdfResult, DcError> {
502    let bus_map = network.bus_index_map();
503    let mut result = OtdfResult::new(
504        monitored_branch_indices,
505        outage_branch_indices,
506        bus_indices,
507        network.n_branches(),
508        network.n_buses(),
509    );
510
511    for (outage_pos, &outage_branch_idx) in outage_branch_indices.iter().enumerate() {
512        let branch_k = match network.branches.get(outage_branch_idx) {
513            Some(branch) if branch.in_service => branch,
514            _ => {
515                for (monitored_pos, &monitored_branch_idx) in
516                    monitored_branch_indices.iter().enumerate()
517                {
518                    if let Some(ptdf_row_m) = ptdf.row(monitored_branch_idx) {
519                        let target = result.vector_at_mut(monitored_pos, outage_pos);
520                        for (value_pos, &bus_idx) in bus_indices.iter().enumerate() {
521                            target[value_pos] = ptdf_row_m[bus_idx];
522                        }
523                    }
524                }
525                continue;
526            }
527        };
528
529        let Some(&from_k) = bus_map.get(&branch_k.from_bus) else {
530            continue;
531        };
532        let Some(&to_k) = bus_map.get(&branch_k.to_bus) else {
533            continue;
534        };
535        let ptdf_row_k = ptdf.row(outage_branch_idx).ok_or_else(|| {
536            DcError::InvalidNetwork(format!(
537                "PTDF row for outage branch {outage_branch_idx} was not computed"
538            ))
539        })?;
540
541        let ptdf_kk = ptdf_row_k[from_k] - ptdf_row_k[to_k];
542        let is_bridge = (1.0 - ptdf_kk).abs() < BRIDGE_THRESHOLD;
543
544        for (monitored_pos, &monitored_branch_idx) in monitored_branch_indices.iter().enumerate() {
545            let target = result.vector_at_mut(monitored_pos, outage_pos);
546
547            if is_bridge {
548                target.fill(f64::INFINITY);
549                continue;
550            }
551
552            let ptdf_row_m = ptdf.row(monitored_branch_idx).ok_or_else(|| {
553                DcError::InvalidNetwork(format!(
554                    "PTDF row for monitored branch {monitored_branch_idx} was not computed"
555                ))
556            })?;
557
558            let ptdf_mk = ptdf_row_m[from_k] - ptdf_row_m[to_k];
559            let lodf_mk = ptdf_mk / (1.0 - ptdf_kk);
560            for (value_pos, &bus_idx) in bus_indices.iter().enumerate() {
561                target[value_pos] = ptdf_row_m[bus_idx] + lodf_mk * ptdf_row_k[bus_idx];
562            }
563        }
564    }
565
566    Ok(result)
567}
568
569/// Second-order LODF sensitivity for a simultaneous double outage (N-2).
570///
571/// Given the simultaneous outage of branches `k` and `l`, computes the N-2
572/// sensitivity coefficient for each branch in `monitored_indices`.
573///
574/// # Mathematical Background — Woodbury Rank-2 Update
575///
576/// ```text
577/// LODF2(m; k,l) = [LODF(m,k) + LODF(m,l) × LODF(l,k)]
578///                 / [1 - LODF(l,k) × LODF(k,l)]
579/// ```
580///
581/// # Full Post-Outage Prediction
582///
583/// ```text
584/// F_m_post ≈ F_m_pre
585///           + compute_n2_lodf(net, (j,k), monitored)[m] × F_j_pre
586///           + compute_n2_lodf(net, (k,j), monitored)[m] × F_k_pre
587/// ```
588///
589/// # Returns
590/// [`N2LodfResult`] ordered exactly like `monitored_indices`.
591pub fn compute_n2_lodf(
592    network: &Network,
593    request: &N2LodfRequest,
594) -> Result<N2LodfResult, DcError> {
595    let monitored_indices_storage;
596    let monitored_indices = if let Some(indices) = request.monitored_branch_indices.as_deref() {
597        indices
598    } else {
599        monitored_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
600        &monitored_indices_storage
601    };
602    info!(
603        outage_k = request.outage_pair.0,
604        outage_l = request.outage_pair.1,
605        monitored = monitored_indices.len(),
606        "computing N-2 LODF for branch pair"
607    );
608    let mut model = PreparedDcStudy::new(network)?;
609    model.compute_n2_lodf(request.outage_pair, monitored_indices)
610}
611
612/// Compute batched N-2 LODF for multiple simultaneous double-outage pairs.
613pub fn compute_n2_lodf_batch(
614    network: &Network,
615    request: &N2LodfBatchRequest,
616) -> Result<N2LodfBatchResult, DcError> {
617    let monitored_indices_storage;
618    let monitored_indices = if let Some(indices) = request.monitored_branch_indices.as_deref() {
619        indices
620    } else {
621        monitored_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
622        &monitored_indices_storage
623    };
624    info!(
625        outage_pairs = request.outage_pairs.len(),
626        monitored = monitored_indices.len(),
627        "computing N-2 LODF batch for branch pairs"
628    );
629    let mut model = PreparedDcStudy::new(network)?;
630    model.compute_n2_lodf_batch(&request.outage_pairs, monitored_indices)
631}
632
633pub(crate) fn collect_unique_branch_indices(
634    n_branches: usize,
635    monitored_branch_indices: &[usize],
636    outage_branch_indices: &[usize],
637) -> Result<Vec<usize>, DcError> {
638    let mut seen = vec![false; n_branches];
639    let mut branch_indices =
640        Vec::with_capacity(monitored_branch_indices.len() + outage_branch_indices.len());
641
642    for &branch_idx in monitored_branch_indices
643        .iter()
644        .chain(outage_branch_indices.iter())
645    {
646        if branch_idx >= n_branches {
647            return Err(DcError::InvalidNetwork(format!(
648                "branch index {branch_idx} out of range for network with {n_branches} branches"
649            )));
650        }
651        if !seen[branch_idx] {
652            seen[branch_idx] = true;
653            branch_indices.push(branch_idx);
654        }
655    }
656
657    Ok(branch_indices)
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::test_util::*;
664
665    fn load_case9() -> Network {
666        load_net("case9")
667    }
668
669    fn load_case14() -> Network {
670        load_net("case14")
671    }
672
673    fn build_balanced_headroom_ptdf_network() -> Network {
674        use surge_network::network::{Branch, Bus, BusType, Generator, Load};
675
676        let mut net = Network::new("balanced_headroom_ptdf");
677        net.base_mva = 100.0;
678
679        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
680        net.buses.push(Bus::new(2, BusType::PV, 230.0));
681        net.buses.push(Bus::new(3, BusType::PV, 230.0));
682        net.buses.push(Bus::new(4, BusType::PQ, 230.0));
683
684        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
685        net.branches.push(Branch::new_line(1, 3, 0.0, 0.1, 0.0));
686        net.branches.push(Branch::new_line(2, 4, 0.0, 0.1, 0.0));
687        net.branches.push(Branch::new_line(3, 4, 0.0, 0.1, 0.0));
688        net.branches.push(Branch::new_line(2, 3, 0.0, 0.2, 0.0));
689
690        let mut g1 = Generator::new(1, 0.0, 1.0);
691        g1.pmax = 140.0;
692        net.generators.push(g1);
693
694        let mut g2 = Generator::new(2, 60.0, 1.0);
695        g2.pmin = 10.0;
696        g2.pmax = 90.0;
697        net.generators.push(g2);
698
699        let mut g3 = Generator::new(3, 60.0, 1.0);
700        g3.pmin = 20.0;
701        g3.pmax = 80.0;
702        net.generators.push(g3);
703
704        net.loads.push(Load::new(4, 120.0, 0.0));
705        net
706    }
707
708    fn get(ptdf: &PtdfRows, branch: usize, bus: usize) -> f64 {
709        ptdf.get(branch, bus)
710    }
711
712    #[test]
713    fn test_ptdf_case9() {
714        let net = load_case9();
715        let n_br = net.n_branches();
716        let n_bus = net.n_buses();
717        let all: Vec<usize> = (0..n_br).collect();
718        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
719
720        assert_eq!(ptdf.n_rows(), n_br);
721        for row_pos in 0..ptdf.n_rows() {
722            assert_eq!(ptdf.row_at(row_pos).len(), n_bus);
723        }
724
725        // PTDF w.r.t. slack bus should be all zeros
726        let slack_idx = net.slack_bus_index().unwrap();
727        for l in 0..n_br {
728            assert!(get(&ptdf, l, slack_idx).abs() < 1e-10);
729        }
730    }
731
732    #[test]
733    fn test_lodf_case9() {
734        let net = load_case9();
735        let n_br = net.n_branches();
736        let all: Vec<usize> = (0..n_br).collect();
737        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
738
739        assert_eq!(lodf.n_rows(), n_br);
740        assert_eq!(lodf.n_cols(), n_br);
741
742        for k in 0..n_br {
743            if lodf[(k, k)].is_infinite() {
744                continue;
745            }
746            assert!(
747                (lodf[(k, k)] - (-1.0)).abs() < 1e-10,
748                "LODF diagonal [{k},{k}] = {}, expected -1.0",
749                lodf[(k, k)]
750            );
751        }
752    }
753
754    #[test]
755    fn test_lodf_subset_matches_dense_case14() {
756        let net = load_case14();
757        let all: Vec<usize> = (0..net.n_branches()).collect();
758        let monitored = vec![0, 2, 5, 7];
759        let outages = vec![1, 6, 8];
760
761        let dense = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
762        let subset = compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages)).unwrap();
763
764        assert_eq!(subset.n_rows(), monitored.len());
765        assert_eq!(subset.n_cols(), outages.len());
766        for (i, &l) in monitored.iter().enumerate() {
767            for (j, &k) in outages.iter().enumerate() {
768                let subset_val = subset[(i, j)];
769                let dense_val = dense[(l, k)];
770                assert!(
771                    (subset_val - dense_val).abs() < 1e-12
772                        || (subset_val.is_infinite() && dense_val.is_infinite()),
773                    "subset LODF[{l},{k}] = {subset_val}, dense = {dense_val}"
774                );
775            }
776        }
777    }
778
779    #[test]
780    fn test_lodf_pairs_match_dense_case14() {
781        let net = load_case14();
782        let all: Vec<usize> = (0..net.n_branches()).collect();
783        let monitored = vec![0, 2, 5, 7];
784        let outages = vec![1, 6, 8];
785
786        let dense = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
787        let pairs = compute_lodf_pairs(&net, &monitored, &outages).unwrap();
788
789        assert_eq!(pairs.len(), monitored.len() * outages.len());
790        for &l in &monitored {
791            for &k in &outages {
792                let pair_val = pairs.get(l, k).expect("missing pair entry in sparse map");
793                let dense_val = dense[(l, k)];
794                assert!(
795                    (pair_val - dense_val).abs() < 1e-12
796                        || (pair_val.is_infinite() && dense_val.is_infinite()),
797                    "pair LODF[{l},{k}] = {pair_val}, dense = {dense_val}"
798                );
799            }
800        }
801    }
802
803    /// Pin specific PTDF entries for case9 against computed reference values.
804    #[test]
805    fn test_ptdf_case9_values() {
806        let net = load_case9();
807        let n_br = net.n_branches();
808        let all: Vec<usize> = (0..n_br).collect();
809        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
810        let tol = 1e-10;
811
812        assert!(
813            (get(&ptdf, 0, 1) - (-1.0)).abs() < tol,
814            "PTDF[0,1] = {}, expected -1.0",
815            get(&ptdf, 0, 1)
816        );
817        assert!(
818            (get(&ptdf, 0, 4) - (-1.0)).abs() < tol,
819            "PTDF[0,4] = {}, expected -1.0",
820            get(&ptdf, 0, 4)
821        );
822        assert!(
823            (get(&ptdf, 0, 8) - (-1.0)).abs() < tol,
824            "PTDF[0,8] = {}, expected -1.0",
825            get(&ptdf, 0, 8)
826        );
827
828        assert!(
829            (get(&ptdf, 1, 1) - (-0.361339600470035)).abs() < tol,
830            "PTDF[1,1] = {}",
831            get(&ptdf, 1, 1)
832        );
833        assert!(
834            (get(&ptdf, 1, 4) - (-0.864864864864865)).abs() < tol,
835            "PTDF[1,4] = {}",
836            get(&ptdf, 1, 4)
837        );
838        assert!(
839            (get(&ptdf, 1, 8) - (-0.124853113983549)).abs() < tol,
840            "PTDF[1,8] = {}",
841            get(&ptdf, 1, 8)
842        );
843
844        assert!(
845            (get(&ptdf, 3, 2) - 1.0).abs() < tol,
846            "PTDF[3,2] = {}",
847            get(&ptdf, 3, 2)
848        );
849        assert!(
850            get(&ptdf, 3, 1).abs() < tol,
851            "PTDF[3,1] = {}",
852            get(&ptdf, 3, 1)
853        );
854        assert!(
855            get(&ptdf, 3, 6).abs() < tol,
856            "PTDF[3,6] = {}",
857            get(&ptdf, 3, 6)
858        );
859
860        assert!(
861            (get(&ptdf, 6, 1) - (-1.0)).abs() < tol,
862            "PTDF[6,1] = {}",
863            get(&ptdf, 6, 1)
864        );
865        assert!(
866            get(&ptdf, 6, 4).abs() < tol,
867            "PTDF[6,4] = {}",
868            get(&ptdf, 6, 4)
869        );
870
871        assert!(
872            (get(&ptdf, 7, 1) - 0.638660399529965).abs() < tol,
873            "PTDF[7,1] = {}",
874            get(&ptdf, 7, 1)
875        );
876        assert!(
877            (get(&ptdf, 7, 6) - 0.532902467685077).abs() < tol,
878            "PTDF[7,6] = {}",
879            get(&ptdf, 7, 6)
880        );
881        assert!(
882            (get(&ptdf, 7, 8) - (-0.124853113983548)).abs() < tol,
883            "PTDF[7,8] = {}",
884            get(&ptdf, 7, 8)
885        );
886
887        // Slack column always zero
888        let slack_idx = net.slack_bus_index().unwrap();
889        for l in 0..n_br {
890            assert!(
891                get(&ptdf, l, slack_idx).abs() < tol,
892                "PTDF[{l},slack={slack_idx}] = {} should be 0",
893                get(&ptdf, l, slack_idx)
894            );
895        }
896    }
897
898    /// Pin specific LODF entries for case14.
899    #[test]
900    fn test_lodf_case14_values() {
901        let net = load_case14();
902        let n_br = net.n_branches();
903        let all: Vec<usize> = (0..n_br).collect();
904        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
905        let tol = 1e-10;
906
907        assert!(
908            (lodf[(1, 0)] - 1.0).abs() < tol,
909            "LODF[1,0] = {}",
910            lodf[(1, 0)]
911        );
912        assert!(
913            (lodf[(2, 0)] - (-0.168846208748234)).abs() < tol,
914            "LODF[2,0] = {}",
915            lodf[(2, 0)]
916        );
917        assert!(
918            (lodf[(4, 0)] - (-0.477794835783875)).abs() < tol,
919            "LODF[4,0] = {}",
920            lodf[(4, 0)]
921        );
922        assert!(
923            (lodf[(6, 0)] - (-0.493343980479742)).abs() < tol,
924            "LODF[6,0] = {}",
925            lodf[(6, 0)]
926        );
927        assert!(
928            (lodf[(0, 2)] - (-0.207667214399152)).abs() < tol,
929            "LODF[0,2] = {}",
930            lodf[(0, 2)]
931        );
932        assert!(
933            (lodf[(5, 2)] - (-1.0)).abs() < tol,
934            "LODF[5,2] = {}",
935            lodf[(5, 2)]
936        );
937        assert!(
938            (lodf[(3, 2)] - 0.455285600326033).abs() < tol,
939            "LODF[3,2] = {}",
940            lodf[(3, 2)]
941        );
942        assert!(
943            (lodf[(3, 6)] - (-0.514489688099607)).abs() < tol,
944            "LODF[3,6] = {}",
945            lodf[(3, 6)]
946        );
947        assert!(
948            (lodf[(4, 6)] - 0.470460951978899).abs() < tol,
949            "LODF[4,6] = {}",
950            lodf[(4, 6)]
951        );
952        assert!(
953            (lodf[(7, 6)] - 0.151344601496801).abs() < tol,
954            "LODF[7,6] = {}",
955            lodf[(7, 6)]
956        );
957        assert!(
958            (lodf[(7, 8)] - 0.638989500215690).abs() < tol,
959            "LODF[7,8] = {}",
960            lodf[(7, 8)]
961        );
962
963        for k in 0..lodf.n_cols() {
964            if lodf[(k, k)].is_finite() {
965                assert!(
966                    (lodf[(k, k)] - (-1.0)).abs() < tol,
967                    "LODF diagonal [{k},{k}] = {}, expected -1.0",
968                    lodf[(k, k)]
969                );
970            }
971        }
972    }
973
974    /// Validate PTDF-LODF consistency for case14.
975    #[test]
976    fn test_ptdf_lodf_consistency() {
977        let net = load_case14();
978        let bus_map = net.bus_index_map();
979        let n_br = net.n_branches();
980        let all: Vec<usize> = (0..n_br).collect();
981        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
982        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
983        let tol = 1e-8;
984
985        for k in 0..n_br {
986            let branch_k = &net.branches[k];
987            if !branch_k.in_service {
988                continue;
989            }
990            let from_k = bus_map[&branch_k.from_bus];
991            let to_k = bus_map[&branch_k.to_bus];
992            let ptdf_kk = get(&ptdf, k, from_k) - get(&ptdf, k, to_k);
993
994            if (1.0 - ptdf_kk).abs() < 1e-10 {
995                continue;
996            }
997            let denom = 1.0 - ptdf_kk;
998
999            for l in 0..n_br {
1000                if l == k {
1001                    assert!(
1002                        (lodf[(l, k)] - (-1.0)).abs() < tol,
1003                        "LODF[{l},{k}] = {}, expected -1.0",
1004                        lodf[(l, k)]
1005                    );
1006                    continue;
1007                }
1008                let ptdf_lk = get(&ptdf, l, from_k) - get(&ptdf, l, to_k);
1009                let expected = ptdf_lk / denom;
1010                assert!(
1011                    (lodf[(l, k)] - expected).abs() < tol,
1012                    "LODF[{l},{k}] = {}, expected {} (diff = {:.2e})",
1013                    lodf[(l, k)],
1014                    expected,
1015                    (lodf[(l, k)] - expected).abs()
1016                );
1017            }
1018        }
1019    }
1020
1021    /// Validate DC flows equal PTDF × injections for case9.
1022    #[test]
1023    fn test_ptdf_flow_reconstruction() {
1024        let net = load_case9();
1025        let n_br = net.n_branches();
1026        let n_bus = net.n_buses();
1027        let all: Vec<usize> = (0..n_br).collect();
1028        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
1029        let dc_result = crate::solver::solve_dc(&net).expect("DC solve failed");
1030        let tol = 1e-8;
1031
1032        let p_inj = net.bus_p_injection_pu();
1033        for l in 0..n_br {
1034            let reconstructed: f64 = (0..n_bus).map(|k| get(&ptdf, l, k) * p_inj[k]).sum();
1035            assert!(
1036                (reconstructed - dc_result.branch_p_flow[l]).abs() < tol,
1037                "Branch {l}: PTDF*P_inj = {:.10}, DC flow = {:.10} (diff = {:.2e})",
1038                reconstructed,
1039                dc_result.branch_p_flow[l],
1040                (reconstructed - dc_result.branch_p_flow[l]).abs()
1041            );
1042        }
1043    }
1044
1045    /// Validate DC flows equal PTDF × injections for case14.
1046    #[test]
1047    fn test_ptdf_flow_reconstruction_case14() {
1048        let net = load_case14();
1049        let n_br = net.n_branches();
1050        let n_bus = net.n_buses();
1051        let all: Vec<usize> = (0..n_br).collect();
1052        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
1053        let dc_result = crate::solver::solve_dc(&net).expect("DC solve failed");
1054        let tol = 1e-8;
1055
1056        let p_inj = net.bus_p_injection_pu();
1057        for l in 0..n_br {
1058            let reconstructed: f64 = (0..n_bus).map(|k| get(&ptdf, l, k) * p_inj[k]).sum();
1059            assert!(
1060                (reconstructed - dc_result.branch_p_flow[l]).abs() < tol,
1061                "Branch {l}: PTDF*P_inj = {:.10}, DC flow = {:.10} (diff = {:.2e})",
1062                reconstructed,
1063                dc_result.branch_p_flow[l],
1064                (reconstructed - dc_result.branch_p_flow[l]).abs()
1065            );
1066        }
1067    }
1068
1069    /// Validate LODF by outaging branches and re-solving DC PF.
1070    #[test]
1071    fn test_lodf_case14_outage_validation() {
1072        let net = load_case14();
1073        let n_br = net.n_branches();
1074        let all: Vec<usize> = (0..n_br).collect();
1075        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1076        let tol = 1e-8;
1077
1078        let base_result = crate::solver::solve_dc(&net).expect("base DC solve failed");
1079
1080        for &k in &[2usize, 6, 8] {
1081            if !lodf[(k, k)].is_finite() {
1082                continue;
1083            }
1084            let mut net_outaged = net.clone();
1085            net_outaged.branches[k].in_service = false;
1086            let outaged_result =
1087                crate::solver::solve_dc(&net_outaged).expect("outage DC solve failed");
1088            let flow_k_pre = base_result.branch_p_flow[k];
1089
1090            for l in 0..n_br {
1091                if l == k || !net.branches[l].in_service {
1092                    continue;
1093                }
1094                let predicted = base_result.branch_p_flow[l] + lodf[(l, k)] * flow_k_pre;
1095                let actual = outaged_result.branch_p_flow[l];
1096                assert!(
1097                    (predicted - actual).abs() < tol,
1098                    "Outage {k}: branch {l} predicted={predicted:.10}, actual={actual:.10} (diff={:.2e})",
1099                    (predicted - actual).abs()
1100                );
1101            }
1102        }
1103    }
1104
1105    /// Case9 LODF specific values.
1106    #[test]
1107    fn test_lodf_case9_values() {
1108        let net = load_case9();
1109        let n_br = net.n_branches();
1110        let all: Vec<usize> = (0..n_br).collect();
1111        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1112        let tol = 1e-8;
1113
1114        assert!(lodf[(0, 0)].is_infinite(), "Branch 0 should be a bridge");
1115        assert!(lodf[(0, 3)].is_infinite(), "Branch 3 should be a bridge");
1116        assert!(lodf[(0, 6)].is_infinite(), "Branch 6 should be a bridge");
1117
1118        assert!(
1119            (lodf[(2, 1)] - (-1.0)).abs() < tol,
1120            "LODF[2,1] = {}",
1121            lodf[(2, 1)]
1122        );
1123        assert!(
1124            (lodf[(4, 1)] - (-1.0)).abs() < tol,
1125            "LODF[4,1] = {}",
1126            lodf[(4, 1)]
1127        );
1128        assert!(
1129            (lodf[(7, 1)] - (-1.0)).abs() < tol,
1130            "LODF[7,1] = {}",
1131            lodf[(7, 1)]
1132        );
1133        assert!(
1134            (lodf[(8, 1)] - (-1.0)).abs() < tol,
1135            "LODF[8,1] = {}",
1136            lodf[(8, 1)]
1137        );
1138        assert!(
1139            (lodf[(1, 7)] - (-1.0)).abs() < tol,
1140            "LODF[1,7] = {}",
1141            lodf[(1, 7)]
1142        );
1143        assert!(
1144            (lodf[(5, 7)] - (-1.0)).abs() < tol,
1145            "LODF[5,7] = {}",
1146            lodf[(5, 7)]
1147        );
1148        assert!(
1149            (lodf[(8, 7)] - (-1.0)).abs() < tol,
1150            "LODF[8,7] = {}",
1151            lodf[(8, 7)]
1152        );
1153        assert!(lodf[(3, 0)].is_infinite(), "LODF[3,0] should be Inf");
1154    }
1155
1156    /// Validate compute_n2_lodf (Woodbury rank-2) against brute-force double outage.
1157    #[test]
1158    fn test_lodf_n2_brute_force_case14() {
1159        let net = load_case14();
1160        let n_br = net.n_branches();
1161        let all: Vec<usize> = (0..n_br).collect();
1162        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1163        let tol = 1e-6;
1164
1165        let base = crate::solver::solve_dc(&net).expect("base DC solve");
1166
1167        for &(j, k) in &[(2usize, 6usize), (2, 8), (6, 8)] {
1168            if !lodf[(j, j)].is_finite() || !lodf[(k, k)].is_finite() {
1169                continue;
1170            }
1171            let mut net_outaged = net.clone();
1172            net_outaged.branches[j].in_service = false;
1173            net_outaged.branches[k].in_service = false;
1174            let outaged = match crate::solver::solve_dc(&net_outaged) {
1175                Ok(r) => r,
1176                Err(_) => continue,
1177            };
1178
1179            let lodf2_jk = match compute_n2_lodf(
1180                &net,
1181                &N2LodfRequest::new((j, k)).with_monitored_branches(&all),
1182            ) {
1183                Ok(v) => v,
1184                Err(_) => continue,
1185            };
1186            let lodf2_kj = match compute_n2_lodf(
1187                &net,
1188                &N2LodfRequest::new((k, j)).with_monitored_branches(&all),
1189            ) {
1190                Ok(v) => v,
1191                Err(_) => continue,
1192            };
1193
1194            for m in (0..n_br).filter(|&m| m != j && m != k && net.branches[m].in_service) {
1195                let predicted = base.branch_p_flow[m]
1196                    + lodf2_jk[m] * base.branch_p_flow[j]
1197                    + lodf2_kj[m] * base.branch_p_flow[k];
1198                let actual = outaged.branch_p_flow[m];
1199                assert!(
1200                    (predicted - actual).abs() < tol,
1201                    "N-2 Woodbury error for outage ({j},{k}), monitored {m}: \
1202                     predicted={predicted:.10}, actual={actual:.10}, diff={:.2e}",
1203                    (predicted - actual).abs()
1204                );
1205            }
1206        }
1207    }
1208
1209    /// PTDF for out-of-service branch is all zeros.
1210    #[test]
1211    fn test_ptdf_zero_for_oos_branch() {
1212        let mut net = load_case9();
1213        net.branches[2].in_service = false;
1214
1215        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&[2]))
1216            .expect("compute_ptdf should not fail");
1217        let col = ptdf.row(2).expect("PTDF row for monitored branch");
1218        for &v in col.iter() {
1219            assert_eq!(v, 0.0, "out-of-service branch column should be all zero");
1220        }
1221    }
1222
1223    /// PST-04: N-2 LODF result length matches monitored_indices length.
1224    #[test]
1225    fn test_pst04_n2_lodf_case9() {
1226        let net = load_case14();
1227
1228        let n_monitored = 5;
1229        let monitored: Vec<usize> = (5..5 + n_monitored).collect();
1230        let result = compute_n2_lodf(
1231            &net,
1232            &N2LodfRequest::new((0, 2)).with_monitored_branches(&monitored),
1233        )
1234        .expect("N-2 LODF should succeed for case14 pair (0,2)");
1235
1236        assert_eq!(
1237            result.len(),
1238            n_monitored,
1239            "result length should equal n_monitored"
1240        );
1241    }
1242
1243    // -----------------------------------------------------------------------
1244    // OTDF tests
1245    // -----------------------------------------------------------------------
1246
1247    /// Construct a simple 3-bus loop network for OTDF unit tests.
1248    ///
1249    /// Topology: Bus 1 (slack) -- Bus 2 -- Bus 3 -- Bus 1
1250    /// Three branches: 0: 1→2 (x=0.2), 1: 2→3 (x=0.4), 2: 3→1 (x=0.3)
1251    fn make_3bus_loop() -> surge_network::Network {
1252        use surge_network::Network;
1253        use surge_network::network::{Branch, Bus, BusType, Generator, Load};
1254
1255        let mut net = Network::new("3bus_loop");
1256        net.base_mva = 100.0;
1257
1258        net.buses.push(Bus::new(1, BusType::Slack, 100.0));
1259        net.buses.push(Bus::new(2, BusType::PQ, 100.0));
1260        net.buses.push(Bus::new(3, BusType::PQ, 100.0));
1261        net.loads.push(Load::new(2, 100.0, 0.0));
1262        net.loads.push(Load::new(3, 50.0, 0.0));
1263
1264        net.generators.push(Generator::new(1, 150.0, 1.0));
1265
1266        net.branches.push(Branch::new_line(1, 2, 0.0, 0.2, 0.0));
1267        net.branches.push(Branch::new_line(2, 3, 0.0, 0.4, 0.0));
1268        net.branches.push(Branch::new_line(3, 1, 0.0, 0.3, 0.0));
1269
1270        net
1271    }
1272
1273    /// Construct a simple 2-bus network with a single bridge branch.
1274    fn make_2bus_bridge() -> surge_network::Network {
1275        use surge_network::Network;
1276        use surge_network::network::{Branch, Bus, BusType, Generator, Load};
1277
1278        let mut net = Network::new("2bus_bridge");
1279        net.base_mva = 100.0;
1280
1281        net.buses.push(Bus::new(1, BusType::Slack, 100.0));
1282        net.buses.push(Bus::new(2, BusType::PQ, 100.0));
1283        net.loads.push(Load::new(2, 100.0, 0.0));
1284
1285        net.generators.push(Generator::new(1, 100.0, 1.0));
1286        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
1287
1288        net
1289    }
1290
1291    /// OTDF analytical check: OTDF[(m,k)][bus] == PTDF[m][bus] + LODF[m,k] * PTDF[k][bus]
1292    #[test]
1293    fn test_otdf_case9_analytical() {
1294        // Use the 3-bus loop — no external data required.
1295        let net = make_3bus_loop();
1296        let n_br = net.n_branches();
1297        let n_bus = net.n_buses();
1298        let bus_map = net.bus_index_map();
1299        let all: Vec<usize> = (0..n_br).collect();
1300
1301        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).expect("PTDF failed");
1302
1303        // Outage branch 1 (bus 2→3): not a bridge in a 3-bus loop
1304        let outage = vec![1usize];
1305        let otdf = compute_otdf(&net, &OtdfRequest::new(&all, &outage)).expect("OTDF failed");
1306
1307        let branch_k = &net.branches[1];
1308        let from_k = bus_map[&branch_k.from_bus];
1309        let to_k = bus_map[&branch_k.to_bus];
1310        let ptdf_row_k = ptdf.row(1).unwrap();
1311        let ptdf_kk = ptdf_row_k[from_k] - ptdf_row_k[to_k];
1312        let denom = 1.0 - ptdf_kk;
1313
1314        for &m in &all {
1315            let ptdf_row_m = ptdf.row(m).unwrap();
1316            let ptdf_mk = ptdf_row_m[from_k] - ptdf_row_m[to_k];
1317            let lodf_mk = if denom.abs() < BRIDGE_THRESHOLD {
1318                0.0
1319            } else {
1320                ptdf_mk / denom
1321            };
1322
1323            let otdf_vec = otdf.vector(m, 1).expect("OTDF entry missing");
1324            assert_eq!(
1325                otdf_vec.len(),
1326                n_bus,
1327                "OTDF vector wrong length for ({m},1)"
1328            );
1329
1330            for b in 0..n_bus {
1331                let expected = ptdf_row_m[b] + lodf_mk * ptdf_row_k[b];
1332                let actual = otdf_vec[b];
1333                assert!(
1334                    (actual - expected).abs() < 1e-12,
1335                    "OTDF[({m},1)][{b}] = {actual:.15}, expected {expected:.15} (diff={:.2e})",
1336                    (actual - expected).abs()
1337                );
1338            }
1339        }
1340    }
1341
1342    /// OTDF for a bridge line should be all INFINITY.
1343    #[test]
1344    fn test_otdf_bridge_line() {
1345        let net = make_2bus_bridge();
1346        let n_bus = net.n_buses();
1347
1348        let otdf = compute_otdf(&net, &OtdfRequest::new(&[0], &[0]))
1349            .expect("OTDF should not error on bridge");
1350        let vec = otdf.vector(0, 0).expect("entry (0,0) must exist");
1351        assert_eq!(vec.len(), n_bus, "OTDF vector wrong length");
1352        for (b, &v) in vec.iter().enumerate() {
1353            assert!(
1354                v.is_infinite(),
1355                "OTDF[(0,0)][{b}] = {v}, expected INFINITY (bridge line)"
1356            );
1357        }
1358    }
1359
1360    /// Self-consistency: OTDF[(m,m)] formula check using the direct PTDF values.
1361    #[test]
1362    fn test_otdf_self_consistency() {
1363        let net = make_3bus_loop();
1364        let n_br = net.n_branches();
1365        let bus_map = net.bus_index_map();
1366        let all: Vec<usize> = (0..n_br).collect();
1367
1368        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).expect("PTDF failed");
1369
1370        // For m == k (self-LODF = -1 by LODF identity), verify the formula gives
1371        // OTDF[(m,m)][from_m] - OTDF[(m,m)][to_m] = LODF[m,m] = -1.
1372        for &m in &all {
1373            let otdf = compute_otdf(&net, &OtdfRequest::new(&[m], &[m])).expect("OTDF failed");
1374            let otdf_vec = match otdf.vector(m, m) {
1375                Some(v) => v,
1376                None => continue,
1377            };
1378
1379            // Skip bridge lines
1380            if otdf_vec.iter().any(|v| v.is_infinite()) {
1381                continue;
1382            }
1383
1384            let branch_m = &net.branches[m];
1385            let from_m = bus_map[&branch_m.from_bus];
1386            let to_m = bus_map[&branch_m.to_bus];
1387
1388            // Derive LODF[m,m] from PTDF
1389            let ptdf_row_m = ptdf.row(m).unwrap();
1390            let ptdf_mm = ptdf_row_m[from_m] - ptdf_row_m[to_m];
1391            let denom = 1.0 - ptdf_mm;
1392            if denom.abs() < 1e-6 {
1393                continue;
1394            }
1395            let lodf_mm = ptdf_mm / denom; // should be close to -1 for self
1396
1397            // OTDF endpoint difference should equal lodf_mm (times b_m / b_m = 1 in PTDF space)
1398            let otdf_mm = otdf_vec[from_m] - otdf_vec[to_m];
1399            assert!(
1400                (otdf_mm - lodf_mm).abs() < 1e-12,
1401                "OTDF[({m},{m})][from-to] = {otdf_mm:.15}, LODF[{m},{m}] = {lodf_mm:.15}"
1402            );
1403        }
1404    }
1405
1406    /// OTDF matches formula: independently compute PTDF + LODF × PTDF_outage.
1407    #[test]
1408    fn test_otdf_matches_formula() {
1409        let net = make_3bus_loop();
1410        let n_br = net.n_branches();
1411        let n_bus = net.n_buses();
1412        let bus_map = net.bus_index_map();
1413        let all: Vec<usize> = (0..n_br).collect();
1414
1415        let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).expect("PTDF failed");
1416        let otdf = compute_otdf(&net, &OtdfRequest::new(&all, &all)).expect("OTDF failed");
1417
1418        for &k in &all {
1419            let branch_k = &net.branches[k];
1420            let from_k = bus_map[&branch_k.from_bus];
1421            let to_k = bus_map[&branch_k.to_bus];
1422            let ptdf_row_k = ptdf.row(k).unwrap();
1423            let ptdf_kk = ptdf_row_k[from_k] - ptdf_row_k[to_k];
1424            let is_bridge = (1.0 - ptdf_kk).abs() < BRIDGE_THRESHOLD;
1425
1426            for &m in &all {
1427                let otdf_vec = otdf.vector(m, k).expect("OTDF entry missing");
1428
1429                if is_bridge {
1430                    assert!(
1431                        otdf_vec.iter().all(|v| v.is_infinite()),
1432                        "bridge outage {k} → all OTDF should be INFINITY"
1433                    );
1434                    continue;
1435                }
1436
1437                let ptdf_row_m = ptdf.row(m).unwrap();
1438                let ptdf_mk = ptdf_row_m[from_k] - ptdf_row_m[to_k];
1439                let lodf_mk = ptdf_mk / (1.0 - ptdf_kk);
1440
1441                for b in 0..n_bus {
1442                    let expected = ptdf_row_m[b] + lodf_mk * ptdf_row_k[b];
1443                    let actual = otdf_vec[b];
1444                    assert!(
1445                        (actual - expected).abs() < 1e-12,
1446                        "OTDF[({m},{k})][{b}]: actual={actual:.15}, expected={expected:.15}, diff={:.2e}",
1447                        (actual - expected).abs()
1448                    );
1449                }
1450            }
1451        }
1452    }
1453
1454    #[test]
1455    fn test_otdf_bus_subset_matches_full() {
1456        let net = load_case14();
1457        let monitored = vec![0, 3, 7];
1458        let outages = vec![1, 5];
1459        let buses = vec![0, 4, 9];
1460
1461        let full = compute_otdf(&net, &OtdfRequest::new(&monitored, &outages)).expect("full otdf");
1462        let subset = compute_otdf(
1463            &net,
1464            &OtdfRequest::new(&monitored, &outages).with_bus_indices(&buses),
1465        )
1466        .expect("subset otdf");
1467
1468        assert_eq!(subset.monitored_branches(), monitored.as_slice());
1469        assert_eq!(subset.outage_branches(), outages.as_slice());
1470        assert_eq!(subset.bus_indices(), buses.as_slice());
1471        assert_eq!(subset.n_buses(), buses.len());
1472
1473        for &m in &monitored {
1474            for &k in &outages {
1475                let full_vec = full.vector(m, k).expect("full OTDF vector");
1476                let subset_vec = subset.vector(m, k).expect("subset OTDF vector");
1477                assert_eq!(subset_vec.len(), buses.len());
1478                for (pos, &bus_idx) in buses.iter().enumerate() {
1479                    assert!(
1480                        (subset_vec[pos] - full_vec[bus_idx]).abs() < 1e-12
1481                            || (subset_vec[pos].is_infinite() && full_vec[bus_idx].is_infinite()),
1482                        "OTDF subset mismatch for ({m},{k}) bus {bus_idx}"
1483                    );
1484                }
1485            }
1486        }
1487    }
1488
1489    // -----------------------------------------------------------------------
1490    // Slack-weighted / headroom-slack PTDF/LODF tests
1491    // -----------------------------------------------------------------------
1492
1493    /// Fixed slack weights apply the requested row correction exactly.
1494    #[test]
1495    fn test_ptdf_with_slack_weights_applies_requested_weights() {
1496        let net = load_case14();
1497        let base = compute_ptdf(&net, &PtdfRequest::for_branches(&[0])).unwrap();
1498        let options = DcSensitivityOptions::with_slack_weights(&[(1usize, 3.0), (2usize, 1.0)]);
1499        let weighted =
1500            compute_ptdf(&net, &PtdfRequest::for_branches(&[0]).with_options(options)).unwrap();
1501
1502        let base_row = base.row(0).unwrap();
1503        let weighted_row = weighted.row(0).unwrap();
1504        let correction = 0.75 * base_row[1] + 0.25 * base_row[2];
1505
1506        for bus_idx in 0..net.n_buses() {
1507            assert!(
1508                (weighted_row[bus_idx] - (base_row[bus_idx] - correction)).abs() < 1e-12,
1509                "bus {bus_idx}: weighted PTDF mismatch"
1510            );
1511        }
1512    }
1513
1514    #[test]
1515    fn test_ptdf_bus_subset_matches_full_rows() {
1516        let net = load_case14();
1517        let monitored = [0usize, 3usize];
1518        let bus_indices = [0usize, 4usize, 7usize];
1519
1520        let full = compute_ptdf(&net, &PtdfRequest::for_branches(&monitored)).expect("full ptdf");
1521        let subset = compute_ptdf(
1522            &net,
1523            &PtdfRequest::for_branches(&monitored).with_bus_indices(&bus_indices),
1524        )
1525        .expect("subset ptdf");
1526
1527        assert_eq!(subset.monitored_branches(), monitored.as_slice());
1528        assert_eq!(subset.bus_indices(), bus_indices.as_slice());
1529        assert_eq!(subset.n_cols(), bus_indices.len());
1530
1531        for &branch_idx in &monitored {
1532            let subset_row = subset.row(branch_idx).expect("subset row");
1533            let full_row = full.row(branch_idx).expect("full row");
1534            for (pos, &bus_idx) in bus_indices.iter().enumerate() {
1535                assert!(
1536                    (subset_row[pos] - full_row[bus_idx]).abs() < 1e-12,
1537                    "branch {branch_idx} bus {bus_idx}: subset PTDF mismatch"
1538                );
1539            }
1540        }
1541    }
1542
1543    #[test]
1544    fn test_otdf_request_matches_formula() {
1545        let net = load_case14();
1546        let monitored = [0usize];
1547        let outages = [1usize];
1548        let buses = [1usize, 2, 5];
1549        let options = DcSensitivityOptions::with_slack_weights(&[(1usize, 3.0), (2usize, 1.0)]);
1550
1551        let otdf = compute_otdf(
1552            &net,
1553            &OtdfRequest::new(&monitored, &outages)
1554                .with_bus_indices(&buses)
1555                .with_options(options.clone()),
1556        )
1557        .expect("weighted otdf");
1558        let ptdf = compute_ptdf(
1559            &net,
1560            &PtdfRequest::for_branches(&[monitored[0], outages[0]]).with_options(options.clone()),
1561        )
1562        .expect("ptdf");
1563        let lodf =
1564            compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages)).expect("lodf");
1565
1566        let otdf_vector = otdf.vector(monitored[0], outages[0]).expect("otdf vector");
1567        let monitored_row = ptdf.row(monitored[0]).expect("monitored ptdf row");
1568        let outage_row = ptdf.row(outages[0]).expect("outage ptdf row");
1569        let lodf_mk = lodf[(0, 0)];
1570
1571        for (pos, &bus_idx) in buses.iter().enumerate() {
1572            let expected = monitored_row[bus_idx] + lodf_mk * outage_row[bus_idx];
1573            assert!(
1574                (otdf_vector[pos] - expected).abs() < 1e-12,
1575                "weighted OTDF mismatch at bus {bus_idx}: actual={} expected={expected}",
1576                otdf_vector[pos]
1577            );
1578        }
1579    }
1580
1581    /// Headroom-slack PTDF: on a balanced base case, a positive bus injection is
1582    /// balanced by counter-withdrawals weighted by downward headroom.
1583    #[test]
1584    fn test_ptdf_headroom_slack_perturbation() {
1585        let net = build_balanced_headroom_ptdf_network();
1586        let n_br = net.n_branches();
1587        let all: Vec<usize> = (0..n_br).collect();
1588
1589        let bus_map = net.bus_index_map();
1590        let participating_buses = vec![bus_map[&1], bus_map[&2], bus_map[&3]];
1591        let options = DcSensitivityOptions::with_headroom_slack(&participating_buses);
1592        let ptdf_ds =
1593            compute_ptdf(&net, &PtdfRequest::for_branches(&all).with_options(options)).unwrap();
1594
1595        let dc_opts = DcPfOptions::with_headroom_slack(&participating_buses);
1596        let base = crate::solver::solve_dc_opts(&net, &dc_opts).expect("base DC solve");
1597
1598        // Start from zero mismatch so the finite difference reflects the PTDF
1599        // contract itself rather than the change between two standing-mismatch
1600        // redispatches.
1601        let test_bus = bus_map[&4];
1602        let delta = 0.01; // 1 MW injection perturbation (pu)
1603        let mut net_perturbed = net.clone();
1604        let perturb_bus_number = net_perturbed.buses[test_bus].number;
1605        net_perturbed.loads.push(surge_network::network::Load::new(
1606            perturb_bus_number,
1607            -(delta * net.base_mva),
1608            0.0,
1609        ));
1610        let perturbed =
1611            crate::solver::solve_dc_opts(&net_perturbed, &dc_opts).expect("perturbed DC solve");
1612
1613        let tol = 1e-6;
1614        for l in 0..n_br {
1615            let actual_delta_flow = perturbed.branch_p_flow[l] - base.branch_p_flow[l];
1616            let predicted = get(&ptdf_ds, l, test_bus) * delta;
1617            assert!(
1618                (actual_delta_flow - predicted).abs() < tol,
1619                "Branch {l}: actual ΔF={:.10}, PTDF_ds×Δ={:.10} (diff={:.2e})",
1620                actual_delta_flow,
1621                predicted,
1622                (actual_delta_flow - predicted).abs()
1623            );
1624        }
1625    }
1626
1627    /// Headroom-slack LODF: outage prediction matches DC PF re-solve.
1628    #[test]
1629    fn test_lodf_headroom_slack_outage_validation() {
1630        let net = load_case14();
1631        let n_br = net.n_branches();
1632        let all: Vec<usize> = (0..n_br).collect();
1633
1634        let gen_bus_indices: Vec<usize> = {
1635            let bus_map = net.bus_index_map();
1636            net.generators
1637                .iter()
1638                .filter(|g| g.in_service)
1639                .filter_map(|g| bus_map.get(&g.bus).copied())
1640                .collect()
1641        };
1642        let participating_buses = gen_bus_indices.clone();
1643        let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1644
1645        let dc_opts = DcPfOptions::with_headroom_slack(&participating_buses);
1646        let base_result = crate::solver::solve_dc_opts(&net, &dc_opts).expect("base DC solve");
1647
1648        let tol = 1e-6;
1649
1650        for &k in &[2usize, 6, 8] {
1651            if !lodf[(k, k)].is_finite() {
1652                continue;
1653            }
1654            let mut net_outaged = net.clone();
1655            net_outaged.branches[k].in_service = false;
1656            let outaged_result =
1657                crate::solver::solve_dc_opts(&net_outaged, &dc_opts).expect("outage DC solve");
1658            let flow_k_pre = base_result.branch_p_flow[k];
1659
1660            for l in 0..n_br {
1661                if l == k || !net.branches[l].in_service {
1662                    continue;
1663                }
1664                let predicted = base_result.branch_p_flow[l] + lodf[(l, k)] * flow_k_pre;
1665                let actual = outaged_result.branch_p_flow[l];
1666                assert!(
1667                    (predicted - actual).abs() < tol,
1668                    "Outage {k}: branch {l} predicted={predicted:.10}, actual={actual:.10} (diff={:.2e})",
1669                    (predicted - actual).abs()
1670                );
1671            }
1672        }
1673    }
1674
1675    #[test]
1676    fn test_lodf_and_n2_are_slack_invariant() {
1677        let net = load_case14();
1678        let monitored = vec![0, 3, 7];
1679        let outages = vec![1, 5];
1680        let outage_pairs = vec![(0, 2), (2, 0)];
1681        let single_lodf = compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages))
1682            .expect("single-slack lodf");
1683        let request_lodf = compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages))
1684            .expect("request lodf");
1685        assert_eq!(single_lodf.n_rows(), request_lodf.n_rows());
1686        assert_eq!(single_lodf.n_cols(), request_lodf.n_cols());
1687        for row in 0..single_lodf.n_rows() {
1688            for col in 0..single_lodf.n_cols() {
1689                assert!(
1690                    (single_lodf[(row, col)] - request_lodf[(row, col)]).abs() < 1e-12
1691                        || (single_lodf[(row, col)].is_infinite()
1692                            && request_lodf[(row, col)].is_infinite()),
1693                    "request LODF changed at ({row},{col})"
1694                );
1695            }
1696        }
1697
1698        let single_n2 = compute_n2_lodf_batch(
1699            &net,
1700            &N2LodfBatchRequest::new(&outage_pairs).with_monitored_branches(&monitored),
1701        )
1702        .expect("single-slack n2");
1703        let request_n2 = compute_n2_lodf_batch(
1704            &net,
1705            &N2LodfBatchRequest::new(&outage_pairs).with_monitored_branches(&monitored),
1706        )
1707        .expect("request n2");
1708        assert_eq!(single_n2.n_rows(), request_n2.n_rows());
1709        assert_eq!(single_n2.n_cols(), request_n2.n_cols());
1710        for row in 0..single_n2.n_rows() {
1711            for col in 0..single_n2.n_cols() {
1712                assert!(
1713                    (single_n2[(row, col)] - request_n2[(row, col)]).abs() < 1e-12
1714                        || (single_n2[(row, col)].is_infinite()
1715                            && request_n2[(row, col)].is_infinite()),
1716                    "request N-2 changed at ({row},{col})"
1717                );
1718            }
1719        }
1720    }
1721}