# Mission Sequence and Finite-Burn Propagation Demo#
Scarabaeus OD Framework | Last revised 2026
What this notebook covers#
This notebook is a self-contained walkthrough of multi-arc trajectory propagation using the Scarabaeus MissionSequence API. It shows:
# |
Topic |
|---|---|
1 |
Parsing a maneuver specification file with |
2 |
Defining a heliocentric spacecraft and initial state |
3 |
Building force models and propagators for coast and finite-burn arcs |
4 |
Assembling and propagating a |
5 |
Visualizing the propagated trajectory |
6 |
Visualizing mass evolution during finite burns |
7 |
Impulsive burn pattern — |
How to run#
Run cells top-to-bottom from the project root directory (scarabaeus/). The first code cell navigates there automatically if you opened the notebook from tutorials/.
0. Imports and Setup#
We import Scarabaeus, the standard scientific stack, load SPICE kernels, and define units and frames used throughout the notebook.
All data paths are relative to the project root. The locked_generic.tm meta-kernel loads planetary ephemerides (DE440), leap-seconds, and Earth ground-station SPKs.
[1]:
import scarabaeus as scb
import supplementary as supp
from pathlib import Path
import numpy as np
# load tutorial data
data = supp.load_data()
# load kernels
scb.SpiceManager.clear_kernels()
scb.SpiceManager.load_kernel_from_mkfile(data.mk.path)
# define units and frame
kg, km, sec, hr, N = scb.Units.get_units(['kg', 'km', 'sec', 'hr', 'N'])
frame = scb.Frame('J2000')
SCB supplementary data up to date.
2. Maneuver Specification File#
Finite burns are loaded from an example measurement specification file which is a comma-delimited (CSV) file produced by mission-design tools (e.g. GMAT).
File format#
The first row is a header that names every column. Each subsequent row represents one burn segment with the following key columns (0-indexed within the repeating block):
Column index |
Name |
Description |
|---|---|---|
0 |
|
Segment identifier |
1 |
|
Maneuvers per row (always |
2 |
|
Reference frame (e.g. |
3 |
|
Burn start time (UTC string, converted to TDB via SPICE) |
4–6 |
|
Unit thrust-direction components (unitless) |
7 |
|
Thrust magnitude (Newtons) |
8 |
|
Spacecraft mass at burn start (kg) |
9 |
|
Propellant mass-flow rate |
13 |
|
Burn duration (seconds) — added to EPOCH to compute end time |
14 |
|
Burn start as raw TDB ET seconds |
ManeuverParser._parse_maneuver_spec reads this file and returns a list of Maneuver objects, one per row.
[2]:
spec_path = data.example_meas_spec.path
parser = scb.ManeuverParser()
with open(spec_path) as fh:
burns = parser._parse_maneuver_spec(fh)
burn_start = lambda b: float(np.atleast_1d(b.start_time.times.values)[0])
burn_end = lambda b: float(np.atleast_1d(b.end_time.times.values)[0])
print(f'Parsed {len(burns)} burn segments from:')
print(f' {Path(spec_path).name}')
print()
print(f'{"Segment":<10} {"Start (UTC)":<30} {"Duration [hr]":>15}')
print('-' * 58)
for i, burn in enumerate(burns):
start, end = burn.start_time, burn.end_time
dur_hr = (end - start).values / 3600.0
thr = float(np.atleast_1d(burn.thrust.values)[0])
print(f"{i:<10} {start.to(rep = 'CAL')} {dur_hr:>15.1f} F = {thr}")
Parsed 15 burn segments from:
example.mission_maneuver_spec
Segment Start (UTC) Duration [hr]
----------------------------------------------------------
0 2030 SEP 16 02:43:16.948 (TDB) 124.8 F = 0.0001
1 2030 SEP 21 07:31:16.952 (TDB) 124.8 F = 0.00011428571
2 2030 SEP 26 12:19:16.957 (TDB) 124.8 F = 0.00012857143
3 2030 OCT 01 17:07:16.961 (TDB) 124.8 F = 0.00014285714
4 2030 OCT 06 21:55:16.966 (TDB) 124.8 F = 0.00015714285999999999
5 2030 OCT 12 02:43:16.970 (TDB) 124.8 F = 0.00017142857
6 2030 OCT 17 07:31:16.975 (TDB) 124.8 F = 0.00018571429
7 2030 OCT 22 12:19:16.979 (TDB) 124.8 F = 0.0002
8 2030 OCT 27 17:07:16.984 (TDB) 124.8 F = 0.00021428571
9 2030 NOV 01 21:55:16.988 (TDB) 124.8 F = 0.00022857143
10 2030 NOV 07 02:43:16.993 (TDB) 124.8 F = 0.00024285714
11 2030 NOV 12 07:31:16.997 (TDB) 124.8 F = 0.00025714286
12 2030 NOV 17 12:19:17.002 (TDB) 124.8 F = 0.00027142857
13 2030 NOV 22 17:07:17.006 (TDB) 124.8 F = 0.00028571429
14 2030 NOV 27 21:55:17.011 (TDB) 124.8 F = 0.0003
3. Spacecraft and Initial State#
We define a heliocentric spacecraft whose initial mass matches the SMASS value of the first burn segment (~2000 kg). The initial position and velocity are approximate heliocentric values placed one day before the first burn.
The StateDefinition.from_components API constructs the state vector as a list of tuples:
('component_name', size, 'estimated'|'consider', 'dynamic'|'static', body, ArrayWFrame)
For non-first legs the position/velocity is NaN-initialised — the sequence propagator overwrites these with the terminal state of the preceding arc.
[3]:
# ── spacecraft ───────────────────────────────────────────────────
dry_mass = scb.ArrayWUnits(1500.0, kg)
fuel_mass = scb.ArrayWUnits(500.0, kg)
area = scb.ArrayWUnits(1e-6, km**2)
cr = scb.ArrayWUnits(1.5, None)
Orbiter = scb.Spacecraft('Orbiter', -60000, dry_mass + fuel_mass, area, cr)
# ── origin ────────────────────────────────────────────────────────
origin = scb.CelestialBody.from_constants('SUN')
# ── timing — start one day before the first burn ──────────────────
t_burn0_start = scb.EpochArray(burn_start(burns[0]), sys = 'TDB')
t_burn0_end = scb.EpochArray(burn_end(burns[0]), sys = 'TDB')
t_coast0_start = t_burn0_start - scb.ArrayWUnits(86400.0, sec) # 1 day before burn 0
print(f"Coast start : {t_coast0_start.to(rep = 'CAL')}")
print(f"Burn 0 start: {t_burn0_start.to(rep = 'CAL')}")
print(f"Burn 0 end : {t_burn0_end.to(rep = 'CAL')}")
print(f"Burn 0 dur : {(t_burn0_end - t_burn0_start).values/86400} days")
# ── initial 6-DOF state (approximate heliocentric values) ─────────
r0 = np.array([-1.5e8, 9.0e7, 3.0e7]) # [km] ~1 AU from Sun
v0 = np.array([ -8.0, -18.0, -5.0]) # [km/s] approximate heliocentric speed
pos_0 = scb.ArrayWFrame(scb.ArrayWUnits(r0, km), frame)
vel_0 = scb.ArrayWFrame(scb.ArrayWUnits(v0, km / sec), frame)
initial_state_def = scb.StateDefinition.from_components([
('position', 3, 'estimated', 'dynamic', Orbiter, pos_0),
('velocity', 3, 'estimated', 'dynamic', Orbiter, vel_0),
])
initial_state = scb.StateArray(
epoch=t_coast0_start,
origin=origin,
state=initial_state_def,
)
print(f'\nInitial mass: {float((dry_mass + fuel_mass).values):.1f} kg')
Coast start : 2030 SEP 15 02:43:16.948 (TDB)
Burn 0 start: 2030 SEP 16 02:43:16.948 (TDB)
Burn 0 end : 2030 SEP 21 07:31:16.952 (TDB)
Burn 0 dur : 5.2000000520836975 days
Initial mass: 2000.0 kg
4. Build Force Models and Propagators#
Each arc gets its own ForceModelTranslation and Propagator.
Arc |
|
|
|---|---|---|
Coast (before burn) |
|
— |
Finite burn arc |
|
|
Final coast (after burn) |
|
— |
The integration step is dt = 3600 s (1 hour).
Non-first arcs are initialised with StateDefinition.empty_pv:
sv = StateArray(
epoch=EpochArray(np.zeros(1), sys='TDB'),
origin=origin,
state=StateDefinition.empty_pv(Orbiter, frame=J2000),
)
empty_pv allocates a NaN position/velocity in km / km/s and marks them estimated + dynamic. The epoch zero is a placeholder — the sequence propagator replaces both the NaN state and the epoch with the terminal state of the preceding arc before integration begins.
[4]:
dt = scb.ArrayWUnits(1, hr)
dt_sec = 3600.0
def empty_state(spacecraft, origin, frame):
"""StateArray for non-first legs using StateDefinition.empty_pv.
StateDefinition.empty_pv fills position and velocity with NaN
(units km / km/s by default) and marks them as 'estimated' / 'dynamic'.
The epoch is set to zero — the sequence propagator will overwrite both
the NaN values and the epoch at run-time from the preceding arc's
terminal state.
"""
return scb.StateArray(
epoch=scb.EpochArray(np.zeros(1), sys='TDB'),
origin=origin,
state=scb.StateDefinition.empty_pv(spacecraft, frame=frame),
)
# ── Coast before burn 0 (1 day) ──────────────────────────────────
ep_coast0 = scb.EpochArray.interval(t_coast0_start.to(rep = 'NUM'),
t_burn0_start.to(rep = 'NUM')+dt, dt, sys = 'TDB')
fm_coast0 = scb.ForceModelTranslation(primary_body=Orbiter, cannonball_SRP=True)
prop_coast0 = scb.Propagator(
primary_body=Orbiter, state_vector=initial_state,
tspan=ep_coast0, force_models=fm_coast0,
)
# ── Finite burn arc 0 ────────────────────────────────────────────
ep_burn0 = scb.EpochArray.interval(t_burn0_start.to(rep = 'NUM'),
t_burn0_end.to(rep = 'NUM'),
dt, sys = 'TDB')
sv_burn0 = empty_state(Orbiter, origin, frame)
fm_burn0 = scb.ForceModelTranslation(
primary_body=Orbiter, cannonball_SRP=True,
finite_burn=True, maneuver=burns[0],
)
prop_burn0 = scb.Propagator(
primary_body=Orbiter, state_vector=sv_burn0,
tspan=ep_burn0, force_models=fm_burn0,
)
# ── Final coast (6 hours after burn 0) ───────────────────────────
coast_final_start_num = ep_burn0.times.values[-1]
coast_final_end_num = coast_final_start_num + 6 * 3600 # 6 hours in seconds
ep_coast_final = scb.EpochArray.interval(coast_final_start_num,
coast_final_end_num + dt_sec, dt, sys = 'TDB')
sv_coast_final = empty_state(Orbiter, origin, frame)
fm_coast_final = scb.ForceModelTranslation(primary_body=Orbiter, cannonball_SRP=True)
prop_coast_final = scb.Propagator(
primary_body=Orbiter, state_vector=sv_coast_final,
tspan=ep_coast_final, force_models=fm_coast_final,
)
print('Propagators built:')
for label, ep in [('Coast before', ep_coast0), ('Burn 0', ep_burn0), ('Final coast', ep_coast_final)]:
n = len(ep.times.values)
t0, tf = ep.to(rep = 'CAL').times[0], ep.to(rep = 'CAL').times[-1]
print(f' {label:<16} {n:>4} steps '
f"{t0} → {tf}")
print('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~')
print('Coast 0 last: ', ep_coast0.times.values[-1])
print('Burn 0 first:', ep_burn0.times.values[0])
print('Burn 0 last: ', ep_burn0.times.values[-1])
print('Coast f first:', ep_coast_final.times.values[0])
Propagators built:
Coast before 25 steps 2030 SEP 15 02:43:16.948 → 2030 SEP 16 02:43:16.948
Burn 0 125 steps 2030 SEP 16 02:43:16.948 → 2030 SEP 21 06:43:16.948
Final coast 7 steps 2030 SEP 21 06:43:16.948 → 2030 SEP 21 12:43:16.948
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Coast 0 last: 969029066.1305069
Burn 0 first: 969029066.1305069
Burn 0 last: 969475466.1305069
Coast f first: 969475466.1305069
5. Assemble and Propagate the Mission Sequence#
MissionSequence chains the arcs in order. The propagation engine:
Propagates the coast leg from the given initial state.
Inserts an automatic node at the burn start (no state discontinuity).
Propagates burn 0 forward, applying thrust and mass depletion at each step.
Inserts another node at the burn end.
Propagates the final coast from the post-burn state.
Use addLeg for coast arcs and addFiniteBurn for thrust arcs.
[5]:
MS = scb.MissionSequence('FiniteBurnDemo')
MS.addLeg(
'Coast before burn 0',
state_vector=initial_state,
prop=prop_coast0,
dt=dt,
)
MS.addFiniteBurn(
'Burn 0',
state_vector=sv_burn0,
prop=prop_burn0,
dt=dt,
)
MS.addLeg(
'Final coast',
state_vector=sv_coast_final,
prop=prop_coast_final,
dt=dt,
)
print('Sequence types :', MS.types)
print('Segment names :', [n for n in MS.names if 'Node' not in n])
scenario = MS.propagate()
print('Done.')
t_all = scenario.total_epochsTDB.times.values
hours = (t_all - t_all[0]) / 3600.0
print(f'Total propagated steps : {len(t_all)}')
print(f'Total duration : {hours[-1]:.1f} hr')
Sequence types : ['Leg', 'Node', 'Finite Burn', 'Node', 'Leg']
Segment names : ['Coast before burn 0', 'Burn 0', 'Final coast']
--- Propagating Segment 1/5: 'Coast before burn 0' [Leg] ---
[IC] segment='Coast before burn 0' type='Leg' epoch=968942666.1305069 sec (TDB)
position n= 3 sid=-60000 frame=J2000 vals[:6]=[-1.5e+08 9.0e+07 3.0e+07]
velocity n= 3 sid=-60000 frame=J2000 vals[:6]=[ -8. -18. -5.]
================================================================================
Starting propagation...
================================================================================
Integrating: 100%|███████████████████████████████████████████████| 86400.00/86400.00 s [00:00<00:00]
=================== DOP853 integration complete. ==================
Propagation complete.
[GLOBAL STATE after 'Coast before burn 0']
key=('position', 3, -60000, 'J2000', 0) -> global[0:3] = [-1.50677868e+08 8.84368593e+07 2.95653506e+07]
key=('velocity', 3, -60000, 'J2000', 0) -> global[3:6] = [ -7.69091695 -18.18342328 -5.06122998]
--- Propagating Segment 2/5: 'Burn 0 Start Node' [Node] ---
--- Propagating Segment 3/5: 'Burn 0' [Finite Burn] ---
[IC] segment='Burn 0' type='Finite Burn' epoch=969029066.1305069 sec (TDB)
position n= 3 sid=-60000 frame=J2000 vals[:6]=[-1.50677868e+08 8.84368593e+07 2.95653506e+07]
velocity n= 3 sid=-60000 frame=J2000 vals[:6]=[ -7.69091695 -18.18342328 -5.06122998]
================================================================================
Starting propagation...
================================================================================
Integrating: 100%|█████████████████████████████████████████████| 446400.00/446400.00 s [00:00<00:00]
=================== DOP853 integration complete. ==================
Propagation complete.
[GLOBAL STATE after 'Burn 0']
key=('position', 3, -60000, 'J2000', 0) -> global[0:3] = [-1.53749554e+08 8.01100857e+07 2.72380593e+07]
key=('velocity', 3, -60000, 'J2000', 0) -> global[3:6] = [ -6.05764965 -19.11231949 -5.36299273]
--- Propagating Segment 4/5: 'Final coast Start Node' [Node] ---
--- Propagating Segment 5/5: 'Final coast' [Leg] ---
[IC] segment='Final coast' type='Leg' epoch=969475466.1305069 sec (TDB)
position n= 3 sid=-60000 frame=J2000 vals[:6]=[-1.53749554e+08 8.01100857e+07 2.72380593e+07]
velocity n= 3 sid=-60000 frame=J2000 vals[:6]=[ -6.05764965 -19.11231949 -5.36299273]
================================================================================
Starting propagation...
================================================================================
Integrating: 100%|███████████████████████████████████████████████| 21600.00/21600.00 s [00:00<00:00]
=================== DOP853 integration complete. ==================
Propagation complete.
[GLOBAL STATE after 'Final coast']
key=('position', 3, -60000, 'J2000', 0) -> global[0:3] = [-1.53879518e+08 7.96968013e+07 2.71220628e+07]
key=('velocity', 3, -60000, 'J2000', 0) -> global[3:6] = [ -5.97600807 -19.15473055 -5.37741934]
Done.
Total propagated steps : 155
Total duration : 154.0 hr
6. Trajectory Visualization#
Position and velocity histories are accessed from the ScenarioSetup object returned by MS.propagate():
r = scenario.total_position.quantity.values # shape (3, N) [km]
v = scenario.total_velocity.quantity.values # shape (3, N) [km/s]
The grey bands mark the finite-burn arc.
[6]:
r = np.array(scenario.total_position.quantity.values, dtype=float) # (N, 3)
v = np.array(scenario.total_velocity.quantity.values, dtype=float) # (N, 3)
# burn window in hours-from-start
h_burn_start = (t_burn0_start.to(rep = 'NUM').times - t_all[0]) / 3600.0
h_burn_end = (t_burn0_end.to(rep = 'NUM').times - t_all[0]) / 3600.0
7. Mass Evolution During Finite Burns#
During a finite-burn arc the Scarabaeus propagator integrates the mass ODE
and stores the result as an SCBPolynomial inside the spacecraft. After propagation, Spacecraft.mass(t) evaluates this polynomial at any epoch within the valid domain of each arc.
We call it at every epoch in scenario.total_epochsTDB to reconstruct the full mass history across coast and burn arcs.
[7]:
# Evaluate mass at every propagated epoch
# Orbiter._mass_profile is updated by the propagator for each arc;
# mass(t) dispatches to the correct arc polynomial via get_mass().
mass_vals = np.array(
[float(np.asarray(Orbiter.mass(float(t)).values).ravel()[0]) for t in t_all]
)
m0 = mass_vals[0]
m_end = mass_vals[-1]
mdot = burns[0].mass_flow.values[0]
delta_m = m0 - m_end
print(f'Initial mass : {m0:.3f} kg')
print(f'Final mass : {m_end:.3f} kg')
print(f'Propellant used : {delta_m:.3f} kg')
print(f'Mass-flow rate : {mdot:.4e} kg/s')
# Plot only during burn, scatter style
burn_mask = (hours >= h_burn_start) & (hours <= h_burn_end)
h_plot = hours[burn_mask][:-1]
m_plot = mass_vals[burn_mask][:-1]
supp.supp_plotting.plot_fb_traj(r, v, hours, h_burn_start, h_burn_end)
Initial mass : 2000.000 kg
Final mass : 2000.000 kg
Propellant used : -0.000 kg
Mass-flow rate : 5.9963e-06 kg/s
9. Notes and Common Pitfalls#
Epoch continuity: the MissionSequence validator checks that the last epoch of each arc matches the first epoch of the next. make_epochs ensures t_arr[-1] == tf so there is no floating-point gap at arc boundaries.
NaN-initialized states: non-first legs must be constructed with NaN position/velocity and a NaN epoch. The sequence propagator fills these at runtime from the terminal state of the preceding arc. Providing real values for subsequent arcs is silently ignored.
``addFiniteBurn`` vs ``addLeg``: use addFiniteBurn for any arc whose ForceModelTranslation was built with finite_burn=True. Mixing them (e.g., addLeg with a thrust force model) will propagate without thrust.
Nodes are inserted automatically: addFiniteBurn inserts a node before the burn, and addLeg following a burn inserts one before the coast. Avoid calling addNode manually unless you need a named boundary marker.
Mass units: ManeuverParser stores mass_flow in kg/sec and thrust in N. The Scarabaeus propagator stores thrust internally in kN so that dividing by mass in kg yields acceleration directly in km/s².