Skip to main content

gridoxide_core/se/
observability.rs

1//! Observability analysis: which parts of the state the measurements actually
2//! determine.
3//!
4//! An unobservable system is not a numerical accident, and treating it as one
5//! produces the worst diagnostic in the library: `SeStatus::Singular`, which
6//! says a factorization failed and nothing about why. The useful answer names
7//! the unknowns nobody is watching.
8//!
9//! # Two kinds of unobservable
10//!
11//! **Structural.** A column of `H` that is identically zero: no measurement
12//! function mentions that unknown at all. [`super::nr::estimate`] already finds
13//! these and pins them, because it has to — they would make the gain matrix
14//! singular on their own. In gridoxide they are usually the virtual slack buses
15//! synthesized per source.
16//!
17//! **Numerical.** A column that is present but linearly dependent on the
18//! others. Two branch-flow measurements on a radial feeder with no injection
19//! measurement between them constrain the same combination of angles twice and
20//! leave another combination free. Nothing about the sparsity pattern gives this
21//! away; it takes a rank computation.
22//!
23//! # Why pivoted Cholesky
24//!
25//! `G = HᵀWH` is symmetric positive *semi*definite by construction, and for
26//! that class the standard rank-revealing factorization is a Cholesky with
27//! symmetric diagonal pivoting: pivots are taken largest-first, and the
28//! factorization stops when the largest remaining diagonal falls below a
29//! tolerance. The number of steps taken is the rank, and the unknowns left in
30//! the trailing block are the ones the measurements do not pin down.
31//!
32//! The factorization itself is `faer`'s
33//! (`linalg::cholesky::llt_pivoting`) — blocked and vectorized, rather than the
34//! naive triple loop this module originally carried.
35//!
36//! The rank *decision*, however, is made here rather than taken from faer's
37//! `PivLltInfo::rank`. faer stops at `eps · n · max_diag`, i.e. around 1e-16
38//! relative: the threshold for "numerically not positive definite".
39//! Observability wants a much looser one. A grid whose measurements determine a
40//! direction only barely — a weak coupling that survives at 1e-12 of the
41//! leading pivot — is unobservable for every practical purpose, and reporting
42//! it as observable would be a worse answer than reporting it as not. So the
43//! factor's diagonal is thresholded here at [`RANK_TOLERANCE`], relative to the
44//! largest pivot, and faer's own rank is an upper bound on the result.
45//!
46//! The alternative — reading zero pivots out of the LU that
47//! [`crate::solver::LinearSolver`] already computes — was the first choice,
48//! but none of the five backends expose their pivots (`faer`'s
49//! `ColPivQr` has no rank accessor, and `klu_native::factor` returns only
50//! `Option<Numeric>`). Widening the trait would force all five to implement
51//! something only this module wants.
52//!
53//! The cost is that this densifies `G`, so it is `O(n³)` time and `O(n²)`
54//! memory. That is fine for an analysis pass on the grids this is useful for and
55//! wrong for a large one, so [`analyze`] refuses above [`DENSE_LIMIT`] rather
56//! than quietly allocating gigabytes. A sparse rank-revealing method is the
57//! follow-up.
58
59use faer::dyn_stack::{MemBuffer, MemStack};
60use faer::linalg::cholesky::llt_pivoting::factor::{cholesky_in_place, cholesky_in_place_scratch};
61use faer::{Mat, Par};
62
63use crate::measurement::Measurement;
64use crate::types::Bus;
65
66use super::SeNetwork;
67use super::jacobian::{StateLayout, measurement_jacobian};
68
69/// Largest state dimension [`analyze`] will densify, chosen so the gain matrix
70/// stays under ~32 MB (`2000² × 8` bytes).
71pub const DENSE_LIMIT: usize = 2000;
72
73/// Relative pivot threshold below which a column counts as dependent.
74///
75/// Applied against the largest pivot, which makes the check a statement about
76/// the gain matrix's *conditioning* as much as its rank — and on a network with
77/// a very stiff source those are not the same question. gridoxide expands a
78/// source into an impedance branch whose admittance follows `sk`, so a fixture
79/// with `sk = 1e10` carries branch admittances of order 1e7; squared into the
80/// gain matrix and weighted, the pivots span some fourteen decades, and a
81/// voltage row's own contribution of order 1e4 falls below this threshold
82/// relative to them. The report then names most of the network unobservable
83/// when nothing of the sort is true.
84///
85/// Not a reason to loosen the threshold, which would blind it to genuine
86/// dependence. It is a reason to read a report on such a network with the
87/// conditioning in mind, and to answer a question about *symmetries* — which is
88/// usually what "is this observable" means — by asking the measurement functions
89/// directly, as `tests/se_three_phase_test.rs` does.
90pub const RANK_TOLERANCE: f64 = 1e-10;
91
92/// Which of a bus's two unknowns an [`Unknown`] refers to.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum Quantity {
95    Angle,
96    Magnitude,
97}
98
99/// One state variable, named in terms of the grid rather than of the solver.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub struct Unknown {
102    pub bus: usize,
103    pub quantity: Quantity,
104}
105
106/// What the measurements do and do not determine.
107#[derive(Clone, Debug)]
108pub struct ObservabilityReport {
109    pub n_unknowns: usize,
110    /// Rank of the gain matrix. Equal to `n_unknowns` exactly when the state is
111    /// fully observable.
112    pub rank: usize,
113    /// Unknowns no measurement function mentions at all.
114    pub structurally_unmeasured: Vec<Unknown>,
115    /// Unknowns left undetermined once linear dependence is accounted for.
116    ///
117    /// A superset of [`structurally_unmeasured`](Self::structurally_unmeasured)
118    /// in effect — a zero column is also rank-deficient — though the specific
119    /// variables reported can differ, since which member of a dependent group
120    /// gets blamed depends on the pivot order. The *count* is what is
121    /// meaningful; the names are representatives.
122    pub unobservable: Vec<Unknown>,
123    /// Set when the system was too large to analyze densely, in which case only
124    /// the structural half of the report is filled in.
125    pub skipped_numerical: bool,
126    /// Unknowns belonging to buses that carry no voltage at all.
127    ///
128    /// These are not a gap in the sensor plan and no sensor would close them.
129    /// A de-energized bus is reported at exactly zero by every estimator path
130    /// here — `nr::estimate` writes it, `iterative::estimate` skips its rows,
131    /// `linear_start` starts it there — so its columns are determined by the
132    /// network rather than by measurements, and they are zero columns of `H` by
133    /// construction. Counting them against the rank would report every CGMES
134    /// model containing one switched-out node as unobservable, which is the
135    /// same mistake, in the same function, that ignoring constraint rows was.
136    ///
137    /// They are excluded from
138    /// [`structurally_unmeasured`](Self::structurally_unmeasured),
139    /// [`unobservable`](Self::unobservable) and
140    /// [`is_observable`](Self::is_observable), and reported here instead.
141    pub de_energized: Vec<Unknown>,
142    /// A global-angle current sensor is present with nothing to measure its
143    /// angle against.
144    ///
145    /// Such a sensor reports `I = i·e^{jθ}` with `θ` absolute, which is only
146    /// meaningful if something else fixes what that reference *is*. With no
147    /// voltage angle in the set, [`StateLayout`] pins a reference bus instead —
148    /// and that pin is then a false constraint, rotating the estimate away from
149    /// the angle the sensor actually measured. The rank check will not catch it,
150    /// because the system is perfectly determined; it is determined to the wrong
151    /// thing.
152    ///
153    /// power-grid-model refuses to run at all here, raising `NotObservableError`
154    /// with "Global angle current sensors require at least one voltage angle
155    /// measurement as a reference point". gridoxide reports rather than refuses,
156    /// on the grounds that this analysis is the place that says what is wrong
157    /// and the estimator's job is to answer the question it was asked.
158    pub global_current_without_angle_reference: bool,
159}
160
161impl ObservabilityReport {
162    /// Whether every state variable is determined.
163    ///
164    /// Deliberately does *not* fold in
165    /// [`global_current_without_angle_reference`](Self::global_current_without_angle_reference):
166    /// that state is fully determined, just to the wrong reference, and calling
167    /// it unobservable would misname it.
168    pub fn is_observable(&self) -> bool {
169        self.rank + self.de_energized.len() == self.n_unknowns
170            && self.structurally_unmeasured.is_empty()
171    }
172}
173
174/// Names the bus and quantity a state-vector column refers to.
175fn describe(layout: &StateLayout, col: usize) -> Unknown {
176    let magnitude_start = layout.vmag(0);
177    if col >= magnitude_start {
178        Unknown {
179            bus: col - magnitude_start,
180            quantity: Quantity::Magnitude,
181        }
182    } else {
183        let bus = (0..layout.n_buses)
184            .find(|&b| layout.theta(b) == Some(col))
185            .expect("angle column belongs to some bus");
186        Unknown {
187            bus,
188            quantity: Quantity::Angle,
189        }
190    }
191}
192
193/// Rank of a symmetric positive semidefinite matrix, and the pivot order.
194///
195/// Returns `(rank, permutation)`: the first `rank` entries of the permutation
196/// are the columns that were eliminated with a pivot above threshold, and the
197/// tail is the deficient block — the unknowns the measurements do not
198/// determine.
199///
200/// The factorization is faer's blocked pivoted `LLᵀ`; the rank decision is this
201/// module's, for the reason given in the module comment. `None` means the
202/// factorization itself failed (a NaN pivot), which for a gain matrix means the
203/// inputs were already corrupt rather than merely rank-deficient.
204fn pivoted_cholesky_rank(mut a: Mat<f64>, tol: f64) -> Option<(usize, Vec<usize>)> {
205    let n = a.nrows();
206    if n == 0 {
207        return Some((0, Vec::new()));
208    }
209
210    let mut perm = vec![0usize; n];
211    let mut perm_inv = vec![0usize; n];
212    // `MemBuffer` rather than a hand-rolled byte vector: faer's scratch is
213    // allocated as `f64`, and a plain `Vec<u8>` is not guaranteed to be aligned
214    // for that, so the stack silently loses bytes to alignment and then runs
215    // out.
216    let mut buffer = MemBuffer::new(cholesky_in_place_scratch::<usize, f64>(
217        n,
218        Par::Seq,
219        Default::default(),
220    ));
221    let stack = MemStack::new(&mut buffer);
222
223    let (info, _) = cholesky_in_place(
224        a.as_mut(),
225        &mut perm,
226        &mut perm_inv,
227        Par::Seq,
228        stack,
229        Default::default(),
230    )
231    .ok()?;
232
233    // faer stopped either at full rank or at its own eps-level threshold; apply
234    // the looser observability threshold to the pivots it did compute. The
235    // diagonal holds L's entries, so a pivot is the square of its diagonal.
236    let pivots: Vec<f64> = (0..info.rank).map(|k| a[(k, k)] * a[(k, k)]).collect();
237    let largest = pivots.iter().copied().fold(0.0f64, f64::max);
238    let rank = if largest <= 0.0 {
239        0
240    } else {
241        pivots.iter().take_while(|&&p| p > tol * largest).count()
242    };
243
244    Some((rank, perm))
245}
246
247/// Analyzes what `measurements` determine about the state at `buses`.
248///
249/// The analysis is linearization-dependent: `H` is evaluated at the state it is
250/// given, so a flat start and a converged estimate can in principle disagree.
251/// In practice observability is a structural property and they do not, but the
252/// honest reading of a report is "at this operating point".
253pub fn analyze(
254    measurements: &[Measurement],
255    buses: &[Bus],
256    net: &SeNetwork,
257    layout: &StateLayout,
258    constraints: &crate::se::constraints::Constraints,
259) -> ObservabilityReport {
260    let n = layout.n_unknowns();
261
262    // Measurements *and* constraints determine state, and until now only the
263    // former were counted — so a bus whose voltage is fixed by a zero-injection
264    // constraint was reported unobservable even though the estimator solves it
265    // exactly. `se::constraints::augment` has always put these rows into the
266    // system; this analysis simply did not know about them.
267    //
268    // A constraint is a hard equality, i.e. a measurement of unbounded weight.
269    // Rank does not depend on the magnitude — `rank(HᵀWH) = rank(H)` for any
270    // positive `W` — so unit weight is the right choice here, and avoids
271    // putting a huge number into a matrix that is about to be factorized.
272    let mut rows = measurement_jacobian(measurements, buses, net, layout);
273    let mut weights: Vec<f64> = measurements.iter().map(|m| m.weight()).collect();
274    let (_, constraint_rows) = constraints.evaluate(buses, net, layout);
275    let n_constraints = constraint_rows.len();
276    rows.extend(constraint_rows);
277    weights.extend(std::iter::repeat_n(1.0, n_constraints));
278
279    // A global-angle current sensor supplies an absolute phase; a voltage angle
280    // is what gives that phase a meaning. `StateLayout` pins a reference bus
281    // when no voltage angle exists, which would silently contradict the sensor.
282    let has_global_current = measurements.iter().any(|m| {
283        matches!(
284            m.target,
285            crate::measurement::Target::BranchTerminalCurrent {
286                frame: crate::measurement::AngleFrame::Global,
287                ..
288            }
289        ) && m.weight() > 0.0
290    });
291    let has_voltage_angle = measurements
292        .iter()
293        .any(|m| m.kind == crate::measurement::MeasurementKind::VoltageAngle && m.weight() > 0.0);
294    let global_current_without_angle_reference = has_global_current && !has_voltage_angle;
295
296    // Structural half: a column no row mentions with a nonzero coefficient.
297    // Note this asks for a nonzero *value*, unlike the estimator's mask, which
298    // deliberately asks only for structural presence so its sparsity pattern
299    // stays fixed. Here there is no pattern to preserve and the stronger
300    // question is the useful one.
301    let mut touched = vec![false; n];
302    for row in &rows {
303        for &(c, v) in row {
304            if v != 0.0 {
305                touched[c] = true;
306            }
307        }
308    }
309    let live = |u: &Unknown| net.energized.get(u.bus).copied().unwrap_or(true);
310    let de_energized: Vec<Unknown> = (0..n)
311        .map(|c| describe(layout, c))
312        .filter(|u| !live(u))
313        .collect();
314    let untouched = (0..n).filter(|&c| !touched[c]).count();
315    let structurally_unmeasured: Vec<Unknown> = (0..n)
316        .filter(|&c| !touched[c])
317        .map(|c| describe(layout, c))
318        .filter(live)
319        .collect();
320
321    if n > DENSE_LIMIT {
322        return ObservabilityReport {
323            n_unknowns: n,
324            rank: n - untouched,
325            structurally_unmeasured,
326            unobservable: Vec::new(),
327            de_energized,
328            skipped_numerical: true,
329            global_current_without_angle_reference,
330        };
331    }
332
333    let mut g = Mat::<f64>::zeros(n, n);
334    for (row, &w) in rows.iter().zip(&weights) {
335        if !w.is_finite() || w == 0.0 {
336            continue;
337        }
338        for &(i, hi) in row {
339            for &(j, hj) in row {
340                g[(i, j)] += w * hi * hj;
341            }
342        }
343    }
344
345    let Some((rank, perm)) = pivoted_cholesky_rank(g, RANK_TOLERANCE) else {
346        // A gain matrix that cannot be factorized at all is not a statement
347        // about observability; report nothing determined rather than guess.
348        return ObservabilityReport {
349            n_unknowns: n,
350            rank: 0,
351            structurally_unmeasured,
352            unobservable: (0..n).map(|c| describe(layout, c)).filter(live).collect(),
353            de_energized,
354            skipped_numerical: false,
355            global_current_without_angle_reference,
356        };
357    };
358    let unobservable = perm[rank..]
359        .iter()
360        .map(|&c| describe(layout, c))
361        .filter(live)
362        .collect();
363
364    ObservabilityReport {
365        n_unknowns: n,
366        rank,
367        structurally_unmeasured,
368        unobservable,
369        de_energized,
370        skipped_numerical: false,
371        global_current_without_angle_reference,
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::branch_flow::Terminal;
379    use crate::measurement::{MeasurementKind, Target};
380    use crate::se::constraints::Constraints;
381
382    fn m(kind: MeasurementKind, target: Target) -> Measurement {
383        Measurement {
384            kind,
385            target,
386            value: 0.0,
387            sigma: 0.01,
388        }
389    }
390
391    /// Builds a dense matrix from rows, for the kernel tests.
392    fn mat(rows: &[&[f64]]) -> Mat<f64> {
393        let n = rows.len();
394        let mut m = Mat::<f64>::zeros(n, n);
395        for (i, row) in rows.iter().enumerate() {
396            for (j, &v) in row.iter().enumerate() {
397                m[(i, j)] = v;
398            }
399        }
400        m
401    }
402
403    #[test]
404    fn identity_is_full_rank() {
405        let (rank, _) = pivoted_cholesky_rank(mat(&[&[1.0, 0.0], &[0.0, 1.0]]), RANK_TOLERANCE)
406            .expect("factorizable");
407        assert_eq!(rank, 2);
408    }
409
410    /// A rank-1 matrix built as `v vᵀ` must be detected as rank 1, and the
411    /// pivot order must put the dependent column last.
412    #[test]
413    fn rank_one_outer_product_is_rank_one() {
414        let v = [1.0, 2.0, -1.0];
415        let rows: Vec<Vec<f64>> = (0..3)
416            .map(|i| (0..3).map(|j| v[i] * v[j]).collect())
417            .collect();
418        let refs: Vec<&[f64]> = rows.iter().map(|r| r.as_slice()).collect();
419        let (rank, _) = pivoted_cholesky_rank(mat(&refs), RANK_TOLERANCE).expect("factorizable");
420        assert_eq!(rank, 1, "v v^T has rank 1");
421    }
422
423    /// The tolerance is relative, so a uniformly tiny matrix is still full rank
424    /// — otherwise a grid measured entirely by high-sigma sensors would be
425    /// reported unobservable purely because its weights are small.
426    #[test]
427    fn rank_detection_is_scale_free() {
428        let (rank, _) = pivoted_cholesky_rank(mat(&[&[1e-12, 0.0], &[0.0, 1e-12]]), RANK_TOLERANCE)
429            .expect("factorizable");
430        assert_eq!(rank, 2);
431    }
432
433    /// Enough measurements to determine the whole state: full rank, nothing
434    /// unobservable.
435    #[test]
436    fn well_measured_system_is_observable() {
437        let (net, buses) = crate::se::tests::two_bus_net();
438        let measurements = vec![
439            m(MeasurementKind::VoltageMagnitude, Target::Bus(0)),
440            m(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
441            m(MeasurementKind::ActivePower, Target::Bus(1)),
442            m(MeasurementKind::ReactivePower, Target::Bus(1)),
443            m(
444                MeasurementKind::ActivePower,
445                Target::BranchTerminal {
446                    branch: 0,
447                    terminal: Terminal::From,
448                },
449            ),
450        ];
451        let layout = StateLayout::new(&buses, &measurements, &net);
452        let report = analyze(
453            &measurements,
454            &buses,
455            &net,
456            &layout,
457            &Constraints::from_flags(&[]),
458        );
459        assert!(
460            report.is_observable(),
461            "expected observable, got {report:?}"
462        );
463        assert_eq!(report.rank, report.n_unknowns);
464    }
465
466    /// A zero-injection constraint determines state just as a measurement does,
467    /// and the report has to say so.
468    ///
469    /// This was wrong until `analyze` took the constraints: `se::constraints`
470    /// has always put these rows into the system the estimator actually solves,
471    /// so a state they determine *is* estimated — but the observability report
472    /// looked only at measurements and called it unobservable, sending a user
473    /// hunting for a sensor they do not need. Node-breaker models make it acute,
474    /// since a merged group's internal structure is entirely
475    /// constraint-determined, but it was already wrong on ordinary data.
476    #[test]
477    fn a_zero_injection_constraint_makes_state_observable() {
478        let (net, buses) = crate::se::tests::two_bus_net();
479        // Bus 1's own injection is not measured; only its magnitude is.
480        let measurements = vec![
481            m(MeasurementKind::VoltageMagnitude, Target::Bus(0)),
482            m(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
483        ];
484        let layout = StateLayout::new(&buses, &measurements, &net);
485
486        let without = analyze(
487            &measurements,
488            &buses,
489            &net,
490            &layout,
491            &Constraints::from_flags(&[]),
492        );
493        assert!(
494            !without.is_observable(),
495            "the fixture must be under-determined without help"
496        );
497
498        // Declaring bus 1 a zero-injection node supplies the missing rows.
499        let mut flags = vec![false; buses.len()];
500        flags[1] = true;
501        let with = analyze(
502            &measurements,
503            &buses,
504            &net,
505            &layout,
506            &Constraints::from_flags(&flags),
507        );
508        assert!(
509            with.rank > without.rank,
510            "a zero-injection constraint determined nothing: rank {} vs {}",
511            with.rank,
512            without.rank
513        );
514    }
515
516    /// Constraints must not be able to *reduce* what is reported as
517    /// determined — adding rows to a rank computation can only raise it.
518    #[test]
519    fn constraints_never_lower_the_reported_rank() {
520        let (net, buses) = crate::se::tests::two_bus_net();
521        let measurements = vec![
522            m(MeasurementKind::VoltageMagnitude, Target::Bus(0)),
523            m(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
524            m(MeasurementKind::ActivePower, Target::Bus(1)),
525            m(MeasurementKind::ReactivePower, Target::Bus(1)),
526        ];
527        let layout = StateLayout::new(&buses, &measurements, &net);
528        let bare = analyze(
529            &measurements,
530            &buses,
531            &net,
532            &layout,
533            &Constraints::from_flags(&[]),
534        );
535
536        let mut flags = vec![false; buses.len()];
537        flags[1] = true;
538        let constrained = analyze(
539            &measurements,
540            &buses,
541            &net,
542            &layout,
543            &Constraints::from_flags(&flags),
544        );
545        assert!(constrained.rank >= bare.rank);
546        assert!(constrained.structurally_unmeasured.len() <= bare.structurally_unmeasured.len());
547    }
548
549    /// One voltage magnitude cannot determine three unknowns, and the report
550    /// should say which — this is the diagnosis that replaces a bare
551    /// `Singular`.
552    #[test]
553    fn under_measured_system_names_what_is_missing() {
554        let (net, buses) = crate::se::tests::two_bus_net();
555        let measurements = vec![m(MeasurementKind::VoltageMagnitude, Target::Bus(0))];
556        let layout = StateLayout::new(&buses, &measurements, &net);
557        let report = analyze(
558            &measurements,
559            &buses,
560            &net,
561            &layout,
562            &Constraints::from_flags(&[]),
563        );
564
565        assert!(!report.is_observable());
566        assert_eq!(report.rank, 1, "only the measured magnitude is determined");
567        assert_eq!(report.unobservable.len(), report.n_unknowns - 1);
568        // The measured bus's magnitude is the one thing that *is* observable.
569        assert!(
570            !report.unobservable.contains(&Unknown {
571                bus: 0,
572                quantity: Quantity::Magnitude
573            }),
574            "the measured quantity must not be reported unobservable: {report:?}"
575        );
576    }
577
578    /// The case structural detection cannot see: every column is touched, and
579    /// the state is still not determined.
580    ///
581    /// Two measurements at one branch terminal reach all three unknowns of this
582    /// network — the near bus's angle and both magnitudes — so nothing is
583    /// structurally missing and the estimator's own mask would report a clean
584    /// bill. But two numbers cannot fix three unknowns, and only the rank
585    /// computation says so. This is the capability the module exists for, on a
586    /// network rather than on a hand-built matrix.
587    #[test]
588    fn structurally_complete_but_rank_deficient_is_caught() {
589        let (net, buses) = crate::se::tests::two_bus_net();
590        let measurements = vec![
591            m(
592                MeasurementKind::ActivePower,
593                Target::BranchTerminal {
594                    branch: 0,
595                    terminal: Terminal::From,
596                },
597            ),
598            m(
599                MeasurementKind::ReactivePower,
600                Target::BranchTerminal {
601                    branch: 0,
602                    terminal: Terminal::From,
603                },
604            ),
605        ];
606        let layout = StateLayout::new(&buses, &measurements, &net);
607        let report = analyze(
608            &measurements,
609            &buses,
610            &net,
611            &layout,
612            &Constraints::from_flags(&[]),
613        );
614
615        assert_eq!(report.n_unknowns, 3);
616        assert!(
617            report.structurally_unmeasured.is_empty(),
618            "every unknown is reached by a measurement, so structure alone sees no problem: {report:?}"
619        );
620        assert_eq!(
621            report.rank, 2,
622            "two measurements cannot determine three unknowns"
623        );
624        assert!(!report.is_observable());
625        assert_eq!(report.unobservable.len(), 1);
626    }
627
628    /// Redundant sensors add confidence, not rank.
629    ///
630    /// A third measurement duplicating the first leaves the rank where it was —
631    /// the naive check of "enough measurements for the unknowns" would pass here
632    /// and be wrong.
633    #[test]
634    fn a_duplicate_measurement_adds_no_rank() {
635        let (net, buses) = crate::se::tests::two_bus_net();
636        let flow = m(
637            MeasurementKind::ActivePower,
638            Target::BranchTerminal {
639                branch: 0,
640                terminal: Terminal::From,
641            },
642        );
643        let measurements = vec![
644            flow,
645            m(
646                MeasurementKind::ReactivePower,
647                Target::BranchTerminal {
648                    branch: 0,
649                    terminal: Terminal::From,
650                },
651            ),
652            // Same quantity, same terminal, independently reported.
653            flow,
654        ];
655        let layout = StateLayout::new(&buses, &measurements, &net);
656        let report = analyze(
657            &measurements,
658            &buses,
659            &net,
660            &layout,
661            &Constraints::from_flags(&[]),
662        );
663
664        assert_eq!(
665            measurements.len(),
666            report.n_unknowns,
667            "as many rows as unknowns"
668        );
669        assert_eq!(report.rank, 2, "but only two of them are independent");
670        assert!(!report.is_observable(), "{report:?}");
671    }
672
673    /// Measuring only powers leaves the global phase free, which is exactly the
674    /// invariance `StateLayout` pins a reference for. With the reference pinned
675    /// the remaining angles are determined; this checks the pinning actually
676    /// buys observability rather than merely making the matrix square.
677    #[test]
678    fn pinned_reference_makes_angles_observable() {
679        let (net, buses) = crate::se::tests::two_bus_net();
680        let measurements = vec![
681            m(MeasurementKind::VoltageMagnitude, Target::Bus(0)),
682            m(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
683            m(
684                MeasurementKind::ActivePower,
685                Target::BranchTerminal {
686                    branch: 0,
687                    terminal: Terminal::From,
688                },
689            ),
690            m(
691                MeasurementKind::ReactivePower,
692                Target::BranchTerminal {
693                    branch: 0,
694                    terminal: Terminal::From,
695                },
696            ),
697        ];
698        let layout = StateLayout::new(&buses, &measurements, &net);
699        assert!(
700            layout.angle_ref.is_some(),
701            "no angle measured, so one is pinned"
702        );
703        let report = analyze(
704            &measurements,
705            &buses,
706            &net,
707            &layout,
708            &Constraints::from_flags(&[]),
709        );
710        assert!(report.is_observable(), "{report:?}");
711    }
712}