Skip to main content

sidereon_core/fusion/
error_state.rs

1//! ECEF indirect error-state system model and covariance prediction.
2
3use crate::astro::constants::earth::{GM_EARTH_M3_S2, OMEGA_E_DOT_RAD_S};
4use crate::astro::math::mat3::{mul_vec3, Mat3};
5use crate::astro::math::vec3::norm3;
6use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
7use crate::inertial::state::skew;
8use crate::inertial::{validate_vec3, ImuSpec, NavState};
9
10use super::state::{
11    identity, invalid_input, matmul, matrix_add, reproject_covariance_psd, symmetrize_in_place,
12    validate_covariance_matrix, validate_nonnegative, validate_positive, validate_square_matrix,
13    ErrorStateLayout, FusionError, ERROR_ACCEL_BIAS_INDEX, ERROR_ACCEL_SCALE_INDEX,
14    ERROR_ATTITUDE_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_GYRO_SCALE_INDEX, ERROR_POSITION_INDEX,
15    ERROR_VELOCITY_INDEX,
16};
17
18/// Body-frame IMU kinematics used to linearize the error-state model.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct ErrorStateImuKinematics {
21    /// Specific force resolved in body axes, in m/s^2.
22    pub specific_force_body_mps2: [f64; 3],
23    /// Angular rate resolved in body axes, in rad/s.
24    pub angular_rate_body_rps: [f64; 3],
25}
26
27impl ErrorStateImuKinematics {
28    /// Build kinematics from body-frame specific force and angular rate.
29    pub fn new(
30        specific_force_body_mps2: [f64; 3],
31        angular_rate_body_rps: [f64; 3],
32    ) -> Result<Self, FusionError> {
33        validate_vec3(specific_force_body_mps2, "specific_force_body_mps2")
34            .map_err(FusionError::from)?;
35        validate_vec3(angular_rate_body_rps, "angular_rate_body_rps").map_err(FusionError::from)?;
36        Ok(Self {
37            specific_force_body_mps2,
38            angular_rate_body_rps,
39        })
40    }
41}
42
43/// Linearized continuous and discrete error-state model for one predict step.
44#[derive(Debug, Clone, PartialEq)]
45pub struct ErrorStateLinearization {
46    /// Continuous-time ECEF error-state system matrix.
47    pub f: Vec<Vec<f64>>,
48    /// Second-order state transition matrix.
49    pub phi: Vec<Vec<f64>>,
50    /// Discrete process-noise covariance.
51    pub q_d: Vec<Vec<f64>>,
52    /// Time step used for `phi` and `q_d`, in seconds.
53    pub dt_s: f64,
54    /// Specific force transformed into ECEF axes, in m/s^2.
55    pub specific_force_ecef_mps2: [f64; 3],
56}
57
58/// Build the ECEF error-state system matrix.
59pub fn error_state_system_matrix_ecef(
60    state: &NavState,
61    kinematics: ErrorStateImuKinematics,
62    imu_spec: &ImuSpec,
63    layout: ErrorStateLayout,
64) -> Result<Vec<Vec<f64>>, FusionError> {
65    state.validate()?;
66    imu_spec.validate()?;
67    let dimension = layout.dimension();
68    let mut f = vec![vec![0.0; dimension]; dimension];
69    let c_b_e = state.attitude_body_to_ecef;
70    let specific_force_ecef = mul_vec3(&c_b_e, kinematics.specific_force_body_mps2);
71
72    for axis in 0..3 {
73        f[ERROR_POSITION_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = 1.0;
74    }
75
76    let gravity_gradient = gravity_gradient_prompt_ecef(state.position_ecef_m)?;
77    add_mat3_block(
78        &mut f,
79        ERROR_VELOCITY_INDEX,
80        ERROR_POSITION_INDEX,
81        &gravity_gradient,
82    );
83
84    let omega = skew([0.0, 0.0, OMEGA_E_DOT_RAD_S]);
85    for row in 0..3 {
86        for col in 0..3 {
87            f[ERROR_VELOCITY_INDEX + row][ERROR_VELOCITY_INDEX + col] = -2.0 * omega[row][col];
88            f[ERROR_ATTITUDE_INDEX + row][ERROR_ATTITUDE_INDEX + col] = -omega[row][col];
89        }
90    }
91
92    let specific_force_skew = skew(specific_force_ecef);
93    for row in 0..3 {
94        for col in 0..3 {
95            f[ERROR_VELOCITY_INDEX + row][ERROR_ATTITUDE_INDEX + col] =
96                -specific_force_skew[row][col];
97            f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_BIAS_INDEX + col] = c_b_e[row][col];
98            f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_BIAS_INDEX + col] = -c_b_e[row][col];
99        }
100    }
101
102    fill_bias_decay(&mut f, ERROR_ACCEL_BIAS_INDEX, imu_spec.accel_bias_tau_s);
103    fill_bias_decay(&mut f, ERROR_GYRO_BIAS_INDEX, imu_spec.gyro_bias_tau_s);
104
105    if layout.includes_scale_factors() {
106        for row in 0..3 {
107            for col in 0..3 {
108                f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_SCALE_INDEX + col] =
109                    c_b_e[row][col] * kinematics.specific_force_body_mps2[col];
110                f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_SCALE_INDEX + col] =
111                    -c_b_e[row][col] * kinematics.angular_rate_body_rps[col];
112            }
113        }
114    }
115
116    Ok(f)
117}
118
119/// Discretize `F` as `I + F dt + 0.5 (F dt)^2`.
120pub fn error_state_transition_matrix(
121    f: &[Vec<f64>],
122    dt_s: f64,
123) -> Result<Vec<Vec<f64>>, FusionError> {
124    validate_nonnegative(dt_s, "dt_s")?;
125    let dimension = f.len();
126    if dimension != ErrorStateLayout::Fifteen.dimension()
127        && dimension != ErrorStateLayout::TwentyOne.dimension()
128    {
129        return Err(invalid_input("f", "dimension must be 15 or 21"));
130    }
131    validate_square_matrix(f, dimension, "f")?;
132
133    let mut fdt = vec![vec![0.0; dimension]; dimension];
134    for row in 0..dimension {
135        for col in 0..dimension {
136            fdt[row][col] = f[row][col] * dt_s;
137        }
138    }
139    let fdt2 = matmul(&fdt, &fdt)?;
140    let mut phi = identity(dimension);
141    for row in 0..dimension {
142        for col in 0..dimension {
143            phi[row][col] += fdt[row][col] + 0.5 * fdt2[row][col];
144        }
145    }
146    Ok(phi)
147}
148
149/// Build the discrete process-noise covariance from an [`ImuSpec`].
150pub fn error_state_process_noise_discrete(
151    imu_spec: &ImuSpec,
152    dt_s: f64,
153    layout: ErrorStateLayout,
154) -> Result<Vec<Vec<f64>>, FusionError> {
155    imu_spec.validate()?;
156    validate_nonnegative(dt_s, "dt_s")?;
157    let dimension = layout.dimension();
158    let mut q_d = vec![vec![0.0; dimension]; dimension];
159    let q_accel = imu_spec.accel_vrw_mps_sqrt_s * imu_spec.accel_vrw_mps_sqrt_s;
160    let q_gyro = imu_spec.gyro_arw_rad_sqrt_s * imu_spec.gyro_arw_rad_sqrt_s;
161    let dt = dt_s.abs();
162    let dt2 = dt * dt;
163    let dt3 = dt2 * dt;
164
165    for axis in 0..3 {
166        q_d[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis] = q_accel * dt3 / 3.0;
167        q_d[ERROR_POSITION_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = q_accel * dt2 / 2.0;
168        q_d[ERROR_VELOCITY_INDEX + axis][ERROR_POSITION_INDEX + axis] = q_accel * dt2 / 2.0;
169        q_d[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = q_accel * dt;
170        q_d[ERROR_ATTITUDE_INDEX + axis][ERROR_ATTITUDE_INDEX + axis] = q_gyro * dt;
171        q_d[ERROR_ACCEL_BIAS_INDEX + axis][ERROR_ACCEL_BIAS_INDEX + axis] =
172            imu_spec.accel_bias_variance_increment(dt_s)?;
173        q_d[ERROR_GYRO_BIAS_INDEX + axis][ERROR_GYRO_BIAS_INDEX + axis] =
174            imu_spec.gyro_bias_variance_increment(dt_s)?;
175    }
176
177    if layout.includes_scale_factors() {
178        let accel_scale = imu_spec.accel_scale_instab_ppm.unwrap_or(0.0) * 1.0e-6;
179        let gyro_scale = imu_spec.gyro_scale_instab_ppm.unwrap_or(0.0) * 1.0e-6;
180        let accel_scale_q = accel_scale * accel_scale;
181        let gyro_scale_q = gyro_scale * gyro_scale;
182        for axis in 0..3 {
183            q_d[ERROR_ACCEL_SCALE_INDEX + axis][ERROR_ACCEL_SCALE_INDEX + axis] =
184                accel_scale_q * dt;
185            q_d[ERROR_GYRO_SCALE_INDEX + axis][ERROR_GYRO_SCALE_INDEX + axis] = gyro_scale_q * dt;
186        }
187    }
188
189    reproject_covariance_psd(&mut q_d, "process_noise")?;
190    Ok(q_d)
191}
192
193/// Build `F`, `Phi`, and `Q_d` for one error-state predict step.
194pub fn linearize_error_state_ecef(
195    state: &NavState,
196    kinematics: ErrorStateImuKinematics,
197    imu_spec: &ImuSpec,
198    dt_s: f64,
199    layout: ErrorStateLayout,
200) -> Result<ErrorStateLinearization, FusionError> {
201    let f = error_state_system_matrix_ecef(state, kinematics, imu_spec, layout)?;
202    let phi = error_state_transition_matrix(&f, dt_s)?;
203    let q_d = error_state_process_noise_discrete(imu_spec, dt_s, layout)?;
204    Ok(ErrorStateLinearization {
205        f,
206        phi,
207        q_d,
208        dt_s,
209        specific_force_ecef_mps2: mul_vec3(
210            &state.attitude_body_to_ecef,
211            kinematics.specific_force_body_mps2,
212        ),
213    })
214}
215
216/// Predict covariance as `P = Phi P Phi^T + Q_d`.
217pub fn predict_error_state_covariance(
218    covariance: &mut Vec<Vec<f64>>,
219    phi: &[Vec<f64>],
220    q_d: &[Vec<f64>],
221) -> Result<(), FusionError> {
222    let dimension = covariance.len();
223    if dimension != ErrorStateLayout::Fifteen.dimension()
224        && dimension != ErrorStateLayout::TwentyOne.dimension()
225    {
226        return Err(invalid_input("covariance", "dimension must be 15 or 21"));
227    }
228    validate_covariance_matrix(covariance, dimension, "covariance")?;
229    validate_square_matrix(phi, dimension, "phi")?;
230    validate_covariance_matrix(q_d, dimension, "q_d")?;
231
232    let mut temp = vec![vec![0.0; dimension]; dimension];
233    for i in 0..dimension {
234        for j in 0..dimension {
235            for k in 0..dimension {
236                temp[i][j] += phi[i][k] * covariance[k][j];
237            }
238        }
239    }
240
241    let mut propagated = vec![vec![0.0; dimension]; dimension];
242    for i in 0..dimension {
243        for j in 0..dimension {
244            for k in 0..dimension {
245                propagated[i][j] += temp[i][k] * phi[j][k];
246            }
247        }
248    }
249    let propagated = matrix_add(&propagated, q_d)?;
250    *covariance = propagated;
251    symmetrize_in_place(covariance);
252    reproject_covariance_psd(covariance, "covariance")
253}
254
255pub(crate) fn gravity_gradient_prompt_ecef(position_ecef_m: [f64; 3]) -> Result<Mat3, FusionError> {
256    validate_vec3(position_ecef_m, "position_ecef_m").map_err(FusionError::from)?;
257    let radius_m = norm3(position_ecef_m);
258    validate_positive(radius_m, "position_radius_m")?;
259    let radius3 = radius_m * radius_m * radius_m;
260    let scale = -GM_EARTH_M3_S2 / radius3;
261    let r_hat = [
262        position_ecef_m[0] / radius_m,
263        position_ecef_m[1] / radius_m,
264        position_ecef_m[2] / radius_m,
265    ];
266    let omega = skew([0.0, 0.0, OMEGA_E_DOT_RAD_S]);
267    let omega2 = [
268        [
269            omega[0][0] * omega[0][0] + omega[0][1] * omega[1][0] + omega[0][2] * omega[2][0],
270            omega[0][0] * omega[0][1] + omega[0][1] * omega[1][1] + omega[0][2] * omega[2][1],
271            omega[0][0] * omega[0][2] + omega[0][1] * omega[1][2] + omega[0][2] * omega[2][2],
272        ],
273        [
274            omega[1][0] * omega[0][0] + omega[1][1] * omega[1][0] + omega[1][2] * omega[2][0],
275            omega[1][0] * omega[0][1] + omega[1][1] * omega[1][1] + omega[1][2] * omega[2][1],
276            omega[1][0] * omega[0][2] + omega[1][1] * omega[1][2] + omega[1][2] * omega[2][2],
277        ],
278        [
279            omega[2][0] * omega[0][0] + omega[2][1] * omega[1][0] + omega[2][2] * omega[2][0],
280            omega[2][0] * omega[0][1] + omega[2][1] * omega[1][1] + omega[2][2] * omega[2][1],
281            omega[2][0] * omega[0][2] + omega[2][1] * omega[1][2] + omega[2][2] * omega[2][2],
282        ],
283    ];
284    let mut gradient = [[0.0; 3]; 3];
285    for row in 0..3 {
286        for col in 0..3 {
287            let identity = if row == col { 1.0 } else { 0.0 };
288            gradient[row][col] =
289                scale * (identity - 3.0 * r_hat[row] * r_hat[col]) - omega2[row][col];
290        }
291    }
292    Ok(gradient)
293}
294
295fn fill_bias_decay(f: &mut [Vec<f64>], index: usize, tau_s: f64) {
296    if tau_s != RANDOM_WALK_BIAS_TAU_S && tau_s.is_finite() {
297        for axis in 0..3 {
298            f[index + axis][index + axis] = -1.0 / tau_s;
299        }
300    }
301}
302
303fn add_mat3_block(matrix: &mut [Vec<f64>], row0: usize, col0: usize, block: &Mat3) {
304    for row in 0..3 {
305        for col in 0..3 {
306            matrix[row0 + row][col0 + col] += block[row][col];
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    //! Provenance: these tests use the ECEF indirect error-state equations from
314    //! Groves, Principles of GNSS, Inertial, and Multisensor Integrated
315    //! Navigation Systems, 2nd ed., Section 14.2.4. The ECEF gravity-gradient
316    //! sign and centrifugal derivative are cross-checked against the public
317    //! Sandia ESKF derivation, OSTI report 2516824, equations 7.58 and 7.65.
318    //! The process-noise position/velocity block follows the white-acceleration integral
319    //! `q * [[dt^3/3, dt^2/2], [dt^2/2, dt]]`, with the same operation order
320    //! used by the covariance propagation process-noise increment.
321
322    use super::*;
323    use crate::astro::constants::earth::WGS84_A_M;
324    use crate::astro::math::mat3::{inline_rxr, inline_tr};
325    use crate::inertial::mechanization::mechanize_ecef;
326    use crate::inertial::state::{mat3_identity, reorthonormalize_dcm};
327    use crate::inertial::{CorrectedImuIncrement, MechanizationConfig};
328    use nalgebra::DMatrix;
329
330    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
331        assert!(
332            (actual - expected).abs() <= tolerance,
333            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
334        );
335    }
336
337    fn reference_state() -> NavState {
338        NavState::new(
339            0.0,
340            [WGS84_A_M + 1000.0, 25.0, -40.0],
341            [3.0, -2.0, 1.0],
342            mat3_identity(),
343        )
344        .expect("reference state")
345    }
346
347    fn reference_imu() -> ErrorStateImuKinematics {
348        ErrorStateImuKinematics::new([0.12, -0.05, 9.72], [0.004, -0.002, 0.001])
349            .expect("imu kinematics")
350    }
351
352    fn reference_spec() -> ImuSpec {
353        ImuSpec::datasheet(
354            0.02,
355            0.003,
356            0.004,
357            0.0002,
358            3600.0,
359            7200.0,
360            Some(25.0),
361            Some(30.0),
362        )
363    }
364
365    fn reference_increment(imu: ErrorStateImuKinematics, dt_s: f64) -> CorrectedImuIncrement {
366        CorrectedImuIncrement {
367            t_j2000_s: dt_s,
368            delta_velocity_mps: [
369                imu.specific_force_body_mps2[0] * dt_s,
370                imu.specific_force_body_mps2[1] * dt_s,
371                imu.specific_force_body_mps2[2] * dt_s,
372            ],
373            delta_theta_rad: [
374                imu.angular_rate_body_rps[0] * dt_s,
375                imu.angular_rate_body_rps[1] * dt_s,
376                imu.angular_rate_body_rps[2] * dt_s,
377            ],
378            dt_s,
379        }
380    }
381
382    fn attitude_error_ecef(perturbed: &Mat3, base: &Mat3) -> [f64; 3] {
383        let delta = inline_rxr(perturbed, &inline_tr(base));
384        [
385            0.5 * (delta[1][2] - delta[2][1]),
386            0.5 * (delta[2][0] - delta[0][2]),
387            0.5 * (delta[0][1] - delta[1][0]),
388        ]
389    }
390
391    #[test]
392    fn gravity_gradient_matches_published_ecef_point_mass_plus_centrifugal_bits() {
393        let radius_m = WGS84_A_M + 1000.0;
394        let gradient = gravity_gradient_prompt_ecef([radius_m, 0.0, 0.0]).expect("gradient");
395        let radius3 = radius_m * radius_m * radius_m;
396        let point = GM_EARTH_M3_S2 / radius3;
397        let omega2 = OMEGA_E_DOT_RAD_S * OMEGA_E_DOT_RAD_S;
398
399        assert_eq!(gradient[0][0].to_bits(), (2.0 * point + omega2).to_bits());
400        assert_eq!(gradient[1][1].to_bits(), (-point + omega2).to_bits());
401        assert_eq!(gradient[2][2].to_bits(), (-point).to_bits());
402        for (row, values) in gradient.iter().enumerate() {
403            for (col, value) in values.iter().enumerate() {
404                if row != col {
405                    assert_eq!(*value, 0.0);
406                }
407            }
408        }
409    }
410
411    #[test]
412    fn phi_matches_mechanization_finite_difference_for_kinematic_block() {
413        let state = reference_state();
414        let imu = reference_imu();
415        let spec = reference_spec();
416        let dt_s = 1.0e-5;
417        let epsilon = 1.0;
418        let linear =
419            linearize_error_state_ecef(&state, imu, &spec, dt_s, ErrorStateLayout::Fifteen)
420                .expect("linearization");
421        let increment = reference_increment(imu, dt_s);
422        let base_next =
423            mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("base step");
424
425        for col in 0..6 {
426            let mut perturbed = state;
427            if col < 3 {
428                perturbed.position_ecef_m[col] += epsilon;
429            } else {
430                perturbed.velocity_ecef_mps[col - 3] += epsilon;
431            }
432            let next = mechanize_ecef(&perturbed, &increment, MechanizationConfig::default())
433                .expect("perturbed step");
434            for row in 0..3 {
435                let fd = (next.position_ecef_m[row] - base_next.position_ecef_m[row]) / epsilon;
436                assert_close(fd, linear.phi[row][col], 2.0e-8);
437            }
438            for row in 0..3 {
439                let fd = (next.velocity_ecef_mps[row] - base_next.velocity_ecef_mps[row]) / epsilon;
440                assert_close(fd, linear.phi[ERROR_VELOCITY_INDEX + row][col], 5.0e-10);
441            }
442        }
443    }
444
445    #[test]
446    fn phi_matches_mechanization_finite_difference_for_attitude_error_block() {
447        let state = reference_state();
448        let imu = reference_imu();
449        let spec = reference_spec();
450        let dt_s = 1.0e-4;
451        let epsilon = 1.0e-6;
452        let linear =
453            linearize_error_state_ecef(&state, imu, &spec, dt_s, ErrorStateLayout::Fifteen)
454                .expect("linearization");
455        let increment = reference_increment(imu, dt_s);
456        let base_next =
457            mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("base step");
458
459        for col in 0..3 {
460            let mut psi = [0.0; 3];
461            psi[col] = epsilon;
462            let psi_skew = skew(psi);
463            let mut correction = mat3_identity();
464            for row in 0..3 {
465                for col in 0..3 {
466                    correction[row][col] += psi_skew[row][col];
467                }
468            }
469            let mut perturbed = state;
470            perturbed.attitude_body_to_ecef =
471                reorthonormalize_dcm(&inline_rxr(&correction, &state.attitude_body_to_ecef))
472                    .expect("perturbed attitude");
473            let next = mechanize_ecef(&perturbed, &increment, MechanizationConfig::default())
474                .expect("perturbed step");
475            let raw_attitude_error = attitude_error_ecef(
476                &next.attitude_body_to_ecef,
477                &base_next.attitude_body_to_ecef,
478            );
479            let psi_next = [
480                -raw_attitude_error[0],
481                -raw_attitude_error[1],
482                -raw_attitude_error[2],
483            ];
484
485            for (row, value) in psi_next.iter().enumerate() {
486                let fd = *value / epsilon;
487                assert_close(
488                    fd,
489                    linear.phi[ERROR_ATTITUDE_INDEX + row][ERROR_ATTITUDE_INDEX + col],
490                    2.0e-9,
491                );
492            }
493            for row in 0..3 {
494                let fd = (next.velocity_ecef_mps[row] - base_next.velocity_ecef_mps[row]) / epsilon;
495                assert_close(
496                    fd,
497                    linear.phi[ERROR_VELOCITY_INDEX + row][ERROR_ATTITUDE_INDEX + col],
498                    5.0e-7,
499                );
500            }
501        }
502    }
503
504    #[test]
505    fn qd_position_velocity_block_matches_closed_form_bits() {
506        let dt_s = 0.125_f64;
507        let q_accel = 0.02_f64 * 0.02_f64;
508        let spec = ImuSpec::datasheet(
509            0.02,
510            0.0,
511            0.0,
512            0.0,
513            RANDOM_WALK_BIAS_TAU_S,
514            RANDOM_WALK_BIAS_TAU_S,
515            None,
516            None,
517        );
518        let q_d = error_state_process_noise_discrete(&spec, dt_s, ErrorStateLayout::Fifteen)
519            .expect("process noise");
520        let dt = dt_s.abs();
521        let dt2 = dt * dt;
522        let dt3 = dt2 * dt;
523        for axis in 0..3 {
524            assert_eq!(q_d[axis][axis].to_bits(), (q_accel * dt3 / 3.0).to_bits());
525            assert_eq!(
526                q_d[axis][ERROR_VELOCITY_INDEX + axis].to_bits(),
527                (q_accel * dt2 / 2.0).to_bits()
528            );
529            assert_eq!(
530                q_d[ERROR_VELOCITY_INDEX + axis][axis].to_bits(),
531                (q_accel * dt2 / 2.0).to_bits()
532            );
533            assert_eq!(
534                q_d[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis].to_bits(),
535                (q_accel * dt).to_bits()
536            );
537        }
538    }
539
540    #[test]
541    fn propagate_only_logdet_grows_monotonically_with_process_noise() {
542        let state = reference_state();
543        let imu = reference_imu();
544        let spec = ImuSpec::datasheet(0.2, 0.03, 0.02, 0.002, 600.0, 900.0, None, None);
545        let mut covariance = vec![vec![0.0; 15]; 15];
546        for (idx, row) in covariance.iter_mut().enumerate() {
547            row[idx] = 1.0e-3;
548        }
549        let mut previous = logdet(&covariance);
550        for _ in 0..12 {
551            let linear =
552                linearize_error_state_ecef(&state, imu, &spec, 0.02, ErrorStateLayout::Fifteen)
553                    .expect("linearization");
554            predict_error_state_covariance(&mut covariance, &linear.phi, &linear.q_d)
555                .expect("predict covariance");
556            let current = logdet(&covariance);
557            assert!(
558                current > previous,
559                "current {current:.17e}, previous {previous:.17e}"
560            );
561            previous = current;
562        }
563    }
564
565    fn logdet(covariance: &[Vec<f64>]) -> f64 {
566        let flat = covariance
567            .iter()
568            .flat_map(|row| row.iter().copied())
569            .collect::<Vec<_>>();
570        let matrix = DMatrix::from_row_slice(covariance.len(), covariance.len(), &flat);
571        let cholesky = matrix.cholesky().expect("test covariance is SPD");
572        2.0 * (0..covariance.len())
573            .map(|idx| cholesky.l()[(idx, idx)].ln())
574            .sum::<f64>()
575    }
576}