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::{inline_tr, mul_vec3, Mat3};
5use crate::astro::math::vec3::norm3;
6use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
7use crate::inertial::state::{mat3_identity, skew, validate_dcm_orthonormal};
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    error_state_system_matrix_ecef_with_imu_to_body(
66        state,
67        kinematics,
68        imu_spec,
69        layout,
70        mat3_identity(),
71    )
72}
73
74/// Build the ECEF error-state system matrix with IMU axes rotated into body axes.
75pub fn error_state_system_matrix_ecef_with_imu_to_body(
76    state: &NavState,
77    kinematics: ErrorStateImuKinematics,
78    imu_spec: &ImuSpec,
79    layout: ErrorStateLayout,
80    imu_to_body_dcm: Mat3,
81) -> Result<Vec<Vec<f64>>, FusionError> {
82    state.validate()?;
83    imu_spec.validate()?;
84    validate_dcm_orthonormal(&imu_to_body_dcm, "imu_to_body_dcm").map_err(FusionError::from)?;
85    let dimension = layout.dimension();
86    let mut f = vec![vec![0.0; dimension]; dimension];
87    let c_b_e = state.attitude_body_to_ecef;
88    let c_imu_e = crate::astro::math::mat3::inline_rxr(&c_b_e, &imu_to_body_dcm);
89    let specific_force_ecef = mul_vec3(&c_b_e, kinematics.specific_force_body_mps2);
90    let body_to_imu_dcm = inline_tr(&imu_to_body_dcm);
91    let specific_force_imu = mul_vec3(&body_to_imu_dcm, kinematics.specific_force_body_mps2);
92    let angular_rate_imu = mul_vec3(&body_to_imu_dcm, kinematics.angular_rate_body_rps);
93
94    for axis in 0..3 {
95        f[ERROR_POSITION_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = 1.0;
96    }
97
98    let gravity_gradient = gravity_gradient_prompt_ecef(state.position_ecef_m)?;
99    add_mat3_block(
100        &mut f,
101        ERROR_VELOCITY_INDEX,
102        ERROR_POSITION_INDEX,
103        &gravity_gradient,
104    );
105
106    let omega = skew([0.0, 0.0, OMEGA_E_DOT_RAD_S]);
107    for row in 0..3 {
108        for col in 0..3 {
109            f[ERROR_VELOCITY_INDEX + row][ERROR_VELOCITY_INDEX + col] = -2.0 * omega[row][col];
110            f[ERROR_ATTITUDE_INDEX + row][ERROR_ATTITUDE_INDEX + col] = -omega[row][col];
111        }
112    }
113
114    let specific_force_skew = skew(specific_force_ecef);
115    for row in 0..3 {
116        for col in 0..3 {
117            f[ERROR_VELOCITY_INDEX + row][ERROR_ATTITUDE_INDEX + col] =
118                -specific_force_skew[row][col];
119            f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_BIAS_INDEX + col] = c_imu_e[row][col];
120            f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_BIAS_INDEX + col] = -c_imu_e[row][col];
121        }
122    }
123
124    fill_bias_decay(&mut f, ERROR_ACCEL_BIAS_INDEX, imu_spec.accel_bias_tau_s);
125    fill_bias_decay(&mut f, ERROR_GYRO_BIAS_INDEX, imu_spec.gyro_bias_tau_s);
126
127    if layout.includes_scale_factors() {
128        for row in 0..3 {
129            for col in 0..3 {
130                f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_SCALE_INDEX + col] =
131                    c_imu_e[row][col] * specific_force_imu[col];
132                f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_SCALE_INDEX + col] =
133                    -c_imu_e[row][col] * angular_rate_imu[col];
134            }
135        }
136    }
137
138    Ok(f)
139}
140
141/// Discretize `F` as `I + F dt + 0.5 (F dt)^2`.
142pub fn error_state_transition_matrix(
143    f: &[Vec<f64>],
144    dt_s: f64,
145) -> Result<Vec<Vec<f64>>, FusionError> {
146    validate_nonnegative(dt_s, "dt_s")?;
147    let dimension = f.len();
148    if dimension != ErrorStateLayout::Fifteen.dimension()
149        && dimension != ErrorStateLayout::TwentyOne.dimension()
150    {
151        return Err(invalid_input("f", "dimension must be 15 or 21"));
152    }
153    validate_square_matrix(f, dimension, "f")?;
154
155    let mut fdt = vec![vec![0.0; dimension]; dimension];
156    for row in 0..dimension {
157        for col in 0..dimension {
158            fdt[row][col] = f[row][col] * dt_s;
159        }
160    }
161    let fdt2 = matmul(&fdt, &fdt)?;
162    let mut phi = identity(dimension);
163    for row in 0..dimension {
164        for col in 0..dimension {
165            phi[row][col] += fdt[row][col] + 0.5 * fdt2[row][col];
166        }
167    }
168    Ok(phi)
169}
170
171/// Build the discrete process-noise covariance from an [`ImuSpec`].
172pub fn error_state_process_noise_discrete(
173    imu_spec: &ImuSpec,
174    dt_s: f64,
175    layout: ErrorStateLayout,
176) -> Result<Vec<Vec<f64>>, FusionError> {
177    imu_spec.validate()?;
178    validate_nonnegative(dt_s, "dt_s")?;
179    let dimension = layout.dimension();
180    let mut q_d = vec![vec![0.0; dimension]; dimension];
181    let q_accel = imu_spec.accel_vrw_mps_sqrt_s * imu_spec.accel_vrw_mps_sqrt_s;
182    let q_gyro = imu_spec.gyro_arw_rad_sqrt_s * imu_spec.gyro_arw_rad_sqrt_s;
183    let dt = dt_s.abs();
184    let dt2 = dt * dt;
185    let dt3 = dt2 * dt;
186
187    for axis in 0..3 {
188        q_d[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis] = q_accel * dt3 / 3.0;
189        q_d[ERROR_POSITION_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = q_accel * dt2 / 2.0;
190        q_d[ERROR_VELOCITY_INDEX + axis][ERROR_POSITION_INDEX + axis] = q_accel * dt2 / 2.0;
191        q_d[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = q_accel * dt;
192        q_d[ERROR_ATTITUDE_INDEX + axis][ERROR_ATTITUDE_INDEX + axis] = q_gyro * dt;
193        q_d[ERROR_ACCEL_BIAS_INDEX + axis][ERROR_ACCEL_BIAS_INDEX + axis] =
194            imu_spec.accel_bias_variance_increment(dt_s)?;
195        q_d[ERROR_GYRO_BIAS_INDEX + axis][ERROR_GYRO_BIAS_INDEX + axis] =
196            imu_spec.gyro_bias_variance_increment(dt_s)?;
197    }
198
199    if layout.includes_scale_factors() {
200        let accel_scale = imu_spec.accel_scale_instab_ppm.unwrap_or(0.0) * 1.0e-6;
201        let gyro_scale = imu_spec.gyro_scale_instab_ppm.unwrap_or(0.0) * 1.0e-6;
202        let accel_scale_q = accel_scale * accel_scale;
203        let gyro_scale_q = gyro_scale * gyro_scale;
204        for axis in 0..3 {
205            q_d[ERROR_ACCEL_SCALE_INDEX + axis][ERROR_ACCEL_SCALE_INDEX + axis] =
206                accel_scale_q * dt;
207            q_d[ERROR_GYRO_SCALE_INDEX + axis][ERROR_GYRO_SCALE_INDEX + axis] = gyro_scale_q * dt;
208        }
209    }
210
211    reproject_covariance_psd(&mut q_d, "process_noise")?;
212    Ok(q_d)
213}
214
215/// Build `F`, `Phi`, and `Q_d` for one error-state predict step.
216pub fn linearize_error_state_ecef(
217    state: &NavState,
218    kinematics: ErrorStateImuKinematics,
219    imu_spec: &ImuSpec,
220    dt_s: f64,
221    layout: ErrorStateLayout,
222) -> Result<ErrorStateLinearization, FusionError> {
223    linearize_error_state_ecef_with_imu_to_body(
224        state,
225        kinematics,
226        imu_spec,
227        dt_s,
228        layout,
229        mat3_identity(),
230    )
231}
232
233/// Build `F`, `Phi`, and `Q_d` for one predict step with IMU axes rotated into body axes.
234pub fn linearize_error_state_ecef_with_imu_to_body(
235    state: &NavState,
236    kinematics: ErrorStateImuKinematics,
237    imu_spec: &ImuSpec,
238    dt_s: f64,
239    layout: ErrorStateLayout,
240    imu_to_body_dcm: Mat3,
241) -> Result<ErrorStateLinearization, FusionError> {
242    let f = error_state_system_matrix_ecef_with_imu_to_body(
243        state,
244        kinematics,
245        imu_spec,
246        layout,
247        imu_to_body_dcm,
248    )?;
249    let phi = error_state_transition_matrix(&f, dt_s)?;
250    let q_d = error_state_process_noise_discrete(imu_spec, dt_s, layout)?;
251    Ok(ErrorStateLinearization {
252        f,
253        phi,
254        q_d,
255        dt_s,
256        specific_force_ecef_mps2: mul_vec3(
257            &state.attitude_body_to_ecef,
258            kinematics.specific_force_body_mps2,
259        ),
260    })
261}
262
263/// Predict covariance as `P = Phi P Phi^T + Q_d`.
264pub fn predict_error_state_covariance(
265    covariance: &mut Vec<Vec<f64>>,
266    phi: &[Vec<f64>],
267    q_d: &[Vec<f64>],
268) -> Result<(), FusionError> {
269    let dimension = covariance.len();
270    if dimension != ErrorStateLayout::Fifteen.dimension()
271        && dimension != ErrorStateLayout::TwentyOne.dimension()
272    {
273        return Err(invalid_input("covariance", "dimension must be 15 or 21"));
274    }
275    validate_covariance_matrix(covariance, dimension, "covariance")?;
276    validate_square_matrix(phi, dimension, "phi")?;
277    validate_covariance_matrix(q_d, dimension, "q_d")?;
278
279    let mut temp = vec![vec![0.0; dimension]; dimension];
280    for i in 0..dimension {
281        for j in 0..dimension {
282            for k in 0..dimension {
283                temp[i][j] += phi[i][k] * covariance[k][j];
284            }
285        }
286    }
287
288    let mut propagated = vec![vec![0.0; dimension]; dimension];
289    for i in 0..dimension {
290        for j in 0..dimension {
291            for k in 0..dimension {
292                propagated[i][j] += temp[i][k] * phi[j][k];
293            }
294        }
295    }
296    let propagated = matrix_add(&propagated, q_d)?;
297    *covariance = propagated;
298    symmetrize_in_place(covariance);
299    reproject_covariance_psd(covariance, "covariance")
300}
301
302pub(crate) fn gravity_gradient_prompt_ecef(position_ecef_m: [f64; 3]) -> Result<Mat3, FusionError> {
303    validate_vec3(position_ecef_m, "position_ecef_m").map_err(FusionError::from)?;
304    let radius_m = norm3(position_ecef_m);
305    validate_positive(radius_m, "position_radius_m")?;
306    let radius3 = radius_m * radius_m * radius_m;
307    let scale = -GM_EARTH_M3_S2 / radius3;
308    let r_hat = [
309        position_ecef_m[0] / radius_m,
310        position_ecef_m[1] / radius_m,
311        position_ecef_m[2] / radius_m,
312    ];
313    let omega = skew([0.0, 0.0, OMEGA_E_DOT_RAD_S]);
314    let omega2 = [
315        [
316            omega[0][0] * omega[0][0] + omega[0][1] * omega[1][0] + omega[0][2] * omega[2][0],
317            omega[0][0] * omega[0][1] + omega[0][1] * omega[1][1] + omega[0][2] * omega[2][1],
318            omega[0][0] * omega[0][2] + omega[0][1] * omega[1][2] + omega[0][2] * omega[2][2],
319        ],
320        [
321            omega[1][0] * omega[0][0] + omega[1][1] * omega[1][0] + omega[1][2] * omega[2][0],
322            omega[1][0] * omega[0][1] + omega[1][1] * omega[1][1] + omega[1][2] * omega[2][1],
323            omega[1][0] * omega[0][2] + omega[1][1] * omega[1][2] + omega[1][2] * omega[2][2],
324        ],
325        [
326            omega[2][0] * omega[0][0] + omega[2][1] * omega[1][0] + omega[2][2] * omega[2][0],
327            omega[2][0] * omega[0][1] + omega[2][1] * omega[1][1] + omega[2][2] * omega[2][1],
328            omega[2][0] * omega[0][2] + omega[2][1] * omega[1][2] + omega[2][2] * omega[2][2],
329        ],
330    ];
331    let mut gradient = [[0.0; 3]; 3];
332    for row in 0..3 {
333        for col in 0..3 {
334            let identity = if row == col { 1.0 } else { 0.0 };
335            gradient[row][col] =
336                scale * (identity - 3.0 * r_hat[row] * r_hat[col]) - omega2[row][col];
337        }
338    }
339    Ok(gradient)
340}
341
342fn fill_bias_decay(f: &mut [Vec<f64>], index: usize, tau_s: f64) {
343    if tau_s != RANDOM_WALK_BIAS_TAU_S && tau_s.is_finite() {
344        for axis in 0..3 {
345            f[index + axis][index + axis] = -1.0 / tau_s;
346        }
347    }
348}
349
350fn add_mat3_block(matrix: &mut [Vec<f64>], row0: usize, col0: usize, block: &Mat3) {
351    for row in 0..3 {
352        for col in 0..3 {
353            matrix[row0 + row][col0 + col] += block[row][col];
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    //! Provenance: these tests use the ECEF indirect error-state equations from
361    //! Groves, Principles of GNSS, Inertial, and Multisensor Integrated
362    //! Navigation Systems, 2nd ed., Section 14.2.4. The ECEF gravity-gradient
363    //! sign and centrifugal derivative are cross-checked against the public
364    //! Sandia ESKF derivation, OSTI report 2516824, equations 7.58 and 7.65.
365    //! The process-noise position/velocity block follows the white-acceleration integral
366    //! `q * [[dt^3/3, dt^2/2], [dt^2/2, dt]]`, with the same operation order
367    //! used by the covariance propagation process-noise increment.
368
369    use super::*;
370    use crate::astro::constants::earth::WGS84_A_M;
371    use crate::astro::math::mat3::{inline_rxr, inline_tr, mul_vec3};
372    use crate::inertial::mechanization::mechanize_ecef;
373    use crate::inertial::state::{mat3_identity, reorthonormalize_dcm};
374    use crate::inertial::{CorrectedImuIncrement, MechanizationConfig};
375    use nalgebra::DMatrix;
376
377    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
378        assert!(
379            (actual - expected).abs() <= tolerance,
380            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
381        );
382    }
383
384    fn reference_state() -> NavState {
385        NavState::new(
386            0.0,
387            [WGS84_A_M + 1000.0, 25.0, -40.0],
388            [3.0, -2.0, 1.0],
389            mat3_identity(),
390        )
391        .expect("reference state")
392    }
393
394    fn reference_imu() -> ErrorStateImuKinematics {
395        ErrorStateImuKinematics::new([0.12, -0.05, 9.72], [0.004, -0.002, 0.001])
396            .expect("imu kinematics")
397    }
398
399    fn reference_spec() -> ImuSpec {
400        ImuSpec::datasheet(
401            0.02,
402            0.003,
403            0.004,
404            0.0002,
405            3600.0,
406            7200.0,
407            Some(25.0),
408            Some(30.0),
409        )
410    }
411
412    fn reference_increment(imu: ErrorStateImuKinematics, dt_s: f64) -> CorrectedImuIncrement {
413        CorrectedImuIncrement {
414            t_j2000_s: dt_s,
415            delta_velocity_mps: [
416                imu.specific_force_body_mps2[0] * dt_s,
417                imu.specific_force_body_mps2[1] * dt_s,
418                imu.specific_force_body_mps2[2] * dt_s,
419            ],
420            delta_theta_rad: [
421                imu.angular_rate_body_rps[0] * dt_s,
422                imu.angular_rate_body_rps[1] * dt_s,
423                imu.angular_rate_body_rps[2] * dt_s,
424            ],
425            dt_s,
426        }
427    }
428
429    fn attitude_error_ecef(perturbed: &Mat3, base: &Mat3) -> [f64; 3] {
430        let delta = inline_rxr(perturbed, &inline_tr(base));
431        [
432            0.5 * (delta[1][2] - delta[2][1]),
433            0.5 * (delta[2][0] - delta[0][2]),
434            0.5 * (delta[0][1] - delta[1][0]),
435        ]
436    }
437
438    #[test]
439    fn gravity_gradient_matches_published_ecef_point_mass_plus_centrifugal_bits() {
440        let radius_m = WGS84_A_M + 1000.0;
441        let gradient = gravity_gradient_prompt_ecef([radius_m, 0.0, 0.0]).expect("gradient");
442        let radius3 = radius_m * radius_m * radius_m;
443        let point = GM_EARTH_M3_S2 / radius3;
444        let omega2 = OMEGA_E_DOT_RAD_S * OMEGA_E_DOT_RAD_S;
445
446        assert_eq!(gradient[0][0].to_bits(), (2.0 * point + omega2).to_bits());
447        assert_eq!(gradient[1][1].to_bits(), (-point + omega2).to_bits());
448        assert_eq!(gradient[2][2].to_bits(), (-point).to_bits());
449        for (row, values) in gradient.iter().enumerate() {
450            for (col, value) in values.iter().enumerate() {
451                if row != col {
452                    assert_eq!(*value, 0.0);
453                }
454            }
455        }
456    }
457
458    #[test]
459    fn phi_matches_mechanization_finite_difference_for_kinematic_block() {
460        let state = reference_state();
461        let imu = reference_imu();
462        let spec = reference_spec();
463        let dt_s = 1.0e-5;
464        let epsilon = 1.0;
465        let linear =
466            linearize_error_state_ecef(&state, imu, &spec, dt_s, ErrorStateLayout::Fifteen)
467                .expect("linearization");
468        let increment = reference_increment(imu, dt_s);
469        let base_next =
470            mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("base step");
471
472        for col in 0..6 {
473            let mut perturbed = state;
474            if col < 3 {
475                perturbed.position_ecef_m[col] += epsilon;
476            } else {
477                perturbed.velocity_ecef_mps[col - 3] += epsilon;
478            }
479            let next = mechanize_ecef(&perturbed, &increment, MechanizationConfig::default())
480                .expect("perturbed step");
481            for row in 0..3 {
482                let fd = (next.position_ecef_m[row] - base_next.position_ecef_m[row]) / epsilon;
483                assert_close(fd, linear.phi[row][col], 2.0e-8);
484            }
485            for row in 0..3 {
486                let fd = (next.velocity_ecef_mps[row] - base_next.velocity_ecef_mps[row]) / epsilon;
487                assert_close(fd, linear.phi[ERROR_VELOCITY_INDEX + row][col], 5.0e-10);
488            }
489        }
490    }
491
492    #[test]
493    fn phi_matches_mechanization_finite_difference_for_attitude_error_block() {
494        let state = reference_state();
495        let imu = reference_imu();
496        let spec = reference_spec();
497        let dt_s = 1.0e-4;
498        let epsilon = 1.0e-6;
499        let linear =
500            linearize_error_state_ecef(&state, imu, &spec, dt_s, ErrorStateLayout::Fifteen)
501                .expect("linearization");
502        let increment = reference_increment(imu, dt_s);
503        let base_next =
504            mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("base step");
505
506        for col in 0..3 {
507            let mut psi = [0.0; 3];
508            psi[col] = epsilon;
509            let psi_skew = skew(psi);
510            let mut correction = mat3_identity();
511            for row in 0..3 {
512                for col in 0..3 {
513                    correction[row][col] += psi_skew[row][col];
514                }
515            }
516            let mut perturbed = state;
517            perturbed.attitude_body_to_ecef =
518                reorthonormalize_dcm(&inline_rxr(&correction, &state.attitude_body_to_ecef))
519                    .expect("perturbed attitude");
520            let next = mechanize_ecef(&perturbed, &increment, MechanizationConfig::default())
521                .expect("perturbed step");
522            let raw_attitude_error = attitude_error_ecef(
523                &next.attitude_body_to_ecef,
524                &base_next.attitude_body_to_ecef,
525            );
526            let psi_next = [
527                -raw_attitude_error[0],
528                -raw_attitude_error[1],
529                -raw_attitude_error[2],
530            ];
531
532            for (row, value) in psi_next.iter().enumerate() {
533                let fd = *value / epsilon;
534                assert_close(
535                    fd,
536                    linear.phi[ERROR_ATTITUDE_INDEX + row][ERROR_ATTITUDE_INDEX + col],
537                    2.0e-9,
538                );
539            }
540            for row in 0..3 {
541                let fd = (next.velocity_ecef_mps[row] - base_next.velocity_ecef_mps[row]) / epsilon;
542                assert_close(
543                    fd,
544                    linear.phi[ERROR_VELOCITY_INDEX + row][ERROR_ATTITUDE_INDEX + col],
545                    5.0e-7,
546                );
547            }
548        }
549    }
550
551    #[test]
552    fn qd_position_velocity_block_matches_closed_form_bits() {
553        let dt_s = 0.125_f64;
554        let q_accel = 0.02_f64 * 0.02_f64;
555        let spec = ImuSpec::datasheet(
556            0.02,
557            0.0,
558            0.0,
559            0.0,
560            RANDOM_WALK_BIAS_TAU_S,
561            RANDOM_WALK_BIAS_TAU_S,
562            None,
563            None,
564        );
565        let q_d = error_state_process_noise_discrete(&spec, dt_s, ErrorStateLayout::Fifteen)
566            .expect("process noise");
567        let dt = dt_s.abs();
568        let dt2 = dt * dt;
569        let dt3 = dt2 * dt;
570        for axis in 0..3 {
571            assert_eq!(q_d[axis][axis].to_bits(), (q_accel * dt3 / 3.0).to_bits());
572            assert_eq!(
573                q_d[axis][ERROR_VELOCITY_INDEX + axis].to_bits(),
574                (q_accel * dt2 / 2.0).to_bits()
575            );
576            assert_eq!(
577                q_d[ERROR_VELOCITY_INDEX + axis][axis].to_bits(),
578                (q_accel * dt2 / 2.0).to_bits()
579            );
580            assert_eq!(
581                q_d[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis].to_bits(),
582                (q_accel * dt).to_bits()
583            );
584        }
585    }
586
587    #[test]
588    fn imu_to_body_dcm_rotates_bias_and_scale_jacobians() {
589        let state = reference_state();
590        let imu = ErrorStateImuKinematics::new([2.0, 3.0, 4.0], [0.1, 0.2, 0.3]).expect("imu");
591        let imu_to_body = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
592        let body_to_imu = inline_tr(&imu_to_body);
593        let specific_force_imu = mul_vec3(&body_to_imu, imu.specific_force_body_mps2);
594        let angular_rate_imu = mul_vec3(&body_to_imu, imu.angular_rate_body_rps);
595        let f = error_state_system_matrix_ecef_with_imu_to_body(
596            &state,
597            imu,
598            &reference_spec(),
599            ErrorStateLayout::TwentyOne,
600            imu_to_body,
601        )
602        .expect("system matrix");
603
604        for row in 0..3 {
605            for col in 0..3 {
606                assert_eq!(
607                    f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_BIAS_INDEX + col].to_bits(),
608                    imu_to_body[row][col].to_bits()
609                );
610                assert_eq!(
611                    f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_BIAS_INDEX + col].to_bits(),
612                    (-imu_to_body[row][col]).to_bits()
613                );
614                assert_eq!(
615                    f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_SCALE_INDEX + col].to_bits(),
616                    (imu_to_body[row][col] * specific_force_imu[col]).to_bits()
617                );
618                assert_eq!(
619                    f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_SCALE_INDEX + col].to_bits(),
620                    (-imu_to_body[row][col] * angular_rate_imu[col]).to_bits()
621                );
622            }
623        }
624    }
625
626    #[test]
627    fn imu_to_body_dcm_matches_identity_mount_in_imu_frame_with_nonidentity_attitude() {
628        let body_to_ecef = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
629        let imu_to_body = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
630        let imu_to_ecef = inline_rxr(&body_to_ecef, &imu_to_body);
631        let body_state = NavState::new(
632            25.0,
633            [WGS84_A_M + 750.0, -125.0, 80.0],
634            [4.0, -3.0, 2.0],
635            body_to_ecef,
636        )
637        .expect("body-frame state");
638        let imu_frame_state = NavState::new(
639            body_state.t_j2000_s,
640            body_state.position_ecef_m,
641            body_state.velocity_ecef_mps,
642            imu_to_ecef,
643        )
644        .expect("imu-frame state");
645        let body_kinematics = ErrorStateImuKinematics::new([1.5, -0.25, 9.6], [0.03, -0.02, 0.01])
646            .expect("body kinematics");
647        let body_to_imu = inline_tr(&imu_to_body);
648        let imu_frame_kinematics = ErrorStateImuKinematics::new(
649            mul_vec3(&body_to_imu, body_kinematics.specific_force_body_mps2),
650            mul_vec3(&body_to_imu, body_kinematics.angular_rate_body_rps),
651        )
652        .expect("imu-frame kinematics");
653
654        let mounted = error_state_system_matrix_ecef_with_imu_to_body(
655            &body_state,
656            body_kinematics,
657            &reference_spec(),
658            ErrorStateLayout::TwentyOne,
659            imu_to_body,
660        )
661        .expect("mounted system matrix");
662        let identity_in_imu_frame = error_state_system_matrix_ecef(
663            &imu_frame_state,
664            imu_frame_kinematics,
665            &reference_spec(),
666            ErrorStateLayout::TwentyOne,
667        )
668        .expect("identity system matrix");
669
670        for row in 0..mounted.len() {
671            for col in 0..mounted[row].len() {
672                assert_close(mounted[row][col], identity_in_imu_frame[row][col], 1.0e-15);
673            }
674        }
675    }
676
677    #[test]
678    fn propagate_only_logdet_grows_monotonically_with_process_noise() {
679        let state = reference_state();
680        let imu = reference_imu();
681        let spec = ImuSpec::datasheet(0.2, 0.03, 0.02, 0.002, 600.0, 900.0, None, None);
682        let mut covariance = vec![vec![0.0; 15]; 15];
683        for (idx, row) in covariance.iter_mut().enumerate() {
684            row[idx] = 1.0e-3;
685        }
686        let mut previous = logdet(&covariance);
687        for _ in 0..12 {
688            let linear =
689                linearize_error_state_ecef(&state, imu, &spec, 0.02, ErrorStateLayout::Fifteen)
690                    .expect("linearization");
691            predict_error_state_covariance(&mut covariance, &linear.phi, &linear.q_d)
692                .expect("predict covariance");
693            let current = logdet(&covariance);
694            assert!(
695                current > previous,
696                "current {current:.17e}, previous {previous:.17e}"
697            );
698            previous = current;
699        }
700    }
701
702    fn logdet(covariance: &[Vec<f64>]) -> f64 {
703        let flat = covariance
704            .iter()
705            .flat_map(|row| row.iter().copied())
706            .collect::<Vec<_>>();
707        let matrix = DMatrix::from_row_slice(covariance.len(), covariance.len(), &flat);
708        let cholesky = matrix.cholesky().expect("test covariance is SPD");
709        2.0 * (0..covariance.len())
710            .map(|idx| cholesky.l()[(idx, idx)].ln())
711            .sum::<f64>()
712    }
713}