1use 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#[derive(Debug, Clone, PartialEq)]
17pub struct EkfCorrection {
18 pub innovation: Vec<f64>,
20 pub design: Vec<Vec<f64>>,
22 pub measurement_covariance: Vec<Vec<f64>>,
24}
25
26impl EkfCorrection {
27 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 pub fn row_count(&self) -> usize {
54 self.innovation.len()
55 }
56
57 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#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct InnovationGate {
78 pub threshold_sigma: f64,
80 pub min_rows: usize,
82}
83
84impl InnovationGate {
85 pub fn validate(&self) -> Result<(), FusionError> {
87 validate_positive(self.threshold_sigma, "threshold_sigma")
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Default)]
93pub struct EkfUpdateOptions {
94 pub innovation_gate: Option<InnovationGate>,
96}
97
98#[derive(Debug, Clone, PartialEq)]
100pub struct InnovationGateReport {
101 pub threshold_sigma: f64,
103 pub min_rows: usize,
105 pub input_rows: usize,
107 pub accepted_rows: usize,
109 pub rejected_rows: usize,
111 pub max_abs_normalized_innovation: Option<f64>,
113 pub max_rejected_abs_normalized_innovation: Option<f64>,
115 pub coasted: bool,
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct EkfCorrectionReport {
122 pub applied: bool,
124 pub normalized_innovation_squared: f64,
126 pub accepted_rows: usize,
128 pub rejected_rows: usize,
130 pub innovation_gate: Option<InnovationGateReport>,
132 pub innovation_covariance: Vec<Vec<f64>>,
134 pub kalman_gain: Vec<Vec<f64>>,
136 pub dx: Vec<f64>,
138}
139
140pub 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
178pub 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
214pub 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 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}