Skip to main content

surge_dc/
solver.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! DC power flow solver implementation.
3//!
4//! Solves the linear DC power flow equations:
5//!   P = B' * theta
6//!
7//! where P is the vector of real power injections (per-unit),
8//! B' is the susceptance matrix (with slack bus removed),
9//! and theta is the vector of bus voltage angles.
10//!
11//! Uses sparse KLU factorization for O(n log n) performance at any scale.
12
13use std::collections::{HashMap, HashSet};
14use std::time::Instant;
15
16use faer::Mat;
17use surge_network::Network;
18use surge_network::network::BusType;
19use surge_network::network::apply_angle_reference_subset;
20use surge_solution::{PfModel, PfSolution, SolveStatus};
21use surge_sparse::KluSolver;
22use surge_topology::islands::detect_islands;
23use tracing::{debug, error, info, warn};
24
25use crate::bprime::{BprimeSparseCsc, build_bprime_sparse_for_buses, build_p_injection_for_buses};
26use crate::types::*;
27
28struct PreparedDcKernel {
29    bus_indices: Vec<usize>,
30    branch_indices: Vec<usize>,
31    bprime: BprimeSparseCsc,
32    reduced_to_full: Vec<usize>,
33    p_injection_base: Vec<f64>,
34    reference_angle0_rad: f64,
35    klu: KluSolver,
36}
37
38struct PreparedSolveInputs<'a> {
39    bus_map: &'a HashMap<u32, usize>,
40    bus_indices: &'a [usize],
41    branch_indices: &'a [usize],
42    bprime: &'a BprimeSparseCsc,
43    klu: &'a mut KluSolver,
44    p_inj_base: &'a [f64],
45    reference_angle0_rad: f64,
46}
47
48/// Prepared DC solve model for a network.
49///
50/// This caches the validated topology, one reduced B' / KLU kernel per AC
51/// island, and the global branch / bus metadata so callers can run `solve()`,
52/// `compute_ptdf()`, and `compute_lodf()` without rebuilding the sparse model
53/// each time. Single-island and multi-island networks share the same public API.
54pub struct PreparedDcStudy<'a> {
55    network: &'a Network,
56    bus_map: HashMap<u32, usize>,
57    branch_metadata: Vec<BranchDcMetadata>,
58    downward_headroom_by_bus_pu: Vec<f64>,
59    kernels: Vec<PreparedDcKernel>,
60    bus_kernel_indices: Vec<Option<usize>>,
61    branch_kernel_indices: Vec<Option<usize>>,
62    bus_island_ids: Vec<usize>,
63    singleton_bus_indices: Vec<usize>,
64}
65
66/// Lazily computes LODF columns from a prepared DC model.
67///
68/// This caches the solved reduced-bus basis columns needed to evaluate outage
69/// columns, so repeated `compute_column()` calls avoid recomputing the same
70/// endpoint solves. Use this for large-system workflows that need to process
71/// one outage column at a time without materializing a dense all-pairs matrix.
72pub struct LodfColumnBuilder<'model, 'network> {
73    model: &'model mut PreparedDcStudy<'network>,
74    reduced_bus_columns_by_kernel: Vec<Vec<Option<Vec<f64>>>>,
75}
76
77/// Lazily computes N-2 LODF columns for a fixed monitored/outage universe.
78///
79/// This builder caches the single-outage LODF columns required to evaluate
80/// Woodbury rank-2 N-2 sensitivities, so repeated `compute_column()` calls for
81/// large contingency studies avoid recomputing the same outage columns.
82pub struct N2LodfColumnBuilder<'model, 'network> {
83    monitored_branch_indices: Vec<usize>,
84    column_branch_indices: Vec<usize>,
85    column_positions: Vec<Option<usize>>,
86    single_outage_columns: HashMap<usize, Vec<f64>>,
87    lodf_columns: LodfColumnBuilder<'model, 'network>,
88}
89
90impl<'model, 'network> LodfColumnBuilder<'model, 'network> {
91    fn new(model: &'model mut PreparedDcStudy<'network>) -> Self {
92        let reduced_bus_columns_by_kernel = model
93            .kernels
94            .iter()
95            .map(|kernel| vec![None; kernel.bprime.dim])
96            .collect();
97        Self {
98            model,
99            reduced_bus_columns_by_kernel,
100        }
101    }
102
103    /// Compute one LODF outage column for the requested monitored branch set.
104    ///
105    /// The returned vector is ordered exactly like `monitored_branch_indices`.
106    /// Entry `i` is `LODF[monitored_branch_indices[i], outage_branch_idx]`.
107    pub fn compute_column(
108        &mut self,
109        monitored_branch_indices: &[usize],
110        outage_branch_idx: usize,
111    ) -> Result<Vec<f64>, DcError> {
112        let branch_k = self.model.branch_metadata(outage_branch_idx)?;
113        let (from_k, to_k) = self.model.branch_terminal_indices(outage_branch_idx)?;
114        let kernel_idx = self.model.branch_kernel_index(outage_branch_idx)?;
115        let b_k = branch_k.b_dc;
116
117        let from_column = self.ensure_reduced_bus_column(kernel_idx, from_k)?;
118        let to_column = self.ensure_reduced_bus_column(kernel_idx, to_k)?;
119
120        let ptdf_k_from = b_k
121            * (self.b_inverse_element(kernel_idx, from_column, from_k)
122                - self.b_inverse_element(kernel_idx, from_column, to_k));
123        let ptdf_k_to = b_k
124            * (self.b_inverse_element(kernel_idx, to_column, from_k)
125                - self.b_inverse_element(kernel_idx, to_column, to_k));
126        let ptdf_kk = ptdf_k_from - ptdf_k_to;
127
128        if (1.0 - ptdf_kk).abs() < crate::sensitivity::BRIDGE_THRESHOLD {
129            return Ok(vec![f64::INFINITY; monitored_branch_indices.len()]);
130        }
131
132        let denom = 1.0 - ptdf_kk;
133        let mut lodf_column = Vec::with_capacity(monitored_branch_indices.len());
134        for &monitored_branch_idx in monitored_branch_indices {
135            if monitored_branch_idx == outage_branch_idx {
136                lodf_column.push(-1.0);
137                continue;
138            }
139
140            let branch_l = self.model.branch_metadata(monitored_branch_idx)?;
141            if !branch_l.is_sensitivity_active() {
142                lodf_column.push(0.0);
143                continue;
144            }
145            if self.model.branch_kernel_indices[monitored_branch_idx] != Some(kernel_idx) {
146                lodf_column.push(0.0);
147                continue;
148            }
149
150            let from_l = branch_l.from_full;
151            let to_l = branch_l.to_full;
152            let b_l = branch_l.b_dc;
153            let ptdf_l_from = b_l
154                * (self.b_inverse_element(kernel_idx, from_column, from_l)
155                    - self.b_inverse_element(kernel_idx, from_column, to_l));
156            let ptdf_l_to = b_l
157                * (self.b_inverse_element(kernel_idx, to_column, from_l)
158                    - self.b_inverse_element(kernel_idx, to_column, to_l));
159            let ptdf_lk = ptdf_l_from - ptdf_l_to;
160            lodf_column.push(ptdf_lk / denom);
161        }
162
163        Ok(lodf_column)
164    }
165
166    /// Visit a sequence of outage columns without materializing a matrix.
167    pub fn stream_columns<F>(
168        &mut self,
169        monitored_branch_indices: &[usize],
170        outage_branch_indices: &[usize],
171        mut visit_column: F,
172    ) -> Result<(), DcError>
173    where
174        F: FnMut(usize, usize, &[f64]) -> Result<(), DcError>,
175    {
176        for (outage_pos, &outage_branch_idx) in outage_branch_indices.iter().enumerate() {
177            let column = self.compute_column(monitored_branch_indices, outage_branch_idx)?;
178            visit_column(outage_pos, outage_branch_idx, &column)?;
179        }
180        Ok(())
181    }
182
183    fn ensure_reduced_bus_column(
184        &mut self,
185        kernel_idx: usize,
186        full_bus_idx: usize,
187    ) -> Result<Option<usize>, DcError> {
188        let kernel = &self.model.kernels[kernel_idx];
189        let Some(reduced_idx) = self.model.kernels[kernel_idx]
190            .bprime
191            .full_to_reduced
192            .get(full_bus_idx)
193            .copied()
194            .flatten()
195        else {
196            return Ok(None);
197        };
198
199        if self.reduced_bus_columns_by_kernel[kernel_idx][reduced_idx].is_none() {
200            let mut rhs = vec![0.0f64; kernel.bprime.dim];
201            rhs[reduced_idx] = 1.0;
202            if self.model.kernels[kernel_idx].klu.solve(&mut rhs).is_err() {
203                return Err(DcError::SingularMatrix);
204            }
205            self.reduced_bus_columns_by_kernel[kernel_idx][reduced_idx] = Some(rhs);
206        }
207
208        Ok(Some(reduced_idx))
209    }
210
211    fn b_inverse_element(
212        &self,
213        kernel_idx: usize,
214        reduced_bus_column: Option<usize>,
215        full_bus_idx: usize,
216    ) -> f64 {
217        let kernel = &self.model.kernels[kernel_idx];
218        match (
219            reduced_bus_column,
220            kernel
221                .bprime
222                .full_to_reduced
223                .get(full_bus_idx)
224                .copied()
225                .flatten(),
226        ) {
227            (Some(column_idx), Some(row_idx)) => self.reduced_bus_columns_by_kernel[kernel_idx]
228                [column_idx]
229                .as_ref()
230                .expect("reduced-bus column should exist after ensure_reduced_bus_column")[row_idx],
231            _ => 0.0,
232        }
233    }
234}
235
236impl<'model, 'network> N2LodfColumnBuilder<'model, 'network> {
237    fn new(
238        model: &'model mut PreparedDcStudy<'network>,
239        monitored_branch_indices: &[usize],
240        candidate_outage_branch_indices: &[usize],
241    ) -> Result<Self, DcError> {
242        let n_branches = model.network.n_branches();
243        let mut column_branch_indices = Vec::with_capacity(
244            monitored_branch_indices.len() + candidate_outage_branch_indices.len(),
245        );
246        let mut column_positions = vec![None; n_branches];
247
248        for &branch_idx in monitored_branch_indices {
249            if branch_idx >= n_branches {
250                return Err(DcError::InvalidNetwork(format!(
251                    "monitored branch index {branch_idx} out of range (len={n_branches})"
252                )));
253            }
254            if column_positions[branch_idx].is_none() {
255                column_positions[branch_idx] = Some(column_branch_indices.len());
256                column_branch_indices.push(branch_idx);
257            }
258        }
259
260        for &branch_idx in candidate_outage_branch_indices {
261            if branch_idx >= n_branches {
262                return Err(DcError::InvalidNetwork(format!(
263                    "candidate outage branch index {branch_idx} out of range (len={n_branches})"
264                )));
265            }
266            if column_positions[branch_idx].is_none() {
267                column_positions[branch_idx] = Some(column_branch_indices.len());
268                column_branch_indices.push(branch_idx);
269            }
270        }
271
272        Ok(Self {
273            monitored_branch_indices: monitored_branch_indices.to_vec(),
274            column_branch_indices,
275            column_positions,
276            single_outage_columns: HashMap::new(),
277            lodf_columns: LodfColumnBuilder::new(model),
278        })
279    }
280
281    /// Compute one rank-2 N-2 LODF column for an ordered outage pair.
282    ///
283    /// The returned vector is ordered exactly like the monitored set passed to
284    /// `PreparedDcStudy::n2_lodf_columns()`. For a pair `(k, l)`, each entry is:
285    ///
286    /// ```text
287    /// [LODF(m,k) + LODF(m,l) × LODF(l,k)] / [1 - LODF(l,k) × LODF(k,l)]
288    /// ```
289    ///
290    /// This is the factor that multiplies the pre-outage flow on branch `k`
291    /// when predicting post-N-2 flow with outages `(k, l)`.
292    pub fn compute_column(
293        &mut self,
294        first_outage_branch_idx: usize,
295        second_outage_branch_idx: usize,
296    ) -> Result<Vec<f64>, DcError> {
297        if first_outage_branch_idx == second_outage_branch_idx {
298            return Err(DcError::Infeasible(format!(
299                "N-2 outage pair must contain two distinct branches, got ({first_outage_branch_idx}, {second_outage_branch_idx})"
300            )));
301        }
302
303        self.ensure_outage_column(first_outage_branch_idx)?;
304        self.ensure_outage_column(second_outage_branch_idx)?;
305
306        let first_position = self.branch_position(first_outage_branch_idx)?;
307        let second_position = self.branch_position(second_outage_branch_idx)?;
308
309        let first_column = self
310            .single_outage_columns
311            .get(&first_outage_branch_idx)
312            .expect("outage column should exist after ensure_outage_column");
313        let second_column = self
314            .single_outage_columns
315            .get(&second_outage_branch_idx)
316            .expect("outage column should exist after ensure_outage_column");
317
318        let lodf_second_first = first_column[second_position];
319        let lodf_first_second = second_column[first_position];
320        let denom = 1.0 - lodf_second_first * lodf_first_second;
321
322        if !lodf_second_first.is_finite()
323            || !lodf_first_second.is_finite()
324            || !denom.is_finite()
325            || denom.abs() < crate::sensitivity::BRIDGE_THRESHOLD
326        {
327            return Err(DcError::Infeasible(format!(
328                "N-2 LODF denominator invalid for branch pair ({first_outage_branch_idx},{second_outage_branch_idx})"
329            )));
330        }
331
332        let mut n2_column = Vec::with_capacity(self.monitored_branch_indices.len());
333        for &monitored_branch_idx in &self.monitored_branch_indices {
334            let monitored_position = self.branch_position(monitored_branch_idx)?;
335            let lodf_monitored_first = first_column[monitored_position];
336            let lodf_monitored_second = second_column[monitored_position];
337            n2_column
338                .push((lodf_monitored_first + lodf_monitored_second * lodf_second_first) / denom);
339        }
340
341        Ok(n2_column)
342    }
343
344    /// Visit a sequence of ordered N-2 outage-pair columns without materializing a matrix.
345    pub fn stream_columns<F>(
346        &mut self,
347        outage_pairs: &[(usize, usize)],
348        mut visit_column: F,
349    ) -> Result<(), DcError>
350    where
351        F: FnMut(usize, (usize, usize), &[f64]) -> Result<(), DcError>,
352    {
353        for (pair_pos, &outage_pair) in outage_pairs.iter().enumerate() {
354            let column = self.compute_column(outage_pair.0, outage_pair.1)?;
355            visit_column(pair_pos, outage_pair, &column)?;
356        }
357        Ok(())
358    }
359
360    fn ensure_outage_column(&mut self, outage_branch_idx: usize) -> Result<(), DcError> {
361        if self.single_outage_columns.contains_key(&outage_branch_idx) {
362            return Ok(());
363        }
364        let Some(_) = self.column_positions.get(outage_branch_idx) else {
365            return Err(DcError::InvalidNetwork(format!(
366                "outage branch index {outage_branch_idx} out of range (len={})",
367                self.column_positions.len()
368            )));
369        };
370        if self.column_positions[outage_branch_idx].is_none() {
371            return Err(DcError::InvalidNetwork(format!(
372                "outage branch index {outage_branch_idx} not in N-2 outage universe"
373            )));
374        }
375
376        let column = self
377            .lodf_columns
378            .compute_column(&self.column_branch_indices, outage_branch_idx)?;
379        self.single_outage_columns.insert(outage_branch_idx, column);
380        Ok(())
381    }
382
383    fn branch_position(&self, branch_idx: usize) -> Result<usize, DcError> {
384        self.column_positions
385            .get(branch_idx)
386            .and_then(|pos| *pos)
387            .ok_or_else(|| {
388                DcError::InvalidNetwork(format!(
389                    "branch index {branch_idx} not available in the N-2 column universe"
390                ))
391            })
392    }
393}
394
395impl<'a> PreparedDcStudy<'a> {
396    /// Prepare a reusable DC model for a network.
397    pub fn new(network: &'a Network) -> Result<Self, DcError> {
398        Self::build(network, true)
399    }
400
401    /// Prepare a reusable DC model for a structurally valid derived topology.
402    ///
403    /// This constructor is intended for advanced/internal workflows such as
404    /// exact post-contingency fallback, where a valid base network may split
405    /// into islands that no longer retain an explicit slack designation. It
406    /// still validates structural integrity, but it intentionally skips the
407    /// public DC solve contract that requires exactly one slack bus per
408    /// connected component.
409    pub fn new_unchecked(network: &'a Network) -> Result<Self, DcError> {
410        Self::build(network, false)
411    }
412
413    fn build(network: &'a Network, validate_ready: bool) -> Result<Self, DcError> {
414        if network.n_buses() == 0 {
415            return Err(DcError::EmptyNetwork);
416        }
417        if validate_ready {
418            network
419                .validate_for_dc_solve()
420                .map_err(|err| DcError::InvalidNetwork(err.to_string()))?;
421        } else {
422            network
423                .validate_structure()
424                .map_err(|err| DcError::InvalidNetwork(err.to_string()))?;
425        }
426
427        let bus_map = network.bus_index_map();
428        let islands = detect_islands(network, &bus_map);
429        let branch_metadata = build_branch_metadata(network, &bus_map)?;
430        let downward_headroom_by_bus_pu = build_downward_headroom_by_bus(network, &bus_map);
431        let mut kernels = Vec::new();
432        let mut bus_kernel_indices = vec![None; network.n_buses()];
433        let mut branch_kernel_indices = vec![None; network.n_branches()];
434        let mut bus_island_ids = vec![0usize; network.n_buses()];
435        let mut singleton_bus_indices = Vec::new();
436        for (island_id, island_buses) in islands.components.iter().enumerate() {
437            for &bus_idx in island_buses {
438                bus_island_ids[bus_idx] = island_id;
439            }
440            if island_buses.len() <= 1 {
441                if let Some(&bus_idx) = island_buses.first() {
442                    singleton_bus_indices.push(bus_idx);
443                }
444                continue;
445            }
446
447            let slack_idx = choose_island_slack_idx(network, island_buses)?;
448            let island_bus_set: HashSet<usize> = island_buses.iter().copied().collect();
449            let branch_indices = island_branch_indices(network, &island_bus_set, &bus_map);
450            let bprime = if island_buses.len() == network.n_buses() && islands.n_islands == 1 {
451                build_bprime_sparse_for_buses(network, None, slack_idx)
452            } else {
453                build_bprime_sparse_for_buses(network, Some(&island_bus_set), slack_idx)
454            };
455            let p_injection_base = build_p_injection_for_buses(
456                network,
457                &bprime.full_to_reduced,
458                slack_idx,
459                Some(&island_bus_set),
460            );
461            let mut reduced_to_full = vec![0usize; bprime.dim];
462            for (full_idx, reduced_idx) in bprime.full_to_reduced.iter().enumerate() {
463                if let Some(ri) = reduced_idx {
464                    reduced_to_full[*ri] = full_idx;
465                }
466            }
467            let mut klu = KluSolver::new(bprime.dim, &bprime.col_ptrs, &bprime.row_indices)
468                .map_err(|_| DcError::SingularMatrix)?;
469            if klu.factor(&bprime.values).is_err() {
470                return Err(DcError::SingularMatrix);
471            }
472
473            let kernel_idx = kernels.len();
474            for &bus_idx in island_buses {
475                bus_kernel_indices[bus_idx] = Some(kernel_idx);
476            }
477            for &branch_idx in &branch_indices {
478                branch_kernel_indices[branch_idx] = Some(kernel_idx);
479            }
480
481            kernels.push(PreparedDcKernel {
482                bus_indices: island_buses.clone(),
483                branch_indices,
484                bprime,
485                reduced_to_full,
486                p_injection_base,
487                reference_angle0_rad: network.buses[slack_idx].voltage_angle_rad,
488                klu,
489            });
490        }
491
492        Ok(Self {
493            network,
494            bus_map,
495            branch_metadata,
496            downward_headroom_by_bus_pu,
497            kernels,
498            bus_kernel_indices,
499            branch_kernel_indices,
500            bus_island_ids,
501            singleton_bus_indices,
502        })
503    }
504
505    /// Solve DC power flow using the prepared model.
506    pub fn solve(&mut self, opts: &DcPfOptions) -> Result<DcPfSolution, DcError> {
507        let start = Instant::now();
508        let mut theta_all = vec![0.0f64; self.network.n_buses()];
509        let mut branch_flow_all = vec![0.0f64; self.network.n_branches()];
510        let mut p_inject_all = vec![0.0f64; self.network.n_buses()];
511        let mut total_slack_p = 0.0f64;
512        let mut slack_distribution = HashMap::new();
513
514        for (bus_idx, kernel_idx) in self.bus_kernel_indices.iter().enumerate() {
515            if kernel_idx.is_none() {
516                theta_all[bus_idx] = self.network.buses[bus_idx].voltage_angle_rad;
517                apply_angle_reference_subset(
518                    &mut theta_all,
519                    self.network,
520                    &[bus_idx],
521                    bus_idx,
522                    self.network.buses[bus_idx].voltage_angle_rad,
523                    opts.angle_reference,
524                );
525            }
526        }
527        for &bus_idx in &self.singleton_bus_indices {
528            let slack_p = compute_singleton_slack_injection_pu(self.network, bus_idx, opts);
529            total_slack_p += slack_p;
530            p_inject_all[bus_idx] = slack_p;
531            if opts.participation_factors.is_some() || opts.headroom_slack_bus_indices.is_some() {
532                slack_distribution.insert(bus_idx, slack_p * self.network.base_mva);
533            }
534        }
535
536        for kernel in &mut self.kernels {
537            let result = solve_prepared(
538                self.network,
539                opts,
540                PreparedSolveInputs {
541                    bus_map: &self.bus_map,
542                    bus_indices: &kernel.bus_indices,
543                    branch_indices: &kernel.branch_indices,
544                    bprime: &kernel.bprime,
545                    klu: &mut kernel.klu,
546                    p_inj_base: &kernel.p_injection_base,
547                    reference_angle0_rad: kernel.reference_angle0_rad,
548                },
549                start,
550            )?;
551
552            for &bus_idx in &kernel.bus_indices {
553                theta_all[bus_idx] = result.theta[bus_idx];
554                p_inject_all[bus_idx] = result.p_inject_pu[bus_idx];
555            }
556            for &branch_idx in &kernel.branch_indices {
557                branch_flow_all[branch_idx] = result.branch_p_flow[branch_idx];
558            }
559
560            total_slack_p += result.slack_p_injection;
561            slack_distribution.extend(result.slack_distribution);
562        }
563
564        let solve_time = start.elapsed().as_secs_f64();
565        Ok(DcPfSolution {
566            theta: theta_all,
567            branch_p_flow: branch_flow_all,
568            slack_p_injection: total_slack_p,
569            solve_time_secs: solve_time,
570            total_generation_mw: if opts.participation_factors.is_some()
571                || opts.headroom_slack_bus_indices.is_some()
572            {
573                self.network.total_load_mw() + total_slack_p * self.network.base_mva
574            } else {
575                0.0
576            },
577            slack_distribution,
578            p_inject_pu: p_inject_all,
579            island_ids: self.compute_island_ids(),
580        })
581    }
582
583    fn compute_island_ids(&self) -> Vec<usize> {
584        self.bus_island_ids.clone()
585    }
586
587    /// Compute the canonical DC power flow + analysis workflow over one prepared study.
588    pub fn run_analysis(
589        &mut self,
590        request: &DcAnalysisRequest,
591    ) -> Result<DcAnalysisResult, DcError> {
592        let monitored_branch_indices = request
593            .monitored_branch_indices
594            .clone()
595            .unwrap_or_else(|| (0..self.network.n_branches()).collect());
596        let sensitivity_options = request.sensitivity_options.clone().unwrap_or_else(|| {
597            if let Some(pf_weights) = request.pf_options.participation_factors.as_ref() {
598                crate::sensitivity::DcSensitivityOptions::with_slack_weights(pf_weights)
599            } else if let Some(bus_indices) = request.pf_options.headroom_slack_bus_indices.as_ref()
600            {
601                crate::sensitivity::DcSensitivityOptions::with_headroom_slack(bus_indices)
602            } else {
603                crate::sensitivity::DcSensitivityOptions::default()
604            }
605        });
606        let normalized_slack =
607            self.resolve_sensitivity_slack_by_kernel(&sensitivity_options.slack)?;
608        let ptdf_bus_indices =
609            self.resolve_selected_bus_indices(request.ptdf_bus_indices.as_deref())?;
610        let power_flow = self.solve(&request.pf_options)?;
611        let ptdf = self.compute_ptdf_with_resolved_slack(
612            &monitored_branch_indices,
613            Some(&ptdf_bus_indices),
614            &normalized_slack,
615        )?;
616        let otdf_bus_indices = if request.otdf_outage_branch_indices.is_empty() {
617            Vec::new()
618        } else {
619            request
620                .otdf_bus_indices
621                .clone()
622                .unwrap_or_else(|| (0..self.network.n_buses()).collect())
623        };
624        let otdf = if request.otdf_outage_branch_indices.is_empty() {
625            None
626        } else {
627            Some(self.compute_otdf_with_resolved_slack(
628                &monitored_branch_indices,
629                &request.otdf_outage_branch_indices,
630                Some(&otdf_bus_indices),
631                &normalized_slack,
632            )?)
633        };
634        let lodf = if request.lodf_outage_branch_indices.is_empty() {
635            None
636        } else {
637            Some(self.compute_lodf(
638                &monitored_branch_indices,
639                &request.lodf_outage_branch_indices,
640            )?)
641        };
642        let n2_lodf = if request.n2_outage_pairs.is_empty() {
643            None
644        } else {
645            Some(self.compute_n2_lodf_batch(&request.n2_outage_pairs, &monitored_branch_indices)?)
646        };
647
648        Ok(DcAnalysisResult {
649            power_flow,
650            monitored_branch_indices,
651            ptdf,
652            ptdf_bus_indices,
653            otdf,
654            otdf_outage_branch_indices: request.otdf_outage_branch_indices.clone(),
655            otdf_bus_indices,
656            lodf,
657            lodf_outage_branch_indices: request.lodf_outage_branch_indices.clone(),
658            n2_lodf,
659            n2_outage_pairs: request.n2_outage_pairs.clone(),
660        })
661    }
662
663    /// Compute PTDF rows for the given monitored branches using single-slack semantics.
664    pub fn compute_ptdf(
665        &mut self,
666        monitored_branch_indices: &[usize],
667    ) -> Result<PtdfRows, DcError> {
668        self.compute_ptdf_with_resolved_slack(
669            monitored_branch_indices,
670            None,
671            &self.single_slack_by_kernel(),
672        )
673    }
674
675    /// Compute DC marginal loss sensitivities without materializing a loss PTDF.
676    ///
677    /// This returns the same single-slack-gauge vector as
678    /// `Σ_l 2 * r_l * flow_l * PTDF[l, bus]`, but evaluates that product with
679    /// one sparse adjoint solve per island instead of a dense branch-by-bus PTDF
680    /// cache. `theta` is indexed by the network's internal bus order.
681    pub fn compute_loss_sensitivities_adjoint(
682        &mut self,
683        theta: &[f64],
684    ) -> Result<Vec<f64>, DcError> {
685        let n_bus = self.network.n_buses();
686        if theta.len() != n_bus {
687            return Err(DcError::InvalidNetwork(format!(
688                "loss sensitivity theta length {} does not match network bus count {}",
689                theta.len(),
690                n_bus
691            )));
692        }
693
694        let mut rhs_by_kernel: Vec<Vec<f64>> = self
695            .kernels
696            .iter()
697            .map(|kernel| vec![0.0_f64; kernel.bprime.dim])
698            .collect();
699
700        for (branch_idx, branch) in self.network.branches.iter().enumerate() {
701            let branch_meta = self.branch_metadata(branch_idx)?;
702            if !branch_meta.is_sensitivity_active() {
703                continue;
704            }
705            if branch.r.abs() < 1e-20 {
706                continue;
707            }
708            let Some(kernel_idx) = self.branch_kernel_indices[branch_idx] else {
709                continue;
710            };
711
712            let flow_pu =
713                branch_meta.b_dc * (theta[branch_meta.from_full] - theta[branch_meta.to_full]);
714            let weight = 2.0 * branch.r * flow_pu * branch_meta.b_dc;
715            if weight.abs() < 1e-20 {
716                continue;
717            }
718
719            let kernel = &self.kernels[kernel_idx];
720            let rhs = &mut rhs_by_kernel[kernel_idx];
721            if let Some(ri_from) = kernel.bprime.full_to_reduced[branch_meta.from_full] {
722                rhs[ri_from] += weight;
723            }
724            if let Some(ri_to) = kernel.bprime.full_to_reduced[branch_meta.to_full] {
725                rhs[ri_to] -= weight;
726            }
727        }
728
729        let mut dloss_dp = vec![0.0_f64; n_bus];
730        for (kernel_idx, kernel) in self.kernels.iter_mut().enumerate() {
731            let rhs = &mut rhs_by_kernel[kernel_idx];
732            if rhs.is_empty() {
733                continue;
734            }
735            kernel.klu.solve(rhs).map_err(|_| DcError::SingularMatrix)?;
736            for (ri, &full_bus_idx) in kernel.reduced_to_full.iter().enumerate() {
737                dloss_dp[full_bus_idx] = rhs[ri];
738            }
739            // The reduced slack row/column is absent, so the slack bus remains
740            // zero. That matches the default single-slack PTDF semantics.
741        }
742
743        Ok(dloss_dp)
744    }
745
746    /// Compute PTDF rows from an explicit [`PtdfRequest`](crate::PtdfRequest).
747    pub fn compute_ptdf_request(
748        &mut self,
749        request: &crate::sensitivity::PtdfRequest,
750    ) -> Result<PtdfRows, DcError> {
751        let monitored_branch_indices_storage;
752        let monitored_branch_indices = if let Some(indices) =
753            request.monitored_branch_indices.as_deref()
754        {
755            indices
756        } else {
757            monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
758            &monitored_branch_indices_storage
759        };
760        self.compute_ptdf_with_options(
761            monitored_branch_indices,
762            request.bus_indices.as_deref(),
763            &request.options,
764        )
765    }
766
767    fn compute_ptdf_with_resolved_slack(
768        &mut self,
769        monitored_branch_indices: &[usize],
770        bus_indices: Option<&[usize]>,
771        normalized_slack_by_kernel: &[Option<Vec<(usize, f64)>>],
772    ) -> Result<PtdfRows, DcError> {
773        let selected_bus_indices = self.resolve_selected_bus_indices(bus_indices)?;
774        let n_branches = self.network.n_branches();
775        let mut result = PtdfRows::new(
776            monitored_branch_indices,
777            &selected_bus_indices,
778            n_branches,
779            self.network.n_buses(),
780        );
781        let mut monitored_by_kernel = vec![Vec::<(usize, usize)>::new(); self.kernels.len()];
782        for (row_pos, &branch_idx) in monitored_branch_indices.iter().enumerate() {
783            let branch = self.branch_metadata(branch_idx)?;
784            if !branch.is_sensitivity_active() {
785                continue;
786            }
787            if let Some(kernel_idx) = self.branch_kernel_indices[branch_idx] {
788                monitored_by_kernel[kernel_idx].push((row_pos, branch_idx));
789            }
790        }
791        let mut bus_positions_by_kernel = vec![Vec::<(usize, usize)>::new(); self.kernels.len()];
792        for (bus_pos, &bus_idx) in selected_bus_indices.iter().enumerate() {
793            if let Some(kernel_idx) = self.bus_kernel_indices[bus_idx] {
794                bus_positions_by_kernel[kernel_idx].push((bus_pos, bus_idx));
795            }
796        }
797
798        for kernel_idx in 0..self.kernels.len() {
799            if monitored_by_kernel[kernel_idx].is_empty() {
800                continue;
801            }
802
803            let local_monitored: Vec<usize> = monitored_by_kernel[kernel_idx]
804                .iter()
805                .map(|&(_, branch_idx)| branch_idx)
806                .collect();
807            let local_buses: Vec<usize> = bus_positions_by_kernel[kernel_idx]
808                .iter()
809                .map(|&(_, bus_idx)| bus_idx)
810                .collect();
811            let local_ptdf = self.compute_kernel_ptdf_with_resolved_slack(
812                kernel_idx,
813                &local_monitored,
814                Some(&local_buses),
815                normalized_slack_by_kernel
816                    .get(kernel_idx)
817                    .and_then(|weights| weights.as_deref()),
818            )?;
819            for (local_row_pos, &(global_row_pos, _)) in
820                monitored_by_kernel[kernel_idx].iter().enumerate()
821            {
822                let source_row = local_ptdf.row_at(local_row_pos);
823                let target_row = result.row_at_mut(global_row_pos);
824                for (local_bus_pos, &(global_bus_pos, _)) in
825                    bus_positions_by_kernel[kernel_idx].iter().enumerate()
826                {
827                    target_row[global_bus_pos] = source_row[local_bus_pos];
828                }
829            }
830        }
831
832        Ok(result)
833    }
834
835    fn compute_kernel_ptdf_with_resolved_slack(
836        &mut self,
837        kernel_idx: usize,
838        monitored_branch_indices: &[usize],
839        bus_indices: Option<&[usize]>,
840        normalized_slack: Option<&[(usize, f64)]>,
841    ) -> Result<PtdfRows, DcError> {
842        let uses_full_bus_axis = bus_indices.is_none();
843        let mut active_branches = Vec::with_capacity(monitored_branch_indices.len());
844        for (row_pos, &branch_idx) in monitored_branch_indices.iter().enumerate() {
845            let branch = self.branch_metadata(branch_idx)?;
846            if !branch.is_sensitivity_active() {
847                continue;
848            }
849            active_branches.push((row_pos, branch.b_dc, branch.from_full, branch.to_full));
850        }
851        let kernel = &mut self.kernels[kernel_idx];
852        let selected_bus_indices = bus_indices
853            .map(|indices| indices.to_vec())
854            .unwrap_or_else(|| kernel.bus_indices.clone());
855        let mut result = PtdfRows::new(
856            monitored_branch_indices,
857            &selected_bus_indices,
858            self.network.n_branches(),
859            self.network.n_buses(),
860        );
861
862        const PTDF_SOLVE_BATCH_SIZE: usize = 128;
863        for branch_chunk in active_branches.chunks(PTDF_SOLVE_BATCH_SIZE) {
864            let chunk_len = branch_chunk.len();
865            let mut rhs_batch = vec![0.0f64; kernel.bprime.dim * chunk_len];
866
867            for (chunk_pos, &(_, _, from_full, to_full)) in branch_chunk.iter().enumerate() {
868                let rhs = &mut rhs_batch
869                    [chunk_pos * kernel.bprime.dim..(chunk_pos + 1) * kernel.bprime.dim];
870                if let Some(ri_from) = kernel.bprime.full_to_reduced[from_full] {
871                    rhs[ri_from] = 1.0;
872                }
873                if let Some(ri_to) = kernel.bprime.full_to_reduced[to_full] {
874                    rhs[ri_to] -= 1.0;
875                }
876            }
877
878            kernel
879                .klu
880                .solve_many(&mut rhs_batch, chunk_len)
881                .map_err(|_| DcError::SingularMatrix)?;
882
883            for (chunk_pos, &(row_pos, branch_b, _, _)) in branch_chunk.iter().enumerate() {
884                let rhs =
885                    &rhs_batch[chunk_pos * kernel.bprime.dim..(chunk_pos + 1) * kernel.bprime.dim];
886                let row = result.row_at_mut(row_pos);
887                let correction = normalized_slack
888                    .map(|participants| {
889                        participants
890                            .iter()
891                            .map(|&(bus_idx, weight)| {
892                                let raw_value = kernel.bprime.full_to_reduced[bus_idx]
893                                    .map(|ri| branch_b * rhs[ri])
894                                    .unwrap_or(0.0);
895                                raw_value * weight
896                            })
897                            .sum()
898                    })
899                    .unwrap_or(0.0);
900                if uses_full_bus_axis {
901                    for (ri, &full_bus_idx) in kernel.reduced_to_full.iter().enumerate() {
902                        row[full_bus_idx] = branch_b * rhs[ri] - correction;
903                    }
904                    if normalized_slack.is_some() && kernel.bprime.slack_idx < row.len() {
905                        row[kernel.bprime.slack_idx] = -correction;
906                    }
907                } else {
908                    for (bus_pos, &full_bus_idx) in selected_bus_indices.iter().enumerate() {
909                        row[bus_pos] = kernel.bprime.full_to_reduced[full_bus_idx]
910                            .map(|ri| branch_b * rhs[ri] - correction)
911                            .unwrap_or(-correction);
912                    }
913                }
914            }
915        }
916
917        Ok(result)
918    }
919
920    pub fn compute_ptdf_with_options(
921        &mut self,
922        monitored_branch_indices: &[usize],
923        bus_indices: Option<&[usize]>,
924        options: &crate::sensitivity::DcSensitivityOptions,
925    ) -> Result<PtdfRows, DcError> {
926        let normalized_slack = self.resolve_sensitivity_slack_by_kernel(&options.slack)?;
927        self.compute_ptdf_with_resolved_slack(
928            monitored_branch_indices,
929            bus_indices,
930            &normalized_slack,
931        )
932    }
933
934    /// Compute OTDF for the given monitored and outage branches using single-slack semantics.
935    pub fn compute_otdf(
936        &mut self,
937        monitored_branch_indices: &[usize],
938        outage_branch_indices: &[usize],
939    ) -> Result<OtdfResult, DcError> {
940        let all_bus_indices: Vec<usize> = (0..self.network.n_buses()).collect();
941        self.compute_otdf_with_resolved_slack(
942            monitored_branch_indices,
943            outage_branch_indices,
944            Some(&all_bus_indices),
945            &self.single_slack_by_kernel(),
946        )
947    }
948
949    /// Compute OTDF from an explicit [`OtdfRequest`](crate::OtdfRequest).
950    pub fn compute_otdf_request(
951        &mut self,
952        request: &crate::sensitivity::OtdfRequest,
953    ) -> Result<OtdfResult, DcError> {
954        self.compute_otdf_with_options(
955            &request.monitored_branch_indices,
956            &request.outage_branch_indices,
957            request.bus_indices.as_deref(),
958            &request.options,
959        )
960    }
961
962    pub(crate) fn compute_otdf_with_options(
963        &mut self,
964        monitored_branch_indices: &[usize],
965        outage_branch_indices: &[usize],
966        bus_indices: Option<&[usize]>,
967        options: &crate::sensitivity::DcSensitivityOptions,
968    ) -> Result<OtdfResult, DcError> {
969        let normalized_slack = self.resolve_sensitivity_slack_by_kernel(&options.slack)?;
970        self.compute_otdf_with_resolved_slack(
971            monitored_branch_indices,
972            outage_branch_indices,
973            bus_indices,
974            &normalized_slack,
975        )
976    }
977
978    fn compute_otdf_with_resolved_slack(
979        &mut self,
980        monitored_branch_indices: &[usize],
981        outage_branch_indices: &[usize],
982        bus_indices: Option<&[usize]>,
983        normalized_slack_by_kernel: &[Option<Vec<(usize, f64)>>],
984    ) -> Result<OtdfResult, DcError> {
985        let selected_bus_indices =
986            crate::sensitivity::collect_selected_bus_indices(self.network, bus_indices)?;
987        let union_branch_indices = crate::sensitivity::collect_unique_branch_indices(
988            self.network.n_branches(),
989            monitored_branch_indices,
990            outage_branch_indices,
991        )?;
992        let ptdf = self.compute_ptdf_with_resolved_slack(
993            &union_branch_indices,
994            None,
995            normalized_slack_by_kernel,
996        )?;
997        crate::sensitivity::compute_otdf_from_ptdf(
998            self.network,
999            monitored_branch_indices,
1000            outage_branch_indices,
1001            &selected_bus_indices,
1002            &ptdf,
1003        )
1004    }
1005
1006    #[allow(clippy::type_complexity)]
1007    fn resolve_sensitivity_slack_by_kernel(
1008        &self,
1009        slack: &crate::sensitivity::DcSensitivitySlack,
1010    ) -> Result<Vec<Option<Vec<(usize, f64)>>>, DcError> {
1011        let mut per_kernel = vec![None; self.kernels.len()];
1012        match slack {
1013            crate::sensitivity::DcSensitivitySlack::SingleSlack => Ok(per_kernel),
1014            crate::sensitivity::DcSensitivitySlack::SlackWeights(slack_weights) => {
1015                let mut grouped_weights = vec![Vec::new(); self.kernels.len()];
1016                for &(bus_idx, weight) in slack_weights {
1017                    if bus_idx >= self.network.n_buses() {
1018                        return Err(DcError::InvalidNetwork(format!(
1019                            "slack weight bus index {bus_idx} out of range for network with {} buses",
1020                            self.network.n_buses()
1021                        )));
1022                    }
1023                    if let Some(kernel_idx) = self.bus_kernel_indices[bus_idx] {
1024                        grouped_weights[kernel_idx].push((bus_idx, weight));
1025                    }
1026                }
1027                for (kernel_idx, weights) in grouped_weights.into_iter().enumerate() {
1028                    let normalized = self.normalize_sensitivity_slack_weights(weights)?;
1029                    if !normalized.is_empty() {
1030                        per_kernel[kernel_idx] = Some(normalized);
1031                    }
1032                }
1033                Ok(per_kernel)
1034            }
1035            crate::sensitivity::DcSensitivitySlack::HeadroomSlack(participating_bus_indices) => {
1036                let mut grouped_weights = vec![Vec::new(); self.kernels.len()];
1037                for &bus_idx in participating_bus_indices {
1038                    if bus_idx >= self.network.n_buses() {
1039                        return Err(DcError::InvalidNetwork(format!(
1040                            "participating bus index {bus_idx} out of range for network with {} buses",
1041                            self.network.n_buses()
1042                        )));
1043                    }
1044                    let Some(kernel_idx) = self.bus_kernel_indices[bus_idx] else {
1045                        continue;
1046                    };
1047                    if bus_idx == self.kernels[kernel_idx].bprime.slack_idx {
1048                        continue;
1049                    }
1050                    let headroom = self.downward_headroom_by_bus_pu[bus_idx];
1051                    if headroom > 0.0 {
1052                        grouped_weights[kernel_idx].push((bus_idx, headroom));
1053                    }
1054                }
1055                for (kernel_idx, weights) in grouped_weights.into_iter().enumerate() {
1056                    let normalized = self.normalize_sensitivity_slack_weights(weights)?;
1057                    if !normalized.is_empty() {
1058                        per_kernel[kernel_idx] = Some(normalized);
1059                    }
1060                }
1061                Ok(per_kernel)
1062            }
1063        }
1064    }
1065
1066    fn normalize_sensitivity_slack_weights<I>(
1067        &self,
1068        slack_weights: I,
1069    ) -> Result<Vec<(usize, f64)>, DcError>
1070    where
1071        I: IntoIterator<Item = (usize, f64)>,
1072    {
1073        let n_buses = self.network.n_buses();
1074        let mut normalized_weights: HashMap<usize, f64> = HashMap::new();
1075        let mut total_weight = 0.0;
1076
1077        for (bus_idx, weight) in slack_weights {
1078            if bus_idx >= n_buses {
1079                return Err(DcError::InvalidNetwork(format!(
1080                    "slack weight bus index {bus_idx} out of range for network with {n_buses} buses"
1081                )));
1082            }
1083            if !weight.is_finite() {
1084                return Err(DcError::InvalidNetwork(format!(
1085                    "slack weight for bus {bus_idx} must be finite"
1086                )));
1087            }
1088            let clipped_weight = weight.max(0.0);
1089            if clipped_weight > 0.0 {
1090                *normalized_weights.entry(bus_idx).or_insert(0.0) += clipped_weight;
1091            }
1092            total_weight += clipped_weight;
1093        }
1094
1095        if total_weight < 1e-12 {
1096            return Ok(Vec::new());
1097        }
1098
1099        let mut normalized_weights: Vec<(usize, f64)> = normalized_weights
1100            .into_iter()
1101            .map(|(bus_idx, weight)| (bus_idx, weight / total_weight))
1102            .collect();
1103        normalized_weights.sort_unstable_by_key(|(bus_idx, _)| *bus_idx);
1104        Ok(normalized_weights)
1105    }
1106
1107    fn resolve_selected_bus_indices(
1108        &self,
1109        bus_indices: Option<&[usize]>,
1110    ) -> Result<Vec<usize>, DcError> {
1111        crate::sensitivity::collect_selected_bus_indices(self.network, bus_indices)
1112    }
1113
1114    fn single_slack_by_kernel(&self) -> Vec<Option<Vec<(usize, f64)>>> {
1115        vec![None; self.kernels.len()]
1116    }
1117
1118    fn branch_kernel_index(&self, branch_idx: usize) -> Result<usize, DcError> {
1119        self.branch_kernel_indices
1120            .get(branch_idx)
1121            .copied()
1122            .flatten()
1123            .ok_or_else(|| {
1124                DcError::Infeasible(format!(
1125                    "branch {branch_idx} does not belong to an active prepared DC island"
1126                ))
1127            })
1128    }
1129
1130    /// Create a lazily-evaluated LODF column builder backed by this prepared model.
1131    pub fn lodf_columns(&mut self) -> LodfColumnBuilder<'_, 'a> {
1132        LodfColumnBuilder::new(self)
1133    }
1134
1135    /// Create a lazily-evaluated N-2 LODF column builder backed by this prepared model.
1136    pub fn n2_lodf_columns(
1137        &mut self,
1138        monitored_branch_indices: &[usize],
1139        candidate_outage_branch_indices: &[usize],
1140    ) -> Result<N2LodfColumnBuilder<'_, 'a>, DcError> {
1141        N2LodfColumnBuilder::new(
1142            self,
1143            monitored_branch_indices,
1144            candidate_outage_branch_indices,
1145        )
1146    }
1147
1148    /// Compute rectangular LODF for the given monitored and outage branch sets.
1149    pub fn compute_lodf(
1150        &mut self,
1151        monitored_branch_indices: &[usize],
1152        outage_branch_indices: &[usize],
1153    ) -> Result<LodfResult, DcError> {
1154        let mut lodf =
1155            Mat::<f64>::zeros(monitored_branch_indices.len(), outage_branch_indices.len());
1156        let mut lodf_columns = self.lodf_columns();
1157        lodf_columns.stream_columns(
1158            monitored_branch_indices,
1159            outage_branch_indices,
1160            |outage_pos, _outage_branch_idx, column| {
1161                for (monitored_pos, &value) in column.iter().enumerate() {
1162                    lodf[(monitored_pos, outage_pos)] = value;
1163                }
1164                Ok(())
1165            },
1166        )?;
1167        Ok(LodfResult::new(
1168            monitored_branch_indices,
1169            outage_branch_indices,
1170            lodf,
1171        ))
1172    }
1173
1174    /// Compute LODF from an explicit [`LodfRequest`](crate::LodfRequest).
1175    pub fn compute_lodf_request(
1176        &mut self,
1177        request: &crate::sensitivity::LodfRequest,
1178    ) -> Result<LodfResult, DcError> {
1179        let monitored_branch_indices_storage;
1180        let monitored_branch_indices = if let Some(indices) =
1181            request.monitored_branch_indices.as_deref()
1182        {
1183            indices
1184        } else {
1185            monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1186            &monitored_branch_indices_storage
1187        };
1188        let outage_branch_indices = request
1189            .outage_branch_indices
1190            .as_deref()
1191            .unwrap_or(monitored_branch_indices);
1192        self.compute_lodf(monitored_branch_indices, outage_branch_indices)
1193    }
1194
1195    /// Compute selected LODF entries as a sparse pair map.
1196    ///
1197    /// The returned map is keyed as `(monitored_branch_idx, outage_branch_idx)`.
1198    pub fn compute_lodf_pairs(
1199        &mut self,
1200        monitored_branch_indices: &[usize],
1201        outage_branch_indices: &[usize],
1202    ) -> Result<LodfPairs, DcError> {
1203        let mut pairs =
1204            HashMap::with_capacity(monitored_branch_indices.len() * outage_branch_indices.len());
1205        let mut lodf_columns = self.lodf_columns();
1206        lodf_columns.stream_columns(
1207            monitored_branch_indices,
1208            outage_branch_indices,
1209            |_, outage_branch_idx, column| {
1210                for (monitored_pos, &monitored_branch_idx) in
1211                    monitored_branch_indices.iter().enumerate()
1212                {
1213                    pairs.insert(
1214                        (monitored_branch_idx, outage_branch_idx),
1215                        column[monitored_pos],
1216                    );
1217                }
1218                Ok(())
1219            },
1220        )?;
1221        Ok(LodfPairs::new(
1222            monitored_branch_indices,
1223            outage_branch_indices,
1224            pairs,
1225        ))
1226    }
1227
1228    /// Compute a dense all-pairs LODF matrix for the given branch set.
1229    pub fn compute_lodf_matrix(&mut self, branches: &[usize]) -> Result<LodfMatrixResult, DcError> {
1230        let lodf = self.compute_lodf(branches, branches)?;
1231        Ok(LodfMatrixResult::new(branches, lodf.into_parts().2))
1232    }
1233
1234    /// Compute a dense all-pairs LODF matrix from an explicit [`LodfMatrixRequest`](crate::LodfMatrixRequest).
1235    pub fn compute_lodf_matrix_request(
1236        &mut self,
1237        request: &crate::sensitivity::LodfMatrixRequest,
1238    ) -> Result<LodfMatrixResult, DcError> {
1239        let branch_indices_storage;
1240        let branch_indices = if let Some(indices) = request.branch_indices.as_deref() {
1241            indices
1242        } else {
1243            branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1244            &branch_indices_storage
1245        };
1246        self.compute_lodf_matrix(branch_indices)
1247    }
1248
1249    /// Compute N-2 LODF for a single simultaneous double-outage pair.
1250    pub fn compute_n2_lodf(
1251        &mut self,
1252        outage_pair: (usize, usize),
1253        monitored_branch_indices: &[usize],
1254    ) -> Result<N2LodfResult, DcError> {
1255        let (k, l) = outage_pair;
1256        let n_br = self.network.n_branches();
1257
1258        if k >= n_br || l >= n_br {
1259            return Err(DcError::Infeasible(format!(
1260                "outage_pair ({k}, {l}) out of range for network with {n_br} branches"
1261            )));
1262        }
1263        if k == l {
1264            return Err(DcError::Infeasible(format!(
1265                "N-2 outage pair must contain two distinct branches, got ({k}, {l})"
1266            )));
1267        }
1268        let mut n2_columns = self.n2_lodf_columns(monitored_branch_indices, &[k, l])?;
1269        let values = n2_columns.compute_column(k, l)?;
1270        Ok(N2LodfResult::new(
1271            monitored_branch_indices,
1272            outage_pair,
1273            values,
1274        ))
1275    }
1276
1277    /// Compute N-2 LODF from an explicit [`N2LodfRequest`](crate::N2LodfRequest).
1278    pub fn compute_n2_lodf_request(
1279        &mut self,
1280        request: &crate::sensitivity::N2LodfRequest,
1281    ) -> Result<N2LodfResult, DcError> {
1282        let monitored_branch_indices_storage;
1283        let monitored_branch_indices = if let Some(indices) =
1284            request.monitored_branch_indices.as_deref()
1285        {
1286            indices
1287        } else {
1288            monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1289            &monitored_branch_indices_storage
1290        };
1291        self.compute_n2_lodf(request.outage_pair, monitored_branch_indices)
1292    }
1293
1294    /// Compute batched N-2 LODF for multiple simultaneous double-outage pairs.
1295    pub fn compute_n2_lodf_batch(
1296        &mut self,
1297        outage_pairs: &[(usize, usize)],
1298        monitored_branch_indices: &[usize],
1299    ) -> Result<N2LodfBatchResult, DcError> {
1300        let mut unique_outages = Vec::with_capacity(outage_pairs.len() * 2);
1301        for &(first, second) in outage_pairs {
1302            unique_outages.push(first);
1303            unique_outages.push(second);
1304        }
1305        unique_outages.sort_unstable();
1306        unique_outages.dedup();
1307
1308        let mut n2_columns = self.n2_lodf_columns(monitored_branch_indices, &unique_outages)?;
1309        let mut result = Mat::<f64>::zeros(monitored_branch_indices.len(), outage_pairs.len());
1310        n2_columns.stream_columns(outage_pairs, |pair_pos, _pair, column| {
1311            for (monitored_pos, &value) in column.iter().enumerate() {
1312                result[(monitored_pos, pair_pos)] = value;
1313            }
1314            Ok(())
1315        })?;
1316        Ok(N2LodfBatchResult::new(
1317            monitored_branch_indices,
1318            outage_pairs,
1319            result,
1320        ))
1321    }
1322
1323    /// Compute batched N-2 LODF from an explicit [`N2LodfBatchRequest`](crate::N2LodfBatchRequest).
1324    pub fn compute_n2_lodf_batch_request(
1325        &mut self,
1326        request: &crate::sensitivity::N2LodfBatchRequest,
1327    ) -> Result<N2LodfBatchResult, DcError> {
1328        let monitored_branch_indices_storage;
1329        let monitored_branch_indices = if let Some(indices) =
1330            request.monitored_branch_indices.as_deref()
1331        {
1332            indices
1333        } else {
1334            monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1335            &monitored_branch_indices_storage
1336        };
1337        self.compute_n2_lodf_batch(&request.outage_pairs, monitored_branch_indices)
1338    }
1339
1340    fn branch_terminal_indices(&self, branch_idx: usize) -> Result<(usize, usize), DcError> {
1341        let branch = self.branch_metadata(branch_idx)?;
1342        if !branch.in_service {
1343            return Err(DcError::Infeasible(format!(
1344                "branch {branch_idx} is out of service and cannot be used as an outage"
1345            )));
1346        }
1347        if !branch.has_reactance {
1348            return Err(DcError::Infeasible(format!(
1349                "branch {branch_idx} has near-zero reactance and cannot be used as an outage"
1350            )));
1351        }
1352        Ok((branch.from_full, branch.to_full))
1353    }
1354
1355    #[inline(always)]
1356    fn branch_metadata(&self, branch_idx: usize) -> Result<BranchDcMetadata, DcError> {
1357        self.branch_metadata
1358            .get(branch_idx)
1359            .copied()
1360            .ok_or_else(|| {
1361                DcError::InvalidNetwork(format!(
1362                    "branch index {branch_idx} out of range (len={})",
1363                    self.network.branches.len()
1364                ))
1365            })
1366    }
1367}
1368
1369fn compute_singleton_slack_injection_pu(
1370    network: &Network,
1371    bus_idx: usize,
1372    opts: &DcPfOptions,
1373) -> f64 {
1374    let mut full_to_reduced = vec![None; network.n_buses()];
1375    full_to_reduced[bus_idx] = Some(0);
1376    let included_buses = HashSet::from([bus_idx]);
1377    let effective_injection =
1378        build_p_injection_for_buses(network, &full_to_reduced, usize::MAX, Some(&included_buses));
1379    let bus_number = network.buses[bus_idx].number;
1380    let external_p_pu: f64 = opts
1381        .external_p_injections_mw
1382        .iter()
1383        .filter(|(target_bus, _)| *target_bus == bus_number)
1384        .map(|(_, p_mw)| *p_mw / network.base_mva)
1385        .sum();
1386    -(effective_injection[0] + external_p_pu)
1387}
1388
1389/// Solve DC power flow for a network using default options (single slack).
1390///
1391/// Returns bus angles and branch flows. The slack bus angle is fixed at 0.
1392///
1393/// # Example
1394///
1395/// ```no_run
1396/// use surge_io::load;
1397/// use surge_dc::solve_dc;
1398///
1399/// let net = load("examples/cases/ieee118/case118.surge.json.zst").unwrap();
1400/// let sol = solve_dc(&net).unwrap();
1401/// println!("slack={:.2} MW, branches={}", sol.slack_p_injection, sol.branch_p_flow.len());
1402/// ```
1403pub fn solve_dc(network: &Network) -> Result<DcPfSolution, DcError> {
1404    solve_dc_opts(network, &DcPfOptions::default())
1405}
1406
1407/// Solve DC power flow for a network with explicit options.
1408///
1409/// Supports PST-corrected injections (always active when `Branch::shift != 0`)
1410/// and optional headroom-limited slack balancing (PST-02).
1411///
1412/// When `opts.headroom_slack_bus_indices` is `Some(buses)`, the total power
1413/// imbalance absorbed by the single slack bus in the first solve is
1414/// redistributed across the listed buses in proportion to available generator
1415/// headroom at those buses. The B' system is re-solved once with the adjusted
1416/// injection vector so that angles and flows reflect the headroom-limited
1417/// balancing. The result includes `total_generation_mw` and
1418/// `slack_distribution` showing each bus's share of the absorbed mismatch.
1419pub fn solve_dc_opts(network: &Network, opts: &DcPfOptions) -> Result<DcPfSolution, DcError> {
1420    let mut study = PreparedDcStudy::new(network)?;
1421    study.solve(opts)
1422}
1423
1424/// Compute DC marginal loss sensitivities with one sparse adjoint solve.
1425///
1426/// Equivalent to multiplying branch loss gradients by the single-slack PTDF,
1427/// but does not allocate a dense branch-by-bus PTDF matrix.
1428pub fn compute_loss_sensitivities_adjoint(
1429    network: &Network,
1430    theta: &[f64],
1431) -> Result<Vec<f64>, DcError> {
1432    let mut study = PreparedDcStudy::new(network)?;
1433    study.compute_loss_sensitivities_adjoint(theta)
1434}
1435
1436/// Compute the canonical DC power flow + sensitivity workflow for one network.
1437pub fn run_dc_analysis(
1438    network: &Network,
1439    request: &DcAnalysisRequest,
1440) -> Result<DcAnalysisResult, DcError> {
1441    let mut study = PreparedDcStudy::new(network)?;
1442    study.run_analysis(request)
1443}
1444
1445fn build_branch_metadata(
1446    network: &Network,
1447    bus_map: &HashMap<u32, usize>,
1448) -> Result<Vec<BranchDcMetadata>, DcError> {
1449    network
1450        .branches
1451        .iter()
1452        .enumerate()
1453        .map(|(branch_idx, branch)| {
1454            let from_full = bus_map.get(&branch.from_bus).copied().ok_or_else(|| {
1455                DcError::InvalidNetwork(format!(
1456                    "branch {branch_idx} references unknown from_bus {}",
1457                    branch.from_bus
1458                ))
1459            })?;
1460            let to_full = bus_map.get(&branch.to_bus).copied().ok_or_else(|| {
1461                DcError::InvalidNetwork(format!(
1462                    "branch {branch_idx} references unknown to_bus {}",
1463                    branch.to_bus
1464                ))
1465            })?;
1466            Ok(BranchDcMetadata {
1467                from_full,
1468                to_full,
1469                b_dc: if branch.x.abs() >= crate::types::MIN_REACTANCE {
1470                    branch.b_dc()
1471                } else {
1472                    0.0
1473                },
1474                in_service: branch.in_service,
1475                has_reactance: branch.x.abs() >= crate::types::MIN_REACTANCE,
1476            })
1477        })
1478        .collect()
1479}
1480
1481fn build_downward_headroom_by_bus(network: &Network, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
1482    let mut downward_headroom_by_bus_pu = vec![0.0; network.n_buses()];
1483    for generator in network
1484        .generators
1485        .iter()
1486        .filter(|generator| generator.in_service)
1487    {
1488        if let Some(&bus_idx) = bus_map.get(&generator.bus) {
1489            downward_headroom_by_bus_pu[bus_idx] +=
1490                (generator.p - generator.pmin).max(0.0) / network.base_mva;
1491        }
1492    }
1493    downward_headroom_by_bus_pu
1494}
1495
1496fn choose_island_slack_idx(network: &Network, island_buses: &[usize]) -> Result<usize, DcError> {
1497    island_buses
1498        .iter()
1499        .copied()
1500        .find(|&idx| network.buses[idx].bus_type == BusType::Slack)
1501        .or_else(|| {
1502            island_buses
1503                .iter()
1504                .copied()
1505                .find(|&idx| network.buses[idx].bus_type == BusType::PV)
1506        })
1507        .or_else(|| island_buses.first().copied())
1508        .ok_or(DcError::NoSlackBus)
1509}
1510
1511fn island_branch_indices(
1512    network: &Network,
1513    island_bus_set: &HashSet<usize>,
1514    bus_map: &HashMap<u32, usize>,
1515) -> Vec<usize> {
1516    network
1517        .branches
1518        .iter()
1519        .enumerate()
1520        .filter_map(|(idx, branch)| {
1521            if !branch.in_service || branch.x.abs() < crate::types::MIN_REACTANCE {
1522                return None;
1523            }
1524            let from_idx = *bus_map.get(&branch.from_bus)?;
1525            let to_idx = *bus_map.get(&branch.to_bus)?;
1526            (island_bus_set.contains(&from_idx) && island_bus_set.contains(&to_idx)).then_some(idx)
1527        })
1528        .collect()
1529}
1530
1531fn solve_prepared(
1532    network: &Network,
1533    opts: &DcPfOptions,
1534    mut prepared: PreparedSolveInputs<'_>,
1535    start: Instant,
1536) -> Result<DcPfSolution, DcError> {
1537    let n = network.n_buses();
1538    let slack_idx = prepared.bprime.slack_idx;
1539
1540    // Build effective injection vector: base + external P injections.
1541    let effective_p_inj: Vec<f64> = if opts.external_p_injections_mw.is_empty() {
1542        prepared.p_inj_base.to_vec()
1543    } else {
1544        let mut p = prepared.p_inj_base.to_vec();
1545        for &(bus_number, p_mw) in &opts.external_p_injections_mw {
1546            if let Some(&full_idx) = prepared.bus_map.get(&bus_number) {
1547                if let Some(ri) = prepared.bprime.full_to_reduced[full_idx] {
1548                    p[ri] += p_mw / network.base_mva;
1549                }
1550            }
1551        }
1552        p
1553    };
1554
1555    let mut p = effective_p_inj.clone();
1556    if prepared.klu.solve(&mut p).is_err() {
1557        error!(buses = n, "DC B' solve failed");
1558        return Err(DcError::SingularMatrix);
1559    }
1560
1561    let mut theta = reconstruct_theta(n, &prepared.bprime.full_to_reduced, &p);
1562
1563    let mut branch_p_flow = compute_branch_flows_subset(
1564        network,
1565        theta.as_slice(),
1566        prepared.branch_indices,
1567        prepared.bus_map,
1568    );
1569    let slack_p_1 = compute_slack_injection_subset(
1570        network,
1571        &branch_p_flow,
1572        prepared.branch_indices,
1573        prepared.bus_map,
1574        slack_idx,
1575    );
1576
1577    let (slack_p_injection, slack_distribution, total_generation_mw, p_inject_pu) =
1578        if let Some(pf_weights) = opts.participation_factors.as_ref() {
1579            if pf_weights.is_empty() {
1580                warn!("participation_factors list is empty — falling back to single slack");
1581                let p_inj_full = expand_p_inject(
1582                    n,
1583                    &prepared.bprime.full_to_reduced,
1584                    &effective_p_inj,
1585                    slack_idx,
1586                    slack_p_1,
1587                );
1588                (slack_p_1, HashMap::new(), 0.0, p_inj_full)
1589            } else {
1590                apply_participation_factor_slack(
1591                    network,
1592                    &mut prepared,
1593                    pf_weights,
1594                    &effective_p_inj,
1595                    slack_p_1,
1596                    slack_idx,
1597                    &mut theta,
1598                    &mut branch_p_flow,
1599                )?
1600            }
1601        } else if let Some(participating_buses) = opts.headroom_slack_bus_indices.as_ref() {
1602            if participating_buses.is_empty() {
1603                warn!("headroom slack bus list is empty — falling back to single slack");
1604                let p_inj_full = expand_p_inject(
1605                    n,
1606                    &prepared.bprime.full_to_reduced,
1607                    &effective_p_inj,
1608                    slack_idx,
1609                    slack_p_1,
1610                );
1611                (slack_p_1, HashMap::new(), 0.0, p_inj_full)
1612            } else {
1613                apply_headroom_slack(
1614                    network,
1615                    &mut prepared,
1616                    participating_buses,
1617                    &effective_p_inj,
1618                    slack_p_1,
1619                    slack_idx,
1620                    &mut theta,
1621                    &mut branch_p_flow,
1622                )?
1623            }
1624        } else {
1625            let p_inj_full = expand_p_inject(
1626                n,
1627                &prepared.bprime.full_to_reduced,
1628                &effective_p_inj,
1629                slack_idx,
1630                slack_p_1,
1631            );
1632            (slack_p_1, HashMap::new(), 0.0, p_inj_full)
1633        };
1634
1635    let solve_time = start.elapsed().as_secs_f64();
1636    info!(
1637        buses = n,
1638        branches = prepared.branch_indices.len(),
1639        solve_time_ms = format_args!("{:.3}", solve_time * 1000.0),
1640        "DC power flow solved"
1641    );
1642
1643    apply_angle_reference_subset(
1644        &mut theta,
1645        network,
1646        prepared.bus_indices,
1647        slack_idx,
1648        prepared.reference_angle0_rad,
1649        opts.angle_reference,
1650    );
1651
1652    Ok(DcPfSolution {
1653        theta,
1654        branch_p_flow,
1655        slack_p_injection,
1656        solve_time_secs: solve_time,
1657        total_generation_mw,
1658        slack_distribution,
1659        p_inject_pu,
1660        island_ids: vec![],
1661    })
1662}
1663
1664// ---------------------------------------------------------------------------
1665// Internal helpers
1666// ---------------------------------------------------------------------------
1667
1668/// Apply headroom-limited slack balancing: redistribute the initial slack
1669/// mismatch across participating buses in proportion to generator headroom,
1670/// then re-solve the B' system for updated angles and flows.
1671///
1672/// Returns `(slack_p, slack_distribution, total_gen_mw, p_inject_pu)`.
1673#[allow(clippy::too_many_arguments)]
1674fn apply_headroom_slack(
1675    network: &Network,
1676    prepared: &mut PreparedSolveInputs<'_>,
1677    participating_buses: &[usize],
1678    effective_p_inj: &[f64],
1679    initial_slack_p: f64,
1680    slack_idx: usize,
1681    theta: &mut Vec<f64>,
1682    branch_p_flow: &mut Vec<f64>,
1683) -> Result<(f64, HashMap<usize, f64>, f64, Vec<f64>), DcError> {
1684    let n = network.n_buses();
1685    let mut p_adj = effective_p_inj.to_vec();
1686    let mut slack_dist: HashMap<usize, f64> = HashMap::with_capacity(participating_buses.len());
1687
1688    let mut bus_headroom_pu: HashMap<usize, f64> = HashMap::new();
1689    for &bus_full_idx in participating_buses {
1690        if bus_full_idx != slack_idx
1691            && prepared
1692                .bprime
1693                .full_to_reduced
1694                .get(bus_full_idx)
1695                .copied()
1696                .flatten()
1697                .is_some()
1698        {
1699            bus_headroom_pu.entry(bus_full_idx).or_insert(0.0);
1700        }
1701    }
1702    for g in network.generators.iter().filter(|g| g.in_service) {
1703        if let Some(&bidx) = prepared.bus_map.get(&g.bus)
1704            && let Some(h) = bus_headroom_pu.get_mut(&bidx)
1705        {
1706            let headroom = if initial_slack_p >= 0.0 {
1707                (g.pmax - g.p) / network.base_mva
1708            } else {
1709                (g.p - g.pmin) / network.base_mva
1710            };
1711            *h += headroom.max(0.0);
1712        }
1713    }
1714
1715    let total_headroom_pu: f64 = bus_headroom_pu.values().sum();
1716
1717    if total_headroom_pu < 1e-12 {
1718        warn!(
1719            mismatch_mw = initial_slack_p.abs() * network.base_mva,
1720            "headroom slack: no participating generator has headroom \
1721             — mismatch absorbed by slack bus"
1722        );
1723    } else {
1724        let absorbable_pu = initial_slack_p.abs().min(total_headroom_pu);
1725        if initial_slack_p.abs() > total_headroom_pu + 1e-9 {
1726            warn!(
1727                total_headroom_mw = total_headroom_pu * network.base_mva,
1728                mismatch_mw = initial_slack_p.abs() * network.base_mva,
1729                "headroom slack: participant headroom exhausted \
1730                 — slack bus absorbs remainder"
1731            );
1732        }
1733
1734        for (&bus_full_idx, &headroom) in &bus_headroom_pu {
1735            let share_pu =
1736                (headroom / total_headroom_pu) * absorbable_pu * initial_slack_p.signum();
1737            slack_dist.insert(bus_full_idx, share_pu * network.base_mva);
1738            if let Some(ri) = prepared.bprime.full_to_reduced[bus_full_idx] {
1739                p_adj[ri] += share_pu;
1740            }
1741        }
1742    }
1743
1744    let p_eff_reduced = p_adj.clone();
1745
1746    if prepared.klu.solve(&mut p_adj).is_err() {
1747        return Err(DcError::SingularMatrix);
1748    }
1749
1750    *theta = reconstruct_theta(n, &prepared.bprime.full_to_reduced, &p_adj);
1751    *branch_p_flow = compute_branch_flows_subset(
1752        network,
1753        theta.as_slice(),
1754        prepared.branch_indices,
1755        prepared.bus_map,
1756    );
1757    let slack_p_2 = compute_slack_injection_subset(
1758        network,
1759        branch_p_flow,
1760        prepared.branch_indices,
1761        prepared.bus_map,
1762        slack_idx,
1763    );
1764
1765    let total_load_mw = network.total_load_mw();
1766    let total_gen = total_load_mw + slack_p_2 * network.base_mva;
1767
1768    debug!(
1769        participating_buses = participating_buses.len(),
1770        total_gen_mw = total_gen,
1771        remaining_slack_pu = slack_p_2,
1772        "headroom slack applied"
1773    );
1774
1775    slack_dist.insert(slack_idx, slack_p_2 * network.base_mva);
1776
1777    let p_inj_full = expand_p_inject(
1778        n,
1779        &prepared.bprime.full_to_reduced,
1780        &p_eff_reduced,
1781        slack_idx,
1782        slack_p_2,
1783    );
1784    Ok((slack_p_2, slack_dist, total_gen, p_inj_full))
1785}
1786
1787/// Apply participation-factor distributed slack: redistribute the initial slack
1788/// mismatch across participating buses in proportion to their weights, then
1789/// re-solve the B' system for updated angles and flows.
1790///
1791/// Returns `(slack_p, slack_distribution, total_gen_mw, p_inject_pu)`.
1792#[allow(clippy::too_many_arguments)]
1793fn apply_participation_factor_slack(
1794    network: &Network,
1795    prepared: &mut PreparedSolveInputs<'_>,
1796    weights: &[(usize, f64)],
1797    effective_p_inj: &[f64],
1798    initial_slack_p: f64,
1799    slack_idx: usize,
1800    theta: &mut Vec<f64>,
1801    branch_p_flow: &mut Vec<f64>,
1802) -> Result<(f64, HashMap<usize, f64>, f64, Vec<f64>), DcError> {
1803    let n = network.n_buses();
1804    let mut p_adj = effective_p_inj.to_vec();
1805    let mut slack_dist: HashMap<usize, f64> = HashMap::with_capacity(weights.len());
1806
1807    // Normalize weights to sum to 1.0, filtering to valid reduced-bus entries.
1808    let mut valid_weights: Vec<(usize, f64)> = Vec::with_capacity(weights.len());
1809    for &(bus_idx, w) in weights {
1810        if w > 0.0
1811            && w.is_finite()
1812            && bus_idx != slack_idx
1813            && bus_idx < n
1814            && prepared
1815                .bprime
1816                .full_to_reduced
1817                .get(bus_idx)
1818                .copied()
1819                .flatten()
1820                .is_some()
1821        {
1822            valid_weights.push((bus_idx, w));
1823        }
1824    }
1825    let total_weight: f64 = valid_weights.iter().map(|(_, w)| w).sum();
1826    if total_weight < 1e-12 {
1827        warn!("participation factor slack: no valid weights — mismatch absorbed by slack bus");
1828        let p_inj_full = expand_p_inject(
1829            n,
1830            &prepared.bprime.full_to_reduced,
1831            effective_p_inj,
1832            slack_idx,
1833            initial_slack_p,
1834        );
1835        return Ok((initial_slack_p, HashMap::new(), 0.0, p_inj_full));
1836    }
1837
1838    // Distribute the full mismatch proportionally. Unlike headroom slack,
1839    // participation-factor slack has no capacity limit — all mismatch is absorbed.
1840    for &(bus_idx, w) in &valid_weights {
1841        let share_pu = (w / total_weight) * initial_slack_p;
1842        slack_dist.insert(bus_idx, share_pu * network.base_mva);
1843        if let Some(ri) = prepared.bprime.full_to_reduced[bus_idx] {
1844            p_adj[ri] += share_pu;
1845        }
1846    }
1847
1848    let p_eff_reduced = p_adj.clone();
1849
1850    if prepared.klu.solve(&mut p_adj).is_err() {
1851        return Err(DcError::SingularMatrix);
1852    }
1853
1854    *theta = reconstruct_theta(n, &prepared.bprime.full_to_reduced, &p_adj);
1855    *branch_p_flow = compute_branch_flows_subset(
1856        network,
1857        theta.as_slice(),
1858        prepared.branch_indices,
1859        prepared.bus_map,
1860    );
1861    let slack_p_2 = compute_slack_injection_subset(
1862        network,
1863        branch_p_flow,
1864        prepared.branch_indices,
1865        prepared.bus_map,
1866        slack_idx,
1867    );
1868
1869    let total_load_mw = network.total_load_mw();
1870    let total_gen = total_load_mw + slack_p_2 * network.base_mva;
1871
1872    debug!(
1873        n_participants = valid_weights.len(),
1874        total_gen_mw = total_gen,
1875        remaining_slack_pu = slack_p_2,
1876        "participation factor slack applied"
1877    );
1878
1879    slack_dist.insert(slack_idx, slack_p_2 * network.base_mva);
1880
1881    let p_inj_full = expand_p_inject(
1882        n,
1883        &prepared.bprime.full_to_reduced,
1884        &p_eff_reduced,
1885        slack_idx,
1886        slack_p_2,
1887    );
1888    Ok((slack_p_2, slack_dist, total_gen, p_inj_full))
1889}
1890
1891/// Expand a reduced (slack-removed) injection vector to a full-bus vector,
1892/// inserting the back-calculated slack injection at `slack_idx`.
1893fn expand_p_inject(
1894    n: usize,
1895    full_to_reduced: &[Option<usize>],
1896    p_reduced: &[f64],
1897    slack_idx: usize,
1898    slack_p: f64,
1899) -> Vec<f64> {
1900    let mut p_full = vec![0.0f64; n];
1901    for (full_idx, ri) in full_to_reduced.iter().enumerate() {
1902        if let Some(ri) = ri {
1903            p_full[full_idx] = p_reduced[*ri];
1904        }
1905    }
1906    p_full[slack_idx] = slack_p;
1907    p_full
1908}
1909
1910/// Reconstruct the full-bus theta vector from the reduced (slack-removed) solution.
1911fn reconstruct_theta(
1912    n: usize,
1913    full_to_reduced: &[Option<usize>],
1914    theta_reduced: &[f64],
1915) -> Vec<f64> {
1916    let mut theta = vec![0.0; n];
1917    for (full_idx, ri) in full_to_reduced.iter().enumerate() {
1918        if let Some(ri) = ri {
1919            theta[full_idx] = theta_reduced[*ri];
1920        }
1921    }
1922    theta
1923}
1924
1925fn compute_branch_flows_subset(
1926    network: &Network,
1927    theta: &[f64],
1928    branch_indices: &[usize],
1929    bus_map: &HashMap<u32, usize>,
1930) -> Vec<f64> {
1931    let mut branch_p_flow = vec![0.0; network.n_branches()];
1932    for &br_idx in branch_indices {
1933        let branch = &network.branches[br_idx];
1934        let from_idx = bus_map[&branch.from_bus];
1935        let to_idx = bus_map[&branch.to_bus];
1936        let phi_rad = branch.phase_shift_rad;
1937        branch_p_flow[br_idx] = branch.b_dc() * (theta[from_idx] - theta[to_idx] - phi_rad);
1938    }
1939    branch_p_flow
1940}
1941
1942fn compute_slack_injection_subset(
1943    network: &Network,
1944    branch_p_flow: &[f64],
1945    branch_indices: &[usize],
1946    bus_map: &HashMap<u32, usize>,
1947    slack_idx: usize,
1948) -> f64 {
1949    let mut slack_p = 0.0;
1950    for &br_idx in branch_indices {
1951        let branch = &network.branches[br_idx];
1952        let from_idx = bus_map[&branch.from_bus];
1953        let to_idx = bus_map[&branch.to_bus];
1954        if from_idx == slack_idx {
1955            slack_p += branch_p_flow[br_idx];
1956        }
1957        if to_idx == slack_idx {
1958            slack_p -= branch_p_flow[br_idx];
1959        }
1960    }
1961    slack_p
1962}
1963
1964/// Convert a DC power flow result into the common [`PfSolution`] format.
1965///
1966/// # Hardcoded DC Power Flow Values
1967///
1968/// The DC power flow model does not compute voltage magnitudes or reactive power
1969/// (see the [`crate`]-level documentation for the three DC PF assumptions). The
1970/// following fields in the returned [`PfSolution`] are therefore set to
1971/// placeholder values reflecting the DC assumptions:
1972///
1973/// - **`vm`** (voltage magnitudes) — hardcoded to **1.0 p.u.** for all buses.
1974///   The DC power flow assumption `|V_i| = 1.0` means voltage magnitudes are not
1975///   solved for. Users requiring actual voltage magnitudes (e.g., for voltage
1976///   violation checking, tap optimization, or reactive planning) must use the
1977///   AC Newton-Raphson solver in `surge_ac`.
1978///
1979/// - **`q_inject`** (reactive power injections) — hardcoded to **0.0** for all buses.
1980///   The DC power flow decouples P from Q by assuming flat voltages and lossless
1981///   branches. Reactive power flow, generator Q limits, and shunt compensation
1982///   effects are not captured. Use AC power flow for reactive power analysis.
1983///
1984/// - **`q_limited_buses`** — empty (no Q-limit enforcement in DC PF).
1985///
1986/// - **`iterations`** — always 1 (DC PF is a single sparse linear solve, not iterative).
1987///
1988/// - **`max_mismatch`** — always 0.0 (the linear system is solved exactly).
1989///
1990/// The `va` (voltage angles) and `p_inject` (real power injections) fields contain
1991/// the actual DC power flow results and are physically meaningful within the DC
1992/// approximation.
1993pub fn to_pf_solution(result: &DcPfSolution, network: &Network) -> PfSolution {
1994    let n = network.n_buses();
1995
1996    // Use the effective injection vector stored in the result — this includes
1997    // all RHS correction terms (Gs shunts, PST phantom injections, HVDC
1998    // schedules, MTDC injections, distributed-slack shares) and is
1999    // self-consistent with branch_p_flow by construction.
2000    let p_inject = result.p_inject_pu.clone();
2001    let base = network.base_mva;
2002    let branch_p_from_mw: Vec<f64> = result.branch_p_flow.iter().map(|&f| f * base).collect();
2003    let branch_p_to_mw: Vec<f64> = branch_p_from_mw.iter().map(|&f| -f).collect();
2004    let n_branches = network.n_branches();
2005
2006    PfSolution {
2007        pf_model: PfModel::Dc,
2008        status: SolveStatus::Converged,
2009        iterations: 1,     // DC PF is a single linear solve — not iterative
2010        max_mismatch: 0.0, // linear system solved exactly (no mismatch)
2011        solve_time_secs: result.solve_time_secs,
2012        // DC power flow assumes |V| = 1.0 p.u. for all buses — voltage magnitudes
2013        // are not computed. For actual Vm values, use AC power flow (surge_ac).
2014        voltage_magnitude_pu: vec![1.0; n],
2015        voltage_angle_rad: result.theta.clone(),
2016        active_power_injection_pu: p_inject,
2017        // DC power flow does not compute reactive power — Q is decoupled from P
2018        // under the flat-voltage, lossless-branch assumptions. For reactive power
2019        // analysis, use AC power flow (surge_ac).
2020        reactive_power_injection_pu: vec![0.0; n],
2021        branch_p_from_mw,
2022        branch_p_to_mw,
2023        branch_q_from_mvar: vec![0.0; n_branches],
2024        branch_q_to_mvar: vec![0.0; n_branches],
2025        bus_numbers: network.buses.iter().map(|b| b.number).collect(),
2026        island_ids: result.island_ids.clone(),
2027        q_limited_buses: vec![], // no Q-limit enforcement in DC PF
2028        n_q_limit_switches: 0,
2029        gen_slack_contribution_mw: vec![],
2030        convergence_history: vec![],
2031        worst_mismatch_bus: None,
2032        area_interchange: None,
2033    }
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038    use super::*;
2039    use crate::test_util::*;
2040    use surge_network::network::{Branch, Bus, BusType, Generator, Load};
2041    use surge_network::{AngleReference, DistributedAngleWeight};
2042
2043    fn check_kcl(net: &Network, result: &DcPfSolution, tol: f64) {
2044        let bus_map = net.bus_index_map();
2045        let p_inj = net.bus_p_injection_pu();
2046        let slack_idx = net.slack_bus_index().unwrap();
2047        let mut bus_flow_sum = vec![0.0; net.n_buses()];
2048
2049        for (br_idx, branch) in net.branches.iter().enumerate() {
2050            if !branch.in_service {
2051                continue;
2052            }
2053            let from = bus_map[&branch.from_bus];
2054            let to = bus_map[&branch.to_bus];
2055            bus_flow_sum[from] += result.branch_p_flow[br_idx];
2056            bus_flow_sum[to] -= result.branch_p_flow[br_idx];
2057        }
2058
2059        for i in 0..net.n_buses() {
2060            if i == slack_idx {
2061                continue;
2062            }
2063            assert!(
2064                (bus_flow_sum[i] - p_inj[i]).abs() < tol,
2065                "KCL violated at bus {} (idx {}): flow_sum={:.6e}, p_inj={:.6e}",
2066                net.buses[i].number,
2067                i,
2068                bus_flow_sum[i],
2069                p_inj[i]
2070            );
2071        }
2072    }
2073
2074    #[test]
2075    fn test_dc_case9() {
2076        skip_if_no_data!();
2077        let net = load_net("case9");
2078        let result = solve_dc(&net).expect("DC PF should converge");
2079
2080        assert_eq!(result.theta.len(), 9);
2081        assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2082        for &angle in &result.theta {
2083            assert!(angle.abs() < 1.0, "unreasonable angle: {angle} rad");
2084        }
2085        check_kcl(&net, &result, 1e-8);
2086    }
2087
2088    #[test]
2089    fn test_dc_case14() {
2090        skip_if_no_data!();
2091        let net = load_net("case14");
2092        let result = solve_dc(&net).expect("DC PF should converge");
2093
2094        assert_eq!(result.theta.len(), 14);
2095        assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2096        check_kcl(&net, &result, 1e-8);
2097    }
2098
2099    #[test]
2100    fn test_dc_case118() {
2101        skip_if_no_data!();
2102        let net = load_net("case118");
2103        let result = solve_dc(&net).expect("DC PF should converge");
2104
2105        assert_eq!(result.theta.len(), 118);
2106        check_kcl(&net, &result, 1e-6);
2107    }
2108
2109    #[test]
2110    fn test_dc_case2383wp() {
2111        skip_if_no_data!();
2112        let net = load_net("case2383wp");
2113        let result = solve_dc(&net).expect("DC PF should converge on case2383wp");
2114
2115        assert_eq!(result.theta.len(), 2383);
2116        assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2117        check_kcl(&net, &result, 1e-5);
2118    }
2119
2120    /// Multi-island test: case16ci has 3 slack buses (3 islands).
2121    #[test]
2122    fn test_dc_multi_island_case16ci() {
2123        skip_if_no_data!();
2124        let net = load_net("case16ci");
2125        let result = solve_dc(&net).expect("DC PF should converge on multi-island case16ci");
2126        assert_eq!(result.theta.len(), 16);
2127        // All angles should be reasonable (< 1 radian)
2128        for &angle in &result.theta {
2129            assert!(angle.abs() < 1.0, "unreasonable angle: {angle} rad");
2130        }
2131    }
2132
2133    #[test]
2134    fn test_dc_solver_rejects_missing_slack_bus() {
2135        let mut net = Network::new("dc_missing_slack");
2136        net.base_mva = 100.0;
2137
2138        net.buses.push(Bus::new(1, BusType::PQ, 230.0));
2139        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
2140        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2141        net.loads.push(Load::new(2, 50.0, 0.0));
2142
2143        let err = solve_dc_opts(&net, &DcPfOptions::default()).unwrap_err();
2144        match err {
2145            DcError::InvalidNetwork(msg) => {
2146                assert!(
2147                    msg.contains("slack"),
2148                    "validation error should mention slack placement, got: {msg}"
2149                );
2150            }
2151            other => panic!("expected invalid-network error from DC validation, got {other:?}"),
2152        }
2153    }
2154
2155    #[test]
2156    fn test_multi_island_dc_line_injections_are_preserved() {
2157        use surge_network::network::{
2158            Branch, Bus, Generator, LccConverterTerminal, LccHvdcLink, Load,
2159        };
2160
2161        let mut net = Network::new("multi_island_dc_line");
2162        net.base_mva = 100.0;
2163
2164        let mut bus1 = Bus::new(1, BusType::Slack, 230.0);
2165        bus1.voltage_angle_rad = 0.05;
2166        net.buses.push(bus1);
2167
2168        let bus2 = Bus::new(2, BusType::PQ, 230.0);
2169        net.buses.push(bus2);
2170        net.loads.push(Load::new(2, 100.0, 0.0));
2171
2172        net.buses.push(Bus::new(3, BusType::Slack, 230.0));
2173        net.buses.push(Bus::new(4, BusType::PQ, 230.0));
2174        net.generators.push(Generator::new(1, 100.0, 1.0));
2175        net.generators.push(Generator::new(3, 0.0, 1.0));
2176
2177        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2178        net.branches.push(Branch::new_line(3, 4, 0.0, 0.1, 0.0));
2179
2180        let base = solve_dc(&net).expect("base multi-island DC solve");
2181
2182        let dc_line = LccHvdcLink {
2183            name: "L12".to_string(),
2184            scheduled_setpoint: 40.0,
2185            scheduled_voltage_kv: 500.0,
2186            rectifier: LccConverterTerminal {
2187                bus: 2,
2188                in_service: true,
2189                ..LccConverterTerminal::default()
2190            },
2191            inverter: LccConverterTerminal {
2192                bus: 4,
2193                in_service: true,
2194                ..LccConverterTerminal::default()
2195            },
2196            ..LccHvdcLink::default()
2197        };
2198        net.hvdc.push_lcc_link(dc_line);
2199
2200        let with_dc = solve_dc(&net).expect("multi-island DC solve with DC line");
2201
2202        assert!(
2203            (with_dc.branch_p_flow[0] - base.branch_p_flow[0] - 0.4).abs() < 1e-9,
2204            "island-A flow should include the rectifier withdrawal"
2205        );
2206        assert!(
2207            (with_dc.branch_p_flow[1] - base.branch_p_flow[1] + 0.4).abs() < 1e-9,
2208            "island-B flow should include the inverter injection"
2209        );
2210        assert!(
2211            (with_dc.p_inject_pu[1] + 1.4).abs() < 1e-9,
2212            "bus 2 should include the extra DC-line withdrawal"
2213        );
2214        assert!(
2215            (with_dc.p_inject_pu[3] - 0.4).abs() < 1e-9,
2216            "bus 4 should include the DC-line injection"
2217        );
2218    }
2219
2220    fn build_two_island_prepared_test_network() -> Network {
2221        use surge_network::network::{Branch, Bus, Generator, Load};
2222
2223        let mut net = Network::new("two_island_prepared_dc");
2224        net.base_mva = 100.0;
2225
2226        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2227        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
2228        net.loads.push(Load::new(2, 50.0, 0.0));
2229        net.buses.push(Bus::new(3, BusType::PQ, 230.0));
2230        net.loads.push(Load::new(3, 50.0, 0.0));
2231
2232        net.buses.push(Bus::new(4, BusType::Slack, 230.0));
2233        net.buses.push(Bus::new(5, BusType::PQ, 230.0));
2234        net.loads.push(Load::new(5, 50.0, 0.0));
2235        net.buses.push(Bus::new(6, BusType::PQ, 230.0));
2236        net.loads.push(Load::new(6, 50.0, 0.0));
2237
2238        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2239        net.branches.push(Branch::new_line(2, 3, 0.0, 0.1, 0.0));
2240        net.branches.push(Branch::new_line(1, 3, 0.0, 0.2, 0.0));
2241
2242        net.branches.push(Branch::new_line(4, 5, 0.0, 0.1, 0.0));
2243        net.branches.push(Branch::new_line(5, 6, 0.0, 0.1, 0.0));
2244        net.branches.push(Branch::new_line(4, 6, 0.0, 0.2, 0.0));
2245
2246        net.generators.push(Generator::new(1, 100.0, 1.0));
2247        net.generators.push(Generator::new(4, 100.0, 1.0));
2248
2249        net
2250    }
2251
2252    fn build_two_island_headroom_test_network() -> Network {
2253        use surge_network::network::{Branch, Bus, Generator, Load};
2254
2255        let mut net = Network::new("two_island_headroom_dc");
2256        net.base_mva = 100.0;
2257
2258        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2259        net.buses.push(Bus::new(2, BusType::PV, 230.0));
2260        net.buses.push(Bus::new(3, BusType::PQ, 230.0));
2261        net.loads.push(Load::new(3, 90.0, 0.0));
2262
2263        net.buses.push(Bus::new(4, BusType::Slack, 230.0));
2264        net.buses.push(Bus::new(5, BusType::PV, 230.0));
2265        net.buses.push(Bus::new(6, BusType::PQ, 230.0));
2266        net.loads.push(Load::new(6, 80.0, 0.0));
2267
2268        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2269        net.branches.push(Branch::new_line(2, 3, 0.0, 0.1, 0.0));
2270        net.branches.push(Branch::new_line(1, 3, 0.0, 0.2, 0.0));
2271        net.branches.push(Branch::new_line(4, 5, 0.0, 0.1, 0.0));
2272        net.branches.push(Branch::new_line(5, 6, 0.0, 0.1, 0.0));
2273        net.branches.push(Branch::new_line(4, 6, 0.0, 0.2, 0.0));
2274
2275        let mut g1 = Generator::new(1, 25.0, 1.0);
2276        g1.pmax = 120.0;
2277        net.generators.push(g1);
2278
2279        let mut g2 = Generator::new(2, 25.0, 1.0);
2280        g2.pmax = 90.0;
2281        net.generators.push(g2);
2282
2283        let mut g3 = Generator::new(4, 20.0, 1.0);
2284        g3.pmax = 110.0;
2285        net.generators.push(g3);
2286
2287        let mut g4 = Generator::new(5, 20.0, 1.0);
2288        g4.pmax = 85.0;
2289        net.generators.push(g4);
2290
2291        net
2292    }
2293
2294    #[test]
2295    fn test_prepared_model_supports_multi_island_networks() {
2296        let net = build_two_island_prepared_test_network();
2297        let mut model = PreparedDcStudy::new(&net).expect("prepared multi-island model");
2298
2299        let prepared = model
2300            .solve(&DcPfOptions::default())
2301            .expect("prepared solve");
2302        let wrapped = solve_dc(&net).expect("wrapped solve");
2303        assert_eq!(prepared.theta, wrapped.theta);
2304        assert_eq!(prepared.branch_p_flow, wrapped.branch_p_flow);
2305        assert_eq!(prepared.p_inject_pu, wrapped.p_inject_pu);
2306
2307        let monitored = vec![0usize, 3usize];
2308        let ptdf = model
2309            .compute_ptdf_request(
2310                &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2311                    .with_bus_indices(&[0, 1, 2, 3, 4, 5]),
2312            )
2313            .expect("prepared ptdf");
2314        let wrapped_ptdf = crate::sensitivity::compute_ptdf(
2315            &net,
2316            &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2317                .with_bus_indices(&[0, 1, 2, 3, 4, 5]),
2318        )
2319        .expect("wrapped ptdf");
2320        assert_eq!(ptdf, wrapped_ptdf);
2321        let row0 = ptdf.row(0).expect("row 0");
2322        let row3 = ptdf.row(3).expect("row 3");
2323        assert!(row0[3].abs() < 1e-12);
2324        assert!(row0[4].abs() < 1e-12);
2325        assert!(row0[5].abs() < 1e-12);
2326        assert!(row3[0].abs() < 1e-12);
2327        assert!(row3[1].abs() < 1e-12);
2328        assert!(row3[2].abs() < 1e-12);
2329
2330        let lodf = model
2331            .compute_lodf(&monitored, &monitored)
2332            .expect("prepared lodf");
2333        let wrapped_lodf = crate::sensitivity::compute_lodf(
2334            &net,
2335            &crate::sensitivity::LodfRequest::for_branches(&monitored, &monitored),
2336        )
2337        .expect("wrapped lodf");
2338        assert_eq!(lodf, wrapped_lodf);
2339        assert!(lodf[(0, 1)].abs() < 1e-12);
2340        assert!(lodf[(1, 0)].abs() < 1e-12);
2341
2342        let n2 = model
2343            .compute_n2_lodf((0, 3), &monitored)
2344            .expect("prepared n2");
2345        let wrapped_n2 = crate::sensitivity::compute_n2_lodf(
2346            &net,
2347            &crate::sensitivity::N2LodfRequest::new((0, 3)).with_monitored_branches(&monitored),
2348        )
2349        .expect("wrapped n2");
2350        assert_eq!(n2, wrapped_n2);
2351    }
2352
2353    #[test]
2354    fn test_multi_island_headroom_metadata_matches_prepared_study() {
2355        let net = build_two_island_headroom_test_network();
2356        let opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4]);
2357
2358        let one_shot = solve_dc_opts(&net, &opts).expect("one-shot multi-island solve");
2359        let mut study = PreparedDcStudy::new(&net).expect("prepared multi-island study");
2360        let prepared = study.solve(&opts).expect("prepared multi-island solve");
2361
2362        assert_eq!(one_shot.theta, prepared.theta);
2363        assert_eq!(one_shot.branch_p_flow, prepared.branch_p_flow);
2364        assert_eq!(one_shot.p_inject_pu, prepared.p_inject_pu);
2365        assert!((one_shot.slack_p_injection - prepared.slack_p_injection).abs() < 1e-12);
2366        assert!((one_shot.total_generation_mw - prepared.total_generation_mw).abs() < 1e-9);
2367        assert_eq!(one_shot.slack_distribution, prepared.slack_distribution);
2368    }
2369
2370    #[test]
2371    fn test_unchecked_singleton_island_keeps_island_id_and_slack_accounting() {
2372        let mut net = Network::new("singleton_island");
2373        net.base_mva = 100.0;
2374        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2375        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
2376        net.buses.push(Bus::new(3, BusType::PQ, 230.0));
2377        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2378        net.loads.push(Load::new(2, 40.0, 0.0));
2379        net.loads.push(Load::new(3, 15.0, 0.0));
2380
2381        let mut g1 = Generator::new(1, 40.0, 1.0);
2382        g1.pmax = 100.0;
2383        net.generators.push(g1);
2384
2385        let mut study = PreparedDcStudy::new_unchecked(&net).expect("prepared study");
2386        let result = study.solve(&DcPfOptions::default()).expect("dc solve");
2387
2388        assert_eq!(result.island_ids, vec![0, 0, 1]);
2389        assert!(
2390            (result.slack_p_injection - 0.55).abs() < 1e-12,
2391            "total slack accounting should include both the main island and the singleton island, got {:.6}",
2392            result.slack_p_injection
2393        );
2394        assert!(
2395            (result.p_inject_pu[2] - 0.15).abs() < 1e-12,
2396            "singleton island bus should keep its local slack injection, got {:.6}",
2397            result.p_inject_pu[2]
2398        );
2399    }
2400
2401    #[test]
2402    fn test_singleton_island_applies_external_injections() {
2403        let mut net = Network::new("singleton_external_injection");
2404        net.base_mva = 100.0;
2405        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2406        net.loads.push(Load::new(1, 10.0, 0.0));
2407
2408        let mut study = PreparedDcStudy::new_unchecked(&net).expect("prepared study");
2409        let opts = DcPfOptions {
2410            external_p_injections_mw: vec![(1, 5.0)],
2411            ..DcPfOptions::default()
2412        };
2413        let result = study.solve(&opts).expect("dc solve");
2414
2415        assert!(
2416            (result.p_inject_pu[0] - 0.05).abs() < 1e-12,
2417            "singleton island should include external injections in slack accounting, got {:.6}",
2418            result.p_inject_pu[0]
2419        );
2420    }
2421
2422    // -----------------------------------------------------------------------
2423    // PST-01: Phase-shifting transformer tests
2424    // -----------------------------------------------------------------------
2425
2426    /// Build a 3-bus network with a PST on branch bus1->bus2.
2427    ///
2428    /// Bus 1 (idx 0): slack (angle = 0)
2429    /// Bus 2 (idx 1): PQ load 100 MW
2430    /// Bus 3 (idx 2): PV generator 100 MW
2431    /// Branch 0 (bus1->bus2): x=0.1, shift=phi_deg converted to radians (PST)
2432    /// Branch 1 (bus2->bus3): x=0.1, no shift
2433    /// Branch 2 (bus1->bus3): x=0.2, no shift
2434    fn build_3bus_pst_network(phi_deg: f64) -> Network {
2435        use surge_network::network::{Branch, Bus, BusType, Generator, Load};
2436
2437        let mut net = Network::new("3bus_pst");
2438        net.base_mva = 100.0;
2439
2440        net.buses.push(Bus::new(1, BusType::Slack, 345.0));
2441        net.generators.push(Generator::new(1, 0.0, 1.0));
2442
2443        net.buses.push(Bus::new(2, BusType::PQ, 345.0));
2444        net.loads.push(Load::new(2, 100.0, 0.0));
2445
2446        net.buses.push(Bus::new(3, BusType::PV, 345.0));
2447
2448        net.generators.push(Generator::new(3, 100.0, 1.0));
2449
2450        // PST branch (bus 1 -> bus 2 with phase shift)
2451        let mut br_pst = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
2452        br_pst.phase_shift_rad = phi_deg.to_radians();
2453        net.branches.push(br_pst);
2454
2455        // Normal lines
2456        net.branches.push(Branch::new_line(2, 3, 0.0, 0.1, 0.0));
2457        net.branches.push(Branch::new_line(1, 3, 0.0, 0.2, 0.0));
2458
2459        net
2460    }
2461
2462    /// PST-01: DC flows must change when a non-zero phase shift is applied.
2463    #[test]
2464    fn test_pst_dc_flow_changes_with_shift() {
2465        skip_if_no_data!();
2466        let net_no_shift = build_3bus_pst_network(0.0);
2467        let net_with_shift = build_3bus_pst_network(5.0);
2468
2469        let r0 = solve_dc(&net_no_shift).expect("DC PF should converge");
2470        let r5 = solve_dc(&net_with_shift).expect("DC PF should converge");
2471
2472        // At least one branch flow must differ.
2473        let all_same = r0
2474            .branch_p_flow
2475            .iter()
2476            .zip(r5.branch_p_flow.iter())
2477            .all(|(a, b)| (a - b).abs() < 1e-10);
2478
2479        assert!(
2480            !all_same,
2481            "DC flows should change when PST shift is non-zero; \
2482             no-shift flows: {:?}, with-shift flows: {:?}",
2483            r0.branch_p_flow, r5.branch_p_flow
2484        );
2485
2486        // The PST branch itself must see a significant flow change.
2487        let delta = (r0.branch_p_flow[0] - r5.branch_p_flow[0]).abs();
2488        assert!(
2489            delta > 1e-4,
2490            "PST branch flow should change noticeably: no-shift={:.4}, with-shift={:.4}",
2491            r0.branch_p_flow[0],
2492            r5.branch_p_flow[0]
2493        );
2494    }
2495
2496    /// PST-01: KCL must hold on the PST network.
2497    #[test]
2498    fn test_pst_kcl_satisfied() {
2499        skip_if_no_data!();
2500        let net = build_3bus_pst_network(10.0);
2501        let result = solve_dc(&net).expect("DC PF should converge");
2502        check_kcl(&net, &result, 1e-8);
2503    }
2504
2505    /// PST-01: case9 (no PSTs) gives identical KCL results with the updated solver.
2506    #[test]
2507    fn test_no_pst_case9_unchanged() {
2508        skip_if_no_data!();
2509        let net = load_net("case9");
2510        for br in &net.branches {
2511            assert!(
2512                br.phase_shift_rad.abs() < 1e-12,
2513                "case9 branch {}->{}  has unexpected shift={}",
2514                br.from_bus,
2515                br.to_bus,
2516                br.phase_shift_rad
2517            );
2518        }
2519        let result = solve_dc(&net).expect("DC PF should converge");
2520        check_kcl(&net, &result, 1e-8);
2521        assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2522    }
2523
2524    /// PST-01: case14 (no PSTs) gives identical KCL results with the updated solver.
2525    #[test]
2526    fn test_no_pst_case14_unchanged() {
2527        skip_if_no_data!();
2528        let net = load_net("case14");
2529        for br in &net.branches {
2530            assert!(
2531                br.phase_shift_rad.abs() < 1e-12,
2532                "case14 branch {}->{} has unexpected shift={}",
2533                br.from_bus,
2534                br.to_bus,
2535                br.phase_shift_rad
2536            );
2537        }
2538        let result = solve_dc(&net).expect("DC PF should converge");
2539        check_kcl(&net, &result, 1e-8);
2540    }
2541
2542    // -----------------------------------------------------------------------
2543    // PST-02: Distributed slack tests
2544    // -----------------------------------------------------------------------
2545
2546    /// PST-02: `solve_dc_opts` with default options produces the same result as `solve_dc`.
2547    #[test]
2548    fn test_default_opts_identical_to_solve_dc() {
2549        skip_if_no_data!();
2550        let net = load_net("case9");
2551        let r1 = solve_dc(&net).expect("solve_dc");
2552        let r2 = solve_dc_opts(&net, &DcPfOptions::default()).expect("solve_dc_opts default");
2553
2554        assert_eq!(r1.theta.len(), r2.theta.len());
2555        for (a, b) in r1.theta.iter().zip(r2.theta.iter()) {
2556            assert!((a - b).abs() < 1e-12, "theta differs: {a:.8e} vs {b:.8e}");
2557        }
2558        for (a, b) in r1.branch_p_flow.iter().zip(r2.branch_p_flow.iter()) {
2559            assert!((a - b).abs() < 1e-12, "flow differs: {a:.8e} vs {b:.8e}");
2560        }
2561    }
2562
2563    #[test]
2564    fn test_angle_reference_changes_only_reported_theta() {
2565        let mut net = build_3bus_pst_network(5.0);
2566        net.buses[0].voltage_angle_rad = 0.12;
2567
2568        let preserve = solve_dc_opts(&net, &DcPfOptions::default()).expect("preserve solve");
2569        let zero = solve_dc_opts(
2570            &net,
2571            &DcPfOptions::default().with_angle_reference(AngleReference::Zero),
2572        )
2573        .expect("zero-reference solve");
2574        let distributed = solve_dc_opts(
2575            &net,
2576            &DcPfOptions::default().with_angle_reference(AngleReference::Distributed(
2577                DistributedAngleWeight::LoadWeighted,
2578            )),
2579        )
2580        .expect("distributed-reference solve");
2581
2582        assert!((preserve.theta[0] - 0.12).abs() < 1e-12);
2583        assert!(zero.theta[0].abs() < 1e-12);
2584
2585        let bus2_idx = net.bus_index_map()[&2];
2586        assert!(distributed.theta[bus2_idx].abs() < 1e-12);
2587
2588        assert!(
2589            preserve
2590                .theta
2591                .iter()
2592                .zip(zero.theta.iter())
2593                .any(|(a, b)| (a - b).abs() > 1e-9),
2594            "angle reference should change the reported theta vector"
2595        );
2596
2597        for ((flow_p, flow_z), flow_d) in preserve
2598            .branch_p_flow
2599            .iter()
2600            .zip(zero.branch_p_flow.iter())
2601            .zip(distributed.branch_p_flow.iter())
2602        {
2603            assert!((flow_p - flow_z).abs() < 1e-12);
2604            assert!((flow_p - flow_d).abs() < 1e-12);
2605        }
2606        for ((inj_p, inj_z), inj_d) in preserve
2607            .p_inject_pu
2608            .iter()
2609            .zip(zero.p_inject_pu.iter())
2610            .zip(distributed.p_inject_pu.iter())
2611        {
2612            assert!((inj_p - inj_z).abs() < 1e-12);
2613            assert!((inj_p - inj_d).abs() < 1e-12);
2614        }
2615        assert!((preserve.slack_p_injection - zero.slack_p_injection).abs() < 1e-12);
2616        assert!((preserve.slack_p_injection - distributed.slack_p_injection).abs() < 1e-12);
2617    }
2618
2619    #[test]
2620    fn test_multi_island_angle_reference_is_applied_per_island() {
2621        let mut net = build_two_island_prepared_test_network();
2622        net.buses[0].voltage_angle_rad = 0.15;
2623        net.buses[3].voltage_angle_rad = -0.25;
2624
2625        let preserve = solve_dc_opts(&net, &DcPfOptions::default()).expect("preserve solve");
2626        let zero = solve_dc_opts(
2627            &net,
2628            &DcPfOptions::default().with_angle_reference(AngleReference::Zero),
2629        )
2630        .expect("zero-reference solve");
2631        let distributed = solve_dc_opts(
2632            &net,
2633            &DcPfOptions::default().with_angle_reference(AngleReference::Distributed(
2634                DistributedAngleWeight::LoadWeighted,
2635            )),
2636        )
2637        .expect("distributed-reference solve");
2638
2639        assert!((preserve.theta[0] - 0.15).abs() < 1e-12);
2640        assert!((preserve.theta[3] + 0.25).abs() < 1e-12);
2641        assert!(zero.theta[0].abs() < 1e-12);
2642        assert!(zero.theta[3].abs() < 1e-12);
2643
2644        let island_a_load_center = 0.5 * distributed.theta[1] + 0.5 * distributed.theta[2];
2645        let island_b_load_center = 0.5 * distributed.theta[4] + 0.5 * distributed.theta[5];
2646        assert!(island_a_load_center.abs() < 1e-12);
2647        assert!(island_b_load_center.abs() < 1e-12);
2648
2649        for (flow_p, flow_d) in preserve
2650            .branch_p_flow
2651            .iter()
2652            .zip(distributed.branch_p_flow.iter())
2653        {
2654            assert!((flow_p - flow_d).abs() < 1e-12);
2655        }
2656    }
2657
2658    #[test]
2659    fn test_angle_reference_remains_orthogonal_to_headroom_slack() {
2660        let mut net = build_two_island_headroom_test_network();
2661        net.buses[0].voltage_angle_rad = 0.08;
2662        net.buses[3].voltage_angle_rad = -0.11;
2663
2664        let preserve_opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4])
2665            .with_angle_reference(AngleReference::PreserveInitial);
2666        let zero_opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4])
2667            .with_angle_reference(AngleReference::Zero);
2668        let distributed_opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4])
2669            .with_angle_reference(AngleReference::Distributed(
2670                DistributedAngleWeight::LoadWeighted,
2671            ));
2672
2673        let preserve = solve_dc_opts(&net, &preserve_opts).expect("preserve solve");
2674        let zero = solve_dc_opts(&net, &zero_opts).expect("zero-reference solve");
2675        let distributed =
2676            solve_dc_opts(&net, &distributed_opts).expect("distributed-reference solve");
2677
2678        for ((flow_p, flow_z), flow_d) in preserve
2679            .branch_p_flow
2680            .iter()
2681            .zip(zero.branch_p_flow.iter())
2682            .zip(distributed.branch_p_flow.iter())
2683        {
2684            assert!((flow_p - flow_z).abs() < 1e-12);
2685            assert!((flow_p - flow_d).abs() < 1e-12);
2686        }
2687
2688        assert_eq!(
2689            preserve.slack_distribution.len(),
2690            zero.slack_distribution.len()
2691        );
2692        assert_eq!(
2693            preserve.slack_distribution.len(),
2694            distributed.slack_distribution.len()
2695        );
2696        for (bus_idx, preserve_share) in &preserve.slack_distribution {
2697            let zero_share = zero
2698                .slack_distribution
2699                .get(bus_idx)
2700                .copied()
2701                .expect("zero-reference slack distribution key");
2702            let distributed_share = distributed
2703                .slack_distribution
2704                .get(bus_idx)
2705                .copied()
2706                .expect("distributed-reference slack distribution key");
2707            assert!((preserve_share - zero_share).abs() < 1e-12);
2708            assert!((preserve_share - distributed_share).abs() < 1e-12);
2709        }
2710    }
2711
2712    #[test]
2713    fn test_prepared_model_matches_wrapper_apis() {
2714        skip_if_no_data!();
2715
2716        let net = load_net("case14");
2717        let mut model = PreparedDcStudy::new(&net).expect("prepared model");
2718
2719        let prepared = model
2720            .solve(&DcPfOptions::default())
2721            .expect("prepared solve");
2722        let wrapped = solve_dc(&net).expect("wrapper solve");
2723        assert_eq!(prepared.theta, wrapped.theta);
2724        assert_eq!(prepared.branch_p_flow, wrapped.branch_p_flow);
2725        assert_eq!(prepared.p_inject_pu, wrapped.p_inject_pu);
2726
2727        let all_branches: Vec<usize> = (0..net.n_branches()).collect();
2728        let prepared_ptdf_rows = model
2729            .compute_ptdf(&all_branches)
2730            .expect("prepared ptdf rows");
2731        let prepared_ptdf = model.compute_ptdf(&all_branches).expect("prepared ptdf");
2732        let wrapped_ptdf = crate::sensitivity::compute_ptdf(
2733            &net,
2734            &crate::sensitivity::PtdfRequest::for_branches(&all_branches),
2735        )
2736        .expect("wrapper ptdf");
2737        assert_eq!(
2738            prepared_ptdf_rows.monitored_branches(),
2739            all_branches.as_slice()
2740        );
2741        assert_eq!(prepared_ptdf, wrapped_ptdf);
2742
2743        let prepared_lodf = model
2744            .compute_lodf_matrix(&all_branches)
2745            .expect("prepared lodf");
2746        let wrapped_lodf = crate::sensitivity::compute_lodf_matrix(
2747            &net,
2748            &crate::sensitivity::LodfMatrixRequest::for_branches(&all_branches),
2749        )
2750        .expect("wrapper lodf");
2751        assert_eq!(prepared_lodf.n_rows(), wrapped_lodf.n_rows());
2752        assert_eq!(prepared_lodf.n_cols(), wrapped_lodf.n_cols());
2753        for i in 0..prepared_lodf.n_rows() {
2754            for j in 0..prepared_lodf.n_cols() {
2755                assert!(
2756                    (prepared_lodf[(i, j)] - wrapped_lodf[(i, j)]).abs() < 1e-12
2757                        || (prepared_lodf[(i, j)].is_infinite()
2758                            && wrapped_lodf[(i, j)].is_infinite())
2759                );
2760            }
2761        }
2762
2763        let monitored = vec![0, 2, 5];
2764        let outages = vec![1, 6];
2765        let prepared_subset = model
2766            .compute_lodf(&monitored, &outages)
2767            .expect("prepared subset lodf");
2768        let wrapped_subset = crate::sensitivity::compute_lodf(
2769            &net,
2770            &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
2771        )
2772        .expect("wrapper subset lodf");
2773        let prepared_otdf = model
2774            .compute_otdf(&monitored, &outages)
2775            .expect("prepared subset otdf");
2776        let wrapped_otdf = crate::sensitivity::compute_otdf(
2777            &net,
2778            &crate::sensitivity::OtdfRequest::new(&monitored, &outages),
2779        )
2780        .expect("wrapper subset otdf");
2781        assert_eq!(prepared_subset.n_rows(), monitored.len());
2782        assert_eq!(prepared_subset.n_cols(), outages.len());
2783        assert_eq!(prepared_subset.n_rows(), wrapped_subset.n_rows());
2784        assert_eq!(prepared_subset.n_cols(), wrapped_subset.n_cols());
2785        for i in 0..prepared_subset.n_rows() {
2786            for j in 0..prepared_subset.n_cols() {
2787                assert!(
2788                    (prepared_subset[(i, j)] - wrapped_subset[(i, j)]).abs() < 1e-12
2789                        || (prepared_subset[(i, j)].is_infinite()
2790                            && wrapped_subset[(i, j)].is_infinite())
2791                );
2792            }
2793        }
2794        assert_eq!(prepared_otdf, wrapped_otdf);
2795
2796        let prepared_pairs = model
2797            .compute_lodf_pairs(&monitored, &outages)
2798            .expect("prepared subset pairs");
2799        let wrapped_pairs = crate::sensitivity::compute_lodf_pairs(&net, &monitored, &outages)
2800            .expect("wrapper pairs");
2801        assert_eq!(prepared_pairs, wrapped_pairs);
2802
2803        let mut lodf_columns = model.lodf_columns();
2804        let prepared_column = lodf_columns
2805            .compute_column(&monitored, outages[0])
2806            .expect("prepared outage column");
2807        for (row, &value) in prepared_column.iter().enumerate() {
2808            let matrix_value = prepared_subset[(row, 0)];
2809            assert!(
2810                (value - matrix_value).abs() < 1e-12
2811                    || (value.is_infinite() && matrix_value.is_infinite())
2812            );
2813        }
2814
2815        let mut prepared_n2_columns = model
2816            .n2_lodf_columns(&all_branches, &all_branches)
2817            .expect("prepared N-2 columns");
2818        let prepared_n2 = prepared_n2_columns
2819            .compute_column(0, 2)
2820            .expect("prepared N-2 column");
2821        let wrapped_n2 = crate::sensitivity::compute_n2_lodf(
2822            &net,
2823            &crate::sensitivity::N2LodfRequest::new((0, 2)).with_monitored_branches(&all_branches),
2824        )
2825        .expect("wrapper N-2");
2826        assert_eq!(prepared_n2.len(), wrapped_n2.len());
2827        for (prepared, wrapped) in prepared_n2.iter().zip(wrapped_n2.iter()) {
2828            assert!(
2829                (prepared - wrapped).abs() < 1e-12
2830                    || (prepared.is_infinite() && wrapped.is_infinite())
2831            );
2832        }
2833
2834        let outage_pairs = vec![(0, 2), (2, 0)];
2835        let prepared_n2_batch = model
2836            .compute_n2_lodf_batch(&outage_pairs, &all_branches)
2837            .expect("prepared N-2 batch");
2838        let wrapped_n2_batch = crate::sensitivity::compute_n2_lodf_batch(
2839            &net,
2840            &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
2841                .with_monitored_branches(&all_branches),
2842        )
2843        .expect("wrapper N-2 batch");
2844        assert_eq!(prepared_n2_batch.n_rows(), wrapped_n2_batch.n_rows());
2845        assert_eq!(prepared_n2_batch.n_cols(), wrapped_n2_batch.n_cols());
2846        for i in 0..prepared_n2_batch.n_rows() {
2847            for j in 0..prepared_n2_batch.n_cols() {
2848                assert!(
2849                    (prepared_n2_batch[(i, j)] - wrapped_n2_batch[(i, j)]).abs() < 1e-12
2850                        || (prepared_n2_batch[(i, j)].is_infinite()
2851                            && wrapped_n2_batch[(i, j)].is_infinite())
2852                );
2853            }
2854        }
2855    }
2856
2857    #[test]
2858    fn test_dc_sensitivity_workflow_matches_individual_calls() {
2859        skip_if_no_data!();
2860
2861        let net = load_net("case14");
2862        let monitored = vec![0, 3, 7];
2863        let ptdf_buses = vec![0, 4, 9];
2864        let outages = vec![1, 5];
2865        let otdf_buses = vec![0, 4, 9];
2866        let outage_pairs = vec![(0, 2), (2, 0)];
2867        let request = DcAnalysisRequest::with_monitored_branches(&monitored)
2868            .with_ptdf_buses(&ptdf_buses)
2869            .with_otdf_outages(&outages)
2870            .with_otdf_buses(&otdf_buses)
2871            .with_lodf_outages(&outages)
2872            .with_n2_outage_pairs(&outage_pairs);
2873
2874        let workflow = run_dc_analysis(&net, &request).expect("workflow");
2875        let standalone_pf = solve_dc(&net).expect("standalone solve");
2876        let standalone_ptdf = crate::sensitivity::compute_ptdf(
2877            &net,
2878            &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2879                .with_bus_indices(&ptdf_buses),
2880        )
2881        .expect("standalone ptdf");
2882        let standalone_otdf = crate::sensitivity::compute_otdf(
2883            &net,
2884            &crate::sensitivity::OtdfRequest::new(&monitored, &outages)
2885                .with_bus_indices(&otdf_buses),
2886        )
2887        .expect("standalone otdf");
2888        let standalone_lodf = crate::sensitivity::compute_lodf(
2889            &net,
2890            &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
2891        )
2892        .expect("standalone lodf");
2893        let standalone_n2 = crate::sensitivity::compute_n2_lodf_batch(
2894            &net,
2895            &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
2896                .with_monitored_branches(&monitored),
2897        )
2898        .expect("standalone n2");
2899
2900        assert_eq!(workflow.monitored_branch_indices, monitored);
2901        assert_eq!(workflow.ptdf_bus_indices, ptdf_buses);
2902        assert_eq!(workflow.power_flow.theta, standalone_pf.theta);
2903        assert_eq!(
2904            workflow.power_flow.branch_p_flow,
2905            standalone_pf.branch_p_flow
2906        );
2907        assert_eq!(workflow.ptdf, standalone_ptdf);
2908        assert_eq!(
2909            workflow.otdf_outage_branch_indices, outages,
2910            "OTDF outage metadata should preserve request order"
2911        );
2912        assert_eq!(
2913            workflow.otdf_bus_indices, otdf_buses,
2914            "OTDF bus metadata should preserve request order"
2915        );
2916        assert_eq!(
2917            workflow.lodf_outage_branch_indices, outages,
2918            "LODF outage metadata should preserve request order"
2919        );
2920        assert_eq!(
2921            workflow.n2_outage_pairs, outage_pairs,
2922            "N-2 outage-pair metadata should preserve request order"
2923        );
2924
2925        let workflow_otdf = workflow.otdf.expect("workflow otdf");
2926        assert_eq!(workflow_otdf, standalone_otdf);
2927
2928        let workflow_lodf = workflow.lodf.expect("workflow lodf");
2929        assert_eq!(workflow_lodf.n_rows(), standalone_lodf.n_rows());
2930        assert_eq!(workflow_lodf.n_cols(), standalone_lodf.n_cols());
2931        for i in 0..workflow_lodf.n_rows() {
2932            for j in 0..workflow_lodf.n_cols() {
2933                assert!(
2934                    (workflow_lodf[(i, j)] - standalone_lodf[(i, j)]).abs() < 1e-12
2935                        || (workflow_lodf[(i, j)].is_infinite()
2936                            && standalone_lodf[(i, j)].is_infinite())
2937                );
2938            }
2939        }
2940
2941        let workflow_n2 = workflow.n2_lodf.expect("workflow n2");
2942        assert_eq!(workflow_n2.n_rows(), standalone_n2.n_rows());
2943        assert_eq!(workflow_n2.n_cols(), standalone_n2.n_cols());
2944        for i in 0..workflow_n2.n_rows() {
2945            for j in 0..workflow_n2.n_cols() {
2946                assert!(
2947                    (workflow_n2[(i, j)] - standalone_n2[(i, j)]).abs() < 1e-12
2948                        || (workflow_n2[(i, j)].is_infinite()
2949                            && standalone_n2[(i, j)].is_infinite())
2950                );
2951            }
2952        }
2953    }
2954
2955    #[test]
2956    fn test_dc_sensitivity_workflow_headroom_slack_matches_individual_calls() {
2957        skip_if_no_data!();
2958
2959        let net = load_net("case14");
2960        let monitored = vec![0, 3, 7];
2961        let outages = vec![1, 5];
2962        let otdf_buses = vec![0, 4, 9];
2963        let outage_pairs = vec![(0, 2), (2, 0)];
2964        let participating_buses: Vec<usize> = {
2965            let bus_map = net.bus_index_map();
2966            net.generators
2967                .iter()
2968                .filter(|g| g.in_service)
2969                .filter_map(|g| bus_map.get(&g.bus).copied())
2970                .collect()
2971        };
2972        let pf_options = DcPfOptions::with_headroom_slack(&participating_buses);
2973        let sensitivity_options =
2974            crate::sensitivity::DcSensitivityOptions::with_headroom_slack(&participating_buses);
2975
2976        let request = DcAnalysisRequest::with_monitored_branches(&monitored)
2977            .with_otdf_outages(&outages)
2978            .with_otdf_buses(&otdf_buses)
2979            .with_lodf_outages(&outages)
2980            .with_n2_outage_pairs(&outage_pairs)
2981            .with_pf_options(pf_options.clone());
2982
2983        let workflow = run_dc_analysis(&net, &request).expect("workflow");
2984        let standalone_pf = solve_dc_opts(&net, &pf_options).expect("standalone solve");
2985        let standalone_ptdf = crate::sensitivity::compute_ptdf(
2986            &net,
2987            &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2988                .with_options(sensitivity_options.clone()),
2989        )
2990        .expect("standalone ptdf");
2991        let standalone_otdf = crate::sensitivity::compute_otdf(
2992            &net,
2993            &crate::sensitivity::OtdfRequest::new(&monitored, &outages)
2994                .with_bus_indices(&otdf_buses)
2995                .with_options(sensitivity_options.clone()),
2996        )
2997        .expect("standalone otdf");
2998        let standalone_lodf = crate::sensitivity::compute_lodf(
2999            &net,
3000            &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
3001        )
3002        .expect("standalone lodf");
3003        let standalone_n2 = crate::sensitivity::compute_n2_lodf_batch(
3004            &net,
3005            &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
3006                .with_monitored_branches(&monitored),
3007        )
3008        .expect("standalone n2");
3009
3010        assert_eq!(workflow.power_flow.theta, standalone_pf.theta);
3011        assert_eq!(
3012            workflow.power_flow.branch_p_flow,
3013            standalone_pf.branch_p_flow
3014        );
3015        assert_eq!(workflow.ptdf, standalone_ptdf);
3016        let workflow_otdf = workflow.otdf.as_ref().expect("workflow otdf");
3017        assert_eq!(workflow_otdf, &standalone_otdf);
3018
3019        let workflow_lodf = workflow.lodf.as_ref().expect("workflow lodf");
3020        assert_eq!(workflow_lodf.n_rows(), standalone_lodf.n_rows());
3021        assert_eq!(workflow_lodf.n_cols(), standalone_lodf.n_cols());
3022        for i in 0..workflow_lodf.n_rows() {
3023            for j in 0..workflow_lodf.n_cols() {
3024                assert!(
3025                    (workflow_lodf[(i, j)] - standalone_lodf[(i, j)]).abs() < 1e-12
3026                        || (workflow_lodf[(i, j)].is_infinite()
3027                            && standalone_lodf[(i, j)].is_infinite())
3028                );
3029            }
3030        }
3031
3032        let workflow_n2 = workflow.n2_lodf.as_ref().expect("workflow n2");
3033        assert_eq!(workflow_n2.n_rows(), standalone_n2.n_rows());
3034        assert_eq!(workflow_n2.n_cols(), standalone_n2.n_cols());
3035        for i in 0..workflow_n2.n_rows() {
3036            for j in 0..workflow_n2.n_cols() {
3037                assert!(
3038                    (workflow_n2[(i, j)] - standalone_n2[(i, j)]).abs() < 1e-12
3039                        || (workflow_n2[(i, j)].is_infinite()
3040                            && standalone_n2[(i, j)].is_infinite())
3041                );
3042            }
3043        }
3044
3045        let single_slack_ptdf = crate::sensitivity::compute_ptdf(
3046            &net,
3047            &crate::sensitivity::PtdfRequest::for_branches(&monitored),
3048        )
3049        .expect("single-slack ptdf");
3050        let workflow_row = workflow.ptdf.row(monitored[0]).expect("workflow ptdf row");
3051        let single_row = single_slack_ptdf
3052            .row(monitored[0])
3053            .expect("single-slack ptdf row");
3054        assert!(
3055            workflow_row
3056                .iter()
3057                .zip(single_row.iter())
3058                .any(|(workflow_value, single_value)| (workflow_value - single_value).abs() > 1e-9),
3059            "headroom-slack workflow PTDF should differ from single-slack PTDF"
3060        );
3061    }
3062
3063    #[test]
3064    fn test_dc_sensitivity_workflow_slack_weights_matches_individual_calls() {
3065        skip_if_no_data!();
3066
3067        let net = load_net("case14");
3068        let monitored = vec![0, 3, 7];
3069        let outages = vec![1, 5];
3070        let otdf_buses = vec![0, 4, 9];
3071        let outage_pairs = vec![(0, 2), (2, 0)];
3072        let sensitivity_options = crate::sensitivity::DcSensitivityOptions::with_slack_weights(&[
3073            (1usize, 3.0),
3074            (2usize, 1.0),
3075        ]);
3076
3077        let request = DcAnalysisRequest::with_monitored_branches(&monitored)
3078            .with_otdf_outages(&outages)
3079            .with_otdf_buses(&otdf_buses)
3080            .with_lodf_outages(&outages)
3081            .with_n2_outage_pairs(&outage_pairs)
3082            .with_sensitivity_options(sensitivity_options.clone());
3083
3084        let workflow = run_dc_analysis(&net, &request).expect("workflow");
3085        let standalone_pf = solve_dc(&net).expect("standalone solve");
3086        let standalone_ptdf = crate::sensitivity::compute_ptdf(
3087            &net,
3088            &crate::sensitivity::PtdfRequest::for_branches(&monitored)
3089                .with_options(sensitivity_options.clone()),
3090        )
3091        .expect("standalone ptdf");
3092        let standalone_otdf = crate::sensitivity::compute_otdf(
3093            &net,
3094            &crate::sensitivity::OtdfRequest::new(&monitored, &outages)
3095                .with_bus_indices(&otdf_buses)
3096                .with_options(sensitivity_options.clone()),
3097        )
3098        .expect("standalone otdf");
3099        let standalone_lodf = crate::sensitivity::compute_lodf(
3100            &net,
3101            &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
3102        )
3103        .expect("standalone lodf");
3104        let standalone_n2 = crate::sensitivity::compute_n2_lodf_batch(
3105            &net,
3106            &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
3107                .with_monitored_branches(&monitored),
3108        )
3109        .expect("standalone n2");
3110
3111        assert_eq!(workflow.power_flow.theta, standalone_pf.theta);
3112        assert_eq!(
3113            workflow.power_flow.branch_p_flow,
3114            standalone_pf.branch_p_flow
3115        );
3116        assert_eq!(workflow.ptdf, standalone_ptdf);
3117        assert_eq!(
3118            workflow.otdf.as_ref().expect("workflow otdf"),
3119            &standalone_otdf
3120        );
3121
3122        let workflow_lodf = workflow.lodf.as_ref().expect("workflow lodf");
3123        assert_eq!(workflow_lodf.n_rows(), standalone_lodf.n_rows());
3124        assert_eq!(workflow_lodf.n_cols(), standalone_lodf.n_cols());
3125        for i in 0..workflow_lodf.n_rows() {
3126            for j in 0..workflow_lodf.n_cols() {
3127                assert!(
3128                    (workflow_lodf[(i, j)] - standalone_lodf[(i, j)]).abs() < 1e-12
3129                        || (workflow_lodf[(i, j)].is_infinite()
3130                            && standalone_lodf[(i, j)].is_infinite())
3131                );
3132            }
3133        }
3134
3135        let workflow_n2 = workflow.n2_lodf.as_ref().expect("workflow n2");
3136        assert_eq!(workflow_n2.n_rows(), standalone_n2.n_rows());
3137        assert_eq!(workflow_n2.n_cols(), standalone_n2.n_cols());
3138        for i in 0..workflow_n2.n_rows() {
3139            for j in 0..workflow_n2.n_cols() {
3140                assert!(
3141                    (workflow_n2[(i, j)] - standalone_n2[(i, j)]).abs() < 1e-12
3142                        || (workflow_n2[(i, j)].is_infinite()
3143                            && standalone_n2[(i, j)].is_infinite())
3144                );
3145            }
3146        }
3147
3148        let single_slack_ptdf = crate::sensitivity::compute_ptdf(
3149            &net,
3150            &crate::sensitivity::PtdfRequest::for_branches(&monitored),
3151        )
3152        .expect("single-slack ptdf");
3153        let workflow_row = workflow.ptdf.row(monitored[0]).expect("workflow ptdf row");
3154        let single_row = single_slack_ptdf
3155            .row(monitored[0])
3156            .expect("single-slack ptdf row");
3157        assert!(
3158            workflow_row
3159                .iter()
3160                .zip(single_row.iter())
3161                .any(|(workflow_value, single_value)| (workflow_value - single_value).abs() > 1e-9),
3162            "weighted workflow PTDF should differ from single-slack PTDF"
3163        );
3164    }
3165
3166    /// PST-02: Distributed slack on case14 among buses 1, 2, 8 (external bus numbers).
3167    ///
3168    /// Distribution is headroom-based: each non-slack participant absorbs
3169    /// (headroom_k / total_headroom) × mismatch, up to available capacity.
3170    /// The slack bus absorbs the residual and is included in slack_distribution.
3171    ///
3172    /// Verifies:
3173    ///   - slack_distribution has 3 entries (one per declared participant).
3174    ///   - Each non-slack participant's share does not exceed its generator headroom.
3175    ///   - Sum of all slack_distribution values equals the single-slack mismatch.
3176    ///   - total_generation_mw = total_load + remaining_slack (lossless DC PF identity).
3177    #[test]
3178    fn test_headroom_slack_case14() {
3179        skip_if_no_data!();
3180        let net = load_net("case14");
3181
3182        let bus_map = net.bus_index_map();
3183        let idx1 = bus_map[&1]; // bus number 1 -> internal idx 0 (slack bus)
3184        let idx2 = bus_map[&2]; // bus number 2 -> internal idx 1
3185        let idx8 = bus_map[&8]; // bus number 8 -> internal idx 7
3186
3187        let opts = DcPfOptions::with_headroom_slack(&[idx1, idx2, idx8]);
3188        let result = solve_dc_opts(&net, &opts).expect("headroom slack DC PF should converge");
3189
3190        let single = solve_dc(&net).expect("single-slack DC PF");
3191        let single_mismatch_mw = single.slack_p_injection * net.base_mva;
3192
3193        // 1. slack_distribution has one entry per declared participant (including
3194        //    the slack bus, which records its residual absorption).
3195        assert_eq!(
3196            result.slack_distribution.len(),
3197            3,
3198            "Expected 3 entries in slack_distribution (one per declared participant)"
3199        );
3200
3201        // 2. Each non-slack participant's share must not exceed its headroom.
3202        for (&bidx, &share_mw) in &result.slack_distribution {
3203            if bidx == idx1 {
3204                continue; // slack bus residual — no headroom constraint
3205            }
3206            let headroom_mw: f64 = net
3207                .generators
3208                .iter()
3209                .filter(|g| g.in_service && bus_map.get(&g.bus) == Some(&bidx))
3210                .map(|g| (g.pmax - g.p).max(0.0))
3211                .sum();
3212            assert!(
3213                share_mw.abs() <= headroom_mw + 1e-6,
3214                "Bus {bidx}: share {share_mw:.4} MW exceeds headroom {headroom_mw:.4} MW"
3215            );
3216        }
3217
3218        // 3. Sum of all slack_distribution values (including slack residual) equals
3219        //    the original single-slack mismatch (conservation).
3220        let total_absorbed: f64 = result.slack_distribution.values().sum();
3221        assert!(
3222            (total_absorbed - single_mismatch_mw).abs() < 1e-4,
3223            "Total distributed ({total_absorbed:.4} MW) should equal \
3224             single-slack mismatch ({single_mismatch_mw:.4} MW)"
3225        );
3226
3227        // 4. total_generation_mw = total_load + remaining_slack (lossless DC PF identity).
3228        let total_load = net.total_load_mw();
3229        let remaining_slack_mw = result.slack_p_injection * net.base_mva;
3230        assert!(
3231            (result.total_generation_mw - (total_load + remaining_slack_mw)).abs() < 1e-4,
3232            "total_generation_mw ({:.4}) should equal total_load + remaining_slack ({:.4})",
3233            result.total_generation_mw,
3234            total_load + remaining_slack_mw
3235        );
3236    }
3237
3238    /// Participation-factor distributed slack on case14.
3239    ///
3240    /// Sets AGC participation factors on generators at buses 2 and 3, then
3241    /// verifies:
3242    ///   - Mismatch is distributed proportionally to participation weights.
3243    ///   - Total absorbed equals single-slack mismatch (conservation).
3244    ///   - Branch flows differ from single-slack (injection profile changed).
3245    ///   - D-PTDF computed with matching SlackWeights reproduces branch flows.
3246    #[test]
3247    fn test_participation_factor_slack_case14() {
3248        skip_if_no_data!();
3249        let mut net = load_net("case14");
3250        let bus_map = net.bus_index_map();
3251        let idx2 = bus_map[&2];
3252        let idx3 = bus_map[&3];
3253
3254        // Assign participation factors: bus 2 gets 3x the weight of bus 3.
3255        for g in &mut net.generators {
3256            if let Some(&bidx) = bus_map.get(&g.bus) {
3257                if bidx == idx2 {
3258                    g.agc_participation_factor = Some(0.75);
3259                } else if bidx == idx3 {
3260                    g.agc_participation_factor = Some(0.25);
3261                }
3262            }
3263        }
3264
3265        let weights = net.agc_participation_by_bus();
3266        assert!(
3267            !weights.is_empty(),
3268            "Should have participation factors from generators"
3269        );
3270
3271        let opts = DcPfOptions::with_participation_factors(&weights);
3272        let result = solve_dc_opts(&net, &opts).expect("participation factor DC PF");
3273        let single = solve_dc(&net).expect("single-slack DC PF");
3274        let single_mismatch_mw = single.slack_p_injection * net.base_mva;
3275
3276        // 1. Mismatch is fully distributed (slack residual should be near-zero
3277        //    since participation factors absorb everything unlike headroom which
3278        //    can be exhausted).
3279        let total_absorbed: f64 = result.slack_distribution.values().sum();
3280        assert!(
3281            (total_absorbed - single_mismatch_mw).abs() < 1e-4,
3282            "Total distributed ({total_absorbed:.4} MW) should equal \
3283             single-slack mismatch ({single_mismatch_mw:.4} MW)"
3284        );
3285
3286        // 2. Non-slack participants received shares proportional to weights.
3287        let bus2_share = result.slack_distribution.get(&idx2).copied().unwrap_or(0.0);
3288        let bus3_share = result.slack_distribution.get(&idx3).copied().unwrap_or(0.0);
3289        let non_slack_total = bus2_share + bus3_share;
3290        if non_slack_total.abs() > 1e-6 {
3291            let ratio = bus2_share / non_slack_total;
3292            assert!(
3293                (ratio - 0.75).abs() < 0.02,
3294                "Bus 2 should get ~75% of distributed slack, got {:.1}%",
3295                ratio * 100.0
3296            );
3297        }
3298
3299        // 3. Branch flows should differ from single-slack (different injection profile).
3300        let max_diff: f64 = result
3301            .branch_p_flow
3302            .iter()
3303            .zip(single.branch_p_flow.iter())
3304            .map(|(a, b)| (a - b).abs())
3305            .fold(0.0f64, f64::max);
3306        assert!(
3307            max_diff > 1e-6,
3308            "Participation-factor flows should differ from single-slack flows"
3309        );
3310
3311        // 4. D-PTDF with matching SlackWeights should reproduce branch flows from
3312        //    the participation-factor solve.
3313        let sens_opts = crate::sensitivity::DcSensitivityOptions::with_slack_weights(&weights);
3314        let all_branches: Vec<usize> = (0..net.n_branches()).collect();
3315        let mut study = PreparedDcStudy::new(&net).expect("prepared study");
3316        let dptdf = study
3317            .compute_ptdf_with_options(&all_branches, None, &sens_opts)
3318            .expect("D-PTDF");
3319        // D-PTDF * P_inj should approximate branch flows.
3320        for (br_idx, &actual_flow) in result.branch_p_flow.iter().enumerate() {
3321            if let Some(row) = dptdf.row(br_idx) {
3322                let predicted: f64 = row
3323                    .iter()
3324                    .enumerate()
3325                    .map(|(col_pos, &ptdf_val)| {
3326                        let bus_idx = dptdf.bus_indices()[col_pos];
3327                        ptdf_val * result.p_inject_pu[bus_idx]
3328                    })
3329                    .sum();
3330                assert!(
3331                    (predicted - actual_flow).abs() < 1e-6,
3332                    "D-PTDF flow reconstruction mismatch on branch {br_idx}: \
3333                     predicted={predicted:.8}, actual={actual_flow:.8}"
3334                );
3335            }
3336        }
3337    }
3338
3339    // -----------------------------------------------------------------------
3340    // MATPOWER DC-PF reference regression tests
3341    // -----------------------------------------------------------------------
3342
3343    /// MATPOWER DC-PF reference regression test — case9.
3344    /// Theta and branch flows validated against MATPOWER 7.1 rundcpf('case9').
3345    #[test]
3346    fn test_dc_case9_matpower_reference() {
3347        skip_if_no_data!();
3348        let net = load_net("case9");
3349        let result = solve_dc(&net).expect("case9 DC PF should converge");
3350
3351        // Reference bus angles (degrees) — MATPOWER 7.1 rundcpf('case9')
3352        // Bus order: 1, 2, 3, 4, 5, 6, 7, 8, 9
3353        let ref_theta_deg: &[f64] = &[
3354            0.0000000000,  // bus 1 (slack)
3355            9.7960188551,  // bus 2
3356            5.0605600451,  // bus 3
3357            -2.2111587230, // bus 4
3358            -3.7380912470, // bus 5
3359            2.2066572676,  // bus 6
3360            0.8224410570,  // bus 7
3361            3.9590113172,  // bus 8
3362            -4.0634004908, // bus 9
3363        ];
3364
3365        assert_eq!(
3366            result.theta.len(),
3367            ref_theta_deg.len(),
3368            "case9: expected {} buses, got {}",
3369            ref_theta_deg.len(),
3370            result.theta.len()
3371        );
3372
3373        for (i, &theta) in result.theta.iter().enumerate() {
3374            let theta_deg = theta.to_degrees();
3375            assert!(
3376                (theta_deg - ref_theta_deg[i]).abs() < 1e-2,
3377                "case9 bus {} theta: got {:.4} deg, expected {:.4} deg",
3378                net.buses[i].number,
3379                theta_deg,
3380                ref_theta_deg[i]
3381            );
3382        }
3383
3384        // Reference branch flows (p.u. on 100 MVA base) — MATPOWER 7.1
3385        // Branch order: 1->4, 4->5, 5->6, 3->6, 6->7, 7->8, 8->2, 8->9, 9->4
3386        let ref_flows: &[f64] = &[
3387            0.6700000000,  // branch 0: 1->4
3388            0.2896739130,  // branch 1: 4->5
3389            -0.6103260870, // branch 2: 5->6
3390            0.8500000000,  // branch 3: 3->6
3391            0.2396739130,  // branch 4: 6->7
3392            -0.7603260870, // branch 5: 7->8
3393            -1.6300000000, // branch 6: 8->2
3394            0.8696739130,  // branch 7: 8->9
3395            -0.3803260870, // branch 8: 9->4
3396        ];
3397
3398        assert_eq!(
3399            result.branch_p_flow.len(),
3400            ref_flows.len(),
3401            "case9: expected {} branches, got {}",
3402            ref_flows.len(),
3403            result.branch_p_flow.len()
3404        );
3405
3406        for (i, &flow) in result.branch_p_flow.iter().enumerate() {
3407            assert!(
3408                (flow - ref_flows[i]).abs() < 1e-4,
3409                "case9 branch {} ({}->{}) flow: got {:.6}, expected {:.6}",
3410                i,
3411                net.branches[i].from_bus,
3412                net.branches[i].to_bus,
3413                flow,
3414                ref_flows[i]
3415            );
3416        }
3417    }
3418
3419    /// MATPOWER DC-PF reference regression test — case14.
3420    /// Theta and branch flows validated against MATPOWER 7.1 rundcpf('case14').
3421    #[test]
3422    fn test_dc_case14_matpower_reference() {
3423        skip_if_no_data!();
3424        let net = load_net("case14");
3425        let result = solve_dc(&net).expect("case14 DC PF should converge");
3426
3427        // Reference bus angles (degrees) — MATPOWER 7.1 rundcpf('case14')
3428        // Bus order: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
3429        let ref_theta_deg: &[f64] = &[
3430            0.0000000000,   // bus 1 (slack)
3431            -5.0120111659,  // bus 2
3432            -12.9536631292, // bus 3
3433            -10.5836674351, // bus 4
3434            -9.0938942486,  // bus 5
3435            -14.8520790526, // bus 6
3436            -13.9070545899, // bus 7
3437            -13.9070545899, // bus 8
3438            -15.6946888799, // bus 9
3439            -15.9741231351, // bus 10
3440            -15.6188501240, // bus 11
3441            -15.9670768584, // bus 12
3442            -16.1397037396, // bus 13
3443            -17.1882875703, // bus 14
3444        ];
3445
3446        assert_eq!(
3447            result.theta.len(),
3448            ref_theta_deg.len(),
3449            "case14: expected {} buses, got {}",
3450            ref_theta_deg.len(),
3451            result.theta.len()
3452        );
3453
3454        for (i, &theta) in result.theta.iter().enumerate() {
3455            let theta_deg = theta.to_degrees();
3456            assert!(
3457                (theta_deg - ref_theta_deg[i]).abs() < 1e-2,
3458                "case14 bus {} theta: got {:.4} deg, expected {:.4} deg",
3459                net.buses[i].number,
3460                theta_deg,
3461                ref_theta_deg[i]
3462            );
3463        }
3464
3465        // Reference branch flows (p.u. on 100 MVA base) — MATPOWER 7.1
3466        // 20 branches in case14
3467        let ref_flows: &[f64] = &[
3468            1.4783859556,  // branch 0:  1->2
3469            0.7116140444,  // branch 1:  1->5
3470            0.7001463596,  // branch 2:  2->3
3471            0.5515185270,  // branch 3:  2->4
3472            0.4097210690,  // branch 4:  2->5
3473            -0.2418536404, // branch 5:  3->4
3474            -0.6174649065, // branch 6:  4->5
3475            0.2836115279,  // branch 7:  4->7
3476            0.1655182652,  // branch 8:  4->9
3477            0.4278702069,  // branch 9:  5->6
3478            0.0672834580,  // branch 10: 6->11
3479            0.0760735814,  // branch 11: 6->12
3480            0.1725131674,  // branch 12: 6->13
3481            0.0000000000,  // branch 13: 7->8
3482            0.2836115279,  // branch 14: 7->9
3483            0.0577165420,  // branch 15: 9->10
3484            0.0964132512,  // branch 16: 9->14
3485            -0.0322834580, // branch 17: 10->11
3486            0.0150735814,  // branch 18: 12->13
3487            0.0525867488,  // branch 19: 13->14
3488        ];
3489
3490        assert_eq!(
3491            result.branch_p_flow.len(),
3492            ref_flows.len(),
3493            "case14: expected {} branches, got {}",
3494            ref_flows.len(),
3495            result.branch_p_flow.len()
3496        );
3497
3498        for (i, &flow) in result.branch_p_flow.iter().enumerate() {
3499            assert!(
3500                (flow - ref_flows[i]).abs() < 1e-4,
3501                "case14 branch {} ({}->{}) flow: got {:.6}, expected {:.6}",
3502                i,
3503                net.branches[i].from_bus,
3504                net.branches[i].to_bus,
3505                flow,
3506                ref_flows[i]
3507            );
3508        }
3509    }
3510}