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