The Extended Kalman Filter (EKF) is a sequential estimator that linearizes dynamics and measurement models around the current state estimate. At each observation, it propagates state and covariance forward in time (predict step), then incorporates the measurement (update step). Brahe's EKF leverages the propagator's built-in State Transition Matrix (STM) for covariance prediction, so no separate covariance propagation is needed.
The EKF constructor takes the initial conditions, measurement models, and propagation configuration. It internally builds a numerical orbit propagator with STM enabled.
The constructor ensures STM propagation is enabled regardless of the propagation config passed in. The initial covariance matrix dimensions must match the state vector length.
Different measurement types can be interleaved by setting different model_index values. For example, position observations at index 0 and range observations at index 1 can arrive in any order when using process_observations().
state=ekf.current_state()# numpy 1D arraycov=ekf.current_covariance()# numpy 2D array or Noneepoch=ekf.current_epoch()# Epochrecords=ekf.records()# list of FilterRecord
Each FilterRecord captures the complete diagnostic state of a single update:
Field
Description
state_predicted
State after propagation, before measurement update
\(\mathbf{z} - h(\mathbf{x}_{upd})\) -- should be small
kalman_gain
\(K\) matrix used for the update
measurement_name
Name of the measurement model used
Pre-fit residuals indicate how well the predicted state matches the observation. Post-fit residuals should be smaller, confirming the update improved the estimate. Monitoring these over time is the primary tool for assessing filter health.
Process noise accounts for unmodeled dynamics (drag variations, gravity model truncation, etc.) by inflating the predicted covariance at each step. Without process noise, the covariance monotonically decreases and the filter eventually ignores new measurements.
When scale_with_dt=True, the effective process noise is \(Q \cdot \Delta t\) (continuous-time model). When False, \(Q\) is applied as-is at each step (discrete-time model).
For systems beyond standard orbital mechanics, you can supply custom dynamics. In Python, pass an additional_dynamics callable to the EKF constructor. In Rust, build a DNumericalPropagator with your dynamics function and pass it to from_propagator():
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.5state=np.array([r,0.0,0.0,0.0,v,0.0])# Initial covariancep0=np.diag([1e6,1e6,1e6,1e2,1e2,1e2])# Define custom two-body dynamics as additional accelerationdeftwo_body_dynamics(t,state,params):"""Custom two-body gravitational acceleration."""pos=state[:3]r_mag=np.linalg.norm(pos)accel=-bh.GM_EARTH/r_mag**3*posderiv=np.zeros(6)deriv[:3]=state[3:6]# velocityderiv[3:6]=accel# accelerationreturnderiv# Create EKF with custom dynamics via additional_dynamics parameterekf=bh.ExtendedKalmanFilter(epoch,state,p0,measurement_models=[bh.InertialPositionMeasurementModel(10.0)],propagation_config=bh.NumericalPropagationConfig.default(),force_config=bh.ForceModelConfig.two_body(),additional_dynamics=two_body_dynamics,)# Process a single observation using truth positionobs=bh.Observation(epoch+60.0,state[:3],model_index=0)record=ekf.process_observation(obs)print("Custom dynamics EKF:")print(f" Prefit residual norm: {np.linalg.norm(record.prefit_residual):.3f} m")print(f" Postfit residual norm: {np.linalg.norm(record.postfit_residual):.6f} m")print(f" State dim: {len(ekf.current_state())}")
usebraheasbh;usenalgebra::{DMatrix,DVector};fnmain(){bh::initialize_eop().unwrap();letepoch=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();letstate=DVector::from_vec(vec![r,0.0,0.0,0.0,v,0.0]);letp0=DMatrix::from_diagonal(&DVector::from_vec(vec![1e6,1e6,1e6,1e2,1e2,1e2]));// Define custom two-body dynamicsletdynamics:bh::integrators::traits::DStateDynamics=Box::new(|_t,state:&DVector<f64>,_params|{letr_vec=state.rows(0,3);letv_vec=state.rows(3,3);letr_mag=r_vec.norm();letmu=bh::constants::physical::GM_EARTH;leta=-mu/r_mag.powi(3)*&r_vec;letmutdx=DVector::zeros(6);dx.rows_mut(0,3).copy_from(&v_vec);dx.rows_mut(3,3).copy_from(&a);dx},);// Build a generic propagator with STM enabledletmutprop_config=bh::propagators::NumericalPropagationConfig::default();prop_config.variational.enable_stm=true;letprop=bh::propagators::DNumericalPropagator::new(epoch,state.clone(),dynamics,prop_config,None,None,Some(p0),).unwrap();// Create EKF from the pre-built propagatorletmodels:Vec<Box<dynbh::estimation::MeasurementModel>>=vec![Box::new(bh::estimation::InertialPositionMeasurementModel::new(10.0)),];letmutekf=bh::estimation::ExtendedKalmanFilter::from_propagator(bh::estimation::DynamicsSource::GenericPropagator(prop),models,bh::estimation::EKFConfig::default(),).unwrap();// Process a single observationletobs=bh::estimation::Observation::new(epoch+60.0,state.rows(0,3).into_owned(),0);letrecord=ekf.process_observation(&obs).unwrap();println!("Custom dynamics EKF:");println!(" Prefit residual norm: {:.3} m",record.prefit_residual.norm());println!(" Postfit residual norm: {:.6} m",record.postfit_residual.norm());println!(" State dim: {}",ekf.current_state().len());}
The EKF alternates between a predict step (propagate to the next observation) and an update step (incorporate the measurement). Below are the equations implemented by Brahe's EKF.
The state is propagated from \(t_{k-1}\) to \(t_k\) using the nonlinear dynamics \(f(\mathbf{x}, t)\), and the covariance is propagated using the State Transition Matrix \(\Phi\):
\[ P_{k}^{+} = (I - K_k H_k) \, P_{k}^{-} \, (I - K_k H_k)^T + K_k \, R \, K_k^T \]
The Joseph form is numerically more stable than the simpler \(P^{+} = (I - KH) P^{-}\) and guarantees the updated covariance remains symmetric positive semi-definite.