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():

  1. 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 into escape_delta_v() which applies a hyperbolic excess formula on top, double-counting the velocity change.

  2. 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.

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.

  1. Apply instantaneous burn: v += burn_dv
  2. For each step: leapfrog position+velocity update, gravity from all bodies via ephemeris interpolation
  3. 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_departure departure times over n_orbits orbital 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"
    1. scan_departure_windows(optimize_for=optimize_for) → sorted candidates
    2. For top n_verify (default 3): build_body_ephemeris() + forward_integrate() to verify SOI entry
    3. Best candidate selection depends on optimize_for:
    • "dv": lowest total_dv among verified (or unverified fallback)
    • "tof": lowest tof among 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 have tof < 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" mode
  • optimize_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

Back to top

Galaxy — Kubernetes-based multiplayer space game

This site uses Just the Docs, a documentation theme for Jekyll.