1use 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
19pub const DEFAULT_IMU_SIM_SEED: u64 = 0x4d59_5df4_d0f3_3173;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ImuSimulationOutput {
25 Rate,
27 #[default]
29 Increment,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct ImuRateRandomWalk {
35 pub accel_mps2_sqrt_s: f64,
37 pub gyro_rps_sqrt_s: f64,
39}
40
41impl ImuRateRandomWalk {
42 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 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#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct ImuSimulationOptions {
60 pub output: ImuSimulationOutput,
62 pub seed: u64,
64 pub initial_bias: ImuBias,
66 pub calibration: ImuCalibration,
68 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 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#[derive(Debug, Clone, PartialEq)]
98pub struct SimulatedImuSequence {
99 pub samples: Vec<ImuSample>,
101 pub bias_history: Vec<ImuBias>,
103 pub rate_random_walk_history: Vec<ImuBias>,
105}
106
107#[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 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 pub const fn bias(&self) -> ImuBias {
133 self.bias
134 }
135
136 pub const fn rate_random_walk(&self) -> ImuBias {
138 self.rate_random_walk
139 }
140
141 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
204pub 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
226pub 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
250pub 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 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}