Skip to main content

gridoxide_core/se/
functional.rs

1//! Every quantity the estimator measures, as one shape.
2//!
3//! A power measurement — a bus injection, a branch terminal flow, a source's
4//! output, a shunt's draw, power-grid-model's node injection — is always the
5//! same thing written five ways: a *current*, linear in the complex bus
6//! voltages, evaluated against the voltage at one bus.
7//!
8//! ```text
9//! I = Σ c_k·V_k        S = V_at · conj(I)
10//! ```
11//!
12//! `c` and `at` are all that distinguish the five. A bus injection's `c` is its
13//! Y-bus row; a branch terminal's is that branch's own half-row; a shunt's is a
14//! single negated admittance. Writing them once means the measurement model,
15//! its Jacobian and the iterative-linear method's row builder stop being three
16//! independent statements of the same physics that can disagree — a
17//! disagreement that used to surface only as `the_two_methods_agree` failing,
18//! with nothing to say which side was wrong.
19//!
20//! It is also what keeps three-phase tractable. A three-phase branch terminal
21//! flow depends on six phasors rather than two, which under a fixed-arity
22//! `FlowDerivs` means twenty-four named fields and a second finite-difference
23//! test; here it is a longer coefficient list and the same loop.
24
25use num_complex::Complex;
26
27use crate::branch_flow::Terminal;
28use crate::measurement::Target;
29
30use super::SeNetwork;
31
32/// The imaginary unit, spelled once so the derivative expressions below read as
33/// their formulas.
34const J: Complex<f64> = Complex::new(0.0, 1.0);
35
36/// A terminal current as a sparse linear functional of the complex bus
37/// voltages, plus the bus whose voltage turns it into a power.
38#[derive(Clone, Debug, PartialEq)]
39pub struct CurrentFunctional {
40    /// The bus whose voltage forms the power `S = V_at · conj(I)`.
41    pub at: usize,
42    /// `I = Σ c_k·V_k`, as `(bus, c)`.
43    ///
44    /// Duplicate buses are allowed and sum, which is what makes gridoxide's
45    /// half-open branch — represented as a self-loop, so `from == to` — come
46    /// out right, and what lets a node injection concatenate its Y-bus row with
47    /// its source and shunt terms instead of merging them.
48    pub coefficients: Vec<(usize, Complex<f64>)>,
49}
50
51/// One bus's contribution to a quantity's derivative, as the pair of partials
52/// with respect to that bus's polar state.
53///
54/// Complex because the quantity is: for a power, `re` is `∂P` and `im` is `∂Q`.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct Partial {
57    pub bus: usize,
58    pub d_dtheta: Complex<f64>,
59    pub d_dvmag: Complex<f64>,
60}
61
62impl CurrentFunctional {
63    /// `I = Σ c_k·V_k`.
64    pub fn current(&self, v: &[Complex<f64>]) -> Complex<f64> {
65        self.coefficients.iter().map(|&(k, c)| c * v[k]).sum()
66    }
67
68    /// `S = V_at · conj(I)`, i.e. `(P, Q)` as one complex number.
69    pub fn power(&self, v: &[Complex<f64>]) -> Complex<f64> {
70        v[self.at] * self.current(v).conj()
71    }
72
73    /// Partials of `S` with respect to every bus it depends on, appended to
74    /// `out` in coefficient order.
75    ///
76    /// ```text
77    /// ∂S/∂θ_k    = −j·V_at·conj(c_k·V_k)      + [k = at]·j·S
78    /// ∂S/∂|V_k|  =    V_at·conj(c_k·e^{jθ_k}) + [k = at]·S/|V_at|
79    /// ```
80    ///
81    /// Every coefficient produces an entry, including one whose value is
82    /// numerically zero. `H`'s sparsity pattern has to depend on the topology
83    /// alone and not on the state — dropping a zero would shrink the row at a
84    /// flat start, where many `sin(θ_i − θ_k)` vanish exactly, and grow it again
85    /// next iteration, invalidating the cached symbolic factorization.
86    ///
87    /// An *empty* functional produces nothing at all rather than a lone `at`
88    /// entry. Its quantity is identically zero with no dependence on anything,
89    /// and a row of structural zeros would defeat
90    /// [`mask_untouched`](super::jacobian::mask_untouched), which is what pins a
91    /// genuinely disconnected bus.
92    pub fn power_partials(&self, v: &[Complex<f64>], out: &mut Vec<Partial>) {
93        power_partials_of(self.at, &self.coefficients, v, out)
94    }
95
96    /// `I_local = S/|V_at|` — see [`local_current_partials_of`].
97    pub fn local_current(&self, v: &[Complex<f64>]) -> Complex<f64> {
98        local_current_of(self.at, &self.coefficients, v)
99    }
100
101    pub fn current_partials(&self, v: &[Complex<f64>], out: &mut Vec<Partial>) {
102        current_partials_of(&self.coefficients, v, out)
103    }
104
105    pub fn local_current_partials(&self, v: &[Complex<f64>], out: &mut Vec<Partial>) {
106        local_current_partials_of(self.at, &self.coefficients, v, out)
107    }
108}
109
110/// [`CurrentFunctional::power_partials`] against borrowed parts.
111///
112/// Callers that already hold a coefficient slice — `se::constraints` hands over
113/// a Y-bus row directly — use this rather than building a functional to own a
114/// copy of it, which would be an allocation per constraint row per iteration.
115pub fn power_partials_of(
116    at: usize,
117    coefficients: &[(usize, Complex<f64>)],
118    v: &[Complex<f64>],
119    out: &mut Vec<Partial>,
120) {
121    {
122        if coefficients.is_empty() {
123            return;
124        }
125        let v_at = v[at];
126        let s = v_at
127            * coefficients
128                .iter()
129                .map(|&(k, c)| c * v[k])
130                .sum::<Complex<f64>>()
131                .conj();
132        let inv_mag = if v_at.norm() > 0.0 {
133            v_at.norm().recip()
134        } else {
135            0.0
136        };
137        let mut saw_at = false;
138
139        for &(k, c) in coefficients {
140            let unit_k = unit(v[k]);
141            let mut d_dtheta = -J * v_at * (c * v[k]).conj();
142            let mut d_dvmag = v_at * (c * unit_k).conj();
143            if k == at {
144                saw_at = true;
145                d_dtheta += J * s;
146                d_dvmag += s * inv_mag;
147            }
148            out.push(Partial {
149                bus: k,
150                d_dtheta,
151                d_dvmag,
152            });
153        }
154
155        // `at` is one of the coefficients for every target gridoxide builds, so
156        // this does not fire today. It is here because the identity `S = V_at ·
157        // conj(I)` does not require it, and a functional that ever separates the
158        // two would otherwise silently lose the leading factor's derivative.
159        if !saw_at {
160            out.push(Partial {
161                bus: at,
162                d_dtheta: J * s,
163                d_dvmag: s * inv_mag,
164            });
165        }
166    }
167}
168
169/// Partials of `I` itself, rather than of the power it forms.
170///
171/// A *global-angle* current sensor measures `I` directly, and `I` is linear in
172/// the voltages, so these are simply the coefficients rotated:
173///
174/// ```text
175/// ∂I/∂θ_k   = j·c_k·V_k
176/// ∂I/∂|V_k| = c_k·e^{jθ_k}
177/// ```
178pub fn current_partials_of(
179    coefficients: &[(usize, Complex<f64>)],
180    v: &[Complex<f64>],
181    out: &mut Vec<Partial>,
182) {
183    for &(k, c) in coefficients {
184        let unit_k = unit(v[k]);
185        out.push(Partial {
186            bus: k,
187            d_dtheta: J * c * v[k],
188            d_dvmag: c * unit_k,
189        });
190    }
191}
192
193/// Partials of `S/|V_at|`, the *local-angle* current frame.
194///
195/// A local-angle sensor measures the phase shift between voltage and current
196/// rather than an absolute angle, so what it reads is
197/// `I_local = conj(I)·e^{jθ_at}`. Substituting `conj(I) = S/V_at` collapses that
198/// to `S/|V_at|` — the same power the estimator already differentiates, divided
199/// by a magnitude. power-grid-model reaches the identical place from the other
200/// direction, computing its local-angle Jacobian as the power form
201/// post-multiplied by `abs_u_chi_inv`.
202///
203/// Only the leading magnitude carries the extra term, since `|V_at|` depends on
204/// no angle at all:
205///
206/// ```text
207/// ∂/∂θ_k   = (∂S/∂θ_k)/|V_at|
208/// ∂/∂|V_k| = (∂S/∂|V_k|)/|V_at| − [k = at]·S/|V_at|²
209/// ```
210pub fn local_current_partials_of(
211    at: usize,
212    coefficients: &[(usize, Complex<f64>)],
213    v: &[Complex<f64>],
214    out: &mut Vec<Partial>,
215) {
216    let start = out.len();
217    power_partials_of(at, coefficients, v, out);
218    if out.len() == start {
219        return;
220    }
221    let mag = v[at].norm();
222    if mag <= 0.0 {
223        return;
224    }
225    let s = local_current_of(at, coefficients, v) * mag;
226    for p in out[start..].iter_mut() {
227        p.d_dtheta /= mag;
228        p.d_dvmag /= mag;
229        if p.bus == at {
230            p.d_dvmag -= s / (mag * mag);
231        }
232    }
233}
234
235/// `S/|V_at|`, the quantity a local-angle current sensor reads.
236pub fn local_current_of(
237    at: usize,
238    coefficients: &[(usize, Complex<f64>)],
239    v: &[Complex<f64>],
240) -> Complex<f64> {
241    let mag = v[at].norm();
242    if mag <= 0.0 {
243        return Complex::new(0.0, 0.0);
244    }
245    let i: Complex<f64> = coefficients.iter().map(|&(k, c)| c * v[k]).sum();
246    v[at] * i.conj() / mag
247}
248
249/// A unit phasor in `z`'s direction, or `1` where `z` has no direction.
250fn unit(z: Complex<f64>) -> Complex<f64> {
251    if z.norm() > 0.0 {
252        z / z.norm()
253    } else {
254        Complex::new(1.0, 0.0)
255    }
256}
257
258impl SeNetwork {
259    /// The current functional a measurement target names, or `None` if the
260    /// target does not resolve in this network.
261    ///
262    /// This is the single description of what each target measures. Everything
263    /// downstream — `h(x)`, the Jacobian, the iterative-linear row builder —
264    /// reads it rather than restating it.
265    pub fn functional(&self, target: Target) -> Option<CurrentFunctional> {
266        Some(match target {
267            // A bus injection is its own Y-bus row: `I_i = Σ_k Y_ik·V_k`.
268            Target::Bus(bus) => CurrentFunctional {
269                at: bus,
270                coefficients: self.ybus.row(bus).to_vec(),
271            },
272            // A current sensor names the same terminal as a power sensor and so
273            // resolves to the same functional. What differs is which quantity of
274            // it the estimator reads — `current` or `local_current` rather than
275            // `power` — not the coefficients.
276            Target::BranchTerminal { branch, terminal }
277            | Target::BranchTerminalCurrent {
278                branch, terminal, ..
279            } => self.terminals.get(branch)?[terminal as usize].clone(),
280            // A source's injection into its node is the negation of the current
281            // flowing from that node into the synthesized branch, and negating
282            // the coefficients negates the power with it.
283            Target::SourceInjection { branch } => {
284                let f = &self.terminals.get(branch)?[Terminal::To as usize];
285                CurrentFunctional {
286                    at: f.at,
287                    coefficients: f.coefficients.iter().map(|&(k, c)| (k, -c)).collect(),
288                }
289            }
290            // A shunt consumes `S = |V|²·conj(y_sh)`; as an injection that is
291            // negated, which is the `-y_sh` here.
292            Target::ShuntInjection { bus } => CurrentFunctional {
293                at: bus,
294                coefficients: vec![(bus, -self.shunt_y[bus])],
295            },
296            // power-grid-model's node injection is the sum over *every*
297            // appliance at the bus. gridoxide's bus injection covers loads and
298            // generators; sources and shunts are structural and are added back.
299            // All three share `at`, so concatenating their coefficients adds
300            // their powers.
301            Target::NodeInjection(bus) => {
302                let mut coefficients = self.ybus.row(bus).to_vec();
303                for &branch in &self.source_branches[bus] {
304                    let f = &self.terminals[branch][Terminal::To as usize];
305                    debug_assert_eq!(
306                        f.at, bus,
307                        "a source branch must feed the bus it is listed on"
308                    );
309                    coefficients.extend(f.coefficients.iter().map(|&(k, c)| (k, -c)));
310                }
311                coefficients.push((bus, -self.shunt_y[bus]));
312                CurrentFunctional {
313                    at: bus,
314                    coefficients,
315                }
316            }
317        })
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::branch_flow::{terminal_flow, terminal_flow_derivs};
325
326    fn state(buses: &[crate::types::Bus]) -> Vec<Complex<f64>> {
327        buses
328            .iter()
329            .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
330            .collect()
331    }
332
333    /// The functional form reproduces `branch_flow::terminal_flow` exactly.
334    ///
335    /// This is the identity the whole module rests on: a terminal flow *is*
336    /// `V_near · conj(y_self·V_near + y_mut·V_far)`, written out longhand in
337    /// `branch_flow` and as two coefficients here.
338    #[test]
339    fn a_branch_terminal_functional_matches_terminal_flow() {
340        let (net, mut buses) = super::super::tests::two_bus_net();
341        buses[0].voltage_mag = 1.04;
342        buses[0].voltage_ang = 0.11;
343        buses[1].voltage_mag = 0.97;
344        buses[1].voltage_ang = -0.07;
345        let v = state(&buses);
346
347        for terminal in [Terminal::From, Terminal::To] {
348            let f = net
349                .functional(Target::BranchTerminal {
350                    branch: 0,
351                    terminal,
352                })
353                .expect("branch 0 exists");
354            let s = f.power(&v);
355            let (p, q) = terminal_flow(&super::super::tests::two_bus_params(), terminal, &v);
356            assert!((s.re - p).abs() < 1e-12, "{terminal:?}: P {} vs {p}", s.re);
357            assert!((s.im - q).abs() < 1e-12, "{terminal:?}: Q {} vs {q}", s.im);
358        }
359    }
360
361    /// And its partials reproduce `terminal_flow_derivs`' eight fields.
362    ///
363    /// `branch_flow`'s closed forms stay as the public branch-flow API and are
364    /// finite-difference tested in their own module, so agreeing with them is a
365    /// check against an independent derivation rather than against a restatement
366    /// of this one.
367    #[test]
368    fn branch_terminal_partials_match_the_closed_form() {
369        let (net, mut buses) = super::super::tests::two_bus_net();
370        buses[0].voltage_mag = 1.04;
371        buses[0].voltage_ang = 0.11;
372        buses[1].voltage_mag = 0.97;
373        buses[1].voltage_ang = -0.07;
374        let v = state(&buses);
375
376        for terminal in [Terminal::From, Terminal::To] {
377            let f = net
378                .functional(Target::BranchTerminal {
379                    branch: 0,
380                    terminal,
381                })
382                .expect("branch 0 exists");
383            let mut partials = Vec::new();
384            f.power_partials(&v, &mut partials);
385
386            let params = super::super::tests::two_bus_params();
387            let d = terminal_flow_derivs(&params, terminal, &v);
388            let (near, _far) = params.buses(terminal);
389            let want = |bus: usize| -> (f64, f64, f64, f64) {
390                if bus == near {
391                    (
392                        d.dp_dtheta_near,
393                        d.dq_dtheta_near,
394                        d.dp_dv_near,
395                        d.dq_dv_near,
396                    )
397                } else {
398                    (d.dp_dtheta_far, d.dq_dtheta_far, d.dp_dv_far, d.dq_dv_far)
399                }
400            };
401            assert_eq!(partials.len(), 2, "a two-bus branch touches two buses");
402            for p in &partials {
403                let (dp_dth, dq_dth, dp_dv, dq_dv) = want(p.bus);
404                assert!((p.d_dtheta.re - dp_dth).abs() < 1e-10, "dP/dθ at {}", p.bus);
405                assert!((p.d_dtheta.im - dq_dth).abs() < 1e-10, "dQ/dθ at {}", p.bus);
406                assert!((p.d_dvmag.re - dp_dv).abs() < 1e-10, "dP/dV at {}", p.bus);
407                assert!((p.d_dvmag.im - dq_dv).abs() < 1e-10, "dQ/dV at {}", p.bus);
408            }
409        }
410    }
411
412    /// Every functional's partials against central differences of its own
413    /// `power`, which is the guard that does not depend on any other module
414    /// being right.
415    #[test]
416    fn partials_match_finite_differences_for_every_target() {
417        let (net, mut buses) = super::super::tests::two_bus_net();
418        // A state where nothing vanishes by symmetry.
419        buses[0].voltage_mag = 1.03;
420        buses[0].voltage_ang = 0.13;
421        buses[1].voltage_mag = 0.96;
422        buses[1].voltage_ang = -0.09;
423
424        let targets = [
425            Target::Bus(1),
426            Target::NodeInjection(1),
427            Target::ShuntInjection { bus: 1 },
428            Target::BranchTerminal {
429                branch: 0,
430                terminal: Terminal::From,
431            },
432            Target::BranchTerminal {
433                branch: 0,
434                terminal: Terminal::To,
435            },
436            Target::SourceInjection { branch: 0 },
437        ];
438
439        const H: f64 = 1e-6;
440        for target in targets {
441            let f = net.functional(target).expect("target resolves");
442            let mut raw = Vec::new();
443            f.power_partials(&state(&buses), &mut raw);
444
445            // Duplicate buses sum, and they genuinely occur: a node injection
446            // lists its Y-bus row *and* its source branch's terms, which land on
447            // the same two buses and largely cancel. Comparing an individual
448            // entry against a finite difference would be comparing half a
449            // derivative.
450            let mut partials: Vec<Partial> = Vec::new();
451            for e in &raw {
452                match partials.iter_mut().find(|p| p.bus == e.bus) {
453                    Some(p) => {
454                        p.d_dtheta += e.d_dtheta;
455                        p.d_dvmag += e.d_dvmag;
456                    }
457                    None => partials.push(*e),
458                }
459            }
460            if matches!(target, Target::NodeInjection(_)) {
461                assert!(
462                    raw.len() > partials.len(),
463                    "the duplicate-summing path should be exercised"
464                );
465            }
466
467            for p in &partials {
468                let mut shifted = buses.clone();
469                shifted[p.bus].voltage_ang += H;
470                let up = f.power(&state(&shifted));
471                shifted[p.bus].voltage_ang -= 2.0 * H;
472                let down = f.power(&state(&shifted));
473                let fd = (up - down) / (2.0 * H);
474                assert!(
475                    (fd - p.d_dtheta).norm() < 1e-5,
476                    "{target:?} d/dθ at bus {}: analytic {} vs finite difference {fd}",
477                    p.bus,
478                    p.d_dtheta
479                );
480
481                let mut shifted = buses.clone();
482                shifted[p.bus].voltage_mag += H;
483                let up = f.power(&state(&shifted));
484                shifted[p.bus].voltage_mag -= 2.0 * H;
485                let down = f.power(&state(&shifted));
486                let fd = (up - down) / (2.0 * H);
487                assert!(
488                    (fd - p.d_dvmag).norm() < 1e-5,
489                    "{target:?} d/d|V| at bus {}: analytic {} vs finite difference {fd}",
490                    p.bus,
491                    p.d_dvmag
492                );
493            }
494        }
495    }
496
497    /// The three identities the local-angle frame rests on.
498    ///
499    /// A local-angle sensor reads `conj(I)·e^{jθ_at}`; that equals `S/|V_at|`
500    /// once `conj(I) = S/V_at` is substituted, which is what lets the estimator
501    /// reuse the power path for it instead of deriving a third quantity.
502    #[test]
503    fn local_current_is_the_power_over_the_magnitude() {
504        let (net, mut buses) = super::super::tests::two_bus_net();
505        buses[0].voltage_mag = 1.041;
506        buses[0].voltage_ang = 0.137;
507        buses[1].voltage_mag = 0.958;
508        buses[1].voltage_ang = -0.061;
509        let v = state(&buses);
510
511        let f = net
512            .functional(Target::BranchTerminal {
513                branch: 0,
514                terminal: Terminal::From,
515            })
516            .expect("branch 0");
517        let local = f.local_current(&v);
518        let s = f.power(&v);
519        let i = f.current(&v);
520        let v_at = v[f.at];
521
522        assert!((local - s / v_at.norm()).norm() < 1e-12, "S/|V| identity");
523        assert!(
524            (local - i.conj() * Complex::from_polar(1.0, v_at.arg())).norm() < 1e-12,
525            "conj(I)·e^{{jθ}} identity"
526        );
527        // And it is genuinely a different quantity from the global current, so
528        // a test confusing the two would not pass by coincidence.
529        assert!(
530            (local - i).norm() > 1e-3,
531            "the two frames should differ here"
532        );
533    }
534
535    /// Both current forms' partials against central differences of their own
536    /// values.
537    #[test]
538    fn current_partials_match_finite_differences() {
539        let (net, mut buses) = super::super::tests::two_bus_net();
540        buses[0].voltage_mag = 1.031;
541        buses[0].voltage_ang = 0.121;
542        buses[1].voltage_mag = 0.967;
543        buses[1].voltage_ang = -0.074;
544
545        let f = net
546            .functional(Target::BranchTerminal {
547                branch: 0,
548                terminal: Terminal::To,
549            })
550            .expect("branch 0");
551
552        const H: f64 = 1e-6;
553        for (name, value, partials) in [
554            (
555                "global",
556                Box::new(|v: &[Complex<f64>]| f.current(v))
557                    as Box<dyn Fn(&[Complex<f64>]) -> Complex<f64>>,
558                {
559                    let mut o = Vec::new();
560                    f.current_partials(&state(&buses), &mut o);
561                    o
562                },
563            ),
564            (
565                "local",
566                Box::new(|v: &[Complex<f64>]| f.local_current(v)),
567                {
568                    let mut o = Vec::new();
569                    f.local_current_partials(&state(&buses), &mut o);
570                    o
571                },
572            ),
573        ] {
574            // Sum duplicates before comparing, for the same reason the power
575            // check does.
576            let mut summed: Vec<Partial> = Vec::new();
577            for e in &partials {
578                match summed.iter_mut().find(|p| p.bus == e.bus) {
579                    Some(p) => {
580                        p.d_dtheta += e.d_dtheta;
581                        p.d_dvmag += e.d_dvmag;
582                    }
583                    None => summed.push(*e),
584                }
585            }
586            for p in &summed {
587                let mut shifted = buses.clone();
588                shifted[p.bus].voltage_ang += H;
589                let up = value(&state(&shifted));
590                shifted[p.bus].voltage_ang -= 2.0 * H;
591                let fd = (up - value(&state(&shifted))) / (2.0 * H);
592                assert!((fd - p.d_dtheta).norm() < 1e-5, "{name} d/dθ at {}", p.bus);
593
594                let mut shifted = buses.clone();
595                shifted[p.bus].voltage_mag += H;
596                let up = value(&state(&shifted));
597                shifted[p.bus].voltage_mag -= 2.0 * H;
598                let fd = (up - value(&state(&shifted))) / (2.0 * H);
599                assert!((fd - p.d_dvmag).norm() < 1e-5, "{name} d/d|V| at {}", p.bus);
600            }
601        }
602    }
603
604    /// A functional with no coefficients yields no partials at all, so an
605    /// isolated bus's column stays untouched and `mask_untouched` can pin it.
606    #[test]
607    fn an_empty_functional_produces_no_partials() {
608        let f = CurrentFunctional {
609            at: 0,
610            coefficients: Vec::new(),
611        };
612        let v = vec![Complex::new(1.0, 0.0)];
613        assert_eq!(f.power(&v), Complex::new(0.0, 0.0));
614        let mut partials = Vec::new();
615        f.power_partials(&v, &mut partials);
616        assert!(partials.is_empty());
617    }
618}