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.
ExtendedKalmanFilter.builder() is the primary way to construct an EKF: it takes the five required inputs -- epoch, state, initial_covariance, force_config, and config -- directly as arguments, and measurement models and remaining optional inputs are set through chained setters. It internally builds a numerical orbit propagator with STM enabled.
importnumpyasnpimportbraheasbhbh.initialize_eop()epoch=bh.Epoch(2024,1,1,0,0,0.0)state=np.array([bh.R_EARTH+500e3,0.0,0.0,0.0,7612.0,0.0])p0=np.diag([1e6,1e6,1e6,1e2,1e2,1e2])ekf=(bh.ExtendedKalmanFilter.builder(epoch,state,p0,bh.ForceModelConfig.two_body(),bh.EKFConfig()).measurement_model(bh.InertialPositionMeasurementModel(10.0)).build())print(f"EKF state dimension: {ekf.current_state().shape[0]}")print(f"EKF current epoch: {ekf.current_epoch()}")
usebrahe::estimation::*;usebrahe::propagators::*;usebrahe::time::{Epoch,TimeSystem};usenalgebra::{DMatrix,DVector};fnmain(){brahe::initialize_eop().unwrap();letepoch=Epoch::from_datetime(2024,1,1,0,0,0.0,0.0,TimeSystem::UTC);letstate=DVector::from_vec(vec![brahe::R_EARTH+500e3,0.0,0.0,0.0,7612.0,0.0]);letp0=DMatrix::from_diagonal(&DVector::from_vec(vec![1e6,1e6,1e6,1e2,1e2,1e2]));letekf=ExtendedKalmanFilter::builder(epoch,state,p0,ForceModelConfig::two_body_gravity(),EKFConfig::default(),).measurement_model(Box::new(InertialPositionMeasurementModel::new(10.0))).build().unwrap();println!("EKF state dimension: {}",ekf.current_state().len());println!("EKF current epoch: {}",ekf.current_epoch());}
importnumpyasnpimportbraheasbhbh.initialize_eop()epoch=bh.Epoch(2024,1,1,0,0,0.0)state=np.array([bh.R_EARTH+500e3,0.0,0.0,0.0,7612.0,0.0])p0=np.diag([1e6,1e6,1e6,1e2,1e2,1e2])ekf=bh.ExtendedKalmanFilter(epoch,state,p0,measurement_models=[bh.InertialPositionMeasurementModel(10.0)],propagation_config=bh.NumericalPropagationConfig.default(),force_config=bh.ForceModelConfig.two_body(),)print(f"EKF state dimension: {ekf.current_state().shape[0]}")print(f"EKF current epoch: {ekf.current_epoch()}")
usebrahe::estimation::*;usebrahe::propagators::*;usebrahe::time::{Epoch,TimeSystem};usenalgebra::{DMatrix,DVector};fnmain(){brahe::initialize_eop().unwrap();letepoch=Epoch::from_datetime(2024,1,1,0,0,0.0,0.0,TimeSystem::UTC);letstate=DVector::from_vec(vec![brahe::R_EARTH+500e3,0.0,0.0,0.0,7612.0,0.0]);letp0=DMatrix::from_diagonal(&DVector::from_vec(vec![1e6,1e6,1e6,1e2,1e2,1e2]));letekf=ExtendedKalmanFilter::new(epoch,state,p0,NumericalPropagationConfig::default(),ForceModelConfig::two_body_gravity(),None,None,None,vec![Box::new(InertialPositionMeasurementModel::new(10.0))],EKFConfig::default(),).unwrap();println!("EKF state dimension: {}",ekf.current_state().len());println!("EKF current epoch: {}",ekf.current_epoch());}
EKF state dimension: 6
EKF current epoch: 2024-01-01 00:00:00.000 UTC
Both paths ensure 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().
\(\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).
By default the process noise for a predict interval is added once, scaled by the full \(\Delta t\). Over a long coast arc this injects all of the noise at the start of the interval, which understates the covariance growth that the nonlinear dynamics produce as earlier noise shears along the trajectory. The max_noise_dt argument caps the time step over which continuous noise is accumulated per chunk:
With max_noise_dt=h, a predict interval longer than h is split into chunks of at most h seconds; the covariance is propagated through each chunk and \(Q \cdot \delta t_{chunk}\) is added per chunk, injecting the noise along the trajectory rather than all at once. A single sub-stepped predict is by construction equivalent to a sequence of single-shot predicts at cadence h. Sub-stepping applies only when scale_with_dt=True (a continuous noise rate); a discrete per-update \(Q\) (scale_with_dt=False) is applied once per interval regardless of max_noise_dt. max_noise_dt=None (the default) preserves the single-shot behavior. This is an approximation using a constant rate per chunk; the exact variational discrete-time \(Q_d\) integration is tracked in issue #408.
For systems beyond standard orbital mechanics, you can supply custom dynamics. In both languages the EKF constructor accepts an additional_dynamics function (along with force model params and a control_input) that passes through to the internal orbit propagator. Alternatively, in Rust, build a DNumericalPropagator with a fully custom 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);Ok(dx)},);// Build a generic propagator with STM enabledletmutprop_config=bh::propagators::NumericalPropagationConfig::default();prop_config.variational.enable_stm=true;letprop=bh::propagators::DNumericalPropagator::builder(epoch,state.clone(),dynamics).propagation_config(prop_config).initial_covariance(p0).build().unwrap();// Create EKF from the pre-built propagator (converts into a DynamicsSource// automatically; the filter starts from the propagator's initial covariance)letmodels:Vec<Box<dynbh::estimation::MeasurementModel>>=vec![Box::new(bh::estimation::InertialPositionMeasurementModel::new(10.0)),];letmutekf=bh::estimation::ExtendedKalmanFilter::from_propagator(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.