Skip to main content

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}