Numerical integration is fundamental to spacecraft trajectory propagation, orbit determination, and mission planning. Brahe provides multiple integration methods optimized for different accuracy and performance requirements.
Experimental Feature
The integrators module is currently experimental. While the core functionality should be stable, the API may change in future MINOR releases as we refine the design and add features.
Numerical integration solves ordinary differential equations (ODEs) of the form:
\[\dot{\mathbf{x}} = \mathbf{f}(t, \mathbf{x})\]
\(\mathbf{x}\) is the state vector, typically position and velocity \(\mathbf{x} = \begin{bmatrix} \mathbf{p} \\ \mathbf{v} \end{bmatrix}\), and \(\mathbf{f}\) defines the dynamics (gravity, perturbations, thrust, etc.). The integrator advances the state forward in time from an initial condition \(\mathbf{x}_0\) at time \(t_0\) to \(\mathbf{x}(t)\) at some future time \(t\).
In an ideal world, we would have closed-form analytical solutions for these equations. However, real-world dynamics are often too complex for exact solutions, necessitating numerical methods that approximate the solution. It is often much easier to write down the equations for the dynamics (force models) than to derive analytical solutions for them. Numerical integrators provide a way to compute these approximations efficiently and accurately.
defdynamics_fn(t:float,state:np.ndarray,params:np.ndarray)->np.ndarray:""" Args: t: Current time state: Current state vector params: Fixed auxiliary parameters Returns: d_state: Derivative of state vector """pass
Must be a closure or function with the signature, that either uses dynamic or static sized vectors:
The plot below shows position error vs. time for different integrators propagating a highly elliptical orbit (HEO) compared to analytical Keplerian propagation. All adaptive integrators use the same tolerances (abs_tol=1e-10, rel_tol=1e-9). In the figure, we can see that after one orbit, RKN1210 achieves sub-millimeter accuracy, while DP54 and RKF45 reach meter-level accuracy. The fixed-step RK4 with a 60s step has the most error, reaching about 1000m after one orbit.
importosimportpathlibimportsysimportplotly.graph_objectsasgoimportnumpyasnpimportbraheasbh# Add plots directory to path for importing brahe_themesys.path.insert(0,str(pathlib.Path(__file__).parent))frombrahe_themeimportsave_themed_html,get_color_sequence# ConfigurationSCRIPT_NAME=pathlib.Path(__file__).stemOUTDIR=pathlib.Path(os.getenv("BRAHE_FIGURE_OUTPUT_DIR","./docs/figures/"))# Ensure output directory existsos.makedirs(OUTDIR,exist_ok=True)# Initialize Brahebh.initialize_eop()# Define Molniya-type HEO orbit# Semi-major axis for 12-hour perioda=26554e3# meterse=0.74# eccentricityi=np.radians(63.4)# inclination (critical inclination)omega=0.0# argument of perigeeOmega=0.0# RAANM0=0.0# mean anomaly# Convert to Cartesian stateoe=np.array([a,e,i,Omega,omega,M0])initial_state=bh.state_koe_to_eci(oe,bh.AngleFormat.RADIANS)# Orbital periodperiod=2*np.pi*np.sqrt(a**3/bh.GM_EARTH)# Define two-body dynamicsdeftwo_body_dynamics(t,state):"""Simple two-body dynamics for integration."""mu=bh.GM_EARTHr=state[0:3]v=state[3:6]r_norm=np.linalg.norm(r)a=-mu/r_norm**3*rreturnnp.concatenate([v,a])# Analytical solution using Keplerian propagationdefanalytical_solution(t):"""Compute analytical Keplerian state at time t."""# Mean motionn=np.sqrt(bh.GM_EARTH/a**3)# Mean anomaly at time tM=M0+n*t# Convert back to Cartesianoe_t=np.array([a,e,i,Omega,omega,M])returnbh.state_koe_to_eci(oe_t,bh.AngleFormat.RADIANS)# Integration parameterst_start=0.0t_end=period# One orbital periodoutput_interval=60.0# Output every 60 seconds# Common configuration for adaptive integratorsabs_tol=1e-10rel_tol=1e-9# Create integratorsconfig_rk4=bh.IntegratorConfig.fixed_step(step_size=60.0)config_adaptive=bh.IntegratorConfig.adaptive(abs_tol=abs_tol,rel_tol=rel_tol)integrator_rk4=bh.RK4Integrator(6,two_body_dynamics,config=config_rk4)integrator_rkf45=bh.RKF45Integrator(6,two_body_dynamics,config=config_adaptive)integrator_dp54=bh.DP54Integrator(6,two_body_dynamics,config=config_adaptive)integrator_rkn1210=bh.RKN1210Integrator(6,two_body_dynamics,config=config_adaptive)# Propagate with each integratordefpropagate(integrator,is_adaptive=True):"""Propagate orbit and record states at output intervals."""times=[]states=[]errors=[]t=t_startstate=initial_state.copy()dt=60.0# Initial step guessnext_output=0.0whilet<t_end:# Check if we should save outputift>=next_output:times.append(t)states.append(state.copy())# Compute error vs analytical solutionanalytical=analytical_solution(t)pos_error=np.linalg.norm(state[0:3]-analytical[0:3])errors.append(pos_error)next_output+=output_interval# Integrate one stepifis_adaptive:result=integrator.step(t,state,min(dt,t_end-t))t+=result.dt_usedstate=result.statedt=result.dt_nextelse:# Fixed stepdt_actual=min(dt,t_end-t)new_state=integrator.step(t,state,dt_actual)t+=dt_actualstate=new_state# Final outputift>=t_end-1e-6:# Handle floating point comparisontimes.append(t_end)analytical=analytical_solution(t_end)pos_error=np.linalg.norm(state[0:3]-analytical[0:3])errors.append(pos_error)returnnp.array(times),np.array(errors)print("Propagating with RK4...")times_rk4,errors_rk4=propagate(integrator_rk4,is_adaptive=False)print("Propagating with RKF45...")times_rkf45,errors_rkf45=propagate(integrator_rkf45,is_adaptive=True)print("Propagating with DP54...")times_dp54,errors_dp54=propagate(integrator_dp54,is_adaptive=True)print("Propagating with RKN1210...")times_rkn1210,errors_rkn1210=propagate(integrator_rkn1210,is_adaptive=True)# Create figure with theme supportdefcreate_figure(theme):"""Create figure with theme-specific colors."""colors=get_color_sequence(theme,num_colors=4)fig=go.Figure()# Add traces for each integrator with custom hover templates# First trace includes time at top, others don't to avoid duplicationfig.add_trace(go.Scatter(x=times_rk4/3600,# Convert to hoursy=errors_rk4,name="RK4 (Fixed, dt=60s)",mode="lines",line=dict(color=colors[0],width=2),hovertemplate="t=%{x:.2f} hours<br><b>RK4</b><br>Error: %{y:.2e} m<extra></extra>",))fig.add_trace(go.Scatter(x=times_rkf45/3600,y=errors_rkf45,name="RKF45 (Adaptive)",mode="lines",line=dict(color=colors[1],width=2),hovertemplate="<b>RKF45</b><br>Error: %{y:.2e} m<extra></extra>",))fig.add_trace(go.Scatter(x=times_dp54/3600,y=errors_dp54,name="DP54 (Adaptive)",mode="lines",line=dict(color=colors[2],width=2),hovertemplate="<b>DP54</b><br>Error: %{y:.2e} m<extra></extra>",))fig.add_trace(go.Scatter(x=times_rkn1210/3600,y=errors_rkn1210,name="RKN1210 (Adaptive)",mode="lines",line=dict(color=colors[3],width=2),hovertemplate="<b>RKN1210</b><br>Error: %{y:.2e} m<extra></extra>",))# Configure layoutfig.update_layout(title="Integrator Accuracy Comparison: HEO Orbit",xaxis_title="Time (hours)",yaxis_title="Position Error (m)",yaxis_type="log",hovermode="x unified",legend=dict(yanchor="top",y=0.99,xanchor="left",x=0.01),)# Configure axes - hide default x-value in hover since we show it in first tracefig.update_xaxes(title_text="Time (hours)",unifiedhovertitle=dict(text=""))fig.update_yaxes(title_text="Position Error (m)",type="log")returnfig# Generate and save both themed versionslight_path,dark_path=save_themed_html(create_figure,OUTDIR/SCRIPT_NAME)print(f"✓ Generated {light_path}")print(f"✓ Generated {dark_path}")
To use an adaptive-step integrator like RKF45, you create it with an IntegratorConfig specifying tolerances, then call step. The adaptive integrator returns an AdaptiveStepResult containing the new state and recommended next step size.
importbraheasbhimportnumpyasnpdefdynamics(t,state):"""Exponential decay dynamics: dx/dt = -k*x"""k=0.1returnnp.array([-k*state[0]])# Create adaptive integratorconfig=bh.IntegratorConfig.adaptive(abs_tol=1e-10,rel_tol=1e-9)integrator=bh.DP54Integrator(1,dynamics,config=config)# Propagate from t=0 to t_endt=0.0t_end=86400.0# One daystate=np.array([1.0])dt=60.0step_count=0whilet<t_end:result=integrator.step(t,state,min(dt,t_end-t))t+=result.dt_usedstate=result.statedt=result.dt_nextstep_count+=1print(f"Propagated from 0 to {t_end}s in {step_count} steps")print(f"Final state: {state[0]:.6e}")print(f"Analytical: {np.exp(-0.1*t_end):.6e}")print(f"Error: {abs(state[0]-np.exp(-0.1*t_end)):.2e}")
//!//! Demonstrates the pattern for propagating over an extended time period//! using an adaptive integrator, using the recommended step size from//! each step for the next step.usebrahe::integrators::*;usenalgebra::DVector;fnmain(){// Dynamics function: Exponential decay dx/dt = -k*xletk=0.1;letdynamics=move|_t:f64,state:&DVector<f64>,_params:Option<&DVector<f64>>|->DVector<f64>{DVector::from_vec(vec![-k*state[0]])};// Create adaptive integratorletconfig=IntegratorConfig::adaptive(1e-10,1e-9);letintegrator=DormandPrince54DIntegrator::with_config(1,Box::new(dynamics),None,None,None,config);// Propagate from t=0 to t_endletmutt=0.0;lett_end=86400.0;// One dayletmutstate=DVector::from_vec(vec![1.0]);letmutdt:f64=60.0;letmutstep_count=0;whilet<t_end{letresult=integrator.step(t,state,None,Some(dt.min(t_end-t)));t+=result.dt_used;state=result.state;dt=result.dt_next;step_count+=1;}letanalytical=(-0.1*t_end).exp();println!("Propagated from 0 to {}s in {} steps",t_end,step_count);println!("Final state: {:.6e}",state[0]);println!("Analytical: {:.6e}",analytical);println!("Error: {:.2e}",(state[0]-analytical).abs());}
For orbit determination and covariance propagation, you often need to propagate the state transition matrix (STM) alongside the state. The STM \(\Phi(t, t_0)\) maps perturbations in initial state to perturbations in final state:
They can be propagated by integrating the variational equations alongside the state, which requires computing the Jacobian of the dynamics. Brahe's integrators support this via the step_with_varmat method. You can learn more about defining Jacobians in the Jacobian Computation guide.
//! State Transition Matrix propagation pattern example.//!//! Demonstrates the basic pattern for propagating a state transition matrix//! alongside the state using variational equations.usebrahe::integrators::*;usebrahe::math::jacobian::*;usenalgebra::{DMatrix,DVector};fnmain(){// Dynamics function: Exponential decay dx/dt = -k*xletk=0.1;letdynamics=move|_t:f64,state:&DVector<f64>,_params:Option<&DVector<f64>>|->DVector<f64>{DVector::from_vec(vec![-k*state[0]])};// Clone for Jacobian computationletdynamics_for_jac=move|_t:f64,state:&DVector<f64>,_params:Option<&DVector<f64>>|->DVector<f64>{DVector::from_vec(vec![-k*state[0]])};// Create Jacobian for variational equationsletjacobian=DNumericalJacobian::central(Box::new(dynamics_for_jac)).with_adaptive(1e-8,1e-6);// Create integrator with Jacobianletconfig=IntegratorConfig::adaptive(1e-12,1e-11);letintegrator=DormandPrince54DIntegrator::with_config(1,Box::new(dynamics),Some(Box::new(jacobian)),None,None,config);// Propagate state and STMlett=0.0;letstate=DVector::from_vec(vec![1.0]);letphi=DMatrix::identity(1,1);// 1x1 identity matrixletdt=60.0;letresult=integrator.step_with_varmat(t,state,None,phi,Some(dt));letnew_state=result.state;letnew_phi=result.phi.unwrap();letdt_used=result.dt_used;println!("Initial state: {:.6}",1.0);println!("State after {:.2}s: {:.6}",dt_used,new_state[0]);println!("State transition matrix:");println!(" Φ = {:.6}",new_phi[(0,0)]);println!(" (Analytical Φ = {:.6})",(-0.1*dt_used).exp());}