Skip to main content

sidereon_core/fusion/
ekf.rs

1//! Generic EKF correction and closed-loop reset for the indirect INS state.
2
3use crate::astro::math::mat3::inline_rxr;
4use crate::inertial::state::{mat3_identity, reorthonormalize_dcm, skew};
5
6use super::state::{
7    dmatrix_from_rows, identity, invalid_input, matmul, matrix_add, matrix_sub, matvec,
8    reproject_covariance_psd, solve_spd, transpose, validate_covariance_matrix,
9    validate_finite_slice, validate_matrix_cols, validate_positive, validate_square_matrix,
10    ErrorStateLayout, FusionError, InsFilterState, ERROR_ACCEL_BIAS_INDEX, ERROR_ACCEL_SCALE_INDEX,
11    ERROR_ATTITUDE_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_GYRO_SCALE_INDEX, ERROR_POSITION_INDEX,
12    ERROR_VELOCITY_INDEX,
13};
14
15/// Generic linearized EKF measurement correction.
16#[derive(Debug, Clone, PartialEq)]
17pub struct EkfCorrection {
18    /// Innovation vector `y = z - h(x)`.
19    pub innovation: Vec<f64>,
20    /// Measurement design matrix `H`, with one row per innovation.
21    pub design: Vec<Vec<f64>>,
22    /// Measurement covariance matrix `R`.
23    pub measurement_covariance: Vec<Vec<f64>>,
24}
25
26impl EkfCorrection {
27    /// Build and validate a generic correction.
28    pub fn new(
29        innovation: Vec<f64>,
30        design: Vec<Vec<f64>>,
31        measurement_covariance: Vec<Vec<f64>>,
32    ) -> Result<Self, FusionError> {
33        if innovation.is_empty() {
34            return Err(invalid_input("innovation", "must not be empty"));
35        }
36        if design.len() != innovation.len() {
37            return Err(FusionError::DimensionMismatch {
38                field: "design",
39                expected: innovation.len(),
40                actual: design.len(),
41            });
42        }
43        validate_finite_slice(&innovation, "innovation")?;
44        validate_measurement_covariance(&measurement_covariance, innovation.len())?;
45        Ok(Self {
46            innovation,
47            design,
48            measurement_covariance,
49        })
50    }
51
52    /// Return the number of measurement rows.
53    pub fn row_count(&self) -> usize {
54        self.innovation.len()
55    }
56
57    /// Validate this correction for a state dimension.
58    pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), FusionError> {
59        if self.innovation.is_empty() {
60            return Err(invalid_input("innovation", "must not be empty"));
61        }
62        validate_finite_slice(&self.innovation, "innovation")?;
63        if self.design.len() != self.innovation.len() {
64            return Err(FusionError::DimensionMismatch {
65                field: "design",
66                expected: self.innovation.len(),
67                actual: self.design.len(),
68            });
69        }
70        validate_matrix_cols(&self.design, dimension, "design")?;
71        validate_measurement_covariance(&self.measurement_covariance, self.innovation.len())
72    }
73}
74
75/// Innovation screening options.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct InnovationGate {
78    /// Rejection threshold in absolute normalized-innovation sigma.
79    pub threshold_sigma: f64,
80    /// Minimum accepted rows required to apply an update.
81    pub min_rows: usize,
82}
83
84impl InnovationGate {
85    /// Validate gate options.
86    pub fn validate(&self) -> Result<(), FusionError> {
87        validate_positive(self.threshold_sigma, "threshold_sigma")
88    }
89}
90
91/// EKF correction options.
92#[derive(Debug, Clone, Copy, PartialEq, Default)]
93pub struct EkfUpdateOptions {
94    /// Optional normalized-innovation screen applied before correction.
95    pub innovation_gate: Option<InnovationGate>,
96}
97
98/// Diagnostics from an innovation screen.
99#[derive(Debug, Clone, PartialEq)]
100pub struct InnovationGateReport {
101    /// Rejection threshold in sigma.
102    pub threshold_sigma: f64,
103    /// Minimum accepted rows requested by the gate.
104    pub min_rows: usize,
105    /// Number of input measurement rows.
106    pub input_rows: usize,
107    /// Number of accepted rows.
108    pub accepted_rows: usize,
109    /// Number of rejected rows.
110    pub rejected_rows: usize,
111    /// Largest absolute normalized innovation across all rows.
112    pub max_abs_normalized_innovation: Option<f64>,
113    /// Largest absolute normalized innovation among rejected rows.
114    pub max_rejected_abs_normalized_innovation: Option<f64>,
115    /// Whether too few rows remained to apply the update.
116    pub coasted: bool,
117}
118
119/// Diagnostics from one EKF correction attempt.
120#[derive(Debug, Clone, PartialEq)]
121pub struct EkfCorrectionReport {
122    /// Whether the correction was applied to the nominal state and covariance.
123    pub applied: bool,
124    /// Normalized innovation squared for the rows used by the report.
125    pub normalized_innovation_squared: f64,
126    /// Number of rows accepted by screening or used without screening.
127    pub accepted_rows: usize,
128    /// Number of rows rejected by screening.
129    pub rejected_rows: usize,
130    /// Optional innovation gate diagnostics.
131    pub innovation_gate: Option<InnovationGateReport>,
132    /// Innovation covariance `S`.
133    pub innovation_covariance: Vec<Vec<f64>>,
134    /// Kalman gain `K`.
135    pub kalman_gain: Vec<Vec<f64>>,
136    /// Error-state estimate applied to the closed-loop nominal state.
137    pub dx: Vec<f64>,
138}
139
140/// Apply one EKF correction, then close the loop and reset the error vector.
141pub fn ekf_correct_closed_loop(
142    state: &mut InsFilterState,
143    correction: &EkfCorrection,
144    options: EkfUpdateOptions,
145) -> Result<EkfCorrectionReport, FusionError> {
146    state.validate()?;
147    correction.validate_for_dimension(state.dimension())?;
148
149    if let Some(gate) = options.innovation_gate {
150        gate.validate()?;
151        let full_s = innovation_covariance(&state.covariance, correction)?;
152        let (screened, report) = screen_correction(correction, &full_s, gate)?;
153        let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
154        if report.coasted {
155            return Ok(EkfCorrectionReport {
156                applied: false,
157                normalized_innovation_squared: full_nis,
158                accepted_rows: report.accepted_rows,
159                rejected_rows: report.rejected_rows,
160                innovation_gate: Some(report),
161                innovation_covariance: full_s,
162                kalman_gain: vec![vec![0.0; correction.row_count()]; state.dimension()],
163                dx: vec![0.0; state.dimension()],
164            });
165        }
166        let accepted_rows = report.accepted_rows;
167        let rejected_rows = report.rejected_rows;
168        let mut applied = apply_correction(state, &screened)?;
169        applied.accepted_rows = accepted_rows;
170        applied.rejected_rows = rejected_rows;
171        applied.innovation_gate = Some(report);
172        return Ok(applied);
173    }
174
175    apply_correction(state, correction)
176}
177
178/// Compute Joseph-form covariance update.
179pub fn joseph_covariance_update(
180    covariance: &[Vec<f64>],
181    design: &[Vec<f64>],
182    kalman_gain: &[Vec<f64>],
183    measurement_covariance: &[Vec<f64>],
184) -> Result<Vec<Vec<f64>>, FusionError> {
185    let dimension = covariance.len();
186    validate_covariance_matrix(covariance, dimension, "covariance")?;
187    if design.is_empty() {
188        return Err(invalid_input("design", "must not be empty"));
189    }
190    validate_matrix_cols(design, dimension, "design")?;
191    if kalman_gain.len() != dimension {
192        return Err(FusionError::DimensionMismatch {
193            field: "kalman_gain",
194            expected: dimension,
195            actual: kalman_gain.len(),
196        });
197    }
198    validate_matrix_cols(kalman_gain, design.len(), "kalman_gain")?;
199    validate_measurement_covariance(measurement_covariance, design.len())?;
200
201    let kh = matmul(kalman_gain, design)?;
202    let identity_minus_kh = matrix_sub(&identity(dimension), &kh)?;
203    let left = matmul(&identity_minus_kh, covariance)?;
204    let right = transpose(&identity_minus_kh)?;
205    let stabilized = matmul(&left, &right)?;
206    let kr = matmul(kalman_gain, measurement_covariance)?;
207    let k_t = transpose(kalman_gain)?;
208    let noise = matmul(&kr, &k_t)?;
209    let mut updated = matrix_add(&stabilized, &noise)?;
210    reproject_covariance_psd(&mut updated, "joseph_covariance")?;
211    Ok(updated)
212}
213
214/// Apply an indirect error estimate to the nominal INS state.
215pub fn apply_closed_loop_error(
216    state: &mut crate::inertial::NavState,
217    dx: &[f64],
218    layout: ErrorStateLayout,
219) -> Result<(), FusionError> {
220    layout.validate_len(dx.len(), "dx")?;
221    validate_finite_slice(dx, "dx")?;
222    if layout.includes_scale_factors()
223        && dx[ERROR_ACCEL_SCALE_INDEX..ERROR_GYRO_SCALE_INDEX + 3]
224            .iter()
225            .any(|value| *value != 0.0)
226    {
227        return Err(invalid_input(
228            "dx",
229            "scale-factor errors require filter state",
230        ));
231    }
232    apply_closed_loop_navigation_error(state, dx)
233}
234
235pub(super) fn apply_closed_loop_navigation_error(
236    state: &mut crate::inertial::NavState,
237    dx: &[f64],
238) -> Result<(), FusionError> {
239    for axis in 0..3 {
240        state.position_ecef_m[axis] -= dx[ERROR_POSITION_INDEX + axis];
241        state.velocity_ecef_mps[axis] -= dx[ERROR_VELOCITY_INDEX + axis];
242    }
243
244    let psi = [
245        dx[ERROR_ATTITUDE_INDEX],
246        dx[ERROR_ATTITUDE_INDEX + 1],
247        dx[ERROR_ATTITUDE_INDEX + 2],
248    ];
249    let psi_skew = skew(psi);
250    let mut correction = mat3_identity();
251    for row in 0..3 {
252        for col in 0..3 {
253            correction[row][col] -= psi_skew[row][col];
254        }
255    }
256    let attitude = inline_rxr(&correction, &state.attitude_body_to_ecef);
257    state.attitude_body_to_ecef = reorthonormalize_dcm(&attitude)?;
258
259    for axis in 0..3 {
260        state.accel_bias_mps2[axis] += dx[ERROR_ACCEL_BIAS_INDEX + axis];
261        state.gyro_bias_rps[axis] += dx[ERROR_GYRO_BIAS_INDEX + axis];
262    }
263    state.validate()?;
264    Ok(())
265}
266
267pub(super) fn apply_closed_loop_scale_error(state: &mut InsFilterState, dx: &[f64]) {
268    if state.layout().includes_scale_factors() {
269        for axis in 0..3 {
270            state.accel_scale_factor[axis] += dx[ERROR_ACCEL_SCALE_INDEX + axis];
271            state.gyro_scale_factor[axis] += dx[ERROR_GYRO_SCALE_INDEX + axis];
272        }
273    }
274}
275
276fn apply_correction(
277    state: &mut InsFilterState,
278    correction: &EkfCorrection,
279) -> Result<EkfCorrectionReport, FusionError> {
280    let dimension = state.dimension();
281    let s = innovation_covariance(&state.covariance, correction)?;
282    let h_t = transpose(&correction.design)?;
283    let p_h_t = matmul(&state.covariance, &h_t)?;
284    let mut kalman_gain = vec![vec![0.0; correction.row_count()]; dimension];
285    let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
286    for row in 0..dimension {
287        kalman_gain[row] = solve_spd(&s, &p_h_t[row], &mut scratch)?;
288    }
289    let dx = matvec(&kalman_gain, &correction.innovation)?;
290    let nis = normalized_innovation_squared(&s, &correction.innovation)?;
291    let covariance = joseph_covariance_update(
292        &state.covariance,
293        &correction.design,
294        &kalman_gain,
295        &correction.measurement_covariance,
296    )?;
297
298    apply_closed_loop_navigation_error(&mut state.nominal, &dx)?;
299    apply_closed_loop_scale_error(state, &dx);
300    state.covariance = covariance;
301    state.reset_error_state();
302    state.validate()?;
303
304    Ok(EkfCorrectionReport {
305        applied: true,
306        normalized_innovation_squared: nis,
307        accepted_rows: correction.row_count(),
308        rejected_rows: 0,
309        innovation_gate: None,
310        innovation_covariance: s,
311        kalman_gain,
312        dx,
313    })
314}
315
316pub(super) fn innovation_covariance(
317    covariance: &[Vec<f64>],
318    correction: &EkfCorrection,
319) -> Result<Vec<Vec<f64>>, FusionError> {
320    let hp = matmul(&correction.design, covariance)?;
321    let h_t = transpose(&correction.design)?;
322    let hph_t = matmul(&hp, &h_t)?;
323    matrix_add(&hph_t, &correction.measurement_covariance)
324}
325
326fn validate_measurement_covariance(
327    measurement_covariance: &[Vec<f64>],
328    dimension: usize,
329) -> Result<(), FusionError> {
330    if dimension == 0 {
331        return Err(invalid_input("measurement_covariance", "must not be empty"));
332    }
333    validate_covariance_matrix(measurement_covariance, dimension, "measurement_covariance")?;
334    let matrix = dmatrix_from_rows(measurement_covariance);
335    if matrix.cholesky().is_some() {
336        Ok(())
337    } else {
338        Err(FusionError::NonPositiveDefinite {
339            field: "measurement_covariance",
340        })
341    }
342}
343
344pub(super) fn normalized_innovation_squared(
345    innovation_covariance: &[Vec<f64>],
346    innovation: &[f64],
347) -> Result<f64, FusionError> {
348    validate_square_matrix(
349        innovation_covariance,
350        innovation.len(),
351        "innovation_covariance",
352    )?;
353    validate_finite_slice(innovation, "innovation")?;
354    let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
355    let solved = solve_spd(innovation_covariance, innovation, &mut scratch)?;
356    Ok(innovation
357        .iter()
358        .zip(solved.iter())
359        .map(|(a, b)| a * b)
360        .sum())
361}
362
363pub(super) fn screen_correction(
364    correction: &EkfCorrection,
365    innovation_covariance: &[Vec<f64>],
366    gate: InnovationGate,
367) -> Result<(EkfCorrection, InnovationGateReport), FusionError> {
368    let mut accepted_indices = Vec::with_capacity(correction.row_count());
369    let mut rejected_rows = 0usize;
370    let mut max_abs_normalized_innovation = None;
371    let mut max_rejected_abs_normalized_innovation = None;
372
373    for (row, s_row) in innovation_covariance
374        .iter()
375        .enumerate()
376        .take(correction.row_count())
377    {
378        let variance = s_row[row];
379        validate_positive(variance, "innovation_covariance_diagonal")?;
380        let normalized = (correction.innovation[row] / variance.sqrt()).abs();
381        max_abs_normalized_innovation = Some(
382            max_abs_normalized_innovation
383                .map_or(normalized, |current: f64| current.max(normalized)),
384        );
385        if normalized <= gate.threshold_sigma {
386            accepted_indices.push(row);
387        } else {
388            rejected_rows += 1;
389            max_rejected_abs_normalized_innovation = Some(
390                max_rejected_abs_normalized_innovation
391                    .map_or(normalized, |current: f64| current.max(normalized)),
392            );
393        }
394    }
395
396    let coasted = accepted_indices.len() < gate.min_rows;
397    let report = InnovationGateReport {
398        threshold_sigma: gate.threshold_sigma,
399        min_rows: gate.min_rows,
400        input_rows: correction.row_count(),
401        accepted_rows: accepted_indices.len(),
402        rejected_rows,
403        max_abs_normalized_innovation,
404        max_rejected_abs_normalized_innovation,
405        coasted,
406    };
407
408    if coasted {
409        return Ok((correction.clone(), report));
410    }
411
412    let innovation = accepted_indices
413        .iter()
414        .map(|idx| correction.innovation[*idx])
415        .collect::<Vec<_>>();
416    let design = accepted_indices
417        .iter()
418        .map(|idx| correction.design[*idx].clone())
419        .collect::<Vec<_>>();
420    let mut measurement_covariance =
421        vec![vec![0.0; accepted_indices.len()]; accepted_indices.len()];
422    for (row_out, row_in) in accepted_indices.iter().enumerate() {
423        for (col_out, col_in) in accepted_indices.iter().enumerate() {
424            measurement_covariance[row_out][col_out] =
425                correction.measurement_covariance[*row_in][*col_in];
426        }
427    }
428    let screened = EkfCorrection::new(innovation, design, measurement_covariance)?;
429    Ok((screened, report))
430}
431
432#[cfg(test)]
433mod tests {
434    //! Provenance: EKF correction tests use the standard Kalman innovation
435    //! equations and Joseph stabilized covariance identity. The closed-loop
436    //! reset follows the indirect INS convention in Groves, Principles of GNSS,
437    //! Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
438    //! Chapter 14.1.
439
440    use super::*;
441    use crate::astro::constants::earth::WGS84_A_M;
442    use crate::inertial::state::mat3_identity;
443    use crate::inertial::NavState;
444
445    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
446        assert!(
447            (actual - expected).abs() <= tolerance,
448            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
449        );
450    }
451
452    fn nominal_state() -> NavState {
453        NavState::new(10.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity())
454            .expect("nominal state")
455    }
456
457    #[test]
458    fn closed_loop_reset_subtracts_navigation_errors_and_adds_biases() {
459        let mut state = nominal_state();
460        let mut dx = vec![0.0; 15];
461        dx[0] = 2.0;
462        dx[4] = -3.0;
463        dx[9] = 0.01;
464        dx[14] = -0.02;
465        apply_closed_loop_error(&mut state, &dx, ErrorStateLayout::Fifteen)
466            .expect("closed-loop reset");
467        assert_eq!(
468            state.position_ecef_m[0].to_bits(),
469            (WGS84_A_M - 2.0).to_bits()
470        );
471        assert_eq!(state.velocity_ecef_mps[1].to_bits(), 3.0_f64.to_bits());
472        assert_eq!(state.accel_bias_mps2[0].to_bits(), 0.01_f64.to_bits());
473        assert_eq!(state.gyro_bias_rps[2].to_bits(), (-0.02_f64).to_bits());
474    }
475
476    #[test]
477    fn closed_loop_nav_helper_rejects_nonzero_scale_errors() {
478        let mut state = nominal_state();
479        let mut dx = vec![0.0; 21];
480        dx[ERROR_ACCEL_SCALE_INDEX] = 0.25;
481        let err = apply_closed_loop_error(&mut state, &dx, ErrorStateLayout::TwentyOne)
482            .expect_err("scale errors require filter state");
483        assert!(matches!(
484            err,
485            FusionError::InvalidInput {
486                field: "dx",
487                reason: "scale-factor errors require filter state"
488            }
489        ));
490    }
491
492    #[test]
493    fn ekf_correction_applies_21_state_scale_errors_before_reset() {
494        let mut covariance = vec![vec![0.0; 21]; 21];
495        for (idx, row) in covariance.iter_mut().enumerate() {
496            row[idx] = 1.0;
497        }
498        let mut state =
499            InsFilterState::new(nominal_state(), ErrorStateLayout::TwentyOne, covariance)
500                .expect("filter state");
501        let mut design = vec![vec![0.0; 21]; 6];
502        for axis in 0..3 {
503            design[axis][ERROR_ACCEL_SCALE_INDEX + axis] = 1.0;
504            design[axis + 3][ERROR_GYRO_SCALE_INDEX + axis] = 1.0;
505        }
506        let correction = EkfCorrection::new(
507            vec![1.0, -2.0, 3.0, -4.0, 5.0, -6.0],
508            design,
509            vec![
510                vec![3.0, 0.0, 0.0, 0.0, 0.0, 0.0],
511                vec![0.0, 3.0, 0.0, 0.0, 0.0, 0.0],
512                vec![0.0, 0.0, 3.0, 0.0, 0.0, 0.0],
513                vec![0.0, 0.0, 0.0, 3.0, 0.0, 0.0],
514                vec![0.0, 0.0, 0.0, 0.0, 3.0, 0.0],
515                vec![0.0, 0.0, 0.0, 0.0, 0.0, 3.0],
516            ],
517        )
518        .expect("correction");
519
520        let report = ekf_correct_closed_loop(&mut state, &correction, EkfUpdateOptions::default())
521            .expect("ekf correction");
522
523        assert!(report.applied);
524        assert_eq!(state.error_state.as_slice(), &[0.0; 21]);
525        assert_eq!(state.accel_scale_factor[0].to_bits(), 0.25_f64.to_bits());
526        assert_eq!(state.accel_scale_factor[1].to_bits(), (-0.5_f64).to_bits());
527        assert_eq!(state.accel_scale_factor[2].to_bits(), 0.75_f64.to_bits());
528        assert_eq!(state.gyro_scale_factor[0].to_bits(), (-1.0_f64).to_bits());
529        assert_eq!(state.gyro_scale_factor[1].to_bits(), 1.25_f64.to_bits());
530        assert_eq!(state.gyro_scale_factor[2].to_bits(), (-1.5_f64).to_bits());
531    }
532
533    #[test]
534    fn joseph_matches_naive_well_conditioned_to_bits() {
535        let covariance = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
536        let design = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
537        let kalman_gain = vec![vec![0.5, 0.0], vec![0.0, 0.5]];
538        let measurement_covariance = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
539        let joseph =
540            joseph_covariance_update(&covariance, &design, &kalman_gain, &measurement_covariance)
541                .expect("joseph covariance");
542        let naive = naive_covariance_update(&covariance, &design, &kalman_gain).expect("naive");
543        for row in 0..2 {
544            for col in 0..2 {
545                assert_eq!(joseph[row][col].to_bits(), naive[row][col].to_bits());
546            }
547        }
548    }
549
550    #[test]
551    fn joseph_stays_psd_for_ill_conditioned_update_where_naive_fails() {
552        let covariance = vec![
553            vec![1.0e6, 1.0e6 * (1.0 - 2.0e-15)],
554            vec![1.0e6 * (1.0 - 2.0e-15), 1.0e6],
555        ];
556        let design = vec![vec![1.0, 0.0]];
557        let measurement_covariance = vec![vec![1.0e-30]];
558        let correction = EkfCorrection::new(vec![0.0], design.clone(), measurement_covariance)
559            .expect("correction");
560        let s = innovation_covariance(&covariance, &correction).expect("innovation covariance");
561        let h_t = transpose(&design).expect("transpose");
562        let p_h_t = matmul(&covariance, &h_t).expect("pht");
563        let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
564        let kalman_gain = vec![
565            solve_spd(&s, &p_h_t[0], &mut scratch).expect("gain row 0"),
566            solve_spd(&s, &p_h_t[1], &mut scratch).expect("gain row 1"),
567        ];
568        let joseph = joseph_covariance_update(
569            &covariance,
570            &design,
571            &kalman_gain,
572            &correction.measurement_covariance,
573        )
574        .expect("joseph covariance");
575        let naive = naive_covariance_update(&covariance, &design, &kalman_gain).expect("naive");
576
577        assert!(
578            super::super::state::covariance_is_positive_semidefinite(&joseph).expect("joseph psd")
579        );
580        assert!(
581            !super::super::state::covariance_is_positive_semidefinite(&naive).expect("naive psd"),
582            "naive covariance unexpectedly remained PSD: {naive:?}"
583        );
584    }
585
586    #[test]
587    fn ekf_correction_applies_closed_loop_and_resets_dx() {
588        let mut covariance = vec![vec![0.0; 15]; 15];
589        for (idx, row) in covariance.iter_mut().enumerate() {
590            row[idx] = 1.0;
591        }
592        let mut state = InsFilterState::new(nominal_state(), ErrorStateLayout::Fifteen, covariance)
593            .expect("filter state");
594        let mut design = vec![vec![0.0; 15]; 3];
595        for (axis, row) in design.iter_mut().enumerate().take(3) {
596            row[axis] = 1.0;
597        }
598        let correction = EkfCorrection::new(
599            vec![1.0, 0.0, 0.0],
600            design,
601            vec![
602                vec![1.0, 0.0, 0.0],
603                vec![0.0, 1.0, 0.0],
604                vec![0.0, 0.0, 1.0],
605            ],
606        )
607        .expect("correction");
608        let report = ekf_correct_closed_loop(
609            &mut state,
610            &correction,
611            EkfUpdateOptions {
612                innovation_gate: Some(InnovationGate {
613                    threshold_sigma: 3.0,
614                    min_rows: 3,
615                }),
616            },
617        )
618        .expect("ekf correction");
619        assert!(report.applied);
620        assert_close(report.normalized_innovation_squared, 0.5, 1.0e-16);
621        assert_eq!(state.error_state.as_slice(), &[0.0; 15]);
622        assert_close(state.nominal.position_ecef_m[0], WGS84_A_M - 0.5, 0.0);
623    }
624
625    #[test]
626    fn ekf_correction_rejects_singular_measurement_covariance() {
627        let mut design = vec![vec![0.0; 15]; 1];
628        design[0][0] = 1.0;
629        let err = EkfCorrection::new(vec![1.0], design, vec![vec![0.0]])
630            .expect_err("singular covariance must be rejected");
631        assert!(matches!(
632            err,
633            FusionError::NonPositiveDefinite {
634                field: "measurement_covariance"
635            }
636        ));
637    }
638
639    #[test]
640    fn innovation_gate_reports_rejected_rows_when_update_still_applies() {
641        let mut covariance = vec![vec![0.0; 15]; 15];
642        for (idx, row) in covariance.iter_mut().enumerate() {
643            row[idx] = 1.0;
644        }
645        let mut state = InsFilterState::new(nominal_state(), ErrorStateLayout::Fifteen, covariance)
646            .expect("filter state");
647        let mut design = vec![vec![0.0; 15]; 2];
648        design[0][0] = 1.0;
649        design[1][1] = 1.0;
650        let correction = EkfCorrection::new(
651            vec![1.0, 10.0],
652            design,
653            vec![vec![1.0, 0.0], vec![0.0, 1.0]],
654        )
655        .expect("correction");
656        let report = ekf_correct_closed_loop(
657            &mut state,
658            &correction,
659            EkfUpdateOptions {
660                innovation_gate: Some(InnovationGate {
661                    threshold_sigma: 3.0,
662                    min_rows: 1,
663                }),
664            },
665        )
666        .expect("ekf correction");
667
668        assert!(report.applied);
669        assert_eq!(report.accepted_rows, 1);
670        assert_eq!(report.rejected_rows, 1);
671        let gate = report.innovation_gate.expect("gate report");
672        assert_eq!(gate.accepted_rows, 1);
673        assert_eq!(gate.rejected_rows, 1);
674    }
675
676    #[test]
677    fn innovation_gate_rejects_large_row_and_coasts_below_minimum() {
678        let mut covariance = vec![vec![0.0; 15]; 15];
679        for (idx, row) in covariance.iter_mut().enumerate() {
680            row[idx] = 1.0;
681        }
682        let mut state = InsFilterState::new(nominal_state(), ErrorStateLayout::Fifteen, covariance)
683            .expect("filter state");
684        let mut design = vec![vec![0.0; 15]; 1];
685        design[0][0] = 1.0;
686        let correction =
687            EkfCorrection::new(vec![10.0], design, vec![vec![1.0]]).expect("correction");
688        let report = ekf_correct_closed_loop(
689            &mut state,
690            &correction,
691            EkfUpdateOptions {
692                innovation_gate: Some(InnovationGate {
693                    threshold_sigma: 3.0,
694                    min_rows: 1,
695                }),
696            },
697        )
698        .expect("ekf correction");
699        assert!(!report.applied);
700        assert_eq!(report.accepted_rows, 0);
701        assert_eq!(report.rejected_rows, 1);
702        assert_eq!(
703            state.nominal.position_ecef_m[0].to_bits(),
704            WGS84_A_M.to_bits()
705        );
706    }
707
708    fn naive_covariance_update(
709        covariance: &[Vec<f64>],
710        design: &[Vec<f64>],
711        kalman_gain: &[Vec<f64>],
712    ) -> Result<Vec<Vec<f64>>, FusionError> {
713        let kh = matmul(kalman_gain, design)?;
714        let identity_minus_kh = matrix_sub(&identity(covariance.len()), &kh)?;
715        matmul(&identity_minus_kh, covariance)
716    }
717}