use nalgebra::DMatrix;
use super::ekf::{
apply_closed_loop_navigation_error, apply_closed_loop_scale_error,
normalized_innovation_squared, EkfCorrection, EkfCorrectionReport, InnovationGate,
InnovationGateReport,
};
use super::state::{
covariance_eigenvalue_tolerance, dmatrix_from_rows, invalid_input, matmul, matrix_sub,
reproject_covariance_psd, solve_spd, symmetrize_in_place, transpose,
validate_covariance_matrix, validate_finite_slice, validate_matrix_cols, validate_nonnegative,
validate_positive, FusionError, InsFilterState,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UnscentedTransformOptions {
pub alpha: f64,
pub beta: f64,
pub kappa: f64,
}
impl Default for UnscentedTransformOptions {
fn default() -> Self {
Self {
alpha: 0.5,
beta: 2.0,
kappa: 0.0,
}
}
}
impl UnscentedTransformOptions {
pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), FusionError> {
if dimension == 0 {
return Err(invalid_input("dimension", "must be positive"));
}
validate_positive(self.alpha, "ukf_alpha")?;
validate_nonnegative(self.beta, "ukf_beta")?;
validate_finite_slice(&[self.kappa], "ukf_kappa")?;
let scale = self.scale(dimension);
if scale.is_finite() && scale > 0.0 {
Ok(())
} else {
Err(invalid_input("ukf_scale", "must be positive"))
}
}
fn lambda(self, dimension: usize) -> f64 {
self.alpha * self.alpha * (dimension as f64 + self.kappa) - dimension as f64
}
fn scale(self, dimension: usize) -> f64 {
dimension as f64 + self.lambda(dimension)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct UkfUpdateOptions {
pub transform: UnscentedTransformOptions,
pub innovation_gate: Option<InnovationGate>,
}
impl UkfUpdateOptions {
pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), FusionError> {
self.transform.validate_for_dimension(dimension)?;
if let Some(gate) = self.innovation_gate {
gate.validate()?;
}
Ok(())
}
}
pub fn ukf_correct_closed_loop(
state: &mut InsFilterState,
correction: &EkfCorrection,
options: UkfUpdateOptions,
) -> Result<EkfCorrectionReport, FusionError> {
state.validate()?;
correction.validate_for_dimension(state.dimension())?;
options.validate_for_dimension(state.dimension())?;
let report = ukf_measurement_update(
&state.covariance,
&correction.innovation,
&correction.measurement_covariance,
options,
|sigma| super::state::matvec(&correction.design, sigma),
)?;
if !report.applied {
return Ok(report.into_public_report());
}
apply_closed_loop_navigation_error(&mut state.nominal, &report.dx)?;
apply_closed_loop_scale_error(state, &report.dx);
state.covariance = report.posterior_covariance.clone();
state.reset_error_state();
state.validate()?;
Ok(report.into_public_report())
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct InternalUkfReport {
pub(crate) applied: bool,
pub(crate) normalized_innovation_squared: f64,
pub(crate) accepted_rows: usize,
pub(crate) rejected_rows: usize,
pub(crate) innovation_gate: Option<InnovationGateReport>,
pub(crate) innovation_covariance: Vec<Vec<f64>>,
pub(crate) kalman_gain: Vec<Vec<f64>>,
pub(crate) dx: Vec<f64>,
pub(crate) posterior_covariance: Vec<Vec<f64>>,
}
impl InternalUkfReport {
pub(crate) fn into_public_report(self) -> EkfCorrectionReport {
EkfCorrectionReport {
applied: self.applied,
normalized_innovation_squared: self.normalized_innovation_squared,
accepted_rows: self.accepted_rows,
rejected_rows: self.rejected_rows,
innovation_gate: self.innovation_gate,
innovation_covariance: self.innovation_covariance,
kalman_gain: self.kalman_gain,
dx: self.dx,
}
}
}
pub(crate) fn ukf_measurement_update<F>(
covariance: &[Vec<f64>],
innovation: &[f64],
measurement_covariance: &[Vec<f64>],
options: UkfUpdateOptions,
measurement_model: F,
) -> Result<InternalUkfReport, FusionError>
where
F: Fn(&[f64]) -> Result<Vec<f64>, FusionError>,
{
let dimension = covariance.len();
validate_covariance_matrix(covariance, dimension, "covariance")?;
validate_finite_slice(innovation, "innovation")?;
validate_covariance_matrix(
measurement_covariance,
innovation.len(),
"measurement_covariance",
)?;
options.validate_for_dimension(dimension)?;
let sigma = sigma_points(covariance, options.transform)?;
let prediction = measurement_statistics(&sigma, innovation.len(), &measurement_model)?;
let full = predicted_update(
covariance,
innovation,
measurement_covariance,
&prediction,
None,
)?;
let Some(gate) = options.innovation_gate else {
return Ok(full);
};
let (accepted, gate_report) = screen_rows(
innovation,
&prediction.mean,
&full.innovation_covariance,
gate,
)?;
if gate_report.coasted {
let full_nis = normalized_innovation_squared(
&full.innovation_covariance,
&innovation_residual(innovation, &prediction.mean)?,
)?;
return Ok(InternalUkfReport {
applied: false,
normalized_innovation_squared: full_nis,
accepted_rows: gate_report.accepted_rows,
rejected_rows: gate_report.rejected_rows,
innovation_gate: Some(gate_report),
innovation_covariance: full.innovation_covariance,
kalman_gain: vec![vec![0.0; innovation.len()]; dimension],
dx: vec![0.0; dimension],
posterior_covariance: covariance.to_vec(),
});
}
let mut screened = predicted_update(
covariance,
innovation,
measurement_covariance,
&prediction,
Some(&accepted),
)?;
screened.accepted_rows = gate_report.accepted_rows;
screened.rejected_rows = gate_report.rejected_rows;
screened.innovation_gate = Some(gate_report);
Ok(screened)
}
#[derive(Debug, Clone, PartialEq)]
struct SigmaSet {
points: Vec<Vec<f64>>,
mean_weights: Vec<f64>,
covariance_weights: Vec<f64>,
}
#[derive(Debug, Clone, PartialEq)]
struct MeasurementPrediction {
values: Vec<Vec<f64>>,
mean: Vec<f64>,
cross_covariance: Vec<Vec<f64>>,
covariance_weights: Vec<f64>,
}
fn sigma_points(
covariance: &[Vec<f64>],
options: UnscentedTransformOptions,
) -> Result<SigmaSet, FusionError> {
let dimension = covariance.len();
options.validate_for_dimension(dimension)?;
let scale = options.scale(dimension);
let lambda = options.lambda(dimension);
let gamma = scale.sqrt();
let sqrt = covariance_square_root(covariance)?;
let point_count = 2 * dimension + 1;
let mut points = Vec::with_capacity(point_count);
points.push(vec![0.0; dimension]);
for col in 0..dimension {
let mut point = vec![0.0; dimension];
for row in 0..dimension {
point[row] = gamma * sqrt[(row, col)];
}
points.push(point);
}
for col in 0..dimension {
let mut point = vec![0.0; dimension];
for row in 0..dimension {
point[row] = -gamma * sqrt[(row, col)];
}
points.push(point);
}
let mut mean_weights = vec![0.5 / scale; point_count];
let mut covariance_weights = mean_weights.clone();
mean_weights[0] = lambda / scale;
covariance_weights[0] = mean_weights[0] + (1.0 - options.alpha * options.alpha + options.beta);
Ok(SigmaSet {
points,
mean_weights,
covariance_weights,
})
}
fn covariance_square_root(covariance: &[Vec<f64>]) -> Result<DMatrix<f64>, FusionError> {
let dimension = covariance.len();
validate_covariance_matrix(covariance, dimension, "covariance")?;
let matrix = dmatrix_from_rows(covariance);
if let Some(cholesky) = matrix.clone().cholesky() {
return Ok(cholesky.l());
}
let eigen = matrix.symmetric_eigen();
let mut diagonal = DMatrix::<f64>::zeros(dimension, dimension);
for idx in 0..dimension {
let eigenvalue = eigen.eigenvalues[idx];
if eigenvalue < 0.0 {
let tolerance = covariance_eigenvalue_tolerance(covariance, &eigen.eigenvectors, idx);
if eigenvalue < -tolerance {
return Err(FusionError::NonPositiveSemidefinite {
field: "covariance",
});
}
diagonal[(idx, idx)] = 0.0;
} else {
diagonal[(idx, idx)] = eigenvalue.sqrt();
}
}
Ok(eigen.eigenvectors * diagonal)
}
fn measurement_statistics<F>(
sigma: &SigmaSet,
measurement_dimension: usize,
measurement_model: &F,
) -> Result<MeasurementPrediction, FusionError>
where
F: Fn(&[f64]) -> Result<Vec<f64>, FusionError>,
{
let mut values = Vec::with_capacity(sigma.points.len());
for point in &sigma.points {
let value = measurement_model(point)?;
if value.len() != measurement_dimension {
return Err(FusionError::DimensionMismatch {
field: "ukf_measurement",
expected: measurement_dimension,
actual: value.len(),
});
}
validate_finite_slice(&value, "ukf_measurement")?;
values.push(value);
}
let mut mean = vec![0.0; measurement_dimension];
for (weight, value) in sigma.mean_weights.iter().zip(values.iter()) {
for col in 0..measurement_dimension {
mean[col] += weight * value[col];
}
}
let state_dimension = sigma.points[0].len();
let mut cross_covariance = vec![vec![0.0; measurement_dimension]; state_dimension];
for (idx, point) in sigma.points.iter().enumerate() {
let weight = sigma.covariance_weights[idx];
for row in 0..state_dimension {
for col in 0..measurement_dimension {
cross_covariance[row][col] += weight * point[row] * (values[idx][col] - mean[col]);
}
}
}
Ok(MeasurementPrediction {
values,
mean,
cross_covariance,
covariance_weights: sigma.covariance_weights.clone(),
})
}
fn predicted_update(
covariance: &[Vec<f64>],
innovation: &[f64],
measurement_covariance: &[Vec<f64>],
prediction: &MeasurementPrediction,
accepted: Option<&[usize]>,
) -> Result<InternalUkfReport, FusionError> {
let selected = accepted
.map(<[usize]>::to_vec)
.unwrap_or_else(|| (0..innovation.len()).collect());
let innovation = select_vector(innovation, &selected)?;
let mean = select_vector(&prediction.mean, &selected)?;
let measurement_covariance = select_matrix(measurement_covariance, &selected)?;
let values = prediction
.values
.iter()
.map(|value| select_vector(value, &selected))
.collect::<Result<Vec<_>, _>>()?;
let cross_covariance = select_columns(&prediction.cross_covariance, &selected)?;
let residual = innovation_residual(&innovation, &mean)?;
let mut innovation_covariance = measurement_covariance;
for (idx, value) in values.iter().enumerate() {
let weight = prediction.covariance_weights[idx];
for row in 0..selected.len() {
let dy_row = value[row] - mean[row];
for col in 0..selected.len() {
innovation_covariance[row][col] += weight * dy_row * (value[col] - mean[col]);
}
}
}
symmetrize_in_place(&mut innovation_covariance);
validate_covariance_matrix(
&innovation_covariance,
selected.len(),
"innovation_covariance",
)?;
let mut kalman_gain = vec![vec![0.0; selected.len()]; covariance.len()];
let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
for row in 0..covariance.len() {
kalman_gain[row] = solve_spd(&innovation_covariance, &cross_covariance[row], &mut scratch)?;
}
let dx = super::state::matvec(&kalman_gain, &residual)?;
let nis = normalized_innovation_squared(&innovation_covariance, &residual)?;
let ks = matmul(&kalman_gain, &innovation_covariance)?;
let k_t = transpose(&kalman_gain)?;
let ksk_t = matmul(&ks, &k_t)?;
let mut posterior_covariance = matrix_sub(covariance, &ksk_t)?;
symmetrize_in_place(&mut posterior_covariance);
reproject_covariance_psd(&mut posterior_covariance, "ukf_covariance")?;
Ok(InternalUkfReport {
applied: true,
normalized_innovation_squared: nis,
accepted_rows: selected.len(),
rejected_rows: innovation.len().saturating_sub(selected.len()),
innovation_gate: None,
innovation_covariance,
kalman_gain,
dx,
posterior_covariance,
})
}
fn innovation_residual(innovation: &[f64], mean: &[f64]) -> Result<Vec<f64>, FusionError> {
if innovation.len() != mean.len() {
return Err(FusionError::DimensionMismatch {
field: "innovation_mean",
expected: innovation.len(),
actual: mean.len(),
});
}
Ok(innovation
.iter()
.zip(mean.iter())
.map(|(actual, predicted)| actual - predicted)
.collect())
}
fn screen_rows(
innovation: &[f64],
mean: &[f64],
innovation_covariance: &[Vec<f64>],
gate: InnovationGate,
) -> Result<(Vec<usize>, InnovationGateReport), FusionError> {
gate.validate()?;
let residual = innovation_residual(innovation, mean)?;
let mut accepted = Vec::with_capacity(innovation.len());
let mut rejected_rows = 0usize;
let mut max_abs_normalized_innovation = None;
let mut max_rejected_abs_normalized_innovation = None;
for (row, value) in residual.iter().enumerate() {
let variance = innovation_covariance[row][row];
validate_positive(variance, "innovation_covariance_diagonal")?;
let normalized = (value / variance.sqrt()).abs();
max_abs_normalized_innovation = Some(
max_abs_normalized_innovation
.map_or(normalized, |current: f64| current.max(normalized)),
);
if normalized <= gate.threshold_sigma {
accepted.push(row);
} else {
rejected_rows += 1;
max_rejected_abs_normalized_innovation = Some(
max_rejected_abs_normalized_innovation
.map_or(normalized, |current: f64| current.max(normalized)),
);
}
}
let coasted = accepted.len() < gate.min_rows;
let report = InnovationGateReport {
threshold_sigma: gate.threshold_sigma,
min_rows: gate.min_rows,
input_rows: innovation.len(),
accepted_rows: accepted.len(),
rejected_rows,
max_abs_normalized_innovation,
max_rejected_abs_normalized_innovation,
coasted,
};
Ok((accepted, report))
}
fn select_vector(values: &[f64], indices: &[usize]) -> Result<Vec<f64>, FusionError> {
let mut selected = Vec::with_capacity(indices.len());
for idx in indices {
let Some(value) = values.get(*idx) else {
return Err(FusionError::DimensionMismatch {
field: "selected_measurement",
expected: values.len(),
actual: *idx,
});
};
selected.push(*value);
}
Ok(selected)
}
fn select_matrix(matrix: &[Vec<f64>], indices: &[usize]) -> Result<Vec<Vec<f64>>, FusionError> {
let mut out = vec![vec![0.0; indices.len()]; indices.len()];
for (row_out, row_in) in indices.iter().enumerate() {
for (col_out, col_in) in indices.iter().enumerate() {
out[row_out][col_out] = matrix[*row_in][*col_in];
}
}
Ok(out)
}
fn select_columns(matrix: &[Vec<f64>], indices: &[usize]) -> Result<Vec<Vec<f64>>, FusionError> {
if matrix.is_empty() {
return Err(invalid_input("matrix", "must not be empty"));
}
validate_matrix_cols(matrix, matrix[0].len(), "matrix")?;
let mut out = vec![vec![0.0; indices.len()]; matrix.len()];
for (row_out, row) in matrix.iter().enumerate() {
for (col_out, col_in) in indices.iter().enumerate() {
out[row_out][col_out] = row[*col_in];
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::constants::earth::WGS84_A_M;
use crate::fusion::ekf::{ekf_correct_closed_loop, EkfUpdateOptions};
use crate::fusion::state::{ErrorStateLayout, ERROR_STATE_DIMENSION_15};
use crate::inertial::state::mat3_identity;
use crate::inertial::NavState;
fn assert_close(actual: f64, expected: f64, tolerance: f64) {
assert!(
(actual - expected).abs() <= tolerance,
"actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
);
}
fn linear_test_state() -> InsFilterState {
let nominal =
NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("nominal");
let mut covariance = vec![vec![0.0; ERROR_STATE_DIMENSION_15]; ERROR_STATE_DIMENSION_15];
for (idx, row) in covariance.iter_mut().enumerate() {
row[idx] = 1.0;
}
covariance[0][0] = 4.0;
covariance[0][1] = 1.0;
covariance[1][0] = 1.0;
covariance[1][1] = 9.0;
InsFilterState::new(nominal, ErrorStateLayout::Fifteen, covariance).expect("state")
}
#[test]
fn linear_measurement_matches_closed_form_and_ekf() {
let mut design = vec![vec![0.0; ERROR_STATE_DIMENSION_15]];
design[0][0] = 0.5;
design[0][1] = -2.0;
let correction =
EkfCorrection::new(vec![1.25], design, vec![vec![0.25]]).expect("correction");
let mut ekf_state = linear_test_state();
let mut ukf_state = linear_test_state();
let ekf = ekf_correct_closed_loop(&mut ekf_state, &correction, EkfUpdateOptions::default())
.expect("ekf");
let ukf = ukf_correct_closed_loop(
&mut ukf_state,
&correction,
UkfUpdateOptions {
transform: UnscentedTransformOptions {
alpha: 1.0,
beta: 2.0,
kappa: 0.0,
},
innovation_gate: None,
},
)
.expect("ukf");
let expected_s = 35.25_f64;
let expected_k0 = 0.0_f64;
let expected_k1 = -17.5 / expected_s;
let expected_dx1 = expected_k1 * 1.25;
assert_close(ukf.innovation_covariance[0][0], expected_s, 1.0e-13);
assert_close(ukf.kalman_gain[0][0], expected_k0, 1.0e-14);
assert_close(ukf.kalman_gain[1][0], expected_k1, 1.0e-14);
assert_close(ukf.dx[1], expected_dx1, 1.0e-14);
for row in 0..ERROR_STATE_DIMENSION_15 {
assert_close(ukf.kalman_gain[row][0], ekf.kalman_gain[row][0], 1.0e-15);
assert_close(ukf.dx[row], ekf.dx[row], 1.0e-15);
for col in 0..ERROR_STATE_DIMENSION_15 {
assert_close(
ukf_state.covariance[row][col],
ekf_state.covariance[row][col],
1.0e-15,
);
}
}
assert_close(
ukf_state.nominal.position_ecef_m[1],
ekf_state.nominal.position_ecef_m[1],
3.0e-13,
);
}
}