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(¶ms, 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}