Skip to main content

sidereon_core/inertial/
sim.rs

1//! Synthetic IMU generation from truth states or truth increments.
2
3use crate::astro::math::mat3::{inline_rxr, inline_tr};
4use crate::astro::math::vec3::{add3, scale3, sub3};
5
6use super::config::{gauss_markov_bias_decay, gauss_markov_bias_variance_increment, ImuSpec};
7use super::frames::gravity_ecef_mps2;
8use super::imu::{
9    apply_calibration_error_vector, CorrectedImuIncrement, ImuBias, ImuCalibration, ImuSample,
10};
11use super::mechanization::{
12    earth_rate_cross, earth_rotation_first_order, mid_interval_dcm, validate_increment,
13};
14use super::state::{mat3_mul_vec, reorthonormalize_dcm, NavState};
15use super::{
16    invalid_input, validate_finite, validate_nonnegative, validate_positive, InertialError,
17};
18
19/// Default deterministic seed used by [`ImuSimulationOptions::default`].
20pub const DEFAULT_IMU_SIM_SEED: u64 = 0x4d59_5df4_d0f3_3173;
21
22/// Output representation for generated IMU samples.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ImuSimulationOutput {
25    /// Return specific force and angular rate samples.
26    Rate,
27    /// Return integrated delta-velocity and delta-angle samples.
28    #[default]
29    Increment,
30}
31
32/// Optional rate-random-walk densities for the generated IMU.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct ImuRateRandomWalk {
35    /// Accelerometer rate-error random-walk density in `(m/s^2)/sqrt(s)`.
36    pub accel_mps2_sqrt_s: f64,
37    /// Gyroscope rate-error random-walk density in `(rad/s)/sqrt(s)`.
38    pub gyro_rps_sqrt_s: f64,
39}
40
41impl ImuRateRandomWalk {
42    /// Build rate-random-walk densities from SI values.
43    pub const fn new(accel_mps2_sqrt_s: f64, gyro_rps_sqrt_s: f64) -> Self {
44        Self {
45            accel_mps2_sqrt_s,
46            gyro_rps_sqrt_s,
47        }
48    }
49
50    /// Validate finite, non-negative densities.
51    pub fn validate(&self) -> Result<(), InertialError> {
52        validate_nonnegative(self.accel_mps2_sqrt_s, "accel_mps2_sqrt_s")?;
53        validate_nonnegative(self.gyro_rps_sqrt_s, "gyro_rps_sqrt_s")
54    }
55}
56
57/// Options for synthetic IMU generation.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct ImuSimulationOptions {
60    /// Sample representation returned by the simulator.
61    pub output: ImuSimulationOutput,
62    /// Seed for the deterministic SplitMix64-backed random stream.
63    pub seed: u64,
64    /// Initial accelerometer and gyroscope bias states.
65    pub initial_bias: ImuBias,
66    /// Sensor scale-factor and misalignment error applied to raw samples.
67    pub calibration: ImuCalibration,
68    /// Optional additive rate-random-walk error state.
69    pub rate_random_walk: Option<ImuRateRandomWalk>,
70}
71
72impl Default for ImuSimulationOptions {
73    fn default() -> Self {
74        Self {
75            output: ImuSimulationOutput::Increment,
76            seed: DEFAULT_IMU_SIM_SEED,
77            initial_bias: ImuBias::default(),
78            calibration: ImuCalibration::default(),
79            rate_random_walk: None,
80        }
81    }
82}
83
84impl ImuSimulationOptions {
85    /// Validate option fields and calibration matrices.
86    pub fn validate(&self) -> Result<(), InertialError> {
87        self.initial_bias.validate()?;
88        self.calibration.validate()?;
89        if let Some(rate_random_walk) = self.rate_random_walk {
90            rate_random_walk.validate()?;
91        }
92        Ok(())
93    }
94}
95
96/// Batch output from synthetic IMU generation.
97#[derive(Debug, Clone, PartialEq)]
98pub struct SimulatedImuSequence {
99    /// Generated IMU samples.
100    pub samples: Vec<ImuSample>,
101    /// Bias state used for each generated sample.
102    pub bias_history: Vec<ImuBias>,
103    /// Rate-random-walk state used for each generated sample.
104    pub rate_random_walk_history: Vec<ImuBias>,
105}
106
107/// Stateful deterministic synthetic IMU generator.
108#[derive(Debug, Clone, PartialEq)]
109pub struct ImuSimulator {
110    spec: ImuSpec,
111    options: ImuSimulationOptions,
112    rng: SplitMix64,
113    bias: ImuBias,
114    rate_random_walk: ImuBias,
115}
116
117impl ImuSimulator {
118    /// Build a simulator from stochastic parameters and generation options.
119    pub fn new(spec: ImuSpec, options: ImuSimulationOptions) -> Result<Self, InertialError> {
120        spec.validate()?;
121        options.validate()?;
122        Ok(Self {
123            spec,
124            options,
125            rng: SplitMix64::new(options.seed),
126            bias: options.initial_bias,
127            rate_random_walk: ImuBias::default(),
128        })
129    }
130
131    /// Current Gauss-Markov bias state.
132    pub const fn bias(&self) -> ImuBias {
133        self.bias
134    }
135
136    /// Current rate-random-walk state.
137    pub const fn rate_random_walk(&self) -> ImuBias {
138        self.rate_random_walk
139    }
140
141    /// Generate one sample from a truth increment.
142    pub fn sample_increment(
143        &mut self,
144        truth: &CorrectedImuIncrement,
145    ) -> Result<ImuSample, InertialError> {
146        validate_increment(truth)?;
147        let dt_s = truth.dt_s;
148        self.bias = propagate_bias(self.bias, &self.spec, dt_s, &mut self.rng)?;
149        self.rate_random_walk = propagate_rate_random_walk(
150            self.rate_random_walk,
151            self.options.rate_random_walk,
152            dt_s,
153            &mut self.rng,
154        )?;
155
156        match self.options.output {
157            ImuSimulationOutput::Rate => Ok(self.rate_sample(truth)),
158            ImuSimulationOutput::Increment => Ok(self.increment_sample(truth)),
159        }
160    }
161
162    fn rate_sample(&mut self, truth: &CorrectedImuIncrement) -> ImuSample {
163        let dt_s = truth.dt_s;
164        let inv_dt = 1.0 / dt_s;
165        let accel_noise = noise_vec(self.spec.accel_vrw_mps_sqrt_s / dt_s.sqrt(), &mut self.rng);
166        let gyro_noise = noise_vec(self.spec.gyro_arw_rad_sqrt_s / dt_s.sqrt(), &mut self.rng);
167        let accel_rate_truth = scale3(truth.delta_velocity_mps, inv_dt);
168        let gyro_rate_truth = scale3(truth.delta_theta_rad, inv_dt);
169        let accel_rate = add_output_errors(
170            apply_accel_calibration(accel_rate_truth, self.options.calibration),
171            self.rate_random_walk.accel_mps2,
172            accel_noise,
173            self.bias.accel_mps2,
174        );
175        let gyro_rate = add_output_errors(
176            apply_gyro_calibration(gyro_rate_truth, self.options.calibration),
177            self.rate_random_walk.gyro_rps,
178            gyro_noise,
179            self.bias.gyro_rps,
180        );
181        ImuSample::rate(truth.t_j2000_s, accel_rate, gyro_rate)
182    }
183
184    fn increment_sample(&mut self, truth: &CorrectedImuIncrement) -> ImuSample {
185        let dt_s = truth.dt_s;
186        let accel_noise = noise_vec(self.spec.accel_vrw_mps_sqrt_s * dt_s.sqrt(), &mut self.rng);
187        let gyro_noise = noise_vec(self.spec.gyro_arw_rad_sqrt_s * dt_s.sqrt(), &mut self.rng);
188        let delta_velocity = add_output_errors(
189            apply_accel_calibration(truth.delta_velocity_mps, self.options.calibration),
190            scale3(self.rate_random_walk.accel_mps2, dt_s),
191            accel_noise,
192            scale3(self.bias.accel_mps2, dt_s),
193        );
194        let delta_theta = add_output_errors(
195            apply_gyro_calibration(truth.delta_theta_rad, self.options.calibration),
196            scale3(self.rate_random_walk.gyro_rps, dt_s),
197            gyro_noise,
198            scale3(self.bias.gyro_rps, dt_s),
199        );
200        ImuSample::increment(truth.t_j2000_s, delta_velocity, delta_theta, dt_s)
201    }
202}
203
204/// Generate IMU samples from a navigation-state truth trajectory.
205pub fn simulate_imu_samples(
206    trajectory: &[NavState],
207    spec: ImuSpec,
208    options: ImuSimulationOptions,
209) -> Result<SimulatedImuSequence, InertialError> {
210    if trajectory.len() < 2 {
211        return Err(invalid_input(
212            "trajectory",
213            "must contain at least two states",
214        ));
215    }
216    for state in trajectory {
217        state.validate()?;
218    }
219    let mut increments = Vec::with_capacity(trajectory.len() - 1);
220    for pair in trajectory.windows(2) {
221        increments.push(true_imu_increment_between(&pair[0], &pair[1])?);
222    }
223    simulate_imu_samples_from_increments(&increments, spec, options)
224}
225
226/// Generate IMU samples from coning/sculling truth increments.
227pub fn simulate_imu_samples_from_increments(
228    truth_increments: &[CorrectedImuIncrement],
229    spec: ImuSpec,
230    options: ImuSimulationOptions,
231) -> Result<SimulatedImuSequence, InertialError> {
232    validate_increment_sequence(truth_increments)?;
233    let mut simulator = ImuSimulator::new(spec, options)?;
234    let mut samples = Vec::with_capacity(truth_increments.len());
235    let mut bias_history = Vec::with_capacity(truth_increments.len());
236    let mut rate_random_walk_history = Vec::with_capacity(truth_increments.len());
237    for truth in truth_increments {
238        let sample = simulator.sample_increment(truth)?;
239        samples.push(sample);
240        bias_history.push(simulator.bias());
241        rate_random_walk_history.push(simulator.rate_random_walk());
242    }
243    Ok(SimulatedImuSequence {
244        samples,
245        bias_history,
246        rate_random_walk_history,
247    })
248}
249
250/// Reconstruct the mechanization-consistent truth increment between two states.
251pub fn true_imu_increment_between(
252    start: &NavState,
253    end: &NavState,
254) -> Result<CorrectedImuIncrement, InertialError> {
255    start.validate()?;
256    end.validate()?;
257    let dt_s = end.t_j2000_s - start.t_j2000_s;
258    validate_positive(dt_s, "dt_s")?;
259    validate_position_consistency(start, end, dt_s)?;
260    let delta_theta_rad = delta_theta_from_attitude_change(start, end, dt_s)?;
261    let c_avg = mid_interval_dcm(&start.attitude_body_to_ecef, delta_theta_rad, dt_s)?;
262    let c_avg_t = inline_tr(&c_avg);
263    let gravity = gravity_ecef_mps2(start.position_ecef_m)?;
264    let coriolis = scale3(earth_rate_cross(start.velocity_ecef_mps), -2.0);
265    let acceleration = add3(gravity, coriolis);
266    let required_ecef = sub3(
267        sub3(end.velocity_ecef_mps, start.velocity_ecef_mps),
268        scale3(acceleration, dt_s),
269    );
270    let increment = CorrectedImuIncrement {
271        t_j2000_s: end.t_j2000_s,
272        delta_velocity_mps: mat3_mul_vec(&c_avg_t, required_ecef),
273        delta_theta_rad,
274        dt_s,
275    };
276    validate_increment(&increment)?;
277    Ok(increment)
278}
279
280fn validate_position_consistency(
281    start: &NavState,
282    end: &NavState,
283    dt_s: f64,
284) -> Result<(), InertialError> {
285    let average_velocity = scale3(add3(start.velocity_ecef_mps, end.velocity_ecef_mps), 0.5);
286    let expected_position = add3(start.position_ecef_m, scale3(average_velocity, dt_s));
287    for (axis, expected) in expected_position.iter().enumerate() {
288        let actual = end.position_ecef_m[axis];
289        let scale = expected.abs().max(actual.abs()).max(1.0);
290        let tolerance = 64.0 * f64::EPSILON * scale;
291        if (actual - expected).abs() > tolerance {
292            return Err(invalid_input(
293                "end.position_ecef_m",
294                "must match trapezoidal position implied by endpoint velocities",
295            ));
296        }
297    }
298    Ok(())
299}
300
301fn validate_increment_sequence(
302    truth_increments: &[CorrectedImuIncrement],
303) -> Result<(), InertialError> {
304    let mut previous_end_t: Option<f64> = None;
305    for increment in truth_increments {
306        validate_increment(increment)?;
307        let start_t = increment.t_j2000_s - increment.dt_s;
308        validate_finite(start_t, "increment.start_t_j2000_s")?;
309        if let Some(previous) = previous_end_t {
310            let tolerance = 1.0e-12_f64.max(1.0e-12 * increment.dt_s.abs());
311            if (start_t - previous).abs() > tolerance {
312                return Err(invalid_input(
313                    "truth_increments",
314                    "must be contiguous in time",
315                ));
316            }
317        }
318        previous_end_t = Some(increment.t_j2000_s);
319    }
320    Ok(())
321}
322
323fn propagate_bias(
324    bias: ImuBias,
325    spec: &ImuSpec,
326    dt_s: f64,
327    rng: &mut SplitMix64,
328) -> Result<ImuBias, InertialError> {
329    Ok(ImuBias {
330        accel_mps2: [
331            propagate_gauss_markov_axis(
332                bias.accel_mps2[0],
333                spec.accel_bias_instab_mps2,
334                dt_s,
335                spec.accel_bias_tau_s,
336                rng,
337            )?,
338            propagate_gauss_markov_axis(
339                bias.accel_mps2[1],
340                spec.accel_bias_instab_mps2,
341                dt_s,
342                spec.accel_bias_tau_s,
343                rng,
344            )?,
345            propagate_gauss_markov_axis(
346                bias.accel_mps2[2],
347                spec.accel_bias_instab_mps2,
348                dt_s,
349                spec.accel_bias_tau_s,
350                rng,
351            )?,
352        ],
353        gyro_rps: [
354            propagate_gauss_markov_axis(
355                bias.gyro_rps[0],
356                spec.gyro_bias_instab_rps,
357                dt_s,
358                spec.gyro_bias_tau_s,
359                rng,
360            )?,
361            propagate_gauss_markov_axis(
362                bias.gyro_rps[1],
363                spec.gyro_bias_instab_rps,
364                dt_s,
365                spec.gyro_bias_tau_s,
366                rng,
367            )?,
368            propagate_gauss_markov_axis(
369                bias.gyro_rps[2],
370                spec.gyro_bias_instab_rps,
371                dt_s,
372                spec.gyro_bias_tau_s,
373                rng,
374            )?,
375        ],
376    })
377}
378
379fn propagate_gauss_markov_axis(
380    previous: f64,
381    instability: f64,
382    dt_s: f64,
383    tau_s: f64,
384    rng: &mut SplitMix64,
385) -> Result<f64, InertialError> {
386    let phi = gauss_markov_bias_decay(dt_s, tau_s)?;
387    let variance = gauss_markov_bias_variance_increment(instability, dt_s, tau_s)?;
388    if variance == 0.0 {
389        Ok(phi * previous)
390    } else {
391        Ok(phi * previous + variance.sqrt() * rng.standard_normal())
392    }
393}
394
395fn propagate_rate_random_walk(
396    current: ImuBias,
397    model: Option<ImuRateRandomWalk>,
398    dt_s: f64,
399    rng: &mut SplitMix64,
400) -> Result<ImuBias, InertialError> {
401    let Some(model) = model else {
402        return Ok(current);
403    };
404    model.validate()?;
405    let sqrt_dt = dt_s.sqrt();
406    Ok(ImuBias {
407        accel_mps2: [
408            current.accel_mps2[0] + noise_scalar(model.accel_mps2_sqrt_s * sqrt_dt, rng),
409            current.accel_mps2[1] + noise_scalar(model.accel_mps2_sqrt_s * sqrt_dt, rng),
410            current.accel_mps2[2] + noise_scalar(model.accel_mps2_sqrt_s * sqrt_dt, rng),
411        ],
412        gyro_rps: [
413            current.gyro_rps[0] + noise_scalar(model.gyro_rps_sqrt_s * sqrt_dt, rng),
414            current.gyro_rps[1] + noise_scalar(model.gyro_rps_sqrt_s * sqrt_dt, rng),
415            current.gyro_rps[2] + noise_scalar(model.gyro_rps_sqrt_s * sqrt_dt, rng),
416        ],
417    })
418}
419
420fn noise_vec(std: f64, rng: &mut SplitMix64) -> [f64; 3] {
421    if std == 0.0 {
422        [0.0; 3]
423    } else {
424        [
425            noise_scalar(std, rng),
426            noise_scalar(std, rng),
427            noise_scalar(std, rng),
428        ]
429    }
430}
431
432fn noise_scalar(std: f64, rng: &mut SplitMix64) -> f64 {
433    if std == 0.0 {
434        0.0
435    } else {
436        std * rng.standard_normal()
437    }
438}
439
440fn add_output_errors(
441    calibrated_truth: [f64; 3],
442    rate_random_walk: [f64; 3],
443    white_noise: [f64; 3],
444    bias: [f64; 3],
445) -> [f64; 3] {
446    let mut value = calibrated_truth;
447    if !is_zero3(rate_random_walk) {
448        value = add3(value, rate_random_walk);
449    }
450    if !is_zero3(white_noise) {
451        value = add3(value, white_noise);
452    }
453    if !is_zero3(bias) {
454        value = add3(value, bias);
455    }
456    value
457}
458
459fn is_zero3(value: [f64; 3]) -> bool {
460    value
461        .iter()
462        .all(|component| component.to_bits() == 0.0_f64.to_bits())
463}
464
465fn apply_accel_calibration(value: [f64; 3], calibration: ImuCalibration) -> [f64; 3] {
466    if calibration.accel_scale_misalignment == [[0.0; 3]; 3] {
467        value
468    } else {
469        apply_calibration_error_vector(value, &calibration.accel_scale_misalignment)
470    }
471}
472
473fn apply_gyro_calibration(value: [f64; 3], calibration: ImuCalibration) -> [f64; 3] {
474    if calibration.gyro_scale_misalignment == [[0.0; 3]; 3] {
475        value
476    } else {
477        apply_calibration_error_vector(value, &calibration.gyro_scale_misalignment)
478    }
479}
480
481fn delta_theta_from_attitude_change(
482    start: &NavState,
483    end: &NavState,
484    dt_s: f64,
485) -> Result<[f64; 3], InertialError> {
486    let earth_inv = earth_rotation_first_order_inverse(dt_s)?;
487    let previous_t = inline_tr(&start.attitude_body_to_ecef);
488    let relative_raw = inline_rxr(
489        &inline_rxr(&previous_t, &earth_inv),
490        &end.attitude_body_to_ecef,
491    );
492    let relative = reorthonormalize_dcm(&relative_raw)?;
493    rotation_vector_from_dcm(&relative)
494}
495
496fn earth_rotation_first_order_inverse(dt_s: f64) -> Result<[[f64; 3]; 3], InertialError> {
497    let earth = earth_rotation_first_order(dt_s);
498    let a = earth[0][1];
499    let denominator = 1.0 + a * a;
500    validate_positive(denominator, "earth_rotation_denominator")?;
501    Ok([
502        [1.0 / denominator, -a / denominator, 0.0],
503        [a / denominator, 1.0 / denominator, 0.0],
504        [0.0, 0.0, 1.0],
505    ])
506}
507
508fn rotation_vector_from_dcm(dcm: &[[f64; 3]; 3]) -> Result<[f64; 3], InertialError> {
509    let trace = dcm[0][0] + dcm[1][1] + dcm[2][2];
510    let cos_angle = bounded_rotation_cosine(0.5 * (trace - 1.0))?;
511    let angle = cos_angle.acos();
512    let skew_part = [
513        dcm[2][1] - dcm[1][2],
514        dcm[0][2] - dcm[2][0],
515        dcm[1][0] - dcm[0][1],
516    ];
517    if angle < 1.0e-8 {
518        Ok(scale3(skew_part, 0.5))
519    } else if core::f64::consts::PI - angle < 1.0e-6 {
520        rotation_vector_near_pi(dcm, angle)
521    } else {
522        Ok(scale3(skew_part, angle / (2.0 * angle.sin())))
523    }
524}
525
526fn bounded_rotation_cosine(value: f64) -> Result<f64, InertialError> {
527    validate_finite(value, "rotation_cosine")?;
528    let tolerance = 16.0 * f64::EPSILON;
529    if value > 1.0 {
530        if value - 1.0 <= tolerance {
531            Ok(1.0)
532        } else {
533            Err(InertialError::DegenerateAttitude)
534        }
535    } else if value < -1.0 {
536        if -1.0 - value <= tolerance {
537            Ok(-1.0)
538        } else {
539            Err(InertialError::DegenerateAttitude)
540        }
541    } else {
542        Ok(value)
543    }
544}
545
546fn rotation_vector_near_pi(dcm: &[[f64; 3]; 3], angle: f64) -> Result<[f64; 3], InertialError> {
547    let x2 = nonnegative_roundoff(0.5 * (dcm[0][0] + 1.0))?;
548    let y2 = nonnegative_roundoff(0.5 * (dcm[1][1] + 1.0))?;
549    let z2 = nonnegative_roundoff(0.5 * (dcm[2][2] + 1.0))?;
550    let axis = if x2 >= y2 && x2 >= z2 {
551        let x = x2.sqrt();
552        if x == 0.0 {
553            return Err(InertialError::DegenerateAttitude);
554        }
555        [
556            x,
557            (dcm[0][1] + dcm[1][0]) / (4.0 * x),
558            (dcm[0][2] + dcm[2][0]) / (4.0 * x),
559        ]
560    } else if y2 >= z2 {
561        let y = y2.sqrt();
562        if y == 0.0 {
563            return Err(InertialError::DegenerateAttitude);
564        }
565        [
566            (dcm[0][1] + dcm[1][0]) / (4.0 * y),
567            y,
568            (dcm[1][2] + dcm[2][1]) / (4.0 * y),
569        ]
570    } else {
571        let z = z2.sqrt();
572        if z == 0.0 {
573            return Err(InertialError::DegenerateAttitude);
574        }
575        [
576            (dcm[0][2] + dcm[2][0]) / (4.0 * z),
577            (dcm[1][2] + dcm[2][1]) / (4.0 * z),
578            z,
579        ]
580    };
581    Ok(scale3(axis, angle))
582}
583
584fn nonnegative_roundoff(value: f64) -> Result<f64, InertialError> {
585    validate_finite(value, "rotation_axis_component")?;
586    let tolerance = 16.0 * f64::EPSILON;
587    if value >= 0.0 {
588        Ok(value)
589    } else if value.abs() <= tolerance {
590        Ok(0.0)
591    } else {
592        Err(InertialError::DegenerateAttitude)
593    }
594}
595
596#[derive(Debug, Clone, Copy, PartialEq)]
597struct SplitMix64 {
598    state: u64,
599    spare_normal: Option<f64>,
600}
601
602impl SplitMix64 {
603    fn new(seed: u64) -> Self {
604        Self {
605            state: seed,
606            spare_normal: None,
607        }
608    }
609
610    fn next_u64(&mut self) -> u64 {
611        self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
612        let mut z = self.state;
613        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
614        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
615        z ^ (z >> 31)
616    }
617
618    fn unit_f64(&mut self) -> f64 {
619        let bits = 0x3ff0_0000_0000_0000 | (self.next_u64() >> 12);
620        f64::from_bits(bits) - 1.0
621    }
622
623    fn standard_normal(&mut self) -> f64 {
624        if let Some(value) = self.spare_normal.take() {
625            return value;
626        }
627        loop {
628            let u = 2.0 * self.unit_f64() - 1.0;
629            let v = 2.0 * self.unit_f64() - 1.0;
630            let s = u * u + v * v;
631            if s > 0.0 && s < 1.0 {
632                let scale = (-2.0 * s.ln() / s).sqrt();
633                self.spare_normal = Some(v * scale);
634                return u * scale;
635            }
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    //! Provenance: IMU simulation equations follow Groves, Principles of GNSS,
643    //! Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
644    //! Chapter 4.4 for white noise, bias instability, and scale-factor
645    //! modeling. First-order Gauss-Markov validation uses the closed-form
646    //! AR(1) transition `phi = exp(-dt/tau)` and stationary variance
647    //! `sigma^2`. Allan deviation validation uses the crate's IEEE 1139 and
648    //! NIST SP 1065 estimators as a cross-module oracle. Increment round-trip
649    //! validation uses the landed ECEF mechanization as the independent
650    //! consumer of coning/sculling truth increments.
651
652    use super::*;
653    use crate::astro::constants::earth::WGS84_A_M;
654    use crate::clock_stability::{overlapping_adev, AllanSeries};
655    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
656    use crate::inertial::mechanization::mechanize_ecef;
657    use crate::inertial::state::mat3_identity;
658    use crate::inertial::{
659        ImuBias, ImuErrorModel, ImuSampleKind, MechanizationConfig, StrapdownMechanizer,
660    };
661
662    fn zero_truth_increments(count: usize, dt_s: f64) -> Vec<CorrectedImuIncrement> {
663        (1..=count)
664            .map(|idx| CorrectedImuIncrement {
665                t_j2000_s: idx as f64 * dt_s,
666                delta_velocity_mps: [0.0; 3],
667                delta_theta_rad: [0.0; 3],
668                dt_s,
669            })
670            .collect()
671    }
672
673    fn bit_hash_samples(samples: &[ImuSample]) -> u64 {
674        let mut hash = 0xcbf2_9ce4_8422_2325_u64;
675        for sample in samples {
676            hash_bits(&mut hash, sample.t_j2000_s.to_bits());
677            match sample.kind {
678                ImuSampleKind::Rate {
679                    specific_force_mps2,
680                    angular_rate_rps,
681                } => {
682                    hash_bits(&mut hash, 0);
683                    for value in specific_force_mps2 {
684                        hash_bits(&mut hash, value.to_bits());
685                    }
686                    for value in angular_rate_rps {
687                        hash_bits(&mut hash, value.to_bits());
688                    }
689                }
690                ImuSampleKind::Increment {
691                    delta_velocity_mps,
692                    delta_theta_rad,
693                    dt_s,
694                } => {
695                    hash_bits(&mut hash, 1);
696                    for value in delta_velocity_mps {
697                        hash_bits(&mut hash, value.to_bits());
698                    }
699                    for value in delta_theta_rad {
700                        hash_bits(&mut hash, value.to_bits());
701                    }
702                    hash_bits(&mut hash, dt_s.to_bits());
703                }
704            }
705        }
706        hash
707    }
708
709    fn hash_bits(hash: &mut u64, bits: u64) {
710        *hash ^= bits;
711        *hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
712    }
713
714    fn autocorrelation_lag1(values: &[f64], burn_in: usize) -> f64 {
715        let retained = &values[burn_in..];
716        let mean = retained.iter().sum::<f64>() / retained.len() as f64;
717        let mut numerator = 0.0;
718        let mut denominator = 0.0;
719        for pair in retained.windows(2) {
720            numerator += (pair[0] - mean) * (pair[1] - mean);
721            denominator += (pair[0] - mean) * (pair[0] - mean);
722        }
723        numerator / denominator
724    }
725
726    fn collect_bias_axis(dt_s: f64, count: usize) -> Vec<f64> {
727        let spec = ImuSpec::datasheet(0.0, 0.0, 0.2, 0.0, 0.75, 0.75, None, None);
728        let mut simulator = ImuSimulator::new(
729            spec,
730            ImuSimulationOptions {
731                output: ImuSimulationOutput::Rate,
732                seed: 0x0123_4567_89ab_cdef,
733                ..ImuSimulationOptions::default()
734            },
735        )
736        .expect("simulator");
737        let truth = CorrectedImuIncrement {
738            t_j2000_s: dt_s,
739            delta_velocity_mps: [0.0; 3],
740            delta_theta_rad: [0.0; 3],
741            dt_s,
742        };
743        let mut values = Vec::with_capacity(count);
744        for idx in 1..=count {
745            let mut step = truth;
746            step.t_j2000_s = idx as f64 * dt_s;
747            simulator.sample_increment(&step).expect("sample");
748            values.push(simulator.bias().accel_mps2[0]);
749        }
750        values
751    }
752
753    #[test]
754    fn gauss_markov_discretization_tracks_closed_form_at_two_rates() {
755        let count = 450_000;
756        let burn_in = 2_000;
757        for dt_s in [0.25_f64, 0.5_f64] {
758            let values = collect_bias_axis(dt_s, count);
759            let rho = autocorrelation_lag1(&values, burn_in);
760            let expected = (-dt_s / 0.75_f64).exp();
761            assert!(
762                (rho - expected).abs() <= 1.2e-3,
763                "dt {dt_s}, rho {rho:.17e}, expected {expected:.17e}"
764            );
765            let phi = gauss_markov_bias_decay(dt_s, 0.75).expect("phi");
766            assert_eq!(phi.to_bits(), expected.to_bits());
767        }
768    }
769
770    #[test]
771    fn white_noise_density_matches_allan_oracle() {
772        let dt_s = 0.02;
773        let count = 1_000_000;
774        let accel_density = 0.032;
775        let gyro_density = 0.0045;
776        let spec = ImuSpec::datasheet(accel_density, gyro_density, 0.0, 0.0, 1.0, 1.0, None, None);
777        let options = ImuSimulationOptions {
778            output: ImuSimulationOutput::Rate,
779            seed: 0xfeed_cafe_1357_2468,
780            ..ImuSimulationOptions::default()
781        };
782        let mut simulator = ImuSimulator::new(spec, options).expect("simulator");
783        let mut accel = Vec::with_capacity(count);
784        let mut gyro = Vec::with_capacity(count);
785        for idx in 1..=count {
786            let truth = CorrectedImuIncrement {
787                t_j2000_s: idx as f64 * dt_s,
788                delta_velocity_mps: [0.0; 3],
789                delta_theta_rad: [0.0; 3],
790                dt_s,
791            };
792            let sample = simulator.sample_increment(&truth).expect("sample");
793            match sample.kind {
794                ImuSampleKind::Rate {
795                    specific_force_mps2,
796                    angular_rate_rps,
797                } => {
798                    accel.push(specific_force_mps2[0]);
799                    gyro.push(angular_rate_rps[0]);
800                }
801                ImuSampleKind::Increment { .. } => unreachable!(),
802            }
803        }
804        let m = [1usize];
805        let accel_adev =
806            overlapping_adev(AllanSeries::FractionalFrequency(&accel), dt_s, &m).expect("accel");
807        let gyro_adev =
808            overlapping_adev(AllanSeries::FractionalFrequency(&gyro), dt_s, &m).expect("gyro");
809        let accel_recovered = accel_adev.deviation[0] * dt_s.sqrt();
810        let gyro_recovered = gyro_adev.deviation[0] * dt_s.sqrt();
811        assert!(
812            (accel_recovered - accel_density).abs() <= 7.0e-5,
813            "accel {accel_recovered:.17e}, expected {accel_density:.17e}"
814        );
815        assert!(
816            (gyro_recovered - gyro_density).abs() <= 1.1e-5,
817            "gyro {gyro_recovered:.17e}, expected {gyro_density:.17e}"
818        );
819    }
820
821    #[test]
822    fn zero_noise_increments_round_trip_through_mechanization_bits() {
823        let initial = NavState::new(
824            0.0,
825            [WGS84_A_M, 0.0, 0.0],
826            [0.0, 7.0, 0.25],
827            mat3_identity(),
828        )
829        .expect("initial");
830        let truth = [
831            CorrectedImuIncrement {
832                t_j2000_s: 0.125,
833                delta_velocity_mps: [0.001, -0.002, 0.0005],
834                delta_theta_rad: [0.0002, -0.0003, 0.0004],
835                dt_s: 0.125,
836            },
837            CorrectedImuIncrement {
838                t_j2000_s: 0.25,
839                delta_velocity_mps: [-0.0007, 0.0015, -0.0002],
840                delta_theta_rad: [-0.0001, 0.00025, -0.00035],
841                dt_s: 0.125,
842            },
843        ];
844        let mut expected = Vec::new();
845        let mut state = initial;
846        for increment in truth {
847            state =
848                mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("truth");
849            expected.push(state);
850        }
851        let generated = simulate_imu_samples_from_increments(
852            &truth,
853            ImuSpec::datasheet(0.0, 0.0, 0.0, 0.0, 1.0, 1.0, None, None),
854            ImuSimulationOptions::default(),
855        )
856        .expect("generated");
857        let mut mechanizer = StrapdownMechanizer::new(initial).expect("mechanizer");
858        for (sample, expected_state) in generated.samples.into_iter().zip(expected) {
859            let actual = *mechanizer.propagate(sample).expect("propagated");
860            assert_eq!(actual, expected_state);
861        }
862    }
863
864    #[test]
865    fn trajectory_position_must_match_mechanization_position_update() {
866        let start = NavState::new(
867            10.0,
868            [WGS84_A_M, 0.0, 0.0],
869            [1.0, 0.0, 0.0],
870            mat3_identity(),
871        )
872        .expect("start");
873        let end = NavState::new(
874            11.0,
875            [WGS84_A_M + 2.0, 0.0, 0.0],
876            [1.0, 0.0, 0.0],
877            mat3_identity(),
878        )
879        .expect("end");
880        assert_eq!(
881            true_imu_increment_between(&start, &end),
882            Err(InertialError::InvalidInput {
883                field: "end.position_ecef_m",
884                reason: "must match trapezoidal position implied by endpoint velocities",
885            })
886        );
887    }
888
889    #[test]
890    fn scale_misalignment_is_applied_before_correction() {
891        let calibration = ImuCalibration {
892            accel_scale_misalignment: [[100.0e-6, 20.0e-6, 0.0], [0.0, -50.0e-6, 0.0], [0.0; 3]],
893            gyro_scale_misalignment: [[10.0e-6, 0.0, 0.0], [30.0e-6, -20.0e-6, 0.0], [0.0; 3]],
894        };
895        let truth = [CorrectedImuIncrement {
896            t_j2000_s: 2.0,
897            delta_velocity_mps: [1.0, -2.0, 0.5],
898            delta_theta_rad: [0.1, -0.2, 0.05],
899            dt_s: 2.0,
900        }];
901        let generated = simulate_imu_samples_from_increments(
902            &truth,
903            ImuSpec::datasheet(0.0, 0.0, 0.0, 0.0, 1.0, 1.0, None, None),
904            ImuSimulationOptions {
905                calibration,
906                ..ImuSimulationOptions::default()
907            },
908        )
909        .expect("generated");
910        match generated.samples[0].kind {
911            ImuSampleKind::Increment {
912                delta_velocity_mps,
913                delta_theta_rad,
914                ..
915            } => {
916                assert_eq!(
917                    delta_velocity_mps[0].to_bits(),
918                    (1.0_f64 * (1.0 + 100.0e-6) + -2.0 * 20.0e-6).to_bits()
919                );
920                assert_eq!(
921                    delta_velocity_mps[1].to_bits(),
922                    (-2.0_f64 * (1.0 - 50.0e-6)).to_bits()
923                );
924                assert_eq!(
925                    delta_theta_rad[0].to_bits(),
926                    (0.1_f64 * (1.0 + 10.0e-6)).to_bits()
927                );
928                assert_eq!(
929                    delta_theta_rad[1].to_bits(),
930                    (0.1_f64 * 30.0e-6 + -0.2 * (1.0 - 20.0e-6)).to_bits()
931                );
932            }
933            ImuSampleKind::Rate { .. } => unreachable!(),
934        }
935    }
936
937    #[test]
938    fn stochastic_output_errors_are_not_scale_misaligned() {
939        let calibration = ImuCalibration {
940            accel_scale_misalignment: [
941                [200.0e-6, -40.0e-6, 15.0e-6],
942                [25.0e-6, -80.0e-6, 0.0],
943                [0.0; 3],
944            ],
945            gyro_scale_misalignment: [
946                [-30.0e-6, 12.0e-6, 0.0],
947                [50.0e-6, 90.0e-6, -8.0e-6],
948                [0.0; 3],
949            ],
950        };
951        let truth = [CorrectedImuIncrement {
952            t_j2000_s: 8.0,
953            delta_velocity_mps: [1.25, -0.75, 0.2],
954            delta_theta_rad: [0.03, -0.015, 0.004],
955            dt_s: 0.5,
956        }];
957        let spec = ImuSpec::datasheet(
958            0.04,
959            0.005,
960            0.0,
961            0.0,
962            RANDOM_WALK_BIAS_TAU_S,
963            RANDOM_WALK_BIAS_TAU_S,
964            None,
965            None,
966        );
967        let options = ImuSimulationOptions {
968            seed: 0x77aa_19f0_cafe_babe,
969            rate_random_walk: Some(ImuRateRandomWalk::new(0.002, 0.0003)),
970            ..ImuSimulationOptions::default()
971        };
972        let base = simulate_imu_samples_from_increments(&truth, spec, options).expect("base");
973        let calibrated = simulate_imu_samples_from_increments(
974            &truth,
975            spec,
976            ImuSimulationOptions {
977                calibration,
978                ..options
979            },
980        )
981        .expect("calibrated");
982        let (
983            ImuSampleKind::Increment {
984                delta_velocity_mps: base_delta_velocity,
985                delta_theta_rad: base_delta_theta,
986                ..
987            },
988            ImuSampleKind::Increment {
989                delta_velocity_mps: calibrated_delta_velocity,
990                delta_theta_rad: calibrated_delta_theta,
991                ..
992            },
993        ) = (base.samples[0].kind, calibrated.samples[0].kind)
994        else {
995            unreachable!()
996        };
997        let expected_accel_delta = [
998            200.0e-6 * truth[0].delta_velocity_mps[0]
999                + -40.0e-6 * truth[0].delta_velocity_mps[1]
1000                + 15.0e-6 * truth[0].delta_velocity_mps[2],
1001            25.0e-6 * truth[0].delta_velocity_mps[0] + -80.0e-6 * truth[0].delta_velocity_mps[1],
1002            0.0,
1003        ];
1004        let expected_gyro_delta = [
1005            -30.0e-6 * truth[0].delta_theta_rad[0] + 12.0e-6 * truth[0].delta_theta_rad[1],
1006            50.0e-6 * truth[0].delta_theta_rad[0]
1007                + 90.0e-6 * truth[0].delta_theta_rad[1]
1008                + -8.0e-6 * truth[0].delta_theta_rad[2],
1009            0.0,
1010        ];
1011        for axis in 0..3 {
1012            assert!(
1013                (calibrated_delta_velocity[axis]
1014                    - base_delta_velocity[axis]
1015                    - expected_accel_delta[axis])
1016                    .abs()
1017                    <= 3.0e-16,
1018                "axis {axis} accel actual {:.17e}, expected {:.17e}",
1019                calibrated_delta_velocity[axis] - base_delta_velocity[axis],
1020                expected_accel_delta[axis]
1021            );
1022            assert!(
1023                (calibrated_delta_theta[axis] - base_delta_theta[axis] - expected_gyro_delta[axis])
1024                    .abs()
1025                    <= 3.0e-17,
1026                "axis {axis} gyro actual {:.17e}, expected {:.17e}",
1027                calibrated_delta_theta[axis] - base_delta_theta[axis],
1028                expected_gyro_delta[axis]
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn calibration_and_bias_are_inverse_of_existing_correction_model() {
1035        let calibration = ImuCalibration {
1036            accel_scale_misalignment: [[100.0e-6, 0.0, 0.0], [0.0; 3], [0.0; 3]],
1037            gyro_scale_misalignment: [[50.0e-6, 0.0, 0.0], [0.0; 3], [0.0; 3]],
1038        };
1039        let bias = ImuBias {
1040            accel_mps2: [0.1, 0.0, 0.0],
1041            gyro_rps: [0.01, 0.0, 0.0],
1042        };
1043        let truth = [CorrectedImuIncrement {
1044            t_j2000_s: 2.0,
1045            delta_velocity_mps: [1.0, -2.0, 0.5],
1046            delta_theta_rad: [0.1, -0.2, 0.05],
1047            dt_s: 2.0,
1048        }];
1049        let generated = simulate_imu_samples_from_increments(
1050            &truth,
1051            ImuSpec::datasheet(
1052                0.0,
1053                0.0,
1054                0.0,
1055                0.0,
1056                RANDOM_WALK_BIAS_TAU_S,
1057                RANDOM_WALK_BIAS_TAU_S,
1058                None,
1059                None,
1060            ),
1061            ImuSimulationOptions {
1062                initial_bias: bias,
1063                calibration,
1064                ..ImuSimulationOptions::default()
1065            },
1066        )
1067        .expect("generated");
1068        let model = ImuErrorModel { bias, calibration };
1069        let corrected = model
1070            .correct_sample(&generated.samples[0], 0.0)
1071            .expect("corrected");
1072        for axis in 0..3 {
1073            assert!(
1074                (corrected.delta_velocity_mps[axis] - truth[0].delta_velocity_mps[axis]).abs()
1075                    <= 2.0e-15,
1076                "axis {axis} accel actual {:.17e}, expected {:.17e}",
1077                corrected.delta_velocity_mps[axis],
1078                truth[0].delta_velocity_mps[axis]
1079            );
1080            assert!(
1081                (corrected.delta_theta_rad[axis] - truth[0].delta_theta_rad[axis]).abs() <= 2.0e-16,
1082                "axis {axis} gyro actual {:.17e}, expected {:.17e}",
1083                corrected.delta_theta_rad[axis],
1084                truth[0].delta_theta_rad[axis]
1085            );
1086        }
1087    }
1088
1089    #[test]
1090    fn seeded_generation_has_exact_golden_hash() {
1091        let truth = zero_truth_increments(32, 0.01);
1092        let spec = ImuSpec::datasheet(0.04, 0.005, 0.003, 0.0002, 12.0, 18.0, None, None);
1093        let options = ImuSimulationOptions {
1094            output: ImuSimulationOutput::Increment,
1095            seed: 0x1111_2222_3333_4444,
1096            rate_random_walk: Some(ImuRateRandomWalk::new(0.0003, 0.00004)),
1097            ..ImuSimulationOptions::default()
1098        };
1099        let first =
1100            simulate_imu_samples_from_increments(&truth, spec, options).expect("first sequence");
1101        let second =
1102            simulate_imu_samples_from_increments(&truth, spec, options).expect("second sequence");
1103        assert_eq!(first.samples, second.samples);
1104        assert_eq!(bit_hash_samples(&first.samples), 0xb4bb_aa05_b75e_5097);
1105    }
1106}