API Reference

The API reference is generated from source docstrings with mkdocstrings.

Mission And Execution

octavian.mission

Mission composition primitives.

Mission dataclass

Top-level user-facing mission container.

Source code in octavian/mission.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@dataclass(slots=True)
class Mission:
    """Top-level user-facing mission container."""

    phases: list[Phase] = field(default_factory=list)
    spacecraft: dict[str, Spacecraft] = field(default_factory=dict)
    name: str = "Mission"
    initial_epoch: str | datetime | float | None = None

    objectives: list[Objective] = field(default_factory=lambda: [minimize_total_delta_v()])

    plan: RunPlan = field(default_factory=RunPlan.default)
    retry: RetryPolicy = field(default_factory=RetryPolicy.default)
    solve_config: SolveConfig = field(default_factory=SolveConfig)
    solver_options: SolverOptions = field(default_factory=SolverOptions)

    mesh_nsegs_transfer: int = 60
    mesh_nsegs_precoast: int = 30
    lambert_grid_size: int = 60
    nrevs_to_try: tuple[int, ...] = (0, 1)
    precoast_grid_size: int = 10
    limit_precoast_to_one_period: bool = True
    w_time: float = 0.0

    def __post_init__(self) -> None:
        self.phases = list(self.phases)

    def add_phase(self, phase: Phase) -> None:
        """Append a phase to the mission.

        Args:
            phase: Phase to append.
        """
        self.phases.append(phase)

    def validate(self) -> None:
        """Validate mission structure before solving.

        Raises:
            ValueError: If the mission has no phases or a phase is invalid.
        """
        if not self.phases:
            raise ValueError("Mission has no phases.")
        for phase in self.phases:
            phase.validate()

    def solve(
        self,
        *,
        plan: RunPlan | None = None,
        retry: RetryPolicy | None = None,
        solve_config: SolveConfig | None = None,
        solver_options: SolverOptions | None = None,
    ) -> Solution:
        """Solve the mission using the configured runner.

        Args:
            plan: Optional continuation or staging plan override.
            retry: Optional retry-policy override.
            solve_config: Optional solve-configuration override.
            solver_options: Optional backend solver-options override.

        Returns:
            The mission solution wrapper.
        """
        mission_runner = MissionRunner(
            solve_options=solver_options or self.solver_options,
            solve_config=solve_config or self.solve_config,
            plan=plan or self.plan,
            retry=retry or self.retry,
        )
        return mission_runner.solve(self)

add_phase(phase: Phase) -> None

Append a phase to the mission.

Args: phase: Phase to append.

Source code in octavian/mission.py
44
45
46
47
48
49
50
def add_phase(self, phase: Phase) -> None:
    """Append a phase to the mission.

    Args:
        phase: Phase to append.
    """
    self.phases.append(phase)

solve(*, plan: RunPlan | None = None, retry: RetryPolicy | None = None, solve_config: SolveConfig | None = None, solver_options: SolverOptions | None = None) -> Solution

Solve the mission using the configured runner.

Args: plan: Optional continuation or staging plan override. retry: Optional retry-policy override. solve_config: Optional solve-configuration override. solver_options: Optional backend solver-options override.

Returns: The mission solution wrapper.

Source code in octavian/mission.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def solve(
    self,
    *,
    plan: RunPlan | None = None,
    retry: RetryPolicy | None = None,
    solve_config: SolveConfig | None = None,
    solver_options: SolverOptions | None = None,
) -> Solution:
    """Solve the mission using the configured runner.

    Args:
        plan: Optional continuation or staging plan override.
        retry: Optional retry-policy override.
        solve_config: Optional solve-configuration override.
        solver_options: Optional backend solver-options override.

    Returns:
        The mission solution wrapper.
    """
    mission_runner = MissionRunner(
        solve_options=solver_options or self.solver_options,
        solve_config=solve_config or self.solve_config,
        plan=plan or self.plan,
        retry=retry or self.retry,
    )
    return mission_runner.solve(self)

validate() -> None

Validate mission structure before solving.

Raises: ValueError: If the mission has no phases or a phase is invalid.

Source code in octavian/mission.py
52
53
54
55
56
57
58
59
60
61
def validate(self) -> None:
    """Validate mission structure before solving.

    Raises:
        ValueError: If the mission has no phases or a phase is invalid.
    """
    if not self.phases:
        raise ValueError("Mission has no phases.")
    for phase in self.phases:
        phase.validate()

octavian.runner

Mission runner and backend mapping logic.

MissionBuildError

Bases: ValueError

Raised when a mission cannot be mapped to a supported solver.

Source code in octavian/runner.py
20
21
class MissionBuildError(ValueError):
    """Raised when a mission cannot be mapped to a supported solver."""

MissionRunner dataclass

Coordinate mission validation, backend selection, and retry behavior.

Source code in octavian/runner.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
@dataclass(slots=True)
class MissionRunner:
    """Coordinate mission validation, backend selection, and retry behavior."""

    solve_options: SolverOptions
    solve_config: SolveConfig = field(default_factory=SolveConfig)
    plan: RunPlan = field(default_factory=RunPlan.default)
    retry: RetryPolicy = field(default_factory=RetryPolicy.default)

    def solve(self, mission: Mission) -> Solution:
        """Solve a mission through validation, staging, backend dispatch, and retry.

        Parameters
        ----------
        mission
            Mission to validate, compile, and solve.

        Returns
        -------
        Solution
            Solver result plus an attempt log describing each stage and retry.
        """
        self._validate_mission(mission)
        backend = _select_backend(mission)
        attempt_logs: list[AttemptLog] = []
        last_error: str | None = None
        last_result: RendezvousResult | None = None

        for stage_index, stage in enumerate(_runner_stages(self.plan)):
            stage_label = _stage_label(stage)
            stage_problem = _build_stage_problem(mission, backend, stage)

            for attempt_index in range(1, self._max_attempts() + 1):
                result = None
                try:
                    result = _solve_stage_problem(stage_problem, self.solve_options)
                except Exception as exc:  # noqa: BLE001
                    last_result = None
                    last_error = str(exc)
                    attempt_logs.append(
                        AttemptLog(
                            stage=stage_label,
                            attempt=attempt_index,
                            status="fail",
                            message=last_error,
                        )
                    )
                    if not self._should_retry(attempt_index):
                        break
                    stage_problem = _retry_stage_problem(
                        stage_problem,
                        attempt_index,
                        last_error,
                    )
                    continue

                last_result = result
                if result.converged:
                    attempt_logs.append(
                        AttemptLog(stage=stage_label, attempt=attempt_index, status="ok")
                    )
                else:
                    last_error = "Solver did not converge"
                    attempt_logs.append(
                        AttemptLog(
                            stage=stage_label,
                            attempt=attempt_index,
                            status="fail",
                            message=last_error,
                        )
                    )
                solution = Solution(ok=bool(result.converged), result=result, attempts=attempt_logs)
                solution.info.update(
                    {
                        "stage": stage_label,
                        "stage_index": stage_index,
                        "mission_name": mission.name,
                        "initial_epoch": mission.initial_epoch,
                    }
                )
                if result.converged or not self.solve_config.raise_on_fail:
                    return solution
                if not self._should_retry(attempt_index):
                    break
                stage_problem = _retry_stage_problem(stage_problem, attempt_index, last_error)

        solution = Solution(
            ok=False,
            result=last_result,
            attempts=attempt_logs,
            last_error=last_error,
        )
        if self.solve_config.raise_on_fail:
            raise RuntimeError(solution.summary())
        return solution

    def _validate_mission(self, mission: Mission) -> None:
        """Validate the mission object before backend selection.

        Parameters
        ----------
        mission
            Candidate mission object.

        Raises
        ------
        TypeError
            If ``mission`` is not an Octavian ``Mission``.
        ValueError
            If mission-level validation fails.
        """
        from .mission import Mission

        if not isinstance(mission, Mission):
            raise TypeError("MissionRunner.solve expects a Mission.")
        mission.validate()

    def _max_attempts(self) -> int:
        """Return the configured maximum attempt count.

        Returns
        -------
        int
            At least one attempt.
        """
        configured_attempts = max(1, int(self.solve_config.max_attempts))
        if not self.retry.enabled:
            return configured_attempts
        retry_limited_attempts = max(1, 1 + int(self.retry.max_retries))
        return min(configured_attempts, retry_limited_attempts)

    def _should_retry(self, attempt_index: int) -> bool:
        """Return whether another attempt should be made.

        Parameters
        ----------
        attempt_index
            One-based attempt index that just finished.

        Returns
        -------
        bool
            Whether retry policy and solve config allow another attempt.
        """
        return bool(self.retry.enabled) and attempt_index < self._max_attempts()

solve(mission: Mission) -> Solution

Solve a mission through validation, staging, backend dispatch, and retry.

Parameters:
  • mission (Mission) –

    Mission to validate, compile, and solve.

Returns:
  • Solution –

    Solver result plus an attempt log describing each stage and retry.

Source code in octavian/runner.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def solve(self, mission: Mission) -> Solution:
    """Solve a mission through validation, staging, backend dispatch, and retry.

    Parameters
    ----------
    mission
        Mission to validate, compile, and solve.

    Returns
    -------
    Solution
        Solver result plus an attempt log describing each stage and retry.
    """
    self._validate_mission(mission)
    backend = _select_backend(mission)
    attempt_logs: list[AttemptLog] = []
    last_error: str | None = None
    last_result: RendezvousResult | None = None

    for stage_index, stage in enumerate(_runner_stages(self.plan)):
        stage_label = _stage_label(stage)
        stage_problem = _build_stage_problem(mission, backend, stage)

        for attempt_index in range(1, self._max_attempts() + 1):
            result = None
            try:
                result = _solve_stage_problem(stage_problem, self.solve_options)
            except Exception as exc:  # noqa: BLE001
                last_result = None
                last_error = str(exc)
                attempt_logs.append(
                    AttemptLog(
                        stage=stage_label,
                        attempt=attempt_index,
                        status="fail",
                        message=last_error,
                    )
                )
                if not self._should_retry(attempt_index):
                    break
                stage_problem = _retry_stage_problem(
                    stage_problem,
                    attempt_index,
                    last_error,
                )
                continue

            last_result = result
            if result.converged:
                attempt_logs.append(
                    AttemptLog(stage=stage_label, attempt=attempt_index, status="ok")
                )
            else:
                last_error = "Solver did not converge"
                attempt_logs.append(
                    AttemptLog(
                        stage=stage_label,
                        attempt=attempt_index,
                        status="fail",
                        message=last_error,
                    )
                )
            solution = Solution(ok=bool(result.converged), result=result, attempts=attempt_logs)
            solution.info.update(
                {
                    "stage": stage_label,
                    "stage_index": stage_index,
                    "mission_name": mission.name,
                    "initial_epoch": mission.initial_epoch,
                }
            )
            if result.converged or not self.solve_config.raise_on_fail:
                return solution
            if not self._should_retry(attempt_index):
                break
            stage_problem = _retry_stage_problem(stage_problem, attempt_index, last_error)

    solution = Solution(
        ok=False,
        result=last_result,
        attempts=attempt_logs,
        last_error=last_error,
    )
    if self.solve_config.raise_on_fail:
        raise RuntimeError(solution.summary())
    return solution

octavian.solution

Solution and reporting.

Solution is Octavian's stable output contract. It wraps backend-specific result objects (currently RendezvousResult) and adds: - attempt history - a consistent summary string - a small viz namespace

AttemptLog dataclass

One solver attempt recorded by MissionRunner.

Attributes: stage: Runner stage label, or "default" when no staged plan is used. attempt: One-based attempt number within the stage. status: Short status string such as "ok" or "fail". message: Optional failure or diagnostic message.

Source code in octavian/solution.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@dataclass(slots=True)
class AttemptLog:
    """One solver attempt recorded by `MissionRunner`.

    Attributes:
        stage: Runner stage label, or ``"default"`` when no staged plan is used.
        attempt: One-based attempt number within the stage.
        status: Short status string such as ``"ok"`` or ``"fail"``.
        message: Optional failure or diagnostic message.
    """

    stage: str
    attempt: int
    status: str
    message: str = ""

Solution dataclass

Stable user-facing wrapper around a backend solve result.

Solution keeps backend result objects from leaking directly into mission scripts. Successful solves expose the backend result through result and convenience accessors such as traj. Failed solves still return structured attempt logs when SolveConfig.raise_on_fail is false.

Source code in octavian/solution.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
@dataclass(slots=True)
class Solution:
    """Stable user-facing wrapper around a backend solve result.

    `Solution` keeps backend result objects from leaking directly into mission
    scripts. Successful solves expose the backend result through ``result`` and
    convenience accessors such as ``traj``. Failed solves still return structured
    attempt logs when ``SolveConfig.raise_on_fail`` is false.
    """

    ok: bool
    result: RendezvousResult | None = None
    attempts: list[AttemptLog] = field(default_factory=list)
    info: dict[str, Any] = field(default_factory=dict)
    last_error: str | None = None

    def summary(self) -> str:
        """Return a human-readable solve summary."""
        if self.result is not None:
            return self.result.summary()
        lines = ["Octavian solution: FAILED"]
        if self.last_error:
            lines.append(f"  last_error: {self.last_error}")
        if self.attempts:
            lines.append("  attempts:")
            for a in self.attempts:
                lines.append(
                    f"    - stage={a.stage} attempt={a.attempt}: {a.status} {a.message}".rstrip()
                )
        return "\n".join(lines)

    @property
    def traj(self) -> np.ndarray:
        """Return the solved trajectory array, or an empty array on failure."""
        if self.result is None:
            return np.empty((0, 0), dtype=float)
        return np.asarray(self.result.traj, dtype=float)

    @property
    def frame(self) -> CoordinateFrame | None:
        """Return the reference frame declared by the solver result."""
        if self.result is None:
            return None
        value = self.result.info.get("frame")
        if isinstance(value, CoordinateFrame):
            return value
        if isinstance(value, dict):
            return CoordinateFrame.from_dict(value)
        return None

    @property
    def scaling(self) -> SolverScaling | None:
        """Return characteristic units used by the solver, when available."""
        if self.result is None:
            return None
        value = self.result.info.get("scaling")
        if isinstance(value, SolverScaling):
            return value
        if isinstance(value, dict):
            return SolverScaling(**value)
        return None

    @property
    def chief_trajectory_eci(self) -> np.ndarray:
        """Return the propagated chief absolute history for relative solves."""
        if self.result is None:
            return np.empty((0, 7), dtype=float)
        value = self.result.info.get("chief_trajectory_eci")
        if value is None:
            return np.empty((0, 7), dtype=float)
        return np.asarray(value, dtype=float)

    @property
    def deputy_trajectory_eci(self) -> np.ndarray:
        """Return the propagated deputy absolute history for relative solves."""
        if self.result is None:
            return np.empty((0, 7), dtype=float)
        value = self.result.info.get("deputy_trajectory_eci")
        if value is None:
            return np.empty((0, 7), dtype=float)
        return np.asarray(value, dtype=float)

    @property
    def native_relative_trajectory(self) -> np.ndarray:
        """Return the solver's native relative state and time history.

        For a D'Amico phase, for example, columns are
        ``[δa, δλ, δex, δey, δix, δiy, t]``. The ordinary :attr:`traj`
        accessor always remains the reconstructed ``[RIC state, t]`` view.
        """
        if self.result is None:
            return np.empty((0, 7), dtype=float)
        value = self.result.info.get("native_relative_trajectory")
        if value is None:
            return np.empty((0, 7), dtype=float)
        return np.asarray(value, dtype=float)

    @property
    def native_relative_phase_trajectories(self) -> tuple[np.ndarray, ...]:
        """Return one control-free native state/time array per relative phase.

        A stitched native trajectory is unavailable when phase layouts change,
        for example when an initial coast does not carry mass but a later
        finite-burn chain does. This accessor always preserves each phase's
        declared native representation.
        """
        if self.result is None:
            return ()
        value = self.result.info.get("native_relative_phase_trajectories")
        if value is None:
            return ()
        return tuple(np.asarray(trajectory, dtype=float) for trajectory in value)

    @property
    def relative_propagation_mode(self) -> str | None:
        """Return the selected relative formulation, or ``None`` if inertial."""
        if self.result is None:
            return None
        value = self.result.info.get("relative_propagation_mode")
        return None if value is None else str(value)

    @property
    def cr3bp_system(self) -> CR3BPSystem | None:
        """Return the solved CR3BP system, or ``None`` for other dynamics."""
        if self.result is None:
            return None
        value = self.result.info.get("cr3bp_system")
        if not isinstance(value, dict):
            return None
        return CR3BPSystem(
            primary=str(value["primary"]),
            secondary=str(value["secondary"]),
            separation_m=float(value["separation_m"]),
            name=str(value["name"]),
        )

    @property
    def cr3bp_dimensional(self) -> bool | None:
        """Return the solved CR3BP unit mode, or ``None`` for other dynamics."""
        if self.result is None:
            return None
        value = self.result.info.get("cr3bp_system")
        if not isinstance(value, dict):
            return None
        return bool(value.get("dimensional", True))

    @property
    def phase_control_trajectories(self) -> tuple[np.ndarray, ...]:
        """Return ``[time, controls...]`` arrays for every compiled phase.

        Vector-throttle components and scalar throttle are dimensionless.
        Euler-rate columns are converted from normalized optimizer variables
        to physical radians per second.
        """
        if self.result is None:
            return ()
        value = self.result.info.get("phase_control_trajectories")
        if value is None:
            return ()
        return tuple(np.asarray(trajectory, dtype=float) for trajectory in value)

    @property
    def attitude_phase_trajectories(self) -> tuple[np.ndarray, ...]:
        """Return Euler attitude histories for kinematic-attitude phases.

        Each array contains ``[yaw, pitch, roll, time, yaw_rate, pitch_rate,
        roll_rate]`` in radians, seconds, and radians per second.
        """
        if self.result is None:
            return ()
        value = self.result.info.get("attitude_phase_trajectories")
        if value is None:
            return ()
        return tuple(np.asarray(trajectory, dtype=float) for trajectory in value)

    def to_ephemeris(
        self,
        *,
        trajectory: str = "auto",
        epoch: str | datetime | float | int | None = None,
        frame_name: str | None = None,
        center_name: str | None = None,
        object_name: str | None = None,
        object_id: int = -100_000,
        center_id: int | None = None,
    ) -> Ephemeris:
        """Return a validated ephemeris selected from this solution.

        Args:
            trajectory: ``"auto"`` (the deputy for relative solutions,
                otherwise the solved trajectory), ``"solved"``, ``"chief"``,
                or ``"deputy"``. Chief and deputy histories are absolute ECI
                trajectories reconstructed by exact relative formulations.
            epoch: Epoch corresponding to trajectory time zero. When omitted,
                use the originating mission's ``initial_epoch``.
            frame_name: Frame label for the state values. This does not rotate
                coordinates. Absolute ECI histories default to ``"J2000"``.
            center_name: Center-of-motion label, normally inferred from the
                solution metadata.
            object_name: Exported object label.
            object_id: NAIF object ID used by BSP output.
            center_id: NAIF center ID used by BSP output, inferred for Earth,
                Moon, and Sun when omitted.

        Returns:
            An :class:`octavian.exports.Ephemeris` ready to write.

        Raises:
            RuntimeError: If the solution has no result.
            ValueError: If the selected trajectory or epoch is unavailable.

        Note:
            File export labels the selected data but does not perform a frame
            rotation. Select ``"chief"`` or ``"deputy"`` when exporting a
            relative solution to an inertial OEM or BSP.
        """
        if self.result is None:
            raise RuntimeError("No result to export")

        selected = str(trajectory).strip().lower()
        if selected == "auto":
            selected = (
                "deputy"
                if self.frame is not None
                and self.frame.kind == "relative"
                and self.deputy_trajectory_eci.size
                else "solved"
            )
        if selected not in {"solved", "chief", "deputy"}:
            raise ValueError("trajectory must be one of: auto, solved, chief, deputy")
        if selected == "solved" and self.cr3bp_dimensional is False:
            raise ValueError(
                "Canonical CR3BP trajectories cannot be exported as SI ephemerides. "
                "Dimensionalize the state and time history first."
            )

        if selected == "chief":
            rows = self.chief_trajectory_eci
        elif selected == "deputy":
            rows = self.deputy_trajectory_eci
        else:
            rows = self.traj
        if rows.ndim != 2 or rows.shape[1] != 7 or len(rows) < 2:
            raise ValueError(f"The solution does not contain an exportable {selected} trajectory")

        export_epoch = epoch
        if export_epoch is None:
            export_epoch = self.info.get("initial_epoch")
        if export_epoch is None:
            export_epoch = self.result.info.get("initial_epoch")
        if export_epoch is None:
            raise ValueError("Ephemeris export requires epoch= or Mission(initial_epoch=...).")

        absolute_history = selected in {"chief", "deputy"}
        solution_frame = self.frame
        inferred_frame = "J2000"
        if not absolute_history and solution_frame is not None:
            orientation = solution_frame.orientation.strip()
            inferred_frame = (
                "J2000"
                if orientation.upper() in {"ECI", "ICRF", "J2000", "EME2000"}
                else orientation
            )
        inferred_center = str(
            self.result.info.get("central_body")
            or (solution_frame.origin if solution_frame is not None else "earth")
        )
        if not absolute_history and solution_frame is not None:
            inferred_center = solution_frame.origin

        resolved_center = str(center_name or inferred_center).strip().upper()
        resolved_object = object_name or (
            "CHIEF" if selected == "chief" else "DEPUTY" if selected == "deputy" else "SPACECRAFT"
        )
        resolved_center_id = (
            int(center_id) if center_id is not None else _common_naif_center_id(resolved_center)
        )
        return Ephemeris.from_trajectory(
            rows,
            epoch=export_epoch,
            frame_name=frame_name or inferred_frame,
            center_name=resolved_center,
            object_name=resolved_object,
            object_id=object_id,
            center_id=resolved_center_id,
        )

    def export_ephemeris(
        self,
        path: str | Path,
        *,
        trajectory: str = "auto",
        epoch: str | datetime | float | int | None = None,
        frame_name: str | None = None,
        center_name: str | None = None,
        object_name: str | None = None,
        object_id: int = -100_000,
        center_id: int | None = None,
        format: EphemerisFormat | None = None,
        overwrite: bool = False,
        interpolation_degree: int = 7,
    ) -> Path:
        """Export a solved, chief, or deputy trajectory to an ephemeris file.

        The output extension selects STK ``.e``, CCSDS ``.oem``, SPICE
        ``.bsp``/``.spk``, or SI-unit ``.csv``. See :meth:`to_ephemeris` for
        trajectory and frame semantics.

        Returns:
            The resolved path written by the selected exporter.
        """
        ephemeris = self.to_ephemeris(
            trajectory=trajectory,
            epoch=epoch,
            frame_name=frame_name,
            center_name=center_name,
            object_name=object_name,
            object_id=object_id,
            center_id=center_id,
        )
        return ephemeris.write(
            path,
            format=format,
            overwrite=overwrite,
            interpolation_degree=interpolation_degree,
        )

    def viz(self):
        """Namespace-style access to visualization helpers."""
        from .viz import matplotlib as _matplotlib
        from .viz import plotly as _plotly

        self_outer = self

        class _Viz:
            def save_html(self, out_html: str, *, title: str = "trajectory") -> None:
                """Save a frame-aware trajectory plot.

                Relative results use RIC axes and a chief marker, rotating
                CR3BP results show both primaries and Lagrange points, and
                inertial results retain the Earth-centered trajectory view.
                """
                if self_outer.result is None:
                    raise RuntimeError("No result to visualize")
                frame = self_outer.frame
                phase_segments = self_outer.result.info.get("phase_segments")
                if frame is not None and frame.kind == "relative":
                    _plotly.save_relative_trajectory_html(
                        self_outer.result.traj,
                        out_html,
                        maneuvers=self_outer.result.maneuvers,
                        phase_segments=phase_segments,
                        title=title,
                    )
                    return
                if frame is not None and frame.kind == "rotating":
                    system = self_outer.cr3bp_system
                    if system is None:
                        raise RuntimeError(
                            "Rotating-frame visualization requires CR3BP system metadata"
                        )
                    _plotly.save_cr3bp_trajectory_html(
                        self_outer.result.traj,
                        out_html,
                        system=system,
                        dimensional=self_outer.cr3bp_dimensional is not False,
                        title=title,
                    )
                    return
                _plotly.save_trajectory_html(
                    self_outer.result.traj,
                    out_html,
                    maneuvers=self_outer.result.maneuvers,
                    phase_segments=phase_segments,
                    title=title,
                )

            def figure(
                self,
                *,
                title: str = "trajectory",
                projection: str = "3d",
            ):
                """Return a frame-aware Matplotlib trajectory figure.

                Use ``projection="xy"``, ``"xz"``, or ``"yz"`` for a 2D
                view; the default ``"3d"`` retains all position axes.
                """
                if self_outer.result is None:
                    raise RuntimeError("No result to visualize")
                frame = self_outer.frame
                phase_segments = self_outer.result.info.get("phase_segments")
                if frame is not None and frame.kind == "relative":
                    return _matplotlib.relative_trajectory_figure(
                        self_outer.result.traj,
                        maneuvers=self_outer.result.maneuvers,
                        phase_segments=phase_segments,
                        title=title,
                        projection=projection,
                    )
                if frame is not None and frame.kind == "rotating":
                    system = self_outer.cr3bp_system
                    if system is None:
                        raise RuntimeError(
                            "Rotating-frame visualization requires CR3BP system metadata"
                        )
                    return _matplotlib.cr3bp_trajectory_figure(
                        self_outer.result.traj,
                        system=system,
                        dimensional=self_outer.cr3bp_dimensional is not False,
                        title=title,
                        projection=projection,
                    )
                return _matplotlib.trajectory_figure(
                    self_outer.result.traj,
                    maneuvers=self_outer.result.maneuvers,
                    phase_segments=phase_segments,
                    title=title,
                    projection=projection,
                )

            def save_image(
                self,
                out_image: str | Path,
                *,
                title: str = "trajectory",
                projection: str = "3d",
                dpi: int = 160,
            ) -> None:
                """Save a frame-aware Matplotlib trajectory as PNG or JPEG."""
                _matplotlib.save_figure_image(
                    self.figure(title=title, projection=projection),
                    out_image,
                    dpi=dpi,
                )

            def show(
                self,
                *,
                title: str = "trajectory",
                projection: str = "3d",
            ):
                """Open a frame-aware trajectory using the Matplotlib GUI backend."""
                return _matplotlib.show_figure(self.figure(title=title, projection=projection))

            def save_relative_html(
                self,
                out_html: str,
                *,
                title: str = "relative trajectory",
                chief_radius_m: float = 0.0,
            ) -> None:
                """Save the result explicitly as a chief-centered RIC plot."""
                if self_outer.result is None:
                    raise RuntimeError("No result to visualize")
                _plotly.save_relative_trajectory_html(
                    self_outer.result.traj,
                    out_html,
                    maneuvers=self_outer.result.maneuvers,
                    phase_segments=self_outer.result.info.get("phase_segments"),
                    title=title,
                    chief_radius_m=chief_radius_m,
                )

            def save_diagnostics_html(
                self,
                out_html: str,
                *,
                title: str = "trajectory diagnostics",
            ) -> None:
                """Save frame-aware state and geometry values over time."""
                if self_outer.result is None:
                    raise RuntimeError("No result to visualize")
                frame = self_outer.frame
                _plotly.save_trajectory_diagnostics_html(
                    self_outer.result.traj,
                    out_html,
                    frame_kind=(frame.kind if frame is not None else "inertial"),
                    mu_m3ps2=self_outer.result.info.get("mu_m3ps2"),
                    solar_directions_ric=self_outer.result.info.get("solar_directions_ric"),
                    cr3bp_system=self_outer.cr3bp_system,
                    cr3bp_dimensional=self_outer.cr3bp_dimensional is not False,
                    title=title,
                )

            def diagnostics_figure(
                self,
                *,
                title: str = "trajectory diagnostics",
            ):
                """Return frame-aware Matplotlib state and geometry panels."""
                if self_outer.result is None:
                    raise RuntimeError("No result to visualize")
                frame = self_outer.frame
                return _matplotlib.trajectory_diagnostics_figure(
                    self_outer.result.traj,
                    frame_kind=(frame.kind if frame is not None else "inertial"),
                    mu_m3ps2=self_outer.result.info.get("mu_m3ps2"),
                    solar_directions_ric=self_outer.result.info.get("solar_directions_ric"),
                    cr3bp_system=self_outer.cr3bp_system,
                    cr3bp_dimensional=self_outer.cr3bp_dimensional is not False,
                    title=title,
                )

            def save_diagnostics_image(
                self,
                out_image: str | Path,
                *,
                title: str = "trajectory diagnostics",
                dpi: int = 160,
            ) -> None:
                """Save frame-aware Matplotlib diagnostics as PNG or JPEG."""
                _matplotlib.save_figure_image(
                    self.diagnostics_figure(title=title),
                    out_image,
                    dpi=dpi,
                )

            def show_diagnostics(
                self,
                *,
                title: str = "trajectory diagnostics",
            ):
                """Open frame-aware diagnostics using the Matplotlib GUI backend."""
                return _matplotlib.show_figure(self.diagnostics_figure(title=title))

        return _Viz()

attitude_phase_trajectories: tuple[np.ndarray, ...] property

Return Euler attitude histories for kinematic-attitude phases.

Each array contains [yaw, pitch, roll, time, yaw_rate, pitch_rate, roll_rate] in radians, seconds, and radians per second.

chief_trajectory_eci: np.ndarray property

Return the propagated chief absolute history for relative solves.

cr3bp_dimensional: bool | None property

Return the solved CR3BP unit mode, or None for other dynamics.

cr3bp_system: CR3BPSystem | None property

Return the solved CR3BP system, or None for other dynamics.

deputy_trajectory_eci: np.ndarray property

Return the propagated deputy absolute history for relative solves.

frame: CoordinateFrame | None property

Return the reference frame declared by the solver result.

native_relative_phase_trajectories: tuple[np.ndarray, ...] property

Return one control-free native state/time array per relative phase.

A stitched native trajectory is unavailable when phase layouts change, for example when an initial coast does not carry mass but a later finite-burn chain does. This accessor always preserves each phase's declared native representation.

native_relative_trajectory: np.ndarray property

Return the solver's native relative state and time history.

For a D'Amico phase, for example, columns are [δa, δλ, δex, δey, δix, δiy, t]. The ordinary :attr:traj accessor always remains the reconstructed [RIC state, t] view.

phase_control_trajectories: tuple[np.ndarray, ...] property

Return [time, controls...] arrays for every compiled phase.

Vector-throttle components and scalar throttle are dimensionless. Euler-rate columns are converted from normalized optimizer variables to physical radians per second.

relative_propagation_mode: str | None property

Return the selected relative formulation, or None if inertial.

scaling: SolverScaling | None property

Return characteristic units used by the solver, when available.

traj: np.ndarray property

Return the solved trajectory array, or an empty array on failure.

export_ephemeris(path: str | Path, *, trajectory: str = 'auto', epoch: str | datetime | float | int | None = None, frame_name: str | None = None, center_name: str | None = None, object_name: str | None = None, object_id: int = -100000, center_id: int | None = None, format: EphemerisFormat | None = None, overwrite: bool = False, interpolation_degree: int = 7) -> Path

Export a solved, chief, or deputy trajectory to an ephemeris file.

The output extension selects STK .e, CCSDS .oem, SPICE .bsp/.spk, or SI-unit .csv. See :meth:to_ephemeris for trajectory and frame semantics.

Returns: The resolved path written by the selected exporter.

Source code in octavian/solution.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def export_ephemeris(
    self,
    path: str | Path,
    *,
    trajectory: str = "auto",
    epoch: str | datetime | float | int | None = None,
    frame_name: str | None = None,
    center_name: str | None = None,
    object_name: str | None = None,
    object_id: int = -100_000,
    center_id: int | None = None,
    format: EphemerisFormat | None = None,
    overwrite: bool = False,
    interpolation_degree: int = 7,
) -> Path:
    """Export a solved, chief, or deputy trajectory to an ephemeris file.

    The output extension selects STK ``.e``, CCSDS ``.oem``, SPICE
    ``.bsp``/``.spk``, or SI-unit ``.csv``. See :meth:`to_ephemeris` for
    trajectory and frame semantics.

    Returns:
        The resolved path written by the selected exporter.
    """
    ephemeris = self.to_ephemeris(
        trajectory=trajectory,
        epoch=epoch,
        frame_name=frame_name,
        center_name=center_name,
        object_name=object_name,
        object_id=object_id,
        center_id=center_id,
    )
    return ephemeris.write(
        path,
        format=format,
        overwrite=overwrite,
        interpolation_degree=interpolation_degree,
    )

summary() -> str

Return a human-readable solve summary.

Source code in octavian/solution.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def summary(self) -> str:
    """Return a human-readable solve summary."""
    if self.result is not None:
        return self.result.summary()
    lines = ["Octavian solution: FAILED"]
    if self.last_error:
        lines.append(f"  last_error: {self.last_error}")
    if self.attempts:
        lines.append("  attempts:")
        for a in self.attempts:
            lines.append(
                f"    - stage={a.stage} attempt={a.attempt}: {a.status} {a.message}".rstrip()
            )
    return "\n".join(lines)

to_ephemeris(*, trajectory: str = 'auto', epoch: str | datetime | float | int | None = None, frame_name: str | None = None, center_name: str | None = None, object_name: str | None = None, object_id: int = -100000, center_id: int | None = None) -> Ephemeris

Return a validated ephemeris selected from this solution.

Args: trajectory: "auto" (the deputy for relative solutions, otherwise the solved trajectory), "solved", "chief", or "deputy". Chief and deputy histories are absolute ECI trajectories reconstructed by exact relative formulations. epoch: Epoch corresponding to trajectory time zero. When omitted, use the originating mission's initial_epoch. frame_name: Frame label for the state values. This does not rotate coordinates. Absolute ECI histories default to "J2000". center_name: Center-of-motion label, normally inferred from the solution metadata. object_name: Exported object label. object_id: NAIF object ID used by BSP output. center_id: NAIF center ID used by BSP output, inferred for Earth, Moon, and Sun when omitted.

Returns: An :class:octavian.exports.Ephemeris ready to write.

Raises: RuntimeError: If the solution has no result. ValueError: If the selected trajectory or epoch is unavailable.

Note: File export labels the selected data but does not perform a frame rotation. Select "chief" or "deputy" when exporting a relative solution to an inertial OEM or BSP.

Source code in octavian/solution.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def to_ephemeris(
    self,
    *,
    trajectory: str = "auto",
    epoch: str | datetime | float | int | None = None,
    frame_name: str | None = None,
    center_name: str | None = None,
    object_name: str | None = None,
    object_id: int = -100_000,
    center_id: int | None = None,
) -> Ephemeris:
    """Return a validated ephemeris selected from this solution.

    Args:
        trajectory: ``"auto"`` (the deputy for relative solutions,
            otherwise the solved trajectory), ``"solved"``, ``"chief"``,
            or ``"deputy"``. Chief and deputy histories are absolute ECI
            trajectories reconstructed by exact relative formulations.
        epoch: Epoch corresponding to trajectory time zero. When omitted,
            use the originating mission's ``initial_epoch``.
        frame_name: Frame label for the state values. This does not rotate
            coordinates. Absolute ECI histories default to ``"J2000"``.
        center_name: Center-of-motion label, normally inferred from the
            solution metadata.
        object_name: Exported object label.
        object_id: NAIF object ID used by BSP output.
        center_id: NAIF center ID used by BSP output, inferred for Earth,
            Moon, and Sun when omitted.

    Returns:
        An :class:`octavian.exports.Ephemeris` ready to write.

    Raises:
        RuntimeError: If the solution has no result.
        ValueError: If the selected trajectory or epoch is unavailable.

    Note:
        File export labels the selected data but does not perform a frame
        rotation. Select ``"chief"`` or ``"deputy"`` when exporting a
        relative solution to an inertial OEM or BSP.
    """
    if self.result is None:
        raise RuntimeError("No result to export")

    selected = str(trajectory).strip().lower()
    if selected == "auto":
        selected = (
            "deputy"
            if self.frame is not None
            and self.frame.kind == "relative"
            and self.deputy_trajectory_eci.size
            else "solved"
        )
    if selected not in {"solved", "chief", "deputy"}:
        raise ValueError("trajectory must be one of: auto, solved, chief, deputy")
    if selected == "solved" and self.cr3bp_dimensional is False:
        raise ValueError(
            "Canonical CR3BP trajectories cannot be exported as SI ephemerides. "
            "Dimensionalize the state and time history first."
        )

    if selected == "chief":
        rows = self.chief_trajectory_eci
    elif selected == "deputy":
        rows = self.deputy_trajectory_eci
    else:
        rows = self.traj
    if rows.ndim != 2 or rows.shape[1] != 7 or len(rows) < 2:
        raise ValueError(f"The solution does not contain an exportable {selected} trajectory")

    export_epoch = epoch
    if export_epoch is None:
        export_epoch = self.info.get("initial_epoch")
    if export_epoch is None:
        export_epoch = self.result.info.get("initial_epoch")
    if export_epoch is None:
        raise ValueError("Ephemeris export requires epoch= or Mission(initial_epoch=...).")

    absolute_history = selected in {"chief", "deputy"}
    solution_frame = self.frame
    inferred_frame = "J2000"
    if not absolute_history and solution_frame is not None:
        orientation = solution_frame.orientation.strip()
        inferred_frame = (
            "J2000"
            if orientation.upper() in {"ECI", "ICRF", "J2000", "EME2000"}
            else orientation
        )
    inferred_center = str(
        self.result.info.get("central_body")
        or (solution_frame.origin if solution_frame is not None else "earth")
    )
    if not absolute_history and solution_frame is not None:
        inferred_center = solution_frame.origin

    resolved_center = str(center_name or inferred_center).strip().upper()
    resolved_object = object_name or (
        "CHIEF" if selected == "chief" else "DEPUTY" if selected == "deputy" else "SPACECRAFT"
    )
    resolved_center_id = (
        int(center_id) if center_id is not None else _common_naif_center_id(resolved_center)
    )
    return Ephemeris.from_trajectory(
        rows,
        epoch=export_epoch,
        frame_name=frame_name or inferred_frame,
        center_name=resolved_center,
        object_name=resolved_object,
        object_id=object_id,
        center_id=resolved_center_id,
    )

viz()

Namespace-style access to visualization helpers.

Source code in octavian/solution.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
def viz(self):
    """Namespace-style access to visualization helpers."""
    from .viz import matplotlib as _matplotlib
    from .viz import plotly as _plotly

    self_outer = self

    class _Viz:
        def save_html(self, out_html: str, *, title: str = "trajectory") -> None:
            """Save a frame-aware trajectory plot.

            Relative results use RIC axes and a chief marker, rotating
            CR3BP results show both primaries and Lagrange points, and
            inertial results retain the Earth-centered trajectory view.
            """
            if self_outer.result is None:
                raise RuntimeError("No result to visualize")
            frame = self_outer.frame
            phase_segments = self_outer.result.info.get("phase_segments")
            if frame is not None and frame.kind == "relative":
                _plotly.save_relative_trajectory_html(
                    self_outer.result.traj,
                    out_html,
                    maneuvers=self_outer.result.maneuvers,
                    phase_segments=phase_segments,
                    title=title,
                )
                return
            if frame is not None and frame.kind == "rotating":
                system = self_outer.cr3bp_system
                if system is None:
                    raise RuntimeError(
                        "Rotating-frame visualization requires CR3BP system metadata"
                    )
                _plotly.save_cr3bp_trajectory_html(
                    self_outer.result.traj,
                    out_html,
                    system=system,
                    dimensional=self_outer.cr3bp_dimensional is not False,
                    title=title,
                )
                return
            _plotly.save_trajectory_html(
                self_outer.result.traj,
                out_html,
                maneuvers=self_outer.result.maneuvers,
                phase_segments=phase_segments,
                title=title,
            )

        def figure(
            self,
            *,
            title: str = "trajectory",
            projection: str = "3d",
        ):
            """Return a frame-aware Matplotlib trajectory figure.

            Use ``projection="xy"``, ``"xz"``, or ``"yz"`` for a 2D
            view; the default ``"3d"`` retains all position axes.
            """
            if self_outer.result is None:
                raise RuntimeError("No result to visualize")
            frame = self_outer.frame
            phase_segments = self_outer.result.info.get("phase_segments")
            if frame is not None and frame.kind == "relative":
                return _matplotlib.relative_trajectory_figure(
                    self_outer.result.traj,
                    maneuvers=self_outer.result.maneuvers,
                    phase_segments=phase_segments,
                    title=title,
                    projection=projection,
                )
            if frame is not None and frame.kind == "rotating":
                system = self_outer.cr3bp_system
                if system is None:
                    raise RuntimeError(
                        "Rotating-frame visualization requires CR3BP system metadata"
                    )
                return _matplotlib.cr3bp_trajectory_figure(
                    self_outer.result.traj,
                    system=system,
                    dimensional=self_outer.cr3bp_dimensional is not False,
                    title=title,
                    projection=projection,
                )
            return _matplotlib.trajectory_figure(
                self_outer.result.traj,
                maneuvers=self_outer.result.maneuvers,
                phase_segments=phase_segments,
                title=title,
                projection=projection,
            )

        def save_image(
            self,
            out_image: str | Path,
            *,
            title: str = "trajectory",
            projection: str = "3d",
            dpi: int = 160,
        ) -> None:
            """Save a frame-aware Matplotlib trajectory as PNG or JPEG."""
            _matplotlib.save_figure_image(
                self.figure(title=title, projection=projection),
                out_image,
                dpi=dpi,
            )

        def show(
            self,
            *,
            title: str = "trajectory",
            projection: str = "3d",
        ):
            """Open a frame-aware trajectory using the Matplotlib GUI backend."""
            return _matplotlib.show_figure(self.figure(title=title, projection=projection))

        def save_relative_html(
            self,
            out_html: str,
            *,
            title: str = "relative trajectory",
            chief_radius_m: float = 0.0,
        ) -> None:
            """Save the result explicitly as a chief-centered RIC plot."""
            if self_outer.result is None:
                raise RuntimeError("No result to visualize")
            _plotly.save_relative_trajectory_html(
                self_outer.result.traj,
                out_html,
                maneuvers=self_outer.result.maneuvers,
                phase_segments=self_outer.result.info.get("phase_segments"),
                title=title,
                chief_radius_m=chief_radius_m,
            )

        def save_diagnostics_html(
            self,
            out_html: str,
            *,
            title: str = "trajectory diagnostics",
        ) -> None:
            """Save frame-aware state and geometry values over time."""
            if self_outer.result is None:
                raise RuntimeError("No result to visualize")
            frame = self_outer.frame
            _plotly.save_trajectory_diagnostics_html(
                self_outer.result.traj,
                out_html,
                frame_kind=(frame.kind if frame is not None else "inertial"),
                mu_m3ps2=self_outer.result.info.get("mu_m3ps2"),
                solar_directions_ric=self_outer.result.info.get("solar_directions_ric"),
                cr3bp_system=self_outer.cr3bp_system,
                cr3bp_dimensional=self_outer.cr3bp_dimensional is not False,
                title=title,
            )

        def diagnostics_figure(
            self,
            *,
            title: str = "trajectory diagnostics",
        ):
            """Return frame-aware Matplotlib state and geometry panels."""
            if self_outer.result is None:
                raise RuntimeError("No result to visualize")
            frame = self_outer.frame
            return _matplotlib.trajectory_diagnostics_figure(
                self_outer.result.traj,
                frame_kind=(frame.kind if frame is not None else "inertial"),
                mu_m3ps2=self_outer.result.info.get("mu_m3ps2"),
                solar_directions_ric=self_outer.result.info.get("solar_directions_ric"),
                cr3bp_system=self_outer.cr3bp_system,
                cr3bp_dimensional=self_outer.cr3bp_dimensional is not False,
                title=title,
            )

        def save_diagnostics_image(
            self,
            out_image: str | Path,
            *,
            title: str = "trajectory diagnostics",
            dpi: int = 160,
        ) -> None:
            """Save frame-aware Matplotlib diagnostics as PNG or JPEG."""
            _matplotlib.save_figure_image(
                self.diagnostics_figure(title=title),
                out_image,
                dpi=dpi,
            )

        def show_diagnostics(
            self,
            *,
            title: str = "trajectory diagnostics",
        ):
            """Open frame-aware diagnostics using the Matplotlib GUI backend."""
            return _matplotlib.show_figure(self.diagnostics_figure(title=title))

    return _Viz()

octavian.config

Declarative JSON and YAML mission configuration.

The config layer only constructs Octavian's existing Python objects. Python mission scripts remain the primary and most expressive interface.

MissionConfigError

Bases: ValueError

A mission configuration file is malformed or internally inconsistent.

Source code in octavian/config/errors.py
6
7
class MissionConfigError(ValueError):
    """A mission configuration file is malformed or internally inconsistent."""

load_mission(path: str | Path) -> Mission

Load a JSON or YAML file and construct its :class:~octavian.Mission.

Args: path: Path to a .json, .yaml, or .yml mission file.

Returns: A validated mission built from the public Python configuration objects.

Source code in octavian/config/__init__.py
19
20
21
22
23
24
25
26
27
28
def load_mission(path: str | Path) -> Mission:
    """Load a JSON or YAML file and construct its :class:`~octavian.Mission`.

    Args:
        path: Path to a ``.json``, ``.yaml``, or ``.yml`` mission file.

    Returns:
        A validated mission built from the public Python configuration objects.
    """
    return mission_from_dict(read_config_file(path))

load_mission_mapping(value: Mapping[str, Any]) -> Mission

Construct a mission from an already-parsed configuration mapping.

Source code in octavian/config/__init__.py
31
32
33
def load_mission_mapping(value: Mapping[str, Any]) -> Mission:
    """Construct a mission from an already-parsed configuration mapping."""
    return mission_from_dict(value)

mission_from_dict(value: Mapping[str, Any]) -> Mission

Construct a validated mission from a schema-versioned mapping.

The mapping names states, spacecraft, and dynamics, then references those objects from ordered phase declarations. Unknown keys are rejected so misspellings cannot silently change a mission.

Args: value: Parsed JSON/YAML mapping using schema version 1.

Returns: A mission composed from Octavian's existing public Python objects.

Raises: MissionConfigError: If the mapping is malformed or references are invalid.

Source code in octavian/config/builder.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def mission_from_dict(value: Mapping[str, Any]) -> Mission:
    """Construct a validated mission from a schema-versioned mapping.

    The mapping names states, spacecraft, and dynamics, then references those
    objects from ordered phase declarations. Unknown keys are rejected so
    misspellings cannot silently change a mission.

    Args:
        value: Parsed JSON/YAML mapping using schema version 1.

    Returns:
        A mission composed from Octavian's existing public Python objects.

    Raises:
        MissionConfigError: If the mapping is malformed or references are invalid.
    """
    try:
        return _build_mission(value)
    except MissionConfigError:
        raise
    except (KeyError, TypeError, ValueError) as exc:
        raise MissionConfigError(str(exc)) from exc

read_config_file(path: str | Path) -> Mapping[str, Any]

Read a JSON or YAML configuration file into a mapping.

JSON support uses the Python standard library. YAML support is optional and uses yaml.safe_load from PyYAML; install it with octavian[yaml].

Args: path: File ending in .json, .yaml, or .yml.

Returns: The parsed top-level mapping.

Raises: MissionConfigError: If the extension, syntax, or top-level value is invalid.

Source code in octavian/config/io.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def read_config_file(path: str | Path) -> Mapping[str, Any]:
    """Read a JSON or YAML configuration file into a mapping.

    JSON support uses the Python standard library. YAML support is optional and
    uses ``yaml.safe_load`` from PyYAML; install it with ``octavian[yaml]``.

    Args:
        path: File ending in ``.json``, ``.yaml``, or ``.yml``.

    Returns:
        The parsed top-level mapping.

    Raises:
        MissionConfigError: If the extension, syntax, or top-level value is invalid.
    """
    config_path = Path(path)
    suffix = config_path.suffix.lower()
    try:
        source = config_path.read_text(encoding="utf-8")
    except OSError as exc:
        raise MissionConfigError(f"Could not read mission config {config_path}: {exc}") from exc

    try:
        if suffix == ".json":
            value = json.loads(source)
        elif suffix in {".yaml", ".yml"}:
            value = _read_yaml(source)
        else:
            raise MissionConfigError(
                f"Unsupported mission config extension {suffix!r}; use .json, .yaml, or .yml."
            )
    except MissionConfigError:
        raise
    except (json.JSONDecodeError, ValueError) as exc:
        raise MissionConfigError(f"Could not parse mission config {config_path}: {exc}") from exc

    if not isinstance(value, Mapping):
        raise MissionConfigError(
            f"Mission config {config_path} must contain a mapping at the top level."
        )
    return value

Mission Building Blocks

octavian.phase

Phase definitions for mission composition.

Phase dataclass

One dynamics segment in a composable mission.

initial_state and final_state are seed anchors unless matching constraints are declared; this distinction permits free-time and partial terminal targets. Relative-element phases may therefore keep convenient RIC seed anchors while applying native ROE constraints.

Args: name: Human-readable phase identifier. mode: Coast, relative-coast, or powered phase semantics. spacecraft: Vehicle configuration or registry name. dynamics: Translational dynamics configuration. initial_state: Optional Cartesian state used to seed the phase. final_state: Optional Cartesian state used to seed the phase. constraints: Boundary, path, geometry, and element constraints. tof_bounds_s: Lower and upper phase-end time or duration. initial_guess: Optional specialized guess configuration, including an explicit dense history from guesses.trajectory(...). thrust_control: Optional finite-thrust direction or kinematic-attitude representation. Linked phases inherit the previous declaration.

Source code in octavian/phase.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@dataclass(slots=True)
class Phase:
    """One dynamics segment in a composable mission.

    ``initial_state`` and ``final_state`` are seed anchors unless matching
    constraints are declared; this distinction permits free-time and partial
    terminal targets. Relative-element phases may therefore keep convenient
    RIC seed anchors while applying native ROE constraints.

    Args:
        name: Human-readable phase identifier.
        mode: Coast, relative-coast, or powered phase semantics.
        spacecraft: Vehicle configuration or registry name.
        dynamics: Translational dynamics configuration.
        initial_state: Optional Cartesian state used to seed the phase.
        final_state: Optional Cartesian state used to seed the phase.
        constraints: Boundary, path, geometry, and element constraints.
        tof_bounds_s: Lower and upper phase-end time or duration.
        initial_guess: Optional specialized guess configuration, including an
            explicit dense history from ``guesses.trajectory(...)``.
        thrust_control: Optional finite-thrust direction or kinematic-attitude
            representation. Linked phases inherit the previous declaration.
    """

    name: str = "phase"
    mode: str = "coast"
    spacecraft: Spacecraft | str | None = None
    dynamics: Dynamics | None = None

    initial_state: BoundaryState | None = None
    final_state: BoundaryState | None = None

    epoch: str | None = None

    constraints: list[Any] = field(default_factory=list)
    events: list[BoundaryEvent] = field(default_factory=list)
    variables: list[Any] = field(default_factory=list)

    previous: Phase | None = None
    link: Link | None = None

    tof_bounds_s: tuple[float, float] | None = None
    tof_is_relative: bool = False
    info: dict[str, Any] = field(default_factory=dict)
    initial_guess: Any | None = None
    thrust_control: ThrustControl | None = None

    def inherit_defaults(self) -> None:
        """Inherit spacecraft, dynamics, and default link from the previous phase."""
        if self.previous is None:
            return

        if self.spacecraft is None:
            self.spacecraft = self.previous.spacecraft
        if self.dynamics is None:
            self.dynamics = self.previous.dynamics
        if self.thrust_control is None:
            self.thrust_control = self.previous.thrust_control

        if self.link is None:
            normalized_mode = (self.mode or "").lower()
            self.link = (
                impulsive_link()
                if normalized_mode in ("rendezvous", "transfer")
                else continuous_link()
            )

    def has_impulse(self, where: str) -> bool:
        """Return whether the phase declares an impulse at a boundary.

        Args:
            where: Boundary name such as ``"Front"`` or ``"Back"``.

        Returns:
            ``True`` if the phase has an impulse event at that boundary.
        """
        normalized_where = (where or "").strip().lower()
        boundary = (
            "Front"
            if normalized_where in ("front", "start", "initial", "t0")
            else "Back"
            if normalized_where in ("back", "end", "final", "tf")
            else None
        )
        if boundary is None:
            raise ValueError("where must be 'front' or 'back'.")
        return any(
            getattr(event, "kind", "") == "impulse" and getattr(event, "where", "") == boundary
            for event in self.events
        )

    def validate(self) -> None:
        """Validate that the phase has the required data to solve.

        Raises:
            ValueError: If the phase is missing dynamics, spacecraft, or valid
                time-of-flight bounds.
        """
        self.inherit_defaults()
        if self.dynamics is None:
            raise ValueError(f"Phase {self.name!r} is missing dynamics.")
        if self.spacecraft is None:
            raise ValueError(f"Phase {self.name!r} is missing spacecraft.")
        if self.tof_bounds_s is not None:
            t_min_s, t_max_s = map(float, self.tof_bounds_s)
            if not (t_max_s > t_min_s >= 0.0):
                raise ValueError(f"Phase {self.name!r} has invalid tof_bounds_s.")

has_impulse(where: str) -> bool

Return whether the phase declares an impulse at a boundary.

Args: where: Boundary name such as "Front" or "Back".

Returns: True if the phase has an impulse event at that boundary.

Source code in octavian/phase.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def has_impulse(self, where: str) -> bool:
    """Return whether the phase declares an impulse at a boundary.

    Args:
        where: Boundary name such as ``"Front"`` or ``"Back"``.

    Returns:
        ``True`` if the phase has an impulse event at that boundary.
    """
    normalized_where = (where or "").strip().lower()
    boundary = (
        "Front"
        if normalized_where in ("front", "start", "initial", "t0")
        else "Back"
        if normalized_where in ("back", "end", "final", "tf")
        else None
    )
    if boundary is None:
        raise ValueError("where must be 'front' or 'back'.")
    return any(
        getattr(event, "kind", "") == "impulse" and getattr(event, "where", "") == boundary
        for event in self.events
    )

inherit_defaults() -> None

Inherit spacecraft, dynamics, and default link from the previous phase.

Source code in octavian/phase.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def inherit_defaults(self) -> None:
    """Inherit spacecraft, dynamics, and default link from the previous phase."""
    if self.previous is None:
        return

    if self.spacecraft is None:
        self.spacecraft = self.previous.spacecraft
    if self.dynamics is None:
        self.dynamics = self.previous.dynamics
    if self.thrust_control is None:
        self.thrust_control = self.previous.thrust_control

    if self.link is None:
        normalized_mode = (self.mode or "").lower()
        self.link = (
            impulsive_link()
            if normalized_mode in ("rendezvous", "transfer")
            else continuous_link()
        )

validate() -> None

Validate that the phase has the required data to solve.

Raises: ValueError: If the phase is missing dynamics, spacecraft, or valid time-of-flight bounds.

Source code in octavian/phase.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def validate(self) -> None:
    """Validate that the phase has the required data to solve.

    Raises:
        ValueError: If the phase is missing dynamics, spacecraft, or valid
            time-of-flight bounds.
    """
    self.inherit_defaults()
    if self.dynamics is None:
        raise ValueError(f"Phase {self.name!r} is missing dynamics.")
    if self.spacecraft is None:
        raise ValueError(f"Phase {self.name!r} is missing spacecraft.")
    if self.tof_bounds_s is not None:
        t_min_s, t_max_s = map(float, self.tof_bounds_s)
        if not (t_max_s > t_min_s >= 0.0):
            raise ValueError(f"Phase {self.name!r} has invalid tof_bounds_s.")

state(r_m: Sequence[float], v_mps: Sequence[float]) -> BoundaryState

Create a boundary state from position and velocity vectors.

Args: r_m: Position vector in meters. v_mps: Velocity vector in meters per second.

Returns: A boundary-state object with reshaped (3,) vectors.

Source code in octavian/phase.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def state(r_m: Sequence[float], v_mps: Sequence[float]) -> BoundaryState:
    """Create a boundary state from position and velocity vectors.

    Args:
        r_m: Position vector in meters.
        v_mps: Velocity vector in meters per second.

    Returns:
        A boundary-state object with reshaped ``(3,)`` vectors.
    """
    return BoundaryState(
        np.asarray(r_m, dtype=float).reshape(3),
        np.asarray(v_mps, dtype=float).reshape(3),
    )

octavian.constraints

User-facing constraint declarations for composable missions.

The constraint objects in this module are intentionally small, explicit, and Pythonic. They describe what the user wants and keep the corresponding solver formula close by in apply; compiler-owned contexts provide phase-specific backend data without being retained by the declarations.

ApproachCone dataclass

Bases: Constraint

Keep position inside a one-sided cone extending from a vertex.

Source code in octavian/constraints.py
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
@dataclass(frozen=True, slots=True)
class ApproachCone(Constraint):
    """Keep position inside a one-sided cone extending from a vertex."""

    kind: ClassVar[str] = "approach_cone"
    family: ClassVar[str] = "relative_geometry"

    axis: Sequence[float] = (1.0, 0.0, 0.0)
    half_angle_deg: float = 30.0
    vertex_m: Sequence[float] = (0.0, 0.0, 0.0)
    where: Where = "Path"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        half_angle = _finite_float("half_angle_deg", self.half_angle_deg)
        if not (0.0 < half_angle < 90.0):
            raise ValueError("half_angle_deg must satisfy 0 < angle < 90")
        object.__setattr__(self, "half_angle_deg", half_angle)
        object.__setattr__(self, "axis", _unit_vector("axis", self.axis))
        object.__setattr__(self, "vertex_m", _finite_vec3("vertex_m", self.vertex_m))

    @property
    def value(self) -> dict[str, Any]:
        """Return the normalized cone declaration."""
        return {
            "axis": self.axis,
            "half_angle_deg": self.half_angle_deg,
            "vertex_m": self.vertex_m,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply the cone-angle inequality and retain its forward half."""
        position, indices = _relative_position_expression(context)
        offset = position - np.asarray(self.vertex_m, dtype=float)
        axial_distance = offset.dot(np.asarray(self.axis, dtype=float))
        cosine_sq = float(np.cos(np.deg2rad(self.half_angle_deg)) ** 2)
        cone_violation = cosine_sq * offset.dot(offset) - axial_distance**2
        vf = context.vector_functions
        phase.addInequalCon(self.where, vf.stack([cone_violation]), indices)
        phase.addInequalCon(self.where, vf.stack([-axial_distance]), indices)

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report the maximum solved angle from the cone axis."""
        positions = _positions_at_location(context.phase_trajectory, self.where)
        angles = _angles_deg(positions - self.vertex_m, np.asarray(self.axis, dtype=float))
        actual = float(np.max(angles)) if angles.size else float("nan")
        tolerance = 1.0e-4
        return [
            _report_row(
                self,
                context,
                name="approach_cone",
                target=self.half_angle_deg,
                actual=actual,
                error=actual - self.half_angle_deg,
                tolerance=tolerance,
                satisfied=bool(
                    np.isfinite(actual) and actual <= self.half_angle_deg + tolerance
                ),
            )
        ]

value: dict[str, Any] property

Return the normalized cone declaration.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply the cone-angle inequality and retain its forward half.

Source code in octavian/constraints.py
645
646
647
648
649
650
651
652
653
654
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply the cone-angle inequality and retain its forward half."""
    position, indices = _relative_position_expression(context)
    offset = position - np.asarray(self.vertex_m, dtype=float)
    axial_distance = offset.dot(np.asarray(self.axis, dtype=float))
    cosine_sq = float(np.cos(np.deg2rad(self.half_angle_deg)) ** 2)
    cone_violation = cosine_sq * offset.dot(offset) - axial_distance**2
    vf = context.vector_functions
    phase.addInequalCon(self.where, vf.stack([cone_violation]), indices)
    phase.addInequalCon(self.where, vf.stack([-axial_distance]), indices)

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report the maximum solved angle from the cone axis.

Source code in octavian/constraints.py
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report the maximum solved angle from the cone axis."""
    positions = _positions_at_location(context.phase_trajectory, self.where)
    angles = _angles_deg(positions - self.vertex_m, np.asarray(self.axis, dtype=float))
    actual = float(np.max(angles)) if angles.size else float("nan")
    tolerance = 1.0e-4
    return [
        _report_row(
            self,
            context,
            name="approach_cone",
            target=self.half_angle_deg,
            actual=actual,
            error=actual - self.half_angle_deg,
            tolerance=tolerance,
            satisfied=bool(
                np.isfinite(actual) and actual <= self.half_angle_deg + tolerance
            ),
        )
    ]

Constraint

Bases: ABC

Abstract base class for all mission constraints.

Concrete constraints own their ASSET compilation formula in :meth:apply. The compiler supplies phase-specific data through a transient context.

Source code in octavian/constraints.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
class Constraint(ABC):
    """Abstract base class for all mission constraints.

    Concrete constraints own their ASSET compilation formula in :meth:`apply`.
    The compiler supplies phase-specific data through a transient context.
    """

    kind: ClassVar[str]
    family: ClassVar[str]
    where: Where

    @property
    @abstractmethod
    def value(self) -> Any:
        """Return the canonical constraint payload."""

    @abstractmethod
    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply this declaration to a compiled solver phase."""

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Return solved-value report rows when this constraint supports them."""
        del context
        return []

value: Any abstractmethod property

Return the canonical constraint payload.

apply(phase: Any, context: ConstraintApplicationContext) -> None abstractmethod

Apply this declaration to a compiled solver phase.

Source code in octavian/constraints.py
205
206
207
@abstractmethod
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply this declaration to a compiled solver phase."""

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Return solved-value report rows when this constraint supports them.

Source code in octavian/constraints.py
209
210
211
212
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Return solved-value report rows when this constraint supports them."""
    del context
    return []

ConstraintApplicationContext

Bases: Protocol

Compiler-owned data available while a constraint is applied.

Source code in octavian/constraints.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class ConstraintApplicationContext(Protocol):
    """Compiler-owned data available while a constraint is applied."""

    vector_functions: Any
    layout: Any
    declared_phase: Any
    phase_index: int
    mu_m3ps2: float
    cr3bp_system: Any | None
    cr3bp_dimensional: bool
    is_relative_phase: bool
    relative_expressions: Any | None
    third_body_tables: dict[str, Any]
    solar_direction_table: Any | None
    solar_position_table: Any | None

ConstraintReportContext

Bases: Protocol

Solved phase data available to constraint reporting methods.

Source code in octavian/constraints.py
41
42
43
44
45
46
47
48
49
50
51
52
class ConstraintReportContext(Protocol):
    """Solved phase data available to constraint reporting methods."""

    phase_name: str
    phase_trajectory: np.ndarray
    native_trajectory: np.ndarray
    relative_trajectory: np.ndarray | None
    layout: Any
    mu_m3ps2: float
    cr3bp_system: Any | None
    cr3bp_dimensional: bool
    solar_direction_at: Callable[[np.ndarray], np.ndarray] | None

Eccentricity dataclass

Bases: OrbitalElementConstraint

Constrain orbital eccentricity.

Circular targets are intentionally rejected for now because they need a non-singular formulation.

Source code in octavian/constraints.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
@dataclass(frozen=True, slots=True)
class Eccentricity(OrbitalElementConstraint):
    """Constrain orbital eccentricity.

    Circular targets are intentionally rejected for now because they need a
    non-singular formulation.
    """

    kind: ClassVar[str] = "eccentricity"

    e: float = 0.0
    where: Where = "Path"
    tol: float | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        eccentricity_value = _finite_float("e", self.e)
        if not (0.0 < eccentricity_value < 1.0):
            raise ValueError(
                "Eccentricity constraint currently requires 0 < e < 1. "
                "Circular and non-elliptic handling is deferred."
            )
        object.__setattr__(self, "e", eccentricity_value)
        tolerance = _optional_nonnegative_float("tol", self.tol)
        if tolerance is not None and tolerance >= eccentricity_value:
            raise ValueError("tol must be smaller than e for eccentricity constraints.")
        object.__setattr__(self, "tol", tolerance)

    @property
    def element_name(self) -> str:
        """Return the compiler-facing element identifier."""
        return "eccentricity"

    @property
    def value(self) -> dict[str, float | None]:
        """Return the dimensionless target and optional tolerance."""
        return {"e": float(self.e), "tol": self.tol}

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply eccentricity through its non-negative squared expression."""
        _validate_inertial_orbital_context(context)
        vf = context.vector_functions
        mu_m3ps2 = float(context.mu_m3ps2)
        arguments = vf.Arguments(6)
        position, velocity = arguments.tolist([(0, 3), (3, 3)])
        angular_momentum = position.cross(velocity)
        radius = position.norm()
        speed = velocity.norm()
        specific_energy = 0.5 * speed**2 - mu_m3ps2 / radius
        angular_momentum_sq = angular_momentum.dot(angular_momentum)
        eccentricity_sq = 1.0 + (
            2.0 * specific_energy * angular_momentum_sq
        ) / mu_m3ps2**2
        if self.tol is None:
            phase.addEqualCon(
                self.where,
                vf.stack([eccentricity_sq - self.e**2]),
                range(0, 6),
            )
            return
        phase.addInequalCon(
            self.where,
            vf.stack([eccentricity_sq - (self.e + self.tol) ** 2]),
            range(0, 6),
        )
        phase.addInequalCon(
            self.where,
            vf.stack([(self.e - self.tol) ** 2 - eccentricity_sq]),
            range(0, 6),
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report solved eccentricity at the selected boundary."""
        if self.where not in {"Front", "Back"}:
            return []
        return [_orbital_report_row(self, context, self.e, self.tol, "e")]

element_name: str property

Return the compiler-facing element identifier.

value: dict[str, float | None] property

Return the dimensionless target and optional tolerance.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply eccentricity through its non-negative squared expression.

Source code in octavian/constraints.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply eccentricity through its non-negative squared expression."""
    _validate_inertial_orbital_context(context)
    vf = context.vector_functions
    mu_m3ps2 = float(context.mu_m3ps2)
    arguments = vf.Arguments(6)
    position, velocity = arguments.tolist([(0, 3), (3, 3)])
    angular_momentum = position.cross(velocity)
    radius = position.norm()
    speed = velocity.norm()
    specific_energy = 0.5 * speed**2 - mu_m3ps2 / radius
    angular_momentum_sq = angular_momentum.dot(angular_momentum)
    eccentricity_sq = 1.0 + (
        2.0 * specific_energy * angular_momentum_sq
    ) / mu_m3ps2**2
    if self.tol is None:
        phase.addEqualCon(
            self.where,
            vf.stack([eccentricity_sq - self.e**2]),
            range(0, 6),
        )
        return
    phase.addInequalCon(
        self.where,
        vf.stack([eccentricity_sq - (self.e + self.tol) ** 2]),
        range(0, 6),
    )
    phase.addInequalCon(
        self.where,
        vf.stack([(self.e - self.tol) ** 2 - eccentricity_sq]),
        range(0, 6),
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report solved eccentricity at the selected boundary.

Source code in octavian/constraints.py
364
365
366
367
368
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report solved eccentricity at the selected boundary."""
    if self.where not in {"Front", "Back"}:
        return []
    return [_orbital_report_row(self, context, self.e, self.tol, "e")]

InclinationDeg dataclass

Bases: OrbitalElementConstraint

Constrain orbital inclination in degrees.

Equatorial targets are intentionally rejected for now because they need a non-singular formulation.

Source code in octavian/constraints.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@dataclass(frozen=True, slots=True)
class InclinationDeg(OrbitalElementConstraint):
    """Constrain orbital inclination in degrees.

    Equatorial targets are intentionally rejected for now because they need a
    non-singular formulation.
    """

    kind: ClassVar[str] = "inclination_deg"

    inc_deg: float = 0.0
    where: Where = "Path"
    tol_deg: float | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        inclination_deg = _finite_float("inc_deg", self.inc_deg)
        if not (0.0 < inclination_deg < 180.0):
            raise ValueError(
                "Inclination constraint currently requires 0 < inc_deg < 180. "
                "Equatorial handling is deferred."
            )
        object.__setattr__(self, "inc_deg", inclination_deg)
        tolerance_deg = _optional_nonnegative_float("tol_deg", self.tol_deg)
        if tolerance_deg is not None and tolerance_deg >= min(
            inclination_deg, 180.0 - inclination_deg
        ):
            raise ValueError("tol_deg is too large for the requested inclination target.")
        object.__setattr__(self, "tol_deg", tolerance_deg)

    @property
    def element_name(self) -> str:
        """Return the compiler-facing element identifier."""
        return "inclination_deg"

    @property
    def value(self) -> dict[str, float | None]:
        """Return the target and optional tolerance in degrees."""
        return {"inc_deg": float(self.inc_deg), "tol_deg": self.tol_deg}

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply inclination using the angular-momentum direction cosine."""
        _validate_inertial_orbital_context(context)
        vf = context.vector_functions
        arguments = vf.Arguments(6)
        position, velocity = arguments.tolist([(0, 3), (3, 3)])
        angular_momentum = position.cross(velocity)
        inclination_cosine = angular_momentum.normalized()[2]
        target_cosine = float(np.cos(np.deg2rad(self.inc_deg)))
        if self.tol_deg is None:
            phase.addEqualCon(
                self.where,
                vf.stack([inclination_cosine - target_cosine]),
                range(0, 6),
            )
            return
        upper_cosine = float(np.cos(np.deg2rad(self.inc_deg - self.tol_deg)))
        lower_cosine = float(np.cos(np.deg2rad(self.inc_deg + self.tol_deg)))
        phase.addInequalCon(
            self.where,
            vf.stack([inclination_cosine - upper_cosine]),
            range(0, 6),
        )
        phase.addInequalCon(
            self.where,
            vf.stack([lower_cosine - inclination_cosine]),
            range(0, 6),
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report solved inclination at the selected boundary."""
        if self.where not in {"Front", "Back"}:
            return []
        return [
            _orbital_report_row(
                self,
                context,
                self.inc_deg,
                self.tol_deg,
                "inc_deg",
            )
        ]

element_name: str property

Return the compiler-facing element identifier.

value: dict[str, float | None] property

Return the target and optional tolerance in degrees.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply inclination using the angular-momentum direction cosine.

Source code in octavian/constraints.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply inclination using the angular-momentum direction cosine."""
    _validate_inertial_orbital_context(context)
    vf = context.vector_functions
    arguments = vf.Arguments(6)
    position, velocity = arguments.tolist([(0, 3), (3, 3)])
    angular_momentum = position.cross(velocity)
    inclination_cosine = angular_momentum.normalized()[2]
    target_cosine = float(np.cos(np.deg2rad(self.inc_deg)))
    if self.tol_deg is None:
        phase.addEqualCon(
            self.where,
            vf.stack([inclination_cosine - target_cosine]),
            range(0, 6),
        )
        return
    upper_cosine = float(np.cos(np.deg2rad(self.inc_deg - self.tol_deg)))
    lower_cosine = float(np.cos(np.deg2rad(self.inc_deg + self.tol_deg)))
    phase.addInequalCon(
        self.where,
        vf.stack([inclination_cosine - upper_cosine]),
        range(0, 6),
    )
    phase.addInequalCon(
        self.where,
        vf.stack([lower_cosine - inclination_cosine]),
        range(0, 6),
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report solved inclination at the selected boundary.

Source code in octavian/constraints.py
440
441
442
443
444
445
446
447
448
449
450
451
452
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report solved inclination at the selected boundary."""
    if self.where not in {"Front", "Back"}:
        return []
    return [
        _orbital_report_row(
            self,
            context,
            self.inc_deg,
            self.tol_deg,
            "inc_deg",
        )
    ]

JacobiConstant dataclass

Bases: Constraint

Constrain the Jacobi integral of a CR3BP phase.

The compiler evaluates the invariant directly from the phase's synodic Cartesian state. Canonical targets are convenient for comparison with CR3BP literature; dimensional targets use square meters per square second.

Args: target: Desired Jacobi constant. where: "Front", "Back", or "Path". tolerance: Optional symmetric non-negative tolerance in the selected unit system. Omitting it creates an equality. dimensional: Interpret target and tolerance as m²/s² when true or canonical CR3BP units when false.

Source code in octavian/constraints.py
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
@dataclass(frozen=True, slots=True)
class JacobiConstant(Constraint):
    """Constrain the Jacobi integral of a CR3BP phase.

    The compiler evaluates the invariant directly from the phase's synodic
    Cartesian state. Canonical targets are convenient for comparison with
    CR3BP literature; dimensional targets use square meters per square second.

    Args:
        target: Desired Jacobi constant.
        where: ``"Front"``, ``"Back"``, or ``"Path"``.
        tolerance: Optional symmetric non-negative tolerance in the selected
            unit system. Omitting it creates an equality.
        dimensional: Interpret ``target`` and ``tolerance`` as ``m²/s²`` when
            true or canonical CR3BP units when false.
    """

    kind: ClassVar[str] = "jacobi_constant"
    family: ClassVar[str] = "cr3bp_invariant"

    target: float = 0.0
    where: Where = "Front"
    tolerance: float | None = None
    dimensional: bool = True

    def __post_init__(self) -> None:
        object.__setattr__(self, "target", _finite_float("target", self.target))
        object.__setattr__(self, "where", _normalize_where(self.where))
        object.__setattr__(
            self,
            "tolerance",
            _optional_nonnegative_float("tolerance", self.tolerance),
        )
        object.__setattr__(self, "dimensional", bool(self.dimensional))

    @property
    def value(self) -> dict[str, float | bool | None]:
        """Return the target, tolerance, and selected unit system."""
        return {
            "target": self.target,
            "tolerance": self.tolerance,
            "dimensional": self.dimensional,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply the CR3BP Jacobi integral in canonical solver units."""
        system = context.cr3bp_system
        if system is None:
            raise ValueError("Jacobi-constant constraints require Dynamics.cr3bp().")
        vf = context.vector_functions
        arguments = vf.Arguments(6)
        phase_position, phase_velocity = arguments.tolist([(0, 3), (3, 3)])
        if context.cr3bp_dimensional:
            position = phase_position / float(system.separation_m)
            velocity = phase_velocity / float(system.velocity_scale_mps)
        else:
            position = phase_position
            velocity = phase_velocity
        mass_parameter = float(system.mass_parameter)
        x = position[0]
        y = position[1]
        z = position[2]
        primary_displacement = vf.stack([x + mass_parameter, y, z])
        secondary_displacement = vf.stack([x - 1.0 + mass_parameter, y, z])
        potential_twice = (
            x**2
            + y**2
            + 2.0 * (1.0 - mass_parameter) / primary_displacement.norm()
            + 2.0 * mass_parameter / secondary_displacement.norm()
        )
        jacobi = potential_twice - velocity.dot(velocity)
        unit_scale = float(system.velocity_scale_mps**2) if self.dimensional else 1.0
        target = self.target / unit_scale
        tolerance = self.tolerance / unit_scale if self.tolerance is not None else None
        if tolerance is None:
            phase.addEqualCon(
                self.where,
                vf.stack([jacobi - target]),
                range(0, 6),
            )
            return
        phase.addInequalCon(
            self.where,
            vf.stack([jacobi - (target + tolerance)]),
            range(0, 6),
        )
        phase.addInequalCon(
            self.where,
            vf.stack([(target - tolerance) - jacobi]),
            range(0, 6),
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report the worst solved Jacobi target error at the selected location."""
        system = context.cr3bp_system
        if system is None:
            return []
        from .cislunar import jacobi_constant as evaluate_jacobi_constant

        values = np.asarray(
            [
                evaluate_jacobi_constant(
                    row[0:6],
                    system=system,
                    dimensional=context.cr3bp_dimensional,
                )
                for row in np.asarray(context.phase_trajectory, dtype=float)
            ],
            dtype=float,
        )
        if self.dimensional and not context.cr3bp_dimensional:
            values *= float(system.velocity_scale_mps**2)
        elif not self.dimensional and context.cr3bp_dimensional:
            values /= float(system.velocity_scale_mps**2)
        selected = _values_at_location(values, self.where)
        errors = np.abs(selected - self.target)
        actual = float(selected[int(np.argmax(errors))])
        error = actual - self.target
        tolerance = float(self.tolerance or 0.0)
        numerical_tolerance = tolerance + max(
            1.0e-10,
            1.0e-7 * max(1.0, abs(self.target)),
        )
        return [
            _report_row(
                self,
                context,
                name=self.kind,
                target=self.target,
                actual=actual,
                error=error,
                tolerance=tolerance,
                satisfied=abs(error) <= numerical_tolerance,
            )
        ]

value: dict[str, float | bool | None] property

Return the target, tolerance, and selected unit system.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply the CR3BP Jacobi integral in canonical solver units.

Source code in octavian/constraints.py
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply the CR3BP Jacobi integral in canonical solver units."""
    system = context.cr3bp_system
    if system is None:
        raise ValueError("Jacobi-constant constraints require Dynamics.cr3bp().")
    vf = context.vector_functions
    arguments = vf.Arguments(6)
    phase_position, phase_velocity = arguments.tolist([(0, 3), (3, 3)])
    if context.cr3bp_dimensional:
        position = phase_position / float(system.separation_m)
        velocity = phase_velocity / float(system.velocity_scale_mps)
    else:
        position = phase_position
        velocity = phase_velocity
    mass_parameter = float(system.mass_parameter)
    x = position[0]
    y = position[1]
    z = position[2]
    primary_displacement = vf.stack([x + mass_parameter, y, z])
    secondary_displacement = vf.stack([x - 1.0 + mass_parameter, y, z])
    potential_twice = (
        x**2
        + y**2
        + 2.0 * (1.0 - mass_parameter) / primary_displacement.norm()
        + 2.0 * mass_parameter / secondary_displacement.norm()
    )
    jacobi = potential_twice - velocity.dot(velocity)
    unit_scale = float(system.velocity_scale_mps**2) if self.dimensional else 1.0
    target = self.target / unit_scale
    tolerance = self.tolerance / unit_scale if self.tolerance is not None else None
    if tolerance is None:
        phase.addEqualCon(
            self.where,
            vf.stack([jacobi - target]),
            range(0, 6),
        )
        return
    phase.addInequalCon(
        self.where,
        vf.stack([jacobi - (target + tolerance)]),
        range(0, 6),
    )
    phase.addInequalCon(
        self.where,
        vf.stack([(target - tolerance) - jacobi]),
        range(0, 6),
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report the worst solved Jacobi target error at the selected location.

Source code in octavian/constraints.py
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report the worst solved Jacobi target error at the selected location."""
    system = context.cr3bp_system
    if system is None:
        return []
    from .cislunar import jacobi_constant as evaluate_jacobi_constant

    values = np.asarray(
        [
            evaluate_jacobi_constant(
                row[0:6],
                system=system,
                dimensional=context.cr3bp_dimensional,
            )
            for row in np.asarray(context.phase_trajectory, dtype=float)
        ],
        dtype=float,
    )
    if self.dimensional and not context.cr3bp_dimensional:
        values *= float(system.velocity_scale_mps**2)
    elif not self.dimensional and context.cr3bp_dimensional:
        values /= float(system.velocity_scale_mps**2)
    selected = _values_at_location(values, self.where)
    errors = np.abs(selected - self.target)
    actual = float(selected[int(np.argmax(errors))])
    error = actual - self.target
    tolerance = float(self.tolerance or 0.0)
    numerical_tolerance = tolerance + max(
        1.0e-10,
        1.0e-7 * max(1.0, abs(self.target)),
    )
    return [
        _report_row(
            self,
            context,
            name=self.kind,
            target=self.target,
            actual=actual,
            error=error,
            tolerance=tolerance,
            satisfied=abs(error) <= numerical_tolerance,
        )
    ]

KeepOutSphere dataclass

Bases: Constraint

Keep Cartesian position outside a sphere centered in the phase frame.

Source code in octavian/constraints.py
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
@dataclass(frozen=True, slots=True)
class KeepOutSphere(Constraint):
    """Keep Cartesian position outside a sphere centered in the phase frame."""

    kind: ClassVar[str] = "keep_out_sphere"
    family: ClassVar[str] = "relative_geometry"

    radius_m: float = 0.0
    center_m: Sequence[float] = (0.0, 0.0, 0.0)
    where: Where = "Path"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        radius = _finite_float("radius_m", self.radius_m)
        if radius <= 0.0:
            raise ValueError("radius_m must be > 0")
        object.__setattr__(self, "radius_m", radius)
        object.__setattr__(self, "center_m", _finite_vec3("center_m", self.center_m))

    @property
    def value(self) -> dict[str, Any]:
        """Return the keep-out radius and center."""
        return {"radius_m": self.radius_m, "center_m": self.center_m}

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply ``radius² - ||position - center||² <= 0``."""
        position, indices = _relative_position_expression(context)
        offset = position - np.asarray(self.center_m, dtype=float)
        violation = self.radius_m**2 - offset.dot(offset)
        phase.addInequalCon(
            self.where,
            context.vector_functions.stack([violation]),
            indices,
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report the minimum solved separation from the keep-out center."""
        positions = _positions_at_location(context.phase_trajectory, self.where)
        actual = float(np.min(np.linalg.norm(positions - self.center_m, axis=1)))
        tolerance = max(1.0e-6, 1.0e-7 * self.radius_m)
        return [
            _report_row(
                self,
                context,
                name="keep_out_sphere",
                target=self.radius_m,
                actual=actual,
                error=actual - self.radius_m,
                tolerance=tolerance,
                satisfied=actual >= self.radius_m - tolerance,
            )
        ]

value: dict[str, Any] property

Return the keep-out radius and center.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply radius² - ||position - center||² <= 0.

Source code in octavian/constraints.py
585
586
587
588
589
590
591
592
593
594
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply ``radius² - ||position - center||² <= 0``."""
    position, indices = _relative_position_expression(context)
    offset = position - np.asarray(self.center_m, dtype=float)
    violation = self.radius_m**2 - offset.dot(offset)
    phase.addInequalCon(
        self.where,
        context.vector_functions.stack([violation]),
        indices,
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report the minimum solved separation from the keep-out center.

Source code in octavian/constraints.py
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report the minimum solved separation from the keep-out center."""
    positions = _positions_at_location(context.phase_trajectory, self.where)
    actual = float(np.min(np.linalg.norm(positions - self.center_m, axis=1)))
    tolerance = max(1.0e-6, 1.0e-7 * self.radius_m)
    return [
        _report_row(
            self,
            context,
            name="keep_out_sphere",
            target=self.radius_m,
            actual=actual,
            error=actual - self.radius_m,
            tolerance=tolerance,
            satisfied=actual >= self.radius_m - tolerance,
        )
    ]

LightingAngle dataclass

Bases: Constraint

Bound the angle from position to a fixed illumination direction.

sun_direction is expressed in the same frame as the phase position and points from the constraint origin toward the light source.

Source code in octavian/constraints.py
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
@dataclass(frozen=True, slots=True)
class LightingAngle(Constraint):
    """Bound the angle from position to a fixed illumination direction.

    ``sun_direction`` is expressed in the same frame as the phase position and
    points from the constraint origin toward the light source.
    """

    kind: ClassVar[str] = "lighting_angle"
    family: ClassVar[str] = "relative_geometry"

    sun_direction: Sequence[float] = (1.0, 0.0, 0.0)
    min_angle_deg: float = 0.0
    max_angle_deg: float = 180.0
    origin_m: Sequence[float] = (0.0, 0.0, 0.0)
    where: Where = "Path"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        minimum = _finite_float("min_angle_deg", self.min_angle_deg)
        maximum = _finite_float("max_angle_deg", self.max_angle_deg)
        if not (0.0 <= minimum < maximum <= 180.0):
            raise ValueError("lighting angles must satisfy 0 <= min < max <= 180")
        object.__setattr__(self, "min_angle_deg", minimum)
        object.__setattr__(self, "max_angle_deg", maximum)
        object.__setattr__(
            self,
            "sun_direction",
            _unit_vector("sun_direction", self.sun_direction),
        )
        object.__setattr__(self, "origin_m", _finite_vec3("origin_m", self.origin_m))

    @property
    def value(self) -> dict[str, Any]:
        """Return the fixed-direction lighting declaration."""
        return {
            "sun_direction": self.sun_direction,
            "min_angle_deg": self.min_angle_deg,
            "max_angle_deg": self.max_angle_deg,
            "origin_m": self.origin_m,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply lower and upper angle bounds to a fixed light direction."""
        position, indices = _relative_position_expression(context)
        offset = position - np.asarray(self.origin_m, dtype=float)
        _apply_angle_bounds(
            phase,
            context.vector_functions,
            self.where,
            offset,
            np.asarray(self.sun_direction, dtype=float),
            self.min_angle_deg,
            self.max_angle_deg,
            indices,
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report solved extrema of the fixed-direction lighting angle."""
        positions = _positions_at_location(context.phase_trajectory, self.where)
        angles = _angles_deg(
            positions - self.origin_m,
            np.asarray(self.sun_direction, dtype=float),
        )
        return _angle_report_rows(self, context, angles, "lighting")

value: dict[str, Any] property

Return the fixed-direction lighting declaration.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply lower and upper angle bounds to a fixed light direction.

Source code in octavian/constraints.py
720
721
722
723
724
725
726
727
728
729
730
731
732
733
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply lower and upper angle bounds to a fixed light direction."""
    position, indices = _relative_position_expression(context)
    offset = position - np.asarray(self.origin_m, dtype=float)
    _apply_angle_bounds(
        phase,
        context.vector_functions,
        self.where,
        offset,
        np.asarray(self.sun_direction, dtype=float),
        self.min_angle_deg,
        self.max_angle_deg,
        indices,
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report solved extrema of the fixed-direction lighting angle.

Source code in octavian/constraints.py
735
736
737
738
739
740
741
742
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report solved extrema of the fixed-direction lighting angle."""
    positions = _positions_at_location(context.phase_trajectory, self.where)
    angles = _angles_deg(
        positions - self.origin_m,
        np.asarray(self.sun_direction, dtype=float),
    )
    return _angle_report_rows(self, context, angles, "lighting")

MinRadius dataclass

Bases: Constraint

Constrain the trajectory radius norm from below.

Source code in octavian/constraints.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
@dataclass(frozen=True, slots=True)
class MinRadius(Constraint):
    """Constrain the trajectory radius norm from below."""

    kind: ClassVar[str] = "min_radius"
    family: ClassVar[str] = "path_geometry"

    r_min_m: float = 0.0
    where: Where = "Path"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))

    @property
    def value(self) -> float:
        """Return the minimum radius in meters."""
        return float(self.r_min_m)

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply a lower bound to the Cartesian position norm."""
        if context.layout.name.endswith("relative_elements"):
            raise ValueError(
                "Minimum Cartesian range is not a native relative-element constraint; "
                "select a RIC formulation."
            )
        if context.relative_expressions is None:
            phase.addLowerNormBound(self.where, "R", self.r_min_m)
            return
        position = context.relative_expressions.position
        violation = self.r_min_m**2 - position.dot(position)
        phase.addInequalCon(
            self.where,
            context.vector_functions.stack([violation]),
            context.relative_expressions.argument_indices,
        )

value: float property

Return the minimum radius in meters.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply a lower bound to the Cartesian position norm.

Source code in octavian/constraints.py
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply a lower bound to the Cartesian position norm."""
    if context.layout.name.endswith("relative_elements"):
        raise ValueError(
            "Minimum Cartesian range is not a native relative-element constraint; "
            "select a RIC formulation."
        )
    if context.relative_expressions is None:
        phase.addLowerNormBound(self.where, "R", self.r_min_m)
        return
    position = context.relative_expressions.position
    violation = self.r_min_m**2 - position.dot(position)
    phase.addInequalCon(
        self.where,
        context.vector_functions.stack([violation]),
        context.relative_expressions.argument_indices,
    )

OrbitalElementConstraint

Bases: Constraint

Marker base class for orbital-element constraints.

Source code in octavian/constraints.py
215
216
217
218
219
220
221
222
223
class OrbitalElementConstraint(Constraint):
    """Marker base class for orbital-element constraints."""

    family: ClassVar[str] = "orbital_element"

    @property
    @abstractmethod
    def element_name(self) -> str:
        """Human-readable orbital element name."""

element_name: str abstractmethod property

Human-readable orbital element name.

PeriodicState dataclass

Bases: Constraint

Require selected Cartesian components to match at both phase ends.

The equality is imposed directly between the phase's front and back state in its declared frame. Time is intentionally excluded: periodicity closes the physical state while allowing a positive orbit period. At least one independent front-boundary phase condition is normally needed to remove the arbitrary time shift of an autonomous periodic orbit.

Args: components: Cartesian components to close. The default closes the full position and velocity state.

Source code in octavian/constraints.py
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
@dataclass(frozen=True, slots=True)
class PeriodicState(Constraint):
    """Require selected Cartesian components to match at both phase ends.

    The equality is imposed directly between the phase's front and back state
    in its declared frame. Time is intentionally excluded: periodicity closes
    the physical state while allowing a positive orbit period. At least one
    independent front-boundary phase condition is normally needed to remove
    the arbitrary time shift of an autonomous periodic orbit.

    Args:
        components: Cartesian components to close. The default closes the full
            position and velocity state.
    """

    kind: ClassVar[str] = "periodic_state"
    family: ClassVar[str] = "boundary_state"
    where: ClassVar[str] = "FrontAndBack"

    components: tuple[str, ...] = ("x", "y", "z", "vx", "vy", "vz")

    def __post_init__(self) -> None:
        normalized = tuple(
            _normalize_cartesian_component(component) for component in self.components
        )
        if not normalized:
            raise ValueError("PeriodicState.components must not be empty")
        if len(set(normalized)) != len(normalized):
            raise ValueError("PeriodicState.components must be unique")
        object.__setattr__(self, "components", normalized)

    @property
    def value(self) -> dict[str, tuple[str, ...]]:
        """Return the components equated between the phase boundaries."""
        return {"components": self.components}

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Equate selected native Cartesian components at both boundaries."""
        indices = [
            _cartesian_component_index(context.layout, component)
            for component in self.components
        ]
        arguments = context.vector_functions.Arguments(2 * len(indices))
        front = arguments.head(len(indices))
        back = arguments.tail(len(indices))
        phase.addEqualCon("FrontandBack", front - back, indices)

value: dict[str, tuple[str, ...]] property

Return the components equated between the phase boundaries.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Equate selected native Cartesian components at both boundaries.

Source code in octavian/constraints.py
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Equate selected native Cartesian components at both boundaries."""
    indices = [
        _cartesian_component_index(context.layout, component)
        for component in self.components
    ]
    arguments = context.vector_functions.Arguments(2 * len(indices))
    front = arguments.head(len(indices))
    back = arguments.tail(len(indices))
    phase.addEqualCon("FrontandBack", front - back, indices)

Position dataclass

Bases: Constraint

Constrain a Cartesian boundary position.

Source code in octavian/constraints.py
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
@dataclass(frozen=True, slots=True)
class Position(Constraint):
    """Constrain a Cartesian boundary position."""

    kind: ClassVar[str] = "position"
    family: ClassVar[str] = "boundary_state"

    r_m: Sequence[float] = (0.0, 0.0, 0.0)
    where: Where = "Front"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))

    @property
    def value(self) -> np.ndarray:
        """Return the constrained position as a three-vector."""
        return np.asarray(self.r_m, dtype=float).reshape(3)

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Fix Cartesian position at the selected phase location."""
        if context.layout.name.endswith("relative_elements"):
            raise ValueError(
                "Cartesian Position constraints are not native to a relative-element phase. "
                "Use constraints.relative_orbital_element(...) instead."
            )
        position = np.asarray(self.r_m, dtype=float).reshape(3)
        if context.relative_expressions is None:
            phase.addBoundaryValue(self.where, ["R"], position)
            return
        phase.addEqualCon(
            self.where,
            context.relative_expressions.position - position,
            context.relative_expressions.argument_indices,
        )

value: np.ndarray property

Return the constrained position as a three-vector.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Fix Cartesian position at the selected phase location.

Source code in octavian/constraints.py
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Fix Cartesian position at the selected phase location."""
    if context.layout.name.endswith("relative_elements"):
        raise ValueError(
            "Cartesian Position constraints are not native to a relative-element phase. "
            "Use constraints.relative_orbital_element(...) instead."
        )
    position = np.asarray(self.r_m, dtype=float).reshape(3)
    if context.relative_expressions is None:
        phase.addBoundaryValue(self.where, ["R"], position)
        return
    phase.addEqualCon(
        self.where,
        context.relative_expressions.position - position,
        context.relative_expressions.argument_indices,
    )

RelativeOrbitalElementConstraint dataclass

Bases: Constraint

Constrain one native relative orbital element.

D'Amico names are delta_a, delta_lambda, delta_ex, delta_ey, delta_ix, and delta_iy. Classical-difference names are delta_a_m, delta_e, delta_i, delta_raan, delta_argp, and delta_mean_anomaly. Angles are radians.

Source code in octavian/constraints.py
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
@dataclass(frozen=True, slots=True)
class RelativeOrbitalElementConstraint(Constraint):
    """Constrain one native relative orbital element.

    D'Amico names are ``delta_a``, ``delta_lambda``, ``delta_ex``,
    ``delta_ey``, ``delta_ix``, and ``delta_iy``. Classical-difference names
    are ``delta_a_m``, ``delta_e``, ``delta_i``, ``delta_raan``,
    ``delta_argp``, and ``delta_mean_anomaly``. Angles are radians.
    """

    kind: ClassVar[str] = "relative_orbital_element"
    family: ClassVar[str] = "relative_orbital_element"

    element: str = "delta_a"
    target: float = 0.0
    representation: RelativeElementRepresentation | str = "damico"
    where: Where = "Back"
    tolerance: float | None = None

    def __post_init__(self) -> None:
        representation = _normalize_relative_element_representation(self.representation)
        element = _normalize_relative_element_name(
            self.element,
            representation,
        )
        object.__setattr__(self, "representation", representation)
        object.__setattr__(self, "element", element)
        object.__setattr__(self, "target", _finite_float("target", self.target))
        object.__setattr__(self, "where", _normalize_where(self.where))
        object.__setattr__(
            self,
            "tolerance",
            _optional_nonnegative_float("tolerance", self.tolerance),
        )

    @property
    def value(self) -> dict[str, float | str | None]:
        """Return the normalized relative-element target payload."""
        return {
            "representation": self.representation,
            "element": self.element,
            "target": self.target,
            "tolerance": self.tolerance,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply one scalar native relative-orbital-element target."""
        expected_layout = (
            "damico_relative_elements"
            if self.representation == "damico"
            else "classical_relative_elements"
        )
        if context.layout.name != expected_layout:
            raise ValueError(
                f"{self.representation!r} constraints require a matching native "
                "relative-element propagation mode; selected layout is "
                f"{context.layout.name!r}"
            )
        _apply_scalar_target(
            phase,
            context,
            where=self.where,
            state_index=context.layout.state_indices(self.element)[0],
            target=self.target,
            tolerance=self.tolerance,
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report the worst solved error for one relative orbital element."""
        trajectory = np.asarray(context.native_trajectory, dtype=float)
        index = context.layout.state_indices(self.element)[0]
        return [
            _scalar_report_row(
                self,
                context,
                name=f"{self.representation}_{self.element}",
                target=self.target,
                tolerance=self.tolerance,
                values=_values_at_location(trajectory[:, index], self.where),
            )
        ]

value: dict[str, float | str | None] property

Return the normalized relative-element target payload.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply one scalar native relative-orbital-element target.

Source code in octavian/constraints.py
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply one scalar native relative-orbital-element target."""
    expected_layout = (
        "damico_relative_elements"
        if self.representation == "damico"
        else "classical_relative_elements"
    )
    if context.layout.name != expected_layout:
        raise ValueError(
            f"{self.representation!r} constraints require a matching native "
            "relative-element propagation mode; selected layout is "
            f"{context.layout.name!r}"
        )
    _apply_scalar_target(
        phase,
        context,
        where=self.where,
        state_index=context.layout.state_indices(self.element)[0],
        target=self.target,
        tolerance=self.tolerance,
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report the worst solved error for one relative orbital element.

Source code in octavian/constraints.py
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report the worst solved error for one relative orbital element."""
    trajectory = np.asarray(context.native_trajectory, dtype=float)
    index = context.layout.state_indices(self.element)[0]
    return [
        _scalar_report_row(
            self,
            context,
            name=f"{self.representation}_{self.element}",
            target=self.target,
            tolerance=self.tolerance,
            values=_values_at_location(trajectory[:, index], self.where),
        )
    ]

RelativeOrbitalElementsConstraint dataclass

Bases: Constraint

Constrain all six native relative orbital elements at one location.

Source code in octavian/constraints.py
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
@dataclass(frozen=True, slots=True)
class RelativeOrbitalElementsConstraint(Constraint):
    """Constrain all six native relative orbital elements at one location."""

    kind: ClassVar[str] = "relative_orbital_elements"
    family: ClassVar[str] = "relative_orbital_element"

    elements: Any = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
    representation: RelativeElementRepresentation | str | None = None
    where: Where = "Front"

    def __post_init__(self) -> None:
        inferred_representation = (
            "classical_elements"
            if hasattr(self.elements, "delta_a_m")
            else "damico"
            if hasattr(self.elements, "delta_lambda_rad")
            else None
        )
        representation = _normalize_relative_element_representation(
            self.representation or inferred_representation or "damico"
        )
        if (
            inferred_representation is not None
            and inferred_representation != representation
        ):
            raise ValueError(
                "elements object does not match the requested "
                f"{representation!r} representation"
            )
        source = (
            self.elements.as_vector()  # type: ignore[attr-defined]
            if hasattr(self.elements, "as_vector")
            else self.elements
        )
        elements = np.asarray(source, dtype=float).reshape(6)
        if not np.all(np.isfinite(elements)):
            raise ValueError("elements must contain six finite values")
        object.__setattr__(self, "representation", representation)
        object.__setattr__(self, "elements", elements)
        object.__setattr__(self, "where", _normalize_where(self.where))

    @property
    def value(self) -> dict[str, Any]:
        """Return the normalized six-element target payload."""
        return {
            "representation": self.representation,
            "elements": np.asarray(self.elements, dtype=float),
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Fix all six native relative orbital elements at one location."""
        expected_layout = (
            "damico_relative_elements"
            if self.representation == "damico"
            else "classical_relative_elements"
        )
        if context.layout.name != expected_layout:
            raise ValueError(
                f"{self.representation!r} constraints require a matching native "
                "relative-element propagation mode; selected layout is "
                f"{context.layout.name!r}"
            )
        phase.addBoundaryValue(
            self.where,
            list(context.layout.state_indices("relative_elements")),
            np.asarray(self.elements, dtype=float),
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report the worst solved error for each relative orbital element."""
        trajectory = np.asarray(context.native_trajectory, dtype=float)
        indices = context.layout.state_indices("relative_elements")
        names = context.layout.state_names
        return [
            _scalar_report_row(
                self,
                context,
                name=f"{self.representation}_{names[index]}",
                target=float(target),
                tolerance=None,
                values=_values_at_location(trajectory[:, index], self.where),
            )
            for index, target in zip(indices, self.elements, strict=True)
        ]

value: dict[str, Any] property

Return the normalized six-element target payload.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Fix all six native relative orbital elements at one location.

Source code in octavian/constraints.py
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Fix all six native relative orbital elements at one location."""
    expected_layout = (
        "damico_relative_elements"
        if self.representation == "damico"
        else "classical_relative_elements"
    )
    if context.layout.name != expected_layout:
        raise ValueError(
            f"{self.representation!r} constraints require a matching native "
            "relative-element propagation mode; selected layout is "
            f"{context.layout.name!r}"
        )
    phase.addBoundaryValue(
        self.where,
        list(context.layout.state_indices("relative_elements")),
        np.asarray(self.elements, dtype=float),
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report the worst solved error for each relative orbital element.

Source code in octavian/constraints.py
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report the worst solved error for each relative orbital element."""
    trajectory = np.asarray(context.native_trajectory, dtype=float)
    indices = context.layout.state_indices("relative_elements")
    names = context.layout.state_names
    return [
        _scalar_report_row(
            self,
            context,
            name=f"{self.representation}_{names[index]}",
            target=float(target),
            tolerance=None,
            values=_values_at_location(trajectory[:, index], self.where),
        )
        for index, target in zip(indices, self.elements, strict=True)
    ]

RelativeStateComponent dataclass

Bases: Constraint

Constrain one native RIC position or velocity component.

Components are R, I, C, Rdot, Idot, and Cdot. The target and optional tolerance use meters for position and meters per second for velocity. The compiler applies this constraint directly to a native RIC formulation; it never reconstructs an absolute deputy state.

Source code in octavian/constraints.py
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
@dataclass(frozen=True, slots=True)
class RelativeStateComponent(Constraint):
    """Constrain one native RIC position or velocity component.

    Components are ``R``, ``I``, ``C``, ``Rdot``, ``Idot``, and ``Cdot``.
    The target and optional tolerance use meters for position and meters per
    second for velocity. The compiler applies this constraint directly to a
    native RIC formulation; it never reconstructs an absolute deputy state.
    """

    kind: ClassVar[str] = "relative_state_component"
    family: ClassVar[str] = "relative_state"

    component: str = "R"
    target: float = 0.0
    where: Where = "Back"
    tolerance: float | None = None

    def __post_init__(self) -> None:
        aliases = {
            "r": "R",
            "radial": "R",
            "x": "R",
            "i": "I",
            "in_track": "I",
            "intrack": "I",
            "y": "I",
            "c": "C",
            "cross_track": "C",
            "crosstrack": "C",
            "z": "C",
            "rdot": "Rdot",
            "r_dot": "Rdot",
            "xdot": "Rdot",
            "idot": "Idot",
            "i_dot": "Idot",
            "ydot": "Idot",
            "cdot": "Cdot",
            "c_dot": "Cdot",
            "zdot": "Cdot",
        }
        normalized = str(self.component).strip().lower().replace("-", "_").replace(" ", "_")
        if normalized not in aliases:
            raise ValueError("component must be one of R, I, C, Rdot, Idot, or Cdot")
        object.__setattr__(self, "component", aliases[normalized])
        object.__setattr__(self, "target", _finite_float("target", self.target))
        object.__setattr__(self, "where", _normalize_where(self.where))
        object.__setattr__(
            self,
            "tolerance",
            _optional_nonnegative_float("tolerance", self.tolerance),
        )

    @property
    def value(self) -> dict[str, float | str | None]:
        """Return the normalized component target payload."""
        return {
            "component": self.component,
            "target": self.target,
            "tolerance": self.tolerance,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply a scalar target to one native RIC component."""
        groups = {
            "R": ("position", 0),
            "I": ("position", 1),
            "C": ("position", 2),
            "Rdot": ("velocity", 0),
            "Idot": ("velocity", 1),
            "Cdot": ("velocity", 2),
        }
        group, offset = groups[self.component]
        try:
            state_index = context.layout.state_indices(group)[offset]
        except (KeyError, IndexError) as exc:
            raise ValueError(
                "RIC component constraints require CWH, 'nonlinear_ric', or "
                "'coupled_ric' propagation; the selected layout is "
                f"{context.layout.name!r}"
            ) from exc
        _apply_scalar_target(
            phase,
            context,
            where=self.where,
            state_index=state_index,
            target=self.target,
            tolerance=self.tolerance,
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report the worst solved error for one RIC component."""
        groups = {
            "R": ("position", 0),
            "I": ("position", 1),
            "C": ("position", 2),
            "Rdot": ("velocity", 0),
            "Idot": ("velocity", 1),
            "Cdot": ("velocity", 2),
        }
        group, offset = groups[self.component]
        index = context.layout.state_indices(group)[offset]
        trajectory = np.asarray(context.native_trajectory, dtype=float)
        return [
            _scalar_report_row(
                self,
                context,
                name=f"ric_{self.component}",
                target=self.target,
                tolerance=self.tolerance,
                values=_values_at_location(trajectory[:, index], self.where),
            )
        ]

value: dict[str, float | str | None] property

Return the normalized component target payload.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply a scalar target to one native RIC component.

Source code in octavian/constraints.py
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply a scalar target to one native RIC component."""
    groups = {
        "R": ("position", 0),
        "I": ("position", 1),
        "C": ("position", 2),
        "Rdot": ("velocity", 0),
        "Idot": ("velocity", 1),
        "Cdot": ("velocity", 2),
    }
    group, offset = groups[self.component]
    try:
        state_index = context.layout.state_indices(group)[offset]
    except (KeyError, IndexError) as exc:
        raise ValueError(
            "RIC component constraints require CWH, 'nonlinear_ric', or "
            "'coupled_ric' propagation; the selected layout is "
            f"{context.layout.name!r}"
        ) from exc
    _apply_scalar_target(
        phase,
        context,
        where=self.where,
        state_index=state_index,
        target=self.target,
        tolerance=self.tolerance,
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report the worst solved error for one RIC component.

Source code in octavian/constraints.py
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report the worst solved error for one RIC component."""
    groups = {
        "R": ("position", 0),
        "I": ("position", 1),
        "C": ("position", 2),
        "Rdot": ("velocity", 0),
        "Idot": ("velocity", 1),
        "Cdot": ("velocity", 2),
    }
    group, offset = groups[self.component]
    index = context.layout.state_indices(group)[offset]
    trajectory = np.asarray(context.native_trajectory, dtype=float)
    return [
        _scalar_report_row(
            self,
            context,
            name=f"ric_{self.component}",
            target=self.target,
            tolerance=self.tolerance,
            values=_values_at_location(trajectory[:, index], self.where),
        )
    ]

SemiMajorAxis dataclass

Bases: OrbitalElementConstraint

Constrain semi-major axis in meters.

Source code in octavian/constraints.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
@dataclass(frozen=True, slots=True)
class SemiMajorAxis(OrbitalElementConstraint):
    """Constrain semi-major axis in meters."""

    kind: ClassVar[str] = "semi_major_axis"

    a_m: float = 0.0
    where: Where = "Path"
    tol_m: float | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        semi_major_axis_m = _finite_float("a_m", self.a_m)
        if semi_major_axis_m <= 0.0:
            raise ValueError("a_m must be > 0.")
        object.__setattr__(self, "a_m", semi_major_axis_m)
        tolerance_m = _optional_nonnegative_float("tol_m", self.tol_m)
        if tolerance_m is not None and tolerance_m >= semi_major_axis_m:
            raise ValueError("tol_m must be smaller than a_m.")
        object.__setattr__(self, "tol_m", tolerance_m)

    @property
    def element_name(self) -> str:
        """Return the compiler-facing element identifier."""
        return "semi_major_axis"

    @property
    def value(self) -> dict[str, float | None]:
        """Return the target and optional tolerance in meters."""
        return {"a_m": float(self.a_m), "tol_m": self.tol_m}

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply the two-body specific-energy definition of semi-major axis."""
        _validate_inertial_orbital_context(context)
        vf = context.vector_functions
        mu_m3ps2 = float(context.mu_m3ps2)
        arguments = vf.Arguments(6)
        position, velocity = arguments.tolist([(0, 3), (3, 3)])
        radius = position.norm()
        speed = velocity.norm()
        specific_energy = 0.5 * speed**2 - mu_m3ps2 / radius
        semi_major_axis_m = -0.5 * mu_m3ps2 / specific_energy
        if self.tol_m is None:
            phase.addEqualCon(
                self.where,
                vf.stack([semi_major_axis_m - self.a_m]),
                range(0, 6),
            )
            return
        phase.addInequalCon(
            self.where,
            vf.stack([semi_major_axis_m - (self.a_m + self.tol_m)]),
            range(0, 6),
        )
        phase.addInequalCon(
            self.where,
            vf.stack([(self.a_m - self.tol_m) - semi_major_axis_m]),
            range(0, 6),
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report solved semi-major axis at the selected boundary."""
        if self.where not in {"Front", "Back"}:
            return []
        return [_orbital_report_row(self, context, self.a_m, self.tol_m, "a_m")]

element_name: str property

Return the compiler-facing element identifier.

value: dict[str, float | None] property

Return the target and optional tolerance in meters.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply the two-body specific-energy definition of semi-major axis.

Source code in octavian/constraints.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply the two-body specific-energy definition of semi-major axis."""
    _validate_inertial_orbital_context(context)
    vf = context.vector_functions
    mu_m3ps2 = float(context.mu_m3ps2)
    arguments = vf.Arguments(6)
    position, velocity = arguments.tolist([(0, 3), (3, 3)])
    radius = position.norm()
    speed = velocity.norm()
    specific_energy = 0.5 * speed**2 - mu_m3ps2 / radius
    semi_major_axis_m = -0.5 * mu_m3ps2 / specific_energy
    if self.tol_m is None:
        phase.addEqualCon(
            self.where,
            vf.stack([semi_major_axis_m - self.a_m]),
            range(0, 6),
        )
        return
    phase.addInequalCon(
        self.where,
        vf.stack([semi_major_axis_m - (self.a_m + self.tol_m)]),
        range(0, 6),
    )
    phase.addInequalCon(
        self.where,
        vf.stack([(self.a_m - self.tol_m) - semi_major_axis_m]),
        range(0, 6),
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report solved semi-major axis at the selected boundary.

Source code in octavian/constraints.py
286
287
288
289
290
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report solved semi-major axis at the selected boundary."""
    if self.where not in {"Front", "Back"}:
        return []
    return [_orbital_report_row(self, context, self.a_m, self.tol_m, "a_m")]

SolarPhaseAngle dataclass

Bases: Constraint

Bound the relative position angle to the ephemeris Sun direction.

Unlike :class:LightingAngle, the direction is not stored as a fixed vector. The composable relative-motion compiler samples the bundled SPICE BSP at Mission.initial_epoch and rotates the Sun line into the chief's RIC frame throughout the phase. CWH dynamics must therefore provide a chief initial inertial state.

Source code in octavian/constraints.py
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
@dataclass(frozen=True, slots=True)
class SolarPhaseAngle(Constraint):
    """Bound the relative position angle to the ephemeris Sun direction.

    Unlike :class:`LightingAngle`, the direction is not stored as a fixed
    vector.  The composable relative-motion compiler samples the bundled SPICE
    BSP at ``Mission.initial_epoch`` and rotates the Sun line into the chief's
    RIC frame throughout the phase.  CWH dynamics must therefore provide a
    chief initial inertial state.
    """

    kind: ClassVar[str] = "solar_phase_angle"
    family: ClassVar[str] = "relative_geometry"

    min_angle_deg: float = 0.0
    max_angle_deg: float = 180.0
    origin_m: Sequence[float] = (0.0, 0.0, 0.0)
    where: Where = "Path"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))
        minimum = _finite_float("min_angle_deg", self.min_angle_deg)
        maximum = _finite_float("max_angle_deg", self.max_angle_deg)
        if not (0.0 <= minimum < maximum <= 180.0):
            raise ValueError("solar phase angles must satisfy 0 <= min < max <= 180")
        object.__setattr__(self, "min_angle_deg", minimum)
        object.__setattr__(self, "max_angle_deg", maximum)
        object.__setattr__(self, "origin_m", _finite_vec3("origin_m", self.origin_m))

    @property
    def value(self) -> dict[str, Any]:
        """Return solar-phase angle bounds and origin."""
        return {
            "min_angle_deg": self.min_angle_deg,
            "max_angle_deg": self.max_angle_deg,
            "origin_m": self.origin_m,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply angle bounds to the time-varying SPICE Sun line in RIC."""
        vf = context.vector_functions
        expressions = context.relative_expressions
        if expressions is not None:
            if context.solar_position_table is None:
                raise ValueError(
                    "SolarPhaseAngle requires a SPICE-derived ECI Sun position table"
                )
            offset_ric = expressions.position - np.asarray(self.origin_m, dtype=float)
            sun_line_eci = (
                context.solar_position_table(expressions.time) - expressions.chief_position
            )
            sun_line_ric = vf.stack(
                [
                    expressions.radial_axis.dot(sun_line_eci),
                    expressions.in_track_axis.dot(sun_line_eci),
                    expressions.cross_track_axis.dot(sun_line_eci),
                ]
            )
            _apply_angle_bounds(
                phase,
                vf,
                self.where,
                offset_ric,
                sun_line_ric,
                self.min_angle_deg,
                self.max_angle_deg,
                expressions.argument_indices,
            )
            return

        if context.layout.name.endswith("relative_elements"):
            raise ValueError(
                "Cartesian relative-geometry constraints require a native RIC or "
                "coupled-ECI propagation mode; they are not defined directly on "
                "relative elements."
            )
        if context.solar_direction_table is None:
            raise ValueError("SolarPhaseAngle requires a SPICE-derived RIC direction table")
        position_time = vf.Arguments(4)
        offset = position_time.head(3) - np.asarray(self.origin_m, dtype=float)
        sun_direction = context.solar_direction_table(position_time[3])
        _apply_angle_bounds(
            phase,
            vf,
            self.where,
            offset,
            sun_direction,
            self.min_angle_deg,
            self.max_angle_deg,
            (*context.layout.state_indices("position"), context.layout.time_column),
            scale_direction_norm=False,
        )

    def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
        """Report solved extrema of the time-varying solar phase angle."""
        if context.solar_direction_at is None:
            raise ValueError("SolarPhaseAngle reporting requires SPICE-derived RIC directions")
        trajectory = np.asarray(context.phase_trajectory, dtype=float)
        positions = _positions_at_location(trajectory, self.where)
        times = _values_at_location(trajectory[:, 6], self.where)
        directions = np.asarray(context.solar_direction_at(times), dtype=float).reshape(-1, 3)
        angles = _angles_between_deg(positions - self.origin_m, directions)
        return _angle_report_rows(self, context, angles, "solar_phase")

value: dict[str, Any] property

Return solar-phase angle bounds and origin.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply angle bounds to the time-varying SPICE Sun line in RIC.

Source code in octavian/constraints.py
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply angle bounds to the time-varying SPICE Sun line in RIC."""
    vf = context.vector_functions
    expressions = context.relative_expressions
    if expressions is not None:
        if context.solar_position_table is None:
            raise ValueError(
                "SolarPhaseAngle requires a SPICE-derived ECI Sun position table"
            )
        offset_ric = expressions.position - np.asarray(self.origin_m, dtype=float)
        sun_line_eci = (
            context.solar_position_table(expressions.time) - expressions.chief_position
        )
        sun_line_ric = vf.stack(
            [
                expressions.radial_axis.dot(sun_line_eci),
                expressions.in_track_axis.dot(sun_line_eci),
                expressions.cross_track_axis.dot(sun_line_eci),
            ]
        )
        _apply_angle_bounds(
            phase,
            vf,
            self.where,
            offset_ric,
            sun_line_ric,
            self.min_angle_deg,
            self.max_angle_deg,
            expressions.argument_indices,
        )
        return

    if context.layout.name.endswith("relative_elements"):
        raise ValueError(
            "Cartesian relative-geometry constraints require a native RIC or "
            "coupled-ECI propagation mode; they are not defined directly on "
            "relative elements."
        )
    if context.solar_direction_table is None:
        raise ValueError("SolarPhaseAngle requires a SPICE-derived RIC direction table")
    position_time = vf.Arguments(4)
    offset = position_time.head(3) - np.asarray(self.origin_m, dtype=float)
    sun_direction = context.solar_direction_table(position_time[3])
    _apply_angle_bounds(
        phase,
        vf,
        self.where,
        offset,
        sun_direction,
        self.min_angle_deg,
        self.max_angle_deg,
        (*context.layout.state_indices("position"), context.layout.time_column),
        scale_direction_norm=False,
    )

report(context: ConstraintReportContext) -> list[dict[str, Any]]

Report solved extrema of the time-varying solar phase angle.

Source code in octavian/constraints.py
838
839
840
841
842
843
844
845
846
847
def report(self, context: ConstraintReportContext) -> list[dict[str, Any]]:
    """Report solved extrema of the time-varying solar phase angle."""
    if context.solar_direction_at is None:
        raise ValueError("SolarPhaseAngle reporting requires SPICE-derived RIC directions")
    trajectory = np.asarray(context.phase_trajectory, dtype=float)
    positions = _positions_at_location(trajectory, self.where)
    times = _values_at_location(trajectory[:, 6], self.where)
    directions = np.asarray(context.solar_direction_at(times), dtype=float).reshape(-1, 3)
    angles = _angles_between_deg(positions - self.origin_m, directions)
    return _angle_report_rows(self, context, angles, "solar_phase")

State dataclass

Bases: Constraint

Constrain a Cartesian boundary state.

Source code in octavian/constraints.py
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
@dataclass(frozen=True, slots=True)
class State(Constraint):
    """Constrain a Cartesian boundary state."""

    kind: ClassVar[str] = "state"
    family: ClassVar[str] = "boundary_state"

    x: BoundaryState = BoundaryState(np.zeros(3), np.zeros(3))
    where: Where = "Front"
    groups: tuple[str, ...] = ("R", "V")

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))

    @property
    def value(self) -> dict[str, Any]:
        """Return the Cartesian state and selected groups."""
        return {"x": self.x, "groups": tuple(str(group) for group in self.groups)}

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Fix the selected position, velocity, or time boundary groups."""
        if context.layout.name.endswith("relative_elements"):
            raise ValueError(
                "Cartesian State constraints are not native to a relative-element phase. "
                "Use constraints.relative_orbital_elements(...) or "
                "constraints.relative_orbital_element(...)."
            )

        groups = tuple(str(group) for group in self.groups)
        declared_phase = context.declared_phase
        previous = getattr(declared_phase, "previous", None)
        link = getattr(declared_phase, "link", None)
        linked_front_impulse = (
            self.where == "Front"
            and previous is not None
            and link is not None
            and not link.is_continuous()
        )
        has_impulse = any(
            getattr(variable, "kind", "") == "impulsive_delta_v"
            and getattr(variable, "where", "") == self.where
            for variable in getattr(declared_phase, "variables", ()) or ()
        ) or any(
            getattr(event, "kind", "") == "impulse"
            and getattr(event, "where", "") == self.where
            for event in getattr(declared_phase, "events", ()) or ()
        )
        if has_impulse and "V" in groups and not linked_front_impulse:
            groups = tuple(group for group in groups if group != "V")

        expressions = context.relative_expressions
        if expressions is not None:
            for group in groups:
                if group == "R":
                    residual = expressions.position - np.asarray(self.x.r_m, dtype=float)
                elif group == "V":
                    residual = expressions.velocity - np.asarray(self.x.v_mps, dtype=float)
                elif group in {"t", "time"}:
                    continue
                else:
                    raise ValueError(f"Unsupported State.groups element: {group!r}")
                phase.addEqualCon(self.where, residual, expressions.argument_indices)
            return

        values: list[float] = []
        applied_groups: list[str] = []
        for group in groups:
            if group == "R":
                values.extend(np.asarray(self.x.r_m, dtype=float).reshape(3))
                applied_groups.append("R")
            elif group == "V":
                values.extend(np.asarray(self.x.v_mps, dtype=float).reshape(3))
                applied_groups.append("V")
            elif group in {"t", "time"}:
                if context.phase_index == 0 and self.where == "Front":
                    applied_groups.append("t")
                    values.append(0.0)
            else:
                raise ValueError(f"Unsupported State.groups element: {group!r}")
        if applied_groups:
            phase.addBoundaryValue(
                self.where,
                applied_groups,
                np.asarray(values, dtype=float),
            )

value: dict[str, Any] property

Return the Cartesian state and selected groups.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Fix the selected position, velocity, or time boundary groups.

Source code in octavian/constraints.py
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Fix the selected position, velocity, or time boundary groups."""
    if context.layout.name.endswith("relative_elements"):
        raise ValueError(
            "Cartesian State constraints are not native to a relative-element phase. "
            "Use constraints.relative_orbital_elements(...) or "
            "constraints.relative_orbital_element(...)."
        )

    groups = tuple(str(group) for group in self.groups)
    declared_phase = context.declared_phase
    previous = getattr(declared_phase, "previous", None)
    link = getattr(declared_phase, "link", None)
    linked_front_impulse = (
        self.where == "Front"
        and previous is not None
        and link is not None
        and not link.is_continuous()
    )
    has_impulse = any(
        getattr(variable, "kind", "") == "impulsive_delta_v"
        and getattr(variable, "where", "") == self.where
        for variable in getattr(declared_phase, "variables", ()) or ()
    ) or any(
        getattr(event, "kind", "") == "impulse"
        and getattr(event, "where", "") == self.where
        for event in getattr(declared_phase, "events", ()) or ()
    )
    if has_impulse and "V" in groups and not linked_front_impulse:
        groups = tuple(group for group in groups if group != "V")

    expressions = context.relative_expressions
    if expressions is not None:
        for group in groups:
            if group == "R":
                residual = expressions.position - np.asarray(self.x.r_m, dtype=float)
            elif group == "V":
                residual = expressions.velocity - np.asarray(self.x.v_mps, dtype=float)
            elif group in {"t", "time"}:
                continue
            else:
                raise ValueError(f"Unsupported State.groups element: {group!r}")
            phase.addEqualCon(self.where, residual, expressions.argument_indices)
        return

    values: list[float] = []
    applied_groups: list[str] = []
    for group in groups:
        if group == "R":
            values.extend(np.asarray(self.x.r_m, dtype=float).reshape(3))
            applied_groups.append("R")
        elif group == "V":
            values.extend(np.asarray(self.x.v_mps, dtype=float).reshape(3))
            applied_groups.append("V")
        elif group in {"t", "time"}:
            if context.phase_index == 0 and self.where == "Front":
                applied_groups.append("t")
                values.append(0.0)
        else:
            raise ValueError(f"Unsupported State.groups element: {group!r}")
    if applied_groups:
        phase.addBoundaryValue(
            self.where,
            applied_groups,
            np.asarray(values, dtype=float),
        )

StateComponent dataclass

Bases: Constraint

Constrain one Cartesian state component in the phase's native frame.

Position components are x, y, and z; velocity components are vx, vy, and vz. Position targets and tolerances use meters, while velocity targets and tolerances use meters per second. The constraint is applied directly to the phase state without changing frames.

Source code in octavian/constraints.py
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
@dataclass(frozen=True, slots=True)
class StateComponent(Constraint):
    """Constrain one Cartesian state component in the phase's native frame.

    Position components are ``x``, ``y``, and ``z``; velocity components are
    ``vx``, ``vy``, and ``vz``. Position targets and tolerances use meters,
    while velocity targets and tolerances use meters per second. The
    constraint is applied directly to the phase state without changing frames.
    """

    kind: ClassVar[str] = "state_component"
    family: ClassVar[str] = "boundary_state"

    component: str = "x"
    target: float = 0.0
    where: Where = "Front"
    tolerance: float | None = None

    def __post_init__(self) -> None:
        object.__setattr__(
            self,
            "component",
            _normalize_cartesian_component(self.component),
        )
        object.__setattr__(self, "target", _finite_float("target", self.target))
        object.__setattr__(self, "where", _normalize_where(self.where))
        object.__setattr__(
            self,
            "tolerance",
            _optional_nonnegative_float("tolerance", self.tolerance),
        )

    @property
    def value(self) -> dict[str, float | str | None]:
        """Return the normalized component target payload."""
        return {
            "component": self.component,
            "target": self.target,
            "tolerance": self.tolerance,
        }

    def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
        """Apply one native Cartesian state component target."""
        index = _cartesian_component_index(context.layout, self.component)
        if self.tolerance is None:
            if self.where == "Path":
                argument = context.vector_functions.Arguments(1)
                phase.addEqualCon("Path", argument - self.target, [index])
                return
            phase.addBoundaryValue(
                self.where,
                [index],
                np.asarray([self.target], dtype=float),
            )
            return
        phase.addLUVarBound(
            self.where,
            index,
            self.target - self.tolerance,
            self.target + self.tolerance,
        )

value: dict[str, float | str | None] property

Return the normalized component target payload.

apply(phase: Any, context: ConstraintApplicationContext) -> None

Apply one native Cartesian state component target.

Source code in octavian/constraints.py
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
def apply(self, phase: Any, context: ConstraintApplicationContext) -> None:
    """Apply one native Cartesian state component target."""
    index = _cartesian_component_index(context.layout, self.component)
    if self.tolerance is None:
        if self.where == "Path":
            argument = context.vector_functions.Arguments(1)
            phase.addEqualCon("Path", argument - self.target, [index])
            return
        phase.addBoundaryValue(
            self.where,
            [index],
            np.asarray([self.target], dtype=float),
        )
        return
    phase.addLUVarBound(
        self.where,
        index,
        self.target - self.tolerance,
        self.target + self.tolerance,
    )

approach_cone(axis: Sequence[float], half_angle_deg: float, *, vertex_m: Sequence[float] = (0.0, 0.0, 0.0), where: str = 'Path') -> ApproachCone

Create a one-sided conical approach-corridor constraint.

Source code in octavian/constraints.py
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
def approach_cone(
    axis: Sequence[float],
    half_angle_deg: float,
    *,
    vertex_m: Sequence[float] = (0.0, 0.0, 0.0),
    where: str = "Path",
) -> ApproachCone:
    """Create a one-sided conical approach-corridor constraint."""
    return ApproachCone(
        axis=axis,
        half_angle_deg=half_angle_deg,
        vertex_m=vertex_m,
        where=where,
    )

eccentricity(e: float, where: str = 'Path', tol: float | None = None) -> Eccentricity

Create an eccentricity constraint.

Source code in octavian/constraints.py
1891
1892
1893
def eccentricity(e: float, where: str = "Path", tol: float | None = None) -> Eccentricity:
    """Create an eccentricity constraint."""
    return Eccentricity(e=e, where=where, tol=tol)

inclination_deg(inc_deg: float, where: str = 'Path', tol_deg: float | None = None) -> InclinationDeg

Create an inclination constraint.

Source code in octavian/constraints.py
1896
1897
1898
1899
1900
def inclination_deg(
    inc_deg: float, where: str = "Path", tol_deg: float | None = None
) -> InclinationDeg:
    """Create an inclination constraint."""
    return InclinationDeg(inc_deg=inc_deg, where=where, tol_deg=tol_deg)

jacobi_constant(target: float, *, where: str = 'Front', tolerance: float | None = None, dimensional: bool = True) -> JacobiConstant

Create a direct Jacobi-constant constraint for a CR3BP phase.

Args: target: Desired Jacobi constant. where: "Front", "Back", or "Path". tolerance: Optional symmetric non-negative tolerance. dimensional: Interpret the target and tolerance as m²/s² when true. Set false for the canonical values normally published with nondimensional CR3BP states.

Returns: A declarative CR3BP invariant constraint.

Source code in octavian/constraints.py
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
def jacobi_constant(
    target: float,
    *,
    where: str = "Front",
    tolerance: float | None = None,
    dimensional: bool = True,
) -> JacobiConstant:
    """Create a direct Jacobi-constant constraint for a CR3BP phase.

    Args:
        target: Desired Jacobi constant.
        where: ``"Front"``, ``"Back"``, or ``"Path"``.
        tolerance: Optional symmetric non-negative tolerance.
        dimensional: Interpret the target and tolerance as ``m²/s²`` when
            true. Set false for the canonical values normally published with
            nondimensional CR3BP states.

    Returns:
        A declarative CR3BP invariant constraint.
    """
    return JacobiConstant(
        target=target,
        where=where,
        tolerance=tolerance,
        dimensional=dimensional,
    )

keep_out_sphere(radius_m: float, *, center_m: Sequence[float] = (0.0, 0.0, 0.0), where: str = 'Path') -> KeepOutSphere

Create an offset spherical keep-out-zone constraint.

Source code in octavian/constraints.py
1908
1909
1910
1911
1912
1913
1914
1915
def keep_out_sphere(
    radius_m: float,
    *,
    center_m: Sequence[float] = (0.0, 0.0, 0.0),
    where: str = "Path",
) -> KeepOutSphere:
    """Create an offset spherical keep-out-zone constraint."""
    return KeepOutSphere(radius_m=radius_m, center_m=center_m, where=where)

lighting_angle(sun_direction: Sequence[float], *, min_angle_deg: float = 0.0, max_angle_deg: float = 180.0, origin_m: Sequence[float] = (0.0, 0.0, 0.0), where: str = 'Path') -> LightingAngle

Create a fixed-direction lighting-angle constraint.

Source code in octavian/constraints.py
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
def lighting_angle(
    sun_direction: Sequence[float],
    *,
    min_angle_deg: float = 0.0,
    max_angle_deg: float = 180.0,
    origin_m: Sequence[float] = (0.0, 0.0, 0.0),
    where: str = "Path",
) -> LightingAngle:
    """Create a fixed-direction lighting-angle constraint."""
    return LightingAngle(
        sun_direction=sun_direction,
        min_angle_deg=min_angle_deg,
        max_angle_deg=max_angle_deg,
        origin_m=origin_m,
        where=where,
    )

min_radius(r_min_m: float, where: str = 'Path') -> MinRadius

Create a minimum-radius constraint.

Source code in octavian/constraints.py
1903
1904
1905
def min_radius(r_min_m: float, where: str = "Path") -> MinRadius:
    """Create a minimum-radius constraint."""
    return MinRadius(r_min_m=r_min_m, where=where)

periodic_state(components: Sequence[str] = ('x', 'y', 'z', 'vx', 'vy', 'vz')) -> PeriodicState

Create a direct front-to-back Cartesian periodicity constraint.

Args: components: Cartesian components to equate between the phase boundaries. The default closes all six position/velocity components while leaving time unconstrained.

Returns: A periodic-state declaration for the composable ASSET compiler.

Notes: Autonomous periodic-orbit problems normally need an additional phase condition, such as a fixed front position component, to remove the arbitrary time shift around the orbit.

Source code in octavian/constraints.py
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
def periodic_state(
    components: Sequence[str] = ("x", "y", "z", "vx", "vy", "vz"),
) -> PeriodicState:
    """Create a direct front-to-back Cartesian periodicity constraint.

    Args:
        components: Cartesian components to equate between the phase
            boundaries. The default closes all six position/velocity
            components while leaving time unconstrained.

    Returns:
        A periodic-state declaration for the composable ASSET compiler.

    Notes:
        Autonomous periodic-orbit problems normally need an additional phase
        condition, such as a fixed front position component, to remove the
        arbitrary time shift around the orbit.
    """
    return PeriodicState(components=tuple(components))

position(r_m: Sequence[float], where: str = 'Front') -> Position

Create a boundary position constraint.

Source code in octavian/constraints.py
1746
1747
1748
def position(r_m: Sequence[float], where: str = "Front") -> Position:
    """Create a boundary position constraint."""
    return Position(r_m=r_m, where=where)

relative_orbital_element(element: str, target: float, *, representation: RelativeElementRepresentation | str = 'damico', where: str = 'Back', tolerance: float | None = None) -> RelativeOrbitalElementConstraint

Create a direct scalar relative-orbital-element constraint.

Source code in octavian/constraints.py
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
def relative_orbital_element(
    element: str,
    target: float,
    *,
    representation: RelativeElementRepresentation | str = "damico",
    where: str = "Back",
    tolerance: float | None = None,
) -> RelativeOrbitalElementConstraint:
    """Create a direct scalar relative-orbital-element constraint."""
    return RelativeOrbitalElementConstraint(
        element=element,
        target=target,
        representation=representation,
        where=where,
        tolerance=tolerance,
    )

relative_orbital_elements(elements: Any, *, representation: RelativeElementRepresentation | str | None = None, where: str = 'Front') -> RelativeOrbitalElementsConstraint

Create a direct six-element relative-orbit boundary constraint.

elements may be a six-value sequence or one of the relative-element dataclasses from :mod:octavian.relative.

Source code in octavian/constraints.py
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
def relative_orbital_elements(
    elements: Any,
    *,
    representation: RelativeElementRepresentation | str | None = None,
    where: str = "Front",
) -> RelativeOrbitalElementsConstraint:
    """Create a direct six-element relative-orbit boundary constraint.

    ``elements`` may be a six-value sequence or one of the relative-element
    dataclasses from :mod:`octavian.relative`.
    """
    return RelativeOrbitalElementsConstraint(
        elements=elements,
        representation=representation,
        where=where,
    )

ric_state(component: str, target: float, *, where: str = 'Back', tolerance: float | None = None) -> RelativeStateComponent

Create a direct constraint on one native RIC state component.

Use a native RIC propagation mode (CWH, "nonlinear_ric", or "coupled_ric") so the optimizer applies the target without converting through absolute coordinates.

Source code in octavian/constraints.py
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
def ric_state(
    component: str,
    target: float,
    *,
    where: str = "Back",
    tolerance: float | None = None,
) -> RelativeStateComponent:
    """Create a direct constraint on one native RIC state component.

    Use a native RIC propagation mode (CWH, ``"nonlinear_ric"``, or
    ``"coupled_ric"``) so the optimizer applies the target without converting
    through absolute coordinates.
    """
    return RelativeStateComponent(
        component=component,
        target=target,
        where=where,
        tolerance=tolerance,
    )

semi_major_axis(a_m: float, where: str = 'Path', tol_m: float | None = None) -> SemiMajorAxis

Create a semi-major-axis constraint.

Source code in octavian/constraints.py
1886
1887
1888
def semi_major_axis(a_m: float, where: str = "Path", tol_m: float | None = None) -> SemiMajorAxis:
    """Create a semi-major-axis constraint."""
    return SemiMajorAxis(a_m=a_m, where=where, tol_m=tol_m)

solar_phase_angle(*, min_angle_deg: float = 0.0, max_angle_deg: float = 180.0, origin_m: Sequence[float] = (0.0, 0.0, 0.0), where: str = 'Path') -> SolarPhaseAngle

Create an ephemeris-driven RIC solar-phase-angle constraint.

Source code in octavian/constraints.py
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
def solar_phase_angle(
    *,
    min_angle_deg: float = 0.0,
    max_angle_deg: float = 180.0,
    origin_m: Sequence[float] = (0.0, 0.0, 0.0),
    where: str = "Path",
) -> SolarPhaseAngle:
    """Create an ephemeris-driven RIC solar-phase-angle constraint."""
    return SolarPhaseAngle(
        min_angle_deg=min_angle_deg,
        max_angle_deg=max_angle_deg,
        origin_m=origin_m,
        where=where,
    )

state(x: BoundaryState, where: str = 'Front', groups: Sequence[str] = ('R', 'V')) -> State

Create a boundary state constraint.

Source code in octavian/constraints.py
1741
1742
1743
def state(x: BoundaryState, where: str = "Front", groups: Sequence[str] = ("R", "V")) -> State:
    """Create a boundary state constraint."""
    return State(x=x, where=where, groups=tuple(str(group) for group in groups))

state_component(component: str, target: float, *, where: str = 'Front', tolerance: float | None = None) -> StateComponent

Create a native-frame Cartesian component constraint.

Args: component: Position component x, y, or z; or velocity component vx, vy, or vz. Common spellings such as xdot are normalized. target: Component target in meters or meters per second. where: "Front", "Back", or "Path". tolerance: Optional symmetric non-negative tolerance in the component's units. Omitting it creates an equality.

Returns: A declarative constraint applied without a coordinate conversion.

Source code in octavian/constraints.py
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
def state_component(
    component: str,
    target: float,
    *,
    where: str = "Front",
    tolerance: float | None = None,
) -> StateComponent:
    """Create a native-frame Cartesian component constraint.

    Args:
        component: Position component ``x``, ``y``, or ``z``; or velocity
            component ``vx``, ``vy``, or ``vz``. Common spellings such as
            ``xdot`` are normalized.
        target: Component target in meters or meters per second.
        where: ``"Front"``, ``"Back"``, or ``"Path"``.
        tolerance: Optional symmetric non-negative tolerance in the
            component's units. Omitting it creates an equality.

    Returns:
        A declarative constraint applied without a coordinate conversion.
    """
    return StateComponent(
        component=component,
        target=target,
        where=where,
        tolerance=tolerance,
    )

octavian.variables

Decision-variable declarations for composable missions.

ImpulsiveDeltaV dataclass

Bases: Variable

Declare an impulsive delta-v at a phase boundary.

The composable compiler interprets this variable declaration to free the boundary velocity and add the appropriate objective terms.

Source code in octavian/variables.py
28
29
30
31
32
33
34
35
36
37
38
39
40
@dataclass(frozen=True, slots=True)
class ImpulsiveDeltaV(Variable):
    """Declare an impulsive delta-v at a phase boundary.

    The composable compiler interprets this variable declaration to free the
    boundary velocity and add the appropriate objective terms.
    """

    kind: ClassVar[str] = "impulsive_delta_v"
    where: Where = "Front"

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_where(self.where))

Variable

Marker base class for composable decision variables.

Source code in octavian/variables.py
21
22
23
24
25
class Variable:
    """Marker base class for composable decision variables."""

    kind: ClassVar[str]
    where: Where

impulsive_delta_v(*, at: str = 'Front') -> ImpulsiveDeltaV

Create an impulsive delta-v declaration.

Args: at: Boundary location, such as "Front" or "Back".

Returns: An impulsive delta-v variable declaration.

Source code in octavian/variables.py
43
44
45
46
47
48
49
50
51
52
def impulsive_delta_v(*, at: str = "Front") -> ImpulsiveDeltaV:
    """Create an impulsive delta-v declaration.

    Args:
        at: Boundary location, such as ``"Front"`` or ``"Back"``.

    Returns:
        An impulsive delta-v variable declaration.
    """
    return ImpulsiveDeltaV(where=at)

octavian.objectives

Objective declarations for missions.

MinimizeDeltaV dataclass

Bases: Objective

Minimize the sum of impulsive maneuver magnitudes.

Source code in octavian/objectives.py
20
21
22
23
24
@dataclass(frozen=True, slots=True)
class MinimizeDeltaV(Objective):
    """Minimize the sum of impulsive maneuver magnitudes."""

    kind: str = "delta_v"

MinimizePropellant dataclass

Bases: Objective

Minimize propellant consumed by finite-thrust phases.

Source code in octavian/objectives.py
34
35
36
37
38
@dataclass(frozen=True, slots=True)
class MinimizePropellant(Objective):
    """Minimize propellant consumed by finite-thrust phases."""

    kind: str = "propellant"

MinimizeTime dataclass

Bases: Objective

Minimize total time of flight.

Source code in octavian/objectives.py
27
28
29
30
31
@dataclass(frozen=True, slots=True)
class MinimizeTime(Objective):
    """Minimize total time of flight."""

    kind: str = "time"

Objective dataclass

Base class for user-facing mission objectives.

Source code in octavian/objectives.py
 8
 9
10
11
12
13
14
15
16
17
@dataclass(frozen=True, slots=True)
class Objective:
    """Base class for user-facing mission objectives."""

    kind: str
    weight: float = 1.0

    def __post_init__(self) -> None:
        if float(self.weight) < 0.0:
            raise ValueError("Objective weight must be non-negative.")

minimize_propellant(weight: float = 1.0) -> MinimizePropellant

Create a total propellant-use objective.

The compiler applies this objective once, to the final mass state in the powered phase chain. This avoids double-counting mass across multiple burn phases while remaining equivalent to maximizing final spacecraft mass.

Args: weight: Scalar weight applied by the solver backend.

Returns: A propellant objective declaration.

Source code in octavian/objectives.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def minimize_propellant(weight: float = 1.0) -> MinimizePropellant:
    """Create a total propellant-use objective.

    The compiler applies this objective once, to the final mass state in the
    powered phase chain. This avoids double-counting mass across multiple burn
    phases while remaining equivalent to maximizing final spacecraft mass.

    Args:
        weight: Scalar weight applied by the solver backend.

    Returns:
        A propellant objective declaration.
    """
    return MinimizePropellant(weight=float(weight))

minimize_total_delta_v(weight: float = 1.0) -> MinimizeDeltaV

Create a total delta-v objective.

Args: weight: Scalar weight applied by the solver backend.

Returns: A delta-v objective declaration.

Source code in octavian/objectives.py
41
42
43
44
45
46
47
48
49
50
def minimize_total_delta_v(weight: float = 1.0) -> MinimizeDeltaV:
    """Create a total delta-v objective.

    Args:
        weight: Scalar weight applied by the solver backend.

    Returns:
        A delta-v objective declaration.
    """
    return MinimizeDeltaV(weight=float(weight))

minimize_total_time(weight: float = 1.0) -> MinimizeTime

Create a total time-of-flight objective.

Args: weight: Scalar weight applied by the solver backend.

Returns: A time objective declaration.

Source code in octavian/objectives.py
53
54
55
56
57
58
59
60
61
62
def minimize_total_time(weight: float = 1.0) -> MinimizeTime:
    """Create a total time-of-flight objective.

    Args:
        weight: Scalar weight applied by the solver backend.

    Returns:
        A time objective declaration.
    """
    return MinimizeTime(weight=float(weight))

octavian.guesses

Typed initial-guess declarations for mission phases.

LowThrustSpiralGuess dataclass

Configure a near-circular tangential spiral initial guess.

Args: throttle: Constant throttle magnitude used only to integrate the seed. direction: "auto" chooses prograde for raising and retrograde for lowering. The direction may also be specified explicitly. steps_per_orbit: Minimum RK4 integration steps per initial orbit. time_scale: Multiplier on the circular-spiral burn-time estimate before it is clamped to the phase time bounds.

Source code in octavian/guesses.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@dataclass(frozen=True, slots=True)
class LowThrustSpiralGuess:
    """Configure a near-circular tangential spiral initial guess.

    Args:
        throttle: Constant throttle magnitude used only to integrate the seed.
        direction: ``"auto"`` chooses prograde for raising and retrograde for
            lowering. The direction may also be specified explicitly.
        steps_per_orbit: Minimum RK4 integration steps per initial orbit.
        time_scale: Multiplier on the circular-spiral burn-time estimate before
            it is clamped to the phase time bounds.
    """

    throttle: float = 0.8
    direction: str = "auto"
    steps_per_orbit: int = 120
    time_scale: float = 1.0

    def __post_init__(self) -> None:
        throttle = float(self.throttle)
        direction = str(self.direction).strip().lower()
        steps_per_orbit = int(self.steps_per_orbit)
        time_scale = float(self.time_scale)
        if not isfinite(throttle) or not (0.0 < throttle <= 1.0):
            raise ValueError("LowThrustSpiralGuess.throttle must be in (0, 1].")
        if direction not in ("auto", "prograde", "retrograde"):
            raise ValueError(
                "LowThrustSpiralGuess.direction must be 'auto', 'prograde', or 'retrograde'."
            )
        if steps_per_orbit < 20:
            raise ValueError("LowThrustSpiralGuess.steps_per_orbit must be at least 20.")
        if not isfinite(time_scale) or time_scale <= 0.0:
            raise ValueError("LowThrustSpiralGuess.time_scale must be positive.")
        object.__setattr__(self, "throttle", throttle)
        object.__setattr__(self, "direction", direction)
        object.__setattr__(self, "steps_per_orbit", steps_per_orbit)
        object.__setattr__(self, "time_scale", time_scale)

TrajectoryGuess dataclass

Provide an explicit dense state/time history to seed a phase.

Rows contain at least [x, y, z, vx, vy, vz, time]. Extra columns are retained for phase formulations that carry additional states or controls. Times must be strictly increasing.

Source code in octavian/guesses.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@dataclass(frozen=True, slots=True)
class TrajectoryGuess:
    """Provide an explicit dense state/time history to seed a phase.

    Rows contain at least ``[x, y, z, vx, vy, vz, time]``. Extra columns are
    retained for phase formulations that carry additional states or controls.
    Times must be strictly increasing.
    """

    rows: NDArray[np.float64]

    def __init__(self, rows: ArrayLike) -> None:
        trajectory = np.asarray(rows, dtype=float)
        if trajectory.ndim != 2 or trajectory.shape[0] < 2 or trajectory.shape[1] < 7:
            raise ValueError(
                "TrajectoryGuess rows must contain at least two "
                "[x, y, z, vx, vy, vz, time] samples."
            )
        if not np.all(np.isfinite(trajectory)):
            raise ValueError("TrajectoryGuess rows must be finite.")
        if not np.all(np.diff(trajectory[:, 6]) > 0.0):
            raise ValueError("TrajectoryGuess times must be strictly increasing.")
        object.__setattr__(self, "rows", trajectory.copy())

low_thrust_spiral(*, throttle: float = 0.8, direction: str = 'auto', steps_per_orbit: int = 120, time_scale: float = 1.0) -> LowThrustSpiralGuess

Create a tangential low-thrust spiral seed declaration.

Source code in octavian/guesses.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def low_thrust_spiral(
    *,
    throttle: float = 0.8,
    direction: str = "auto",
    steps_per_orbit: int = 120,
    time_scale: float = 1.0,
) -> LowThrustSpiralGuess:
    """Create a tangential low-thrust spiral seed declaration."""
    return LowThrustSpiralGuess(
        throttle=float(throttle),
        direction=str(direction),
        steps_per_orbit=int(steps_per_orbit),
        time_scale=float(time_scale),
    )

trajectory(rows: ArrayLike) -> TrajectoryGuess

Create an explicit dense trajectory seed declaration.

Source code in octavian/guesses.py
92
93
94
def trajectory(rows: ArrayLike) -> TrajectoryGuess:
    """Create an explicit dense trajectory seed declaration."""
    return TrajectoryGuess(rows)

Phase-linking semantics for composable missions.

Describe continuity requirements across a phase boundary.

Source code in octavian/links.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@dataclass(frozen=True, slots=True)
class Link:
    """Describe continuity requirements across a phase boundary."""

    kind: str = "continuous"
    dv_max_mps: float | None = None
    name: str = "link"

    def is_impulsive(self) -> bool:
        """Return whether the link allows a boundary velocity jump."""
        return self.kind.lower() == "impulsive"

    def is_continuous(self) -> bool:
        """Return whether the link enforces full state continuity."""
        return self.kind.lower() == "continuous"

is_continuous() -> bool

Return whether the link enforces full state continuity.

Source code in octavian/links.py
20
21
22
def is_continuous(self) -> bool:
    """Return whether the link enforces full state continuity."""
    return self.kind.lower() == "continuous"

is_impulsive() -> bool

Return whether the link allows a boundary velocity jump.

Source code in octavian/links.py
16
17
18
def is_impulsive(self) -> bool:
    """Return whether the link allows a boundary velocity jump."""
    return self.kind.lower() == "impulsive"

continuous(*, name: str = 'continuous') -> Link

Create a continuous link between phases.

Args: name: Human-readable name for the link object.

Returns: A link that enforces continuity in position, velocity, and time.

Source code in octavian/links.py
25
26
27
28
29
30
31
32
33
34
def continuous(*, name: str = "continuous") -> Link:
    """Create a continuous link between phases.

    Args:
        name: Human-readable name for the link object.

    Returns:
        A link that enforces continuity in position, velocity, and time.
    """
    return Link(kind="continuous", name=name)

impulsive(*, dv_max_mps: float | None = None, name: str = 'impulsive') -> Link

Create an impulsive link between phases.

Args: dv_max_mps: Optional delta-v cap metadata for the link. name: Human-readable name for the link object.

Returns: A link that enforces continuity in position and time while allowing a velocity jump at the boundary.

Notes: In v0.x solvers, delta-v is represented implicitly by allowing front/back boundary velocities to differ and optionally adding a delta-v objective term. If dv_max_mps is provided, current solvers treat it as soft metadata unless a solver adds an explicit hard bound.

Source code in octavian/links.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def impulsive(*, dv_max_mps: float | None = None, name: str = "impulsive") -> Link:
    """Create an impulsive link between phases.

    Args:
        dv_max_mps: Optional delta-v cap metadata for the link.
        name: Human-readable name for the link object.

    Returns:
        A link that enforces continuity in position and time while allowing a
        velocity jump at the boundary.

    Notes:
        In v0.x solvers, delta-v is represented implicitly by allowing
        front/back boundary velocities to differ and optionally adding a
        delta-v objective term. If ``dv_max_mps`` is provided, current solvers
        treat it as soft metadata unless a solver adds an explicit hard bound.
    """
    return Link(kind="impulsive", dv_max_mps=dv_max_mps, name=name)

octavian.events

Boundary-event declarations for phases.

BoundaryEvent

Marker base class for boundary events.

Source code in octavian/events.py
21
22
23
24
25
class BoundaryEvent:
    """Marker base class for boundary events."""

    kind: ClassVar[str]
    where: BoundaryWhere

Impulse dataclass

Bases: BoundaryEvent

Declare an impulsive event at a phase boundary.

Source code in octavian/events.py
28
29
30
31
32
33
34
35
36
37
@dataclass(frozen=True, slots=True)
class Impulse(BoundaryEvent):
    """Declare an impulsive event at a phase boundary."""

    kind: ClassVar[str] = "impulse"
    where: BoundaryWhere = "Front"
    dv_max_mps: float | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "where", _normalize_boundary_where(self.where))

impulse(where: str, dv_max_mps: float | None = None) -> Impulse

Create an impulsive boundary event.

Args: where: Boundary location, such as "Front" or "Back". dv_max_mps: Optional delta-v cap metadata for the event.

Returns: An impulse event declaration.

Source code in octavian/events.py
40
41
42
43
44
45
46
47
48
49
50
def impulse(where: str, dv_max_mps: float | None = None) -> Impulse:
    """Create an impulsive boundary event.

    Args:
        where: Boundary location, such as ``"Front"`` or ``"Back"``.
        dv_max_mps: Optional delta-v cap metadata for the event.

    Returns:
        An impulse event declaration.
    """
    return Impulse(where=where, dv_max_mps=dv_max_mps)

octavian.control

User-facing finite-thrust direction and kinematic-attitude configuration.

ThrustControl dataclass

Configure how a finite-thrust phase represents its direction.

vector preserves Octavian's original three-component vector-throttle control while allowing those components to be expressed in inertial or RIC axes. fixed removes direction from the decision vector and optimizes only scalar throttle along a prescribed direction. euler adds yaw-pitch-roll kinematic states and angular-rate controls; it does not model torque, inertia, or six-degree-of-freedom rotational dynamics.

Euler angles use a 3-2-1 yaw-pitch-roll convention relative to frame. The vehicle body +X axis is the thrust axis. Roll is therefore retained for attitude continuity and constraints even though it does not change the translational thrust direction.

Args: representation: "vector", "fixed", or "euler". frame: Reference axes for the direction or Euler angles. Common inertial/ECI and RIC/RTN/LVLH spellings are accepted. direction: Fixed direction components; valid only for "fixed". initial_angles_rad: Initial yaw, pitch, and roll for Euler control. max_slew_rate_radps: Maximum magnitude of the Euler-angle-rate vector. yaw_bounds_rad: Path bounds for yaw. pitch_bounds_rad: Path bounds for pitch. roll_bounds_rad: Path bounds for roll.

Source code in octavian/control.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@dataclass(frozen=True, slots=True)
class ThrustControl:
    """Configure how a finite-thrust phase represents its direction.

    ``vector`` preserves Octavian's original three-component vector-throttle
    control while allowing those components to be expressed in inertial or RIC
    axes. ``fixed`` removes direction from the decision vector and optimizes
    only scalar throttle along a prescribed direction. ``euler`` adds
    yaw-pitch-roll kinematic states and angular-rate controls; it does not model
    torque, inertia, or six-degree-of-freedom rotational dynamics.

    Euler angles use a 3-2-1 yaw-pitch-roll convention relative to ``frame``.
    The vehicle body +X axis is the thrust axis. Roll is therefore retained for
    attitude continuity and constraints even though it does not change the
    translational thrust direction.

    Args:
        representation: ``"vector"``, ``"fixed"``, or ``"euler"``.
        frame: Reference axes for the direction or Euler angles. Common
            inertial/ECI and RIC/RTN/LVLH spellings are accepted.
        direction: Fixed direction components; valid only for ``"fixed"``.
        initial_angles_rad: Initial yaw, pitch, and roll for Euler control.
        max_slew_rate_radps: Maximum magnitude of the Euler-angle-rate vector.
        yaw_bounds_rad: Path bounds for yaw.
        pitch_bounds_rad: Path bounds for pitch.
        roll_bounds_rad: Path bounds for roll.
    """

    representation: ThrustRepresentation = "vector"
    frame: DirectionFrame = "inertial"
    direction: tuple[float, float, float] | None = None
    initial_angles_rad: tuple[float, float, float] = (0.0, 0.0, 0.0)
    max_slew_rate_radps: float = math.radians(1.0)
    yaw_bounds_rad: tuple[float, float] = (-math.pi, math.pi)
    pitch_bounds_rad: tuple[float, float] = (-0.5 * math.pi, 0.5 * math.pi)
    roll_bounds_rad: tuple[float, float] = (-math.pi, math.pi)

    def __post_init__(self) -> None:
        representation = str(self.representation).strip().lower().replace("-", "_")
        if representation not in {"vector", "fixed", "euler"}:
            raise ValueError("representation must be vector, fixed, or euler")
        frame = _normalize_frame(self.frame)
        object.__setattr__(self, "representation", representation)
        object.__setattr__(self, "frame", frame)

        direction = self.direction
        if representation == "fixed":
            if direction is None:
                raise ValueError("Fixed thrust control requires direction=")
            vector = _unit_vector(direction, name="direction")
            object.__setattr__(self, "direction", tuple(float(value) for value in vector))
        elif direction is not None:
            raise ValueError("direction= is only valid for fixed thrust control")

        angles = _finite_triplet(self.initial_angles_rad, name="initial_angles_rad")
        object.__setattr__(self, "initial_angles_rad", angles)
        rate = float(self.max_slew_rate_radps)
        if not math.isfinite(rate) or rate < 0.0:
            raise ValueError("max_slew_rate_radps must be finite and non-negative")
        object.__setattr__(self, "max_slew_rate_radps", rate)
        for name in ("yaw_bounds_rad", "pitch_bounds_rad", "roll_bounds_rad"):
            bounds = _finite_bounds(getattr(self, name), name=name)
            object.__setattr__(self, name, bounds)
        yaw, pitch, roll = angles
        for name, value, bounds in (
            ("yaw", yaw, self.yaw_bounds_rad),
            ("pitch", pitch, self.pitch_bounds_rad),
            ("roll", roll, self.roll_bounds_rad),
        ):
            if not bounds[0] <= value <= bounds[1]:
                raise ValueError(f"Initial {name} angle is outside {name}_bounds_rad")

    @classmethod
    def vector(cls, *, frame: str = "inertial") -> ThrustControl:
        """Create a free three-component vector-throttle control.

        Args:
            frame: Frame in which the optimizer's vector components are
                expressed.

        Returns:
            A vector-throttle configuration.
        """
        return cls(representation="vector", frame=_normalize_frame(frame))

    @classmethod
    def fixed(
        cls,
        direction: Sequence[float],
        *,
        frame: str = "inertial",
    ) -> ThrustControl:
        """Create a prescribed direction with optimized scalar throttle.

        Args:
            direction: Non-zero direction components in ``frame``. The vector
                is normalized automatically.
            frame: Reference frame for ``direction``.

        Returns:
            A fixed-direction scalar-throttle configuration.
        """
        vector = _unit_vector(direction, name="direction")
        return cls(
            representation="fixed",
            frame=_normalize_frame(frame),
            direction=tuple(float(value) for value in vector),
        )

    @classmethod
    def euler(
        cls,
        *,
        frame: str = "inertial",
        initial_angles_rad: Sequence[float] = (0.0, 0.0, 0.0),
        max_slew_rate_radps: float = math.radians(1.0),
        yaw_bounds_rad: Sequence[float] = (-math.pi, math.pi),
        pitch_bounds_rad: Sequence[float] = (-0.5 * math.pi, 0.5 * math.pi),
        roll_bounds_rad: Sequence[float] = (-math.pi, math.pi),
    ) -> ThrustControl:
        """Create yaw-pitch-roll states with bounded angular-rate controls.

        Args:
            frame: Reference frame for the 3-2-1 Euler angles.
            initial_angles_rad: Initial yaw, pitch, and roll in radians.
            max_slew_rate_radps: Maximum Euler-rate-vector magnitude.
            yaw_bounds_rad: Lower and upper yaw path bounds.
            pitch_bounds_rad: Lower and upper pitch path bounds.
            roll_bounds_rad: Lower and upper roll path bounds.

        Returns:
            A kinematic-attitude scalar-throttle configuration.
        """
        return cls(
            representation="euler",
            frame=_normalize_frame(frame),
            initial_angles_rad=_finite_triplet(
                initial_angles_rad,
                name="initial_angles_rad",
            ),
            max_slew_rate_radps=max_slew_rate_radps,
            yaw_bounds_rad=_finite_bounds(yaw_bounds_rad, name="yaw_bounds_rad"),
            pitch_bounds_rad=_finite_bounds(pitch_bounds_rad, name="pitch_bounds_rad"),
            roll_bounds_rad=_finite_bounds(roll_bounds_rad, name="roll_bounds_rad"),
        )

    @property
    def carries_attitude(self) -> bool:
        """Return whether this representation adds Euler attitude states."""
        return self.representation == "euler"

    def to_dict(self) -> dict[str, object]:
        """Return JSON-serializable control metadata for solution reports."""
        return asdict(self)

carries_attitude: bool property

Return whether this representation adds Euler attitude states.

euler(*, frame: str = 'inertial', initial_angles_rad: Sequence[float] = (0.0, 0.0, 0.0), max_slew_rate_radps: float = math.radians(1.0), yaw_bounds_rad: Sequence[float] = (-math.pi, math.pi), pitch_bounds_rad: Sequence[float] = (-0.5 * math.pi, 0.5 * math.pi), roll_bounds_rad: Sequence[float] = (-math.pi, math.pi)) -> ThrustControl classmethod

Create yaw-pitch-roll states with bounded angular-rate controls.

Args: frame: Reference frame for the 3-2-1 Euler angles. initial_angles_rad: Initial yaw, pitch, and roll in radians. max_slew_rate_radps: Maximum Euler-rate-vector magnitude. yaw_bounds_rad: Lower and upper yaw path bounds. pitch_bounds_rad: Lower and upper pitch path bounds. roll_bounds_rad: Lower and upper roll path bounds.

Returns: A kinematic-attitude scalar-throttle configuration.

Source code in octavian/control.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
@classmethod
def euler(
    cls,
    *,
    frame: str = "inertial",
    initial_angles_rad: Sequence[float] = (0.0, 0.0, 0.0),
    max_slew_rate_radps: float = math.radians(1.0),
    yaw_bounds_rad: Sequence[float] = (-math.pi, math.pi),
    pitch_bounds_rad: Sequence[float] = (-0.5 * math.pi, 0.5 * math.pi),
    roll_bounds_rad: Sequence[float] = (-math.pi, math.pi),
) -> ThrustControl:
    """Create yaw-pitch-roll states with bounded angular-rate controls.

    Args:
        frame: Reference frame for the 3-2-1 Euler angles.
        initial_angles_rad: Initial yaw, pitch, and roll in radians.
        max_slew_rate_radps: Maximum Euler-rate-vector magnitude.
        yaw_bounds_rad: Lower and upper yaw path bounds.
        pitch_bounds_rad: Lower and upper pitch path bounds.
        roll_bounds_rad: Lower and upper roll path bounds.

    Returns:
        A kinematic-attitude scalar-throttle configuration.
    """
    return cls(
        representation="euler",
        frame=_normalize_frame(frame),
        initial_angles_rad=_finite_triplet(
            initial_angles_rad,
            name="initial_angles_rad",
        ),
        max_slew_rate_radps=max_slew_rate_radps,
        yaw_bounds_rad=_finite_bounds(yaw_bounds_rad, name="yaw_bounds_rad"),
        pitch_bounds_rad=_finite_bounds(pitch_bounds_rad, name="pitch_bounds_rad"),
        roll_bounds_rad=_finite_bounds(roll_bounds_rad, name="roll_bounds_rad"),
    )

fixed(direction: Sequence[float], *, frame: str = 'inertial') -> ThrustControl classmethod

Create a prescribed direction with optimized scalar throttle.

Args: direction: Non-zero direction components in frame. The vector is normalized automatically. frame: Reference frame for direction.

Returns: A fixed-direction scalar-throttle configuration.

Source code in octavian/control.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
@classmethod
def fixed(
    cls,
    direction: Sequence[float],
    *,
    frame: str = "inertial",
) -> ThrustControl:
    """Create a prescribed direction with optimized scalar throttle.

    Args:
        direction: Non-zero direction components in ``frame``. The vector
            is normalized automatically.
        frame: Reference frame for ``direction``.

    Returns:
        A fixed-direction scalar-throttle configuration.
    """
    vector = _unit_vector(direction, name="direction")
    return cls(
        representation="fixed",
        frame=_normalize_frame(frame),
        direction=tuple(float(value) for value in vector),
    )

to_dict() -> dict[str, object]

Return JSON-serializable control metadata for solution reports.

Source code in octavian/control.py
167
168
169
def to_dict(self) -> dict[str, object]:
    """Return JSON-serializable control metadata for solution reports."""
    return asdict(self)

vector(*, frame: str = 'inertial') -> ThrustControl classmethod

Create a free three-component vector-throttle control.

Args: frame: Frame in which the optimizer's vector components are expressed.

Returns: A vector-throttle configuration.

Source code in octavian/control.py
88
89
90
91
92
93
94
95
96
97
98
99
@classmethod
def vector(cls, *, frame: str = "inertial") -> ThrustControl:
    """Create a free three-component vector-throttle control.

    Args:
        frame: Frame in which the optimizer's vector components are
            expressed.

    Returns:
        A vector-throttle configuration.
    """
    return cls(representation="vector", frame=_normalize_frame(frame))

euler_thrust_direction(angles_rad: Sequence[float]) -> np.ndarray

Return the body +X thrust direction in the Euler reference frame.

Args: angles_rad: [yaw, pitch, roll] using the 3-2-1 convention.

Returns: Unit direction components in the configured attitude reference frame.

Source code in octavian/control.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def euler_thrust_direction(
    angles_rad: Sequence[float],
) -> np.ndarray:
    """Return the body +X thrust direction in the Euler reference frame.

    Args:
        angles_rad: ``[yaw, pitch, roll]`` using the 3-2-1 convention.

    Returns:
        Unit direction components in the configured attitude reference frame.
    """
    yaw, pitch, _roll = _finite_triplet(angles_rad, name="angles_rad")
    return np.asarray(
        [
            math.cos(pitch) * math.cos(yaw),
            math.cos(pitch) * math.sin(yaw),
            -math.sin(pitch),
        ],
        dtype=float,
    )

Models

octavian.models

High-level configuration models.

These classes exist to keep user scripts readable while still allowing advanced solving behavior (continuation / retries) via sensible defaults.

Dynamics dataclass

Environment and dynamics configuration.

model selects an alternate translational model such as CWH. frame records the meaning of Cartesian states throughout compilation and reporting. scaling optionally overrides the solver's automatic characteristic units while preserving SI inputs and outputs. cr3bp_dimensional records whether a CR3BP phase uses SI or canonical variables and is set by :meth:Dynamics.cr3bp. A spherical-harmonic field with published GM supplies mu_m3ps2 automatically, taking precedence over general central-body constants. third_body_table_margin_s extends Sun/Moon and solar-geometry tables beyond the mission's cumulative absolute upper time bound.

Source code in octavian/models.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
@dataclass(slots=True)
class Dynamics:
    """Environment and dynamics configuration.

    ``model`` selects an alternate translational model such as CWH. ``frame``
    records the meaning of Cartesian states throughout compilation and
    reporting. ``scaling`` optionally overrides the solver's automatic
    characteristic units while preserving SI inputs and outputs.
    ``cr3bp_dimensional`` records whether a CR3BP phase uses SI or canonical
    variables and is set by :meth:`Dynamics.cr3bp`.
    A spherical-harmonic field with published GM supplies ``mu_m3ps2``
    automatically, taking precedence over general central-body constants.
    ``third_body_table_margin_s`` extends Sun/Moon and solar-geometry tables
    beyond the mission's cumulative absolute upper time bound.
    """

    mu_m3ps2: float = 3.986004418e14
    central_body_radius_m: float = 6_378_136.3
    j2_coefficient: float = 1.08262668e-3
    third_body_table_step_s: float = 3600.0
    third_body_table_margin_s: float = 86400.0
    third_bodies: tuple[str, ...] = ()
    j2: bool = False
    moon: bool = False
    sun: bool = False
    srp: bool = False
    drag: bool = False
    perturbations: Perturbations | None = None
    central_body: CelestialBody | str | None = None
    model: TranslationalModel | None = None
    frame: CoordinateFrame = EARTH_INERTIAL
    scaling: SolverScaling | None = None
    cr3bp_dimensional: bool = True
    info: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.model is not None:
            self.frame = self.model.frame
            if self.scaling is None:
                self.scaling = self.model.scaling
        if self.central_body is not None:
            body = resolve_body(self.central_body)
            self.central_body = body
            self.mu_m3ps2 = body.mu_m3ps2
            self.central_body_radius_m = body.mean_radius_m
            self.j2_coefficient = body.j2_coefficient
            if self.model is None and self.frame.origin != body.name:
                self.frame = body.inertial_frame()
        gravity = getattr(self.active_perturbations(), "spherical_harmonics", None)
        if gravity is not None:
            self.mu_m3ps2 = gravity.resolve_mu(self.mu_m3ps2)

    @classmethod
    def for_body(cls, body: CelestialBody | str, **kwargs: Any) -> Dynamics:
        """Create dynamics from a built-in or custom central body.

        Body constants intentionally override raw ``mu_m3ps2``, radius, and J2
        keyword values so a named body cannot silently become inconsistent.
        """
        resolved = resolve_body(body)
        return cls(central_body=resolved, **kwargs)

    @classmethod
    def cwh(
        cls,
        *,
        chief_orbit_radius_m: float,
        central_body: CelestialBody | str = "earth",
        chief_name: str = "chief",
        reference_length_m: float = 1_000.0,
        chief_initial_state_eci: BoundaryState | None = None,
        **kwargs: Any,
    ) -> Dynamics:
        """Create circular-chief Clohessy-Wiltshire relative dynamics."""
        from .relative import ClohessyWiltshire

        body = resolve_body(central_body)
        model = ClohessyWiltshire.from_circular_orbit(
            chief_orbit_radius_m,
            body=body,
            chief_name=chief_name,
            reference_length_m=reference_length_m,
            chief_initial_state_eci=chief_initial_state_eci,
        )
        dynamics = cls(central_body=body, model=model, **kwargs)
        perturbations = dynamics.active_perturbations()
        if any(
            (
                perturbations.j2,
                getattr(perturbations, "spherical_harmonics", None) is not None,
                perturbations.srp,
                perturbations.drag,
                bool(perturbations.active_third_bodies()),
            )
        ):
            raise ValueError(
                "Dynamics.cwh is an unforced linear model. Use "
                "Dynamics.relative(...) for exact nonlinear relative motion "
                "with perturbations."
            )
        return dynamics

    @classmethod
    def relative(
        cls,
        *,
        chief_initial_state_eci: BoundaryState,
        central_body: CelestialBody | str = "earth",
        chief_name: str = "chief",
        chief_spacecraft: Spacecraft | None = None,
        reference_length_m: float = 1_000.0,
        propagation_mode: str = "coupled_eci",
        **kwargs: Any,
    ) -> Dynamics:
        """Create nonlinear or relative-element dynamics.

        Args:
            chief_initial_state_eci: Absolute chief state defining the RIC frame.
            central_body: Central body name or object.
            chief_name: Name used for the returned chief-centered frame.
            chief_spacecraft: Optional chief mass and cannonball properties.
                Omit it to model a gravity-only chief while drag or SRP acts
                on the phase spacecraft (the deputy).
            reference_length_m: Characteristic relative distance for scaling.
            propagation_mode: One of ``"coupled_eci"`` (default),
                ``"coupled_ric"``, ``"nonlinear_ric"``, ``"damico"``, or
                ``"classical_elements"``. Select ``"coupled_eci"`` for J2,
                third-body perturbations, or finite-thrust relative phases.
            **kwargs: Additional :class:`Dynamics` configuration.

        Returns:
            A dynamics configuration whose public reporting frame is RIC.
        """
        from .relative import NonlinearRelative

        body = resolve_body(central_body)
        model = NonlinearRelative(
            chief_initial_state_eci=chief_initial_state_eci,
            central_body=body,
            chief_name=chief_name,
            chief_spacecraft=chief_spacecraft,
            reference_length_m=reference_length_m,
            propagation_mode=propagation_mode,
        )
        return cls(central_body=body, model=model, **kwargs)

    @classmethod
    def cr3bp(
        cls,
        *,
        primary: CelestialBody | str = "earth",
        secondary: CelestialBody | str = "moon",
        separation_m: float = 384_400_000.0,
        dimensional: bool = True,
        **kwargs: Any,
    ) -> Dynamics:
        """Create barycentric-synodic CR3BP dynamics.

        Args:
            primary: More massive body or catalog name.
            secondary: Less massive body or catalog name.
            separation_m: Constant circular body separation.
            dimensional: Use SI states and seconds when true. Set false to
                solve directly with canonical CR3BP states and time units.
            **kwargs: Additional :class:`Dynamics` metadata. Perturbations are
                not supported by this first-order CR3BP model.

        Returns:
            Dynamics configured for the selected CR3BP units and a synodic frame.
        """
        from .cislunar import CR3BPSystem

        model = CR3BPSystem(
            primary=primary,
            secondary=secondary,
            separation_m=separation_m,
        )
        if not dimensional and "scaling" not in kwargs:
            kwargs["scaling"] = SolverScaling(
                length_m=1.0,
                velocity_mps=1.0,
                time_s=1.0,
            )
        dynamics = cls(
            model=model,
            cr3bp_dimensional=bool(dimensional),
            **kwargs,
        )
        perturbations = dynamics.active_perturbations()
        if any(
            (
                perturbations.j2,
                getattr(perturbations, "spherical_harmonics", None) is not None,
                perturbations.srp,
                perturbations.drag,
                bool(perturbations.active_third_bodies()),
            )
        ):
            raise ValueError(
                "Dynamics.cr3bp is the unperturbed circular restricted "
                "three-body model and cannot include additional perturbations."
            )
        return dynamics

    def active_perturbations(self) -> Perturbations:
        """Return normalized perturbation flags for solver compilation."""
        if self.perturbations is not None:
            return self.perturbations
        return Perturbations(
            j2=bool(self.j2),
            moon=bool(self.moon),
            sun=bool(self.sun),
            srp=bool(self.srp),
            drag=bool(self.drag),
            third_bodies=tuple(str(body) for body in self.third_bodies),
        )

active_perturbations() -> Perturbations

Return normalized perturbation flags for solver compilation.

Source code in octavian/models.py
306
307
308
309
310
311
312
313
314
315
316
317
def active_perturbations(self) -> Perturbations:
    """Return normalized perturbation flags for solver compilation."""
    if self.perturbations is not None:
        return self.perturbations
    return Perturbations(
        j2=bool(self.j2),
        moon=bool(self.moon),
        sun=bool(self.sun),
        srp=bool(self.srp),
        drag=bool(self.drag),
        third_bodies=tuple(str(body) for body in self.third_bodies),
    )

cr3bp(*, primary: CelestialBody | str = 'earth', secondary: CelestialBody | str = 'moon', separation_m: float = 384400000.0, dimensional: bool = True, **kwargs: Any) -> Dynamics classmethod

Create barycentric-synodic CR3BP dynamics.

Args: primary: More massive body or catalog name. secondary: Less massive body or catalog name. separation_m: Constant circular body separation. dimensional: Use SI states and seconds when true. Set false to solve directly with canonical CR3BP states and time units. **kwargs: Additional :class:Dynamics metadata. Perturbations are not supported by this first-order CR3BP model.

Returns: Dynamics configured for the selected CR3BP units and a synodic frame.

Source code in octavian/models.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
@classmethod
def cr3bp(
    cls,
    *,
    primary: CelestialBody | str = "earth",
    secondary: CelestialBody | str = "moon",
    separation_m: float = 384_400_000.0,
    dimensional: bool = True,
    **kwargs: Any,
) -> Dynamics:
    """Create barycentric-synodic CR3BP dynamics.

    Args:
        primary: More massive body or catalog name.
        secondary: Less massive body or catalog name.
        separation_m: Constant circular body separation.
        dimensional: Use SI states and seconds when true. Set false to
            solve directly with canonical CR3BP states and time units.
        **kwargs: Additional :class:`Dynamics` metadata. Perturbations are
            not supported by this first-order CR3BP model.

    Returns:
        Dynamics configured for the selected CR3BP units and a synodic frame.
    """
    from .cislunar import CR3BPSystem

    model = CR3BPSystem(
        primary=primary,
        secondary=secondary,
        separation_m=separation_m,
    )
    if not dimensional and "scaling" not in kwargs:
        kwargs["scaling"] = SolverScaling(
            length_m=1.0,
            velocity_mps=1.0,
            time_s=1.0,
        )
    dynamics = cls(
        model=model,
        cr3bp_dimensional=bool(dimensional),
        **kwargs,
    )
    perturbations = dynamics.active_perturbations()
    if any(
        (
            perturbations.j2,
            getattr(perturbations, "spherical_harmonics", None) is not None,
            perturbations.srp,
            perturbations.drag,
            bool(perturbations.active_third_bodies()),
        )
    ):
        raise ValueError(
            "Dynamics.cr3bp is the unperturbed circular restricted "
            "three-body model and cannot include additional perturbations."
        )
    return dynamics

cwh(*, chief_orbit_radius_m: float, central_body: CelestialBody | str = 'earth', chief_name: str = 'chief', reference_length_m: float = 1000.0, chief_initial_state_eci: BoundaryState | None = None, **kwargs: Any) -> Dynamics classmethod

Create circular-chief Clohessy-Wiltshire relative dynamics.

Source code in octavian/models.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
@classmethod
def cwh(
    cls,
    *,
    chief_orbit_radius_m: float,
    central_body: CelestialBody | str = "earth",
    chief_name: str = "chief",
    reference_length_m: float = 1_000.0,
    chief_initial_state_eci: BoundaryState | None = None,
    **kwargs: Any,
) -> Dynamics:
    """Create circular-chief Clohessy-Wiltshire relative dynamics."""
    from .relative import ClohessyWiltshire

    body = resolve_body(central_body)
    model = ClohessyWiltshire.from_circular_orbit(
        chief_orbit_radius_m,
        body=body,
        chief_name=chief_name,
        reference_length_m=reference_length_m,
        chief_initial_state_eci=chief_initial_state_eci,
    )
    dynamics = cls(central_body=body, model=model, **kwargs)
    perturbations = dynamics.active_perturbations()
    if any(
        (
            perturbations.j2,
            getattr(perturbations, "spherical_harmonics", None) is not None,
            perturbations.srp,
            perturbations.drag,
            bool(perturbations.active_third_bodies()),
        )
    ):
        raise ValueError(
            "Dynamics.cwh is an unforced linear model. Use "
            "Dynamics.relative(...) for exact nonlinear relative motion "
            "with perturbations."
        )
    return dynamics

for_body(body: CelestialBody | str, **kwargs: Any) -> Dynamics classmethod

Create dynamics from a built-in or custom central body.

Body constants intentionally override raw mu_m3ps2, radius, and J2 keyword values so a named body cannot silently become inconsistent.

Source code in octavian/models.py
154
155
156
157
158
159
160
161
162
@classmethod
def for_body(cls, body: CelestialBody | str, **kwargs: Any) -> Dynamics:
    """Create dynamics from a built-in or custom central body.

    Body constants intentionally override raw ``mu_m3ps2``, radius, and J2
    keyword values so a named body cannot silently become inconsistent.
    """
    resolved = resolve_body(body)
    return cls(central_body=resolved, **kwargs)

relative(*, chief_initial_state_eci: BoundaryState, central_body: CelestialBody | str = 'earth', chief_name: str = 'chief', chief_spacecraft: Spacecraft | None = None, reference_length_m: float = 1000.0, propagation_mode: str = 'coupled_eci', **kwargs: Any) -> Dynamics classmethod

Create nonlinear or relative-element dynamics.

Args: chief_initial_state_eci: Absolute chief state defining the RIC frame. central_body: Central body name or object. chief_name: Name used for the returned chief-centered frame. chief_spacecraft: Optional chief mass and cannonball properties. Omit it to model a gravity-only chief while drag or SRP acts on the phase spacecraft (the deputy). reference_length_m: Characteristic relative distance for scaling. propagation_mode: One of "coupled_eci" (default), "coupled_ric", "nonlinear_ric", "damico", or "classical_elements". Select "coupled_eci" for J2, third-body perturbations, or finite-thrust relative phases. **kwargs: Additional :class:Dynamics configuration.

Returns: A dynamics configuration whose public reporting frame is RIC.

Source code in octavian/models.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@classmethod
def relative(
    cls,
    *,
    chief_initial_state_eci: BoundaryState,
    central_body: CelestialBody | str = "earth",
    chief_name: str = "chief",
    chief_spacecraft: Spacecraft | None = None,
    reference_length_m: float = 1_000.0,
    propagation_mode: str = "coupled_eci",
    **kwargs: Any,
) -> Dynamics:
    """Create nonlinear or relative-element dynamics.

    Args:
        chief_initial_state_eci: Absolute chief state defining the RIC frame.
        central_body: Central body name or object.
        chief_name: Name used for the returned chief-centered frame.
        chief_spacecraft: Optional chief mass and cannonball properties.
            Omit it to model a gravity-only chief while drag or SRP acts
            on the phase spacecraft (the deputy).
        reference_length_m: Characteristic relative distance for scaling.
        propagation_mode: One of ``"coupled_eci"`` (default),
            ``"coupled_ric"``, ``"nonlinear_ric"``, ``"damico"``, or
            ``"classical_elements"``. Select ``"coupled_eci"`` for J2,
            third-body perturbations, or finite-thrust relative phases.
        **kwargs: Additional :class:`Dynamics` configuration.

    Returns:
        A dynamics configuration whose public reporting frame is RIC.
    """
    from .relative import NonlinearRelative

    body = resolve_body(central_body)
    model = NonlinearRelative(
        chief_initial_state_eci=chief_initial_state_eci,
        central_body=body,
        chief_name=chief_name,
        chief_spacecraft=chief_spacecraft,
        reference_length_m=reference_length_m,
        propagation_mode=propagation_mode,
    )
    return cls(central_body=body, model=model, **kwargs)

Perturbations dataclass

Perturbation flags for translational dynamics.

The composable ASSET backend supports the core Earth-orbit perturbations: J2 or a spherical-harmonic field, lunar and solar third-body gravity, exponential-atmosphere cannonball drag, and cannonball solar radiation pressure. moon and sun are convenience flags; third_bodies=("moon", "sun") remains accepted for scripts that prefer a body list.

Args: j2: Enable central-body J2. spherical_harmonics: Fully normalized gravity field; mutually exclusive with j2. moon: Enable lunar third-body gravity. sun: Enable solar third-body gravity. srp: Enable cannonball solar radiation pressure. This does not implicitly enable solar third-body gravity. drag: Enable exponential-atmosphere cannonball drag. third_bodies: Additional normalized third-body gravity names. atmosphere: Atmosphere used by drag. None selects Octavian's simple Earth model for Earth-centered dynamics; non-Earth central bodies must supply a model explicitly. solar_pressure_at_1au_Npm2: Radiation pressure at one astronomical unit.

Source code in octavian/models.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@dataclass(frozen=True, slots=True)
class Perturbations:
    """Perturbation flags for translational dynamics.

    The composable ASSET backend supports the core Earth-orbit perturbations:
    J2 or a spherical-harmonic field, lunar and solar third-body gravity,
    exponential-atmosphere cannonball
    drag, and cannonball solar radiation pressure. ``moon`` and ``sun`` are
    convenience flags; ``third_bodies=("moon", "sun")`` remains accepted for
    scripts that prefer a body list.

    Args:
        j2: Enable central-body J2.
        spherical_harmonics: Fully normalized gravity field; mutually exclusive with j2.
        moon: Enable lunar third-body gravity.
        sun: Enable solar third-body gravity.
        srp: Enable cannonball solar radiation pressure. This does not
            implicitly enable solar third-body gravity.
        drag: Enable exponential-atmosphere cannonball drag.
        third_bodies: Additional normalized third-body gravity names.
        atmosphere: Atmosphere used by drag. ``None`` selects Octavian's
            simple Earth model for Earth-centered dynamics; non-Earth central
            bodies must supply a model explicitly.
        solar_pressure_at_1au_Npm2: Radiation pressure at one astronomical unit.
    """

    j2: bool = False
    moon: bool = False
    sun: bool = False
    srp: bool = False
    drag: bool = False
    third_bodies: tuple[str, ...] = ()
    atmosphere: ExponentialAtmosphere | None = None
    solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2
    spherical_harmonics: SphericalHarmonics | None = None

    def __post_init__(self) -> None:
        if self.spherical_harmonics is not None:
            if not isinstance(self.spherical_harmonics, SphericalHarmonics):
                raise TypeError("spherical_harmonics must be a SphericalHarmonics model")
            if self.j2:
                raise ValueError(
                    "Choose spherical_harmonics or j2; a harmonic field already includes C20"
                )
        pressure = float(self.solar_pressure_at_1au_Npm2)
        if not math.isfinite(pressure) or pressure <= 0.0:
            raise ValueError("solar_pressure_at_1au_Npm2 must be finite and positive")
        object.__setattr__(self, "solar_pressure_at_1au_Npm2", pressure)

    def active_third_bodies(self) -> tuple[str, ...]:
        """Return normalized third-body names requested by this config."""
        names: list[str] = []
        if self.moon:
            names.append("moon")
        if self.sun:
            names.append("sun")
        for body in self.third_bodies:
            normalized = str(body).strip().lower().replace("-", "_")
            if normalized in {"luna", "earth_moon"}:
                normalized = "moon"
            if normalized not in names:
                names.append(normalized)
        return tuple(names)

active_third_bodies() -> tuple[str, ...]

Return normalized third-body names requested by this config.

Source code in octavian/models.py
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def active_third_bodies(self) -> tuple[str, ...]:
    """Return normalized third-body names requested by this config."""
    names: list[str] = []
    if self.moon:
        names.append("moon")
    if self.sun:
        names.append("sun")
    for body in self.third_bodies:
        normalized = str(body).strip().lower().replace("-", "_")
        if normalized in {"luna", "earth_moon"}:
            normalized = "moon"
        if normalized not in names:
            names.append(normalized)
    return tuple(names)

RetryPolicy dataclass

Retry behavior when a solve fails.

Source code in octavian/models.py
350
351
352
353
354
355
356
357
358
359
360
@dataclass(slots=True)
class RetryPolicy:
    """Retry behavior when a solve fails."""

    enabled: bool = True
    max_retries: int = 2

    @staticmethod
    def default() -> RetryPolicy:
        """Return the default enabled retry policy."""
        return RetryPolicy(enabled=True, max_retries=2)

default() -> RetryPolicy staticmethod

Return the default enabled retry policy.

Source code in octavian/models.py
357
358
359
360
@staticmethod
def default() -> RetryPolicy:
    """Return the default enabled retry policy."""
    return RetryPolicy(enabled=True, max_retries=2)

RunPlan dataclass

Continuation / crawl-walk-run plan.

Source code in octavian/models.py
338
339
340
341
342
343
344
345
346
347
@dataclass(slots=True)
class RunPlan:
    """Continuation / crawl-walk-run plan."""

    stages: Sequence[Stage] = ()

    @staticmethod
    def default() -> RunPlan:
        """Return an empty continuation plan."""
        return RunPlan(stages=())

default() -> RunPlan staticmethod

Return an empty continuation plan.

Source code in octavian/models.py
344
345
346
347
@staticmethod
def default() -> RunPlan:
    """Return an empty continuation plan."""
    return RunPlan(stages=())

SolveConfig dataclass

Runner behavior settings.

Source code in octavian/models.py
320
321
322
323
324
325
326
@dataclass(slots=True)
class SolveConfig:
    """Runner behavior settings."""

    max_attempts: int = 3
    raise_on_fail: bool = True
    verbose: bool = True

Stage dataclass

A single continuation stage (minimal in v0.x).

Source code in octavian/models.py
329
330
331
332
333
334
335
@dataclass(slots=True)
class Stage:
    """A single continuation stage (minimal in v0.x)."""

    name: str
    nsegs_scale: float | None = None
    tighten_bounds: bool = False

TranslationalModel

Bases: Protocol

Configuration contract for non-default translational dynamics models.

Source code in octavian/models.py
23
24
25
26
27
28
29
30
31
32
33
34
class TranslationalModel(Protocol):
    """Configuration contract for non-default translational dynamics models."""

    @property
    def frame(self) -> CoordinateFrame:
        """Return the coordinate frame used by the model."""
        ...

    @property
    def scaling(self) -> SolverScaling:
        """Return the model's recommended solver scaling."""
        ...

frame: CoordinateFrame property

Return the coordinate frame used by the model.

scaling: SolverScaling property

Return the model's recommended solver scaling.

octavian.gravity

Fully normalized spherical-harmonic gravity in a uniformly rotating body frame.

Coefficients use geodetic normalization, without the Condon--Shortley phase: Pbar_nm = sqrt((2-delta_m0)(2n+1)*(n-m)!/(n+m)!) * P_nm. Only degrees 2 and above are perturbations; central gravity is supplied by the EOM.

SphericalHarmonics dataclass

A central body's static, fully normalized gravity coefficients.

cosine[n][m] and sine[n][m] are dimensionless Cbar/Sbar arrays. Supply square arrays, including zero entries above the diagonal. C00 may be 0 or 1 and is ignored; degree one must be zero (center-of-mass origin). For J2 alone, Cbar20 = -J2 / sqrt(5).

The body rotates about the inertial +Z axis. Its prime-meridian angle is reference_angle_rad + rotation_rate_radps * (time_s-reference_time_s). Times are mission-relative seconds, not UTC or SPICE epochs. This simple model does not include precession, nutation, polar motion, or tidal changes. The coefficient reference radius is independent of the body's mean radius; a field with a published GM supplies that constant to both central and harmonic gravity. Custom fields without GM use the EOM parameter.

backend='python' builds ASSET expressions using the same recurrence as numerical evaluation. backend='cpp' (the default) uses the bundled native extension with analytic derivatives, without Python callbacks during integration. Degree/order default to the full supplied array. Use SphericalHarmonics.earth() to load the bundled measured EGM2008 field through 200x200.

Source code in octavian/gravity.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
@dataclass(frozen=True)
class SphericalHarmonics:
    """A central body's static, fully normalized gravity coefficients.

    ``cosine[n][m]`` and ``sine[n][m]`` are dimensionless Cbar/Sbar arrays.
    Supply square arrays, including zero entries above the diagonal. C00 may
    be 0 or 1 and is ignored; degree one must be zero (center-of-mass origin).
    For J2 alone, Cbar20 = -J2 / sqrt(5).

    The body rotates about the inertial +Z axis. Its prime-meridian angle is
    ``reference_angle_rad + rotation_rate_radps * (time_s-reference_time_s)``.
    Times are mission-relative seconds, not UTC or SPICE epochs. This simple
    model does not include precession, nutation, polar motion, or tidal changes.
    The coefficient reference radius is independent of the body's mean radius;
    a field with a published GM supplies that constant to both central and
    harmonic gravity. Custom fields without GM use the EOM parameter.

    ``backend='python'`` builds ASSET expressions using the same recurrence as
    numerical evaluation. ``backend='cpp'`` (the default) uses the bundled native extension
    with analytic derivatives, without Python callbacks during integration.
    Degree/order default to the full supplied array. Use ``SphericalHarmonics.earth()``
    to load the bundled measured EGM2008 field through 200x200.
    """

    cosine: tuple[tuple[float, ...], ...]
    sine: tuple[tuple[float, ...], ...]
    reference_radius_m: float
    degree: int | None = None
    order: int | None = None
    rotation_rate_radps: float = 0.0
    reference_angle_rad: float = 0.0
    reference_time_s: float = 0.0
    backend: str = "cpp"

    reference_mu_m3ps2: float | None = None
    model_name: str | None = None
    tide_system: str | None = None

    @classmethod
    def earth(
        cls,
        *,
        degree: int = 200,
        order: int | None = None,
        backend: str = "cpp",
        rotation_rate_radps: float = 7.292115e-5,
        reference_angle_rad: float = 0.0,
        reference_time_s: float = 0.0,
    ):
        """Load NGA EGM2008 (tide-free), offline, truncated to at most 200x200.

        No coefficient calculations or downloads are needed. Central and harmonic
        gravity automatically use the model's published GM. Set the prime-meridian angle at the mission
        epoch explicitly; rotation is uniform about Z, not a full ITRF model.
        """
        from ._earth_gravity import earth_coefficients

        cosine, sine, metadata = earth_coefficients(degree, order)
        return cls(
            cosine,
            sine,
            metadata["reference_radius_m"],
            degree=degree,
            order=order,
            backend=backend,
            rotation_rate_radps=rotation_rate_radps,
            reference_angle_rad=reference_angle_rad,
            reference_time_s=reference_time_s,
            reference_mu_m3ps2=metadata["mu_m3ps2"],
            model_name=metadata["model"],
            tide_system=metadata["tide_system"],
        )

    def __post_init__(self):
        c, s = np.asarray(self.cosine, dtype=float), np.asarray(self.sine, dtype=float)
        if c.ndim != 2 or c.shape[0] != c.shape[1] or c.shape[0] < 3 or s.shape != c.shape:
            raise ValueError("cosine and sine must be matching square arrays of size at least 3")
        if not np.isfinite(c).all() or not np.isfinite(s).all():
            raise ValueError("harmonic coefficients must be finite")
        if np.any(np.triu(c, 1)) or np.any(np.triu(s, 1)) or np.any(s[:, 0]):
            raise ValueError("coefficients above the diagonal and sine[:, 0] must be zero")
        if c[0, 0] not in (0, 1) or np.any(c[1]) or np.any(s[1]):
            raise ValueError("C00 must be 0 or 1 and degree-one coefficients must be zero")
        degree = c.shape[0] - 1 if self.degree is None else self.degree
        order = degree if self.order is None else self.order
        if (
            isinstance(degree, bool)
            or not isinstance(degree, (int, np.integer))
            or not 2 <= degree < c.shape[0]
        ):
            raise ValueError("degree must be an integer from 2 to the supplied maximum degree")
        if (
            isinstance(order, bool)
            or not isinstance(order, (int, np.integer))
            or not 0 <= order <= degree
        ):
            raise ValueError("order must be an integer from 0 to degree")
        for name in (
            "reference_radius_m",
            "rotation_rate_radps",
            "reference_angle_rad",
            "reference_time_s",
        ):
            value = float(getattr(self, name))
            if not math.isfinite(value) or (name == "reference_radius_m" and value <= 0):
                raise ValueError(
                    f"{name} must be finite"
                    + (" and positive" if name == "reference_radius_m" else "")
                )
            object.__setattr__(self, name, value)
        if self.reference_mu_m3ps2 is not None:
            value = float(self.reference_mu_m3ps2)
            if not math.isfinite(value) or value <= 0:
                raise ValueError("reference_mu_m3ps2 must be finite and positive")
            object.__setattr__(self, "reference_mu_m3ps2", value)
        if self.backend not in ("python", "cpp"):
            raise ValueError("backend must be 'python' or 'cpp'")
        object.__setattr__(self, "cosine", tuple(tuple(row) for row in c))
        object.__setattr__(self, "sine", tuple(tuple(row) for row in s))
        object.__setattr__(self, "degree", int(degree))
        object.__setattr__(self, "order", int(order))

    def resolve_mu(self, mu_m3ps2: float | None = None) -> float:
        """Use the field's published GM, or the supplied GM for a custom field.

        Published coefficients and GM define one gravity model. A model GM
        takes precedence over general central-body defaults and EOM arguments.
        """
        value = self.reference_mu_m3ps2 if self.reference_mu_m3ps2 is not None else mu_m3ps2
        if value is None or not math.isfinite(value) or value <= 0:
            raise ValueError(
                "A custom gravity field without reference_mu_m3ps2 requires a positive mu_m3ps2"
            )
        return float(value)

    @cached_property
    def _native_function(self):
        from ._asset import require_asset

        require_asset("compiled spherical harmonics")
        try:
            from importlib import import_module

            try:
                native = import_module("octavian.octavian_harmonics_native")
            except ModuleNotFoundError as exc:
                if exc.name != "octavian.octavian_harmonics_native":
                    raise
                native = import_module("octavian_harmonics_native")
        except ImportError as exc:
            raise RuntimeError(
                "backend='cpp' requires Octavian's compiled spherical-harmonic extension. "
                "Install a supported Octavian wheel, or reinstall the checkout with "
                "pip install -e . after configuring the native build prerequisites. "
                "For an explicit OCTAVIAN_BUILD_NATIVE=0 installation, select backend='python'. "
                "See docs/tutorials/spherical-harmonics.md for build instructions."
            ) from exc
        return native.acceleration_function(self.cosine, self.sine, self.degree, self.order)

    def acceleration(self, position_m, *, mu_m3ps2: float | None = None, time_s: float = 0.0):
        """Return perturbing acceleration in m/s², using the field GM when available."""
        mu_m3ps2 = self.resolve_mu(mu_m3ps2)
        position = np.asarray(position_m, dtype=float)
        if (
            position.shape != (3,)
            or not np.isfinite(position).all()
            or np.linalg.norm(position) == 0
        ):
            raise ValueError("position_m must contain three finite values with nonzero norm")
        if not math.isfinite(mu_m3ps2) or mu_m3ps2 <= 0 or not math.isfinite(time_s):
            raise ValueError("mu_m3ps2 must be finite and positive and time_s must be finite")
        angle = self.reference_angle_rad + self.rotation_rate_radps * (
            time_s - self.reference_time_s
        )
        c, s = math.cos(angle), math.sin(angle)
        x, y, z = position / self.reference_radius_m
        body = (c * x + s * y, -s * x + c * y, z)
        if self.backend == "cpp":
            ax, ay, az = self._native_function.compute(np.asarray(body))
        else:
            ax, ay, az = _potential_gradient(body, self.cosine, self.sine, self.degree, self.order)
        return (
            mu_m3ps2
            / self.reference_radius_m**2
            * np.asarray((c * ax - s * ay, s * ax + c * ay, az))
        )

    def asset_acceleration(self, position, time, *, mu_m3ps2: float | None = None):
        """Compose an ASSET inertial acceleration, including rotation derivatives."""
        from ._asset import require_asset, vf

        require_asset("spherical-harmonic EOMs")
        mu_m3ps2 = self.resolve_mu(mu_m3ps2)
        if time is None:
            if self.rotation_rate_radps:
                raise ValueError("Rotating spherical harmonics require an ASSET time variable")
            time = 0.0
        angle = self.reference_angle_rad + self.rotation_rate_radps * (time - self.reference_time_s)
        c, s = (
            (math.cos(angle), math.sin(angle))
            if isinstance(angle, (int, float))
            else (angle.cos(), angle.sin())
        )
        r = position / self.reference_radius_m
        x, y, z = r[0], r[1], r[2]
        body = (c * x + s * y, -s * x + c * y, z)
        if self.backend == "cpp":
            acc = self._native_function(vf.stack(list(body)))
            ax, ay, az = acc[0], acc[1], acc[2]
        else:
            ax, ay, az = _potential_gradient(body, self.cosine, self.sine, self.degree, self.order)
        # Promote zero components to scalar expressions for empty/truncated fields.
        zero = 0.0 * x
        return (
            mu_m3ps2
            / self.reference_radius_m**2
            * vf.stack([c * ax - s * ay + zero, s * ax + c * ay + zero, az + zero])
        )

acceleration(position_m, *, mu_m3ps2: float | None = None, time_s: float = 0.0)

Return perturbing acceleration in m/s², using the field GM when available.

Source code in octavian/gravity.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def acceleration(self, position_m, *, mu_m3ps2: float | None = None, time_s: float = 0.0):
    """Return perturbing acceleration in m/s², using the field GM when available."""
    mu_m3ps2 = self.resolve_mu(mu_m3ps2)
    position = np.asarray(position_m, dtype=float)
    if (
        position.shape != (3,)
        or not np.isfinite(position).all()
        or np.linalg.norm(position) == 0
    ):
        raise ValueError("position_m must contain three finite values with nonzero norm")
    if not math.isfinite(mu_m3ps2) or mu_m3ps2 <= 0 or not math.isfinite(time_s):
        raise ValueError("mu_m3ps2 must be finite and positive and time_s must be finite")
    angle = self.reference_angle_rad + self.rotation_rate_radps * (
        time_s - self.reference_time_s
    )
    c, s = math.cos(angle), math.sin(angle)
    x, y, z = position / self.reference_radius_m
    body = (c * x + s * y, -s * x + c * y, z)
    if self.backend == "cpp":
        ax, ay, az = self._native_function.compute(np.asarray(body))
    else:
        ax, ay, az = _potential_gradient(body, self.cosine, self.sine, self.degree, self.order)
    return (
        mu_m3ps2
        / self.reference_radius_m**2
        * np.asarray((c * ax - s * ay, s * ax + c * ay, az))
    )

asset_acceleration(position, time, *, mu_m3ps2: float | None = None)

Compose an ASSET inertial acceleration, including rotation derivatives.

Source code in octavian/gravity.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def asset_acceleration(self, position, time, *, mu_m3ps2: float | None = None):
    """Compose an ASSET inertial acceleration, including rotation derivatives."""
    from ._asset import require_asset, vf

    require_asset("spherical-harmonic EOMs")
    mu_m3ps2 = self.resolve_mu(mu_m3ps2)
    if time is None:
        if self.rotation_rate_radps:
            raise ValueError("Rotating spherical harmonics require an ASSET time variable")
        time = 0.0
    angle = self.reference_angle_rad + self.rotation_rate_radps * (time - self.reference_time_s)
    c, s = (
        (math.cos(angle), math.sin(angle))
        if isinstance(angle, (int, float))
        else (angle.cos(), angle.sin())
    )
    r = position / self.reference_radius_m
    x, y, z = r[0], r[1], r[2]
    body = (c * x + s * y, -s * x + c * y, z)
    if self.backend == "cpp":
        acc = self._native_function(vf.stack(list(body)))
        ax, ay, az = acc[0], acc[1], acc[2]
    else:
        ax, ay, az = _potential_gradient(body, self.cosine, self.sine, self.degree, self.order)
    # Promote zero components to scalar expressions for empty/truncated fields.
    zero = 0.0 * x
    return (
        mu_m3ps2
        / self.reference_radius_m**2
        * vf.stack([c * ax - s * ay + zero, s * ax + c * ay + zero, az + zero])
    )

earth(*, degree: int = 200, order: int | None = None, backend: str = 'cpp', rotation_rate_radps: float = 7.292115e-05, reference_angle_rad: float = 0.0, reference_time_s: float = 0.0) classmethod

Load NGA EGM2008 (tide-free), offline, truncated to at most 200x200.

No coefficient calculations or downloads are needed. Central and harmonic gravity automatically use the model's published GM. Set the prime-meridian angle at the mission epoch explicitly; rotation is uniform about Z, not a full ITRF model.

Source code in octavian/gravity.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@classmethod
def earth(
    cls,
    *,
    degree: int = 200,
    order: int | None = None,
    backend: str = "cpp",
    rotation_rate_radps: float = 7.292115e-5,
    reference_angle_rad: float = 0.0,
    reference_time_s: float = 0.0,
):
    """Load NGA EGM2008 (tide-free), offline, truncated to at most 200x200.

    No coefficient calculations or downloads are needed. Central and harmonic
    gravity automatically use the model's published GM. Set the prime-meridian angle at the mission
    epoch explicitly; rotation is uniform about Z, not a full ITRF model.
    """
    from ._earth_gravity import earth_coefficients

    cosine, sine, metadata = earth_coefficients(degree, order)
    return cls(
        cosine,
        sine,
        metadata["reference_radius_m"],
        degree=degree,
        order=order,
        backend=backend,
        rotation_rate_radps=rotation_rate_radps,
        reference_angle_rad=reference_angle_rad,
        reference_time_s=reference_time_s,
        reference_mu_m3ps2=metadata["mu_m3ps2"],
        model_name=metadata["model"],
        tide_system=metadata["tide_system"],
    )

resolve_mu(mu_m3ps2: float | None = None) -> float

Use the field's published GM, or the supplied GM for a custom field.

Published coefficients and GM define one gravity model. A model GM takes precedence over general central-body defaults and EOM arguments.

Source code in octavian/gravity.py
139
140
141
142
143
144
145
146
147
148
149
150
def resolve_mu(self, mu_m3ps2: float | None = None) -> float:
    """Use the field's published GM, or the supplied GM for a custom field.

    Published coefficients and GM define one gravity model. A model GM
    takes precedence over general central-body defaults and EOM arguments.
    """
    value = self.reference_mu_m3ps2 if self.reference_mu_m3ps2 is not None else mu_m3ps2
    if value is None or not math.isfinite(value) or value <= 0:
        raise ValueError(
            "A custom gravity field without reference_mu_m3ps2 requires a positive mu_m3ps2"
        )
    return float(value)

octavian.bodies.catalog

Small, explicit catalog of central-body physical constants.

CelestialBody dataclass

Physical constants and naming metadata for a central body.

Source code in octavian/bodies/catalog.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@dataclass(frozen=True, slots=True)
class CelestialBody:
    """Physical constants and naming metadata for a central body."""

    name: str
    mu_m3ps2: float
    mean_radius_m: float
    j2_coefficient: float = 0.0
    aliases: tuple[str, ...] = ()
    inertial_orientation: str = "ICRF"

    def __post_init__(self) -> None:
        normalized_name = _normalize_name(self.name)
        if not normalized_name:
            raise ValueError("CelestialBody.name must not be empty")
        object.__setattr__(self, "name", normalized_name)
        if float(self.mu_m3ps2) <= 0.0:
            raise ValueError("CelestialBody.mu_m3ps2 must be positive")
        if float(self.mean_radius_m) <= 0.0:
            raise ValueError("CelestialBody.mean_radius_m must be positive")
        if float(self.j2_coefficient) < 0.0:
            raise ValueError("CelestialBody.j2_coefficient must be non-negative")
        object.__setattr__(self, "mu_m3ps2", float(self.mu_m3ps2))
        object.__setattr__(self, "mean_radius_m", float(self.mean_radius_m))
        object.__setattr__(self, "j2_coefficient", float(self.j2_coefficient))
        object.__setattr__(
            self,
            "aliases",
            tuple(_normalize_name(alias) for alias in self.aliases),
        )

    def inertial_frame(self, *, orientation: str | None = None) -> CoordinateFrame:
        """Return an inertial frame centered on this body."""
        return inertial(
            self.name,
            orientation=orientation or self.inertial_orientation,
            name=f"{self.name}_inertial",
        )

inertial_frame(*, orientation: str | None = None) -> CoordinateFrame

Return an inertial frame centered on this body.

Source code in octavian/bodies/catalog.py
47
48
49
50
51
52
53
def inertial_frame(self, *, orientation: str | None = None) -> CoordinateFrame:
    """Return an inertial frame centered on this body."""
    return inertial(
        self.name,
        orientation=orientation or self.inertial_orientation,
        name=f"{self.name}_inertial",
    )

resolve(body: CelestialBody | str) -> CelestialBody

Resolve a body object or case-insensitive catalog name.

Source code in octavian/bodies/catalog.py
 94
 95
 96
 97
 98
 99
100
101
102
103
def resolve(body: CelestialBody | str) -> CelestialBody:
    """Resolve a body object or case-insensitive catalog name."""
    if isinstance(body, CelestialBody):
        return body
    normalized = _normalize_name(body)
    try:
        return CATALOG[normalized]
    except KeyError as exc:
        available = ", ".join(sorted({item.name for item in CATALOG.values()}))
        raise KeyError(f"Unknown celestial body {body!r}. Available bodies: {available}") from exc

octavian.spacecraft

Spacecraft and propulsion models.

These objects are intentionally "config-like": they are meant to read well in Python scripts and to remain stable even as solver backends evolve.

Spacecraft dataclass

Spacecraft mass, propulsion, and simple force-model properties.

Args: name: Human-readable spacecraft name. dry_mass_kg: Dry mass in kilograms. thrusters: Available propulsion models. info: Free-form user metadata. cannonball: Constant projected areas and coefficients used when drag or solar radiation pressure is enabled.

Source code in octavian/spacecraft.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@dataclass(slots=True)
class Spacecraft:
    """Spacecraft mass, propulsion, and simple force-model properties.

    Args:
        name: Human-readable spacecraft name.
        dry_mass_kg: Dry mass in kilograms.
        thrusters: Available propulsion models.
        info: Free-form user metadata.
        cannonball: Constant projected areas and coefficients used when drag
            or solar radiation pressure is enabled.
    """

    name: str = "SC"
    dry_mass_kg: float = 0.0
    thrusters: list[Thruster] = field(default_factory=list)
    info: dict[str, Any] = field(default_factory=dict)
    cannonball: Cannonball = field(default_factory=Cannonball)

    @property
    def initial_mass_kg(self) -> float:
        """Return dry mass plus configured propellant masses.

        Returns
        -------
        float
            Initial spacecraft mass in kilograms.
        """
        prop = 0.0
        for t in self.thrusters:
            if t.propellant_mass_kg is not None:
                prop += float(t.propellant_mass_kg)
        return float(self.dry_mass_kg) + prop

    def get_thruster(self, name: str) -> Thruster | None:
        """Return a named thruster if it exists.

        Parameters
        ----------
        name
            Thruster name to look up.

        Returns
        -------
        Thruster or None
            Matching thruster, or ``None`` when no thruster has that name.
        """
        for t in self.thrusters:
            if t.name == name:
                return t
        return None

    def thruster(self, name: str) -> Thruster:
        """Return a named thruster.

        Parameters
        ----------
        name
            Thruster name to look up.

        Returns
        -------
        Thruster
            Matching thruster.

        Raises
        ------
        KeyError
            If no thruster has the requested name.
        """
        for t in self.thrusters:
            if t.name == name:
                return t
        raise KeyError(f"No thruster named {name!r} on spacecraft {self.name!r}")

initial_mass_kg: float property

Return dry mass plus configured propellant masses.

Returns:
  • float –

    Initial spacecraft mass in kilograms.

get_thruster(name: str) -> Thruster | None

Return a named thruster if it exists.

Parameters:
  • name (str) –

    Thruster name to look up.

Returns:
  • Thruster or None –

    Matching thruster, or None when no thruster has that name.

Source code in octavian/spacecraft.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def get_thruster(self, name: str) -> Thruster | None:
    """Return a named thruster if it exists.

    Parameters
    ----------
    name
        Thruster name to look up.

    Returns
    -------
    Thruster or None
        Matching thruster, or ``None`` when no thruster has that name.
    """
    for t in self.thrusters:
        if t.name == name:
            return t
    return None

thruster(name: str) -> Thruster

Return a named thruster.

Parameters:
  • name (str) –

    Thruster name to look up.

Returns:
Raises:
  • KeyError –

    If no thruster has the requested name.

Source code in octavian/spacecraft.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def thruster(self, name: str) -> Thruster:
    """Return a named thruster.

    Parameters
    ----------
    name
        Thruster name to look up.

    Returns
    -------
    Thruster
        Matching thruster.

    Raises
    ------
    KeyError
        If no thruster has the requested name.
    """
    for t in self.thrusters:
        if t.name == name:
            return t
    raise KeyError(f"No thruster named {name!r} on spacecraft {self.name!r}")

Thruster dataclass

Simple thruster model (data-only).

Source code in octavian/spacecraft.py
15
16
17
18
19
20
21
22
23
@dataclass(slots=True)
class Thruster:
    """Simple thruster model (data-only)."""

    name: str = "main"
    thrust_N: float = 0.0
    isp_s: float = 0.0
    propellant_mass_kg: float | None = None
    info: dict[str, Any] = field(default_factory=dict)

octavian.forces

Simple non-gravitational force models.

The models in this module are deliberately small and explicit. They provide useful first-order drag and solar-radiation-pressure behavior without implying high-fidelity atmosphere, geometry, shadowing, or attitude modeling.

Cannonball dataclass

Spacecraft properties for simple drag and SRP acceleration.

Areas are projected constant areas. They do not vary with attitude. A zero area produces no corresponding acceleration. Composable phases require a positive area on their primary spacecraft when that force is enabled, while an optional relative chief may intentionally keep zero area.

Args: drag_area_m2: Constant projected area used by atmospheric drag. drag_coefficient: Dimensionless drag coefficient. srp_area_m2: Constant projected area used by solar radiation pressure. reflectivity_coefficient: Dimensionless SRP coefficient, commonly called Cr.

Source code in octavian/forces.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@dataclass(frozen=True, slots=True)
class Cannonball:
    """Spacecraft properties for simple drag and SRP acceleration.

    Areas are projected constant areas.  They do not vary with attitude.  A
    zero area produces no corresponding acceleration. Composable phases
    require a positive area on their primary spacecraft when that force is
    enabled, while an optional relative chief may intentionally keep zero area.

    Args:
        drag_area_m2: Constant projected area used by atmospheric drag.
        drag_coefficient: Dimensionless drag coefficient.
        srp_area_m2: Constant projected area used by solar radiation pressure.
        reflectivity_coefficient: Dimensionless SRP coefficient, commonly
            called ``Cr``.
    """

    drag_area_m2: float = 0.0
    drag_coefficient: float = 2.2
    srp_area_m2: float = 0.0
    reflectivity_coefficient: float = 1.3

    def __post_init__(self) -> None:
        object.__setattr__(
            self,
            "drag_area_m2",
            _finite_nonnegative(self.drag_area_m2, "drag_area_m2"),
        )
        object.__setattr__(
            self,
            "drag_coefficient",
            _finite_positive(self.drag_coefficient, "drag_coefficient"),
        )
        object.__setattr__(
            self,
            "srp_area_m2",
            _finite_nonnegative(self.srp_area_m2, "srp_area_m2"),
        )
        object.__setattr__(
            self,
            "reflectivity_coefficient",
            _finite_positive(
                self.reflectivity_coefficient,
                "reflectivity_coefficient",
            ),
        )

ExponentialAtmosphere dataclass

Single-scale-height co-rotating atmosphere.

Density follows

rho(h) = reference_density * exp(-(h - reference_altitude) / scale_height).

The rotation rate defines the atmosphere velocity as omega × r in the central-body inertial frame. This is a screening and preliminary-design model, not a substitute for a space-weather-driven atmosphere.

Args: reference_density_kgpm3: Density at reference_altitude_m. reference_altitude_m: Reference altitude above the central body. scale_height_m: Constant density scale height. rotation_rate_radps: Central-body atmosphere rotation rate about +Z.

Source code in octavian/forces.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
@dataclass(frozen=True, slots=True)
class ExponentialAtmosphere:
    """Single-scale-height co-rotating atmosphere.

    Density follows

    ``rho(h) = reference_density * exp(-(h - reference_altitude) / scale_height)``.

    The rotation rate defines the atmosphere velocity as ``omega × r`` in the
    central-body inertial frame.  This is a screening and preliminary-design
    model, not a substitute for a space-weather-driven atmosphere.

    Args:
        reference_density_kgpm3: Density at ``reference_altitude_m``.
        reference_altitude_m: Reference altitude above the central body.
        scale_height_m: Constant density scale height.
        rotation_rate_radps: Central-body atmosphere rotation rate about +Z.
    """

    reference_density_kgpm3: float
    reference_altitude_m: float
    scale_height_m: float
    rotation_rate_radps: float

    def __post_init__(self) -> None:
        object.__setattr__(
            self,
            "reference_density_kgpm3",
            _finite_positive(
                self.reference_density_kgpm3,
                "reference_density_kgpm3",
            ),
        )
        object.__setattr__(
            self,
            "reference_altitude_m",
            _finite_nonnegative(self.reference_altitude_m, "reference_altitude_m"),
        )
        object.__setattr__(
            self,
            "scale_height_m",
            _finite_positive(self.scale_height_m, "scale_height_m"),
        )
        rotation_rate = float(self.rotation_rate_radps)
        if not np.isfinite(rotation_rate):
            raise ValueError("rotation_rate_radps must be finite")
        object.__setattr__(self, "rotation_rate_radps", rotation_rate)

    def density_kgpm3(self, altitude_m: float) -> float:
        """Return atmospheric density at an altitude in meters.

        The exponent is clipped only to keep exploratory numerical propagation
        finite far outside the model's useful altitude range.
        """
        altitude = float(altitude_m)
        if not np.isfinite(altitude):
            raise ValueError("altitude_m must be finite")
        exponent = -(altitude - self.reference_altitude_m) / self.scale_height_m
        return float(self.reference_density_kgpm3 * np.exp(np.clip(exponent, -700.0, 700.0)))

density_kgpm3(altitude_m: float) -> float

Return atmospheric density at an altitude in meters.

The exponent is clipped only to keep exploratory numerical propagation finite far outside the model's useful altitude range.

Source code in octavian/forces.py
131
132
133
134
135
136
137
138
139
140
141
def density_kgpm3(self, altitude_m: float) -> float:
    """Return atmospheric density at an altitude in meters.

    The exponent is clipped only to keep exploratory numerical propagation
    finite far outside the model's useful altitude range.
    """
    altitude = float(altitude_m)
    if not np.isfinite(altitude):
        raise ValueError("altitude_m must be finite")
    exponent = -(altitude - self.reference_altitude_m) / self.scale_height_m
    return float(self.reference_density_kgpm3 * np.exp(np.clip(exponent, -700.0, 700.0)))

cannonball_drag_acceleration(position_m: ArrayLike, velocity_mps: ArrayLike, *, mass_kg: float, central_body_radius_m: float, cannonball: Cannonball, atmosphere: ExponentialAtmosphere = EARTH_EXPONENTIAL_ATMOSPHERE) -> NDArray[np.float64]

Return simple cannonball drag acceleration in an inertial frame.

Args: position_m: Central-body inertial position. velocity_mps: Central-body inertial velocity. mass_kg: Instantaneous spacecraft mass. central_body_radius_m: Radius used to convert position to altitude. cannonball: Spacecraft projected area and drag coefficient. atmosphere: Exponential, co-rotating atmosphere model.

Returns: Three-component inertial acceleration in m/s².

Source code in octavian/forces.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def cannonball_drag_acceleration(
    position_m: ArrayLike,
    velocity_mps: ArrayLike,
    *,
    mass_kg: float,
    central_body_radius_m: float,
    cannonball: Cannonball,
    atmosphere: ExponentialAtmosphere = EARTH_EXPONENTIAL_ATMOSPHERE,
) -> NDArray[np.float64]:
    """Return simple cannonball drag acceleration in an inertial frame.

    Args:
        position_m: Central-body inertial position.
        velocity_mps: Central-body inertial velocity.
        mass_kg: Instantaneous spacecraft mass.
        central_body_radius_m: Radius used to convert position to altitude.
        cannonball: Spacecraft projected area and drag coefficient.
        atmosphere: Exponential, co-rotating atmosphere model.

    Returns:
        Three-component inertial acceleration in m/s².
    """
    position = np.asarray(position_m, dtype=float).reshape(3)
    velocity = np.asarray(velocity_mps, dtype=float).reshape(3)
    mass = _finite_positive(mass_kg, "mass_kg")
    body_radius = _finite_positive(central_body_radius_m, "central_body_radius_m")
    if not np.all(np.isfinite(position)) or not np.all(np.isfinite(velocity)):
        raise ValueError("position_m and velocity_mps must contain finite values")
    radius = float(np.linalg.norm(position))
    if radius <= 0.0:
        raise ValueError("position_m must have non-zero norm")
    if cannonball.drag_area_m2 == 0.0:
        return np.zeros(3, dtype=float)
    atmospheric_velocity = np.cross(
        np.asarray([0.0, 0.0, atmosphere.rotation_rate_radps]),
        position,
    )
    relative_velocity = velocity - atmospheric_velocity
    relative_speed = float(np.linalg.norm(relative_velocity))
    density = atmosphere.density_kgpm3(radius - body_radius)
    scale = -0.5 * density * cannonball.drag_coefficient * cannonball.drag_area_m2 / mass
    return np.asarray(scale * relative_speed * relative_velocity, dtype=float)

cannonball_srp_acceleration(position_m: ArrayLike, sun_position_m: ArrayLike, *, mass_kg: float, cannonball: Cannonball, solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2) -> NDArray[np.float64]

Return cannonball solar-radiation-pressure acceleration.

The acceleration points away from the Sun and varies with inverse-square distance. This first-order model intentionally omits eclipses and penumbra transitions.

Args: position_m: Spacecraft position in a central-body inertial frame. sun_position_m: Sun position in the same frame. mass_kg: Instantaneous spacecraft mass. cannonball: Spacecraft projected area and reflectivity coefficient. solar_pressure_at_1au_Npm2: Radiation pressure at one astronomical unit.

Returns: Three-component inertial acceleration in m/s².

Source code in octavian/forces.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def cannonball_srp_acceleration(
    position_m: ArrayLike,
    sun_position_m: ArrayLike,
    *,
    mass_kg: float,
    cannonball: Cannonball,
    solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2,
) -> NDArray[np.float64]:
    """Return cannonball solar-radiation-pressure acceleration.

    The acceleration points away from the Sun and varies with inverse-square
    distance.  This first-order model intentionally omits eclipses and
    penumbra transitions.

    Args:
        position_m: Spacecraft position in a central-body inertial frame.
        sun_position_m: Sun position in the same frame.
        mass_kg: Instantaneous spacecraft mass.
        cannonball: Spacecraft projected area and reflectivity coefficient.
        solar_pressure_at_1au_Npm2: Radiation pressure at one astronomical unit.

    Returns:
        Three-component inertial acceleration in m/s².
    """
    position = np.asarray(position_m, dtype=float).reshape(3)
    sun_position = np.asarray(sun_position_m, dtype=float).reshape(3)
    mass = _finite_positive(mass_kg, "mass_kg")
    pressure = _finite_positive(
        solar_pressure_at_1au_Npm2,
        "solar_pressure_at_1au_Npm2",
    )
    if not np.all(np.isfinite(position)) or not np.all(np.isfinite(sun_position)):
        raise ValueError("position_m and sun_position_m must contain finite values")
    if cannonball.srp_area_m2 == 0.0:
        return np.zeros(3, dtype=float)
    sun_to_spacecraft = position - sun_position
    distance = float(np.linalg.norm(sun_to_spacecraft))
    if distance <= 0.0:
        raise ValueError("Spacecraft and Sun positions must not coincide")
    magnitude = (
        pressure
        * cannonball.reflectivity_coefficient
        * cannonball.srp_area_m2
        / mass
        * (ASTRONOMICAL_UNIT_M / distance) ** 2
    )
    return np.asarray(magnitude * sun_to_spacecraft / distance, dtype=float)

octavian.dynamics

Dynamics models.

Octavian's primary optimal-control backend is ASSET. To keep the rest of the package importable (for utilities, studies, and result I/O), ASSET is treated as an optional runtime dependency at import time.

If ASSET is not installed, constructing ASSET-backed dynamics will raise a clear error, but importing this module will still succeed.

FiniteThrustECI

Bases: ODEBase if oc is not None else object

Finite-thrust dynamics with configurable direction representation.

The default retains the original three-component inertial vector-throttle control. RIC vectors are rotated into inertial coordinates from the current state. Fixed direction uses one scalar throttle. Euler mode augments the state with yaw, pitch, and roll and uses throttle plus three angular rates.

Source code in octavian/dynamics.py
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
class FiniteThrustECI(oc.ODEBase if oc is not None else object):
    """Finite-thrust dynamics with configurable direction representation.

    The default retains the original three-component inertial vector-throttle
    control. RIC vectors are rotated into inertial coordinates from the current
    state. Fixed direction uses one scalar throttle. Euler mode augments the
    state with yaw, pitch, and roll and uses throttle plus three angular rates.
    """

    def __init__(
        self,
        *,
        mu_m3ps2: float = 3.986004418e14,
        thrust_N: float,
        isp_s: float,
        j2: bool = False,
        spherical_harmonics: SphericalHarmonics | None = None,
        central_body_radius_m: float = 6_378_136.3,
        j2_coefficient: float = 1.08262668e-3,
        third_body_tables: Sequence[ThirdBodyTable] = (),
        g0_mps2: float = 9.80665,
        thrust_control: ThrustControl | None = None,
        cannonball: Cannonball | None = None,
        drag: bool = False,
        srp: bool = False,
        atmosphere: ExponentialAtmosphere | None = None,
        sun_table: ThirdBodyTable | None = None,
        solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2,
    ) -> None:
        _require_asset()
        self.mu = (
            spherical_harmonics.resolve_mu(mu_m3ps2)
            if spherical_harmonics is not None
            else float(mu_m3ps2)
        )
        self.thrust_N = float(thrust_N)
        self.isp_s = float(isp_s)
        self.g0_mps2 = float(g0_mps2)

        if self.thrust_N <= 0.0:
            raise ValueError("FiniteThrustECI requires thrust_N > 0.")
        if self.isp_s <= 0.0:
            raise ValueError("FiniteThrustECI requires isp_s > 0.")

        control_config = thrust_control or ThrustControl.vector()
        carries_attitude = control_config.carries_attitude
        state_dim = 10 if carries_attitude else 7
        control_dim = (
            4
            if control_config.representation == "euler"
            else 1 if control_config.representation == "fixed" else 3
        )
        XtU = oc.ODEArguments(state_dim, control_dim)
        X = XtU.XVec()
        U = XtU.UVec()
        R = X.head(3)
        V = X.segment(3, 3)
        M = X[6]
        attitude = X.segment(7, 3) if carries_attitude else None

        environment_acceleration = _translational_acceleration(
            R,
            V,
            M,
            mu_m3ps2=self.mu,
            include_j2=bool(j2),
            spherical_harmonics=spherical_harmonics,
            central_body_radius_m=float(central_body_radius_m),
            j2_coefficient=float(j2_coefficient),
            time_var=XtU.TVar(),
            third_body_tables=tuple(third_body_tables),
            include_drag=bool(drag),
            include_srp=bool(srp),
            cannonball=cannonball,
            atmosphere=atmosphere,
            sun_table=sun_table,
            solar_pressure_at_1au_Npm2=solar_pressure_at_1au_Npm2,
        )
        thrust_vector, throttle, attitude_rate = thrust_vector_and_rate(
            control_config,
            controls=U,
            position=R,
            velocity=V,
            attitude=attitude,
        )
        thrust_acceleration = (self.thrust_N / M) * thrust_vector
        mass_flow = -(self.thrust_N / (self.isp_s * self.g0_mps2)) * throttle
        ode_terms = [V, environment_acceleration + thrust_acceleration, mass_flow]
        if attitude_rate is not None:
            ode_terms.append(attitude_rate)
        ode = vf.stack(ode_terms)

        Vgroups = {
            ("R", "Position"): R,
            ("V", "Velocity"): V,
            ("M", "Mass"): [6],
            ("t", "time"): XtU.TVar(),
            "RV": [0, 1, 2, 3, 4, 5],
        }
        if control_config.representation == "vector":
            Vgroups[("U", "Control", "Thrust")] = U
        else:
            Vgroups["Throttle"] = vf.stack([U[0]])
        if attitude is not None and attitude_rate is not None:
            Vgroups[("Attitude", "EulerAngles")] = attitude
            Vgroups[("AttitudeRate", "EulerRates")] = attitude_rate
            Vgroups[("SlewControl", "NormalizedAttitudeRate")] = U.segment(1, 3)
            Vgroups["Yaw"] = attitude[0]
            Vgroups["Pitch"] = attitude[1]
            Vgroups["Roll"] = attitude[2]

        super().__init__(ode, state_dim, control_dim, Vgroups=Vgroups)

MassCoastECI

Bases: ODEBase if oc is not None else object

Ballistic coast dynamics carrying spacecraft mass as a constant state.

This ODE is used between finite-burn phases so ASSET continuity links can preserve the spacecraft mass state across burn-coast-burn transfers. The mass derivative is identically zero; translational acceleration still uses the configured gravity and perturbation model. A phase using Euler thrust control also carries its kinematic attitude through the coast.

Source code in octavian/dynamics.py
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
class MassCoastECI(oc.ODEBase if oc is not None else object):
    """Ballistic coast dynamics carrying spacecraft mass as a constant state.

    This ODE is used between finite-burn phases so ASSET continuity links can
    preserve the spacecraft mass state across burn-coast-burn transfers. The
    mass derivative is identically zero; translational acceleration still uses
    the configured gravity and perturbation model. A phase using Euler thrust
    control also carries its kinematic attitude through the coast.
    """

    def __init__(
        self,
        *,
        mu_m3ps2: float = 3.986004418e14,
        j2: bool = False,
        spherical_harmonics: SphericalHarmonics | None = None,
        central_body_radius_m: float = 6_378_136.3,
        j2_coefficient: float = 1.08262668e-3,
        third_body_tables: Sequence[ThirdBodyTable] = (),
        thrust_control: ThrustControl | None = None,
        cannonball: Cannonball | None = None,
        drag: bool = False,
        srp: bool = False,
        atmosphere: ExponentialAtmosphere | None = None,
        sun_table: ThirdBodyTable | None = None,
        solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2,
    ) -> None:
        _require_asset()
        self.mu = (
            spherical_harmonics.resolve_mu(mu_m3ps2)
            if spherical_harmonics is not None
            else float(mu_m3ps2)
        )
        control_config = thrust_control or ThrustControl.vector()
        carries_attitude = control_config.carries_attitude

        state_dim = 10 if carries_attitude else 7
        control_dim = 3 if carries_attitude else 0
        XtU = oc.ODEArguments(state_dim, control_dim)
        X = XtU.XVec()
        R = X.head(3)
        V = X.segment(3, 3)
        M = X[6]
        attitude = X.segment(7, 3) if carries_attitude else None
        slew_control = XtU.UVec() if carries_attitude else None
        attitude_rate = (
            float(control_config.max_slew_rate_radps) * slew_control
            if slew_control is not None
            else None
        )

        A = _translational_acceleration(
            R,
            V,
            M,
            mu_m3ps2=self.mu,
            include_j2=bool(j2),
            spherical_harmonics=spherical_harmonics,
            central_body_radius_m=float(central_body_radius_m),
            j2_coefficient=float(j2_coefficient),
            time_var=XtU.TVar(),
            third_body_tables=tuple(third_body_tables),
            include_drag=bool(drag),
            include_srp=bool(srp),
            cannonball=cannonball,
            atmosphere=atmosphere,
            sun_table=sun_table,
            solar_pressure_at_1au_Npm2=solar_pressure_at_1au_Npm2,
        )
        ode_terms = [V, A, M * 0.0]
        if attitude_rate is not None:
            ode_terms.append(attitude_rate)
        ode = vf.stack(ode_terms)

        Vgroups = {
            ("R", "Position"): R,
            ("V", "Velocity"): V,
            ("M", "Mass"): [6],
            ("t", "time"): XtU.TVar(),
            "RV": [0, 1, 2, 3, 4, 5],
        }
        if attitude is not None and attitude_rate is not None and slew_control is not None:
            Vgroups[("Attitude", "EulerAngles")] = attitude
            Vgroups[("AttitudeRate", "EulerRates")] = attitude_rate
            Vgroups[("SlewControl", "NormalizedAttitudeRate")] = slew_control
            Vgroups["Yaw"] = attitude[0]
            Vgroups["Pitch"] = attitude[1]
            Vgroups["Roll"] = attitude[2]

        super().__init__(ode, state_dim, control_dim, Vgroups=Vgroups)

PerturbedECI

Bases: ODEBase if oc is not None else object

Point-mass gravity with optional perturbation accelerations.

Currently implemented perturbations: - J2 zonal harmonic or a fully normalized spherical-harmonic field - Sun and Moon third-body point-mass gravity through interpolation tables - Exponential-atmosphere cannonball drag - Cannonball solar radiation pressure

The state is [r(3), v(3)]. Time remains ASSET's phase time variable, not a state, which is why ephemeris tables are sampled with XtU.TVar(). Spacecraft mass is constant in this coast formulation.

Source code in octavian/dynamics.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
class PerturbedECI(oc.ODEBase if oc is not None else object):
    """Point-mass gravity with optional perturbation accelerations.

    Currently implemented perturbations:
        - J2 zonal harmonic or a fully normalized spherical-harmonic field
        - Sun and Moon third-body point-mass gravity through interpolation tables
        - Exponential-atmosphere cannonball drag
        - Cannonball solar radiation pressure

    The state is ``[r(3), v(3)]``. Time remains ASSET's phase time variable, not
    a state, which is why ephemeris tables are sampled with ``XtU.TVar()``.
    Spacecraft mass is constant in this coast formulation.
    """

    def __init__(
        self,
        *,
        mu_m3ps2: float = 3.986004418e14,
        j2: bool = False,
        spherical_harmonics: SphericalHarmonics | None = None,
        central_body_radius_m: float = 6_378_136.3,
        j2_coefficient: float = 1.08262668e-3,
        third_body_tables: Sequence[ThirdBodyTable] = (),
        spacecraft_mass_kg: float = 1.0,
        cannonball: Cannonball | None = None,
        drag: bool = False,
        srp: bool = False,
        atmosphere: ExponentialAtmosphere | None = None,
        sun_table: ThirdBodyTable | None = None,
        solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2,
    ) -> None:
        _require_asset()
        self.mu = (
            spherical_harmonics.resolve_mu(mu_m3ps2)
            if spherical_harmonics is not None
            else float(mu_m3ps2)
        )

        XtU = oc.ODEArguments(6, 0)
        R = XtU.XVec().head(3)
        V = XtU.XVec().segment(3, 3)

        A = _translational_acceleration(
            R,
            V,
            float(spacecraft_mass_kg),
            mu_m3ps2=self.mu,
            include_j2=bool(j2),
            spherical_harmonics=spherical_harmonics,
            central_body_radius_m=float(central_body_radius_m),
            j2_coefficient=float(j2_coefficient),
            time_var=XtU.TVar(),
            third_body_tables=tuple(third_body_tables),
            include_drag=bool(drag),
            include_srp=bool(srp),
            cannonball=cannonball,
            atmosphere=atmosphere,
            sun_table=sun_table,
            solar_pressure_at_1au_Npm2=solar_pressure_at_1au_Npm2,
        )
        ode = vf.stack([V, A])

        Vgroups = {
            ("R", "Position"): R,
            ("V", "Velocity"): V,
            ("t", "time"): XtU.TVar(),
            "RV": [0, 1, 2, 3, 4, 5],
        }

        super().__init__(ode, 6, 0, Vgroups=Vgroups)

ThirdBodyTable dataclass

Interpolated third-body ephemeris used by ASSET dynamics.

Attributes: name: Normalized body name, currently "sun" or "moon". mu_m3ps2: Gravitational parameter for the third body in SI units. position_table: ASSET vector function that returns the body's Earth-centered position in meters at the current phase time. times_s: Optional numeric sample times used for result conversion. positions_eci_m: Optional numeric positions paired with times_s.

Source code in octavian/dynamics.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@dataclass(frozen=True)
class ThirdBodyTable:
    """Interpolated third-body ephemeris used by ASSET dynamics.

    Attributes:
        name: Normalized body name, currently ``"sun"`` or ``"moon"``.
        mu_m3ps2: Gravitational parameter for the third body in SI units.
        position_table: ASSET vector function that returns the body's
            Earth-centered position in meters at the current phase time.
        times_s: Optional numeric sample times used for result conversion.
        positions_eci_m: Optional numeric positions paired with ``times_s``.
    """

    name: str
    mu_m3ps2: float
    position_table: Any
    times_s: NDArray[np.float64] | None = None
    positions_eci_m: NDArray[np.float64] | None = None

    def position_at(self, time_s: float) -> NDArray[np.float64]:
        """Interpolate position, clipping endpoint time roundoff up to 1e-8 s."""
        if self.times_s is None or self.positions_eci_m is None:
            raise ValueError(f"ThirdBodyTable {self.name!r} does not contain numeric samples")
        times = np.asarray(self.times_s, dtype=float)
        positions = np.asarray(self.positions_eci_m, dtype=float)
        time_s = float(bounded_query_times(time_s, times, quantity=f"{self.name} position"))
        return np.asarray(
            [np.interp(float(time_s), times, positions[:, component]) for component in range(3)],
            dtype=float,
        )

position_at(time_s: float) -> NDArray[np.float64]

Interpolate position, clipping endpoint time roundoff up to 1e-8 s.

Source code in octavian/dynamics.py
58
59
60
61
62
63
64
65
66
67
68
def position_at(self, time_s: float) -> NDArray[np.float64]:
    """Interpolate position, clipping endpoint time roundoff up to 1e-8 s."""
    if self.times_s is None or self.positions_eci_m is None:
        raise ValueError(f"ThirdBodyTable {self.name!r} does not contain numeric samples")
    times = np.asarray(self.times_s, dtype=float)
    positions = np.asarray(self.positions_eci_m, dtype=float)
    time_s = float(bounded_query_times(time_s, times, quantity=f"{self.name} position"))
    return np.asarray(
        [np.interp(float(time_s), times, positions[:, component]) for component in range(3)],
        dtype=float,
    )

TwoBodyECI

Bases: ODEBase if oc is not None else object

Two-body point-mass gravity in ECI.

backend="cpp" (default) uses ASSET's specialized C++ Kepler integrator for propagation. backend="expression" uses the generic ASSET expression integrator. Both run without Python force callbacks; phase construction retains the existing expressions and variable groups.

ODE state has 6 components: [r(3), v(3)]. Time is the phase time variable (ODEArguments.TVar()), not a state component.

Vgroups: - R: position (3) - V: velocity (3) - t: phase time variable

Source code in octavian/dynamics.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
class TwoBodyECI(oc.ODEBase if oc is not None else object):
    """Two-body point-mass gravity in ECI.

    ``backend="cpp"`` (default) uses ASSET's specialized C++ Kepler
    integrator for propagation. ``backend="expression"`` uses the generic
    ASSET expression integrator. Both run without Python force callbacks;
    phase construction retains the existing expressions and variable groups.

    ODE state has 6 components: ``[r(3), v(3)]``.
    Time is the *phase time variable* (``ODEArguments.TVar()``), not a state component.

    Vgroups:
        - ``R``: position (3)
        - ``V``: velocity (3)
        - ``t``: phase time variable
    """

    def __init__(self, *, mu_m3ps2: float, backend: str = "cpp") -> None:
        _require_asset()
        if backend not in {"cpp", "expression"}:
            raise ValueError("TwoBodyECI backend must be 'cpp' or 'expression'")
        self.mu = float(mu_m3ps2)
        if not np.isfinite(self.mu) or self.mu <= 0:
            raise ValueError("mu_m3ps2 must be finite and positive")
        self.backend = backend
        self._native_ode = ast.Astro.Kepler.ode(self.mu) if backend == "cpp" else None

        XtU = oc.ODEArguments(6, 0)  # 6 states, 0 controls
        R = XtU.XVec().head(3)
        V = XtU.XVec().segment(3, 3)

        A = _gravity_acceleration(R, mu_m3ps2=self.mu)

        ode = vf.stack([V, A])

        Vgroups = {
            ("R", "Position"): R,
            ("V", "Velocity"): V,
            ("t", "time"): XtU.TVar(),
            "RV": [0, 1, 2, 3, 4, 5],
        }

        super().__init__(ode, 6, 0, Vgroups=Vgroups)

    def integrator(self, *args):
        """Create an ASSET integrator with ``step_s`` or ``method, step_s``.

        The C++ backend specializes the complete adaptive integration loop
        for point-mass dynamics. The returned integrator supports ASSET's
        existing tolerance controls, ``integrate``, and ``integrate_dense``.
        """
        if self._native_ode is not None:
            return self._native_ode.integrator(*args)
        return super().integrator(*args)

integrator(*args)

Create an ASSET integrator with step_s or method, step_s.

The C++ backend specializes the complete adaptive integration loop for point-mass dynamics. The returned integrator supports ASSET's existing tolerance controls, integrate, and integrate_dense.

Source code in octavian/dynamics.py
497
498
499
500
501
502
503
504
505
506
def integrator(self, *args):
    """Create an ASSET integrator with ``step_s`` or ``method, step_s``.

    The C++ backend specializes the complete adaptive integration loop
    for point-mass dynamics. The returned integrator supports ASSET's
    existing tolerance controls, ``integrate``, and ``integrate_dense``.
    """
    if self._native_ode is not None:
        return self._native_ode.integrator(*args)
    return super().integrator(*args)

gravity_acceleration_components(position_m: ArrayLike, *, time_s: float = 0.0, mu_m3ps2: float, include_j2: bool = False, spherical_harmonics: SphericalHarmonics | None = None, central_body_radius_m: float = 6378136.3, j2_coefficient: float = 0.00108262668, third_body_tables: Sequence[ThirdBodyTable] = ()) -> NDArray[np.float64]

Evaluate the full configured acceleration numerically.

This is the numeric counterpart of :func:_gravity_acceleration. It is used when converting solved coupled chief/deputy histories to the instantaneous RIC frame, where the chief acceleration determines the frame's cross-track angular rate.

Source code in octavian/dynamics.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def gravity_acceleration_components(
    position_m: ArrayLike,
    *,
    time_s: float = 0.0,
    mu_m3ps2: float,
    include_j2: bool = False,
    spherical_harmonics: SphericalHarmonics | None = None,
    central_body_radius_m: float = 6_378_136.3,
    j2_coefficient: float = 1.08262668e-3,
    third_body_tables: Sequence[ThirdBodyTable] = (),
) -> NDArray[np.float64]:
    """Evaluate the full configured acceleration numerically.

    This is the numeric counterpart of :func:`_gravity_acceleration`.  It is
    used when converting solved coupled chief/deputy histories to the
    instantaneous RIC frame, where the chief acceleration determines the
    frame's cross-track angular rate.
    """
    position = np.asarray(position_m, dtype=float).reshape(3)
    radius = float(np.linalg.norm(position))
    if radius <= 0.0:
        raise ValueError("position_m must have non-zero norm")
    if spherical_harmonics is not None:
        mu_m3ps2 = spherical_harmonics.resolve_mu(mu_m3ps2)
    acceleration = -float(mu_m3ps2) * position / radius**3
    if include_j2 and spherical_harmonics is not None:
        raise ValueError("Choose spherical_harmonics or J2, not both")
    if include_j2:
        acceleration = acceleration + np.asarray(
            j2_acceleration_components(
                position,
                mu_m3ps2=float(mu_m3ps2),
                radius_m=float(central_body_radius_m),
                j2=float(j2_coefficient),
            ),
            dtype=float,
        )
    if spherical_harmonics is not None:
        acceleration += spherical_harmonics.acceleration(position, mu_m3ps2=mu_m3ps2, time_s=time_s)
    for body in third_body_tables:
        acceleration = acceleration + np.asarray(
            third_body_acceleration_components(
                position,
                body.position_at(float(time_s)),
                mu_m3ps2=float(body.mu_m3ps2),
            ),
            dtype=float,
        )
    return np.asarray(acceleration, dtype=float)

j2_acceleration_components(position_m, *, mu_m3ps2: float, radius_m: float = 6378136.3, j2: float = 0.00108262668)

Return the Cartesian J2 acceleration components for an ECI position.

This follows the standard oblate-body acceleration model used by the ASSET vector-function implementation below, and is intentionally numpy-free so it can be used in lightweight validation tests.

Source code in octavian/dynamics.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def j2_acceleration_components(
    position_m,
    *,
    mu_m3ps2: float,
    radius_m: float = 6_378_136.3,
    j2: float = 1.08262668e-3,
):
    """Return the Cartesian J2 acceleration components for an ECI position.

    This follows the standard oblate-body acceleration model used by the ASSET
    vector-function implementation below, and is intentionally numpy-free so it
    can be used in lightweight validation tests.
    """
    x, y, z = [float(component) for component in position_m]
    radius_sq = x * x + y * y + z * z
    radius = radius_sq**0.5
    z_sq_over_r_sq = (z * z) / radius_sq
    scale = 1.5 * float(j2) * float(mu_m3ps2) * (float(radius_m) ** 2) / (radius**5)
    common_xy = 5.0 * z_sq_over_r_sq - 1.0
    z_term = 5.0 * z_sq_over_r_sq - 3.0
    return (
        scale * x * common_xy,
        scale * y * common_xy,
        scale * z * z_term,
    )

third_body_acceleration_components(spacecraft_position_m, body_position_m, *, mu_m3ps2: float)

Return third-body acceleration in an Earth-centered frame.

The expression includes both the third body's gravity on the spacecraft and the same body's gravity on the Earth-centered frame origin. It mirrors the ASSET vector-function implementation used by coast and burn EOMs.

Source code in octavian/dynamics.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def third_body_acceleration_components(
    spacecraft_position_m,
    body_position_m,
    *,
    mu_m3ps2: float,
):
    """Return third-body acceleration in an Earth-centered frame.

    The expression includes both the third body's gravity on the spacecraft
    and the same body's gravity on the Earth-centered frame origin. It mirrors
    the ASSET vector-function implementation used by coast and burn EOMs.
    """
    rx, ry, rz = [float(component) for component in spacecraft_position_m]
    bx, by, bz = [float(component) for component in body_position_m]

    rel_x = bx - rx
    rel_y = by - ry
    rel_z = bz - rz
    rel_radius = (rel_x * rel_x + rel_y * rel_y + rel_z * rel_z) ** 0.5
    body_radius = (bx * bx + by * by + bz * bz) ** 0.5

    mu = float(mu_m3ps2)
    return (
        mu * (rel_x / rel_radius**3 - bx / body_radius**3),
        mu * (rel_y / rel_radius**3 - by / body_radius**3),
        mu * (rel_z / rel_radius**3 - bz / body_radius**3),
    )

translational_acceleration_components(position_m: ArrayLike, velocity_mps: ArrayLike, *, time_s: float = 0.0, mass_kg: float, mu_m3ps2: float, include_j2: bool = False, spherical_harmonics: SphericalHarmonics | None = None, central_body_radius_m: float = 6378136.3, j2_coefficient: float = 0.00108262668, third_body_tables: Sequence[ThirdBodyTable] = (), include_drag: bool = False, include_srp: bool = False, cannonball: Cannonball | None = None, atmosphere: ExponentialAtmosphere | None = None, sun_table: ThirdBodyTable | None = None, solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2) -> NDArray[np.float64]

Evaluate gravity plus optional cannonball drag and SRP numerically.

This function is the numerical counterpart of the common ASSET force expression. It is used by relative propagation and by RIC conversion, where the chief acceleration determines the instantaneous frame rate.

Args: position_m: Central-body inertial position. velocity_mps: Central-body inertial velocity. time_s: Mission-relative time used by ephemeris tables. mass_kg: Instantaneous spacecraft mass. mu_m3ps2: Central-body gravitational parameter. include_j2: Include central-body J2. spherical_harmonics: Optional normalized gravity field, mutually exclusive with J2. central_body_radius_m: Central-body radius. j2_coefficient: Central-body J2 coefficient. third_body_tables: Gravity ephemeris tables. include_drag: Include exponential-atmosphere drag. include_srp: Include cannonball solar radiation pressure. cannonball: Spacecraft area and coefficient properties. atmosphere: Required when drag is enabled. sun_table: Required when SRP is enabled. solar_pressure_at_1au_Npm2: Radiation pressure at one AU.

Returns: Three-component inertial acceleration in m/s².

Source code in octavian/dynamics.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def translational_acceleration_components(
    position_m: ArrayLike,
    velocity_mps: ArrayLike,
    *,
    time_s: float = 0.0,
    mass_kg: float,
    mu_m3ps2: float,
    include_j2: bool = False,
    spherical_harmonics: SphericalHarmonics | None = None,
    central_body_radius_m: float = 6_378_136.3,
    j2_coefficient: float = 1.08262668e-3,
    third_body_tables: Sequence[ThirdBodyTable] = (),
    include_drag: bool = False,
    include_srp: bool = False,
    cannonball: Cannonball | None = None,
    atmosphere: ExponentialAtmosphere | None = None,
    sun_table: ThirdBodyTable | None = None,
    solar_pressure_at_1au_Npm2: float = SOLAR_PRESSURE_AT_1_AU_NPM2,
) -> NDArray[np.float64]:
    """Evaluate gravity plus optional cannonball drag and SRP numerically.

    This function is the numerical counterpart of the common ASSET force
    expression.  It is used by relative propagation and by RIC conversion,
    where the chief acceleration determines the instantaneous frame rate.

    Args:
        position_m: Central-body inertial position.
        velocity_mps: Central-body inertial velocity.
        time_s: Mission-relative time used by ephemeris tables.
        mass_kg: Instantaneous spacecraft mass.
        mu_m3ps2: Central-body gravitational parameter.
        include_j2: Include central-body J2.
        spherical_harmonics: Optional normalized gravity field, mutually exclusive with J2.
        central_body_radius_m: Central-body radius.
        j2_coefficient: Central-body J2 coefficient.
        third_body_tables: Gravity ephemeris tables.
        include_drag: Include exponential-atmosphere drag.
        include_srp: Include cannonball solar radiation pressure.
        cannonball: Spacecraft area and coefficient properties.
        atmosphere: Required when drag is enabled.
        sun_table: Required when SRP is enabled.
        solar_pressure_at_1au_Npm2: Radiation pressure at one AU.

    Returns:
        Three-component inertial acceleration in m/s².
    """
    position = np.asarray(position_m, dtype=float).reshape(3)
    velocity = np.asarray(velocity_mps, dtype=float).reshape(3)
    acceleration = gravity_acceleration_components(
        position,
        time_s=time_s,
        mu_m3ps2=mu_m3ps2,
        include_j2=include_j2,
        spherical_harmonics=spherical_harmonics,
        central_body_radius_m=central_body_radius_m,
        j2_coefficient=j2_coefficient,
        third_body_tables=third_body_tables,
    )
    properties = cannonball or Cannonball()
    if include_drag and properties.drag_area_m2 > 0.0:
        if atmosphere is None:
            raise ValueError("Drag acceleration requires an atmosphere model")
        acceleration = acceleration + cannonball_drag_acceleration(
            position,
            velocity,
            mass_kg=mass_kg,
            central_body_radius_m=central_body_radius_m,
            cannonball=properties,
            atmosphere=atmosphere,
        )
    if include_srp and properties.srp_area_m2 > 0.0:
        if sun_table is None:
            raise ValueError("SRP acceleration requires a Sun ephemeris table")
        acceleration = acceleration + cannonball_srp_acceleration(
            position,
            sun_table.position_at(float(time_s)),
            mass_kg=mass_kg,
            cannonball=properties,
            solar_pressure_at_1au_Npm2=solar_pressure_at_1au_Npm2,
        )
    return np.asarray(acceleration, dtype=float)

octavian.coordinates.frames

Reference-frame metadata carried by dynamics and solved trajectories.

CoordinateFrame dataclass

Describe the origin and orientation used by a trajectory.

This object is metadata, not a coordinate transformation by itself. Frame transformation services are added alongside dynamics that need them, while every solver result can already state what its coordinates mean.

Source code in octavian/coordinates/frames.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(frozen=True, slots=True)
class CoordinateFrame:
    """Describe the origin and orientation used by a trajectory.

    This object is metadata, not a coordinate transformation by itself. Frame
    transformation services are added alongside dynamics that need them, while
    every solver result can already state what its coordinates mean.
    """

    name: str
    origin: str
    orientation: str
    kind: FrameKind = "inertial"

    def __post_init__(self) -> None:
        for field_name in ("name", "origin", "orientation"):
            value = str(getattr(self, field_name)).strip()
            if not value:
                raise ValueError(f"CoordinateFrame.{field_name} must not be empty")
            object.__setattr__(self, field_name, value)
        if self.kind not in ("inertial", "rotating", "relative"):
            raise ValueError("CoordinateFrame.kind must be inertial, rotating, or relative")

    def to_dict(self) -> dict[str, str]:
        """Return JSON-serializable frame metadata."""
        return asdict(self)

    @classmethod
    def from_dict(cls, value: dict[str, str]) -> CoordinateFrame:
        """Reconstruct frame metadata stored in a result archive."""
        return cls(
            name=value["name"],
            origin=value["origin"],
            orientation=value["orientation"],
            kind=value.get("kind", "inertial"),  # type: ignore[arg-type]
        )

from_dict(value: dict[str, str]) -> CoordinateFrame classmethod

Reconstruct frame metadata stored in a result archive.

Source code in octavian/coordinates/frames.py
38
39
40
41
42
43
44
45
46
@classmethod
def from_dict(cls, value: dict[str, str]) -> CoordinateFrame:
    """Reconstruct frame metadata stored in a result archive."""
    return cls(
        name=value["name"],
        origin=value["origin"],
        orientation=value["orientation"],
        kind=value.get("kind", "inertial"),  # type: ignore[arg-type]
    )

to_dict() -> dict[str, str]

Return JSON-serializable frame metadata.

Source code in octavian/coordinates/frames.py
34
35
36
def to_dict(self) -> dict[str, str]:
    """Return JSON-serializable frame metadata."""
    return asdict(self)

inertial(origin: str, *, orientation: str = 'ICRF', name: str | None = None) -> CoordinateFrame

Create an inertial Cartesian frame centered on origin.

Source code in octavian/coordinates/frames.py
49
50
51
52
53
54
55
56
57
def inertial(origin: str, *, orientation: str = "ICRF", name: str | None = None) -> CoordinateFrame:
    """Create an inertial Cartesian frame centered on ``origin``."""
    normalized_origin = str(origin).strip().lower().replace(" ", "_")
    return CoordinateFrame(
        name=name or f"{normalized_origin}_inertial",
        origin=normalized_origin,
        orientation=orientation,
        kind="inertial",
    )

lvlh(chief: str = 'chief', *, name: str | None = None) -> CoordinateFrame

Create a chief-centered local-vertical/local-horizontal frame.

The orientation follows the RTN convention used by Octavian's relative dynamics: x is radial, y is along track, and z is orbit normal.

Source code in octavian/coordinates/frames.py
60
61
62
63
64
65
66
67
68
69
70
71
72
def lvlh(chief: str = "chief", *, name: str | None = None) -> CoordinateFrame:
    """Create a chief-centered local-vertical/local-horizontal frame.

    The orientation follows the RTN convention used by Octavian's relative
    dynamics: x is radial, y is along track, and z is orbit normal.
    """
    normalized_chief = str(chief).strip().lower().replace(" ", "_")
    return CoordinateFrame(
        name=name or f"{normalized_chief}_lvlh",
        origin=normalized_chief,
        orientation="LVLH/RTN",
        kind="relative",
    )

ric(chief: str = 'chief', *, name: str | None = None) -> CoordinateFrame

Create a chief-centered radial/in-track/cross-track frame.

RIC, RTN, and the LVLH convention used by Octavian describe the same right-handed axes: radial R points away from the central body, in-track I follows the chief's motion, and cross-track C follows the chief's angular momentum. This named constructor lets mission scripts use the terminology most common in relative-motion analysis.

Source code in octavian/coordinates/frames.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def ric(chief: str = "chief", *, name: str | None = None) -> CoordinateFrame:
    """Create a chief-centered radial/in-track/cross-track frame.

    RIC, RTN, and the LVLH convention used by Octavian describe the same
    right-handed axes: radial ``R`` points away from the central body,
    in-track ``I`` follows the chief's motion, and cross-track ``C`` follows
    the chief's angular momentum.  This named constructor lets mission scripts
    use the terminology most common in relative-motion analysis.
    """
    normalized_chief = str(chief).strip().lower().replace(" ", "_")
    return CoordinateFrame(
        name=name or f"{normalized_chief}_ric",
        origin=normalized_chief,
        orientation="RIC/RTN/LVLH",
        kind="relative",
    )

synodic(primary: str, secondary: str, *, name: str | None = None) -> CoordinateFrame

Create a barycentric primary-secondary rotating frame.

The +X axis points from primary to secondary and +Z follows the circular system angular momentum.

Source code in octavian/coordinates/frames.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def synodic(
    primary: str,
    secondary: str,
    *,
    name: str | None = None,
) -> CoordinateFrame:
    """Create a barycentric primary-secondary rotating frame.

    The +X axis points from primary to secondary and +Z follows the circular
    system angular momentum.
    """
    primary_name = str(primary).strip().lower().replace(" ", "_")
    secondary_name = str(secondary).strip().lower().replace(" ", "_")
    if not primary_name or not secondary_name:
        raise ValueError("Synodic primary and secondary names must not be empty")
    return CoordinateFrame(
        name=name or f"{primary_name}_{secondary_name}_synodic",
        origin=f"{primary_name}_{secondary_name}_barycenter",
        orientation=f"{primary_name}_to_{secondary_name}_rotating",
        kind="rotating",
    )

octavian.coordinates.layouts

Named state and control layouts used at the compiler boundary.

StateLayout dataclass

Describe state/control columns without hard-coded solver indices.

Group indices refer to either state or control columns. Time remains the ASSET phase-time column immediately after the state columns and is exposed through :attr:time_column.

Source code in octavian/coordinates/layouts.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@dataclass(frozen=True, slots=True)
class StateLayout:
    """Describe state/control columns without hard-coded solver indices.

    Group indices refer to either state or control columns. Time remains the
    ASSET phase-time column immediately after the state columns and is exposed
    through :attr:`time_column`.
    """

    name: str
    state_names: tuple[str, ...]
    control_names: tuple[str, ...] = ()
    state_groups: tuple[tuple[str, tuple[int, ...]], ...] = ()
    control_groups: tuple[tuple[str, tuple[int, ...]], ...] = ()

    def __post_init__(self) -> None:
        if not str(self.name).strip():
            raise ValueError("StateLayout.name must not be empty")
        _validate_names("state_names", self.state_names)
        _validate_names("control_names", self.control_names, allow_empty=True)
        _validate_groups("state_groups", self.state_groups, len(self.state_names))
        _validate_groups("control_groups", self.control_groups, len(self.control_names))

    @property
    def state_dim(self) -> int:
        """Return the number of differential states."""
        return len(self.state_names)

    @property
    def control_dim(self) -> int:
        """Return the number of controls."""
        return len(self.control_names)

    @property
    def time_column(self) -> int:
        """Return the trajectory column containing phase time."""
        return self.state_dim

    def state_indices(self, group: str) -> tuple[int, ...]:
        """Return state indices for a named semantic group."""
        return _find_group(self.state_groups, group, "state")

    def control_indices(self, group: str) -> tuple[int, ...]:
        """Return control indices for a named semantic group."""
        return _find_group(self.control_groups, group, "control")

    def public_rvt_columns(self) -> tuple[int, ...]:
        """Return position, velocity, and time columns for Cartesian reporting."""
        return self.state_indices("position") + self.state_indices("velocity") + (self.time_column,)

control_dim: int property

Return the number of controls.

state_dim: int property

Return the number of differential states.

time_column: int property

Return the trajectory column containing phase time.

control_indices(group: str) -> tuple[int, ...]

Return control indices for a named semantic group.

Source code in octavian/coordinates/layouts.py
50
51
52
def control_indices(self, group: str) -> tuple[int, ...]:
    """Return control indices for a named semantic group."""
    return _find_group(self.control_groups, group, "control")

public_rvt_columns() -> tuple[int, ...]

Return position, velocity, and time columns for Cartesian reporting.

Source code in octavian/coordinates/layouts.py
54
55
56
def public_rvt_columns(self) -> tuple[int, ...]:
    """Return position, velocity, and time columns for Cartesian reporting."""
    return self.state_indices("position") + self.state_indices("velocity") + (self.time_column,)

state_indices(group: str) -> tuple[int, ...]

Return state indices for a named semantic group.

Source code in octavian/coordinates/layouts.py
46
47
48
def state_indices(self, group: str) -> tuple[int, ...]:
    """Return state indices for a named semantic group."""
    return _find_group(self.state_groups, group, "state")

octavian.coordinates.scaling

Characteristic scaling metadata for dimensional solver problems.

SolverScaling dataclass

Characteristic units used to condition one compiled problem.

Source code in octavian/coordinates/scaling.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@dataclass(frozen=True, slots=True)
class SolverScaling:
    """Characteristic units used to condition one compiled problem."""

    length_m: float
    velocity_mps: float
    time_s: float
    mass_kg: float = 1.0

    def __post_init__(self) -> None:
        for field_name in ("length_m", "velocity_mps", "time_s", "mass_kg"):
            value = float(getattr(self, field_name))
            if value <= 0.0:
                raise ValueError(f"SolverScaling.{field_name} must be positive")
            object.__setattr__(self, field_name, value)

    @property
    def acceleration_mps2(self) -> float:
        """Return the implied characteristic acceleration."""
        return self.velocity_mps / self.time_s

    @property
    def force_N(self) -> float:
        """Return the implied characteristic force."""
        return self.mass_kg * self.acceleration_mps2

    def to_dict(self) -> dict[str, float]:
        """Return JSON-serializable scaling metadata."""
        return asdict(self)

acceleration_mps2: float property

Return the implied characteristic acceleration.

force_N: float property

Return the implied characteristic force.

to_dict() -> dict[str, float]

Return JSON-serializable scaling metadata.

Source code in octavian/coordinates/scaling.py
34
35
36
def to_dict(self) -> dict[str, float]:
    """Return JSON-serializable scaling metadata."""
    return asdict(self)

octavian.relative.cwh

Clohessy-Wiltshire dynamics for motion near a circular chief orbit.

ClohessyWiltshire dataclass

Linear time-invariant relative dynamics about a circular chief.

States use a chief-centered LVLH/RTN frame with radial x, along-track y, and orbit-normal z. reference_length_m controls numerical scaling; it is not a validity limit for the linear model.

Source code in octavian/relative/cwh.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@dataclass(frozen=True, slots=True)
class ClohessyWiltshire:
    """Linear time-invariant relative dynamics about a circular chief.

    States use a chief-centered LVLH/RTN frame with radial x, along-track y,
    and orbit-normal z. ``reference_length_m`` controls numerical scaling; it
    is not a validity limit for the linear model.
    """

    mean_motion_radps: float
    chief_orbit_radius_m: float | None = None
    chief_name: str = "chief"
    reference_length_m: float = 1_000.0
    chief_initial_state_eci: BoundaryState | None = None

    def __post_init__(self) -> None:
        if float(self.mean_motion_radps) <= 0.0:
            raise ValueError("ClohessyWiltshire.mean_motion_radps must be positive")
        if self.chief_orbit_radius_m is not None and float(self.chief_orbit_radius_m) <= 0.0:
            raise ValueError("ClohessyWiltshire.chief_orbit_radius_m must be positive")
        if float(self.reference_length_m) <= 0.0:
            raise ValueError("ClohessyWiltshire.reference_length_m must be positive")
        if not str(self.chief_name).strip():
            raise ValueError("ClohessyWiltshire.chief_name must not be empty")
        if self.chief_initial_state_eci is not None:
            chief_radius = float(np.linalg.norm(self.chief_initial_state_eci.r_m))
            chief_speed = float(np.linalg.norm(self.chief_initial_state_eci.v_mps))
            expected_speed = self.mean_motion_radps * chief_radius
            radial_speed = float(
                np.dot(
                    self.chief_initial_state_eci.r_m,
                    self.chief_initial_state_eci.v_mps,
                )
                / chief_radius
            )
            if (
                self.chief_orbit_radius_m is not None
                and not np.isclose(
                    chief_radius,
                    self.chief_orbit_radius_m,
                    rtol=1.0e-6,
                    atol=1.0,
                )
            ):
                raise ValueError(
                    "chief_initial_state_eci radius must match chief_orbit_radius_m"
                )
            if not np.isclose(chief_speed, expected_speed, rtol=1.0e-5):
                raise ValueError(
                    "chief_initial_state_eci speed must match the configured circular orbit"
                )
            if abs(radial_speed) > max(1.0e-6 * chief_speed, 1.0e-6):
                raise ValueError(
                    "chief_initial_state_eci position and velocity must be perpendicular"
                )
        object.__setattr__(self, "mean_motion_radps", float(self.mean_motion_radps))
        if self.chief_orbit_radius_m is not None:
            object.__setattr__(self, "chief_orbit_radius_m", float(self.chief_orbit_radius_m))
        object.__setattr__(self, "reference_length_m", float(self.reference_length_m))

    @classmethod
    def from_circular_orbit(
        cls,
        radius_m: float,
        *,
        body: CelestialBody = EARTH,
        chief_name: str = "chief",
        reference_length_m: float = 1_000.0,
        chief_initial_state_eci: BoundaryState | None = None,
    ) -> ClohessyWiltshire:
        """Construct CWH parameters from a circular chief-orbit radius."""
        radius = float(radius_m)
        if radius <= 0.0:
            raise ValueError("radius_m must be positive")
        mean_motion = float(np.sqrt(float(body.mu_m3ps2) / radius**3))
        return cls(
            mean_motion_radps=mean_motion,
            chief_orbit_radius_m=radius,
            chief_name=chief_name,
            reference_length_m=reference_length_m,
            chief_initial_state_eci=chief_initial_state_eci,
        )

    @property
    def frame(self) -> CoordinateFrame:
        """Return the LVLH frame in which this model's states are expressed."""
        return lvlh(self.chief_name)

    @property
    def scaling(self) -> SolverScaling:
        """Return physically consistent characteristic units for this model."""
        return SolverScaling(
            length_m=self.reference_length_m,
            velocity_mps=self.mean_motion_radps * self.reference_length_m,
            time_s=1.0 / self.mean_motion_radps,
        )

frame: CoordinateFrame property

Return the LVLH frame in which this model's states are expressed.

scaling: SolverScaling property

Return physically consistent characteristic units for this model.

from_circular_orbit(radius_m: float, *, body: CelestialBody = EARTH, chief_name: str = 'chief', reference_length_m: float = 1000.0, chief_initial_state_eci: BoundaryState | None = None) -> ClohessyWiltshire classmethod

Construct CWH parameters from a circular chief-orbit radius.

Source code in octavian/relative/cwh.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@classmethod
def from_circular_orbit(
    cls,
    radius_m: float,
    *,
    body: CelestialBody = EARTH,
    chief_name: str = "chief",
    reference_length_m: float = 1_000.0,
    chief_initial_state_eci: BoundaryState | None = None,
) -> ClohessyWiltshire:
    """Construct CWH parameters from a circular chief-orbit radius."""
    radius = float(radius_m)
    if radius <= 0.0:
        raise ValueError("radius_m must be positive")
    mean_motion = float(np.sqrt(float(body.mu_m3ps2) / radius**3))
    return cls(
        mean_motion_radps=mean_motion,
        chief_orbit_radius_m=radius,
        chief_name=chief_name,
        reference_length_m=reference_length_m,
        chief_initial_state_eci=chief_initial_state_eci,
    )

cwh_derivative(state: ArrayLike, mean_motion_radps: float) -> StateVector

Evaluate the six-state unforced CWH differential equation.

Source code in octavian/relative/cwh.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def cwh_derivative(state: ArrayLike, mean_motion_radps: float) -> StateVector:
    """Evaluate the six-state unforced CWH differential equation."""
    x, y, z, xdot, ydot, zdot = np.asarray(state, dtype=float).reshape(6)
    del y  # Position y is absent from the linearized acceleration expression.
    n = float(mean_motion_radps)
    if n <= 0.0:
        raise ValueError("mean_motion_radps must be positive")
    return np.asarray(
        [
            xdot,
            ydot,
            zdot,
            3.0 * n**2 * x + 2.0 * n * ydot,
            -2.0 * n * xdot,
            -(n**2) * z,
        ],
        dtype=float,
    )

cwh_rendezvous_velocity(initial_position_m: ArrayLike, final_position_m: ArrayLike, tof_s: float, mean_motion_radps: float) -> StateVector

Return the initial velocity that connects two CWH positions in time.

Source code in octavian/relative/cwh.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def cwh_rendezvous_velocity(
    initial_position_m: ArrayLike,
    final_position_m: ArrayLike,
    tof_s: float,
    mean_motion_radps: float,
) -> StateVector:
    """Return the initial velocity that connects two CWH positions in time."""
    phi = cwh_state_transition(tof_s, mean_motion_radps)
    phi_rr = phi[0:3, 0:3]
    phi_rv = phi[0:3, 3:6]
    rhs = np.asarray(final_position_m, dtype=float).reshape(3) - phi_rr @ np.asarray(
        initial_position_m, dtype=float
    ).reshape(3)
    try:
        return np.linalg.solve(phi_rv, rhs)
    except np.linalg.LinAlgError as exc:
        raise ValueError(
            "The requested CWH transfer time is singular for position targeting; "
            "choose a different time of flight."
        ) from exc

cwh_state_transition(dt_s: float, mean_motion_radps: float) -> StateMatrix

Return the analytic 6x6 CWH state-transition matrix.

Source code in octavian/relative/cwh.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def cwh_state_transition(dt_s: float, mean_motion_radps: float) -> StateMatrix:
    """Return the analytic 6x6 CWH state-transition matrix."""
    n = float(mean_motion_radps)
    if n <= 0.0:
        raise ValueError("mean_motion_radps must be positive")
    t = float(dt_s)
    nt = n * t
    c = float(np.cos(nt))
    s = float(np.sin(nt))
    return np.asarray(
        [
            [4.0 - 3.0 * c, 0.0, 0.0, s / n, 2.0 * (1.0 - c) / n, 0.0],
            [6.0 * (s - nt), 1.0, 0.0, -2.0 * (1.0 - c) / n, (4.0 * s - 3.0 * nt) / n, 0.0],
            [0.0, 0.0, c, 0.0, 0.0, s / n],
            [3.0 * n * s, 0.0, 0.0, c, 2.0 * s, 0.0],
            [-6.0 * n * (1.0 - c), 0.0, 0.0, -2.0 * s, 4.0 * c - 3.0, 0.0],
            [0.0, 0.0, -n * s, 0.0, 0.0, c],
        ],
        dtype=float,
    )

propagate_cwh(state: ArrayLike, dt_s: float, mean_motion_radps: float) -> StateVector

Propagate one relative state with the analytic CWH solution.

Source code in octavian/relative/cwh.py
158
159
160
161
162
def propagate_cwh(state: ArrayLike, dt_s: float, mean_motion_radps: float) -> StateVector:
    """Propagate one relative state with the analytic CWH solution."""
    return cwh_state_transition(dt_s, mean_motion_radps) @ np.asarray(
        state, dtype=float
    ).reshape(6)

octavian.relative.transforms

Cartesian inertial and chief-centered RIC state transformations.

Octavian uses the common RIC/RTN/LVLH convention: radial, in-track, and cross-track axes. State transforms include the rotating-frame velocity term; simply rotating an inertial velocity difference is not a valid relative velocity transformation.

absolute_to_relative_history(chief_history: ArrayLike, deputy_history: ArrayLike, *, chief_acceleration_history_mps2: ArrayLike | None = None) -> StateHistory

Convert matching absolute histories to [rho_RIC, rho_dot_RIC, t].

Input rows may contain either six state columns or seven columns with time. If time is present in both histories, values must match and are preserved.

Source code in octavian/relative/transforms.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def absolute_to_relative_history(
    chief_history: ArrayLike,
    deputy_history: ArrayLike,
    *,
    chief_acceleration_history_mps2: ArrayLike | None = None,
) -> StateHistory:
    """Convert matching absolute histories to ``[rho_RIC, rho_dot_RIC, t]``.

    Input rows may contain either six state columns or seven columns with time.
    If time is present in both histories, values must match and are preserved.
    """
    chief_rows, deputy_rows, include_time = _matching_histories(
        chief_history, deputy_history
    )
    converted = np.empty((chief_rows.shape[0], 7 if include_time else 6), dtype=float)
    accelerations = _acceleration_history(
        chief_acceleration_history_mps2,
        chief_rows.shape[0],
    )
    for index, (chief_row, deputy_row) in enumerate(
        zip(chief_rows, deputy_rows, strict=True)
    ):
        relative = inertial_to_relative_state(
            BoundaryState(chief_row[0:3], chief_row[3:6]),
            BoundaryState(deputy_row[0:3], deputy_row[3:6]),
            chief_acceleration_mps2=(
                None if accelerations is None else accelerations[index]
            ),
        )
        converted[index, 0:6] = np.hstack([relative.r_m, relative.v_mps])
    if include_time:
        converted[:, 6] = chief_rows[:, 6]
    return converted

absolute_to_relative_state(chief: BoundaryState, deputy: BoundaryState, *, chief_acceleration_mps2: ArrayLike | None = None) -> BoundaryState

Alias for :func:inertial_to_relative_state with explicit terminology.

Source code in octavian/relative/transforms.py
118
119
120
121
122
123
124
125
126
127
128
129
def absolute_to_relative_state(
    chief: BoundaryState,
    deputy: BoundaryState,
    *,
    chief_acceleration_mps2: ArrayLike | None = None,
) -> BoundaryState:
    """Alias for :func:`inertial_to_relative_state` with explicit terminology."""
    return inertial_to_relative_state(
        chief,
        deputy,
        chief_acceleration_mps2=chief_acceleration_mps2,
    )

chief_ric_angular_velocity(chief: BoundaryState, chief_acceleration_mps2: ArrayLike | None = None) -> NDArray[np.float64]

Return the chief RIC frame angular velocity expressed in RIC.

With chief acceleration, the result is [r*a_C/h, 0, h/r²] and includes orbit-plane rotation caused by cross-track acceleration. Omitting acceleration assumes a fixed orbit plane.

Source code in octavian/relative/transforms.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def chief_ric_angular_velocity(
    chief: BoundaryState,
    chief_acceleration_mps2: ArrayLike | None = None,
) -> NDArray[np.float64]:
    """Return the chief RIC frame angular velocity expressed in RIC.

    With chief acceleration, the result is ``[r*a_C/h, 0, h/r²]`` and includes
    orbit-plane rotation caused by cross-track acceleration.  Omitting
    acceleration assumes a fixed orbit plane.
    """
    radius_sq = float(np.dot(chief.r_m, chief.r_m))
    if radius_sq <= 0.0:
        raise ValueError("Chief position must have non-zero norm")
    angular_momentum = float(np.linalg.norm(np.cross(chief.r_m, chief.v_mps)))
    if angular_momentum <= 0.0:
        raise ValueError("Chief position and velocity must define an orbital plane")
    radial_rate = 0.0
    if chief_acceleration_mps2 is not None:
        acceleration = np.asarray(chief_acceleration_mps2, dtype=float).reshape(3)
        cross_track = ric_basis(chief.r_m, chief.v_mps)[2]
        cross_track_acceleration = float(np.dot(acceleration, cross_track))
        radial_rate = (
            float(np.linalg.norm(chief.r_m))
            * cross_track_acceleration
            / angular_momentum
        )
    return np.asarray(
        [radial_rate, 0.0, angular_momentum / radius_sq],
        dtype=float,
    )

inertial_to_relative_state(chief: BoundaryState, deputy: BoundaryState, *, chief_acceleration_mps2: ArrayLike | None = None) -> BoundaryState

Transform a deputy inertial state to the chief's instantaneous RIC frame.

Source code in octavian/relative/transforms.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def inertial_to_relative_state(
    chief: BoundaryState,
    deputy: BoundaryState,
    *,
    chief_acceleration_mps2: ArrayLike | None = None,
) -> BoundaryState:
    """Transform a deputy inertial state to the chief's instantaneous RIC frame."""
    dcm = ric_basis(chief.r_m, chief.v_mps)
    relative_position = dcm @ (deputy.r_m - chief.r_m)
    inertial_relative_velocity = dcm @ (deputy.v_mps - chief.v_mps)
    relative_velocity = inertial_relative_velocity - np.cross(
        chief_ric_angular_velocity(chief, chief_acceleration_mps2),
        relative_position,
    )
    return BoundaryState(relative_position, relative_velocity)

lvlh_basis(chief_position_m: ArrayLike, chief_velocity_mps: ArrayLike) -> Matrix3

Return the inertial-to-LVLH/RTN matrix.

This compatibility name is exactly equivalent to :func:ric_basis.

Source code in octavian/relative/transforms.py
42
43
44
45
46
47
def lvlh_basis(chief_position_m: ArrayLike, chief_velocity_mps: ArrayLike) -> Matrix3:
    """Return the inertial-to-LVLH/RTN matrix.

    This compatibility name is exactly equivalent to :func:`ric_basis`.
    """
    return ric_basis(chief_position_m, chief_velocity_mps)

relative_to_absolute_history(chief_history: ArrayLike, relative_history: ArrayLike, *, chief_acceleration_history_mps2: ArrayLike | None = None) -> StateHistory

Reconstruct deputy absolute history from matching chief and RIC rows.

Input rows may contain either six state columns or seven columns with time. If time is present in both histories, values must match and are preserved.

Source code in octavian/relative/transforms.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def relative_to_absolute_history(
    chief_history: ArrayLike,
    relative_history: ArrayLike,
    *,
    chief_acceleration_history_mps2: ArrayLike | None = None,
) -> StateHistory:
    """Reconstruct deputy absolute history from matching chief and RIC rows.

    Input rows may contain either six state columns or seven columns with time.
    If time is present in both histories, values must match and are preserved.
    """
    chief_rows, relative_rows, include_time = _matching_histories(
        chief_history, relative_history
    )
    converted = np.empty((chief_rows.shape[0], 7 if include_time else 6), dtype=float)
    accelerations = _acceleration_history(
        chief_acceleration_history_mps2,
        chief_rows.shape[0],
    )
    for index, (chief_row, relative_row) in enumerate(
        zip(chief_rows, relative_rows, strict=True)
    ):
        deputy = relative_to_inertial_state(
            BoundaryState(chief_row[0:3], chief_row[3:6]),
            BoundaryState(relative_row[0:3], relative_row[3:6]),
            chief_acceleration_mps2=(
                None if accelerations is None else accelerations[index]
            ),
        )
        converted[index, 0:6] = np.hstack([deputy.r_m, deputy.v_mps])
    if include_time:
        converted[:, 6] = chief_rows[:, 6]
    return converted

relative_to_absolute_state(chief: BoundaryState, relative: BoundaryState, *, chief_acceleration_mps2: ArrayLike | None = None) -> BoundaryState

Return the reconstructed deputy absolute Cartesian state.

Source code in octavian/relative/transforms.py
132
133
134
135
136
137
138
139
140
141
142
143
def relative_to_absolute_state(
    chief: BoundaryState,
    relative: BoundaryState,
    *,
    chief_acceleration_mps2: ArrayLike | None = None,
) -> BoundaryState:
    """Return the reconstructed deputy absolute Cartesian state."""
    return relative_to_inertial_state(
        chief,
        relative,
        chief_acceleration_mps2=chief_acceleration_mps2,
    )

relative_to_inertial_state(chief: BoundaryState, relative: BoundaryState, *, chief_acceleration_mps2: ArrayLike | None = None) -> BoundaryState

Transform a chief-centered RIC state to inertial Cartesian coordinates.

Source code in octavian/relative/transforms.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def relative_to_inertial_state(
    chief: BoundaryState,
    relative: BoundaryState,
    *,
    chief_acceleration_mps2: ArrayLike | None = None,
) -> BoundaryState:
    """Transform a chief-centered RIC state to inertial Cartesian coordinates."""
    dcm = ric_basis(chief.r_m, chief.v_mps)
    inertial_position = chief.r_m + dcm.T @ relative.r_m
    inertial_velocity = chief.v_mps + dcm.T @ (
        relative.v_mps
        + np.cross(
            chief_ric_angular_velocity(chief, chief_acceleration_mps2),
            relative.r_m,
        )
    )
    return BoundaryState(inertial_position, inertial_velocity)

ric_basis(chief_position_m: ArrayLike, chief_velocity_mps: ArrayLike) -> Matrix3

Return the inertial-to-RIC direction-cosine matrix.

Matrix rows are radial, in-track, and cross-track unit vectors. Left multiplication therefore rotates an inertial vector into RIC coordinates; the transpose rotates a RIC vector back to inertial coordinates.

Source code in octavian/relative/transforms.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def ric_basis(chief_position_m: ArrayLike, chief_velocity_mps: ArrayLike) -> Matrix3:
    """Return the inertial-to-RIC direction-cosine matrix.

    Matrix rows are radial, in-track, and cross-track unit vectors.  Left
    multiplication therefore rotates an inertial vector into RIC coordinates;
    the transpose rotates a RIC vector back to inertial coordinates.
    """
    position = np.asarray(chief_position_m, dtype=float).reshape(3)
    velocity = np.asarray(chief_velocity_mps, dtype=float).reshape(3)
    radius = float(np.linalg.norm(position))
    angular_momentum = np.cross(position, velocity)
    momentum_norm = float(np.linalg.norm(angular_momentum))
    if radius <= 0.0:
        raise ValueError("chief_position_m must have non-zero norm")
    if momentum_norm <= 0.0:
        raise ValueError("Chief position and velocity must define an orbital plane")
    radial = position / radius
    normal = angular_momentum / momentum_norm
    along_track = np.cross(normal, radial)
    return np.vstack([radial, along_track, normal])

octavian.relative.elements

Relative orbital-element types, conversions, and two-body propagation.

ClassicalRelativeOrbitalElements dataclass

Differences between deputy and chief classical orbital elements.

The vector is [Δa, Δe, Δi, ΔΩ, Δω, ΔM]. Δa is meters and all angular differences are radians. This representation is intuitive but inherits the circular/equatorial singularities of classical elements; use :class:RelativeOrbitalElements for D'Amico's quasi-nonsingular form.

Source code in octavian/relative/elements.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
@dataclass(frozen=True, slots=True)
class ClassicalRelativeOrbitalElements:
    """Differences between deputy and chief classical orbital elements.

    The vector is ``[Δa, Δe, Δi, ΔΩ, Δω, ΔM]``. ``Δa`` is meters and all
    angular differences are radians. This representation is intuitive but
    inherits the circular/equatorial singularities of classical elements; use
    :class:`RelativeOrbitalElements` for D'Amico's quasi-nonsingular form.
    """

    delta_a_m: float
    delta_e: float
    delta_i_rad: float
    delta_raan_rad: float
    delta_argp_rad: float
    delta_mean_anomaly_rad: float

    def __post_init__(self) -> None:
        values = np.asarray(self.as_vector(), dtype=float)
        if not np.all(np.isfinite(values)):
            raise ValueError("Classical relative orbital elements must be finite")

    def as_vector(self) -> NDArray[np.float64]:
        """Return ``[Δa, Δe, Δi, ΔΩ, Δω, ΔM]`` in meters/radians."""
        return np.asarray(
            [
                self.delta_a_m,
                self.delta_e,
                self.delta_i_rad,
                self.delta_raan_rad,
                self.delta_argp_rad,
                self.delta_mean_anomaly_rad,
            ],
            dtype=float,
        )

    @classmethod
    def from_vector(cls, values: ArrayLike) -> ClassicalRelativeOrbitalElements:
        """Construct classical relative elements from a six-value vector."""
        return cls(*np.asarray(values, dtype=float).reshape(6))

as_vector() -> NDArray[np.float64]

Return [Δa, Δe, Δi, ΔΩ, Δω, ΔM] in meters/radians.

Source code in octavian/relative/elements.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def as_vector(self) -> NDArray[np.float64]:
    """Return ``[Δa, Δe, Δi, ΔΩ, Δω, ΔM]`` in meters/radians."""
    return np.asarray(
        [
            self.delta_a_m,
            self.delta_e,
            self.delta_i_rad,
            self.delta_raan_rad,
            self.delta_argp_rad,
            self.delta_mean_anomaly_rad,
        ],
        dtype=float,
    )

from_vector(values: ArrayLike) -> ClassicalRelativeOrbitalElements classmethod

Construct classical relative elements from a six-value vector.

Source code in octavian/relative/elements.py
111
112
113
114
@classmethod
def from_vector(cls, values: ArrayLike) -> ClassicalRelativeOrbitalElements:
    """Construct classical relative elements from a six-value vector."""
    return cls(*np.asarray(values, dtype=float).reshape(6))

RelativeElementPropagationResult dataclass

Paired osculating-element and RIC histories from one propagation.

Attributes: elements: (N, 7) native relative-element and elapsed-time rows. ric: (N, 7) equivalent RIC Cartesian state and elapsed-time rows. representation: "damico" or "classical_elements".

Source code in octavian/relative/elements.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@dataclass(frozen=True, slots=True)
class RelativeElementPropagationResult:
    """Paired osculating-element and RIC histories from one propagation.

    Attributes:
        elements: ``(N, 7)`` native relative-element and elapsed-time rows.
        ric: ``(N, 7)`` equivalent RIC Cartesian state and elapsed-time rows.
        representation: ``"damico"`` or ``"classical_elements"``.
    """

    elements: NDArray[np.float64]
    ric: NDArray[np.float64]
    representation: str

    def __post_init__(self) -> None:
        elements = np.asarray(self.elements, dtype=float)
        ric = np.asarray(self.ric, dtype=float)
        representation = _normalize_relative_representation(self.representation)
        if elements.ndim != 2 or elements.shape[1] != 7:
            raise ValueError("elements must have shape (N, 7)")
        if ric.shape != elements.shape:
            raise ValueError("ric must have the same (N, 7) shape as elements")
        if not np.all(np.isfinite(elements)) or not np.all(np.isfinite(ric)):
            raise ValueError("Propagation histories must contain finite values")
        if not np.allclose(elements[:, 6], ric[:, 6], rtol=0.0, atol=1.0e-12):
            raise ValueError("Element and RIC histories must use the same times")
        object.__setattr__(self, "elements", elements)
        object.__setattr__(self, "ric", ric)
        object.__setattr__(self, "representation", representation)

    @property
    def times_s(self) -> NDArray[np.float64]:
        """Return elapsed output times without copying the history."""
        return self.elements[:, 6]

times_s: NDArray[np.float64] property

Return elapsed output times without copying the history.

RelativeOrbitalElements dataclass

D'Amico-style quasi-nonsingular relative orbital elements.

The dimensionless/angular vector is [δa, δλ, δex, δey, δix, δiy]:

  • δa = (a_d - a_c) / a_c;
  • δλ = (u_d - u_c) + (Ω_d - Ω_c) cos(i_c);
  • δex, δey are differences of eccentricity-vector components;
  • δix = i_d - i_c;
  • δiy = (Ω_d - Ω_c) sin(i_c).

Angles are radians and wrapped to [-π, π) when constructed from Cartesian states.

Source code in octavian/relative/elements.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@dataclass(frozen=True, slots=True)
class RelativeOrbitalElements:
    """D'Amico-style quasi-nonsingular relative orbital elements.

    The dimensionless/angular vector is ``[δa, δλ, δex, δey, δix, δiy]``:

    - ``δa = (a_d - a_c) / a_c``;
    - ``δλ = (u_d - u_c) + (Ω_d - Ω_c) cos(i_c)``;
    - ``δex, δey`` are differences of eccentricity-vector components;
    - ``δix = i_d - i_c``;
    - ``δiy = (Ω_d - Ω_c) sin(i_c)``.

    Angles are radians and wrapped to ``[-π, π)`` when constructed from
    Cartesian states.
    """

    delta_a: float
    delta_lambda_rad: float
    delta_ex: float
    delta_ey: float
    delta_ix_rad: float
    delta_iy_rad: float

    def __post_init__(self) -> None:
        values = np.asarray(self.as_vector(), dtype=float)
        if not np.all(np.isfinite(values)):
            raise ValueError("Relative orbital elements must be finite")

    def as_vector(self) -> NDArray[np.float64]:
        """Return ``[δa, δλ, δex, δey, δix, δiy]``."""
        return np.asarray(
            [
                self.delta_a,
                self.delta_lambda_rad,
                self.delta_ex,
                self.delta_ey,
                self.delta_ix_rad,
                self.delta_iy_rad,
            ],
            dtype=float,
        )

    @classmethod
    def from_vector(cls, values: ArrayLike) -> RelativeOrbitalElements:
        """Construct relative orbital elements from a six-value vector."""
        return cls(*np.asarray(values, dtype=float).reshape(6))

as_vector() -> NDArray[np.float64]

Return [δa, δλ, δex, δey, δix, δiy].

Source code in octavian/relative/elements.py
55
56
57
58
59
60
61
62
63
64
65
66
67
def as_vector(self) -> NDArray[np.float64]:
    """Return ``[δa, δλ, δex, δey, δix, δiy]``."""
    return np.asarray(
        [
            self.delta_a,
            self.delta_lambda_rad,
            self.delta_ex,
            self.delta_ey,
            self.delta_ix_rad,
            self.delta_iy_rad,
        ],
        dtype=float,
    )

from_vector(values: ArrayLike) -> RelativeOrbitalElements classmethod

Construct relative orbital elements from a six-value vector.

Source code in octavian/relative/elements.py
69
70
71
72
@classmethod
def from_vector(cls, values: ArrayLike) -> RelativeOrbitalElements:
    """Construct relative orbital elements from a six-value vector."""
    return cls(*np.asarray(values, dtype=float).reshape(6))

absolute_to_classical_relative_orbital_elements(chief: BoundaryState, deputy: BoundaryState, *, mu_m3ps2: float) -> ClassicalRelativeOrbitalElements

Convert two absolute Cartesian states to classical element differences.

Source code in octavian/relative/elements.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def absolute_to_classical_relative_orbital_elements(
    chief: BoundaryState,
    deputy: BoundaryState,
    *,
    mu_m3ps2: float,
) -> ClassicalRelativeOrbitalElements:
    """Convert two absolute Cartesian states to classical element differences."""
    chief_elements = _cartesian_elements(chief, mu_m3ps2)
    deputy_elements = _cartesian_elements(deputy, mu_m3ps2)
    return ClassicalRelativeOrbitalElements(
        delta_a_m=deputy_elements["a_m"] - chief_elements["a_m"],
        delta_e=deputy_elements["e"] - chief_elements["e"],
        delta_i_rad=_wrap_angle(deputy_elements["inc_rad"] - chief_elements["inc_rad"]),
        delta_raan_rad=_wrap_angle(deputy_elements["raan_rad"] - chief_elements["raan_rad"]),
        delta_argp_rad=_wrap_angle(deputy_elements["argp_rad"] - chief_elements["argp_rad"]),
        delta_mean_anomaly_rad=_wrap_angle(
            deputy_elements["mean_anomaly_rad"] - chief_elements["mean_anomaly_rad"]
        ),
    )

absolute_to_relative_orbital_elements(chief: BoundaryState, deputy: BoundaryState, *, mu_m3ps2: float) -> RelativeOrbitalElements

Convert chief/deputy absolute Cartesian states to relative elements.

Source code in octavian/relative/elements.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def absolute_to_relative_orbital_elements(
    chief: BoundaryState,
    deputy: BoundaryState,
    *,
    mu_m3ps2: float,
) -> RelativeOrbitalElements:
    """Convert chief/deputy absolute Cartesian states to relative elements."""
    chief_elements = _cartesian_elements(chief, mu_m3ps2)
    deputy_elements = _cartesian_elements(deputy, mu_m3ps2)

    chief_eccentricity = chief_elements["e"]
    deputy_eccentricity = deputy_elements["e"]
    chief_argument = chief_elements["argp_rad"]
    deputy_argument = deputy_elements["argp_rad"]
    delta_raan = _wrap_angle(deputy_elements["raan_rad"] - chief_elements["raan_rad"])
    chief_mean_argument_latitude = _wrap_angle(chief_elements["mean_anomaly_rad"] + chief_argument)
    deputy_mean_argument_latitude = _wrap_angle(
        deputy_elements["mean_anomaly_rad"] + deputy_argument
    )

    return RelativeOrbitalElements(
        delta_a=(deputy_elements["a_m"] - chief_elements["a_m"]) / chief_elements["a_m"],
        delta_lambda_rad=_wrap_angle(
            deputy_mean_argument_latitude
            - chief_mean_argument_latitude
            + delta_raan * np.cos(chief_elements["inc_rad"])
        ),
        delta_ex=deputy_eccentricity * np.cos(deputy_argument)
        - chief_eccentricity * np.cos(chief_argument),
        delta_ey=deputy_eccentricity * np.sin(deputy_argument)
        - chief_eccentricity * np.sin(chief_argument),
        delta_ix_rad=_wrap_angle(deputy_elements["inc_rad"] - chief_elements["inc_rad"]),
        delta_iy_rad=delta_raan * np.sin(chief_elements["inc_rad"]),
    )

classical_relative_orbital_elements_to_absolute_state(chief: BoundaryState, relative_elements: ClassicalRelativeOrbitalElements | ArrayLike, *, mu_m3ps2: float) -> BoundaryState

Reconstruct the deputy from chief state and classical differences.

Source code in octavian/relative/elements.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def classical_relative_orbital_elements_to_absolute_state(
    chief: BoundaryState,
    relative_elements: ClassicalRelativeOrbitalElements | ArrayLike,
    *,
    mu_m3ps2: float,
) -> BoundaryState:
    """Reconstruct the deputy from chief state and classical differences."""
    elements = (
        relative_elements
        if isinstance(relative_elements, ClassicalRelativeOrbitalElements)
        else ClassicalRelativeOrbitalElements.from_vector(relative_elements)
    )
    chief_elements = _cartesian_elements(chief, mu_m3ps2)
    deputy_a_m = chief_elements["a_m"] + elements.delta_a_m
    deputy_e = chief_elements["e"] + elements.delta_e
    deputy_i = chief_elements["inc_rad"] + elements.delta_i_rad
    if deputy_a_m <= 0.0:
        raise ValueError("Classical relative elements produce a non-positive semi-major axis")
    if not (0.0 <= deputy_e < 1.0):
        raise ValueError("Classical relative elements currently require an elliptic deputy orbit")
    if not (0.0 <= deputy_i <= np.pi):
        raise ValueError("Classical relative elements produce inclination outside [0, pi]")
    deputy_mean_anomaly = chief_elements["mean_anomaly_rad"] + elements.delta_mean_anomaly_rad
    deputy_true_anomaly = _mean_to_true_anomaly(
        deputy_mean_anomaly,
        deputy_e,
    )
    position, velocity = classical_to_cartesian(
        a_m=deputy_a_m,
        e=deputy_e,
        inc_deg=float(np.rad2deg(deputy_i)),
        raan_deg=float(
            np.rad2deg(_wrap_angle(chief_elements["raan_rad"] + elements.delta_raan_rad))
        ),
        argp_deg=float(
            np.rad2deg(_wrap_angle(chief_elements["argp_rad"] + elements.delta_argp_rad))
        ),
        true_anomaly_deg=float(np.rad2deg(deputy_true_anomaly)),
        mu_m3ps2=float(mu_m3ps2),
    )
    return BoundaryState(position, velocity)

classical_relative_orbital_elements_to_relative_state(chief: BoundaryState, relative_elements: ClassicalRelativeOrbitalElements | ArrayLike, *, mu_m3ps2: float) -> BoundaryState

Convert classical element differences directly to a deputy RIC state.

Source code in octavian/relative/elements.py
366
367
368
369
370
371
372
373
374
375
376
377
378
def classical_relative_orbital_elements_to_relative_state(
    chief: BoundaryState,
    relative_elements: ClassicalRelativeOrbitalElements | ArrayLike,
    *,
    mu_m3ps2: float,
) -> BoundaryState:
    """Convert classical element differences directly to a deputy RIC state."""
    deputy = classical_relative_orbital_elements_to_absolute_state(
        chief,
        relative_elements,
        mu_m3ps2=mu_m3ps2,
    )
    return inertial_to_relative_state(chief, deputy)

classical_to_damico_relative_orbital_elements(chief: BoundaryState, relative_elements: ClassicalRelativeOrbitalElements | ArrayLike, *, mu_m3ps2: float) -> RelativeOrbitalElements

Convert classical element differences to D'Amico elements at one epoch.

Source code in octavian/relative/elements.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
def classical_to_damico_relative_orbital_elements(
    chief: BoundaryState,
    relative_elements: ClassicalRelativeOrbitalElements | ArrayLike,
    *,
    mu_m3ps2: float,
) -> RelativeOrbitalElements:
    """Convert classical element differences to D'Amico elements at one epoch."""
    deputy = classical_relative_orbital_elements_to_absolute_state(
        chief,
        relative_elements,
        mu_m3ps2=mu_m3ps2,
    )
    return absolute_to_relative_orbital_elements(
        chief,
        deputy,
        mu_m3ps2=mu_m3ps2,
    )

damico_to_classical_relative_orbital_elements(chief: BoundaryState, relative_elements: RelativeOrbitalElements | ArrayLike, *, mu_m3ps2: float) -> ClassicalRelativeOrbitalElements

Convert D'Amico elements to classical differences at one epoch.

Source code in octavian/relative/elements.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
def damico_to_classical_relative_orbital_elements(
    chief: BoundaryState,
    relative_elements: RelativeOrbitalElements | ArrayLike,
    *,
    mu_m3ps2: float,
) -> ClassicalRelativeOrbitalElements:
    """Convert D'Amico elements to classical differences at one epoch."""
    deputy = relative_orbital_elements_to_absolute_state(
        chief,
        relative_elements,
        mu_m3ps2=mu_m3ps2,
    )
    return absolute_to_classical_relative_orbital_elements(
        chief,
        deputy,
        mu_m3ps2=mu_m3ps2,
    )

propagate_relative_element_history(initial_elements: RelativeElements | ArrayLike, times_s: ArrayLike, *, chief_initial_state_eci: BoundaryState, mu_m3ps2: float, representation: str = 'damico', central_body: CelestialBody | str = EARTH, perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | int | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP, chief_spacecraft: Spacecraft | None = None, deputy_spacecraft: Spacecraft | None = None) -> RelativeElementPropagationResult

Propagate relative elements and return both native and RIC histories.

This is the consolidated analysis entry point for relative orbital elements. Perturbed propagation advances the coupled chief/deputy states only once, then derives both output representations from that history. The older representation-specific functions remain available for scripts that need only one array.

Args: initial_elements: Initial D'Amico or classical relative elements. times_s: Requested elapsed output times. chief_initial_state_eci: Chief Cartesian state at elapsed time zero. mu_m3ps2: Central-body gravitational parameter. representation: "damico" or "classical_elements". central_body: Body constants used by numerical perturbations. perturbations: Optional differential force model. initial_epoch: UTC or SPICE ET at time zero. Required for Sun, Moon, or SRP. max_step_s: Maximum internal RK4 step for numerical propagation. ephemeris_step_s: Sun/Moon interpolation spacing. bsp_path: SPICE BSP containing Earth-centered Sun/Moon states. chief_spacecraft: Optional chief mass and cannonball properties. deputy_spacecraft: Deputy mass and cannonball properties, required for drag or SRP.

Returns: Native relative elements and equivalent RIC states sharing one time vector.

Source code in octavian/relative/elements.py
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
def propagate_relative_element_history(
    initial_elements: RelativeElements | ArrayLike,
    times_s: ArrayLike,
    *,
    chief_initial_state_eci: BoundaryState,
    mu_m3ps2: float,
    representation: str = "damico",
    central_body: CelestialBody | str = EARTH,
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
    chief_spacecraft: Spacecraft | None = None,
    deputy_spacecraft: Spacecraft | None = None,
) -> RelativeElementPropagationResult:
    """Propagate relative elements and return both native and RIC histories.

    This is the consolidated analysis entry point for relative orbital
    elements. Perturbed propagation advances the coupled chief/deputy states
    only once, then derives both output representations from that history.
    The older representation-specific functions remain available for scripts
    that need only one array.

    Args:
        initial_elements: Initial D'Amico or classical relative elements.
        times_s: Requested elapsed output times.
        chief_initial_state_eci: Chief Cartesian state at elapsed time zero.
        mu_m3ps2: Central-body gravitational parameter.
        representation: ``"damico"`` or ``"classical_elements"``.
        central_body: Body constants used by numerical perturbations.
        perturbations: Optional differential force model.
        initial_epoch: UTC or SPICE ET at time zero. Required for Sun, Moon,
            or SRP.
        max_step_s: Maximum internal RK4 step for numerical propagation.
        ephemeris_step_s: Sun/Moon interpolation spacing.
        bsp_path: SPICE BSP containing Earth-centered Sun/Moon states.
        chief_spacecraft: Optional chief mass and cannonball properties.
        deputy_spacecraft: Deputy mass and cannonball properties, required for
            drag or SRP.

    Returns:
        Native relative elements and equivalent RIC states sharing one time
        vector.
    """
    normalized = _normalize_relative_representation(representation)
    if perturbations is None:
        elements = propagate_relative_orbital_elements(
            initial_elements,
            times_s,
            chief_initial_state_eci=chief_initial_state_eci,
            mu_m3ps2=mu_m3ps2,
            representation=normalized,
        )
        ric = propagate_relative_elements_to_ric(
            initial_elements,
            times_s,
            chief_initial_state_eci=chief_initial_state_eci,
            mu_m3ps2=mu_m3ps2,
            representation=normalized,
        )
        return RelativeElementPropagationResult(
            elements=elements,
            ric=ric,
            representation=normalized,
        )

    spacecraft_values = {
        "chief_spacecraft": chief_spacecraft,
        "deputy_spacecraft": deputy_spacecraft,
    }
    supported_parameters = signature(
        _propagate_relative_elements_numerical
    ).parameters
    accepts_any_keyword = any(
        parameter.kind is Parameter.VAR_KEYWORD
        for parameter in supported_parameters.values()
    )
    accepted_spacecraft = {
        name
        for name in spacecraft_values
        if name in supported_parameters or accepts_any_keyword
    }
    unsupported = {
        name
        for name, value in spacecraft_values.items()
        if value is not None and name not in accepted_spacecraft
    }
    if unsupported:
        raise NotImplementedError(
            "This Octavian build does not provide cannonball spacecraft "
            "properties for relative-element propagation"
        )
    optional_kwargs = {
        name: value
        for name, value in spacecraft_values.items()
        if name in accepted_spacecraft
    }

    numerical = _propagate_relative_elements_numerical(
        initial_elements,
        times_s,
        chief_initial_state_eci=chief_initial_state_eci,
        mu_m3ps2=mu_m3ps2,
        representation=normalized,
        central_body=central_body,
        perturbations=perturbations,
        initial_epoch=initial_epoch,
        max_step_s=max_step_s,
        ephemeris_step_s=ephemeris_step_s,
        bsp_path=bsp_path,
        **optional_kwargs,
    )
    return RelativeElementPropagationResult(
        elements=_relative_elements_from_coupled_history(
            numerical,
            representation=normalized,
            mu_m3ps2=mu_m3ps2,
        ),
        ric=numerical.relative_trajectory_ric,
        representation=normalized,
    )

propagate_relative_elements_to_ric(initial_elements: RelativeElements | ArrayLike, times_s: ArrayLike, *, chief_initial_state_eci: BoundaryState, mu_m3ps2: float, representation: str = 'damico', central_body: CelestialBody | str = EARTH, perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | int | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP, chief_spacecraft: Spacecraft | None = None, deputy_spacecraft: Spacecraft | None = None) -> NDArray[np.float64]

Propagate relative elements and return equivalent RIC state history.

With perturbations=None this uses analytical two-body element drift and supports arbitrary finite output times, including negative analysis times. Supplying :class:octavian.Perturbations selects coupled numerical chief/deputy propagation; those output times must be strictly monotonic with zero at one endpoint.

Args: initial_elements: Initial D'Amico or classical relative elements. times_s: Requested elapsed output times. chief_initial_state_eci: Chief Cartesian state at elapsed time zero. mu_m3ps2: Central-body gravitational parameter. representation: "damico" or "classical_elements". central_body: Body constants used by numerical central gravity and J2. perturbations: Optional differential gravity, drag, and SRP configuration. initial_epoch: UTC or SPICE ET at time zero. Required for Sun, Moon, or SRP. max_step_s: Maximum internal RK4 step for numerical propagation. ephemeris_step_s: Sun/Moon interpolation spacing. bsp_path: SPICE BSP containing Earth-centered Sun/Moon states. chief_spacecraft: Optional chief mass and cannonball properties. deputy_spacecraft: Deputy mass and cannonball properties, required for drag or SRP.

Returns: An (N, 7) RIC state-and-time history ready for plotting.

Source code in octavian/relative/elements.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
def propagate_relative_elements_to_ric(
    initial_elements: RelativeElements | ArrayLike,
    times_s: ArrayLike,
    *,
    chief_initial_state_eci: BoundaryState,
    mu_m3ps2: float,
    representation: str = "damico",
    central_body: CelestialBody | str = EARTH,
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
    chief_spacecraft: Spacecraft | None = None,
    deputy_spacecraft: Spacecraft | None = None,
) -> NDArray[np.float64]:
    """Propagate relative elements and return equivalent RIC state history.

    With ``perturbations=None`` this uses analytical two-body element drift and
    supports arbitrary finite output times, including negative analysis times.
    Supplying :class:`octavian.Perturbations` selects coupled numerical
    chief/deputy propagation; those output times must be strictly monotonic
    with zero at one endpoint.

    Args:
        initial_elements: Initial D'Amico or classical relative elements.
        times_s: Requested elapsed output times.
        chief_initial_state_eci: Chief Cartesian state at elapsed time zero.
        mu_m3ps2: Central-body gravitational parameter.
        representation: ``"damico"`` or ``"classical_elements"``.
        central_body: Body constants used by numerical central gravity and J2.
        perturbations: Optional differential gravity, drag, and SRP configuration.
        initial_epoch: UTC or SPICE ET at time zero. Required for Sun, Moon,
            or SRP.
        max_step_s: Maximum internal RK4 step for numerical propagation.
        ephemeris_step_s: Sun/Moon interpolation spacing.
        bsp_path: SPICE BSP containing Earth-centered Sun/Moon states.
        chief_spacecraft: Optional chief mass and cannonball properties.
        deputy_spacecraft: Deputy mass and cannonball properties, required for
            drag or SRP.

    Returns:
        An ``(N, 7)`` RIC state-and-time history ready for plotting.
    """
    if perturbations is not None:
        numerical = _propagate_relative_elements_numerical(
            initial_elements,
            times_s,
            chief_initial_state_eci=chief_initial_state_eci,
            mu_m3ps2=mu_m3ps2,
            representation=representation,
            central_body=central_body,
            perturbations=perturbations,
            initial_epoch=initial_epoch,
            max_step_s=max_step_s,
            ephemeris_step_s=ephemeris_step_s,
            bsp_path=bsp_path,
            chief_spacecraft=chief_spacecraft,
            deputy_spacecraft=deputy_spacecraft,
        )
        return numerical.relative_trajectory_ric

    element_history = propagate_relative_orbital_elements(
        initial_elements,
        times_s,
        chief_initial_state_eci=chief_initial_state_eci,
        mu_m3ps2=mu_m3ps2,
        representation=representation,
    )
    output = np.empty((element_history.shape[0], 7), dtype=float)
    for index, row in enumerate(element_history):
        chief = propagate_two_body_state(
            chief_initial_state_eci,
            float(row[6]),
            mu_m3ps2,
        )
        relative = (
            relative_orbital_elements_to_relative_state(
                chief,
                row[0:6],
                mu_m3ps2=mu_m3ps2,
            )
            if str(representation).strip().lower().replace("-", "_") == "damico"
            else classical_relative_orbital_elements_to_relative_state(
                chief,
                row[0:6],
                mu_m3ps2=mu_m3ps2,
            )
        )
        output[index] = np.hstack([relative.r_m, relative.v_mps, row[6]])
    return output

propagate_relative_orbital_elements(initial_elements: RelativeElements | ArrayLike, times_s: ArrayLike, *, chief_initial_state_eci: BoundaryState, mu_m3ps2: float, representation: str = 'damico', central_body: CelestialBody | str = EARTH, perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | int | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP, chief_spacecraft: Spacecraft | None = None, deputy_spacecraft: Spacecraft | None = None) -> NDArray[np.float64]

Propagate relative elements at requested elapsed times.

The returned rows contain six relative elements followed by elapsed time. Without perturbations, the fast analytical two-body model keeps every element constant except D'Amico δλ or classical ΔM. With a perturbation configuration, the chief and deputy are instead propagated as coupled absolute states and converted back to osculating relative elements at every requested output time.

Args: initial_elements: Initial D'Amico or classical relative elements. times_s: Finite elapsed output times. Perturbed propagation requires strictly monotonic values with zero at one endpoint. chief_initial_state_eci: Chief Cartesian state at elapsed time zero. mu_m3ps2: Central-body gravitational parameter. representation: "damico" or "classical_elements". central_body: Body constants used by numerical central gravity and J2. perturbations: Optional differential gravity, drag, and SRP configuration. initial_epoch: UTC or SPICE ET at time zero. Required for Sun, Moon, or SRP. max_step_s: Maximum internal RK4 step for numerical propagation. ephemeris_step_s: Sun/Moon interpolation spacing. bsp_path: SPICE BSP containing Earth-centered Sun/Moon states. chief_spacecraft: Optional chief mass and cannonball properties. deputy_spacecraft: Deputy mass and cannonball properties, required for drag or SRP.

Returns: An (N, 7) array of native element states and time.

Source code in octavian/relative/elements.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
def propagate_relative_orbital_elements(
    initial_elements: RelativeElements | ArrayLike,
    times_s: ArrayLike,
    *,
    chief_initial_state_eci: BoundaryState,
    mu_m3ps2: float,
    representation: str = "damico",
    central_body: CelestialBody | str = EARTH,
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
    chief_spacecraft: Spacecraft | None = None,
    deputy_spacecraft: Spacecraft | None = None,
) -> NDArray[np.float64]:
    """Propagate relative elements at requested elapsed times.

    The returned rows contain six relative elements followed by elapsed time.
    Without ``perturbations``, the fast analytical two-body model keeps every
    element constant except D'Amico ``δλ`` or classical ``ΔM``. With a
    perturbation configuration, the chief and deputy are instead propagated
    as coupled absolute states and converted back to osculating relative
    elements at every requested output time.

    Args:
        initial_elements: Initial D'Amico or classical relative elements.
        times_s: Finite elapsed output times. Perturbed propagation requires
            strictly monotonic values with zero at one endpoint.
        chief_initial_state_eci: Chief Cartesian state at elapsed time zero.
        mu_m3ps2: Central-body gravitational parameter.
        representation: ``"damico"`` or ``"classical_elements"``.
        central_body: Body constants used by numerical central gravity and J2.
        perturbations: Optional differential gravity, drag, and SRP configuration.
        initial_epoch: UTC or SPICE ET at time zero. Required for Sun, Moon,
            or SRP.
        max_step_s: Maximum internal RK4 step for numerical propagation.
        ephemeris_step_s: Sun/Moon interpolation spacing.
        bsp_path: SPICE BSP containing Earth-centered Sun/Moon states.
        chief_spacecraft: Optional chief mass and cannonball properties.
        deputy_spacecraft: Deputy mass and cannonball properties, required for
            drag or SRP.

    Returns:
        An ``(N, 7)`` array of native element states and time.
    """
    if perturbations is not None:
        numerical = _propagate_relative_elements_numerical(
            initial_elements,
            times_s,
            chief_initial_state_eci=chief_initial_state_eci,
            mu_m3ps2=mu_m3ps2,
            representation=representation,
            central_body=central_body,
            perturbations=perturbations,
            initial_epoch=initial_epoch,
            max_step_s=max_step_s,
            ephemeris_step_s=ephemeris_step_s,
            bsp_path=bsp_path,
            chief_spacecraft=chief_spacecraft,
            deputy_spacecraft=deputy_spacecraft,
        )
        return _relative_elements_from_coupled_history(
            numerical,
            representation=representation,
            mu_m3ps2=mu_m3ps2,
        )

    times = np.asarray(times_s, dtype=float).reshape(-1)
    if times.size == 0 or not np.all(np.isfinite(times)):
        raise ValueError("times_s must contain at least one finite value")
    normalized = _normalize_relative_representation(representation)
    if isinstance(initial_elements, RelativeOrbitalElements):
        vector = initial_elements.as_vector()
        inferred = "damico"
    elif isinstance(initial_elements, ClassicalRelativeOrbitalElements):
        vector = initial_elements.as_vector()
        inferred = "classical_elements"
    else:
        vector = np.asarray(initial_elements, dtype=float).reshape(6)
        inferred = normalized
    if inferred != normalized:
        raise ValueError(f"initial_elements uses {inferred!r}, not requested {normalized!r}")
    if not np.all(np.isfinite(vector)):
        raise ValueError("initial_elements must contain finite values")

    chief_a_m = _cartesian_elements(
        chief_initial_state_eci,
        mu_m3ps2,
    )["a_m"]
    deputy_a_m = chief_a_m * (1.0 + vector[0]) if normalized == "damico" else chief_a_m + vector[0]
    if deputy_a_m <= 0.0:
        raise ValueError("Relative elements produce a non-positive deputy orbit")
    relative_rate = np.sqrt(float(mu_m3ps2) / deputy_a_m**3) - np.sqrt(
        float(mu_m3ps2) / chief_a_m**3
    )
    result = np.repeat(vector[None, :], times.size, axis=0)
    evolving_index = 1 if normalized == "damico" else 5
    result[:, evolving_index] = vector[evolving_index] + relative_rate * times
    return np.column_stack([result, times])

propagate_two_body_state(initial_state: BoundaryState, elapsed_time_s: float, mu_m3ps2: float) -> BoundaryState

Propagate an elliptic Cartesian state analytically under two-body gravity.

Source code in octavian/relative/elements.py
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
def propagate_two_body_state(
    initial_state: BoundaryState,
    elapsed_time_s: float,
    mu_m3ps2: float,
) -> BoundaryState:
    """Propagate an elliptic Cartesian state analytically under two-body gravity."""
    elements = _cartesian_elements(initial_state, mu_m3ps2)
    mean_motion = np.sqrt(float(mu_m3ps2) / elements["a_m"] ** 3)
    true_anomaly = _mean_to_true_anomaly(
        elements["mean_anomaly_rad"] + mean_motion * float(elapsed_time_s),
        elements["e"],
    )
    position, velocity = classical_to_cartesian(
        a_m=elements["a_m"],
        e=elements["e"],
        inc_deg=float(np.rad2deg(elements["inc_rad"])),
        raan_deg=float(np.rad2deg(elements["raan_rad"])),
        argp_deg=float(np.rad2deg(elements["argp_rad"])),
        true_anomaly_deg=float(np.rad2deg(true_anomaly)),
        mu_m3ps2=float(mu_m3ps2),
    )
    return BoundaryState(position, velocity)

relative_orbital_elements_to_absolute_state(chief: BoundaryState, relative_elements: RelativeOrbitalElements | ArrayLike, *, mu_m3ps2: float) -> BoundaryState

Reconstruct a deputy absolute state from chief state and ROE.

Equatorial chief orbits cannot encode right-ascension separation through δiy. The conversion accepts them only when δiy is effectively zero; use Cartesian RIC states when the missing node geometry matters.

Source code in octavian/relative/elements.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def relative_orbital_elements_to_absolute_state(
    chief: BoundaryState,
    relative_elements: RelativeOrbitalElements | ArrayLike,
    *,
    mu_m3ps2: float,
) -> BoundaryState:
    """Reconstruct a deputy absolute state from chief state and ROE.

    Equatorial chief orbits cannot encode right-ascension separation through
    ``δiy``.  The conversion accepts them only when ``δiy`` is effectively
    zero; use Cartesian RIC states when the missing node geometry matters.
    """
    roe = (
        relative_elements
        if isinstance(relative_elements, RelativeOrbitalElements)
        else RelativeOrbitalElements.from_vector(relative_elements)
    )
    chief_elements = _cartesian_elements(chief, mu_m3ps2)
    chief_inclination = chief_elements["inc_rad"]
    sine_inclination = float(np.sin(chief_inclination))
    if abs(sine_inclination) <= 1.0e-10:
        if abs(roe.delta_iy_rad) > 1.0e-10:
            raise ValueError("delta_iy_rad is singular for an equatorial chief orbit")
        delta_raan = 0.0
    else:
        delta_raan = roe.delta_iy_rad / sine_inclination

    deputy_semi_major_axis = chief_elements["a_m"] * (1.0 + roe.delta_a)
    if deputy_semi_major_axis <= 0.0:
        raise ValueError("Relative elements produce a non-positive deputy semi-major axis")
    deputy_ex = chief_elements["e"] * np.cos(chief_elements["argp_rad"]) + roe.delta_ex
    deputy_ey = chief_elements["e"] * np.sin(chief_elements["argp_rad"]) + roe.delta_ey
    deputy_eccentricity = float(np.hypot(deputy_ex, deputy_ey))
    if not (0.0 <= deputy_eccentricity < 1.0):
        raise ValueError("Relative elements currently require an elliptic deputy orbit")
    deputy_argument = (
        float(np.arctan2(deputy_ey, deputy_ex)) if deputy_eccentricity > 1.0e-14 else 0.0
    )
    deputy_inclination = chief_inclination + roe.delta_ix_rad
    if not (0.0 <= deputy_inclination <= np.pi):
        raise ValueError("Relative elements produce deputy inclination outside [0, pi]")
    deputy_raan = _wrap_angle(chief_elements["raan_rad"] + delta_raan)
    chief_mean_argument_latitude = _wrap_angle(
        chief_elements["mean_anomaly_rad"] + chief_elements["argp_rad"]
    )
    deputy_mean_argument_latitude = (
        chief_mean_argument_latitude + roe.delta_lambda_rad - delta_raan * np.cos(chief_inclination)
    )
    deputy_mean_anomaly = _wrap_angle(deputy_mean_argument_latitude - deputy_argument)
    deputy_true_anomaly = _mean_to_true_anomaly(
        deputy_mean_anomaly,
        deputy_eccentricity,
    )
    position, velocity = classical_to_cartesian(
        a_m=deputy_semi_major_axis,
        e=deputy_eccentricity,
        inc_deg=float(np.rad2deg(deputy_inclination)),
        raan_deg=float(np.rad2deg(deputy_raan)),
        argp_deg=float(np.rad2deg(deputy_argument)),
        true_anomaly_deg=float(np.rad2deg(deputy_true_anomaly)),
        mu_m3ps2=float(mu_m3ps2),
    )
    return BoundaryState(position, velocity)

relative_orbital_elements_to_relative_state(chief: BoundaryState, relative_elements: RelativeOrbitalElements | ArrayLike, *, mu_m3ps2: float) -> BoundaryState

Convert D'Amico relative elements directly to a deputy RIC state.

Source code in octavian/relative/elements.py
336
337
338
339
340
341
342
343
344
345
346
347
348
def relative_orbital_elements_to_relative_state(
    chief: BoundaryState,
    relative_elements: RelativeOrbitalElements | ArrayLike,
    *,
    mu_m3ps2: float,
) -> BoundaryState:
    """Convert D'Amico relative elements directly to a deputy RIC state."""
    deputy = relative_orbital_elements_to_absolute_state(
        chief,
        relative_elements,
        mu_m3ps2=mu_m3ps2,
    )
    return inertial_to_relative_state(chief, deputy)

relative_state_to_classical_relative_orbital_elements(chief: BoundaryState, relative_state_ric: BoundaryState, *, mu_m3ps2: float) -> ClassicalRelativeOrbitalElements

Convert a deputy RIC state to classical orbital-element differences.

Source code in octavian/relative/elements.py
351
352
353
354
355
356
357
358
359
360
361
362
363
def relative_state_to_classical_relative_orbital_elements(
    chief: BoundaryState,
    relative_state_ric: BoundaryState,
    *,
    mu_m3ps2: float,
) -> ClassicalRelativeOrbitalElements:
    """Convert a deputy RIC state to classical orbital-element differences."""
    deputy = relative_to_inertial_state(chief, relative_state_ric)
    return absolute_to_classical_relative_orbital_elements(
        chief,
        deputy,
        mu_m3ps2=mu_m3ps2,
    )

relative_state_to_relative_orbital_elements(chief: BoundaryState, relative_state_ric: BoundaryState, *, mu_m3ps2: float) -> RelativeOrbitalElements

Convert a deputy RIC state directly to D'Amico relative elements.

Source code in octavian/relative/elements.py
321
322
323
324
325
326
327
328
329
330
331
332
333
def relative_state_to_relative_orbital_elements(
    chief: BoundaryState,
    relative_state_ric: BoundaryState,
    *,
    mu_m3ps2: float,
) -> RelativeOrbitalElements:
    """Convert a deputy RIC state directly to D'Amico relative elements."""
    deputy = relative_to_inertial_state(chief, relative_state_ric)
    return absolute_to_relative_orbital_elements(
        chief,
        deputy,
        mu_m3ps2=mu_m3ps2,
    )

octavian.relative.propagation

Coupled absolute-state propagation for perturbed relative motion.

The public result remains chief-centered RIC, while the numerical integration advances chief and deputy absolute states under the same force model. Taking the difference only after propagation naturally retains differential J2 and third-body accelerations without embedding frame-rotation approximations in the force model.

RelativePropagationResult dataclass

Coupled chief/deputy propagation histories.

Attributes: times_s: Elapsed seconds from the supplied initial states. chief_states_eci: Absolute chief states with shape (N, 6). deputy_states_eci: Absolute deputy states with shape (N, 6). relative_states_ric: Deputy-minus-chief RIC states with shape (N, 6).

Source code in octavian/relative/propagation.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@dataclass(frozen=True, slots=True)
class RelativePropagationResult:
    """Coupled chief/deputy propagation histories.

    Attributes:
        times_s: Elapsed seconds from the supplied initial states.
        chief_states_eci: Absolute chief states with shape ``(N, 6)``.
        deputy_states_eci: Absolute deputy states with shape ``(N, 6)``.
        relative_states_ric: Deputy-minus-chief RIC states with shape ``(N, 6)``.
    """

    times_s: NDArray[np.float64]
    chief_states_eci: StateHistory
    deputy_states_eci: StateHistory
    relative_states_ric: StateHistory

    def __post_init__(self) -> None:
        times = np.asarray(self.times_s, dtype=float).reshape(-1)
        chief = np.asarray(self.chief_states_eci, dtype=float)
        deputy = np.asarray(self.deputy_states_eci, dtype=float)
        relative = np.asarray(self.relative_states_ric, dtype=float)
        expected = (times.size, 6)
        for name, value in (
            ("chief_states_eci", chief),
            ("deputy_states_eci", deputy),
            ("relative_states_ric", relative),
        ):
            if value.shape != expected:
                raise ValueError(f"{name} must have shape {expected}")
        object.__setattr__(self, "times_s", times)
        object.__setattr__(self, "chief_states_eci", chief)
        object.__setattr__(self, "deputy_states_eci", deputy)
        object.__setattr__(self, "relative_states_ric", relative)

    @property
    def chief_trajectory_eci(self) -> StateHistory:
        """Return ``[chief ECI state, time]`` rows."""
        return np.column_stack([self.chief_states_eci, self.times_s])

    @property
    def deputy_trajectory_eci(self) -> StateHistory:
        """Return ``[deputy ECI state, time]`` rows."""
        return np.column_stack([self.deputy_states_eci, self.times_s])

    @property
    def relative_trajectory_ric(self) -> StateHistory:
        """Return ``[relative RIC state, time]`` rows ready for plotting."""
        return np.column_stack([self.relative_states_ric, self.times_s])

chief_trajectory_eci: StateHistory property

Return [chief ECI state, time] rows.

deputy_trajectory_eci: StateHistory property

Return [deputy ECI state, time] rows.

relative_trajectory_ric: StateHistory property

Return [relative RIC state, time] rows ready for plotting.

coupled_relative_ric_derivative(stacked_state: ArrayLike, *, mu_m3ps2: float) -> NDArray[np.float64]

Evaluate exact two-body chief-ECI/deputy-RIC stacked dynamics.

Args: stacked_state: [chief_r, chief_v, rho_RIC, rho_dot_RIC] in SI. mu_m3ps2: Central-body gravitational parameter.

Returns: Twelve derivatives in the same state ordering.

Source code in octavian/relative/propagation.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
def coupled_relative_ric_derivative(
    stacked_state: ArrayLike,
    *,
    mu_m3ps2: float,
) -> NDArray[np.float64]:
    """Evaluate exact two-body chief-ECI/deputy-RIC stacked dynamics.

    Args:
        stacked_state: ``[chief_r, chief_v, rho_RIC, rho_dot_RIC]`` in SI.
        mu_m3ps2: Central-body gravitational parameter.

    Returns:
        Twelve derivatives in the same state ordering.
    """
    state = np.asarray(stacked_state, dtype=float).reshape(12)
    mu = float(mu_m3ps2)
    if not np.all(np.isfinite(state)):
        raise ValueError("stacked_state must contain finite values")
    if mu <= 0.0:
        raise ValueError("mu_m3ps2 must be positive")
    chief = BoundaryState(state[0:3], state[3:6])
    relative = BoundaryState(state[6:9], state[9:12])
    deputy = relative_to_inertial_state(chief, relative)
    chief_radius = float(np.linalg.norm(chief.r_m))
    deputy_radius = float(np.linalg.norm(deputy.r_m))
    if chief_radius <= 0.0 or deputy_radius <= 0.0:
        raise ValueError("Chief and deputy positions must have non-zero norm")

    chief_acceleration = -mu * chief.r_m / chief_radius**3
    deputy_acceleration = -mu * deputy.r_m / deputy_radius**3
    basis = ric_basis(chief.r_m, chief.v_mps)
    gravity_difference_ric = basis @ (deputy_acceleration - chief_acceleration)
    angular_momentum = float(np.linalg.norm(np.cross(chief.r_m, chief.v_mps)))
    frame_rate = angular_momentum / chief_radius**2
    frame_rate_derivative = (
        -2.0 * frame_rate * float(np.dot(chief.r_m, chief.v_mps)) / chief_radius**2
    )
    x, y, _ = relative.r_m
    xdot, ydot, _ = relative.v_mps
    rotating_terms = np.asarray(
        [
            2.0 * frame_rate * ydot + frame_rate_derivative * y + frame_rate**2 * x,
            -2.0 * frame_rate * xdot - frame_rate_derivative * x + frame_rate**2 * y,
            0.0,
        ],
        dtype=float,
    )
    return np.hstack(
        [
            chief.v_mps,
            chief_acceleration,
            relative.v_mps,
            gravity_difference_ric + rotating_terms,
        ]
    )

nonlinear_relative_ric_derivative(relative_state_ric: ArrayLike, *, mu_m3ps2: float, chief_orbit_radius_m: float) -> NDArray[np.float64]

Evaluate exact circular-chief RIC dynamics before linearization.

Args: relative_state_ric: [R, I, C, Rdot, Idot, Cdot] in SI units. mu_m3ps2: Central-body gravitational parameter. chief_orbit_radius_m: Circular chief radius in meters.

Returns: Six state derivatives in SI units.

Source code in octavian/relative/propagation.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
def nonlinear_relative_ric_derivative(
    relative_state_ric: ArrayLike,
    *,
    mu_m3ps2: float,
    chief_orbit_radius_m: float,
) -> NDArray[np.float64]:
    """Evaluate exact circular-chief RIC dynamics before linearization.

    Args:
        relative_state_ric: ``[R, I, C, Rdot, Idot, Cdot]`` in SI units.
        mu_m3ps2: Central-body gravitational parameter.
        chief_orbit_radius_m: Circular chief radius in meters.

    Returns:
        Six state derivatives in SI units.
    """
    state = np.asarray(relative_state_ric, dtype=float).reshape(6)
    mu = float(mu_m3ps2)
    chief_radius = float(chief_orbit_radius_m)
    if not np.all(np.isfinite(state)):
        raise ValueError("relative_state_ric must contain finite values")
    if mu <= 0.0 or chief_radius <= 0.0:
        raise ValueError("mu_m3ps2 and chief_orbit_radius_m must be positive")
    x, y, z, xdot, ydot, zdot = state
    deputy_radius = float(np.sqrt((chief_radius + x) ** 2 + y**2 + z**2))
    if deputy_radius <= 0.0:
        raise ValueError("Relative state places the deputy at the central body")
    mean_motion = float(np.sqrt(mu / chief_radius**3))
    gravity_rate = mu / deputy_radius**3
    return np.asarray(
        [
            xdot,
            ydot,
            zdot,
            2.0 * mean_motion * ydot + (chief_radius + x) * (mean_motion**2 - gravity_rate),
            -2.0 * mean_motion * xdot + y * (mean_motion**2 - gravity_rate),
            -gravity_rate * z,
        ],
        dtype=float,
    )

propagate_nonlinear_relative_ric(initial_state_ric: ArrayLike, times_s: ArrayLike, *, mu_m3ps2: float, chief_orbit_radius_m: float, max_step_s: float = 10.0) -> NDArray[np.float64]

Propagate the exact autonomous circular-chief RIC equations with RK4.

Args: initial_state_ric: Initial six-state RIC vector in SI units. times_s: Strictly increasing elapsed times beginning at zero. mu_m3ps2: Central-body gravitational parameter. chief_orbit_radius_m: Circular chief radius in meters. max_step_s: Maximum internal Runge-Kutta step.

Returns: An (N, 7) history containing RIC state and elapsed time.

Source code in octavian/relative/propagation.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
def propagate_nonlinear_relative_ric(
    initial_state_ric: ArrayLike,
    times_s: ArrayLike,
    *,
    mu_m3ps2: float,
    chief_orbit_radius_m: float,
    max_step_s: float = 10.0,
) -> NDArray[np.float64]:
    """Propagate the exact autonomous circular-chief RIC equations with RK4.

    Args:
        initial_state_ric: Initial six-state RIC vector in SI units.
        times_s: Strictly increasing elapsed times beginning at zero.
        mu_m3ps2: Central-body gravitational parameter.
        chief_orbit_radius_m: Circular chief radius in meters.
        max_step_s: Maximum internal Runge-Kutta step.

    Returns:
        An ``(N, 7)`` history containing RIC state and elapsed time.
    """
    output_times = np.asarray(times_s, dtype=float).reshape(-1)
    state = np.asarray(initial_state_ric, dtype=float).reshape(6)
    maximum_step = float(max_step_s)
    if output_times.size == 0 or not np.all(np.isfinite(output_times)):
        raise ValueError("times_s must contain at least one finite value")
    if not np.isclose(output_times[0], 0.0, atol=1.0e-12):
        raise ValueError("times_s must begin at 0.0")
    if output_times.size > 1 and np.any(np.diff(output_times) <= 0.0):
        raise ValueError("times_s must be strictly increasing")
    if maximum_step <= 0.0 or not np.isfinite(maximum_step):
        raise ValueError("max_step_s must be finite and positive")
    history = np.empty((output_times.size, 7), dtype=float)
    history[0] = np.hstack([state, output_times[0]])

    def derivative(value: NDArray[np.float64]) -> NDArray[np.float64]:
        """Evaluate the autonomous RIC derivative for one RK4 stage."""
        return nonlinear_relative_ric_derivative(
            value,
            mu_m3ps2=mu_m3ps2,
            chief_orbit_radius_m=chief_orbit_radius_m,
        )

    current_time = 0.0
    for output_index, output_time in enumerate(output_times[1:], start=1):
        interval = float(output_time - current_time)
        substeps = max(1, int(np.ceil(interval / maximum_step)))
        step = interval / substeps
        for _ in range(substeps):
            k1 = derivative(state)
            k2 = derivative(state + 0.5 * step * k1)
            k3 = derivative(state + 0.5 * step * k2)
            k4 = derivative(state + step * k3)
            state = state + (step / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
        current_time = float(output_time)
        history[output_index] = np.hstack([state, current_time])
    return history

propagate_relative_numerical(chief_initial_eci: BoundaryState, relative_initial_ric: BoundaryState | None, times_s: ArrayLike, *, deputy_initial_eci: BoundaryState | None = None, central_body: CelestialBody = EARTH, perturbations: Any | None = None, initial_epoch: str | datetime | float | int | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP, chief_spacecraft: Spacecraft | None = None, deputy_spacecraft: Spacecraft | None = None) -> RelativePropagationResult

Propagate chief and deputy together and report their RIC separation.

The integrator is fixed-step fourth-order Runge-Kutta with exact output at each requested time. Both spacecraft receive central gravity plus the requested gravitational and cannonball accelerations. This coupled absolute-state formulation keeps differential drag and SRP physical without inserting approximations into rotating-frame equations.

Args: chief_initial_eci: Chief absolute Cartesian state at elapsed time zero. relative_initial_ric: Deputy state relative to the chief in RIC. Pass None when supplying deputy_initial_eci directly. times_s: Strictly monotonic output times with zero at either endpoint. Forward histories normally begin at zero; backward histories may be requested as either [0, ..., negative] or [negative, ..., 0]. deputy_initial_eci: Optional absolute deputy state at time zero. This avoids a frame-velocity round trip when osculating elements define the deputy under a perturbed force model. Exactly one deputy initial-state representation must be supplied. central_body: Central-body constants used by gravity and J2. perturbations: An :class:octavian.Perturbations configuration. initial_epoch: UTC or SPICE ET corresponding to time zero. Required when lunar/solar gravity or SRP is enabled. max_step_s: Maximum internal RK4 step in seconds. ephemeris_step_s: Sun/Moon interpolation sample spacing in seconds. bsp_path: SPICE BSP containing Earth-centered Sun/Moon states. chief_spacecraft: Optional chief mass and cannonball properties. When omitted, non-gravitational forces act only on the deputy. deputy_spacecraft: Deputy mass and cannonball properties. Required when drag or SRP is enabled.

Returns: Absolute chief/deputy histories and the equivalent RIC history.

Source code in octavian/relative/propagation.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def propagate_relative_numerical(
    chief_initial_eci: BoundaryState,
    relative_initial_ric: BoundaryState | None,
    times_s: ArrayLike,
    *,
    deputy_initial_eci: BoundaryState | None = None,
    central_body: CelestialBody = EARTH,
    perturbations: Any | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
    chief_spacecraft: Spacecraft | None = None,
    deputy_spacecraft: Spacecraft | None = None,
) -> RelativePropagationResult:
    """Propagate chief and deputy together and report their RIC separation.

    The integrator is fixed-step fourth-order Runge-Kutta with exact output at
    each requested time.  Both spacecraft receive central gravity plus the
    requested gravitational and cannonball accelerations.  This coupled
    absolute-state formulation keeps differential drag and SRP physical
    without inserting approximations into rotating-frame equations.

    Args:
        chief_initial_eci: Chief absolute Cartesian state at elapsed time zero.
        relative_initial_ric: Deputy state relative to the chief in RIC. Pass
            ``None`` when supplying ``deputy_initial_eci`` directly.
        times_s: Strictly monotonic output times with zero at either endpoint.
            Forward histories normally begin at zero; backward histories may
            be requested as either ``[0, ..., negative]`` or
            ``[negative, ..., 0]``.
        deputy_initial_eci: Optional absolute deputy state at time zero. This
            avoids a frame-velocity round trip when osculating elements define
            the deputy under a perturbed force model. Exactly one deputy
            initial-state representation must be supplied.
        central_body: Central-body constants used by gravity and J2.
        perturbations: An :class:`octavian.Perturbations` configuration.
        initial_epoch: UTC or SPICE ET corresponding to time zero.  Required
            when lunar/solar gravity or SRP is enabled.
        max_step_s: Maximum internal RK4 step in seconds.
        ephemeris_step_s: Sun/Moon interpolation sample spacing in seconds.
        bsp_path: SPICE BSP containing Earth-centered Sun/Moon states.
        chief_spacecraft: Optional chief mass and cannonball properties. When
            omitted, non-gravitational forces act only on the deputy.
        deputy_spacecraft: Deputy mass and cannonball properties. Required
            when drag or SRP is enabled.

    Returns:
        Absolute chief/deputy histories and the equivalent RIC history.
    """
    requested_times = np.asarray(times_s, dtype=float).reshape(-1)
    if requested_times.size < 1 or not np.all(np.isfinite(requested_times)):
        raise ValueError("times_s must contain at least one finite value")
    if np.isclose(requested_times[0], 0.0, atol=1.0e-12):
        integration_times = requested_times
        reverse_output = False
    elif np.isclose(requested_times[-1], 0.0, atol=1.0e-12):
        integration_times = requested_times[::-1]
        reverse_output = True
    else:
        raise ValueError("times_s must have 0.0 at the first or last output")
    if integration_times.size > 1:
        differences = np.diff(integration_times)
        if not (np.all(differences > 0.0) or np.all(differences < 0.0)):
            raise ValueError("times_s must be strictly monotonic")
    maximum_step = float(max_step_s)
    ephemeris_step = float(ephemeris_step_s)
    if not np.isfinite(maximum_step) or maximum_step <= 0.0:
        raise ValueError("max_step_s must be finite and positive")
    if not np.isfinite(ephemeris_step) or ephemeris_step <= 0.0:
        raise ValueError("ephemeris_step_s must be finite and positive")
    if (relative_initial_ric is None) == (deputy_initial_eci is None):
        raise ValueError("Supply exactly one of relative_initial_ric or deputy_initial_eci")

    flags = _normalize_perturbations(perturbations)
    if flags["srp"] and central_body.name.strip().lower() != "earth":
        raise ValueError(
            "The bundled Sun BSP is Earth-centered, so SRP propagation "
            "currently requires an Earth central body."
        )
    _validate_cannonball_spacecraft(
        deputy_spacecraft,
        flags=flags,
        role="deputy",
        required=bool(flags["drag"] or flags["srp"]),
    )
    _validate_cannonball_spacecraft(
        chief_spacecraft,
        flags=flags,
        role="chief",
        required=False,
    )
    atmosphere = _resolved_atmosphere(
        flags=flags,
        central_body=central_body,
    )
    requested_bodies = list(flags["third_bodies"])
    if flags["srp"] and "sun" not in requested_bodies:
        requested_bodies.append("sun")
    body_positions = _build_body_position_interpolator(
        requested_bodies=tuple(requested_bodies),
        initial_epoch=initial_epoch,
        start_time_s=float(np.min(integration_times)),
        end_time_s=float(np.max(integration_times)),
        step_s=ephemeris_step,
        bsp_path=bsp_path,
    )

    initial_chief_acceleration = _absolute_acceleration(
        chief_initial_eci.r_m,
        chief_initial_eci.v_mps,
        central_body=central_body,
        flags=flags,
        sampled_bodies=body_positions(0.0),
        time_s=0.0,
        spacecraft=chief_spacecraft,
        atmosphere=atmosphere,
    )
    if deputy_initial_eci is None:
        if relative_initial_ric is None:  # guarded by the exclusive-input check
            raise RuntimeError("Relative initial state validation failed")
        deputy_initial_eci = relative_to_inertial_state(
            chief_initial_eci,
            relative_initial_ric,
            chief_acceleration_mps2=initial_chief_acceleration,
        )
    combined_state = np.hstack(
        [
            chief_initial_eci.r_m,
            chief_initial_eci.v_mps,
            deputy_initial_eci.r_m,
            deputy_initial_eci.v_mps,
        ]
    ).astype(float)
    absolute_history = np.empty((integration_times.size, 12), dtype=float)
    absolute_history[0] = combined_state

    def derivative(time_s: float, state: NDArray[np.float64]) -> NDArray[np.float64]:
        """Evaluate the stacked chief/deputy absolute derivative."""
        chief_position = state[0:3]
        chief_velocity = state[3:6]
        deputy_position = state[6:9]
        deputy_velocity = state[9:12]
        sampled_bodies = body_positions(time_s)
        chief_acceleration = _absolute_acceleration(
            chief_position,
            chief_velocity,
            central_body=central_body,
            flags=flags,
            sampled_bodies=sampled_bodies,
            time_s=time_s,
            spacecraft=chief_spacecraft,
            atmosphere=atmosphere,
        )
        deputy_acceleration = _absolute_acceleration(
            deputy_position,
            deputy_velocity,
            central_body=central_body,
            flags=flags,
            sampled_bodies=sampled_bodies,
            time_s=time_s,
            spacecraft=deputy_spacecraft,
            atmosphere=atmosphere,
        )
        return np.hstack(
            [
                chief_velocity,
                chief_acceleration,
                deputy_velocity,
                deputy_acceleration,
            ]
        )

    current_time = 0.0
    for output_index, output_time in enumerate(integration_times[1:], start=1):
        interval = float(output_time - current_time)
        substeps = max(1, int(np.ceil(abs(interval) / maximum_step)))
        step = interval / substeps
        for _ in range(substeps):
            k1 = derivative(current_time, combined_state)
            k2 = derivative(current_time + 0.5 * step, combined_state + 0.5 * step * k1)
            k3 = derivative(current_time + 0.5 * step, combined_state + 0.5 * step * k2)
            k4 = derivative(current_time + step, combined_state + step * k3)
            combined_state = combined_state + (step / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
            current_time += step
        current_time = float(output_time)
        if not np.all(np.isfinite(combined_state)):
            raise RuntimeError(f"Relative propagation became non-finite at t={current_time:.6f} s")
        absolute_history[output_index] = combined_state

    chief_states = absolute_history[:, 0:6]
    deputy_states = absolute_history[:, 6:12]
    relative_states = np.empty_like(chief_states)
    for index, time_s in enumerate(integration_times):
        chief = BoundaryState(chief_states[index, 0:3], chief_states[index, 3:6])
        deputy = BoundaryState(
            deputy_states[index, 0:3],
            deputy_states[index, 3:6],
        )
        chief_acceleration = _absolute_acceleration(
            chief.r_m,
            chief.v_mps,
            central_body=central_body,
            flags=flags,
            sampled_bodies=body_positions(float(time_s)),
            time_s=float(time_s),
            spacecraft=chief_spacecraft,
            atmosphere=atmosphere,
        )
        relative = inertial_to_relative_state(
            chief,
            deputy,
            chief_acceleration_mps2=chief_acceleration,
        )
        relative_states[index] = np.hstack([relative.r_m, relative.v_mps])
    if reverse_output:
        chief_states = chief_states[::-1].copy()
        deputy_states = deputy_states[::-1].copy()
        relative_states = relative_states[::-1].copy()
    return RelativePropagationResult(
        times_s=requested_times,
        chief_states_eci=chief_states,
        deputy_states_eci=deputy_states,
        relative_states_ric=relative_states,
    )

octavian.relative.solar

SPICE-driven Sun directions for chief-centered RIC geometry.

SolarDirectionTable dataclass

Sampled Sun geometry for relative constraints and diagnostics.

Queries within 1e-8 s of an endpoint are clipped to that endpoint to tolerate optimized time roundoff. Queries farther outside coverage fail.

Source code in octavian/relative/solar.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@dataclass(frozen=True, slots=True)
class SolarDirectionTable:
    """Sampled Sun geometry for relative constraints and diagnostics.

    Queries within 1e-8 s of an endpoint are clipped to that endpoint to
    tolerate optimized time roundoff. Queries farther outside coverage fail.
    """

    times_s: NDArray[np.float64]
    directions_ric: NDArray[np.float64]
    sun_positions_eci_m: NDArray[np.float64] | None = None

    def __post_init__(self) -> None:
        times = np.asarray(self.times_s, dtype=float).reshape(-1)
        directions = np.asarray(self.directions_ric, dtype=float)
        if times.size < 2 or directions.shape != (times.size, 3):
            raise ValueError("SolarDirectionTable requires N>=2 times and an (N, 3) array")
        if np.any(np.diff(times) <= 0.0) or not np.all(np.isfinite(directions)):
            raise ValueError("SolarDirectionTable samples must be finite and increasing")
        norms = np.linalg.norm(directions, axis=1)
        if np.any(norms <= 0.0):
            raise ValueError("SolarDirectionTable directions must have non-zero norm")
        object.__setattr__(self, "times_s", times)
        object.__setattr__(self, "directions_ric", directions / norms[:, None])
        if self.sun_positions_eci_m is not None:
            positions = np.asarray(self.sun_positions_eci_m, dtype=float)
            if positions.shape != (times.size, 3) or not np.all(np.isfinite(positions)):
                raise ValueError(
                    "SolarDirectionTable sun positions must have shape (N, 3)"
                )
            object.__setattr__(self, "sun_positions_eci_m", positions)

    def at(self, times_s: ArrayLike) -> NDArray[np.float64]:
        """Interpolate and renormalize Sun directions at elapsed times."""
        query = bounded_query_times(times_s, self.times_s, quantity="solar direction")
        flat_query = query.reshape(-1)
        interpolated = np.column_stack(
            [
                np.interp(flat_query, self.times_s, self.directions_ric[:, component])
                for component in range(3)
            ]
        )
        norms = np.linalg.norm(interpolated, axis=1)
        result = interpolated / norms[:, None]
        return result.reshape((*query.shape, 3))

    def sun_position_at(self, times_s: ArrayLike) -> NDArray[np.float64]:
        """Interpolate the SPICE-derived Sun position in ECI."""
        if self.sun_positions_eci_m is None:
            raise ValueError("SolarDirectionTable does not contain ECI Sun positions")
        query = bounded_query_times(times_s, self.times_s, quantity="Sun position")
        flat_query = query.reshape(-1)
        interpolated = np.column_stack(
            [
                np.interp(
                    flat_query,
                    self.times_s,
                    self.sun_positions_eci_m[:, component],
                )
                for component in range(3)
            ]
        )
        return interpolated.reshape((*query.shape, 3))

at(times_s: ArrayLike) -> NDArray[np.float64]

Interpolate and renormalize Sun directions at elapsed times.

Source code in octavian/relative/solar.py
50
51
52
53
54
55
56
57
58
59
60
61
62
def at(self, times_s: ArrayLike) -> NDArray[np.float64]:
    """Interpolate and renormalize Sun directions at elapsed times."""
    query = bounded_query_times(times_s, self.times_s, quantity="solar direction")
    flat_query = query.reshape(-1)
    interpolated = np.column_stack(
        [
            np.interp(flat_query, self.times_s, self.directions_ric[:, component])
            for component in range(3)
        ]
    )
    norms = np.linalg.norm(interpolated, axis=1)
    result = interpolated / norms[:, None]
    return result.reshape((*query.shape, 3))

sun_position_at(times_s: ArrayLike) -> NDArray[np.float64]

Interpolate the SPICE-derived Sun position in ECI.

Source code in octavian/relative/solar.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def sun_position_at(self, times_s: ArrayLike) -> NDArray[np.float64]:
    """Interpolate the SPICE-derived Sun position in ECI."""
    if self.sun_positions_eci_m is None:
        raise ValueError("SolarDirectionTable does not contain ECI Sun positions")
    query = bounded_query_times(times_s, self.times_s, quantity="Sun position")
    flat_query = query.reshape(-1)
    interpolated = np.column_stack(
        [
            np.interp(
                flat_query,
                self.times_s,
                self.sun_positions_eci_m[:, component],
            )
            for component in range(3)
        ]
    )
    return interpolated.reshape((*query.shape, 3))

circular_chief_state(initial_state_eci: BoundaryState, elapsed_time_s: float, mean_motion_radps: float) -> BoundaryState

Propagate a circular chief reference state analytically.

Source code in octavian/relative/solar.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def circular_chief_state(
    initial_state_eci: BoundaryState,
    elapsed_time_s: float,
    mean_motion_radps: float,
) -> BoundaryState:
    """Propagate a circular chief reference state analytically."""
    angle = float(mean_motion_radps) * float(elapsed_time_s)
    cosine = float(np.cos(angle))
    sine = float(np.sin(angle))
    position = (
        initial_state_eci.r_m * cosine
        + initial_state_eci.v_mps * (sine / float(mean_motion_radps))
    )
    velocity = (
        -initial_state_eci.r_m * (float(mean_motion_radps) * sine)
        + initial_state_eci.v_mps * cosine
    )
    return BoundaryState(position, velocity)

sample_solar_directions_ric(*, chief_initial_state_eci: BoundaryState, mean_motion_radps: float, initial_epoch: str | datetime | float | int, duration_s: float, step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP) -> SolarDirectionTable

Sample the SPICE Sun line and rotate it into the chief's RIC frame.

The chief state and bundled BSP both use Octavian's Earth-centered ECI_TOD orientation for this calculation.

Source code in octavian/relative/solar.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def sample_solar_directions_ric(
    *,
    chief_initial_state_eci: BoundaryState,
    mean_motion_radps: float,
    initial_epoch: str | datetime | float | int,
    duration_s: float,
    step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
) -> SolarDirectionTable:
    """Sample the SPICE Sun line and rotate it into the chief's RIC frame.

    The chief state and bundled BSP both use Octavian's Earth-centered
    ``ECI_TOD`` orientation for this calculation.
    """
    if float(mean_motion_radps) <= 0.0:
        raise ValueError("mean_motion_radps must be positive")
    times_s, body_positions = sample_sun_moon_positions_eci_tod(
        initial_epoch=initial_epoch,
        duration_s=float(duration_s),
        step_s=float(step_s),
        bsp_path=bsp_path,
    )
    directions = np.empty((times_s.size, 3), dtype=float)
    for index, (time_s, sun_position_m) in enumerate(
        zip(times_s, body_positions["sun"], strict=True)
    ):
        chief = circular_chief_state(
            chief_initial_state_eci,
            float(time_s),
            float(mean_motion_radps),
        )
        sun_line_eci = sun_position_m - chief.r_m
        sun_line_ric = ric_basis(chief.r_m, chief.v_mps) @ sun_line_eci
        directions[index] = sun_line_ric / np.linalg.norm(sun_line_ric)
    return SolarDirectionTable(
        times_s=times_s,
        directions_ric=directions,
        sun_positions_eci_m=body_positions["sun"],
    )

octavian.propagate

One discoverable namespace for Octavian's analysis propagators.

Use this module as from octavian import propagate. Existing specialized functions remain available in :mod:octavian.relative, :mod:octavian.astro, and :mod:octavian.cislunar; this namespace provides consistent history outputs and a shorter path for common analysis workflows.

cr3bp(initial_state: BoundaryState, times: Sequence[float], *, system: CR3BPSystem, dimensional: bool = True, max_step: float | None = None) -> StateHistory

Propagate a state in a circular restricted three-body system.

Args: initial_state: Synodic state at times[0]. times: Strictly monotonic SI or canonical output times. system: Primary-secondary CR3BP system. dimensional: Use dimensional SI state and time units when true. max_step: Maximum internal RK4 step in selected units.

Returns: (N, 7) rows of [synodic state, time].

Source code in octavian/propagate.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def cr3bp(
    initial_state: BoundaryState,
    times: Sequence[float],
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
    max_step: float | None = None,
) -> StateHistory:
    """Propagate a state in a circular restricted three-body system.

    Args:
        initial_state: Synodic state at ``times[0]``.
        times: Strictly monotonic SI or canonical output times.
        system: Primary-secondary CR3BP system.
        dimensional: Use dimensional SI state and time units when true.
        max_step: Maximum internal RK4 step in selected units.

    Returns:
        ``(N, 7)`` rows of ``[synodic state, time]``.
    """
    try:
        from .cislunar import propagate_cr3bp
    except ImportError as exc:
        raise ImportError("CR3BP propagation requires Octavian's cislunar module") from exc

    return propagate_cr3bp(
        initial_state,
        times,
        system=system,
        dimensional=dimensional,
        max_step=max_step,
    )

cwh(initial_state_ric: ArrayLike, times_s: ArrayLike, *, mean_motion_radps: float) -> StateHistory

Propagate a relative RIC state with the analytic CWH solution.

Args: initial_state_ric: Six-component RIC state at elapsed time zero. times_s: Finite elapsed output times. mean_motion_radps: Circular chief mean motion.

Returns: (N, 7) rows of [RIC state, elapsed_time].

Source code in octavian/propagate.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def cwh(
    initial_state_ric: ArrayLike,
    times_s: ArrayLike,
    *,
    mean_motion_radps: float,
) -> StateHistory:
    """Propagate a relative RIC state with the analytic CWH solution.

    Args:
        initial_state_ric: Six-component RIC state at elapsed time zero.
        times_s: Finite elapsed output times.
        mean_motion_radps: Circular chief mean motion.

    Returns:
        ``(N, 7)`` rows of ``[RIC state, elapsed_time]``.
    """
    initial = np.asarray(initial_state_ric, dtype=float).reshape(6)
    if not np.all(np.isfinite(initial)):
        raise ValueError("initial_state_ric must contain finite values")
    times = _finite_times(times_s)
    history = np.empty((times.size, 7), dtype=float)
    for index, time_s in enumerate(times):
        history[index] = np.hstack(
            [
                _propagate_cwh(
                    initial,
                    float(time_s),
                    float(mean_motion_radps),
                ),
                float(time_s),
            ]
        )
    return history

inertial(initial_state: BoundaryState, times_s: ArrayLike, *, central_body: CelestialBody = EARTH, perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | int | None = None, spacecraft: Spacecraft | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP) -> StateHistory

Numerically propagate an unpowered inertial Cartesian state.

Uses the same force model as :func:relative and fixed-step RK4, with shortened steps to hit each output time exactly. Circular and equatorial states are integrated directly without singular orbital elements.

Args: initial_state: Central-body inertial position/velocity at time zero. times_s: Strictly monotonic elapsed seconds, with zero at either end. Forward and backward propagation are supported. central_body: Gravity/J2 defaults; defaults to Earth. A harmonic field with published GM takes precedence for central and harmonic gravity. perturbations: Optional spherical harmonics or J2, Moon/Sun gravity, drag, and SRP. initial_epoch: UTC or SPICE ET at time zero, required for third-body gravity or SRP. Ephemeris positions use the Earth-centered TOD frame. spacecraft: Constant mass and cannonball properties, required for drag or SRP. This analysis propagator does not apply thrust. max_step_s: Maximum internal RK4 step in seconds. ephemeris_step_s: Sun/Moon interpolation spacing in seconds. bsp_path: BSP containing Earth-centered Sun/Moon states.

Returns: (N, 7) rows [r, v, elapsed_time] in SI units, in requested order.

Source code in octavian/propagate.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def inertial(
    initial_state: BoundaryState,
    times_s: ArrayLike,
    *,
    central_body: CelestialBody = EARTH,
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    spacecraft: Spacecraft | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
) -> StateHistory:
    """Numerically propagate an unpowered inertial Cartesian state.

    Uses the same force model as :func:`relative` and fixed-step RK4, with
    shortened steps to hit each output time exactly. Circular and equatorial
    states are integrated directly without singular orbital elements.

    Args:
        initial_state: Central-body inertial position/velocity at time zero.
        times_s: Strictly monotonic elapsed seconds, with zero at either end.
            Forward and backward propagation are supported.
        central_body: Gravity/J2 defaults; defaults to Earth. A harmonic field
            with published GM takes precedence for central and harmonic gravity.
        perturbations: Optional spherical harmonics or J2, Moon/Sun gravity, drag, and SRP.
        initial_epoch: UTC or SPICE ET at time zero, required for third-body
            gravity or SRP. Ephemeris positions use the Earth-centered TOD frame.
        spacecraft: Constant mass and cannonball properties, required for
            drag or SRP. This analysis propagator does not apply thrust.
        max_step_s: Maximum internal RK4 step in seconds.
        ephemeris_step_s: Sun/Moon interpolation spacing in seconds.
        bsp_path: BSP containing Earth-centered Sun/Moon states.

    Returns:
        ``(N, 7)`` rows ``[r, v, elapsed_time]`` in SI units, in requested order.
    """
    times = _finite_times(times_s)
    if times[0] == 0.0:
        integration_times = times
    elif times[-1] == 0.0:
        integration_times = times[::-1]
    else:
        raise ValueError("times_s must have 0.0 at the first or last output")
    differences = np.diff(integration_times)
    if not (np.all(differences > 0.0) or np.all(differences < 0.0)):
        raise ValueError("times_s must be strictly monotonic")
    if not np.isfinite(max_step_s) or max_step_s <= 0.0:
        raise ValueError("max_step_s must be finite and positive")
    if not np.isfinite(ephemeris_step_s) or ephemeris_step_s <= 0.0:
        raise ValueError("ephemeris_step_s must be finite and positive")
    current_state = np.hstack([initial_state.r_m, initial_state.v_mps]).astype(float)
    if not np.all(np.isfinite(current_state)) or np.linalg.norm(current_state[:3]) == 0.0:
        raise ValueError("initial_state must be finite with nonzero position")
    flags = _normalize_perturbations(perturbations)
    if (flags["third_bodies"] or flags["srp"]) and central_body.name.lower() != "earth":
        raise ValueError("The bundled Sun/Moon BSP requires an Earth central body")
    _validate_cannonball_spacecraft(
        spacecraft,
        flags=flags,
        role="inertial",
        required=bool(flags["drag"] or flags["srp"]),
    )
    atmosphere = _resolved_atmosphere(flags=flags, central_body=central_body)
    requested_bodies = list(flags["third_bodies"])
    if flags["srp"] and "sun" not in requested_bodies:
        requested_bodies.append("sun")
    body_positions = _build_body_position_interpolator(
        requested_bodies=tuple(requested_bodies),
        initial_epoch=initial_epoch,
        start_time_s=float(times.min()),
        end_time_s=float(times.max()),
        step_s=ephemeris_step_s,
        bsp_path=bsp_path,
    )

    def derivative(time_s: float, state: np.ndarray) -> np.ndarray:
        acceleration = _absolute_acceleration(
            state[:3],
            state[3:6],
            central_body=central_body,
            flags=flags,
            sampled_bodies=body_positions(time_s),
            time_s=time_s,
            spacecraft=spacecraft,
            atmosphere=atmosphere,
        )
        return np.hstack([state[3:6], acceleration])

    history = np.empty((times.size, 7), dtype=float)
    history[0] = np.hstack([current_state, 0.0])
    current_time = 0.0
    for index, output_time in enumerate(integration_times[1:], start=1):
        interval = float(output_time - current_time)
        substeps = max(1, int(np.ceil(abs(interval) / max_step_s)))
        step = interval / substeps
        for _ in range(substeps):
            k1 = derivative(current_time, current_state)
            k2 = derivative(current_time + step / 2, current_state + step * k1 / 2)
            k3 = derivative(current_time + step / 2, current_state + step * k2 / 2)
            k4 = derivative(current_time + step, current_state + step * k3)
            current_state += step * (k1 + 2 * k2 + 2 * k3 + k4) / 6
            current_time += step
        current_time = float(output_time)
        if not np.all(np.isfinite(current_state)):
            raise RuntimeError(f"Inertial propagation became non-finite at t={current_time:.6f} s")
        history[index] = np.hstack([current_state, current_time])
    return history if times[0] == 0.0 else history[::-1].copy()

nonlinear_ric(initial_state_ric: ArrayLike, times_s: ArrayLike, *, mu_m3ps2: float, chief_orbit_radius_m: float, max_step_s: float = 10.0) -> StateHistory

Propagate the exact circular-chief RIC equations before linearization.

Args: initial_state_ric: Six-component RIC state at elapsed time zero. times_s: Strictly increasing elapsed times beginning at zero. mu_m3ps2: Central-body gravitational parameter. chief_orbit_radius_m: Circular chief radius. max_step_s: Maximum internal RK4 step.

Returns: (N, 7) rows of [RIC state, elapsed_time].

Source code in octavian/propagate.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
def nonlinear_ric(
    initial_state_ric: ArrayLike,
    times_s: ArrayLike,
    *,
    mu_m3ps2: float,
    chief_orbit_radius_m: float,
    max_step_s: float = 10.0,
) -> StateHistory:
    """Propagate the exact circular-chief RIC equations before linearization.

    Args:
        initial_state_ric: Six-component RIC state at elapsed time zero.
        times_s: Strictly increasing elapsed times beginning at zero.
        mu_m3ps2: Central-body gravitational parameter.
        chief_orbit_radius_m: Circular chief radius.
        max_step_s: Maximum internal RK4 step.

    Returns:
        ``(N, 7)`` rows of ``[RIC state, elapsed_time]``.
    """
    return _propagate_nonlinear_relative_ric(
        initial_state_ric,
        times_s,
        mu_m3ps2=mu_m3ps2,
        chief_orbit_radius_m=chief_orbit_radius_m,
        max_step_s=max_step_s,
    )

relative(chief_initial_eci: BoundaryState, relative_initial_ric: BoundaryState | None, times_s: ArrayLike, *, deputy_initial_eci: BoundaryState | None = None, central_body: CelestialBody = EARTH, perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | int | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP, chief_spacecraft: Spacecraft | None = None, deputy_spacecraft: Spacecraft | None = None) -> RelativePropagationResult

Propagate exact coupled chief/deputy states and report RIC motion.

Args: chief_initial_eci: Chief absolute state at elapsed time zero. relative_initial_ric: Deputy state in chief RIC axes. Pass None when supplying deputy_initial_eci. times_s: Strictly monotonic times with zero at one endpoint. deputy_initial_eci: Optional absolute deputy state at time zero. central_body: Central-body gravity and radius constants. perturbations: Optional J2, third-body, drag, and SRP model. initial_epoch: Required for Moon, Sun, or SRP. max_step_s: Maximum internal RK4 step. ephemeris_step_s: Sun/Moon interpolation spacing. bsp_path: SPICE BSP containing Earth-centered Sun/Moon states. chief_spacecraft: Optional chief mass and cannonball properties. deputy_spacecraft: Deputy mass and cannonball properties, required for drag or SRP.

Returns: Absolute chief/deputy histories and their equivalent RIC history.

Source code in octavian/propagate.py
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def relative(
    chief_initial_eci: BoundaryState,
    relative_initial_ric: BoundaryState | None,
    times_s: ArrayLike,
    *,
    deputy_initial_eci: BoundaryState | None = None,
    central_body: CelestialBody = EARTH,
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
    chief_spacecraft: Spacecraft | None = None,
    deputy_spacecraft: Spacecraft | None = None,
) -> RelativePropagationResult:
    """Propagate exact coupled chief/deputy states and report RIC motion.

    Args:
        chief_initial_eci: Chief absolute state at elapsed time zero.
        relative_initial_ric: Deputy state in chief RIC axes. Pass ``None``
            when supplying ``deputy_initial_eci``.
        times_s: Strictly monotonic times with zero at one endpoint.
        deputy_initial_eci: Optional absolute deputy state at time zero.
        central_body: Central-body gravity and radius constants.
        perturbations: Optional J2, third-body, drag, and SRP model.
        initial_epoch: Required for Moon, Sun, or SRP.
        max_step_s: Maximum internal RK4 step.
        ephemeris_step_s: Sun/Moon interpolation spacing.
        bsp_path: SPICE BSP containing Earth-centered Sun/Moon states.
        chief_spacecraft: Optional chief mass and cannonball properties.
        deputy_spacecraft: Deputy mass and cannonball properties, required for
            drag or SRP.

    Returns:
        Absolute chief/deputy histories and their equivalent RIC history.
    """
    optional_kwargs: dict[str, object] = {}
    if chief_spacecraft is not None:
        optional_kwargs["chief_spacecraft"] = chief_spacecraft
    if deputy_spacecraft is not None:
        optional_kwargs["deputy_spacecraft"] = deputy_spacecraft
    unsupported = (
        optional_kwargs.keys() - signature(_propagate_relative_numerical).parameters.keys()
    )
    if unsupported:
        raise NotImplementedError(
            "This Octavian build does not provide cannonball spacecraft "
            "properties for coupled relative propagation"
        )

    return _propagate_relative_numerical(
        chief_initial_eci,
        relative_initial_ric,
        times_s,
        deputy_initial_eci=deputy_initial_eci,
        central_body=central_body,
        perturbations=perturbations,
        initial_epoch=initial_epoch,
        max_step_s=max_step_s,
        ephemeris_step_s=ephemeris_step_s,
        bsp_path=bsp_path,
        **optional_kwargs,
    )

relative_elements(initial_elements: RelativeOrbitalElements | ClassicalRelativeOrbitalElements | ArrayLike, times_s: ArrayLike, *, chief_initial_state_eci: BoundaryState, mu_m3ps2: float, representation: str = 'damico', central_body: CelestialBody | str = EARTH, perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | int | None = None, max_step_s: float = 10.0, ephemeris_step_s: float = 600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP, chief_spacecraft: Spacecraft | None = None, deputy_spacecraft: Spacecraft | None = None) -> RelativeElementPropagationResult

Propagate relative elements and return native plus RIC histories.

Perturbed propagation advances the coupled absolute states once. Use result.elements for osculating D'Amico/classical elements and result.ric for plotting or Cartesian relative analysis.

Args: initial_elements: Initial D'Amico or classical relative elements. times_s: Requested elapsed output times. chief_initial_state_eci: Chief absolute state at elapsed time zero. mu_m3ps2: Central-body gravitational parameter. representation: "damico" or "classical_elements". central_body: Body constants used by perturbations. perturbations: Optional differential force model. initial_epoch: Required for Moon, Sun, or SRP. max_step_s: Maximum internal RK4 step. ephemeris_step_s: Sun/Moon interpolation spacing. bsp_path: SPICE BSP containing Earth-centered Sun/Moon states. chief_spacecraft: Optional chief mass and cannonball properties. deputy_spacecraft: Deputy mass and cannonball properties, required for drag or SRP.

Returns: Paired native-element and equivalent RIC histories.

Source code in octavian/propagate.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def relative_elements(
    initial_elements: RelativeOrbitalElements | ClassicalRelativeOrbitalElements | ArrayLike,
    times_s: ArrayLike,
    *,
    chief_initial_state_eci: BoundaryState,
    mu_m3ps2: float,
    representation: str = "damico",
    central_body: CelestialBody | str = EARTH,
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | int | None = None,
    max_step_s: float = 10.0,
    ephemeris_step_s: float = 600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
    chief_spacecraft: Spacecraft | None = None,
    deputy_spacecraft: Spacecraft | None = None,
) -> RelativeElementPropagationResult:
    """Propagate relative elements and return native plus RIC histories.

    Perturbed propagation advances the coupled absolute states once. Use
    ``result.elements`` for osculating D'Amico/classical elements and
    ``result.ric`` for plotting or Cartesian relative analysis.

    Args:
        initial_elements: Initial D'Amico or classical relative elements.
        times_s: Requested elapsed output times.
        chief_initial_state_eci: Chief absolute state at elapsed time zero.
        mu_m3ps2: Central-body gravitational parameter.
        representation: ``"damico"`` or ``"classical_elements"``.
        central_body: Body constants used by perturbations.
        perturbations: Optional differential force model.
        initial_epoch: Required for Moon, Sun, or SRP.
        max_step_s: Maximum internal RK4 step.
        ephemeris_step_s: Sun/Moon interpolation spacing.
        bsp_path: SPICE BSP containing Earth-centered Sun/Moon states.
        chief_spacecraft: Optional chief mass and cannonball properties.
        deputy_spacecraft: Deputy mass and cannonball properties, required for
            drag or SRP.

    Returns:
        Paired native-element and equivalent RIC histories.
    """
    return propagate_relative_element_history(
        initial_elements,
        times_s,
        chief_initial_state_eci=chief_initial_state_eci,
        mu_m3ps2=mu_m3ps2,
        representation=representation,
        central_body=central_body,
        perturbations=perturbations,
        initial_epoch=initial_epoch,
        max_step_s=max_step_s,
        ephemeris_step_s=ephemeris_step_s,
        bsp_path=bsp_path,
        chief_spacecraft=chief_spacecraft,
        deputy_spacecraft=deputy_spacecraft,
    )

two_body(initial_state: BoundaryState, times_s: ArrayLike, *, mu_m3ps2: float) -> StateHistory

Propagate an elliptic inertial Cartesian state at elapsed times.

Args: initial_state: Cartesian state at elapsed time zero. times_s: Finite elapsed output times. Analytical evaluation permits either order and negative times. mu_m3ps2: Central-body gravitational parameter.

Returns: (N, 7) rows of [r, v, elapsed_time] in SI units.

Source code in octavian/propagate.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def two_body(
    initial_state: BoundaryState,
    times_s: ArrayLike,
    *,
    mu_m3ps2: float,
) -> StateHistory:
    """Propagate an elliptic inertial Cartesian state at elapsed times.

    Args:
        initial_state: Cartesian state at elapsed time zero.
        times_s: Finite elapsed output times. Analytical evaluation permits
            either order and negative times.
        mu_m3ps2: Central-body gravitational parameter.

    Returns:
        ``(N, 7)`` rows of ``[r, v, elapsed_time]`` in SI units.
    """
    times = _finite_times(times_s)
    history = np.empty((times.size, 7), dtype=float)
    initial = np.hstack([initial_state.r_m, initial_state.v_mps])
    for index, time_s in enumerate(times):
        if ast is not None:
            # Cartesian Kepler propagation avoids circular/equatorial element singularities.
            propagated = propagate_cartesian_rv(initial, float(time_s), float(mu_m3ps2))
            history[index] = np.hstack([propagated, float(time_s)])
        else:
            propagated_state = propagate_two_body_state(
                initial_state, float(time_s), float(mu_m3ps2)
            )
            history[index] = np.hstack(
                [propagated_state.r_m, propagated_state.v_mps, float(time_s)]
            )

    return history

octavian.cislunar.model

Physical system definition and equilibrium geometry for the CR3BP.

CR3BPSystem dataclass

Circular restricted three-body problem physical configuration.

The synodic frame is barycentric. Its +X axis points from primary to secondary, +Z follows their orbital angular momentum, and it rotates at the pair's constant CR3BP mean motion.

Args: primary: More massive body or catalog name. secondary: Less massive body or catalog name. separation_m: Constant circular separation between the bodies. name: Optional display name.

Source code in octavian/cislunar/model.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
@dataclass(frozen=True, slots=True)
class CR3BPSystem:
    """Circular restricted three-body problem physical configuration.

    The synodic frame is barycentric. Its +X axis points from ``primary`` to
    ``secondary``, +Z follows their orbital angular momentum, and it rotates at
    the pair's constant CR3BP mean motion.

    Args:
        primary: More massive body or catalog name.
        secondary: Less massive body or catalog name.
        separation_m: Constant circular separation between the bodies.
        name: Optional display name.
    """

    primary: CelestialBody | str
    secondary: CelestialBody | str
    separation_m: float
    name: str | None = None

    def __post_init__(self) -> None:
        primary = resolve_body(self.primary)
        secondary = resolve_body(self.secondary)
        separation = float(self.separation_m)
        if primary == secondary:
            raise ValueError("CR3BP primary and secondary must be different bodies")
        if not math.isfinite(separation) or separation <= 0.0:
            raise ValueError("CR3BP separation_m must be finite and positive")
        if secondary.mu_m3ps2 > primary.mu_m3ps2:
            raise ValueError("CR3BP primary must be at least as massive as secondary")
        object.__setattr__(self, "primary", primary)
        object.__setattr__(self, "secondary", secondary)
        object.__setattr__(self, "separation_m", separation)
        resolved_name = self.name or f"{primary.name}_{secondary.name}_cr3bp"
        normalized_name = str(resolved_name).strip()
        if not normalized_name:
            raise ValueError("CR3BP system name must not be empty")
        object.__setattr__(self, "name", normalized_name)

    @classmethod
    def earth_moon(
        cls,
        *,
        separation_m: float = 384_400_000.0,
    ) -> CR3BPSystem:
        """Return the conventional circular Earth–Moon CR3BP system."""
        return cls(EARTH, MOON, separation_m, name="earth_moon_cr3bp")

    @property
    def total_mu_m3ps2(self) -> float:
        """Return the sum of the primary and secondary gravitational parameters."""
        return self.primary.mu_m3ps2 + self.secondary.mu_m3ps2  # type: ignore[union-attr]

    @property
    def mass_parameter(self) -> float:
        """Return nondimensional secondary mass fraction ``mu``."""
        return self.secondary.mu_m3ps2 / self.total_mu_m3ps2  # type: ignore[union-attr]

    @property
    def mean_motion_radps(self) -> float:
        """Return constant synodic angular rate in radians per second."""
        return math.sqrt(self.total_mu_m3ps2 / self.separation_m**3)

    @property
    def period_s(self) -> float:
        """Return the circular primary-secondary period in seconds."""
        return 2.0 * math.pi / self.mean_motion_radps

    @property
    def time_scale_s(self) -> float:
        """Return the nondimensional CR3BP time unit in seconds."""
        return 1.0 / self.mean_motion_radps

    @property
    def velocity_scale_mps(self) -> float:
        """Return the nondimensional CR3BP velocity unit in m/s."""
        return self.separation_m * self.mean_motion_radps

    @property
    def frame(self) -> CoordinateFrame:
        """Return barycentric synodic frame metadata."""
        return synodic(self.primary.name, self.secondary.name)  # type: ignore[union-attr]

    @property
    def scaling(self) -> SolverScaling:
        """Return natural dimensional CR3BP solver units."""
        return SolverScaling(
            length_m=self.separation_m,
            velocity_mps=self.velocity_scale_mps,
            time_s=self.time_scale_s,
        )

    @property
    def primary_position_nondimensional(self) -> Vector3:
        """Return the primary's fixed nondimensional synodic position."""
        return np.asarray([-self.mass_parameter, 0.0, 0.0], dtype=float)

    @property
    def secondary_position_nondimensional(self) -> Vector3:
        """Return the secondary's fixed nondimensional synodic position."""
        return np.asarray([1.0 - self.mass_parameter, 0.0, 0.0], dtype=float)

    @property
    def primary_position_m(self) -> Vector3:
        """Return the primary's fixed dimensional synodic position."""
        return self.separation_m * self.primary_position_nondimensional

    @property
    def secondary_position_m(self) -> Vector3:
        """Return the secondary's fixed dimensional synodic position."""
        return self.separation_m * self.secondary_position_nondimensional

    def lagrange_points(
        self,
        *,
        dimensional: bool = True,
        tolerance: float = 1.0e-13,
    ) -> dict[str, Vector3]:
        """Return the five equilibrium points in the synodic frame.

        Args:
            dimensional: Return meters when true, nondimensional coordinates
                when false.
            tolerance: Collinear-root bisection tolerance in nondimensional
                distance.
        """
        if not math.isfinite(float(tolerance)) or float(tolerance) <= 0.0:
            raise ValueError("Lagrange-point tolerance must be finite and positive")
        mu = self.mass_parameter
        epsilon = max(1.0e-10, 10.0 * float(tolerance))
        x_l1 = _bisect_collinear(mu, -mu + epsilon, 1.0 - mu - epsilon, tolerance)
        x_l2 = _bisect_collinear(mu, 1.0 - mu + epsilon, 3.0, tolerance)
        x_l3 = _bisect_collinear(mu, -3.0, -mu - epsilon, tolerance)
        scale = self.separation_m if dimensional else 1.0
        return {
            "L1": scale * np.asarray([x_l1, 0.0, 0.0]),
            "L2": scale * np.asarray([x_l2, 0.0, 0.0]),
            "L3": scale * np.asarray([x_l3, 0.0, 0.0]),
            "L4": scale * np.asarray([0.5 - mu, math.sqrt(3.0) / 2.0, 0.0]),
            "L5": scale * np.asarray([0.5 - mu, -math.sqrt(3.0) / 2.0, 0.0]),
        }

frame: CoordinateFrame property

Return barycentric synodic frame metadata.

mass_parameter: float property

Return nondimensional secondary mass fraction mu.

mean_motion_radps: float property

Return constant synodic angular rate in radians per second.

period_s: float property

Return the circular primary-secondary period in seconds.

primary_position_m: Vector3 property

Return the primary's fixed dimensional synodic position.

primary_position_nondimensional: Vector3 property

Return the primary's fixed nondimensional synodic position.

scaling: SolverScaling property

Return natural dimensional CR3BP solver units.

secondary_position_m: Vector3 property

Return the secondary's fixed dimensional synodic position.

secondary_position_nondimensional: Vector3 property

Return the secondary's fixed nondimensional synodic position.

time_scale_s: float property

Return the nondimensional CR3BP time unit in seconds.

total_mu_m3ps2: float property

Return the sum of the primary and secondary gravitational parameters.

velocity_scale_mps: float property

Return the nondimensional CR3BP velocity unit in m/s.

earth_moon(*, separation_m: float = 384400000.0) -> CR3BPSystem classmethod

Return the conventional circular Earth–Moon CR3BP system.

Source code in octavian/cislunar/model.py
58
59
60
61
62
63
64
65
@classmethod
def earth_moon(
    cls,
    *,
    separation_m: float = 384_400_000.0,
) -> CR3BPSystem:
    """Return the conventional circular Earth–Moon CR3BP system."""
    return cls(EARTH, MOON, separation_m, name="earth_moon_cr3bp")

lagrange_points(*, dimensional: bool = True, tolerance: float = 1e-13) -> dict[str, Vector3]

Return the five equilibrium points in the synodic frame.

Args: dimensional: Return meters when true, nondimensional coordinates when false. tolerance: Collinear-root bisection tolerance in nondimensional distance.

Source code in octavian/cislunar/model.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def lagrange_points(
    self,
    *,
    dimensional: bool = True,
    tolerance: float = 1.0e-13,
) -> dict[str, Vector3]:
    """Return the five equilibrium points in the synodic frame.

    Args:
        dimensional: Return meters when true, nondimensional coordinates
            when false.
        tolerance: Collinear-root bisection tolerance in nondimensional
            distance.
    """
    if not math.isfinite(float(tolerance)) or float(tolerance) <= 0.0:
        raise ValueError("Lagrange-point tolerance must be finite and positive")
    mu = self.mass_parameter
    epsilon = max(1.0e-10, 10.0 * float(tolerance))
    x_l1 = _bisect_collinear(mu, -mu + epsilon, 1.0 - mu - epsilon, tolerance)
    x_l2 = _bisect_collinear(mu, 1.0 - mu + epsilon, 3.0, tolerance)
    x_l3 = _bisect_collinear(mu, -3.0, -mu - epsilon, tolerance)
    scale = self.separation_m if dimensional else 1.0
    return {
        "L1": scale * np.asarray([x_l1, 0.0, 0.0]),
        "L2": scale * np.asarray([x_l2, 0.0, 0.0]),
        "L3": scale * np.asarray([x_l3, 0.0, 0.0]),
        "L4": scale * np.asarray([0.5 - mu, math.sqrt(3.0) / 2.0, 0.0]),
        "L5": scale * np.asarray([0.5 - mu, -math.sqrt(3.0) / 2.0, 0.0]),
    }

octavian.cislunar.transforms

CR3BP dimensional scaling and synodic/inertial transformations.

dimensionalize_state(state_nondimensional: BoundaryState, system: CR3BPSystem) -> BoundaryState

Convert a canonical CR3BP state to dimensional synodic SI units.

Source code in octavian/cislunar/transforms.py
25
26
27
28
29
30
31
32
33
def dimensionalize_state(
    state_nondimensional: BoundaryState,
    system: CR3BPSystem,
) -> BoundaryState:
    """Convert a canonical CR3BP state to dimensional synodic SI units."""
    return BoundaryState(
        state_nondimensional.r_m * system.separation_m,
        state_nondimensional.v_mps * system.velocity_scale_mps,
    )

dimensionalize_time(time_nondimensional: float | Sequence[float], system: CR3BPSystem)

Convert canonical CR3BP time to seconds.

Source code in octavian/cislunar/transforms.py
42
43
44
45
def dimensionalize_time(time_nondimensional: float | Sequence[float], system: CR3BPSystem):
    """Convert canonical CR3BP time to seconds."""
    result = np.asarray(time_nondimensional, dtype=float) * system.time_scale_s
    return float(result) if result.ndim == 0 else result

inertial_to_synodic_state(state_inertial: BoundaryState, *, time_s: float, system: CR3BPSystem, origin: str = 'primary', phase_at_epoch_rad: float = 0.0, inertial_basis_at_epoch: Sequence[Sequence[float]] | None = None) -> BoundaryState

Rotate an origin-centered inertial state into barycentric synodic axes.

inertial_basis_at_epoch has the same convention as :func:synodic_to_inertial_state.

Source code in octavian/cislunar/transforms.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def inertial_to_synodic_state(
    state_inertial: BoundaryState,
    *,
    time_s: float,
    system: CR3BPSystem,
    origin: str = "primary",
    phase_at_epoch_rad: float = 0.0,
    inertial_basis_at_epoch: Sequence[Sequence[float]] | None = None,
) -> BoundaryState:
    """Rotate an origin-centered inertial state into barycentric synodic axes.

    ``inertial_basis_at_epoch`` has the same convention as
    :func:`synodic_to_inertial_state`.
    """
    _validate_rotation_inputs(time_s, phase_at_epoch_rad)
    epoch_basis = _validate_inertial_basis(inertial_basis_at_epoch)
    rotation = (
        _rotation_z(-(float(phase_at_epoch_rad) + system.mean_motion_radps * float(time_s)))
        @ epoch_basis.T
    )
    relative_position = rotation @ state_inertial.r_m
    rotating_velocity = rotation @ state_inertial.v_mps - np.cross(
        [0.0, 0.0, system.mean_motion_radps],
        relative_position,
    )
    return BoundaryState(
        relative_position + _origin_position_synodic(system, origin),
        rotating_velocity,
    )

nondimensionalize_state(state_synodic: BoundaryState, system: CR3BPSystem) -> BoundaryState

Convert a dimensional synodic state to CR3BP canonical units.

Source code in octavian/cislunar/transforms.py
14
15
16
17
18
19
20
21
22
def nondimensionalize_state(
    state_synodic: BoundaryState,
    system: CR3BPSystem,
) -> BoundaryState:
    """Convert a dimensional synodic state to CR3BP canonical units."""
    return BoundaryState(
        state_synodic.r_m / system.separation_m,
        state_synodic.v_mps / system.velocity_scale_mps,
    )

nondimensionalize_time(time_s: float | Sequence[float], system: CR3BPSystem)

Convert seconds to canonical CR3BP time.

Source code in octavian/cislunar/transforms.py
36
37
38
39
def nondimensionalize_time(time_s: float | Sequence[float], system: CR3BPSystem):
    """Convert seconds to canonical CR3BP time."""
    result = np.asarray(time_s, dtype=float) / system.time_scale_s
    return float(result) if result.ndim == 0 else result

synodic_to_inertial_state(state_synodic: BoundaryState, *, time_s: float, system: CR3BPSystem, origin: str = 'primary', phase_at_epoch_rad: float = 0.0, inertial_basis_at_epoch: Sequence[Sequence[float]] | None = None) -> BoundaryState

Rotate a dimensional barycentric synodic state into inertial axes.

Args: state_synodic: Dimensional barycentric synodic state. time_s: Seconds from the frame alignment epoch. system: CR3BP system. origin: "barycenter", "primary", or "secondary". phase_at_epoch_rad: Inertial angle of synodic +X at time zero. inertial_basis_at_epoch: Optional right-handed orthonormal matrix whose columns are the synodic X, Y, and Z axes expressed in inertial coordinates at time zero. This supports inclined primary-secondary planes; the default is the inertial XYZ basis.

Returns: State in inertial axes relative to the selected origin.

Source code in octavian/cislunar/transforms.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def synodic_to_inertial_state(
    state_synodic: BoundaryState,
    *,
    time_s: float,
    system: CR3BPSystem,
    origin: str = "primary",
    phase_at_epoch_rad: float = 0.0,
    inertial_basis_at_epoch: Sequence[Sequence[float]] | None = None,
) -> BoundaryState:
    """Rotate a dimensional barycentric synodic state into inertial axes.

    Args:
        state_synodic: Dimensional barycentric synodic state.
        time_s: Seconds from the frame alignment epoch.
        system: CR3BP system.
        origin: ``"barycenter"``, ``"primary"``, or ``"secondary"``.
        phase_at_epoch_rad: Inertial angle of synodic +X at time zero.
        inertial_basis_at_epoch: Optional right-handed orthonormal matrix whose
            columns are the synodic X, Y, and Z axes expressed in inertial
            coordinates at time zero. This supports inclined primary-secondary
            planes; the default is the inertial XYZ basis.

    Returns:
        State in inertial axes relative to the selected origin.
    """
    _validate_rotation_inputs(time_s, phase_at_epoch_rad)
    origin_position = _origin_position_synodic(system, origin)
    relative_position = state_synodic.r_m - origin_position
    epoch_basis = _validate_inertial_basis(inertial_basis_at_epoch)
    rotation = epoch_basis @ _rotation_z(
        float(phase_at_epoch_rad) + system.mean_motion_radps * float(time_s)
    )
    omega_cross_r = np.cross(
        [0.0, 0.0, system.mean_motion_radps],
        relative_position,
    )
    return BoundaryState(
        rotation @ relative_position,
        rotation @ (state_synodic.v_mps + omega_cross_r),
    )

octavian.cislunar.propagation

Numerical propagation and invariants for CR3BP trajectories.

cr3bp_derivative(state: ArrayLike, *, system: CR3BPSystem, dimensional: bool = True) -> NDArray[np.float64]

Return a six-state CR3BP derivative in synodic coordinates.

Args: state: [x, y, z, xdot, ydot, zdot]. system: Physical primary-secondary system. dimensional: Interpret and return SI units when true; canonical CR3BP units otherwise.

Source code in octavian/cislunar/propagation.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def cr3bp_derivative(
    state: ArrayLike,
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
) -> NDArray[np.float64]:
    """Return a six-state CR3BP derivative in synodic coordinates.

    Args:
        state: ``[x, y, z, xdot, ydot, zdot]``.
        system: Physical primary-secondary system.
        dimensional: Interpret and return SI units when true; canonical CR3BP
            units otherwise.
    """
    values = np.asarray(state, dtype=float).reshape(6)
    if dimensional:
        canonical = nondimensionalize_state(
            BoundaryState(values[0:3], values[3:6]),
            system,
        )
        derivative_canonical = _canonical_derivative(
            np.hstack([canonical.r_m, canonical.v_mps]),
            system.mass_parameter,
        )
        return np.hstack(
            [
                derivative_canonical[0:3] * system.velocity_scale_mps,
                derivative_canonical[3:6] * system.separation_m / system.time_scale_s**2,
            ]
        )
    return _canonical_derivative(values, system.mass_parameter)

jacobi_constant(state: ArrayLike, *, system: CR3BPSystem, dimensional: bool = True) -> float

Return the Jacobi integral for one synodic CR3BP state.

Source code in octavian/cislunar/propagation.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def jacobi_constant(
    state: ArrayLike,
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
) -> float:
    """Return the Jacobi integral for one synodic CR3BP state."""
    values = np.asarray(state, dtype=float).reshape(6)
    if dimensional:
        canonical = nondimensionalize_state(
            BoundaryState(values[0:3], values[3:6]),
            system,
        )
        canonical_value = _canonical_jacobi(
            np.hstack([canonical.r_m, canonical.v_mps]),
            system.mass_parameter,
        )
        return canonical_value * system.velocity_scale_mps**2
    return _canonical_jacobi(values, system.mass_parameter)

propagate_cr3bp(initial_state: BoundaryState, times: Sequence[float], *, system: CR3BPSystem, dimensional: bool = True, max_step: float | None = None) -> StateHistory

Propagate a CR3BP state with deterministic fourth-order Runge-Kutta.

Requested times must be strictly monotonic and may run forward or backward. The returned columns are [x, y, z, xdot, ydot, zdot, time].

Args: initial_state: State at times[0]. times: Output times in seconds or canonical time. system: Physical primary-secondary system. dimensional: Use SI state/time units when true. max_step: Maximum internal step in the selected time units. Defaults to 900 seconds or 0.0025 canonical time.

Source code in octavian/cislunar/propagation.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def propagate_cr3bp(
    initial_state: BoundaryState,
    times: Sequence[float],
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
    max_step: float | None = None,
) -> StateHistory:
    """Propagate a CR3BP state with deterministic fourth-order Runge-Kutta.

    Requested times must be strictly monotonic and may run forward or
    backward. The returned columns are ``[x, y, z, xdot, ydot, zdot, time]``.

    Args:
        initial_state: State at ``times[0]``.
        times: Output times in seconds or canonical time.
        system: Physical primary-secondary system.
        dimensional: Use SI state/time units when true.
        max_step: Maximum internal step in the selected time units. Defaults
            to 900 seconds or 0.0025 canonical time.
    """
    output_times = np.asarray(times, dtype=float).reshape(-1)
    if output_times.size < 2:
        raise ValueError("propagate_cr3bp requires at least two output times")
    differences = np.diff(output_times)
    if not (np.all(differences > 0.0) or np.all(differences < 0.0)):
        raise ValueError("CR3BP output times must be strictly monotonic")
    if not np.all(np.isfinite(output_times)):
        raise ValueError("CR3BP output times must be finite")
    initial_values = np.hstack([initial_state.r_m, initial_state.v_mps])
    if not np.all(np.isfinite(initial_values)):
        raise ValueError("CR3BP initial state must be finite")
    step_limit = float(max_step) if max_step is not None else (900.0 if dimensional else 0.0025)
    if not np.isfinite(step_limit) or step_limit <= 0.0:
        raise ValueError("max_step must be finite and positive")

    if dimensional:
        canonical_state = nondimensionalize_state(initial_state, system)
        state = np.hstack([canonical_state.r_m, canonical_state.v_mps])
        canonical_times = output_times / system.time_scale_s
        canonical_step = step_limit / system.time_scale_s
    else:
        state = initial_values.astype(float)
        canonical_times = output_times
        canonical_step = step_limit

    history = np.empty((output_times.size, 7), dtype=float)
    history[0, 0:6] = np.hstack([initial_state.r_m, initial_state.v_mps])
    history[0, 6] = output_times[0]
    current_time = float(canonical_times[0])
    for output_index in range(1, output_times.size):
        target_time = float(canonical_times[output_index])
        interval = target_time - current_time
        steps = max(1, int(np.ceil(abs(interval) / canonical_step)))
        step = interval / steps
        for _ in range(steps):
            state = _rk4_step(
                state,
                step,
                system.mass_parameter,
            )
            current_time += step
        if dimensional:
            dimensional_state = dimensionalize_state(
                BoundaryState(state[0:3], state[3:6]),
                system,
            )
            history[output_index, 0:6] = np.hstack([dimensional_state.r_m, dimensional_state.v_mps])
        else:
            history[output_index, 0:6] = state
        history[output_index, 6] = output_times[output_index]
    return history

octavian.cislunar.guessing

Initial-guess builders for dimensional or canonical CR3BP phases.

cr3bp_hermite_guess(initial_state: BoundaryState, final_state: BoundaryState, *, t0_s: float, tf_s: float, npts: int) -> list[np.ndarray]

Build a boundary-matching cubic Hermite state guess.

This geometric seed matches both endpoint positions and velocities. It is intended for CR3BP collocation initialization; the optimizer enforces the actual equations of motion.

Args: initial_state: Synodic state at t0_s. final_state: Synodic state at tf_s. t0_s: Initial phase time in the state's consistent unit convention. tf_s: Final phase time in the state's consistent unit convention. npts: Number of returned rows, including endpoints.

Source code in octavian/cislunar/guessing.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def cr3bp_hermite_guess(
    initial_state: BoundaryState,
    final_state: BoundaryState,
    *,
    t0_s: float,
    tf_s: float,
    npts: int,
) -> list[np.ndarray]:
    """Build a boundary-matching cubic Hermite state guess.

    This geometric seed matches both endpoint positions and velocities. It is
    intended for CR3BP collocation initialization; the optimizer enforces the
    actual equations of motion.

    Args:
        initial_state: Synodic state at ``t0_s``.
        final_state: Synodic state at ``tf_s``.
        t0_s: Initial phase time in the state's consistent unit convention.
        tf_s: Final phase time in the state's consistent unit convention.
        npts: Number of returned rows, including endpoints.
    """
    start = float(t0_s)
    end = float(tf_s)
    if end <= start:
        raise ValueError("CR3BP guess requires tf_s > t0_s")
    if int(npts) < 2:
        raise ValueError("CR3BP guess requires at least two points")
    duration = end - start
    times = np.linspace(start, end, int(npts))
    rows: list[np.ndarray] = []
    for time_s in times:
        fraction = (time_s - start) / duration
        fraction_sq = fraction**2
        fraction_cu = fraction**3
        h00 = 2.0 * fraction_cu - 3.0 * fraction_sq + 1.0
        h10 = fraction_cu - 2.0 * fraction_sq + fraction
        h01 = -2.0 * fraction_cu + 3.0 * fraction_sq
        h11 = fraction_cu - fraction_sq
        position = (
            h00 * initial_state.r_m
            + h10 * duration * initial_state.v_mps
            + h01 * final_state.r_m
            + h11 * duration * final_state.v_mps
        )
        dh00 = (6.0 * fraction_sq - 6.0 * fraction) / duration
        dh10 = 3.0 * fraction_sq - 4.0 * fraction + 1.0
        dh01 = (-6.0 * fraction_sq + 6.0 * fraction) / duration
        dh11 = 3.0 * fraction_sq - 2.0 * fraction
        velocity = (
            dh00 * initial_state.r_m
            + dh10 * initial_state.v_mps
            + dh01 * final_state.r_m
            + dh11 * final_state.v_mps
        )
        rows.append(np.hstack([position, velocity, time_s]))
    return rows

octavian.cislunar.dynamics

ASSET equations of motion for dimensional or canonical CR3BP phases.

CR3BPODE

Bases: ODEBase if oc is not None else object

Barycentric-synodic CR3BP equations in SI or canonical units.

Args: system: Primary-secondary physical system. dimensional: Use SI state and time variables when true, or canonical CR3BP variables when false.

Source code in octavian/cislunar/dynamics.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class CR3BPODE(oc.ODEBase if oc is not None else object):
    """Barycentric-synodic CR3BP equations in SI or canonical units.

    Args:
        system: Primary-secondary physical system.
        dimensional: Use SI state and time variables when true, or canonical
            CR3BP variables when false.
    """

    def __init__(self, *, system: CR3BPSystem, dimensional: bool = True) -> None:
        require_asset("CR3BP dynamics")
        self.system = system
        self.dimensional = bool(dimensional)
        arguments = oc.ODEArguments(6, 0)
        state = arguments.XVec()
        position = state.head(3)
        velocity = state.segment(3, 3)
        x = position[0]
        y = position[1]
        z = position[2]
        xdot = velocity[0]
        ydot = velocity[1]

        if self.dimensional:
            primary_x = float(system.primary_position_m[0])
            secondary_x = float(system.secondary_position_m[0])
            mean_motion = float(system.mean_motion_radps)
            primary_mu = float(system.primary.mu_m3ps2)  # type: ignore[union-attr]
            secondary_mu = float(system.secondary.mu_m3ps2)  # type: ignore[union-attr]
        else:
            mass_parameter = float(system.mass_parameter)
            primary_x = -mass_parameter
            secondary_x = 1.0 - mass_parameter
            mean_motion = 1.0
            primary_mu = 1.0 - mass_parameter
            secondary_mu = mass_parameter
        primary_displacement = vf.stack([x - primary_x, y, z])
        secondary_displacement = vf.stack([x - secondary_x, y, z])
        primary_distance = primary_displacement.norm()
        secondary_distance = secondary_displacement.norm()

        acceleration = vf.stack(
            [
                2.0 * mean_motion * ydot + mean_motion**2 * x,
                -2.0 * mean_motion * xdot + mean_motion**2 * y,
                0.0 * z,
            ]
        )
        acceleration -= primary_mu * primary_displacement / primary_distance**3
        acceleration -= secondary_mu * secondary_displacement / secondary_distance**3
        ode = vf.stack([velocity, acceleration])
        groups = {
            ("R", "Position"): position,
            ("V", "Velocity"): velocity,
            ("t", "time"): arguments.TVar(),
            "RV": [0, 1, 2, 3, 4, 5],
        }
        super().__init__(ode, 6, 0, Vgroups=groups)

Quick Builders And Specs

octavian.quick

Low-friction mission builders.

relative_hop(initial_state_ric: BoundaryState, final_state_ric: BoundaryState, *, chief_initial_state_eci: BoundaryState, departure_coast_time_bounds_s: TimeBounds = (1.0, 600.0), transfer_time_bounds_s: TimeBounds = (600.0, 3600.0), dynamics: Dynamics | None = None, central_body: CelestialBody | str = EARTH, propagation_mode: str = 'coupled_eci', perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | None = None, spacecraft: Spacecraft | None = None, reference_length_m: float = 1000.0, nsegs_coast: int = 30, nsegs_transfer: int = 60, seed_grid_size: int = 60, w_time: float = 0.0, name: str = 'Relative hop', solver_options: SolverOptions | None = None) -> Mission

Build a relative coast–impulse–transfer–impulse mission.

The first phase lets the deputy coast on its initial relative orbit. An impulsive link starts the transfer phase, and a terminal impulse matches the requested final RIC velocity. Both optimized dynamics phases remain chief-centered and use the same native relative formulation.

Args: initial_state_ric: Initial deputy state in the chief RIC frame. final_state_ric: Desired post-arrival deputy state in RIC. chief_initial_state_eci: Absolute chief state at mission time zero. departure_coast_time_bounds_s: Bounds on the initial coast duration. transfer_time_bounds_s: Bounds on transfer duration after departure. dynamics: Optional preconfigured relative dynamics. When omitted, Dynamics.relative is built from the remaining dynamics inputs. central_body: Central body used when constructing default dynamics. propagation_mode: Relative formulation used by default dynamics. perturbations: Optional differential perturbation configuration. initial_epoch: Mission epoch required by Sun, Moon, or SRP. spacecraft: Optional deputy configuration. reference_length_m: Relative-distance scaling for default dynamics. nsegs_coast: Mesh segments on the initial coast. nsegs_transfer: Mesh segments on the transfer. seed_grid_size: Number of CWH seed times evaluated per target. w_time: Optional total-time objective weight. name: Human-readable mission name. solver_options: Optional solver configuration.

Returns: A two-phase mission ready for mission.solve().

Source code in octavian/quick.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def relative_hop(
    initial_state_ric: BoundaryState,
    final_state_ric: BoundaryState,
    *,
    chief_initial_state_eci: BoundaryState,
    departure_coast_time_bounds_s: TimeBounds = (1.0, 600.0),
    transfer_time_bounds_s: TimeBounds = (600.0, 3_600.0),
    dynamics: Dynamics | None = None,
    central_body: CelestialBody | str = EARTH,
    propagation_mode: str = "coupled_eci",
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | None = None,
    spacecraft: Spacecraft | None = None,
    reference_length_m: float = 1_000.0,
    nsegs_coast: int = 30,
    nsegs_transfer: int = 60,
    seed_grid_size: int = 60,
    w_time: float = 0.0,
    name: str = "Relative hop",
    solver_options: SolverOptions | None = None,
) -> Mission:
    """Build a relative coast–impulse–transfer–impulse mission.

    The first phase lets the deputy coast on its initial relative orbit.  An
    impulsive link starts the transfer phase, and a terminal impulse matches
    the requested final RIC velocity.  Both optimized dynamics phases remain
    chief-centered and use the same native relative formulation.

    Args:
        initial_state_ric: Initial deputy state in the chief RIC frame.
        final_state_ric: Desired post-arrival deputy state in RIC.
        chief_initial_state_eci: Absolute chief state at mission time zero.
        departure_coast_time_bounds_s: Bounds on the initial coast duration.
        transfer_time_bounds_s: Bounds on transfer duration after departure.
        dynamics: Optional preconfigured relative dynamics. When omitted,
            ``Dynamics.relative`` is built from the remaining dynamics inputs.
        central_body: Central body used when constructing default dynamics.
        propagation_mode: Relative formulation used by default dynamics.
        perturbations: Optional differential perturbation configuration.
        initial_epoch: Mission epoch required by Sun, Moon, or SRP.
        spacecraft: Optional deputy configuration.
        reference_length_m: Relative-distance scaling for default dynamics.
        nsegs_coast: Mesh segments on the initial coast.
        nsegs_transfer: Mesh segments on the transfer.
        seed_grid_size: Number of CWH seed times evaluated per target.
        w_time: Optional total-time objective weight.
        name: Human-readable mission name.
        solver_options: Optional solver configuration.

    Returns:
        A two-phase mission ready for ``mission.solve()``.
    """
    return relative_transfer_chain(
        initial_state_ric,
        [final_state_ric],
        chief_initial_state_eci=chief_initial_state_eci,
        departure_coast_time_bounds_s=departure_coast_time_bounds_s,
        transfer_time_bounds_s=transfer_time_bounds_s,
        coast_time_bounds_s=(),
        dynamics=dynamics,
        central_body=central_body,
        propagation_mode=propagation_mode,
        perturbations=perturbations,
        initial_epoch=initial_epoch,
        spacecraft=spacecraft,
        reference_length_m=reference_length_m,
        nsegs_coast=nsegs_coast,
        nsegs_transfer=nsegs_transfer,
        seed_grid_size=seed_grid_size,
        w_time=w_time,
        name=name,
        solver_options=solver_options,
    )

relative_transfer_chain(initial_state_ric: BoundaryState, target_states_ric: Sequence[BoundaryState], *, chief_initial_state_eci: BoundaryState, transfer_time_bounds_s: TimeBoundsInput = (600.0, 3600.0), coast_time_bounds_s: TimeBoundsInput | None = None, departure_coast_time_bounds_s: TimeBounds | None = None, dynamics: Dynamics | None = None, central_body: CelestialBody | str = EARTH, propagation_mode: str = 'coupled_eci', perturbations: Perturbations | None = None, initial_epoch: str | datetime | float | None = None, spacecraft: Spacecraft | None = None, reference_length_m: float = 1000.0, nsegs_coast: int = 30, nsegs_transfer: int = 60, seed_grid_size: int = 60, w_time: float = 0.0, name: str = 'Relative transfer chain', solver_options: SolverOptions | None = None) -> Mission

Build one or more impulsive relative transfers with coasts between.

Each target is a post-arrival RIC state. For nonterminal targets, an impulsive link inserts the arrival burn and starts a natural coast at that exact state. A later impulsive link departs that coast for the next transfer. This yields four burns for two ordinary two-burn transfers, or can be adapted into a three-burn design by editing the returned phases.

A single time-bound pair is repeated for every applicable phase. Pass a sequence of pairs to configure individual transfers or intermediate coasts.

Args: initial_state_ric: Initial deputy state in the chief RIC frame. target_states_ric: Ordered post-arrival RIC targets. chief_initial_state_eci: Absolute chief state at mission time zero. transfer_time_bounds_s: One duration bound or one per target. coast_time_bounds_s: One duration bound or one per intermediate target. Defaults to (300, 900) when multiple targets are supplied. departure_coast_time_bounds_s: Optional coast before the first burn. dynamics: Optional preconfigured relative dynamics. central_body: Central body used when constructing default dynamics. propagation_mode: Relative formulation used by default dynamics. perturbations: Optional differential perturbation configuration. initial_epoch: Mission epoch required by Sun, Moon, or SRP. spacecraft: Optional deputy configuration. reference_length_m: Relative-distance scaling for default dynamics. nsegs_coast: Mesh segments used for coast phases. nsegs_transfer: Mesh segments used for transfer phases. seed_grid_size: Number of CWH seed times evaluated per target. w_time: Optional total-time objective weight. name: Human-readable mission name. solver_options: Optional solver configuration.

Returns: A linked relative mission ready for mission.solve().

Raises: ValueError: If no targets are supplied, bounds counts are inconsistent, or perturbations are supplied alongside preconfigured dynamics.

Source code in octavian/quick.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def relative_transfer_chain(
    initial_state_ric: BoundaryState,
    target_states_ric: Sequence[BoundaryState],
    *,
    chief_initial_state_eci: BoundaryState,
    transfer_time_bounds_s: TimeBoundsInput = (600.0, 3_600.0),
    coast_time_bounds_s: TimeBoundsInput | None = None,
    departure_coast_time_bounds_s: TimeBounds | None = None,
    dynamics: Dynamics | None = None,
    central_body: CelestialBody | str = EARTH,
    propagation_mode: str = "coupled_eci",
    perturbations: Perturbations | None = None,
    initial_epoch: str | datetime | float | None = None,
    spacecraft: Spacecraft | None = None,
    reference_length_m: float = 1_000.0,
    nsegs_coast: int = 30,
    nsegs_transfer: int = 60,
    seed_grid_size: int = 60,
    w_time: float = 0.0,
    name: str = "Relative transfer chain",
    solver_options: SolverOptions | None = None,
) -> Mission:
    """Build one or more impulsive relative transfers with coasts between.

    Each target is a post-arrival RIC state.  For nonterminal targets, an
    impulsive link inserts the arrival burn and starts a natural coast at that
    exact state.  A later impulsive link departs that coast for the next
    transfer.  This yields four burns for two ordinary two-burn transfers, or
    can be adapted into a three-burn design by editing the returned phases.

    A single time-bound pair is repeated for every applicable phase.  Pass a
    sequence of pairs to configure individual transfers or intermediate
    coasts.

    Args:
        initial_state_ric: Initial deputy state in the chief RIC frame.
        target_states_ric: Ordered post-arrival RIC targets.
        chief_initial_state_eci: Absolute chief state at mission time zero.
        transfer_time_bounds_s: One duration bound or one per target.
        coast_time_bounds_s: One duration bound or one per intermediate target.
            Defaults to ``(300, 900)`` when multiple targets are supplied.
        departure_coast_time_bounds_s: Optional coast before the first burn.
        dynamics: Optional preconfigured relative dynamics.
        central_body: Central body used when constructing default dynamics.
        propagation_mode: Relative formulation used by default dynamics.
        perturbations: Optional differential perturbation configuration.
        initial_epoch: Mission epoch required by Sun, Moon, or SRP.
        spacecraft: Optional deputy configuration.
        reference_length_m: Relative-distance scaling for default dynamics.
        nsegs_coast: Mesh segments used for coast phases.
        nsegs_transfer: Mesh segments used for transfer phases.
        seed_grid_size: Number of CWH seed times evaluated per target.
        w_time: Optional total-time objective weight.
        name: Human-readable mission name.
        solver_options: Optional solver configuration.

    Returns:
        A linked relative mission ready for ``mission.solve()``.

    Raises:
        ValueError: If no targets are supplied, bounds counts are inconsistent,
            or perturbations are supplied alongside preconfigured dynamics.
    """
    targets = list(target_states_ric)
    if not targets:
        raise ValueError("target_states_ric must contain at least one target")
    transfer_bounds = _expand_time_bounds(
        transfer_time_bounds_s,
        count=len(targets),
        name="transfer_time_bounds_s",
    )
    coast_count = max(len(targets) - 1, 0)
    intermediate_coast_bounds = _expand_time_bounds(
        (300.0, 900.0) if coast_time_bounds_s is None else coast_time_bounds_s,
        count=coast_count,
        name="coast_time_bounds_s",
    )
    if dynamics is not None and perturbations is not None:
        raise ValueError(
            "Pass perturbations through the preconfigured dynamics, or omit "
            "dynamics and let relative_transfer_chain construct it."
        )
    relative_dynamics = dynamics or Dynamics.relative(
        chief_initial_state_eci=chief_initial_state_eci,
        central_body=central_body,
        reference_length_m=reference_length_m,
        propagation_mode=propagation_mode,
        perturbations=perturbations,
    )
    if relative_dynamics.frame.kind != "relative":
        raise ValueError("relative_transfer_chain requires relative dynamics")
    deputy = spacecraft or Spacecraft(name="Deputy", dry_mass_kg=250.0)

    phases: list[Phase] = []
    previous: Phase | None = None
    if departure_coast_time_bounds_s is not None:
        departure_coast = Phase(
            name="departure_coast",
            mode="relative_coast",
            spacecraft=deputy,
            dynamics=relative_dynamics,
            initial_state=initial_state_ric,
            tof_bounds_s=_validated_time_bounds(
                departure_coast_time_bounds_s,
                name="departure_coast_time_bounds_s",
            ),
            tof_is_relative=True,
            constraints=[
                mission_constraints.state(initial_state_ric, where="Front"),
            ],
        )
        phases.append(departure_coast)
        previous = departure_coast

    for target_index, (target, bounds) in enumerate(
        zip(targets, transfer_bounds, strict=True),
        start=1,
    ):
        is_last_target = target_index == len(targets)
        transfer_constraints = []
        if previous is None:
            transfer_constraints.append(
                mission_constraints.state(initial_state_ric, where="Front")
            )
        if is_last_target:
            transfer_constraints.append(
                mission_constraints.state(target, where="Back")
            )
        transfer_variables = [variables.impulsive_delta_v(at="Front")]
        if is_last_target:
            transfer_variables.append(variables.impulsive_delta_v(at="Back"))
        transfer = Phase(
            name=f"transfer_{target_index}",
            mode="relative_coast",
            spacecraft=deputy,
            dynamics=relative_dynamics,
            previous=previous,
            link=links.impulsive() if previous is not None else None,
            initial_state=initial_state_ric if previous is None else None,
            final_state=target,
            tof_bounds_s=bounds,
            tof_is_relative=True,
            constraints=transfer_constraints,
            variables=transfer_variables,
        )
        phases.append(transfer)
        previous = transfer

        if not is_last_target:
            target_coast = Phase(
                name=f"target_{target_index}_coast",
                mode="relative_coast",
                spacecraft=deputy,
                dynamics=relative_dynamics,
                previous=transfer,
                link=links.impulsive(),
                initial_state=target,
                tof_bounds_s=intermediate_coast_bounds[target_index - 1],
                tof_is_relative=True,
                constraints=[
                    mission_constraints.state(target, where="Front"),
                ],
                variables=[variables.impulsive_delta_v(at="Front")],
            )
            phases.append(target_coast)
            previous = target_coast

    mission_objectives = [objectives.minimize_total_delta_v()]
    if float(w_time) != 0.0:
        mission_objectives.append(objectives.minimize_total_time(weight=float(w_time)))
    return Mission(
        phases=phases,
        name=name,
        initial_epoch=initial_epoch,
        objectives=mission_objectives,
        mesh_nsegs_precoast=int(nsegs_coast),
        mesh_nsegs_transfer=int(nsegs_transfer),
        lambert_grid_size=int(seed_grid_size),
        w_time=float(w_time),
        solver_options=solver_options or SolverOptions(),
    )

state(r_m: np.ndarray | Sequence[float], v_mps: np.ndarray | Sequence[float]) -> BoundaryState

Create a boundary state from Cartesian position and velocity vectors.

Args: r_m: Position vector in meters. v_mps: Velocity vector in meters per second.

Returns: A boundary-state object that can be passed to quick builders, ConOps helpers, or lower-level specs.

Source code in octavian/quick.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def state(
    r_m: np.ndarray | Sequence[float],
    v_mps: np.ndarray | Sequence[float],
) -> BoundaryState:
    """Create a boundary state from Cartesian position and velocity vectors.

    Args:
        r_m: Position vector in meters.
        v_mps: Velocity vector in meters per second.

    Returns:
        A boundary-state object that can be passed to quick builders, ConOps
        helpers, or lower-level specs.
    """
    return _state(r_m, v_mps)

two_burn_rendezvous(x0: BoundaryState, xf: BoundaryState, *, mu_m3ps2: float = 398600441800000.0, central_body: CelestialBody | str | None = None, tf_bounds_s: tuple[float, float] = (600.0, 7200.0), nsegs: int = 60, lambert_grid_size: int = 60, nrevs_to_try: tuple[int, ...] = (0, 1), w_time: float = 0.0, precoast: bool = False, t1_bounds_s: tuple[float, float] = (0.0, 1800.0), precoast_grid_size: int = 10, limit_precoast_to_one_period: bool = True, name: str = 'Two-burn rendezvous', constraints: Sequence[object] | None = None, solver_options: SolverOptions | None = None) -> Mission

Create a ready-to-solve impulsive rendezvous mission.

Args: x0: Initial boundary state. xf: Final boundary state. mu_m3ps2: Central-body gravitational parameter in m^3/s^2. central_body: Optional built-in or custom body. When provided, its gravity, radius, J2, and inertial frame replace mu_m3ps2 and the legacy Earth defaults. tf_bounds_s: Bounds on elapsed mission time at rendezvous, including any precoast. Transfer duration is final time minus precoast time. nsegs: Number of mesh segments used by the transfer phase. lambert_grid_size: Number of Lambert time-of-flight samples to try. nrevs_to_try: Revolution counts to include in the Lambert seed search. w_time: Weight on final time in the objective. precoast: If True, build a precoast-plus-transfer mission instead of a single transfer phase. t1_bounds_s: Bounds on precoast duration in seconds when precoast is enabled. A zero lower bound is allowed; the two-phase solver uses its positive minimum phase duration when constructing seeds. precoast_grid_size: Number of precoast candidates to test when seeding. limit_precoast_to_one_period: Whether to cap the precoast seed sweep to one orbital period when possible. name: Human-readable mission name. constraints: Additional constraints attached to the rendezvous phase. solver_options: Optional solver overrides attached to the mission.

Returns: A configured mission object ready for mission.solve().

Source code in octavian/quick.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def two_burn_rendezvous(
    x0: BoundaryState,
    xf: BoundaryState,
    *,
    mu_m3ps2: float = 3.986004418e14,
    central_body: CelestialBody | str | None = None,
    tf_bounds_s: tuple[float, float] = (600.0, 7200.0),
    nsegs: int = 60,
    lambert_grid_size: int = 60,
    nrevs_to_try: tuple[int, ...] = (0, 1),
    w_time: float = 0.0,
    precoast: bool = False,
    t1_bounds_s: tuple[float, float] = (0.0, 1800.0),
    precoast_grid_size: int = 10,
    limit_precoast_to_one_period: bool = True,
    name: str = "Two-burn rendezvous",
    constraints: Sequence[object] | None = None,
    solver_options: SolverOptions | None = None,
) -> Mission:
    """Create a ready-to-solve impulsive rendezvous mission.

    Args:
        x0: Initial boundary state.
        xf: Final boundary state.
        mu_m3ps2: Central-body gravitational parameter in m^3/s^2.
        central_body: Optional built-in or custom body. When provided, its
            gravity, radius, J2, and inertial frame replace ``mu_m3ps2`` and
            the legacy Earth defaults.
        tf_bounds_s: Bounds on elapsed mission time at rendezvous, including
            any precoast. Transfer duration is final time minus precoast time.
        nsegs: Number of mesh segments used by the transfer phase.
        lambert_grid_size: Number of Lambert time-of-flight samples to try.
        nrevs_to_try: Revolution counts to include in the Lambert seed search.
        w_time: Weight on final time in the objective.
        precoast: If ``True``, build a precoast-plus-transfer mission instead
            of a single transfer phase.
        t1_bounds_s: Bounds on precoast duration in seconds when ``precoast``
            is enabled. A zero lower bound is allowed; the two-phase solver
            uses its positive minimum phase duration when constructing seeds.
        precoast_grid_size: Number of precoast candidates to test when seeding.
        limit_precoast_to_one_period: Whether to cap the precoast seed sweep to
            one orbital period when possible.
        name: Human-readable mission name.
        constraints: Additional constraints attached to the rendezvous phase.
        solver_options: Optional solver overrides attached to the mission.

    Returns:
        A configured mission object ready for ``mission.solve()``.
    """
    default_thruster = Thruster(name="main")
    spacecraft = Spacecraft(name="SC", dry_mass_kg=0.0, thrusters=[default_thruster])
    dynamics = (
        Dynamics.for_body(central_body)
        if central_body is not None
        else Dynamics(mu_m3ps2=float(mu_m3ps2))
    )
    if not precoast:
        mission = rendezvous_two_impulse(
            spacecraft=spacecraft,
            dynamics=dynamics,
            initial_state=x0,
            final_state=xf,
            tf_bounds_s=tf_bounds_s,
            nsegs=nsegs,
            lambert_grid_size=lambert_grid_size,
            nrevs_to_try=nrevs_to_try,
            w_time=w_time,
            name=name,
            constraints=constraints,
        )
    else:
        mission = rendezvous_precoast_then_transfer(
            spacecraft=spacecraft,
            dynamics=dynamics,
            initial_state=x0,
            final_state=xf,
            t1_bounds_s=t1_bounds_s,
            tf_bounds_s=tf_bounds_s,
            nsegs_precoast=max(10, int(nsegs // 2)),
            nsegs_transfer=int(nsegs),
            precoast_grid_size=precoast_grid_size,
            limit_precoast_to_one_period=limit_precoast_to_one_period,
            lambert_grid_size=lambert_grid_size,
            nrevs_to_try=nrevs_to_try,
            w_time=w_time,
            name=name,
            constraints_rendezvous=constraints,
        )

    if solver_options is not None:
        mission.solver_options = solver_options
    return mission

octavian.conops

Concept-of-operations mission builders.

rendezvous_precoast_then_transfer(*, spacecraft: Spacecraft, dynamics: Dynamics, initial_state: BoundaryState, final_state: BoundaryState, t1_bounds_s: tuple[float, float] = (0.0, 1800.0), tf_bounds_s: tuple[float, float] = (600.0, 7200.0), nsegs_precoast: int = 30, nsegs_transfer: int = 60, precoast_grid_size: int = 10, limit_precoast_to_one_period: bool = True, lambert_grid_size: int = 60, nrevs_to_try: tuple[int, ...] = (0, 1), w_time: float = 0.0, name: str = 'Precoast + two-impulse rendezvous', constraints_precoast: Sequence[object] | None = None, constraints_rendezvous: Sequence[object] | None = None) -> Mission

Build a two-phase mission with a precoast before rendezvous.

Args: spacecraft: Spacecraft used by the mission. dynamics: Dynamics model shared by both phases. initial_state: Initial state fixed at the start of the precoast phase. final_state: Final state target applied at the end of the transfer phase. t1_bounds_s: Bounds on precoast duration in seconds. tf_bounds_s: Bounds on elapsed mission time at rendezvous, including the precoast; these are not bounds on transfer duration alone. nsegs_precoast: Number of mesh segments for the precoast phase. nsegs_transfer: Number of mesh segments for the transfer phase. precoast_grid_size: Number of precoast seed candidates to evaluate. limit_precoast_to_one_period: Whether to cap the seed sweep to one orbital period when the initial orbit period is known. lambert_grid_size: Number of Lambert seed samples per candidate. nrevs_to_try: Revolution counts included in the Lambert seed search. w_time: Weight on final time in the objective. name: Human-readable mission name. constraints_precoast: Additional constraints for the precoast phase. constraints_rendezvous: Additional constraints for the transfer phase.

Returns: A mission with a precoast phase followed by an impulsive rendezvous phase.

Source code in octavian/conops.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def rendezvous_precoast_then_transfer(
    *,
    spacecraft: Spacecraft,
    dynamics: Dynamics,
    initial_state: BoundaryState,
    final_state: BoundaryState,
    t1_bounds_s: tuple[float, float] = (0.0, 1800.0),
    tf_bounds_s: tuple[float, float] = (600.0, 7200.0),
    nsegs_precoast: int = 30,
    nsegs_transfer: int = 60,
    precoast_grid_size: int = 10,
    limit_precoast_to_one_period: bool = True,
    lambert_grid_size: int = 60,
    nrevs_to_try: tuple[int, ...] = (0, 1),
    w_time: float = 0.0,
    name: str = "Precoast + two-impulse rendezvous",
    constraints_precoast: Sequence[object] | None = None,
    constraints_rendezvous: Sequence[object] | None = None,
) -> Mission:
    """Build a two-phase mission with a precoast before rendezvous.

    Args:
        spacecraft: Spacecraft used by the mission.
        dynamics: Dynamics model shared by both phases.
        initial_state: Initial state fixed at the start of the precoast phase.
        final_state: Final state target applied at the end of the transfer phase.
        t1_bounds_s: Bounds on precoast duration in seconds.
        tf_bounds_s: Bounds on elapsed mission time at rendezvous, including
            the precoast; these are not bounds on transfer duration alone.
        nsegs_precoast: Number of mesh segments for the precoast phase.
        nsegs_transfer: Number of mesh segments for the transfer phase.
        precoast_grid_size: Number of precoast seed candidates to evaluate.
        limit_precoast_to_one_period: Whether to cap the seed sweep to one
            orbital period when the initial orbit period is known.
        lambert_grid_size: Number of Lambert seed samples per candidate.
        nrevs_to_try: Revolution counts included in the Lambert seed search.
        w_time: Weight on final time in the objective.
        name: Human-readable mission name.
        constraints_precoast: Additional constraints for the precoast phase.
        constraints_rendezvous: Additional constraints for the transfer phase.

    Returns:
        A mission with a precoast phase followed by an impulsive rendezvous phase.
    """
    precoast_phase = Phase(
        name="precoast",
        mode="coast",
        spacecraft=spacecraft,
        dynamics=dynamics,
        initial_state=initial_state,
        tof_bounds_s=t1_bounds_s,
        constraints=list(constraints_precoast or []),
    )

    transfer_phase = Phase(
        name="rendezvous",
        mode="rendezvous",
        previous=precoast_phase,
        dynamics=dynamics,
        spacecraft=spacecraft,
        final_state=final_state,
        tof_bounds_s=tf_bounds_s,
        constraints=list(constraints_rendezvous or []),
    )

    return Mission(
        phases=[precoast_phase, transfer_phase],
        name=name,
        mesh_nsegs_precoast=int(nsegs_precoast),
        mesh_nsegs_transfer=int(nsegs_transfer),
        precoast_grid_size=int(precoast_grid_size),
        limit_precoast_to_one_period=bool(limit_precoast_to_one_period),
        lambert_grid_size=int(lambert_grid_size),
        nrevs_to_try=tuple(int(revolution_count) for revolution_count in nrevs_to_try),
        w_time=float(w_time),
    )

rendezvous_two_impulse(*, spacecraft: Spacecraft, dynamics: Dynamics, initial_state: BoundaryState, final_state: BoundaryState, tf_bounds_s: tuple[float, float] = (600.0, 7200.0), nsegs: int = 60, lambert_grid_size: int = 60, nrevs_to_try: tuple[int, ...] = (0, 1), w_time: float = 0.0, name: str = 'Two-impulse rendezvous', constraints: Sequence[object] | None = None) -> Mission

Build a single-phase impulsive rendezvous mission.

Args: spacecraft: Spacecraft used by the mission. dynamics: Dynamics model applied to the phase. initial_state: Initial state fixed at the front boundary. final_state: Final state target applied at the back boundary. tf_bounds_s: Bounds on total transfer time in seconds. nsegs: Number of transfer mesh segments. lambert_grid_size: Number of Lambert seed samples. nrevs_to_try: Revolution counts included in the Lambert seed search. w_time: Weight on final time in the objective. name: Human-readable mission name. constraints: Additional constraints for the rendezvous phase.

Returns: A mission configured for the built-in two-impulse rendezvous solver.

Source code in octavian/conops.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def rendezvous_two_impulse(
    *,
    spacecraft: Spacecraft,
    dynamics: Dynamics,
    initial_state: BoundaryState,
    final_state: BoundaryState,
    tf_bounds_s: tuple[float, float] = (600.0, 7200.0),
    nsegs: int = 60,
    lambert_grid_size: int = 60,
    nrevs_to_try: tuple[int, ...] = (0, 1),
    w_time: float = 0.0,
    name: str = "Two-impulse rendezvous",
    constraints: Sequence[object] | None = None,
) -> Mission:
    """Build a single-phase impulsive rendezvous mission.

    Args:
        spacecraft: Spacecraft used by the mission.
        dynamics: Dynamics model applied to the phase.
        initial_state: Initial state fixed at the front boundary.
        final_state: Final state target applied at the back boundary.
        tf_bounds_s: Bounds on total transfer time in seconds.
        nsegs: Number of transfer mesh segments.
        lambert_grid_size: Number of Lambert seed samples.
        nrevs_to_try: Revolution counts included in the Lambert seed search.
        w_time: Weight on final time in the objective.
        name: Human-readable mission name.
        constraints: Additional constraints for the rendezvous phase.

    Returns:
        A mission configured for the built-in two-impulse rendezvous solver.
    """
    rendezvous_phase = Phase(
        name="rendezvous",
        mode="rendezvous",
        spacecraft=spacecraft,
        dynamics=dynamics,
        initial_state=initial_state,
        final_state=final_state,
        tof_bounds_s=tf_bounds_s,
        constraints=list(constraints or []),
    )

    return Mission(
        phases=[rendezvous_phase],
        name=name,
        mesh_nsegs_transfer=int(nsegs),
        lambert_grid_size=int(lambert_grid_size),
        nrevs_to_try=tuple(int(revolution_count) for revolution_count in nrevs_to_try),
        w_time=float(w_time),
    )

octavian.specs

BoundaryState dataclass

Dimensional Cartesian boundary state in the mission dynamics frame.

Attributes: r_m: Position in meters, shape (3,). v_mps: Velocity in meters per second, shape (3,).

Source code in octavian/specs.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@dataclass(frozen=True)
class BoundaryState:
    """Dimensional Cartesian boundary state in the mission dynamics frame.

    Attributes:
        r_m: Position in meters, shape (3,).
        v_mps: Velocity in meters per second, shape (3,).
    """

    r_m: np.ndarray
    v_mps: np.ndarray

    def __post_init__(self) -> None:
        object.__setattr__(self, "r_m", np.asarray(self.r_m, dtype=float).reshape(3))
        object.__setattr__(self, "v_mps", np.asarray(self.v_mps, dtype=float).reshape(3))

TwoImpulseFreeTimeSpec dataclass

Problem specification for two-impulse rendezvous with free final time.

Source code in octavian/specs.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass(frozen=True)
class TwoImpulseFreeTimeSpec:
    """Problem specification for two-impulse rendezvous with free final time."""

    x0: BoundaryState
    xf: BoundaryState

    tf_bounds_s: tuple[float, float] = (600.0, 7200.0)
    tf_guess_s: float | None = None

    mu_m3ps2: float = 3.986004418e14
    central_body_name: str | None = None
    frame: CoordinateFrame = EARTH_INERTIAL
    scaling: SolverScaling | None = None
    nsegs: int = 60

    # Auto-scaling overrides
    r_unit_m: float | None = None
    v_unit_mps: float | None = None
    t_unit_s: float | None = None

    w_time: float = 0.0

    # Objective toggles
    minimize_dv: bool = True
    dv_weight: float = 1.0
    minimize_time: bool = False

    # Boundary impulse structure
    dv_front: bool = True
    dv_back: bool = True
    dv_front_max_mps: float | None = None
    dv_back_max_mps: float | None = None

    lambert_grid_size: int = 50
    nrevs_to_try: tuple[int, ...] = (0, 1)

    @property
    def initial_state(self) -> BoundaryState:
        """Alias for `x0` with a clearer name."""
        return self.x0

    @property
    def final_state(self) -> BoundaryState:
        """Alias for `xf` with a clearer name."""
        return self.xf

    @property
    def final_time_bounds_s(self) -> tuple[float, float]:
        """Alias for `tf_bounds_s` with a clearer name."""
        return self.tf_bounds_s

final_state: BoundaryState property

Alias for xf with a clearer name.

final_time_bounds_s: tuple[float, float] property

Alias for tf_bounds_s with a clearer name.

initial_state: BoundaryState property

Alias for x0 with a clearer name.

TwoImpulsePreCoastSpec dataclass

Problem specification for two-impulse rendezvous with variable pre-coast.

Source code in octavian/specs.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@dataclass(frozen=True)
class TwoImpulsePreCoastSpec:
    """Problem specification for two-impulse rendezvous with variable pre-coast."""

    x0: BoundaryState
    xf: BoundaryState

    t1_bounds_s: tuple[float, float] = (0.0, 1800.0)
    tf_bounds_s: tuple[float, float] = (600.0, 7200.0)

    mu_m3ps2: float = 3.986004418e14
    central_body_name: str | None = None
    frame: CoordinateFrame = EARTH_INERTIAL
    scaling: SolverScaling | None = None

    nsegs_precoast: int = 30
    nsegs_transfer: int = 60

    # Auto-scaling overrides
    r_unit_m: float | None = None
    v_unit_mps: float | None = None
    t_unit_s: float | None = None

    w_time: float = 0.0

    # Objective toggles
    minimize_dv: bool = True
    dv_weight: float = 1.0
    minimize_time: bool = False

    # Boundary impulse structure
    dv_front: bool = True
    dv_back: bool = True
    dv_front_max_mps: float | None = None
    dv_back_max_mps: float | None = None

    # Link semantics between precoast and transfer:
    # "continuous" => (R, V, t) continuous
    # "impulsive"  => (R, t) continuous, allow V jump (Δv at link)
    link_kind: str = "impulsive"
    dv_link: bool = True

    precoast_grid_size: int = 10
    limit_precoast_to_one_period: bool = True

    lambert_grid_size: int = 50
    nrevs_to_try: tuple[int, ...] = (0, 1)

    min_dt_precoast_s: float = 0.0001
    min_dt_transfer_s: float = 0.0001

    @property
    def initial_state(self) -> BoundaryState:
        """Alias for `x0` with a clearer name."""
        return self.x0

    @property
    def final_state(self) -> BoundaryState:
        """Alias for `xf` with a clearer name."""
        return self.xf

    @property
    def precoast_time_bounds_s(self) -> tuple[float, float]:
        """Alias for `t1_bounds_s` with a clearer name."""
        return self.t1_bounds_s

    @property
    def final_time_bounds_s(self) -> tuple[float, float]:
        """Alias for `tf_bounds_s` with a clearer name."""
        return self.tf_bounds_s

final_state: BoundaryState property

Alias for xf with a clearer name.

final_time_bounds_s: tuple[float, float] property

Alias for tf_bounds_s with a clearer name.

initial_state: BoundaryState property

Alias for x0 with a clearer name.

precoast_time_bounds_s: tuple[float, float] property

Alias for t1_bounds_s with a clearer name.

octavian.solvers.options

Solver configuration objects.

Octavian's philosophy is that Python is the GUI: - User-facing configuration is explicit and typed. - Mission/solver scripts are readable and composable. - Internals remain small and unsurprising.

This module defines small option dataclasses that keep optimizer knobs out of solver implementations.

SolverOptions dataclass

Common options shared across ASSET-backed solvers.

Attributes: print_level: ASSET optimizer PrintLevel. 0 is quiet. max_ls_iters: ASSET optimizer MaxLSIters (line-search iterations). qp_ordering_mode: QP ordering mode used by ASSET (string passed to optimizer.set_QPOrderingMode). enable_auto_scaling: Whether to enable ASSET phase auto-scaling. enable_adaptive_mesh: Whether to allow adaptive mesh refinement. asset_threads: Optional (optimizer_threads, mesh_threads) pair passed to ASSET's ocp.setThreads. Use (1, 1) for deterministic regression tests when ASSET's multi-threaded solve path is noisy.

Source code in octavian/solvers/options.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@dataclass(slots=True)
class SolverOptions:
    """Common options shared across ASSET-backed solvers.

    Attributes:
        print_level: ASSET optimizer PrintLevel. 0 is quiet.
        max_ls_iters: ASSET optimizer MaxLSIters (line-search iterations).
        qp_ordering_mode: QP ordering mode used by ASSET (string passed to
            ``optimizer.set_QPOrderingMode``).
        enable_auto_scaling: Whether to enable ASSET phase auto-scaling.
        enable_adaptive_mesh: Whether to allow adaptive mesh refinement.
        asset_threads: Optional ``(optimizer_threads, mesh_threads)`` pair passed
            to ASSET's ``ocp.setThreads``. Use ``(1, 1)`` for deterministic
            regression tests when ASSET's multi-threaded solve path is noisy.
    """

    print_level: int = 0
    max_ls_iters: int = 2
    qp_ordering_mode: str = "MINDEG"
    enable_auto_scaling: bool = True
    enable_adaptive_mesh: bool = True
    asset_threads: tuple[int, int] | None = None

octavian.solvers.preconfigured

Preconfigured impulsive transfer solvers built on ASSET's UpdatedInterface.

Key correctness notes (from ASSET source/examples): - Time is not a state. Time is the phase time variable (ODEArguments.TVar()) and appears in the phase variable stack after X: for XVars=6, the time index is 6. - Use Vgroups in the ODE ("R", "V", "t"/"time") so constraints can refer to semantic groups (see examples/UpdatedInterface/Delta3Launch.py in ASSET).

This module owns Octavian's built-in, preconfigured two-impulse problem shapes. It is separate from the composable compiler: callers provide a typed problem specification, and this backend builds one of the fixed ASSET OCP layouts below. Everything remains dimensional at the user API and relies on ASSET auto-scaling.

RendezvousResult dataclass

Result returned by ASSET-backed transfer solvers.

Attributes: converged: Whether ASSET reported convergence. traj: Dense trajectory array. For the current impulsive problems this is typically [x0..x5, t] where t is the phase time variable. maneuvers: Maneuver markers derived from the solution. last_obj: Last objective value returned by ASSET. info: Free-form metadata (seed selection, bounds used, etc.).

The result object is designed to be the currency of studies: it supports human-readable summaries and simple persistence.

Source code in octavian/solvers/preconfigured.py
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
@dataclass
class RendezvousResult:
    """Result returned by ASSET-backed transfer solvers.

    Attributes:
        converged: Whether ASSET reported convergence.
        traj: Dense trajectory array. For the current impulsive problems this is
            typically ``[x0..x5, t]`` where ``t`` is the phase time variable.
        maneuvers: Maneuver markers derived from the solution.
        last_obj: Last objective value returned by ASSET.
        info: Free-form metadata (seed selection, bounds used, etc.).

    The result object is designed to be the *currency* of studies:
    it supports human-readable summaries and simple persistence.
    """

    converged: bool
    traj: TrajArray
    maneuvers: list[Maneuver] = field(default_factory=list)
    last_obj: float = float("nan")
    info: dict[str, Any] = field(default_factory=dict)

    def tf_s(self) -> float:
        """Return the final trajectory time.

        Returns:
            The last sample in seconds, or in TU for a canonical CR3BP solve.
            The ``_s`` suffix is retained for API compatibility.
        """
        if self.traj.size == 0:
            return float("nan")
        return float(self.traj[-1, -1])

    def total_dv_mps(self) -> float:
        """Return the total maneuver delta-v magnitude.

        Returns:
            The sum in m/s, or in VU for a canonical CR3BP solve. Returns
            ``0.0`` when no maneuvers are stored. The ``_mps`` suffix is
            retained for API compatibility.
        """
        if not self.maneuvers:
            return 0.0
        return float(sum(np.linalg.norm(m.dv_mps) for m in self.maneuvers))

    def summary(self) -> str:
        """Build a compact human-readable summary of the result.

        Returns:
            A multiline summary string containing convergence status, timing,
            delta-v, and selected metadata.
        """
        lines: list[str] = []
        status = "CONVERGED" if self.converged else "NOT CONVERGED"
        cr3bp_metadata = self.info.get("cr3bp_system")
        canonical_cr3bp = (
            isinstance(cr3bp_metadata, dict)
            and cr3bp_metadata.get("dimensional") is False
        )
        time_unit = "TU" if canonical_cr3bp else "s"
        velocity_unit = "VU" if canonical_cr3bp else "m/s"
        lines.append(f"Octavian result: {status}")
        if not self.converged and self.info.get("optimizer_converged") is True:
            lines.append("  reported constraint validation: FAILED")
        lines.append(f"  tf: {self.tf_s():.3f} {time_unit}")
        lines.append(f"  total dv: {self.total_dv_mps():.6f} {velocity_unit}")
        if np.isfinite(self.last_obj):
            lines.append(f"  last objective: {self.last_obj:.6g}")
        if self.maneuvers:
            lines.append("  maneuvers:")
            for m in self.maneuvers:
                dv = float(np.linalg.norm(m.dv_mps))
                lines.append(
                    f"    - {m.name}: t={m.t_s:.3f} {time_unit} | "
                    f"|dv|={dv:.6f} {velocity_unit}"
                )
        constraint_report = self.info.get("constraint_report", [])
        if constraint_report:
            lines.append("  constraints:")
            for row in constraint_report:
                if not isinstance(row, dict):
                    continue
                lines.append(
                    "    - "
                    f"{row.get('phase', '?')} {row.get('where', '?')} {row.get('constraint', '?')}: "
                    f"target={float(row.get('target', float('nan'))):.6g} "
                    f"actual={float(row.get('actual', float('nan'))):.6g} "
                    f"ok={bool(row.get('satisfied', False))}"
                )
        # show a couple of useful info keys, without dumping everything
        for k in ("seed", "nrev", "precoast_t1_s", "study_index"):
            if k in self.info:
                lines.append(f"  {k}: {self.info[k]!r}")
        return "\n".join(lines)

    def to_npz(self, path: str | Path) -> None:
        """Save this result to a compressed ``.npz`` file.

        Args:
            path: Output file path.

        Notes:
            The archive stores the trajectory, convergence flag, objective
            value, maneuver fields, and JSON-serialized ``info`` metadata.
        """
        import json as _json
        from pathlib import Path as _Path

        p = _Path(path)
        r = (
            np.asarray([m.r_m for m in self.maneuvers], dtype=float)
            if self.maneuvers
            else np.empty((0, 3), dtype=float)
        )
        dv = (
            np.asarray([m.dv_mps for m in self.maneuvers], dtype=float)
            if self.maneuvers
            else np.empty((0, 3), dtype=float)
        )
        t = (
            np.asarray([m.t_s for m in self.maneuvers], dtype=float)
            if self.maneuvers
            else np.empty((0,), dtype=float)
        )
        names = (
            np.asarray([m.name for m in self.maneuvers], dtype=object)
            if self.maneuvers
            else np.empty((0,), dtype=object)
        )
        info_json = _json.dumps(self.info, ensure_ascii=False)
        np.savez_compressed(
            p,
            traj=np.asarray(self.traj, dtype=float),
            converged=np.asarray(int(bool(self.converged)), dtype=np.int8),
            last_obj=np.asarray(float(self.last_obj), dtype=float),
            maneuver_r_m=r,
            maneuver_dv_mps=dv,
            maneuver_t_s=t,
            maneuver_name=names,
            info_json=np.asarray(info_json, dtype=object),
        )

    @classmethod
    def from_npz(cls, path: str | Path) -> RendezvousResult:
        """Load a result from a compressed ``.npz`` file.

        Args:
            path: Path previously written by :meth:`to_npz`.

        Returns:
            The reconstructed result object.
        """
        import json as _json
        from pathlib import Path as _Path

        p = _Path(path)
        data = np.load(p, allow_pickle=True)
        traj = np.asarray(data["traj"], dtype=float)
        converged = bool(int(data["converged"]))
        last_obj = float(data["last_obj"])
        info_json = str(data["info_json"].item())
        info = _json.loads(info_json) if info_json else {}
        r = np.asarray(data["maneuver_r_m"], dtype=float)
        dv = np.asarray(data["maneuver_dv_mps"], dtype=float)
        t = np.asarray(data["maneuver_t_s"], dtype=float)
        names = np.asarray(data["maneuver_name"], dtype=object)
        maneuvers = [
            Maneuver(r_m=r[i], t_s=float(t[i]), dv_mps=dv[i], name=str(names[i]))
            for i in range(len(t))
        ]
        return cls(
            converged=converged, traj=traj, maneuvers=maneuvers, last_obj=last_obj, info=info
        )

    def to_json(self, *, indent: int | None = None) -> str:
        """Serialize summary metadata to JSON.

        Args:
            indent: Optional indentation level passed to ``json.dumps``.

        Returns:
            A JSON string containing summary fields, maneuvers, and metadata.
        """
        import json as _json

        obj = {
            "converged": bool(self.converged),
            "last_obj": float(self.last_obj),
            "tf_s": self.tf_s(),
            "total_dv_mps": self.total_dv_mps(),
            "maneuvers": [
                {
                    "name": m.name,
                    "t_s": float(m.t_s),
                    "r_m": [float(x) for x in m.r_m],
                    "dv_mps": [float(x) for x in m.dv_mps],
                }
                for m in self.maneuvers
            ],
            "info": self.info,
        }
        return _json.dumps(obj, indent=indent, ensure_ascii=False)

from_npz(path: str | Path) -> RendezvousResult classmethod

Load a result from a compressed .npz file.

Args: path: Path previously written by :meth:to_npz.

Returns: The reconstructed result object.

Source code in octavian/solvers/preconfigured.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@classmethod
def from_npz(cls, path: str | Path) -> RendezvousResult:
    """Load a result from a compressed ``.npz`` file.

    Args:
        path: Path previously written by :meth:`to_npz`.

    Returns:
        The reconstructed result object.
    """
    import json as _json
    from pathlib import Path as _Path

    p = _Path(path)
    data = np.load(p, allow_pickle=True)
    traj = np.asarray(data["traj"], dtype=float)
    converged = bool(int(data["converged"]))
    last_obj = float(data["last_obj"])
    info_json = str(data["info_json"].item())
    info = _json.loads(info_json) if info_json else {}
    r = np.asarray(data["maneuver_r_m"], dtype=float)
    dv = np.asarray(data["maneuver_dv_mps"], dtype=float)
    t = np.asarray(data["maneuver_t_s"], dtype=float)
    names = np.asarray(data["maneuver_name"], dtype=object)
    maneuvers = [
        Maneuver(r_m=r[i], t_s=float(t[i]), dv_mps=dv[i], name=str(names[i]))
        for i in range(len(t))
    ]
    return cls(
        converged=converged, traj=traj, maneuvers=maneuvers, last_obj=last_obj, info=info
    )

summary() -> str

Build a compact human-readable summary of the result.

Returns: A multiline summary string containing convergence status, timing, delta-v, and selected metadata.

Source code in octavian/solvers/preconfigured.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def summary(self) -> str:
    """Build a compact human-readable summary of the result.

    Returns:
        A multiline summary string containing convergence status, timing,
        delta-v, and selected metadata.
    """
    lines: list[str] = []
    status = "CONVERGED" if self.converged else "NOT CONVERGED"
    cr3bp_metadata = self.info.get("cr3bp_system")
    canonical_cr3bp = (
        isinstance(cr3bp_metadata, dict)
        and cr3bp_metadata.get("dimensional") is False
    )
    time_unit = "TU" if canonical_cr3bp else "s"
    velocity_unit = "VU" if canonical_cr3bp else "m/s"
    lines.append(f"Octavian result: {status}")
    if not self.converged and self.info.get("optimizer_converged") is True:
        lines.append("  reported constraint validation: FAILED")
    lines.append(f"  tf: {self.tf_s():.3f} {time_unit}")
    lines.append(f"  total dv: {self.total_dv_mps():.6f} {velocity_unit}")
    if np.isfinite(self.last_obj):
        lines.append(f"  last objective: {self.last_obj:.6g}")
    if self.maneuvers:
        lines.append("  maneuvers:")
        for m in self.maneuvers:
            dv = float(np.linalg.norm(m.dv_mps))
            lines.append(
                f"    - {m.name}: t={m.t_s:.3f} {time_unit} | "
                f"|dv|={dv:.6f} {velocity_unit}"
            )
    constraint_report = self.info.get("constraint_report", [])
    if constraint_report:
        lines.append("  constraints:")
        for row in constraint_report:
            if not isinstance(row, dict):
                continue
            lines.append(
                "    - "
                f"{row.get('phase', '?')} {row.get('where', '?')} {row.get('constraint', '?')}: "
                f"target={float(row.get('target', float('nan'))):.6g} "
                f"actual={float(row.get('actual', float('nan'))):.6g} "
                f"ok={bool(row.get('satisfied', False))}"
            )
    # show a couple of useful info keys, without dumping everything
    for k in ("seed", "nrev", "precoast_t1_s", "study_index"):
        if k in self.info:
            lines.append(f"  {k}: {self.info[k]!r}")
    return "\n".join(lines)

tf_s() -> float

Return the final trajectory time.

Returns: The last sample in seconds, or in TU for a canonical CR3BP solve. The _s suffix is retained for API compatibility.

Source code in octavian/solvers/preconfigured.py
72
73
74
75
76
77
78
79
80
81
def tf_s(self) -> float:
    """Return the final trajectory time.

    Returns:
        The last sample in seconds, or in TU for a canonical CR3BP solve.
        The ``_s`` suffix is retained for API compatibility.
    """
    if self.traj.size == 0:
        return float("nan")
    return float(self.traj[-1, -1])

to_json(*, indent: int | None = None) -> str

Serialize summary metadata to JSON.

Args: indent: Optional indentation level passed to json.dumps.

Returns: A JSON string containing summary fields, maneuvers, and metadata.

Source code in octavian/solvers/preconfigured.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def to_json(self, *, indent: int | None = None) -> str:
    """Serialize summary metadata to JSON.

    Args:
        indent: Optional indentation level passed to ``json.dumps``.

    Returns:
        A JSON string containing summary fields, maneuvers, and metadata.
    """
    import json as _json

    obj = {
        "converged": bool(self.converged),
        "last_obj": float(self.last_obj),
        "tf_s": self.tf_s(),
        "total_dv_mps": self.total_dv_mps(),
        "maneuvers": [
            {
                "name": m.name,
                "t_s": float(m.t_s),
                "r_m": [float(x) for x in m.r_m],
                "dv_mps": [float(x) for x in m.dv_mps],
            }
            for m in self.maneuvers
        ],
        "info": self.info,
    }
    return _json.dumps(obj, indent=indent, ensure_ascii=False)

to_npz(path: str | Path) -> None

Save this result to a compressed .npz file.

Args: path: Output file path.

Notes: The archive stores the trajectory, convergence flag, objective value, maneuver fields, and JSON-serialized info metadata.

Source code in octavian/solvers/preconfigured.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def to_npz(self, path: str | Path) -> None:
    """Save this result to a compressed ``.npz`` file.

    Args:
        path: Output file path.

    Notes:
        The archive stores the trajectory, convergence flag, objective
        value, maneuver fields, and JSON-serialized ``info`` metadata.
    """
    import json as _json
    from pathlib import Path as _Path

    p = _Path(path)
    r = (
        np.asarray([m.r_m for m in self.maneuvers], dtype=float)
        if self.maneuvers
        else np.empty((0, 3), dtype=float)
    )
    dv = (
        np.asarray([m.dv_mps for m in self.maneuvers], dtype=float)
        if self.maneuvers
        else np.empty((0, 3), dtype=float)
    )
    t = (
        np.asarray([m.t_s for m in self.maneuvers], dtype=float)
        if self.maneuvers
        else np.empty((0,), dtype=float)
    )
    names = (
        np.asarray([m.name for m in self.maneuvers], dtype=object)
        if self.maneuvers
        else np.empty((0,), dtype=object)
    )
    info_json = _json.dumps(self.info, ensure_ascii=False)
    np.savez_compressed(
        p,
        traj=np.asarray(self.traj, dtype=float),
        converged=np.asarray(int(bool(self.converged)), dtype=np.int8),
        last_obj=np.asarray(float(self.last_obj), dtype=float),
        maneuver_r_m=r,
        maneuver_dv_mps=dv,
        maneuver_t_s=t,
        maneuver_name=names,
        info_json=np.asarray(info_json, dtype=object),
    )

total_dv_mps() -> float

Return the total maneuver delta-v magnitude.

Returns: The sum in m/s, or in VU for a canonical CR3BP solve. Returns 0.0 when no maneuvers are stored. The _mps suffix is retained for API compatibility.

Source code in octavian/solvers/preconfigured.py
83
84
85
86
87
88
89
90
91
92
93
def total_dv_mps(self) -> float:
    """Return the total maneuver delta-v magnitude.

    Returns:
        The sum in m/s, or in VU for a canonical CR3BP solve. Returns
        ``0.0`` when no maneuvers are stored. The ``_mps`` suffix is
        retained for API compatibility.
    """
    if not self.maneuvers:
        return 0.0
    return float(sum(np.linalg.norm(m.dv_mps) for m in self.maneuvers))

solve(spec: TwoImpulseFreeTimeSpec | TwoImpulsePreCoastSpec, *, options: SolverOptions | None = None) -> RendezvousResult

Solve a preconfigured transfer specification with the matching backend.

This dispatcher is intentionally small: the typed spec determines which fixed OCP layout is built. More open-ended mission scripts should enter through the composable backend instead.

Args: spec: Rendezvous problem specification. options: Optional solver configuration overrides.

Returns: The solver result for the provided specification.

Raises: TypeError: If spec is not a supported rendezvous spec type.

Source code in octavian/solvers/preconfigured.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def solve(
    spec: TwoImpulseFreeTimeSpec | TwoImpulsePreCoastSpec,
    *,
    options: SolverOptions | None = None,
) -> RendezvousResult:
    """Solve a preconfigured transfer specification with the matching backend.

    This dispatcher is intentionally small: the typed spec determines which
    fixed OCP layout is built. More open-ended mission scripts should enter
    through the composable backend instead.

    Args:
        spec: Rendezvous problem specification.
        options: Optional solver configuration overrides.

    Returns:
        The solver result for the provided specification.

    Raises:
        TypeError: If ``spec`` is not a supported rendezvous spec type.
    """
    if isinstance(spec, TwoImpulseFreeTimeSpec):
        return solve_two_impulse_free_time(spec, options=options)
    if isinstance(spec, TwoImpulsePreCoastSpec):
        return solve_two_impulse_precoast(spec, options=options)
    raise TypeError(f"Unsupported spec type: {type(spec).__name__}")

solve_two_impulse_free_time(spec: TwoImpulseFreeTimeSpec, *, options: SolverOptions | None = None) -> RendezvousResult

Solve a single-phase two-impulse transfer with bounded final time.

The initial guess is Lambert-seeded, then propagated into an ASSET LGL mesh with the Lambert departure velocity. Boundary position constraints are hard; optional velocity constraints are applied only when the spec disables the corresponding impulsive boundary. Delta-v objectives are expressed as boundary velocity mismatches.

Args: spec: Single-phase rendezvous specification. options: Optional solver configuration overrides.

Returns: The optimized rendezvous result.

Raises: RuntimeError: If ASSET is not installed. ValueError: If the time bounds are invalid.

Source code in octavian/solvers/preconfigured.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
def solve_two_impulse_free_time(
    spec: TwoImpulseFreeTimeSpec,
    *,
    options: SolverOptions | None = None,
) -> RendezvousResult:
    """Solve a single-phase two-impulse transfer with bounded final time.

    The initial guess is Lambert-seeded, then propagated into an ASSET LGL mesh
    with the Lambert departure velocity. Boundary position constraints are hard;
    optional velocity constraints are applied only when the spec disables the
    corresponding impulsive boundary. Delta-v objectives are expressed as
    boundary velocity mismatches.

    Args:
        spec: Single-phase rendezvous specification.
        options: Optional solver configuration overrides.

    Returns:
        The optimized rendezvous result.

    Raises:
        RuntimeError: If ASSET is not installed.
        ValueError: If the time bounds are invalid.
    """
    _require_asset()
    tfmin, tfmax = map(float, spec.tf_bounds_s)
    if not (tfmin > 0.0 and tfmax > tfmin):
        raise ValueError("tf_bounds_s must satisfy 0 < tfmin < tfmax")

    solver_scaling = default_scaling(spec)
    r_unit = solver_scaling.length_m
    v_unit = solver_scaling.velocity_mps
    t_unit = solver_scaling.time_s

    ode = TwoBodyECI(mu_m3ps2=float(spec.mu_m3ps2))
    t0 = 0.0

    seed = select_best_lambert_seed(
        r0_m=as_vec3(spec.x0.r_m),
        rf_m=as_vec3(spec.xf.r_m),
        v0_mps=as_vec3(spec.x0.v_mps),
        vf_mps=as_vec3(spec.xf.v_mps),
        mu_m3ps2=float(spec.mu_m3ps2),
        tmin_s=tfmin,
        tmax_s=tfmax,
        n_tofs=int(spec.lambert_grid_size),
        nrevs=tuple(int(n) for n in spec.nrevs_to_try),
    )

    tf_guess = float(spec.tf_guess_s) if spec.tf_guess_s is not None else float(seed.tof_s)
    tf_guess = min(max(tf_guess, tfmin), tfmax)

    # Initial guess: Kepler propagation from Lambert departure velocity
    ig = kepler_dense_guess(
        r0_m=as_vec3(spec.x0.r_m),
        v0_mps=as_vec3(seed.v1_mps),
        t0_s=t0,
        tf_s=tf_guess,
        npts=int(spec.nsegs) + 1,
        mu_m3ps2=float(spec.mu_m3ps2),
    )

    phase = ode.phase(Tmodes.LGL3, ig, int(spec.nsegs))

    # Constraints: fix start R and time, fix end R, bound end time (time index = 6)
    phase.addBoundaryValue("Front", ["R", "t"], np.hstack([as_vec3(spec.x0.r_m), [t0]]))
    phase.addBoundaryValue("Back", ["R"], as_vec3(spec.xf.r_m))

    # Back time bound
    add_back_time_bound(phase, 6, tfmin, tfmax)

    # Optional: if boundary impulses are disabled, fix boundary velocity to the provided boundary state.
    if not bool(getattr(spec, "dv_front", True)):
        phase.addBoundaryValue("Front", ["V"], as_vec3(spec.x0.v_mps))
    if not bool(getattr(spec, "dv_back", True)):
        phase.addBoundaryValue("Back", ["V"], as_vec3(spec.xf.v_mps))

    # Objectives (explicit):
    #  - If minimize_dv: include Δv penalties at enabled impulsive boundaries.
    #  - If w_time != 0: include w_time * tf at Back.
    v0 = as_vec3(spec.x0.v_mps)
    vf_ = as_vec3(spec.xf.v_mps)

    vel_obj_scale = 1.0 / float(v_unit)

    if bool(getattr(spec, "minimize_dv", True)):
        w_dv = float(getattr(spec, "dv_weight", 1.0) or 1.0)

        if bool(getattr(spec, "dv_front", True)):
            a = vf.Arguments(3)
            dv1 = vf.sqrt((a - v0).dot(a - v0))
            phase.addStateObjective("Front", w_dv * dv1, [3, 4, 5], [], [], AutoScale=vel_obj_scale)

        if bool(getattr(spec, "dv_back", True)):
            b = vf.Arguments(3)
            dv2 = vf.sqrt((vf_ - b).dot(vf_ - b))
            phase.addStateObjective("Back", w_dv * dv2, [3, 4, 5], [], [], AutoScale=vel_obj_scale)

    phase.addLowerDeltaTimeBound(0.1)

    if float(spec.w_time) != 0.0:
        at = vf.Arguments(1).tolist()[0]
        phase.addStateObjective(
            "Back", float(spec.w_time) * at, [6], [], [], AutoScale=1.0 / float(t_unit)
        )

    ocp = oc.OptimalControlProblem()
    ocp.addPhase(phase)

    opts = options or SolverOptions()
    ocp.optimizer.PrintLevel = int(opts.print_level)
    ocp.optimizer.MaxLSIters = int(opts.max_ls_iters)
    ocp.optimizer.set_QPOrderingMode(str(opts.qp_ordering_mode))
    set_ocp_threads(ocp, opts.asset_threads)

    phase.setAutoScaling(bool(opts.enable_auto_scaling))
    phase.setUnits(R=r_unit, V=v_unit, t=t_unit)
    phase.setAdaptiveMesh(bool(opts.enable_adaptive_mesh))
    ocp.setAutoScaling(True, True)
    ocp.setAdaptiveMesh(bool(opts.enable_adaptive_mesh))
    ocp.PrintMeshInfo = False

    converged = solve_with_standard_sequence(ocp, phases=(phase,))

    traj = np.asarray(phase.returnTraj(), dtype=np.float64)
    # traj columns for XVars=6,UVars=0 typically: [x0..x5, t]
    v_front = traj[0, 3:6]
    v_back = traj[-1, 3:6]

    dv1 = v_front - v0
    dv2 = vf_ - v_back

    maneuvers = [
        Maneuver(r_m=traj[0, 0:3], t_s=float(traj[0, 6]), dv_mps=dv1, name="Δv1 (start)"),
        Maneuver(r_m=traj[-1, 0:3], t_s=float(traj[-1, 6]), dv_mps=dv2, name="Δv2 (end)"),
    ]

    return RendezvousResult(
        converged=converged,
        traj=traj,
        maneuvers=maneuvers,
        last_obj=float(getattr(ocp.optimizer, "LastObjVal", np.nan)),
        info={
            "tf_sol_s": float(traj[-1, 6]),
            "r_unit_m": r_unit,
            "v_unit_mps": v_unit,
            "t_unit_s": t_unit,
            "scaling": solver_scaling.to_dict(),
            "frame": spec.frame.to_dict(),
            "central_body": spec.central_body_name or spec.frame.origin,
            "mu_m3ps2": float(spec.mu_m3ps2),
            "state_layouts": ["cartesian"],
            "seed_tof_s": seed.tof_s,
            "seed_longway": seed.longway,
            "seed_nrev": seed.nrev,
            "seed_rightbranch": seed.rightbranch,
            "seed_total_dv_mps": seed.total_dv_mps,
        },
    )

solve_two_impulse_precoast(spec: TwoImpulsePreCoastSpec, *, options: SolverOptions | None = None) -> RendezvousResult

Solve a two-phase transfer with a variable coast before the impulse arc.

The backend sweeps candidate precoast durations, propagates the initial state to each candidate link point, scores Lambert transfers from that point to the terminal position, and uses the best candidate to seed two ASSET phases. The compiled OCP then optimizes the precoast duration, transfer duration, link impulse, and optional boundary impulses together.

Args: spec: Precoast rendezvous specification. options: Optional solver configuration overrides.

Returns: The optimized rendezvous result.

Raises: RuntimeError: If ASSET is not installed or no feasible seed is found. ValueError: If the precoast or transfer time bounds are invalid.

Source code in octavian/solvers/preconfigured.py
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
def solve_two_impulse_precoast(
    spec: TwoImpulsePreCoastSpec,
    *,
    options: SolverOptions | None = None,
) -> RendezvousResult:
    """Solve a two-phase transfer with a variable coast before the impulse arc.

    The backend sweeps candidate precoast durations, propagates the initial
    state to each candidate link point, scores Lambert transfers from that point
    to the terminal position, and uses the best candidate to seed two ASSET
    phases. The compiled OCP then optimizes the precoast duration, transfer
    duration, link impulse, and optional boundary impulses together.

    Args:
        spec: Precoast rendezvous specification.
        options: Optional solver configuration overrides.

    Returns:
        The optimized rendezvous result.

    Raises:
        RuntimeError: If ASSET is not installed or no feasible seed is found.
        ValueError: If the precoast or transfer time bounds are invalid.
    """
    _require_asset()
    t1min, t1max = map(float, spec.t1_bounds_s)
    tfmin, tfmax = map(float, spec.tf_bounds_s)

    if not (t1min >= 0.0 and t1max > t1min):
        raise ValueError("t1_bounds_s must satisfy 0 <= t1min < t1max")
    if not (tfmin > 0.0 and tfmax > tfmin):
        raise ValueError("tf_bounds_s must satisfy 0 < tfmin < tfmax")
    if tfmax <= t1min:
        raise ValueError("tf must be after t1: require tfmax > t1min")

    solver_scaling = default_scaling(spec)
    r_unit = solver_scaling.length_m
    v_unit = solver_scaling.velocity_mps
    t_unit = solver_scaling.time_s
    _require_asset()

    mu = float(spec.mu_m3ps2)
    ode = TwoBodyECI(mu_m3ps2=mu)
    t0 = 0.0

    # Seed both phases with positive durations inside the requested time bounds.
    min_precoast = float(spec.min_dt_precoast_s)
    min_transfer = float(spec.min_dt_transfer_s)
    if not all(np.isfinite(dt) and dt > 0.0 for dt in (min_precoast, min_transfer)):
        raise ValueError("Minimum precoast and transfer durations must be finite and positive")
    seed_t1_min = max(t1min, min_precoast)
    seed_t1_max = min(t1max, tfmax - min_transfer)
    if seed_t1_max < seed_t1_min:
        raise ValueError("Time bounds leave no room for the minimum precoast and transfer durations")

    n_t1 = max(int(spec.precoast_grid_size), 2)
    t1_candidates = np.linspace(seed_t1_min, seed_t1_max, n_t1)

    if bool(spec.limit_precoast_to_one_period):
        T0 = estimate_orbital_period_s(spec.x0.r_m, spec.x0.v_mps, mu)
        if T0 is not None:
            span = seed_t1_max - seed_t1_min
            if span > 1.5 * T0:
                t1_candidates = np.linspace(
                    seed_t1_min, min(seed_t1_min + T0, seed_t1_max), n_t1
                )

    rv0 = np.hstack([as_vec3(spec.x0.r_m), as_vec3(spec.x0.v_mps)])

    best = None
    for t1_try in t1_candidates:
        try:
            rv1 = propagate_cartesian_rv(rv0, float(t1_try - t0), mu)
        except Exception:
            continue
        r1 = rv1[0:3]
        v1_minus = rv1[3:6]

        dtmin = max(min_transfer, tfmin - float(t1_try))
        dtmax = tfmax - float(t1_try)
        if dtmax <= dtmin:
            continue

        seed = select_best_lambert_seed(
            r0_m=as_vec3(r1),
            rf_m=as_vec3(spec.xf.r_m),
            v0_mps=as_vec3(v1_minus),
            vf_mps=as_vec3(spec.xf.v_mps),
            mu_m3ps2=mu,
            tmin_s=float(dtmin),
            tmax_s=float(dtmax),
            n_tofs=int(spec.lambert_grid_size),
            nrevs=tuple(int(n) for n in spec.nrevs_to_try),
        )

        dv1 = as_vec3(seed.v1_mps) - as_vec3(v1_minus)
        dv2 = as_vec3(spec.xf.v_mps) - as_vec3(seed.v2_mps)
        score = float(np.linalg.norm(dv1) + np.linalg.norm(dv2))
        if best is None or score < best["score"]:
            best = {"t1": float(t1_try), "rv1": rv1, "seed": seed, "score": score}

    if best is None:
        raise RuntimeError("Failed to find any feasible pre-coast + Lambert seed.")

    t1_guess = float(best["t1"])
    rv1_guess = np.asarray(best["rv1"], dtype=float).reshape(6)
    r1_guess = rv1_guess[0:3]
    rv1_guess[3:6]
    seed: LambertSeed = best["seed"]

    tf_guess = float(t1_guess + seed.tof_s)
    v1_plus_guess = as_vec3(seed.v1_mps)

    # Dense guesses for both phases
    ig0 = kepler_dense_guess(
        r0_m=as_vec3(spec.x0.r_m),
        v0_mps=as_vec3(spec.x0.v_mps),
        t0_s=t0,
        tf_s=t1_guess,
        npts=int(spec.nsegs_precoast) + 1,
        mu_m3ps2=mu,
    )
    ig1 = kepler_dense_guess(
        r0_m=as_vec3(r1_guess),
        v0_mps=v1_plus_guess,
        t0_s=t1_guess,
        tf_s=tf_guess,
        npts=int(spec.nsegs_transfer) + 1,
        mu_m3ps2=mu,
    )

    phase0 = ode.phase(Tmodes.LGL3, ig0, int(spec.nsegs_precoast))
    phase1 = ode.phase(Tmodes.LGL3, ig1, int(spec.nsegs_transfer))

    # Phase 0 boundary: fix full state and t0, bound t1 (time index 6)

    if bool(getattr(spec, "dv_front", False)):
        phase0.addBoundaryValue("Front", ["R", "t"], np.hstack([as_vec3(spec.x0.r_m), [t0]]))
    else:
        phase0.addBoundaryValue(
            "Front",
            ["R", "V", "t"],
            np.hstack([as_vec3(spec.x0.r_m), as_vec3(spec.x0.v_mps), [t0]]),
        )
    add_back_time_bound(phase0, 6, t1min, t1max)
    phase0.addLowerDeltaTimeBound(float(spec.min_dt_precoast_s))

    # Phase 1 boundary: fix final position, bound tf
    phase1.addBoundaryValue("Back", ["R"], as_vec3(spec.xf.r_m))
    # Optional: if terminal impulse is disabled, fix final velocity.
    if not bool(getattr(spec, "dv_back", True)):
        phase1.addBoundaryValue("Back", ["V"], as_vec3(spec.xf.v_mps))
    add_back_time_bound(phase1, 6, tfmin, tfmax)
    phase1.addLowerDeltaTimeBound(float(spec.min_dt_transfer_s))

    # phase0

    ocp = oc.OptimalControlProblem()
    ocp.addPhase(phase0)
    ocp.addPhase(phase1)

    # Link constraints: choose continuity groups based on spec.link_kind
    if str(getattr(spec, "link_kind", "impulsive")).lower() == "continuous":
        ocp.addForwardLinkEqualCon(phase0, phase1, ["R", "V", "t"])
    else:
        ocp.addForwardLinkEqualCon(phase0, phase1, ["R", "t"])

    # Objectives (explicit):

    vel_obj_scale = 1.0 / float(v_unit)

    if bool(getattr(spec, "minimize_dv", True)):
        w_dv = float(getattr(spec, "dv_weight", 1.0) or 1.0)

        # Optional initial Δv0 at phase0 Front
        if bool(getattr(spec, "dv_front", False)):
            v0 = as_vec3(spec.x0.v_mps)
            a0 = vf.Arguments(3)
            dv0 = vf.sqrt((a0 - v0).dot(a0 - v0))
            phase0.addStateObjective(
                "Front", w_dv * dv0, [3, 4, 5], [], [], AutoScale=vel_obj_scale
            )

        # Link Δv1 only for impulsive links
        if (
            bool(getattr(spec, "dv_link", True))
            and str(getattr(spec, "link_kind", "impulsive")).lower() != "continuous"
        ):
            a = vf.Arguments(6)
            v_minus = a.head(3)
            v_plus = a.segment(3, 3)
            dv = v_plus - v_minus
            dv1 = vf.sqrt(dv.dot(dv))
            ocp.addLinkObjective(
                w_dv * dv1,
                phase0,
                "Back",
                [3, 4, 5],
                [],
                [],
                phase1,
                "Front",
                [3, 4, 5],
                [],
                [],
                [],
                AutoScale=vel_obj_scale,
            )

        # Terminal Δv2
        if bool(getattr(spec, "dv_back", True)):
            vf_ = as_vec3(spec.xf.v_mps)
            b = vf.Arguments(3)
            dv2 = vf.sqrt((vf_ - b).dot(vf_ - b))
            phase1.addStateObjective("Back", w_dv * dv2, [3, 4, 5], [], [], AutoScale=vel_obj_scale)

    if float(spec.w_time) != 0.0:
        at = vf.Arguments(1).tolist()[0]
        phase1.addStateObjective(
            "Back", float(spec.w_time) * at, [6], [], [], AutoScale=1.0 / float(t_unit)
        )

    # ocp.optimizer.set_EContol(tol)
    ocp.optimizer.set_AccKKTtol(1e-6)

    opts = options or SolverOptions()
    ocp.optimizer.PrintLevel = int(opts.print_level)
    ocp.optimizer.MaxLSIters = int(opts.max_ls_iters)
    ocp.optimizer.set_QPOrderingMode(str(opts.qp_ordering_mode))
    set_ocp_threads(ocp, opts.asset_threads)

    for phase in [phase1, phase0]:
        phase.setAutoScaling(bool(opts.enable_auto_scaling))
        phase.setUnits(R=r_unit, V=v_unit, t=t_unit)
        phase.setAdaptiveMesh(bool(opts.enable_adaptive_mesh))

    ocp.setAutoScaling(True, True)
    ocp.setAdaptiveMesh(bool(opts.enable_adaptive_mesh))
    ocp.PrintMeshInfo = False

    converged = solve_with_standard_sequence(ocp, phases=(phase0, phase1))

    traj0 = np.asarray(phase0.returnTraj(), dtype=np.float64)
    traj1 = np.asarray(phase1.returnTraj(), dtype=np.float64)
    traj = np.vstack([traj0, traj1[1:, :]])

    v1m = traj0[-1, 3:6]
    v1p = traj1[0, 3:6]
    dv1 = v1p - v1m

    vf_target = as_vec3(spec.xf.v_mps)

    v2m = traj1[-1, 3:6]
    dv2 = vf_target - v2m

    maneuvers = [
        Maneuver(r_m=traj0[-1, 0:3], t_s=float(traj0[-1, 6]), dv_mps=dv1, name="Δv1 (link)"),
    ]
    if bool(getattr(spec, "dv_back", True)):
        maneuvers.append(
            Maneuver(r_m=traj1[-1, 0:3], t_s=float(traj1[-1, 6]), dv_mps=dv2, name="Δv2 (end)")
        )

    return RendezvousResult(
        converged=converged,
        traj=traj,
        maneuvers=maneuvers,
        last_obj=float(getattr(ocp.optimizer, "LastObjVal", np.nan)),
        info={
            "t1_sol_s": float(traj0[-1, 6]),
            "tf_sol_s": float(traj1[-1, 6]),
            "r_unit_m": r_unit,
            "v_unit_mps": v_unit,
            "t_unit_s": t_unit,
            "scaling": solver_scaling.to_dict(),
            "frame": spec.frame.to_dict(),
            "central_body": spec.central_body_name or spec.frame.origin,
            "mu_m3ps2": float(spec.mu_m3ps2),
            "state_layouts": ["cartesian", "cartesian"],
            "seed_dt_s": seed.tof_s,
            "seed_longway": seed.longway,
            "seed_nrev": seed.nrev,
            "seed_rightbranch": seed.rightbranch,
            "seed_total_dv_mps": seed.total_dv_mps,
            "seed_precoast_s": t1_guess,
            "precoast_seed_score_mps": float(best["score"]),
        },
    )

Astrodynamics Helpers

octavian.astro.kepler

cartesian_to_classic(*, r_m: Vec3, v_mps: Vec3, mu_m3ps2: float) -> dict[str, float]

Convert Cartesian position and velocity to classical orbital elements.

Source code in octavian/astro/kepler.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def cartesian_to_classic(
    *, r_m: Vec3, v_mps: Vec3, mu_m3ps2: float
) -> dict[str, float]:
    """Convert Cartesian position and velocity to classical orbital elements."""
    r = as_vec3(r_m)
    v = as_vec3(v_mps)
    mu = float(mu_m3ps2)

    if ast is not None:
        rv = np.hstack([r, v]).astype(float)
        oe = np.asarray(ast.Astro.cartesian_to_classic(rv, mu), dtype=float).reshape(6)
        return {
            "a_m": float(oe[0]),
            "e": float(oe[1]),
            "inc_deg": float(np.rad2deg(oe[2])),
            "raan_deg": float(np.rad2deg(oe[3])),
            "argp_deg": float(np.rad2deg(oe[4])),
            "true_anomaly_deg": float(np.rad2deg(oe[5])),
        }

    rnorm = float(np.linalg.norm(r))
    vnorm = float(np.linalg.norm(v))
    if rnorm <= 0.0:
        raise ValueError("r_m must have non-zero norm")

    h = np.cross(r, v)
    hnorm = float(np.linalg.norm(h))
    if hnorm <= 0.0:
        raise ValueError("r_m and v_mps must define a non-degenerate orbit")

    n = np.cross(np.array([0.0, 0.0, 1.0], dtype=float), h)
    nnorm = float(np.linalg.norm(n))
    evec = np.cross(v, h) / mu - r / rnorm
    e = float(np.linalg.norm(evec))
    energy = 0.5 * vnorm**2 - mu / rnorm
    if abs(energy) <= 1e-15:
        raise ValueError("Parabolic orbits are not supported")
    a_m = float(-mu / (2.0 * energy))

    inc = float(np.arccos(np.clip(h[2] / hnorm, -1.0, 1.0)))
    raan = float(np.arctan2(n[1], n[0]) % (2.0 * np.pi)) if nnorm > 0.0 else 0.0

    if e > 0.0 and nnorm > 0.0:
        argp = float(
            np.arctan2(
                np.dot(np.cross(n, evec), h) / (nnorm * hnorm),
                np.dot(n, evec) / nnorm,
            )
            % (2.0 * np.pi)
        )
    else:
        argp = 0.0

    if e > 0.0:
        nu = float(
            np.arctan2(
                np.dot(np.cross(evec, r), h) / (e * hnorm * rnorm),
                np.dot(evec, r) / (e * rnorm),
            )
            % (2.0 * np.pi)
        )
    elif nnorm > 0.0:
        nu = float(
            np.arctan2(
                np.dot(np.cross(n, r), h) / (nnorm * hnorm * rnorm),
                np.dot(n, r) / (nnorm * rnorm),
            )
            % (2.0 * np.pi)
        )
    else:
        nu = float(np.arctan2(r[1], r[0]) % (2.0 * np.pi))

    return {
        "a_m": a_m,
        "e": e,
        "inc_deg": float(np.rad2deg(inc)),
        "raan_deg": float(np.rad2deg(raan)),
        "argp_deg": float(np.rad2deg(argp)),
        "true_anomaly_deg": float(np.rad2deg(nu)),
    }

classic_to_cartesian(*, a_m: float, e: float, inc_deg: float, raan_deg: float, argp_deg: float, true_anomaly_deg: float, mu_m3ps2: float) -> tuple[np.ndarray, np.ndarray]

Convert classical orbital elements to Cartesian position and velocity.

Source code in octavian/astro/kepler.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def classic_to_cartesian(
    *,
    a_m: float,
    e: float,
    inc_deg: float,
    raan_deg: float,
    argp_deg: float,
    true_anomaly_deg: float,
    mu_m3ps2: float,
) -> tuple[np.ndarray, np.ndarray]:
    """Convert classical orbital elements to Cartesian position and velocity."""
    if ast is not None:
        oe = np.array(
            [
                float(a_m),
                float(e),
                float(np.deg2rad(inc_deg)),
                float(np.deg2rad(raan_deg)),
                float(np.deg2rad(argp_deg)),
                float(np.deg2rad(true_anomaly_deg)),
            ],
            dtype=float,
        )
        rv = np.asarray(ast.Astro.classic_to_cartesian(oe, float(mu_m3ps2)), dtype=float).reshape(6)
        return rv[0:3], rv[3:6]

    inc = np.deg2rad(float(inc_deg))
    raan = np.deg2rad(float(raan_deg))
    argp = np.deg2rad(float(argp_deg))
    nu = np.deg2rad(float(true_anomaly_deg))

    p = float(a_m) * (1.0 - float(e) ** 2)
    r_pf = (p / (1.0 + float(e) * np.cos(nu))) * np.array([np.cos(nu), np.sin(nu), 0.0], dtype=float)
    v_pf = np.sqrt(float(mu_m3ps2) / p) * np.array(
        [-np.sin(nu), float(e) + np.cos(nu), 0.0],
        dtype=float,
    )

    c_omega = np.cos(raan)
    s_omega = np.sin(raan)
    c_inc = np.cos(inc)
    s_inc = np.sin(inc)
    c_argp = np.cos(argp)
    s_argp = np.sin(argp)
    rot = np.array(
        [
            [c_omega * c_argp - s_omega * s_argp * c_inc, -c_omega * s_argp - s_omega * c_argp * c_inc, s_omega * s_inc],
            [s_omega * c_argp + c_omega * s_argp * c_inc, -s_omega * s_argp + c_omega * c_argp * c_inc, -c_omega * s_inc],
            [s_argp * s_inc, c_argp * s_inc, c_inc],
        ],
        dtype=float,
    )
    return rot @ r_pf, rot @ v_pf

classical_to_cartesian(*, a_m: float, e: float, inc_deg: float, raan_deg: float, argp_deg: float, true_anomaly_deg: float, mu_m3ps2: float) -> tuple[np.ndarray, np.ndarray]

Alias for :func:classic_to_cartesian using a more explicit name.

Source code in octavian/astro/kepler.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def classical_to_cartesian(
    *,
    a_m: float,
    e: float,
    inc_deg: float,
    raan_deg: float,
    argp_deg: float,
    true_anomaly_deg: float,
    mu_m3ps2: float,
) -> tuple[np.ndarray, np.ndarray]:
    """Alias for :func:`classic_to_cartesian` using a more explicit name."""
    return classic_to_cartesian(
        a_m=a_m,
        e=e,
        inc_deg=inc_deg,
        raan_deg=raan_deg,
        argp_deg=argp_deg,
        true_anomaly_deg=true_anomaly_deg,
        mu_m3ps2=mu_m3ps2,
    )

estimate_orbital_period_s(r_m: Vec3, v_mps: Vec3, mu_m3ps2: float) -> float | None

Estimate orbital period for elliptic orbit; else None.

Source code in octavian/astro/kepler.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def estimate_orbital_period_s(r_m: Vec3, v_mps: Vec3, mu_m3ps2: float) -> float | None:
    """Estimate orbital period for elliptic orbit; else None."""
    r = as_vec3(r_m)
    v = as_vec3(v_mps)
    mu = float(mu_m3ps2)
    rnorm = float(np.linalg.norm(r))
    if rnorm <= 0:
        return None
    eps = 0.5 * float(v @ v) - mu / rnorm
    if eps >= 0:
        return None
    a = -mu / (2 * eps)
    if a <= 0:
        return None
    return float(2 * np.pi * np.sqrt(a**3 / mu))

kepler_dense_guess(*, r0_m: Vec3, v0_mps: Vec3, t0_s: float, tf_s: float, npts: int, mu_m3ps2: float) -> list[np.ndarray]

Dense guess [x(6), t] built via repeated Kepler propagation.

Source code in octavian/astro/kepler.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def kepler_dense_guess(
    *, r0_m: Vec3, v0_mps: Vec3, t0_s: float, tf_s: float, npts: int, mu_m3ps2: float
) -> list[np.ndarray]:
    """Dense guess [x(6), t] built via repeated Kepler propagation."""
    if npts < 2:
        raise ValueError("npts must be >= 2")
    t0 = float(t0_s)
    tf = float(tf_s)
    if tf <= t0:
        raise ValueError("Require tf_s > t0_s")
    rv0 = np.hstack([as_vec3(r0_m), as_vec3(v0_mps)])
    ts = np.linspace(t0, tf, int(npts))
    out: list[np.ndarray] = []
    for t in ts:
        rv = propagate_cartesian_rv(rv0, float(t - t0), float(mu_m3ps2))
        out.append(np.hstack([rv, float(t)]))
    return out

propagate_cartesian_rv(rv6: np.ndarray, dt_s: float, mu_m3ps2: float) -> np.ndarray

Propagate a 6D Cartesian state under two-body dynamics using ASSET.

Source code in octavian/astro/kepler.py
168
169
170
171
172
173
174
def propagate_cartesian_rv(rv6: np.ndarray, dt_s: float, mu_m3ps2: float) -> np.ndarray:
    """Propagate a 6D Cartesian state under two-body dynamics using ASSET."""
    if ast is None:
        raise RuntimeError("asset_asrl is required for Kepler propagation.")
    rv6 = np.asarray(rv6, dtype=float).reshape(6)
    out = ast.Astro.propagate_cartesian(rv6, float(dt_s), float(mu_m3ps2))
    return np.asarray(out, dtype=float).reshape(6)

octavian.astro.lambert

LambertSeed dataclass

One Lambert branch and its endpoint velocity cost.

Source code in octavian/astro/lambert.py
15
16
17
18
19
20
21
22
23
24
25
@dataclass(frozen=True)
class LambertSeed:
    """One Lambert branch and its endpoint velocity cost."""

    tof_s: float
    longway: bool
    nrev: int
    rightbranch: bool
    v1_mps: Vec3
    v2_mps: Vec3
    total_dv_mps: float

select_best_lambert_seed(*, r0_m: Vec3, rf_m: Vec3, v0_mps: Vec3, vf_mps: Vec3, mu_m3ps2: float, tmin_s: float, tmax_s: float, n_tofs: int = 50, nrevs: Sequence[int] = (0, 1)) -> LambertSeed

Sweep Lambert solutions and return the seed with minimum total delta-v.

This helper is designed for initial guess generation in impulsive transfers. It evaluates a grid of time-of-flight samples and Lambert branch options, then chooses the solution that minimizes::

|v1 - v0| + |vf - v2|

where (v1, v2) is the Lambert departure/arrival velocity pair.

Args: r0_m: Initial position [m]. rf_m: Final position [m]. v0_mps: Initial velocity [m/s]. vf_mps: Final velocity [m/s]. mu_m3ps2: Gravitational parameter [m^3/s^2]. tmin_s: Minimum time-of-flight to test [s]. tmax_s: Maximum time-of-flight to test [s]. Set equal to tmin_s to compare Lambert branches at one fixed flight time. n_tofs: Number of TOF samples in [tmin_s, tmax_s]. nrevs: Sequence of integer revolution counts to attempt.

Returns: The best Lambert seed found.

Raises: ValueError: If bounds are invalid. RuntimeError: If no Lambert solution succeeds over the sweep.

Notes: Exactly parallel or antiparallel positions do not define a unique Lambert plane. For seed generation only, Octavian rotates the target by a tiny deterministic angle in the plane suggested by the boundary velocities. The optimization boundary remains the original position.

Source code in octavian/astro/lambert.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def select_best_lambert_seed(
    *,
    r0_m: Vec3,
    rf_m: Vec3,
    v0_mps: Vec3,
    vf_mps: Vec3,
    mu_m3ps2: float,
    tmin_s: float,
    tmax_s: float,
    n_tofs: int = 50,
    nrevs: Sequence[int] = (0, 1),
) -> LambertSeed:
    """Sweep Lambert solutions and return the seed with minimum total delta-v.

    This helper is designed for *initial guess generation* in impulsive transfers.
    It evaluates a grid of time-of-flight samples and Lambert branch options, then
    chooses the solution that minimizes::

        |v1 - v0| + |vf - v2|

    where ``(v1, v2)`` is the Lambert departure/arrival velocity pair.

    Args:
        r0_m: Initial position [m].
        rf_m: Final position [m].
        v0_mps: Initial velocity [m/s].
        vf_mps: Final velocity [m/s].
        mu_m3ps2: Gravitational parameter [m^3/s^2].
        tmin_s: Minimum time-of-flight to test [s].
        tmax_s: Maximum time-of-flight to test [s]. Set equal to ``tmin_s``
            to compare Lambert branches at one fixed flight time.
        n_tofs: Number of TOF samples in ``[tmin_s, tmax_s]``.
        nrevs: Sequence of integer revolution counts to attempt.

    Returns:
        The best Lambert seed found.

    Raises:
        ValueError: If bounds are invalid.
        RuntimeError: If no Lambert solution succeeds over the sweep.

    Notes:
        Exactly parallel or antiparallel positions do not define a unique
        Lambert plane. For seed generation only, Octavian rotates the target
        by a tiny deterministic angle in the plane suggested by the boundary
        velocities. The optimization boundary remains the original position.
    """
    tmin = float(tmin_s)
    tmax = float(tmax_s)
    if not (np.isfinite(tmin) and np.isfinite(tmax) and 0.0 < tmin <= tmax):
        raise ValueError("Require finite bounds with 0 < tmin_s <= tmax_s.")
    if int(n_tofs) < 2:
        raise ValueError("n_tofs must be >= 2.")

    r0 = as_vec3(r0_m)
    rf = as_vec3(rf_m)
    v0 = as_vec3(v0_mps)
    vf = as_vec3(vf_mps)
    lambert_rf = _lambert_target_position(r0, rf, v0, vf)

    best: LambertSeed | None = None
    times = [tmin] if tmin == tmax else np.linspace(tmin, tmax, int(n_tofs))
    for tof in times:
        for longway in (False, True):
            for nrev in nrevs:
                rb_iter: Iterable[bool] = (True,) if int(nrev) == 0 else (False, True)
                for rightbranch in rb_iter:
                    try:
                        v1, v2 = _call_lambert_izzo(
                            r0,
                            lambert_rf,
                            float(tof),
                            float(mu_m3ps2),
                            bool(longway),
                            int(nrev),
                            bool(rightbranch),
                        )
                    except Exception:
                        continue

                    if not (np.all(np.isfinite(v1)) and np.all(np.isfinite(v2))):
                        # Some Lambert backends return NaNs instead of raising
                        # for singular geometries such as exactly antipodal
                        # endpoints. A non-finite candidate is not a seed.
                        continue
                    tot = _total_dv(v1, v2, v0, vf)
                    if not np.isfinite(tot):
                        continue
                    cand = LambertSeed(
                        tof_s=float(tof),
                        longway=bool(longway),
                        nrev=int(nrev),
                        rightbranch=bool(rightbranch),
                        v1_mps=v1,
                        v2_mps=v2,
                        total_dv_mps=float(tot),
                    )
                    if best is None or cand.total_dv_mps < best.total_dv_mps:
                        best = cand

    if best is None:
        raise RuntimeError("No finite Lambert solution succeeded for any TOF/branch.")
    return best

octavian.astro.units

Dimensional scaling helpers for ASSET-backed solves.

default_scaling(spec: Any) -> SolverScaling

Choose characteristic units for a dimensional solver problem.

An explicit spec.scaling declaration wins. Otherwise the legacy endpoint-derived length, velocity, and time rules are preserved, with an optional mass scale read from mass_unit_kg.

Source code in octavian/astro/units.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def default_scaling(spec: Any) -> SolverScaling:
    """Choose characteristic units for a dimensional solver problem.

    An explicit ``spec.scaling`` declaration wins. Otherwise the legacy
    endpoint-derived length, velocity, and time rules are preserved, with an
    optional mass scale read from ``mass_unit_kg``.
    """
    configured = getattr(spec, "scaling", None)
    if configured is not None:
        if not isinstance(configured, SolverScaling):
            raise TypeError("scaling must be a SolverScaling instance")
        return configured

    radius_scale_m = max(np.linalg.norm(spec.x0.r_m), np.linalg.norm(spec.xf.r_m), 1.0)
    velocity_scale_mps = max(np.linalg.norm(spec.x0.v_mps), np.linalg.norm(spec.xf.v_mps), 1.0)

    if hasattr(spec, "tf_bounds_s"):
        t_min_s, t_max_s = map(float, spec.tf_bounds_s)
        average_time_s = 0.5 * (t_min_s + t_max_s)
    else:
        average_time_s = 10.0
    time_scale_s = max(average_time_s / 10.0, 1.0)

    return SolverScaling(
        length_m=(
            float(spec.r_unit_m)
            if getattr(spec, "r_unit_m", None) is not None
            else float(radius_scale_m)
        ),
        velocity_mps=(
            float(spec.v_unit_mps)
            if getattr(spec, "v_unit_mps", None) is not None
            else float(velocity_scale_mps)
        ),
        time_s=(
            float(spec.t_unit_s)
            if getattr(spec, "t_unit_s", None) is not None
            else float(time_scale_s)
        ),
        mass_kg=float(getattr(spec, "mass_unit_kg", None) or 1.0),
    )

default_units(spec: Any) -> tuple[float, float, float]

Choose default dimensional units for solver auto-scaling.

Args: spec: Object with boundary states x0 and xf and optional unit overrides such as r_unit_m, v_unit_mps, and t_unit_s.

Returns: A tuple (r_unit_m, v_unit_mps, t_unit_s) suitable for ASSET phase scaling.

Source code in octavian/astro/units.py
55
56
57
58
59
60
61
62
63
64
65
66
67
def default_units(spec: Any) -> tuple[float, float, float]:
    """Choose default dimensional units for solver auto-scaling.

    Args:
        spec: Object with boundary states ``x0`` and ``xf`` and optional unit
            overrides such as ``r_unit_m``, ``v_unit_mps``, and ``t_unit_s``.

    Returns:
        A tuple ``(r_unit_m, v_unit_mps, t_unit_s)`` suitable for ASSET phase
        scaling.
    """
    scaling = default_scaling(spec)
    return scaling.length_m, scaling.velocity_mps, scaling.time_s

octavian.astro.types

Shared numeric types for astrodynamics helpers.

as_vec3(value: Any) -> Vec3

Convert an input into a length-3 float64 NumPy vector.

Args: value: Array-like object with three elements.

Returns: A (3,) NumPy array of dtype float64.

Source code in octavian/astro/types.py
13
14
15
16
17
18
19
20
21
22
def as_vec3(value: Any) -> Vec3:
    """Convert an input into a length-3 float64 NumPy vector.

    Args:
        value: Array-like object with three elements.

    Returns:
        A ``(3,)`` NumPy array of dtype ``float64``.
    """
    return np.asarray(value, dtype=np.float64).reshape(3)

octavian.data.ephemeris

Runtime ephemeris helpers for Sun/Moon third-body perturbations.

epoch_to_datetime_utc(epoch: str | datetime | float | int) -> datetime

Convert a user epoch to a timezone-aware UTC datetime.

String and :class:datetime.datetime inputs are interpreted as UTC. Numeric inputs are SPICE ephemeris seconds past J2000, matching :func:epoch_to_et. If a leap-second kernel is loaded, SpiceyPy performs the conversion. Otherwise Octavian uses the same built-in leap-second history as :func:epoch_to_et, neglecting only the millisecond-scale periodic TDB-minus-TT term.

Args: epoch: UTC string, datetime, or SPICE ET seconds past J2000.

Returns: A timezone-aware UTC datetime.

Source code in octavian/data/ephemeris.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def epoch_to_datetime_utc(epoch: str | datetime | float | int) -> datetime:
    """Convert a user epoch to a timezone-aware UTC datetime.

    String and :class:`datetime.datetime` inputs are interpreted as UTC.
    Numeric inputs are SPICE ephemeris seconds past J2000, matching
    :func:`epoch_to_et`. If a leap-second kernel is loaded, SpiceyPy performs
    the conversion. Otherwise Octavian uses the same built-in leap-second
    history as :func:`epoch_to_et`, neglecting only the millisecond-scale
    periodic TDB-minus-TT term.

    Args:
        epoch: UTC string, datetime, or SPICE ET seconds past J2000.

    Returns:
        A timezone-aware UTC datetime.
    """
    if isinstance(epoch, str):
        return _parse_utc_datetime(epoch)
    if isinstance(epoch, datetime):
        value = epoch if epoch.tzinfo is not None else epoch.replace(tzinfo=UTC)
        return value.astimezone(UTC)
    if not isinstance(epoch, int | float):
        raise TypeError("epoch must be a UTC string, datetime, or ET seconds.")

    et = float(epoch)
    if not math.isfinite(et):
        raise ValueError("Numeric ET epochs must be finite.")
    with suppress(Exception):
        return spice.et2datetime(et).astimezone(UTC)

    # Fixed-point inversion of the UTC -> ET fallback. TAI-UTC changes only at
    # leap-second boundaries, so two iterations are sufficient away from the
    # exact inserted second and a third keeps the intent obvious.
    utc = _J2000_UTC + timedelta(seconds=et - 64.184)
    for _ in range(3):
        tai_minus_utc = _tai_minus_utc_seconds(utc)
        utc = _J2000_UTC + timedelta(seconds=et - tai_minus_utc - 32.184)
    return utc

epoch_to_et(epoch: str | datetime | float | int) -> float

Convert a user epoch to SPICE ephemeris seconds past J2000.

Numeric inputs are treated as ET seconds. String and datetime inputs are interpreted as UTC. If a SPICE leap-second kernel is loaded, SpiceyPy performs the conversion; otherwise a built-in UTC-to-TT fallback using the published leap-second history is used.

Source code in octavian/data/ephemeris.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def epoch_to_et(epoch: str | datetime | float | int) -> float:
    """Convert a user epoch to SPICE ephemeris seconds past J2000.

    Numeric inputs are treated as ET seconds. String and ``datetime`` inputs are
    interpreted as UTC. If a SPICE leap-second kernel is loaded, SpiceyPy
    performs the conversion; otherwise a built-in UTC-to-TT fallback using the
    published leap-second history is used.
    """
    if isinstance(epoch, int | float):
        return float(epoch)

    if isinstance(epoch, str):
        text = epoch.strip()
        with suppress(Exception):
            return float(spice.str2et(text))
        return _datetime_utc_to_et_approx(_parse_utc_datetime(text))

    if isinstance(epoch, datetime):
        with suppress(Exception):
            return float(spice.datetime2et(epoch))
        return _datetime_utc_to_et_approx(epoch)

    raise TypeError("epoch must be a UTC string, datetime, or ET seconds.")

get_sun_moon_states_eci_tod(et: float) -> tuple[np.ndarray, np.ndarray]

Return Earth-centered geometric Sun and Moon states in ECI_TOD.

States are returned in SPICE units: position in kilometers and velocity in kilometers per second.

Source code in octavian/data/ephemeris.py
72
73
74
75
76
77
78
79
80
def get_sun_moon_states_eci_tod(et: float) -> tuple[np.ndarray, np.ndarray]:
    """Return Earth-centered geometric Sun and Moon states in ECI_TOD.

    States are returned in SPICE units: position in kilometers and velocity in
    kilometers per second.
    """
    sun_state, _ = spice.spkezr("SUN", float(et), "ECI_TOD", "NONE", "EARTH")
    moon_state, _ = spice.spkezr("MOON", float(et), "ECI_TOD", "NONE", "EARTH")
    return np.asarray(sun_state, dtype=float), np.asarray(moon_state, dtype=float)

load_reduced_ephemeris(bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP) -> None

Load the reduced Earth-centered Sun/Moon BSP and ECI_TOD frame.

Source code in octavian/data/ephemeris.py
64
65
66
67
68
69
def load_reduced_ephemeris(
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
) -> None:
    """Load the reduced Earth-centered Sun/Moon BSP and ECI_TOD frame."""
    _load_eci_tod_frame()
    spice.furnsh(str(Path(bsp_path).resolve()))

sample_sun_moon_positions_eci_tod(*, initial_epoch: str | datetime | float | int, duration_s: float, step_s: float = 3600.0, bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP) -> tuple[np.ndarray, dict[str, np.ndarray]]

Sample Earth-centered Sun/Moon positions in ECI_TOD for table building.

Returns (times_s, positions_m) where positions_m has "sun" and "moon" arrays with shape (N, 3) in meters.

Source code in octavian/data/ephemeris.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
def sample_sun_moon_positions_eci_tod(
    *,
    initial_epoch: str | datetime | float | int,
    duration_s: float,
    step_s: float = 3600.0,
    bsp_path: str | Path = DEFAULT_EPHEMERIS_BSP,
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
    """Sample Earth-centered Sun/Moon positions in ECI_TOD for table building.

    Returns ``(times_s, positions_m)`` where ``positions_m`` has ``"sun"`` and
    ``"moon"`` arrays with shape ``(N, 3)`` in meters.
    """
    if duration_s <= 0.0:
        raise ValueError("duration_s must be positive.")
    if step_s <= 0.0:
        raise ValueError("step_s must be positive.")

    n_intervals = max(5, int(math.ceil(float(duration_s) / float(step_s))))
    times_s = np.linspace(0.0, float(duration_s), n_intervals + 1, dtype=np.float64)
    epochs = epoch_to_et(initial_epoch) + times_s

    kernel_path = Path(bsp_path).resolve()
    if not kernel_path.is_file():
        raise FileNotFoundError(f"Sun/Moon BSP does not exist: {kernel_path}")

    try:
        _load_eci_tod_frame()
        spice.furnsh(str(kernel_path))
        sun_states, _ = spice.spkezr("SUN", epochs, "ECI_TOD", "NONE", "EARTH")
        moon_states, _ = spice.spkezr("MOON", epochs, "ECI_TOD", "NONE", "EARTH")
    finally:
        with suppress(Exception):
            spice.unload(str(kernel_path))

    return times_s, {
        "sun": np.asarray(sun_states, dtype=np.float64).reshape(-1, 6)[:, 0:3] * 1000.0,
        "moon": np.asarray(moon_states, dtype=np.float64).reshape(-1, 6)[:, 0:3] * 1000.0,
    }

Output, Visualization, And Studies

octavian.exports.ephemeris

Validated trajectory exports for common ephemeris file formats.

Ephemeris dataclass

A Cartesian state history with the metadata needed for file export.

Octavian stores positions in meters, velocities in meters per second, and elapsed times in seconds. Writers perform any format-required unit conversion at the file boundary.

Attributes: times_s: Strictly increasing elapsed seconds from epoch. states_m_mps: Cartesian rows [x, y, z, vx, vy, vz] in SI units. epoch: UTC string/datetime or SPICE ET seconds at times_s == 0. frame_name: Name of the frame already represented by the state rows. Export does not rotate coordinates; this label must describe the supplied data. center_name: Human-readable center of motion. object_name: Human-readable ephemeris object name. object_id: NAIF object ID used by the BSP writer. center_id: NAIF center ID used by the BSP writer.

Source code in octavian/exports/ephemeris.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@dataclass(frozen=True, slots=True)
class Ephemeris:
    """A Cartesian state history with the metadata needed for file export.

    Octavian stores positions in meters, velocities in meters per second, and
    elapsed times in seconds. Writers perform any format-required unit
    conversion at the file boundary.

    Attributes:
        times_s: Strictly increasing elapsed seconds from ``epoch``.
        states_m_mps: Cartesian rows ``[x, y, z, vx, vy, vz]`` in SI units.
        epoch: UTC string/datetime or SPICE ET seconds at ``times_s == 0``.
        frame_name: Name of the frame already represented by the state rows.
            Export does not rotate coordinates; this label must describe the
            supplied data.
        center_name: Human-readable center of motion.
        object_name: Human-readable ephemeris object name.
        object_id: NAIF object ID used by the BSP writer.
        center_id: NAIF center ID used by the BSP writer.
    """

    times_s: NDArray[np.float64]
    states_m_mps: NDArray[np.float64]
    epoch: Epoch
    frame_name: str = "J2000"
    center_name: str = "EARTH"
    object_name: str = "SPACECRAFT"
    object_id: int = -100_000
    center_id: int = 399

    def __post_init__(self) -> None:
        times = np.asarray(self.times_s, dtype=np.float64).reshape(-1)
        states = np.asarray(self.states_m_mps, dtype=np.float64)
        if states.ndim != 2 or states.shape[1] != 6:
            raise ValueError("states_m_mps must have shape (N, 6)")
        if states.shape[0] != times.size:
            raise ValueError("times_s and states_m_mps must contain the same number of rows")
        if times.size < 2:
            raise ValueError("Ephemeris export requires at least two samples")
        if not np.all(np.isfinite(times)) or not np.all(np.isfinite(states)):
            raise ValueError("Ephemeris times and states must be finite")
        if np.any(np.diff(times) <= 0.0):
            raise ValueError("times_s must be strictly increasing")
        if not str(self.frame_name).strip():
            raise ValueError("frame_name must not be empty")
        if not str(self.center_name).strip():
            raise ValueError("center_name must not be empty")
        if not str(self.object_name).strip():
            raise ValueError("object_name must not be empty")
        # Validate the epoch at construction so file writers fail before
        # creating partial output.
        epoch_to_et(self.epoch)

        object.__setattr__(self, "times_s", times.copy())
        object.__setattr__(self, "states_m_mps", states.copy())
        object.__setattr__(self, "frame_name", str(self.frame_name).strip())
        object.__setattr__(self, "center_name", str(self.center_name).strip().upper())
        object.__setattr__(self, "object_name", str(self.object_name).strip())
        object.__setattr__(self, "object_id", int(self.object_id))
        object.__setattr__(self, "center_id", int(self.center_id))

    @classmethod
    def from_trajectory(
        cls,
        trajectory: ArrayLike,
        *,
        epoch: Epoch,
        frame_name: str = "J2000",
        center_name: str = "EARTH",
        object_name: str = "SPACECRAFT",
        object_id: int = -100_000,
        center_id: int = 399,
    ) -> Ephemeris:
        """Construct an ephemeris from Octavian ``[R, V, t]`` rows.

        Args:
            trajectory: Array with shape ``(N, 7)`` and columns
                ``[x, y, z, vx, vy, vz, elapsed_time]`` in SI units.
            epoch: Epoch corresponding to elapsed time zero.
            frame_name: Frame already represented by the trajectory.
            center_name: Human-readable center of motion.
            object_name: Human-readable ephemeris object name.
            object_id: NAIF object ID for BSP output.
            center_id: NAIF center ID for BSP output.

        Returns:
            A validated ephemeris ready for one or more exports.
        """
        rows = np.asarray(trajectory, dtype=np.float64)
        if rows.ndim != 2 or rows.shape[1] != 7:
            raise ValueError("trajectory must have shape (N, 7) with [R, V, t] columns")
        return cls(
            times_s=rows[:, 6],
            states_m_mps=rows[:, 0:6],
            epoch=epoch,
            frame_name=frame_name,
            center_name=center_name,
            object_name=object_name,
            object_id=object_id,
            center_id=center_id,
        )

    @property
    def epochs_et(self) -> NDArray[np.float64]:
        """Return one SPICE ET epoch per state row."""
        return np.asarray(epoch_to_et(self.epoch) + self.times_s, dtype=np.float64)

    def write(
        self,
        path: str | Path,
        *,
        format: EphemerisFormat | None = None,
        overwrite: bool = False,
        interpolation_degree: int = 7,
    ) -> Path:
        """Write this ephemeris, inferring the format from the extension.

        Supported extensions are ``.e`` (STK), ``.oem`` (CCSDS OEM), ``.bsp``
        or ``.spk`` (SPICE type-9 SPK), and ``.csv``.

        Args:
            path: Output file.
            format: Explicit format override, normally inferred from ``path``.
            overwrite: Replace an existing file when true.
            interpolation_degree: Requested Lagrange degree for OEM metadata
                and BSP interpolation. It is reduced when the history contains
                too few samples.

        Returns:
            The resolved output path.
        """
        return write_ephemeris(
            self,
            path,
            format=format,
            overwrite=overwrite,
            interpolation_degree=interpolation_degree,
        )

epochs_et: NDArray[np.float64] property

Return one SPICE ET epoch per state row.

from_trajectory(trajectory: ArrayLike, *, epoch: Epoch, frame_name: str = 'J2000', center_name: str = 'EARTH', object_name: str = 'SPACECRAFT', object_id: int = -100000, center_id: int = 399) -> Ephemeris classmethod

Construct an ephemeris from Octavian [R, V, t] rows.

Args: trajectory: Array with shape (N, 7) and columns [x, y, z, vx, vy, vz, elapsed_time] in SI units. epoch: Epoch corresponding to elapsed time zero. frame_name: Frame already represented by the trajectory. center_name: Human-readable center of motion. object_name: Human-readable ephemeris object name. object_id: NAIF object ID for BSP output. center_id: NAIF center ID for BSP output.

Returns: A validated ephemeris ready for one or more exports.

Source code in octavian/exports/ephemeris.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@classmethod
def from_trajectory(
    cls,
    trajectory: ArrayLike,
    *,
    epoch: Epoch,
    frame_name: str = "J2000",
    center_name: str = "EARTH",
    object_name: str = "SPACECRAFT",
    object_id: int = -100_000,
    center_id: int = 399,
) -> Ephemeris:
    """Construct an ephemeris from Octavian ``[R, V, t]`` rows.

    Args:
        trajectory: Array with shape ``(N, 7)`` and columns
            ``[x, y, z, vx, vy, vz, elapsed_time]`` in SI units.
        epoch: Epoch corresponding to elapsed time zero.
        frame_name: Frame already represented by the trajectory.
        center_name: Human-readable center of motion.
        object_name: Human-readable ephemeris object name.
        object_id: NAIF object ID for BSP output.
        center_id: NAIF center ID for BSP output.

    Returns:
        A validated ephemeris ready for one or more exports.
    """
    rows = np.asarray(trajectory, dtype=np.float64)
    if rows.ndim != 2 or rows.shape[1] != 7:
        raise ValueError("trajectory must have shape (N, 7) with [R, V, t] columns")
    return cls(
        times_s=rows[:, 6],
        states_m_mps=rows[:, 0:6],
        epoch=epoch,
        frame_name=frame_name,
        center_name=center_name,
        object_name=object_name,
        object_id=object_id,
        center_id=center_id,
    )

write(path: str | Path, *, format: EphemerisFormat | None = None, overwrite: bool = False, interpolation_degree: int = 7) -> Path

Write this ephemeris, inferring the format from the extension.

Supported extensions are .e (STK), .oem (CCSDS OEM), .bsp or .spk (SPICE type-9 SPK), and .csv.

Args: path: Output file. format: Explicit format override, normally inferred from path. overwrite: Replace an existing file when true. interpolation_degree: Requested Lagrange degree for OEM metadata and BSP interpolation. It is reduced when the history contains too few samples.

Returns: The resolved output path.

Source code in octavian/exports/ephemeris.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def write(
    self,
    path: str | Path,
    *,
    format: EphemerisFormat | None = None,
    overwrite: bool = False,
    interpolation_degree: int = 7,
) -> Path:
    """Write this ephemeris, inferring the format from the extension.

    Supported extensions are ``.e`` (STK), ``.oem`` (CCSDS OEM), ``.bsp``
    or ``.spk`` (SPICE type-9 SPK), and ``.csv``.

    Args:
        path: Output file.
        format: Explicit format override, normally inferred from ``path``.
        overwrite: Replace an existing file when true.
        interpolation_degree: Requested Lagrange degree for OEM metadata
            and BSP interpolation. It is reduced when the history contains
            too few samples.

    Returns:
        The resolved output path.
    """
    return write_ephemeris(
        self,
        path,
        format=format,
        overwrite=overwrite,
        interpolation_degree=interpolation_degree,
    )

write_csv_ephemeris(ephemeris: Ephemeris, path: str | Path, *, overwrite: bool = False) -> Path

Write a portable SI-unit CSV ephemeris with UTC and elapsed time.

Source code in octavian/exports/ephemeris.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
def write_csv_ephemeris(
    ephemeris: Ephemeris,
    path: str | Path,
    *,
    overwrite: bool = False,
) -> Path:
    """Write a portable SI-unit CSV ephemeris with UTC and elapsed time."""
    output = _prepare_output_path(path, overwrite=overwrite)
    mode = "w" if overwrite else "x"
    epochs = _utc_epochs(ephemeris)
    with output.open(mode, newline="", encoding="utf-8") as stream:
        writer = csv.writer(stream)
        writer.writerow(
            [
                "epoch_utc",
                "elapsed_time_s",
                "x_m",
                "y_m",
                "z_m",
                "vx_mps",
                "vy_mps",
                "vz_mps",
            ]
        )
        for epoch, time_s, state in zip(
            epochs,
            ephemeris.times_s,
            ephemeris.states_m_mps,
            strict=True,
        ):
            writer.writerow(
                [
                    _format_utc_iso(epoch),
                    _format_float(time_s),
                    *(_format_float(value) for value in state),
                ]
            )
    return output

write_ephemeris(ephemeris: Ephemeris, path: str | Path, *, format: EphemerisFormat | None = None, overwrite: bool = False, interpolation_degree: int = 7) -> Path

Write an ephemeris using an explicit format or output extension.

Source code in octavian/exports/ephemeris.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def write_ephemeris(
    ephemeris: Ephemeris,
    path: str | Path,
    *,
    format: EphemerisFormat | None = None,
    overwrite: bool = False,
    interpolation_degree: int = 7,
) -> Path:
    """Write an ephemeris using an explicit format or output extension."""
    output = Path(path).expanduser().resolve()
    selected = _resolve_format(output, format)
    if selected == "stk":
        return write_stk_ephemeris(
            ephemeris,
            output,
            overwrite=overwrite,
            interpolation_degree=interpolation_degree,
        )
    if selected == "oem":
        return write_oem(
            ephemeris,
            output,
            overwrite=overwrite,
            interpolation_degree=interpolation_degree,
        )
    if selected == "bsp":
        return write_spice_bsp(
            ephemeris,
            output,
            overwrite=overwrite,
            interpolation_degree=interpolation_degree,
        )
    return write_csv_ephemeris(ephemeris, output, overwrite=overwrite)

write_oem(ephemeris: Ephemeris, path: str | Path, *, overwrite: bool = False, interpolation_degree: int = 7, originator: str = 'OCTAVIAN') -> Path

Write a CCSDS OEM 2.0 KVN file in kilometers and UTC.

Source code in octavian/exports/ephemeris.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def write_oem(
    ephemeris: Ephemeris,
    path: str | Path,
    *,
    overwrite: bool = False,
    interpolation_degree: int = 7,
    originator: str = "OCTAVIAN",
) -> Path:
    """Write a CCSDS OEM 2.0 KVN file in kilometers and UTC."""
    output = _prepare_output_path(path, overwrite=overwrite)
    epochs = _utc_epochs(ephemeris)
    degree = _interpolation_degree(
        len(ephemeris.times_s),
        interpolation_degree,
        require_odd=False,
    )
    now = datetime.now(timezone.utc)
    lines = [
        "CCSDS_OEM_VERS = 2.0",
        f"CREATION_DATE = {_format_utc_iso(now)}",
        f"ORIGINATOR = {str(originator).strip() or 'OCTAVIAN'}",
        "",
        "META_START",
        f"OBJECT_NAME = {ephemeris.object_name}",
        f"OBJECT_ID = {ephemeris.object_id}",
        f"CENTER_NAME = {ephemeris.center_name}",
        f"REF_FRAME = {ephemeris.frame_name}",
        "TIME_SYSTEM = UTC",
        f"START_TIME = {_format_utc_iso(epochs[0])}",
        f"STOP_TIME = {_format_utc_iso(epochs[-1])}",
        "INTERPOLATION = LAGRANGE",
        f"INTERPOLATION_DEGREE = {degree}",
        "META_STOP",
        "",
    ]
    states_km_kmps = ephemeris.states_m_mps / 1_000.0
    for epoch, state in zip(epochs, states_km_kmps, strict=True):
        values = " ".join(_format_float(value) for value in state)
        lines.append(f"{_format_utc_iso(epoch)} {values}")
    lines.append("")
    _write_text(output, "\n".join(lines), overwrite=overwrite)
    return output

write_spice_bsp(ephemeris: Ephemeris, path: str | Path, *, overwrite: bool = False, interpolation_degree: int = 7) -> Path

Write a SPICE type-9 SPK segment (normally named .bsp).

SPICE stores positions in kilometers, velocities in kilometers per second, and epochs as TDB-compatible ephemeris seconds. frame_name must be a frame recognized by SPICE, such as "J2000".

Source code in octavian/exports/ephemeris.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
def write_spice_bsp(
    ephemeris: Ephemeris,
    path: str | Path,
    *,
    overwrite: bool = False,
    interpolation_degree: int = 7,
) -> Path:
    """Write a SPICE type-9 SPK segment (normally named ``.bsp``).

    SPICE stores positions in kilometers, velocities in kilometers per second,
    and epochs as TDB-compatible ephemeris seconds. ``frame_name`` must be a
    frame recognized by SPICE, such as ``"J2000"``.
    """
    output = _prepare_output_path(path, overwrite=overwrite)
    degree = _interpolation_degree(
        len(ephemeris.times_s),
        interpolation_degree,
        require_odd=True,
    )
    epochs = ephemeris.epochs_et
    states = ephemeris.states_m_mps / 1_000.0
    temp_path = _temporary_sibling(output)
    handle: int | None = None
    try:
        handle = spice.spkopn(str(temp_path), "OCTAVIAN EPHEMERIS", 0)
        segment_id = f"OCTAVIAN {ephemeris.object_name}"[:40]
        spice.spkw09(
            handle,
            ephemeris.object_id,
            ephemeris.center_id,
            ephemeris.frame_name,
            float(epochs[0]),
            float(epochs[-1]),
            segment_id,
            degree,
            len(epochs),
            states,
            epochs,
        )
        spice.spkcls(handle)
        handle = None
        if output.exists() and not overwrite:
            raise FileExistsError(f"Output file already exists: {output}")
        os.replace(temp_path, output)
    finally:
        if handle is not None:
            with suppress(Exception):
                spice.spkcls(handle)
        with suppress(FileNotFoundError):
            temp_path.unlink()
    return output

write_stk_ephemeris(ephemeris: Ephemeris, path: str | Path, *, overwrite: bool = False, interpolation_degree: int = 7) -> Path

Write an STK ASCII ephemeris (.e) in meters and seconds.

Source code in octavian/exports/ephemeris.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def write_stk_ephemeris(
    ephemeris: Ephemeris,
    path: str | Path,
    *,
    overwrite: bool = False,
    interpolation_degree: int = 7,
) -> Path:
    """Write an STK ASCII ephemeris (``.e``) in meters and seconds."""
    output = _prepare_output_path(path, overwrite=overwrite)
    epoch = epoch_to_datetime_utc(ephemeris.epoch)
    scenario_epoch = epoch.strftime("%d %b %Y %H:%M:%S.%f")
    interpolation_order = _interpolation_degree(
        len(ephemeris.times_s),
        interpolation_degree,
        require_odd=False,
    )
    lines = [
        "stk.v.12.0",
        "",
        "BEGIN Ephemeris",
        "",
        f"NumberOfEphemerisPoints {len(ephemeris.times_s)}",
        f"ScenarioEpoch {scenario_epoch}",
        "InterpolationMethod Lagrange",
        f"InterpolationOrder {interpolation_order}",
        f"CentralBody {ephemeris.center_name.title()}",
        f"CoordinateSystem {ephemeris.frame_name}",
        "DistanceUnit Meters",
        "",
        "EphemerisTimePosVel",
    ]
    for time_s, state in zip(
        ephemeris.times_s,
        ephemeris.states_m_mps,
        strict=True,
    ):
        values = " ".join(_format_float(value) for value in state)
        lines.append(f"{_format_float(time_s)} {values}")
    lines.extend(["", "END Ephemeris", ""])
    _write_text(output, "\n".join(lines), overwrite=overwrite)
    return output

octavian.viz.plotly

Plotly-based trajectory visualization helpers.

cr3bp_trajectory_figure(traj: np.ndarray, *, system: CR3BPSystem, dimensional: bool = True, lagrange_point_names: Sequence[str] | None = None, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian CR3BP trajectory') -> Any

Build an interactive barycentric-synodic CR3BP trajectory figure.

Args: traj: Rows [x, y, z, xdot, ydot, zdot, time]. system: Primary-secondary CR3BP system. dimensional: Interpret positions as meters when true or canonical distance units otherwise. lagrange_point_names: Lagrange points to include. The default includes all five; pass an empty sequence to omit them. maneuvers: Optional maneuver markers in the trajectory's units. phase_segments: Optional phase interval dictionaries with name, t_start_s, t_end_s, and optional color keys. reference_trajectories: Optional dictionaries with name and traj keys plus an optional Plotly color. These are useful for plotting departure and arrival periodic orbits around a solved transfer. title: Figure title.

Returns: A Plotly 3D figure with independently selectable body and Lagrange-point traces.

Source code in octavian/viz/plotly.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
def cr3bp_trajectory_figure(
    traj: np.ndarray,
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
    lagrange_point_names: Sequence[str] | None = None,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian CR3BP trajectory",
) -> Any:
    """Build an interactive barycentric-synodic CR3BP trajectory figure.

    Args:
        traj: Rows ``[x, y, z, xdot, ydot, zdot, time]``.
        system: Primary-secondary CR3BP system.
        dimensional: Interpret positions as meters when true or canonical
            distance units otherwise.
        lagrange_point_names: Lagrange points to include. The default includes
            all five; pass an empty sequence to omit them.
        maneuvers: Optional maneuver markers in the trajectory's units.
        phase_segments: Optional phase interval dictionaries with ``name``,
            ``t_start_s``, ``t_end_s``, and optional ``color`` keys.
        reference_trajectories: Optional dictionaries with ``name`` and
            ``traj`` keys plus an optional Plotly ``color``. These are useful
            for plotting departure and arrival periodic orbits around a
            solved transfer.
        title: Figure title.

    Returns:
        A Plotly 3D figure with independently selectable body and Lagrange-point
        traces.
    """
    try:
        import plotly.graph_objects as go
    except ImportError as exc:
        raise RuntimeError(
            "Plotly visualization requires the optional viz dependencies. "
            "Install them with `pip install \"octavian[viz]\"`."
        ) from exc

    trajectory = np.asarray(traj, dtype=float)
    if (
        trajectory.ndim != 2
        or trajectory.shape[0] < 1
        or trajectory.shape[1] < 7
        or not np.all(np.isfinite(trajectory[:, 0:7]))
    ):
        raise ValueError("traj must contain finite [x, y, z, xdot, ydot, zdot, time] rows")
    scale = 1.0 / 1_000.0 if dimensional else 1.0
    unit = "km" if dimensional else "DU"
    primary_position = (
        system.primary_position_m if dimensional else system.primary_position_nondimensional
    )
    secondary_position = (
        system.secondary_position_m if dimensional else system.secondary_position_nondimensional
    )
    all_lagrange_points = system.lagrange_points(dimensional=dimensional)
    if lagrange_point_names is None:
        selected_lagrange_names = tuple(all_lagrange_points)
    else:
        selected_lagrange_names = tuple(str(name).strip().upper() for name in lagrange_point_names)
        unknown_names = [
            name for name in selected_lagrange_names if name not in all_lagrange_points
        ]
        if unknown_names:
            raise ValueError(
                "lagrange_point_names entries must be L1, L2, L3, L4, or L5; "
                f"received {unknown_names!r}"
            )
        if len(set(selected_lagrange_names)) != len(selected_lagrange_names):
            raise ValueError("lagrange_point_names entries must be unique")
    positions = scale * trajectory[:, 0:3]
    hover_text = [
        (
            f"t = {row[6]:.3f}<br>"
            f"x,y,z = [{position[0]:.6f}, {position[1]:.6f}, "
            f"{position[2]:.6f}] {unit}"
        )
        for row, position in zip(trajectory, positions, strict=True)
    ]
    figure = go.Figure()
    figure.add_trace(
        go.Scatter3d(
            x=positions[:, 0],
            y=positions[:, 1],
            z=positions[:, 2],
            mode="lines",
            name="Trajectory",
            line=dict(width=6, color="#00CC96"),
            text=hover_text,
            hovertemplate="%{text}<extra></extra>",
        )
    )
    for phase_index, segment in enumerate(phase_segments or (), start=1):
        start_time = float(segment["t_start_s"])
        end_time = float(segment["t_end_s"])
        phase_mask = (trajectory[:, 6] >= start_time - 1.0e-9) & (
            trajectory[:, 6] <= end_time + 1.0e-9
        )
        if int(np.count_nonzero(phase_mask)) < 2:
            continue
        phase_positions = positions[phase_mask]
        figure.add_trace(
            go.Scatter3d(
                x=phase_positions[:, 0],
                y=phase_positions[:, 1],
                z=phase_positions[:, 2],
                mode="lines",
                name=str(segment.get("name", f"Phase {phase_index}")),
                line=dict(
                    width=8,
                    color=str(segment.get("color", "#00CC96")),
                ),
            )
        )
    figure.add_traces(_reference_traces(go, reference_trajectories, scale=scale))
    for maneuver_index, maneuver in enumerate(maneuvers or (), start=1):
        maneuver_position = scale * np.asarray(maneuver.r_m, dtype=float).reshape(3)
        delta_v = np.asarray(maneuver.dv_mps, dtype=float).reshape(3)
        figure.add_trace(
            go.Scatter3d(
                x=[maneuver_position[0]],
                y=[maneuver_position[1]],
                z=[maneuver_position[2]],
                mode="markers",
                name=f"M{maneuver_index}: {maneuver.name}",
                marker=dict(size=7, color="#FFA15A", symbol="diamond"),
                text=[
                    (
                        f"<b>{maneuver.name}</b><br>"
                        f"t = {float(maneuver.t_s):.3f}<br>"
                        f"|Δv| = {float(np.linalg.norm(delta_v)):.6f}"
                    )
                ],
                hovertemplate="%{text}<extra></extra>",
            )
        )
    for body_name, body_position, marker_size, marker_color in (
        (system.primary.name.title(), primary_position, 16, "#3B82F6"),
        (system.secondary.name.title(), secondary_position, 9, "#9CA3AF"),
    ):
        figure.add_trace(
            go.Scatter3d(
                x=[scale * body_position[0]],
                y=[scale * body_position[1]],
                z=[scale * body_position[2]],
                mode="markers+text",
                name=body_name,
                text=[body_name],
                textposition="top center",
                marker=dict(size=marker_size, color=marker_color),
            )
        )
    for point_name in selected_lagrange_names:
        point = all_lagrange_points[point_name]
        figure.add_trace(
            go.Scatter3d(
                x=[scale * point[0]],
                y=[scale * point[1]],
                z=[scale * point[2]],
                mode="markers+text",
                name=point_name,
                text=[point_name],
                textposition="top center",
                marker=dict(size=5, color="#EF553B", symbol="diamond"),
            )
        )
    axis_style = dict(
        backgroundcolor="black",
        gridcolor="rgba(255,255,255,0.12)",
        zerolinecolor="rgba(255,255,255,0.25)",
    )
    figure.update_layout(
        title=dict(text=title, x=0.5),
        template="plotly_dark",
        scene=dict(
            xaxis=dict(title=f"Synodic X ({unit})", **axis_style),
            yaxis=dict(title=f"Synodic Y ({unit})", **axis_style),
            zaxis=dict(title=f"Synodic Z ({unit})", **axis_style),
            aspectmode="data",
        ),
        margin=dict(l=0, r=0, t=55, b=0),
    )
    return figure

relative_trajectory_figure(traj: np.ndarray, *, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian relative trajectory', chief_radius_m: float = 0.0) -> Any

Build an interactive 3D RIC trajectory figure.

Args: traj: Relative trajectory rows [R, I, C, Rdot, Idot, Cdot, t] in meters, meters per second, and seconds. maneuvers: Optional maneuver markers expressed in the same RIC frame. phase_segments: Optional phase interval dictionaries with name, t_start_s, t_end_s, and optional color keys. reference_trajectories: Dashed overlays with traj position rows in the same frame and units, plus optional name and color. Velocity and time columns are ignored. title: Plot title. chief_radius_m: Optional physical or keep-out radius drawn about the chief. Zero draws a marker without a surrounding sphere.

Returns: A :class:plotly.graph_objects.Figure with equal RIC axis scaling.

Raises: ValueError: If the trajectory shape, values, or chief radius is invalid. RuntimeError: If Plotly is not installed.

Source code in octavian/viz/plotly.py
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
def relative_trajectory_figure(
    traj: np.ndarray,
    *,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian relative trajectory",
    chief_radius_m: float = 0.0,
) -> Any:
    """Build an interactive 3D RIC trajectory figure.

    Args:
        traj: Relative trajectory rows ``[R, I, C, Rdot, Idot, Cdot, t]`` in
            meters, meters per second, and seconds.
        maneuvers: Optional maneuver markers expressed in the same RIC frame.
        phase_segments: Optional phase interval dictionaries with ``name``,
            ``t_start_s``, ``t_end_s``, and optional ``color`` keys.
        reference_trajectories: Dashed overlays with ``traj`` position rows
            in the same frame and units, plus optional ``name`` and ``color``.
            Velocity and time columns are ignored.
        title: Plot title.
        chief_radius_m: Optional physical or keep-out radius drawn about the
            chief.  Zero draws a marker without a surrounding sphere.

    Returns:
        A :class:`plotly.graph_objects.Figure` with equal RIC axis scaling.

    Raises:
        ValueError: If the trajectory shape, values, or chief radius is invalid.
        RuntimeError: If Plotly is not installed.
    """
    try:
        import plotly.graph_objects as go
    except ImportError as exc:
        raise RuntimeError(
            "Plotly visualization requires the optional viz dependencies. "
            "Install them with `pip install \"octavian[viz]\"`."
        ) from exc

    trajectory = np.asarray(traj, dtype=float)
    if trajectory.ndim != 2 or trajectory.shape[1] < 7:
        raise ValueError(
            "traj must be a 2D array with at least 7 columns: "
            "[R, I, C, Rdot, Idot, Cdot, t]."
        )
    if trajectory.shape[0] < 1 or not np.all(np.isfinite(trajectory[:, 0:7])):
        raise ValueError("traj must contain at least one finite RIC state")
    chief_radius = float(chief_radius_m)
    if not np.isfinite(chief_radius) or chief_radius < 0.0:
        raise ValueError("chief_radius_m must be finite and non-negative")

    position_history_m = trajectory[:, 0:3]
    velocity_history_mps = trajectory[:, 3:6]
    time_history_s = trajectory[:, 6]
    distance_history_m = np.linalg.norm(position_history_m, axis=1)
    speed_history_mps = np.linalg.norm(velocity_history_mps, axis=1)
    hover_text = [
        (
            f"t = {time_s:.3f} s<br>"
            f"RIC = [{position[0]:.3f}, {position[1]:.3f}, {position[2]:.3f}] m<br>"
            f"|ρ| = {distance:.3f} m<br>"
            f"|ρ̇| = {speed:.6f} m/s"
        )
        for position, distance, speed, time_s in zip(
            position_history_m,
            distance_history_m,
            speed_history_mps,
            time_history_s,
            strict=True,
        )
    ]

    traces: list[Any] = [
        go.Scatter3d(
            x=position_history_m[:, 0],
            y=position_history_m[:, 1],
            z=position_history_m[:, 2],
            mode="lines",
            name="Relative trajectory",
            line=dict(width=6, color="#3BA3FF"),
            text=hover_text,
            hovertemplate="%{text}<extra></extra>",
        )
    ]

    for phase_index, segment in enumerate(phase_segments or (), start=1):
        start_s = float(segment["t_start_s"])
        end_s = float(segment["t_end_s"])
        mask = (time_history_s >= start_s - 1.0e-9) & (
            time_history_s <= end_s + 1.0e-9
        )
        if int(np.count_nonzero(mask)) < 2:
            continue
        positions = position_history_m[mask]
        traces.append(
            go.Scatter3d(
                x=positions[:, 0],
                y=positions[:, 1],
                z=positions[:, 2],
                mode="lines",
                name=str(segment.get("name", f"phase {phase_index}")),
                line=dict(width=8, color=str(segment.get("color", "#3BA3FF"))),
            )
        )

    traces.extend(_reference_traces(go, reference_trajectories))

    traces.extend(
        [
            go.Scatter3d(
                x=[position_history_m[0, 0]],
                y=[position_history_m[0, 1]],
                z=[position_history_m[0, 2]],
                mode="markers",
                name="Start",
                marker=dict(size=6, color="#69DB7C"),
                text=[hover_text[0]],
                hovertemplate="%{text}<extra></extra>",
            ),
            go.Scatter3d(
                x=[position_history_m[-1, 0]],
                y=[position_history_m[-1, 1]],
                z=[position_history_m[-1, 2]],
                mode="markers",
                name="End",
                marker=dict(size=6, color="#FFD43B"),
                text=[hover_text[-1]],
                hovertemplate="%{text}<extra></extra>",
            ),
            go.Scatter3d(
                x=[0.0],
                y=[0.0],
                z=[0.0],
                mode="markers",
                name="Chief",
                marker=dict(size=8, color="white", symbol="diamond"),
                hovertemplate="<b>Chief / RIC origin</b><extra></extra>",
            ),
        ]
    )

    if chief_radius > 0.0:
        longitude = np.linspace(0.0, 2.0 * np.pi, 48)
        colatitude = np.linspace(0.0, np.pi, 24)
        longitude_grid, colatitude_grid = np.meshgrid(longitude, colatitude)
        traces.insert(
            0,
            go.Surface(
                x=chief_radius * np.cos(longitude_grid) * np.sin(colatitude_grid),
                y=chief_radius * np.sin(longitude_grid) * np.sin(colatitude_grid),
                z=chief_radius * np.cos(colatitude_grid),
                colorscale=[[0.0, "#868E96"], [1.0, "#ADB5BD"]],
                opacity=0.55,
                showscale=False,
                name="Chief boundary",
                hovertemplate=f"Chief boundary: {chief_radius:.3f} m<extra></extra>",
            ),
        )

    for index, maneuver in enumerate(maneuvers or (), start=1):
        position = np.asarray(maneuver.r_m, dtype=float).reshape(3)
        delta_v = np.asarray(maneuver.dv_mps, dtype=float).reshape(3)
        text = (
            f"<b>{maneuver.name}</b><br>"
            f"t = {float(maneuver.t_s):.3f} s<br>"
            f"RIC = [{position[0]:.3f}, {position[1]:.3f}, {position[2]:.3f}] m<br>"
            f"Δv = [{delta_v[0]:.6f}, {delta_v[1]:.6f}, {delta_v[2]:.6f}] m/s<br>"
            f"|Δv| = {float(np.linalg.norm(delta_v)):.6f} m/s"
        )
        traces.append(
            go.Scatter3d(
                x=[position[0]],
                y=[position[1]],
                z=[position[2]],
                mode="markers",
                name=f"M{index}: {maneuver.name}",
                marker=dict(size=7, color="#FF6B6B"),
                text=[text],
                hovertemplate="%{text}<extra></extra>",
            )
        )

    axis_style = dict(
        backgroundcolor="black",
        gridcolor="rgba(255,255,255,0.12)",
        zerolinecolor="rgba(255,255,255,0.35)",
        showspikes=False,
    )
    figure = go.Figure(data=traces)
    figure.update_layout(
        title=dict(text=title, x=0.5),
        paper_bgcolor="black",
        plot_bgcolor="black",
        font=dict(color="white"),
        scene=dict(
            bgcolor="black",
            xaxis={**axis_style, "title": "Radial, R (m)"},
            yaxis={**axis_style, "title": "In-track, I (m)"},
            zaxis={**axis_style, "title": "Cross-track, C (m)"},
            aspectmode="data",
        ),
        margin=dict(l=0, r=0, t=40, b=0),
        legend=dict(bgcolor="rgba(0,0,0,0.4)"),
    )
    return figure

save_cr3bp_trajectory_html(traj: np.ndarray, out_html: str, *, system: CR3BPSystem, dimensional: bool = True, lagrange_point_names: Sequence[str] | None = None, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian CR3BP trajectory') -> None

Save a barycentric-synodic CR3BP trajectory as interactive HTML.

Args: traj: Rows [x, y, z, xdot, ydot, zdot, time]. out_html: Destination HTML path. system: Primary-secondary CR3BP system. dimensional: Interpret trajectory and marker positions as meters when true or canonical distance units otherwise. lagrange_point_names: Lagrange points to include. The default includes all five; pass an empty sequence to omit them. maneuvers: Optional maneuver markers in the trajectory's units. phase_segments: Optional phase interval dictionaries with name, t_start_s, t_end_s, and optional color keys. reference_trajectories: Optional named trajectory dictionaries to overlay, such as departure and arrival periodic orbits. title: Figure title.

Source code in octavian/viz/plotly.py
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
def save_cr3bp_trajectory_html(
    traj: np.ndarray,
    out_html: str,
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
    lagrange_point_names: Sequence[str] | None = None,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian CR3BP trajectory",
) -> None:
    """Save a barycentric-synodic CR3BP trajectory as interactive HTML.

    Args:
        traj: Rows ``[x, y, z, xdot, ydot, zdot, time]``.
        out_html: Destination HTML path.
        system: Primary-secondary CR3BP system.
        dimensional: Interpret trajectory and marker positions as meters when
            true or canonical distance units otherwise.
        lagrange_point_names: Lagrange points to include. The default includes
            all five; pass an empty sequence to omit them.
        maneuvers: Optional maneuver markers in the trajectory's units.
        phase_segments: Optional phase interval dictionaries with ``name``,
            ``t_start_s``, ``t_end_s``, and optional ``color`` keys.
        reference_trajectories: Optional named trajectory dictionaries to
            overlay, such as departure and arrival periodic orbits.
        title: Figure title.
    """
    figure = cr3bp_trajectory_figure(
        traj,
        system=system,
        dimensional=dimensional,
        lagrange_point_names=lagrange_point_names,
        maneuvers=maneuvers,
        phase_segments=phase_segments,
        reference_trajectories=reference_trajectories,
        title=title,
    )
    figure.write_html(out_html, include_plotlyjs="cdn")

save_relative_trajectory_html(traj: np.ndarray, out_html: str, *, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian relative trajectory', chief_radius_m: float = 0.0) -> None

Save an interactive chief-centered RIC trajectory as HTML.

This is the relative-motion counterpart to :func:save_trajectory_html. It labels the RIC axes explicitly, places the chief at the origin, and does not draw Earth in a frame where that geometry would be misleading.

Source code in octavian/viz/plotly.py
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
def save_relative_trajectory_html(
    traj: np.ndarray,
    out_html: str,
    *,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian relative trajectory",
    chief_radius_m: float = 0.0,
) -> None:
    """Save an interactive chief-centered RIC trajectory as HTML.

    This is the relative-motion counterpart to :func:`save_trajectory_html`.
    It labels the RIC axes explicitly, places the chief at the origin, and does
    not draw Earth in a frame where that geometry would be misleading.
    """
    figure = relative_trajectory_figure(
        traj,
        maneuvers=maneuvers,
        phase_segments=phase_segments,
        reference_trajectories=reference_trajectories,
        title=title,
        chief_radius_m=chief_radius_m,
    )
    figure.write_html(out_html, include_plotlyjs="cdn")

save_trajectory_diagnostics_html(traj: np.ndarray, out_html: str, *, frame_kind: str, mu_m3ps2: float | None = None, solar_directions_ric: np.ndarray | None = None, cr3bp_system: CR3BPSystem | None = None, cr3bp_dimensional: bool = True, title: str = 'octavian trajectory diagnostics') -> None

Save frame-aware trajectory time histories as interactive HTML.

Source code in octavian/viz/plotly.py
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
def save_trajectory_diagnostics_html(
    traj: np.ndarray,
    out_html: str,
    *,
    frame_kind: str,
    mu_m3ps2: float | None = None,
    solar_directions_ric: np.ndarray | None = None,
    cr3bp_system: CR3BPSystem | None = None,
    cr3bp_dimensional: bool = True,
    title: str = "octavian trajectory diagnostics",
) -> None:
    """Save frame-aware trajectory time histories as interactive HTML."""
    figure = trajectory_diagnostics_figure(
        traj,
        frame_kind=frame_kind,
        mu_m3ps2=mu_m3ps2,
        solar_directions_ric=solar_directions_ric,
        cr3bp_system=cr3bp_system,
        cr3bp_dimensional=cr3bp_dimensional,
        title=title,
    )
    figure.write_html(out_html, include_plotlyjs="cdn")

save_trajectory_html(traj: np.ndarray, out_html: str, *, x0_r_m: np.ndarray | None = None, x0_v_mps: np.ndarray | None = None, xf_r_m: np.ndarray | None = None, xf_v_mps: np.ndarray | None = None, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian trajectory', earth_radius_m: float = EARTH_RADIUS_M, use_earth_texture: bool = True, earth_texture_path: str | None = None) -> None

Save a 3D Plotly HTML visualization of an ECI trajectory.

Args: traj: Trajectory array with columns [rx, ry, rz, vx, vy, vz, t] in meters, meters per second, and seconds. out_html: Output HTML path. x0_r_m: Optional initial position override in meters. x0_v_mps: Optional initial velocity override in meters per second. xf_r_m: Optional final position override in meters. xf_v_mps: Optional final velocity override in meters per second. maneuvers: Optional maneuver markers. phase_segments: Optional phase interval dictionaries with name, t_start_s, t_end_s, and optional color keys. reference_trajectories: Dashed overlays with traj position rows in the same frame and units, plus optional name and color. Velocity and time columns are ignored. title: Plot title. earth_radius_m: Earth radius used for the sphere in meters. use_earth_texture: Whether to render Earth with a texture map. earth_texture_path: Optional path to an equirectangular Earth texture. None uses the packaged default and "" disables the texture.

Raises: ValueError: If traj is not a 2D array with at least seven columns.

Source code in octavian/viz/plotly.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def save_trajectory_html(
    traj: np.ndarray,
    out_html: str,
    *,
    x0_r_m: np.ndarray | None = None,
    x0_v_mps: np.ndarray | None = None,
    xf_r_m: np.ndarray | None = None,
    xf_v_mps: np.ndarray | None = None,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian trajectory",
    earth_radius_m: float = EARTH_RADIUS_M,
    use_earth_texture: bool = True,
    earth_texture_path: str | None = None,
) -> None:
    """Save a 3D Plotly HTML visualization of an ECI trajectory.

    Args:
        traj: Trajectory array with columns ``[rx, ry, rz, vx, vy, vz, t]`` in
            meters, meters per second, and seconds.
        out_html: Output HTML path.
        x0_r_m: Optional initial position override in meters.
        x0_v_mps: Optional initial velocity override in meters per second.
        xf_r_m: Optional final position override in meters.
        xf_v_mps: Optional final velocity override in meters per second.
        maneuvers: Optional maneuver markers.
        phase_segments: Optional phase interval dictionaries with ``name``,
            ``t_start_s``, ``t_end_s``, and optional ``color`` keys.
        reference_trajectories: Dashed overlays with ``traj`` position rows
            in the same frame and units, plus optional ``name`` and ``color``.
            Velocity and time columns are ignored.
        title: Plot title.
        earth_radius_m: Earth radius used for the sphere in meters.
        use_earth_texture: Whether to render Earth with a texture map.
        earth_texture_path: Optional path to an equirectangular Earth texture.
            ``None`` uses the packaged default and ``""`` disables the texture.

    Raises:
        ValueError: If ``traj`` is not a 2D array with at least seven columns.
    """
    try:
        import plotly.graph_objects as go
    except ImportError as exc:
        raise RuntimeError(
            "Plotly visualization requires the optional viz dependencies. "
            "Install them with `pip install \"octavian[viz]\"`."
        ) from exc

    if not use_earth_texture:
        resolved_texture_path: str | None = None
    elif earth_texture_path is None:
        resolved_texture_path = _get_default_earth_texture_path()
    elif earth_texture_path == "":
        resolved_texture_path = None
    else:
        resolved_texture_path = str(Path(earth_texture_path).expanduser())

    trajectory = np.asarray(traj, dtype=float)
    if trajectory.ndim != 2 or trajectory.shape[1] < 7:
        raise ValueError("traj must be a 2D array with at least 7 columns: [r(3), v(3), t].")

    position_history_m = trajectory[:, 0:3]
    velocity_history_mps = trajectory[:, 3:6]
    time_history_s = trajectory[:, 6]
    final_time_s = float(time_history_s[-1])

    initial_position_m = (
        position_history_m[0].copy()
        if x0_r_m is None
        else np.asarray(x0_r_m, dtype=float).reshape(3)
    )
    initial_velocity_mps = (
        velocity_history_mps[0].copy()
        if x0_v_mps is None
        else np.asarray(x0_v_mps, dtype=float).reshape(3)
    )
    final_position_m = (
        position_history_m[-1].copy()
        if xf_r_m is None
        else np.asarray(xf_r_m, dtype=float).reshape(3)
    )
    final_velocity_mps = (
        velocity_history_mps[-1].copy()
        if xf_v_mps is None
        else np.asarray(xf_v_mps, dtype=float).reshape(3)
    )

    earth_radius = float(earth_radius_m)
    if resolved_texture_path is not None:
        try:
            from PIL import Image
        except ImportError as exc:
            raise RuntimeError(
                "Textured visualization requires the optional viz dependencies. "
                "Install them with `pip install \"octavian[viz]\"`."
            ) from exc

        earth_image = Image.open(resolved_texture_path).convert("RGB")
        earth_image = earth_image.resize((720, 360))
        quantized_image = earth_image.quantize(colors=256, method=Image.Quantize.MEDIANCUT).convert("P")
        palette = quantized_image.getpalette() or ([0, 0, 0] * 256)
        palette_rgb = np.array(palette, dtype=np.uint8).reshape(-1, 3)

        texture_indices = np.asarray(quantized_image, dtype=np.uint8)
        texture_height, texture_width = texture_indices.shape

        longitude = np.linspace(0.0, 2.0 * np.pi, texture_width)
        colatitude = np.linspace(0.0, np.pi, texture_height)
        longitude_grid, colatitude_grid = np.meshgrid(longitude, colatitude)

        earth_x = earth_radius * np.cos(longitude_grid) * np.sin(colatitude_grid)
        earth_y = earth_radius * np.sin(longitude_grid) * np.sin(colatitude_grid)
        earth_z = earth_radius * np.cos(colatitude_grid)

        colorscale = []
        for palette_index in range(256):
            red, green, blue = palette_rgb[palette_index]
            colorscale.append(
                [palette_index / 255.0, f"rgb({int(red)},{int(green)},{int(blue)})"]
            )

        earth_trace = go.Surface(
            x=earth_x,
            y=earth_y,
            z=earth_z,
            surfacecolor=texture_indices.astype(np.float64),
            cmin=0,
            cmax=255,
            colorscale=colorscale,
            showscale=False,
            name="Earth",
            hoverinfo="skip",
            lighting=dict(ambient=0.9, diffuse=0.8, specular=0.2, roughness=0.9),
        )
    else:
        longitude = np.linspace(0.0, 2.0 * np.pi, 60)
        colatitude = np.linspace(0.0, np.pi, 30)
        longitude_grid, colatitude_grid = np.meshgrid(longitude, colatitude)

        earth_x = earth_radius * np.cos(longitude_grid) * np.sin(colatitude_grid)
        earth_y = earth_radius * np.sin(longitude_grid) * np.sin(colatitude_grid)
        earth_z = earth_radius * np.cos(colatitude_grid)

        earth_trace = go.Surface(
            x=earth_x,
            y=earth_y,
            z=earth_z,
            showscale=False,
            colorscale=[[0.0, "blue"], [1.0, "blue"]],
            name="Earth",
            hoverinfo="skip",
        )

    trajectory_trace = go.Scatter3d(
        x=position_history_m[:, 0],
        y=position_history_m[:, 1],
        z=position_history_m[:, 2],
        mode="lines",
        name="Trajectory outline",
        line=dict(width=3, color="rgba(255,255,255,0.35)"),
    )

    phase_traces = []
    for phase_index, segment in enumerate(phase_segments or (), start=1):
        t_start_s = float(segment["t_start_s"])
        t_end_s = float(segment["t_end_s"])
        phase_mask = (time_history_s >= t_start_s - 1.0e-9) & (time_history_s <= t_end_s + 1.0e-9)
        if int(np.count_nonzero(phase_mask)) < 2:
            continue
        phase_positions_m = position_history_m[phase_mask]
        phase_name = str(segment.get("name", f"phase {phase_index}"))
        phase_color = str(segment.get("color", "white"))
        phase_traces.append(
            go.Scatter3d(
                x=phase_positions_m[:, 0],
                y=phase_positions_m[:, 1],
                z=phase_positions_m[:, 2],
                mode="lines",
                name=phase_name,
                line=dict(width=7, color=phase_color),
            )
        )

    start_hover_text = (
        f"<b>Start</b><br>"
        f"t = {time_history_s[0]:.3f} s<br>"
        f"r0 = [{initial_position_m[0]:.3f}, {initial_position_m[1]:.3f}, {initial_position_m[2]:.3f}] m<br>"
        f"v0 = [{initial_velocity_mps[0]:.6f}, {initial_velocity_mps[1]:.6f}, {initial_velocity_mps[2]:.6f}] m/s<br>"
        f"<b>TOF</b> = {final_time_s:.3f} s"
    )
    end_hover_text = (
        f"<b>End</b><br>"
        f"t = {final_time_s:.3f} s<br>"
        f"rf = [{final_position_m[0]:.3f}, {final_position_m[1]:.3f}, {final_position_m[2]:.3f}] m<br>"
        f"vf = [{final_velocity_mps[0]:.6f}, {final_velocity_mps[1]:.6f}, {final_velocity_mps[2]:.6f}] m/s<br>"
        f"<b>TOF</b> = {final_time_s:.3f} s"
    )

    start_trace = go.Scatter3d(
        x=[position_history_m[0, 0]],
        y=[position_history_m[0, 1]],
        z=[position_history_m[0, 2]],
        mode="markers",
        marker=dict(size=6, color="white"),
        name="Start",
        text=[start_hover_text],
        hovertemplate="%{text}<extra></extra>",
    )
    end_trace = go.Scatter3d(
        x=[position_history_m[-1, 0]],
        y=[position_history_m[-1, 1]],
        z=[position_history_m[-1, 2]],
        mode="markers",
        marker=dict(size=6, color="white"),
        name="End",
        text=[end_hover_text],
        hovertemplate="%{text}<extra></extra>",
    )

    maneuver_traces = []
    for maneuver_index, maneuver in enumerate(maneuvers or (), start=1):
        maneuver_position_m = np.asarray(maneuver.r_m, dtype=float).reshape(3)
        maneuver_time_s = float(maneuver.t_s)
        maneuver_delta_v_mps = np.asarray(maneuver.dv_mps, dtype=float).reshape(3)
        maneuver_delta_v_mag_mps = float(np.linalg.norm(maneuver_delta_v_mps))
        maneuver_hover_text = (
            f"<b>{maneuver.name}</b><br>"
            f"t = {maneuver_time_s:.3f} s<br>"
            f"r = [{maneuver_position_m[0]:.3f}, {maneuver_position_m[1]:.3f}, {maneuver_position_m[2]:.3f}] m<br>"
            f"Δv = [{maneuver_delta_v_mps[0]:.6f}, {maneuver_delta_v_mps[1]:.6f}, {maneuver_delta_v_mps[2]:.6f}] m/s<br>"
            f"|Δv| = {maneuver_delta_v_mag_mps:.6f} m/s"
        )
        maneuver_traces.append(
            go.Scatter3d(
                x=[maneuver_position_m[0]],
                y=[maneuver_position_m[1]],
                z=[maneuver_position_m[2]],
                mode="markers",
                marker=dict(size=7, color="red"),
                name=f"M{maneuver_index}: {maneuver.name}",
                text=[maneuver_hover_text],
                hovertemplate="%{text}<extra></extra>",
            )
        )

    figure = go.Figure(
        data=[
            earth_trace,
            trajectory_trace,
            *phase_traces,
            *_reference_traces(go, reference_trajectories),
            start_trace,
            end_trace,
            *maneuver_traces,
        ]
    )
    figure.update_layout(
        title=dict(text=title, x=0.5),
        paper_bgcolor="black",
        plot_bgcolor="black",
        font=dict(color="white"),
        scene=dict(
            bgcolor="black",
            xaxis=dict(
                backgroundcolor="black",
                gridcolor="rgba(255,255,255,0.1)",
                zerolinecolor="rgba(255,255,255,0.2)",
            ),
            yaxis=dict(
                backgroundcolor="black",
                gridcolor="rgba(255,255,255,0.1)",
                zerolinecolor="rgba(255,255,255,0.2)",
            ),
            zaxis=dict(
                backgroundcolor="black",
                gridcolor="rgba(255,255,255,0.1)",
                zerolinecolor="rgba(255,255,255,0.2)",
            ),
            aspectmode="data",
        ),
        margin=dict(l=0, r=0, t=40, b=0),
        legend=dict(bgcolor="rgba(0,0,0,0.4)"),
    )
    figure.write_html(out_html, include_plotlyjs="cdn")

trajectory_diagnostics_figure(traj: np.ndarray, *, frame_kind: str, mu_m3ps2: float | None = None, solar_directions_ric: np.ndarray | None = None, cr3bp_system: CR3BPSystem | None = None, cr3bp_dimensional: bool = True, title: str = 'octavian trajectory diagnostics') -> Any

Build stacked, shared-time plots appropriate to the trajectory frame.

Source code in octavian/viz/plotly.py
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
def trajectory_diagnostics_figure(
    traj: np.ndarray,
    *,
    frame_kind: str,
    mu_m3ps2: float | None = None,
    solar_directions_ric: np.ndarray | None = None,
    cr3bp_system: CR3BPSystem | None = None,
    cr3bp_dimensional: bool = True,
    title: str = "octavian trajectory diagnostics",
) -> Any:
    """Build stacked, shared-time plots appropriate to the trajectory frame."""
    try:
        import plotly.graph_objects as go
        from plotly.subplots import make_subplots
    except ImportError as exc:
        raise RuntimeError(
            "Plotly visualization requires the optional viz dependencies. "
            "Install them with `pip install \"octavian[viz]\"`."
        ) from exc

    trajectory = np.asarray(traj, dtype=float)
    normalized_frame = str(frame_kind).strip().lower()
    if normalized_frame == "relative":
        panels = relative_diagnostic_panels(
            trajectory,
            solar_directions_ric=solar_directions_ric,
        )
    elif normalized_frame == "rotating":
        if cr3bp_system is None:
            raise ValueError("Rotating trajectory diagnostics require cr3bp_system")
        panels = cr3bp_diagnostic_panels(
            trajectory,
            system=cr3bp_system,
            dimensional=cr3bp_dimensional,
        )
    else:
        if mu_m3ps2 is None or float(mu_m3ps2) <= 0.0:
            raise ValueError(
                "Inertial trajectory diagnostics require a positive mu_m3ps2"
            )
        panels = inertial_diagnostic_panels(
            trajectory,
            mu_m3ps2=float(mu_m3ps2),
        )

    figure = make_subplots(
        rows=len(panels),
        cols=1,
        shared_xaxes=True,
        vertical_spacing=min(0.08, 0.25 / len(panels)),
        subplot_titles=[panel.title for panel in panels],
    )
    time_values = trajectory[:, 6]
    for row, panel in enumerate(panels, start=1):
        for series in panel.series:
            label = (
                f"{series.name} ({series.unit})" if series.unit else series.name
            )
            figure.add_trace(
                go.Scatter(
                    x=time_values,
                    y=series.values,
                    mode="lines",
                    name=label,
                ),
                row=row,
                col=1,
            )
        figure.update_yaxes(title_text=panel.y_axis_title, row=row, col=1)
    time_unit = (
        "TU"
        if normalized_frame == "rotating" and not cr3bp_dimensional
        else "s"
    )
    figure.update_xaxes(
        title_text=f"Time ({time_unit})",
        row=len(panels),
        col=1,
    )
    figure.update_layout(
        title=dict(text=title, x=0.5),
        template="plotly_dark",
        height=max(650, 245 * len(panels)),
        hovermode="x unified",
        legend=dict(orientation="h", yanchor="bottom", y=1.01),
        margin=dict(l=75, r=25, t=90, b=55),
    )
    return figure

octavian.viz.matplotlib

Matplotlib-based trajectory and diagnostic visualization helpers.

The functions in this module mirror Octavian's Plotly views while producing ordinary Matplotlib figures. Figure builders support further customization, save_*_image helpers write PNG or JPEG files, and show_* helpers open the active Matplotlib GUI backend.

cr3bp_trajectory_figure(traj: np.ndarray, *, system: CR3BPSystem, dimensional: bool = True, lagrange_point_names: Sequence[str] | None = None, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian CR3BP trajectory', projection: str = '3d', figsize: tuple[float, float] = (9.0, 7.0)) -> Any

Build a static barycentric-synodic CR3BP trajectory figure.

projection accepts "3d", "xy", "xz", or "yz". reference_trajectories contains dictionaries with traj position rows in the same frame/units and optional name and color.

Source code in octavian/viz/matplotlib.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def cr3bp_trajectory_figure(
    traj: np.ndarray,
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
    lagrange_point_names: Sequence[str] | None = None,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian CR3BP trajectory",
    projection: str = "3d",
    figsize: tuple[float, float] = (9.0, 7.0),
) -> Any:
    """Build a static barycentric-synodic CR3BP trajectory figure.

    ``projection`` accepts ``"3d"``, ``"xy"``, ``"xz"``, or ``"yz"``.
    ``reference_trajectories`` contains dictionaries with ``traj`` position
    rows in the same frame/units and optional ``name`` and ``color``.
    """
    plt = _pyplot()
    trajectory = _trajectory(traj)
    scale = 1.0 / 1_000.0 if dimensional else 1.0
    unit = "km" if dimensional else "DU"
    positions = scale * trajectory[:, 0:3]
    time_values = trajectory[:, 6]
    primary_position = scale * (
        system.primary_position_m if dimensional else system.primary_position_nondimensional
    )
    secondary_position = scale * (
        system.secondary_position_m if dimensional else system.secondary_position_nondimensional
    )
    all_lagrange_points = system.lagrange_points(dimensional=dimensional)
    selected_lagrange_names = _selected_lagrange_names(
        all_lagrange_points,
        lagrange_point_names,
    )

    position_indices = _projection_indices(projection)
    figure, axes = _trajectory_axes(plt, figsize, title, position_indices)
    axes.plot(
        *(positions[:, index] for index in position_indices),
        color="#00CC96",
        linewidth=2.4,
        label="Trajectory",
    )
    _plot_phase_segments(
        axes,
        positions,
        time_values,
        phase_segments,
        position_indices,
    )
    _plot_reference_trajectories(axes, reference_trajectories, position_indices, scale=scale)
    for index, maneuver in enumerate(maneuvers or (), start=1):
        position = scale * np.asarray(maneuver.r_m, dtype=float).reshape(3)
        _scatter_position(
            axes,
            position,
            position_indices,
            color="#FFA15A",
            marker="D",
            s=48,
            label=f"M{index}: {maneuver.name}",
        )
    for body_name, body_position, marker_size, marker_color in (
        (system.primary.name.title(), primary_position, 130, "#3B82F6"),
        (system.secondary.name.title(), secondary_position, 75, "#9CA3AF"),
    ):
        _scatter_position(
            axes,
            body_position,
            position_indices,
            color=marker_color,
            s=marker_size,
            label=body_name,
        )
        _position_text(axes, body_position, position_indices, f" {body_name}")
    for point_name in selected_lagrange_names:
        point = scale * all_lagrange_points[point_name]
        _scatter_position(
            axes,
            point,
            position_indices,
            color="#EF553B",
            marker="D",
            s=32,
            label=point_name,
        )
        _position_text(axes, point, position_indices, f" {point_name}")

    _set_position_axis_labels(
        axes,
        tuple(f"Synodic {axis} ({unit})" for axis in ("X", "Y", "Z")),
        position_indices,
    )
    _finish_trajectory_figure(figure, axes, position_indices)
    return figure

relative_trajectory_figure(traj: np.ndarray, *, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian relative trajectory', chief_radius_m: float = 0.0, projection: str = '3d', figsize: tuple[float, float] = (9.0, 7.0)) -> Any

Build a static chief-centered RIC trajectory figure.

projection accepts "3d", "xy" (radial/in-track), "xz" (radial/cross-track), or "yz" (in-track/cross-track). reference_trajectories contains dictionaries with traj position rows in RIC meters and optional name and color for dashed overlays.

Source code in octavian/viz/matplotlib.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def relative_trajectory_figure(
    traj: np.ndarray,
    *,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian relative trajectory",
    chief_radius_m: float = 0.0,
    projection: str = "3d",
    figsize: tuple[float, float] = (9.0, 7.0),
) -> Any:
    """Build a static chief-centered RIC trajectory figure.

    ``projection`` accepts ``"3d"``, ``"xy"`` (radial/in-track), ``"xz"``
    (radial/cross-track), or ``"yz"`` (in-track/cross-track).
    ``reference_trajectories`` contains dictionaries with ``traj`` position
    rows in RIC meters and optional ``name`` and ``color`` for dashed overlays.
    """
    plt = _pyplot()
    trajectory = _trajectory(traj)
    chief_radius = float(chief_radius_m)
    if not np.isfinite(chief_radius) or chief_radius < 0.0:
        raise ValueError("chief_radius_m must be finite and non-negative")

    position_indices = _projection_indices(projection)
    positions = trajectory[:, 0:3]
    time_s = trajectory[:, 6]
    figure, axes = _trajectory_axes(plt, figsize, title, position_indices)
    axes.plot(
        *(positions[:, index] for index in position_indices),
        color="#3BA3FF",
        linewidth=2.2,
        label="Relative trajectory",
    )
    _plot_phase_segments(
        axes,
        positions,
        time_s,
        phase_segments,
        position_indices,
    )
    _plot_reference_trajectories(axes, reference_trajectories, position_indices)
    _scatter_position(
        axes,
        positions[0],
        position_indices,
        color="#69DB7C",
        s=40,
        label="Start",
    )
    _scatter_position(
        axes,
        positions[-1],
        position_indices,
        color="#FFD43B",
        s=40,
        label="End",
    )
    if chief_radius > 0.0:
        _plot_spherical_body(
            plt,
            axes,
            np.zeros(3),
            chief_radius,
            position_indices,
            color="#868E96",
            alpha=0.45,
        )
    _scatter_position(
        axes,
        np.zeros(3),
        position_indices,
        color="#F8F9FA",
        marker="D",
        s=55,
        label="Chief",
    )
    for index, maneuver in enumerate(maneuvers or (), start=1):
        position = np.asarray(maneuver.r_m, dtype=float).reshape(3)
        _scatter_position(
            axes,
            position,
            position_indices,
            color="#FF6B6B",
            marker="D",
            s=48,
            label=f"M{index}: {maneuver.name}",
        )

    _set_position_axis_labels(
        axes,
        ("Radial, R (m)", "In-track, I (m)", "Cross-track, C (m)"),
        position_indices,
    )
    _finish_trajectory_figure(figure, axes, position_indices)
    return figure

save_cr3bp_trajectory_image(traj: np.ndarray, out_image: str | Path, *, system: CR3BPSystem, dimensional: bool = True, lagrange_point_names: Sequence[str] | None = None, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian CR3BP trajectory', projection: str = '3d', figsize: tuple[float, float] = (9.0, 7.0), dpi: int = 160) -> None

Save a barycentric-synodic CR3BP trajectory as PNG or JPEG.

Source code in octavian/viz/matplotlib.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def save_cr3bp_trajectory_image(
    traj: np.ndarray,
    out_image: str | Path,
    *,
    system: CR3BPSystem,
    dimensional: bool = True,
    lagrange_point_names: Sequence[str] | None = None,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian CR3BP trajectory",
    projection: str = "3d",
    figsize: tuple[float, float] = (9.0, 7.0),
    dpi: int = 160,
) -> None:
    """Save a barycentric-synodic CR3BP trajectory as PNG or JPEG."""
    figure = cr3bp_trajectory_figure(
        traj,
        system=system,
        dimensional=dimensional,
        lagrange_point_names=lagrange_point_names,
        maneuvers=maneuvers,
        phase_segments=phase_segments,
        reference_trajectories=reference_trajectories,
        title=title,
        projection=projection,
        figsize=figsize,
    )
    save_figure_image(figure, out_image, dpi=dpi)

save_figure_image(figure: Any, out_image: str | Path, *, dpi: int = 160) -> None

Save an existing Matplotlib figure as PNG or JPEG, then close it.

Source code in octavian/viz/matplotlib.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
def save_figure_image(
    figure: Any,
    out_image: str | Path,
    *,
    dpi: int = 160,
) -> None:
    """Save an existing Matplotlib figure as PNG or JPEG, then close it."""
    plt = _pyplot()
    try:
        path = Path(out_image).expanduser()
        suffix = path.suffix.lower()
        if suffix not in {".png", ".jpg", ".jpeg"}:
            raise ValueError("out_image must end in .png, .jpg, or .jpeg")
        resolved_dpi = int(dpi)
        if resolved_dpi <= 0:
            raise ValueError("dpi must be positive")
        figure.savefig(
            path,
            dpi=resolved_dpi,
            bbox_inches="tight",
            facecolor=figure.get_facecolor(),
        )
    finally:
        plt.close(figure)

save_relative_trajectory_image(traj: np.ndarray, out_image: str | Path, *, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian relative trajectory', chief_radius_m: float = 0.0, projection: str = '3d', figsize: tuple[float, float] = (9.0, 7.0), dpi: int = 160) -> None

Save a chief-centered RIC trajectory as PNG or JPEG.

Source code in octavian/viz/matplotlib.py
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
def save_relative_trajectory_image(
    traj: np.ndarray,
    out_image: str | Path,
    *,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian relative trajectory",
    chief_radius_m: float = 0.0,
    projection: str = "3d",
    figsize: tuple[float, float] = (9.0, 7.0),
    dpi: int = 160,
) -> None:
    """Save a chief-centered RIC trajectory as PNG or JPEG."""
    figure = relative_trajectory_figure(
        traj,
        maneuvers=maneuvers,
        phase_segments=phase_segments,
        reference_trajectories=reference_trajectories,
        title=title,
        chief_radius_m=chief_radius_m,
        projection=projection,
        figsize=figsize,
    )
    save_figure_image(figure, out_image, dpi=dpi)

save_trajectory_diagnostics_image(traj: np.ndarray, out_image: str | Path, *, frame_kind: str, mu_m3ps2: float | None = None, solar_directions_ric: np.ndarray | None = None, cr3bp_system: CR3BPSystem | None = None, cr3bp_dimensional: bool = True, title: str = 'octavian trajectory diagnostics', figsize: tuple[float, float] | None = None, dpi: int = 160) -> None

Save frame-aware trajectory diagnostic panels as PNG or JPEG.

Source code in octavian/viz/matplotlib.py
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def save_trajectory_diagnostics_image(
    traj: np.ndarray,
    out_image: str | Path,
    *,
    frame_kind: str,
    mu_m3ps2: float | None = None,
    solar_directions_ric: np.ndarray | None = None,
    cr3bp_system: CR3BPSystem | None = None,
    cr3bp_dimensional: bool = True,
    title: str = "octavian trajectory diagnostics",
    figsize: tuple[float, float] | None = None,
    dpi: int = 160,
) -> None:
    """Save frame-aware trajectory diagnostic panels as PNG or JPEG."""
    figure = trajectory_diagnostics_figure(
        traj,
        frame_kind=frame_kind,
        mu_m3ps2=mu_m3ps2,
        solar_directions_ric=solar_directions_ric,
        cr3bp_system=cr3bp_system,
        cr3bp_dimensional=cr3bp_dimensional,
        title=title,
        figsize=figsize,
    )
    save_figure_image(figure, out_image, dpi=dpi)

save_trajectory_image(traj: np.ndarray, out_image: str | Path, *, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian trajectory', earth_radius_m: float = EARTH_RADIUS_M, projection: str = '3d', figsize: tuple[float, float] = (9.0, 7.0), dpi: int = 160) -> None

Save an Earth-centered trajectory as PNG or JPEG.

Source code in octavian/viz/matplotlib.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def save_trajectory_image(
    traj: np.ndarray,
    out_image: str | Path,
    *,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian trajectory",
    earth_radius_m: float = EARTH_RADIUS_M,
    projection: str = "3d",
    figsize: tuple[float, float] = (9.0, 7.0),
    dpi: int = 160,
) -> None:
    """Save an Earth-centered trajectory as PNG or JPEG."""
    figure = trajectory_figure(
        traj,
        maneuvers=maneuvers,
        phase_segments=phase_segments,
        reference_trajectories=reference_trajectories,
        title=title,
        earth_radius_m=earth_radius_m,
        projection=projection,
        figsize=figsize,
    )
    save_figure_image(figure, out_image, dpi=dpi)

show_cr3bp_trajectory(traj: np.ndarray, **kwargs: Any) -> Any

Open a barycentric-synodic trajectory with the active GUI backend.

Source code in octavian/viz/matplotlib.py
521
522
523
def show_cr3bp_trajectory(traj: np.ndarray, **kwargs: Any) -> Any:
    """Open a barycentric-synodic trajectory with the active GUI backend."""
    return show_figure(cr3bp_trajectory_figure(traj, **kwargs))

show_figure(figure: Any) -> Any

Display an existing figure with the active Matplotlib GUI backend.

Source code in octavian/viz/matplotlib.py
810
811
812
813
def show_figure(figure: Any) -> Any:
    """Display an existing figure with the active Matplotlib GUI backend."""
    _pyplot().show()
    return figure

show_relative_trajectory(traj: np.ndarray, **kwargs: Any) -> Any

Open a chief-centered RIC trajectory with the active GUI backend.

Source code in octavian/viz/matplotlib.py
516
517
518
def show_relative_trajectory(traj: np.ndarray, **kwargs: Any) -> Any:
    """Open a chief-centered RIC trajectory with the active GUI backend."""
    return show_figure(relative_trajectory_figure(traj, **kwargs))

show_trajectory(traj: np.ndarray, **kwargs: Any) -> Any

Open an Earth-centered trajectory with the active GUI backend.

Source code in octavian/viz/matplotlib.py
511
512
513
def show_trajectory(traj: np.ndarray, **kwargs: Any) -> Any:
    """Open an Earth-centered trajectory with the active GUI backend."""
    return show_figure(trajectory_figure(traj, **kwargs))

show_trajectory_diagnostics(traj: np.ndarray, **kwargs: Any) -> Any

Open frame-aware trajectory diagnostics with the active GUI backend.

Source code in octavian/viz/matplotlib.py
526
527
528
def show_trajectory_diagnostics(traj: np.ndarray, **kwargs: Any) -> Any:
    """Open frame-aware trajectory diagnostics with the active GUI backend."""
    return show_figure(trajectory_diagnostics_figure(traj, **kwargs))

trajectory_diagnostics_figure(traj: np.ndarray, *, frame_kind: str, mu_m3ps2: float | None = None, solar_directions_ric: np.ndarray | None = None, cr3bp_system: CR3BPSystem | None = None, cr3bp_dimensional: bool = True, title: str = 'octavian trajectory diagnostics', figsize: tuple[float, float] | None = None) -> Any

Build static, shared-time diagnostic panels for a trajectory frame.

Source code in octavian/viz/matplotlib.py
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
def trajectory_diagnostics_figure(
    traj: np.ndarray,
    *,
    frame_kind: str,
    mu_m3ps2: float | None = None,
    solar_directions_ric: np.ndarray | None = None,
    cr3bp_system: CR3BPSystem | None = None,
    cr3bp_dimensional: bool = True,
    title: str = "octavian trajectory diagnostics",
    figsize: tuple[float, float] | None = None,
) -> Any:
    """Build static, shared-time diagnostic panels for a trajectory frame."""
    plt = _pyplot()
    trajectory = _trajectory(traj)
    normalized_frame = str(frame_kind).strip().lower()
    if normalized_frame == "relative":
        panels = relative_diagnostic_panels(
            trajectory,
            solar_directions_ric=solar_directions_ric,
        )
    elif normalized_frame == "rotating":
        if cr3bp_system is None:
            raise ValueError("Rotating trajectory diagnostics require cr3bp_system")
        panels = cr3bp_diagnostic_panels(
            trajectory,
            system=cr3bp_system,
            dimensional=cr3bp_dimensional,
        )
    else:
        if mu_m3ps2 is None or float(mu_m3ps2) <= 0.0:
            raise ValueError("Inertial trajectory diagnostics require a positive mu_m3ps2")
        panels = inertial_diagnostic_panels(
            trajectory,
            mu_m3ps2=float(mu_m3ps2),
        )

    resolved_figsize = figsize or (10.0, max(6.0, 2.35 * len(panels)))
    figure, axes_values = plt.subplots(
        len(panels),
        1,
        sharex=True,
        figsize=resolved_figsize,
        squeeze=False,
    )
    axes_list = axes_values[:, 0]
    figure.patch.set_facecolor(_BACKGROUND_COLOR)
    time_values = trajectory[:, 6]
    for axes, panel in zip(axes_list, panels, strict=True):
        _style_2d_axes(axes)
        for series in panel.series:
            label = f"{series.name} ({series.unit})" if series.unit else series.name
            axes.plot(time_values, series.values, linewidth=1.7, label=label)
        axes.set_title(panel.title, color=_FOREGROUND_COLOR)
        axes.set_ylabel(panel.y_axis_title, color=_FOREGROUND_COLOR)
        axes.legend(
            loc="best",
            facecolor=_BACKGROUND_COLOR,
            edgecolor=_GRID_COLOR,
            labelcolor=_FOREGROUND_COLOR,
        )
    time_unit = "TU" if normalized_frame == "rotating" and not cr3bp_dimensional else "s"
    axes_list[-1].set_xlabel(f"Time ({time_unit})", color=_FOREGROUND_COLOR)
    figure.suptitle(title, color=_FOREGROUND_COLOR, fontsize=14)
    figure.tight_layout(rect=(0.0, 0.0, 1.0, 0.97))
    return figure

trajectory_figure(traj: np.ndarray, *, maneuvers: Sequence[Maneuver] | None = None, phase_segments: Sequence[dict[str, object]] | None = None, reference_trajectories: Sequence[dict[str, object]] | None = None, title: str = 'octavian trajectory', earth_radius_m: float = EARTH_RADIUS_M, projection: str = '3d', figsize: tuple[float, float] = (9.0, 7.0)) -> Any

Build a static Earth-centered inertial trajectory figure.

Args: traj: Rows [rx, ry, rz, vx, vy, vz, time] in SI units. maneuvers: Optional maneuver markers. phase_segments: Optional phase dictionaries with name, t_start_s, t_end_s, and optional color keys. reference_trajectories: Dashed overlays with traj position rows, optional name and color. Use the same ECI frame and SI units as traj; velocity and time columns are ignored. title: Figure title. earth_radius_m: Earth radius in meters. projection: "3d" or a 2D "xy", "xz", or "yz" projection. figsize: Matplotlib figure size in inches.

Returns: A :class:matplotlib.figure.Figure. Positions are displayed in km.

Source code in octavian/viz/matplotlib.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def trajectory_figure(
    traj: np.ndarray,
    *,
    maneuvers: Sequence[Maneuver] | None = None,
    phase_segments: Sequence[dict[str, object]] | None = None,
    reference_trajectories: Sequence[dict[str, object]] | None = None,
    title: str = "octavian trajectory",
    earth_radius_m: float = EARTH_RADIUS_M,
    projection: str = "3d",
    figsize: tuple[float, float] = (9.0, 7.0),
) -> Any:
    """Build a static Earth-centered inertial trajectory figure.

    Args:
        traj: Rows ``[rx, ry, rz, vx, vy, vz, time]`` in SI units.
        maneuvers: Optional maneuver markers.
        phase_segments: Optional phase dictionaries with ``name``,
            ``t_start_s``, ``t_end_s``, and optional ``color`` keys.
        reference_trajectories: Dashed overlays with ``traj`` position rows,
            optional ``name`` and ``color``. Use the same ECI frame and SI
            units as ``traj``; velocity and time columns are ignored.
        title: Figure title.
        earth_radius_m: Earth radius in meters.
        projection: ``"3d"`` or a 2D ``"xy"``, ``"xz"``, or ``"yz"``
            projection.
        figsize: Matplotlib figure size in inches.

    Returns:
        A :class:`matplotlib.figure.Figure`. Positions are displayed in km.
    """
    plt = _pyplot()
    trajectory = _trajectory(traj)
    earth_radius = float(earth_radius_m)
    if not np.isfinite(earth_radius) or earth_radius <= 0.0:
        raise ValueError("earth_radius_m must be finite and positive")

    position_indices = _projection_indices(projection)
    positions_km = trajectory[:, 0:3] / 1_000.0
    time_s = trajectory[:, 6]
    figure, axes = _trajectory_axes(plt, figsize, title, position_indices)
    earth_radius_km = earth_radius / 1_000.0
    _plot_spherical_body(
        plt,
        axes,
        np.zeros(3),
        earth_radius_km,
        position_indices,
        color="#3B82F6",
        alpha=0.7,
        label="Earth",
    )
    axes.plot(
        *(positions_km[:, index] for index in position_indices),
        color=_TRAJECTORY_COLOR,
        linewidth=2.2,
        label="Trajectory",
    )
    _plot_phase_segments(
        axes,
        positions_km,
        time_s,
        phase_segments,
        position_indices,
    )
    _plot_reference_trajectories(axes, reference_trajectories, position_indices, scale=0.001)
    _scatter_position(
        axes,
        positions_km[0],
        position_indices,
        color="#69DB7C",
        s=40,
        label="Start",
    )
    _scatter_position(
        axes,
        positions_km[-1],
        position_indices,
        color="#FFD43B",
        s=40,
        label="End",
    )
    for index, maneuver in enumerate(maneuvers or (), start=1):
        position_km = np.asarray(maneuver.r_m, dtype=float).reshape(3) / 1_000.0
        _scatter_position(
            axes,
            position_km,
            position_indices,
            color="#FF6B6B",
            marker="D",
            s=48,
            label=f"M{index}: {maneuver.name}",
        )

    _set_position_axis_labels(
        axes,
        ("ECI X (km)", "ECI Y (km)", "ECI Z (km)"),
        position_indices,
    )
    _finish_trajectory_figure(figure, axes, position_indices)
    return figure

octavian.study

Study helpers for repeatable parameter sweeps.

best_by(results: Iterable[RendezvousResult], key: str = 'total_dv_mps', *, require_converged: bool = True) -> RendezvousResult

Select the best result in a study by a named metric.

Args: results: Results to compare. key: Metric name. Supported values are "total_dv_mps", "tf_s", and "last_obj". require_converged: Whether to discard non-converged results before ranking.

Returns: The best result under the requested metric.

Raises: ValueError: If no results remain after filtering or the metric name is unsupported.

Source code in octavian/study.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def best_by(
    results: Iterable[RendezvousResult],
    key: str = "total_dv_mps",
    *,
    require_converged: bool = True,
) -> RendezvousResult:
    """Select the best result in a study by a named metric.

    Args:
        results: Results to compare.
        key: Metric name. Supported values are ``"total_dv_mps"``, ``"tf_s"``,
            and ``"last_obj"``.
        require_converged: Whether to discard non-converged results before
            ranking.

    Returns:
        The best result under the requested metric.

    Raises:
        ValueError: If no results remain after filtering or the metric name is
            unsupported.
    """
    candidate_results = [
        result for result in results if (result.converged or not require_converged)
    ]
    if not candidate_results:
        raise ValueError("No results available after filtering.")
    if key == "total_dv_mps":
        return min(candidate_results, key=lambda result: result.total_dv_mps())
    if key == "tf_s":
        return min(candidate_results, key=lambda result: result.tf_s())
    if key == "last_obj":
        return min(candidate_results, key=lambda result: float(result.last_obj))
    raise ValueError(f"Unsupported key: {key!r}.")

grid(base_spec: Spec, overrides: Sequence[dict[str, Any]], *, options: SolverOptions | None = None, save_dir: str | Path | None = None, save_prefix: str = 'run') -> list[RendezvousResult]

Run a grid study by applying overrides to a base spec.

Args: base_spec: Baseline problem specification. overrides: Override dictionaries passed to dataclasses.replace(base_spec, **override). options: Shared solver options. If omitted, defaults are used. save_dir: Optional directory where each result is saved as .npz and companion JSON. save_prefix: Prefix for output filenames.

Returns: Results in the same order as overrides.

Source code in octavian/study.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def grid(
    base_spec: Spec,
    overrides: Sequence[dict[str, Any]],
    *,
    options: SolverOptions | None = None,
    save_dir: str | Path | None = None,
    save_prefix: str = "run",
) -> list[RendezvousResult]:
    """Run a grid study by applying overrides to a base spec.

    Args:
        base_spec: Baseline problem specification.
        overrides: Override dictionaries passed to
            ``dataclasses.replace(base_spec, **override)``.
        options: Shared solver options. If omitted, defaults are used.
        save_dir: Optional directory where each result is saved as ``.npz`` and
            companion JSON.
        save_prefix: Prefix for output filenames.

    Returns:
        Results in the same order as ``overrides``.
    """
    solver_options = options or SolverOptions()
    output_dir = Path(save_dir).expanduser().resolve() if save_dir is not None else None
    if output_dir is not None:
        output_dir.mkdir(parents=True, exist_ok=True)

    results: list[RendezvousResult] = []
    for override_index, override_values in enumerate(overrides):
        sweep_spec = replace(base_spec, **override_values)
        result = solve(sweep_spec, options=solver_options)
        result.info.setdefault("study_index", override_index)
        result.info.setdefault("overrides", dict(override_values))
        results.append(result)

        if output_dir is not None:
            stem = f"{save_prefix}_{override_index:04d}"
            result.to_npz(output_dir / f"{stem}.npz")
            (output_dir / f"{stem}.json").write_text(result.to_json(indent=2))

    return results