1use crate::astro::constants::earth::{GM_EARTH_M3_S2, OMEGA_E_DOT_RAD_S};
4use crate::astro::math::mat3::{mul_vec3, Mat3};
5use crate::astro::math::vec3::norm3;
6use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
7use crate::inertial::state::skew;
8use crate::inertial::{validate_vec3, ImuSpec, NavState};
9
10use super::state::{
11 identity, invalid_input, matmul, matrix_add, reproject_covariance_psd, symmetrize_in_place,
12 validate_covariance_matrix, validate_nonnegative, validate_positive, validate_square_matrix,
13 ErrorStateLayout, FusionError, ERROR_ACCEL_BIAS_INDEX, ERROR_ACCEL_SCALE_INDEX,
14 ERROR_ATTITUDE_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_GYRO_SCALE_INDEX, ERROR_POSITION_INDEX,
15 ERROR_VELOCITY_INDEX,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct ErrorStateImuKinematics {
21 pub specific_force_body_mps2: [f64; 3],
23 pub angular_rate_body_rps: [f64; 3],
25}
26
27impl ErrorStateImuKinematics {
28 pub fn new(
30 specific_force_body_mps2: [f64; 3],
31 angular_rate_body_rps: [f64; 3],
32 ) -> Result<Self, FusionError> {
33 validate_vec3(specific_force_body_mps2, "specific_force_body_mps2")
34 .map_err(FusionError::from)?;
35 validate_vec3(angular_rate_body_rps, "angular_rate_body_rps").map_err(FusionError::from)?;
36 Ok(Self {
37 specific_force_body_mps2,
38 angular_rate_body_rps,
39 })
40 }
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct ErrorStateLinearization {
46 pub f: Vec<Vec<f64>>,
48 pub phi: Vec<Vec<f64>>,
50 pub q_d: Vec<Vec<f64>>,
52 pub dt_s: f64,
54 pub specific_force_ecef_mps2: [f64; 3],
56}
57
58pub fn error_state_system_matrix_ecef(
60 state: &NavState,
61 kinematics: ErrorStateImuKinematics,
62 imu_spec: &ImuSpec,
63 layout: ErrorStateLayout,
64) -> Result<Vec<Vec<f64>>, FusionError> {
65 state.validate()?;
66 imu_spec.validate()?;
67 let dimension = layout.dimension();
68 let mut f = vec![vec![0.0; dimension]; dimension];
69 let c_b_e = state.attitude_body_to_ecef;
70 let specific_force_ecef = mul_vec3(&c_b_e, kinematics.specific_force_body_mps2);
71
72 for axis in 0..3 {
73 f[ERROR_POSITION_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = 1.0;
74 }
75
76 let gravity_gradient = gravity_gradient_prompt_ecef(state.position_ecef_m)?;
77 add_mat3_block(
78 &mut f,
79 ERROR_VELOCITY_INDEX,
80 ERROR_POSITION_INDEX,
81 &gravity_gradient,
82 );
83
84 let omega = skew([0.0, 0.0, OMEGA_E_DOT_RAD_S]);
85 for row in 0..3 {
86 for col in 0..3 {
87 f[ERROR_VELOCITY_INDEX + row][ERROR_VELOCITY_INDEX + col] = -2.0 * omega[row][col];
88 f[ERROR_ATTITUDE_INDEX + row][ERROR_ATTITUDE_INDEX + col] = -omega[row][col];
89 }
90 }
91
92 let specific_force_skew = skew(specific_force_ecef);
93 for row in 0..3 {
94 for col in 0..3 {
95 f[ERROR_VELOCITY_INDEX + row][ERROR_ATTITUDE_INDEX + col] =
96 -specific_force_skew[row][col];
97 f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_BIAS_INDEX + col] = c_b_e[row][col];
98 f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_BIAS_INDEX + col] = -c_b_e[row][col];
99 }
100 }
101
102 fill_bias_decay(&mut f, ERROR_ACCEL_BIAS_INDEX, imu_spec.accel_bias_tau_s);
103 fill_bias_decay(&mut f, ERROR_GYRO_BIAS_INDEX, imu_spec.gyro_bias_tau_s);
104
105 if layout.includes_scale_factors() {
106 for row in 0..3 {
107 for col in 0..3 {
108 f[ERROR_VELOCITY_INDEX + row][ERROR_ACCEL_SCALE_INDEX + col] =
109 c_b_e[row][col] * kinematics.specific_force_body_mps2[col];
110 f[ERROR_ATTITUDE_INDEX + row][ERROR_GYRO_SCALE_INDEX + col] =
111 -c_b_e[row][col] * kinematics.angular_rate_body_rps[col];
112 }
113 }
114 }
115
116 Ok(f)
117}
118
119pub fn error_state_transition_matrix(
121 f: &[Vec<f64>],
122 dt_s: f64,
123) -> Result<Vec<Vec<f64>>, FusionError> {
124 validate_nonnegative(dt_s, "dt_s")?;
125 let dimension = f.len();
126 if dimension != ErrorStateLayout::Fifteen.dimension()
127 && dimension != ErrorStateLayout::TwentyOne.dimension()
128 {
129 return Err(invalid_input("f", "dimension must be 15 or 21"));
130 }
131 validate_square_matrix(f, dimension, "f")?;
132
133 let mut fdt = vec![vec![0.0; dimension]; dimension];
134 for row in 0..dimension {
135 for col in 0..dimension {
136 fdt[row][col] = f[row][col] * dt_s;
137 }
138 }
139 let fdt2 = matmul(&fdt, &fdt)?;
140 let mut phi = identity(dimension);
141 for row in 0..dimension {
142 for col in 0..dimension {
143 phi[row][col] += fdt[row][col] + 0.5 * fdt2[row][col];
144 }
145 }
146 Ok(phi)
147}
148
149pub fn error_state_process_noise_discrete(
151 imu_spec: &ImuSpec,
152 dt_s: f64,
153 layout: ErrorStateLayout,
154) -> Result<Vec<Vec<f64>>, FusionError> {
155 imu_spec.validate()?;
156 validate_nonnegative(dt_s, "dt_s")?;
157 let dimension = layout.dimension();
158 let mut q_d = vec![vec![0.0; dimension]; dimension];
159 let q_accel = imu_spec.accel_vrw_mps_sqrt_s * imu_spec.accel_vrw_mps_sqrt_s;
160 let q_gyro = imu_spec.gyro_arw_rad_sqrt_s * imu_spec.gyro_arw_rad_sqrt_s;
161 let dt = dt_s.abs();
162 let dt2 = dt * dt;
163 let dt3 = dt2 * dt;
164
165 for axis in 0..3 {
166 q_d[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis] = q_accel * dt3 / 3.0;
167 q_d[ERROR_POSITION_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = q_accel * dt2 / 2.0;
168 q_d[ERROR_VELOCITY_INDEX + axis][ERROR_POSITION_INDEX + axis] = q_accel * dt2 / 2.0;
169 q_d[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis] = q_accel * dt;
170 q_d[ERROR_ATTITUDE_INDEX + axis][ERROR_ATTITUDE_INDEX + axis] = q_gyro * dt;
171 q_d[ERROR_ACCEL_BIAS_INDEX + axis][ERROR_ACCEL_BIAS_INDEX + axis] =
172 imu_spec.accel_bias_variance_increment(dt_s)?;
173 q_d[ERROR_GYRO_BIAS_INDEX + axis][ERROR_GYRO_BIAS_INDEX + axis] =
174 imu_spec.gyro_bias_variance_increment(dt_s)?;
175 }
176
177 if layout.includes_scale_factors() {
178 let accel_scale = imu_spec.accel_scale_instab_ppm.unwrap_or(0.0) * 1.0e-6;
179 let gyro_scale = imu_spec.gyro_scale_instab_ppm.unwrap_or(0.0) * 1.0e-6;
180 let accel_scale_q = accel_scale * accel_scale;
181 let gyro_scale_q = gyro_scale * gyro_scale;
182 for axis in 0..3 {
183 q_d[ERROR_ACCEL_SCALE_INDEX + axis][ERROR_ACCEL_SCALE_INDEX + axis] =
184 accel_scale_q * dt;
185 q_d[ERROR_GYRO_SCALE_INDEX + axis][ERROR_GYRO_SCALE_INDEX + axis] = gyro_scale_q * dt;
186 }
187 }
188
189 reproject_covariance_psd(&mut q_d, "process_noise")?;
190 Ok(q_d)
191}
192
193pub fn linearize_error_state_ecef(
195 state: &NavState,
196 kinematics: ErrorStateImuKinematics,
197 imu_spec: &ImuSpec,
198 dt_s: f64,
199 layout: ErrorStateLayout,
200) -> Result<ErrorStateLinearization, FusionError> {
201 let f = error_state_system_matrix_ecef(state, kinematics, imu_spec, layout)?;
202 let phi = error_state_transition_matrix(&f, dt_s)?;
203 let q_d = error_state_process_noise_discrete(imu_spec, dt_s, layout)?;
204 Ok(ErrorStateLinearization {
205 f,
206 phi,
207 q_d,
208 dt_s,
209 specific_force_ecef_mps2: mul_vec3(
210 &state.attitude_body_to_ecef,
211 kinematics.specific_force_body_mps2,
212 ),
213 })
214}
215
216pub fn predict_error_state_covariance(
218 covariance: &mut Vec<Vec<f64>>,
219 phi: &[Vec<f64>],
220 q_d: &[Vec<f64>],
221) -> Result<(), FusionError> {
222 let dimension = covariance.len();
223 if dimension != ErrorStateLayout::Fifteen.dimension()
224 && dimension != ErrorStateLayout::TwentyOne.dimension()
225 {
226 return Err(invalid_input("covariance", "dimension must be 15 or 21"));
227 }
228 validate_covariance_matrix(covariance, dimension, "covariance")?;
229 validate_square_matrix(phi, dimension, "phi")?;
230 validate_covariance_matrix(q_d, dimension, "q_d")?;
231
232 let mut temp = vec![vec![0.0; dimension]; dimension];
233 for i in 0..dimension {
234 for j in 0..dimension {
235 for k in 0..dimension {
236 temp[i][j] += phi[i][k] * covariance[k][j];
237 }
238 }
239 }
240
241 let mut propagated = vec![vec![0.0; dimension]; dimension];
242 for i in 0..dimension {
243 for j in 0..dimension {
244 for k in 0..dimension {
245 propagated[i][j] += temp[i][k] * phi[j][k];
246 }
247 }
248 }
249 let propagated = matrix_add(&propagated, q_d)?;
250 *covariance = propagated;
251 symmetrize_in_place(covariance);
252 reproject_covariance_psd(covariance, "covariance")
253}
254
255pub(crate) fn gravity_gradient_prompt_ecef(position_ecef_m: [f64; 3]) -> Result<Mat3, FusionError> {
256 validate_vec3(position_ecef_m, "position_ecef_m").map_err(FusionError::from)?;
257 let radius_m = norm3(position_ecef_m);
258 validate_positive(radius_m, "position_radius_m")?;
259 let radius3 = radius_m * radius_m * radius_m;
260 let scale = -GM_EARTH_M3_S2 / radius3;
261 let r_hat = [
262 position_ecef_m[0] / radius_m,
263 position_ecef_m[1] / radius_m,
264 position_ecef_m[2] / radius_m,
265 ];
266 let omega = skew([0.0, 0.0, OMEGA_E_DOT_RAD_S]);
267 let omega2 = [
268 [
269 omega[0][0] * omega[0][0] + omega[0][1] * omega[1][0] + omega[0][2] * omega[2][0],
270 omega[0][0] * omega[0][1] + omega[0][1] * omega[1][1] + omega[0][2] * omega[2][1],
271 omega[0][0] * omega[0][2] + omega[0][1] * omega[1][2] + omega[0][2] * omega[2][2],
272 ],
273 [
274 omega[1][0] * omega[0][0] + omega[1][1] * omega[1][0] + omega[1][2] * omega[2][0],
275 omega[1][0] * omega[0][1] + omega[1][1] * omega[1][1] + omega[1][2] * omega[2][1],
276 omega[1][0] * omega[0][2] + omega[1][1] * omega[1][2] + omega[1][2] * omega[2][2],
277 ],
278 [
279 omega[2][0] * omega[0][0] + omega[2][1] * omega[1][0] + omega[2][2] * omega[2][0],
280 omega[2][0] * omega[0][1] + omega[2][1] * omega[1][1] + omega[2][2] * omega[2][1],
281 omega[2][0] * omega[0][2] + omega[2][1] * omega[1][2] + omega[2][2] * omega[2][2],
282 ],
283 ];
284 let mut gradient = [[0.0; 3]; 3];
285 for row in 0..3 {
286 for col in 0..3 {
287 let identity = if row == col { 1.0 } else { 0.0 };
288 gradient[row][col] =
289 scale * (identity - 3.0 * r_hat[row] * r_hat[col]) - omega2[row][col];
290 }
291 }
292 Ok(gradient)
293}
294
295fn fill_bias_decay(f: &mut [Vec<f64>], index: usize, tau_s: f64) {
296 if tau_s != RANDOM_WALK_BIAS_TAU_S && tau_s.is_finite() {
297 for axis in 0..3 {
298 f[index + axis][index + axis] = -1.0 / tau_s;
299 }
300 }
301}
302
303fn add_mat3_block(matrix: &mut [Vec<f64>], row0: usize, col0: usize, block: &Mat3) {
304 for row in 0..3 {
305 for col in 0..3 {
306 matrix[row0 + row][col0 + col] += block[row][col];
307 }
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
323 use crate::astro::constants::earth::WGS84_A_M;
324 use crate::astro::math::mat3::{inline_rxr, inline_tr};
325 use crate::inertial::mechanization::mechanize_ecef;
326 use crate::inertial::state::{mat3_identity, reorthonormalize_dcm};
327 use crate::inertial::{CorrectedImuIncrement, MechanizationConfig};
328 use nalgebra::DMatrix;
329
330 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
331 assert!(
332 (actual - expected).abs() <= tolerance,
333 "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
334 );
335 }
336
337 fn reference_state() -> NavState {
338 NavState::new(
339 0.0,
340 [WGS84_A_M + 1000.0, 25.0, -40.0],
341 [3.0, -2.0, 1.0],
342 mat3_identity(),
343 )
344 .expect("reference state")
345 }
346
347 fn reference_imu() -> ErrorStateImuKinematics {
348 ErrorStateImuKinematics::new([0.12, -0.05, 9.72], [0.004, -0.002, 0.001])
349 .expect("imu kinematics")
350 }
351
352 fn reference_spec() -> ImuSpec {
353 ImuSpec::datasheet(
354 0.02,
355 0.003,
356 0.004,
357 0.0002,
358 3600.0,
359 7200.0,
360 Some(25.0),
361 Some(30.0),
362 )
363 }
364
365 fn reference_increment(imu: ErrorStateImuKinematics, dt_s: f64) -> CorrectedImuIncrement {
366 CorrectedImuIncrement {
367 t_j2000_s: dt_s,
368 delta_velocity_mps: [
369 imu.specific_force_body_mps2[0] * dt_s,
370 imu.specific_force_body_mps2[1] * dt_s,
371 imu.specific_force_body_mps2[2] * dt_s,
372 ],
373 delta_theta_rad: [
374 imu.angular_rate_body_rps[0] * dt_s,
375 imu.angular_rate_body_rps[1] * dt_s,
376 imu.angular_rate_body_rps[2] * dt_s,
377 ],
378 dt_s,
379 }
380 }
381
382 fn attitude_error_ecef(perturbed: &Mat3, base: &Mat3) -> [f64; 3] {
383 let delta = inline_rxr(perturbed, &inline_tr(base));
384 [
385 0.5 * (delta[1][2] - delta[2][1]),
386 0.5 * (delta[2][0] - delta[0][2]),
387 0.5 * (delta[0][1] - delta[1][0]),
388 ]
389 }
390
391 #[test]
392 fn gravity_gradient_matches_published_ecef_point_mass_plus_centrifugal_bits() {
393 let radius_m = WGS84_A_M + 1000.0;
394 let gradient = gravity_gradient_prompt_ecef([radius_m, 0.0, 0.0]).expect("gradient");
395 let radius3 = radius_m * radius_m * radius_m;
396 let point = GM_EARTH_M3_S2 / radius3;
397 let omega2 = OMEGA_E_DOT_RAD_S * OMEGA_E_DOT_RAD_S;
398
399 assert_eq!(gradient[0][0].to_bits(), (2.0 * point + omega2).to_bits());
400 assert_eq!(gradient[1][1].to_bits(), (-point + omega2).to_bits());
401 assert_eq!(gradient[2][2].to_bits(), (-point).to_bits());
402 for (row, values) in gradient.iter().enumerate() {
403 for (col, value) in values.iter().enumerate() {
404 if row != col {
405 assert_eq!(*value, 0.0);
406 }
407 }
408 }
409 }
410
411 #[test]
412 fn phi_matches_mechanization_finite_difference_for_kinematic_block() {
413 let state = reference_state();
414 let imu = reference_imu();
415 let spec = reference_spec();
416 let dt_s = 1.0e-5;
417 let epsilon = 1.0;
418 let linear =
419 linearize_error_state_ecef(&state, imu, &spec, dt_s, ErrorStateLayout::Fifteen)
420 .expect("linearization");
421 let increment = reference_increment(imu, dt_s);
422 let base_next =
423 mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("base step");
424
425 for col in 0..6 {
426 let mut perturbed = state;
427 if col < 3 {
428 perturbed.position_ecef_m[col] += epsilon;
429 } else {
430 perturbed.velocity_ecef_mps[col - 3] += epsilon;
431 }
432 let next = mechanize_ecef(&perturbed, &increment, MechanizationConfig::default())
433 .expect("perturbed step");
434 for row in 0..3 {
435 let fd = (next.position_ecef_m[row] - base_next.position_ecef_m[row]) / epsilon;
436 assert_close(fd, linear.phi[row][col], 2.0e-8);
437 }
438 for row in 0..3 {
439 let fd = (next.velocity_ecef_mps[row] - base_next.velocity_ecef_mps[row]) / epsilon;
440 assert_close(fd, linear.phi[ERROR_VELOCITY_INDEX + row][col], 5.0e-10);
441 }
442 }
443 }
444
445 #[test]
446 fn phi_matches_mechanization_finite_difference_for_attitude_error_block() {
447 let state = reference_state();
448 let imu = reference_imu();
449 let spec = reference_spec();
450 let dt_s = 1.0e-4;
451 let epsilon = 1.0e-6;
452 let linear =
453 linearize_error_state_ecef(&state, imu, &spec, dt_s, ErrorStateLayout::Fifteen)
454 .expect("linearization");
455 let increment = reference_increment(imu, dt_s);
456 let base_next =
457 mechanize_ecef(&state, &increment, MechanizationConfig::default()).expect("base step");
458
459 for col in 0..3 {
460 let mut psi = [0.0; 3];
461 psi[col] = epsilon;
462 let psi_skew = skew(psi);
463 let mut correction = mat3_identity();
464 for row in 0..3 {
465 for col in 0..3 {
466 correction[row][col] += psi_skew[row][col];
467 }
468 }
469 let mut perturbed = state;
470 perturbed.attitude_body_to_ecef =
471 reorthonormalize_dcm(&inline_rxr(&correction, &state.attitude_body_to_ecef))
472 .expect("perturbed attitude");
473 let next = mechanize_ecef(&perturbed, &increment, MechanizationConfig::default())
474 .expect("perturbed step");
475 let raw_attitude_error = attitude_error_ecef(
476 &next.attitude_body_to_ecef,
477 &base_next.attitude_body_to_ecef,
478 );
479 let psi_next = [
480 -raw_attitude_error[0],
481 -raw_attitude_error[1],
482 -raw_attitude_error[2],
483 ];
484
485 for (row, value) in psi_next.iter().enumerate() {
486 let fd = *value / epsilon;
487 assert_close(
488 fd,
489 linear.phi[ERROR_ATTITUDE_INDEX + row][ERROR_ATTITUDE_INDEX + col],
490 2.0e-9,
491 );
492 }
493 for row in 0..3 {
494 let fd = (next.velocity_ecef_mps[row] - base_next.velocity_ecef_mps[row]) / epsilon;
495 assert_close(
496 fd,
497 linear.phi[ERROR_VELOCITY_INDEX + row][ERROR_ATTITUDE_INDEX + col],
498 5.0e-7,
499 );
500 }
501 }
502 }
503
504 #[test]
505 fn qd_position_velocity_block_matches_closed_form_bits() {
506 let dt_s = 0.125_f64;
507 let q_accel = 0.02_f64 * 0.02_f64;
508 let spec = ImuSpec::datasheet(
509 0.02,
510 0.0,
511 0.0,
512 0.0,
513 RANDOM_WALK_BIAS_TAU_S,
514 RANDOM_WALK_BIAS_TAU_S,
515 None,
516 None,
517 );
518 let q_d = error_state_process_noise_discrete(&spec, dt_s, ErrorStateLayout::Fifteen)
519 .expect("process noise");
520 let dt = dt_s.abs();
521 let dt2 = dt * dt;
522 let dt3 = dt2 * dt;
523 for axis in 0..3 {
524 assert_eq!(q_d[axis][axis].to_bits(), (q_accel * dt3 / 3.0).to_bits());
525 assert_eq!(
526 q_d[axis][ERROR_VELOCITY_INDEX + axis].to_bits(),
527 (q_accel * dt2 / 2.0).to_bits()
528 );
529 assert_eq!(
530 q_d[ERROR_VELOCITY_INDEX + axis][axis].to_bits(),
531 (q_accel * dt2 / 2.0).to_bits()
532 );
533 assert_eq!(
534 q_d[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis].to_bits(),
535 (q_accel * dt).to_bits()
536 );
537 }
538 }
539
540 #[test]
541 fn propagate_only_logdet_grows_monotonically_with_process_noise() {
542 let state = reference_state();
543 let imu = reference_imu();
544 let spec = ImuSpec::datasheet(0.2, 0.03, 0.02, 0.002, 600.0, 900.0, None, None);
545 let mut covariance = vec![vec![0.0; 15]; 15];
546 for (idx, row) in covariance.iter_mut().enumerate() {
547 row[idx] = 1.0e-3;
548 }
549 let mut previous = logdet(&covariance);
550 for _ in 0..12 {
551 let linear =
552 linearize_error_state_ecef(&state, imu, &spec, 0.02, ErrorStateLayout::Fifteen)
553 .expect("linearization");
554 predict_error_state_covariance(&mut covariance, &linear.phi, &linear.q_d)
555 .expect("predict covariance");
556 let current = logdet(&covariance);
557 assert!(
558 current > previous,
559 "current {current:.17e}, previous {previous:.17e}"
560 );
561 previous = current;
562 }
563 }
564
565 fn logdet(covariance: &[Vec<f64>]) -> f64 {
566 let flat = covariance
567 .iter()
568 .flat_map(|row| row.iter().copied())
569 .collect::<Vec<_>>();
570 let matrix = DMatrix::from_row_slice(covariance.len(), covariance.len(), &flat);
571 let cholesky = matrix.cholesky().expect("test covariance is SPD");
572 2.0 * (0..covariance.len())
573 .map(|idx| cholesky.l()[(idx, idx)].ln())
574 .sum::<f64>()
575 }
576}