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}