Skip to main content

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}