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 [`series_admittance_of`](crate::series_admittance_of) is the guarded
18/// entry point.
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, [`solver_edge_weight`](Self::solver_edge_weight).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[non_exhaustive]
45pub enum DcConvention {
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    ///
54    /// Serialized under its own name; `Matpower`, the stored 0.9 spelling,
55    /// is still read.
56    #[serde(alias = "Matpower")]
57    TapAdjustedReactance,
58    /// `b = imag(inv(r + jx)) = -x/(r² + x²)` with phase shift injections.
59    ///
60    /// Reads the whole series impedance, so it describes a branch with a real
61    /// r/x ratio. A transformer tap does not scale it, and it reduces to
62    /// `-1/x` when the resistance is zero. This is PowerModels' DC branch
63    /// susceptance exactly.
64    ///
65    /// Serialized under its own name; `SeriesImpedance`, the stored 0.9
66    /// spelling, is still read.
67    #[default]
68    #[serde(alias = "SeriesImpedance")]
69    SeriesSusceptance,
70}
71
72impl DcConvention {
73    /// The public branch susceptance, in PowerModels signs: the imaginary
74    /// part of the series admittance the selected formula models, negative
75    /// for an inductive branch. Only [`Self::TapAdjustedReactance`] reads the
76    /// tap, and only [`Self::SeriesSusceptance`] reads the resistance; a
77    /// value the selected formula never reads cannot reject a branch.
78    ///
79    /// Non-finite in, non-finite out. The reciprocal rules need the guard
80    /// because `1/±inf` is a finite `0.0`: a branch Y_bus rejects outright
81    /// would otherwise join the DC system as a zero-weight edge with nothing
82    /// to report it.
83    #[must_use]
84    pub fn branch_susceptance(self, resistance: f64, reactance: f64, effective_tap: f64) -> f64 {
85        // Guard the denominator, not its factors: `x * tap` can overflow to
86        // infinity from two finite factors and reach the same silent zero.
87        let negated_reciprocal = |denominator: f64| {
88            if denominator.is_finite() {
89                -1.0 / denominator
90            } else {
91                f64::NAN
92            }
93        };
94        match self {
95            Self::ReactanceOnly => negated_reciprocal(reactance),
96            Self::TapAdjustedReactance => negated_reciprocal(reactance * effective_tap),
97            Self::SeriesSusceptance => series_admittance_parts(resistance, reactance).1,
98        }
99    }
100
101    /// The internal positive factor weight of the same branch: the edge
102    /// weight of the positive semidefinite DC Laplacian a sparse Cholesky
103    /// solver factors, which is the negation of
104    /// [`branch_susceptance`](Self::branch_susceptance). Public results carry
105    /// PowerModels signs; a solver path fills its factor from this weight and
106    /// converts sign only while writing a caller's output.
107    #[must_use]
108    pub fn solver_edge_weight(self, resistance: f64, reactance: f64, effective_tap: f64) -> f64 {
109        -self.branch_susceptance(resistance, reactance, effective_tap)
110    }
111
112    /// Whether the selected formula reads the transformer tap, and so whether
113    /// the tap can bound or reject a branch.
114    #[must_use]
115    pub fn reads_tap(self) -> bool {
116        matches!(self, Self::TapAdjustedReactance)
117    }
118
119    /// Whether phase shifts contribute to the nodal injection vector.
120    #[must_use]
121    pub fn includes_phase_shifts(self) -> bool {
122        match self {
123            Self::ReactanceOnly => false,
124            Self::TapAdjustedReactance | Self::SeriesSusceptance => true,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn three_bus_network() -> crate::BalancedNetwork {
134        use crate::{Branch, Bus, BusId, BusType};
135        let mut shifted = Branch::new(BusId(2), BusId(3), 0.0, 0.2);
136        shifted.shift = 30.0;
137        let mut out = Branch::new(BusId(1), BusId(3), 0.01, 0.1);
138        out.in_service = false;
139        crate::BalancedNetwork::in_memory(
140            "dc-data",
141            100.0,
142            vec![
143                Bus::new(BusId(1), BusType::Ref, 230.0),
144                Bus::new(BusId(2), BusType::Pq, 230.0),
145                Bus::new(BusId(3), BusType::Pq, 230.0),
146            ],
147            vec![Branch::new(BusId(1), BusId(2), 0.0, 0.1), shifted, out],
148        )
149    }
150
151    /// The shared assembly: PowerModels orientation, stable identity for
152    /// every included and omitted row, the per row shift in radians, and the
153    /// shift injection `p_shift = A' (b .* shift)`.
154    #[test]
155    fn dc_network_data_maps_rows_and_omissions() {
156        let network = three_bus_network();
157        let view = crate::IndexedNetwork::new(&network);
158        let data = dc_network_data(&view, DcConvention::SeriesSusceptance);
159        assert_eq!(data.formula, "series_susceptance");
160        assert_eq!(data.from_indices, vec![0, 1]);
161        assert_eq!(data.to_indices, vec![1, 2]);
162        assert_eq!(data.row_ids, vec!["branches:0", "branches:1"]);
163        assert_eq!(data.bus_ids, vec!["1", "2", "3"]);
164        assert_eq!(data.omitted.len(), 1);
165        assert_eq!(data.omitted[0].0, "branches:2");
166        assert!(data.omitted[0].1.contains("out of service"));
167
168        let b = data.susceptance[1];
169        // PowerModels sign: imag(inv(j 0.2)) = -5.
170        assert!((b + 5.0).abs() < 1e-12);
171        let shift = 30.0_f64.to_radians();
172        assert!(data.shift[0].abs() < 1e-15);
173        assert!((data.shift[1] - shift).abs() < 1e-12);
174        // b is negative, so the from bus of the shifted row carries
175        // b * shift = (1/x)(-shift): the MATPOWER `makeBdc` fixed term.
176        assert!((data.shift_injection[1] - (b * shift)).abs() < 1e-12);
177        assert!((data.shift_injection[2] - (-b * shift)).abs() < 1e-12);
178        assert!(data.shift_injection[0].abs() < 1e-15);
179        // Numeric pin: with x = 0.2 and a +30 degree shift at flat start the
180        // branch flow is (1/x)(dva - shift) = -b * shift = -2.618 per unit.
181        let p_branch = -b * 0.0 + b * shift;
182        assert!((p_branch - 5.0 * (0.0 - shift)).abs() < 1e-12);
183    }
184
185    /// Adversarial partition audit: every branch lands in exactly one of
186    /// included or omitted, whatever mix of degeneracies the case carries,
187    /// and the stable IDs across both sets are unique.
188    #[test]
189    fn every_branch_is_included_or_omitted_exactly_once() {
190        use crate::{Branch, Bus, BusId, BusType};
191        let mut branches = vec![
192            Branch::new(BusId(1), BusId(2), 0.0, 0.1),
193            Branch::new(BusId(2), BusId(2), 0.0, 0.1),
194            Branch::new(BusId(1), BusId(9), 0.01, 0.1),
195            Branch::new(BusId(1), BusId(3), 0.0, 0.0),
196            Branch::new(BusId(2), BusId(3), 0.0, f64::NAN),
197            Branch::new(BusId(1), BusId(3), 0.02, 0.2),
198        ];
199        branches[5].in_service = false;
200        let mut giant_tap = Branch::new(BusId(2), BusId(3), 0.0, 1.0e308);
201        giant_tap.tap = 1.0e308;
202        branches.push(giant_tap);
203        let network = crate::BalancedNetwork::in_memory(
204            "partition",
205            100.0,
206            vec![
207                Bus::new(BusId(1), BusType::Ref, 230.0),
208                Bus::new(BusId(2), BusType::Pq, 230.0),
209                Bus::new(BusId(3), BusType::Pq, 230.0),
210            ],
211            branches,
212        );
213        let view = crate::IndexedNetwork::new(&network);
214        for convention in [
215            DcConvention::SeriesSusceptance,
216            DcConvention::TapAdjustedReactance,
217            DcConvention::ReactanceOnly,
218        ] {
219            let data = dc_network_data(&view, convention);
220            let included = data.row_ids.len();
221            assert_eq!(included, data.susceptance.len());
222            assert_eq!(included, data.from_indices.len());
223            assert_eq!(
224                included + data.omitted.len(),
225                network.branches().len(),
226                "{convention:?}"
227            );
228            let mut ids: Vec<&str> = data
229                .row_ids
230                .iter()
231                .map(String::as_str)
232                .chain(data.omitted.iter().map(|(id, _)| id.as_str()))
233                .collect();
234            ids.sort_unstable();
235            ids.dedup();
236            assert_eq!(ids.len(), network.branches().len(), "{convention:?}");
237            assert!(data.susceptance.iter().all(|b| b.is_finite()));
238        }
239    }
240
241    /// The degeneracy bound follows the selected formula: a purely resistive
242    /// branch has a finite (zero) series susceptance and stays an included
243    /// row under the series formula, while the two reactance formulas omit
244    /// it; a branch with no impedance at all is omitted under every formula.
245    #[test]
246    fn the_degeneracy_bound_follows_the_formula() {
247        use crate::{Branch, Bus, BusId, BusType};
248        let mut resistive = Branch::new(BusId(1), BusId(2), 0.05, 0.0);
249        resistive.uid = Some("resistive".to_owned());
250        let mut nothing = Branch::new(BusId(2), BusId(3), 0.0, 0.0);
251        nothing.uid = Some("nothing".to_owned());
252        let network = crate::BalancedNetwork::in_memory(
253            "dc-degenerate",
254            100.0,
255            vec![
256                Bus::new(BusId(1), BusType::Ref, 230.0),
257                Bus::new(BusId(2), BusType::Pq, 230.0),
258                Bus::new(BusId(3), BusType::Pq, 230.0),
259            ],
260            vec![resistive, nothing],
261        );
262        let view = crate::IndexedNetwork::new(&network);
263
264        let series = dc_network_data(&view, DcConvention::SeriesSusceptance);
265        assert_eq!(series.row_ids, vec!["resistive"]);
266        assert_eq!(series.from_indices, vec![0]);
267        assert_eq!(series.to_indices, vec![1]);
268        assert!(series.susceptance[0].abs() < 1e-15);
269        assert_eq!(series.omitted.len(), 1);
270        assert_eq!(series.omitted[0].0, "nothing");
271
272        for convention in [
273            DcConvention::TapAdjustedReactance,
274            DcConvention::ReactanceOnly,
275        ] {
276            let data = dc_network_data(&view, convention);
277            assert!(data.row_ids.is_empty(), "{convention:?}");
278            let omitted: Vec<&str> = data.omitted.iter().map(|(id, _)| id.as_str()).collect();
279            assert_eq!(omitted, vec!["resistive", "nothing"], "{convention:?}");
280            for (_, reason) in &data.omitted {
281                assert!(reason.contains("reactance"), "{reason}");
282            }
283        }
284    }
285
286    /// A three winding transformer's star bus and winding branches appear in
287    /// every table: `bus_ids` matches the incidence column count and the
288    /// winding rows are included or omitted, never absent.
289    #[test]
290    fn three_winding_expansion_keeps_every_table_aligned() {
291        let path = concat!(
292            env!("CARGO_MANIFEST_DIR"),
293            "/../tests/data/psse/case3_3w_v33.raw"
294        );
295        let source = powerio_core::Source::open(std::path::Path::new(path)).unwrap();
296        let module =
297            crate::parse(source.with_format(powerio_core::FormatId::new("psse").unwrap())).unwrap();
298        let network = module.value();
299        let view = crate::IndexedNetwork::new(network);
300        let data = dc_network_data(&view, DcConvention::SeriesSusceptance);
301        assert_eq!(data.bus_ids.len(), view.n());
302        // Three declared buses plus the synthetic star bus.
303        assert_eq!(data.bus_ids.len(), 4);
304        assert!(
305            data.row_ids.len() + data.omitted.len() >= 3,
306            "winding branches missing: {} rows, {} omitted",
307            data.row_ids.len(),
308            data.omitted.len()
309        );
310        for index in &data.from_indices {
311            assert!(*index < data.bus_ids.len());
312        }
313        for index in &data.to_indices {
314            assert!(*index < data.bus_ids.len());
315        }
316    }
317
318    /// The formula names are the cross language vocabulary; unknown names
319    /// resolve to nothing rather than a default.
320    #[test]
321    fn formula_names_round_trip() {
322        for convention in [
323            DcConvention::SeriesSusceptance,
324            DcConvention::TapAdjustedReactance,
325            DcConvention::ReactanceOnly,
326        ] {
327            assert_eq!(
328                DcConvention::from_formula_name(convention.formula_name()),
329                Some(convention)
330            );
331        }
332        assert_eq!(DcConvention::from_formula_name("mystery"), None);
333    }
334
335    /// Public values carry PowerModels signs: negative for an inductive
336    /// branch, `imag(inv(r + jx))` exactly. A resistanceless branch reads the
337    /// same under both live conventions, so the default only moves a case
338    /// that carries resistance.
339    #[test]
340    fn series_susceptance_reduces_to_negated_one_over_x() {
341        let b = DcConvention::SeriesSusceptance.branch_susceptance(0.0, 0.25, 1.0);
342        assert!((b + 4.0).abs() < 1e-12);
343        // The internal factor weight is its negation.
344        let weight = DcConvention::SeriesSusceptance.solver_edge_weight(0.0, 0.25, 1.0);
345        assert!((weight - 4.0).abs() < 1e-12);
346    }
347
348    /// Resistance lowers the susceptance magnitude, by more as `r` grows
349    /// against `x`.
350    #[test]
351    fn resistance_lowers_the_susceptance_magnitude() {
352        let lossless = DcConvention::SeriesSusceptance.branch_susceptance(0.0, 0.1, 1.0);
353        let lossy = DcConvention::SeriesSusceptance.branch_susceptance(0.1, 0.1, 1.0);
354        assert!(lossy.abs() < lossless.abs());
355        assert!((lossy + 5.0).abs() < 1e-12);
356    }
357
358    #[test]
359    fn matpower_scales_by_the_tap() {
360        let b = DcConvention::TapAdjustedReactance.branch_susceptance(0.01, 0.2, 2.0);
361        assert!((b + 2.5).abs() < 1e-12);
362    }
363
364    /// Only the tap-reading formula can be rejected by a tap: the other
365    /// formulas never read the value (#324).
366    #[test]
367    fn an_unread_tap_never_rejects_a_branch() {
368        for conv in [DcConvention::ReactanceOnly, DcConvention::SeriesSusceptance] {
369            assert!(!conv.reads_tap());
370            let b = conv.branch_susceptance(0.01, 0.1, 1e-200);
371            assert!(b.is_finite(), "{conv:?} read the tap it never divides by");
372        }
373        assert!(DcConvention::TapAdjustedReactance.reads_tap());
374    }
375
376    /// `1/±inf` is `0.0`, which is finite, so a branch the Y_bus builder rejects
377    /// outright would enter the DC Laplacian as a zero-weight edge instead. The
378    /// tap divides the same denominator, so two finite factors whose product
379    /// overflows collapse the same way.
380    #[test]
381    fn a_non_finite_denominator_is_not_a_susceptance() {
382        for x in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
383            for conv in [
384                DcConvention::ReactanceOnly,
385                DcConvention::TapAdjustedReactance,
386                DcConvention::SeriesSusceptance,
387            ] {
388                let b = conv.branch_susceptance(0.01, x, 1.0);
389                assert!(!b.is_finite(), "{conv:?} read x = {x} as b = {b}");
390            }
391        }
392        for (x, tap) in [
393            (0.1, f64::INFINITY),
394            (0.1, f64::NAN),
395            (1e300, 1e300),
396            (1e300, -1e300),
397        ] {
398            let b = DcConvention::TapAdjustedReactance.branch_susceptance(0.0, x, tap);
399            assert!(!b.is_finite(), "x = {x}, tap = {tap} read as b = {b}");
400        }
401    }
402
403    /// An impedance well inside [`MIN_DIVISIBLE_MAGNITUDE`] whose *square* is
404    /// not: `r² + x²` overflows past about 1e154 and the quotient reads as an
405    /// exact zero, which drops the branch from the DC network with nothing to
406    /// say so. The bound is on the magnitude precisely so the square never
407    /// decides.
408    #[test]
409    fn an_impedance_whose_square_overflows_still_has_a_susceptance() {
410        let (r, x) = (1e160, 1e160);
411        assert!(r * r + x * x == f64::INFINITY, "the direct form overflows");
412
413        let b = DcConvention::SeriesSusceptance.branch_susceptance(r, x, 1.0);
414        // b = -x/(r² + x²) = -1/(2 · 1e160).
415        assert!(
416            (b / -5e-161 - 1.0).abs() < 1e-12,
417            "the branch is not dropped, got {b}"
418        );
419
420        let (g, susceptance) = series_admittance_parts(r, x);
421        assert!((g / 5e-161 - 1.0).abs() < 1e-12, "got {g}");
422        assert!(
423            (susceptance - b).abs() < 1e-175,
424            "the public rule is the series susceptance itself"
425        );
426    }
427
428    /// Below the overflow the scaled form is never reached, so every ordinary
429    /// branch keeps the exact bits the direct quotient produced.
430    #[test]
431    fn the_ordinary_range_is_bit_identical_to_the_direct_quotient() {
432        for (r, x) in [
433            (0.01, 0.1),
434            (0.03, 0.04),
435            (0.0, 0.25),
436            (1e-6, 1e-5),
437            (7.0, 3.0),
438        ] {
439            let denom = r * r + x * x;
440            assert_eq!(series_admittance_parts(r, x), (r / denom, -x / denom));
441        }
442    }
443}
444
445/// The DC branch data of one balanced network under one susceptance formula,
446/// with the stable element mappings that interpret every row: the one
447/// assembly Rust, C, Python, and Julia all read, so names, element order,
448/// and omission reasons agree across languages by construction.
449///
450/// Rows follow `A[e, from] = +1`, `A[e, to] = -1` (PowerModels orientation);
451/// susceptance carries the PowerModels sign for the selected formula; the
452/// phase shift injection is `p_shift = A' * (b .* shift)` per bus (with the
453/// negative `b`, the same fixed term MATPOWER's `makeBdc` builds), and the
454/// complete affine branch flow is
455/// `p_branch = -b .* (va_from - va_to) + b .* shift`, so
456/// `A' * p_branch` equals the angle terms plus `shift_injection`. Rows and
457/// columns describe the analysis network after three winding transformer
458/// expansion.
459#[derive(Clone, Debug, PartialEq)]
460#[non_exhaustive]
461pub struct DcNetworkData {
462    /// From bus column per included row.
463    pub from_indices: Vec<usize>,
464    /// To bus column per included row.
465    pub to_indices: Vec<usize>,
466    /// Branch susceptance per included row.
467    pub susceptance: Vec<f64>,
468    /// Phase shift angle per included row, radians; `0` for an unshifted
469    /// branch or a formula that excludes shifts.
470    pub shift: Vec<f64>,
471    /// Phase shift bus injection, one entry per bus.
472    pub shift_injection: Vec<f64>,
473    /// Stable module element ID per included row.
474    pub row_ids: Vec<String>,
475    /// Stable bus element ID per incidence column.
476    pub bus_ids: Vec<String>,
477    /// Branches the selected formula cannot represent: stable element ID and
478    /// the diagnostic reason. Zero impedance branches land here by default;
479    /// nothing removes them silently.
480    pub omitted: Vec<(String, String)>,
481    /// The selected formula's stable cross language name.
482    pub formula: &'static str,
483}
484
485impl DcConvention {
486    /// The formula's stable cross language name.
487    #[must_use]
488    pub fn formula_name(self) -> &'static str {
489        match self {
490            Self::SeriesSusceptance => "series_susceptance",
491            Self::TapAdjustedReactance => "tap_adjusted_reactance",
492            Self::ReactanceOnly => "reactance_only",
493        }
494    }
495
496    /// The convention for one stable formula name, `None` for an unknown
497    /// name. Accepts the storage aliases (`series`, `matpower`).
498    #[must_use]
499    pub fn from_formula_name(name: &str) -> Option<Self> {
500        match name {
501            "series_susceptance" | "series" => Some(Self::SeriesSusceptance),
502            "tap_adjusted_reactance" | "matpower" => Some(Self::TapAdjustedReactance),
503            "reactance_only" => Some(Self::ReactanceOnly),
504            _ => None,
505        }
506    }
507}
508
509/// Assemble [`DcNetworkData`]: in-service branches in table order, self
510/// loops and formula degenerate branches reported as omitted rows by stable
511/// ID, never dropped silently and never replaced with an epsilon impedance.
512/// The degeneracy bound applies to the magnitude the selected formula
513/// actually divides by: the series impedance magnitude for
514/// [`DcConvention::SeriesSusceptance`], the reactance alone for the two
515/// reactance formulas.
516#[must_use]
517pub fn dc_network_data(
518    view: &crate::IndexedNetwork<'_>,
519    convention: DcConvention,
520) -> DcNetworkData {
521    let network = view.network();
522    let n = view.n();
523    let mut data = DcNetworkData {
524        from_indices: Vec::new(),
525        to_indices: Vec::new(),
526        susceptance: Vec::new(),
527        shift: Vec::new(),
528        shift_injection: vec![0.0; n],
529        row_ids: Vec::new(),
530        bus_ids: network
531            .buses()
532            .iter()
533            .map(|bus| bus.id.0.to_string())
534            .collect(),
535        omitted: Vec::new(),
536        formula: convention.formula_name(),
537    };
538    for (idx, branch) in network.branches().iter().enumerate() {
539        let id = branch
540            .uid
541            .clone()
542            .unwrap_or_else(|| format!("branches:{idx}"));
543        if !branch.in_service {
544            data.omitted.push((id, "out of service".to_owned()));
545            continue;
546        }
547        let (Some(i), Some(j)) = (view.bus_index(branch.from), view.bus_index(branch.to)) else {
548            data.omitted
549                .push((id, "references an undeclared bus".to_owned()));
550            continue;
551        };
552        if i == j {
553            data.omitted.push((id, "self loop".to_owned()));
554            continue;
555        }
556        let degenerate = match convention {
557            DcConvention::SeriesSusceptance => branch.r.hypot(branch.x) < MIN_DIVISIBLE_MAGNITUDE,
558            DcConvention::TapAdjustedReactance | DcConvention::ReactanceOnly => {
559                branch.x.abs() < MIN_DIVISIBLE_MAGNITUDE
560            }
561        };
562        if degenerate {
563            let reason = match convention {
564                DcConvention::SeriesSusceptance => {
565                    "zero impedance: the series impedance magnitude is below the divisibility \
566                     floor"
567                }
568                DcConvention::TapAdjustedReactance | DcConvention::ReactanceOnly => {
569                    "zero reactance: the selected formula divides by reactance"
570                }
571            };
572            data.omitted.push((id, reason.to_owned()));
573            continue;
574        }
575        let tap = match branch.divisible_tap(idx) {
576            Ok(tap) => tap,
577            Err(error) => {
578                data.omitted.push((id, error.to_string()));
579                continue;
580            }
581        };
582        let b = convention.branch_susceptance(branch.r, branch.x, tap);
583        if !b.is_finite() {
584            data.omitted
585                .push((id, "susceptance is not finite".to_owned()));
586            continue;
587        }
588        let row_shift = if convention.includes_phase_shifts() {
589            view.angle_radians(branch.shift)
590        } else {
591            0.0
592        };
593        if row_shift != 0.0 {
594            data.shift_injection[i] += b * row_shift;
595            data.shift_injection[j] -= b * row_shift;
596        }
597        data.from_indices.push(i);
598        data.to_indices.push(j);
599        data.susceptance.push(b);
600        data.shift.push(row_shift);
601        data.row_ids.push(id);
602    }
603    data
604}