The Zero-Order-Hold trajectory leg#

The Zero-Order-Hold (ZOH, parameterized by the cut parameter) trajectory leg is implemented in pykep as pykep.leg.zoh. The method is described in [IHA+26] as a ZOH \(_\alpha\) transcription. Conceptually, it extends the Sims-Flanagan leg by modeling thrust as a piecewise-constant continuous acceleration over each segment, while supporting generic dynamics and nonuniform segment durations.

Given an initial state \(\mathbf{x}_0\), a final state \(\mathbf{x}_f\), and a time grid \(\{t_k\}_{k=0}^{N}\), the leg is partitioned into \(N\) segments. On each segment \(k\), the control is represented by $\( \mathbf{u}_k = [T_k,\ i_{x,k},\ i_{y,k},\ i_{z,k}], \)\( where \)T_k\( is the throttle magnitude and \)(i_{x,k}, i_{y,k}, i_{z,k})\( defines the throttle direction in the selected frame. The full control vector is \)\( \mathbf{u} = [\mathbf{u}_0,\mathbf{u}_1,\ldots,\mathbf{u}_{N-1}]. \)$

A forward-backward shooting construction enforces continuity at the matching point, yielding the mismatch (defect) constraints. The class provides these constraints and their analytical Jacobians with respect to states, controls, and time-grid variables, making it suitable for gradient-based trajectory optimization (including multiphase and moving-endpoint formulations).

Throttle constraints, and corresponding gradients, are also available, in the form \(|\mathbf i_k| \le 1 \)

We start with the required imports:

import pykep as pk
import heyoka as hy
import numpy as np
import pygmo as pg
from copy import deepcopy

#%matplotlib ipympl
%matplotlib inline
# Tolerances used in the numerical integration
# Lower tolerances are faster but less accurate (required tolerance depends on regime).
tol=1e-10
tol_var = 1e-6

# Instantiate ZOH Taylor integrators for Keplerian dynamics (provided by pykep).
# Contract with leg.zoh: state dimension = 7; first 4 parameters are controls;
# variational dynamics are first-order sensitivities w.r.t. state and controls.
ta_global = pk.ta.get_zoh_kep(tol)
ta_var_global = pk.ta.get_zoh_kep_var(tol_var)

# Non-dimensional units (the ZOH Taylor integrator in pykep expects MU=1).
L = pk.AU
MU = pk.MU_SUN
TIME = np.sqrt(L**3/MU)
V =  L/TIME
ACC = V/TIME
MASS = 1000
F = MASS*ACC

1 - Validate the Zero-Order-Hold leg#

We first validate the implementation by comparing analytical gradients from pykep.leg.zoh against finite-difference estimates.

This section also illustrates the standard construction workflow: define boundary states, build controls and time grid, instantiate the leg, then query constraints and Jacobians.

We start with two helper functions used for numerical differentiation:

# These assume some copy of the leg as they will modify it.
def compute_mismatch_constraints_n(leg_mod, state0, controls, state1, tgrid):
   leg_mod.tgrid = tgrid
   leg_mod.state0 = state0
   leg_mod.state1 = state1
   leg_mod.controls = controls
   leg_mod.state1 = state1
   return leg_mod.compute_mismatch_constraints()

def compute_throttle_constraints_n(leg_mod, controls):
    leg_mod.controls = controls
    return leg_mod.compute_throttle_constraints()

We now instantiate a representative leg by selecting the number of segments, the cut, the effective exhaust velocity, and the control vector.

To obtain physically meaningful endpoints, we construct them from a Lambert transfer between two Solar System planets. This choice is only for realism in the example; the method itself is general.

# Create a Lambert leg 
t0 = 10000
t1 = 10400
pl0 = pk.planet(pk.udpla.jpl_lp("Earth"))
pl1 = pk.planet(pk.udpla.jpl_lp("Mars"))
r0, v0 = pl0.eph(t0)
r1, v1 = pl1.eph(t1)
# We create some starting conditions from a Lambert arc
l = pk.lambert_problem(r0=r0, r1=r1, tof = (t1-t0) * pk.DAY2SEC, mu = pk.MU_SUN)
m0 = 1000
m1 = 1000

# leg random data
nseg = int(np.random.uniform(4, 20))
nseg=10
veff = np.random.uniform(4000, 8000) * pk.G0
controls = np.random.uniform(-1,1, (4*nseg,))
controls[0::4] /= (F)  # force will be in [-1, 1] N
controls[0::4] = np.abs(controls[0::4]) # force will be in [0.s, 1] N
cut = np.random.uniform(0,1)

# putting all in nd units
state0 = [it/L for it in r0] + [it/V for it in l.v0[0]] + [m0/MASS]
state1 = [it/L for it in r1] + [it/V for it in l.v1[0]] + [m1/MASS]
veff_nd = veff / V
tgrid = np.linspace(t0*pk.DAY2SEC/TIME, t1*pk.DAY2SEC/TIME, nseg+1)

# Setting the integrator parameters
ta_global.pars[4] = 1. / veff_nd
ta_var_global.pars[4] = 1. / veff_nd

# Instantiate the leg
leg = pk.leg.zoh(state0, controls.tolist(), state1, tgrid, cut = cut, tas = [ta_global, ta_var_global])
#leg = pk.leg.zoh_py(state0, controls.tolist(), state1, tgrid, cut = cut, tas = [ta_global, ta_var_global])

We now compute and store the analytical Jacobians: mismatch-constraint Jacobians and throttle-constraint Jacobian.

grad_an_mc = leg.compute_mc_grad()
tcgrad_an_tc = leg.compute_tc_grad()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[5], line 2
      1 grad_an_mc = leg.compute_mc_grad()
----> 2 tcgrad_an_tc = leg.compute_tc_grad()

AttributeError: 'zoh' object has no attribute 'compute_tc_grad'

… and compare with numerical estimates. We use pagmo finite differences as a consistency check.

# Check on dmc/dx0
leg_copy = deepcopy(leg)
grad_num = pg.estimate_gradient(lambda x: compute_mismatch_constraints_n(leg_copy, x, leg_copy.controls, leg_copy.state1, leg_copy.tgrid), leg.state0).reshape(7,-1)
np.linalg.norm(grad_num-grad_an_mc[0])
1.7472969614811818e-07
# Check on dmc/dxf
leg_copy = deepcopy(leg)
grad_num = pg.estimate_gradient(lambda x: compute_mismatch_constraints_n(leg_copy, leg_copy.state0, leg_copy.controls, x, leg_copy.tgrid), leg.state1).reshape(7,-1)
np.linalg.norm(grad_num-grad_an_mc[1])
3.3313971559876023e-07
# Check on dmc/dcontrols
leg_copy = deepcopy(leg)
grad_num = pg.estimate_gradient(lambda x: compute_mismatch_constraints_n(leg_copy, leg_copy.state0, x, leg_copy.state1, leg_copy.tgrid), leg.controls, dx=1e-8).reshape(7,-1)
np.linalg.norm(grad_num-grad_an_mc[2])
2.4101124140165991e-07
leg_copy = deepcopy(leg)
grad_num = pg.estimate_gradient(lambda x: compute_mismatch_constraints_n(leg_copy, leg_copy.state0, leg_copy.controls, leg_copy.state1, x), leg.tgrid).reshape(7,-1)
np.linalg.norm(grad_num-grad_an_mc[3])
2.5313263390850063e-08
leg_copy = deepcopy(leg)
grad_num = pg.estimate_gradient(lambda x: compute_throttle_constraints_n(leg_copy, x), leg.controls).reshape(nseg,-1)
np.linalg.norm(grad_num-tcgrad_an_tc)
2.9570551748922567e-08

The differences are small and consistent with finite-difference truncation/roundoff errors. In practice, analytical Jacobians are preferable for accuracy and efficiency.

We can now visualize the stitched forward/backward trajectory segments, together with the planetary orbits.

ax = pk.plot.make_3Daxis()
pk.plot.add_planet_orbit(ax, pl0, units=pk.AU)
pk.plot.add_planet_orbit(ax, pl1, units=pk.AU)
fwd, bck = leg.get_state_info(100)
for segment in fwd:
    ax.scatter(segment[0,0], segment[0,1], segment[0,2], c = 'blue') 
    ax.plot(segment[:,0], segment[:,1], segment[:,2], c= 'blue') 
for segment in bck:
    ax.scatter(segment[0,0], segment[0,1], segment[0,2], c = 'darkorange') 
    ax.plot(segment[:,0], segment[:,1], segment[:,2], c= 'darkorange') 

ax.view_init(90,-90)
../_images/50af14730bf96f97a4ffe8a274afc88991068d3fa0348f913b4ad637ce2d0a0f.png

2 - Use with arbitrary dynamics#

A key feature of pykep.leg.zoh is that it is not restricted to Keplerian Cartesian dynamics. Any dynamics compatible with the interface can be used (e.g., CR3BP, equinoctial dynamics, higher-fidelity models).

For compatibility with pykep.leg.zoh, the dynamics must satisfy:

  • State dimension is 7.

  • The first four heyoka parameters are, in order: hy.par[0]=T, hy.par[1]=i_x, hy.par[2]=i_y, hy.par[3]=i_z.

  • Variational integrator state dimension is 84, corresponding to 7 state equations plus first-order sensitivities with respect to 7 state variables and 4 control parameters: \(7 + 7\times 7 + 7\times 4\).

In pykep, compatible integrators are already provided (e.g., pykep.ta.zoh_kep, pykep.ta.zoh_eq, pykep.ta.zoh_cr3bp and their variational counterparts).

Below, for didactic clarity, we explicitly define a CR3BP ZOH dynamics, even though pykep.ta.zoh_cr3bp could be used directly.

# The symbolic variables.
[x, y, z, vx, vy, vz, m] = hy.make_vars("x", "y", "z", "vx", "vy", "vz", "m")

# Naming the system controls
T_norm = hy.par[0]
i_x, i_y, i_z = hy.par[1], hy.par[2], hy.par[3]
# Naming the system parameters
# Naming the system parameters
c = hy.par[4]  # 1/veff
mu = hy.par[5]

# Distances to the bodies.
r_1 = hy.sqrt(hy.sum([pow(x + mu, 2.0), pow(y, 2.0), pow(z, 2.0)]))
r_2 = hy.sqrt(hy.sum([pow(x - (1.0 - mu), 2.0), pow(y, 2.0), pow(z, 2.0)]))

# The Equations of Motion.
xdot = vx
ydot = vy
zdot = vz
vxdot = (
    2.0 * vy
    + x
    - (1.0 - mu) * (x + mu) / pow(r_1, 3.0)
    - mu * (x + mu - 1.0) / pow(r_2, 3.0)
    + T_norm * i_x / m
)
vydot = (
    -2.0 * vx
    + y
    - (1.0 - mu) * y / pow(r_1, 3.0)
    - mu * y / pow(r_2, 3.0)
    + T_norm * i_y / m
)
vzdot = -(1.0 - mu) * z / pow(r_1, 3.0) - mu * z / pow(r_2, 3.0) + T_norm * i_z / m
mdot = (
    -c * T_norm * hy.exp(-1.0 / m / 1e16)
)  # the added term regularizes the dynamics keeping it differentiable

dyn = [
    (x, xdot),
    (y, ydot),
    (z, zdot),
    (vx, vxdot),
    (vy, vydot),
    (vz, vzdot),
    (m, mdot),
]

ta_cr3bp = hy.taylor_adaptive(dyn, tol=tol, pars=[0.0, 0.0, 0.0, 0.0, 0.0, 0.01])
vsys = hy.var_ode_sys(
    dyn,
    [x, y, z, vx, vy, vz, m, hy.par[0], hy.par[1], hy.par[2], hy.par[3]],
    1,
)
ta_cr3bp_var = hy.taylor_adaptive(
    vsys,
    tol=tol,
    compact_mode=True,
)

The model is standard CR3BP augmented with thrust acceleration and a (regularized) mass-flow equation. To satisfy the ZOH parameter contract, we reserve par[0:4] for thrust controls and use par[4], par[5] for additional physical parameters (here \(1/v_\mathrm{eff}\) and \(\mu\)).

As a sanity check, we first propagate a known periodic Lyapunov-orbit initial condition in zero-thrust mode.

mu = 0.01215058560962404
ic = np.array([5.5643551520142581e-02, 9.2420772211102929e-27, 1.3616512785913887e-31, 2.6173746491479341e-12, 5.2390814115699671e+00, 5.3268092625591314e-30, 1000/MASS])
period = 6.301205688481844
ta_cr3bp.pars[4] = 1./veff_nd
ta_cr3bp.pars[5] = mu
ta_cr3bp_var.pars[4] = 1./veff_nd
ta_cr3bp_var.pars[5] = mu

Propagate the system over one period (zero thrust) to verify the setup.

ta_cr3bp.time=0
ta_cr3bp.state[:]=ic
ta_cr3bp.pars[:4] = [0,0,0,0] # No thrust
sol = ta_cr3bp.propagate_grid(np.linspace(0, period,1000))[-1]

Inspect the trajectory to confirm near-periodicity and consistency of the dynamics setup.

ax = pk.plot.make_3Daxis()
ax.plot(sol[:,0], sol[:,1], sol[:,2])
ax.view_init(90,-90)
../_images/a7f6416012af0333789fcade9fb7c58c55c13259c23788d1730047b1a93b72fe.png

Select two points on the orbit to define initial and final boundary states for a ZOH leg.

ts = -period/3
tf = period/5
ta_cr3bp.pars[:4] = [0,0,0,0] # No thrust
ta_cr3bp.propagate_until(tf, 1000)
xf = ta_cr3bp.state.copy()
ta_cr3bp.propagate_until(ts, 1000)
xs = ta_cr3bp.state.copy()

Visualize these two boundary states on the reference trajectory.

ax = pk.plot.make_3Daxis()
ax.plot(sol[:,0], sol[:,1], sol[:,2])
ax.scatter3D(xs[0], xs[1], xs[2], c='red', s=20)
ax.scatter3D(xf[0], xf[1], xf[2], c='green', s=20)
ax.view_init(90,-90)
../_images/91b5525835ffd478539edcd7baf67f6952699e2831df94a1ddb34e49971608c7.png

Instantiate the ZOH leg in the CR3BP dynamics with the selected boundaries and time grid.

# Instantiate the leg
tgrid = np.linspace(ts, tf, nseg + 1)
controls = [0.0, 0.0, 0.0, 0] * nseg # no thrust
leg_cr3bp = pk.leg.zoh(xs, controls, xf, tgrid, cut=cut, tas=[ta_cr3bp, ta_cr3bp_var])
print("The mismatch constraints are: ", leg_cr3bp.compute_mismatch_constraints())
The mismatch constraints are:  [-4.168110301350225e-12, -5.252465129501616e-13, -1.0881432900098286e-41, -5.813849401903326e-12, 2.1009860518006462e-12, -9.693482126966922e-42, 0.0]

Visualize the forward/backward segment propagation and compare with the reference orbit.

ax = pk.plot.make_3Daxis()
ax.plot(sol[:,0], sol[:,1], sol[:,2])
ax.scatter3D(xs[0], xs[1], xs[2], c='red', s=20)
ax.scatter3D(xf[0], xf[1], xf[2], c='green', s=20)

fwd, bck = leg_cr3bp.get_state_info(N=100)
for segment in fwd:
    ax.scatter(segment[0,0], segment[0,1], segment[0,2], c = 'blue') 
    ax.plot(segment[:,0], segment[:,1], segment[:,2], c= 'blue') 
for segment in bck:
    ax.scatter(segment[0,0], segment[0,1], segment[0,2], c = 'darkorange') 
    ax.plot(segment[:,0], segment[:,1], segment[:,2], c= 'darkorange') 

ax.view_init(90,-90)
../_images/ffb416c148c8885facdc7002025625e8193e8f94973464e53ac4d4579e24b117.png

This no-thrust case yields a near-zero mismatch, as expected from consistency with the reference dynamics.

Next, apply non-zero controls to observe how the mismatch changes when boundary states and controls are no longer dynamically consistent.

# Instantiate the leg
tgrid = np.linspace(ts, tf, nseg + 1)
controls = [0.3, 0.1, 0.1, 0] * nseg # no thrust
leg_cr3bp = pk.leg.zoh(xs, controls, xf, tgrid, cut=cut, tas=[ta_cr3bp, ta_cr3bp_var])
print("The mismatch constraints are: ", leg_cr3bp.compute_mismatch_constraints())
The mismatch constraints are:  [-0.08237193006874788, 0.005291237018280492, -1.4514539439469395e-31, 0.086780402871281, 0.06605752972759138, -3.16512818033977e-32, -0.4872013925400698]
ax = pk.plot.make_3Daxis()
ax.plot(sol[:,0], sol[:,1], sol[:,2])
ax.scatter3D(xs[0], xs[1], xs[2], c='red', s=20)
ax.scatter3D(xf[0], xf[1], xf[2], c='green', s=20)

fwd, bck = leg_cr3bp.get_state_info(N=100)
for segment in fwd:
    ax.scatter(segment[0,0], segment[0,1], segment[0,2], c = 'blue') 
    ax.plot(segment[:,0], segment[:,1], segment[:,2], c= 'blue') 
for segment in bck:
    ax.scatter(segment[0,0], segment[0,1], segment[0,2], c = 'darkorange') 
    ax.plot(segment[:,0], segment[:,1], segment[:,2], c= 'darkorange') 

ax.view_init(90,-90)
../_images/3862eeca7a669f5edf55868589bfa9e6427e81fe9b2cf5c159d23c83555db59a.png

The result is consistent: introducing non-zero controls with fixed boundaries generally increases mismatch unless controls are optimized.