#![cfg_attr(not(feature = "std"), no_std)]
use crate::core::scalar::ControlScalar;
use crate::fdi::parity_space::{FaultStatus, FdiError};
#[derive(Debug, Clone)]
pub struct ChiSquareTest<S: ControlScalar, const M: usize> {
sigma_inv: [[S; M]; M],
threshold: S,
n_samples: usize,
n_alarms: usize,
}
impl<S: ControlScalar, const M: usize> ChiSquareTest<S, M> {
pub fn new(sigma_inv: [[S; M]; M], threshold: S) -> Self {
Self {
sigma_inv,
threshold,
n_samples: 0,
n_alarms: 0,
}
}
pub fn statistic(residual: &[S; M], sigma_inv: &[[S; M]; M]) -> S {
let mut t = S::ZERO;
for i in 0..M {
for j in 0..M {
t += residual[i] * sigma_inv[i][j] * residual[j];
}
}
t
}
pub fn test(&mut self, residual: &[S; M]) -> FaultStatus {
let t = Self::statistic(residual, &self.sigma_inv);
self.n_samples += 1;
if t > self.threshold {
self.n_alarms += 1;
FaultStatus::FaultDetected
} else {
FaultStatus::Normal
}
}
pub fn alarm_rate(&self) -> S {
if self.n_samples == 0 {
S::ZERO
} else {
S::from_f64(self.n_alarms as f64 / self.n_samples as f64)
}
}
pub fn reset(&mut self) {
self.n_samples = 0;
self.n_alarms = 0;
}
pub fn n_samples(&self) -> usize {
self.n_samples
}
pub fn n_alarms(&self) -> usize {
self.n_alarms
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SprtDecision {
Fault,
Normal,
Indeterminate,
}
#[derive(Debug, Clone)]
pub struct Sprt<S: ControlScalar, const M: usize> {
sigma: [[S; M]; M],
mu_fault: [S; M],
log_lambda: S,
threshold_upper: S,
threshold_lower: S,
}
impl<S: ControlScalar, const M: usize> Sprt<S, M> {
pub fn new(
sigma: [[S; M]; M],
mu_fault: [S; M],
threshold_upper: S,
threshold_lower: S,
) -> Result<Self, FdiError> {
if threshold_upper <= threshold_lower {
return Err(FdiError::InvalidParameter);
}
Ok(Self {
sigma,
mu_fault,
log_lambda: S::ZERO,
threshold_upper,
threshold_lower,
})
}
#[allow(clippy::needless_range_loop)]
pub fn update(&mut self, residual: &[S; M]) -> SprtDecision {
let mut delta = S::ZERO;
for i in 0..M {
let sigma_ii = self.sigma[i][i];
if sigma_ii <= S::ZERO {
continue;
}
let ri = residual[i];
let mu_i = self.mu_fault[i];
let log_p0_i = S::from_f64(-0.5) * ri * ri / sigma_ii;
let diff = ri - mu_i;
let log_p1_i = S::from_f64(-0.5) * diff * diff / sigma_ii;
delta += log_p1_i - log_p0_i;
}
self.log_lambda += delta;
if self.log_lambda > self.threshold_upper {
SprtDecision::Fault
} else if self.log_lambda < self.threshold_lower {
SprtDecision::Normal
} else {
SprtDecision::Indeterminate
}
}
pub fn reset(&mut self) {
self.log_lambda = S::ZERO;
}
pub fn log_ratio(&self) -> S {
self.log_lambda
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chi2_zero_residual_normal() {
let sigma_inv = [[1.0_f64, 0.0], [0.0, 1.0]];
let mut test = ChiSquareTest::new(sigma_inv, 1.0);
let status = test.test(&[0.0, 0.0]);
assert_eq!(status, FaultStatus::Normal);
assert_eq!(test.n_alarms(), 0);
}
#[test]
fn chi2_large_residual_detected() {
let sigma_inv = [[1.0_f64, 0.0], [0.0, 1.0]];
let mut test = ChiSquareTest::new(sigma_inv, 1.0);
let status = test.test(&[10.0, 10.0]);
assert_eq!(status, FaultStatus::FaultDetected);
}
#[test]
fn chi2_alarm_rate_calculation() {
let sigma_inv = [[1.0_f64]];
let mut test = ChiSquareTest::new(sigma_inv, 1.0);
for _ in 0..5 {
test.test(&[5.0]); }
for _ in 0..5 {
test.test(&[0.0]); }
let rate = test.alarm_rate();
assert!((rate - 0.5).abs() < 1e-9, "expected 0.5, got {rate}");
}
#[test]
fn chi2_reset_clears_counts() {
let sigma_inv = [[1.0_f64]];
let mut test = ChiSquareTest::new(sigma_inv, 1.0);
test.test(&[5.0]);
test.test(&[5.0]);
assert_eq!(test.n_samples(), 2);
assert_eq!(test.n_alarms(), 2);
test.reset();
assert_eq!(test.n_samples(), 0);
assert_eq!(test.n_alarms(), 0);
assert_eq!(test.alarm_rate(), 0.0);
}
#[test]
fn chi2_statistic_helper() {
let sigma_inv = [[1.0_f64, 0.0], [0.0, 1.0]];
let r = [3.0_f64, 4.0];
let t = ChiSquareTest::statistic(&r, &sigma_inv);
assert!((t - 25.0).abs() < 1e-9, "expected T=25, got {t}");
}
#[test]
fn chi2_alarm_rate_zero_samples() {
let sigma_inv = [[1.0_f64]];
let test: ChiSquareTest<f64, 1> = ChiSquareTest::new(sigma_inv, 1.0);
assert_eq!(test.alarm_rate(), 0.0);
}
#[test]
fn sprt_zero_residual_stays_indeterminate() {
let sigma = [[1.0_f64]];
let mu_fault = [1.0_f64];
let mut sprt = Sprt::new(sigma, mu_fault, 5.0_f64, -5.0_f64).expect("ok");
let mut saw_normal = false;
for _ in 0..20 {
let d = sprt.update(&[0.0]);
if d == SprtDecision::Normal {
saw_normal = true;
break;
}
}
assert!(
saw_normal,
"SPRT should declare Normal for zero residual against fault hypothesis"
);
}
#[test]
fn sprt_fault_residual_accumulates_to_fault() {
let sigma = [[1.0_f64]];
let mu_fault = [3.0_f64];
let mut sprt = Sprt::new(sigma, mu_fault, 10.0_f64, -10.0_f64).expect("ok");
let mut saw_fault = false;
for _ in 0..10 {
let d = sprt.update(&[3.0]);
if d == SprtDecision::Fault {
saw_fault = true;
break;
}
}
assert!(
saw_fault,
"SPRT should declare Fault when residual matches fault mean"
);
}
#[test]
fn sprt_reset_clears_log_lambda() {
let sigma = [[1.0_f64]];
let mu_fault = [3.0_f64];
let mut sprt = Sprt::new(sigma, mu_fault, 100.0_f64, -100.0_f64).expect("ok");
sprt.update(&[3.0]);
sprt.update(&[3.0]);
assert!(sprt.log_ratio() > 0.0, "log_lambda should have grown");
sprt.reset();
assert_eq!(sprt.log_ratio(), 0.0);
}
#[test]
fn sprt_threshold_order_validation() {
let sigma = [[1.0_f64]];
let mu_fault = [1.0_f64];
assert!(
Sprt::new(sigma, mu_fault, 5.0_f64, 5.0_f64).is_err(),
"equal thresholds should be invalid"
);
assert!(
Sprt::new(sigma, mu_fault, -1.0_f64, 5.0_f64).is_err(),
"upper < lower should be invalid"
);
assert!(Sprt::new(sigma, mu_fault, 5.0_f64, -5.0_f64).is_ok());
}
#[test]
fn sprt_log_ratio_monotone_under_fault() {
let sigma = [[2.0_f64]];
let mu_fault = [2.0_f64];
let mut sprt = Sprt::new(sigma, mu_fault, 1000.0_f64, -1000.0_f64).expect("ok");
let mut prev = sprt.log_ratio();
for _ in 0..20 {
sprt.update(&[2.0]);
let current = sprt.log_ratio();
assert!(
current > prev,
"log_ratio should increase under fault: prev={prev}, current={current}"
);
prev = current;
}
}
#[test]
fn sprt_indeterminate_region() {
let sigma = [[1.0_f64]];
let mu_fault = [0.1_f64]; let mut sprt = Sprt::new(sigma, mu_fault, 1e9_f64, -1e9_f64).expect("ok");
for _ in 0..10 {
let d = sprt.update(&[0.05]);
assert_eq!(d, SprtDecision::Indeterminate);
}
}
}