Skip to content

Hohmann Transfer

Hohmann transfer with impulsive delta-v controls (LEO → GEO).

This example mirrors the Hohmann transfer calculation from Orbital Mechanics & Astrodynamics by Bryan Weber and embeds it in a trajectory optimization problem that uses impulsive delta-v controls.

We consider a planar, two-body Earth-centered problem:

  • Initial circular orbit: 250 km altitude LEO
  • Final circular orbit: GEO with 1 sidereal-day period
  • 2D position/velocity states in an inertial frame (km, km/s)
  • Central gravity with mu = 3.986e5 km3/s2
  • Two impulsive delta-v maneuvers applied at the initial and final nodes
  • A scalar cost state that accumulates the L2 norm of each impulse and is minimized at the final time (approximate minimum-fuel objective)

Continuous dynamics between impulses:

x_dot = vx
y_dot = vy
vx_dot = -mu * x / r^3
vy_dot = -mu * y / r^3

Discrete dynamics at impulsive nodes:

  • position unchanged
  • velocity += delta_v
  • cost += ||delta_v||

The transfer time is fixed to the Hohmann half-period of the transfer ellipse. In the main block we also compute and print the analytic Hohmann Δv and propellant mass from Weber's example for comparison.

File: examples/spacecraft/hohmann_transfer.py

import os
import sys

import numpy as np

# Add grandparent directory to path to import examples.plotting
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 examples.plotting_viser import create_hohmann_transfer_server
from openscvx import Problem
from openscvx.plotting import plot_controls, plot_scp_iterations, plot_states

# Problem configuration (match Weber's LEO → GEO example)
n = 15

mu = 3.986e5  # km^3 / s^2
R_E = 6378.0  # km
r_leo = 250.0 + R_E

sidereal_day = 86164.0905  # s
r_cubed = mu * sidereal_day**2 / (4 * np.pi**2)
r_geo = r_cubed ** (1.0 / 3.0)

r1 = r_leo
r2 = r_geo

# Hohmann transfer half-period for the transfer ellipse (fixed final time)
a_transfer = 0.5 * (r1 + r2)
T_transfer = np.pi * np.sqrt(a_transfer**3 / mu)

# States: planar position, planar velocity, and scalar accumulated cost
position = ox.State("position", shape=(2,))
position.initial = np.array([r1, 0.0])
position.final = np.array([-r2, 0.0])  # Opposite side for half-period transfer
position.min = np.array([-(1.5 * r2), -(1.5 * r2)])
position.max = np.array([1.5 * r2, 1.5 * r2])

# Initial guess: follow an approximate Hohmann-like arc that never passes
# through the origin (to avoid r ≈ 0 causing singular dynamics).
theta_guess = np.linspace(0.0, np.pi, n)
radius_guess = np.linspace(r1, r2, n)
position_guess = np.stack(
    [radius_guess * np.cos(theta_guess), radius_guess * np.sin(theta_guess)],
    axis=1,
)
position.guess = position_guess

velocity = ox.State("velocity", shape=(2,))
v_c1 = np.sqrt(mu / r1)
v_c2 = np.sqrt(mu / r2)
velocity.initial = np.array([0.0, v_c1])
velocity.final = np.array([0.0, -v_c2])
velocity_min_mag = -2.0 * max(v_c1, v_c2)
velocity_max_mag = 2.0 * max(v_c1, v_c2)
velocity.min = np.array([velocity_min_mag, velocity_min_mag])
velocity.max = np.array([velocity_max_mag, velocity_max_mag])
velocity.guess = np.tile(np.array([0.0, (v_c1 + v_c2) / 2.0]), (n, 1))

cost = ox.State("cost", shape=(1,))
cost.initial = np.array([0.0])
# Minimize final accumulated impulse norm with a loose upper bound
cost.final = [("minimize", 10.0)]
cost.min = np.array([0.0])
cost.max = np.array([10.0])
cost.guess = np.zeros((n, 1))

# Impulsive delta-v control applied only at the first and last nodes
dv = ox.Control(
    "delta_v",
    shape=(2,),
    parameterization="impulsive",
    nodes=[0, n - 1],
)
dv_bound = 5.0  # km/s
dv.min = np.array([-dv_bound, -dv_bound])
dv.max = np.array([dv_bound, dv_bound])
dv.guess = np.zeros((n, 2))

states = [position, velocity, cost]
controls = [dv]

# Continuous dynamics: planar two-body gravity with no continuous thrust.
# We clamp the radius away from zero for robustness in the linearization.
r = ox.linalg.Norm(position)

dynamics = {
    "position": velocity,
    "velocity": ox.Concat(
        -mu * position[0] / r**3,
        -mu * position[1] / r**3,
    ),
    "cost": 0.0,
}

# Discrete dynamics at impulsive nodes: update velocity and accumulate cost.
# Use a small epsilon inside the norm to avoid NaNs in derivatives at dv = 0.
eps_impulse = 1e-6
d_impulse = ox.linalg.Norm(dv + eps_impulse)

dynamics_discrete = {
    "position": position,
    "velocity": velocity + dv,
    "cost": cost + d_impulse,
}

# Box constraints on all states
constraints = []
for state in states:
    constraints.extend([ox.ctcs(state <= state.max), ox.ctcs(state.min <= state)])

time = ox.Time(
    initial=0.0,
    final=T_transfer,
    min=0.0,
    max=T_transfer,
)

problem = Problem(
    dynamics=dynamics,
    dynamics_discrete=dynamics_discrete,
    states=states,
    controls=controls,
    time=time,
    constraints=constraints,
    N=n,
)

problem.discretizer.ode_solver = "Dopri8"
problem.settings.prp.dt = 10.0

plotting_dict = {}

if __name__ == "__main__":
    # Analytic Hohmann values from Weber's example for reference
    v_leo = np.sqrt(mu / r_leo)
    v_geo = np.sqrt(mu / r_geo)
    r_p = r_leo
    r_a = r_geo
    h_t = np.sqrt(2 * mu * r_a * r_p / (r_a + r_p))
    v_tp = h_t / r_p
    v_ta = h_t / r_a

    Delta_v_analytic = abs(v_geo - v_ta) + abs(v_tp - v_leo)
    I_sp = 450.5  # s
    goes_mass = 5192.0  # kg
    Delta_m_analytic = goes_mass * (1.0 - np.exp(-Delta_v_analytic / (I_sp * 9.81e-3)))

    print(f"Analytic Hohmann Δv ≈ {Delta_v_analytic:.3f} km/s")
    print(f"Analytic propellant mass ≈ {Delta_m_analytic:.3f} kg")

    problem.initialize()
    results = problem.solve()
    results = problem.post_process()
    results.update(plotting_dict)

    # Always show standard Plotly diagnostics.
    plot_states(results).show()
    plot_controls(results).show()
    plot_scp_iterations(results).show()

    server = create_hohmann_transfer_server(
        results,
        r1=r1,
        r2=r2,
        scene_scale=1000.0,  # km -> Mm-ish for nicer viewing
    )
    server.sleep_forever()