gridoxide_core/se/jacobian.rs
1//! The measurement Jacobian `H` and the gain matrix `G = HᵀWH`.
2//!
3//! # State layout
4//!
5//! State estimation's unknowns are not power flow's. There are no PV buses
6//! here — a generator's voltage is something to be estimated, not asserted — so
7//! *every* bus contributes a magnitude, and every bus but the angle reference
8//! contributes an angle:
9//!
10//! ```text
11//! x = [ θ₀ … θ_{N-1} except the reference , V₀ … V_{N-1} ]
12//! ```
13//!
14//! giving `n = 2N − 1`, against power flow's `n_angle + n_pq`
15//! (`solver::newton_raphson_cached`). Confusing the two layouts is the easiest
16//! way to produce a Jacobian that is subtly, consistently wrong, so
17//! [`StateLayout`] owns the mapping and nothing else indexes by hand.
18//!
19//! # Why the normal equations
20//!
21//! `H` is rectangular (`m × n`), and gridoxide's
22//! [`LinearSolver`](crate::solver::LinearSolver) backends are
23//! all square — they are power flow's Jacobian solvers. Forming the normal
24//! equations `G Δx = HᵀW r` makes the system square *and* symmetric, so all
25//! five backends carry state estimation with no new solver abstraction. The
26//! cost is conditioning: `G` squares `H`'s condition number, which is why an
27//! orthogonal method stays an open option and why `se::constraints` prefers
28//! equality constraints over huge weights.
29
30use crate::measurement::{AngleFrame, Measurement, MeasurementKind, Target};
31use crate::se::functional::{
32 Partial, current_partials_of, local_current_partials_of, power_partials_of,
33};
34use crate::types::Bus;
35
36use super::SeNetwork;
37
38/// Maps buses to positions in the state vector.
39pub struct StateLayout {
40 pub n_buses: usize,
41 /// The bus whose angle is held at zero, if one is pinned.
42 ///
43 /// A network measured only in magnitudes and powers is invariant under a
44 /// global phase shift, so without a reference `G` is singular by
45 /// construction no matter how many measurements there are. But that
46 /// invariance disappears the moment a *phasor* measurement supplies an
47 /// absolute angle — and pinning a reference on top of one would then be a
48 /// false constraint, rotating the whole estimate away from the angles that
49 /// were actually measured. So this is `None` exactly when the measurement
50 /// set determines the phase by itself.
51 pub angle_ref: Option<usize>,
52 /// Position of each bus's angle unknown, or `None` for the reference.
53 theta_pos: Vec<Option<usize>>,
54 /// Where the magnitude block starts, i.e. the number of angle unknowns.
55 vmag_offset: usize,
56}
57
58impl StateLayout {
59 /// Builds the layout for a measurement set.
60 ///
61 /// A reference is pinned only when the measurements contain no voltage
62 /// angle; see [`angle_ref`](Self::angle_ref).
63 ///
64 /// When one is pinned it is the *physical node a source feeds*, not the
65 /// virtual slack bus behind it. power-grid-model normalizes by setting its
66 /// slack bus's angle to zero, and its slack bus is that physical node,
67 /// since it has no virtual bus at all. Pinning the virtual bus instead
68 /// leaves every angle offset by the drop across the source impedance — a
69 /// constant, invisible in magnitudes, and exactly the 6.5e-3 rad uniform
70 /// shift the transmission-case fixture showed before this was fixed. Stiff
71 /// sources hide it, which is why the smaller fixtures agreed either way.
72 pub fn new(buses: &[Bus], measurements: &[Measurement], net: &SeNetwork) -> Self {
73 let phase_is_measured = measurements
74 .iter()
75 .any(|m| m.kind == MeasurementKind::VoltageAngle && m.weight() > 0.0);
76
77 let angle_ref = (!phase_is_measured).then(|| net.reference_bus(buses));
78
79 let mut theta_pos = vec![None; buses.len()];
80 let mut next = 0;
81 for i in 0..buses.len() {
82 if Some(i) != angle_ref {
83 theta_pos[i] = Some(next);
84 next += 1;
85 }
86 }
87 Self {
88 n_buses: buses.len(),
89 angle_ref,
90 theta_pos,
91 vmag_offset: next,
92 }
93 }
94
95 /// Number of unknowns: `2N` when the phase is measured, `2N − 1` when a
96 /// reference has to be pinned.
97 pub fn n_unknowns(&self) -> usize {
98 self.vmag_offset + self.n_buses
99 }
100
101 /// Column of a bus's angle, or `None` for the reference bus.
102 pub fn theta(&self, bus: usize) -> Option<usize> {
103 self.theta_pos[bus]
104 }
105
106 /// Column of a bus's voltage magnitude.
107 pub fn vmag(&self, bus: usize) -> usize {
108 self.vmag_offset + bus
109 }
110}
111
112/// One row of `H`: the nonzero partials of a measurement, as `(column, value)`.
113///
114/// Rows are short — a voltage measurement touches one column, a branch flow
115/// four, a bus injection two per adjacent bus — which is what keeps `G` sparse.
116pub type Row = Vec<(usize, f64)>;
117
118/// Accumulates a partial into a row.
119///
120/// Only the reference bus's angle is dropped, because it is not an unknown at
121/// all. A numerically zero partial is *kept*: `H`'s pattern has to depend on the
122/// topology alone, not on the current state. Dropping zeros would shrink the row
123/// at a flat start — where many `sin(θ_i − θ_k)` terms vanish exactly — and grow
124/// it again on the next iteration, changing the gain matrix's sparsity pattern
125/// between iterations and invalidating the cached symbolic factorization
126/// (`sparse::RealSparseSystem` documents that its argsort assumes positional
127/// correspondence, and faer asserts on the mismatch).
128fn push(row: &mut Row, col: Option<usize>, value: f64) {
129 if let Some(col) = col {
130 row.push((col, value));
131 }
132}
133
134/// Partials of the bus injections `P_i`, `Q_i` with respect to a neighbour's
135/// angle and magnitude — the classic power-flow Jacobian expressions.
136///
137/// Superseded by [`CurrentFunctional::power_partials`], which derives the same
138/// numbers for every target rather than for injections alone. Kept as a
139/// test-only oracle: it is an independent derivation, so agreeing with it is
140/// evidence beyond a finite-difference check of the new code against itself.
141#[cfg(test)]
142fn injection_partials(
143 net: &SeNetwork,
144 buses: &[Bus],
145 p_inj: &[f64],
146 q_inj: &[f64],
147 i: usize,
148 k: usize,
149) -> (f64, f64, f64, f64) {
150 let y_ik = net.ybus.get(i, k);
151 let (g, b) = (y_ik.re, y_ik.im);
152 let (v_i, v_k) = (buses[i].voltage_mag, buses[k].voltage_mag);
153
154 if i == k {
155 let v2 = v_i * v_i;
156 (
157 // dP/dθ, dP/dV, dQ/dθ, dQ/dV
158 -q_inj[i] - b * v2,
159 p_inj[i] / v_i + g * v_i,
160 p_inj[i] - g * v2,
161 q_inj[i] / v_i - b * v_i,
162 )
163 } else {
164 let (s, c) = (buses[i].voltage_ang - buses[k].voltage_ang).sin_cos();
165 (
166 v_i * v_k * (g * s - b * c),
167 v_i * (g * c + b * s),
168 -v_i * v_k * (g * c + b * s),
169 v_i * (g * s - b * c),
170 )
171 }
172}
173
174/// Adds a functional's partials to `row`, in the estimator's column layout.
175///
176/// Takes `(at, coefficients)` borrowed rather than a whole
177/// [`CurrentFunctional`], so a caller already holding a coefficient slice — a
178/// zero-injection constraint hands over a Y-bus row directly — does not
179/// allocate a copy of it per row per iteration.
180fn add_functional_row(
181 row: &mut Row,
182 layout: &StateLayout,
183 at: usize,
184 coefficients: &[(usize, num_complex::Complex<f64>)],
185 v: &[num_complex::Complex<f64>],
186 active: bool,
187 scratch: &mut Vec<Partial>,
188) {
189 scratch.clear();
190 power_partials_of(at, coefficients, v, scratch);
191 push_partials(row, layout, scratch, active);
192}
193
194/// Writes partials into a row, taking the real or imaginary half of each.
195///
196/// A partial repeated on one bus is summed, not overwritten: `Row` is an
197/// association list and `gain_and_rhs` sums duplicates. That is what makes a
198/// node injection's overlapping Y-bus and source terms come out right, and a
199/// half-open branch's self-loop with it.
200fn push_partials(row: &mut Row, layout: &StateLayout, partials: &[Partial], real: bool) {
201 for p in partials {
202 let (d_th, d_v) = if real {
203 (p.d_dtheta.re, p.d_dvmag.re)
204 } else {
205 (p.d_dtheta.im, p.d_dvmag.im)
206 };
207 push(row, layout.theta(p.bus), d_th);
208 push(row, Some(layout.vmag(p.bus)), d_v);
209 }
210}
211
212/// One bus-injection row on its own, for callers that need the same partials
213/// outside the measurement set — `se::constraints` builds its constraint rows
214/// from exactly this, since a zero-injection constraint and an injection
215/// measurement differ only in how the system consumes the row.
216///
217/// `v` is the state as complex phasors, passed in because a caller building
218/// many rows should form it once.
219pub fn injection_row(
220 layout: &StateLayout,
221 net: &SeNetwork,
222 v: &[num_complex::Complex<f64>],
223 bus: usize,
224 active: bool,
225 scratch: &mut Vec<Partial>,
226) -> Row {
227 let mut row = Row::new();
228 add_functional_row(&mut row, layout, bus, net.ybus.row(bus), v, active, scratch);
229 row
230}
231
232/// Builds `H`, one row per measurement, in the same order.
233pub fn measurement_jacobian(
234 measurements: &[Measurement],
235 buses: &[Bus],
236 net: &SeNetwork,
237 layout: &StateLayout,
238) -> Vec<Row> {
239 let model = crate::se::MeasurementModel::new(measurements, net);
240 measurement_jacobian_with(measurements, buses, layout, &model)
241}
242
243/// [`measurement_jacobian`] against an already-resolved model.
244///
245/// Two arms, not five. A voltage row is a single unit entry; every power row is
246/// its functional's partials, whatever target produced it.
247pub fn measurement_jacobian_with(
248 measurements: &[Measurement],
249 buses: &[Bus],
250 layout: &StateLayout,
251 model: &crate::se::MeasurementModel,
252) -> Vec<Row> {
253 let v: Vec<num_complex::Complex<f64>> = buses
254 .iter()
255 .map(|b| num_complex::Complex::from_polar(b.voltage_mag, b.voltage_ang))
256 .collect();
257 let mut scratch = Vec::new();
258
259 measurements
260 .iter()
261 .enumerate()
262 .map(|(i, m)| {
263 let mut row = Row::new();
264 // A measurement on a de-energized bus contributes no row at all,
265 // not a zero-weighted one: an empty row leaves its columns
266 // untouched, which is what lets `mask_untouched` pin them.
267 if !model.is_live(i) {
268 return row;
269 }
270 match (m.kind, m.target) {
271 (MeasurementKind::VoltageMagnitude, Target::Bus(b)) => {
272 push(&mut row, Some(layout.vmag(b)), 1.0)
273 }
274 (MeasurementKind::VoltageAngle, Target::Bus(b)) => {
275 push(&mut row, layout.theta(b), 1.0)
276 }
277 _ => {
278 if let Some(f) = model.functional(i) {
279 scratch.clear();
280 match m.target {
281 Target::BranchTerminalCurrent { frame, .. } => match frame {
282 AngleFrame::Global => {
283 current_partials_of(&f.coefficients, &v, &mut scratch)
284 }
285 AngleFrame::Local => local_current_partials_of(
286 f.at,
287 &f.coefficients,
288 &v,
289 &mut scratch,
290 ),
291 },
292 _ => power_partials_of(f.at, &f.coefficients, &v, &mut scratch),
293 }
294 let real = matches!(
295 m.kind,
296 MeasurementKind::ActivePower | MeasurementKind::CurrentReal
297 );
298 push_partials(&mut row, layout, &scratch, real);
299 }
300 }
301 }
302 row
303 })
304 .collect()
305}
306
307/// Pins state variables that nothing reaches, and reports which.
308///
309/// A column no row touches leaves an all-zero row and column in `G`, making the
310/// whole system singular even when the rest of it is perfectly determined.
311/// Writing an identity there with a zero right-hand side holds that variable at
312/// its current value and lets the observable part solve — the same masking
313/// `bde::mask_scenario` uses for a converged block, and for the same reason:
314/// dropping the row instead would change the sparsity pattern and invalidate a
315/// cached symbolic factorization.
316///
317/// "Touched" is structural — any appearance in a row, regardless of value — so
318/// that the mask, and hence the sparsity pattern, is identical across
319/// iterations. Whether a present-but-degenerate column is *usefully* determined
320/// is a different question, and
321/// [`observability`](super::observability)'s.
322pub fn mask_untouched(
323 triplets: &mut Vec<(usize, usize, f64)>,
324 rhs: &mut [f64],
325 row_sets: &[&[Row]],
326 n: usize,
327) -> Vec<usize> {
328 let mut touched = vec![false; n];
329 for rows in row_sets {
330 for row in rows.iter() {
331 for &(c, _) in row {
332 touched[c] = true;
333 }
334 }
335 }
336 let mut untouched = Vec::new();
337 for (c, &seen) in touched.iter().enumerate() {
338 if !seen {
339 triplets.push((c, c, 1.0));
340 rhs[c] = 0.0;
341 untouched.push(c);
342 }
343 }
344 untouched
345}
346
347/// Assembles `G = HᵀWH` as triplets, and `HᵀW r` as a dense vector.
348///
349/// `G = Σ_m w_m·hₘhₘᵀ`, so it is built as a sum of per-row outer products —
350/// cheap because rows are short. Duplicate `(row, col)` triplets are summed by
351/// the sparse backends, which is also what makes a self-loop branch's repeated
352/// columns come out right.
353pub fn gain_and_rhs(
354 rows: &[Row],
355 measurements: &[Measurement],
356 residuals: &[f64],
357) -> (Vec<(usize, usize, f64)>, Vec<f64>, usize) {
358 let n = rows
359 .iter()
360 .flat_map(|r| r.iter().map(|&(c, _)| c + 1))
361 .max()
362 .unwrap_or(0);
363 let mut triplets = Vec::new();
364 let mut rhs = vec![0.0; n];
365
366 for ((row, m), &r) in rows.iter().zip(measurements).zip(residuals) {
367 // An infinite sigma contributes nothing, by design — but it has to
368 // contribute nothing *numerically*, not structurally. Skipping the row
369 // outright would make the triplet list's length and order depend on a
370 // measurement's sigma, i.e. on a value rather than on the topology and
371 // the measurement set's shape. That breaks two things that assume
372 // otherwise: `RealSparseSystem`'s cached argsort, which relies on
373 // positional correspondence between calls, and `PersistentEstimator`'s
374 // stated validity condition, which promises a weight scales `G`'s values
375 // and never its pattern. Stamping an exact zero keeps both true and is
376 // numerically identical.
377 //
378 // A non-finite weight means a zero sigma, i.e. a perfectly known
379 // measurement, which would properly be a hard constraint rather than a
380 // very heavy row. `checked_sigma` rejects it at the door, so this clamp
381 // is a guard on an unreachable case rather than a modelling decision.
382 let w = m.weight();
383 let w = if w.is_finite() { w } else { 0.0 };
384 for &(i, hi) in row {
385 rhs[i] += w * hi * r;
386 for &(j, hj) in row {
387 triplets.push((i, j, w * hi * hj));
388 }
389 }
390 }
391 (triplets, rhs, n)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::branch_flow::Terminal;
398 use crate::measurement::Target;
399 use crate::se::measurement_functions;
400
401 /// Every row of `H` against central differences of `h(x)`.
402 ///
403 /// This is the real guard on this module: the injection partials in
404 /// particular are eight sign-sensitive expressions that are easy to write
405 /// plausibly and wrongly, and a consistently wrong Jacobian still converges
406 /// — to the wrong answer, slowly, in a way that looks like a bad fixture.
407 pub(super) fn assert_jacobian_matches_finite_differences(
408 measurements: &[Measurement],
409 buses: &[Bus],
410 net: &SeNetwork,
411 tol: f64,
412 ) {
413 let layout = StateLayout::new(buses, measurements, net);
414 let rows = measurement_jacobian(measurements, buses, net, &layout);
415 let n = layout.n_unknowns();
416 let h = 1e-7;
417
418 // Dense the rows out for easy comparison, summing duplicates.
419 let mut dense = vec![vec![0.0; n]; measurements.len()];
420 for (r, row) in rows.iter().enumerate() {
421 for &(c, val) in row {
422 dense[r][c] += val;
423 }
424 }
425
426 for col in 0..n {
427 let bump = |delta: f64| {
428 let mut b = buses.to_vec();
429 // Columns below `vmag(0)` are angles, the rest magnitudes.
430 if col < layout.vmag(0) {
431 let bus = (0..buses.len())
432 .find(|&i| layout.theta(i) == Some(col))
433 .expect("angle column maps to a bus");
434 b[bus].voltage_ang += delta;
435 } else {
436 b[col - layout.vmag(0)].voltage_mag += delta;
437 }
438 measurement_functions(measurements, &b, net)
439 };
440 let plus = bump(h);
441 let minus = bump(-h);
442
443 for (r, m) in measurements.iter().enumerate() {
444 let numeric = (plus[r] - minus[r]) / (2.0 * h);
445 let analytic = dense[r][col];
446 assert!(
447 (numeric - analytic).abs() < tol,
448 "row {r} ({:?} on {:?}), column {col}: analytic {analytic}, numeric {numeric}",
449 m.kind,
450 m.target,
451 );
452 }
453 }
454 }
455
456 fn measurement(kind: MeasurementKind, target: Target) -> Measurement {
457 Measurement {
458 kind,
459 target,
460 value: 0.0,
461 sigma: 1.0,
462 }
463 }
464
465 /// The unified row reproduces the closed-form injection partials it
466 /// replaced.
467 ///
468 /// `injection_partials` is the classic power-flow Jacobian written out as
469 /// eight expressions in `p_inj`, `q_inj`, `g`, `b` and the angle difference.
470 /// `power_partials_of` derives the same numbers from `S = V_at · conj(I)`
471 /// without ever naming them. Two independent derivations agreeing to 1e-12
472 /// at an arbitrary state is stronger evidence than either one agreeing with
473 /// finite differences of itself, which is why the old form is retained
474 /// `#[cfg(test)]` rather than deleted.
475 #[test]
476 fn the_unified_row_matches_the_closed_form_it_replaced() {
477 let (net, mut buses) = super::super::tests::two_bus_net();
478 // Nothing zero by symmetry: distinct magnitudes and a real angle spread.
479 buses[0].voltage_mag = 1.037;
480 buses[0].voltage_ang = 0.147;
481 buses[1].voltage_mag = 0.962;
482 buses[1].voltage_ang = -0.083;
483
484 let (p_inj, q_inj) = crate::network::power_injections(&buses, &net.ybus);
485 let v: Vec<num_complex::Complex<f64>> = buses
486 .iter()
487 .map(|b| num_complex::Complex::from_polar(b.voltage_mag, b.voltage_ang))
488 .collect();
489
490 let mut compared = 0;
491 for bus in 0..buses.len() {
492 let mut partials = Vec::new();
493 power_partials_of(bus, net.ybus.row(bus), &v, &mut partials);
494 for p in &partials {
495 let (dp_dth, dp_dv, dq_dth, dq_dv) =
496 injection_partials(&net, &buses, &p_inj, &q_inj, bus, p.bus);
497 assert!(
498 (p.d_dtheta.re - dp_dth).abs() < 1e-12,
499 "dP/dθ at ({bus},{})",
500 p.bus
501 );
502 assert!(
503 (p.d_dvmag.re - dp_dv).abs() < 1e-12,
504 "dP/dV at ({bus},{})",
505 p.bus
506 );
507 assert!(
508 (p.d_dtheta.im - dq_dth).abs() < 1e-12,
509 "dQ/dθ at ({bus},{})",
510 p.bus
511 );
512 assert!(
513 (p.d_dvmag.im - dq_dv).abs() < 1e-12,
514 "dQ/dV at ({bus},{})",
515 p.bus
516 );
517 compared += 1;
518 }
519 }
520 assert!(
521 compared >= 4,
522 "expected several neighbours to compare, got {compared}"
523 );
524 }
525
526 /// Covers every target kind at once, on a network where no partial is zero
527 /// by symmetry.
528 #[test]
529 fn jacobian_matches_finite_differences_for_every_target_kind() {
530 let (net, buses) = super::super::tests::two_bus_net();
531 let measurements = vec![
532 measurement(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
533 measurement(MeasurementKind::VoltageAngle, Target::Bus(1)),
534 measurement(MeasurementKind::ActivePower, Target::Bus(1)),
535 measurement(MeasurementKind::ReactivePower, Target::Bus(1)),
536 measurement(
537 MeasurementKind::ActivePower,
538 Target::BranchTerminal {
539 branch: 0,
540 terminal: Terminal::From,
541 },
542 ),
543 measurement(
544 MeasurementKind::ReactivePower,
545 Target::BranchTerminal {
546 branch: 0,
547 terminal: Terminal::To,
548 },
549 ),
550 measurement(
551 MeasurementKind::ActivePower,
552 Target::SourceInjection { branch: 0 },
553 ),
554 measurement(
555 MeasurementKind::ReactivePower,
556 Target::ShuntInjection { bus: 1 },
557 ),
558 measurement(MeasurementKind::ActivePower, Target::NodeInjection(1)),
559 measurement(MeasurementKind::ReactivePower, Target::NodeInjection(1)),
560 ];
561 assert_jacobian_matches_finite_differences(&measurements, &buses, &net, 1e-5);
562 }
563
564 /// The layout is `2N − 1`, and the reference bus has no angle column.
565 #[test]
566 fn state_layout_excludes_only_the_reference_angle() {
567 let (net, buses) = super::super::tests::two_bus_net();
568 let layout = StateLayout::new(&buses, &[], &net);
569 assert_eq!(layout.n_unknowns(), 2 * buses.len() - 1);
570 let reference = layout
571 .angle_ref
572 .expect("no angle measurements, so a reference is pinned");
573 assert_eq!(layout.theta(reference), None);
574 for i in 0..buses.len() {
575 if i != reference {
576 assert!(
577 layout.theta(i).is_some(),
578 "bus {i} should carry an angle unknown"
579 );
580 }
581 }
582 }
583
584 /// A sigma going to infinity must change `G`'s *values* and not its
585 /// pattern.
586 ///
587 /// This is what `PersistentEstimator` promises ("a weight scales `G`'s
588 /// values, never its pattern") and what `RealSparseSystem`'s cached argsort
589 /// requires, since it assumes the value slice it is handed corresponds
590 /// positionally to the one the pattern was built from. `gain_and_rhs` used
591 /// to `continue` past a zero-weight row, which broke both: the triplet list
592 /// got shorter when a sensor was disabled. Batch estimation makes that live
593 /// — a scenario that disables a sensor would hand faer a mismatched slice.
594 #[test]
595 fn an_infinite_sigma_changes_values_but_not_the_gain_pattern() {
596 let (net, buses) = super::super::tests::two_bus_net();
597 let finite = vec![
598 measurement(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
599 measurement(MeasurementKind::ActivePower, Target::Bus(1)),
600 ];
601 let mut disabled = finite.clone();
602 disabled[1].sigma = f64::INFINITY;
603
604 let layout = StateLayout::new(&buses, &finite, &net);
605 let residuals = [0.1, 0.2];
606 let pattern = |ms: &[crate::measurement::Measurement]| {
607 let rows = measurement_jacobian(ms, &buses, &net, &layout);
608 let (triplets, _, _) = gain_and_rhs(&rows, ms, &residuals);
609 triplets
610 };
611
612 let a = pattern(&finite);
613 let b = pattern(&disabled);
614 assert_eq!(
615 a.iter().map(|&(i, j, _)| (i, j)).collect::<Vec<_>>(),
616 b.iter().map(|&(i, j, _)| (i, j)).collect::<Vec<_>>(),
617 "disabling a sensor must not move, add or drop a triplet position"
618 );
619 assert!(
620 a.iter().zip(&b).any(|(x, y)| x.2 != y.2),
621 "the values should differ — otherwise the sigma did nothing at all"
622 );
623 // And the disabled row really is inert, not merely present.
624 let rows = measurement_jacobian(&disabled, &buses, &net, &layout);
625 let (_, rhs, _) = gain_and_rhs(&rows, &disabled, &residuals);
626 let only_voltage = vec![finite[0]];
627 let vrows = measurement_jacobian(&only_voltage, &buses, &net, &layout);
628 let (_, vrhs, _) = gain_and_rhs(&vrows, &only_voltage, &residuals[..1]);
629 for (x, y) in rhs.iter().zip(&vrhs) {
630 assert!(
631 (x - y).abs() < 1e-15,
632 "an infinite sigma still moved the rhs: {x} vs {y}"
633 );
634 }
635 }
636
637 /// `G` must be symmetric — it is `HᵀWH` — and its diagonal non-negative.
638 #[test]
639 fn gain_matrix_is_symmetric() {
640 let (net, buses) = super::super::tests::two_bus_net();
641 let measurements = vec![
642 measurement(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
643 measurement(MeasurementKind::ActivePower, Target::Bus(1)),
644 measurement(
645 MeasurementKind::ReactivePower,
646 Target::BranchTerminal {
647 branch: 0,
648 terminal: Terminal::From,
649 },
650 ),
651 ];
652 let layout = StateLayout::new(&buses, &measurements, &net);
653 let rows = measurement_jacobian(&measurements, &buses, &net, &layout);
654 let (triplets, _, n) = gain_and_rhs(&rows, &measurements, &[0.1, 0.2, 0.3]);
655
656 let mut dense = vec![vec![0.0; n]; n];
657 for (i, j, v) in triplets {
658 dense[i][j] += v;
659 }
660 for i in 0..n {
661 assert!(
662 dense[i][i] >= -1e-12,
663 "diagonal {i} is negative: {}",
664 dense[i][i]
665 );
666 for j in 0..n {
667 assert!(
668 (dense[i][j] - dense[j][i]).abs() < 1e-9,
669 "G is not symmetric at ({i},{j}): {} vs {}",
670 dense[i][j],
671 dense[j][i]
672 );
673 }
674 }
675 }
676}