Skip to main content

gridoxide_core/linear/
sensitivity.rs

1//! DC sensitivity factors: PTDF and LODF.
2//!
3//! Because the DC model is exactly linear, the derivative of a branch flow
4//! with respect to a bus injection is a constant — not a local slope that
5//! drifts as the operating point moves, but the true, global answer. That is
6//! what makes these factors worth precomputing, and it is why they exist for
7//! DC and not for AC.
8//!
9//! **PTDF** (power transfer distribution factors) answers *if I inject one
10//! more unit at bus j and let the reference absorb it, how much of it shows up
11//! on branch k?* **LODF** (line outage distribution factors) answers *if
12//! branch l trips, what fraction of the power it was carrying lands on branch
13//! k?* — the workhorse of contingency screening, since it turns "re-solve the
14//! network once per outage" into one matrix column.
15//!
16//! # The formulation
17//!
18//! With `N` the non-reference buses of one island, `B_NN` its reduced
19//! susceptance matrix, and `Bf` the branch-flow matrix
20//! (`Bf[k, from_k] = +b_k`, `Bf[k, to_k] = −b_k`):
21//!
22//! \\[ \mathrm{PTDF}[:, j] = B_f\,\theta^{(j)}, \qquad B_{NN}\theta^{(j)} = e_j \\]
23//! \\[ \mathrm{PTDF}[k, :] = x, \qquad B_{NN}\,x = B_f[k, N]^\mathsf{T} \\]
24//!
25//! The second line uses `B_NN = B_NNᵀ` — true even with phase shifters, since
26//! the shift lives on the right-hand side — so a *row* costs one solve rather
27//! than one per bus.
28//!
29//! \\[ \mathrm{LODF}[:, l] = \frac{\mathrm{PTDF}[:, from_l] - \mathrm{PTDF}[:, to_l]}{1 - d_l},
30//!    \qquad d_l = \mathrm{PTDF}[l, from_l] - \mathrm{PTDF}[l, to_l] \\]
31//!
32//! The numerator is one solve against `e_from − e_to`, not two, and it yields
33//! `d_l` as a by-product — so a LODF column costs exactly what a PTDF column
34//! does.
35//!
36//! # Why columns and rows, not matrices
37//!
38//! The dense factors are enormous. On `case9241pegase` (9,241 buses, 16,049
39//! branches) a full PTDF is 1.19 GB and a full LODF 2.06 GB. Both are offered
40//! ([`DcSensitivity::ptdf_dense`], [`DcSensitivity::lodf_dense`]) because small
41//! networks and test code genuinely want them, but the column and row accessors
42//! are the API to reach for, and each costs a single triangular solve against a
43//! factorization computed once in [`DcSensitivity::new`].
44//!
45//! # Scope
46//!
47//! Distributed slack is not supported: each island has one reference, or
48//! several whose pickup split is arbitrary. lightsim2grid carries the same
49//! limitation, as a `TODO PTDF: distributed slack` in the equivalent place.
50
51use crate::types::{Bus, BusType};
52use gridoxide_sparse::sparse::RealFactorization;
53
54use super::btheta::{DcBranch, NOT_UNKNOWN, Partition, partition, reduced_b_triplets};
55use crate::network::Verdict;
56
57/// Below this, `1 − d_l` is treated as zero and the branch is called radial.
58///
59/// `d_l → 1` exactly when branch `l` is a bridge: removing it disconnects the
60/// network, so its power has nowhere to redistribute to and no finite
61/// redistribution factor exists. This is a structural fact, not a numerical
62/// accident, which is why the accessors return `None` rather than a large
63/// number.
64const LODF_BRIDGE_TOL: f64 = 1e-9;
65
66/// A minimal row-major dense matrix.
67///
68/// Deliberately not `faer::Mat`: `sparse.rs`'s module doc commits to keeping
69/// the linear-algebra backend out of gridoxide's public signatures, and a
70/// dense return type is exactly where that would leak.
71#[derive(Clone, Debug)]
72pub struct DenseMatrix {
73    pub rows: usize,
74    pub cols: usize,
75    /// Row-major, `rows * cols` entries.
76    pub data: Vec<f64>,
77}
78
79impl DenseMatrix {
80    pub fn get(&self, row: usize, col: usize) -> f64 {
81        self.data[row * self.cols + col]
82    }
83
84    pub fn row(&self, row: usize) -> &[f64] {
85        &self.data[row * self.cols..(row + 1) * self.cols]
86    }
87}
88
89/// One island's factorized reduced system, plus the bookkeeping to map
90/// between global bus indices and its own rows.
91struct Island {
92    factorization: RealFactorization,
93    /// Reduced row -> global bus index.
94    unknown: Vec<usize>,
95    /// Positions into `DcSensitivity::branches` for this island's branches.
96    branch_positions: Vec<usize>,
97}
98
99/// Precomputed DC sensitivity factors for one network.
100///
101/// Holds one factorization per island, computed once. Every accessor below is
102/// a solve against those, never a refactorization — which is the entire reason
103/// [`sparse::RealFactorization`](gridoxide_sparse::sparse::RealFactorization) exists
104/// rather than reusing the Newton path's
105/// [`solver::LinearSolver`](crate::solver::LinearSolver).
106pub struct DcSensitivity {
107    n_buses: usize,
108    /// Total flat branch count (lines + transformers), the index space every
109    /// returned vector uses.
110    n_branches: usize,
111    branches: Vec<DcBranch>,
112    islands: Vec<Island>,
113    /// Bus -> index into `islands`, or `NOT_UNKNOWN` when its island has no
114    /// factorization — either because it has no reference bus, or because it
115    /// has no unknowns to solve for. [`referenced`](Self::referenced) tells
116    /// those two apart.
117    island_of: Vec<usize>,
118    /// Bus -> whether its island has at least one `Slack` bus. Tracked
119    /// separately from `island_of` so that a lone reference bus — an island
120    /// with a reference but nothing to solve — is not mistaken for an
121    /// unreferenced one, which is the distinction `ptdf_column`'s `None`
122    /// documents.
123    referenced: Vec<bool>,
124    /// Bus -> row within its island's reduced system, or `NOT_UNKNOWN` for a
125    /// reference bus.
126    reduced_pos: Vec<usize>,
127    /// Flat branch index -> position in `branches`, or `NOT_UNKNOWN` for a
128    /// branch that carries no DC flow.
129    ///
130    /// A map rather than a linear scan because `ptdf_row`, `lodf_column` and
131    /// `is_radial` are each called once per branch by the dense builders and
132    /// by `examples/bench_dc.rs`'s sweeps; scanning would make those O(m²) —
133    /// ~2.6e8 comparisons at case9241pegase, in a module whose whole purpose
134    /// is not paying costs of that shape.
135    branch_position: Vec<usize>,
136}
137
138impl DcSensitivity {
139    /// Factorizes every referenced island's reduced `B`.
140    ///
141    /// `branches` must come from
142    /// [`dc_branches`](super::btheta::dc_branches) over the same network, and
143    /// `n_branches` must be its flat branch count (`lines.len() +
144    /// transformers.len()`) — the same index space
145    /// [`DcSolution::branch_p`](super::btheta::DcSolution::branch_p) uses.
146    ///
147    /// Returns `None` if any referenced island's `B` is singular. Islands with
148    /// no reference bus are not an error: they simply have no sensitivities,
149    /// and every accessor returns `None` for their buses and branches.
150    pub fn new(buses: &[Bus], branches: &[DcBranch], n_branches: usize) -> Option<Self> {
151        let n = buses.len();
152        let Partition {
153            classified,
154            island_branches,
155        } = partition(buses, branches);
156
157        let mut islands = Vec::new();
158        let mut island_of = vec![NOT_UNKNOWN; n];
159        let mut referenced = vec![false; n];
160        let mut reduced_pos = vec![NOT_UNKNOWN; n];
161
162        let mut branch_position = vec![NOT_UNKNOWN; n_branches];
163        for (pos, br) in branches.iter().enumerate() {
164            branch_position[br.index] = pos;
165        }
166
167        for (ci, c) in classified.iter().enumerate() {
168            if matches!(c.verdict, Verdict::NoReferenceBus) {
169                continue;
170            }
171            for &i in &c.bus_indices {
172                referenced[i] = true;
173            }
174
175            let mut unknown = Vec::new();
176            for &i in &c.bus_indices {
177                if buses[i].bus_type != BusType::Slack {
178                    reduced_pos[i] = unknown.len();
179                    unknown.push(i);
180                }
181            }
182            let m = unknown.len();
183            if m == 0 {
184                // Every bus here is a reference, so there is nothing to solve
185                // and nothing that could respond. Such an island gets no
186                // `Island` entry, but `referenced` still marks it, so its
187                // buses return an all-zero column rather than `None`.
188                continue;
189            }
190
191            let triplets = reduced_b_triplets(branches, &island_branches[ci], &reduced_pos);
192            let factorization = RealFactorization::new(m, &triplets)?;
193
194            let idx = islands.len();
195            for &i in &c.bus_indices {
196                island_of[i] = idx;
197            }
198            islands.push(Island {
199                factorization,
200                unknown,
201                branch_positions: island_branches[ci].clone(),
202            });
203        }
204
205        Some(Self {
206            n_buses: n,
207            n_branches,
208            branches: branches.to_vec(),
209            islands,
210            island_of,
211            referenced,
212            reduced_pos,
213            branch_position,
214        })
215    }
216
217    pub fn n_buses(&self) -> usize {
218        self.n_buses
219    }
220
221    pub fn n_branches(&self) -> usize {
222        self.n_branches
223    }
224
225    /// Turns one island's solved reduced angles into per-branch flow responses
226    /// over the full flat branch index space.
227    ///
228    /// The phase shift is deliberately absent: these are *responses* to a
229    /// change, and a constant term contributes nothing to a derivative.
230    fn flows_from_angles(&self, island: &Island, theta: &[f64]) -> Vec<f64> {
231        let angle = |bus: usize| -> f64 {
232            match self.reduced_pos[bus] {
233                NOT_UNKNOWN => 0.0,
234                r => theta[r],
235            }
236        };
237        let mut out = vec![0.0; self.n_branches];
238        for &pos in &island.branch_positions {
239            let br = &self.branches[pos];
240            out[br.index] = br.b * (angle(br.from) - angle(br.to));
241        }
242        out
243    }
244
245    /// Branch-flow response to an arbitrary injection pattern — the primitive
246    /// the other accessors are special cases of.
247    ///
248    /// `injections` is per-bus and per-unit; within each island whatever does
249    /// not sum to zero is absorbed at that island's reference. Returns per-
250    /// branch flow *changes*, in the flat branch index space.
251    ///
252    /// Costs one solve per island that the pattern actually touches.
253    pub fn transfer_factors(&self, injections: &[f64]) -> Option<Vec<f64>> {
254        self.response(injections).map(|(_, flows)| flows)
255    }
256
257    /// Both halves of the response to an injection pattern: the change in
258    /// every bus angle and the change in every branch flow.
259    ///
260    /// Returns `(d_angle, d_flow)`, indexed by bus and by flat branch index.
261    /// This is what [`linear::batch`](super::batch) builds a whole batch on:
262    /// because DC is exactly linear, a scenario's answer is the base solve
263    /// plus this response to the scenario's injection *delta* — one solve
264    /// against a factorization computed once, rather than a fresh solve per
265    /// scenario.
266    ///
267    /// Islands the pattern does not touch are skipped without a solve, and
268    /// contribute nothing.
269    pub fn response(&self, injections: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
270        if injections.len() != self.n_buses {
271            return None;
272        }
273        let mut d_angle = vec![0.0; self.n_buses];
274        let mut d_flow = vec![0.0; self.n_branches];
275        for island in &self.islands {
276            let rhs: Vec<f64> = island.unknown.iter().map(|&i| injections[i]).collect();
277            if rhs.iter().all(|v| *v == 0.0) {
278                continue;
279            }
280            let theta = island.factorization.solve(&rhs)?;
281            for (dst, src) in d_flow
282                .iter_mut()
283                .zip(self.flows_from_angles(island, &theta))
284            {
285                *dst += src;
286            }
287            for (r, &bus) in island.unknown.iter().enumerate() {
288                d_angle[bus] += theta[r];
289            }
290        }
291        Some((d_angle, d_flow))
292    }
293
294    /// Branch flows after `branch` trips, given the flows before it did.
295    ///
296    /// This is the N-1 screening primitive: `f + LODF[:, l]·f[l]`, one solve
297    /// against the cached factorization and no re-solve of the network. The
298    /// outaged branch itself comes back at exactly zero.
299    ///
300    /// `None` if `branch` is radial — removing it islands the network, so its
301    /// power has nowhere to redistribute to and no post-outage flow exists —
302    /// or if it is out of range or in an unreferenced island. Use
303    /// [`is_radial`](Self::is_radial) to tell those apart in advance.
304    ///
305    /// `base_flows` must be indexed by flat branch index, as
306    /// [`DcSolution::branch_p`](super::btheta::DcSolution::branch_p) is.
307    pub fn outage_flows(&self, base_flows: &[f64], branch: usize) -> Option<Vec<f64>> {
308        self.multi_outage_flows(base_flows, &[branch])
309    }
310
311    /// Branch flows after **every** branch in `branches` trips at once.
312    ///
313    /// The N-2 (and N-k) generalization of [`outage_flows`](Self::outage_flows),
314    /// and not obtainable by applying single-branch LODF columns one after
315    /// another: simultaneous outages interact, because each one changes how the
316    /// others' power redistributes.
317    ///
318    /// # The generalized formulation
319    ///
320    /// Removing a set `L` is a rank-`k` update to `B`, so Woodbury gives the
321    /// whole answer in one `k × k` solve. With `ψ[m][l]` the flow appearing on
322    /// branch `m` per unit of power transferred across branch `l`'s terminals —
323    /// one triangular solve against the cached factorization per outaged branch,
324    /// exactly what a single-branch outage already needs:
325    ///
326    /// \\[ (I - \Psi_{LL})\,c = f_L, \qquad
327    ///     f^{new} = f + \sum_{l \in L} c_l\, \psi[:, l] \\]
328    ///
329    /// For `k = 1` this collapses to `c = f_l/(1 - d_l)` and the familiar
330    /// `LODF[:, l] = ψ[:, l]/(1 - d_l)`, which is why
331    /// [`outage_flows`](Self::outage_flows) simply delegates here rather than
332    /// keeping a second implementation.
333    ///
334    /// # When it returns `None`
335    ///
336    /// `I - Ψ_LL` is singular exactly when the set is *breaking* — removing all
337    /// of it disconnects the network, so the power has nowhere to redistribute
338    /// to and no post-outage flow exists. That is the multi-branch analogue of
339    /// a radial branch, and it is a structural fact rather than a numerical
340    /// accident, so it is reported rather than papered over. Also `None` for a
341    /// repeated index, an out-of-range index, or a branch that carries no DC
342    /// flow.
343    ///
344    /// An empty set returns `base_flows` unchanged.
345    pub fn multi_outage_flows(&self, base_flows: &[f64], branches: &[usize]) -> Option<Vec<f64>> {
346        if base_flows.len() != self.n_branches {
347            return None;
348        }
349        if branches.is_empty() {
350            return Some(base_flows.to_vec());
351        }
352        // A repeated branch would make `I - Ψ_LL` singular for a reason that
353        // has nothing to do with the network, so reject it as malformed input
354        // rather than reporting it as a breaking set.
355        for (i, a) in branches.iter().enumerate() {
356            if branches[i + 1..].contains(a) {
357                return None;
358            }
359        }
360
361        // One solve per outaged branch. `psi[l]` is the full flow response to a
362        // unit transfer across branches[l]'s terminals — zero outside that
363        // branch's own island, which is what makes an outage set spanning
364        // several islands fall out correctly: Ψ_LL is block diagonal and the
365        // system decouples on its own.
366        let k = branches.len();
367        let mut psi = Vec::with_capacity(k);
368        for &l in branches {
369            psi.push(self.outage_response(l)?.0);
370        }
371
372        // M = I - Ψ_LL, with Ψ_LL[m][l] the flow on outaged branch m caused by
373        // a unit transfer at outaged branch l.
374        let mut m = vec![vec![0.0; k]; k];
375        for (row, &branch_m) in branches.iter().enumerate() {
376            for (col, _) in branches.iter().enumerate() {
377                let identity = if row == col { 1.0 } else { 0.0 };
378                m[row][col] = identity - psi[col][branch_m];
379            }
380        }
381        let rhs: Vec<f64> = branches.iter().map(|&l| base_flows[l]).collect();
382        let c = gridoxide_sparse::sparse::solve_dense(m, rhs)?;
383
384        let mut out = base_flows.to_vec();
385        for (idx, &factor) in c.iter().enumerate() {
386            for (dst, src) in out.iter_mut().zip(&psi[idx]) {
387                *dst += factor * src;
388            }
389        }
390        // A branch that has been removed carries nothing. That is a fact about
391        // the post-outage network, not a computed value — the formula above
392        // instead yields the compensating transfer `c_l` in that slot.
393        for &l in branches {
394            out[l] = 0.0;
395        }
396        Some(out)
397    }
398
399    /// Whether removing every branch in `branches` at once would disconnect the
400    /// network, leaving no post-outage flows to compute.
401    ///
402    /// The multi-branch analogue of [`is_radial`](Self::is_radial), and `true`
403    /// for the same malformed inputs [`multi_outage_flows`](Self::multi_outage_flows)
404    /// rejects.
405    pub fn is_breaking_set(&self, branches: &[usize]) -> bool {
406        self.multi_outage_flows(&vec![0.0; self.n_branches], branches)
407            .is_none()
408    }
409
410    /// `∂P_branch/∂P_bus` over every branch, for injection at one bus.
411    ///
412    /// `None` if `bus` is out of range or sits in an island with no reference.
413    /// A reference bus's own column is all zeros — injecting at the slack and
414    /// letting the slack absorb it moves nothing.
415    pub fn ptdf_column(&self, bus: usize) -> Option<Vec<f64>> {
416        // Out of range, or an island with no reference: no factors exist.
417        if !self.referenced.get(bus).copied().unwrap_or(false) {
418            return None;
419        }
420        let r = self.reduced_pos[bus];
421        // A reference bus, or a bus in an island with nothing to solve for.
422        // Injecting at the slack and letting the slack absorb it moves nothing.
423        if r == NOT_UNKNOWN || self.island_of[bus] == NOT_UNKNOWN {
424            return Some(vec![0.0; self.n_branches]);
425        }
426        let island = &self.islands[self.island_of[bus]];
427        let mut rhs = vec![0.0; island.unknown.len()];
428        rhs[r] = 1.0;
429        let theta = island.factorization.solve(&rhs)?;
430        Some(self.flows_from_angles(island, &theta))
431    }
432
433    /// Resolves a flat branch index to its `DcBranch` and its island's
434    /// factorization, or `None` if it carries no DC flow or has no
435    /// factorization to solve against.
436    fn branch_and_island(&self, branch: usize) -> Option<(&DcBranch, &Island)> {
437        let pos = *self.branch_position.get(branch)?;
438        if pos == NOT_UNKNOWN {
439            return None;
440        }
441        let br = &self.branches[pos];
442        let island_idx = self.island_of[br.from];
443        if island_idx == NOT_UNKNOWN {
444            return None;
445        }
446        Some((br, &self.islands[island_idx]))
447    }
448
449    /// `∂P_branch/∂P_bus` over every bus, for one branch.
450    ///
451    /// One solve, not `n_buses` of them, because `B_NN` is symmetric.
452    /// `None` if `branch` is out of range, carries no DC flow, or sits in an
453    /// island with no reference.
454    pub fn ptdf_row(&self, branch: usize) -> Option<Vec<f64>> {
455        let (br, island) = self.branch_and_island(branch)?;
456
457        let mut rhs = vec![0.0; island.unknown.len()];
458        if self.reduced_pos[br.from] != NOT_UNKNOWN {
459            rhs[self.reduced_pos[br.from]] += br.b;
460        }
461        if self.reduced_pos[br.to] != NOT_UNKNOWN {
462            rhs[self.reduced_pos[br.to]] -= br.b;
463        }
464        let x = island.factorization.solve(&rhs)?;
465
466        let mut out = vec![0.0; self.n_buses];
467        for (r, &bus) in island.unknown.iter().enumerate() {
468            out[bus] = x[r];
469        }
470        Some(out)
471    }
472
473    /// Solves `e_from − e_to` for `branch`, returning the resulting per-branch
474    /// flows and `d_l`. Shared by [`lodf_column`](Self::lodf_column) and
475    /// [`is_radial`](Self::is_radial).
476    fn outage_response(&self, branch: usize) -> Option<(Vec<f64>, f64)> {
477        let (br, island) = self.branch_and_island(branch)?;
478
479        let mut rhs = vec![0.0; island.unknown.len()];
480        if self.reduced_pos[br.from] != NOT_UNKNOWN {
481            rhs[self.reduced_pos[br.from]] += 1.0;
482        }
483        if self.reduced_pos[br.to] != NOT_UNKNOWN {
484            rhs[self.reduced_pos[br.to]] -= 1.0;
485        }
486        let theta = island.factorization.solve(&rhs)?;
487        let flows = self.flows_from_angles(island, &theta);
488        let d = flows[branch];
489        Some((flows, d))
490    }
491
492    /// The fraction of `branch`'s pre-outage flow that lands on each other
493    /// branch when it trips.
494    ///
495    /// `None` if `branch` is radial (see [`is_radial`](Self::is_radial)), out
496    /// of range, or in an unreferenced island. The outaged branch's own entry
497    /// is `−1`: it loses all of its flow, by definition.
498    pub fn lodf_column(&self, branch: usize) -> Option<Vec<f64>> {
499        let (flows, d) = self.outage_response(branch)?;
500        if (1.0 - d).abs() < LODF_BRIDGE_TOL {
501            return None;
502        }
503        let scale = 1.0 / (1.0 - d);
504        let mut out: Vec<f64> = flows.iter().map(|f| f * scale).collect();
505        out[branch] = -1.0;
506        Some(out)
507    }
508
509    /// Whether removing `branch` would disconnect the network, leaving its
510    /// flow nowhere to redistribute to and its LODF column undefined.
511    ///
512    /// `true` for a branch that is out of range or in an unreferenced island:
513    /// there are no redistribution factors for it either way.
514    pub fn is_radial(&self, branch: usize) -> bool {
515        match self.outage_response(branch) {
516            Some((_, d)) => (1.0 - d).abs() < LODF_BRIDGE_TOL,
517            None => true,
518        }
519    }
520
521    /// The full `n_branches × n_buses` PTDF.
522    ///
523    /// Allocates `n_branches · n_buses · 8` bytes — **1.19 GB** on
524    /// `case9241pegase`. Prefer [`ptdf_column`](Self::ptdf_column) or
525    /// [`ptdf_row`](Self::ptdf_row) unless the whole matrix is genuinely
526    /// wanted.
527    pub fn ptdf_dense(&self) -> Option<DenseMatrix> {
528        let mut data = vec![0.0; self.n_branches * self.n_buses];
529        for bus in 0..self.n_buses {
530            let Some(column) = self.ptdf_column(bus) else {
531                continue;
532            };
533            for (k, v) in column.into_iter().enumerate() {
534                data[k * self.n_buses + bus] = v;
535            }
536        }
537        Some(DenseMatrix {
538            rows: self.n_branches,
539            cols: self.n_buses,
540            data,
541        })
542    }
543
544    /// The full `n_branches × n_branches` LODF.
545    ///
546    /// Allocates `n_branches² · 8` bytes — **2.06 GB** on `case9241pegase`.
547    /// Radial branches' columns are left at zero, since they have no defined
548    /// factors; use [`is_radial`](Self::is_radial) to tell those apart from
549    /// branches that genuinely redistribute nothing.
550    pub fn lodf_dense(&self) -> Option<DenseMatrix> {
551        let mut data = vec![0.0; self.n_branches * self.n_branches];
552        for l in 0..self.n_branches {
553            let Some(column) = self.lodf_column(l) else {
554                continue;
555            };
556            for (k, v) in column.into_iter().enumerate() {
557                data[k * self.n_branches + l] = v;
558            }
559        }
560        Some(DenseMatrix {
561            rows: self.n_branches,
562            cols: self.n_branches,
563            data,
564        })
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::linear::{DcOptions, dc_branches};
572    use crate::types::Line;
573
574    fn bus(idx: usize, bus_type: BusType) -> Bus {
575        Bus {
576            idx,
577            bus_type,
578            voltage_mag: 1.0,
579            voltage_ang: 0.0,
580            p_spec: 0.0,
581            q_spec: 0.0,
582            q_min: 0.0,
583            q_max: 0.0,
584            u_rated: 0.0,
585            zip_terms: Vec::new(),
586        }
587    }
588
589    fn line(from: usize, to: usize, x: f64) -> Line {
590        Line {
591            from,
592            to,
593            r: 0.0,
594            x,
595            b_shunt: 0.0,
596            g_shunt: 0.0,
597        }
598    }
599
600    /// Three buses in a triangle, all reactances equal, slack at 0. Injecting
601    /// at bus 1 and absorbing at bus 0 splits 2:1 between the direct path
602    /// (0–1) and the long way round (1–2–0), by symmetry — the textbook
603    /// answer, and one that needs no solver to know.
604    fn triangle() -> (Vec<Bus>, Vec<Line>) {
605        (
606            vec![
607                bus(0, BusType::Slack),
608                bus(1, BusType::PQ),
609                bus(2, BusType::PQ),
610            ],
611            vec![line(0, 1, 0.1), line(1, 2, 0.1), line(0, 2, 0.1)],
612        )
613    }
614
615    fn sensitivity(buses: &[Bus], lines: &[Line]) -> DcSensitivity {
616        let branches = dc_branches(lines, &[], DcOptions::default());
617        DcSensitivity::new(buses, &branches, lines.len()).unwrap()
618    }
619
620    #[test]
621    fn ptdf_column_matches_the_symmetric_two_thirds_split() {
622        let (buses, lines) = triangle();
623        let s = sensitivity(&buses, &lines);
624        let col = s.ptdf_column(1).unwrap();
625
626        // Signs follow each branch's own from→to orientation, so power moving
627        // toward the slack reads negative on a branch that points away from it:
628        //   branch 0 is 0→1, carrying 2/3 the "wrong" way   → −2/3
629        //   branch 1 is 1→2, carrying 1/3 the right way     → +1/3
630        //   branch 2 is 0→2, carrying that same 1/3 back    → −1/3
631        assert!((col[0] + 2.0 / 3.0).abs() < 1e-12, "{col:?}");
632        assert!((col[1] - 1.0 / 3.0).abs() < 1e-12, "{col:?}");
633        assert!((col[2] + 1.0 / 3.0).abs() < 1e-12, "{col:?}");
634    }
635
636    /// The reference's own column is zero, and `ptdf_row` must reproduce
637    /// `ptdf_column` transposed — the check that the symmetry shortcut in
638    /// `ptdf_row` is sound.
639    #[test]
640    fn ptdf_row_is_the_transpose_of_the_columns() {
641        let (buses, lines) = triangle();
642        let s = sensitivity(&buses, &lines);
643
644        assert!(s.ptdf_column(0).unwrap().iter().all(|v| *v == 0.0));
645
646        let columns: Vec<Vec<f64>> = (0..3).map(|b| s.ptdf_column(b).unwrap()).collect();
647        for branch in 0..3 {
648            let row = s.ptdf_row(branch).unwrap();
649            for bus in 0..3 {
650                assert!(
651                    (row[bus] - columns[bus][branch]).abs() < 1e-12,
652                    "branch {branch} bus {bus}: row {} vs column {}",
653                    row[bus],
654                    columns[bus][branch]
655                );
656            }
657        }
658    }
659
660    /// Outaging one edge of a symmetric triangle pushes all of its flow onto
661    /// the remaining path, so both surviving branches take exactly 1.
662    #[test]
663    fn lodf_of_a_symmetric_triangle_is_unity() {
664        let (buses, lines) = triangle();
665        let s = sensitivity(&buses, &lines);
666        let col = s.lodf_column(0).unwrap();
667
668        assert_eq!(col[0], -1.0);
669        assert!((col[1].abs() - 1.0).abs() < 1e-12, "{col:?}");
670        assert!((col[2].abs() - 1.0).abs() < 1e-12, "{col:?}");
671        assert!(!s.is_radial(0));
672    }
673
674    /// A bridge has no redistribution factors, because removing it islands
675    /// the network rather than rerouting anything.
676    #[test]
677    fn a_radial_branch_has_no_lodf_column() {
678        let buses = vec![
679            bus(0, BusType::Slack),
680            bus(1, BusType::PQ),
681            bus(2, BusType::PQ),
682        ];
683        let lines = vec![line(0, 1, 0.1), line(1, 2, 0.2)];
684        let s = sensitivity(&buses, &lines);
685
686        for branch in 0..2 {
687            assert!(s.is_radial(branch), "branch {branch} should be radial");
688            assert!(
689                s.lodf_column(branch).is_none(),
690                "branch {branch} should have no LODF"
691            );
692        }
693        // PTDF is still perfectly well defined on a radial network.
694        let col = s.ptdf_column(2).unwrap();
695        assert!(
696            (col[0] + 1.0).abs() < 1e-12 && (col[1] + 1.0).abs() < 1e-12,
697            "{col:?}"
698        );
699    }
700
701    /// `transfer_factors` must agree with a weighted sum of PTDF columns,
702    /// which is the linearity the whole module rests on.
703    #[test]
704    fn transfer_factors_are_a_linear_combination_of_ptdf_columns() {
705        let (buses, lines) = triangle();
706        let s = sensitivity(&buses, &lines);
707
708        // Inject 0.3 at bus 1 and 0.7 at bus 2; the reference absorbs both.
709        let got = s.transfer_factors(&[0.0, 0.3, 0.7]).unwrap();
710        let c1 = s.ptdf_column(1).unwrap();
711        let c2 = s.ptdf_column(2).unwrap();
712        for k in 0..3 {
713            let expected = 0.3 * c1[k] + 0.7 * c2[k];
714            assert!(
715                (got[k] - expected).abs() < 1e-12,
716                "branch {k}: {} vs {expected}",
717                got[k]
718            );
719        }
720        assert!(
721            s.transfer_factors(&[0.0, 0.0]).is_none(),
722            "length mismatch must be rejected"
723        );
724    }
725
726    #[test]
727    fn dense_matrices_agree_with_the_accessors() {
728        let (buses, lines) = triangle();
729        let s = sensitivity(&buses, &lines);
730
731        let ptdf = s.ptdf_dense().unwrap();
732        assert_eq!((ptdf.rows, ptdf.cols), (3, 3));
733        for bus in 0..3 {
734            let col = s.ptdf_column(bus).unwrap();
735            for branch in 0..3 {
736                assert_eq!(ptdf.get(branch, bus), col[branch]);
737            }
738        }
739
740        let lodf = s.lodf_dense().unwrap();
741        for l in 0..3 {
742            let col = s.lodf_column(l).unwrap();
743            for k in 0..3 {
744                assert_eq!(lodf.get(k, l), col[k]);
745            }
746        }
747    }
748
749    /// Buses in an island with no reference have no sensitivities, and say so
750    /// rather than returning zeros that look like an answer.
751    #[test]
752    fn an_unreferenced_island_has_no_factors() {
753        let buses = vec![
754            bus(0, BusType::Slack),
755            bus(1, BusType::PQ),
756            bus(2, BusType::PQ),
757            bus(3, BusType::PQ),
758        ];
759        let lines = vec![line(0, 1, 0.1), line(2, 3, 0.2)];
760        let s = sensitivity(&buses, &lines);
761
762        assert!(s.ptdf_column(1).is_some());
763        assert!(s.ptdf_column(2).is_none());
764        assert!(s.ptdf_column(3).is_none());
765        assert!(
766            s.ptdf_row(1).is_none(),
767            "the unreferenced island's branch has no row"
768        );
769        assert!(s.is_radial(1));
770    }
771
772    /// `None` from `ptdf_column` means "no reference bus", per its own doc.
773    /// A lone reference bus is a different thing — it has a reference and
774    /// simply nothing that could respond — so it must return zeros. Confusing
775    /// the two would make `None` useless for detecting unreferenced islands.
776    #[test]
777    fn a_lone_reference_bus_has_a_zero_column_not_a_missing_one() {
778        let buses = vec![
779            bus(0, BusType::Slack),
780            bus(1, BusType::Slack),
781            bus(2, BusType::PQ),
782        ];
783        // Bus 0 is an island unto itself; buses 1 and 2 are the other island.
784        let lines = vec![line(1, 2, 0.1)];
785        let s = sensitivity(&buses, &lines);
786
787        let column = s
788            .ptdf_column(0)
789            .expect("a referenced island must not report None");
790        assert!(column.iter().all(|v| *v == 0.0), "{column:?}");
791        assert!(s.ptdf_column(2).is_some());
792        // Out of range still means None.
793        assert!(s.ptdf_column(3).is_none());
794    }
795
796    /// Branch lookup must be a map, not a scan: the dense builders call these
797    /// once per branch, so a scan would make them quadratic. Checked by
798    /// behavior — every valid index resolves, and gaps left by branches that
799    /// carry no DC flow resolve to `None` rather than to a neighbour.
800    #[test]
801    fn branch_lookup_handles_gaps_in_the_flat_index() {
802        let buses = vec![
803            bus(0, BusType::Slack),
804            bus(1, BusType::PQ),
805            bus(2, BusType::PQ),
806        ];
807        // Branch 1 is a self-loop: gridoxide's half-open representation, which
808        // `dc_branches` skips, leaving a hole in the flat index space.
809        let lines = vec![
810            line(0, 1, 0.1),
811            line(1, 1, 0.2),
812            line(1, 2, 0.3),
813            line(0, 2, 0.4),
814        ];
815        let s = sensitivity(&buses, &lines);
816
817        assert!(s.ptdf_row(0).is_some());
818        assert!(
819            s.ptdf_row(1).is_none(),
820            "the skipped self-loop must not resolve"
821        );
822        assert!(s.ptdf_row(2).is_some());
823        assert!(s.ptdf_row(3).is_some());
824        assert!(s.ptdf_row(4).is_none(), "out of range must not resolve");
825        assert!(
826            s.is_radial(1),
827            "a branch carrying no DC flow has no factors"
828        );
829    }
830}