use crate::measurement::Measurement;
use crate::solver::LinearSolver;
use crate::types::Bus;
use gridoxide_sparse::sparse::RealSparseSystem;
use super::SeNetwork;
use super::constraints::{Constraints, augment};
use super::jacobian::{Row, StateLayout, gain_and_rhs, mask_untouched, measurement_jacobian};
#[derive(Clone, Copy, Debug)]
pub struct Candidates {
pub limit: usize,
}
impl Default for Candidates {
fn default() -> Self {
Self { limit: 20 }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Suspect {
pub measurement: usize,
pub normalized_residual: f64,
}
#[derive(Clone, Debug)]
pub struct BadDataReport {
pub chi_squared: f64,
pub degrees_of_freedom: usize,
pub p_value: f64,
pub suspects: Vec<Suspect>,
}
impl BadDataReport {
pub fn rejects_at(&self, significance: f64) -> bool {
self.p_value < significance
}
pub fn worst(&self) -> Option<Suspect> {
self.suspects.first().copied()
}
}
fn gamma_p_series(a: f64, x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
let mut ap = a;
let mut sum = 1.0 / a;
let mut del = sum;
for _ in 0..500 {
ap += 1.0;
del *= x / ap;
sum += del;
if del.abs() < sum.abs() * 1e-15 {
break;
}
}
sum * (-x + a * x.ln() - ln_gamma(a)).exp()
}
fn gamma_q_continued_fraction(a: f64, x: f64) -> f64 {
const TINY: f64 = 1e-300;
let mut b = x + 1.0 - a;
let mut c = 1.0 / TINY;
let mut d = 1.0 / b;
let mut h = d;
for i in 1..500 {
let an = -(i as f64) * (i as f64 - a);
b += 2.0;
d = an * d + b;
if d.abs() < TINY {
d = TINY;
}
c = b + an / c;
if c.abs() < TINY {
c = TINY;
}
d = 1.0 / d;
let del = d * c;
h *= del;
if (del - 1.0).abs() < 1e-15 {
break;
}
}
(-x + a * x.ln() - ln_gamma(a)).exp() * h
}
fn ln_gamma(x: f64) -> f64 {
const COF: [f64; 6] = [
76.180_091_729_471_46,
-86.505_320_329_416_77,
24.014_098_240_830_91,
-1.231_739_572_450_155,
0.120_865_097_386_617_9e-2,
-0.539_523_938_495_3e-5,
];
let mut y = x;
let tmp = x + 5.5 - (x + 0.5) * (x + 5.5).ln();
let mut ser = 1.000_000_000_190_015;
for c in COF {
y += 1.0;
ser += c / y;
}
-tmp + (2.506_628_274_631_000_5 * ser / x).ln()
}
fn gamma_q(a: f64, x: f64) -> f64 {
if x < a + 1.0 {
1.0 - gamma_p_series(a, x)
} else {
gamma_q_continued_fraction(a, x)
}
}
pub fn chi_squared_upper_tail(x: f64, dof: usize) -> f64 {
if dof == 0 {
return if x > 0.0 { 0.0 } else { 1.0 };
}
if x <= 0.0 {
return 1.0;
}
gamma_q(dof as f64 / 2.0, x / 2.0).clamp(0.0, 1.0)
}
pub fn analyze(
measurements: &[Measurement],
residuals: &[f64],
buses: &[Bus],
net: &SeNetwork,
layout: &StateLayout,
constraints: &Constraints,
candidates: Candidates,
) -> BadDataReport {
let weighted: Vec<(usize, &Measurement, f64)> = measurements
.iter()
.zip(residuals)
.enumerate()
.filter(|(_, (m, _))| m.weight().is_finite() && m.weight() > 0.0)
.map(|(i, (m, &r))| (i, m, r))
.collect();
let chi_squared: f64 = weighted.iter().map(|(_, m, r)| m.weight() * r * r).sum();
let rows = measurement_jacobian(measurements, buses, net, layout);
let (c_values, c_rows) = constraints.evaluate(buses, net, layout);
let n = layout.n_unknowns();
let (mut triplets, mut rhs, _) = gain_and_rhs(&rows, measurements, residuals);
rhs.resize(n, 0.0);
let untouched = mask_untouched(&mut triplets, &mut rhs, &[&rows, &c_rows], n);
let (triplets, _) = augment(triplets, rhs, n, &c_values, &c_rows);
let n_aug = n + constraints.len();
let estimated = n.saturating_sub(untouched.len());
let degrees_of_freedom = weighted
.len()
.saturating_sub(estimated)
.saturating_add(constraints.len());
let p_value = chi_squared_upper_tail(chi_squared, degrees_of_freedom);
let mut shortlist: Vec<(usize, &Measurement, f64)> = weighted.clone();
shortlist.sort_by(|a, b| {
let key = |t: &(usize, &Measurement, f64)| (t.2 / t.1.sigma).abs();
key(b)
.partial_cmp(&key(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
shortlist.truncate(candidates.limit);
let mut system = match RealSparseSystem::new(n_aug, &triplets) {
Some(s) => s,
None => {
return BadDataReport {
chi_squared,
degrees_of_freedom,
p_value,
suspects: Vec::new(),
};
}
};
let values: Vec<f64> = triplets.iter().map(|&(_, _, v)| v).collect();
let mut suspects = Vec::new();
for (index, m, r) in shortlist {
let Some(omega) = residual_variance(&mut system, &values, &rows[index], m, n_aug) else {
continue;
};
if omega <= MIN_REDUNDANCY * m.sigma * m.sigma {
continue;
}
suspects.push(Suspect {
measurement: index,
normalized_residual: r.abs() / omega.sqrt(),
});
}
suspects.sort_by(|a, b| {
b.normalized_residual
.partial_cmp(&a.normalized_residual)
.unwrap_or(std::cmp::Ordering::Equal)
});
BadDataReport {
chi_squared,
degrees_of_freedom,
p_value,
suspects,
}
}
const MIN_REDUNDANCY: f64 = 1e-10;
fn residual_variance<S: LinearSolver>(
system: &mut S,
values: &[f64],
row: &Row,
m: &Measurement,
n_aug: usize,
) -> Option<f64> {
let mut rhs = vec![0.0; n_aug];
for &(c, v) in row {
rhs[c] += v;
}
let y = system.factor_and_solve_values(values, &rhs)?;
let quadratic: f64 = row.iter().map(|&(c, v)| v * y[c]).sum();
Some(m.sigma * m.sigma - quadratic)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::branch_flow::Terminal;
use crate::measurement::{MeasurementKind, Target};
use crate::se::measurement_functions;
use crate::se::nr::{SeOptions, estimate, flat_start};
#[test]
fn chi_squared_matches_published_critical_values() {
for (dof, critical) in [
(1, 3.841),
(2, 5.991),
(5, 11.070),
(10, 18.307),
(20, 31.410),
] {
let p = chi_squared_upper_tail(critical, dof);
assert!(
(p - 0.05).abs() < 1e-3,
"dof {dof}: P(X > {critical}) = {p}, expected 0.05"
);
}
}
#[test]
fn chi_squared_matches_other_quantiles() {
assert!((chi_squared_upper_tail(0.004, 1) - 0.95).abs() < 1e-3);
assert!((chi_squared_upper_tail(9.342, 10) - 0.50).abs() < 1e-3);
assert!((chi_squared_upper_tail(23.209, 10) - 0.01).abs() < 1e-3);
}
#[test]
fn chi_squared_edges_are_sane() {
assert_eq!(chi_squared_upper_tail(-1.0, 5), 1.0);
assert_eq!(chi_squared_upper_tail(0.0, 5), 1.0);
assert!(chi_squared_upper_tail(1e6, 5) < 1e-12);
}
fn measurements_from(
truth: &[Bus],
net: &SeNetwork,
corrupt: Option<(usize, f64)>,
) -> Vec<Measurement> {
let probe: Vec<Measurement> = [
(MeasurementKind::VoltageMagnitude, Target::Bus(0)),
(MeasurementKind::VoltageMagnitude, Target::Bus(1)),
(MeasurementKind::ActivePower, Target::Bus(1)),
(MeasurementKind::ReactivePower, Target::Bus(1)),
(
MeasurementKind::ActivePower,
Target::BranchTerminal {
branch: 0,
terminal: Terminal::From,
},
),
(
MeasurementKind::ReactivePower,
Target::BranchTerminal {
branch: 0,
terminal: Terminal::From,
},
),
(
MeasurementKind::ActivePower,
Target::BranchTerminal {
branch: 0,
terminal: Terminal::To,
},
),
]
.into_iter()
.map(|(kind, target)| Measurement {
kind,
target,
value: 0.0,
sigma: 0.01,
})
.collect();
let exact = measurement_functions(&probe, truth, net);
probe
.iter()
.zip(&exact)
.enumerate()
.map(|(i, (p, &v))| {
let value = match corrupt {
Some((j, delta)) if j == i => v + delta,
_ => v,
};
Measurement { value, ..*p }
})
.collect()
}
#[test]
fn consistent_measurements_are_not_flagged() {
let (net, truth) = crate::se::tests::two_bus_net();
let measurements = measurements_from(&truth, &net, None);
let mut buses = truth.clone();
flat_start(&mut buses, &measurements);
let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
let layout = StateLayout::new(&buses, &measurements, &net);
let constraints = Constraints::new(&net);
let bad = analyze(
&measurements,
&report.residuals,
&buses,
&net,
&layout,
&constraints,
Candidates::default(),
);
assert!(bad.chi_squared < 1e-12, "noise-free data: {bad:?}");
assert!(
!bad.rejects_at(0.05),
"clean data must not be rejected: {bad:?}"
);
}
#[test]
fn a_gross_error_is_detected_and_identified() {
let (net, truth) = crate::se::tests::two_bus_net();
let corrupted = 4; let measurements = measurements_from(&truth, &net, Some((corrupted, 0.2)));
let mut buses = truth.clone();
flat_start(&mut buses, &measurements);
let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
let layout = StateLayout::new(&buses, &measurements, &net);
let constraints = Constraints::new(&net);
let bad = analyze(
&measurements,
&report.residuals,
&buses,
&net,
&layout,
&constraints,
Candidates::default(),
);
assert!(
bad.rejects_at(0.05),
"a 20-sigma error must fail the chi-squared test: {bad:?}"
);
let worst = bad.worst().expect("some measurement should be examined");
assert_eq!(
worst.measurement, corrupted,
"the corrupted measurement should be the worst: {bad:?}"
);
assert!(
worst.normalized_residual > 3.0,
"and should exceed the conventional threshold: {worst:?}"
);
}
}