Measurement models define \(h(\mathbf{x}, t)\) — the function mapping a filter state to a predicted observation — along with a noise covariance \(R\). Brahe provides six built-in models for GNSS-like observations in ECEF and inertial frames. All assume the filter state is Cartesian ECI: \(\mathbf{x} = [x, y, z, v_x, v_y, v_z, \ldots]\) in meters and m/s.
The most common starting point is an ECEF position model consuming raw GNSS receiver outputs:
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])# Truth propagator for generating simulated GNSS observationstruth_prop=bh.NumericalOrbitPropagator(epoch,true_state,bh.NumericalPropagationConfig.default(),bh.ForceModelConfig.two_body(),)# Perturbed initial state: 1 km position errorinitial_state=true_state.copy()initial_state[0]+=1000.0initial_state[4]+=1.0p0=np.diag([1e6,1e6,1e6,1e2,1e2,1e2])# ECEF position model with typical GNSS accuracy (5 m noise)ecef_model=bh.ECEFPositionMeasurementModel(5.0)ekf=bh.ExtendedKalmanFilter(epoch,initial_state,p0,measurement_models=[ecef_model],propagation_config=bh.NumericalPropagationConfig.default(),force_config=bh.ForceModelConfig.two_body(),)# Simulate GNSS observations: get truth ECI state, convert to ECEFdt=60.0foriinrange(1,21):obs_epoch=epoch+dt*itruth_prop.propagate_to(obs_epoch)truth_eci=truth_prop.current_state()# Simulate GNSS: convert truth position to ECEFtruth_ecef_pos=bh.position_eci_to_ecef(obs_epoch,truth_eci[:3])obs=bh.Observation(obs_epoch,truth_ecef_pos,model_index=0)ekf.process_observation(obs)# Compare final state to truthtruth_prop.propagate_to(ekf.current_epoch())truth_final=truth_prop.current_state()final_state=ekf.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("ECEF GNSS tracking with ECEFPositionMeasurementModel:")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(ekf.records())}")
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]);// Truth propagator for generating simulated GNSS observationsletmuttruth_prop=bh::propagators::DNumericalOrbitPropagator::builder(epoch,true_state.clone(),bh::propagators::force_model_config::ForceModelConfig::two_body_gravity(),).build().unwrap();// Perturbed initial state: 1 km position errorletmutinitial_state=true_state.clone();initial_state[0]+=1000.0;initial_state[4]+=1.0;letp0=DMatrix::from_diagonal(&DVector::from_vec(vec![1e6,1e6,1e6,1e2,1e2,1e2,]));// ECEF position model with typical GNSS accuracy (5 m noise)letecef_model=bh::estimation::ECEFPositionMeasurementModel::new(5.0);letmodels:Vec<Box<dynbh::estimation::MeasurementModel>>=vec![Box::new(ecef_model),];letmutekf=bh::estimation::ExtendedKalmanFilter::builder(epoch,initial_state,p0,bh::propagators::force_model_config::ForceModelConfig::two_body_gravity(),bh::estimation::EKFConfig::default(),).measurement_models(models).build().unwrap();// Simulate GNSS observations: get truth ECI state, convert to ECEFletdt=60.0;foriin1..=20{letobs_epoch=epoch+dt*iasf64;truth_prop.propagate_to(obs_epoch).unwrap();lettruth_eci=truth_prop.current_state();// Simulate GNSS: convert truth position to ECEFlettruth_eci_pos=nalgebra::Vector3::new(truth_eci[0],truth_eci[1],truth_eci[2]);lettruth_ecef_pos=bh::frames::position_eci_to_ecef(obs_epoch,truth_eci_pos);letz=DVector::from_vec(vec![truth_ecef_pos[0],truth_ecef_pos[1],truth_ecef_pos[2]]);letobs=bh::estimation::Observation::new(obs_epoch,z,0);ekf.process_observation(&obs).unwrap();}// Compare final state to truthusebh::propagators::traits::DStatePropagator;truth_prop.propagate_to(ekf.current_epoch()).unwrap();lettruth_final=truth_prop.current_state();letfinal_state=ekf.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!("ECEF GNSS tracking with ECEFPositionMeasurementModel:");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: {}",ekf.records().len());}
ECEF GNSS tracking with ECEFPositionMeasurementModel:
Initial position error: 1000.0 m
Final position error: 0.00 m
Final velocity error: 0.0000 m/s
Observations processed: 20
ECEF GNSS tracking with ECEFPositionMeasurementModel:
Initial position error: 1000.0 m
Final position error: 0.00 m
Final velocity error: 0.0000 m/s
Observations processed: 20
ECEF models process GNSS receiver outputs reported in the Earth-fixed frame. The filter state remains in ECI — these models internally rotate the predicted state from ECI to ECEF at each observation epoch. Jacobians are computed via central finite differences because the rotation is epoch-dependent.
Inertial models directly extract components from the ECI state vector. The mapping is a simple selection (identity sub-matrix), so Jacobians are analytical — fast and exact. Use these when measurements are already in ECI, for simulation, or when the frame conversion is handled externally.
AzElRangeMeasurementModel handles ground-based radar/tracking sensor observations in the station's local topocentric (SEZ) frame. Unlike the ECEF and inertial models above, the measurement is angular plus range, not a Cartesian sub-vector of the state:
Measurement: \(\mathbf{z} = [\text{azimuth}, \text{elevation}, \text{range}] + \mathbf{b}\) -- azimuth clockwise from north, elevation from the local horizon, in the units given by AngleFormat at construction (degrees or radians); range in meters
Jacobian: Numerical (finite difference) -- the ECI-to-ECEF rotation is epoch-dependent
The model accepts a constant bias [bias_az, bias_el, bias_range], applied inside predict(). This models a calibrated sensor (e.g. Vallado Table 4-4 az/el/range bias values): a filter built with the same bias as the measurement source stays consistent, rather than needing to estimate the bias away as an unmodeled error.
residual() wraps the azimuth component into \([-180°, 180°)\) (or \([-\pi, \pi)\) in radians; round-half-away-from-zero maps an exact \(+180°\) to \(-180°\)) so a pass crossing the 0/360° boundary does not produce a spurious ~360° residual:
importnumpyasnpimportbraheasbhbh.initialize_eop()# ConfigurationMEAS_INTERVAL=15.0# seconds between measurements during a passDURATION=2*3600.0# tracking duration (seconds)SEED=42# Truth orbit: LEO at 700 km, 72 degree inclinationepoch=bh.Epoch(2024,1,1,0,0,0.0)oe=np.array([bh.R_EARTH+700e3,0.001,72.0,30.0,0.0,0.0])true_state=bh.state_koe_to_eci(oe,bh.AngleFormat.DEGREES)# Hermite-cubic interpolation (the propagator default) lets the trajectory,# stored at the ~60 s adaptive-step cadence, be sampled accurately at the much# finer measurement cadence used for measurement simulation.truth_config=bh.NumericalPropagationConfig.default()truth_prop=bh.NumericalOrbitPropagator(epoch,true_state,truth_config,bh.ForceModelConfig.two_body(),)epoch_end=epoch+DURATIONtruth_prop.propagate_to(epoch_end)truth_traj=truth_prop.trajectory# Build sensors from the Vallado SSN dataset (calibrated radar and optical# sites; radar measures az/el/range, optical measures angles-only az/el)sites=bh.datasets.ssn_sensors.load()sensors=bh.SimpleSSNSensor.from_locations_calibrated(sites,seed=SEED)print(f"Loaded {len(sites)} SSN sites, {len(sensors)} calibrated sensors")# Find passes and simulate measurements only inside themobservations=[]passes=[]fori,sensorinenumerate(sensors):constraint=bh.ElevationConstraint(min_elevation_deg=max(sensor.el_min,1.0))windows=bh.location_accesses(sensor.location,truth_prop,epoch,epoch_end,constraint)forwinwindows:obs=sensor.simulate_observations(truth_traj,w.window_open,w.window_close,MEAS_INTERVAL,i)observations.extend(obs)ifobs:passes.append((sensor.name,w))observations.sort(key=lambdao:o.epoch)print(f"Simulated {len(observations)} measurements over {len(passes)} passes")# EKF from a perturbed initial state, using each sensor's matching modelinitial_state=np.array(true_state)initial_state[0]+=1000.0initial_state[4]+=1.0p0=np.diag([1e6,1e6,1e6,1e2,1e2,1e2])ekf=bh.ExtendedKalmanFilter(epoch,initial_state,p0,measurement_models=[s.measurement_model()forsinsensors],propagation_config=bh.NumericalPropagationConfig.default(),force_config=bh.ForceModelConfig.two_body(),)# Process observations in order; propagate through gaps between passesGAP_SPLIT=600.0# start a new arc when consecutive obs are > 10 min apartprev_epoch=epochforobsinobservations:ifobs.epoch-prev_epoch>GAP_SPLIT:# advance through the gap in 60 s steps to record covariance growtht=prev_epoch+60.0whilet<obs.epoch:ekf.propagate_to(t)t=t+60.0ekf.process_observation(obs)prev_epoch=obs.epoch# Compare final estimate to truthtruth_final=truth_traj.interpolate(ekf.current_epoch())err=np.linalg.norm(ekf.current_state()[:3]-truth_final[:3])print(f"Final position error: {err:.1f} m")sigma=np.sqrt(np.diag(ekf.current_covariance()))print(f"Final position 1-sigma: [{sigma[0]:.1f}, {sigma[1]:.1f}, {sigma[2]:.1f}] m")asserterr<500.0,"EKF should converge to a small position error"print("Example validated successfully!")
//! simulates az/el/range measurements during passes, and processes them with//! an Extended Kalman Filter, propagating through gaps between passes.#[allow(unused_imports)]usebraheasbh;usebh::access::{ElevationConstraint,location_accesses};usebh::datasets::ssn_sensors::load_ssn_sensors;usebh::estimation::{EKFConfig,ExtendedKalmanFilter,MeasurementModel,Observation,SimpleSSNSensor};usebh::traits::{DStatePropagator,InterpolatableTrajectory};usenalgebra::{DMatrix,DVector,SVector};fnmain(){bh::initialize_eop().unwrap();// Configurationletmeas_interval=15.0;// seconds between measurements during a passletduration=2.0*3600.0;// tracking duration (seconds)letseed=42u64;// Truth orbit: LEO at 700 km, 72 degree inclinationletepoch=bh::Epoch::from_datetime(2024,1,1,0,0,0.0,0.0,bh::TimeSystem::UTC);letoe=SVector::<f64,6>::new(bh::R_EARTH+700e3,0.001,72.0,30.0,0.0,0.0);lettrue_state=bh::state_koe_to_eci(oe,bh::AngleFormat::Degrees);// Hermite-cubic interpolation (the propagator default) lets the// trajectory, stored at the ~60 s adaptive-step cadence, be sampled// accurately at the much finer measurement cadence used for measurement// simulation.lettruth_config=bh::NumericalPropagationConfig::default();letmuttruth_prop=bh::DNumericalOrbitPropagator::new(epoch,DVector::from_column_slice(true_state.as_slice()),truth_config,bh::ForceModelConfig::two_body_gravity(),None,None,None,None,).unwrap();letepoch_end=epoch+duration;truth_prop.propagate_to(epoch_end).unwrap();lettruth_traj=truth_prop.trajectory().clone();// Build sensors from the Vallado SSN dataset (calibrated radar and optical// sites; radar measures az/el/range, optical measures angles-only az/el)letsites=load_ssn_sensors().unwrap();letmutsensors=SimpleSSNSensor::from_locations_calibrated(&sites,Some(seed));println!("Loaded {} SSN sites, {} calibrated sensors",sites.len(),sensors.len());// Find passes and simulate measurements only inside themletmutobservations:Vec<Observation>=Vec::new();letmutpass_count=0;for(i,sensor)insensors.iter_mut().enumerate(){letconstraint=ElevationConstraint::new(Some(sensor.el_min().max(1.0)),None).unwrap();letwindows=location_accesses(sensor.location(),&truth_prop,epoch,epoch_end,&constraint,None,None,).unwrap();forwinwindows{letobs=sensor.simulate_observations(&truth_traj,w.start(),w.end(),meas_interval,i).unwrap();if!obs.is_empty(){pass_count+=1;}observations.extend(obs);}}observations.sort_by(|a,b|a.epoch.partial_cmp(&b.epoch).unwrap());println!("Simulated {} measurements over {} passes",observations.len(),pass_count);// EKF from a perturbed initial state, using each sensor's matching modelletmutinitial_state=DVector::from_column_slice(true_state.as_slice());initial_state[0]+=1000.0;initial_state[4]+=1.0;letp0=DMatrix::from_diagonal(&DVector::from_vec(vec![1e6,1e6,1e6,1e2,1e2,1e2,]));letmodels:Vec<Box<dynMeasurementModel>>=sensors.iter().map(|s|s.measurement_model()).collect();letmutekf=ExtendedKalmanFilter::new(epoch,initial_state,p0,bh::NumericalPropagationConfig::default(),bh::ForceModelConfig::two_body_gravity(),None,None,None,models,EKFConfig::default(),).unwrap();// Process observations in order; propagate through gaps between passesletgap_split=600.0;// start a new arc when consecutive obs are > 10 min apartletmutprev_epoch=epoch;forobsin&observations{ifobs.epoch-prev_epoch>gap_split{// advance through the gap in 60 s steps to record covariance growthletmutt=prev_epoch+60.0;whilet<obs.epoch{ekf.propagate_to(t).unwrap();t+=60.0;}}ekf.process_observation(obs).unwrap();prev_epoch=obs.epoch;}// Compare final estimate to truthlettruth_final=truth_traj.interpolate(&ekf.current_epoch()).unwrap();letfinal_state=ekf.current_state();leterr=(final_state.rows(0,3)-truth_final.rows(0,3)).norm();println!("Final position error: {:.1} m",err);letcov=ekf.current_covariance();println!("Final position 1-sigma: [{:.1}, {:.1}, {:.1}] m",cov[(0,0)].sqrt(),cov[(1,1)].sqrt(),cov[(2,2)].sqrt());assert!(err<500.0,"EKF should converge to a small position error");println!("Example validated successfully!");}
Loaded 21 SSN sites, 16 calibrated sensors
Simulated 337 measurements over 10 passes
Final position error: 7.6 m
Final position 1-sigma: [7.9, 4.9, 6.9] m
Example validated successfully!
Loaded 21 SSN sites, 16 calibrated sensors
Simulated 337 measurements over 10 passes
Final position error: 7.6 m
Final position 1-sigma: [7.9, 4.9, 6.9] m
Example validated successfully!
The azimuth wrap is handled consistently everywhere the measurement is differenced. The AzElRangeMeasurementModel Jacobian override differences its two perturbed predictions through residual(), and the Unscented Kalman Filter forms its predicted measurement mean with the reference-point trick z_mean = z_0 + Σ wᵢ · residual(zᵢ, z_0) and computes its innovation and cross-covariance deviations through residual() as well. So a pass whose sigma-point azimuths straddle the wrap (e.g. some near 359°, others near 1°) yields a well-defined mean near the true azimuth rather than a value biased toward the middle of the circle. For measurement models with plain-subtraction residuals the reference-point mean is algebraically identical to the ordinary weighted mean, so non-angular models are unaffected.
SimpleSSNSensor pairs a sensor site (location, field-of-view limits, bias/noise calibration -- see the SSN Sensor Datasets guide) with measurement generation, and its measurement_model() method returns an AzElRangeMeasurementModel built from the same bias and noise, so simulated measurements and the filter's model stay consistent by construction. See the SSN Radar Tracking example for a full EKF/UKF/BLS walkthrough built on this dataset.
For observations beyond the built-in models — range, range-rate, angles, Doppler, or any nonlinear function — define a custom measurement model. Subclass MeasurementModel in Python or implement the MeasurementModel trait in Rust.
The full pattern, including analytical Jacobians and mixing custom models with built-in models in a single filter, is covered in the Custom Models guide.