powerio_tx/dc.rs
1//! Conventions shared by DC network models and matrix builders.
2
3use serde::{Deserialize, Serialize};
4
5/// The magnitude below which a reactance, an impedance, or a tap ratio stops
6/// being a number a builder can divide by.
7///
8/// It is `f64::MIN_POSITIVE.sqrt()`: the square of anything smaller underflows
9/// to zero, and the reciprocal is above 1e153, which annihilates every real
10/// branch sharing its diagonal. Each builder compares a magnitude against it —
11/// `|x|`, `hypot(r, x)`, the tap — never `r² + x²`, which is a square. Per unit
12/// reactances run from 1e-6 to 10, so it rejects poison and nothing else.
13pub const MIN_DIVISIBLE_MAGNITUDE: f64 = 1.491_668_146_240_041_3e-154;
14
15/// The series admittance `(g, b) = (r - jx)/(r² + x²)` of an impedance, with no
16/// bound applied: the caller has already decided the impedance is one to divide
17/// by, and [`calc_series_admittance_of`](crate::calc_series_admittance_of) is the
18/// entry point that checks it.
19///
20/// `r² + x²` is not formed directly. It overflows to infinity for an impedance
21/// magnitude past about 1e154 — an admittance around 1e-154, which is perfectly
22/// representable — and the quotient would then read as an exact zero, dropping
23/// the branch from the DC network with nothing to say it happened. Dividing by
24/// the larger term first keeps both squares inside `[0, 1]`. Below that
25/// magnitude the two forms agree bit for bit, so the direct one still runs.
26pub(crate) fn series_admittance_parts(r: f64, x: f64) -> (f64, f64) {
27 let denom = r * r + x * x;
28 if denom.is_finite() {
29 return (r / denom, -x / denom);
30 }
31 let scale = r.abs().max(x.abs());
32 let (r, x) = (r / scale, x / scale);
33 let denom = (r * r + x * x) * scale;
34 (r / denom, -x / denom)
35}
36
37/// Rule for the DC branch susceptance `b`.
38///
39/// The public `b` follows PowerModels: it is negative for an inductive
40/// branch, the imaginary part of the series admittance the selected formula
41/// models. The positive edge weight a sparse factorization uses is its
42/// negation, [`calc_solver_edge_weight`](Self::calc_solver_edge_weight).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[non_exhaustive]
45pub enum BranchSusceptanceFormula {
46 /// `b = -1/x`, ignoring resistance, transformer taps, and phase shifts.
47 ///
48 /// The textbook DC linearization, which a paper reproducing a published
49 /// result needs exactly as written.
50 ReactanceOnly,
51 /// `b = -1/(x tau)` with phase shift injections, matching MATPOWER
52 /// `makeBdc` up to MATPOWER's own sign spelling.
53 TapAdjustedReactance,
54 /// `b = imag(inv(r + jx)) = -x/(r² + x²)` with phase shift injections.
55 ///
56 /// Reads the whole series impedance, so it describes a branch with a real
57 /// r/x ratio. A transformer tap does not scale it, and it reduces to
58 /// `-1/x` when the resistance is zero. This is PowerModels' DC branch
59 /// susceptance exactly.
60 #[default]
61 SeriesSusceptance,
62}
63
64impl BranchSusceptanceFormula {
65 /// The public branch susceptance, in PowerModels signs: the imaginary
66 /// part of the series admittance the selected formula models, negative
67 /// for an inductive branch. Only [`Self::TapAdjustedReactance`] reads the
68 /// tap, and only [`Self::SeriesSusceptance`] reads the resistance; a
69 /// value the selected formula never reads cannot reject a branch.
70 ///
71 /// Non-finite in, non-finite out. The reciprocal rules need the guard
72 /// because `1/±inf` is a finite `0.0`: a branch Y_bus rejects outright
73 /// would otherwise join the DC system as a zero-weight edge with nothing
74 /// to report it.
75 #[must_use]
76 pub fn calc_branch_susceptance(
77 self,
78 resistance: f64,
79 reactance: f64,
80 effective_tap: f64,
81 ) -> f64 {
82 // Guard the denominator, not its factors: `x * tap` can overflow to
83 // infinity from two finite factors and reach the same silent zero.
84 let negated_reciprocal = |denominator: f64| {
85 if denominator.is_finite() {
86 -1.0 / denominator
87 } else {
88 f64::NAN
89 }
90 };
91 match self {
92 Self::ReactanceOnly => negated_reciprocal(reactance),
93 Self::TapAdjustedReactance => negated_reciprocal(reactance * effective_tap),
94 Self::SeriesSusceptance => series_admittance_parts(resistance, reactance).1,
95 }
96 }
97
98 /// The internal positive factor weight of the same branch: the edge
99 /// weight of the positive semidefinite DC Laplacian a sparse Cholesky
100 /// solver factors, which is the negation of
101 /// [`calc_branch_susceptance`](Self::calc_branch_susceptance). Public results carry
102 /// PowerModels signs; a solver path fills its factor from this weight and
103 /// converts sign only while writing a caller's output.
104 #[must_use]
105 pub fn calc_solver_edge_weight(
106 self,
107 resistance: f64,
108 reactance: f64,
109 effective_tap: f64,
110 ) -> f64 {
111 -self.calc_branch_susceptance(resistance, reactance, effective_tap)
112 }
113
114 /// Whether the selected formula reads the transformer tap, and so whether
115 /// the tap can bound or reject a branch.
116 #[must_use]
117 pub fn reads_tap(self) -> bool {
118 matches!(self, Self::TapAdjustedReactance)
119 }
120
121 /// Whether phase shifts contribute to the nodal injection vector.
122 #[must_use]
123 pub fn includes_phase_shifts(self) -> bool {
124 match self {
125 Self::ReactanceOnly => false,
126 Self::TapAdjustedReactance | Self::SeriesSusceptance => true,
127 }
128 }
129
130 /// The formula's stable cross language name.
131 #[must_use]
132 pub fn formula_name(self) -> &'static str {
133 match self {
134 Self::SeriesSusceptance => "series_susceptance",
135 Self::TapAdjustedReactance => "tap_adjusted_reactance",
136 Self::ReactanceOnly => "reactance_only",
137 }
138 }
139
140 /// The formula for one stable name, or `None` for an unknown name.
141 #[must_use]
142 pub fn from_formula_name(name: &str) -> Option<Self> {
143 match name {
144 "series_susceptance" => Some(Self::SeriesSusceptance),
145 "tap_adjusted_reactance" => Some(Self::TapAdjustedReactance),
146 "reactance_only" => Some(Self::ReactanceOnly),
147 _ => None,
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 /// The formula names are the cross language vocabulary; unknown names
157 /// resolve to nothing rather than a default.
158 #[test]
159 fn formula_names_round_trip() {
160 for convention in [
161 BranchSusceptanceFormula::SeriesSusceptance,
162 BranchSusceptanceFormula::TapAdjustedReactance,
163 BranchSusceptanceFormula::ReactanceOnly,
164 ] {
165 assert_eq!(
166 BranchSusceptanceFormula::from_formula_name(convention.formula_name()),
167 Some(convention)
168 );
169 }
170 assert_eq!(BranchSusceptanceFormula::from_formula_name("mystery"), None);
171 assert_eq!(BranchSusceptanceFormula::from_formula_name("series"), None);
172 assert_eq!(
173 BranchSusceptanceFormula::from_formula_name("matpower"),
174 None
175 );
176 }
177
178 /// Public values carry PowerModels signs: negative for an inductive
179 /// branch, `imag(inv(r + jx))` exactly. A resistanceless branch reads the
180 /// same under both live conventions, so the default only moves a case
181 /// that carries resistance.
182 #[test]
183 fn series_susceptance_reduces_to_negated_one_over_x() {
184 let b = BranchSusceptanceFormula::SeriesSusceptance.calc_branch_susceptance(0.0, 0.25, 1.0);
185 assert!((b + 4.0).abs() < 1e-12);
186 // The internal factor weight is its negation.
187 let weight =
188 BranchSusceptanceFormula::SeriesSusceptance.calc_solver_edge_weight(0.0, 0.25, 1.0);
189 assert!((weight - 4.0).abs() < 1e-12);
190 }
191
192 /// Resistance lowers the susceptance magnitude, by more as `r` grows
193 /// against `x`.
194 #[test]
195 fn resistance_lowers_the_susceptance_magnitude() {
196 let lossless =
197 BranchSusceptanceFormula::SeriesSusceptance.calc_branch_susceptance(0.0, 0.1, 1.0);
198 let lossy =
199 BranchSusceptanceFormula::SeriesSusceptance.calc_branch_susceptance(0.1, 0.1, 1.0);
200 assert!(lossy.abs() < lossless.abs());
201 assert!((lossy + 5.0).abs() < 1e-12);
202 }
203
204 #[test]
205 fn matpower_scales_by_the_tap() {
206 let b =
207 BranchSusceptanceFormula::TapAdjustedReactance.calc_branch_susceptance(0.01, 0.2, 2.0);
208 assert!((b + 2.5).abs() < 1e-12);
209 }
210
211 /// Only the tap-reading formula can be rejected by a tap: the other
212 /// formulas never read the value (#324).
213 #[test]
214 fn an_unread_tap_never_rejects_a_branch() {
215 for conv in [
216 BranchSusceptanceFormula::ReactanceOnly,
217 BranchSusceptanceFormula::SeriesSusceptance,
218 ] {
219 assert!(!conv.reads_tap());
220 let b = conv.calc_branch_susceptance(0.01, 0.1, 1e-200);
221 assert!(b.is_finite(), "{conv:?} read the tap it never divides by");
222 }
223 assert!(BranchSusceptanceFormula::TapAdjustedReactance.reads_tap());
224 }
225
226 /// `1/±inf` is `0.0`, which is finite, so a branch the Y_bus builder rejects
227 /// outright would enter the DC Laplacian as a zero-weight edge instead. The
228 /// tap divides the same denominator, so two finite factors whose product
229 /// overflows collapse the same way.
230 #[test]
231 fn a_non_finite_denominator_is_not_a_susceptance() {
232 for x in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
233 for conv in [
234 BranchSusceptanceFormula::ReactanceOnly,
235 BranchSusceptanceFormula::TapAdjustedReactance,
236 BranchSusceptanceFormula::SeriesSusceptance,
237 ] {
238 let b = conv.calc_branch_susceptance(0.01, x, 1.0);
239 assert!(!b.is_finite(), "{conv:?} read x = {x} as b = {b}");
240 }
241 }
242 for (x, tap) in [
243 (0.1, f64::INFINITY),
244 (0.1, f64::NAN),
245 (1e300, 1e300),
246 (1e300, -1e300),
247 ] {
248 let b =
249 BranchSusceptanceFormula::TapAdjustedReactance.calc_branch_susceptance(0.0, x, tap);
250 assert!(!b.is_finite(), "x = {x}, tap = {tap} read as b = {b}");
251 }
252 }
253
254 /// An impedance well inside [`MIN_DIVISIBLE_MAGNITUDE`] whose *square* is
255 /// not: `r² + x²` overflows past about 1e154 and the quotient reads as an
256 /// exact zero, which drops the branch from the DC network with nothing to
257 /// say so. The bound is on the magnitude precisely so the square never
258 /// decides.
259 #[test]
260 fn an_impedance_whose_square_overflows_still_has_a_susceptance() {
261 let (r, x) = (1e160, 1e160);
262 assert!(r * r + x * x == f64::INFINITY, "the direct form overflows");
263
264 let b = BranchSusceptanceFormula::SeriesSusceptance.calc_branch_susceptance(r, x, 1.0);
265 // b = -x/(r² + x²) = -1/(2 · 1e160).
266 assert!(
267 (b / -5e-161 - 1.0).abs() < 1e-12,
268 "the branch is not dropped, got {b}"
269 );
270
271 let (g, susceptance) = series_admittance_parts(r, x);
272 assert!((g / 5e-161 - 1.0).abs() < 1e-12, "got {g}");
273 assert!(
274 (susceptance - b).abs() < 1e-175,
275 "the public rule is the series susceptance itself"
276 );
277 }
278
279 /// Below the overflow the scaled form is never reached, so every ordinary
280 /// branch keeps the exact bits the direct quotient produced.
281 #[test]
282 fn the_ordinary_range_is_bit_identical_to_the_direct_quotient() {
283 for (r, x) in [
284 (0.01, 0.1),
285 (0.03, 0.04),
286 (0.0, 0.25),
287 (1e-6, 1e-5),
288 (7.0, 3.0),
289 ] {
290 let denom = r * r + x * x;
291 assert_eq!(series_admittance_parts(r, x), (r / denom, -x / denom));
292 }
293 }
294}