1use 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#[derive(Debug, Clone, PartialEq)]
18pub struct EkfCorrection {
19 pub innovation: Vec<f64>,
21 pub design: Vec<Vec<f64>>,
23 pub measurement_covariance: Vec<Vec<f64>>,
25}
26
27impl EkfCorrection {
28 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 pub fn row_count(&self) -> usize {
55 self.innovation.len()
56 }
57
58 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#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct InnovationGate {
79 pub threshold_sigma: f64,
81 pub min_rows: usize,
83}
84
85impl InnovationGate {
86 pub fn validate(&self) -> Result<(), FusionError> {
88 validate_positive(self.threshold_sigma, "threshold_sigma")
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Default)]
94pub struct EkfUpdateOptions {
95 pub innovation_gate: Option<InnovationGate>,
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub struct InnovationGateReport {
102 pub threshold_sigma: f64,
104 pub min_rows: usize,
106 pub input_rows: usize,
108 pub accepted_rows: usize,
110 pub rejected_rows: usize,
112 pub max_abs_normalized_innovation: Option<f64>,
114 pub max_rejected_abs_normalized_innovation: Option<f64>,
116 pub coasted: bool,
118}
119
120#[derive(Debug, Clone, PartialEq)]
122pub struct EkfCorrectionReport {
123 pub applied: bool,
125 pub normalized_innovation_squared: f64,
127 pub accepted_rows: usize,
129 pub rejected_rows: usize,
131 pub innovation_gate: Option<InnovationGateReport>,
133 pub innovation_covariance: Vec<Vec<f64>>,
135 pub kalman_gain: Vec<Vec<f64>>,
137 pub dx: Vec<f64>,
139}
140
141pub 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
179pub(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
231pub 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
267pub 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 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}