This tutorial is the hands-on companion to the historical Neptune notebook. It is designed for readers who want to see the numerical pieces directly: how the bundled data is loaded, how a small REBOUND simulation is assembled, and how the Uranus anomaly becomes a search problem for Neptune.
The notebook stays offline-friendly and only uses the data files bundled under data/.
REBOUND Basics
We start with a minimal integrator setup and a few helper paths so this notebook works from either the repository root or the docs/ directory.
Code
from pathlib import Pathimport numpy as npimport pandas as pdimport reboundfrom scipy.optimize import differential_evolutioncwd = Path.cwd()# Works from the repository root or the `docs/` directory.candidate_data_dirs = [cwd /"data", cwd.parent /"data"]data_dir =next((path for path in candidate_data_dirs if (path /"outer_planet_state_vectors_1781_01_01.csv").exists()), None)if data_dir isNone:raiseFileNotFoundError("Could not find bundled notebook data in `data/`." )outer_planets = pd.read_csv(data_dir /"outer_planet_state_vectors_1781_01_01.csv")observations = pd.read_csv(data_dir /"uranus_observations_1781_1846.csv")observation_times_jd = observations["datetime_jd"].to_numpy(dtype=float)simulation_epoch_jd =float(observation_times_jd[0])
Build the Outer Solar System
The next step is to put the Sun and the known outer planets into a small simulation. Neptune is still missing on purpose: this baseline gives us the reference system that the search cells will perturb and compare against the observations.
With observations loaded, we can compare modeled and reference Uranus longitudes directly. The same residuals feed the objective below and the final fit summary.
Code
observed_longitudes_rad = observations["longitude_rad"].to_numpy(dtype=float)observed_longitudes_unwrapped_rad = np.unwrap(observed_longitudes_rad)observed_offsets_arcsec = np.rad2deg( observed_longitudes_unwrapped_rad - observed_longitudes_unwrapped_rad[0]) *3600.0def simulate_uranus_longitudes( neptune_parameters: dict[str, float] |None, times_jd: np.ndarray = observation_times_jd,) -> np.ndarray: times_jd = np.asarray(times_jd, dtype=float)if times_jd.ndim !=1or times_jd.size ==0:raiseValueError("times_jd must be a one-dimensional array with at least one epoch" ) sim = build_simulation(neptune_parameters) longitudes_rad = np.empty(times_jd.size, dtype=float)for index, jd inenumerate(times_jd): years = (float(jd) - simulation_epoch_jd) / days_per_year sim.integrate(years, exact_finish_time=1) sun = sim.particles[0] uranus = sim.particles["Uranus"] longitudes_rad[index] = np.arctan2( uranus.y - sun.y, uranus.x - sun.x ) longitudes_rad = np.unwrap(longitudes_rad)return np.rad2deg(longitudes_rad - longitudes_rad[0]) *3600.0observed_offsets_arcsec[:5]
The optimization below uses a reduced budget so the notebook runs quickly, but it is still the same Neptune search workflow. We report the subset objective and the full-data check afterward.
The tutorial stays offline by default, but the next code cell shows how to rebuild the same bundled tables directly from JPL Horizons in a networked environment. If live fetch fails or stays disabled, the notebook continues to use the bundled CSV files.
Code
ENABLE_HORIZONS_FETCH =FalseHORIZONS_PLANET_IDS = {"jupiter": "599","saturn": "699","uranus": "799",}HORIZONS_QUERY = {"start": "1781-01-01","stop": "1846-09-23","step": "1y",}def query_horizons_vectors( horizons_id: str,*, start: str, stop: str, step: str,) -> pd.DataFrame:from astroquery.jplhorizons import Horizons table = Horizons(id=horizons_id, location="@sun", epochs={"start": start, "stop": stop, "step": step}, ).vectors()iflen(table) ==0:raiseRuntimeError(f"Horizons returned no rows for id={horizons_id}") frame = pd.DataFrame( {"datetime_jd": np.asarray(table["datetime_jd"], dtype=float),"x": np.asarray(table["x"], dtype=float),"y": np.asarray(table["y"], dtype=float),"z": np.asarray(table["z"], dtype=float),"vx": np.asarray(table["vx"], dtype=float),"vy": np.asarray(table["vy"], dtype=float),"vz": np.asarray(table["vz"], dtype=float), } ) frame["longitude_rad"] = np.arctan2(frame["y"], frame["x"])return framedef fetch_horizons_observations() -> pd.DataFrame: uranus_vectors = query_horizons_vectors( HORIZONS_PLANET_IDS["uranus"],**HORIZONS_QUERY, )return uranus_vectors.loc[:, ["datetime_jd", "longitude_rad"]].copy()def fetch_horizons_state_vectors() -> pd.DataFrame: rows: list[dict[str, float|str]] = []for planet_name in ("jupiter", "saturn", "uranus"): vectors = query_horizons_vectors( HORIZONS_PLANET_IDS[planet_name],**HORIZONS_QUERY, ) first_row = vectors.iloc[0] rows.append( {"planet": planet_name,"x": float(first_row["x"]),"y": float(first_row["y"]),"z": float(first_row["z"]),"vx": float(first_row["vx"]),"vy": float(first_row["vy"]),"vz": float(first_row["vz"]), } )return pd.DataFrame(rows)live_observations = observations.copy()live_outer_planets = outer_planets.copy()live_fetch_status = ("Using bundled data from data/. Set ENABLE_HORIZONS_FETCH = ""True to refresh the tutorial inputs from JPL Horizons.")if ENABLE_HORIZONS_FETCH:try: live_observations = fetch_horizons_observations() live_outer_planets = fetch_horizons_state_vectors() live_fetch_status = ("Fetched live Horizons data with the same observation and state-""vector columns as the bundled CSV files." )exceptExceptionas exc: live_observations = observations.copy() live_outer_planets = outer_planets.copy() live_fetch_status = ("Using bundled data after a live Horizons fetch failure: "f"{exc}" )print(live_fetch_status)pd.Series( {"observation_rows": int(live_observations.shape[0]),"state_vector_rows": int(live_outer_planets.shape[0]),"observation_columns": ", ".join(live_observations.columns),"state_vector_columns": ", ".join(live_outer_planets.columns), })
Using bundled data from data/. Set ENABLE_HORIZONS_FETCH = True to refresh the tutorial inputs from JPL Horizons.
A top-down view ties the pieces together: integrate the system for one Uranus period (84 years) and trace each orbit. This is the geometry every longitude in this project is measured on.
Code
import matplotlib.pyplot as pltfrom IPython.display import displayfrom discoverneptune.plot_style import apply_style, category_cycle, dual_renderorbit_sim = build_simulation(best_neptune_parameters)trace_names = ["Jupiter", "Saturn", "Uranus", "Neptune"]trace_times_yr = np.linspace(0.0, 84.0, 600)orbit_tracks = {name: np.empty((trace_times_yr.size, 2)) for name in trace_names}for index, t_yr inenumerate(trace_times_yr): orbit_sim.integrate(t_yr) sun = orbit_sim.particles[0]for name in trace_names: body = orbit_sim.particles[name] orbit_tracks[name][index] = (body.x - sun.x, body.y - sun.y)def build_orbit_figure(theme="light"): apply_style(theme) cycle = category_cycle(theme) fig, ax = plt.subplots(figsize=(7, 7))# Gruvbox gold for the Sun — distinct from every orbit color in both themes. ax.plot(0, 0, "o", ms=8, color="#d79921", zorder=5) ax.annotate("Sun", (0, 0), xytext=(6, 6), textcoords="offset points", fontsize=10, color="#d79921")for color, name inzip(cycle, trace_names, strict=False): ax.plot(orbit_tracks[name][:, 0], orbit_tracks[name][:, 1], lw=1.0, color=color) ax.plot(*orbit_tracks[name][-1], "o", ms=5, color=color) ax.annotate(name, orbit_tracks[name][-1], xytext=(6, 6), textcoords="offset points", fontsize=10, color=color) ax.set_aspect("equal") ax.set_xlabel("x (AU)") ax.set_ylabel("y (AU)") ax.set_title("One Uranus period, top-down (1781–1865)")return figdisplay(dual_render(build_orbit_figure, alt="Top-down traces of Jupiter, Saturn, Uranus, and Neptune orbits over one 84-year Uranus period"))