The Unscented Kalman Filter (UKF) is a sequential estimator that propagates deterministic sigma points through the true nonlinear dynamics and measurement models, avoiding linearization entirely. It captures the mean and covariance of the state distribution to second order without computing Jacobians or a State Transition Matrix.
The UKF constructor takes the same arguments as the EKF, plus UKF-specific tuning parameters (alpha, beta, kappa) via UKFConfig. It does not require STM propagation.
importnumpyasnpimportbraheasbhbh.initialize_eop()# Define a LEO circular orbitepoch=bh.Epoch(2024,1,1,0,0,0.0)r=bh.R_EARTH+500e3v=(bh.GM_EARTH/r)**0.5true_state=np.array([r,0.0,0.0,0.0,v,0.0])# Create a truth propagator for generating observationstruth_prop=bh.NumericalOrbitPropagator(epoch,true_state,bh.NumericalPropagationConfig.default(),bh.ForceModelConfig.two_body(),)# Perturbed initial state: 1 km position error, 1 m/s velocity errorinitial_state=true_state.copy()initial_state[0]+=1000.0initial_state[4]+=1.0# Initial covariance reflecting our uncertaintyp0=np.diag([1e6,1e6,1e6,1e2,1e2,1e2])# Create the UKF with inertial position measurements (10 m noise)ukf=bh.UnscentedKalmanFilter(epoch,initial_state,p0,measurement_models=[bh.InertialPositionMeasurementModel(10.0)],propagation_config=bh.NumericalPropagationConfig.default(),force_config=bh.ForceModelConfig.two_body(),)# Process 30 observations at 60-second intervalsdt=60.0foriinrange(1,31):obs_epoch=epoch+dt*itruth_prop.propagate_to(obs_epoch)truth_pos=truth_prop.current_state()[:3]obs=bh.Observation(obs_epoch,truth_pos,model_index=0)ukf.process_observation(obs)# Compare final state to truthtruth_prop.propagate_to(ukf.current_epoch())truth_final=truth_prop.current_state()final_state=ukf.current_state()pos_error=np.linalg.norm(final_state[:3]-truth_final[:3])vel_error=np.linalg.norm(final_state[3:6]-truth_final[3:6])print("Initial position error: 1000.0 m")print(f"Final position error: {pos_error:.2f} m")print(f"Final velocity error: {vel_error:.4f} m/s")print(f"Observations processed: {len(ukf.records())}")# Show final covariance diagonal (1-sigma uncertainties)cov=ukf.current_covariance()sigma=np.sqrt(np.diag(cov))print("\n1-sigma uncertainties:")print(f" Position: [{sigma[0]:.1f}, {sigma[1]:.1f}, {sigma[2]:.1f}] m")print(f" Velocity: [{sigma[3]:.4f}, {sigma[4]:.4f}, {sigma[5]:.4f}] m/s")
usebraheasbh;usenalgebra::{DMatrix,DVector};fnmain(){bh::initialize_eop().unwrap();// Define a LEO circular orbitletepoch=bh::time::Epoch::from_datetime(2024,1,1,0,0,0.0,0.0,bh::time::TimeSystem::UTC);letr=bh::constants::physical::R_EARTH+500e3;letv=(bh::constants::physical::GM_EARTH/r).sqrt();lettrue_state=DVector::from_vec(vec![r,0.0,0.0,0.0,v,0.0]);// Create a truth propagator for generating observationsletmuttruth_prop=bh::propagators::DNumericalOrbitPropagator::new(epoch,true_state.clone(),bh::propagators::NumericalPropagationConfig::default(),bh::propagators::force_model_config::ForceModelConfig::two_body_gravity(),None,None,None,None,).unwrap();// Perturbed initial state: 1 km position error, 1 m/s velocity errorletmutinitial_state=true_state.clone();initial_state[0]+=1000.0;initial_state[4]+=1.0;// Initial covariance reflecting uncertaintyletp0=DMatrix::from_diagonal(&DVector::from_vec(vec![1e6,1e6,1e6,1e2,1e2,1e2,]));// Create the UKF with flat constructorletmodels:Vec<Box<dynbh::estimation::MeasurementModel>>=vec![Box::new(bh::estimation::InertialPositionMeasurementModel::new(10.0)),];letmutukf=bh::estimation::UnscentedKalmanFilter::new(epoch,initial_state,p0,bh::propagators::NumericalPropagationConfig::default(),bh::propagators::force_model_config::ForceModelConfig::two_body_gravity(),None,None,None,models,bh::estimation::UKFConfig::default(),).unwrap();// Process 30 observations at 60-second intervalsletdt=60.0;foriin1..=30{letobs_epoch=epoch+dt*iasf64;truth_prop.propagate_to(obs_epoch);lettruth_pos=truth_prop.current_state().rows(0,3).into_owned();letobs=bh::estimation::Observation::new(obs_epoch,truth_pos,0);ukf.process_observation(&obs).unwrap();}// Compare final state to truthusebh::propagators::traits::DStatePropagator;truth_prop.propagate_to(ukf.current_epoch());lettruth_final=truth_prop.current_state();letfinal_state=ukf.current_state();letpos_error=(final_state.rows(0,3)-truth_final.rows(0,3)).norm();letvel_error=(final_state.rows(3,3)-truth_final.rows(3,3)).norm();println!("Initial position error: 1000.0 m");println!("Final position error: {:.2} m",pos_error);println!("Final velocity error: {:.4} m/s",vel_error);println!("Observations processed: {}",ukf.records().len());// Show final covariance diagonal (1-sigma uncertainties)letcov=ukf.current_covariance();println!("\n1-sigma uncertainties:");println!(" Position: [{:.1}, {:.1}, {:.1}] m",cov[(0,0)].sqrt(),cov[(1,1)].sqrt(),cov[(2,2)].sqrt());println!(" Velocity: [{:.4}, {:.4}, {:.4}] m/s",cov[(3,3)].sqrt(),cov[(4,4)].sqrt(),cov[(5,5)].sqrt());}
Initial position error: 1000.0 m
Final position error: 0.01 m
Final velocity error: 0.0000 m/s
Observations processed: 30
1-sigma uncertainties:
Position: [3.2, 4.2, 3.1] m
Velocity: [0.0027, 0.0068, 0.0029] m/s
Initial position error: 1000.0 m
Final position error: 0.01 m
Final velocity error: 0.0000 m/s
Observations processed: 30
1-sigma uncertainties:
Position: [3.2, 4.2, 3.1] m
Velocity: [0.0027, 0.0068, 0.0029] m/s
The constructor internally builds a numerical propagator, generates sigma point weights from the UKFConfig parameters, and validates that the initial covariance matches the state dimension.
Predict: generate 2n+1 sigma points from the current state and covariance (where n is the state dimension), propagate each through the dynamics to the observation epoch, then reconstruct the predicted mean and covariance from the propagated points.
Update: transform the sigma points through the measurement model, compute the innovation covariance and cross-covariance, then apply a Kalman-like gain to incorporate the measurement.
The sigma points are chosen to exactly match the first and second moments of the state distribution. The alpha parameter controls how far the sigma points spread from the mean (smaller values keep them closer), beta encodes prior distribution knowledge (2.0 is optimal for Gaussian), and kappa provides an additional scaling degree of freedom.
# One at a timerecord=ukf.process_observation(obs)# Batch (auto-sorts by epoch)ukf.process_observations(observations)
FilterRecord fields are identical to the EKF: pre/post-fit states, covariances, residuals, and gain matrix. The kalman_gain field contains the UKF gain analog \(K = P_{xz} S^{-1}\), which has the same interpretation as the EKF gain (maps innovation to state correction).
The EKF linearizes dynamics and measurements around the current state, computing the State Transition Matrix and measurement Jacobian. The UKF instead propagates 2n+1 sigma points through the true nonlinear functions.
EKF computes one dynamics propagation per step plus Jacobian evaluations. It requires STM propagation, which adds overhead to the dynamics integration. Linearization errors accumulate when dynamics or measurements are strongly nonlinear.
UKF computes 2n+1 dynamics propagations per step (13 for a 6D state) but each propagation is simpler (no STM or variational equations). It captures nonlinearity to second order and works with non-differentiable measurement models. The trade-off is more function evaluations per step.
Both filters use the same measurement models, observation types, and filter record format. Switching between them requires only changing the constructor.