CR3BP: Lyapunov Orbit Seeding
In the Circular Restricted Three-Body Problem (CR3BP), Lyapunov orbits are planar periodic orbits that exist around the collinear libration points (L₁, L₂, L₃). Finding them numerically requires two ingredients: a good initial guess and a differential corrector to refine it.
This tutorial walks through both steps for the Earth–Moon L₁ point:
- Compute the libration point and its local eigen-structure,
- Build a linearized seed from the center-manifold direction,
- Refine the seed into a periodic orbit with a single-shooting corrector.
The corrected orbit will serve as the starting point for the family continuation in the next tutorial.
This file is compatible with Literate.jl: run it as a plain Julia script, or convert it to Markdown / Jupyter with Literate.markdown or Literate.notebook.
using LinearAlgebra
using Serialization
using OrdinaryDiffEqVerner
using StaticArrays
using Plots
using Motion
using Motion.ContinuationSystem parameters
The CR3BP is fully characterized by the mass ratio μ = m₂/(m₁ + m₂). Here we use the Earth–Moon value:
const μ = 1.215058560962404e-20.01215058560962404Libration point and eigen-structure
The collinear libration points are equilibria of the CR3BP equations of motion. We compute L₁ and evaluate the Jacobian of the vector field there:
xLP = Motion.libration_point(μ, Val(:L1))
JLP = Motion.CR3BP.jacobian(xLP, μ);The eigenvalues of this 6×6 Jacobian come in three pairs: one real pair (saddle — stable/unstable manifolds), one purely imaginary pair (in-plane center — Lyapunov family), and one purely imaginary pair (out-of-plane center — vertical family). The eigenvectors associated with the imaginary eigenvalues span the center manifold, which is where periodic orbit families originate.
L, W = eigen(JLP)LinearAlgebra.Eigen{ComplexF64, ComplexF64, StaticArraysCore.SMatrix{6, 6, ComplexF64, 36}, StaticArraysCore.SVector{6, ComplexF64}}
values:
6-element StaticArraysCore.SVector{6, ComplexF64} with indices SOneTo(6):
-2.932055933642146 + 0.0im
0.0 - 2.2688310949728914im
0.0 + 2.2688310949728914im
2.3592239273284576e-16 - 2.334385885086317im
2.3592239273284576e-16 + 2.334385885086317im
2.9320559336421455 + 0.0im
vectors:
6×6 StaticArraysCore.SMatrix{6, 6, ComplexF64, 36} with indices SOneTo(6)×SOneTo(6):
-0.293246+0.0im 0.0-0.0im 0.0+0.0im -0.105758+3.36332e-17im -0.105758-3.36332e-17im 0.293246+0.0im
-0.134931+0.0im 0.0-0.0im 0.0+0.0im 1.10147e-16+0.379301im 1.10147e-16-0.379301im -0.134931+0.0im
0.0+0.0im 0.0+0.403318im 0.0-0.403318im 0.0-0.0im 0.0+0.0im 0.0+0.0im
0.859815+0.0im 0.0-0.0im 0.0+0.0im -1.80383e-16+0.24688im -1.80383e-16-0.24688im 0.859815+0.0im
0.395624+0.0im 0.0-0.0im 0.0+0.0im 0.885435-0.0im 0.885435+0.0im -0.395624+0.0im
0.0+0.0im 0.91506-0.0im 0.91506+0.0im 0.0-0.0im 0.0+0.0im 0.0+0.0imLinearized Lyapunov seed
A small displacement along a center eigenvector (one with a purely imaginary eigenvalue) produces an approximate periodic orbit — the linearized seed. We take a unit-normalized eigenvector and scale it by a small amplitude ε:
x0g = xLP + 1e-3 * real(W[:, 2] / norm(W[:, 2]));Planar Lyapunov orbits are symmetric about the x-axis. A trajectory that crosses the x-axis perpendicularly (y = 0, vₓ = 0) will, by symmetry, cross it perpendicularly again after half a period. We therefore keep only x and vy from the eigenvector displacement, zeroing out the rest:
x0 = [x0g[1], 0, 0, 0, x0g[5], 0]6-element Vector{Float64}:
0.8369151257723573
0.0
0.0
0.0
0.0
0.0The imaginary part of the corresponding eigenvalue gives the linear oscillation frequency, so 2π / |Im(λ)| is our first guess for the period:
T0 = 2π / abs(L[2])2.7693490807232872Single-shooting correction
The linearized seed is only approximate — it satisfies the linear equations of motion, not the full nonlinear CR3BP. A single-shooting corrector iteratively adjusts the initial conditions (and possibly the period) until the trajectory closes on itself to machine precision.
Because we only vary a subset of the full state, we define a reduced layout: out of the 6 state components [x, y, z, vₓ, vy, vz], only x (index 1) and vy (index 5) are free; the period T is also free. This gives a reduced state vector z = [x, vy, T] of dimension 3.
layout = SingleShootingReducedLayout(6, [1, 5], true);Choosing a constraint
A planar Lyapunov orbit's symmetry lets us enforce periodicity with only two scalar constraints (matching the two free state components). Two equivalent formulations exist:
Half-period symmetry — require the trajectory to hit the x-axis perpendicularly at T/2:
y(T/2) = 0vₓ(T/2) = 0
Full-period periodicity — require the trajectory to return to its initial state after one full period:
x(T) - x(0) = 0vy(T) - vy(0) = 0
Here we use the full-period constraint via Continuation.Periodicity:
f(x, T, λ) = Motion.CR3BP.flow(μ, x, 0.0, T, Vern9(); abstol = reltol=1e-14 );
sr = SingleShootingResidual(
SingleShooting(f, layout), Continuation.Periodicity(layout),
);Phase constraint
With 3 unknowns (x, vy, T) and 2 periodicity constraints the system is under-determined — one degree of freedom remains, corresponding to the family parameter (amplitude). To pin a unique solution we add a natural-parameter constraint that fixes x to its initial-guess value:
r = NaturalParameterShootingResidual(sr, 1);Solve the corrector
Pack the initial guess into the reduced vector z₀ = [x, vy, T] and solve the resulting nonlinear system with Newton–Raphson:
z0 = [x0[1], x0[5], T0]
corr = Continuation.SciMLCorrector(; abstol = reltol=1e-10 , verbose = false);
zsol, stat = solve(r, corr, z0, z0[1]);Plot the initial guess vs the corrected orbit
Integrate the linearized seed over one period:
sol_guess = Motion.CR3BP.build_solution(μ, x0, 0.0, T0, Vern9(); abstol = reltol=1e-14 );
X_guess = reduce(hcat, sol_guess.(LinRange(0, T0, 1000)));Integrate the corrected orbit over one period:
xn, Tn = Continuation.unpack(layout, zsol)
sol = Motion.CR3BP.build_solution(μ, xn, 0.0, Tn, Vern9(); abstol = reltol=1e-14 )
X = reduce(hcat, sol.(LinRange(0, Tn, 1000)));
cache_path = joinpath(@__DIR__, "cache", "010_L1_Lyap_seed.jls")
mkpath(dirname(cache_path))
serialize(cache_path,
(; μ = μ, x = xn, T = Tn )
) # hideThe corrected orbit (black) closes on itself, while the linearized seed (red, dotted) visibly drifts — demonstrating why the differential correction step is essential.
begin
p = plot(framestyle = :box, xlabel = "x (-)", ylabel = "y (-)", aspect_ratio = 1, legend = :bottomright)
scatter!(p, [xLP[1]], [xLP[2]], label = false, marker = :d, color = :red)
plot!(p, X_guess[1, :], X_guess[2, :], color = :red, style = :dot, label = "Initial guess")
plot!(p, X[1, :], X[2, :], color = :black, label = "Corrected orbit")
endThis page was generated using Literate.jl.