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}