Skip to main content

sidereon_core/inertial/
imu.rs

1//! IMU samples, calibration, and pre-mechanization correction.
2
3use crate::astro::math::mat3::{mul_vec3, Mat3};
4use crate::astro::math::vec3::{scale3, sub3};
5
6use super::{invalid_input, validate_finite, validate_mat3, validate_vec3, InertialError};
7
8/// One IMU sample tagged by its end time in seconds since J2000.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct ImuSample {
11    /// Sample end time in seconds since J2000 on the caller's GNSS time scale.
12    pub t_j2000_s: f64,
13    /// Sample payload as rates or sensor-provided increments.
14    pub kind: ImuSampleKind,
15}
16
17impl ImuSample {
18    /// Build an IMU sample from rates.
19    pub const fn rate(
20        t_j2000_s: f64,
21        specific_force_mps2: [f64; 3],
22        angular_rate_rps: [f64; 3],
23    ) -> Self {
24        Self {
25            t_j2000_s,
26            kind: ImuSampleKind::Rate {
27                specific_force_mps2,
28                angular_rate_rps,
29            },
30        }
31    }
32
33    /// Build an IMU sample from increments.
34    pub const fn increment(
35        t_j2000_s: f64,
36        delta_velocity_mps: [f64; 3],
37        delta_theta_rad: [f64; 3],
38        dt_s: f64,
39    ) -> Self {
40        Self {
41            t_j2000_s,
42            kind: ImuSampleKind::Increment {
43                delta_velocity_mps,
44                delta_theta_rad,
45                dt_s,
46            },
47        }
48    }
49}
50
51/// IMU sample payload.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub enum ImuSampleKind {
54    /// Specific force and angular rate sampled over the interval ending at the
55    /// sample time.
56    Rate {
57        /// Specific force in body axes, m/s^2.
58        specific_force_mps2: [f64; 3],
59        /// Angular rate in body axes, rad/s.
60        angular_rate_rps: [f64; 3],
61    },
62    /// Sensor-provided increments over `dt_s`.
63    Increment {
64        /// Body-frame delta velocity in m/s.
65        delta_velocity_mps: [f64; 3],
66        /// Body-frame delta angle in rad.
67        delta_theta_rad: [f64; 3],
68        /// Sample integration interval in seconds.
69        dt_s: f64,
70    },
71}
72
73/// Accelerometer and gyroscope biases.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct ImuBias {
76    /// Accelerometer bias in m/s^2.
77    pub accel_mps2: [f64; 3],
78    /// Gyroscope bias in rad/s.
79    pub gyro_rps: [f64; 3],
80}
81
82impl Default for ImuBias {
83    fn default() -> Self {
84        Self {
85            accel_mps2: [0.0; 3],
86            gyro_rps: [0.0; 3],
87        }
88    }
89}
90
91impl ImuBias {
92    /// Validate finite bias components.
93    pub fn validate(&self) -> Result<(), InertialError> {
94        validate_vec3(self.accel_mps2, "accel_mps2")?;
95        validate_vec3(self.gyro_rps, "gyro_rps")
96    }
97}
98
99/// IMU scale-factor and misalignment matrices.
100///
101/// Each matrix is the `M` in `(I + M)^-1(measured - bias)`.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct ImuCalibration {
104    /// Accelerometer scale and misalignment matrix.
105    pub accel_scale_misalignment: Mat3,
106    /// Gyroscope scale and misalignment matrix.
107    pub gyro_scale_misalignment: Mat3,
108}
109
110impl Default for ImuCalibration {
111    fn default() -> Self {
112        Self {
113            accel_scale_misalignment: [[0.0; 3]; 3],
114            gyro_scale_misalignment: [[0.0; 3]; 3],
115        }
116    }
117}
118
119impl ImuCalibration {
120    /// Build a diagonal calibration from scale factors in parts per million.
121    pub fn from_scale_ppm(
122        accel_scale_ppm: [f64; 3],
123        gyro_scale_ppm: [f64; 3],
124    ) -> Result<Self, InertialError> {
125        validate_vec3(accel_scale_ppm, "accel_scale_ppm")?;
126        validate_vec3(gyro_scale_ppm, "gyro_scale_ppm")?;
127        let mut accel = [[0.0; 3]; 3];
128        let mut gyro = [[0.0; 3]; 3];
129        for i in 0..3 {
130            accel[i][i] = accel_scale_ppm[i] * 1.0e-6;
131            gyro[i][i] = gyro_scale_ppm[i] * 1.0e-6;
132        }
133        Ok(Self {
134            accel_scale_misalignment: accel,
135            gyro_scale_misalignment: gyro,
136        })
137    }
138
139    /// Validate finite calibration matrices and invertibility of `I + M`.
140    pub fn validate(&self) -> Result<(), InertialError> {
141        validate_mat3(&self.accel_scale_misalignment, "accel_scale_misalignment")?;
142        validate_mat3(&self.gyro_scale_misalignment, "gyro_scale_misalignment")?;
143        inverse3(&identity_plus(&self.accel_scale_misalignment))?;
144        inverse3(&identity_plus(&self.gyro_scale_misalignment))?;
145        Ok(())
146    }
147}
148
149/// Bias and calibration model applied before strapdown mechanization.
150#[derive(Debug, Clone, Copy, PartialEq, Default)]
151pub struct ImuErrorModel {
152    /// Biases subtracted before scale and misalignment correction.
153    pub bias: ImuBias,
154    /// Scale-factor and misalignment correction.
155    pub calibration: ImuCalibration,
156}
157
158impl ImuErrorModel {
159    /// Correct a raw IMU sample into a body-frame increment.
160    pub fn correct_sample(
161        &self,
162        sample: &ImuSample,
163        previous_t_j2000_s: f64,
164    ) -> Result<CorrectedImuIncrement, InertialError> {
165        validate_finite(previous_t_j2000_s, "previous_t_j2000_s")?;
166        validate_finite(sample.t_j2000_s, "t_j2000_s")?;
167        self.bias.validate()?;
168        self.calibration.validate()?;
169        let epoch_dt_s = sample.t_j2000_s - previous_t_j2000_s;
170        if epoch_dt_s <= 0.0 {
171            return Err(InertialError::NonMonotonicSample);
172        }
173
174        match sample.kind {
175            ImuSampleKind::Rate {
176                specific_force_mps2,
177                angular_rate_rps,
178            } => {
179                validate_vec3(specific_force_mps2, "specific_force_mps2")?;
180                validate_vec3(angular_rate_rps, "angular_rate_rps")?;
181                let f_corr = correct_rate_vector(
182                    specific_force_mps2,
183                    self.bias.accel_mps2,
184                    &self.calibration.accel_scale_misalignment,
185                )?;
186                let omega_corr = correct_rate_vector(
187                    angular_rate_rps,
188                    self.bias.gyro_rps,
189                    &self.calibration.gyro_scale_misalignment,
190                )?;
191                Ok(CorrectedImuIncrement {
192                    t_j2000_s: sample.t_j2000_s,
193                    delta_velocity_mps: scale3(f_corr, epoch_dt_s),
194                    delta_theta_rad: scale3(omega_corr, epoch_dt_s),
195                    dt_s: epoch_dt_s,
196                })
197            }
198            ImuSampleKind::Increment {
199                delta_velocity_mps,
200                delta_theta_rad,
201                dt_s,
202            } => {
203                validate_vec3(delta_velocity_mps, "delta_velocity_mps")?;
204                validate_vec3(delta_theta_rad, "delta_theta_rad")?;
205                validate_finite(dt_s, "dt_s")?;
206                if dt_s <= 0.0 {
207                    return Err(invalid_input("dt_s", "must be positive"));
208                }
209                let tolerance = 1.0e-12_f64.max(1.0e-12 * dt_s.abs());
210                if (epoch_dt_s - dt_s).abs() > tolerance {
211                    return Err(invalid_input("dt_s", "must match sample time step"));
212                }
213                let f_meas = scale3(delta_velocity_mps, 1.0 / dt_s);
214                let omega_meas = scale3(delta_theta_rad, 1.0 / dt_s);
215                let f_corr = correct_rate_vector(
216                    f_meas,
217                    self.bias.accel_mps2,
218                    &self.calibration.accel_scale_misalignment,
219                )?;
220                let omega_corr = correct_rate_vector(
221                    omega_meas,
222                    self.bias.gyro_rps,
223                    &self.calibration.gyro_scale_misalignment,
224                )?;
225                Ok(CorrectedImuIncrement {
226                    t_j2000_s: sample.t_j2000_s,
227                    delta_velocity_mps: scale3(f_corr, dt_s),
228                    delta_theta_rad: scale3(omega_corr, dt_s),
229                    dt_s,
230                })
231            }
232        }
233    }
234}
235
236/// Bias-corrected, scale-corrected IMU increment consumed by mechanization.
237#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct CorrectedImuIncrement {
239    /// Sample end time in seconds since J2000.
240    pub t_j2000_s: f64,
241    /// Corrected body-frame delta velocity in m/s.
242    pub delta_velocity_mps: [f64; 3],
243    /// Corrected body-frame delta angle in rad.
244    pub delta_theta_rad: [f64; 3],
245    /// Sample integration interval in seconds.
246    pub dt_s: f64,
247}
248
249fn correct_rate_vector(
250    measured: [f64; 3],
251    bias: [f64; 3],
252    scale_misalignment: &Mat3,
253) -> Result<[f64; 3], InertialError> {
254    let inverse = inverse3(&identity_plus(scale_misalignment))?;
255    Ok(mul_vec3(&inverse, sub3(measured, bias)))
256}
257
258pub(crate) fn apply_calibration_error_vector(
259    value: [f64; 3],
260    scale_misalignment: &Mat3,
261) -> [f64; 3] {
262    mul_vec3(&identity_plus(scale_misalignment), value)
263}
264
265fn identity_plus(m: &Mat3) -> Mat3 {
266    [
267        [1.0 + m[0][0], m[0][1], m[0][2]],
268        [m[1][0], 1.0 + m[1][1], m[1][2]],
269        [m[2][0], m[2][1], 1.0 + m[2][2]],
270    ]
271}
272
273fn inverse3(m: &Mat3) -> Result<Mat3, InertialError> {
274    let c00 = m[1][1] * m[2][2] - m[1][2] * m[2][1];
275    let c01 = -(m[1][0] * m[2][2] - m[1][2] * m[2][0]);
276    let c02 = m[1][0] * m[2][1] - m[1][1] * m[2][0];
277    let c10 = -(m[0][1] * m[2][2] - m[0][2] * m[2][1]);
278    let c11 = m[0][0] * m[2][2] - m[0][2] * m[2][0];
279    let c12 = -(m[0][0] * m[2][1] - m[0][1] * m[2][0]);
280    let c20 = m[0][1] * m[1][2] - m[0][2] * m[1][1];
281    let c21 = -(m[0][0] * m[1][2] - m[0][2] * m[1][0]);
282    let c22 = m[0][0] * m[1][1] - m[0][1] * m[1][0];
283    let det = m[0][0] * c00 + m[0][1] * c01 + m[0][2] * c02;
284    if !det.is_finite() || det == 0.0 {
285        return Err(InertialError::SingularCalibration);
286    }
287    let inv_det = 1.0 / det;
288    Ok([
289        [c00 * inv_det, c10 * inv_det, c20 * inv_det],
290        [c01 * inv_det, c11 * inv_det, c21 * inv_det],
291        [c02 * inv_det, c12 * inv_det, c22 * inv_det],
292    ])
293}
294
295#[cfg(test)]
296mod tests {
297    //! Provenance: IMU correction follows Groves, Principles of GNSS,
298    //! Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
299    //! Chapter 4.4, using `(I + M)^-1(measured - bias)` before
300    //! mechanization.
301
302    use super::*;
303
304    #[test]
305    fn increment_correction_applies_bias_and_scale_exact_formula() {
306        let calibration = ImuCalibration::from_scale_ppm([100.0, -50.0, 25.0], [10.0, -20.0, 40.0])
307            .expect("calibration");
308        let model = ImuErrorModel {
309            bias: ImuBias {
310                accel_mps2: [0.1, -0.2, 0.05],
311                gyro_rps: [0.01, -0.02, 0.03],
312            },
313            calibration,
314        };
315        let sample = ImuSample::increment(11.0, [1.1, -2.4, 0.7], [0.11, -0.24, 0.37], 1.0);
316        let corrected = model.correct_sample(&sample, 10.0).expect("corrected");
317
318        let expected_accel_x = (1.1_f64 - 0.1) / (1.0 + 100.0e-6);
319        let expected_accel_y = (-2.4_f64 + 0.2) / (1.0 - 50.0e-6);
320        let expected_accel_z = (0.7_f64 - 0.05) / (1.0 + 25.0e-6);
321        assert_eq!(
322            corrected.delta_velocity_mps[0].to_bits(),
323            expected_accel_x.to_bits()
324        );
325        assert!(
326            (corrected.delta_velocity_mps[1] - expected_accel_y).abs() <= 5.0e-16,
327            "actual {:.17e}, expected {:.17e}",
328            corrected.delta_velocity_mps[1],
329            expected_accel_y
330        );
331        assert_eq!(
332            corrected.delta_velocity_mps[2].to_bits(),
333            expected_accel_z.to_bits()
334        );
335
336        let expected_gyro_x = (0.11_f64 - 0.01) / (1.0 + 10.0e-6);
337        let expected_gyro_y = (-0.24_f64 + 0.02) / (1.0 - 20.0e-6);
338        let expected_gyro_z = (0.37_f64 - 0.03) / (1.0 + 40.0e-6);
339        assert_eq!(
340            corrected.delta_theta_rad[0].to_bits(),
341            expected_gyro_x.to_bits()
342        );
343        assert!(
344            (corrected.delta_theta_rad[1] - expected_gyro_y).abs() <= 1.0e-16,
345            "actual {:.17e}, expected {:.17e}",
346            corrected.delta_theta_rad[1],
347            expected_gyro_y
348        );
349        assert!(
350            (corrected.delta_theta_rad[2] - expected_gyro_z).abs() <= 1.0e-16,
351            "actual {:.17e}, expected {:.17e}",
352            corrected.delta_theta_rad[2],
353            expected_gyro_z
354        );
355    }
356
357    #[test]
358    fn singular_calibration_is_flagged() {
359        let calibration = ImuCalibration {
360            accel_scale_misalignment: [[-1.0, 0.0, 0.0], [0.0; 3], [0.0; 3]],
361            gyro_scale_misalignment: [[0.0; 3]; 3],
362        };
363        assert_eq!(
364            calibration.validate(),
365            Err(InertialError::SingularCalibration)
366        );
367    }
368}