Skip to content

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

FunctionRoleSource formula
heliocentricSpeed(rAu, aTransferAu)Vis-viva on transfer ellipse√(µ·(2/r − 1/a)) × AU/yr→km/s
distanceBetween(a, b)Heliocentric Euclidean distancehypot(ax−bx, az−bz)
auToKm(au) / auToMkm(au)Unit conversionau × 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 progresslinear interp + clamp
dvRemaining(total, used)∆v ledgermax(0, total − used)
moonPositionAtMet(metDays)Moon orbital position in scene coords(cos θ, sin θ) × MOON_VISUAL_DISTANCE
moonOutboundArc(moonPos, steps)Earth → Moon Bezierquadratic Bezier with perpendicular control offset
moonReturnArc(moonPos, steps)Moon → Earth Beziermirrored 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_KMS
  • C_LIGHT_KM_S, MOON_ORBITAL_PERIOD_DAYS, MOON_VISUAL_DISTANCE
  • MOON_BEZIER_CTRL_OFFSET

Validation harness

src/lib/fly-physics-validation.test.ts walks 10 real missions (9 measured + 1 sparse) and asserts:

  1. Peak heliocentric speed along the outbound arc matches mission.flight.cruise.peak_heliocentric_speed_km_s within a per-mission tolerance.
  2. 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:

ClassMissionsPeak-speed tolerance
Energetic (C3 ≳ 10)Curiosity, Perseverance, InSight, MAVEN, Mars Express, Hope, Tianwen-11.5 km/s
Direct entry / flyby (high V∞)Mariner 4, Mars Pathfinder2.0 km/s
Sparse (reconstructed golden)Mars 33.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.ts must 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.ts could 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.ts is 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.svelte net diff is ~30 lines smaller after extraction — formulas replaced by import + call sites.
  • Moon-mode arc generation in applyMissionAsLoaded now calls flyMoonOutboundArc() + flyMoonReturnArc() instead of inline Bezier loops.
  • Validation harness (fly-physics-validation.test.ts) reads mission JSON directly from static/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.

Orrery — architecture documentation · MIT · No tracking