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(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
230pub 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
266pub 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 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}