Rediscover Neptune from scratch using N-body simulation and optimization.
Author
Jonathan Whitmore
Published
April 3, 2026
Introduction
In 1846 Urbain Le Verrier predicted the existence and position of an unseen planet from nothing more than decades of anomalous Uranus observations. This tutorial recreates that discovery computationally. You will:
Then load the two bundled data files. The observations file contains 66 heliocentric Uranus longitudes in radians, sampled roughly once per year from Herschel’s 1781 discovery through the eve of Neptune’s identification. The state-vectors file gives the Cartesian positions and velocities of Jupiter, Saturn, and Uranus at the simulation epoch (1781-01-01).
Load bundled CSV data
from pathlib import Pathimport numpy as npimport pandas as pdfrom discoverneptune.data import find_bundled_data_dirdata_dir = find_bundled_data_dir()observations = pd.read_csv(data_dir /"uranus_observations_1781_1846.csv")outer_planets = pd.read_csv(data_dir /"outer_planet_state_vectors_1781_01_01.csv")print(f"Observations: {len(observations)} rows")print(f"State vectors: {len(outer_planets)} rows")observations.head()
Observations: 66 rows
State vectors: 3 rows
datetime_jd
longitude_rad
0
2371557.5
1.564843
1
2371922.5
1.641149
2
2372287.5
1.717966
3
2372652.5
1.795309
4
2373018.5
1.873409
Uranus observations
The raw longitudes trace Uranus’s slow march around the ecliptic — about one full orbit every 84 years. Plotting them shows the smooth progression that 18th- and 19th-century astronomers tracked with increasing precision.
The 66 annual observations as Earth-to-Uranus sight lines accumulating across the 1781–1846 window — the raw geometry behind every plot that follows.
Plot raw Uranus longitudes
import matplotlib.pyplot as pltobs_years = (observations["datetime_jd"].values -2371557.5) /365.25+1781.0_lon_deg = np.degrees(observations["longitude_rad"].values)def _build(theme="light"): apply_style(theme) pal = palette(theme) cycle = category_cycle(theme) fig, ax = plt.subplots(figsize=fig_size("wide")) ax.scatter(obs_years, _lon_deg, s=12, color=cycle[0], zorder=3) ax.set_xlabel("Year") ax.set_ylabel("Longitude (degrees)") ax.set_title("Uranus heliocentric longitude")return figdisplay(dual_render(_build, alt="Heliocentric longitude of Uranus 1781–1846, one point per annual observation"))
Figure 1: Heliocentric longitude of Uranus, 1781–1846. Each point is one annual observation in degrees.
Build the forward model
The forward model places the Sun, Jupiter, Saturn, and Uranus into a REBOUND simulation using JPL state vectors at the 1781 epoch. An optional Neptune candidate — specified by mass, semi-major axis, eccentricity, and mean longitude — can be injected via orbital elements.
A few details matter:
Units first.sim.units = ("yr", "AU", "Msun") must be set before any particles are added; REBOUND uses the unit declaration to scale its internal constants.
Velocity conversion. The bundled CSV stores velocities in AU/day (the JPL Horizons default). REBOUND expects AU/yr, so every velocity component is multiplied by 365.25.
Centre of mass.sim.move_to_com() shifts the coordinate origin to the system barycenter, which the WHFast symplectic integrator requires for accurate long-term integration.
Heliocentric longitude. After integration, Uranus’s longitude is extracted as arctan2(y_uranus − y_sun, x_uranus − x_sun).
Define build_simulation()
import reboundPLANET_MASSES = {"jupiter": 1/1047.35,"saturn": 1/3501.6,"uranus": 1/22869.0,}DAYS_PER_YEAR =365.25def build_simulation( neptune_params: dict[str, float] |None=None,) -> rebound.Simulation:"""Create a REBOUND simulation of the outer solar system. Args: neptune_params: If provided, must contain keys "mass_solar", "a", "e", and "l_rad" (mean longitude in radians). Neptune is added via orbital elements. Returns: A configured REBOUND Simulation ready to integrate. """ sim = rebound.Simulation() sim.units = ("yr", "AU", "Msun")# Sun sim.add(m=1.0)# Jupiter, Saturn, Uranus from JPL state vectorsfor row in outer_planets.itertuples(index=False): sim.add( m=PLANET_MASSES[row.planet], x=float(row.x), y=float(row.y), z=float(row.z), vx=float(row.vx) * DAYS_PER_YEAR, vy=float(row.vy) * DAYS_PER_YEAR, vz=float(row.vz) * DAYS_PER_YEAR,hash=row.planet.title(), )# Optional Neptune candidateif neptune_params isnotNone: sim.add( m=neptune_params["mass_solar"], a=neptune_params["a"], e=neptune_params["e"], l=neptune_params["l_rad"],hash="Neptune", ) sim.move_to_com() sim.integrator ="whfast" sim.dt =0.05return simprint("build_simulation() defined.")
build_simulation() defined.
The companion function integrates the simulation forward and extracts Uranus’s heliocentric longitude at each observation epoch.
Neptune is placed in the ecliptic plane with its pericenter along the reference direction (inclination, argument of pericenter, and longitude of ascending node all default to zero). This coplanar assumption matches Le Verrier’s approach and keeps the search at 4 free parameters.
See the anomaly
With no Neptune in the simulation, the modeled Uranus longitudes should match the observations if the four-body model (Sun + Jupiter + Saturn + Uranus) were complete. The residuals reveal that it is not.
Angle-wrapping keeps the residual in the range (−π, π) before converting to arcseconds. The signal grows to roughly 130 arcsec over 65 years — the gravitational fingerprint of the missing planet.
Figure 2: Uranus residuals (observed minus modeled) with no Neptune. The ~130 arcsec drift is the signal Le Verrier used.
Peak residual: 114.0 arcsec
Manual exploration
Before unleashing an optimizer, it helps to build intuition by trying a few Neptune candidates by hand. Each candidate differs in semi-major axis and starting longitude — the two parameters the residuals are most sensitive to.
Figure 3: Residuals for three hand-picked Neptune candidates. None is a good fit, but the residual shape responds strongly to (a, l).
The residual shape changes dramatically with different parameters, confirming that the signal carries real information about Neptune’s orbit. But finding the right combination by hand is impractical with four free parameters — time to optimize.
Optimize
We use scipy.optimize.differential_evolution, a global optimizer well suited to problems with broad, possibly multimodal search spaces. The objective function computes the sum of squared angle-wrapped residuals across all 66 observation epochs.
The bounds below are deliberately wide: mass spans an order of magnitude, semi-major axis covers 25–45 AU (well beyond the true ~30 AU), and the mean longitude is unconstrained over the full circle.
Note
Tutorial runtime configuration. The code cells below use workers=1 (single-core, for reproducible cell output across platforms) and polish=True (SciPy’s built-in L-BFGS-B polish). The production solver in src/discoverneptune/solver.py uses workers=-1 (parallel) and runs L-BFGS-B as an explicit second stage with polish=False for better control and logging. Both approaches find the same answer; the difference is presentation, not correctness.
Best-fit Neptune parameters:
Mass: 6.58e-05 M_sun
Semi-major axis: 32.74 AU
Eccentricity: 0.1496
Mean longitude: 215.4°
Optimizer cost: 9.6828e-09
Visualize the result
The before-and-after comparison makes the improvement concrete. On the left, the unperturbed model drifts by over a hundred arcseconds. On the right, the optimized Neptune brings the residuals down to a few arcseconds — the model noise floor representing the gap between our simplified five-body Newtonian model and the modern JPL Horizons ephemeris.
Side-by-side residual comparison
sim_lon_best = simulate_uranus_longitudes(neptune_params=best)res_best_rad = (observed_longitudes_rad - sim_lon_best + np.pi) % (2* np.pi) - np.pires_best_arcsec = np.degrees(res_best_rad) *3600.0rms_before = np.sqrt(np.mean(residuals_arcsec**2))rms_after = np.sqrt(np.mean(res_best_arcsec**2))def _build(theme="light"): apply_style(theme) pal = palette(theme) cycle = category_cycle(theme) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=fig_size("double"), sharey=True) ax1.scatter(obs_years, residuals_arcsec, s=12, color=cycle[1], zorder=3) truth_line(ax1, 0, axis="y", theme=theme) ax1.set_xlabel("Year") ax1.set_ylabel("Residual (arcsec)") ax1.set_title(f"No Neptune — RMS = {rms_before:.1f}″") ax2.scatter(obs_years, res_best_arcsec, s=12, color=cycle[2], zorder=3) truth_line(ax2, 0, axis="y", theme=theme) ax2.set_xlabel("Year") ax2.set_title(f"Best-fit Neptune — RMS = {rms_after:.1f}″")return figdisplay(dual_render(_build, alt="Uranus residuals before and after adding best-fit Neptune, showing reduction from ~130 arcsec to a few arcsec"))print(f"RMS before Neptune: {rms_before:.1f} arcsec")print(f"RMS after Neptune: {rms_after:.1f} arcsec")
Figure 4: Uranus residuals before (left) and after (right) adding the best-fit Neptune. The optimizer reduces the signal from ~130 arcsec to a few arcsec.
RMS before Neptune: 31.5 arcsec
RMS after Neptune: 2.5 arcsec
Starting from 66 longitude measurements and a simple N-body integrator, the optimizer recovers a Neptune-like planet that collapses the residuals by more than an order of magnitude. The remaining few-arcsecond scatter is the model noise floor — Le Verrier’s mathematical triumph, reproduced in a few seconds of computation.