The committed run loads deterministic JPL Horizons-derived J2000 state vectors from data/inner_planet_state_vectors_j2000.csv; set USE_LIVE_HORIZONS = True only when intentionally refreshing them. Venus and Earth dominate Mercury’s classical precession; Jupiter and Saturn supply additional secular perturbations.
Using bundled J2000 state vectors
Mercury x=-0.1407 AU, y=-0.4439 AU
Venus x=-0.7186 AU, y=-0.0225 AU
Earth x=-0.1685 AU, y=0.9688 AU
Jupiter x=4.0035 AU, y=2.9354 AU
Saturn x=6.4086 AU, y=6.5680 AU
Textbook GR precession formula
Einstein’s formula gives the excess perihelion advance per orbit: Δφ = 6πGM/(ac²(1−e²)). For Mercury this predicts ~43 arcsec/century. Le Verrier reported about 38 arcsec/century in 1859; later analyses refined the discrepancy before Einstein’s general relativity explained it in 1915.
Code
# ── Textbook GR formula ───────────────────────────────────────────────────────## Δφ/orbit = 6π G M_sun / (a_M c² (1−e_M²))## In REBOUND units (G = 4π² AU³ yr⁻² M_sun⁻¹):# Δφ/orbit = 6π × 4π² / (a_M × c²_AU_per_yr × (1−e_M²))## Rate = Δφ/orbit × (100 yr/century / T_Mercury) × (180°/π × 3600 arcsec/°)gr_formula = analytical_gr_precession()print(f"Textbook GR precession: {gr_formula:.2f} arcsec/century")print("(Modern residual estimate: ~43.0 arcsec/century; Le Verrier reported ~38)")
Textbook GR precession: 42.98 arcsec/century
(Modern residual estimate: ~43.0 arcsec/century; Le Verrier reported ~38)
Newtonian integration (500 years)
We integrate Mercury’s orbit for 500 years under purely Newtonian gravity with Venus, Earth, Jupiter, and Saturn as perturbers. The longitude of perihelion (ω̃) precesses at ~525 arcsec/century in this six-body run. Adding the relativistic correction supplies the remaining ~43 arcsec/century.
The same integration with reboundx post-Newtonian corrections enabled. The difference between the GR-corrected and Newtonian rates isolates general relativity’s contribution.
Left panel: the raw longitude of perihelion over 500 years, showing the Newtonian and GR-corrected curves diverging. Right panel: the GR−Newtonian difference grows linearly at ~43 arcsec/century, matching Einstein’s prediction.
Code
# ── Plot: pomega drift — GR minus Newtonian ──────────────────────────────────delta_arcsec = (np.unwrap(pomega_g) - np.unwrap(pomega_n)) * np.degrees(1.0) *3600.0def _build_precession(delta_arcsec, times_n, pomega_n, times_g, pomega_g, gr_contribution, *, theme="light"): apply_style(theme) pal = palette(theme) fig, axes = plt.subplots(1, 2, figsize=fig_size("double"))# Left: raw pomega series ax = axes[0] ax.plot(times_n, np.degrees(np.unwrap(pomega_n)), color=pal.before, label="Newtonian", lw=1.0) ax.plot(times_g, np.degrees(np.unwrap(pomega_g)), color=pal.after, label="GR-corrected", lw=1.0, ls="--") ax.set_xlabel("Time (yr)") ax.set_ylabel("ω̃ — longitude of perihelion (°)") ax.set_title("Perihelion precession: Newtonian vs GR") ax.legend()# Right: GR − Newtonian difference ax = axes[1] ax.plot(times_g, delta_arcsec, color=pal.after, lw=1.0)# Overlay the fitted linear trend trend = gr_contribution /100.0* times_g # arcsec, since gr_contribution is per century ax.plot(times_g, trend, color=pal.truth, ls="--", lw=0.8, label=f"Fit: {gr_contribution:.1f} arcsec/century") ax.set_xlabel("Time (yr)") ax.set_ylabel("Δω̃ = GR − Newtonian (arcsec)") ax.set_title("GR contribution to Mercury precession") ax.legend()return figdisplay(dual_render( _build_precession, delta_arcsec, times_n, pomega_n, times_g, pomega_g, gr_contribution, alt="Mercury perihelion precession: Newtonian vs GR-corrected",))print("Figure rendered.")
Figure rendered.
Vulcan: the planet that wasn’t
If a hypothetical inner planet (Vulcan) were responsible for Mercury’s anomaly instead of GR, how massive would it need to be? First-order secular perturbation theory gives the required mass as a function of orbital distance. The result: any such planet would be far too massive to have escaped detection during solar observations and transits.
Code
# ── Vulcan table ──────────────────────────────────────────────────────────────## What mass would a hypothetical inner planet (Vulcan) need to explain# Mercury's 43 arcsec/century anomaly?## Formula (first-order secular perturbation, inner perturber):# dω/dt ≈ (3/4) × n_M × (m_V / M_sun) × (a_V / a_M)²## Solving for m_V: m_V = dω/dt × (4/3) / (n_M × (a_V/a_M)²)M_EARTH_IN_SOLAR =1.0/332_946.0# solar masses per Earth massprint(f"{'a_Vulcan (AU)':>14}{'Mass (M_sun)':>14}{'Mass (M_Earth)':>14}{'Detectability':>30}")print("-"*80)for a_v in [0.10, 0.15, 0.20, 0.25, 0.30]: m_sun = vulcan_mass_estimate(a_v) m_earth = m_sun / M_EARTH_IN_SOLAR# Rough detectability: Venus (0.72 AU) has max elongation ~47°# Mercury (0.387 AU) has max elongation ~28°# Inner planets would be observable during transits / solar observationsif m_earth >0.1: note ="≫ Moon — easily detected by transits"elif m_earth >0.001: note ="Moon-class — detectable by transits"else: note ="Sub-lunar — still no transit observed"print(f"{a_v:>14.2f}{m_sun:>14.4e}{m_earth:>14.3f}{note:>30}")print()print("Conclusion: any planet massive enough to produce 43 arcsec/century")print("would be far too large to have escaped detection. GR is the only answer.")
a_Vulcan (AU) Mass (M_sun) Mass (M_Earth) Detectability
--------------------------------------------------------------------------------
0.10 1.5966e-06 0.532 ≫ Moon — easily detected by transits
0.15 7.0960e-07 0.236 ≫ Moon — easily detected by transits
0.20 3.9915e-07 0.133 ≫ Moon — easily detected by transits
0.25 2.5546e-07 0.085 Moon-class — detectable by transits
0.30 1.7740e-07 0.059 Moon-class — detectable by transits
Conclusion: any planet massive enough to produce 43 arcsec/century
would be far too large to have escaped detection. GR is the only answer.
Neptune vs Vulcan: two predictions, two outcomes
A side-by-side comparison of Le Verrier’s two uses of the inverse method. For Uranus, the anomaly was caused by a real planet and the method succeeded. For Mercury, the anomaly was caused by spacetime curvature — no planet could explain it.
Code
# ── Side-by-side comparison table ────────────────────────────────────────────print("\n"+"="*70)print(" Le Verrier's Two Predictions: Uranus / Neptune vs Mercury / Vulcan")print("="*70)print()rows = [ ("Anomaly", "31.5 RMS; 131.4 peak-to-peak", "~38 reported; later ~43"), ("Observed body", "Uranus (19.2 AU)", "Mercury (0.39 AU)"), ("Le Verrier's", "New outer planet", "New inner planet (Vulcan)"), ("prediction", "at ~36 AU", "at ~0.14 AU"), ("True explanation","New planet — Neptune", "General Relativity (GR)"), ("Prediction result","Neptune found 1846 ✓", "Vulcan — never found ✗"), ("True distance", "30.07 AU (20.2 % error)", "N/A — no planet exists"), ("Our simulation", "See current JPL-mode run", f"GR contrib = {gr_contribution:.1f} arcsec/cy"),]col_w =28header =f"{'':20}{'Uranus / Neptune':>{col_w}}{'Mercury / GR':>{col_w}}"print(header)print("-"*len(header))for label, col1, col2 in rows:print(f"{label:20}{col1:>{col_w}}{col2:>{col_w}}")print()print("The same method — predict a body from orbital anomalies — succeeded for")print("Uranus and failed for Mercury. The difference: GR is not negligible at 0.39 AU.")
======================================================================
Le Verrier's Two Predictions: Uranus / Neptune vs Mercury / Vulcan
======================================================================
Uranus / Neptune Mercury / GR
--------------------------------------------------------------------------------
Anomaly 31.5 RMS; 131.4 peak-to-peak ~38 reported; later ~43
Observed body Uranus (19.2 AU) Mercury (0.39 AU)
Le Verrier's New outer planet New inner planet (Vulcan)
prediction at ~36 AU at ~0.14 AU
True explanation New planet — Neptune General Relativity (GR)
Prediction result Neptune found 1846 ✓ Vulcan — never found ✗
True distance 30.07 AU (20.2 % error) N/A — no planet exists
Our simulation See current JPL-mode run GR contrib = 43.0 arcsec/cy
The same method — predict a body from orbital anomalies — succeeded for
Uranus and failed for Mercury. The difference: GR is not negligible at 0.39 AU.
Summary
Code
# ── Summary ───────────────────────────────────────────────────────────────────print("\n"+"="*60)print(" Summary")print("="*60)print(f" Textbook GR formula: {gr_formula:.2f} arcsec/century")print(f" Newtonian simulation rate: {rate_newt:.1f} arcsec/century")print(f" GR-corrected rate: {rate_gr:.1f} arcsec/century")print(f" GR contribution (sim): {gr_contribution:.1f} arcsec/century")print(f" GR contribution (formula): {gr_formula:.1f} arcsec/century")print()print(" → GR closes the Mercury anomaly exactly.")print(" → No planet (Vulcan) is needed or consistent with observations.")print()print(" This contrasts with Neptune: GR corrections at 19+ AU are only")print(" ~0.01 arcsec/century — far below this comparison's residual scale.")print(" There, a new planet was the correct (and only) explanation.")print("="*60)
============================================================
Summary
============================================================
Textbook GR formula: 42.98 arcsec/century
Newtonian simulation rate: 524.7 arcsec/century
GR-corrected rate: 567.6 arcsec/century
GR contribution (sim): 43.0 arcsec/century
GR contribution (formula): 43.0 arcsec/century
→ GR closes the Mercury anomaly exactly.
→ No planet (Vulcan) is needed or consistent with observations.
This contrasts with Neptune: GR corrections at 19+ AU are only
~0.01 arcsec/century — far below this comparison's residual scale.
There, a new planet was the correct (and only) explanation.
============================================================