powerio_matrix/matrix/ybus.rs
1//! Bus admittance matrix `Y_bus = G + jB` per MATPOWER's `makeYbus`.
2//!
3//! For each in-service branch from bus `i` to bus `j` with series impedance
4//! `z = r + jx`, terminal shunts `y_fr`/`y_to`, complex tap `a = tap * exp(j shift)`:
5//!
6//! ```text
7//! Y[i,i] += (1/z + y_fr) / |a|^2
8//! Y[j,j] += (1/z + y_to)
9//! Y[i,j] += -(1/z) / conj(a)
10//! Y[j,i] += -(1/z) / a
11//! ```
12//!
13//! Plus bus shunts `Y[i,i] += (g_s + j b_s) / baseMVA`.
14
15use num_complex::Complex64;
16use sprs::CsMat;
17
18use crate::Result;
19use crate::indexed::IndexedNetwork;
20
21use super::triplet::CooBuilder;
22
23/// `Re(Y_bus)` and `Im(Y_bus)` as separate CSR matrices.
24#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub struct YbusParts {
27 pub g: CsMat<f64>,
28 pub b: CsMat<f64>,
29}
30
31/// Internal flags used to derive MATPOWER `Bp` and `Bpp` from `Y_bus`.
32// Six independent on/off switches into one Y_bus kernel; an enum per pair
33// would just spread the same data across more types.
34#[allow(clippy::struct_excessive_bools)]
35#[derive(Debug, Clone, Copy, Default)]
36pub(crate) struct YbusFlags {
37 pub zero_resistance: bool,
38 pub zero_charging: bool,
39 pub unity_taps: bool,
40 pub zero_shifts: bool,
41 pub skip_bus_shunts: bool,
42 /// Matches `BuildOptions::default()`: false, so a zero impedance branch
43 /// (preserved in networks and instances) errors the physical admittances
44 /// unless a caller opts into skipping it.
45 pub skip_zero_impedance: bool,
46}
47
48/// Calculate the bus admittance matrix, returned as separate real and
49/// imaginary sparse matrices.
50pub fn calc_admittance_matrix(
51 case: &IndexedNetwork,
52 opts: &super::BuildOptions,
53) -> Result<YbusParts> {
54 let flags = YbusFlags {
55 zero_resistance: false,
56 zero_charging: false,
57 unity_taps: !opts.include_taps,
58 zero_shifts: !opts.include_shifts,
59 skip_bus_shunts: false,
60 skip_zero_impedance: opts.skip_zero_impedance,
61 };
62 build_ybus_with_flags(case, flags)
63}
64
65// i/j bus indices, r/x impedance, a complex tap: the single-letter names are
66// the standard makeYbus notation and the math reads worse spelled out.
67#[allow(clippy::many_single_char_names)]
68pub(crate) fn build_ybus_with_flags(case: &IndexedNetwork, flags: YbusFlags) -> Result<YbusParts> {
69 // The bus shunt block divides by `per_unit_base()`. A zero or non-finite
70 // base made that `0.0/0.0`, storing NaN on every diagonal — including
71 // shunt-free buses, since `CooBuilder::add` skips only exact zero — and
72 // the matrix shipped with no error.
73 case.network().check_base_mva()?;
74 let n = case.n();
75 let mut g_coo = CooBuilder::with_capacity(n, 4 * case.branches().len() + n);
76 let mut b_coo = CooBuilder::with_capacity(n, 4 * case.branches().len() + n);
77
78 for (row_idx, br) in case.in_service_branches() {
79 let i = case
80 .bus_index(br.from)
81 .ok_or(powerio_tx::Error::UnknownBus {
82 bus_id: br.from,
83 element_index: row_idx,
84 })?;
85 let j = case.bus_index(br.to).ok_or(powerio_tx::Error::UnknownBus {
86 bus_id: br.to,
87 element_index: row_idx,
88 })?;
89
90 let shift_rad = if flags.zero_shifts {
91 0.0
92 } else {
93 case.to_radians(br.shift)
94 };
95 let Some([y_ii, y_ij, y_ji, y_jj]) = branch_admittance(br, flags, shift_rad, row_idx)?
96 else {
97 // Zero-impedance branch (r² + x² = 0): no admittance to scatter.
98 continue;
99 };
100
101 if i == j {
102 // Self-loop branch: combine all four contributions onto bus i.
103 let combined = y_ii + y_jj + y_ij + y_ji;
104 g_coo.add(i, i, combined.re);
105 b_coo.add(i, i, combined.im);
106 continue;
107 }
108
109 g_coo.add(i, i, y_ii.re);
110 b_coo.add(i, i, y_ii.im);
111 g_coo.add(j, j, y_jj.re);
112 b_coo.add(j, j, y_jj.im);
113 g_coo.add(i, j, y_ij.re);
114 b_coo.add(i, j, y_ij.im);
115 g_coo.add(j, i, y_ji.re);
116 b_coo.add(j, i, y_ji.im);
117 }
118
119 if !flags.skip_bus_shunts {
120 // ÷ per-unit base (1.0 if the network is already normalized), so a
121 // normalized network's shunts aren't divided by base a second time.
122 let base = case.per_unit_base();
123 for idx in 0..n {
124 g_coo.add(idx, idx, case.gs()[idx] / base);
125 b_coo.add(idx, idx, case.bs()[idx] / base);
126 }
127 }
128
129 Ok(YbusParts {
130 g: g_coo.finish_csr(),
131 b: b_coo.finish_csr(),
132 })
133}
134
135/// The four entries of a branch's 2×2 nodal admittance block, in per-unit:
136/// `[Yff, Yft, Ytf, Ytt]` (= `[y_ii, y_ij, y_ji, y_jj]` in `makeYbus` notation).
137/// A pure function of the branch — no bus indexing, no shunt fold — so the Y_bus
138/// assembly and the gridfm branch table compute the same numbers from one place.
139/// `flags` lets the Y_bus builder zero taps/shifts/charging; pass
140/// [`YbusFlags::default`] for the physical admittances (taps and shifts on).
141///
142/// Returns `Ok(None)` for a zero-impedance branch — one whose impedance
143/// magnitude is under [`MIN_DIVISIBLE_MAGNITUDE`](super::MIN_DIVISIBLE_MAGNITUDE) —
144/// which the callers skip (Y_bus) or zero out (gridfm). `row` only labels the
145/// error.
146///
147/// # Errors
148/// [`powerio_tx::Error::NonFiniteSusceptance`] when `r`/`x` are NaN/Inf, so a bad value can't
149/// slip a NaN into Y_bus or a Parquet column. [`powerio_tx::Error::DegenerateTap`] when the
150/// tap ratio is one the four admittances cannot be divided by.
151#[allow(clippy::many_single_char_names)]
152pub(crate) fn branch_admittance(
153 br: &crate::network::Branch,
154 flags: YbusFlags,
155 shift_rad: f64,
156 row: usize,
157) -> Result<Option<[Complex64; 4]>> {
158 let r = if flags.zero_resistance { 0.0 } else { br.r };
159 let x = br.x;
160 // Zero impedance in every sense the builder can act on; exact zero used to
161 // be the whole test. The guard and the division are shared with the AC
162 // path, so the two cannot drift; only the skip-vs-error policy is local.
163 let Some((g, b)) = powerio_tx::calc_series_admittance_of(r, x, row)? else {
164 if flags.skip_zero_impedance {
165 return Ok(None);
166 }
167 return Err(powerio_tx::Error::ZeroImpedance { row }.into());
168 };
169 let y_series = Complex64::new(g, b);
170
171 let charging = if flags.zero_charging {
172 crate::network::BranchCharging::new(0.0, 0.0, 0.0, 0.0)
173 } else {
174 br.calc_terminal_charging()
175 };
176 let y_fr = Complex64::new(charging.g_fr, charging.b_fr);
177 let y_to = Complex64::new(charging.g_to, charging.b_to);
178
179 // A tap of 1e-200 underflows `a_norm_sqr` to zero and scatters +/-Inf
180 // through the four admittances.
181 let tap_mag = if flags.unity_taps {
182 1.0
183 } else {
184 br.calc_divisible_tap(row)?
185 };
186 // `shift_rad` is supplied already in radians and already zeroed when
187 // `flags.zero_shifts` is set (the caller has the network, so it knows whether
188 // the source angle is degrees or — for a normalized network — radians).
189 let a = Complex64::from_polar(tap_mag, shift_rad);
190 let a_norm_sqr = tap_mag * tap_mag;
191
192 let y_ff = (y_series + y_fr) / a_norm_sqr;
193 let y_tt = y_series + y_to;
194 let y_ft = -y_series / a.conj();
195 let y_tf = -y_series / a;
196 let out = [y_ff, y_ft, y_tf, y_tt];
197 // Each input is bounded on its own above; the products can still overflow
198 // when several sit near their bound at once.
199 if out.iter().any(|y| !y.re.is_finite() || !y.im.is_finite()) {
200 return Err(powerio_tx::Error::NonFiniteSusceptance { row }.into());
201 }
202 Ok(Some(out))
203}
204
205/// Complex from/to power injections for one branch at the given bus voltages, in
206/// MVA before the per-unit → MW scaling the caller applies. `vi`/`vj` are complex
207/// bus voltages `vm·e^{jθ}` (θ in radians) and `y = [Yff, Yft, Ytf, Ytt]`:
208///
209/// ```text
210/// S_from = vi · conj(Yff·vi + Yft·vj)
211/// S_to = vj · conj(Ytf·vi + Ytt·vj)
212/// ```
213///
214/// At a converged operating point these are the line flows; powerio computes them
215/// at the case's stored voltages (the parsed snapshot), not from a fresh solve.
216#[cfg(feature = "gridfm")]
217pub(crate) fn branch_flows(
218 y: &[Complex64; 4],
219 vi: Complex64,
220 vj: Complex64,
221) -> (Complex64, Complex64) {
222 let i_from = y[0] * vi + y[1] * vj;
223 let i_to = y[2] * vi + y[3] * vj;
224 (vi * i_from.conj(), vj * i_to.conj())
225}