Skip to main content

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}