Trajectory Planning: Forward-Integrated Cross-SOI Transfers
Issue: #653
Problem
Cross-SOI transfer Δv estimates are ~35% too high (Earth→Luna: 4,505 vs 3,130 m/s real-world). Two root causes in _compute_cross_soi_plan():
-
Bug: When
ref_body == common(e.g., Earth→Luna where common=Earth), the Lambert solution already gives the departure Δv directly (ship position is in the parent frame). But the code feeds this Δv intoescape_delta_v()which applies a hyperbolic excess formula on top, double-counting the velocity change. -
No departure window search: A single Lambert solve at current geometry with Hohmann TOF. Plane mismatch inflates out-of-plane Δv.
Solution
Fix 1: escape_delta_v Bypass Rule
When ref_body_name == common, the Lambert problem is solved directly in the parent frame from the ship’s actual position. The Lambert Δv is the departure Δv — no escape maneuver from a parking orbit is needed.
if ref_body == common:
dv_escape = v_inf_dep # Lambert Δv is the actual departure Δv
else:
dv_escape = escape_delta_v(r_park, v_inf_dep, mu_body)
if target_ref == common:
dv_capture = v_inf_arr # Lambert Δv is the actual capture Δv
else:
dv_capture = escape_delta_v(r_park_arr, v_inf_arr, mu_target)
This alone brings Earth→Luna from ~4,505 to ~3,100-3,200 m/s.
Fix 2: Departure Window Search
Scan departure timing × TOF grid for optimal Lambert solution, then forward-integrate top candidates under multi-body gravity to verify SOI entry.
Module: trajectory_planner.py
Pure-math module (no async/Redis/gRPC), same pattern as qlaw.py. All pure Python (no NumPy).
Reused Functions (imported)
| Function | Source | Purpose |
|---|---|---|
kepler_propagate() |
qlaw.py |
Propagate position/velocity under Keplerian gravity |
lambert_solve() |
qlaw.py |
Solve Lambert’s problem |
hohmann_transfer_time() |
qlaw.py |
Estimate transfer time |
find_common_parent() |
automation_orbital.py |
Find lowest common ancestor in body hierarchy |
soi_chain() |
automation_orbital.py |
SOI transition sequence |
compute_soi_radius() |
automation_orbital.py |
Hill sphere radius |
BODY_PARENTS |
galaxy_config |
Body hierarchy |
Function Specifications
build_body_ephemeris(body_names, body_data, dt_step, t_span)
Pre-compute body positions on a time grid via hierarchical Kepler propagation (parent first, then child relative to parent).
- Input: body names, body_data dict (positions, velocities, masses), time step, total time span
- Output:
dict[name → list[(pos, vel)]]— position/velocity at each grid point - Sort bodies by hierarchy level (Sun → planets → moons)
- For each grid time: Kepler-propagate each body around its parent
- Two-level hierarchy sufficient (moon→planet→Sun)
_interp_ephemeris(ephemeris, body_name, t, dt_step)
O(1) linear interpolation of body position/velocity from pre-computed grid.
- Clamps to grid boundaries (no extrapolation)
- Returns
(pos, vel)tuple
forward_integrate(ship_pos, ship_vel, burn_dv, body_names, ephemeris, dt_ephem, dt_integ, duration, target_body, target_soi, mu_bodies)
Velocity-Verlet (leapfrog) integration of ship trajectory under multi-body gravity.
- Apply instantaneous burn:
v += burn_dv - For each step: leapfrog position+velocity update, gravity from all bodies via ephemeris interpolation
- Track minimum distance to target, detect SOI entry
Returns: {min_distance, closest_time, soi_entry_time, soi_entry_pos, soi_entry_vel, final_pos, final_vel}
Integration step size scaling:
dt_step = max(60, min(3600, duration / 5000)) # cap at 5000 steps
- Earth→Luna (5d): 300s step, 1,440 steps
- Earth→Mars (180d): 3600s step, 4,320 steps
scan_departure_windows(ship_pos, ship_vel, ref_body, target_body, common, body_data, n_tof, n_departure, n_orbits, optimize_for)
Lambert grid search over departure timing × TOF:
- Defaults:
n_tof=5,n_departure=20,n_orbits=2.0,optimize_for="dv" - Sample
n_departuredeparture times overn_orbitsorbital periods - TOF multipliers depend on
optimize_for:"dv"(default):[0.7, 0.85, 1.0, 1.2, 1.5] × Hohmann TOF"tof":[0.3, 0.4, 0.5, 0.6, 0.7] × Hohmann TOF(aggressive, sub-Hohmann)
- For each: Kepler-propagate ship+target, Lambert solve, compute Δv
- Apply escape_delta_v bypass rule (ref_body == common → direct Δv)
- Sort order depends on
optimize_for:"dv": sorted by total Δv (ascending) — minimum energy"tof": sorted by TOF (ascending) — minimum transfer time
Performance: 100 Lambert solves × ~1ms each ≈ 100ms
_relevant_bodies(ref_body, target_ref, body_data)
Determine gravitationally significant bodies for forward integration.
Returns: Sun + departure body + target body + their parents. Filtered to bodies present in body_data. Typically 3-5 bodies.
plan_transfer(ship_pos, ship_vel, ref_body, target_ref, body_data, n_verify, optimize_for)
Main entry point:
- Defaults:
n_verify=3,optimize_for="dv"scan_departure_windows(optimize_for=optimize_for)→ sorted candidates- For top
n_verify(default 3):build_body_ephemeris()+forward_integrate()to verify SOI entry - Best candidate selection depends on
optimize_for:
"dv": lowesttotal_dvamong verified (or unverified fallback)"tof": lowesttofamong verified (or unverified fallback)
Returns: {departure_dv, departure_dv_vec, v_inf_dep, v_inf_arr, capture_dv, tof, departure_offset, verified}
Total performance: ~200-400ms (well within 2s budget)
Integration into _compute_cross_soi_plan()
from .trajectory_planner import plan_transfer
# In _compute_cross_soi_plan():
plan = plan_transfer(ship_pos, ship_vel, ref_body_name, target_ref, body_positions)
if plan is not None:
# Use planner results for dv_escape, dv_capture, tof, v_inf vectors
maneuver["planner_verified"] = str(plan["verified"])
maneuver["planner_departure_offset"] = str(round(plan["departure_offset"]))
else:
# Fall back to current Lambert approach (with escape_delta_v bypass fix)
New Maneuver Fields
| Field | Type | Description |
|---|---|---|
planner_verified |
string (“True”/”False”) | Whether forward integration confirmed SOI entry |
planner_departure_offset |
string (seconds) | Optimal departure delay from current time |
Test Cases
TestBuildBodyEphemeris (3 tests)
- Grid spacing produces correct number of entries
- Hierarchical moon propagation (moon position = planet pos + relative propagation)
- Missing body in body_data is handled gracefully (skipped)
TestInterpEphemeris (3 tests)
- Exact grid point returns that grid point’s data
- Midpoint interpolation returns average of neighbors
- Out-of-range time clamps to boundary
TestForwardIntegrate (4 tests)
- Circular orbit conservation (radius stable over 1 orbit)
- Energy conservation (specific energy within 1% over 1 orbit)
- SOI detection (ship entering target SOI triggers entry detection)
- Escape trajectory (ship on hyperbolic orbit moves away)
TestScanDepartureWindows (5 tests)
- Finds better Δv than single Lambert at current geometry
- Includes current geometry (departure_offset=0) in search
- Handles Lambert failures gracefully (returns remaining valid solutions)
optimize_for="tof"uses sub-Hohmann TOF multipliers (all candidates havetof < hohmann_tof)optimize_for="tof"returns candidates sorted by TOF ascending
TestPlanTransfer (6 tests)
- Earth→Luna departure Δv < 4,000 m/s (vs current 4,505)
- Earth→Mars produces reasonable Δv
- Fallback on failure returns None
- Verified flag set correctly when forward integration confirms SOI entry
optimize_for="tof"produces shorter TOF than default"dv"modeoptimize_for="tof"produces equal or higher Δv than default"dv"mode
TestEscapeDvBypass (2 tests)
- ref_body==common → departure Δv equals Lambert Δv directly
- ref_body≠common → departure Δv uses escape_delta_v() formula