#![cfg_attr(not(feature = "std"), no_std)]
use crate::core::scalar::ControlScalar;
use crate::fdi::parity_space::FdiError;
#[derive(Debug, Clone, Copy)]
pub struct FaultIsolationResult<S: ControlScalar, const M: usize> {
pub residual: [S; M],
pub threshold_exceeded: [bool; M],
pub largest_channel: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ObserverFdi<S: ControlScalar, const N: usize, const M: usize, const I: usize> {
a: [[S; N]; N],
b: [[S; N]; I],
c: [[S; M]; N],
l: [[S; N]; M],
x_hat: [S; N],
residual: [S; M],
threshold: S,
dt: S,
r_ema: [S; M],
alpha: S,
}
impl<S: ControlScalar, const N: usize, const M: usize, const I: usize> ObserverFdi<S, N, M, I> {
pub fn new(
a: [[S; N]; N],
b: [[S; N]; I],
c: [[S; M]; N],
l: [[S; N]; M],
threshold: S,
dt: S,
alpha: S,
) -> Result<Self, FdiError> {
if threshold <= S::ZERO || dt <= S::ZERO || alpha <= S::ZERO || alpha >= S::ONE {
return Err(FdiError::InvalidParameter);
}
Ok(Self {
a,
b,
c,
l,
x_hat: [S::ZERO; N],
residual: [S::ZERO; M],
threshold,
dt,
r_ema: [S::ZERO; M],
alpha,
})
}
#[allow(clippy::needless_range_loop)]
pub fn update(
&mut self,
u: &[S; I],
y: &[S; M],
) -> Result<FaultIsolationResult<S, M>, FdiError> {
let mut innovation = [S::ZERO; M];
for i in 0..M {
let mut cx_i = S::ZERO;
for j in 0..N {
cx_i += self.c[j][i] * self.x_hat[j];
}
innovation[i] = y[i] - cx_i;
}
let mut dx = [S::ZERO; N];
for j in 0..N {
for k in 0..N {
dx[j] += self.a[j][k] * self.x_hat[k];
}
for k in 0..I {
dx[j] += self.b[k][j] * u[k];
}
for m in 0..M {
dx[j] += self.l[m][j] * innovation[m];
}
}
for j in 0..N {
self.x_hat[j] += self.dt * dx[j];
}
let mut r = [S::ZERO; M];
for i in 0..M {
let mut cx_i = S::ZERO;
for j in 0..N {
cx_i += self.c[j][i] * self.x_hat[j];
}
r[i] = y[i] - cx_i;
}
self.residual = r;
let one_minus_alpha = S::ONE - self.alpha;
for i in 0..M {
self.r_ema[i] = self.alpha * self.r_ema[i] + one_minus_alpha * r[i];
}
let mut threshold_exceeded = [false; M];
let mut max_abs = S::ZERO;
let mut largest_channel: Option<usize> = None;
for i in 0..M {
let abs_ri = if r[i] < S::ZERO { -r[i] } else { r[i] };
if abs_ri > self.threshold {
threshold_exceeded[i] = true;
if abs_ri > max_abs {
max_abs = abs_ri;
largest_channel = Some(i);
}
}
}
Ok(FaultIsolationResult {
residual: r,
threshold_exceeded,
largest_channel,
})
}
pub fn residual(&self) -> &[S; M] {
&self.residual
}
pub fn residual_ema(&self) -> &[S; M] {
&self.r_ema
}
pub fn reset(&mut self, x0: [S; N]) {
self.x_hat = x0;
self.residual = [S::ZERO; M];
self.r_ema = [S::ZERO; M];
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_scalar_observer(
a_val: f64,
l_val: f64,
threshold: f64,
dt: f64,
alpha: f64,
) -> ObserverFdi<f64, 1, 1, 1> {
let a = [[-a_val]];
let b = [[1.0_f64]];
let c = [[1.0_f64]];
let l = [[l_val]];
ObserverFdi::new(a, b, c, l, threshold, dt, alpha).expect("valid params")
}
#[test]
fn perfect_model_zero_residual() {
let mut obs = make_scalar_observer(1.0, 5.0, 0.5, 0.01, 0.9);
for _ in 0..200 {
let res = obs.update(&[0.0], &[0.0]).expect("ok");
assert!(
!res.threshold_exceeded[0],
"residual should not exceed threshold"
);
}
assert!(obs.residual()[0].abs() < 1e-6);
}
#[test]
fn step_fault_detected_channel0() {
let mut obs = make_scalar_observer(1.0, 5.0, 0.5, 0.01, 0.5);
for _ in 0..200 {
obs.update(&[0.0], &[0.0]).expect("ok");
}
let res = obs.update(&[0.0], &[5.0]).expect("ok");
assert_eq!(
res.largest_channel,
Some(0),
"fault should show on channel 0"
);
assert!(res.threshold_exceeded[0]);
}
#[test]
fn observer_convergence() {
let a = [[0.0_f64]]; let b = [[0.0_f64]];
let c = [[1.0_f64]];
let l = [[10.0_f64]]; let mut obs: ObserverFdi<f64, 1, 1, 1> =
ObserverFdi::new(a, b, c, l, 0.5, 0.01, 0.5).expect("ok");
let mut last_res = 0.0_f64;
for _ in 0..500 {
let res = obs.update(&[0.0], &[1.0]).expect("ok");
last_res = res.residual[0];
}
assert!(
last_res.abs() < 0.01,
"observer should have converged, residual={last_res}"
);
}
#[test]
fn reset_clears_observer_state() {
let mut obs = make_scalar_observer(1.0, 5.0, 0.5, 0.01, 0.5);
for _ in 0..50 {
obs.update(&[0.0], &[10.0]).expect("ok");
}
obs.reset([0.0]);
let res = obs.update(&[0.0], &[0.0]).expect("ok");
assert!(
!res.threshold_exceeded[0],
"after reset residual should be near zero"
);
}
#[test]
fn threshold_per_channel_2d() {
let a = [[-1.0_f64, 0.0], [0.0, -1.0]];
let b = [[0.0_f64, 0.0]]; let c = [[1.0_f64, 0.0], [0.0, 1.0]];
let l = [[5.0_f64, 0.0], [0.0, 5.0]]; let mut obs: ObserverFdi<f64, 2, 2, 1> =
ObserverFdi::new(a, b, c, l, 0.5, 0.01, 0.5).expect("ok");
for _ in 0..300 {
obs.update(&[0.0], &[0.0, 0.0]).expect("ok");
}
let res = obs.update(&[0.0], &[0.0, 5.0]).expect("ok");
assert!(
!res.threshold_exceeded[0],
"channel 0 should NOT be flagged"
);
assert!(res.threshold_exceeded[1], "channel 1 SHOULD be flagged");
assert_eq!(res.largest_channel, Some(1));
}
#[test]
fn alpha_bounds_validation() {
let a = [[-1.0_f64]];
let b = [[0.0_f64]];
let c = [[1.0_f64]];
let l = [[5.0_f64]];
assert!(
ObserverFdi::<f64, 1, 1, 1>::new(a, b, c, l, 1.0, 0.01, 0.0).is_err(),
"alpha=0 should be invalid"
);
assert!(
ObserverFdi::<f64, 1, 1, 1>::new(a, b, c, l, 1.0, 0.01, 1.0).is_err(),
"alpha=1 should be invalid"
);
assert!(ObserverFdi::<f64, 1, 1, 1>::new(a, b, c, l, 1.0, 0.01, 0.5).is_ok());
}
#[test]
fn ema_tracks_residual_trend() {
let mut obs = make_scalar_observer(1.0, 2.0, 0.5, 0.01, 0.1);
for _ in 0..300 {
obs.update(&[0.0], &[0.0]).expect("ok");
}
let ema_before = obs.residual_ema()[0];
for _ in 0..50 {
obs.update(&[0.0], &[3.0]).expect("ok");
}
let ema_after = obs.residual_ema()[0];
assert!(
ema_after.abs() > ema_before.abs(),
"EMA should grow under persistent fault: before={ema_before}, after={ema_after}"
);
}
}