Files
orbital-test/soi_calculator.py
Lucy 655f1c9af6 Initial commit: Keplerian orbital mechanics with patched-conics SOI transitions
- orbital_elements.py: elliptical + hyperbolic orbit support
- orbit_drawer.py: orbit point generation with SOI truncation
- soi_calculator.py: SOI crossing time calculator
- frame_transition.py: reference frame switching
- test_orbital.py: 147 assertions, all passing
- visual_test.py: pygame flyby visualization
2026-05-21 20:31:17 +02:00

198 lines
6.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
SOI (Sphere of Influence) crossing-time calculator.
Given two bodies in Keplerian orbits around the same parent,
find all times when the distance between them equals a given
SOI radius — i.e., when one body enters or exits the other's
gravitational sphere of influence.
Algorithm:
1. Quick radial-overlap check (rejects impossible cases).
2. Coarse time-scan across the search window.
3. Newton-Raphson refinement of each sign-change bracket.
"""
import math
from typing import List, Optional, Tuple
from orbital_elements import OrbitalElements
def find_soi_crossings(
body: OrbitalElements,
planet: OrbitalElements,
soi_radius: float,
window_start: float,
window_end: float,
n_steps: int = 500,
) -> List[Tuple[float, float]]:
"""
Find all (enter_time, exit_time) SOI crossing pairs.
Parameters
----------
body, planet : OrbitalElements
Two bodies orbiting the same central body (same mu).
soi_radius : float
Radius of the planet's sphere of influence.
window_start, window_end : float
Search window in simulation time.
n_steps : int
Number of coarse-scan steps (default 500).
Returns
-------
List of (enter, exit) time pairs, sorted chronologically.
Empty list if no crossings found.
"""
# ── Quick rejection: orbital radius ranges must overlap ──
b_min, b_max = body.radius_range()
p_min, p_max = planet.radius_range()
if b_max + soi_radius < p_min or p_max + soi_radius < b_min:
return [] # orbits never come close enough
# ── Coarse time scan ────────────────────────────────────
duration = window_end - window_start
dt = duration / n_steps
crossings: List[Tuple[float, float]] = []
prev_dist: Optional[float] = None
enter_bracket: Optional[Tuple[float, float]] = None
for i in range(n_steps + 1):
t = window_start + i * dt
bx, by = body.position_at(t)
px, py = planet.position_at(t)
dist = math.hypot(bx - px, by - py)
if prev_dist is not None:
prev_t = t - dt
was_outside = prev_dist > soi_radius
is_inside = dist <= soi_radius
if was_outside and is_inside:
# ── Entering SOI — save narrow bracket ──
enter_bracket = (prev_t, t)
elif not was_outside and not is_inside and enter_bracket is not None:
# ── Exiting SOI — refine both crossings ──
exit_bracket = (prev_t, t)
enter_t = _refine(
body=body, planet=planet, soi_radius=soi_radius,
bracket=enter_bracket, entering=True,
)
exit_t = _refine(
body=body, planet=planet, soi_radius=soi_radius,
bracket=exit_bracket, entering=False,
)
crossings.append((enter_t, exit_t))
enter_bracket = None
prev_dist = dist
# ── Still inside SOI at window end? ─────────────────────
if enter_bracket is not None:
t_end = window_end
exit_bracket = (window_end - dt, window_end)
enter_t = _refine(
body=body, planet=planet, soi_radius=soi_radius,
bracket=enter_bracket, entering=True,
)
exit_t = _refine(
body=body, planet=planet, soi_radius=soi_radius,
bracket=exit_bracket, entering=False,
)
crossings.append((enter_t, exit_t))
return crossings
# ── Newton-Raphson refinement ───────────────────────────────────
def _refine(
body: OrbitalElements,
planet: OrbitalElements,
soi_radius: float,
bracket: Tuple[float, float],
entering: bool,
) -> float:
"""
Newton-Raphson refinement of an SOI crossing time.
Solves |r_body(t) r_planet(t)| soi_radius = 0.
Parameters
----------
bracket : (t_low, t_high)
Narrow bracket around the crossing (ideally one scan-step wide).
entering : bool
True → distance *drops* to SOI (outer → inner).
False → distance *rises* to SOI (inner → outer).
"""
t_low, t_high = bracket
# Start from the side closest to the expected root.
# For entering: outside edge (t_low, where dist > soi).
# For exiting: outside edge (t_high, where dist > soi).
t = t_low if entering else t_high
newton_tol = 1e-8
max_iter = 30
for _ in range(max_iter):
bx, by, bvx, bvy = body.compute_state_at(t)
px, py, pvx, pvy = planet.compute_state_at(t)
dx = bx - px
dy = by - py
dvx = bvx - pvx
dvy = bvy - pvy
dist = math.hypot(dx, dy)
f_val = dist - soi_radius
if dist < 1e-14:
break # co-located — degenerate case
fprime = (dx * dvx + dy * dvy) / dist
if abs(fprime) < 1e-14:
break # stationary w.r.t. SOI boundary
dt_correction = -f_val / fprime
t += dt_correction
# Stay inside bracket
t = max(t_low, min(t_high, t))
if abs(dt_correction) < newton_tol:
break
return t
# ── Convenience: next SOI event ──────────────────────────────────
def find_next_soi_event(
body: OrbitalElements,
planet: OrbitalElements,
soi_radius: float,
from_time: float,
max_orbits: int = 3,
) -> Optional[Tuple[float, float]]:
"""
Find the first (enter, exit) SOI crossing after *from_time*.
Searches *max_orbits* × the longer of the two orbital periods.
Returns None if no crossing found in that window.
"""
window = max_orbits * max(body.period, planet.period)
crossings = find_soi_crossings(
body, planet, soi_radius, from_time, from_time + window,
)
return crossings[0] if crossings else None