Skip to main content

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}