Halo Orbit¶
Southern L2 Halo orbit initial conditions definition
- Earth-Moon CR3BP rotating-frame dynamics
- Symmetry structure at initial node: y0 = vx0 = vz0 = 0
- Free design variables at initial node: x0, z0, vy0
- Terminal objective to drive (y, vx, vz) toward zero after n revolutions
File: examples/spacecraft/halo_orbit.py
import os
import sys
import jax
import jax.numpy as jnp
import numpy as np
# Add grandparent directory to path to import openscvx without installation.
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 import Problem
from openscvx.algorithms import OptimizationResults
from openscvx.integrators import solve_ivp_diffrax
from openscvx.plotting import plot_projections_2d
from openscvx.symbolic.lower import lower_to_jax
# Use float64 in JAX for high-accuracy propagation.
jax.config.update("jax_enable_x64", True)
r0 = np.array([0.98736, 0.0, 0.00877])
v0 = np.array([0.0, 1.63446, 0.0])
x0_seed = np.concatenate([r0, v0])
# Earth-Moon mass ratio used in normalized CR3BP
mu_earth = 398600.4354360959
mu_moon = 4902.800066163796
mu = mu_moon / (mu_earth + mu_moon)
t_f = 1.522
t_opt = 6.0 * t_f # 6 revolutions
n_nodes = 2
integration_tol = 1e-10
# Build symbolic CR3BP model once and reuse it for both optimization and propagation.
position = ox.State("position", shape=(3,))
velocity = ox.State("velocity", shape=(3,))
# Assign slices for standalone lowering/evaluation on [x, y, z, vx, vy, vz].
position._slice = slice(0, 3)
velocity._slice = slice(3, 6)
r1x = position[0] + mu
r1y = position[1]
r1z = position[2]
r2x = position[0] - (1.0 - mu)
r2y = position[1]
r2z = position[2]
d1 = ox.Sqrt(r1x**2 + r1y**2 + r1z**2)
d2 = ox.Sqrt(r2x**2 + r2y**2 + r2z**2)
ax = 2.0 * velocity[1] + position[0] - (1.0 - mu) * r1x / d1**3 - mu * r2x / d2**3
ay = -2.0 * velocity[0] + position[1] - (1.0 - mu) * r1y / d1**3 - mu * r2y / d2**3
az = -(1.0 - mu) * r1z / d1**3 - mu * r2z / d2**3
velocity_dot = ox.Concat(ax, ay, az)
dynamics = {
"position": velocity,
"velocity": velocity_dot,
}
cr3bp_rhs = lower_to_jax(ox.Concat(velocity, velocity_dot))
# Dense high-accuracy propagation used only to build a reliable guess and for plotting.
guess_dense = np.asarray(
solve_ivp_diffrax(
lambda t, x: cr3bp_rhs(x, jnp.zeros((0,), dtype=x.dtype), 0, {}),
tau_final=t_opt,
y_0=jnp.asarray(x0_seed, dtype=jnp.float64),
args=(),
tau_0=0.0,
num_substeps=1000,
solver_name="Dopri8",
rtol=integration_tol,
atol=integration_tol,
),
dtype=float,
)
# Keep optimization decision vector at two nodes only: initial and final.
nominal_guess = np.vstack([guess_dense[0], guess_dense[-1]])
# Broad bounds (required by OpenSCvx for all variables)
position.min = np.array([-2.0, -2.0, -2.0])
position.max = np.array([2.0, 2.0, 2.0])
velocity.min = np.array([-3.0, -3.0, -3.0])
velocity.max = np.array([3.0, 3.0, 3.0])
# Initial/final conditions:
# y0 = vx0 = vz0 = 0 (fixed), x0/z0/vy0 free around the seed values.
position.initial = [ox.Free(float(x0_seed[0])), 0.0, ox.Free(float(x0_seed[2]))]
velocity.initial = [0.0, ox.Free(float(x0_seed[4])), 0.0]
# Final state is mostly free; terminal objective handles y,vx,vz.
x_tf_guess = nominal_guess[-1]
position.final = [
ox.Free(float(x_tf_guess[0])),
0,
ox.Free(float(x_tf_guess[2])),
]
velocity.final = [
0,
ox.Free(float(x_tf_guess[4])),
0,
]
# Guesses
position.guess = nominal_guess[:, :3]
velocity.guess = nominal_guess[:, 3:]
states = [position, velocity]
time = ox.Time(initial=0.0, final=t_opt, min=0.0, max=t_opt)
discretizer = {
"ode_solver": "Dopri8",
"diffrax_kwargs": {"atol": integration_tol, "rtol": integration_tol},
}
algorithm = {
"k_max": 400,
"lam_prox": 1e0,
"lam_vc": 2.5e-1,
"lam_cost": 5e-1,
"ep_vc": 1e-6,
"autotuner": ox.AugmentedLagrangian(eta_lambda=1e0),
}
problem = Problem(
dynamics=dynamics,
states=states,
controls=[],
time=time,
constraints=[],
N=n_nodes,
discretizer=discretizer,
algorithm=algorithm,
float_dtype="float64",
)
# Keep post-process propagation tolerances aligned with discretization.
problem.settings.prp.solver = "Dopri8"
problem.settings.prp.atol = integration_tol
problem.settings.prp.rtol = integration_tol
if __name__ == "__main__":
problem.initialize()
results = problem.solve()
results = problem.post_process()
pos = results.trajectory["position"]
vel = results.trajectory["velocity"]
x0_opt = np.concatenate([pos[0], vel[0]])
xf_opt = np.concatenate([pos[-1], vel[-1]])
# Match the post-optimization check style in main_orbs_2.py:
# integrate only to t_f from optimized initial state and export xyz.
traj_plot = np.asarray(
solve_ivp_diffrax(
lambda t, x: cr3bp_rhs(x, jnp.zeros((0,), dtype=x.dtype), 0, {}),
tau_final=t_opt,
y_0=jnp.asarray(x0_opt, dtype=jnp.float64),
args=(),
tau_0=0.0,
num_substeps=2000,
solver_name="Dopri8",
rtol=integration_tol,
atol=integration_tol,
),
dtype=float,
)
# Plot guess and solution using the exact plot_projections_2d style.
guess_results = OptimizationResults(converged=True, t_final=float(t_opt))
guess_results.trajectory = {
"time": np.linspace(0.0, t_opt, guess_dense.shape[0]).reshape(-1, 1),
"position": guess_dense[:, :3],
"velocity": guess_dense[:, 3:6],
}
guess_results.nodes = {
"time": np.array([0.0, t_opt]).reshape(-1, 1),
"position": nominal_guess[:, :3],
"velocity": nominal_guess[:, 3:6],
}
fig_guess = plot_projections_2d(guess_results, velocity_var_name="velocity")
fig_guess.update_layout(title="Initial Guess - XY, XZ, YZ Projections")
fig_guess.show()
solution_results = OptimizationResults(converged=bool(results.converged), t_final=float(t_opt))
solution_results.trajectory = {
"time": np.linspace(0.0, t_opt, traj_plot.shape[0]).reshape(-1, 1),
"position": traj_plot[:, :3],
"velocity": traj_plot[:, 3:6],
}
solution_results.nodes = {
"time": results.nodes["time"],
"position": results.nodes["position"],
"velocity": results.nodes["velocity"],
}
fig_solution = plot_projections_2d(solution_results, velocity_var_name="velocity")
fig_solution.update_layout(title="Solution - XY, XZ, YZ Projections")
fig_solution.show()