ADR-030 — /fly trajectory math: pure-function isolation + per-mission validation
Status · Accepted Date · 2026-05-02 Closes · Phase 4 of the multi-phase polish plan TA anchor · §components/fly-screen · §components/test-infra Related ADRs · ADR-009 (free-return scenario), ADR-010 (Keplerian half-ellipses), ADR-015 (Vitest+Playwright), ADR-027 (mission flight params)
Context
Through v0.1.x the /fly trajectory math grew incrementally inside src/routes/fly/+page.svelte: vis-viva for heliocentric speed, Euclidean distance helpers, signal-delay light-minute calc, MET derivations, Moon-mode Bezier curves, ∆v ledger arithmetic. All visible to the renderer, all invisible to vitest. Issue #31 added rich golden values (peak heliocentric speed, V∞, total ∆v) for 21 measured missions but nothing in the test suite asserted that the visualization matches those values.
This ADR locks the pure-function boundary + the per-mission validation harness shape.
Decision
Pure-function boundary
src/lib/fly-physics.ts is the authoritative source for every numeric formula consumed by /fly's render loop. The +page.svelte file imports + composes; it does not own the math.
Functions extracted (v0.2.0):
| Function | Role | Source formula |
|---|---|---|
heliocentricSpeed(rAu, aTransferAu) | Vis-viva on transfer ellipse | √(µ·(2/r − 1/a)) × AU/yr→km/s |
distanceBetween(a, b) | Heliocentric Euclidean distance | hypot(ax−bx, az−bz) |
auToKm(au) / auToMkm(au) | Unit conversion | au × 149_597_870.7 |
signalDelayMin(distAu) | One-way light-minute delay | (distAu × AU_TO_KM) / c / 60 |
missionElapsedDays(simDay, depDay, arrDay, totalMissionDays) | MET from arc progress | linear interp + clamp |
dvRemaining(total, used) | ∆v ledger | max(0, total − used) |
moonPositionAtMet(metDays) | Moon orbital position in scene coords | (cos θ, sin θ) × MOON_VISUAL_DISTANCE |
moonOutboundArc(moonPos, steps) | Earth → Moon Bezier | quadratic Bezier with perpendicular control offset |
moonReturnArc(moonPos, steps) | Moon → Earth Bezier | mirrored Bezier with opposite offset sign |
Constants centralised in src/lib/fly-physics-constants.ts:
MU_SUN_AU3_YR2,AU_TO_KM,AU_TO_LMIN,AU_PER_YR_TO_KMSC_LIGHT_KM_S,MOON_ORBITAL_PERIOD_DAYS,MOON_VISUAL_DISTANCEMOON_BEZIER_CTRL_OFFSET
Validation harness
src/lib/fly-physics-validation.test.ts walks 10 real missions (9 measured + 1 sparse) and asserts:
- Peak heliocentric speed along the outbound arc matches
mission.flight.cruise.peak_heliocentric_speed_km_swithin a per-mission tolerance. - Outbound-arc terminal radius lands within 0.05 AU of the destination orbit.
Tolerance bands reflect the Hohmann-approximation envelope of the model — /fly visualises a Hohmann transfer ellipse, not a real Lambert solution. The harness validates that the visualization is close to reality, not exact:
| Class | Missions | Peak-speed tolerance |
|---|---|---|
| Energetic (C3 ≳ 10) | Curiosity, Perseverance, InSight, MAVEN, Mars Express, Hope, Tianwen-1 | 1.5 km/s |
| Direct entry / flyby (high V∞) | Mariner 4, Mars Pathfinder | 2.0 km/s |
| Sparse (reconstructed golden) | Mars 3 | 3.0 km/s |
The test helper src/lib/test-helpers/expect-close.ts#expectCloseTo() provides explicit ±tolerance assertions with descriptive errors so vitest output makes clear which mission diverged.
V∞ shaping coverage
The V∞ shaping inside outboundArc() (src/lib/mission-arc.ts:76) shipped in v0.1.10 with no unit-test coverage. v0.2.0 adds 4 tests in fly-physics.test.ts:
- Baseline passthrough (V∞=undefined matches no-arg call)
- Bend out for energetic V∞
- Hohmann-baseline V∞ produces ~zero bend
- Extreme V∞ clamped to bend cap (no unphysical loop)
Moon Bezier coverage
Pre-v0.2.0 the Moon-mode arcs in +page.svelte:478–512 had no test coverage. After extraction to fly-physics.ts#moonOutboundArc/moonReturnArc, 6 tests cover:
- Arc start at Earth (origin), end at Moon position
- Period wrap on
moonPositionAtMet() - Outbound + return arcs curve oppositely (control-point flip)
- t=0 sits on the +X axis
Rationale
The decision cluster around three principles:
- CLAUDE.md "every function in
src/lib/orbital.tsmust have tests" — extending that to fly-physics.ts is the structural fix for the test gap. The pure-function boundary is what makes the validation possible. - PA §promises — real physics — the per-mission validation harness is the structural assertion that the /fly visualization tracks public flight-data records within a documented tolerance envelope.
- PA §promises — fail honestly — the tolerance bands are wider for higher-energy transfers (and explicitly widest for sparse-data missions) because we're using a Hohmann approximation. The harness names the limitation in code rather than papering over it.
Alternatives considered
- Use real Lambert solver per mission — the existing
src/lib/lambert-grid.tscould in principle be parameterised per mission. Rejected because (a) the v0.1.6 work already pre-computes per-destination grids, not per-mission Lambert, and (b) the educational visualization doesn't need exact Lambert; a Hohmann-with-V∞-shaping model is honest enough. - toBeCloseTo decimal-place tolerance — vitest's built-in. Rejected because decimal-place tolerance couples too tightly to value magnitude (33.4 vs 33.5 fails at 1 digit, passes at 0). Explicit ±tolerance is more honest.
- Fewer than 10 missions in the harness — would speed tests but reduce coverage. The 10-mission set is the minimum that exercises the breadth of energy regimes (low V∞ Mars orbiters → high V∞ flyby missions → reconstructed sparse data).
- Validate signal delay + MET separately per mission — overkill; signal delay is a function of distance only and gets unit-test coverage; MET is a linear interp and gets unit-test coverage. The mission-specific harness focuses on trajectory geometry.
Consequences
Positive:
- /fly math is now testable independently of the Three.js + Canvas2D rendering. Refactors to the geometry don't risk silent regressions.
- Per-mission tolerance bands documented in code, not folklore. New missions added to the catalogue can be validated by adding a single line to
CASES. - The 4 V∞ shaping tests close the v0.1.10 coverage gap.
expect-close.tsis reusable for any future per-mission validation (e.g. when v0.3.0 adds outer-planet missions).
Negative:
- One extra import line per consumer in
+page.svelte(acceptable cost vs the visibility of the math). - Tolerance bands are loose (1.5 km/s for energetic transfers) — the harness validates the model envelope, not Lambert-grade precision. This is intentional but worth documenting (done above).
- 11 new test entries in the validation harness — adds ~200 ms to the test run. Acceptable.
Implementation notes
- Phase 4 ships in v0.2.0 (minor version bump because the trajectory core is touched).
- The /fly
+page.sveltenet diff is ~30 lines smaller after extraction — formulas replaced by import + call sites. - Moon-mode arc generation in
applyMissionAsLoadednow callsflyMoonOutboundArc()+flyMoonReturnArc()instead of inline Bezier loops. - Validation harness (
fly-physics-validation.test.ts) reads mission JSON directly fromstatic/data/missions/<dest>/<id>.json— same path the runtime uses.
Future work tracked separately:
- Outer-planet missions (Voyager 2, New Horizons, Galileo, Dawn) join the harness in v0.3.0 once their flight data lands.
- Lighthouse CI gate (Theme C.C3 deferred from v0.1.12) — independent of this ADR.