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