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
258fn identity_plus(m: &Mat3) -> Mat3 {
259    [
260        [1.0 + m[0][0], m[0][1], m[0][2]],
261        [m[1][0], 1.0 + m[1][1], m[1][2]],
262        [m[2][0], m[2][1], 1.0 + m[2][2]],
263    ]
264}
265
266fn inverse3(m: &Mat3) -> Result<Mat3, InertialError> {
267    let c00 = m[1][1] * m[2][2] - m[1][2] * m[2][1];
268    let c01 = -(m[1][0] * m[2][2] - m[1][2] * m[2][0]);
269    let c02 = m[1][0] * m[2][1] - m[1][1] * m[2][0];
270    let c10 = -(m[0][1] * m[2][2] - m[0][2] * m[2][1]);
271    let c11 = m[0][0] * m[2][2] - m[0][2] * m[2][0];
272    let c12 = -(m[0][0] * m[2][1] - m[0][1] * m[2][0]);
273    let c20 = m[0][1] * m[1][2] - m[0][2] * m[1][1];
274    let c21 = -(m[0][0] * m[1][2] - m[0][2] * m[1][0]);
275    let c22 = m[0][0] * m[1][1] - m[0][1] * m[1][0];
276    let det = m[0][0] * c00 + m[0][1] * c01 + m[0][2] * c02;
277    if !det.is_finite() || det == 0.0 {
278        return Err(InertialError::SingularCalibration);
279    }
280    let inv_det = 1.0 / det;
281    Ok([
282        [c00 * inv_det, c10 * inv_det, c20 * inv_det],
283        [c01 * inv_det, c11 * inv_det, c21 * inv_det],
284        [c02 * inv_det, c12 * inv_det, c22 * inv_det],
285    ])
286}
287
288#[cfg(test)]
289mod tests {
290    //! Provenance: IMU correction follows Groves, Principles of GNSS,
291    //! Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
292    //! Chapter 4.4, using `(I + M)^-1(measured - bias)` before
293    //! mechanization.
294
295    use super::*;
296
297    #[test]
298    fn increment_correction_applies_bias_and_scale_exact_formula() {
299        let calibration = ImuCalibration::from_scale_ppm([100.0, -50.0, 25.0], [10.0, -20.0, 40.0])
300            .expect("calibration");
301        let model = ImuErrorModel {
302            bias: ImuBias {
303                accel_mps2: [0.1, -0.2, 0.05],
304                gyro_rps: [0.01, -0.02, 0.03],
305            },
306            calibration,
307        };
308        let sample = ImuSample::increment(11.0, [1.1, -2.4, 0.7], [0.11, -0.24, 0.37], 1.0);
309        let corrected = model.correct_sample(&sample, 10.0).expect("corrected");
310
311        let expected_accel_x = (1.1_f64 - 0.1) / (1.0 + 100.0e-6);
312        let expected_accel_y = (-2.4_f64 + 0.2) / (1.0 - 50.0e-6);
313        let expected_accel_z = (0.7_f64 - 0.05) / (1.0 + 25.0e-6);
314        assert_eq!(
315            corrected.delta_velocity_mps[0].to_bits(),
316            expected_accel_x.to_bits()
317        );
318        assert!(
319            (corrected.delta_velocity_mps[1] - expected_accel_y).abs() <= 5.0e-16,
320            "actual {:.17e}, expected {:.17e}",
321            corrected.delta_velocity_mps[1],
322            expected_accel_y
323        );
324        assert_eq!(
325            corrected.delta_velocity_mps[2].to_bits(),
326            expected_accel_z.to_bits()
327        );
328
329        let expected_gyro_x = (0.11_f64 - 0.01) / (1.0 + 10.0e-6);
330        let expected_gyro_y = (-0.24_f64 + 0.02) / (1.0 - 20.0e-6);
331        let expected_gyro_z = (0.37_f64 - 0.03) / (1.0 + 40.0e-6);
332        assert_eq!(
333            corrected.delta_theta_rad[0].to_bits(),
334            expected_gyro_x.to_bits()
335        );
336        assert!(
337            (corrected.delta_theta_rad[1] - expected_gyro_y).abs() <= 1.0e-16,
338            "actual {:.17e}, expected {:.17e}",
339            corrected.delta_theta_rad[1],
340            expected_gyro_y
341        );
342        assert!(
343            (corrected.delta_theta_rad[2] - expected_gyro_z).abs() <= 1.0e-16,
344            "actual {:.17e}, expected {:.17e}",
345            corrected.delta_theta_rad[2],
346            expected_gyro_z
347        );
348    }
349
350    #[test]
351    fn singular_calibration_is_flagged() {
352        let calibration = ImuCalibration {
353            accel_scale_misalignment: [[-1.0, 0.0, 0.0], [0.0; 3], [0.0; 3]],
354            gyro_scale_misalignment: [[0.0; 3]; 3],
355        };
356        assert_eq!(
357            calibration.validate(),
358            Err(InertialError::SingularCalibration)
359        );
360    }
361}