Skip to content

ADR-085 — Geocentric Earth→Moon porkchop on /plan

Status · Accepted (implemented + Fable-5-reviewed 2026-08-28 — see §Implementation & review notes for deviations from the proposal) Date · 2026-08-28 Gates · #308 (geocentric Lambert porkchop — Moon) Builds on · ADR-026 / ADR-028 (multi-destination heliocentric porkchop), ADR-022 / RFC-003 (Lambert worker protocol), ADR-006/017 (data client + locale overlay)

Gating sentence: /plan gains the Moon as a destination via a second, geocentric Lambert path that reuses the solver solveLambert(r1, r2, tof, mu) with μ_Earth (in km/s), plus one small solver change — an aMax parameter (default AU-scale, so the 9 heliocentric grids stay byte-identical) — and the app's existing astronomy/moon.ts#geocentricMoon ephemeris (AU→km); destinations become a discriminated union on kind: 'helio' | 'geo' so the grid + precompute pick the right central body; each cell is shaded by total TLI + LOI ∆v, where TLI is a geocentric departure burn from a circular LEO and LOI is a patched-conic insertion at the Moon (via v∞ and μ_Moon) into a circular low-lunar orbit — the two impulses broken out in the readout; the TOF axis is [2.5, 5.5 d] (the short-way solver's feasible band), amending #308's original [3,14 d]; the existing 9 heliocentric destinations are untouched. Planetocentric moons of the giants (Europa/Titan/…) are explicitly deferred to a follow-up (same tag, three-body sequencing).

Context

/plan's porkchop is heliocentric-only: every destination plugs into lambert-grid.constants.ts#DESTINATIONS with a heliocentric semi-major axis, and computeDv (in lambert-grid.ts) solves a Sun-centered Lambert (MU_SUN, AU/yr units) between Earth's circular-orbit position and the destination's. The Moon has no heliocentric orbit — it orbits Earth — so "plan a trip to the Moon," one of the most natural /plan requests, silently isn't offered. static/data/porkchop/earth-to-moon.json does not exist.

Two facts make a second path tractable (verified in code):

  1. The Lambert solver is μ-parameterizedsolveLambert(r1, r2, tof, mu) takes mu as a parameter (src/lib/lambert.ts:51). A geocentric sibling computeDvGeo reuses it. Caveat (found in Fable-5 ADR review): the solver is μ-agnostic but not scale-agnostic — its bisection ceiling aHi = 200.0 (lambert.ts:67) is an AU-scale bound; an Earth–Moon transfer needs a ≈ 1.8–3.5 × 10⁵ km, so without a larger ceiling every geocentric cell returns null. Hence the aMax parameter (D5). All other epsilons are relatively smaller in km and stay valid.
  2. The Moon ephemeris already existsastronomy/moon.ts#geocentricMoon(jd): MoonPos (Schlyter/Brown analytic, ~1–2 arcmin, J2000 ecliptic, AU), the same model /explore uses. No new ephemeris data.

Decision

D1 — Tagged destination kind, one code path (not a parallel module)

Make DestinationConstants a discriminated union on kind (per the Fable-5 review — avoids a Moon entry carrying meaningless heliocentric a/a0/meanMotionRadPerDay fields): { kind: 'helio'; a; a0; meanMotionRadPerDay; e? } | { kind: 'geo' } — the 9 existing entries become explicit kind: 'helio' (their shape unchanged); the geo constants (μ_Earth, μ_Moon, R_LEO, R_LLO, epoch, the geocentricMoon adapter) live in a new lambert-geocentric-grid.constants.ts. The grid driver branches on kind: 'helio' → today's computeDv (μ_Sun, AU/yr); 'geo' → new computeDvGeo (μ_Earth, km/s). Rejected: a fully parallel duplicated module (drifts; the deferred planetocentric-moons work would then need a third copy — the tag generalizes, a fork does not).

D2 — The geocentric transfer model (2D-ecliptic patched conic)

Consistent with the heliocentric porkchop, which is itself a 2D ecliptic-plane circular-orbit approximation. solveLambert returns scalar transfer speeds v1, v2 (km/s) at the two endpoints (lambert.ts:18-25). For each (departure_day, tof) cell:

  • r2 = geocentricMoon(jd_arrival) position (AU) × AU_TO_KM = 1.495978707×10⁸, projected to the 2D ecliptic plane (x, y in km). jd_arrival maps from depDay + tof against the same epoch the heliocentric grid's L0 uses (D5).
  • r1 = a point at LEO radius R_LEO = 6578 km (200 km circular parking orbit), fixed antipodal to the arrival direction (−r̂2 · R_LEO) — the perigee-opposite-apogee geometry of a trans-lunar coast. The Fable-5 review confirmed the ∆v is second-order-insensitive to r1's direction (R_LEO ≪ |r2|; a 90° swing moves v1 < 1%), and antipodal makes r1 the exact perigee at the min-∆v cell, where the scalar-difference ∆v below is exact.
  • Solve solveLambert(r1, r2, tof_seconds, MU_EARTH, aMax_geo) with TOF in seconds (tofDay × 86400), MU_EARTH = 398600.4418 km³/s², aMax_geo = 2×10⁶ km (D5).
  • TLI = v1 − v_LEO_circ, both geocentric (v_LEO_circ = √(μ_Earth/R_LEO) = 7.784 km/s). Departure burn from the circular parking orbit.
  • LOI (patched-conic, corrected per Fable-5 B1). v1/v2 are geocentric speeds; a low-lunar orbit is selenocentric, so LOI must cross the lunar SOI, not subtract mismatched frames:
    • v∞ = | v_Moon − v2 | — the hyperbolic-excess speed relative to the Moon, where v_Moon is the Moon's geocentric orbital speed at arrival (finite-difference geocentricMoon at jd ± Δ). ≈ 0.83–0.88 km/s.
    • LOI = √(v∞² + 2·μ_Moon/R_LLO) − √(μ_Moon/R_LLO), with μ_Moon = 4902.8 km³/s² and R_LLO = 1837 km (≈ 100 km altitude). ≈ 0.82–0.84 km/s.
  • Cell ∆v = TLI + LOI (shaded with the geo path's own min/max range ~3.9–4.3 km/s — not the heliocentric max(3.2, …) clamp; D5); the readout shows the TLI/LOI split.

TOF axis = [3, 14 d] (#308's full original band; the [2.5, 5.5 d] narrowing in the first cut is superseded — see the 2026-08-28 amendment below). With the antipodal-r1 (≈180°) geometry there are two Lambert branches on the same conic: the low branch (α as computed) covers the fast side from the parabolic floor (~2.1 d) up to the minimum-energy ellipse (a = s/2, α = π, ~5 d at mean distance), monotonically decreasing in a; the high branch (α → 2π − α) covers the slow/phasing transfers above it, monotonically increasing in a out to 14 d. geoTransferDv solves the low branch first (every fast cell stays byte-identical) and falls back to the high branch, so there are no more false-"unreachable" cells above 5 d. The 180° geometry makes β = 0, so no long-way/short-way distinction is needed — only the α branch. Departure axis [0, 365 d] at ≥150 columns to resolve the ~27.55-d perigee-distance stripe the geocentricMoon eccentricity produces (a real, teachable effect).

Honesty bar (validation). The model is a teaching approximation, not a trajectory tool — it ignores the Moon's 5° inclination (real missions launch into the transfer plane, so there's no plane-change burn to model — the 2D projection is honest, ≤0.4% on |r2|) and lunar-SOI fine structure. It is honest iff it lands on the real numbers: TLI ≈ 3.05–3.15 km/s, LOI ≈ 0.8–0.9 km/s, total ≈ 3.9–4.05 (Apollo LEO→LLO). Unit tests assert the minimum-∆v cell falls in those bands (Fable-5 back-of-envelope: TLI ≈ 3.13, LOI ≈ 0.82–0.84 ✓); if it doesn't, the model — not the test — is wrong (PA §"fail honestly", §"real physics not approximations that mislead"). The /plan caption states it's a representative two-body estimate; phasing/launch-window geometry is not modeled.

D5 — Solver + units changes (minimal, back-compat)

  • solveLambert gains an optional aMax parameter, default 200.0 (AU-scale) — the 9 heliocentric grids call it unchanged and stay byte-identical; the geo path passes 2×10⁶ (km). This is the only change to the shared solver.
  • Units are isolated inside each computeDv*: heliocentric stays AU + years + μ(AU³/yr²); geocentric uses km + seconds + μ(km³/s²). Both emit km/s into the grid. The days→seconds conversion (×86400) is explicit at the geo call site — the one seam a silent days-vs-seconds bug would hide.
  • Epoch: depDay/arrDay map to JD against the same epoch anchoring the heliocentric L0; pinned in lambert-geocentric-grid.constants.ts so the two grids share a clock.
  • geocentricMoon adapter converts its AU output to km (× AU_TO_KM) in the geo constants module.

D3 — Ephemeris source: reuse geocentricMoon (not a new JPL sample)

The precompute samples the app's existing astronomy/moon.ts#geocentricMoon. Rejected: sampling JPL DE-440 into a new data file — higher fidelity than a 2D circular-orbit porkchop can use, and it introduces a second lunar-ephemeris source to keep in sync with /explore.

D4 — Precompute + delivery, unchanged pipeline shape

scripts/precompute-porkchops.ts gains an earth-to-moon spec (its own axis ranges) and emits static/data/porkchop/earth-to-moon.json — the same per-destination JSON contract /plan already loads. No new runtime path; the grid is precomputed at build like the other 9.

Consequences

Positive

  • The single most-requested /plan destination is offered, honestly modeled and validated against Apollo.
  • The kind tag is the seam the deferred planetocentric-moons work (Europa/Titan) extends — no rework.
  • Zero change to the 9 heliocentric destinations; the solver, worker protocol, and JSON contract are reused.

Negative / accepted

  • A second ∆v semantics (TLI+LOI vs heliocentric total) means /plan must label which model a destination uses, and the geo grid needs its own colour range (~3.9–4.3) — small UI/caption complexity.
  • The shared solveLambert gains one parameter (aMax) — a minimal, default-preserving touch to a load-bearing function; the 9 heliocentric grids stay byte-identical (asserted by their existing tests).
  • The shared solveLambert gains an opt-in highPath branch — the second, default-preserving touch: the low branch is untouched (helio grids byte-identical, asserted by tests), the high branch (α → 2π − α) is only reached via { highPath: true }. This is what lets the geo grid cover the full [3, 14 d] band honestly instead of the narrowed [2.5, 5.5 d] first cut.
  • The 2D-ecliptic circular approximation understates lunar-SOI fine structure; mitigated by the honest caption + the validation bands (we ship the number only if it matches Apollo reality).

Neutral

  • Units differ per path (AU/yr heliocentric, km/s geocentric); isolated inside each computeDv*, both output km/s for the grid.

Alternatives considered

  • Parallel duplicated module (D1 alt) — rejected: drift + a third copy for moons.
  • Single-impulse (LEO-departure ∆v only), no LOI — rejected: dishonest for the Apollo/Artemis context the issue names; LOI is half the story.
  • Full 3D inclined patched-conic through the lunar SOI — rejected for v1: correct but far beyond a teaching porkchop's fidelity budget and the 2D heliocentric model it sits beside; revisit only if /fly needs it.
  • JPL DE-440 sampled ephemeris (D3 alt) — rejected: over-fidelity + a second lunar source.

Implementation & review notes (2026-08-28)

Built + browser-verified; a post-implementation Fable-5 review caught one physics blocker and two integration bugs (all fixed). Deviations from the proposal above, recorded per the anti-drift rule:

  • D1 — no discriminated union. /plan loads porkchops as static JSON (getPorkchopGridearth-to-{id}.json); the live Lambert worker is unused by any route. So the Moon needs no shared-worker kind dispatch — it is a PlanDestinationId = DestinationId | 'moon' string union (the Moon is deliberately absent from the heliocentric DESTINATIONS, matching the issue's premise), and the grid is built entirely in precompute-porkchops.ts via geoTransferDv. Simpler than the proposed union; the heliocentric path is untouched.
  • D2 — LOI uses a VECTOR v∞, not scalar (review fix A1). The proposal's scalar |v2 − vMoon| (mirroring the heliocentric arrival ∆v) is wrong here: it inverts the TOF gradient (fast arrivals carry a large radial v∞ the scalar ignores, so it made fast transfers look cheapest — teaching "faster is cheaper", which is false). The 180° antipodal geometry gives an exact semi-latus-rectum p = 2·r1·r2/(r1+r2); decompose the arrival velocity into tangential vt2 = √(µ_Earth·p)/r2 + radial vr2 = √(v2² − vt2²), then v∞ = hypot(vr2, vt2 − vMoon). Result: fast = expensive, near-Hohmann = cheap; min-total ≈ 3.94 km/s (Apollo band). TLI stays scalar (the transfer is ≥99.9% tangential at LEO). The honesty test now anchors on the min-total cell (the one the UI shades cheapest) + a gradient regression guard.
  • Colour range is [3.84, 4.38] km/s (was estimated ~3.9–4.3), recomputed from the corrected grid.
  • /plan↔/fly consistency (review fixes A2/A3). Moon dep/tof are URL-encoded fractional (grid rows are ~0.1 d apart on the [3, 14 d] band; integer rounding snapped to the wrong transfer). The /fly handoff (applyPlanSelectionMoon) sets simSpeed = 0.4 (a few-day cislunar flight would otherwise play in <1 s at the 7 d/s default) and builds the cislunar trajectory with lunar_arrival: 'orbit' so a one-way LANDING arrives at the Moon rather than a mid-mission flyby.
  • Caption: the readout shows from LEO (14 locales); the fuller "representative two-body estimate" provenance lives in the grid's credit field.

Amendment — full [3, 14 d] TOF band via the high branch (2026-08-28)

The first cut narrowed the TOF axis to [2.5, 5.5 d] and deferred slow transfers as "solver surgery." That deferral is now closed — the full [3, 14 d] band ships. The surgery turned out small because the antipodal (180°) geometry gives β = 0, so there is no long-way/short-way split; only the α branch matters. lambertTOF and solveLambert gained an opt-in highPath flag (α → 2π − α; the high-branch TOF is monotonically increasing in a, so solveLambert flips its bisection direction). geoTransferDv tries the low branch, then the high branch. The LOI decomposition is unchanged and correct on both branches: p = 2·r1·r2/(r1+r2) is exact for any 180° transfer (from r1(1+e·cos ν1) = r2(1−e·cos ν1) ⇒ p is independent of a), so the tangential/radial arrival split holds throughout. Verified: cheapest cell at ~5 d (near-Hohmann, still in the Apollo band), V-shaped gradient (fast and slow cost more), continuous low↔high handoff — with round-trip + monotonicity + continuity tests added. Only earth-to-moon.json changed on recompute; the 12 heliocentric grids stayed byte-identical.

Orrery — architecture documentation · MIT · No tracking