Bayesian Inference for Neptune’s Orbit

This notebook maps the posterior distribution of Neptune’s orbital parameters using emcee’s affine-invariant ensemble sampler, in two complementary regimes:

Each MCMC run targets the 50τ convergence heuristic. Convergence is assessed via ESS (effective sample size) and split-R̂ from ArviZ. Chains are checkpointed to data/cache/mcmc/*.h5 so interrupted runs can resume without resampling.

Setup

Code
import logging
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from IPython.display import display

from discoverneptune.bayesian import (
    AR1_MCMC_BOUNDS,
    AR1_MCMC_PARAM_NAMES,
    run_mcmc_fixed_sigma,
    run_prior_comparison,
)
from discoverneptune.data import PLANET_IDS, fetch_planet_vectors
from discoverneptune.objective import BOUNDS
from discoverneptune.plot_style import (
    apply_style,
    category_cycle,
    dual_render,
    fig_size,
    palette,
    truth_line,
)
from discoverneptune.plotting import (
    plot_corner,
    plot_residuals,
    plot_traces,
)
from discoverneptune.plotting import (
    plot_prior_comparison as plot_prior_cmp,
)
from discoverneptune.sensitivity import inject_noise
from discoverneptune.simulation import (
    MASS_NEPTUNE_APPROX,
    NeptuneCandidate,
    StateVector,
    simulate_uranus_longitudes_from_vectors,
)
from discoverneptune.solver import parameters_near_bounds, solve
from discoverneptune.synthetic import build_synthetic_problem

apply_style()


logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-8s  %(name)s  %(message)s",
    datefmt="%H:%M:%S",
)
logger = logging.getLogger("bayesian_nb")

REPO_ROOT = Path.cwd()
if REPO_ROOT.name == "docs":
    REPO_ROOT = REPO_ROOT.parent

BAYESIAN_CACHE_DIR = REPO_ROOT / "data/cache/mcmc"
BAYESIAN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
SYNTHETIC_POSTERIOR_CACHE = BAYESIAN_CACHE_DIR / "bayesian_synthetic_fixed_sigma.h5"
JPL_FLAT_POSTERIOR_CACHE = BAYESIAN_CACHE_DIR / "bayesian_jpl_flat.h5"
JPL_BODE_POSTERIOR_CACHE = BAYESIAN_CACHE_DIR / "bayesian_jpl_bode.h5"
MCMC_WORKER_OPTIONS = [1, 4, 6, 8]
MCMC_WORKERS = 4  # chosen from the local 1/4/6/8-worker benchmark on Apple Silicon

print(f"MCMC cache dir      : {BAYESIAN_CACHE_DIR}")
print(f"Synthetic cache     : {SYNTHETIC_POSTERIOR_CACHE}")
print(f"JPL flat cache      : {JPL_FLAT_POSTERIOR_CACHE}")
print(f"JPL Bode cache      : {JPL_BODE_POSTERIOR_CACHE}")
print(
    f"MCMC workers        : {MCMC_WORKERS} (process-based, candidate set {MCMC_WORKER_OPTIONS})"
)


def _sv_from_df(df) -> StateVector:
    return StateVector(
        x=float(df["x"].to_numpy()[0]),
        y=float(df["y"].to_numpy()[0]),
        z=float(df["z"].to_numpy()[0]),
        vx=float(df["vx"].to_numpy()[0]),
        vy=float(df["vy"].to_numpy()[0]),
        vz=float(df["vz"].to_numpy()[0]),
    )


def _rms_arcsec(truth: np.ndarray, sim: np.ndarray) -> float:
    residuals = ((truth - sim + np.pi) % (2 * np.pi)) - np.pi
    return float(np.degrees(np.sqrt(np.mean(residuals**2))) * 3600.0)


def _print_result_summary(result, *, label: str) -> None:
    print(label)
    for i, name in enumerate(result.labels):
        med = result.median_params[i]
        lo = result.ci_16[i]
        hi = result.ci_84[i]
        if name == "sigma_rad":
            med = np.degrees(med) * 3600.0
            lo = np.degrees(lo) * 3600.0
            hi = np.degrees(hi) * 3600.0
            print(f"  sigma_arcsec : {med:.3f}  [{lo:.3f}, {hi:.3f}]")
        else:
            print(f"  {name:12s}: {med:.6g}  [{lo:.6g}, {hi:.6g}]")
    print(f"  Acceptance   : {result.acceptance_fraction:.3f}")
    print(f"  Autocorr tau : {', '.join(f'{t:.0f}' for t in result.autocorr_times)}")
    print(f"  ESS          : {', '.join(f'{v:.0f}' for v in result.ess)}")
    print(f"  Split-R_hat  : {', '.join(f'{v:.3f}' for v in result.rhat)}")
    print(f"  Eff. samples : {len(result.flat_samples):,}")



def _print_convergence_summary(result, *, label: str) -> None:
    tau_max = float(np.max(result.autocorr_times))
    steps_per_tau = float(result.n_steps) / tau_max if tau_max > 0 else float("inf")
    max_rhat = float(np.max(result.rhat))
    min_ess = float(np.min(result.ess))
    passed_50tau = steps_per_tau > 50.0
    verdict = "PASS" if passed_50tau and max_rhat < 1.05 else "CHECK"

    print(label)
    print(f"  Max tau      : {tau_max:.1f}")
    print(f"  Steps / tau  : {steps_per_tau:.1f}x")
    print(f"  50τ target   : {'passed' if passed_50tau else 'not reached'}")
    print(f"  Max R_hat    : {max_rhat:.3f}")
    print(f"  Min ESS      : {min_ess:.0f}")
    print(f"  Verdict      : {verdict}")
MCMC cache dir      : <local-path>
Synthetic cache     : <local-path>
JPL flat cache      : <local-path>
JPL Bode cache      : <local-path>
MCMC workers        : 4 (process-based, candidate set [1, 4, 6, 8])

Synthetic benchmark posterior

Running MCMC on the noiseless synthetic benchmark would push σ toward the lower prior bound — the forward model can reproduce its own observations exactly, so there is no residual floor to infer. To make the Bayesian problem well-posed, we inject 2 arcsec Gaussian noise into the synthetic Uranus longitudes and hold σ fixed at that known value.

The posterior then answers a clean question: given known measurement noise and 66 annual observations spanning 1781–1846, how tightly can MCMC constrain the four Neptune parameters (mass, semi-major axis, eccentricity, mean longitude)?

Code
TRUE_NEPTUNE = NeptuneCandidate(
    mass_solar=MASS_NEPTUNE_APPROX,
    a=30.07,
    e=0.009,
    l_rad=np.radians(132.0),
)
SIGMA_SYNTHETIC_ARCSEC = 2.0

logger.info("Building synthetic benchmark problem ...")
problem = build_synthetic_problem(TRUE_NEPTUNE)
synthetic_rng = np.random.default_rng(42)
synthetic_truth_clean = problem["truth_longs"]
synthetic_truth_longs = inject_noise(
    synthetic_truth_clean,
    sigma_arcsec=SIGMA_SYNTHETIC_ARCSEC,
    rng=synthetic_rng,
)

synthetic_mle = solve(
    jd_times=problem["jd_times"],
    truth_longs=synthetic_truth_longs,
    start_jd=problem["start_jd"],
    jupiter_sv=problem["jupiter_sv"],
    saturn_sv=problem["saturn_sv"],
    uranus_sv=problem["uranus_sv"],
    de_maxiter=30,
    de_popsize=5,
    seed=42,
)
synthetic_rms = np.degrees(
    np.sqrt(synthetic_mle.sse / len(problem["jd_times"]))
) * 3600.0

print(f"Synthetic observations: {len(problem['jd_times'])} annual epochs")
print(f"Injected sigma       : {SIGMA_SYNTHETIC_ARCSEC:.1f} arcsec")
print(
    f"MLE                  : a = {synthetic_mle.neptune.a:.3f} AU   "
    f"e = {synthetic_mle.neptune.e:.4f}   "
    f"l = {np.degrees(synthetic_mle.neptune.l_rad):.2f} deg"
)
print(f"Synthetic RMS        : {synthetic_rms:.2f} arcsec")
Synthetic observations: 66 annual epochs
Injected sigma       : 2.0 arcsec
MLE                  : a = 26.167 AU   e = 0.1399   l = 119.53 deg
Synthetic RMS        : 1.67 arcsec
Code
# Residuals before and after Neptune — visual context for what the MCMC is fitting
neptune_absent = NeptuneCandidate(
    mass_solar=1e-12, a=30.07, e=0.009, l_rad=synthetic_mle.neptune.l_rad,
)
sim_baseline = simulate_uranus_longitudes_from_vectors(
    problem["jd_times"], problem["start_jd"],
    problem["jupiter_sv"], problem["saturn_sv"], problem["uranus_sv"],
    neptune_absent,
)
sim_best = simulate_uranus_longitudes_from_vectors(
    problem["jd_times"], problem["start_jd"],
    problem["jupiter_sv"], problem["saturn_sv"], problem["uranus_sv"],
    synthetic_mle.neptune,
)
display(dual_render(
    plot_residuals,
    problem["jd_times"], synthetic_truth_longs, sim_baseline, sim_best,
    alt="Uranus residuals before and after Neptune perturbation",
))
Code
logger.info("Running fixed-sigma MCMC on noisy synthetic data ...")
synthetic_mcmc = run_mcmc_fixed_sigma(
    mle_result=synthetic_mle,
    jd_times=problem["jd_times"],
    truth_longs=synthetic_truth_longs,
    start_jd=problem["start_jd"],
    jupiter_sv=problem["jupiter_sv"],
    saturn_sv=problem["saturn_sv"],
    uranus_sv=problem["uranus_sv"],
    sigma_rad=np.radians(SIGMA_SYNTHETIC_ARCSEC / 3600.0),
    n_walkers=32,
    n_steps=40000,
    seed=42,
    backend_path=SYNTHETIC_POSTERIOR_CACHE,
    resume=True,
    n_workers=MCMC_WORKERS,
    progress=False,
)
_print_result_summary(synthetic_mcmc, label="Synthetic fixed-σ posterior")
_print_convergence_summary(synthetic_mcmc, label="Synthetic convergence summary")
synthetic_near_bounds = parameters_near_bounds(synthetic_mcmc.median_params, BOUNDS)
synthetic_mle_near_bounds = parameters_near_bounds(synthetic_mle.params, BOUNDS)

synthetic_truths = [
    TRUE_NEPTUNE.mass_solar,
    TRUE_NEPTUNE.a,
    TRUE_NEPTUNE.e,
    TRUE_NEPTUNE.l_rad,
]
synthetic_truths_outside_68 = tuple(
    name
    for name, truth, lo, hi in zip(
        synthetic_mcmc.labels,
        synthetic_truths,
        synthetic_mcmc.ci_16,
        synthetic_mcmc.ci_84,
        strict=True,
    )
    if not lo <= truth <= hi
)
print(f"Synthetic posterior boundary check: {synthetic_near_bounds or 'none'}")
print(f"Synthetic MLE boundary check: {synthetic_mle_near_bounds or 'none'}")
print(f"Planted values outside the 68% intervals: {synthetic_truths_outside_68 or 'none'}")
display(dual_render(plot_traces, synthetic_mcmc, alt="MCMC trace plots — synthetic fixed-sigma posterior"))
display(dual_render(plot_corner, synthetic_mcmc, truths=synthetic_truths, alt="Corner plot — synthetic fixed-sigma posterior"))
Synthetic fixed-σ posterior
  mass_solar  : 6.93869e-05  [5.2767e-05, 8.77008e-05]
  a           : 30.3332  [28.5895, 32.1092]
  e           : 0.0511022  [0.0159365, 0.101954]
  l_rad       : 2.2948  [2.21629, 2.3603]
  Acceptance   : 0.279
  Autocorr tau : 666, 564, 324, 500
  ESS          : 1160, 1531, 3318, 1881
  Split-R_hat  : 1.028, 1.021, 1.010, 1.017
  Eff. samples : 6,432
Synthetic convergence summary
  Max tau      : 666.0
  Steps / tau  : 51.0x
  50τ target   : passed
  Max R_hat    : 1.028
  Min ESS      : 1160
  Verdict      : PASS
Synthetic posterior boundary check: none
Synthetic MLE boundary check: none
Planted values outside the 68% intervals: ('mass_solar', 'e')

JPL comparison posterior

When the simplified 4-parameter Neptune model is confronted with JPL Horizons Uranus longitudes, its residuals include structured model discrepancy from simplifications such as coplanar orbits, fixed perihelion, and missing planets. The JPL fit therefore uses a stationary AR(1) residual model with free marginal scale σ and correlation ρ. These are model-conditional discrepancy parameters, not a measurement of an irreducible physical noise floor.

This is also the natural place to compare flat and Bode-style priors. The mass-distance degeneracy ridge is broad enough in the JPL regime that prior pressure visibly shifts the posterior — the Bode prior (centered near 38.8 AU) pulls the semi-major axis estimate higher, illustrating why Le Verrier’s distance was biased while his longitude prediction remained accurate.

Code
logger.info("Loading cached JPL vectors ...")
df_uranus = fetch_planet_vectors(PLANET_IDS["uranus"], use_cache=True)
df_jupiter = fetch_planet_vectors(PLANET_IDS["jupiter"], use_cache=True)
df_saturn = fetch_planet_vectors(PLANET_IDS["saturn"], use_cache=True)

jpl_jd_times = df_uranus["datetime_jd"].to_numpy(dtype=float)
jpl_truth_longs = df_uranus["longitude_rad"].to_numpy(dtype=float)
jpl_start_jd = float(jpl_jd_times[0])
jpl_jupiter_sv = _sv_from_df(df_jupiter)
jpl_saturn_sv = _sv_from_df(df_saturn)
jpl_uranus_sv = _sv_from_df(df_uranus)

logger.info("Running JPL MLE baseline ...")
jpl_mle = solve(
    jd_times=jpl_jd_times,
    truth_longs=jpl_truth_longs,
    start_jd=jpl_start_jd,
    jupiter_sv=jpl_jupiter_sv,
    saturn_sv=jpl_saturn_sv,
    uranus_sv=jpl_uranus_sv,
    de_maxiter=30,
    de_popsize=5,
    seed=42,
)
jpl_mle_rms = np.degrees(np.sqrt(jpl_mle.sse / len(jpl_jd_times))) * 3600.0
print(
    f"JPL MLE              : a = {jpl_mle.neptune.a:.3f} AU   "
    f"e = {jpl_mle.neptune.e:.4f}   "
    f"l = {np.degrees(jpl_mle.neptune.l_rad):.2f} deg"
)
print(f"JPL RMS (historical search domain): {jpl_mle_rms:.2f} arcsec")
print()

logger.info("Running JPL prior comparison MCMC ...")
jpl_comparison = run_prior_comparison(
    mle_result=jpl_mle,
    jd_times=jpl_jd_times,
    truth_longs=jpl_truth_longs,
    start_jd=jpl_start_jd,
    jupiter_sv=jpl_jupiter_sv,
    saturn_sv=jpl_saturn_sv,
    uranus_sv=jpl_uranus_sv,
    n_walkers=32,
    n_steps=26000,
    seed=42,
    flat_backend_path=JPL_FLAT_POSTERIOR_CACHE,
    bode_backend_path=JPL_BODE_POSTERIOR_CACHE,
    resume=True,
    n_workers=MCMC_WORKERS,
    residual_model="ar1",
    progress=False,
)
jpl_flat = jpl_comparison.flat
jpl_bode = jpl_comparison.bode

_print_result_summary(jpl_flat, label="JPL posterior (flat prior, AR(1) σ and ρ)")
_print_convergence_summary(jpl_flat, label="JPL flat-prior convergence summary")
print()
_print_result_summary(jpl_bode, label="JPL posterior (Bode prior, AR(1) σ and ρ)")
_print_convergence_summary(jpl_bode, label="JPL Bode-prior convergence summary")

near_bounds = parameters_near_bounds(
    jpl_flat.median_params,
    AR1_MCMC_BOUNDS,
    parameter_names=AR1_MCMC_PARAM_NAMES,
)
print(f"JPL flat-prior boundary check: {near_bounds or 'none'}")
if "rho" in near_bounds:
    print("  rho is near the upper bound; interpret intervals as boundary-sensitive.")

display(dual_render(plot_traces, jpl_flat, alt="MCMC trace plots — JPL flat prior"))
display(dual_render(plot_corner, jpl_flat, alt="Corner plot — JPL flat prior"))
display(dual_render(plot_prior_cmp, jpl_comparison, alt="Flat vs Bode prior comparison"))
JPL MLE              : a = 32.578 AU   e = 0.1393   l = 213.78 deg
JPL RMS (historical search domain): 2.52 arcsec

JPL posterior (flat prior, AR(1) σ and ρ)
  mass_solar  : 0.000122843  [0.000110007, 0.000138115]
  a           : 36.6476  [35.5973, 37.905]
  e           : 0.16355  [0.126627, 0.201581]
  l_rad       : 3.96126  [3.84403, 4.08609]
  sigma_arcsec : 2.366  [1.658, 3.607]
  rho         : 0.997383  [0.994578, 0.998895]
  Acceptance   : 0.341
  Autocorr tau : 296, 335, 345, 344, 393, 312
  ESS          : 1876, 1655, 1487, 1528, 784, 759
  Split-R_hat  : 1.014, 1.015, 1.017, 1.016, 1.032, 1.032
  Eff. samples : 4,128
JPL flat-prior convergence summary
  Max tau      : 393.1
  Steps / tau  : 50.9x
  50τ target   : passed
  Max R_hat    : 1.032
  Min ESS      : 759
  Verdict      : PASS

JPL posterior (Bode prior, AR(1) σ and ρ)
  mass_solar  : 0.00012477  [0.000112082, 0.000138898]
  a           : 36.7953  [35.7481, 37.9958]
  e           : 0.167777  [0.132902, 0.203584]
  l_rad       : 3.97642  [3.8648, 4.09377]
  sigma_arcsec : 2.326  [1.615, 3.530]
  rho         : 0.997364  [0.994318, 0.998852]
  Acceptance   : 0.340
  Autocorr tau : 258, 288, 310, 302, 302, 239
  ESS          : 1754, 1544, 1373, 1427, 1228, 1217
  Split-R_hat  : 1.015, 1.016, 1.020, 1.019, 1.025, 1.026
  Eff. samples : 4,128
JPL Bode-prior convergence summary
  Max tau      : 310.0
  Steps / tau  : 51.6x
  50τ target   : passed
  Max R_hat    : 1.026
  Min ESS      : 1217
  Verdict      : PASS
JPL flat-prior boundary check: ('rho',)
  rho is near the upper bound; interpret intervals as boundary-sensitive.
Code
# Stationary marginal discrepancy scale under the AR(1) model
sigma_samples_arcsec = np.degrees(jpl_flat.flat_samples[:, 4]) * 3600.0
med_sigma = float(np.median(sigma_samples_arcsec))


def _build_sigma_hist(theme="light"):
    apply_style(theme)
    palette(theme)
    cy = category_cycle(theme)
    fig, ax = plt.subplots(figsize=fig_size("wide"))
    ax.hist(sigma_samples_arcsec, bins=60, density=True, color=cy[2], alpha=0.7,
            edgecolor="white", linewidth=0.5)
    ax.axvline(jpl_mle_rms, color=cy[3], ls="--", lw=1.5,
               label=f'MLE RMS: {jpl_mle_rms:.2f}"')
    ax.axvline(med_sigma, color=cy[0], ls="-", lw=1.5,
               label=f'Posterior median: {med_sigma:.2f}"')
    ax.set_xlabel("σ (arcsec)")
    ax.set_ylabel("Density")
    ax.set_title("AR(1) stationary discrepancy scale — JPL flat prior")
    ax.legend()
    fig.tight_layout()
    return fig


display(dual_render(_build_sigma_hist, alt="Sigma posterior marginal histogram — JPL flat prior"))
Code
# Parameter-conditional residual envelope. This propagates orbital-parameter
# uncertainty only; the calibrated posterior predictive check is in validation.ipynb.
n_draws = 100
rng = np.random.default_rng(0)
draw_indices = rng.choice(len(jpl_flat.flat_samples), size=n_draws, replace=False)

jpl_years = (jpl_jd_times - jpl_jd_times[0]) / 365.25 + 1781
residual_draws = np.empty((n_draws, len(jpl_jd_times)))

for i, idx in enumerate(draw_indices):
    sample = jpl_flat.flat_samples[idx]
    candidate = NeptuneCandidate(
        mass_solar=float(sample[0]),
        a=float(sample[1]),
        e=float(sample[2]),
        l_rad=float(sample[3]),
    )
    sim_longs = simulate_uranus_longitudes_from_vectors(
        jpl_jd_times, jpl_start_jd,
        jpl_jupiter_sv, jpl_saturn_sv, jpl_uranus_sv, candidate,
    )
    delta = ((jpl_truth_longs - sim_longs + np.pi) % (2 * np.pi)) - np.pi
    residual_draws[i] = np.degrees(delta) * 3600.0

q10 = np.percentile(residual_draws, 10, axis=0)
q50 = np.percentile(residual_draws, 50, axis=0)
q90 = np.percentile(residual_draws, 90, axis=0)

# Observed residuals at MLE for reference
mle_sim = simulate_uranus_longitudes_from_vectors(
    jpl_jd_times, jpl_start_jd,
    jpl_jupiter_sv, jpl_saturn_sv, jpl_uranus_sv, jpl_mle.neptune,
)
mle_delta = ((jpl_truth_longs - mle_sim + np.pi) % (2 * np.pi)) - np.pi
mle_residuals = np.degrees(mle_delta) * 3600.0


def _build_ppc_band(theme="light"):
    apply_style(theme)
    palette(theme)
    cy = category_cycle(theme)
    fig, ax = plt.subplots(figsize=fig_size("double"))
    ax.fill_between(jpl_years, q10, q90, alpha=0.3, color=cy[0], label="80% posterior band")
    ax.plot(jpl_years, q50, lw=1.2, color=cy[0], label="Posterior median")
    ax.scatter(jpl_years, mle_residuals, s=12, color=cy[1], zorder=5, label="MLE residuals")
    truth_line(ax, 0, theme=theme)
    ax.set_xlabel("Year")
    ax.set_ylabel("Residual (arcsec)")
    ax.set_title("Orbital-parameter residual envelope — JPL AR(1) fit")
    ax.legend(loc="upper left")
    return fig


display(dual_render(_build_ppc_band, alt="Orbital-parameter residual envelope — JPL AR(1) fit"))

Interpretation

The two regimes answer different questions by design:

  • Synthetic benchmark: with σ fixed at the known 2 arcsec injection level, the posterior is a controlled calibration example, not a guarantee that every planted value lies in a 68% interval. In this committed realization the mass and eccentricity truths fall just below their 16th percentiles, while the MLE eccentricity is high (0.1399 versus the 0.15 cap) but not within the diagnostic’s 1% boundary tolerance. The notebook reports both boundary checks and the interval misses explicitly; the mass–semi-major axis correlation reveals the degeneracy ridge that makes the noisy inverse problem weakly identified.

  • JPL comparison: the six-parameter fit uses a stationary AR(1) discrepancy model because the independent-Gaussian residuals are strongly autocorrelated. Its intervals remain model-conditional: convergence, boundary sensitivity, SBC, and posterior-predictive checks determine whether they are calibrated. The reported rho is near the upper bound, and modern Neptune lies outside the displayed orbital intervals. This is evidence of simplified-model discrepancy, not a physical recovery of Neptune’s orbit. The optimizer RMS is reported only within its stated search domain, not as an irreducible floor.

  • Mass-distance degeneracy: the strong positive correlation between mass and semi-major axis appears in both regimes, but it is more scientifically interesting in the JPL case, where the ridge is broad enough for prior choice to matter. A more distant Neptune must be more massive to produce the same Uranus perturbations, so the two parameters trade off while longitude stays tightly constrained.