orbit-gen 0.1.0__tar.gz

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.
@@ -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.
@@ -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,116 @@
1
+ # orbit-gen
2
+
3
+ Periodic orbit family generation and continuation in the Circular Restricted
4
+ Three-Body Problem (CR3BP).
5
+
6
+ ## Overview
7
+
8
+ `orbit-gen` generates families of periodic orbits in the CR3BP using
9
+ single-shooting differential correction and pseudo-arclength continuation.
10
+ It currently supports various families in the Earth-Moon and Sun-Earth systems.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install orbit-gen
16
+ ```
17
+ Requires Python 3.12+.
18
+
19
+ ## Usage
20
+
21
+ ### From a config file
22
+
23
+ ```yaml
24
+ # orbit_config.yaml
25
+ system: SE # "EM" (Earth-Moon) or "SE" (Sun-Earth)
26
+ preset: SE_L2_Northern_Halo
27
+
28
+ max_iter: 1000 # Maximum Newton iterations for a single differential correct attempt
29
+ eps: 1.0e-6 # Differential-correction convergence tolerance on residual norm
30
+ step: 0.01 # Pseudo-arclength continuation step size in canonical units
31
+ max_solutions: 20 # Maximum number of orbits to generate
32
+ max_period_days: 1000.0 # Maximum full orbit period in days
33
+
34
+ output_dir: results
35
+ show_plots: true # Optional; for user use only
36
+ ```
37
+
38
+ ```python
39
+ from orbit_gen import Family, RunConfig
40
+
41
+ cfg = RunConfig.from_yaml("orbit_config.yaml")
42
+ fam = Family(cfg)
43
+
44
+ fam.save()
45
+ if cfg.show_plots:
46
+ fam.plot()
47
+ ```
48
+
49
+ > **Note:** constructing `Family(cfg)` runs the continuation, but does not save or plot results on its own — call `.save()` / `.plot()` explicitly, as shown above.
50
+
51
+ ### Programmatically
52
+
53
+ ```python
54
+ from orbit_gen import Family, RunConfig
55
+ from orbit_gen.setup.system_config import SE_SYSTEM
56
+ from orbit_gen.setup.presets import PRESETS
57
+
58
+ cfg = RunConfig(system=SE_SYSTEM, ic=PRESETS["SE_L2_Northern_Halo"])
59
+ fam = Family(cfg)
60
+ fam.save()
61
+ ```
62
+
63
+ Available preset keys (see `orbit_gen.setup.presets.PRESETS`):
64
+ `EM_L2_Northern_Halo`, `EM_DRO_1`, `EM_DRO_2`, `EM_L1`, `EM_Butterfly`,
65
+ `EM_Unnamed_1`, `SE_L2_Northern_Halo`, `SE_L1_Vertical`, `SE_L2_Lyapunov`,
66
+ `SE_L2_Small_Lyapunov`, `SE_L2_Large_Lyapunov`, `SE_L2_Southern_Halo`,
67
+ `SE_Butterfly`, `SE_Dragonfly`.
68
+
69
+ ## Supported Systems
70
+
71
+ | Key | System |
72
+ |-----|--------|
73
+ | `EM` | Earth-Moon |
74
+ | `SE` | Sun-Earth |
75
+
76
+ ## Output
77
+
78
+ `fam.save()` writes a `.npz` file (named after the preset, in `cfg.output_dir`)
79
+ containing initial states, periods, and `mu_star` for all converged orbits:
80
+
81
+ ```python
82
+ import numpy as np
83
+ data = np.load("results/SE_L2_Northern_Halo.npz")
84
+ data["states"] # (N, 6) containing initial [x, y, z, vx, vy, vz] in canonical units for each orbit
85
+ data["periods"] # (N,) containing full period in canonical units for each orbit
86
+ data["mu_star"] # System mass ratio
87
+ ```
88
+
89
+ ## Contributing
90
+
91
+ Clone or download the repository and run
92
+
93
+ ```bash
94
+ uv sync
95
+ ```
96
+
97
+ to install the package in editable mode plus dev dependencies. Run the test suite with:
98
+
99
+ ```bash
100
+ uv run pytest
101
+ ```
102
+
103
+ The repo includes a driver script, `scripts/crtbpModel.py`,
104
+ which reads `orbit_config.yaml` from the project root, runs continuation,
105
+ saves results, and shows plots.
106
+
107
+ An example configuration file (`orbit_config.example.yaml`), is included in the repository, and `orbit_config.yaml` can be created and used by running:
108
+
109
+ ```bash
110
+ cp orbit_config.example.yaml orbit_config.yaml # edit as needed
111
+ uv run python scripts/crtbpModel.py
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.7,<0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "orbit-gen"
7
+ version = "0.1.0"
8
+ description = "Tools for generating families and orbits in the CR3BP"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = [
14
+ "astropy>=7.2.0",
15
+ "matplotlib>=3.10.9",
16
+ "numpy>=2.4.6",
17
+ "platformdirs>=4.10.0",
18
+ "pyyaml>=6.0.3",
19
+ "scipy>=1.17.1",
20
+ "spiceypy>=8.1.0",
21
+ ]
22
+
23
+ [[project.authors]]
24
+ name = "Jayden Ripic"
25
+ email = "jmr595@cornell.edu"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "deptry>=0.25.1",
30
+ "pytest>=9.1.1",
31
+ "pytest-cov>=7.1.0",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ log_cli = true
36
+ log_cli_level = "WARNING"
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.7,<0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "orbit-gen"
7
+ version = "0.1.0"
8
+ description = "Tools for generating families and orbits in the CR3BP"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [
13
+ { name = "Jayden Ripic", email = "jmr595@cornell.edu" },
14
+ ]
15
+ requires-python = ">=3.12"
16
+ dependencies = [
17
+ "astropy>=7.2.0",
18
+ "matplotlib>=3.10.9",
19
+ "numpy>=2.4.6",
20
+ "platformdirs>=4.10.0",
21
+ "pyyaml>=6.0.3",
22
+ "scipy>=1.17.1",
23
+ "spiceypy>=8.1.0",
24
+ ]
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "deptry>=0.25.1",
29
+ "pytest>=9.1.1",
30
+ "pytest-cov>=7.1.0",
31
+ ]
32
+
33
+ [tool.pytest.ini_options]
34
+ log_cli = true
35
+ log_cli_level = "WARNING"
@@ -0,0 +1,3 @@
1
+ from orbit_gen.orbit import Orbit
2
+ from orbit_gen.family import Family
3
+ from orbit_gen.setup.config import RunConfig
@@ -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
+ )