orbit-gen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- orbit_gen/__init__.py +3 -0
- orbit_gen/family.py +296 -0
- orbit_gen/orbit.py +135 -0
- orbit_gen/setup/__init__.py +0 -0
- orbit_gen/setup/config.py +114 -0
- orbit_gen/setup/kernels.py +56 -0
- orbit_gen/setup/orbit_ic.py +41 -0
- orbit_gen/setup/presets.py +102 -0
- orbit_gen/setup/system_config.py +54 -0
- orbit_gen/utils/eom.py +265 -0
- orbit_gen/utils/units.py +27 -0
- orbit_gen-0.1.0.dist-info/METADATA +134 -0
- orbit_gen-0.1.0.dist-info/RECORD +15 -0
- orbit_gen-0.1.0.dist-info/WHEEL +4 -0
- orbit_gen-0.1.0.dist-info/licenses/LICENSE +21 -0
orbit_gen/__init__.py
ADDED
orbit_gen/family.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import spiceypy as spice
|
|
3
|
+
from matplotlib import pyplot as plt
|
|
4
|
+
from scipy.optimize import fsolve
|
|
5
|
+
import astropy.units as u
|
|
6
|
+
|
|
7
|
+
from orbit_gen.utils import eom, units
|
|
8
|
+
from orbit_gen.orbit import Orbit
|
|
9
|
+
from orbit_gen.setup.config import RunConfig
|
|
10
|
+
from orbit_gen.setup.system_config import CanonicalUnits
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Family:
|
|
14
|
+
"""Family of CR3BP periodic orbits
|
|
15
|
+
|
|
16
|
+
Generates a family of periodic orbits in the circular restricted
|
|
17
|
+
three-body problem (CR3BP).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
cfg (RunConfig):
|
|
21
|
+
Full run configuration.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
mu_star (float):
|
|
25
|
+
CR3BP mass ratio.
|
|
26
|
+
m1 (float):
|
|
27
|
+
Normalized primary mass (1 - mu_star).
|
|
28
|
+
m2 (float):
|
|
29
|
+
Normalized secondary mass (mu_star).
|
|
30
|
+
r_secondary (float):
|
|
31
|
+
Radius of the secondary body in canonical distance units (DU).
|
|
32
|
+
orbits (list[Orbit]):
|
|
33
|
+
Collection of valid periodic orbit solutions obtained.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, cfg: RunConfig):
|
|
37
|
+
self.cfg = cfg
|
|
38
|
+
self.orbits: list[Orbit] = []
|
|
39
|
+
|
|
40
|
+
self._setup_constants()
|
|
41
|
+
self._run_continuation()
|
|
42
|
+
|
|
43
|
+
def _setup_constants(self):
|
|
44
|
+
"""Load SPICE kernel and compute CR3BP constants from system config."""
|
|
45
|
+
for path in self.cfg.kernel_paths.values():
|
|
46
|
+
spice.furnsh(str(path))
|
|
47
|
+
|
|
48
|
+
sys = self.cfg.system
|
|
49
|
+
gm_primary = spice.bodvrd(sys.primary, "GM", 1)[1][0]
|
|
50
|
+
gm_secondary = spice.bodvrd(sys.secondary, "GM", 1)[1][0]
|
|
51
|
+
|
|
52
|
+
self.mu_star = gm_secondary / (gm_primary + gm_secondary)
|
|
53
|
+
self.m1 = 1 - self.mu_star
|
|
54
|
+
self.m2 = self.mu_star
|
|
55
|
+
|
|
56
|
+
dist_km = sys.canonical_units.dist_km
|
|
57
|
+
time_seconds = np.sqrt(dist_km**3 / (gm_primary + gm_secondary))
|
|
58
|
+
sys.canonical_units = CanonicalUnits(
|
|
59
|
+
dist_km=dist_km, time_days=time_seconds / 86400
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
radii = spice.bodvrd(sys.secondary, "RADII", 3)[1][0]
|
|
63
|
+
self.r_secondary = units.pos_to_canonical(radii * u.km, sys.canonical_units)
|
|
64
|
+
|
|
65
|
+
def _run_continuation(self):
|
|
66
|
+
"""Outer pseudo-arclength loop. Populates self.orbits."""
|
|
67
|
+
cont = self.cfg.continuation
|
|
68
|
+
Tp_lim = units.time_to_canonical(
|
|
69
|
+
cont.max_period_days * u.d, self.cfg.system.canonical_units
|
|
70
|
+
)
|
|
71
|
+
stm_identity = np.reshape(np.eye(6), (1, 36))[0]
|
|
72
|
+
|
|
73
|
+
# Free variables: [x0, z0, vy0, T/2]
|
|
74
|
+
X = self.cfg.ic.as_list
|
|
75
|
+
z = np.array([0.0, 0.0, 0.0, -1.0])
|
|
76
|
+
|
|
77
|
+
while X[-1] * 2 < Tp_lim and len(self.orbits) < cont.max_solutions:
|
|
78
|
+
X, error = self._correct_orbit(X, stm_identity)
|
|
79
|
+
|
|
80
|
+
if X[-1] < 0:
|
|
81
|
+
print("Negative period.")
|
|
82
|
+
break
|
|
83
|
+
if error > cont.eps:
|
|
84
|
+
print("Corrector did not converge.")
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
period_days = units.time_to_dimensional(
|
|
88
|
+
2 * X[-1], self.cfg.system.canonical_units
|
|
89
|
+
).to_value(u.day)
|
|
90
|
+
print(f"Period: {period_days:.4f} days")
|
|
91
|
+
|
|
92
|
+
states, times, valid = self._propagate_and_validate(X)
|
|
93
|
+
|
|
94
|
+
if valid:
|
|
95
|
+
orbit = Orbit(
|
|
96
|
+
x0=X[0],
|
|
97
|
+
z0=X[1],
|
|
98
|
+
vy0=X[2],
|
|
99
|
+
period=times[-1],
|
|
100
|
+
mu_star=self.mu_star,
|
|
101
|
+
canonical_units=self.cfg.system.canonical_units,
|
|
102
|
+
)
|
|
103
|
+
# Store already-computed trajectory so propagate() isn't needed again
|
|
104
|
+
orbit.states = states
|
|
105
|
+
orbit.times = times
|
|
106
|
+
self.orbits.append(orbit)
|
|
107
|
+
|
|
108
|
+
X, z, success = self._continuation_step(X, z)
|
|
109
|
+
if not success:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
print(f"Orbits found: {len(self.orbits)}\n")
|
|
113
|
+
|
|
114
|
+
if not self.orbits:
|
|
115
|
+
raise RuntimeError("Continuation failed")
|
|
116
|
+
|
|
117
|
+
def _correct_orbit(self, X: list, stm_identity: np.ndarray) -> tuple[list, float]:
|
|
118
|
+
"""Single-shooting differential corrector
|
|
119
|
+
|
|
120
|
+
Newton-style differential correction using a single-shooting formulation
|
|
121
|
+
for periodic orbit boundary condition enforcement.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
X (array-like):
|
|
125
|
+
Free-variable vector of the form [x0, z0, vy0, T/2].
|
|
126
|
+
stm_identity (ndarray):
|
|
127
|
+
Flattened 6x6 identity matrix used to initialize/propagate the
|
|
128
|
+
state transition matrix.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
X (ndarray):
|
|
132
|
+
Corrected free-variable vector after convergence.
|
|
133
|
+
error (float):
|
|
134
|
+
Final residual norm ||F(X)|| at termination.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
cont = self.cfg.continuation
|
|
138
|
+
error = np.inf
|
|
139
|
+
|
|
140
|
+
for i in range(cont.max_iter):
|
|
141
|
+
X_full = np.append(X, stm_identity).tolist()
|
|
142
|
+
|
|
143
|
+
Fx, state_half_T, Phi = eom.constraint(X_full, self.mu_star)
|
|
144
|
+
error = np.linalg.norm(Fx)
|
|
145
|
+
|
|
146
|
+
if error < cont.eps:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
dFx = eom.jacobian(X, self.mu_star, state_half_T, Phi)
|
|
150
|
+
X = X - 0.3 * dFx.T @ (np.linalg.inv(dFx @ dFx.T) @ Fx)
|
|
151
|
+
|
|
152
|
+
print(
|
|
153
|
+
f" iter {i+1:2d} | error = {error:.3e} | cond = {np.linalg.cond(dFx @ dFx.T):.2e}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return X, error
|
|
157
|
+
|
|
158
|
+
def _propagate_and_validate(self, X: list) -> tuple[np.ndarray, np.ndarray, bool]:
|
|
159
|
+
"""Propagate one full orbital period and check secondary-body intersection
|
|
160
|
+
|
|
161
|
+
Numerically integrates the trajectory over a full orbital period from the
|
|
162
|
+
given initial conditions, then evaluates whether the trajectory intersects
|
|
163
|
+
or enters the secondary body's exclusion region.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
X (array-like):
|
|
167
|
+
Free-variable vector [x0, z0, vy0, T/2] defining the initial state
|
|
168
|
+
and half-period used for symmetric propagation.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
states (ndarray):
|
|
172
|
+
Propagated state history with shape (N, 6), where each row is
|
|
173
|
+
[x, y, z, vx, vy, vz].
|
|
174
|
+
times (ndarray):
|
|
175
|
+
Time samples corresponding to each state, shape (N,).
|
|
176
|
+
valid (bool):
|
|
177
|
+
False if the trajectory intersects the secondary body or violates
|
|
178
|
+
the exclusion radius; True otherwise.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
X_full = X.copy()
|
|
182
|
+
X_full[-1] = 2 * X_full[-1]
|
|
183
|
+
|
|
184
|
+
states, times = eom.propagate(X_full, self.mu_star)
|
|
185
|
+
positions = states[:, 0:3]
|
|
186
|
+
r_mag = np.linalg.norm(positions, axis=1)
|
|
187
|
+
|
|
188
|
+
periapsis_km = units.pos_to_dimensional(
|
|
189
|
+
min(r_mag), self.cfg.system.canonical_units
|
|
190
|
+
).to_value(u.km)
|
|
191
|
+
print(f" Periapsis: {periapsis_km:.2f} km")
|
|
192
|
+
|
|
193
|
+
valid = not np.any(r_mag < self.r_secondary)
|
|
194
|
+
if not valid:
|
|
195
|
+
print(" Intersects secondary — discarding.")
|
|
196
|
+
|
|
197
|
+
return states, times, valid
|
|
198
|
+
|
|
199
|
+
def _continuation_step(
|
|
200
|
+
self, X: list, z: np.ndarray
|
|
201
|
+
) -> tuple[list, np.ndarray, bool]:
|
|
202
|
+
"""Advance one pseudo-arclength continuation step along an orbit family
|
|
203
|
+
|
|
204
|
+
Performs a predictor-corrector step along the solution manifold using a
|
|
205
|
+
pseudo-arclength continuation method. Advances the current solution estimate
|
|
206
|
+
and updates the tangent direction along the family.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
X (ndarray):
|
|
210
|
+
Current free-variable vector at the current solution point.
|
|
211
|
+
z (ndarray):
|
|
212
|
+
Current tangent direction vector along the continuation manifold.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
X_new (ndarray):
|
|
216
|
+
Predicted/corrected free-variable vector for the next orbit.
|
|
217
|
+
z_new (ndarray):
|
|
218
|
+
Updated tangent direction at the new solution point.
|
|
219
|
+
success (bool):
|
|
220
|
+
False if the Jacobian is singular or the correction step fails;
|
|
221
|
+
True otherwise.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
sol_predictor = X + z * self.cfg.continuation.step
|
|
225
|
+
|
|
226
|
+
fss = fsolve(
|
|
227
|
+
eom.continuation_constraint,
|
|
228
|
+
X,
|
|
229
|
+
args=(z, sol_predictor, self.mu_star),
|
|
230
|
+
full_output=True,
|
|
231
|
+
xtol=1e-6,
|
|
232
|
+
)
|
|
233
|
+
X_new = fss[0]
|
|
234
|
+
|
|
235
|
+
# Reconstruct Jacobian
|
|
236
|
+
Q = fss[1]["fjac"]
|
|
237
|
+
Rs = fss[1]["r"]
|
|
238
|
+
R = np.zeros((4, 4))
|
|
239
|
+
idx, col = np.triu_indices(4, k=0)
|
|
240
|
+
R[idx, col] = Rs
|
|
241
|
+
J = Q.T @ R
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
z_new = np.linalg.inv(J) @ z
|
|
245
|
+
z_new = z_new / np.linalg.norm(z_new)
|
|
246
|
+
return X_new, z_new, True
|
|
247
|
+
except np.linalg.LinAlgError:
|
|
248
|
+
print("Singular Jacobian — stopping continuation.")
|
|
249
|
+
return X_new, z, False
|
|
250
|
+
|
|
251
|
+
def plot(self):
|
|
252
|
+
"""Plot all orbits in the family on a single 3D figure
|
|
253
|
+
|
|
254
|
+
Generates a 3D visualization of all stored periodic orbits in the family,
|
|
255
|
+
typically in canonical CR3BP coordinates. Each orbit is plotted in the same
|
|
256
|
+
figure for qualitative comparison of geometry across the continuation family.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
show (bool):
|
|
260
|
+
If True, displays the figure via matplotlib.pyplot.show().
|
|
261
|
+
If False, the figure is created but not rendered interactively.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
fig = plt.figure()
|
|
265
|
+
ax = fig.add_subplot(projection="3d")
|
|
266
|
+
|
|
267
|
+
for orbit in self.orbits:
|
|
268
|
+
orbit.plot(ax=ax)
|
|
269
|
+
|
|
270
|
+
ax.set_title(f"{self.cfg.ic.name} family — {len(self.orbits)} orbits")
|
|
271
|
+
|
|
272
|
+
plt.show()
|
|
273
|
+
|
|
274
|
+
return ax
|
|
275
|
+
|
|
276
|
+
def save(self):
|
|
277
|
+
"""Save all orbit ICs, periods, and mu_star to cfg.output_path."""
|
|
278
|
+
self.cfg.output_dir.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
states = np.array([o.states[0, :] for o in self.orbits])
|
|
281
|
+
periods = np.array([o.period for o in self.orbits])
|
|
282
|
+
|
|
283
|
+
np.savez(
|
|
284
|
+
self.cfg.output_path,
|
|
285
|
+
states=states,
|
|
286
|
+
periods=periods,
|
|
287
|
+
mu_star=self.mu_star,
|
|
288
|
+
)
|
|
289
|
+
print(f"Saved {len(self.orbits)} orbits to {self.cfg.output_path}")
|
|
290
|
+
|
|
291
|
+
def __repr__(self):
|
|
292
|
+
return (
|
|
293
|
+
f"Family(system={self.cfg.system.name}, "
|
|
294
|
+
f"ic={self.cfg.ic.name}, "
|
|
295
|
+
f"orbits={len(self.orbits)})"
|
|
296
|
+
)
|
orbit_gen/orbit.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from matplotlib import pyplot as plt
|
|
3
|
+
|
|
4
|
+
from orbit_gen.utils import eom, units
|
|
5
|
+
from orbit_gen.setup.system_config import CanonicalUnits
|
|
6
|
+
import astropy.units as u
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Orbit:
|
|
10
|
+
"""Single corrected periodic orbit in the CR3BP
|
|
11
|
+
|
|
12
|
+
Represents a converged periodic orbit obtained from a differential correction
|
|
13
|
+
procedure in the circular restricted three-body problem (CR3BP). The orbit
|
|
14
|
+
is defined at a symmetry-plane crossing and can be numerically propagated to
|
|
15
|
+
recover the full trajectory.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
x0 (float):
|
|
19
|
+
x-position at symmetry-plane crossing in canonical distance units (DU).
|
|
20
|
+
z0 (float):
|
|
21
|
+
z-position at symmetry-plane crossing in DU.
|
|
22
|
+
vy0 (float):
|
|
23
|
+
y-velocity at symmetry-plane crossing in canonical units (DU/TU).
|
|
24
|
+
period (float):
|
|
25
|
+
Full orbital period T in canonical time units (TU).
|
|
26
|
+
mu_star (float):
|
|
27
|
+
CR3BP mass ratio (normalized secondary mass parameter).
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
states (ndarray or None):
|
|
31
|
+
Trajectory array of shape (N, 6) containing
|
|
32
|
+
[x, y, z, vx, vy, vz] along the orbit. None until `propagate()` is called.
|
|
33
|
+
times (ndarray or None):
|
|
34
|
+
Time array of shape (N,) corresponding to `states`. None until
|
|
35
|
+
`propagate()` is called.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
x0: float,
|
|
41
|
+
z0: float,
|
|
42
|
+
vy0: float,
|
|
43
|
+
period: float,
|
|
44
|
+
mu_star: float,
|
|
45
|
+
canonical_units: CanonicalUnits,
|
|
46
|
+
):
|
|
47
|
+
self.x0 = x0
|
|
48
|
+
self.z0 = z0
|
|
49
|
+
self.vy0 = vy0
|
|
50
|
+
self.period = period
|
|
51
|
+
self.mu_star = mu_star
|
|
52
|
+
self.canonical_units = canonical_units
|
|
53
|
+
|
|
54
|
+
self.states = None
|
|
55
|
+
self.times = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def period_days(self) -> float:
|
|
59
|
+
"""Full orbital period in days."""
|
|
60
|
+
return units.time_to_dimensional(self.period, self.canonical_units).to_value(
|
|
61
|
+
u.day
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def _free_var(self) -> list:
|
|
66
|
+
"""Full-period free variable vector [x0, z0, vy0, T]."""
|
|
67
|
+
return [self.x0, self.z0, self.vy0, self.period]
|
|
68
|
+
|
|
69
|
+
def propagate(self):
|
|
70
|
+
"""Integrate the trajectory for one full period.
|
|
71
|
+
|
|
72
|
+
Stores results in self.states and self.times.
|
|
73
|
+
"""
|
|
74
|
+
self.states, self.times = eom.propagate(self._free_var, self.mu_star)
|
|
75
|
+
|
|
76
|
+
def plot(self, ax=None, **kwargs):
|
|
77
|
+
"""Plot the orbit in 3D
|
|
78
|
+
|
|
79
|
+
Plots the trajectory of the orbit in a 3D matplotlib figure using the
|
|
80
|
+
stored state history.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
ax (matplotlib.axes._subplots.Axes3DSubplot, optional):
|
|
84
|
+
Existing 3D axes object. If None, a new figure and 3D axes are
|
|
85
|
+
created internally.
|
|
86
|
+
**kwargs:
|
|
87
|
+
Additional keyword arguments forwarded directly to `ax.plot()`,
|
|
88
|
+
controlling styling (e.g., color, linewidth, alpha).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
ax (matplotlib.axes._subplots.Axes3DSubplot):
|
|
92
|
+
The matplotlib 3D axes containing the plotted orbit.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
if self.states is None:
|
|
96
|
+
self.propagate()
|
|
97
|
+
|
|
98
|
+
if ax is None:
|
|
99
|
+
fig = plt.figure()
|
|
100
|
+
ax = fig.add_subplot(projection="3d")
|
|
101
|
+
|
|
102
|
+
ax.plot(self.states[:, 0], self.states[:, 1], self.states[:, 2], **kwargs)
|
|
103
|
+
ax.set_xlabel("X [DU]")
|
|
104
|
+
ax.set_ylabel("Y [DU]")
|
|
105
|
+
ax.set_zlabel("Z [DU]")
|
|
106
|
+
|
|
107
|
+
return ax
|
|
108
|
+
|
|
109
|
+
def save(self, path):
|
|
110
|
+
"""Save orbit initial conditions and period to an NPZ file
|
|
111
|
+
|
|
112
|
+
Serializes the orbit’s defining parameters (initial state and period)
|
|
113
|
+
to a NumPy `.npz` archive for later reuse or reconstruction.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
path (str or pathlib.Path):
|
|
117
|
+
Destination file path for the `.npz` archive. If the extension is
|
|
118
|
+
omitted, `.npz` is assumed.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
if self.states is None:
|
|
122
|
+
self.propagate()
|
|
123
|
+
|
|
124
|
+
np.savez(
|
|
125
|
+
path,
|
|
126
|
+
ic=self.states[0, :],
|
|
127
|
+
period=self.period,
|
|
128
|
+
mu_star=self.mu_star,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def __repr__(self):
|
|
132
|
+
return (
|
|
133
|
+
f"Orbit(x0={self.x0:.6f}, z0={self.z0:.6f}, "
|
|
134
|
+
f"vy0={self.vy0:.6f}, T={self.period:.6f})"
|
|
135
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from .orbit_ic import OrbitIC
|
|
3
|
+
from .system_config import SystemConfig, EM_SYSTEM, SE_SYSTEM
|
|
4
|
+
from .presets import PRESETS
|
|
5
|
+
from .kernels import ensure_kernels
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
10
|
+
|
|
11
|
+
_SYSTEMS = {
|
|
12
|
+
"EM": EM_SYSTEM,
|
|
13
|
+
"SE": SE_SYSTEM,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ContinuationConfig:
|
|
19
|
+
"""Parameters controlling differential correction and family continuation.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
eps (float):
|
|
23
|
+
Convergence tolerance on the constraint residual norm
|
|
24
|
+
max_iter (int):
|
|
25
|
+
Maximum number of Newton iterations for a single
|
|
26
|
+
differential correction attempt
|
|
27
|
+
max_period_days (float):
|
|
28
|
+
Maximum allowable full orbit period, in days
|
|
29
|
+
max_solutions (int):
|
|
30
|
+
Maximum number of converged periodic orbits to generate before
|
|
31
|
+
terminating continuation
|
|
32
|
+
step (float):
|
|
33
|
+
Pseudo-arclength continuation step size in canonical units
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
max_iter: int = 1000
|
|
38
|
+
eps: float = 1e-6
|
|
39
|
+
step: float = 0.01
|
|
40
|
+
max_solutions: int = 10
|
|
41
|
+
max_period_days: float = 1000.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RunConfig:
|
|
46
|
+
"""Top-level configuration for a continuation run.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
continuation (ContinuationConfig):
|
|
50
|
+
Differential correction and continuation parameters
|
|
51
|
+
ic (OrbitIC):
|
|
52
|
+
Periodic orbit used to initialize continuation
|
|
53
|
+
output_dir (Path):
|
|
54
|
+
Directory where output files will be written
|
|
55
|
+
output_filename (str):
|
|
56
|
+
Name of the output .npz file containing continuation results
|
|
57
|
+
show_plots (bool):
|
|
58
|
+
If True, display plots interactively during execution
|
|
59
|
+
spice_kernel (Path):
|
|
60
|
+
Path to the SPICE meta-kernel furnished before propagation and
|
|
61
|
+
analysis
|
|
62
|
+
system (SystemConfig):
|
|
63
|
+
CR3BP system used
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
system: SystemConfig
|
|
68
|
+
ic: OrbitIC
|
|
69
|
+
continuation: ContinuationConfig = field(default_factory=ContinuationConfig)
|
|
70
|
+
output_dir: Path = PROJECT_ROOT.parent.parent / "results"
|
|
71
|
+
show_plots: bool = True
|
|
72
|
+
kernel_paths: dict[str, Path] = field(
|
|
73
|
+
init=False, default_factory=ensure_kernels, repr=False
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def output_filename(self) -> str:
|
|
78
|
+
return f"{self.ic.name}.npz"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def output_path(self) -> Path:
|
|
82
|
+
return self.output_dir / self.output_filename
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_yaml(cls, yaml_path: Path) -> "RunConfig":
|
|
86
|
+
with open(yaml_path) as yp:
|
|
87
|
+
data = yaml.safe_load(yp)
|
|
88
|
+
|
|
89
|
+
system = _SYSTEMS[data["system"]]
|
|
90
|
+
ic = PRESETS[data["preset"]]
|
|
91
|
+
|
|
92
|
+
if system.name == "EM":
|
|
93
|
+
period_max_default = 50.0
|
|
94
|
+
elif system.name == "SE":
|
|
95
|
+
period_max_default = 500.0
|
|
96
|
+
else:
|
|
97
|
+
raise RuntimeError("Unknown system name")
|
|
98
|
+
|
|
99
|
+
continuation = ContinuationConfig(
|
|
100
|
+
max_iter=data.get("max_iter", 100),
|
|
101
|
+
eps=data.get("eps", 1e-6),
|
|
102
|
+
step=data.get("step", 0.01),
|
|
103
|
+
max_solutions=data.get("max_solutions", 20),
|
|
104
|
+
max_period_days=data.get("max_period_days", period_max_default),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return cls(
|
|
108
|
+
system=system,
|
|
109
|
+
ic=ic,
|
|
110
|
+
continuation=continuation,
|
|
111
|
+
output_dir=PROJECT_ROOT.parent.parent
|
|
112
|
+
/ Path(data.get("output_dir", "results")),
|
|
113
|
+
show_plots=data.get("show_plots", True),
|
|
114
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from urllib.request import urlopen
|
|
4
|
+
from urllib.error import URLError
|
|
5
|
+
from platformdirs import user_cache_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class KernelSpec:
|
|
10
|
+
filename: str
|
|
11
|
+
url: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
REQUIRED_KERNELS: tuple[KernelSpec, ...] = (
|
|
15
|
+
KernelSpec(
|
|
16
|
+
filename="naif0012.tls",
|
|
17
|
+
url="https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls",
|
|
18
|
+
),
|
|
19
|
+
KernelSpec(
|
|
20
|
+
filename="gm_de440.tpc",
|
|
21
|
+
url="https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/gm_de440.tpc",
|
|
22
|
+
),
|
|
23
|
+
KernelSpec(
|
|
24
|
+
filename="pck00011.tpc",
|
|
25
|
+
url="https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/pck00011.tpc",
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cache_dir() -> Path:
|
|
31
|
+
direc = Path(user_cache_dir("orbit-gen")) / "kernels"
|
|
32
|
+
direc.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return direc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_kernel(spec: KernelSpec) -> Path:
|
|
37
|
+
cache = cache_dir()
|
|
38
|
+
dest = cache / spec.filename
|
|
39
|
+
|
|
40
|
+
if dest.exists() and dest.stat().st_size > 0:
|
|
41
|
+
return dest
|
|
42
|
+
|
|
43
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
44
|
+
try:
|
|
45
|
+
with urlopen(spec.url, timeout=30) as resp, open(tmp, "wb") as f:
|
|
46
|
+
f.write(resp.read())
|
|
47
|
+
except URLError as e:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
f"Failed to download {spec.filename} from {spec.url}: {e}"
|
|
50
|
+
) from e
|
|
51
|
+
tmp.rename(dest)
|
|
52
|
+
return dest
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ensure_kernels() -> dict[str, Path]:
|
|
56
|
+
return {spec.filename: ensure_kernel(spec) for spec in REQUIRED_KERNELS}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class OrbitIC:
|
|
6
|
+
"""A single periodic orbit initial condition.
|
|
7
|
+
|
|
8
|
+
Attributes:
|
|
9
|
+
half_period (float):
|
|
10
|
+
Half-period T/2 in canonical time units (TU)
|
|
11
|
+
name (str):
|
|
12
|
+
Label for the orbit family, including the system and orbit type
|
|
13
|
+
vy0 (float):
|
|
14
|
+
y-velocity in canonical velocity units (DU/TU)
|
|
15
|
+
x0 (float):
|
|
16
|
+
x-position in canonical distance units (DU)
|
|
17
|
+
z0 (float):
|
|
18
|
+
z-position in canonical distance units (DU)
|
|
19
|
+
|
|
20
|
+
.. note::
|
|
21
|
+
|
|
22
|
+
y0, vx0, and vz0 are always zero by the CR3BP symmetry condition. System
|
|
23
|
+
is denoted by "EM" for Earth/Moon, and "SE" for Sun/Earth.
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
x0: float
|
|
29
|
+
z0: float
|
|
30
|
+
vy0: float
|
|
31
|
+
half_period: float
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def as_list(self) -> list:
|
|
35
|
+
"""Return free variables as [x0, z0, vy0, T/2]."""
|
|
36
|
+
return [self.x0, self.z0, self.vy0, self.half_period]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def full_ic(self) -> list:
|
|
40
|
+
"""Return the full 7-element IC [x0, 0, z0, 0, vy0, 0, T/2]."""
|
|
41
|
+
return [self.x0, 0.0, self.z0, 0.0, self.vy0, 0.0, self.half_period]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from .orbit_ic import OrbitIC
|
|
2
|
+
|
|
3
|
+
PRESETS: dict[str, OrbitIC] = {
|
|
4
|
+
"EM_L2_Northern_Halo": OrbitIC(
|
|
5
|
+
name="EM_L2_Northern",
|
|
6
|
+
x0=1.0110350588,
|
|
7
|
+
z0=-0.1731500000,
|
|
8
|
+
vy0=-0.0780141199,
|
|
9
|
+
half_period=1.3632096570 / 2,
|
|
10
|
+
),
|
|
11
|
+
"EM_DRO_1": OrbitIC(
|
|
12
|
+
name="EM_DRO_1",
|
|
13
|
+
x0=0.583856747,
|
|
14
|
+
z0=0.0,
|
|
15
|
+
vy0=0.96455414,
|
|
16
|
+
half_period=5.70245716 / 2,
|
|
17
|
+
),
|
|
18
|
+
"EM_DRO_2": OrbitIC(
|
|
19
|
+
name="EM_DRO_2",
|
|
20
|
+
x0=0.429519110229904,
|
|
21
|
+
z0=0.0,
|
|
22
|
+
vy0=1.440796689672539,
|
|
23
|
+
half_period=3.051133070334277,
|
|
24
|
+
),
|
|
25
|
+
"EM_L1": OrbitIC(
|
|
26
|
+
name="EM_L1",
|
|
27
|
+
x0=0.856382122325864,
|
|
28
|
+
z0=0.181519309916197,
|
|
29
|
+
vy0=0.257898218422393,
|
|
30
|
+
half_period=1.22727308466325,
|
|
31
|
+
),
|
|
32
|
+
"EM_Butterfly": OrbitIC(
|
|
33
|
+
name="EM_Butterfly",
|
|
34
|
+
x0=1.06896234204296,
|
|
35
|
+
z0=0.159599443574046,
|
|
36
|
+
vy0=-0.00769167653854165,
|
|
37
|
+
half_period=1.66142030228280,
|
|
38
|
+
),
|
|
39
|
+
"EM_Unnamed_1": OrbitIC(
|
|
40
|
+
name="EM_Unnamed_1",
|
|
41
|
+
x0=0.95571113,
|
|
42
|
+
z0=0.16892834,
|
|
43
|
+
vy0=0.29101955,
|
|
44
|
+
half_period=6.8828406 / 2,
|
|
45
|
+
),
|
|
46
|
+
"SE_L2_Northern_Halo": OrbitIC(
|
|
47
|
+
name="SE_L2_Northern_Halo",
|
|
48
|
+
x0=1.0082893998277027,
|
|
49
|
+
z0=0.011434182696839030,
|
|
50
|
+
vy0=-0.015179690420484332,
|
|
51
|
+
half_period=2.7047531095113841 / 2,
|
|
52
|
+
),
|
|
53
|
+
"SE_L1_Vertical": OrbitIC(
|
|
54
|
+
name="SE_L1_Vertical",
|
|
55
|
+
x0=1.0022515716338869,
|
|
56
|
+
z0=-1.9346947758385478e-13,
|
|
57
|
+
vy0=-1.3010231214778553e-1,
|
|
58
|
+
half_period=6.2826376084648130 / 2,
|
|
59
|
+
),
|
|
60
|
+
"SE_L2_Lyapunov": OrbitIC(
|
|
61
|
+
name="SE_L2_Lyapunov",
|
|
62
|
+
x0=1.0030516289641727,
|
|
63
|
+
z0=1.7613075082610678e-17,
|
|
64
|
+
vy0=4.2071729820709863e-2,
|
|
65
|
+
half_period=4.1379689311747248 / 2,
|
|
66
|
+
),
|
|
67
|
+
"SE_L2_Small_Lyapunov": OrbitIC(
|
|
68
|
+
name="SE_L2_Small_Lyapunov",
|
|
69
|
+
x0=1.0173058431158761,
|
|
70
|
+
z0=-8.9129284257995943e-27,
|
|
71
|
+
vy0=-3.5071329991448227e-2,
|
|
72
|
+
half_period=5.5314456810353905 / 2,
|
|
73
|
+
),
|
|
74
|
+
"SE_L2_Large_Lyapunov": OrbitIC(
|
|
75
|
+
name="SE_L2_Large_Lyapunov",
|
|
76
|
+
x0=1.0437961011210057,
|
|
77
|
+
z0=0,
|
|
78
|
+
vy0=-8.2206717351832326e-2,
|
|
79
|
+
half_period=8.3852287199494260 / 2,
|
|
80
|
+
),
|
|
81
|
+
"SE_L2_Southern_Halo": OrbitIC(
|
|
82
|
+
name="SE_L2_Southern_Halo",
|
|
83
|
+
x0=1.0057032659079346,
|
|
84
|
+
z0=-1.2409260038177099e-2,
|
|
85
|
+
vy0=-1.2623398608372105e-2,
|
|
86
|
+
half_period=2.1172370586539060 / 2,
|
|
87
|
+
),
|
|
88
|
+
"SE_Butterfly": OrbitIC(
|
|
89
|
+
name="SE_Butterfly",
|
|
90
|
+
x0=9.9478400099057562e-1,
|
|
91
|
+
z0=9.3307317319370944e-3,
|
|
92
|
+
vy0=-2.7018843432500038e-3,
|
|
93
|
+
half_period=3.6918732624381563 / 2,
|
|
94
|
+
),
|
|
95
|
+
"SE_Dragonfly": OrbitIC(
|
|
96
|
+
name="SE_Dragonfly",
|
|
97
|
+
x0=1.0087123143716323,
|
|
98
|
+
z0=1.0173103235716078e-2,
|
|
99
|
+
vy0=-1.6680589951751042e-2,
|
|
100
|
+
half_period=5.6228423897645730 / 2,
|
|
101
|
+
),
|
|
102
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import astropy.units as u
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class CanonicalUnits:
|
|
7
|
+
"""Characteristic length/time scales for a specific CR3BP system."""
|
|
8
|
+
|
|
9
|
+
dist_km: float # canonical distance [km]
|
|
10
|
+
time_days: float | None = None # canonical time / (2*pi) [days]
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def du_m(self) -> float:
|
|
14
|
+
return (self.dist_km * u.km).to("m").value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SystemConfig:
|
|
19
|
+
"""Determines parameters based on the CR3BP system
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name (str):
|
|
23
|
+
Label for the system
|
|
24
|
+
primary (str):
|
|
25
|
+
Primary body in the CR3BP
|
|
26
|
+
secondary (str):
|
|
27
|
+
Secondary body in the CR3BP
|
|
28
|
+
|
|
29
|
+
.. note::
|
|
30
|
+
|
|
31
|
+
"SE" for Sun/Earth "EM" for Earth/Moon, "Sun" for sun, "Earth" for Earth
|
|
32
|
+
"Moon" for moon
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
primary: str
|
|
38
|
+
secondary: str
|
|
39
|
+
canonical_units: CanonicalUnits
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
EM_SYSTEM = SystemConfig(
|
|
43
|
+
name="EM",
|
|
44
|
+
primary="Earth",
|
|
45
|
+
secondary="Moon",
|
|
46
|
+
canonical_units=CanonicalUnits(dist_km=3.844e5),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
SE_SYSTEM = SystemConfig(
|
|
50
|
+
name="SE",
|
|
51
|
+
primary="Sun",
|
|
52
|
+
secondary="Earth",
|
|
53
|
+
canonical_units=CanonicalUnits(dist_km=u.au.to("km")),
|
|
54
|
+
)
|
orbit_gen/utils/eom.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.integrate import solve_ivp
|
|
3
|
+
from scipy.optimize import fsolve
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def crtbp_eom(t, w, mu_star):
|
|
7
|
+
"""Equations of motion for the CR3BP in the rotating frame with optional STM propagation
|
|
8
|
+
|
|
9
|
+
Computes the time derivative of the state in the circular restricted three-body problem
|
|
10
|
+
(CR3BP). Supports both pure state propagation and augmented state propagation including
|
|
11
|
+
a flattened 6×6 state transition matrix (STM).
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
t (float):
|
|
15
|
+
Time variable (required by ODE integrators such as `scipy.integrate.solve_ivp`
|
|
16
|
+
but not explicitly used in the CR3BP formulation).
|
|
17
|
+
w (array_like):
|
|
18
|
+
State vector. Two supported formats:
|
|
19
|
+
- len == 6: [x, y, z, vx, vy, vz]
|
|
20
|
+
- len == 42: augmented state including flattened 6×6 STM.
|
|
21
|
+
mu_star (float):
|
|
22
|
+
CR3BP mass parameter (normalized secondary mass).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
dw (ndarray):
|
|
26
|
+
Time derivative of the input state vector (and STM if present),
|
|
27
|
+
matching the shape of `w`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
[x, y, z, vx, vy, vz] = w[:6]
|
|
31
|
+
|
|
32
|
+
m1 = 1 - mu_star
|
|
33
|
+
m2 = mu_star
|
|
34
|
+
|
|
35
|
+
r1 = mu_star
|
|
36
|
+
r2 = 1 - mu_star
|
|
37
|
+
r_1O = r1 * np.array([-1, 0, 0])
|
|
38
|
+
r_2O = r2 * np.array([1, 0, 0])
|
|
39
|
+
|
|
40
|
+
r_PO = np.array([x, y, z])
|
|
41
|
+
v_PO = np.array([vx, vy, vz])
|
|
42
|
+
|
|
43
|
+
r_P1 = r_PO - r_1O
|
|
44
|
+
r_P2 = r_PO - r_2O
|
|
45
|
+
r1_mag = np.linalg.norm(r_P1)
|
|
46
|
+
r2_mag = np.linalg.norm(r_P2)
|
|
47
|
+
|
|
48
|
+
F_g = -m1 / r1_mag**3 * r_P1 - m2 / r2_mag**3 * r_P2
|
|
49
|
+
|
|
50
|
+
e3 = np.array([0, 0, 1])
|
|
51
|
+
a_PO = F_g - 2 * np.cross(e3, v_PO) - np.cross(e3, np.cross(e3, r_PO))
|
|
52
|
+
ax, ay, az = a_PO
|
|
53
|
+
|
|
54
|
+
if len(w) > 7:
|
|
55
|
+
dxdx = (
|
|
56
|
+
1
|
|
57
|
+
- m1 / r1_mag**3
|
|
58
|
+
- m2 / r2_mag**3
|
|
59
|
+
+ 3 * m1 * (x + m2) ** 2 / r1_mag**5
|
|
60
|
+
+ 3 * m2 * (x - 1 + m2) ** 2 / r2_mag**5
|
|
61
|
+
)
|
|
62
|
+
dxdy = 3 * m1 * (x + m2) * y / r1_mag**5 + 3 * m2 * (x - 1 + m2) * y / r2_mag**5
|
|
63
|
+
dxdz = 3 * m1 * (x + m2) * z / r1_mag**5 + 3 * m2 * (x - 1 + m2) * z / r2_mag**5
|
|
64
|
+
dydy = (
|
|
65
|
+
1
|
|
66
|
+
- m1 / r1_mag**3
|
|
67
|
+
- m2 / r2_mag**3
|
|
68
|
+
+ 3 * m1 * y**2 / r1_mag**5
|
|
69
|
+
+ 3 * m2 * y**2 / r2_mag**5
|
|
70
|
+
)
|
|
71
|
+
dydz = 3 * m1 * y * z / r1_mag**5 + 3 * m2 * y * z / r2_mag**5
|
|
72
|
+
dzdz = (
|
|
73
|
+
-m1 / r1_mag**3
|
|
74
|
+
- m2 / r2_mag**3
|
|
75
|
+
+ 3 * m1 * z**2 / r1_mag**5
|
|
76
|
+
+ 3 * m2 * z**2 / r2_mag**5
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
Phi = np.reshape(w[6:], (6, 6))
|
|
80
|
+
Z = np.zeros([3, 3])
|
|
81
|
+
I = np.identity(3)
|
|
82
|
+
A = np.array([[dxdx, dxdy, dxdz], [dxdy, dydy, dydz], [dxdz, dydz, dzdz]])
|
|
83
|
+
Omega = np.array([[0, 2, 0], [-2, 0, 0], [0, 0, 0]])
|
|
84
|
+
J = np.block([[Z, I], [A, Omega]])
|
|
85
|
+
dPhi = np.reshape(J @ Phi, (1, 36))[0]
|
|
86
|
+
dw = np.append([vx, vy, vz, ax, ay, az], dPhi)
|
|
87
|
+
else:
|
|
88
|
+
dw = [vx, vy, vz, ax, ay, az]
|
|
89
|
+
|
|
90
|
+
return dw
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def propagate(free_var, mu_star):
|
|
94
|
+
"""Integrate the CR3BP equations of motion over one full orbital period
|
|
95
|
+
|
|
96
|
+
Performs numerical propagation of the CR3BP dynamics using the provided
|
|
97
|
+
initial free variables. Optionally propagates the state transition matrix
|
|
98
|
+
(STM) if included in the input.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
free_var (array_like):
|
|
102
|
+
Initial condition vector. Supported formats:
|
|
103
|
+
- [x0, z0, vy0, T]
|
|
104
|
+
- [x0, z0, vy0, T, STM_flat] where STM_flat has length 36
|
|
105
|
+
(flattened 6×6 STM, total state dimension = 42).
|
|
106
|
+
mu_star (float):
|
|
107
|
+
CR3BP mass parameter.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
states (ndarray):
|
|
111
|
+
Trajectory array. Shape is either (N, 6) for state-only propagation
|
|
112
|
+
or (N, 42) when STM propagation is included.
|
|
113
|
+
times (ndarray):
|
|
114
|
+
Time vector of shape (N,) corresponding to integration output.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
if len(free_var) > 4:
|
|
118
|
+
x0 = np.append(
|
|
119
|
+
[free_var[0], 0, free_var[1], 0, free_var[2], 0], free_var[4:]
|
|
120
|
+
).tolist()
|
|
121
|
+
else:
|
|
122
|
+
x0 = [free_var[0], 0, free_var[1], 0, free_var[2], 0]
|
|
123
|
+
|
|
124
|
+
T = free_var[3]
|
|
125
|
+
|
|
126
|
+
sol = solve_ivp(crtbp_eom, [0, T], x0, args=(mu_star,), rtol=1e-12, atol=1e-12)
|
|
127
|
+
return sol.y.T, sol.t
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def constraint(free_var, mu_star):
|
|
131
|
+
"""Evaluate periodicity constraints for CR3BP symmetric periodic orbit
|
|
132
|
+
|
|
133
|
+
Computes the constraint vector enforcing periodicity at the half-period
|
|
134
|
+
crossing and returns the associated state transition matrix (STM) evaluated
|
|
135
|
+
at T/2.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
free_var (array_like):
|
|
139
|
+
Free-variable vector of the form [x0, z0, vy0, T/2] optionally
|
|
140
|
+
followed by a flattened 6×6 state transition matrix (STM).
|
|
141
|
+
mu_star (float):
|
|
142
|
+
CR3BP mass parameter (normalized secondary mass).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Fx (ndarray, shape (3,)):
|
|
146
|
+
Periodicity constraint residuals evaluated at T/2:
|
|
147
|
+
typically enforcing symmetry conditions such as [y, vx, vz].
|
|
148
|
+
state_half_T (ndarray, shape (6,)):
|
|
149
|
+
State evaluated at T/2.
|
|
150
|
+
Phi (ndarray, shape (6, 6)):
|
|
151
|
+
State transition matrix evaluated at T/2.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
states, _ = propagate(free_var, mu_star)
|
|
155
|
+
state = states[-1]
|
|
156
|
+
|
|
157
|
+
Fx = np.array([state[1], state[3], state[5]])
|
|
158
|
+
|
|
159
|
+
if len(state) > 7:
|
|
160
|
+
Phi = np.reshape(state[6:], (6, 6))
|
|
161
|
+
else:
|
|
162
|
+
Phi = None
|
|
163
|
+
|
|
164
|
+
state_half_T = state[0:6]
|
|
165
|
+
|
|
166
|
+
return Fx, state_half_T, Phi
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def jacobian(free_var, mu_star, state_half_T, Phi):
|
|
170
|
+
"""Compute Jacobian of periodicity constraints for differential correction
|
|
171
|
+
|
|
172
|
+
Evaluates the sensitivity matrix dFx/dX for the CR3BP periodicity constraints
|
|
173
|
+
used in the single-shooting differential corrector.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
free_var (array_like):
|
|
177
|
+
Free-variable vector [x0, z0, vy0, T/2].
|
|
178
|
+
mu_star (float):
|
|
179
|
+
CR3BP mass parameter (normalized secondary mass).
|
|
180
|
+
state_half_T (ndarray, shape (6,)):
|
|
181
|
+
State evaluated at T/2.
|
|
182
|
+
Phi (ndarray, shape (6, 6)):
|
|
183
|
+
State transition matrix evaluated at T/2.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
dFx (ndarray, shape (3, 4)):
|
|
187
|
+
Jacobian matrix of constraint residuals with respect to free variables.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
phis = np.array(
|
|
191
|
+
[
|
|
192
|
+
[Phi[1, 0], Phi[1, 2], Phi[1, 4]],
|
|
193
|
+
[Phi[3, 0], Phi[3, 2], Phi[3, 4]],
|
|
194
|
+
[Phi[5, 0], Phi[5, 2], Phi[5, 4]],
|
|
195
|
+
]
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
dw = crtbp_eom(free_var[-1], state_half_T, mu_star)
|
|
199
|
+
ddT = np.array([dw[1], dw[3], dw[5]])
|
|
200
|
+
|
|
201
|
+
dFx = np.zeros([3, 4])
|
|
202
|
+
dFx[:, 0:3] = phis
|
|
203
|
+
dFx[:, 3] = ddT
|
|
204
|
+
|
|
205
|
+
return dFx
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def continuation_constraint(w, z, sol_predictor, mu_star):
|
|
209
|
+
"""System of equations for pseudo-arclength continuation (for root finding)
|
|
210
|
+
|
|
211
|
+
Defines the augmented nonlinear system used in pseudo-arclength continuation,
|
|
212
|
+
typically solved using `fsolve`. Combines the CR3BP periodicity constraints
|
|
213
|
+
with the arclength constraint relative to a predictor step.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
w (array_like):
|
|
217
|
+
Current free-variable vector [x0, z0, vy0, T/2].
|
|
218
|
+
z (ndarray):
|
|
219
|
+
Tangent direction vector at the current solution point.
|
|
220
|
+
sol_predictor (ndarray):
|
|
221
|
+
Predicted solution from the continuation predictor step.
|
|
222
|
+
mu_star (float):
|
|
223
|
+
CR3BP mass parameter (normalized secondary mass).
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
ndarray, shape (4,):
|
|
227
|
+
Residual vector combining periodicity constraints and arclength
|
|
228
|
+
condition, suitable for nonlinear root solving.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
Fx, _, _ = constraint(w, mu_star)
|
|
232
|
+
zeq = z.T @ (w - sol_predictor)
|
|
233
|
+
return np.append(Fx, zeq)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def jacobi_constant(pos, vel, mu_star):
|
|
237
|
+
"""Compute the Jacobi constant in the circular restricted three-body problem
|
|
238
|
+
|
|
239
|
+
Evaluates the Jacobi integral in the rotating frame for a given state. The
|
|
240
|
+
Jacobi constant is conserved along CR3BP trajectories and is commonly used
|
|
241
|
+
as an invariant for validation and classification of orbits.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
pos (array_like, shape (3,)):
|
|
245
|
+
Position vector [x, y, z] in canonical rotating-frame coordinates.
|
|
246
|
+
vel (array_like, shape (3,)):
|
|
247
|
+
Velocity vector [vx, vy, vz] in canonical units.
|
|
248
|
+
mu_star (float):
|
|
249
|
+
CR3BP mass parameter (normalized secondary mass).
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
float:
|
|
253
|
+
Value of the Jacobi constant for the given state.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
r_Mbary = np.array([1 - mu_star, 0, 0])
|
|
257
|
+
r_Ebary = np.array([-mu_star, 0, 0])
|
|
258
|
+
|
|
259
|
+
KE = np.dot(vel, vel) / 2
|
|
260
|
+
U1 = -(pos[0] ** 2 + pos[1] ** 2) / 2
|
|
261
|
+
U2 = -(
|
|
262
|
+
(1 - mu_star) / np.linalg.norm(pos - r_Ebary)
|
|
263
|
+
+ mu_star / np.linalg.norm(pos - r_Mbary)
|
|
264
|
+
)
|
|
265
|
+
return KE + U1 + U2
|
orbit_gen/utils/units.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from ..setup.system_config import CanonicalUnits
|
|
2
|
+
import astropy.units as u
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def time_to_canonical(dim_time, cu: CanonicalUnits):
|
|
6
|
+
return dim_time.to("day").value / cu.time_days
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def time_to_dimensional(canonical_time, cu: CanonicalUnits):
|
|
10
|
+
return canonical_time * cu.time_days * u.day
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pos_to_canonical(dim_pos, cu: CanonicalUnits):
|
|
14
|
+
return dim_pos.to("m").value / cu.du_m
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pos_to_dimensional(canonical_pos, cu: CanonicalUnits):
|
|
18
|
+
return canonical_pos * cu.du_m * u.m
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def vel_to_canonical(dim_vel, cu: CanonicalUnits):
|
|
22
|
+
dim_vel_mps = dim_vel.to("m/d").value
|
|
23
|
+
return (dim_vel_mps / cu.du_m) * cu.time_days
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def vel_to_dimensional(canonical_vel, cu: CanonicalUnits):
|
|
27
|
+
return canonical_vel * cu.du_m / cu.time_days * (u.m / u.day)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orbit-gen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tools for generating families and orbits in the CR3BP
|
|
5
|
+
Author: Jayden Ripic
|
|
6
|
+
Author-email: Jayden Ripic <jmr595@cornell.edu>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: astropy>=7.2.0
|
|
10
|
+
Requires-Dist: matplotlib>=3.10.9
|
|
11
|
+
Requires-Dist: numpy>=2.4.6
|
|
12
|
+
Requires-Dist: platformdirs>=4.10.0
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
14
|
+
Requires-Dist: scipy>=1.17.1
|
|
15
|
+
Requires-Dist: spiceypy>=8.1.0
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# orbit-gen
|
|
20
|
+
|
|
21
|
+
Periodic orbit family generation and continuation in the Circular Restricted
|
|
22
|
+
Three-Body Problem (CR3BP).
|
|
23
|
+
|
|
24
|
+
## Overview
|
|
25
|
+
|
|
26
|
+
`orbit-gen` generates families of periodic orbits in the CR3BP using
|
|
27
|
+
single-shooting differential correction and pseudo-arclength continuation.
|
|
28
|
+
It currently supports various families in the Earth-Moon and Sun-Earth systems.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install orbit-gen
|
|
34
|
+
```
|
|
35
|
+
Requires Python 3.12+.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### From a config file
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# orbit_config.yaml
|
|
43
|
+
system: SE # "EM" (Earth-Moon) or "SE" (Sun-Earth)
|
|
44
|
+
preset: SE_L2_Northern_Halo
|
|
45
|
+
|
|
46
|
+
max_iter: 1000 # Maximum Newton iterations for a single differential correct attempt
|
|
47
|
+
eps: 1.0e-6 # Differential-correction convergence tolerance on residual norm
|
|
48
|
+
step: 0.01 # Pseudo-arclength continuation step size in canonical units
|
|
49
|
+
max_solutions: 20 # Maximum number of orbits to generate
|
|
50
|
+
max_period_days: 1000.0 # Maximum full orbit period in days
|
|
51
|
+
|
|
52
|
+
output_dir: results
|
|
53
|
+
show_plots: true # Optional; for user use only
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from orbit_gen import Family, RunConfig
|
|
58
|
+
|
|
59
|
+
cfg = RunConfig.from_yaml("orbit_config.yaml")
|
|
60
|
+
fam = Family(cfg)
|
|
61
|
+
|
|
62
|
+
fam.save()
|
|
63
|
+
if cfg.show_plots:
|
|
64
|
+
fam.plot()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> **Note:** constructing `Family(cfg)` runs the continuation, but does not save or plot results on its own — call `.save()` / `.plot()` explicitly, as shown above.
|
|
68
|
+
|
|
69
|
+
### Programmatically
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from orbit_gen import Family, RunConfig
|
|
73
|
+
from orbit_gen.setup.system_config import SE_SYSTEM
|
|
74
|
+
from orbit_gen.setup.presets import PRESETS
|
|
75
|
+
|
|
76
|
+
cfg = RunConfig(system=SE_SYSTEM, ic=PRESETS["SE_L2_Northern_Halo"])
|
|
77
|
+
fam = Family(cfg)
|
|
78
|
+
fam.save()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Available preset keys (see `orbit_gen.setup.presets.PRESETS`):
|
|
82
|
+
`EM_L2_Northern_Halo`, `EM_DRO_1`, `EM_DRO_2`, `EM_L1`, `EM_Butterfly`,
|
|
83
|
+
`EM_Unnamed_1`, `SE_L2_Northern_Halo`, `SE_L1_Vertical`, `SE_L2_Lyapunov`,
|
|
84
|
+
`SE_L2_Small_Lyapunov`, `SE_L2_Large_Lyapunov`, `SE_L2_Southern_Halo`,
|
|
85
|
+
`SE_Butterfly`, `SE_Dragonfly`.
|
|
86
|
+
|
|
87
|
+
## Supported Systems
|
|
88
|
+
|
|
89
|
+
| Key | System |
|
|
90
|
+
|-----|--------|
|
|
91
|
+
| `EM` | Earth-Moon |
|
|
92
|
+
| `SE` | Sun-Earth |
|
|
93
|
+
|
|
94
|
+
## Output
|
|
95
|
+
|
|
96
|
+
`fam.save()` writes a `.npz` file (named after the preset, in `cfg.output_dir`)
|
|
97
|
+
containing initial states, periods, and `mu_star` for all converged orbits:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import numpy as np
|
|
101
|
+
data = np.load("results/SE_L2_Northern_Halo.npz")
|
|
102
|
+
data["states"] # (N, 6) containing initial [x, y, z, vx, vy, vz] in canonical units for each orbit
|
|
103
|
+
data["periods"] # (N,) containing full period in canonical units for each orbit
|
|
104
|
+
data["mu_star"] # System mass ratio
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Contributing
|
|
108
|
+
|
|
109
|
+
Clone or download the repository and run
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv sync
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
to install the package in editable mode plus dev dependencies. Run the test suite with:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
uv run pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The repo includes a driver script, `scripts/crtbpModel.py`,
|
|
122
|
+
which reads `orbit_config.yaml` from the project root, runs continuation,
|
|
123
|
+
saves results, and shows plots.
|
|
124
|
+
|
|
125
|
+
An example configuration file (`orbit_config.example.yaml`), is included in the repository, and `orbit_config.yaml` can be created and used by running:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cp orbit_config.example.yaml orbit_config.yaml # edit as needed
|
|
129
|
+
uv run python scripts/crtbpModel.py
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
orbit_gen/__init__.py,sha256=WwscDhNcpEt4Du2Km-jRjQHIdx2NlXqhE7wGaz_-ZKs,115
|
|
2
|
+
orbit_gen/family.py,sha256=_FCp6Mk9tR2VZ8jf4mVBEf2gru6ylWmNlX0iwmkAwmo,10052
|
|
3
|
+
orbit_gen/orbit.py,sha256=eaOgC3oMFKWTpKynpxQeOPETj1bsiSqhVI51y0WP2mg,4244
|
|
4
|
+
orbit_gen/setup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
orbit_gen/setup/config.py,sha256=VWNFEZqbzcouLLTMQaidEC35KDlPFTOPI-4GGT1849Y,3482
|
|
6
|
+
orbit_gen/setup/kernels.py,sha256=vv7oOnD-C50SvdUDoQDR9vSCMcUnmy8t1romdhBXWU8,1497
|
|
7
|
+
orbit_gen/setup/orbit_ic.py,sha256=c1Vq4DuJESXYSiP-oQf-GS4PQk4zsom8SxxuO6sxXFY,1159
|
|
8
|
+
orbit_gen/setup/presets.py,sha256=TjHxsmqIMekbkNqTocWAAfU3bbi_scsCD2GCkgInIdc,2865
|
|
9
|
+
orbit_gen/setup/system_config.py,sha256=5-0DdHuO6AfqsTE3K8vIIO1MEGYFzIzhiiUar-APxiw,1179
|
|
10
|
+
orbit_gen/utils/eom.py,sha256=LNUCAojl1HbnRGAP3QqXtym5zV76rUUBymNIfUiNatk,8609
|
|
11
|
+
orbit_gen/utils/units.py,sha256=UC84KnM5ixz_-Cnz-FKUBab6CYdCVPRHBnO-XinbOek,763
|
|
12
|
+
orbit_gen-0.1.0.dist-info/licenses/LICENSE,sha256=gL4A20LPzIj-2WSYJhRqu2qJMDJbcWYveyQzUtswiK0,1069
|
|
13
|
+
orbit_gen-0.1.0.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
|
|
14
|
+
orbit_gen-0.1.0.dist-info/METADATA,sha256=L_sn__BgYYPr8tx8sXUwLBwgwYyPLcvle4m9FDyi_dA,3826
|
|
15
|
+
orbit_gen-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jayden Ripic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|