gridoxide_core/branch_flow.rs
1//! Branch terminal power flows and their partial derivatives.
2//!
3//! Until this module, [`network::power_injections`](crate::network::power_injections)
4//! was the only place in the crate where complex power was formed at all, and it
5//! is strictly bus-level: `S_k = V_k · (Y V)_k^*`. That answers "what does the
6//! network inject at this bus", not "what flows through this branch, measured at
7//! this end of it" — which is what a line/transformer power sensor reports, and
8//! what PGM's own `sym_output.json` records per `line` entry.
9//!
10//! Both quantities are needed by state estimation: bus-injection measurements
11//! reuse `power_injections` and the `H`/`N`/`M`/`L` derivative blocks already in
12//! [`crate::jacobian`], while branch power measurements need what is here.
13//!
14//! # Model
15//!
16//! Every branch is reduced to its four π-model admittance entries
17//! `[yff, yft, ytf, ytt]` — the same `[Complex<f64>; 4]` that
18//! [`network::branch_calc_param`](crate::network::branch_calc_param) already
19//! produces for transformers, and that [`network::build_ybus`](crate::network::build_ybus)
20//! forms inline for lines. The terminal flows are then
21//!
22//! ```text
23//! S_from = V_i · (yff·V_i + yft·V_j)^*
24//! S_to = V_j · (ytf·V_i + ytt·V_j)^*
25//! ```
26//!
27//! Expanding with `V = v·e^(jθ)`, `y = g + jb` and `θ_ij = θ_i − θ_j` gives the
28//! real form this module evaluates, for the terminal whose own bus is "near" and
29//! whose opposite bus is "far":
30//!
31//! ```text
32//! P = v_near²·g_self + v_near·v_far·( g_mut·cos θ + b_mut·sin θ)
33//! Q = −v_near²·b_self + v_near·v_far·( g_mut·sin θ − b_mut·cos θ)
34//! ```
35//!
36//! where the from-terminal reads `(g_self, b_self)` off `yff` and `(g_mut,
37//! b_mut)` off `yft`, and the to-terminal reads them off `ytt` and `ytf` with
38//! bus `j` as near and bus `i` as far. The two terminals are therefore the same
39//! expression under a relabelling, which is why [`terminal_flow`] takes a
40//! [`Terminal`] and reorders its inputs rather than implementing two
41//! near-duplicate formulas.
42
43use num_complex::Complex;
44
45use crate::network::branch_calc_param;
46use crate::types::{Bus, Line, Transformer};
47
48/// Which end of a branch a flow is measured at. Matches the first two variants
49/// of PGM's `MeasuredTerminalType` (`branch_from` = 0, `branch_to` = 1).
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub enum Terminal {
52 From,
53 To,
54}
55
56/// A branch reduced to the four π-model admittance entries plus its endpoints.
57///
58/// Deliberately independent of whether the branch started as a [`Line`] or a
59/// [`Transformer`]: once reduced, terminal flows do not care, and state
60/// estimation addresses branches by a single flat index across both.
61#[derive(Clone, Copy, Debug)]
62pub struct BranchParams {
63 pub from: usize,
64 pub to: usize,
65 /// `[yff, yft, ytf, ytt]`, the same layout `branch_calc_param` returns.
66 pub y: [Complex<f64>; 4],
67}
68
69impl BranchParams {
70 /// `(y_self, y_mutual)` as seen from `terminal`, i.e. the two entries whose
71 /// row is that terminal's own bus.
72 pub(crate) fn seen_from(&self, terminal: Terminal) -> (Complex<f64>, Complex<f64>) {
73 match terminal {
74 Terminal::From => (self.y[0], self.y[1]),
75 Terminal::To => (self.y[3], self.y[2]),
76 }
77 }
78
79 /// `(near_bus, far_bus)` for `terminal`.
80 pub(crate) fn buses(&self, terminal: Terminal) -> (usize, usize) {
81 match terminal {
82 Terminal::From => (self.from, self.to),
83 Terminal::To => (self.to, self.from),
84 }
85 }
86}
87
88/// Reduces a [`Line`] to its π-model entries.
89///
90/// Mirrors the stamping [`build_ybus`](crate::network::build_ybus) does inline:
91/// series admittance `1/(r + jx)`, total shunt split equally between the ends.
92///
93/// A `from == to` line is gridoxide's representation of a *half-open* branch —
94/// `pgm::pgm_to_buses_and_branches` collapses a line with one terminal open into
95/// a self-loop carrying only that line's shunt, discarding which end was open.
96/// Its entire flow is therefore reported at [`Terminal::From`] and zero at
97/// [`Terminal::To`]; a caller needing PGM's actual per-end convention for such a
98/// branch has to consult the original `from_status`/`to_status`, which no longer
99/// exist on `Line`.
100pub fn line_params(line: &Line) -> BranchParams {
101 let zero = Complex::new(0.0, 0.0);
102 if line.from == line.to {
103 return BranchParams {
104 from: line.from,
105 to: line.to,
106 y: [Complex::new(line.g_shunt, line.b_shunt), zero, zero, zero],
107 };
108 }
109 let y_series = Complex::new(1.0, 0.0) / Complex::new(line.r, line.x);
110 let y_shunt_half = Complex::new(line.g_shunt / 2.0, line.b_shunt / 2.0);
111 let y_diag = y_series + y_shunt_half;
112 BranchParams {
113 from: line.from,
114 to: line.to,
115 y: [y_diag, -y_series, -y_series, y_diag],
116 }
117}
118
119/// Reduces a [`Transformer`] to its π-model entries, reusing
120/// [`branch_calc_param`] verbatim — including its open-terminal rules, so a
121/// transformer with a disconnected end contributes only its shunt, exactly as
122/// it does to the Y-bus.
123pub fn transformer_params(t: &Transformer) -> BranchParams {
124 BranchParams {
125 from: t.from,
126 to: t.to,
127 y: branch_calc_param(t.y_series, t.y_shunt, t.tap, t.from_status, t.to_status),
128 }
129}
130
131/// Reduces every branch in a network to [`BranchParams`], lines first and
132/// transformers second.
133///
134/// That order is the crate's flat branch index, and it matches the order
135/// [`build_ybus`](crate::network::build_ybus) stamps them in. Note that it is
136/// *not* the order of the original PGM input: fully-open lines are dropped
137/// during conversion and virtual source branches are appended, so mapping a PGM
138/// object id to an index here requires the map built by
139/// gridoxide's `pgm::pgm_to_network`, never arithmetic on
140/// input positions.
141pub fn branch_params(lines: &[Line], transformers: &[Transformer]) -> Vec<BranchParams> {
142 lines
143 .iter()
144 .map(line_params)
145 .chain(transformers.iter().map(transformer_params))
146 .collect()
147}
148
149/// Complex bus voltages in the form the flow formulas consume.
150pub fn bus_voltages(buses: &[Bus]) -> Vec<Complex<f64>> {
151 buses
152 .iter()
153 .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
154 .collect()
155}
156
157/// Active and reactive power flowing *into* `branch` at `terminal`, in per-unit
158/// on the system base.
159///
160/// Sign convention matches PGM's `p_from`/`p_to`: positive means power entering
161/// the branch at that terminal, so a lossless branch would report
162/// `p_from = −p_to`.
163pub fn terminal_flow(branch: &BranchParams, terminal: Terminal, v: &[Complex<f64>]) -> (f64, f64) {
164 let (near, far) = branch.buses(terminal);
165 let (y_self, y_mut) = branch.seen_from(terminal);
166 let (v_near, th_near) = (v[near].norm(), v[near].arg());
167 let (v_far, th_far) = (v[far].norm(), v[far].arg());
168 let (s, c) = (th_near - th_far).sin_cos();
169
170 let p = v_near * v_near * y_self.re + v_near * v_far * (y_mut.re * c + y_mut.im * s);
171 let q = -v_near * v_near * y_self.im + v_near * v_far * (y_mut.re * s - y_mut.im * c);
172 (p, q)
173}
174
175/// Partial derivatives of one terminal's `(P, Q)` with respect to the four state
176/// variables it depends on.
177///
178/// "near" is the terminal's own bus, "far" the opposite one. Every other state
179/// variable has zero partial, which is what makes the measurement Jacobian's
180/// rows sparse: a branch power measurement touches exactly two buses.
181#[derive(Clone, Copy, Debug, Default, PartialEq)]
182pub struct FlowDerivs {
183 pub dp_dtheta_near: f64,
184 pub dp_dtheta_far: f64,
185 pub dp_dv_near: f64,
186 pub dp_dv_far: f64,
187 pub dq_dtheta_near: f64,
188 pub dq_dtheta_far: f64,
189 pub dq_dv_near: f64,
190 pub dq_dv_far: f64,
191}
192
193/// Analytic derivatives of [`terminal_flow`].
194///
195/// The angle partials are exactly antisymmetric (`∂/∂θ_far = −∂/∂θ_near`) since
196/// both flows depend on the angle difference alone; they are still returned as
197/// separate fields so callers assembling a Jacobian row do not have to remember
198/// to negate.
199///
200/// `tests::derivs_match_finite_differences` checks all eight against central
201/// differences on an asymmetric branch, which is the real guard here — these
202/// formulas are easy to write plausibly and wrongly.
203pub fn terminal_flow_derivs(
204 branch: &BranchParams,
205 terminal: Terminal,
206 v: &[Complex<f64>],
207) -> FlowDerivs {
208 let (near, far) = branch.buses(terminal);
209 let (y_self, y_mut) = branch.seen_from(terminal);
210 let (v_near, th_near) = (v[near].norm(), v[near].arg());
211 let (v_far, th_far) = (v[far].norm(), v[far].arg());
212 let (s, c) = (th_near - th_far).sin_cos();
213
214 // The two recurring combinations: `cos_term` appears in P's mutual part and
215 // in ∂Q/∂θ, `sin_term` in Q's mutual part and in −∂P/∂θ.
216 let cos_term = y_mut.re * c + y_mut.im * s;
217 let sin_term = y_mut.re * s - y_mut.im * c;
218
219 FlowDerivs {
220 dp_dtheta_near: -v_near * v_far * sin_term,
221 dp_dtheta_far: v_near * v_far * sin_term,
222 dp_dv_near: 2.0 * v_near * y_self.re + v_far * cos_term,
223 dp_dv_far: v_near * cos_term,
224 dq_dtheta_near: v_near * v_far * cos_term,
225 dq_dtheta_far: -v_near * v_far * cos_term,
226 dq_dv_near: -2.0 * v_near * y_self.im + v_far * sin_term,
227 dq_dv_far: v_near * sin_term,
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn test_line() -> Line {
236 Line {
237 from: 0,
238 to: 1,
239 r: 0.05,
240 x: 0.2,
241 b_shunt: 0.04,
242 g_shunt: 0.01,
243 }
244 }
245
246 /// Two buses at different magnitudes *and* angles, so no derivative term can
247 /// vanish by accident.
248 fn test_voltages() -> Vec<Complex<f64>> {
249 vec![
250 Complex::from_polar(1.03, 0.07),
251 Complex::from_polar(0.97, -0.05),
252 ]
253 }
254
255 /// The flows a branch reports must be the same power the Y-bus attributes to
256 /// that branch — for a two-bus network with a single branch, the sum of the
257 /// terminal flows across all branches at a bus is that bus's injection.
258 #[test]
259 fn terminal_flows_sum_to_bus_injection() {
260 let line = test_line();
261 let p = line_params(&line);
262 let v = test_voltages();
263
264 let (p_from, q_from) = terminal_flow(&p, Terminal::From, &v);
265 let (p_to, q_to) = terminal_flow(&p, Terminal::To, &v);
266
267 // S_bus0 = V_0 · (Y V)_0^*, built from this branch's entries alone.
268 let i0 = p.y[0] * v[0] + p.y[1] * v[1];
269 let s0 = v[0] * i0.conj();
270 let i1 = p.y[2] * v[0] + p.y[3] * v[1];
271 let s1 = v[1] * i1.conj();
272
273 assert!((p_from - s0.re).abs() < 1e-12, "p_from vs injection");
274 assert!((q_from - s0.im).abs() < 1e-12, "q_from vs injection");
275 assert!((p_to - s1.re).abs() < 1e-12, "p_to vs injection");
276 assert!((q_to - s1.im).abs() < 1e-12, "q_to vs injection");
277 }
278
279 /// A lossless, shunt-free branch conserves power: whatever enters one end
280 /// leaves the other.
281 #[test]
282 fn lossless_branch_conserves_active_power() {
283 let line = Line {
284 from: 0,
285 to: 1,
286 r: 0.0,
287 x: 0.2,
288 b_shunt: 0.0,
289 g_shunt: 0.0,
290 };
291 let p = line_params(&line);
292 let v = test_voltages();
293 let (p_from, _) = terminal_flow(&p, Terminal::From, &v);
294 let (p_to, _) = terminal_flow(&p, Terminal::To, &v);
295 assert!((p_from + p_to).abs() < 1e-12, "p_from={p_from} p_to={p_to}");
296 }
297
298 /// All eight partials against central differences. The step is 1e-6 and the
299 /// tolerance 1e-6, which leaves ~4 orders of margin over the ~1e-10
300 /// truncation error of a central difference at this scale.
301 #[test]
302 fn derivs_match_finite_differences() {
303 let branch = line_params(&test_line());
304 let base = test_voltages();
305
306 for terminal in [Terminal::From, Terminal::To] {
307 let d = terminal_flow_derivs(&branch, terminal, &base);
308 let (near, far) = branch.buses(terminal);
309 let h = 1e-6;
310
311 // Perturb one polar component of one bus and re-evaluate.
312 let bump = |bus: usize, d_mag: f64, d_ang: f64| {
313 let mut v = base.clone();
314 v[bus] = Complex::from_polar(base[bus].norm() + d_mag, base[bus].arg() + d_ang);
315 terminal_flow(&branch, terminal, &v)
316 };
317 let central = |bus: usize, mag: bool| {
318 let (pp, qp) = if mag {
319 bump(bus, h, 0.0)
320 } else {
321 bump(bus, 0.0, h)
322 };
323 let (pm, qm) = if mag {
324 bump(bus, -h, 0.0)
325 } else {
326 bump(bus, 0.0, -h)
327 };
328 ((pp - pm) / (2.0 * h), (qp - qm) / (2.0 * h))
329 };
330
331 let (dp_dth_near, dq_dth_near) = central(near, false);
332 let (dp_dth_far, dq_dth_far) = central(far, false);
333 let (dp_dv_near, dq_dv_near) = central(near, true);
334 let (dp_dv_far, dq_dv_far) = central(far, true);
335
336 let close = |a: f64, b: f64, what: &str| {
337 assert!(
338 (a - b).abs() < 1e-6,
339 "{terminal:?} {what}: analytic {a}, numeric {b}"
340 );
341 };
342 close(d.dp_dtheta_near, dp_dth_near, "dP/dtheta_near");
343 close(d.dp_dtheta_far, dp_dth_far, "dP/dtheta_far");
344 close(d.dp_dv_near, dp_dv_near, "dP/dv_near");
345 close(d.dp_dv_far, dp_dv_far, "dP/dv_far");
346 close(d.dq_dtheta_near, dq_dth_near, "dQ/dtheta_near");
347 close(d.dq_dtheta_far, dq_dth_far, "dQ/dtheta_far");
348 close(d.dq_dv_near, dq_dv_near, "dQ/dv_near");
349 close(d.dq_dv_far, dq_dv_far, "dQ/dv_far");
350 }
351 }
352
353 /// A self-loop line is a half-open branch: shunt-only, all of it on the
354 /// from-terminal.
355 #[test]
356 fn self_loop_line_is_shunt_only() {
357 let line = Line {
358 from: 2,
359 to: 2,
360 r: 0.0,
361 x: 0.0,
362 b_shunt: 0.03,
363 g_shunt: 0.0,
364 };
365 let p = line_params(&line);
366 assert_eq!(p.y[1], Complex::new(0.0, 0.0));
367 assert_eq!(p.y[3], Complex::new(0.0, 0.0));
368
369 let v = vec![Complex::new(1.0, 0.0); 3];
370 let (p_from, q_from) = terminal_flow(&p, Terminal::From, &v);
371 assert!(p_from.abs() < 1e-12);
372 // S = V·(y·V)^* with y = j0.03 gives Q = −0.03 at |V| = 1.
373 assert!((q_from + 0.03).abs() < 1e-12, "q_from={q_from}");
374 assert_eq!(terminal_flow(&p, Terminal::To, &v), (0.0, 0.0));
375 }
376
377 /// Transformer branches go through `branch_calc_param`, so an off-nominal
378 /// tap must show up as an asymmetry between the two terminals that a plain
379 /// line does not have.
380 #[test]
381 fn transformer_tap_breaks_terminal_symmetry() {
382 let t = Transformer {
383 from: 0,
384 to: 1,
385 from_status: 1,
386 to_status: 1,
387 y_series: Complex::new(1.0, 0.0) / Complex::new(0.01, 0.1),
388 y_shunt: Complex::new(0.0, 0.0),
389 tap: Complex::new(1.05, 0.0),
390 };
391 let p = transformer_params(&t);
392 assert!(
393 (p.y[0] - p.y[3]).norm() > 1e-9,
394 "tap should make yff != ytt"
395 );
396
397 let v = test_voltages();
398 let (p_from, _) = terminal_flow(&p, Terminal::From, &v);
399 let (p_to, _) = terminal_flow(&p, Terminal::To, &v);
400 // Still a passive element: losses are positive, so the flows cannot be
401 // equal and opposite.
402 assert!(p_from + p_to > 0.0, "losses should be positive");
403 }
404}