1use nalgebra::DMatrix;
4
5use super::ekf::{
6 apply_closed_loop_navigation_error, apply_closed_loop_scale_error,
7 normalized_innovation_squared, EkfCorrection, EkfCorrectionReport, InnovationGate,
8 InnovationGateReport,
9};
10use super::state::{
11 covariance_eigenvalue_tolerance, dmatrix_from_rows, invalid_input, matmul, matrix_sub,
12 reproject_covariance_psd, solve_spd, symmetrize_in_place, transpose,
13 validate_covariance_matrix, validate_finite_slice, validate_matrix_cols, validate_nonnegative,
14 validate_positive, FusionError, InsFilterState,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct UnscentedTransformOptions {
23 pub alpha: f64,
25 pub beta: f64,
27 pub kappa: f64,
29}
30
31impl Default for UnscentedTransformOptions {
32 fn default() -> Self {
33 Self {
34 alpha: 0.5,
35 beta: 2.0,
36 kappa: 0.0,
37 }
38 }
39}
40
41impl UnscentedTransformOptions {
42 pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), FusionError> {
44 if dimension == 0 {
45 return Err(invalid_input("dimension", "must be positive"));
46 }
47 validate_positive(self.alpha, "ukf_alpha")?;
48 validate_nonnegative(self.beta, "ukf_beta")?;
49 validate_finite_slice(&[self.kappa], "ukf_kappa")?;
50 let scale = self.scale(dimension);
51 if scale.is_finite() && scale > 0.0 {
52 Ok(())
53 } else {
54 Err(invalid_input("ukf_scale", "must be positive"))
55 }
56 }
57
58 fn lambda(self, dimension: usize) -> f64 {
59 self.alpha * self.alpha * (dimension as f64 + self.kappa) - dimension as f64
60 }
61
62 fn scale(self, dimension: usize) -> f64 {
63 dimension as f64 + self.lambda(dimension)
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Default)]
69pub struct UkfUpdateOptions {
70 pub transform: UnscentedTransformOptions,
72 pub innovation_gate: Option<InnovationGate>,
74}
75
76impl UkfUpdateOptions {
77 pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), FusionError> {
79 self.transform.validate_for_dimension(dimension)?;
80 if let Some(gate) = self.innovation_gate {
81 gate.validate()?;
82 }
83 Ok(())
84 }
85}
86
87pub fn ukf_correct_closed_loop(
93 state: &mut InsFilterState,
94 correction: &EkfCorrection,
95 options: UkfUpdateOptions,
96) -> Result<EkfCorrectionReport, FusionError> {
97 state.validate()?;
98 correction.validate_for_dimension(state.dimension())?;
99 options.validate_for_dimension(state.dimension())?;
100
101 let report = ukf_measurement_update(
102 &state.covariance,
103 &correction.innovation,
104 &correction.measurement_covariance,
105 options,
106 |sigma| super::state::matvec(&correction.design, sigma),
107 )?;
108 if !report.applied {
109 return Ok(report.into_public_report());
110 }
111
112 apply_closed_loop_navigation_error(&mut state.nominal, &report.dx)?;
113 apply_closed_loop_scale_error(state, &report.dx);
114 state.covariance = report.posterior_covariance.clone();
115 state.reset_error_state();
116 state.validate()?;
117 Ok(report.into_public_report())
118}
119
120#[derive(Debug, Clone, PartialEq)]
121pub(crate) struct InternalUkfReport {
122 pub(crate) applied: bool,
123 pub(crate) normalized_innovation_squared: f64,
124 pub(crate) accepted_rows: usize,
125 pub(crate) rejected_rows: usize,
126 pub(crate) innovation_gate: Option<InnovationGateReport>,
127 pub(crate) innovation_covariance: Vec<Vec<f64>>,
128 pub(crate) kalman_gain: Vec<Vec<f64>>,
129 pub(crate) dx: Vec<f64>,
130 pub(crate) posterior_covariance: Vec<Vec<f64>>,
131}
132
133impl InternalUkfReport {
134 pub(crate) fn into_public_report(self) -> EkfCorrectionReport {
135 EkfCorrectionReport {
136 applied: self.applied,
137 normalized_innovation_squared: self.normalized_innovation_squared,
138 accepted_rows: self.accepted_rows,
139 rejected_rows: self.rejected_rows,
140 innovation_gate: self.innovation_gate,
141 innovation_covariance: self.innovation_covariance,
142 kalman_gain: self.kalman_gain,
143 dx: self.dx,
144 }
145 }
146}
147
148pub(crate) fn ukf_measurement_update<F>(
149 covariance: &[Vec<f64>],
150 innovation: &[f64],
151 measurement_covariance: &[Vec<f64>],
152 options: UkfUpdateOptions,
153 measurement_model: F,
154) -> Result<InternalUkfReport, FusionError>
155where
156 F: Fn(&[f64]) -> Result<Vec<f64>, FusionError>,
157{
158 let dimension = covariance.len();
159 validate_covariance_matrix(covariance, dimension, "covariance")?;
160 validate_finite_slice(innovation, "innovation")?;
161 validate_covariance_matrix(
162 measurement_covariance,
163 innovation.len(),
164 "measurement_covariance",
165 )?;
166 options.validate_for_dimension(dimension)?;
167
168 let sigma = sigma_points(covariance, options.transform)?;
169 let prediction = measurement_statistics(&sigma, innovation.len(), &measurement_model)?;
170 let full = predicted_update(
171 covariance,
172 innovation,
173 measurement_covariance,
174 &prediction,
175 None,
176 )?;
177
178 let Some(gate) = options.innovation_gate else {
179 return Ok(full);
180 };
181
182 let (accepted, gate_report) = screen_rows(
183 innovation,
184 &prediction.mean,
185 &full.innovation_covariance,
186 gate,
187 )?;
188 if gate_report.coasted {
189 let full_nis = normalized_innovation_squared(
190 &full.innovation_covariance,
191 &innovation_residual(innovation, &prediction.mean)?,
192 )?;
193 return Ok(InternalUkfReport {
194 applied: false,
195 normalized_innovation_squared: full_nis,
196 accepted_rows: gate_report.accepted_rows,
197 rejected_rows: gate_report.rejected_rows,
198 innovation_gate: Some(gate_report),
199 innovation_covariance: full.innovation_covariance,
200 kalman_gain: vec![vec![0.0; innovation.len()]; dimension],
201 dx: vec![0.0; dimension],
202 posterior_covariance: covariance.to_vec(),
203 });
204 }
205
206 let mut screened = predicted_update(
207 covariance,
208 innovation,
209 measurement_covariance,
210 &prediction,
211 Some(&accepted),
212 )?;
213 screened.accepted_rows = gate_report.accepted_rows;
214 screened.rejected_rows = gate_report.rejected_rows;
215 screened.innovation_gate = Some(gate_report);
216 Ok(screened)
217}
218
219#[derive(Debug, Clone, PartialEq)]
220struct SigmaSet {
221 points: Vec<Vec<f64>>,
222 mean_weights: Vec<f64>,
223 covariance_weights: Vec<f64>,
224}
225
226#[derive(Debug, Clone, PartialEq)]
227struct MeasurementPrediction {
228 values: Vec<Vec<f64>>,
229 mean: Vec<f64>,
230 cross_covariance: Vec<Vec<f64>>,
231 covariance_weights: Vec<f64>,
232}
233
234fn sigma_points(
235 covariance: &[Vec<f64>],
236 options: UnscentedTransformOptions,
237) -> Result<SigmaSet, FusionError> {
238 let dimension = covariance.len();
239 options.validate_for_dimension(dimension)?;
240 let scale = options.scale(dimension);
241 let lambda = options.lambda(dimension);
242 let gamma = scale.sqrt();
243 let sqrt = covariance_square_root(covariance)?;
244
245 let point_count = 2 * dimension + 1;
246 let mut points = Vec::with_capacity(point_count);
247 points.push(vec![0.0; dimension]);
248 for col in 0..dimension {
249 let mut point = vec![0.0; dimension];
250 for row in 0..dimension {
251 point[row] = gamma * sqrt[(row, col)];
252 }
253 points.push(point);
254 }
255 for col in 0..dimension {
256 let mut point = vec![0.0; dimension];
257 for row in 0..dimension {
258 point[row] = -gamma * sqrt[(row, col)];
259 }
260 points.push(point);
261 }
262
263 let mut mean_weights = vec![0.5 / scale; point_count];
264 let mut covariance_weights = mean_weights.clone();
265 mean_weights[0] = lambda / scale;
266 covariance_weights[0] = mean_weights[0] + (1.0 - options.alpha * options.alpha + options.beta);
267
268 Ok(SigmaSet {
269 points,
270 mean_weights,
271 covariance_weights,
272 })
273}
274
275fn covariance_square_root(covariance: &[Vec<f64>]) -> Result<DMatrix<f64>, FusionError> {
276 let dimension = covariance.len();
277 validate_covariance_matrix(covariance, dimension, "covariance")?;
278 let matrix = dmatrix_from_rows(covariance);
279 if let Some(cholesky) = matrix.clone().cholesky() {
280 return Ok(cholesky.l());
281 }
282
283 let eigen = matrix.symmetric_eigen();
284 let mut diagonal = DMatrix::<f64>::zeros(dimension, dimension);
285 for idx in 0..dimension {
286 let eigenvalue = eigen.eigenvalues[idx];
287 if eigenvalue < 0.0 {
288 let tolerance = covariance_eigenvalue_tolerance(covariance, &eigen.eigenvectors, idx);
289 if eigenvalue < -tolerance {
290 return Err(FusionError::NonPositiveSemidefinite {
291 field: "covariance",
292 });
293 }
294 diagonal[(idx, idx)] = 0.0;
295 } else {
296 diagonal[(idx, idx)] = eigenvalue.sqrt();
297 }
298 }
299 Ok(eigen.eigenvectors * diagonal)
300}
301
302fn measurement_statistics<F>(
303 sigma: &SigmaSet,
304 measurement_dimension: usize,
305 measurement_model: &F,
306) -> Result<MeasurementPrediction, FusionError>
307where
308 F: Fn(&[f64]) -> Result<Vec<f64>, FusionError>,
309{
310 let mut values = Vec::with_capacity(sigma.points.len());
311 for point in &sigma.points {
312 let value = measurement_model(point)?;
313 if value.len() != measurement_dimension {
314 return Err(FusionError::DimensionMismatch {
315 field: "ukf_measurement",
316 expected: measurement_dimension,
317 actual: value.len(),
318 });
319 }
320 validate_finite_slice(&value, "ukf_measurement")?;
321 values.push(value);
322 }
323
324 let mut mean = vec![0.0; measurement_dimension];
325 for (weight, value) in sigma.mean_weights.iter().zip(values.iter()) {
326 for col in 0..measurement_dimension {
327 mean[col] += weight * value[col];
328 }
329 }
330
331 let state_dimension = sigma.points[0].len();
332 let mut cross_covariance = vec![vec![0.0; measurement_dimension]; state_dimension];
333 for (idx, point) in sigma.points.iter().enumerate() {
334 let weight = sigma.covariance_weights[idx];
335 for row in 0..state_dimension {
336 for col in 0..measurement_dimension {
337 cross_covariance[row][col] += weight * point[row] * (values[idx][col] - mean[col]);
338 }
339 }
340 }
341
342 Ok(MeasurementPrediction {
343 values,
344 mean,
345 cross_covariance,
346 covariance_weights: sigma.covariance_weights.clone(),
347 })
348}
349
350fn predicted_update(
351 covariance: &[Vec<f64>],
352 innovation: &[f64],
353 measurement_covariance: &[Vec<f64>],
354 prediction: &MeasurementPrediction,
355 accepted: Option<&[usize]>,
356) -> Result<InternalUkfReport, FusionError> {
357 let selected = accepted
358 .map(<[usize]>::to_vec)
359 .unwrap_or_else(|| (0..innovation.len()).collect());
360 let innovation = select_vector(innovation, &selected)?;
361 let mean = select_vector(&prediction.mean, &selected)?;
362 let measurement_covariance = select_matrix(measurement_covariance, &selected)?;
363 let values = prediction
364 .values
365 .iter()
366 .map(|value| select_vector(value, &selected))
367 .collect::<Result<Vec<_>, _>>()?;
368 let cross_covariance = select_columns(&prediction.cross_covariance, &selected)?;
369
370 let residual = innovation_residual(&innovation, &mean)?;
371 let mut innovation_covariance = measurement_covariance;
372 for (idx, value) in values.iter().enumerate() {
373 let weight = prediction.covariance_weights[idx];
374 for row in 0..selected.len() {
375 let dy_row = value[row] - mean[row];
376 for col in 0..selected.len() {
377 innovation_covariance[row][col] += weight * dy_row * (value[col] - mean[col]);
378 }
379 }
380 }
381 symmetrize_in_place(&mut innovation_covariance);
382 validate_covariance_matrix(
383 &innovation_covariance,
384 selected.len(),
385 "innovation_covariance",
386 )?;
387
388 let mut kalman_gain = vec![vec![0.0; selected.len()]; covariance.len()];
389 let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
390 for row in 0..covariance.len() {
391 kalman_gain[row] = solve_spd(&innovation_covariance, &cross_covariance[row], &mut scratch)?;
392 }
393
394 let dx = super::state::matvec(&kalman_gain, &residual)?;
395 let nis = normalized_innovation_squared(&innovation_covariance, &residual)?;
396 let ks = matmul(&kalman_gain, &innovation_covariance)?;
397 let k_t = transpose(&kalman_gain)?;
398 let ksk_t = matmul(&ks, &k_t)?;
399 let mut posterior_covariance = matrix_sub(covariance, &ksk_t)?;
400 symmetrize_in_place(&mut posterior_covariance);
401 reproject_covariance_psd(&mut posterior_covariance, "ukf_covariance")?;
402
403 Ok(InternalUkfReport {
404 applied: true,
405 normalized_innovation_squared: nis,
406 accepted_rows: selected.len(),
407 rejected_rows: innovation.len().saturating_sub(selected.len()),
408 innovation_gate: None,
409 innovation_covariance,
410 kalman_gain,
411 dx,
412 posterior_covariance,
413 })
414}
415
416fn innovation_residual(innovation: &[f64], mean: &[f64]) -> Result<Vec<f64>, FusionError> {
417 if innovation.len() != mean.len() {
418 return Err(FusionError::DimensionMismatch {
419 field: "innovation_mean",
420 expected: innovation.len(),
421 actual: mean.len(),
422 });
423 }
424 Ok(innovation
425 .iter()
426 .zip(mean.iter())
427 .map(|(actual, predicted)| actual - predicted)
428 .collect())
429}
430
431fn screen_rows(
432 innovation: &[f64],
433 mean: &[f64],
434 innovation_covariance: &[Vec<f64>],
435 gate: InnovationGate,
436) -> Result<(Vec<usize>, InnovationGateReport), FusionError> {
437 gate.validate()?;
438 let residual = innovation_residual(innovation, mean)?;
439 let mut accepted = Vec::with_capacity(innovation.len());
440 let mut rejected_rows = 0usize;
441 let mut max_abs_normalized_innovation = None;
442 let mut max_rejected_abs_normalized_innovation = None;
443
444 for (row, value) in residual.iter().enumerate() {
445 let variance = innovation_covariance[row][row];
446 validate_positive(variance, "innovation_covariance_diagonal")?;
447 let normalized = (value / variance.sqrt()).abs();
448 max_abs_normalized_innovation = Some(
449 max_abs_normalized_innovation
450 .map_or(normalized, |current: f64| current.max(normalized)),
451 );
452 if normalized <= gate.threshold_sigma {
453 accepted.push(row);
454 } else {
455 rejected_rows += 1;
456 max_rejected_abs_normalized_innovation = Some(
457 max_rejected_abs_normalized_innovation
458 .map_or(normalized, |current: f64| current.max(normalized)),
459 );
460 }
461 }
462
463 let coasted = accepted.len() < gate.min_rows;
464 let report = InnovationGateReport {
465 threshold_sigma: gate.threshold_sigma,
466 min_rows: gate.min_rows,
467 input_rows: innovation.len(),
468 accepted_rows: accepted.len(),
469 rejected_rows,
470 max_abs_normalized_innovation,
471 max_rejected_abs_normalized_innovation,
472 coasted,
473 };
474 Ok((accepted, report))
475}
476
477fn select_vector(values: &[f64], indices: &[usize]) -> Result<Vec<f64>, FusionError> {
478 let mut selected = Vec::with_capacity(indices.len());
479 for idx in indices {
480 let Some(value) = values.get(*idx) else {
481 return Err(FusionError::DimensionMismatch {
482 field: "selected_measurement",
483 expected: values.len(),
484 actual: *idx,
485 });
486 };
487 selected.push(*value);
488 }
489 Ok(selected)
490}
491
492fn select_matrix(matrix: &[Vec<f64>], indices: &[usize]) -> Result<Vec<Vec<f64>>, FusionError> {
493 let mut out = vec![vec![0.0; indices.len()]; indices.len()];
494 for (row_out, row_in) in indices.iter().enumerate() {
495 for (col_out, col_in) in indices.iter().enumerate() {
496 out[row_out][col_out] = matrix[*row_in][*col_in];
497 }
498 }
499 Ok(out)
500}
501
502fn select_columns(matrix: &[Vec<f64>], indices: &[usize]) -> Result<Vec<Vec<f64>>, FusionError> {
503 if matrix.is_empty() {
504 return Err(invalid_input("matrix", "must not be empty"));
505 }
506 validate_matrix_cols(matrix, matrix[0].len(), "matrix")?;
507 let mut out = vec![vec![0.0; indices.len()]; matrix.len()];
508 for (row_out, row) in matrix.iter().enumerate() {
509 for (col_out, col_in) in indices.iter().enumerate() {
510 out[row_out][col_out] = row[*col_in];
511 }
512 }
513 Ok(out)
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
525 use crate::astro::constants::earth::WGS84_A_M;
526 use crate::fusion::ekf::{ekf_correct_closed_loop, EkfUpdateOptions};
527 use crate::fusion::state::{ErrorStateLayout, ERROR_STATE_DIMENSION_15};
528 use crate::inertial::state::mat3_identity;
529 use crate::inertial::NavState;
530
531 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
532 assert!(
533 (actual - expected).abs() <= tolerance,
534 "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
535 );
536 }
537
538 fn linear_test_state() -> InsFilterState {
539 let nominal =
540 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("nominal");
541 let mut covariance = vec![vec![0.0; ERROR_STATE_DIMENSION_15]; ERROR_STATE_DIMENSION_15];
542 for (idx, row) in covariance.iter_mut().enumerate() {
543 row[idx] = 1.0;
544 }
545 covariance[0][0] = 4.0;
546 covariance[0][1] = 1.0;
547 covariance[1][0] = 1.0;
548 covariance[1][1] = 9.0;
549 InsFilterState::new(nominal, ErrorStateLayout::Fifteen, covariance).expect("state")
550 }
551
552 #[test]
553 fn linear_measurement_matches_closed_form_and_ekf() {
554 let mut design = vec![vec![0.0; ERROR_STATE_DIMENSION_15]];
555 design[0][0] = 0.5;
556 design[0][1] = -2.0;
557 let correction =
558 EkfCorrection::new(vec![1.25], design, vec![vec![0.25]]).expect("correction");
559 let mut ekf_state = linear_test_state();
560 let mut ukf_state = linear_test_state();
561
562 let ekf = ekf_correct_closed_loop(&mut ekf_state, &correction, EkfUpdateOptions::default())
563 .expect("ekf");
564 let ukf = ukf_correct_closed_loop(
565 &mut ukf_state,
566 &correction,
567 UkfUpdateOptions {
568 transform: UnscentedTransformOptions {
569 alpha: 1.0,
570 beta: 2.0,
571 kappa: 0.0,
572 },
573 innovation_gate: None,
574 },
575 )
576 .expect("ukf");
577
578 let expected_s = 35.25_f64;
579 let expected_k0 = 0.0_f64;
580 let expected_k1 = -17.5 / expected_s;
581 let expected_dx1 = expected_k1 * 1.25;
582 assert_close(ukf.innovation_covariance[0][0], expected_s, 1.0e-13);
583 assert_close(ukf.kalman_gain[0][0], expected_k0, 1.0e-14);
584 assert_close(ukf.kalman_gain[1][0], expected_k1, 1.0e-14);
585 assert_close(ukf.dx[1], expected_dx1, 1.0e-14);
586
587 for row in 0..ERROR_STATE_DIMENSION_15 {
588 assert_close(ukf.kalman_gain[row][0], ekf.kalman_gain[row][0], 1.0e-15);
589 assert_close(ukf.dx[row], ekf.dx[row], 1.0e-15);
590 for col in 0..ERROR_STATE_DIMENSION_15 {
591 assert_close(
592 ukf_state.covariance[row][col],
593 ekf_state.covariance[row][col],
594 1.0e-15,
595 );
596 }
597 }
598 assert_close(
599 ukf_state.nominal.position_ecef_m[1],
600 ekf_state.nominal.position_ecef_m[1],
601 3.0e-13,
602 );
603 }
604}