Skip to main content

sidereon_core/fusion/
ukf.rs

1//! Scaled-sigma-point unscented correction for the fusion error state.
2
3use 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/// Scaled unscented-transform parameters.
18///
19/// `alpha`, `beta`, and `kappa` produce Wan/van der Merwe sigma-point weights
20/// with `lambda = alpha^2 * (n + kappa) - n`.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct UnscentedTransformOptions {
23    /// Sigma-point spread around the mean.
24    pub alpha: f64,
25    /// Prior-distribution shape parameter. `2.0` is the Gaussian choice.
26    pub beta: f64,
27    /// Secondary sigma-point scaling parameter.
28    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    /// Validate scaling parameters for a state dimension.
43    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/// UKF measurement-correction options.
68#[derive(Debug, Clone, Copy, PartialEq, Default)]
69pub struct UkfUpdateOptions {
70    /// Scaled unscented-transform parameters.
71    pub transform: UnscentedTransformOptions,
72    /// Optional normalized-innovation screen applied before correction.
73    pub innovation_gate: Option<InnovationGate>,
74}
75
76impl UkfUpdateOptions {
77    /// Validate transform and gate options for a state dimension.
78    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
87/// Apply a linear UKF correction, then close the loop and reset the error vector.
88///
89/// This uses the same [`EkfCorrection`] measurement struct as the EKF path. The
90/// supplied design matrix is evaluated as a linear measurement function at each
91/// sigma point.
92pub 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    //! Provenance: UKF weights and correction equations follow Wan and van der
519    //! Merwe, The Unscented Kalman Filter for Nonlinear Estimation, 2000, and
520    //! van der Merwe, Sigma-Point Kalman Filters for Probabilistic Inference in
521    //! Dynamic State-Space Models, 2004, Section 3.2.3. The linear-measurement
522    //! oracle is the closed-form scalar Kalman update `K = P H' / (H P H' + R)`.
523
524    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}