gridoxide_core/se/bad_data.rs
1//! Detecting and identifying measurements that disagree with the rest.
2//!
3//! A converged estimate is not a correct one. Weighted least squares will
4//! happily absorb a broken sensor by bending the state toward it, and the
5//! result looks exactly like a successful solve — the fixture
6//! `node-injection-sensor-and-zero-injection` is a worked example of how far
7//! that can go. Bad-data analysis is the step that asks whether the
8//! measurements were mutually consistent in the first place.
9//!
10//! Two questions, answered separately:
11//!
12//! 1. **Is there bad data at all?** The chi-squared test on the objective. Under
13//! the assumption that each measurement error is independent and normal with
14//! the declared sigma, `J = rᵀWr` is chi-squared distributed with `m − n`
15//! degrees of freedom. A `J` far out in the tail says the residuals are too
16//! large to be noise.
17//! 2. **Which measurement?** The largest normalized residual. Raw residuals are
18//! not comparable — a residual of 0.1 is enormous for a sigma of 0.001 and
19//! negligible for a sigma of 1 — and dividing by sigma alone is not enough
20//! either, because a redundantly measured quantity spreads its error across
21//! its neighbours. The right scale is the residual's own standard deviation,
22//! `Ω = R − H G⁻¹ Hᵀ`.
23//!
24//! # Cost, and why only the top candidates
25//!
26//! `Ωᵢᵢ` needs `hᵢ G⁻¹ hᵢᵀ`, i.e. one linear solve per measurement. Computing
27//! the whole diagonal costs `m` solves, which for a real grid is far more than
28//! the estimate itself. [`analyze`] therefore ranks candidates by the cheap
29//! proxy `|rᵢ|/σᵢ`, computes `Ωᵢᵢ` for the worst [`Candidates::limit`] of them,
30//! and reports those.
31//!
32//! That is an approximation and worth being precise about: the true largest
33//! normalized residual can in principle sit outside the shortlist, because
34//! `Ωᵢᵢ ≤ Rᵢᵢ` varies per measurement. In practice the two orderings agree
35//! closely — a measurement with a large normalized residual almost always has a
36//! large raw one — but "almost always" is not "always", and a caller that needs
37//! certainty can raise the limit to `m`.
38
39use crate::measurement::Measurement;
40use crate::solver::LinearSolver;
41use crate::types::Bus;
42use gridoxide_sparse::sparse::RealSparseSystem;
43
44use super::SeNetwork;
45use super::constraints::{Constraints, augment};
46use super::jacobian::{Row, StateLayout, gain_and_rhs, mask_untouched, measurement_jacobian};
47
48/// How many measurements to examine in detail.
49#[derive(Clone, Copy, Debug)]
50pub struct Candidates {
51 /// Maximum number of measurements to compute `Ωᵢᵢ` for.
52 pub limit: usize,
53}
54
55impl Default for Candidates {
56 fn default() -> Self {
57 Self { limit: 20 }
58 }
59}
60
61/// A measurement flagged as suspect.
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct Suspect {
64 /// Index into the measurement list the estimate was run on.
65 pub measurement: usize,
66 /// `|rᵢ| / √Ωᵢᵢ`. Conventionally compared against 3.
67 pub normalized_residual: f64,
68}
69
70/// The outcome of a bad-data analysis.
71#[derive(Clone, Debug)]
72pub struct BadDataReport {
73 /// `J = rᵀWr`, the chi-squared statistic. Twice
74 /// [`SeReport::objective`](super::nr::SeReport::objective), which carries
75 /// the conventional ½.
76 pub chi_squared: f64,
77 pub degrees_of_freedom: usize,
78 /// Probability of seeing a `J` at least this large if every measurement
79 /// were merely noisy. Small means the data is not merely noisy.
80 pub p_value: f64,
81 /// Measurements examined, worst first.
82 pub suspects: Vec<Suspect>,
83}
84
85impl BadDataReport {
86 /// Whether the chi-squared test rejects at the given significance (0.05 is
87 /// conventional).
88 ///
89 /// Rejection says *something* is wrong, not which measurement — that is
90 /// what [`suspects`](Self::suspects) is for. A test that does not reject is
91 /// also not a clean bill of health: a single moderate error, or several
92 /// that partly cancel, can sit inside the threshold.
93 pub fn rejects_at(&self, significance: f64) -> bool {
94 self.p_value < significance
95 }
96
97 /// The worst measurement, if any were examined.
98 pub fn worst(&self) -> Option<Suspect> {
99 self.suspects.first().copied()
100 }
101}
102
103/// Regularized lower incomplete gamma `P(a, x)`, by series expansion.
104///
105/// Converges quickly for `x < a + 1`; [`gamma_q`] picks between this and the
106/// continued fraction accordingly.
107fn gamma_p_series(a: f64, x: f64) -> f64 {
108 if x <= 0.0 {
109 return 0.0;
110 }
111 let mut ap = a;
112 let mut sum = 1.0 / a;
113 let mut del = sum;
114 for _ in 0..500 {
115 ap += 1.0;
116 del *= x / ap;
117 sum += del;
118 if del.abs() < sum.abs() * 1e-15 {
119 break;
120 }
121 }
122 sum * (-x + a * x.ln() - ln_gamma(a)).exp()
123}
124
125/// Regularized upper incomplete gamma `Q(a, x)`, by continued fraction
126/// (modified Lentz).
127fn gamma_q_continued_fraction(a: f64, x: f64) -> f64 {
128 const TINY: f64 = 1e-300;
129 let mut b = x + 1.0 - a;
130 let mut c = 1.0 / TINY;
131 let mut d = 1.0 / b;
132 let mut h = d;
133 for i in 1..500 {
134 let an = -(i as f64) * (i as f64 - a);
135 b += 2.0;
136 d = an * d + b;
137 if d.abs() < TINY {
138 d = TINY;
139 }
140 c = b + an / c;
141 if c.abs() < TINY {
142 c = TINY;
143 }
144 d = 1.0 / d;
145 let del = d * c;
146 h *= del;
147 if (del - 1.0).abs() < 1e-15 {
148 break;
149 }
150 }
151 (-x + a * x.ln() - ln_gamma(a)).exp() * h
152}
153
154/// Lanczos approximation to `ln Γ(x)`, accurate to ~1e-15 for `x > 0`.
155fn ln_gamma(x: f64) -> f64 {
156 const COF: [f64; 6] = [
157 76.180_091_729_471_46,
158 -86.505_320_329_416_77,
159 24.014_098_240_830_91,
160 -1.231_739_572_450_155,
161 0.120_865_097_386_617_9e-2,
162 -0.539_523_938_495_3e-5,
163 ];
164 let mut y = x;
165 let tmp = x + 5.5 - (x + 0.5) * (x + 5.5).ln();
166 let mut ser = 1.000_000_000_190_015;
167 for c in COF {
168 y += 1.0;
169 ser += c / y;
170 }
171 -tmp + (2.506_628_274_631_000_5 * ser / x).ln()
172}
173
174/// Regularized upper incomplete gamma `Q(a, x) = 1 − P(a, x)`.
175fn gamma_q(a: f64, x: f64) -> f64 {
176 if x < a + 1.0 {
177 1.0 - gamma_p_series(a, x)
178 } else {
179 gamma_q_continued_fraction(a, x)
180 }
181}
182
183/// Upper-tail probability of the chi-squared distribution: the chance of
184/// observing at least `x` with `dof` degrees of freedom.
185///
186/// Implemented here rather than pulled from a statistics crate because it is
187/// the only special function this crate needs, and the candidates bring a
188/// second linear-algebra stack alongside `faer`. `tests` checks it against
189/// published chi-squared table values.
190pub fn chi_squared_upper_tail(x: f64, dof: usize) -> f64 {
191 if dof == 0 {
192 return if x > 0.0 { 0.0 } else { 1.0 };
193 }
194 if x <= 0.0 {
195 return 1.0;
196 }
197 gamma_q(dof as f64 / 2.0, x / 2.0).clamp(0.0, 1.0)
198}
199
200/// Runs bad-data analysis at a converged state.
201///
202/// `residuals` must be the ones the estimate finished with
203/// ([`SeReport::residuals`](super::nr::SeReport::residuals)), and `buses` the
204/// state it finished at — the analysis linearizes there.
205pub fn analyze(
206 measurements: &[Measurement],
207 residuals: &[f64],
208 buses: &[Bus],
209 net: &SeNetwork,
210 layout: &StateLayout,
211 constraints: &Constraints,
212 candidates: Candidates,
213) -> BadDataReport {
214 let weighted: Vec<(usize, &Measurement, f64)> = measurements
215 .iter()
216 .zip(residuals)
217 .enumerate()
218 .filter(|(_, (m, _))| m.weight().is_finite() && m.weight() > 0.0)
219 .map(|(i, (m, &r))| (i, m, r))
220 .collect();
221
222 let chi_squared: f64 = weighted.iter().map(|(_, m, r)| m.weight() * r * r).sum();
223
224 let rows = measurement_jacobian(measurements, buses, net, layout);
225 let (c_values, c_rows) = constraints.evaluate(buses, net, layout);
226 let n = layout.n_unknowns();
227
228 let (mut triplets, mut rhs, _) = gain_and_rhs(&rows, measurements, residuals);
229 rhs.resize(n, 0.0);
230 let untouched = mask_untouched(&mut triplets, &mut rhs, &[&rows, &c_rows], n);
231 let (triplets, _) = augment(triplets, rhs, n, &c_values, &c_rows);
232 let n_aug = n + constraints.len();
233
234 // Degrees of freedom: measurements, less the state variables they had to
235 // determine. Variables that were pinned rather than estimated do not
236 // consume a degree of freedom, and each equality constraint gives one back.
237 let estimated = n.saturating_sub(untouched.len());
238 let degrees_of_freedom = weighted
239 .len()
240 .saturating_sub(estimated)
241 .saturating_add(constraints.len());
242
243 let p_value = chi_squared_upper_tail(chi_squared, degrees_of_freedom);
244
245 // Shortlist by the cheap proxy, then pay for the real thing.
246 let mut shortlist: Vec<(usize, &Measurement, f64)> = weighted.clone();
247 shortlist.sort_by(|a, b| {
248 let key = |t: &(usize, &Measurement, f64)| (t.2 / t.1.sigma).abs();
249 key(b)
250 .partial_cmp(&key(a))
251 .unwrap_or(std::cmp::Ordering::Equal)
252 });
253 shortlist.truncate(candidates.limit);
254
255 let mut system = match RealSparseSystem::new(n_aug, &triplets) {
256 Some(s) => s,
257 // Without a factorizable system there is no residual covariance to
258 // compute; the chi-squared half of the report still stands.
259 None => {
260 return BadDataReport {
261 chi_squared,
262 degrees_of_freedom,
263 p_value,
264 suspects: Vec::new(),
265 };
266 }
267 };
268
269 // The backend caches its symbolic factorization but wants the values on
270 // every call, so they are hoisted out of the candidate loop. Each candidate
271 // still costs one numeric refactorization — the documented price of the
272 // exact residual covariance.
273 let values: Vec<f64> = triplets.iter().map(|&(_, _, v)| v).collect();
274
275 let mut suspects = Vec::new();
276 for (index, m, r) in shortlist {
277 let Some(omega) = residual_variance(&mut system, &values, &rows[index], m, n_aug) else {
278 continue;
279 };
280 // A measurement whose residual has no variance is one the estimate is
281 // forced to reproduce exactly — *critical*, in the usual terminology.
282 // Its error cannot be detected at all, so reporting a normalized
283 // residual for it would be meaningless.
284 //
285 // The test has to be relative, and `omega <= 0.0` was not. `omega` is
286 // `sigma² − hᵀG⁻¹h`, a difference of two nearly equal numbers for
287 // exactly the measurements this is meant to exclude, so a critical
288 // one's true zero arrives as round-off of either sign — and a sign test
289 // lets half of them through with a variance of `1e-54`, which then
290 // divides a residual. What is scale-free is `omega/sigma²`, the
291 // measurement's **redundancy**, which lies in `[0, 1]` by construction.
292 //
293 // A hazard closed rather than a defect observed: no case in the suite
294 // distinguishes the two tests, and this was written while chasing
295 // something else that turned out to be a diverged estimate rather than
296 // a bad variance. It is kept because the absolute test is wrong about a
297 // cancelling subtraction whether or not a fixture has caught it yet.
298 if omega <= MIN_REDUNDANCY * m.sigma * m.sigma {
299 continue;
300 }
301 suspects.push(Suspect {
302 measurement: index,
303 normalized_residual: r.abs() / omega.sqrt(),
304 });
305 }
306 suspects.sort_by(|a, b| {
307 b.normalized_residual
308 .partial_cmp(&a.normalized_residual)
309 .unwrap_or(std::cmp::Ordering::Equal)
310 });
311
312 BadDataReport {
313 chi_squared,
314 degrees_of_freedom,
315 p_value,
316 suspects,
317 }
318}
319
320/// `Ωᵢᵢ = Rᵢᵢ − hᵢ G⁻¹ hᵢᵀ`, the variance of one measurement's residual.
321///
322/// Solved through the *augmented* system rather than `G` alone, so the
323/// zero-injection constraints are accounted for: a constrained estimate has
324/// less freedom to move, which changes how much of an error shows up in the
325/// residual rather than in the state.
326/// The least redundancy a measurement may have and still be judged.
327///
328/// Below this its residual variance is indistinguishable from zero: the
329/// subtraction that forms it loses everything to cancellation at about `1e-16`
330/// relative, so `1e-10` is a floor with six orders of margin. A measurement at
331/// this redundancy would have its residual amplified a hundred thousand times
332/// over, which is not a diagnosis either.
333const MIN_REDUNDANCY: f64 = 1e-10;
334
335fn residual_variance<S: LinearSolver>(
336 system: &mut S,
337 values: &[f64],
338 row: &Row,
339 m: &Measurement,
340 n_aug: usize,
341) -> Option<f64> {
342 let mut rhs = vec![0.0; n_aug];
343 for &(c, v) in row {
344 rhs[c] += v;
345 }
346 let y = system.factor_and_solve_values(values, &rhs)?;
347 let quadratic: f64 = row.iter().map(|&(c, v)| v * y[c]).sum();
348 Some(m.sigma * m.sigma - quadratic)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::branch_flow::Terminal;
355 use crate::measurement::{MeasurementKind, Target};
356 use crate::se::measurement_functions;
357 use crate::se::nr::{SeOptions, estimate, flat_start};
358
359 /// Published chi-squared critical values: `P(X > x) = 0.05`.
360 ///
361 /// These are the numbers in the back of every statistics textbook, and they
362 /// are the reason this hand-written incomplete gamma is defensible — it is
363 /// checkable against an external source rather than against itself.
364 #[test]
365 fn chi_squared_matches_published_critical_values() {
366 for (dof, critical) in [
367 (1, 3.841),
368 (2, 5.991),
369 (5, 11.070),
370 (10, 18.307),
371 (20, 31.410),
372 ] {
373 let p = chi_squared_upper_tail(critical, dof);
374 assert!(
375 (p - 0.05).abs() < 1e-3,
376 "dof {dof}: P(X > {critical}) = {p}, expected 0.05"
377 );
378 }
379 }
380
381 /// The other tail, and the median, from the same tables.
382 #[test]
383 fn chi_squared_matches_other_quantiles() {
384 assert!((chi_squared_upper_tail(0.004, 1) - 0.95).abs() < 1e-3);
385 assert!((chi_squared_upper_tail(9.342, 10) - 0.50).abs() < 1e-3);
386 assert!((chi_squared_upper_tail(23.209, 10) - 0.01).abs() < 1e-3);
387 }
388
389 #[test]
390 fn chi_squared_edges_are_sane() {
391 assert_eq!(chi_squared_upper_tail(-1.0, 5), 1.0);
392 assert_eq!(chi_squared_upper_tail(0.0, 5), 1.0);
393 assert!(chi_squared_upper_tail(1e6, 5) < 1e-12);
394 }
395
396 /// Builds a measurement set read exactly off a known state, optionally with
397 /// one reading corrupted.
398 fn measurements_from(
399 truth: &[Bus],
400 net: &SeNetwork,
401 corrupt: Option<(usize, f64)>,
402 ) -> Vec<Measurement> {
403 let probe: Vec<Measurement> = [
404 (MeasurementKind::VoltageMagnitude, Target::Bus(0)),
405 (MeasurementKind::VoltageMagnitude, Target::Bus(1)),
406 (MeasurementKind::ActivePower, Target::Bus(1)),
407 (MeasurementKind::ReactivePower, Target::Bus(1)),
408 (
409 MeasurementKind::ActivePower,
410 Target::BranchTerminal {
411 branch: 0,
412 terminal: Terminal::From,
413 },
414 ),
415 (
416 MeasurementKind::ReactivePower,
417 Target::BranchTerminal {
418 branch: 0,
419 terminal: Terminal::From,
420 },
421 ),
422 (
423 MeasurementKind::ActivePower,
424 Target::BranchTerminal {
425 branch: 0,
426 terminal: Terminal::To,
427 },
428 ),
429 ]
430 .into_iter()
431 .map(|(kind, target)| Measurement {
432 kind,
433 target,
434 value: 0.0,
435 sigma: 0.01,
436 })
437 .collect();
438
439 let exact = measurement_functions(&probe, truth, net);
440 probe
441 .iter()
442 .zip(&exact)
443 .enumerate()
444 .map(|(i, (p, &v))| {
445 let value = match corrupt {
446 Some((j, delta)) if j == i => v + delta,
447 _ => v,
448 };
449 Measurement { value, ..*p }
450 })
451 .collect()
452 }
453
454 /// Consistent data must not be flagged. A test that only detects bad data
455 /// proves nothing if it also flags good data.
456 #[test]
457 fn consistent_measurements_are_not_flagged() {
458 let (net, truth) = crate::se::tests::two_bus_net();
459 let measurements = measurements_from(&truth, &net, None);
460
461 let mut buses = truth.clone();
462 flat_start(&mut buses, &measurements);
463 let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
464
465 let layout = StateLayout::new(&buses, &measurements, &net);
466 let constraints = Constraints::new(&net);
467 let bad = analyze(
468 &measurements,
469 &report.residuals,
470 &buses,
471 &net,
472 &layout,
473 &constraints,
474 Candidates::default(),
475 );
476
477 assert!(bad.chi_squared < 1e-12, "noise-free data: {bad:?}");
478 assert!(
479 !bad.rejects_at(0.05),
480 "clean data must not be rejected: {bad:?}"
481 );
482 }
483
484 /// One grossly wrong reading, twenty sigma out, must be both detected and
485 /// identified.
486 #[test]
487 fn a_gross_error_is_detected_and_identified() {
488 let (net, truth) = crate::se::tests::two_bus_net();
489 let corrupted = 4; // the branch active-power measurement
490 let measurements = measurements_from(&truth, &net, Some((corrupted, 0.2)));
491
492 let mut buses = truth.clone();
493 flat_start(&mut buses, &measurements);
494 let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
495
496 let layout = StateLayout::new(&buses, &measurements, &net);
497 let constraints = Constraints::new(&net);
498 let bad = analyze(
499 &measurements,
500 &report.residuals,
501 &buses,
502 &net,
503 &layout,
504 &constraints,
505 Candidates::default(),
506 );
507
508 assert!(
509 bad.rejects_at(0.05),
510 "a 20-sigma error must fail the chi-squared test: {bad:?}"
511 );
512 let worst = bad.worst().expect("some measurement should be examined");
513 assert_eq!(
514 worst.measurement, corrupted,
515 "the corrupted measurement should be the worst: {bad:?}"
516 );
517 assert!(
518 worst.normalized_residual > 3.0,
519 "and should exceed the conventional threshold: {worst:?}"
520 );
521 }
522}