Skip to content

Model the Shapiro delay in radar residuals - #529

Merged
matthewholman merged 4 commits into
mainfrom
feat/radar-shapiro
Sep 3, 2026
Merged

Model the Shapiro delay in radar residuals#529
matthewholman merged 4 commits into
mainfrom
feat/radar-shapiro

Conversation

@matthewholman

@matthewholman matthewholman commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Radar signals pass through the Sun's gravitational potential on both legs, and that delay was not modelled. For a leg between A and B,

dt = (2 GM / c^3) ln[(r_A + r_B + rho) / (r_A + r_B - rho)]

with r_A and r_B heliocentric distances. It is one to two microseconds at the geometries these observations are made, which sounds negligible until you notice that radar delay uncertainties in the JPL database run 0.2 to 4 microseconds, and are 0.30 for Golevka at Arecibo. Leaving it out biases every radar observation by several sigma.

Validated against real Apophis radar, on the epochs near the reference orbit where propagation error is negligible: the delay residual falls from 2.16 sigma to 0.312 sigma, and Doppler is unchanged at 0.28 sigma. The comparison is against an independent Python implementation of the same formula (ISSUE_146_shapiro_validation.py), so it checks the physics rather than this code. That confirms the conjecture recorded when the two-leg model was written, which attributed its residual to exactly this term. It also bears on the Apophis covariance work, where the unmodelled term was running at a median 8.5 sigma.

Two things came with it:

  • The ephemeris is passed into compute_radar_residuals rather than read from r->extras. The simulation used for residuals has no assist_extras attached, so that pointer is NULL there.
  • The radar test fixtures carry the term now. radar_synthetic.json was generated with delay = 2 rho/c and no Shapiro, so once the fitter modelled it the truth was inconsistent by 19.2 us per observation. There were two stale generators: the fixture script, and a second copy inlined in test_radar_end_to_end. Fixing both made the end-to-end tests pass with no tolerance touched.

test_radar_validation's chi-square bound did move, and the reason is in the test: the fixture now comes from an independent implementation, which agrees with the C++ to about 0.3 ns per observation rather than exactly. That is 3e-4 of the fixture's own stated uncertainty. The old 1e-6 bound was achievable only while fixture and fitter shared a formula. State tolerances are tightened rather than relaxed, 10x on position and 100x on velocity, since those carry the physics.

The partials are left alone: the Jacobian is already a single-leg approximation by design, and a microsecond-scale slowly varying term does not change the step direction.

Suite: 536 passed, 1 skipped on all four CI legs. Related: #528.

Reviewing this branch requires rebuilding the C++ extension. orbit_fit.cpp and radar_synthetic.json change together, so an older binary gets scored against a fixture that now carries the Shapiro term. The fit still converges and absorbs most of the 19.2 us into the orbit, which makes the failure look like a physics error rather than a stale build: test_radar_validation reports chi-square 0.0104 against its 1e-4 bound, and the end-to-end test a 3.7e-6 velocity drift. After pip install -e . all five radar tests pass.

A radar signal passes through the Sun's gravitational potential on each leg, and
the resulting delay was not modelled. For a leg between A and B,

    dt = (2 GM / c^3) ln[(r_A + r_B + rho) / (r_A + r_B - rho)]

with r_A and r_B heliocentric distances. At the geometries these observations are
made it is one to two microseconds, which sounds negligible until you notice that
radar delay uncertainties are 0.3 to 2 us: leaving it out biases every radar
observation by several sigma.

Validated against real Apophis radar astrometry, on the epochs near the reference
orbit where propagation error is negligible. The delay residual falls from
2.16 sigma to 0.312 sigma, and the Doppler residual is unchanged at 0.28 sigma.
The comparison is against an independent Python implementation of the same
formula, so it checks the physics rather than this code; the harness is
ISSUE_146_shapiro_validation.py. That confirms the conjecture recorded when the
two-leg model was written, which attributed its residual to exactly this term.

The ephemeris is passed into compute_radar_residuals rather than read from
r->extras: the simulation used for residuals has no assist_extras attached, so
that pointer is NULL there.

The partials are left alone. The Jacobian is already a single-leg approximation
by design, and a microsecond-scale, slowly varying term does not change the step
direction.
The synthetic radar truth was generated with delay = 2 rho/c and no Shapiro term.
Once the fitter models it, that truth is inconsistent with the fitter by 19.2 us
per observation -- on seven observations with a stated 1 us uncertainty, which
walked the chi-square to 1.0e-2.

Both generators now include the term: tests/data/radar_synthetic.json is
regenerated (ISSUE_146_gen_radar_fixture.py, kept with the other harnesses), and
test_radar_end_to_end builds its observables inline, so that copy is fixed too.
The Sun is taken at the emission time, where the C++ light-time solution leaves
the simulation.

The end-to-end tests then pass unchanged. test_radar_validation needed its
chi-square bound moved, because that bound changed meaning: the fixture comes
from an independent implementation of the same model, and the two agree to about
0.3 ns per observation rather than exactly. That is 3e-4 of the fixture's own
stated uncertainty, and a hundred times finer than the best real radar timing in
the JPL database. The old 1e-6 bound was achievable only while fixture and fitter
shared a formula.

The state tolerances are tightened rather than relaxed -- 10x on position and
100x on velocity -- since those carry the physics the test exists to check, and
recovery is 2.7e-9 au and 1.5e-11 au/day.
An Observation carries one station, used for both the transmit and the receive
leg. That is monostatic by design -- ISSUE_146_RADAR_DESIGN.md scopes the first
implementation to TX == RX and lists bistatic as a refinement -- but nothing said
so at the point where a caller assembles radar rows.

Passing a bistatic measurement therefore evaluates the receive leg at the
transmitting antenna, and does it silently. On real Goldstone bistatic pairs that
is a ~4% Doppler error (observed/model 1.039-1.044 against 0.999934 for
monostatic rows), which against a 0.1 Hz uncertainty is of order a hundred sigma
per observation. Fitting (6489) Golevka with its 18 bistatic rows included gave
flag 2 and a reduced chi-square of 1.5e7; with them removed, flag 0 at 0.80.

No behaviour change -- the constraint already existed, it was just not written
down where someone would meet it.
The end-to-end test was added after the last formatting pass and used
hand-aligned trailing comments and a manually wrapped Shapiro
expression, both of which black 25.1.0 rewrites. No functional change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@kjnapier kjnapier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very impressive

@matthewholman
matthewholman merged commit 6e35209 into main Sep 3, 2026
7 checks passed
@matthewholman
matthewholman deleted the feat/radar-shapiro branch September 3, 2026 17:30
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