Skip to content

Reusable Launch Vehicle

File: examples/rocket/reusable_launch_vehicle.py

import os
import sys

import jax

# use float64
jax.config.update("jax_enable_x64", True)

import numpy as np

current_dir = os.path.dirname(os.path.abspath(__file__))
grandparent_dir = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(grandparent_dir)

import openscvx as ox
from openscvx.plotting import plot_controls, plot_scp_convergence_histories, plot_states

# Number of discretization nodes
n = 200

# Physical constants from Betts reference
# Earth radius (m)
R_earth = 6371203.92  # m

# Vehicle parameters
S = 249.9091776  # Vehicle reference area (m^2)
mass = 92079.2525560557  # Vehicle mass (kg)

# Aerodynamic coefficients
cl = np.array([-0.2070, 1.6756])  # Lift coefficient parameters [cl0, cl1]
cd = np.array([0.0785, -0.3529, 2.0400])  # Drag coefficient parameters [cd0, cd1, cd2]

# Atmospheric model parameters
rho0 = 1.225570827014494  # Sea level atmospheric density (kg/m^3)
H = 7254.24  # Density scale height (m)

# Earth gravitational parameter
mu_earth = 3.986031954093051e14  # m^3/s^2

# Boundary conditions from problem statement (matching Betts reference exactly)
# Heights: h(0) = 79248 m, h(t_f) = 24384 m
h_initial = 79248.0  # m
h_final = 24384.0  # m

# Initial conditions from reference
lon0 = 0.0  # deg
lat0 = 0.0  # deg
speed0 = 7802.88  # m/s (reference uses 7802.88, problem statement says 7803)
speedf = 762.0  # m/s
fpa0 = -1.0 * np.pi / 180  # deg
fpaf = -5.0 * np.pi / 180  # deg
azi0 = 90.0 * np.pi / 180  # deg (reference shows +90, not -90)
azif = -90.0 * np.pi / 180  # deg (reference shows -90)

# Define state components
# h: height above Earth surface (m)
h = ox.State("h", shape=(1,))
h.max = np.array([h_initial])  # m (max is initial altitude, matching reference radMax = rad0)
h.min = np.array([0.0])  # m
h.initial = np.array([h_initial])
h.final = np.array([h_final])
h.guess = np.linspace(h_initial, h_final, n).reshape(-1, 1)

# θ: longitude (radians)
theta = ox.State("theta", shape=(1,))
theta.max = np.array([np.pi])  # rad
theta.min = np.array([-np.pi])  # rad
theta.initial = np.array([lon0])  # 0 deg
theta.final = [ox.Free(lon0 + 10.0 * np.pi / 180)]  # Free (reference guess: lon0+10 deg)
theta.guess = np.linspace(lon0, lon0 + 10.0 * np.pi / 180, n).reshape(-1, 1)

# φ: latitude (radians) - this is what we want to maximize (crossrange)
phi = ox.State("phi", shape=(1,))
phi.max = np.array([70.0 * np.pi / 180])  # rad (70 deg)
phi.min = np.array([-70.0 * np.pi / 180])  # rad (-70 deg)
phi.initial = np.array([lat0])  # 0 deg
phi.final = [
    ox.Maximize(lat0 + 10.0 * np.pi / 180)
]  # Maximize final latitude (reference guess: lat0+10 deg)
phi.guess = np.linspace(lat0, lat0 + 10.0 * np.pi / 180, n).reshape(-1, 1)
phi.scaling_max = np.array([15.0 * np.pi / 180])
phi.scaling_min = np.array([0.0 * np.pi / 180])

# v: velocity magnitude (m/s)
v = ox.State("v", shape=(1,))
v.max = np.array([45000.0])  # m/s
v.min = np.array([10.0])  # m/s
v.initial = np.array([speed0])  # m/s (reference uses 7802.88)
v.final = np.array([speedf])  # m/s
v.guess = np.linspace(speed0, speedf, n).reshape(-1, 1)
v.scaling_max = np.array([1e4])
v.scaling_min = np.array([0.0])

# γ: flight path angle (radians)
gamma = ox.State("gamma", shape=(1,))
gamma.max = np.array([80.0 * np.pi / 180])  # rad (80 deg)
gamma.min = np.array([-80.0 * np.pi / 180])  # rad (-80 deg)
gamma.initial = np.array([fpa0])  # -1 deg
gamma.final = np.array([fpaf])  # -5 deg
gamma.guess = np.linspace(fpa0, fpaf, n).reshape(-1, 1)

# ψ: heading angle (azimuth, radians)
psi = ox.State("psi", shape=(1,))
psi.max = np.array([np.pi])  # rad (180 deg)
psi.min = np.array([-np.pi])  # rad (-180 deg)
psi.initial = np.array([azi0])  # 90 deg (reference: +90 deg)
psi.final = [ox.Free(azif)]  # Free (reference guess: -90 deg)
psi.guess = np.linspace(azi0, azif, n).reshape(-1, 1)

# Define control components
# α: angle of attack (radians)
alpha = ox.Control("alpha", shape=(1,))
alpha.max = np.array([90.0 * np.pi / 180])  # rad (90 deg)
alpha.min = np.array([-90.0 * np.pi / 180])  # rad (-90 deg)
alpha.guess = np.repeat(np.array([[0.0]]), n, axis=0)

# σ: bank angle (radians)
sigma = ox.Control("sigma", shape=(1,))
sigma.max = np.array([1.0 * np.pi / 180])  # rad (1 deg)
sigma.min = np.array([-90.0 * np.pi / 180])  # rad (-90 deg)
sigma.guess = np.linspace(sigma.min, sigma.max, n)

# Define list of all states and controls
states = [h, theta, phi, v, gamma, psi]
controls = [alpha, sigma]

# Physical parameters
m = ox.Parameter("m", value=mass)  # kg

# Define dynamics as dictionary mapping state names to their derivatives
# Dynamics from Betts reference MATLAB code (using r = h + R_earth):
# \dot{r} = v sin γ
# θ̇ = (v cos γ sin ψ) / ((h + R_earth) cos φ)
# φ̇ = (v cos γ cos ψ) / (h + R_earth)
# v̇ = -D - g sin γ
# γ̇ = (L cos σ - cos γ (g - v²/r)) / v
# ψ̇ = (L sin σ / cos γ + v² cos γ sin ψ tan φ / r) / v

# Convert height to radial distance: r = h (h already represents r = altitude + R_earth)
r = h + R_earth

# Compute gravitational acceleration: g = μ / r^2
g = mu_earth / (r**2)  # m/s^2

# Aerodynamic model (matching Betts reference exactly)
# Lift and drag coefficients
CL = cl[0] + cl[1] * alpha[0]  # Linear lift coefficient: CL = cl0 + cl1*α
CD = (
    cd[0] + cd[1] * alpha[0] + cd[2] * alpha[0] ** 2
)  # Quadratic drag coefficient: CD = cd0 + cd1*α + cd2*α²

# Atmospheric density: ρ = ρ0 * exp(-altitude/H)
# Note: altitude = h - R_earth = r - R_earth, where r = h
# (h is already r = altitude + R_earth)
altitude = h  # Height above Earth surface
rho = rho0 * ox.Exp(-altitude / H)  # kg/m^3

# Dynamic pressure: q = 0.5 * ρ * v²
q = 0.5 * rho * v**2  # Pa

# Lift and drag forces (normalized by mass): L = q*S*CL/m, D = q*S*CD/m
# These are accelerations (m/s^2), matching the MATLAB code
L = q * S * CL / m  # m/s^2 (acceleration)
D = q * S * CD / m  # m/s^2 (acceleration)

# Dynamics equations matching MATLAB code exactly
# Note: The reference uses division by v and cos(gamma), which are bounded away from zero
# by the state bounds (v >= 10 m/s, |gamma| <= 80 deg, |phi| <= 70 deg)
dynamics = {
    "h": v * ox.Sin(gamma),
    "theta": (v * ox.Cos(gamma) * ox.Sin(psi)) / (r * ox.Cos(phi)),
    "phi": (v * ox.Cos(gamma) * ox.Cos(psi)) / r,
    "v": -D - g * ox.Sin(gamma),
    "gamma": (L * ox.Cos(sigma) - ox.Cos(gamma) * (g - v**2 / r)) / v,
    "psi": (
        L * ox.Sin(sigma) / ox.Cos(gamma) + v**2 * ox.Cos(gamma) * ox.Sin(psi) * ox.Tan(phi) / r
    )
    / v,
}

# Generate box constraints for all states
# Use separate idx for each constraint and huber penalty to prevent huge penalty accumulation
constraints = []

# State constraints - use huber penalty and separate groups to prevent penalty blowup
for state in states:
    constraints.extend(
        [
            ox.ctcs(state <= state.max),
            ox.ctcs(state.min <= state),
        ]
    )

# Build the problem
# Time is free (t_f is free)
time = ox.Time(
    initial=0.0,
    final=ox.Free(1000.0),  # Free final time with initial guess
    min=0.0,
    max=3000.0,  # Maximum time in seconds
)

problem = ox.Problem(
    dynamics=dynamics,
    states=states,
    controls=controls,
    time=time,
    constraints=constraints,
    N=n,
    algorithm={
        "autotuner": ox.AugmentedLagrangian(eta_lambda=1e2),
        "lam_vc": 2e0,
        "lam_prox": 5e-1,
        "lam_cost": 9e-1,
    },
    float_dtype="float64",
)

problem.algorithm.k_max = 500

if __name__ == "__main__":
    problem.initialize()
    results = problem.solve()
    results = problem.post_process()

    plot_scp_convergence_histories(results).show()

    plot_states(results).show()
    plot_controls(results).show()