Skip to content

Neutron.
One forward pass.

Start with a rocket at rest. Calculate its forces, advance its state, then follow the payload to orbit and the booster home.

Follow one 8,000 kg payload to a 400 km orbit, with booster recovery. Every flight result comes from the same default run. Calculations use SI units; displayed kilometres are labelled.

  1. yn\mathbf y_nCurrent state
  2. F,  D\mathbf F,\;\mathbf DThrust & drag
  3. y˙\dot{\mathbf y}Rates of change
  4. yn+1\mathbf y_{n+1}Next state
Executed physics loop: re-evaluate at each solver trial; repeat until cutoff. Then coast and separate.

This is a five-state planar translation model. Guidance reads the simulated state directly at each solver evaluation, and thrust force is applied immediately. Sensors, state estimation, attitude dynamics and actuator response are outside this model. The Python model computes the complete flight before playback begins.

The attached stack

Stage 1: launch to separation

Begin at rest on a spherical, nonrotating Earth. The model tracks motion in one flight plane, with a still atmosphere and ideal thrust pointing.

Start with five numbers

Symbols in this section

t, t0t,\ t_0
Elapsed time and launch time; the reference run starts at zero. [s]
r, REr,\ R_E
Distance from Earth's centre and the fixed spherical Earth radius. [m]
θ\theta
Signed angle from the launch radius, increasing in the chosen downrange direction. [rad]
er, et\mathbf e_r,\ \mathbf e_t
Local unit directions: outward from Earth's centre and perpendicular to it toward increasing angle. [dimensionless]
vr, vtv_r,\ v_t
Signed velocity components along er\mathbf e_r and et\mathbf e_t; positive means outward and downrange. [m/s]
m, m0, mum,\ m_0,\ m_u
Current attached mass, launch mass, and carried upper assembly mass (upper dry structure, propellant and payload). [kg]
h, sh,\ s
Altitude above the model surface and signed downrange arc length on that surface; these are derived outputs. [m]
y, (⋅)T, (⋅)0\mathbf y,\ (\cdot)^T,\ (\cdot)_0
Ordered five-state column vector, transpose, and launch-value subscript; its entries have different units. [mixed, by entry]

At time tt in seconds, the state y\mathbf y is the five numbers carried from one calculation to the next. Subscript 00 means liftoff: the rocket starts at rest with full thrust applied immediately.

y=[rθvrvtm],y0=[6 378 137000480 000]\mathbf y=\begin{bmatrix}r\\\theta\\v_r\\v_t\\m\end{bmatrix},\qquad \mathbf y_0=\begin{bmatrix}6\,378\,137\\0\\0\\0\\480\,000\end{bmatrix}

rr is distance from Earth’s centre (m); θ\theta is the angle travelled from the launch point (rad).

vrv_r is outward velocity and vtv_t is local tangential velocity, positive downrange (m/s); mm is the entire attached stack’s mass (kg).

m0=30 000+335 000+115 000m_0=30\,000+335\,000+115\,000

The three terms are stage 1 dry mass, stage 1 propellant, and the carried assembly. That last 115,000 kg includes its structure, fuel and an 8,000 kg payload. These internal masses are model assumptions.

Carry forward: the current time and this five-number state.

Why these terms appear

h=r−RE,s=REθvr=r˙,vt=rθ˙\begin{aligned}h&=r-R_E,&s&=R_E\theta\\v_r&=\dot r,&v_t&=r\dot\theta\end{aligned}

Radius is measured from Earth's centre, not from the ground. Tangential velocity is the local arc rate at radius rr; ground downrange instead uses the fixed surface radius RER_E. Consequently s˙=(RE/r)vt\dot s=(R_E/r)v_t, not generally vtv_t.

er=[cos⁡θsin⁡θ],et=[−sin⁡θcos⁡θ]v=vrer+vtet\begin{aligned}\mathbf e_r&=\begin{bmatrix}\cos\theta\\\sin\theta\end{bmatrix},&\mathbf e_t&=\begin{bmatrix}-\sin\theta\\\cos\theta\end{bmatrix}\\\mathbf v&=v_r\mathbf e_r+v_t\mathbf e_t\end{aligned}

The two entries in each unit vector are components along fixed Cartesian axes in the flight plane. The first axis points from Earth's centre through the pad. The local directions rotate as θ\theta changes, even though this Earth does not rotate. A velocity component may be negative; speed is a nonnegative magnitude.

The five states describe planar translation and attached mass. There is no attitude, angular rate, out-of-plane motion, pad hold-down, Earth rotation or launch-latitude model. At liftoff the engine force is already applied; engine startup is outside this calculation.

All worked numbers in this lesson use an 8,000 kg payload and 60,000 kg recovery reserve. The quoted 480,000 kg is the resulting reference-case mass; changing the payload in the executable runner changes the actual initial mass.

Associated code · 3 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Earth constantspython/neutron/model.py:16 · Python
MU = 3.986004418e14              # Earth gravitational parameter [m^3/s^2]
EARTH_RADIUS = 6_378_137.0       # spherical Earth radius [m]
G0 = 9.80665                    # Isp reference gravity [m/s^2]
Vehicle parameters and assumed mass splitpython/neutron/model.py:27 · Python
@dataclass(frozen=True)
class Vehicle:
    """Published geometry/thrust; explicitly assumed internal mass and Isp."""
    height_m: float = 43.0
    diameter_m: float = 7.0
    published_liftoff_mass_kg: float = 480_000.0
    advertised_leo_payload_kg: float = 13_000.0
    booster_engines: int = 9
    booster_thrust_n: float = 1_450_000.0 * 4.4482216152605
    upper_thrust_n: float = 890_000.0
    booster_dry_kg: float = 30_000.0   # assumption, includes captive fairing
    booster_propellant_kg: float = 335_000.0
    booster_isp_s: float = 330.0
    upper_dry_kg: float = 5_000.0
    upper_propellant_kg: float = 102_000.0
    upper_isp_s: float = 365.0
    drag_coefficient: float = 0.35
Initialize the five-state stackpython/neutron/model.py:361 · Python
    area = math.pi * vehicle.diameter_m**2 / 4
    upper_wet = vehicle.upper_dry_kg + vehicle.upper_propellant_kg * upper_propellant_scale + payload_kg
    initial_mass = vehicle.booster_dry_kg + vehicle.booster_propellant_kg + upper_wet
    initial = np.array([EARTH_RADIUS, 0.0, 0.0, 0.0, initial_mass])
Inside each derivative evaluation

Steps 2–4 use the same trial time and state: thrust, drag, then all five rates.

Point the thrust

Symbols in this section

βdeg, β\beta_{deg},\ \beta
Commanded thrust-direction angle above the local positive horizontal, in degrees and in radians respectively. [°, rad]
ta, tb; βa, βbt_a,\ t_b;\ \beta_a,\ \beta_b
Times and degree-valued pitches at the two knots bracketing the current time; a and b label endpoints. [s; °]
TT
Magnitude of the total nine-engine thrust, held constant during the powered ascent. [N]
Fr, FtF_r,\ F_t
Signed thrust-force components in the local outward and positive downrange directions; gravity and drag are separate. [N]
γ\gamma
Flight-path angle of velocity above the local horizontal; defined only when speed is nonzero. [rad]

The pitch angle β\beta is measured above the local horizontal. The model linearly interpolates this assumed schedule and holds the endpoint pitch outside its time range:

Prescribed pitch, not a feedback controller
Time (s)Pitch (°)
090
1290
3582
7067
11552
16040
Thrust FβTangential · downrangeRadial · outward
β(t)=π180 interp⁡(t;table)\beta(t)=\frac{\pi}{180}\,\operatorname{interp}(t;\text{table})
Fr=Tsin⁡β,Ft=Tcos⁡βF_r=T\sin\beta,\qquad F_t=T\cos\beta

Fr,FtF_r,F_t are thrust components (N). The total thrust TT stays at 6.4499 MN during ascent. The implementation applies the commanded direction instantly.

Why these terms appear

β=90∘:(Fr,Ft)=(T,0)β=0∘:(Fr,Ft)=(0,T)\begin{aligned}\beta=90^\circ:&\quad(F_r,F_t)=(T,0)\\\beta=0^\circ:&\quad(F_r,F_t)=(0,T)\end{aligned}

These endpoint checks explain the sine on the radial component and cosine on the tangential component. The angle is measured from the horizontal, so the radial force is opposite the angle in the component triangle. Degree labels here describe the directions; trigonometric evaluation in Python uses radians.

γ=atan2⁡(vr,vt)(V>0)\gamma=\operatorname{atan2}(v_r,v_t)\quad(V>0)

Pointing the thrust at β\beta does not instantly turn the velocity to the same angle. Forces change velocity over time, so the flight-path angle γ\gamma generally differs from β\beta. This model commands thrust direction directly; it does not integrate body attitude, gimbal motion or angle-of-attack aerodynamics.

The thrust conversion uses 1 lbf=4.4482216152605 N1\ \mathrm{lbf}=4.4482216152605\ \mathrm N; lbf means pound-force, not mass. The total is not multiplied by nine again. Constant thrust and specific impulse omit throttle, startup and ambient-pressure variation.

The pitch knots are an assumed open-loop schedule. Linear interpolation makes pitch continuous at each knot, while its slope can change there. Pitch is held at 40° after the last knot until the mass event stops the powered phase.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Interpolate pitch and resolve thrustpython/neutron/model.py:146 · Python
def ascent_guidance(time, state, vehicle):
    """An assumed pitch program; angles measured above local horizontal."""
    pitch = np.interp(time, [0, 12, 35, 70, 115, 160], [90, 90, 82, 67, 52, 40])
    beta = math.radians(pitch)
    return vehicle.booster_thrust_n * np.array([math.sin(beta), math.cos(beta)])

Find the air, then the drag

Symbols in this section

h, ρ, ρ0h,\ \rho,\ \rho_0
Altitude, atmospheric density at that altitude, and reference sea-level density (1.225 kg/m³). [m; kg/m³]
HH
Density scale height: 8,500 m, the altitude increase that reduces density by a factor of e above the surface. [m]
V=∥v∥V=\lVert\mathbf v\rVert
Speed relative to the still atmosphere; the magnitude of the two signed velocity components. [m/s]
d, A, CDd,\ A,\ C_D
Vehicle diameter (7 m), circular reference area, and constant assumed drag coefficient (0.35). [m; m²; dimensionless]
qdyn, Dq_{dyn},\ D
Dynamic pressure and nonnegative drag-force magnitude; pressure becomes force only after multiplying by coefficient and area. [Pa; N]
Dr, DtD_r,\ D_t
Signed components of the drag force, opposite the respective components of velocity. [N]
h=r−RE,V=vr2+vt2h=r-R_E,\qquad V=\sqrt{v_r^2+v_t^2}

hh is altitude (m), RE=6 378 137 mR_E=6\,378\,137\ \mathrm m is Earth’s radius, and VV is speed through the still atmosphere (m/s).

ρ=1.225exp⁡ ⁣(−max⁡(0,h)8500)\rho=1.225\exp\!\left(-\frac{\max(0,h)}{8500}\right)

ρ\rho is air density (kg/m³). The assumed atmosphere has sea-level density 1.225 kg/m³ and an 8,500 m scale height.

Dr=−12ρCDAVvrDt=−12ρCDAVvt\begin{aligned}D_r&=-\tfrac12\rho C_D A Vv_r\\D_t&=-\tfrac12\rho C_D A Vv_t\end{aligned}

Dr,DtD_r,D_t are drag components (N). The assumed drag coefficient is CD=0.35C_D=0.35. The reference area is A=π(7 m)2/4A=\pi(7\,\mathrm m)^2/4 = 38.48 m². Negative signs make drag oppose velocity.

Why these terms appear

ρ=ρ0e−max⁡(0,h)/Hqdyn=12ρV2,D=qdynCDA\begin{aligned}\rho&=\rho_0 e^{-\max(0,h)/H}\\q_{dyn}&=\frac12\rho V^2,\qquad D=q_{dyn}C_DA\end{aligned}

The density exponent is dimensionless because altitude and scale height use the same units. The max operation caps density at its sea-level value for a trial state below the surface; the separate impact event terminates physical flight at the surface.

D=−DvV=−12ρCDAVv(V>0)D=0(V=0)\begin{aligned}\mathbf D&=-D\frac{\mathbf v}{V}\\&=-\frac12\rho C_D A V\mathbf v\quad(V>0)\\\mathbf D&=\mathbf0\quad(V=0)\end{aligned}

Divide velocity by speed to obtain its direction, then reverse it and multiply by drag magnitude. Cancelling one speed factor gives the implementation's expression, which also evaluates safely to zero at launch without division by zero. During descent the radial velocity is negative, so radial drag points outward.

Dimensional check: ρV2\rho V^2 has units kg/(m·s²) = Pa; multiplying by AA gives kg·m/s² = N. qdynq_{dyn} is a pressure, not an acceleration. The solver-component index qq in step 5 is unrelated to dynamic pressure.

This is a one-layer density law with constant drag coefficient and frontal area. It omits lift, winds, atmospheric rotation, Mach-dependent aerodynamics and heating. Fairing opening later does not alter this area or coefficient.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Altitude to densitypython/neutron/model.py:46 · Python
def atmosphere(altitude_m):
    """One-layer density model, not a weather or aerothermal model."""
    return 1.225 * math.exp(-max(0.0, altitude_m) / 8500.0)
Velocity-opposing drag componentspython/neutron/model.py:103 · Python
def drag_force(state, area_m2, cd):
    """Components in the local outward/tangential frame; still atmosphere."""
    radius, _, vr, vt, _ = state
    speed = math.hypot(vr, vt)
    scale = -0.5 * atmosphere(radius - EARTH_RADIUS) * cd * area_m2 * speed
    return np.array([scale * vr, scale * vt])

Calculate all five rates of change

Symbols in this section

( )˙=d( )/dt\dot{(\ )}=d(\ )/dt
A time derivative: rate of change per second in the fixed Earth-centred frame, with local velocity components expressed on the rotating basis. [quantity/s]
f(t,y)\mathbf f(t,\mathbf y)
The right-hand-side function returning all five state rates from the same trial time and state. [mixed, by entry]
ar, ata_r,\ a_t
Physical acceleration vector components along the current local radial and tangential directions, obtained from net force per unit mass. [m/s²]
v˙r, v˙t\dot v_r,\ \dot v_t
Rates of the stored velocity components; changing local directions makes these differ from physical acceleration components. [m/s²]
μ, g(r)\mu,\ g(r)
Earth's gravitational parameter and local inward gravity magnitude g(r)=μ/r2g(r)=\mu/r^2. [m³/s²; m/s²]
Isp, g0, ceffI_{sp},\ g_0,\ c_{eff}
Specific impulse (330 s), fixed reference gravity (9.80665 m/s²), and effective exhaust speed, their product. [s; m/s²; m/s]
ℓ=rvt\ell=r v_t
Signed specific angular momentum about Earth's centre; specific means per unit mass. [m²/s]

A dot means “change per second.” Insert the thrust and drag just computed into the following equations. Together they form y˙=f(t,y)\dot{\mathbf y}=f(t,\mathbf y), the function evaluated by the solver.

r˙=vr,θ˙=vtr\dot r=v_r,\qquad\dot\theta=\frac{v_t}{r}

Velocity advances the position and the angle around Earth.

v˙r=vt2r−μr2+Fr+Drm\dot v_r=\frac{v_t^2}{r}-\frac{\mu}{r^2}+\frac{F_r+D_r}{m}

The rate of the outward velocity component combines the coordinate term, gravity and radial force per unit mass. Earth’s gravitational parameter is μ=3.986004418×1014\mu=3.986004418\times10^{14} m³/s².

v˙t=−vrvtr+Ft+Dtm\dot v_t=-\frac{v_rv_t}{r}+\frac{F_t+D_t}{m}

This is the rate of the tangential velocity component, not just tangential force divided by mass. The term −vrvt/r-v_rv_t/raccounts for the turning local basis. The derivation below shows exactly where its sign and factors come from.

m˙=−Fr2+Ft2Ispg0\dot m=-\frac{\sqrt{F_r^2+F_t^2}}{I_{sp}g_0}

Isp=330 sI_{sp}=330\ \mathrm s is the assumed specific impulse; g0=9.80665 m/s2g_0=9.80665\ \mathrm{m/s^2} is standard gravity. Their product is effective exhaust speed. The thrust magnitude equals TT during ascent and zero during coasts. Thrust already accounts for exhaust momentum.

Carry forward: five derivatives, all evaluated from the same state. No state component has been advanced yet.

The outward radial and downrange tangential unit vectors rotate as theta increases. Radial velocity changes distance from Earth’s centre; tangential velocity moves along the local tangent.

Scroll the diagram sideways to inspect both ends.

Coordinate sketch, not a computed trajectory. X and Y are fixed axes through Earth’s centre. The local basis turns with position even when the inertial velocity is unchanged.

Why these terms appear

derdθ=et,detdθ=−ere˙r=θ˙et,e˙t=−θ˙er\begin{aligned}\frac{d\mathbf e_r}{d\theta}&=\mathbf e_t,&\frac{d\mathbf e_t}{d\theta}&=-\mathbf e_r\\\dot{\mathbf e}_r&=\dot\theta\mathbf e_t,&\dot{\mathbf e}_t&=-\dot\theta\mathbf e_r\end{aligned}

Differentiate the sine/cosine unit vectors from step 1. Increasing θ\theta turns the outward direction toward the tangent and turns the tangent toward the inward direction. The chain rule contributes θ˙=vt/r\dot\theta=v_t/r.

a=dvdt=v˙rer+vre˙r+v˙tet+vte˙t=(v˙r−vtθ˙)er+(v˙t+vrθ˙)et\begin{aligned}\mathbf a=\frac{d\mathbf v}{dt}&=\dot v_r\mathbf e_r+v_r\dot{\mathbf e}_r\\&\quad+\dot v_t\mathbf e_t+v_t\dot{\mathbf e}_t\\&=(\dot v_r-v_t\dot\theta)\mathbf e_r\\&\quad+(\dot v_t+v_r\dot\theta)\mathbf e_t\end{aligned}

Apply the product rule to both velocity components and their directions. The cross terms appear because the directions themselves change; differentiating the two stored numbers alone would miss them.

ar=v˙r−vt2r=−μr2+Fr+Drmat=v˙t+vrvtr=Ft+Dtm\begin{aligned}a_r&=\dot v_r-\frac{v_t^2}{r}\\&=-\frac{\mu}{r^2}+\frac{F_r+D_r}{m}\\a_t&=\dot v_t+\frac{v_rv_t}{r}\\&=\frac{F_t+D_t}{m}\end{aligned}

Newton's law applies to physical acceleration. Gravity points entirely inward, so it contributes to ara_r only. Solve these identities for the stored component rates to obtain the two velocity equations in the ODE.

v˙t=at−vrvtrat=Ft+Dtm\begin{gathered}\boxed{\dot v_t=a_t-\frac{v_rv_t}{r}}\\a_t=\frac{F_t+D_t}{m}\end{gathered}

Tangential acceleration is not simply vrvtv_rv_t. The force-driven tangential acceleration is at=(Ft+Dt)/ma_t=(F_t+D_t)/m; the extra negative term belongs to the derivative of the tangential velocity component. It includes division by radius and is required even on a nonrotating Earth. It is not an extra aerodynamic or engine force.

dℓdt=d(rvt)dt=vrvt+rv˙t=rat\begin{aligned}\frac{d\ell}{dt}=\frac{d(rv_t)}{dt}&=v_rv_t+r\dot v_t\\&=r a_t\end{aligned}

An independent check: with no tangential force (for example gravity-only flight with drag omitted), at=0a_t=0 and rvtrv_t stays constant. As an outward-moving vehicle's radius grows, its positive tangential component must fall. During inward motion it grows. Omitting the cross term would violate this angular-momentum identity.

r=6.5×106 mvr=1000 m/s,vt=2000 m/sat=3 m/s2vrvtr=0.307692 m/s2v˙t=3−0.307692=2.692308 m/s2\begin{aligned}r&=6.5\times10^6\ \mathrm m\\v_r&=1000\ \mathrm{m/s},\quad v_t=2000\ \mathrm{m/s}\\a_t&=3\ \mathrm{m/s^2}\\\frac{v_rv_t}{r}&=0.307692\ \mathrm{m/s^2}\\\dot v_t&=3-0.307692\\&=2.692308\ \mathrm{m/s^2}\end{aligned}

This is a chosen local-state example, not a sampled flight result. The units are (m/s)(m/s)/m=m/s2(\mathrm{m/s})(\mathrm{m/s})/\mathrm m=\mathrm{m/s^2}. With vr=−1000v_r=-1000 m/s at the same radius, tangential speed and force, the correction changes sign and v˙t=3.307692\dot v_t=3.307692 m/s². With either velocity component zero, this correction is zero.

ceff=Ispg0=3236.1945 m/sm˙=−Tceff\begin{aligned}c_{eff}&=I_{sp}g_0=3236.1945\ \mathrm{m/s}\\\dot m&=-\frac{T}{c_{eff}}\end{aligned}

Specific impulse is thrust divided by propellant weight flow, so multiplying it by the fixed reference gravity gives effective exhaust speed. Dividing thrust by that speed gives a positive propellant consumption rate; the vehicle mass derivative is its negative. Use g0g_0 here, not altitude-dependent μ/r2\mu/r^2.

Evaluation order follows dependencies, not a sequence of physical changes: read one complete trial state (r,θ,vr,vt,m)(r,\theta,v_r,v_t,m) and its time; derive altitude and speed; evaluate density, thrust direction and drag; divide the net forces by that same mass; then form all five rates. The ascent pitch schedule depends only on time, so its evaluation can occur before or after the atmosphere calculation. None of these calculations overwrites the trial state.

The radial term has the same geometric origin: v˙r=ar+vt2/r\dot v_r=a_r+v_t^2/r. In a circular gravity-only trajectory, vr=0v_r=0 and vt2/r=μ/r2v_t^2/r=\mu/r^2, so v˙r=0\dot v_r=0 even while physical acceleration is inward. Zero radial velocity derivative does not mean zero acceleration vector.

Python's rhs names radial and tangential for the thrust-plus-drag acceleration components before adding gravity and the geometric terms. The returned entries [2] and [3] are the complete radial and tangential velocity derivatives. Tiny nonzero horizontal values at a nominal 90° pitch can arise from floating-point cosine evaluation.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

The five simultaneous state derivativespython/neutron/model.py:111 · Python
def rhs(time, state, guidance, area_m2, cd, isp_s):
    """Polar Newton equations; state = [r, theta, v_r, v_t, mass].

    Pointing is ideal: guidance commands force directly, not attitude/torque.
    The changing mass appears in F/m and dm/dt; thrust includes exhaust momentum.
    """
    radius, _, vr, vt, mass = state
    force = guidance(time, state)
    radial, tangential = (force + drag_force(state, area_m2, cd)) / mass
    return [vr, vt / radius,
            vt * vt / radius - MU / radius**2 + radial,
            -vr * vt / radius + tangential,
            -np.linalg.norm(force) / (isp_s * G0)]

Advance the state, then repeat

Symbols in this section

n, i, j, qn,\ i,\ j,\ q
Accepted-step index; trial-stage index; earlier-stage index; state-component index from 1 to 5. None is time itself. [dimensionless indices]
tn, Δt, ynt_n,\ \Delta t,\ \mathbf y_n
Time of the current accepted state, proposed time increment, and current accepted five-state vector. [s; s; state units]
ki\mathbf k_i
Five-rate vector evaluated at trial stage i; its components have each state's units divided by seconds. [state units/s]
aij, ci, bi, b^ia_{ij},\ c_i,\ b_i,\ \widehat b_i
Fixed method coefficients: trial-state weights, time fractions, fifth-order weights and embedded fourth-order weights. [dimensionless]
yn+1(5), e\mathbf y_{n+1}^{(5)},\ \mathbf e
Proposed fifth-order state and difference between the embedded fifth- and fourth-order estimates; (5) means method order, not exponent. [state units]
atolq, rtol, sq\mathrm{atol}_q,\ \mathrm{rtol},\ s_q
Component absolute tolerance, dimensionless relative tolerance (10⁻⁷), and combined error scale for that component. [state units; dimensionless; state units]
eq, Ee_q,\ E
One component of the estimated local error, and the root-mean-square of all five scaled errors. [state units; dimensionless]
τ, y~i\tau,\ \widetilde{\mathbf y}_i
Dummy time variable inside an integral and the complete trial state passed to rate evaluation i. [s; state units]
y[i], y(5)\mathbf y^{[i]},\ \mathbf y^{(5)}
Equivalent web shorthand: y[i]=y~i\mathbf y^{[i]}=\widetilde{\mathbf y}_i is a trial state; y(5)=yn+1(5)\mathbf y^{(5)}=\mathbf y_{n+1}^{(5)} is the fifth-order candidate. [state units]

The solver uses Dormand–Prince RK45. Within a trial step Δt\Delta t (s), it recalculates forces and derivatives at intermediate times and states, producing slopes ki\mathbf k_i.

One trial state in. One complete derivative vector out.
  1. Read the trial snapshott,  (r,θ,vr,vt,m)t,\;(r,\theta,v_r,v_t,m) all belong to the same solver trial.
  2. Calculate dependenciesCurrent radius and velocity give altitude, density and speed. Current time, state and phase determine the thrust command.
  3. Calculate forces, then ratesResolve thrust and drag. Divide forces by the current mass, add gravity and coordinate terms, and calculate mass flow. Collect f(t,y)f(t,\mathbf y) without overwriting the state.
  4. Build the next trial togetherRK45 combines earlier slopes to construct all five components of its next trial state. Repeat the force calculation on that whole state.
  5. Accept, reject or stop at an eventAccept the complete state if the error test passes; otherwise retry from the previous accepted state. A terminal event ends the phase at its root, then the new phase uses that event state.

There is no physical “radius first, velocity second” update. Forces set acceleration; velocity sets position rate. These coupled rates evolve over the same time interval.

ki=f ⁣(tn+ciΔt,  y[i])y[i]=yn+Δt∑j<iaijkj\begin{aligned}\mathbf k_i&=f\!\left(t_n+c_i\Delta t,\;\mathbf y^{[i]}\right)\\\mathbf y^{[i]}&=\mathbf y_n+\Delta t\sum_{j<i}a_{ij}\mathbf k_j\end{aligned}

nn counts accepted steps; ii counts solver stages and jj earlier stages. ci,aijc_i,a_{ij} are fixed RK45 coefficients; y[i]\mathbf y^{[i]} is a trial state.

y(5)=yn+Δt∑i=17biki\mathbf y^{(5)}=\mathbf y_n+\Delta t\sum_{i=1}^{7}b_i\mathbf k_i

bib_i are the fifth-order weights. A second set of weights gives a fourth-order estimate. If their scaled difference is small enough, accept yn+1=y(5)\mathbf y_{n+1}=\mathbf y^{(5)} and tn+1=tn+Δtt_{n+1}=t_n+\Delta t. Otherwise shrink the step and retry from yn\mathbf y_n.

RK45 coefficients and error control

The seven rows give the trial time fractions cic_i and the weights aija_{ij} on earlier slopes. Blank entries are zero. The seventh row evaluates the derivative at the fifth-order candidate.

Dormand–Prince 5(4) coefficients
cᵢaᵢ₁aᵢ₂aᵢ₃aᵢ₄aᵢ₅aᵢ₆
0—
1/51/5
3/103/409/40
4/544/45−56/1532/9
8/919372/6561−25360/218764448/6561−212/729
19017/3168−355/3346732/524749/176−5103/18656
135/3840500/1113125/192−2187/678411/84

The fifth-order weights bib_i are the last row’s six slope weights followed by zero. The fourth-order weights, in order, are 5179/57600, 0, 7571/16695, 393/640, −92097/339200, 187/2100 and 1/40.

ej=yj(5)−yj(4)e_j=y_j^{(5)}-y_j^{(4)}
sj=atol⁡j+10−7max⁡(∣yn,j∣,∣yj(5)∣)s_j=\operatorname{atol}_j+10^{-7}\max(|y_{n,j}|,|y_j^{(5)}|)
E=15∑j=15(ejsj)2<1E=\sqrt{\frac{1}{5}\sum_{j=1}^{5}\left(\frac{e_j}{s_j}\right)^2}<1

Here jj selects one of the five state components. The absolute tolerances atol⁡j\operatorname{atol}_j, in state order, are 0.001 m, 10⁻¹¹ rad, 10⁻⁵ m/s, 10⁻⁵ m/s and 0.0001 kg. SciPy adjusts the step using the error to the power −1/5, with safety and growth bounds; the maximum step is 2 s.

SciPy RK45 method reference. The model uses SciPy 1.17.0.

Repeat this force-to-state calculation while monitoring the cutoff condition below.

Why these terms appear

y(tn+Δt)=yn+∫tntn+Δtf(τ,y(τ)) dτ\begin{aligned}\mathbf y(t_n+\Delta t)&=\mathbf y_n\\&\quad+\int_{t_n}^{t_n+\Delta t}\mathbf f(\tau,\mathbf y(\tau))\,d\tau\end{aligned}

Start from the definition of a derivative: each state change is the integral of its rate over the same time interval. All five rates depend on the evolving state, so the equations are coupled. Position, velocity and mass evolve together; there is no physical rule that one must finish updating before another starts.

y~i=yn+Δt∑j<iaijkjki=f(tn+ciΔt,y~i)\begin{aligned}\widetilde{\mathbf y}_i&=\mathbf y_n+\Delta t\sum_{j<i}a_{ij}\mathbf k_j\\\mathbf k_i&=\mathbf f(t_n+c_i\Delta t,\widetilde{\mathbf y}_i)\end{aligned}

The actual RK45 ordering is between trial stages: build all five entries of a trial state from already available slopes, then read that complete trial state to compute all five new rates. Within a rate evaluation, compute the needed derived quantities and forces before the rates. Earlier-stage slopes are known inputs; intermediate trial states are not accepted flight states.

yn+1=yn+Δt f(tn,yn)rn+1=rn+Δt vr,nθn+1=θn+Δt vt,n/rnvr,n+1=vr,n+Δt v˙r,nvt,n+1=vt,n+Δt v˙t,nmn+1=mn−Δt Tn/(Ispg0)\begin{aligned}\mathbf y_{n+1}&=\mathbf y_n+\Delta t\,\mathbf f(t_n,\mathbf y_n)\\r_{n+1}&=r_n+\Delta t\,v_{r,n}\\\theta_{n+1}&=\theta_n+\Delta t\,v_{t,n}/r_n\\v_{r,n+1}&=v_{r,n}+\Delta t\,\dot v_{r,n}\\v_{t,n+1}&=v_{t,n}+\Delta t\,\dot v_{t,n}\\m_{n+1}&=m_n-\Delta t\,T_n/(I_{sp}g_0)\end{aligned}

A teaching-only forward-Euler example makes the dependency rule visible: every right-hand side has the old index nn. The saved rates v˙r,n\dot v_{r,n} and v˙t,n\dot v_{t,n} are the complete expressions from step 4, evaluated at that same old state and time. Save all rates before writing any new component. Updating radius in place and then using that new radius with old velocities silently defines a different numerical method. The implemented RK45 uses multiple trial evaluations and weighted slopes, not these Euler updates.

r(Δt)−RE≈12v˙r,0(Δt)2>0vr(Δt)≈v˙r,0Δt\begin{aligned}r(\Delta t)-R_E&\approx\tfrac12\dot v_{r,0}(\Delta t)^2>0\\v_r(\Delta t)&\approx\dot v_{r,0}\Delta t\end{aligned}

Near launch, outward acceleration first creates velocity, and the time integral of that increasing velocity creates altitude. Euler using only the zero initial velocity would produce no altitude change on its first step. RK45 samples accelerated intermediate states, which is why its first accepted altitude is positive. This describes continuous causation, not permission to overwrite velocity before evaluating the other rates.

e=yn+1(5)−yn+1(4),[eqsq]=1\mathbf e=\mathbf y_{n+1}^{(5)}-\mathbf y_{n+1}^{(4)},\qquad\left[\frac{e_q}{s_q}\right]=1

The embedded methods reuse rate evaluations to estimate local error. Scaling each component before combining them prevents metres, radians, metres per second and kilograms from being compared directly. SciPy may use the opposite sign for the difference; the squared norm and acceptance decision are identical.

sr∣r≈RE≈10−3 m+10−7(6 378 137 m)≈0.639 m\begin{aligned}s_r\big|_{r\approx R_E}&\approx10^{-3}\ \mathrm m\\&\quad+10^{-7}(6\,378\,137\ \mathrm m)\\&\approx0.639\ \mathrm m\end{aligned}

The relative tolerance acts on the stored Earth-centred radius, not altitude. Thus a radius absolute tolerance of one millimetre does not imply one-millimetre altitude accuracy. This scale participates in an RMS acceptance test across all five states; it is not a guaranteed per-component or global error bound.

The coefficient table uses jj for earlier Runge–Kutta stages. The web error-control panel also uses jj to select a state component; that role is written qq in the download. In the error formulas, ej,sj,atolje_j,s_j,\mathrm{atol}_j therefore mean exactly eq,sq,atolqe_q,s_q,\mathrm{atol}_q; neither index denotes a physical variable.

The full loop is: accepted time and five-state vector → complete trial stage → derived altitude, speed and density plus commanded thrust → drag and net forces → all five rates → further trial stages → candidate state and error estimate → accept the whole vector or retry the whole step. Force evaluation uses a decreasing trial mass on later stages, so rising thrust acceleration is included without a separate mass-first update.

A rejected trial does not become a new flight state. RK45 retries from the same accepted state with a smaller time increment. Several trial force evaluations can therefore occur before one state is accepted; a function-evaluation count is not a count of accepted steps.

The 2 s maximum step is a ceiling, and the 2 s output interval is a separate display choice. The solver selects its own first and later steps. Dense output uses a quartic interpolating polynomial inside accepted RK45 steps; it supplies both display samples and states used by the event root search.

A small numerical error estimate cannot validate the physical assumptions. Check numerical convergence by tightening tolerances and maximum step and comparing event times and separation states. The download's automated checks also compare against the canonical simulator, analytic burn time and separation momentum.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Configure SciPy's adaptive RK45 solverpython/neutron/model.py:133 · Python
def integrate(state, start_s, end_s, guidance, area_m2, cd, isp_s,
              events=(), max_step_s=2.0, rtol=1e-7):
    """Integrate one continuous phase; restart after every discrete change."""
    solution = solve_ivp(
        lambda t, y: rhs(t, y, guidance, area_m2, cd, isp_s),
        (start_s, end_s), state, method="RK45", dense_output=True,
        events=events, max_step=max_step_s, rtol=rtol,
        atol=[1e-3, 1e-11, 1e-5, 1e-5, 1e-4])
    if not solution.success:
        raise RuntimeError(solution.message)
    return solution

Stop burning at the fuel reserve

Symbols in this section

mb, mum_b,\ m_u
Booster dry mass plus retained recovery propellant, and the still-attached upper assembly mass. [kg]
mcut=mb+mum_{cut}=m_b+m_u
Total attached mass at which the ascent burn stops; retained propellant is still part of this mass. [kg]
gMECO(t,y), g(t,y)g_{MECO}(t,\mathbf y),\ g(t,\mathbf y)
Event residual: current mass minus cutoff mass; g is the web shorthand for this same gate, not gravitational acceleration. [kg]
tMECO, tct_{MECO},\ t_c
Elapsed time of main engine cutoff, located at the downward zero crossing of the mass residual; the two symbols name the same time. [s]

Main engine cutoff (MECO) occurs when the stack reaches the carried assembly’s mass plus stage 1 dry mass and its unburned reserve.

mcut=115 000+30 000+60 000=205 000 kg\begin{aligned}m_{\rm cut}&=115\,000+30\,000+60\,000\\&=205\,000\ \mathrm{kg}\end{aligned}

mcutm_{\rm cut} is the cutoff threshold. The 60,000 kg reserve is a chosen model input.

g(t,y)=m−mcut=0(↓)g(t,\mathbf y)=m-m_{\rm cut}=0\quad(\downarrow)

gg is the event function. SciPy locates its zero crossing within the accepted step and ends powered integration at that time, tct_c. The arrow ↓\downarrow means a crossing from positive to negative; ↑\uparrow means the reverse.

tc=m0−mcutT/(Ispg0)t_c=\frac{m_0-m_{\rm cut}}{T/(I_{sp}g_0)}

The integration also stops on downward ground contact, or at 600 s if cutoff is never reached. Those paths do not proceed to a successful separation.

Why these terms appear

m(t)=m0−TIspg0tm(tMECO)=mcut\begin{aligned}m(t)&=m_0-\frac{T}{I_{sp}g_0}t\\m(t_{MECO})&=m_{cut}\end{aligned}

Integrate the constant mass derivative during the powered phase and solve for the crossing time. The 275,000 kg burned is initial mass minus cutoff mass; the 60,000 kg reserve is retained, so MECO is not fuel exhaustion. This analytic time works because thrust and specific impulse are constant.

gMECO>0:keep burninggMECO=0:cutoffg˙MECO=m˙<0\begin{aligned}g_{MECO}>0:&\quad\text{keep burning}\\g_{MECO}=0:&\quad\text{cutoff}\\\dot g_{MECO}&=\dot m<0\end{aligned}

The terminal event requests the decreasing crossing. A sign change between accepted states brackets a root; the solver refines its time using the continuous interpolated solution and returns that root state. The event need not coincide with a 2 s output sample, and the model does not wait for the next display frame.

Changing only payload adds the same mass to launch and cutoff, so the analytic burn time is unchanged at fixed thrust and reserve, even though the acceleration and trajectory change. Increasing the reserve reduces the mass burned and shortens the burn.

The ground residual is altitude in metres and the MECO residual is mass in kilograms; an event function needs a zero crossing, not common units with other events. Ground impact and the 600 s phase limit are failure paths, checked separately before the coast is started.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Set terminal root and crossing directionpython/neutron/model.py:126 · Python
def terminal_event(function, direction=-1):
    """SciPy locates a zero crossing between accepted RK45 steps."""
    function.terminal = True
    function.direction = direction
    return function
Reserve-mass MECO gate and powered ascentpython/neutron/model.py:450 · Python
    coast = lambda t, y: np.zeros(2)
    reserve_mass = vehicle.booster_dry_kg + recovery_propellant_kg
    meco = terminal_event(lambda t, y: y[4] - upper_wet - reserve_mass)
    record("liftoff", 0, "stack", "Nine-engine ascent begins.")
    time, state, impacted, ascent_solution = phase("stack", "ascent", initial, 0, 600,
                                                  lambda t, y: ascent_guidance(t, y, vehicle), (meco,))
    ascent_complete = bool(ascent_solution.t_events[1].size) and not impacted

Coast for four seconds

Symbols in this section

tMECO=tc, tsep=tst_{MECO}=t_c,\ t_{sep}=t_s
Main engine cutoff time and stage-separation time, four seconds later in the reference sequence; each pair is equivalent notation. [s]
y(t−), y(t+)\mathbf y(t^-),\ \mathbf y(t^+)
State immediately before and immediately after a change of phase; minus and plus indicate one-sided limits, not negative and positive times. [state units]
fopen, clip⁡(z,0,1)f_{open},\ \operatorname{clip}(z,0,1)
Prescribed fairing-opening fraction, and a clamp that limits its argument z to the interval from zero to one. [dimensionless]
T=0,m˙=0,ts=tc+4 sT=0,\qquad\dot m=0,\qquad t_s=t_c+4\ \mathrm s

tst_s is separation time, written as tsept_{sep} in the branches below. Restart the same five equations from the cutoff state with zero thrust. Gravity and drag still change the velocities; mass remains 205,000 kg. Downward ground contact remains a terminal event.

The captive fairing opens during these four seconds. Its opening is a kinematic display model; it changes neither drag area nor mass in the equations.

Carry forward: the final attached-stack state, ready for an instantaneous mass split.

Why these terms appear

v˙r=vt2r−μr2+Drmv˙t=−vrvtr+Dtmm˙=0\begin{aligned}\dot v_r&=\frac{v_t^2}{r}-\frac{\mu}{r^2}+\frac{D_r}{m}\\\dot v_t&=-\frac{v_rv_t}{r}+\frac{D_t}{m}\\\dot m&=0\end{aligned}

Substitute zero thrust into the same equations. Coast means no engine thrust; it does not mean constant velocity or zero acceleration. Gravity, drag and the conversion between physical acceleration and rotating velocity components still apply.

fopen(tMECO)=0fopen(tMECO+2 s)=12fopen(tsep)=1\begin{aligned}f_{open}(t_{MECO})&=0\\f_{open}(t_{MECO}+2\ \mathrm s)&=\frac12\\f_{open}(t_{sep})&=1\end{aligned}

The four-second denominator gives a dimensionless fraction. It is prescribed mechanism progress, not an extra ODE state. The fairing stays attached, so opening does not remove mass.

At cutoff, position, velocity and mass stay continuous because no impulse or mass drop occurs. Their derivatives may jump when thrust turns off. The solver is restarted at this known change so a single integration phase does not straddle the thrust discontinuity.

At an event the ordering is explicit: locate and retain the common event time and state, select the next phase's thrust command, then evaluate fresh rates from that unchanged state under the new command. At separation in step 8, apply the mass partition and paired velocity impulse before any subsequent branch integration. Never reuse the old phase's force or rate vector after a mode change.

The animation is kinematic: it adds no actuator forces, torques, drag-area change, contact or structural dynamics. Separation is attempted only after the full four-second coast without ground contact.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Restart from MECO for the four-second coastpython/neutron/model.py:464 · Python
    if ascent_complete:
        record("meco", time, "stack", "Main engine cutoff preserves the requested recovery reserve.",
               recovery_propellant_kg=float(recovery_propellant_kg))
        record("fairing_opening", time, "stack", "Captive fairing opens over four seconds (kinematic mechanism).")
        time, state, impacted, _ = phase("stack", "fairing_opening", state, time, time + 4, coast)
Recorded kinematic fairing opening and closurepython/neutron/model.py:379 · Python
    def fairing_fraction(time, body):
        opening = next((e["time_s"] for e in events if e["name"] == "fairing_opening"), None)
        if opening is None or body not in ("stack", "booster"):
            return 0.0
        if separation_time is None or time <= separation_time:
            return float(np.clip((time - opening) / 4, 0, 1))
        return float(np.clip(1 - (time - separation_time) / 4, 0, 1))

Separate with momentum conserved

Symbols in this section

b, u, (⋅)−b,\ u,\ (\cdot)^-
Booster subscript, upper-assembly subscript, and the common attached state immediately before separation. [labels]
m−, mb, mum^-,\ m_b,\ m_u
Mass before separation and the two positive body masses after it; their sum is unchanged. [kg]
Δv=vr,u−vr,b\Delta v=v_{r,u}-v_{r,b}
Prescribed relative outward separation speed (0.5 m/s); this is shared between the bodies, not applied in full to each. [m/s]
JJ
Magnitude of the instantaneous internal radial impulse: positive outward on the upper assembly, equally negative on the booster. [N·s = kg·m/s]
ΔK\Delta K
Increase in the pair's translational kinetic energy due to the prescribed separation impulse. [J (joules)]

Split the final mass into the booster mbm_b and the released assembly mum_u. The fairing stays with stage 1.

mb=90 000 kg,mu=115 000 kgm_b=90\,000\ \mathrm{kg},\qquad m_u=115\,000\ \mathrm{kg}

The assumed separation speed Δv=0.5 m/s\Delta v=0.5\ \mathrm{m/s} is the relative outward velocity between the two bodies. Superscript −- means just before separation; ++ means just after.

vr,b+=vr−−Δvmumb+muvr,u+=vr−+Δvmbmb+mu\begin{aligned}v_{r,b}^{+}&=v_r^{-}-\Delta v\frac{m_u}{m_b+m_u}\\v_{r,u}^{+}&=v_r^{-}+\Delta v\frac{m_b}{m_b+m_u}\end{aligned}

Both bodies inherit the same r,θ,vtr,\theta,v_t. The impulses change only their radial velocities. There is no artificial position jump.

mbvr,b++muvr,u+=(mb+mu)vr−m_bv_{r,b}^{+}+m_uv_{r,u}^{+}=(m_b+m_u)v_r^{-}

Why these terms appear

mb(vr,b−vr−)=−Jmu(vr,u−vr−)=JΔv=J(1mu+1mb)\begin{aligned}m_b(v_{r,b}-v_r^-)&=-J\\m_u(v_{r,u}-v_r^-)&=J\\\Delta v&=J\left(\frac1{m_u}+\frac1{m_b}\right)\end{aligned}

Start with equal and opposite impulses. Divide each impulse by that body's mass to obtain its velocity change, then subtract the two velocities to impose the requested relative speed. Over this ideal instantaneous split, the accumulated impulse from continuous external forces is neglected.

J=Δvmbmumb+muΔvr,b=−0.280488 m/sΔvr,u=+0.219512 m/s\begin{aligned}J&=\Delta v\frac{m_bm_u}{m_b+m_u}\\\Delta v_{r,b}&=-0.280488\ \mathrm{m/s}\\\Delta v_{r,u}&=+0.219512\ \mathrm{m/s}\end{aligned}

For 90,000 kg of booster and 115,000 kg of upper assembly, the lighter booster receives the larger speed change. Their difference is 0.5 m/s and their mass-weighted sum is zero, giving the momentum-preserving update above. These are increments, not the final radial velocities.

ΔK=12mbmumb+mu(Δv)2≈6311 J\Delta K=\frac12\frac{m_bm_u}{m_b+m_u}(\Delta v)^2\approx6311\ \mathrm J

Momentum is conserved by the internal impulse, but translational kinetic energy increases. The prescribed impulse implicitly supplies separation energy; this model does not solve a spring, pneumatic system or contact mechanism. Energy conservation of the two bodies' translation alone is therefore not the intended separation check.

Both point masses initially occupy the common pre-separation position. The radial impulse changes their radial velocities immediately; position does not jump. No tangential impulse is applied, so both tangential velocities retain the common value.

There is no mass jettison at this split: the original attached mass is partitioned between two bodies. The captive fairing belongs to booster dry mass. The stage-one calculation ends with two initial separated states, each ready for its own subsequent propagation.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Split mass and apply the relative radial impulsepython/neutron/model.py:198 · Python
def separate(state, booster_mass_kg, relative_speed_m_s=0.5):
    """Split mass with equal/opposite impulses: conserve linear momentum.

    Both bodies start at the same position. Geometry and contact are not modeled.
    The reusable fairing stays within booster dry mass.
    """
    booster, upper = state.copy(), state.copy()
    upper[4] = state[4] - booster_mass_kg
    booster[4] = booster_mass_kg
    booster[2] -= relative_speed_m_s * upper[4] / state[4]
    upper[2] += relative_speed_m_s * booster[4] / state[4]
    return booster, upper

The state we just propagated

Computed by the Python model. Display altitude is r−REr-R_E and ground distance is REθR_E\theta.

Scroll the table sideways for altitude, distance and mass.

StateTime
(s)
Altitude
(km)
Downrange
(km)
Mass
(kg)
Launch0.0000.0000.000480,000
Engine cutoff137.97956.58840.314205,000
Before separation141.97960.58144.612205,000

One separation. Two trajectories.

Separation creates two independent initial-value problems at the same mission time. Each branch now carries its own five-number state; mm is the mass of that body. Throughout these sections, h=r−REh=r-R_E means altitude and ℓ=rvt\ell=rv_t means specific angular momentum; they are different quantities. Both reuse the atmosphere, drag, ODE and adaptive RK45 loop already established above. Within each solver evaluation, every command and derivative comes from the same trial state: guidance reads it, force limits bound the command, and the ODE returns all five derivatives together. RK45 then constructs its next trial or accepted state. These equations describe dependencies, not sequential in-place updates of radius followed by velocity followed by mass. Phase-specific force commands, reference areas, propellant parameters and event gates use that same ODE. Read either branch forward from separation; the booster lands while the upper stage is still flying.

Upper-stage branch

Upper stage → payload orbit

Carry the upper state forward through clearance, powered insertion, apogee coast, circularization, deployment and one real unpowered orbit.

Coast clear of the booster

Symbols in this section

t, tsep, tignt,\ t_{sep},\ t_{ign}
Mission elapsed time, stage-separation time, and upper-engine ignition time; all share the same clock. [s]
Fr, FtF_r,\ F_t
Engine-force components: positive radially outward and in the direction of increasing angle, respectively. These exclude gravity and drag. [N]
m, m˙m,\ \dot m
Mass of the upper stage plus attached payload, and its time derivative. A dot always means differentiation with respect to mission time. [kg; kg/s]

Start with the upper assembly state from separation. Here tsept_{sep} is the shared stage-separation time. Its clock is still mission elapsed time; it does not restart at zero. For three seconds, feed zero engine force into the same five-state ODE.

tign=tsep+3 st_{ign}=t_{sep}+3\ \mathrm s

The upper engine ignition time is three seconds after the shared separation time.

Fr=Ft=0,m˙=0F_r=F_t=0,\qquad\dot m=0

Gravity and drag keep changing velocity; mass stays constant during clearance.

Use the integrated clearance state as the burn initial condition. The current implementation keeps the 38.485 m² reference area during this coast, then changes it to 12 m² from ignition onward. This timed coast assumes clearance; body contact and plume interaction are not modeled.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Three-second clearance and upper-stage fuel floorpython/neutron/model.py:480 · Python
        upper_time, upper, upper_hit, _ = phase("upper_stage", "separation_coast", upper, time, time + 3, coast)
        fuel_floor = vehicle.upper_dry_kg + payload_kg
        empty = terminal_event(lambda t, y: y[4] - fuel_floor)

Command the upper-stage engine force

Symbols in this section

RE, rR_E,\ r
Fixed spherical Earth radius (6,378,137 m), and vehicle distance from Earth's centre. [m]
hc, htargeth_c,\ h_{target}
Commanded altitude in this burn, and requested final orbital altitude; altitude is measured above the spherical surface. [m]
vr, vtv_r,\ v_t
Radial speed (positive outward) and tangential speed (positive with increasing angle). In particular, v_t is a linear speed, not an angular rate. [m/s]
μ\mu
Earth gravitational parameter, 3.986004418 × 10¹⁴; local gravitational acceleration magnitude is μ/r². [m³/s²]
mm
Current upper-stage plus attached-payload mass, including remaining propellant. [kg]
τ\tau
Chosen radial feedback response time, 30 s; smaller values request faster correction. [s]
ar∗a_r^*
Requested derivative of radial speed, dot v_r, before command limits; the star denotes a request, not a measured or guaranteed acceleration. [m/s²]
Tu, qT_u,\ q
Upper-engine thrust magnitude (890,000 N), and its signed radial fraction F_r/TuT_u. The clipped fraction lies between −0.5 and 0.95. [N; dimensionless]
Fr, FtF_r,\ F_t
Commanded engine forces in the outward radial and positive tangential directions. [N]
Isp,u, g0I_{sp,u},\ g_0
Assumed upper-engine specific impulse (365 s), and fixed reference gravity (9.80665 m/s²). g_0 converts specific impulse to effective exhaust speed; it is not local gravity. [s; m/s²]
m˙\dot m
Rate of change of this body's mass; negative while propellant is consumed. [kg/s]
ehe_h
Altitude error r − (R_E + hch_c), used only in the ideal feedback derivation below; distinct from orbital eccentricity e. [m]

The target altitude htargeth_{target} is 400 km. First command a 220 km reference altitude while building horizontal speed. Let hch_c be the commanded altitude and τ\tau = 30 s the response time. The requested radial-speed derivative is ar∗a_r^*. A star marks a requested value. The vacuum-engine thrust is TuT_u = 890,000 N, with assumed specific impulse Isp,uI_{sp,u} = 365 s.

ar∗=RE+hc−rτ2−2vrτa_r^*=\frac{R_E+h_c-r}{\tau^2}-\frac{2v_r}{\tau}

Altitude error requests acceleration; radial velocity adds damping. Initially hch_c = min(htargeth_{target}, 220 km).

q=clip⁡ ⁣[mTu(ar∗+μr2−vt2r),−0.5,0.95]\begin{aligned}q=\operatorname{clip}\!\Big[&\frac{m}{T_u}\left(a_r^*+\frac{\mu}{r^2}-\frac{v_t^2}{r}\right),\\&-0.5,0.95\Big]\end{aligned}

The radial thrust fraction qq compensates gravity and the polar-basis term. The function clip limits its first argument to the stated lower and upper bounds. This command does not compensate drag.

Fr=Tuq,Ft=Tu1−q2F_r=T_uq,\qquad F_t=T_u\sqrt{1-q^2}

The two force components give constant total thrust and positive tangential thrust.

m˙=−TuIsp,ug0\dot m=-\frac{T_u}{I_{sp,u}g_0}

Use these force components and propellant flow in the established ODE; retain drag with reference area 12 m².

At each RK45 trial state, read radius, velocities and mass; compute altitude and radial-speed errors; form ar∗a_r^*; then compute and clip the radial force fraction before constructing both force components. Only after that does the shared ODE combine engine force with drag and gravity to calculate all five state derivatives. The integrator, not the guidance law, updates the state. Constant thrust does not mean constant acceleration: mass and gravity change. Clipping and uncompensated drag can prevent the requested radial acceleration from being achieved. Here qq is a scalar thrust fraction, distinct from dynamic pressure or a quaternion.

Why these terms appear

eh=r−(RE+hc)e¨h+2τe˙h+1τ2eh=0\begin{aligned}e_h&=r-(R_E+h_c)\\\ddot e_h+\frac{2}{\tau}\dot e_h+\frac{1}{\tau^2}e_h&=0\end{aligned}

For a constant altitude command, ignored drag and an unsaturated engine command, substitution into the ODE gives this critically damped error equation. It explains the feedback gains: altitude error is corrected without oscillation in this ideal limit. Clipping, finite thrust and drag break that ideal equation.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Requested radial acceleration to fixed-magnitude thrustpython/neutron/model.py:153 · Python
def upper_guidance(time, state, vehicle, target_m):
    """PD radial acceleration request plus spherical-gravity compensation."""
    radius, _, vr, vt, mass = state
    tau = 30.0
    requested = (EARTH_RADIUS + target_m - radius) / tau**2 - 2 * vr / tau
    radial_fraction = (requested + MU / radius**2 - vt * vt / radius) * mass / vehicle.upper_thrust_n
    beta = math.asin(np.clip(radial_fraction, -0.5, 0.95))
    return vehicle.upper_thrust_n * np.array([math.sin(beta), math.cos(beta)])

Burn until the trajectory reaches the target apogee

Symbols in this section

r, REr,\ R_E
Current distance from Earth's centre and fixed spherical surface radius. [m]
vr, vtv_r,\ v_t
Signed outward radial speed and signed tangential speed. [m/s]
μ\mu
Earth gravitational parameter, 3.986004418 × 10¹⁴. [m³/s²]
ε\varepsilon
Specific orbital mechanical energy: kinetic plus gravitational potential energy per unit vehicle mass, with zero potential at infinity. [J/kg = m²/s²]
ℓ\ell
Signed specific angular momentum normal to the simulation plane; positive for increasing angle. It is angular momentum per unit mass. [m²/s]
e, pe,\ p
Nonnegative orbital eccentricity, and semi-latus rectum (the orbit's radius 90° from perigee in the two-body ellipse). These are not vehicle mass or thrust parameters. [dimensionless; m]
ha, htargeth_a,\ h_{target}
Apogee altitude of the instantaneous two-body orbit, and requested final orbit altitude. Neither is necessarily the current altitude. [m]
↑\uparrow
An event function crossing zero from negative to positive. The arrow describes the gate value, not necessarily the vehicle's vertical motion. [event direction]

From the current position and velocity, infer the instantaneous unpowered two-body orbit. Its specific energy ε\varepsilon and specific angular momentum ℓ\ell determine whether that instantaneous orbit is bound. The dimensionless eccentricity ee and semi-latus rectum pp (m) locate its low and high points.

ε=vr2+vt22−μr,ℓ=rvt\varepsilon=\frac{v_r^2+v_t^2}{2}-\frac{\mu}{r},\qquad\ell=rv_t

Energy is in J/kg; angular momentum is in m²/s. These describe the current state, not a prescribed flight path.

e2=(rvt2μ−1)2+(rvrvtμ)2\begin{aligned}e^2={}&\left(\frac{rv_t^2}{\mu}-1\right)^2\\&+\left(\frac{rv_rv_t}{\mu}\right)^2\end{aligned}

Take the nonnegative square root for eccentricity ee. This formula avoids cancellation near a circular orbit.

p=ℓ2μ,ha=p1−e−REp=\frac{\ell^2}{\mu},\qquad h_a=\frac{p}{1-e}-R_E

For ε<0\varepsilon < 0 and e<1e < 1, hah_a is the osculating apogee altitude. An unbound state has no finite apogee here.

ha−htarget=0(↑)h_a-h_{target}=0\quad(\uparrow)

Locate the upward crossing of the target apogee, here htargeth_{target} = 400 km; then cut thrust.

The event finder locates the cutoff on the integrated solution. Accept that state, stop the powered phase, and pass the same time, position, velocity and mass into a new coast with zero thrust. Do not move the vehicle to apogee. A downward fuel-floor crossing m − (5,000 kg + payload mass) = 0, ground contact, or the 1,200 s burn limit can end the burn before the target gate.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Osculating energy, angular momentum and apsidespython/neutron/model.py:51 · Python
def orbital_elements(state):
    """Osculating two-body apsides; a bound ellipse alone is not a safe orbit."""
    radius, _, vr, vt, _ = map(float, state)
    energy = (vr * vr + vt * vt) / 2 - MU / radius
    momentum = radius * vt
    # Polar eccentricity-vector components avoid cancellation near a circle.
    eccentricity = math.hypot(radius * vt * vt / MU - 1, radius * vr * vt / MU)
    p = momentum**2 / MU
    return {"perigee_km": (p / (1 + eccentricity) - EARTH_RADIUS) / 1000,
            "apogee_km": ((p / (1 - eccentricity) - EARTH_RADIUS) / 1000
                          if energy < 0 and eccentricity < 1 else None),
            "eccentricity": eccentricity, "specific_energy_j_kg": energy}
Select transfer target, root gate and powered integrationpython/neutron/model.py:489 · Python
            transfer = target_altitude_km > TRANSFER_ALTITUDE_KM
            def transfer_apogee(t, y):
                apogee = orbital_elements(y)["apogee_km"]
                return (apogee if apogee is not None else -1e6) - target_altitude_km
            transfer_gate = terminal_event(transfer_apogee, 1)
            upper_time, upper, upper_hit, solution = phase(
                "upper_stage", "transfer_insertion" if transfer else "orbit_insertion", upper, upper_time, upper_time + 1200,
                lambda t, y: upper_guidance(t, y, vehicle, min(target_altitude_km, TRANSFER_ALTITUDE_KM) * 1000),
                (empty, transfer_gate if transfer else target), 12.0)

Coast to the real high point

Symbols in this section

Fr, FtF_r,\ F_t
Outward radial and positive tangential engine forces; both vanish during coast. [N]
m, m˙m,\ \dot m
Upper stage plus attached-payload mass, and its derivative. [kg; kg/s]
vrv_r
Outward radial speed, equal to the derivative of altitude. At the high point it changes from climbing to descending. [m/s]
↓\downarrow
A positive-to-negative zero crossing of the event function; here it distinguishes apogee from a low point. [event direction]

With the engine off, propagate the cutoff state under the same gravity and drag equations. The vehicle is still climbing until its radial velocity changes sign.

Fr=Ft=0,m˙=0F_r=F_t=0,\qquad\dot m=0

The upper stage coasts with reference area 12 m² and retains its cutoff mass.

vr=0(↓)v_r=0\quad(\downarrow)

Find the downward zero crossing of radial velocity: this is the propagated apogee event.

Accept the event's integrated time and state before switching guidance. At this event, restart the upper engine from the integrated apogee state. The restart changes force and mass-flow rate, not position, velocity or mass instantaneously. Restart is assumed instantaneous and perfect. An impact or the 6,000 s coast limit prevents this transition.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Coast until radial velocity crosses zeropython/neutron/model.py:501 · Python
                apex = terminal_event(lambda t, y: y[2])
                upper_time, upper, upper_hit, solution = phase("upper_stage", "apogee_coast", upper,
                    upper_time, upper_time + 6000, coast, (apex,), 12.0, max(sample_step_s, 10.0))

Make a finite circularization burn

Symbols in this section

hc, htargeth_c,\ h_{target}
Commanded altitude for upper guidance and final target altitude, both measured above the spherical surface. [m]
r, μr,\ \mu
Current geocentric radius and Earth gravitational parameter. [m; m³/s²]
vc, vtv_c,\ v_t
Local two-body circular speed and actual signed tangential speed. Their equality alone does not guarantee a circular orbit. [m/s]
vr, v˙rv_r,\ \dot v_r
Outward radial speed and its time derivative, used in the circular-balance derivation. [m/s; m/s²]
Fr, DrF_r,\ D_r
Outward radial engine force and signed radial drag force. Both are zero in the ideal unpowered circular-balance derivation. [N]
↑\uparrow
The speed-error gate v_t − v_c crosses from negative to positive; v_c itself changes as radius changes. [event direction]

Now set the altitude command hch_c to the final 400 km target and reuse the upper guidance. The burn takes finite time, consumes propellant, and continuously changes the state. Circular speed vcv_c depends on the current radius.

hc=htarget,vc=μrh_c=h_{target},\qquad v_c=\sqrt{\frac{\mu}{r}}

Use the new altitude command in the same radial request and force calculation.

vt−vc=0(↑)v_t-v_c=0\quad(\uparrow)

Cut thrust at the upward crossing of circular speed, unless fuel depletion or ground contact occurs first.

The speed crossing only ends the burn; the next checks decide whether insertion succeeded. For targets at or below 220 km, the first upper burn uses this speed gate directly and skips the transfer coast and restart. The circularization burn has the same 13,000 kg reference fuel floor and a 600 s time limit.

Why these terms appear

vr=v˙r=0,Fr=Dr=0⟹ vt2r=μr2\begin{aligned}v_r&=\dot v_r=0,\quad F_r=D_r=0\\&\Longrightarrow\ \frac{v_t^2}{r}=\frac{\mu}{r^2}\end{aligned}

The circular-speed formula comes from unpowered radial balance in the two-body limit. During a finite burn those assumptions do not all hold; this is why crossing circular speed is followed by independent orbit checks.

Associated code · 3 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Circular-speed upward-crossing gatepython/neutron/model.py:485 · Python
        target = terminal_event(lambda t, y: y[3] - math.sqrt(MU / y[0]), 1)
Restart the engine at the target-altitude commandpython/neutron/model.py:508 · Python
                    upper_time, upper, upper_hit, solution = phase("upper_stage", "circularization_burn", upper,
                        upper_time, upper_time + 600,
                        lambda t, y: upper_guidance(t, y, vehicle, target_altitude_km * 1000), (empty, target), 12.0)
The same finite-thrust upper-stage feedbackpython/neutron/model.py:153 · Python
def upper_guidance(time, state, vehicle, target_m):
    """PD radial acceleration request plus spherical-gravity compensation."""
    radius, _, vr, vt, mass = state
    tau = 30.0
    requested = (EARTH_RADIUS + target_m - radius) / tau**2 - 2 * vr / tau
    radial_fraction = (requested + MU / radius**2 - vt * vt / radius) * mass / vehicle.upper_thrust_n
    beta = math.asin(np.clip(radial_fraction, -0.5, 0.95))
    return vehicle.upper_thrust_n * np.array([math.sin(beta), math.cos(beta)])

Test the achieved orbit before releasing anything

Symbols in this section

p, REp,\ R_E
Semi-latus rectum from ℓ²/μ and the fixed spherical Earth radius. [m]
ee
Nonnegative eccentricity: zero is circular; a bound nondegenerate ellipse has e < 1. [dimensionless]
ε\varepsilon
Specific orbital mechanical energy; negative is required for a bound two-body orbit. [J/kg]
hp, ha, htargeth_p,\ h_a,\ h_{target}
Osculating perigee altitude, osculating apogee altitude, and final target altitude. The displayed 2 km tolerance is converted to 2,000 m when comparing SI quantities. [m]
vrv_r
Actual outward radial speed at cutoff; a circular orbit would have zero radial speed. [m/s]

Compute perigee altitude hph_p from the cutoff state, using the same orbital quantities as before. The model requires a bound orbit, both apsides near the target, low radial speed, and low eccentricity together.

hp=p1+e−REh_p=\frac{p}{1+e}-R_E

Perigee is the low point of the orbit implied by the current state.

ε<0,e≤0.001\varepsilon<0,\qquad e\leq0.001

The orbit must be bound and nearly circular.

∣hp−htarget∣≤2 km|h_p-h_{target}|\leq2\ \mathrm{km}

Perigee must be within the altitude tolerance.

∣ha−htarget∣≤2 km|h_a-h_{target}|\leq2\ \mathrm{km}

Apogee must independently be within the same tolerance.

∣vr∣≤5 m/s|v_r|\leq5\ \mathrm{m/s}

A circular-speed crossing at the wrong altitude or with large radial motion is a failure.

Success also requires reaching the intended speed gate without impact. Failed insertion is reported as fuel depletion, missed orbit, impact, or timeout; it is never repaired by changing the state. A surviving failed insertion coasts for up to 1,200 s without release.

An ellipse has Earth at one focus. Perigee and apogee radii run from Earth’s centre to the nearest and farthest points. Altitude subtracts Earth’s radius from either distance.

Scroll the diagram sideways to inspect both ends.

Geometry sketch, not the simulated orbit or to scale. The nearest and farthest radii are rₚ and rₐ (m); hₚ and hₐ are their altitudes (m). The semi-major axis a (m) is half the ellipse’s long diameter; e is dimensionless eccentricity. Earth is at a focus. Osculating apsides describe the two-body orbit implied by the current state; thrust and drag can change them.
Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

All orbit tolerances must pass togetherpython/neutron/model.py:65 · Python
def orbit_target_error(state, target_altitude_km):
    """Enter the target set only when BOTH apsides and radial speed agree.

    A proper target orbit is bound, within 2 km at each apsis, |vr| <= 5 m/s,
    and e <= 0.001. A negative result satisfies all four constraints.
    """
    orbit = orbital_elements(state)
    if orbit["apogee_km"] is None or orbit["specific_energy_j_kg"] >= 0:
        return 1e6
    return max(abs(orbit["perigee_km"] - target_altitude_km) / 2,
               abs(orbit["apogee_km"] - target_altitude_km) / 2,
               abs(state[2]) / 5, orbit["eccentricity"] / 0.001) - 1
Require the speed gate and no impact before declaring successpython/neutron/model.py:512 · Python
            elements = orbital_elements(upper)
            reached = bool(cutoff_triggered and not upper_hit
                           and orbit_target_error(upper, target_altitude_km) <= 1e-8)

Coast ten seconds, then release the payload

Symbols in this section

trelease, tcutofft_{release},\ t_{cutoff}
Payload-release and verified upper-stage cutoff times on the mission clock. [s]
m−, ms, mpm^-,\ m_s,\ m_p
Combined mass immediately before release, retained upper-stage mass immediately after release, and payload mass. Superscript − means before the event, not negative mass. [kg]
vr−, vr,s, vr,pv_r^-,\ v_{r,s},\ v_{r,p}
Common outward radial speed immediately before release, and retained-stage and payload radial speeds immediately after release. [m/s]
δv\delta v
Specified relative outward separation speed v_r,p − v_r,s = 0.2 m/s. It is the difference between the two final speeds, not the increment given to each body. [m/s]

After verified insertion at the upper-stage cutoff time tcutofft_{cutoff}, coast for ten seconds before release. Apply the same momentum-conserving separation rule, now with relative radial speed δv\delta v = 0.2 m/s. Let mpm_p be payload mass and msm_s the retained upper-stage mass; m−m^- is their combined mass just before release.

trelease=tcutoff+10 st_{release}=t_{cutoff}+10\ \mathrm s

The deployment coast uses zero thrust; impact during this coast blocks release.

ms=m−−mp,mp=8 000 kgm_s=m^- -m_p,\qquad m_p=8\,000\ \mathrm{kg}

Only the retained body loses the payload mass; the total mass is conserved.

vr,s=vr−−δvmpm−v_{r,s}=v_r^- -\delta v\frac{m_p}{m^-}

The retained upper stage receives the equal-and-opposite recoil.

vr,p=vr−+δvmsm−v_{r,p}=v_r^- +\delta v\frac{m_s}{m^-}

The payload receives its share of the separation impulse. Position and tangential velocity are unchanged.

First finish the ten-second coast and accept its endpoint. At that same mission time, split the accepted mass and apply the equal-and-opposite velocity increments; then start one new integration per body. Reference area is 12 m² for the retained upper stage and 2 m² for the payload. The point masses begin at an identical location; the model assumes successful physical clearance.

Why these terms appear

msvr,s+mpvr,p=m−vr−vr,p−vr,s=δv\begin{aligned}m_s v_{r,s}+m_p v_{r,p}&=m^-v_r^-\\v_{r,p}-v_{r,s}&=\delta v\end{aligned}

Solve conservation of radial linear momentum together with the prescribed relative speed to obtain the two recoil formulas. This is an instantaneous internal impulse at a shared location. It conserves total momentum, but separation supplies a small amount of kinetic energy; no finite mechanism or contact force is modeled.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Coast, check for impact, then separate the payloadpython/neutron/model.py:524 · Python
            coast_name, duration = ("deployment_coast", 10) if reached else ("failed_insertion_coast", 1200)
            upper_time, upper, upper_hit, _ = phase("upper_stage", coast_name, upper,
                                                  upper_time, upper_time + duration, coast, body_area=12.0)
            if upper_hit:
                record("upper_impact", upper_time, "upper_stage", "Upper stage contacted the ground during unpowered flight.")
        if reached and not upper_hit:
            retained, payload = separate(upper, upper[4] - payload_kg, 0.2)
The same momentum-conserving split with 0.2 m/s relative speedpython/neutron/model.py:198 · Python
def separate(state, booster_mass_kg, relative_speed_m_s=0.5):
    """Split mass with equal/opposite impulses: conserve linear momentum.

    Both bodies start at the same position. Geometry and contact are not modeled.
    The reusable fairing stays within booster dry mass.
    """
    booster, upper = state.copy(), state.copy()
    upper[4] = state[4] - booster_mass_kg
    booster[4] = booster_mass_kg
    booster[2] -= relative_speed_m_s * upper[4] / state[4]
    upper[2] += relative_speed_m_s * booster[4] / state[4]
    return booster, upper

Propagate a whole unpowered revolution

Symbols in this section

μ\mu
Earth gravitational parameter, 3.986004418 × 10¹⁴. [m³/s²]
εp, ℓp\varepsilon_p,\ \ell_p
Payload specific energy and signed specific angular momentum immediately after release; the reference values for the drift tests. [J/kg; m²/s]
ap, Pa_p,\ P
Payload's osculating two-body semi-major axis and corresponding estimated orbital period. The subscript p denotes payload here, whereas hph_p denotes perigee. [m; s]
trelease, treq, tendt_{release},\ t_{req},\ t_{end}
Release time, requested integration endpoint, and actual endpoint returned by the solver, all on the mission clock. [s]
θ, Δθ\theta,\ \Delta\theta
Unwrapped Earth-centred angle, positive along the ascent direction, and its total change since release. The angle is not reset at 2π. [rad]
n, tnn,\ t_n
Index of a returned solver state, and that state's mission time. These are accepted integration endpoints and the initial state, not the coarser playback frames. [integer index; s]
h(tn), htargeth(t_n),\ h_{target}
Payload altitude r(t_n) − R_E at each checked state, and target altitude; the 2 km bound means 2,000 m. [m]
ε(tn), ℓ(tn)\varepsilon(t_n),\ \ell(t_n)
Specific energy (v_r² + v_t²)/2 − μ/r and signed specific angular momentum r v_t evaluated at each checked state. [J/kg; m²/s]
max⁡n\max_n
Largest value over all returned solver states. Relative drift divides by the nonzero release value, making it dimensionless. [operator]

Use the released payload's specific energy εp\varepsilon_p to estimate semi-major axis apa_p and orbital period PP. Then integrate for that duration; the estimated period does not substitute an analytic ellipse for the actual trajectory.

ap=−μ2εpa_p=-\frac{\mu}{2\varepsilon_p}

Evaluate payload energy from its post-release state, including the small separation impulse.

P=2πap3μP=2\pi\sqrt{\frac{a_p^3}{\mu}}

This is the requested unpowered propagation duration for both released bodies.

treq=trelease+P∣tend−treq∣<10−3 s\begin{aligned}t_{req}&=t_{release}+P\\|t_{end}-t_{req}|&<10^{-3}\ \mathrm s\end{aligned}

Propagate with zero thrust until requested time treqt_{req} or ground contact. The actual endpoint tendt_{end} must reach the requested time within 0.001 s, with no impact.

Δθ≥2π−10−3\Delta\theta\geq2\pi-10^{-3}

The swept angle is Δθ=θ(tend)−θ(trelease)\Delta\theta=\theta(t_{end})-\theta(t_{release}); it must also cover a complete revolution within this angular tolerance.

∣h(tn)−htarget∣≤2 kmε(tn)<0\begin{aligned}|h(t_n)-h_{target}|&\leq2\ \mathrm{km}\\\varepsilon(t_n)&<0\end{aligned}

Check every returned solver state, indexed by nn: the initial state, accepted step endpoints and any final event endpoint. All must remain near the target altitude and bound.

max⁡n∣ε(tn)−εpεp∣<10−5\max_n\left|\frac{\varepsilon(t_n)-\varepsilon_p}{\varepsilon_p}\right|<10^{-5}

The maximum relative energy change over these solver states must remain small during the unpowered orbit.

max⁡n∣ℓ(tn)−ℓpℓp∣<10−5\max_n\left|\frac{\ell(t_n)-\ell_p}{\ell_p}\right|<10^{-5}

The same relative bound applies to specific angular momentum, measured from its release value ℓp\ell_p. These are discrete checks, not continuous extrema.

The period formula assumes an isolated bound two-body ellipse. The integrated coast retains the still-atmosphere drag model, so energy and angular momentum need only remain within the stated small drift thresholds, rather than being exactly conserved. Earth oblateness, Earth rotation, third bodies and orbital-plane dynamics are absent. The payload-orbit result comes from the integrated solution. This completes the upper branch. Next, return to separation and follow the booster on the shared mission clock.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Calculate period and propagate both unpowered bodiespython/neutron/model.py:535 · Python
            energy = orbital_elements(payload)["specific_energy_j_kg"]
            semimajor_axis = -MU / (2 * energy)
            period = 2 * math.pi * math.sqrt(semimajor_axis**3 / MU)
            # Keep long-coast exports compact without changing solver steps.
            interval = max(sample_step_s, 10.0)
            phase("upper_stage", "post_release_coast", retained, upper_time, upper_time + period,
                  coast, body_area=12.0, sample_interval=interval)
            payload_time, _, _, solution = phase("payload", "free_flight", payload,
                upper_time, upper_time + period, coast, body_area=2.0, sample_interval=interval)
            payload_orbit = coast_verification(solution, period, target_altitude_km)
Check returned solver states, duration and swept anglepython/neutron/model.py:79 · Python
def coast_verification(solution, period_s, target_altitude_km):
    """Verify an actual propagated revolution; no analytic orbit is substituted."""
    radius, angle, vr, vt, _ = solution.y
    altitude_km = (radius - EARTH_RADIUS) / 1000
    energy = (vr**2 + vt**2) / 2 - MU / radius
    momentum = radius * vt
    energy_change = float(np.max(np.abs((energy - energy[0]) / energy[0])))
    momentum_change = float(np.max(np.abs((momentum - momentum[0]) / momentum[0])))
    duration = float(solution.t[-1] - solution.t[0])
    swept_angle = float(angle[-1] - angle[0])
    completed = abs(duration - period_s) < 1e-3 and not solution.t_events[0].size
    minimum, maximum = float(altitude_km.min()), float(altitude_km.max())
    initial_orbit = orbital_elements(solution.y[:, 0])
    success = (completed and np.all(energy < 0) and swept_angle >= 2 * math.pi - 1e-3
               and minimum >= target_altitude_km - 2 and maximum <= target_altitude_km + 2
               and energy_change < 1e-5 and momentum_change < 1e-5)
    return dict(success=bool(success), completed_one_orbit=bool(completed),
                period_s=period_s, propagated_duration_s=duration, swept_angle_rad=swept_angle,
                perigee_km=initial_orbit["perigee_km"], apogee_km=initial_orbit["apogee_km"],
                eccentricity=initial_orbit["eccentricity"],
                min_altitude_km=minimum, max_altitude_km=maximum,
                relative_energy_change=energy_change, relative_angular_momentum_change=momentum_change)

Booster branch

Booster → return and landing

Carry the booster state forward from the same separation epoch through closure, boostback, descent and feedback landing.

Coast while the upper stage clears the fairing

Symbols in this section

t, tsep, tbb, tcloset,\ t_{sep},\ t_{bb},\ t_{close}
Current mission time, shared stage separation, boostback ignition, and clearance-gated fairing closure times. [s]
fopenf_{open}
Prescribed fairing opening fraction: 1 means fully open and 0 fully closed. clip limits the result to this interval. [dimensionless]
Fr, FtF_r,\ F_t
Outward radial and positive tangential engine forces; they are zero during the initial separation coast. [N]
m, m˙m,\ \dot m
Booster mass including captive fairing and remaining recovery propellant, and its time derivative. [kg; kg/s]

Return to the booster state at stage separation, not the end of upper-stage flight. The booster retains its 90,000 kg mass, including 60,000 kg recovery propellant and the fairing. It coasts for four seconds before boostback. The fairing remains open until integrated upper-stage displacement along the release axis reaches 23.2 m, then closes over four seconds.

tbb=tsep+4 st_{bb}=t_{sep}+4\ \mathrm s

Boostback can start at tbbt_{bb}, four seconds after separation.

fopen=clip⁡ ⁣(1−t−tclose4 s,0,1)f_{open}=\operatorname{clip}\!\left(1-\frac{t-t_{close}}{4\ \mathrm s},0,1\right)

The clearance root is found from the continuous integrated trajectories. Closure is kinematic: it neither ejects mass nor changes the aerodynamic reference area.

Fr=Ft=0,m˙=0F_r=F_t=0,\qquad\dot m=0

Advance the booster under gravity and drag with the established 7 m diameter reference area.

The branches are physically concurrent. Each starts from its own post-separation state at tsept_{sep}; each force law advances its own state. The final fairing calculation compares both integrated positions at the same mission time. It does not feed back into either force law, even though Python evaluates one branch after the other.

Associated code · 3 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Restart the booster at the shared separation timepython/neutron/model.py:549 · Python
        bt, booster, booster_hit, _ = phase("booster", "clearance_coast", booster, time, time + 4, coast)
Initial fairing samples before the clearance-gated hardware passpython/neutron/model.py:379 · Python
    def fairing_fraction(time, body):
        opening = next((e["time_s"] for e in events if e["name"] == "fairing_opening"), None)
        if opening is None or body not in ("stack", "booster"):
            return 0.0
        if separation_time is None or time <= separation_time:
            return float(np.clip((time - opening) / 4, 0, 1))
        return float(np.clip(1 - (time - separation_time) / 4, 0, 1))
Final dense-state clearance gate, closure and coast pointingpython/neutron/model.py:216 · Python
def apply_kinematic_hardware(trajectories, events, dense_phases):
    """Prescribed hardware coordinates, sampled from the actual integrated flight.

    Coast pointing uses a bounded cubic pre-alignment before the next ignition.
    Powered pointing is the actual force axis. Fairing closure waits for physical
    upper-stage clearance. No position, velocity, mass or applied force changes.
    """
    from scipy.optimize import brentq

    def phase_at(body, time):
        return next((phase for phase in reversed(dense_phases[body])
                     if phase["start"] <= time <= phase["stop"]), None)

    def state_xy(body, time):
        state = phase_at(body, time)["solution"].sol(time)
        return state[0] * np.array([math.cos(state[1]), math.sin(state[1])])

    def insert_samples(body, times):
        rows = trajectories[body]
        for time in times:
            if any(abs(row["time_s"] - time) < 1e-8 for row in rows):
                continue
            phase = phase_at(body, time)
            if phase is None:
                continue
            state = phase["solution"].sol(time)
            radius, angle, vr, vt, mass = map(float, state)
            force = phase["guidance"](time, state)
            thrust = float(np.linalg.norm(force))
            ca, sa = math.cos(angle), math.sin(angle)
            fx, fy = float(force[0] * ca - force[1] * sa), float(force[0] * sa + force[1] * ca)
            before = max((row for row in rows if row["time_s"] <= time), key=lambda row: row["time_s"])
            row = dict(before, time_s=float(time), phase=phase["name"],
                       altitude_m=radius - EARTH_RADIUS, downrange_m=EARTH_RADIUS * angle,
                       radial_velocity_m_s=vr, tangential_velocity_m_s=vt,
                       speed_m_s=math.hypot(vr, vt), mass_kg=mass, thrust_n=thrust,
                       force_x_n=fx, force_y_n=fy,
                       dynamic_pressure_pa=0.5 * atmosphere(radius - EARTH_RADIUS) * (vr * vr + vt * vt),
                       x_m=radius * ca, y_m=radius * sa,
                       vx_m_s=vr * ca - vt * sa, vy_m_s=vr * sa + vt * ca)
            if thrust > 0:
                row["axis_x"], row["axis_y"] = fx / thrust, fy / thrust
            if body == "booster" and any(sample.get("leg_open_fraction", 0) > 0 for sample in rows):
                command = next((event["time_s"] for event in events if event["name"] == "landing_burn"), math.inf)
                row["leg_open_fraction"] = float(np.clip((time - command) / 2, 0, 1))
            rows.append(row)
        rows.sort(key=lambda row: row["time_s"])

    for body, rows in trajectories.items():
        segments = []
        index = 0
        while index < len(rows):
            if rows[index]["thrust_n"] > 0:
                index += 1
                continue
            start = index
            while index < len(rows) and rows[index]["thrust_n"] == 0:
                index += 1
            if index == len(rows):
                break
            first, target = rows[start], rows[index]
            initial = math.atan2(first["axis_y"], first["axis_x"])
            final = math.atan2(target["force_y_n"], target["force_x_n"])
            turn = math.atan2(math.sin(final - initial), math.cos(final - initial))
            available = target["time_s"] - first["time_s"]
            duration = min(available, max(2.0, 1.5 * abs(turn) / COAST_POINTING_RATE_RAD_S))
            if duration > 0:
                segments.append((target["time_s"] - duration, target["time_s"], initial, turn))
        # Include the profile endpoints and 0.1 s kinematic samples even when
        # the caller chooses sparse trajectory output. States use RK45 dense
        # output here; they are never linearly invented or re-integrated.
        insert_samples(body, [float(t) for begin, end, _, _ in segments
                             for t in np.r_[np.arange(begin, end, 0.1), end]])
        for begin, end, initial, turn in segments:
            for row in rows:
                if row["thrust_n"] == 0 and begin <= row["time_s"] <= end:
                    u = float(np.clip((row["time_s"] - begin) / (end - begin), 0, 1))
                    angle = initial + turn * u * u * (3 - 2 * u)
                    row["axis_x"], row["axis_y"] = math.cos(angle), math.sin(angle)

    opening = next((event["time_s"] for event in events if event["name"] == "fairing_opening"), None)
    separation = next((event["time_s"] for event in events if event["name"] == "stage_separation"), None)
    if opening is None or separation is None or not trajectories["booster"] or not trajectories["upper_stage"]:
        return
    attached = trajectories["stack"][-1]
    axis = np.array([attached["axis_x"], attached["axis_y"]])
    def clearance(time):
        return float((state_xy("upper_stage", time) - state_xy("booster", time)) @ axis) - UPPER_EXIT_CLEARANCE_M
    stop = min(dense_phases[body][-1]["stop"] for body in ("booster", "upper_stage"))
    times = sorted(set(float(t) for body in ("booster", "upper_stage")
                       for phase in dense_phases[body] for t in phase["solution"].t
                       if separation <= t <= stop))
    close = math.inf
    for left, right in zip(times, times[1:]):
        if clearance(left) <= 0 <= clearance(right):
            close = float(brentq(clearance, left, right, xtol=1e-9))
            break
    events[:] = [event for event in events if event["name"] not in ("fairing_closing", "fairing_closed")]
    if math.isfinite(close):
        for name, time, text in [
            ("fairing_closing", close, "Captive fairing closes after integrated upper-stage clearance."),
            ("fairing_closed", close + 4, "Four-second closure completes after upper-stage clearance.")]:
            events.append(dict(name=name, time_s=time, body="booster", description=text,
                               required_axial_clearance_m=UPPER_EXIT_CLEARANCE_M))
        insert_samples("booster", [close, close + 4])
    for body in ("stack", "booster"):
        for row in trajectories[body]:
            time = row["time_s"]
            fraction = min(1.0, max(0.0, (time - opening) / 4))
            if time > close:
                fraction = max(0.0, 1 - (time - close) / 4)
            row["fairing_open_fraction"] = fraction

Reverse downrange motion, then cut off on predicted miss

Symbols in this section

Te, Fr, FtT_e,\ F_r,\ F_t
One engine's assumed available thrust, outward radial engine force, and signed tangential engine force. Negative F_t points against the positive downrange direction. [N]
r, RE, hr,\ R_E,\ h
Vehicle geocentric radius, fixed Earth radius, and altitude h = r − R_E. [m]
μ, g\mu,\ g
Earth gravitational parameter and current positive local gravity magnitude μ/r². The predictor holds this g constant during its imagined fall. [m³/s²; m/s²]
vr, vtv_r,\ v_t
Actual radial and tangential speeds at the predictor's starting state; v_r is negative while descending. [m/s]
tft_f
Nonnegative estimated time remaining until ground contact in the constant-gravity, drag-free predictor; it is a duration, not an absolute mission time. [s]
θ\theta
Signed, unwrapped angle from the launch-site radius; positive toward the original ascent direction. [rad]
xpred, xx_{pred},\ x
Predicted signed miss at contact and actual current signed surface-arc distance x = R_E θ\theta from the launch site. [m]
↓\downarrow
Predicted miss crossing zero from positive to negative; this gate ends boostback. [event direction]

Assume three engines point directly against the positive tangential direction. Let TeT_e be one engine's thrust, one ninth of the published nine-engine ascent thrust. For cutoff only, estimate remaining fall time tft_f under constant local gravity g and the current vertical state. The estimated ground miss xpredx_{pred} is current downrange plus tangential speed times this fall time.

Te=716 657.927 NT_e=716\,657.927\ \mathrm N

One engine's thrust is the nine-engine ascent thrust divided by nine.

Fr=0,Ft=−3TeF_r=0,\qquad F_t=-3T_e

Feed these forces into the same ODE with booster Isp = 330 s; this burn also consumes the recovery reserve.

g=μr2tf=vr+vr2+2gmax⁡(0,h)g\begin{aligned}g&=\frac{\mu}{r^2}\\t_f&=\frac{v_r+\sqrt{v_r^2+2g\max(0,h)}}{g}\end{aligned}

The predictor assumes constant gravity and no drag. It only decides when to stop the burn.

xpred=REθ+vttfx_{pred}=R_E\theta+v_t t_f

Predicted miss is signed downrange distance from the launch site. This local approximation uses tangential speed as surface-distance rate; exactly, x˙=(RE/r)vt\dot x=(R_E/r)v_t for x=REθx=R_E\theta. It also ignores curvature during the predicted fall.

xpred=0(↓)x_{pred}=0\quad(\downarrow)

Cut off at the downward zero crossing, or earlier at the dry-mass fuel floor or ground contact.

The actual path is still integrated with spherical gravity and drag; the predictor never overwrites position. This educational return-to-launch-site scenario differs from Rocket Lab's published downrange sea-recovery baseline. The boostback time limit is 300 s.

Why these terms appear

0=max⁡(0,h)+vrtf−12gtf20=\max(0,h)+v_r t_f-\tfrac12g t_f^2

This constant-acceleration height equation is the predictor's model. The nonnegative root of this quadratic gives the stated fall-time expression; the main trajectory continues to use changing spherical gravity, drag and tangential motion.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Approximate fall time and signed miss for cutoff onlypython/neutron/model.py:163 · Python
def predicted_miss(state, target_downrange_m=0.0, contact_height_m=0.0):
    """Cheap constant-g ballistic predictor used only to stop boostback.

    It does not set a landing position; the integrated trajectory can miss.
    Landing feedback subsequently corrects remaining position/velocity errors.
    """
    radius, angle, vr, vt, _ = state
    gravity = MU / radius**2
    fall_time = (vr + math.sqrt(vr * vr + 2 * gravity * max(0, radius - EARTH_RADIUS - contact_height_m))) / gravity
    return EARTH_RADIUS * angle + vt * fall_time - target_downrange_m
Three-engine reverse thrust and stopping eventspython/neutron/model.py:550 · Python
        engine = vehicle.booster_thrust_n / vehicle.booster_engines
        empty = terminal_event(lambda t, y: y[4] - vehicle.booster_dry_kg)
        initial_miss = predicted_miss(booster, recovery_target_downrange_m, contact_height)
        correction = -1.0 if initial_miss >= 0 else 1.0
        return_target = terminal_event(
            lambda t, y: predicted_miss(y, recovery_target_downrange_m, contact_height), correction)
        if not booster_hit:
            record("fairing_closed", bt, "booster", "Captive fairing closed for recovery.")
        if not booster_hit and booster[4] > vehicle.booster_dry_kg + 1e-3:
            record("boostback_ignition", bt, "booster",
                   "Three-engine range correction targets the fixed recovery ship." if ship_recovery
                   else "Three-engine boostback targets the launch site (assumed RTLS scenario).")
            bt, booster, booster_hit, solution = phase("booster", "boostback", booster, bt, bt + 300,
                                                       lambda t, y: np.array([0.0, correction * 3 * engine]), (empty, return_target))
            if not booster_hit:
                reason = "predicted zero miss" if solution.t_events[2].size else "propellant depletion" if solution.t_events[1].size else "time limit"
                record("boostback_cutoff", bt, "booster", "Boostback stops at " + reason + ".")

Coast to the landing-burn ignition point

Symbols in this section

Te, mT_e,\ m
One engine's available thrust and current booster mass, including remaining recovery propellant. [N; kg]
μ, r\mu,\ r
Earth gravitational parameter and current geocentric radius; μ/r² is the local gravitational acceleration magnitude. [m³/s²; m]
h, vrh,\ v_r
Current altitude r − R_E and outward radial speed; only negative v_r contributes to the stopping-distance estimate. [m; m/s]
amaxa_{max}
Estimated upward net acceleration for three engines, floored at 1 m/s². This heuristic estimate is not a guaranteed available deceleration. [m/s²]
dstopd_{stop}
Estimated vertical stopping distance at constant amaxa_{max}; excludes tangential speed and lateral thrust demand. [m]
↓\downarrow
The altitude-margin gate crossing zero from positive to negative; it triggers landing ignition. [event direction]

Coast with zero thrust after boostback. At each state, estimate maximum net upward acceleration amaxa_{max} from three engines, then compute stopping distance dstopd_{stop} using only downward radial speed.

amax=max⁡ ⁣(1 m/s2,3Tem−μr2)a_{max}=\max\!\left(1\ \mathrm{m/s^2},\frac{3T_e}{m}-\frac{\mu}{r^2}\right)

The acceleration estimate uses current mass and gravity, with a 1 m/s² lower bound.

dstop=min⁡(vr,0)22amaxd_{stop}=\frac{\min(v_r,0)^2}{2a_{max}}

Only descending speed contributes to this approximate stopping-distance gate.

h−1.5dstop−3 000 m=0(↓)h-1.5d_{stop}-3\,000\ \mathrm m=0\quad(\downarrow)

Ignite at the downward crossing, leaving a 50% stopping-distance margin plus 3 km.

Descending through 80 km is recorded at 326.230 s; drag has acted continuously all along. This marker does not switch the atmosphere on. Ground impact remains terminal, and this coast is limited to 1,000 s.

Why these terms appear

0=min⁡(vr,0)2−2amaxdstop0=\min(v_r,0)^2-2a_{max}d_{stop}

The stopping-distance estimate is the familiar squared-speed relation for constant deceleration. Actual available vertical deceleration changes as fuel burns and thrust is redirected laterally; drag and polar geometry also act. The factor 1.5 and 3 km offset are chosen ignition margins, not guarantees of a safe touchdown.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Stopping-distance ignition gatepython/neutron/model.py:569 · Python
        def landing_gate(t, y):
            height, vr, mass = y[0] - EARTH_RADIUS - contact_height, y[2], y[4]
            maximum_deceleration = max(1.0, 3 * engine / mass - MU / y[0]**2)
            stopping_distance = min(vr, 0)**2 / (2 * maximum_deceleration)
            return height - 1.5 * stopping_distance - 3000
Coast with ignition gate and nonterminal reentry markerpython/neutron/model.py:576 · Python
            gate = terminal_event(landing_gate)
            reentry = lambda t, y: y[0] - EARTH_RADIUS - 80_000
            reentry.direction = -1
            bt, booster, booster_hit, solution = phase("booster", "coast_and_reentry", booster, bt, bt + 1000,
                                                       coast, (gate, reentry))

Use the current descent error to command thrust

Symbols in this section

r, RE, h, h+r,\ R_E,\ h,\ h_+
Geocentric radius, fixed Earth radius, altitude r − R_E, and altitude clamped below at zero. [m]
vr, vt, vr∗v_r,\ v_t,\ v_r^*
Actual outward radial speed, signed tangential speed, and desired radial descent speed. A negative radial value means descent. [m/s]
ar∗, at∗a_r^*,\ a_t^*
Requested derivatives dot v_r and dot v_t. These are requests for changing the polar velocity components, not the physical acceleration projections before basis correction. [m/s²]
θ, x\theta,\ x
Signed angle from launch and signed surface-arc downrange distance x = R_E θ\theta; x = 0 is the desired landing site. [rad; m]
τl\tau_l
Estimated remaining landing duration, recomputed from the current state and bounded below by 3 s. [s]
m, μm,\ \mu
Current booster mass and Earth gravitational parameter. [kg; m³/s²]
Dr, DtD_r,\ D_t
Signed drag-force components in the same radial/tangential frame. Subtracting these in the command compensates the drag already included in the ODE. [N]
F~r, F~t\widetilde F_r,\ \widetilde F_t
Engine-force components that would realize the requested velocity derivatives before any pointing or magnitude limits. A tilde denotes this unconstrained request. [N]
Fc, Q\mathbf F_c,\ Q
Requested engine-force vector after setting any downward radial component to zero, and its Euclidean magnitude sqrt(Fc\mathbf F_c,r² + Fc\mathbf F_c,t²). Bold symbols are vectors. [N]
Te, FT_e,\ \mathbf F
Single-engine available thrust, and final bounded engine-force vector [F_r, F_t] supplied to the dynamics. [N]
m˙, g0\dot m,\ g_0
Mass-consumption rate and fixed specific-impulse reference gravity (9.80665 m/s²); the assumed booster specific impulse is 330 s. [kg/s; m/s²]

First set a desired downward speed vr∗v_r^* from height, clamped to h+=max⁡(0,h)h_+=\max(0,h). Then request radial acceleration ar∗a_r^* to follow that speed. Use the estimated time remaining τl\tau_l to drive both downrange x=REθx=R_E\theta and tangential speed toward zero.

vr∗=−2(6 m/s2)h++(1 m/s)2v_r^*=-\sqrt{2(6\ \mathrm{m/s^2})h_++(1\ \mathrm{m/s})^2}

This soft-descent profile approaches −1 m/s at ground level.

ar∗=−(6 m/s2)vr−vr∗+vr∗−vr2 sa_r^*=-\frac{(6\ \mathrm{m/s^2})v_r}{-v_r^*}+\frac{v_r^*-v_r}{2\ \mathrm s}

The first term follows the changing descent profile; the second corrects radial velocity error.

τl=max⁡ ⁣(3 s,2h+max⁡(1 m/s,−vr))\tau_l=\max\!\left(3\ \mathrm s,\frac{2h_+}{\max(1\ \mathrm{m/s},-v_r)}\right)

The time estimate stays finite near touchdown and during shallow descent.

at∗=−6xτl2−4vtτla_t^*=-\frac{6x}{\tau_l^2}-\frac{4v_t}{\tau_l}

The lateral request corrects position and tangential velocity. Using vtv_t as x˙\dot x is a near-surface approximation; the trajectory itself retains the exact polar equations.

F~r=m ⁣(ar∗+μr2−vt2r)−Dr\widetilde F_r=m\!\left(a_r^*+\frac{\mu}{r^2}-\frac{v_t^2}{r}\right)-D_r

Convert requested radial acceleration into engine force, compensating gravity, polar motion and drag.

F~t=m ⁣(at∗+vrvtr)−Dt\widetilde F_t=m\!\left(a_t^*+\frac{v_rv_t}{r}\right)-D_t

Convert the tangential request into engine force with the matching polar and drag compensation.

Fc=[max⁡(0,F~r)F~t]\mathbf F_c=\begin{bmatrix}\max(0,\widetilde F_r)\\\widetilde F_t\end{bmatrix}

Rectify the radial component so the engine cannot point downward; Fc\mathbf F_c is this clipped vector.

Q=∥Fc∥Q=\|\mathbf F_c\|

QQ is the magnitude of the rectified force vector, in newtons.

F=Fcclip⁡(Q,0.3Te,3Te)max⁡(Q,10−9 N)\mathbf F=\mathbf F_c\frac{\operatorname{clip}(Q,0.3T_e,3T_e)}{\max(Q,10^{-9}\ \mathrm N)}

Scale to the assumed envelope from 30% of one engine to three full engines when Q≥10−9Q\geq10^{-9} N. Below that denominator guard, thrust can fall below the minimum; an exactly zero vector remains zero.

The update order at each RK45 trial state is: read current height, velocities and mass; construct the desired descent speed and landing-time estimate; request the two velocity derivatives; compensate gravity, basis rotation and drag; rectify the radial force; then limit total force. Feed only this final force into the ODE, with mass flow −∥F∥/(330g0)-\|\mathbf F\|/(330g_0). The ODE produces actual derivatives and RK45 advances the state together. Rectification and magnitude limits can change both achieved derivatives, so the requested ar∗a_r^* and at∗a_t^* must never be written straight into the state. Pointing and engine switching are ideal: ignition transients, attitude slew and gimbal limits are absent. The landing-burn time limit is 600 s.

Why these terms appear

dvr∗dt=−(6 m/s2)vr−vr∗(h>0)\frac{d v_r^*}{dt}=-\frac{(6\ \mathrm{m/s^2})v_r}{-v_r^*}\qquad(h>0)

Differentiate the desired speed with respect to height and use dot h = v_r. This gives the first, feed-forward term in ar∗a_r^*. Adding (vr∗v_r^* − v_r)/(2 s) corrects tracking error. The chosen 6 m/s² shapes the descent-speed profile; it is not an additional force or the full engine acceleration.

v˙t=−vrvtr+Ft+Dtmv˙t=at∗⟹Ft=m(at∗+vrvtr)−Dt\begin{aligned}\dot v_t&=-\frac{v_rv_t}{r}+\frac{F_t+D_t}{m}\\\dot v_t&=a_t^*\\\Longrightarrow\quad F_t&=m\left(a_t^*+\frac{v_rv_t}{r}\right)-D_t\end{aligned}

Set the desired derivative dot v_t to at∗a_t^* and rearrange the actual ODE. This is why the command contains a positive v_r v_t/r compensation term: it cancels the negative moving-basis term in the velocity derivative. Tangential physical acceleration is (F_t + D_t)/m, not v_r v_t by itself.

Associated code · 1 source excerpt

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Descent feedback, force compensation and engine envelopepython/neutron/model.py:175 · Python
def landing_guidance(time, state, vehicle, target_downrange_m=0.0, contact_height_m=0.0):
    """Soft-descent velocity law + finite-time lateral position feedback.

    Ideal engine envelope: 0.3 of one engine to three engines. Engine switching,
    ignition transients, attitude slew and gimbal limits are not simulated.
    """
    radius, angle, vr, vt, mass = state
    height = max(0.0, radius - EARTH_RADIUS - contact_height_m)
    desired_vr = -math.sqrt(2 * 6.0 * height + 1.0**2)
    radial_request = -6.0 * vr / -desired_vr + (desired_vr - vr) / 2.0
    remaining = max(3.0, 2 * height / max(1.0, -vr))
    lateral_request = -6 * (EARTH_RADIUS * angle - target_downrange_m) / remaining**2 - 4 * vt / remaining
    acceleration = np.array([radial_request + MU / radius**2 - vt * vt / radius,
                             lateral_request + vr * vt / radius])
    area = math.pi * vehicle.diameter_m**2 / 4
    force = mass * acceleration - drag_force(state, area, vehicle.drag_coefficient)
    # Only upward pointing during the landing burn; bounded total force.
    force[0] = max(0.0, force[0])
    magnitude = np.linalg.norm(force)
    engine = vehicle.booster_thrust_n / vehicle.booster_engines
    return force * np.clip(magnitude, 0.3 * engine, 3 * engine) / max(magnitude, 1e-9)

Stop at contact and score what actually happened

Symbols in this section

r, REr,\ R_E
Integrated booster distance from Earth's centre and fixed spherical surface radius; contact occurs when they coincide. [m]
vr, vtv_r,\ v_t
Actual radial and tangential speeds at contact. Neither is reset when the landing is scored. [m/s]
θ\theta
Signed angle from the launch-site radius at contact. [rad]
Vcontact, xmissV_{contact},\ x_{miss}
Nonnegative total contact speed and absolute surface-arc distance from launch; both must satisfy their own limit. [m/s; m]
↓\downarrow
Altitude crossing zero from positive to negative; this is a terminal impact/contact event. [event direction]

The downward ground crossing ends the flight. Evaluate impact speed VcontactV_{contact} and absolute downrange miss xmissx_{miss} from that integrated state, then turn the engine display off. Do not force position or velocity to an ideal landing.

r−RE=0(↓)r-R_E=0\quad(\downarrow)

Ground contact is a solver-located event, not a sampled frame chosen by the renderer.

Vcontact=vr2+vt2V_{contact}=\sqrt{v_r^2+v_t^2}

Both radial and tangential velocity contribute to contact speed.

xmiss=∣REθ∣x_{miss}=|R_E\theta|

Measure the actual surface-arc distance from the launch site.

Vcontact≤2 m/s,xmiss≤100 mV_{contact}\leq2\ \mathrm{m/s},\qquad x_{miss}\leq100\ \mathrm m

Both tests must pass to label the contact a successful landing.

Every powered recovery phase has a downward mass gate at 30,000 kg booster dry mass. If fuel runs out, the remaining descent is unpowered for up to 1,000 s. Fast or remote contact is an impact; no contact before the phase limits is a recovery timeout. Contact mechanics and motion after impact are outside the model.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Integrate to ground or actual contact within the finite recovery deckpython/neutron/model.py:397 · Python
        impact = terminal_event(lambda t, y: y[0] - EARTH_RADIUS)
        deck_events = ()
        if body == "booster" and ship_recovery:
            # A fixed flat deck, not an elevated spherical surface everywhere.
            # Check its finite along-track footprint at actual downward roots.
            def deck_crossing(t, y):
                return (y[0] * math.cos(y[1] - target_angle) - EARTH_RADIUS
                        - deck_height - leg_clearance * leg_fraction(t, body))
            deck_crossing.direction = -1
            deck_events = (deck_crossing,)
        solution = integrate(state, start, end, guidance, body_area, vehicle.drag_coefficient,
                             isp, (impact, *monitors, *deck_events), max_step_s, rtol)
        integrations += solution.nfev
        stop = float(solution.t[-1])
        hit = bool(solution.t_events[0].size)
        if hit and body == "booster":
            booster_contact_surface = "ocean" if ship_recovery else "ground"
        if deck_events:
            for contact_time in solution.t_events[-1]:
                contact_state = solution.sol(contact_time)
                along_track = contact_state[0] * math.sin(contact_state[1] - target_angle)
                if abs(along_track) <= RECOVERY_SHIP_LENGTH_M / 2:
                    stop, hit = float(contact_time), True
                    booster_contact_surface = "deck"
                    solution.t_events = [times[times <= stop + 1e-9] for times in solution.t_events]
                    break
        dense_phases[body].append({"start": float(start), "stop": stop,
                                   "solution": solution, "guidance": guidance, "name": name})
Score actual contact, preserve its state and cut displayed thrustpython/neutron/model.py:591 · Python
            speed = math.hypot(booster[2], booster[3])
            miss = abs(EARTH_RADIUS * booster[1] - recovery_target_downrange_m)
            success = (speed <= 2.0 and booster_contact_surface == "deck" and leg_fraction(bt, "booster") == 1
                       if ship_recovery else speed <= 2.0 and miss <= 100.0)
            contact = trajectories["booster"][-1]
            if contact["thrust_n"] > 0:
                record("booster_engine_cutoff", bt, "booster", "Engine shuts down at ground contact; contact mechanics are outside this model.")
            trajectories["booster"].append(dict(contact, phase="touchdown" if success else "impact",
                                                 thrust_n=0.0, force_x_n=0.0, force_y_n=0.0))
            landing = dict(success=bool(success), touchdown_time_s=bt, speed_m_s=speed,
                           miss_distance_m=miss, propellant_remaining_kg=max(0.0, booster[4] - vehicle.booster_dry_kg),
                           contact_surface=booster_contact_surface)
            record("touchdown" if success else "booster_impact", bt, "booster",
                   ("Soft landing on the fixed recovery deck." if ship_recovery else "Soft landing within 100 m of launch.")
                   if success else "Surface contact fails the speed or recovery-target criteria.")

Turn the state into the display

Symbols in this section

r, θr,\ \theta
Integrated geocentric radius and unwrapped angle. θ\theta = 0 lies on the launch-site radius; increasing θ\theta is the positive tangential direction. [m; rad]
X, YX,\ Y
Cartesian position components in the fixed simulation plane, with Earth at the origin and launch on the positive X axis. These are physical positions before camera projection. [m]
t, ta, tbt,\ t_a,\ t_b
Requested playback mission time and the neighbouring distinct recorded mission times bracketing it, with t_a ≤ t ≤ t_b. [s]
λ\lambda
Interpolation fraction between those samples, from 0 at the earlier sample to 1 at the later sample. [dimensionless]
za, zb, zdisplayz_a,\ z_b,\ z_{display}
Earlier recorded value, later recorded value, and displayed interpolated value of one numeric field, such as altitude or mass. z is a generic field, not a sixth physical state. [same units as the selected field]

The Run button calculates the entire mission in a Python worker before playback begins. The two physical branches are complete. To show them together on the shared mission clock, Python evaluates the dense numerical solution at output times. It records regular two-second samples and exact phase endpoints; the apogee coast and long post-release coasts use ten-second samples. Earth-centred drawing coordinates X,YX,Y come directly from the integrated radius and angle.

X=rcos⁡θ,Y=rsin⁡θX=r\cos\theta,\qquad Y=r\sin\theta

These coordinates are derived for each recorded physical sample.

λ=t−tatb−ta\lambda=\frac{t-t_a}{t_b-t_a}

For a display time tt between adjacent recorded times ta,tbt_a,t_b, λ\lambda is the interpolation fraction.

zdisplay=(1−λ)za+λzbz_{display}=(1-\lambda)z_a+\lambda z_b

For each numeric sample field zz, the browser interpolates the two neighbouring recorded values. The phase label stays with the earlier sample.

Playback speed scales elapsed wall time into display time; it does not change solver accuracy. Interpolated values never feed back into the physical state or event gates. At or beyond a body's last sample, the browser holds its final state. Complete mission success requires verified insertion, payload release, a verified propagated payload orbit, and successful booster landing together. These checks verify the assumed model's results; they do not validate real vehicle performance.

Associated code · 2 source excerpts

Copied directly from the implementation used by this site. Excerpts show the surrounding logic; run the complete downloaded notebook for dependencies and execution order.

Dense-output samples and Earth-centred display coordinatespython/neutron/model.py:393 · Python
    def phase(body, name, state, start, end, guidance, monitors=(), body_area=None, sample_interval=None):
        nonlocal integrations, booster_contact_surface
        body_area = area if body_area is None else body_area
        isp = vehicle.upper_isp_s if body in ("upper_stage", "payload") else vehicle.booster_isp_s
        impact = terminal_event(lambda t, y: y[0] - EARTH_RADIUS)
        deck_events = ()
        if body == "booster" and ship_recovery:
            # A fixed flat deck, not an elevated spherical surface everywhere.
            # Check its finite along-track footprint at actual downward roots.
            def deck_crossing(t, y):
                return (y[0] * math.cos(y[1] - target_angle) - EARTH_RADIUS
                        - deck_height - leg_clearance * leg_fraction(t, body))
            deck_crossing.direction = -1
            deck_events = (deck_crossing,)
        solution = integrate(state, start, end, guidance, body_area, vehicle.drag_coefficient,
                             isp, (impact, *monitors, *deck_events), max_step_s, rtol)
        integrations += solution.nfev
        stop = float(solution.t[-1])
        hit = bool(solution.t_events[0].size)
        if hit and body == "booster":
            booster_contact_surface = "ocean" if ship_recovery else "ground"
        if deck_events:
            for contact_time in solution.t_events[-1]:
                contact_state = solution.sol(contact_time)
                along_track = contact_state[0] * math.sin(contact_state[1] - target_angle)
                if abs(along_track) <= RECOVERY_SHIP_LENGTH_M / 2:
                    stop, hit = float(contact_time), True
                    booster_contact_surface = "deck"
                    solution.t_events = [times[times <= stop + 1e-9] for times in solution.t_events]
                    break
        dense_phases[body].append({"start": float(start), "stop": stop,
                                   "solution": solution, "guidance": guidance, "name": name})
        samples = np.unique(np.r_[start, np.arange(start, stop, sample_interval or sample_step_s), stop])
        for time, y in zip(samples, solution.sol(samples).T):
            radius, angle, vr, vt, mass = y
            force = guidance(time, y)
            cos_a, sin_a = math.cos(angle), math.sin(angle)
            speed = math.hypot(vr, vt)
            thrust = float(np.linalg.norm(force))
            force_x = float(force[0] * cos_a - force[1] * sin_a)
            force_y = float(force[0] * sin_a + force[1] * cos_a)
            if thrust > 0:
                pointing[body] = (force_x / thrust, force_y / thrust)
            trajectories[body].append(dict(
                time_s=float(time), phase=name, altitude_m=float(radius - EARTH_RADIUS),
                downrange_m=float(EARTH_RADIUS * angle), radial_velocity_m_s=float(vr),
                tangential_velocity_m_s=float(vt), speed_m_s=speed, mass_kg=float(mass),
                thrust_n=thrust, force_x_n=force_x, force_y_n=force_y,
                axis_x=pointing[body][0], axis_y=pointing[body][1],
                fairing_open_fraction=fairing_fraction(time, body),
                leg_open_fraction=leg_fraction(time, body),
                dynamic_pressure_pa=0.5 * atmosphere(radius - EARTH_RADIUS) * speed**2,
                x_m=radius * cos_a, y_m=radius * sin_a,
                vx_m_s=vr * cos_a - vt * sin_a, vy_m_s=vr * sin_a + vt * cos_a))
        final_state = solution.y[:, -1] if stop == float(solution.t[-1]) else solution.sol(stop)
        return stop, final_state.copy(), hit, solution
Browser interpolation and endpoint handlingapp/trajectory/neutron-types.ts:87 · TypeScript
export function sampleAt(samples: FlightSample[], time: number) {
  if (!samples.length || time < samples[0].time_s) return null;
  let low = 0;
  let high = samples.length - 1;
  while (low < high) {
    const middle = Math.ceil((low + high) / 2);
    if (samples[middle].time_s <= time) low = middle;
    else high = middle - 1;
  }
  const first = samples[low];
  const next = samples[Math.min(low + 1, samples.length - 1)];
  const span = next.time_s - first.time_s;
  const fraction = span > 0 ? Math.min(1, (time - first.time_s) / span) : 0;
  const sample = { ...first };
  for (const key of Object.keys(first) as (keyof FlightSample)[]) {
    if (key !== 'phase')
      sample[key] = first[key] + fraction * (next[key] - first[key]);
  }
  return { sample, index: low, ended: time > samples.at(-1)!.time_s };
}

The complete calculated mission

Both branches are now complete. These results belong to the same 8,000 kg payload example followed above; each time is measured from launch.

A mission passes only when insertion, payload release, the full unpowered orbit check and booster landing all succeed.

Upper-stage cutoff
3,270.846 s
Insertion perigee
400.000 km
Insertion apogee
400.000 km
Payload release
3,280.846 s
Verified unpowered orbit
5,553.624 s
Booster touchdown
475.517 s
Touchdown speed
1.272 m/s
Landing miss
0.335 m
Recovery propellant remaining
3,795.595 kg

Now run that flight below. Keep the payload at 8,000 kg to reproduce these values, then change it to see how the same equations respond.

Run the computed flight

Target a 400 km circular orbit and return the booster to the launch site. Guidance reads the simulated state directly.

Open 3D trajectory simulator ↗

Choose the payload

Predict how extra mass will affect the flight, then calculate a run.

Teaching scenario: return to launch site.
Published Neutron baseline: downrange sea recovery.

Ready to calculate a new missionAdaptive Dormand–Prince 5(4)

Follow the flight

Scrub through separation. Both plots use the same mission time to show the computed ascent, orbit attempt and booster return.

T+ 00:00

Flight paths

Full stackBoosterSecond stagePayload
Ascent and booster return

Height above Earth against ground range. Axes fit the selected run and body; horizontal and vertical scales differ.

Integrated flight pathsEach trace uses positions from the selected Python run. Ground range and altitude use different scales. Body markers indicate position, not vehicle size or attitude.0285684112-53268105Altitude / kmGround range / kmAscent. Orbit. Return.Run a mission to calculate the flight paths.
Payload flight around Earth

The full flight and unpowered coast in the inertial orbital plane. Earth and the trajectories use equal distance scales; the view fits this run. Dots show calculated positions, not vehicle attitude.

Integrated orbital flightThe globe and trajectories use the same distance scale. Every path and marker comes from integrated Cartesian positions. No ideal orbit is drawn.EarthInertial flight planeEqual scales · integrated positions

Full stack

Awaiting liftoff
Altitude
— km
Speed
— km/s
Vertical velocity
— m/s
Mass
— t
Engine thrust
— kN
Booster fairing
—

Each run solves the Python equations using your selected payload.

Full stackOn the pad—
BoosterAttached—
Second stageAttached—
PayloadAttached—

Check the evidence

Compare orbit size and coast stability, then check touchdown speed and distance.

Payload: reaching 400 km is only part of the test. Check perigee, apogee and a full unpowered orbit.

Booster: returning near the pad also needs a slow touchdown. Both speed and miss distance matter.

Playback interpolates the states calculated by this run.

No model executed yet.