Skip to content

Feature/light travel time - #182

Merged
jdeast merged 10 commits into
jdeast:masterfrom
melodyxtang:feature/light-travel-time
Aug 16, 2026
Merged

Feature/light travel time#182
jdeast merged 10 commits into
jdeast:masterfrom
melodyxtang:feature/light-travel-time

Conversation

@melodyxtang

Copy link
Copy Markdown
Contributor

Adds light travel time (Rømer delay). Physics follows EXOFASTv2; solves the timing correction with a direct formula following the exoplanet toolkit, since a runtime loop isn't differentiable under NUTS. On by default, set per file with light_travel_time. Turning it off reproduces the old results exactly.

melodyxtang and others added 10 commits August 13, 2026 18:57
…lidation

Implements exoplanet's analytic retarded-time method in components/ltt.py
(one Kepler solve, closed-form Taylor-expansion delay, differentiable for
NUTS). Adds C_LIGHT_RSUN_PER_DAY constant. Validated: delay amplitude
499s at 1 AU, secondary-eclipse offset 2a/c, finite gradient at az=0.
Phase 1 only -- not yet wired into transit/rm.
Applies the Roemer delay correction to the transit geometry and reflection
in both build_likelihood and compile_plotters, keeping the two paths
consistent (plotted==likelihood invariant preserved). Beam/ellipsoidal stay
on uncorrected time (star-emitted; reflex delay off by default). LTT
hardcoded on pending config keys. Off-path reproduces pre-LTT model exactly.
EXOFASTv2 flux-parity tests run with LTT off (fixtures are pre-LTT).
The OFF-equivalence test loaded pre-LTT code via git show HEAD, which stops
being pre-LTT once the LTT commit lands (HEAD becomes the LTT-on code).
Freeze a genuinely-pre-LTT reference (from commit 3ae5466) as a stored
fixture and compare against that instead, so the test survives commits.
Applies the Roemer delay inside compute_rm_rv (both build_likelihood and
compile_plotters inherit it), correcting only the occultation geometry via
get_true_anomaly; the plain Keplerian RV term stays undelayed. Uses physical
orbits.a and the m_primary/m_total planet factor. Off-path reproduces the
pre-LTT RM output (stored fixture from df96bd9).
Replaces the hardcoded LTT flags in transit and RM with a per-file
light_travel_time config option, default true. Transit group loop skips the
correction when a group is fully off and the mask when fully on, paying both
only for mixed groups. Off-equivalence and parity tests set the config key
instead of patching a constant. Full suite green; LTT default-on shifts no
committed baseline.
Two independent errors in the light-travel-time correction, both invisible
to the tests that shipped with it.

1. solve_delay entered vz with the WRONG SIGN. The expression was
   transcribed from exoplanet's _get_retarded_position, but exoplanet's
   `vz` is -dZ/dt for its own Z (its _rotate_vector returns
   Z = -r*sin(f+omega)*sin(i), carrying a minus its vz does not), so its
   `(c + vz)` is really `(c - dZ/dt)`. This module builds a genuinely
   self-consistent (z, dz/dt, d2z/dt2), so the transcription flipped vz.
   Measured against a brentq solve of the retardation condition
   (a=0.05 AU, P=3 d, e=0.3, i=89): 14 ms error, ~1000x the truncation
   error the expansion is supposed to have.

   The quadratic is now solved from scratch. That also drops a second,
   smaller error inherited from the same source: exoplanet's expression is
   the small root of the quadratic with az -> -az, so it is correct to
   first order in the acceleration and wrong at second, by
   (c/az)*B^2/(4*A^3) -- 9 us for that orbit, against 4 ns for the correct
   root. Negligible for exoplanet; free not to inherit.

   Every earlier test evaluated the delay where vz == 0 EXACTLY (a
   conjunction of a circular omega=0 orbit, or a face-on orbit where
   z/vz/az all vanish), so none of them constrained vz's sign at all.

2. The occultation used m_primary/m_total. A transit is not an emission
   event -- the planet emits nothing, it blocks light the STAR emitted --
   so the observed separation needs both bodies at their own retarded
   times, and expanding both leaves the relative orbit evaluated at
   t - [(m_primary - m_companion)/m_total]*z_rel/c. The star's own delay
   partially cancels the planet's; m_primary/m_total is the same thing
   with the star pinned at the barycenter, i.e. with z_star/c dropped.

   The two agree to O(q) for a planet (tens of ms) but differ
   qualitatively for comparable masses: at equal masses the true
   primary-to-secondary offset is EXACTLY zero by symmetry -- the standard
   eclipsing-binary result used to measure mass ratios (Kaplan 2010,
   Fabrycky 2010) -- while m_primary/m_total predicts a/c, 25 s for a
   0.05 AU orbit. Applies to transit (likelihood and plotters) and to RM,
   which is the same seam.

Also guards the three call sites against an orbit whose bodies did not
resolve: Orbit only registers a/m_primary/m_companion/m_total when
_validate_bodies passes, and light_travel_time defaults to ON, so reading
them unguarded turned a previously-working geometry-only config into an
AttributeError at build time. Now warns and disables.

Tests pin the delay against a brentq solve of the retardation condition at
nine phases spread over a full period (where vz is large and of both
signs), assert the answer is actually sensitive to vz's sign, and check
the occultation factor against a direct two-body solve with each body at
its own retarded time, over mass ratios from 1e-3 to 1.

Documents, but does not implement, the multi-companion cross terms: the
star's reflex from OTHER companions contributes a
sum_{i!=b} (M_i/M_tot)*z_rel,i/c time shift -- the classic LITE/LTT
eclipse-timing signal, 2.5 s for a Jupiter at 5 AU and varying on the
outer period -- plus a transverse term that is not a time shift at all.
Both need the full system state, not one orbit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to the Roemer-delay fix.

1. Reflection, beaming and ellipsoidal now use their own time bases.

   The correction factor depends on WHICH observable is being retarded,
   not on the timestamps, so one corrected time array cannot serve a whole
   phase curve. Three roles, three factors (ltt.py's `factor` docs):

     occultation seam (transit/eclipse shape, and via planetvisible the
       thermal gating)      (m_primary - m_companion)/m_total
     light emitted by the planet (reflection)   m_primary/m_total
     light emitted by the star (beaming, ellipsoidal)
                                                m_companion/m_total

   Before this, reflection rode the occultation seam's time while beaming
   and ellipsoidal used the UNCORRECTED time, so a single phase curve
   mixed three references differing by ~a/c (~25 s, ~1e-4 in phase). Note
   the stellar factor is ~q rather than ~1: leaving those terms
   uncorrected was much closer to right than correcting them with the
   planet's factor would have been, but still ~q*a/c off and inconsistent
   with the geometry they are added to.

   Both the likelihood and the plotter paths change together -- they must
   stay identical or the plotted curve stops matching what the fit
   optimized (test_plotted_model_matches_likelihood_model). Each extra
   time base costs one Kepler solve and is built only when its term is
   actually active, so an ordinary transit-only fit pays exactly what it
   paid before.

2. Model-selecting per-file keys now reach the structural hash.

   `light_travel_time`, `gp`, `likelihood`, `mask`, `mass_parameterization`,
   `ld_law`, `chen`, `rm`, `data_format` and `mass_function` each flip
   WHICH likelihood is built while leaving the component set, the file
   list and every parameter's structure untouched -- so structural_hash
   could not see them, and a trace sampled with one setting was silently
   reused under the other by recompute_trace: false, exozippy-modes and
   mkparam.write_param_file. light_travel_time is the sharpest case
   because it defaults to ON.

   Deliberately an allowlist (evaluator._STRUCTURAL_INSTANCE_KEYS): a
   denylist would need no maintenance but would change the payload for
   every config and so invalidate every trace on disk. The price is that a
   new model-affecting per-file key must be added there in the same commit
   that introduces it. Keys are recorded only when PRESENT, so a config
   that sets none of them keeps its old payload byte-for-byte and adding
   entries cannot stale unrelated traces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inverts the failure mode of the structural hash. Nearly every key inside a
component instance selects part of the model -- light_travel_time, gp,
likelihood, mask, ld_law, data_format, rm, exptime, ninterp, band,
star_ndx, the SED's mag/err photometry -- so an allowlist is a
hand-maintained list of everything, and one forgotten entry fails
SILENTLY: the trace reloads under a model it was never sampled from, via
recompute_trace: false, exozippy-modes or mkparam.write_param_file.
light_travel_time was exactly that, and it defaulted to ON.

A denylist fails the other way: forget a cosmetic key and the worst that
happens is an honest re-run. Only `plot` and `label` are dropped as
cosmetic, plus `file`/`files` because structural_payload already hashes
those under its own "files" key (`path` is NOT dropped -- nothing else
captures it).

This invalidates every trace on disk once, deliberately: the payload shape
changed for every config. That is the one-time cost of never silently
reusing foreign draws again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdeast
jdeast merged commit 1612c9b into jdeast:master Aug 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants