Zero Order Hold: planet to planet low thrust transfer#
In this tutorial we demonstrate the use of the zoh_pl2pl class to find a low-thrust trajectory connecting two moving planets using the Zero-Order Hold (ZOH) direct method with free departure and arrival velocities.
Key Features#
The zoh_pl2pl class offers several features that make it a powerful tool for low-thrust trajectory optimization:
Free departure and arrival velocities - The spacecraft can depart and arrive with excess velocity relative to the planets
Non-dimensional formulation - Works internally with non-dimensional units for better numerical conditioning
Taylor-adaptive integration - Uses heyoka as Taylor propagator for accurate trajectory propagation
Analytical gradients - Optional analytical gradient computation for faster optimization
Flexible time encoding - Supports both
uniformandsoftmaxtime grid encodings
Decision Vector#
The decision vector for this class is:
Where:
\(t_0\) is the departure epoch (MJD2000, in days)
\(m_f\) is the final spacecraft mass (non-dimensional)
\(v^{\infty}_{\text{dep/arr}}\) are the magnitudes of departure/arrival excess velocities (non-dimensional)
\(\hat{i}_{\text{dep/arr}}\) are unit direction vectors for the excess velocities
controls = \([T, \hat{i}_x, \hat{i}_y, \hat{i}_z] \times n_{\text{seg}}\) where \(T\) is thrust magnitude and \(\hat{i}\) is thrust direction
\(T_{\text{tof}}\) is the time of flight (non-dimensional)
\(\mathbf{w}\) are softmax weights (only for softmax encoding)
Note
This notebook can use the commercial solver SNOPT 7 or the open-source IPOPT solver. Both are demonstrated below.
Basic Imports#
import pykep as pk
import numpy as np
import time
import pygmo as pg
try:
import pygmo_plugins_nonfree as ppnf
has_snopt = True
except:
has_snopt = False
print("SNOPT not available, will use IPOPT instead")
from matplotlib import pyplot as plt
Problem Definition#
We define the problem parameters including planets, spacecraft characteristics, and non-dimensional scaling factors.
# Problem data
mu = pk.MU_SUN
max_thrust = 0.6
isp = 3000
veff = isp * pk.G0
# Initial mass
ms = 1500.0
# Number of segments
nseg = 30
# Non dimensional units
L = pk.AU
MU = mu # (central body mu must be 1 in these units as a requirement of the ZOH integrator used)
TIME = np.sqrt(L**3 / MU)
V = L / TIME
ACC = V / TIME
MASS = ms
F = MASS * ACC
ms_nd = ms / MASS
# Source and destination planets
earth = pk.planet_to_keplerian(
pk.planet(pk.udpla.jpl_lp(body="EARTH")),
when=pk.epoch(5000),
)
mars = pk.planet_to_keplerian(
pk.planet(pk.udpla.jpl_lp(body="MARS")),
when=pk.epoch(5000),
)
# Low tolerances result in higher speed (the needed tolerance depends on the orbital regime)
tol = 1e-10
tol_var = 1e-6
# We instantiate ZOH Taylor integrators for Keplerian dynamics.
ta = pk.ta.get_zoh_kep(tol)
ta_var = pk.ta.get_zoh_kep_var(tol_var)
# We set the Taylor integrator parameters
veff_nd = veff / V
# Creating Taylor-Adaptive Integrators
# The ZOH method requires Taylor-adaptive integrators for high-precision trajectory propagation. We create two integrators:
# 1. Nominal dynamics integrator (for fitness evaluation)
# 2. Variational dynamics integrator (for gradient computation)
ta.pars[4] = 1.0 / veff_nd
ta_var.pars[4] = 1.0 / veff_nd
print(f"Nominal integrator state dimension: {len(ta.state)}")
print(f"Variational integrator state dimension: {len(ta_var.state)}")
Nominal integrator state dimension: 7
Variational integrator state dimension: 84
udp_uniform = pk.trajopt.zoh_pl2pl(
pls=earth,
plf=mars,
ms=ms_nd,
nseg=nseg,
cut=0.6,
t0_bounds=[7360, 8300.0],
tof_bounds=[100.*pk.DAY2SEC/TIME, 350.*pk.DAY2SEC/TIME],
mf_bounds=[2/3,1.],
vinf_dep_bounds = [0., 0.],
vinf_arr_bounds = [0., 0.],
tas = (ta,None),
max_thrust=max_thrust/F,
# w_bounds_softmax=[-1., 1.],
time_encoding='uniform',
)
udp_g_uniform = pk.trajopt.zoh_pl2pl(
pls=earth,
plf=mars,
ms=ms_nd,
nseg=nseg,
cut=0.6,
t0_bounds=[7360, 8300.0],
tof_bounds=[100.*pk.DAY2SEC/TIME, 350.*pk.DAY2SEC/TIME],
mf_bounds=[2/3,1.],
vinf_dep_bounds = [0., 0.],
vinf_arr_bounds = [0., 0.],
tas = (ta,ta_var),
max_thrust=max_thrust/F,
# w_bounds_softmax=[-1., 1.],
time_encoding='uniform',
)
print("UDP instances created successfully")
print(f"With gradient: {udp_g_uniform.has_gradient()}")
print(f"Without gradient: {udp_uniform.has_gradient()}")
UDP instances created successfully
With gradient: True
Without gradient: False
Part 1: Gradient vs No-Gradient Comparison#
We first compare the performance of the UDP with and without analytical gradients. Both use uniform time encoding.
Gradient Performance Analysis#
Let’s compare the performance of analytical gradients versus numerical gradients. The analytical gradients use variational equations integrated alongside the dynamics, providing exact derivatives.
# Create a problem and population to test gradient
prob_g = pg.problem(udp_g_uniform)
pop_g = pg.population(prob_g, 1)
x_test = pop_g.champion_x
print("\n=== Gradient Performance Comparison ===")
print(f"\nDecision vector dimension: {len(x_test)}")
print(f"Fitness dimension: {len(prob_g.fitness(x_test))}")
print("\nAnalytical gradient (from variational equations + chain rule):")
%timeit udp_g_uniform.gradient(x_test)
print("\nNumerical gradient (simple finite difference):")
%timeit pg.estimate_gradient(udp_g_uniform.fitness, x_test)
print("\n✓ The analytical gradient is significantly faster!")
=== Gradient Performance Comparison ===
Decision vector dimension: 131
Fitness dimension: 40
Analytical gradient (from variational equations + chain rule):
310 μs ± 1.33 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Numerical gradient (simple finite difference):
12.2 ms ± 279 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
✓ The analytical gradient is significantly faster!
Solving with and without Gradients#
Now we solve the optimization problem using both approaches to compare convergence.
# Setup optimization algorithm
if has_snopt:
# Use SNOPT if available
library_snopt72 = "/Users/dario.izzo/opt/libsnopt7_c.dylib"
uda = ppnf.snopt7(library=library_snopt72, minor_version=2, screen_output=False)
uda.set_integer_option("Major iterations limit", 1000)
uda.set_integer_option("Iterations limit", 10000)
uda.set_numeric_option("Major optimality tolerance", 1e-3)
uda.set_numeric_option("Major feasibility tolerance", 1e-9)
algo_name = "SNOPT7"
else:
# Use IPOPT as fallback
uda = pg.ipopt()
uda.set_numeric_option("tol", 1e-8)
uda.set_numeric_option("constr_viol_tol", 1e-8)
uda.set_integer_option("max_iter", 2000)
algo_name = "IPOPT"
algo = pg.algorithm(uda)
print(f"Using {algo_name} optimizer")
Using SNOPT7 optimizer
Optimization with Gradients#
# Solve with gradients
prob_g_uniform = pg.problem(udp_g_uniform)
prob_g_uniform.c_tol = 1e-6
print("\n=== Optimization WITH Gradients (Uniform) ===")
masses = []
xs=[]
for i in range(10):
pop_g = pg.population(prob_g_uniform, 1)
pop_g = algo.evolve(pop_g)
if(prob_g_uniform.feasibility_f(pop_g.champion_f)):
print(".", end="")
masses.append(pop_g.champion_x[1])
xs.append(pop_g.champion_x)
else:
print("x", end ="")
print("\nBest mass is: ", np.max(masses)*MASS)
print("Worst mass is: ", np.min(masses)*MASS)
best_idx = np.argmax(masses)
=== Optimization WITH Gradients (Uniform) ===
..........
Best mass is: 1233.36286109
Worst mass is: 1207.65469155
Optimization without Gradients#
prob_uniform = pg.problem(udp_uniform)
prob_uniform.c_tol = 1e-6
print("\n=== Optimization WITHOUT Gradients (Uniform) ===")
masses_no_grad = []
for i in range(10):
pop = pg.population(prob_uniform, 1)
pop = algo.evolve(pop)
if(prob_uniform.feasibility_f(pop.champion_f)):
print(".", end="")
masses_no_grad.append(pop.champion_x[1])
else:
print("x", end ="")
print("\nBest mass is: ", np.max(masses_no_grad)*MASS)
print("Worst mass is: ", np.min(masses_no_grad)*MASS)
=== Optimization WITHOUT Gradients (Uniform) ===
.x..xx.xx.
Best mass is: 1229.03971614
Worst mass is: 1178.63457089
if len(xs) > 0:
fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111, projection='3d')
t0 = udp_g_uniform.compute_t0(xs[best_idx])
ax=pk.plot.add_planet_orbit(ax=ax, pla=earth, label='earth', units=1., color='green')
ax=pk.plot.add_planet_orbit(ax=ax, pla=mars, label='mars', units=1., color='orange')
ax = udp_g_uniform.plot(xs[best_idx], ax=ax, N=20, c='k', s=5)
ax=pk.plot.add_planet(ax=ax, when=t0, pla=earth, units=1., color='darkgreen', s=20)
ax=pk.plot.add_planet(ax=ax, when=t0+xs[best_idx][10+4*udp_g_uniform.nseg]*udp_g_uniform.TIME/pk.DAY2SEC, pla=mars, units=1., color='darkorange', s=20)
ax.set_title("Best ZOH Transfer Trajectory (with gradients, uniform)")
ax.legend()
udp_g_uniform.plot_throttle(xs[best_idx])
<Axes: xlabel='time grid', ylabel='throttle value (nd)'>
Part 2: Time Encoding Comparison (Uniform vs Softmax)#
Now we compare the two time encoding methods: uniform and softmax.
Time Encoding Explanation#
Uniform Encoding: Time grid nodes are evenly spaced from 0 to TOF: $\(t_i = \frac{i \cdot T_{\text{tof}}}{n_{\text{seg}}}, \quad i = 0, 1, ..., n_{\text{seg}}\)$
Softmax Encoding: Time grid nodes are determined by learnable weights \(\mathbf{w}\): $\(\Delta t_i = T_{\text{tof}} \cdot \text{softmax}(\mathbf{w})_i, \quad t_i = \sum_{j=0}^{i-1} \Delta t_j\)$
The softmax encoding adds \(n_{\text{seg}}\) additional decision variables but allows the optimizer to adaptively distribute time where it’s most needed.
For a better discussion on the softmax encoding see also the tutorial: udp_zoh_point2point.ipynb.
Create UDP with Softmax Encoding#
udp_g_softmax = pk.trajopt.zoh_pl2pl(
pls=earth,
plf=mars,
ms=ms_nd,
nseg=nseg,
cut=0.6,
t0_bounds=[7360, 8300.0],
tof_bounds=[100.*pk.DAY2SEC/TIME, 350.*pk.DAY2SEC/TIME],
mf_bounds=[2/3,1.],
vinf_dep_bounds = [0., 0.],
vinf_arr_bounds = [0., 0.],
tas = (ta,ta_var),
max_thrust=max_thrust/F,
w_bounds_softmax=[-1., 1.],
time_encoding='softmax',
)
print("UDP with softmax encoding created successfully")
print(f"Decision vector size:")
print(f"With softmax: {len(udp_g_softmax.get_bounds()[0])} (includes softmax weights)")
print(f"Without softmax: {len(udp_g_uniform.get_bounds()[0])}")
UDP with softmax encoding created successfully
Decision vector size:
With softmax: 161 (includes softmax weights)
Without softmax: 131
Solve with Softmax Encoding#
#solve with gradients:
prob_g_softmax = pg.problem(udp_g_softmax)
prob_g_softmax.c_tol = 1e-6
print("\n=== Optimization WITH Gradients (Softmax) ===")
masses_softmax = []
xs_softmax = []
for i in range(10):
pop_g_sm = pg.population(prob_g_softmax, 1)
pop_g_sm = algo.evolve(pop_g_sm)
if(prob_g_softmax.feasibility_f(pop_g_sm.champion_f)):
print(".", end="")
masses_softmax.append(pop_g_sm.champion_x[1])
xs_softmax.append(pop_g_sm.champion_x)
else:
print("x", end ="")
print("\nBest mass is: ", np.max(masses_softmax)*MASS)
print("Worst mass is: ", np.min(masses_softmax)*MASS)
best_idx_sm = np.argmax(masses_softmax)
=== Optimization WITH Gradients (Softmax) ===
..x.....x.
Best mass is: 1233.97957177
Worst mass is: 1215.06076073
Display Best Solution with Softmax#
if len(xs) > 0:
fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111, projection='3d')
t0 = udp_g_softmax.compute_t0(xs_softmax[best_idx_sm])
ax=pk.plot.add_planet_orbit(ax=ax, pla=earth, label='earth', units=1., color='green')
ax=pk.plot.add_planet_orbit(ax=ax, pla=mars, label='mars', units=1., color='orange')
ax = udp_g_softmax.plot(xs_softmax[best_idx_sm], ax=ax, N=20, c='k', s=5)
ax=pk.plot.add_planet(ax=ax, when=t0, pla=earth, units=1., color='darkgreen', s=20)
ax=pk.plot.add_planet(ax=ax, when=t0+xs_softmax[best_idx_sm][10+4*udp_g_softmax.nseg]*udp_g_softmax.TIME/pk.DAY2SEC, pla=mars, units=1., color='darkorange', s=20)
ax.set_title("Best ZOH Transfer Trajectory (with gradients, uniform)")
ax.legend()
udp_g_softmax.pretty(xs_softmax[best_idx_sm])
Low-thrust ZOH transfer (free velocities, non-dimensional formulation)
Departure: earth(jpl_lp)(K)
Arrival: mars(jpl_lp)(K)
Launch epoch: 7452.11294 MJD2000, a.k.a. 2020-05-27T02:42:37.988699
Arrival epoch: 7802.11294 MJD2000, a.k.a. 2021-05-12T02:42:37.988699
Time of flight: 6.02073 (nd), 350.00000 days
Departure excess velocity:
Magnitude: 0.000000 (nd), 0.000000 km/s
Vector (nd): [-0.000000, -0.000000, 0.000000]
Vector (km/s): [-0.000000, -0.000000, 0.000000]
Direction: [-0.458249, -0.152246, 0.875688], norm: 1.000000
Arrival excess velocity:
Magnitude: 0.000000 (nd), 0.000000 km/s
Vector (nd): [-0.000000, 0.000000, 0.000000]
Vector (km/s): [-0.000000, 0.000000, 0.000000]
Direction: [-0.145760, 0.987435, 0.061043], norm: 1.000000
Final mass: 0.822653 (nd)
Scaling factors: L=149.598 Gm, V=29.785 km/s, TIME=58.132 days
Details on the ZOH leg:
Dynamics dimension: 7
Control dimension: 4
Number of segments: 30
Number of fwd segments: 18
Number of bck segments: 12
Cut parameter: 0.6
Variational integrator available: true
Maximum propagation steps: none
Initial state: [-0.41228838581227, -0.925570767758029, 2.8874712003856994e-05, 0.8973183813042093, -0.4107088381114756, 1.2812741965299295e-05, 1]
Final state: [-1.0344479955409878, 1.2809107326753661, 0.05223326215793006, -0.6021376946466708, -0.44184620554207943, 0.005524194056897131, 0.8226530478482634]
Time grid: [0, 0.1685490906988014, 0.33732802292945674, 0.503593188137969, 0.6671175546904856, 0.8302544649598823, 0.9933465232375999, 1.1569553904182452, 1.1988311512049292, 1.313949623571403, 1.4302554744611404, 1.6665154548116774, 1.8647987198274172, 2.1423042289218364, 2.447150077222255, 2.6559470918030947, 2.8757686231015436, 3.098569383413903, 3.3776407954400094, 3.563512676366831, 3.8607079525468526, 4.142042764683886, 4.345008366997576, 4.654430712635784, 4.8511154486701225, 5.08037077668507, 5.296174810752023, 5.463525174773438, 5.650075440410627, 5.835537225325473, 6.02073463248503]
Control values: [0.06745267560754033, 0.9108248640320349, -0.28513347431941516, -0.29849115720547403, 0.06745267560754033, 0.9548049454435172, -0.14201926610668633, -0.26110925770737275, 0.06745267560754033, 0.9760461288945445, 0.005177118466990535, -0.21750207353195697, 0.06745267560754033, 0.973740911554742, 0.15282661848975584, -0.16873844260517037, 0.06745267560754033, 0.9477550579707571, 0.2973243852611719, -0.11557925602030741, 0.06745267560754033, 0.8983118843217188, 0.4354129610071857, -0.05874788177724918, 0.06745267560754033, 0.8265801657065955, 0.5628187248628127, 0.0005600591667233601, 0, 0.5464471413223676, -0.7883826412699249, -0.28257447281180464, 0.06745267560754033, 0.7241542989164973, 0.6863533522427722, 0.06722820453371321, 0.06745267560754033, 0.6484537202463332, 0.7533739850938446, 0.10924933442412793, 0, 0.2562456545193487, 0.9033449912099412, 0.3439563800772957, 0, 0.07885949971211509, 0.8440556644412343, 0.5304255033742449, 0, 0.19203596493002237, 0.9386706458960573, 0.2863906540138387, 0, -0.24138615554125953, 0.4708729904059395, -0.8485348259318405, 0.06745267560754033, -0.22752952087966508, 0.8815545763154753, 0.4136325013739846, 0.06745267560754033, -0.36077440579069525, 0.8239262469475226, 0.4370210152423137, 0.06745267560754033, -0.4845542700275393, 0.7493280416183028, 0.45134759061880986, 0, -0.6131741386631627, 0.6789427432607051, 0.40379973631531735, 0, -0.7834205452466179, 0.4260920205107331, 0.4524354532334673, 0, -0.8532284854340219, 0.326182378872719, 0.40694742579008725, 0, -0.9185736936084746, 0.15421994713434195, 0.36392111413926753, 0, -0.9449751000935996, 0.021033457863548398, 0.3264653945725246, 0, -0.9608982013128101, -0.06713308268988197, 0.26864064458372855, 0, -0.9517989526589168, -0.1455184234778065, 0.270005818726047, 0, -0.9399170869818104, -0.2897698548830744, 0.18052506972902596, 0, -0.9072286308556379, -0.4006918908645311, 0.12799304649554832, 0, -0.8897223216075358, -0.44172327284520796, 0.11521606077379296, 0.06745267560754033, -0.8574394389752797, -0.5071784871419585, 0.0869919122561812, 0.06745267560754033, -0.8097016612572941, -0.5853825636332625, 0.04135789251476087, 0.06745267560754033, -0.7544142325999624, -0.6563904804467803, -0.0032719337987038937]
Mismatch constraints: [-3.4107927593396425e-10, -8.086553648922745e-11, 3.6761030851317766e-11, -2.948822297454967e-10, 1.4732881581380752e-11, 6.848102640510945e-11, -7.053346795515836e-11]
udp_g_softmax.plot_throttle(xs_softmax[best_idx_sm])
<Axes: xlabel='time grid', ylabel='throttle value (nd)'>