Skip to main content

aegis_filter/
fusion.rs

1//! Multi-sensor fusion engine with dynamic health scoring and Fault Detection, Isolation, and Recovery (FDIR).
2//!
3//! Designed for mission-critical aerospace, robotics, and maritime telemetry where redundant sensors
4//! (e.g., GPS, Barometer, Rangefinder/LiDAR) must be fused safely with real-time adversarial isolation.
5
6use crate::common::{FilterError, FloatOps};
7
8/// Represents an individual sensor source participating in the multi-sensor fusion pipeline.
9#[derive(Debug, Clone, Copy)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct SensorSource {
12    /// Sensor unique identifier
13    pub id: u8,
14    /// Measurement noise variance (R)
15    pub variance: f64,
16    /// 3-sigma innovation gating threshold
17    pub gate_threshold: f64,
18    /// Consecutive anomaly count for automated isolation
19    pub anomaly_count: u32,
20    /// Maximum allowable consecutive anomalies before quarantine
21    pub max_anomalies: u32,
22    /// Whether this sensor is currently isolated / quarantined
23    pub is_quarantined: bool,
24    /// Health score from 0.0 (failed) to 1.0 (nominal)
25    pub health_score: f64,
26    /// Consecutive healthy measurements observed while quarantined
27    pub consecutive_healthy: u32,
28    /// Threshold of consecutive healthy measurements required to auto-recover (0 to disable)
29    pub auto_recovery_threshold: u32,
30}
31
32impl SensorSource {
33    /// Creates a new sensor source configuration.
34    pub fn new(id: u8, variance: f64, max_anomalies: u32) -> Result<Self, FilterError> {
35        if !variance.is_valid_float() || variance <= 0.0 {
36            return Err(FilterError::InvalidParameter);
37        }
38        Ok(Self {
39            id,
40            variance,
41            gate_threshold: 3.0,
42            anomaly_count: 0,
43            max_anomalies,
44            is_quarantined: false,
45            health_score: 1.0,
46            consecutive_healthy: 0,
47            auto_recovery_threshold: 0,
48        })
49    }
50
51    /// Sets custom chi-square / sigma gating threshold.
52    pub fn with_gate_threshold(mut self, threshold: f64) -> Result<Self, FilterError> {
53        if !threshold.is_valid_float() || threshold <= 0.0 {
54            return Err(FilterError::InvalidParameter);
55        }
56        self.gate_threshold = threshold;
57        Ok(self)
58    }
59
60    /// Configures automatic recovery threshold.
61    pub fn with_auto_recovery(mut self, threshold: u32) -> Self {
62        self.auto_recovery_threshold = threshold;
63        self
64    }
65
66    /// Manually resets quarantine status.
67    pub fn reset_quarantine(&mut self) {
68        self.is_quarantined = false;
69        self.anomaly_count = 0;
70        self.consecutive_healthy = 0;
71        self.health_score = 1.0;
72    }
73}
74
75/// A measurement packet from an identifiable sensor source.
76#[derive(Debug, Clone, Copy)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct SensorMeasurement {
79    /// Sensor ID matching the configured `SensorSource`
80    pub sensor_id: u8,
81    /// Measured observation value
82    pub value: f64,
83}
84
85/// Result of a sensor fusion update step.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
88pub enum FusionStatus {
89    /// Measurement was successfully fused into the state vector
90    Fused,
91    /// Measurement violated outlier gate but sensor remains active
92    Gated,
93    /// Measurement failed and sensor exceeded threshold, triggering immediate quarantine
94    Quarantined,
95    /// Measurement came from an already quarantined/disabled sensor
96    IgnoredQuarantined,
97    /// Sensor met consecutive consistency criteria and was recovered from quarantine
98    Recovered,
99    /// Sensor ID not registered in the fusion engine
100    UnregisteredSensor,
101}
102
103/// Multi-sensor state estimator with Joseph-stabilized covariance updates and automated FDIR.
104#[derive(Debug, Clone)]
105pub struct MultiSensorFusion1D<const N: usize> {
106    /// Estimated state mean
107    pub x: f64,
108    /// Estimated state error variance
109    pub p: f64,
110    /// Process noise variance (Q)
111    pub q: f64,
112    /// Array of registered sensor sources
113    pub sensors: [Option<SensorSource>; N],
114}
115
116impl<const N: usize> MultiSensorFusion1D<N> {
117    /// Creates a new multi-sensor fusion filter.
118    pub fn new(x0: f64, p0: f64, q: f64) -> Result<Self, FilterError> {
119        if !x0.is_valid_float() || !p0.is_valid_float() || !q.is_valid_float() {
120            return Err(FilterError::NonFiniteInput);
121        }
122        if p0 <= 0.0 || q < 0.0 {
123            return Err(FilterError::InvalidParameter);
124        }
125
126        Ok(Self {
127            x: x0,
128            p: p0,
129            q,
130            sensors: [None; N],
131        })
132    }
133
134    /// Registers a sensor source into the fusion engine.
135    pub fn register_sensor(&mut self, source: SensorSource) -> Result<(), FilterError> {
136        for slot in self.sensors.iter_mut() {
137            if let Some(existing) = slot {
138                if existing.id == source.id {
139                    *slot = Some(source);
140                    return Ok(());
141                }
142            } else {
143                *slot = Some(source);
144                return Ok(());
145            }
146        }
147        Err(FilterError::CapacityExceeded)
148    }
149
150    /// Predicts state forward using linear physical transition.
151    pub fn predict(&mut self, control_input: f64) -> Result<(), FilterError> {
152        if !control_input.is_valid_float() {
153            return Err(FilterError::NonFiniteInput);
154        }
155        self.x += control_input;
156        self.p += self.q;
157        Ok(())
158    }
159
160    /// Fuses an incoming measurement from a sensor with FDIR checking.
161    pub fn fuse(&mut self, measurement: SensorMeasurement) -> Result<FusionStatus, FilterError> {
162        if !measurement.value.is_valid_float() {
163            return Err(FilterError::NonFiniteInput);
164        }
165
166        // Find matching registered sensor
167        let mut target_sensor_idx = None;
168        for (i, slot) in self.sensors.iter().enumerate() {
169            if let Some(ref s) = slot {
170                if s.id == measurement.sensor_id {
171                    target_sensor_idx = Some(i);
172                    break;
173                }
174            }
175        }
176
177        let idx = match target_sensor_idx {
178            Some(i) => i,
179            None => return Ok(FusionStatus::UnregisteredSensor),
180        };
181
182        let sensor = match self.sensors[idx].as_mut() {
183            Some(s) => s,
184            None => return Ok(FusionStatus::UnregisteredSensor),
185        };
186
187        if sensor.is_quarantined {
188            if sensor.auto_recovery_threshold > 0 {
189                let innovation = measurement.value - self.x;
190                let s = self.p + sensor.variance;
191                let sigma_bound = sensor.gate_threshold * s.sqrt_val();
192                if innovation.abs_val() <= sigma_bound {
193                    sensor.consecutive_healthy += 1;
194                    if sensor.consecutive_healthy >= sensor.auto_recovery_threshold {
195                        sensor.is_quarantined = false;
196                        sensor.anomaly_count = 0;
197                        sensor.health_score = 0.5;
198                        sensor.consecutive_healthy = 0;
199                        return Ok(FusionStatus::Recovered);
200                    }
201                } else {
202                    sensor.consecutive_healthy = 0;
203                }
204            }
205            return Ok(FusionStatus::IgnoredQuarantined);
206        }
207
208        // Calculate innovation and innovation variance
209        let innovation = measurement.value - self.x;
210        let s = self.p + sensor.variance;
211        let sigma_bound = sensor.gate_threshold * s.sqrt_val();
212
213        // Check innovation gate
214        if innovation.abs_val() > sigma_bound {
215            sensor.anomaly_count += 1;
216            sensor.health_score = (sensor.health_score * 0.5).max(0.0);
217            sensor.consecutive_healthy = 0;
218
219            if sensor.anomaly_count >= sensor.max_anomalies {
220                sensor.is_quarantined = true;
221                sensor.health_score = 0.0;
222                sensor.consecutive_healthy = 0;
223                return Ok(FusionStatus::Quarantined);
224            }
225            return Ok(FusionStatus::Gated);
226        }
227
228        // Measurement passed gate - perform Joseph-stabilized covariance update
229        sensor.anomaly_count = sensor.anomaly_count.saturating_sub(1);
230        sensor.health_score = (sensor.health_score + 0.1).min(1.0);
231
232        let k = self.p / s;
233        self.x += k * innovation;
234
235        // Joseph Form: P = (1 - K) * P * (1 - K) + K * R * K
236        let one_minus_k = 1.0 - k;
237        self.p = (one_minus_k * self.p * one_minus_k) + (k * sensor.variance * k);
238
239        Ok(FusionStatus::Fused)
240    }
241
242    /// Returns current state estimate.
243    pub fn state(&self) -> f64 {
244        self.x
245    }
246
247    /// Returns current estimation error variance.
248    pub fn variance(&self) -> f64 {
249        self.p
250    }
251
252    /// Returns the active health score of a sensor by ID.
253    pub fn sensor_health(&self, sensor_id: u8) -> Option<f64> {
254        for s in self.sensors.iter().flatten() {
255            if s.id == sensor_id {
256                return Some(s.health_score);
257            }
258        }
259        None
260    }
261}