Zero Order Hold: point to point low thrust transfer#

In this tutorial we show the use of the pykep.trajopt.zoh_point2point to find a low-thrust trajectory connecting two fixed points in space.
The underlying transcription, that is the process to transform an Optimal Control Problem (OCP) into a Non Linear Programming Problem (NLP), is based on pykep’s Zero Order Hold legs (pykep.leg.zoh, pykep.leg.zoh_ss), and was originally conceived as a generalization to the Sims-Flanagan transcription.

According to the encoding of the various segments, the decision vector for this problem will vary.

Fixed Time Grid Transcription#

In the fixed time grid formulation, the total time of flight is divided into N segments of equal duration.

The decision vector for this problem, compatible with [BI20] UDPs, is defined as:

\[ \mathbf x = [m_f] + [T_0, i_{x0}, i_{y0}, i_{z0}] + [T_1, i_{x1}, i_{y1}, i_{z1}] + \ldots + [tof] \]

where:

  • \( m_f \) is the final mass,

  • \( T_k \) is the thrust magnitude on segment \( k \),

  • \( (i_{xk}, i_{yk}, i_{zk}) \) define the thrust direction components,

  • \( tof \) is the total time of flight.

If the grid has \( N \) segments, then each segment duration is:

\[ \Delta t = \frac{tof}{N} \]

Thus, all segments have equal duration.

Variable-Length Segment Transcription (Softmax Encoding)#

In the second formulation, the total time of flight is still a decision variable, but the individual segment durations are also optimization variables.

Instead of optimizing segment durations directly (which would require enforcing positivity and a sum constraint), we introduce a softmax encoding. The decision vector becomes:

\[ \mathbf x = [m_f] + [T_0, i_{x0}, i_{y0}, i_{z0}] + [T_1, i_{x1}, i_{y1}, i_{z1}] + \ldots + [tof] + [w_0, w_1, \ldots, w_{N-1}] \]

where:

  • \( w_k \) is the weight associated with segment \( k \),

  • \( tof \) is the total time of flight,

  • \( N \) is the number of segments.

Relationship Between Weights and Segment Duration#

The segment durations are obtained by applying the softmax transformation to the weights:

\[ \alpha_k = \frac{e^{w_k}}{\sum_{j=0}^{N-1} e^{w_j}} \]

Each \(\alpha_k\) satisfies:

  • \(\alpha_k > 0\)

  • \(\sum_{k=0}^{N-1} \alpha_k = 1\)

The duration of segment \(k\) is then:

\[ \Delta t_k = tof \cdot \alpha_k \]

Therefore,

\[ \Delta t_k = tof \cdot \frac{e^{w_k}}{\sum_{j=0}^{N-1} e^{w_j}} \]

Each weight \( w_k \) controls the relative duration of segment \( k \).

Properties:

  • Increasing \( w_k \) increases \( \Delta t_k \) relative to the other segments.

  • If all weights are equal, all segments have equal duration.

  • Positivity and the sum-to-\( tof \) constraint are automatically enforced.

  • The optimizer can smoothly redistribute time among segments.

Why Use Softmax Encoding?#

The softmax-based variable segment formulation:

  • Avoids explicit equality constraints,

  • Improves feasibility handling,

  • Allows adaptive time distribution,

  • Should improve convergence for difficult low-thrust problems.

However, it introduces stronger nonlinear coupling between variables and may affect conditioning of the optimization problem.

Let’s now delve into both… First we setup a problem. Here we use the Keplerian (Cartesian) dynamics.

First some imports …

import pykep as pk 
import numpy as np
import pygmo as pg
import pygmo_plugins_nonfree as ppnf

… and the SQP solvers setup

#library_snopt72 = "/usr/local/lib/libsnopt7_c.so"
library_snopt72 = "/Users/dario.izzo/opt/libsnopt7_c.dylib"

snopt72 = ppnf.snopt7(library=library_snopt72, minor_version=2, screen_output=False)
snopt72.set_integer_option("Major iterations limit", 2000)
snopt72.set_integer_option("Iterations limit", 20000)
snopt72.set_numeric_option("Major optimality tolerance", 1e-10)
snopt72.set_numeric_option("Major feasibility tolerance", 1e-12)

slsqp = pg.nlopt("slsqp")
slsqp.xtol_abs=1e-10
slsqp.xtol_rel=1e-10
slsqp.ftol_abs=1e-10
algo = pg.algorithm(snopt72)

We can now setup the problem.

# Physical parameters
max_thrust = 0.22 # N
veff = 3000. * pk.G0 # m/s
mu = pk.MU_SUN # m^3/s^2

# Initial state (Cartesian)
ms = 1000.0 # kg
rs = np.array([1.2, 0.0, -0.01]) * pk.AU
vs = np.array([0.01, 1, -0.01]) * pk.EARTH_VELOCITY

# Final state (Cartesian)
rf = np.array([1, 0.0, -0.0]) * pk.AU
vf = np.array([0.01, 1.1, -0.0]) * pk.EARTH_VELOCITY

Then we instantiate the ZOH Taylor integrators, these need to be set in nondimensional units so we define those too!

# 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

# Non dimensional problem data
ms_nd = ms / MASS
rs_nd = [it / L for it in rs]
vs_nd = [it / V for it in vs]
rf_nd = [it / L for it in rf]
vf_nd = [it / V for it in vf]

# Instantiating the ZOH integrators
# Tolerances used in the numerical integration
# 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
ta.pars[4] = 1.0 / veff_nd
ta_var.pars[4] = 1.0 / veff_nd

Using a uniform time grid#

# Throttles and tof
nseg = 8
throttles = np.random.uniform(-1, 1, size=(nseg * 3))
tof = 2 * np.pi * np.sqrt(pk.AU**3 / pk.MU_SUN) / 4

udp = pk.trajopt.zoh_point2point(
    states=rs_nd + vs_nd,
    statef=rf_nd + vf_nd,
    ms=ms_nd,
    max_thrust=max_thrust / F,
    tof_bounds=[600 * pk.DAY2SEC / TIME, 700 * pk.DAY2SEC / TIME],
    mf_bounds=[0.2, 1],
    nseg=nseg,
    cut=0.6,
    tas=[ta, ta_var],
    time_encoding='uniform'
)
prob = pg.problem(udp)
prob.c_tol = 1e-6

Let’s check the correctness of the analytical gradients:

pop = pg.population(prob, 1)
(pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).min(), (pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).max()
(-9.2346332802861752e-09, 7.3932155397749e-09)
x = pop.champion_x
ax = udp.plot(x=x,N=10, c='k', s=5)
ax.set_xlim(-2.0,2.0)
ax.set_ylim(-2.0,2.0)
ax.view_init(90,0)
../_images/3417c09425b7a5d325f5a377ef9208de85a5e16b9665b18db2e8263599c80b56.png
pop = pg.population(prob, 1)
pop = algo.evolve(pop)
print(prob.feasibility_f(pop.champion_f))
print("Final mass:", pop.champion_x[0] * MASS)
print("Final tof:", pop.champion_x[4*nseg+1] * TIME * pk.SEC2DAY)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[8], line 2
      1 pop = pg.population(prob, 1)
----> 2 pop = algo.evolve(pop)
      3 print(prob.feasibility_f(pop.champion_f))
      4 print("Final mass:", pop.champion_x[0] * MASS)

ValueError: 
function: evolve_version
where: /home/conda/feedstock_root/build_artifacts/pygmo_plugins_nonfree_1772975327396/work/src/snopt7.cpp, 603
what: 
An error occurred while loading the snopt7_c library at run-time. This is typically caused by one of the following
reasons:

- The file declared to be the snopt7_c library, i.e. /Users/dario.izzo/opt/libsnopt7_c.dylib, is not a shared library containing the necessary C interface symbols (is the file path really pointing to
a valid shared library?)
 - The library is found and it does contain the C interface symbols, but it needs linking to some additional libraries that are not found
at run-time.

We report the exact text of the original exception thrown:

 
function: evolve_version
where: /home/conda/feedstock_root/build_artifacts/pygmo_plugins_nonfree_1772975327396/work/src/snopt7.cpp, 553
what: The snopt7_c library path was constructed to be: /Users/dario.izzo/opt/libsnopt7_c.dylib and it does not appear to be a file
x = pop.champion_x
ax = udp.plot(x=x, N=10, s=5, c='k')
ax.set_xlim(-2.0,2.0)
ax.set_ylim(-2.0,2.0)
ax.view_init(90,0)
../_images/6ebf2b27dc2dfb212e74e1d6662abecdd36ad143037c9c0af86eff639abb7ed7.png

Using the softmax encoding:#

# Throttles and tof
nseg = 8
udp = pk.trajopt.zoh_point2point(
    states=rs_nd + vs_nd,
    statef=rf_nd + vf_nd,
    ms=ms_nd,
    max_thrust=max_thrust / F,
    tof_bounds=[600 * pk.DAY2SEC / TIME, 700 * pk.DAY2SEC / TIME],
    mf_bounds=[0.2, 1],
    nseg=nseg,
    cut=0.6,
    tas=[ta, ta_var],
    time_encoding='softmax'
)
prob = pg.problem(udp)
prob.c_tol = 1e-6
pop = pg.population(prob, 1)

Let’s check the correctness of the analytical gradients

(pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).min(), (pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).max()
(-4.8853863454656477e-09, 6.8031940324286833e-09)
x = pop.champion_x
ax = udp.plot(x=x,N=10, s=5, c='k')
ax.set_xlim(-2.0,2.0)
ax.set_ylim(-2.0,2.0)
ax.view_init(90,0)
../_images/78527bcf73d97a9e8337a41ad37adbe2057ec68f96ff269e2e91e439e594576a.png
pop = algo.evolve(pop)
print(prob.feasibility_f(pop.champion_f))
print("Final mass:", pop.champion_x[0] * MASS)
print("Final tof:", pop.champion_x[4*nseg+1] * TIME * pk.SEC2DAY)
True
Final mass: 928.818877582
Final tof: 623.147255258
x = pop.champion_x
ax = udp.plot(x=x, N=20, s=5, c='k')
ax.set_xlim(-2.0,2.0)
ax.set_ylim(-2.0,2.0)
ax.view_init(90,0)
../_images/51b8fef4df09e2dac9884b1d680a383b67136ac29d76a62db50c86bc2f62ed30.png

A different dynamics: equinoctial elements#

The pykep.trajopt.zoh_point2point internally makes use of a pykep.leg.zoh which is will make use of a generic dynamics defined in the numerical integrators used and passed by the user via the kwarg tas. This is a rather powerful setup as it allows to use the very same ZOH transcription over a generic dynamics.

In the initial part of this notebook we have used a Cartesian dynamics. We now show how to instantiate the very same optimization problem, but in modified equinoctial elements.

# We instantiate ZOH Taylor integrators for the dynamics in equinoctial elements.
ta_eq = pk.ta.get_zoh_eq(tol)
ta_eq_var = pk.ta.get_zoh_eq_var(tol_var)

# We set the Taylor integrator parameters
veff_nd = veff / V
ta_eq.pars[4] = 1.0 / veff_nd
ta_eq_var.pars[4] = 1.0 / veff_nd

We use the softmax encoding allowing for variable length segments. Note that we have only changed the dynamics and the corresponding initial conditions.

# Throttles and tof
nseg = 8
states=pk.ic2mee([rs_nd, vs_nd], 1.)
statef=pk.ic2mee([rf_nd ,vf_nd], 1.)
statef[-1]+=2*np.pi
udp = pk.trajopt.zoh_point2point(
    states=states,
    statef=statef,
    ms=ms_nd,
    max_thrust=max_thrust / F,
    tof_bounds=[600 * pk.DAY2SEC / TIME, 700 * pk.DAY2SEC / TIME],
    mf_bounds=[0.2, 1],
    nseg=nseg,
    cut=0.6,
    tas=[ta_eq, ta_eq_var],
    time_encoding='softmax'
)
prob = pg.problem(udp)
prob.c_tol = 1e-6
pop = pg.population(prob, 1)

Let’s check the correctness of the analytical gradients (now using equinoctial elements)

(pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).min(), (pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).max()
(-4.8828083312746351e-09, 4.8903709241876481e-09)
x = pop.champion_x
ax = udp.plot(x=x,N=10, to_cartesian=lambda x: np.array(pk.mee2ic(x[:-1], mu = 1.)).flatten(), c='k', s=5)
ax.set_xlim(-2.0,2.0)
ax.set_ylim(-2.0,2.0)
ax.view_init(90,0)
../_images/13120eb43586def6b59021b26861c03114ed65b0705e49a1a1c5effe2d9ae0ae.png
pop = algo.evolve(pop)
print(prob.feasibility_f(pop.champion_f))
print("Final mass:", pop.champion_x[0] * MASS)
print("Final tof:", pop.champion_x[4*nseg+1] * TIME * pk.SEC2DAY)
True
Final mass: 929.422255631
Final tof: 622.584988339
x = pop.champion_x
ax = udp.plot(x=x,N=10, to_cartesian=lambda x: np.array(pk.mee2ic(x[:-1], mu = 1.)).flatten(), s=5, c='k')
ax.set_xlim(-2.0,2.0)
ax.set_ylim(-2.0,2.0)
ax.view_init(90,0)
../_images/e7b60d167fe7ea208fc2d96cf21d4f98cfe1784648c38ecb0c529cdf9a10fa75.png

A different dynamics: CR3BP#

We now show how to instantiate the very same optimization problem type (i.e. a point to point low-thrust problem) but in the CR3BP dynamics.

# Instantiating the ZOH integrators
# Tolerances used in the numerical integration
# Low tolerances result in higher speed (the needed tolerance depends on the orbit)
tol = 1e-16
tol_var = 1e-6

# We instantiate ZOH Taylor integrators for the CR3BP dynamics.
ta_cr3bp = pk.ta.get_zoh_cr3bp(tol)
ta_cr3bp_var = pk.ta.get_zoh_cr3bp_var(tol_var)

# We set the Taylor integrators parameters (veff and mu in this case)
veff_nd = 11.56499372183432
mu_cr3bp = 0.01215058560962404
ta_cr3bp.pars[4] = 1.0 / veff_nd
ta_cr3bp_var.pars[4] = 1.0 / veff_nd
ta_cr3bp.pars[5] = mu_cr3bp
ta_cr3bp_var.pars[5] = mu_cr3bp

We are ready to instantiate a pykep.trajopt.zoh_point2point representing this gym problem:

nseg=10
udp = pk.trajopt.zoh_point2point(
    states=[1.0809931218390707, 0.0, -0.20235953267405354, 0.0, -0.19895001215078018, 0.0], # mass is excluded in the states kwarg definition
    statef=[1.1648780946517576, 0.0, -0.11145303634437023, 0.0, -0.20191923237095796, 0.0], # mass is excluded in the states kwarg definition
    ms=1., # initial mass
    max_thrust=0.3010999584011414,
    tof_bounds=[5., 5.],
    mf_bounds=[0.8, 1],
    nseg=nseg,
    cut=0.6,
    tas=[ta_cr3bp, ta_cr3bp_var],
    time_encoding='uniform'
)
prob = pg.problem(udp)
prob.c_tol = 1e-6
pop = pg.population(prob, 1)

# For quick plotting purposes we create a decision vector corresponding to a ballistic leg 
x_ballistic = np.array([1.]+[0.0,0.0,0.0,1.]*nseg+[15.3]+[0.1]*nseg)

We check that the analytical gradients are correct …

(pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).min(), (pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).max()
(-2.3391790620053143e-06, 1.5331602894741447e-06)

Plot a random chromosome

ax = udp.plot(x=x_ballistic,N=100, mark_segments=False, mark_mismatch=False)
udp.plot(x=pop.champion_x,N=100, mark_segments=True, ax=ax, s=5, c='k')

ax.set_xlim(0.8,1.2)
ax.set_ylim(-1.0,2.0)
(-1.0, 2.0)
../_images/5349002b7f9cff975032d42efb4bca6bbbecc3e21a7f32166cc500f323fc5a03.png

Optimize.

pop = pg.population(prob, 1)
pop = algo.evolve(pop)
pop = algo.evolve(pop) # to refine a few final steps its sometime useful
print(prob.feasibility_f(pop.champion_f))
print("Final mass (nd):", pop.champion_x[0])
print("Final tof (nd):", pop.champion_x[4*nseg+1])
True
Final mass (nd): 0.928212868042
Final tof (nd): 5.0
ax = udp.plot(x=x_ballistic,N=100, mark_segments=False, mark_mismatch=False)
udp.plot(x=pop.champion_x,N=100, mark_segments=True, ax=ax, s=5, c='k')

ax.set_xlim(0.8,1.2)
ax.set_ylim(-0.5,0.5)
(-0.5, 0.5)
../_images/3f5a999c1695c0fc52702f2bcab5e41da93d22348299c267faaf870a113859dd.png

A different dynamics: Solar Sailing#

The dynamics of solar sailing is radically different than the low-thrust one as the spacecraft mass is no longer a state variable for the spacecarft and is constant. Besides, the controls are the sail clock and cone angles, which have a smaller dimensionality than the 3D low-thrust vector. For this reason and API development reasons, in pykep we provide a separate dedicated Solar Sailing trajectory leg in the class pykep.leg.zoh_ss, showcased in a corresponding leg tutorial.

This also means that we need an added class to instantiate a point to point transfer making use of this dynamics and we cannot use pykep.trajopt.zoh_point2point as we did above when the dynamics was equinoctial parameters or the CR3BP.

# Instantiating the ZOH integrators
# Tolerances used in the numerical integration
# Low tolerances result in higher speed (the needed tolerance depends on the orbit)
tol = 1e-10
tol_var = 1e-6

# We instantiate ZOH Taylor integrators for the CR3BP dynamics.
ta_ss = pk.ta.get_zoh_ss(tol)
ta_ss_var = pk.ta.get_zoh_ss_var(tol_var)

# We set the Taylor integrators parameters (c_sail in this case)
# c_sail is 2 C A / M. (C is the solar flux at reference distance L, A is the area, M the mass)
C = 5.4026*1E-6 # N/m^2 (Sun)
A = 120*120 # m^2
M = 500 # Kg
c_sail = 2 * C * A / M # m/s^2
MU = pk.MU_SUN
L = pk.AU
TIME = np.sqrt(L**3/MU)
c_sail_nd = c_sail / (L/TIME/TIME)
ta_ss.pars[2] = c_sail_nd
ta_ss_var.pars[2] = c_sail_nd
nseg=10
udp = pk.trajopt.zoh_ss_point2point(
    states=[1.,0.,0.,0.,1.,0.],
    statef=[1.03,0.,0.,0.,1.,0.], 
    tof_bounds=[0.1, 12.],
    nseg=nseg,
    cut=0.6,
    tas=[ta_ss, ta_ss_var],
    time_encoding='softmax'
)
prob = pg.problem(udp)
prob.c_tol = 1e-6
pop = pg.population(prob, 1)
(pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).min(), (pg.estimate_gradient_h(udp.fitness, pop.champion_x, dx=1e-6)-udp.gradient(pop.champion_x)).max()
(-8.1309493582537584e-09, 3.6341662734695745e-09)
ax = udp.plot(x=pop.champion_x,N=100, mark_segments=True, s=5, c='k', plot_sail=True)
ax.view_init(90,0)
../_images/a1a0e016ac088c4a0d2825256021a3d7c575056da52aa9ab3c5a8445ff818c07.png
pop = pg.population(prob, 1)
pop = algo.evolve(pop)
pop = algo.evolve(pop) # to refine a few final steps its sometime useful
print(prob.feasibility_f(pop.champion_f))
print("Final tof (nd):", pop.champion_x[2*nseg])
True
Final tof (nd): 6.50664557209
ax = udp.plot(x=pop.champion_x,N=100, mark_segments=True, s=5, c='k')
../_images/7c9cdc69b0c41ace4f591f15a520beea648a54fa6189a95af9a0a6c0df9896de.png