import rebound
PLANET_MASSES = {
"jupiter": 1 / 1047.35,
"saturn": 1 / 3501.6,
"uranus": 1 / 22869.0,
}
def 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).
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 vectors
for 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 candidate (mean longitude via l=, NOT M=)
if neptune_params is not None:
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.05
return sim
def simulate_uranus(
times_jd: np.ndarray,
neptune_params: dict[str, float] | None = None,
) -> np.ndarray:
"""Integrate and return Uranus heliocentric longitudes.
Args:
times_jd: Julian dates at which to extract longitudes.
neptune_params: Optional Neptune candidate parameters.
Returns:
Array of longitudes (radians) at each observation epoch.
"""
sim = build_simulation(neptune_params)
longitudes = np.empty(len(times_jd))
for i, jd in enumerate(times_jd):
t_yr = (jd - epoch_jd) / DAYS_PER_YEAR
sim.integrate(t_yr)
sun = sim.particles[0]
uranus = sim.particles["Uranus"]
longitudes[i] = np.arctan2(
uranus.y - sun.y, uranus.x - sun.x
)
return longitudes
def run_search(
times_jd: np.ndarray,
obs_lon_rad: np.ndarray,
) -> dict:
"""Run differential evolution to find Neptune.
Args:
times_jd: Julian dates of the observations to fit.
obs_lon_rad: Observed longitudes (radians) at those dates.
Returns:
Dict with keys "params", "rms_arcsec", and "converged".
"""
def objective(x: np.ndarray) -> float:
params = {"mass_solar": x[0], "a": x[1], "e": x[2], "l_rad": x[3]}
sim_lon = simulate_uranus(times_jd, neptune_params=params)
res_rad = (obs_lon_rad - sim_lon + np.pi) % (2 * np.pi) - np.pi
return float(np.sum(res_rad**2))
bounds = [
(1e-5, 1e-4),
(25.0, 45.0),
(0.0, 0.15),
(0.0, 2 * np.pi),
]
result = differential_evolution(
objective,
bounds=bounds,
maxiter=30,
popsize=5,
seed=42,
workers=1,
updating="deferred",
polish=True,
)
best = {
"mass_solar": result.x[0],
"a": result.x[1],
"e": result.x[2],
"l_rad": result.x[3],
}
sim_lon = simulate_uranus(times_jd, neptune_params=best)
res_rad = (obs_lon_rad - sim_lon + np.pi) % (2 * np.pi) - np.pi
rms = float(np.sqrt(np.mean(np.degrees(res_rad) ** 2)) * 3600.0)
return {"params": best, "rms_arcsec": rms, "converged": result.success}
print("Core functions defined.")