This notebook maps the posterior distribution of Neptune’s orbital parameters using emcee’s affine-invariant ensemble sampler, in two complementary regimes:
Synthetic benchmark (4-parameter): inject known Gaussian noise into synthetic Uranus longitudes and run MCMC with σ fixed at the injected value. This tests whether the posterior recovers the planted Neptune with correct uncertainties.
JPL comparison (5-parameter): fit the simplified Neptune model to real JPL Horizons Uranus longitudes and treat σ as a free parameter. Here σ absorbs the mismatch between the 4-parameter model and the modern ephemeris, and the flat-vs-Bode prior comparison reveals how prior assumptions reshape the mass-distance ridge.
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
from IPython.display import displayfrom pathlib import Pathimport loggingimport matplotlib.pyplot as pltimport numpy as npfrom discoverneptune.bayesian import run_mcmc_fixed_sigma, run_prior_comparisonfrom discoverneptune.data import PLANET_IDS, fetch_planet_vectorsfrom discoverneptune.sensitivity import inject_noisefrom discoverneptune.simulation import ( MASS_NEPTUNE_APPROX, NeptuneCandidate, StateVector, simulate_uranus_longitudes_from_vectors,)from discoverneptune.solver import solvefrom discoverneptune.synthetic import build_synthetic_problemfrom discoverneptune.plotting import ( plot_corner, plot_prior_comparison as plot_prior_cmp, plot_residuals, plot_traces,)# plot_style imports added by transform scriptfrom discoverneptune.plot_style import ( apply_style, palette, category_cycle, dual_render, SEQUENTIAL_CMAP, truth_line, annotate_stat, fig_size,)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.parentBAYESIAN_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 Siliconprint(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.pireturnfloat(np.degrees(np.sqrt(np.mean(residuals**2))) *3600.0)def _print_result_summary(result, *, label: str) ->None:print(label)for i, name inenumerate(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.0print(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 >0elsefloat("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.05else"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}")
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)?
Synthetic observations: 66 annual epochs
Injected sigma : 2.0 arcsec
MLE : a = 31.173 AU e = 0.0126 l = 133.99 deg
Synthetic RMS : 1.57 arcsec
Code
# Residuals before and after Neptune — visual context for what the MCMC is fittingneptune_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",))
Synthetic fixed-σ posterior
mass_solar : 6.86549e-05 [5.1137e-05, 8.65407e-05]
a : 30.2095 [28.3883, 32.0713]
e : 0.0532538 [0.0152844, 0.102868]
l_rad : 2.28886 [2.20991, 2.3593]
Acceptance : 0.270
Autocorr tau : 802, 650, 331, 568
ESS : 727, 1312, 3732, 1902
Split-R_hat : 1.032, 1.022, 1.010, 1.018
Eff. samples : 7,424
Synthetic convergence summary
Max tau : 802.4
Steps / tau : 49.9x
50τ target : not reached
Max R_hat : 1.032
Min ESS : 727
Verdict : CHECK
JPL comparison posterior
When the simplified 4-parameter Neptune model is confronted with real JPL Horizons Uranus longitudes, the residual floor is no longer zero — it reflects genuine model-fidelity limitations (coplanar orbits, fixed perihelion, missing planets). Treating σ as a free fifth parameter lets the posterior absorb that mismatch floor, giving σ a physically meaningful interpretation rather than an arbitrary fixed value.
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.
The two regimes answer different questions by design:
Synthetic benchmark: with σ fixed at the known 2 arcsec injection level, the posterior tests whether MCMC recovers the planted Neptune. The corner plot should show the true parameters inside the credible regions, and the mass–semi-major axis correlation reveals the degeneracy ridge even in this controlled setting.
JPL comparison: with σ free, the posterior quantifies both Neptune’s orbital uncertainties and the model’s residual floor. The inferred σ (typically ~2.5 arcsec) matches the optimizer’s RMS residual, confirming that the likelihood is well-calibrated. The flat-vs-Bode prior comparison shows that prior pressure on semi-major axis shifts the mass estimate along the degeneracy ridge without meaningfully changing the longitude — the same qualitative finding as Le Verrier’s historical experience.
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.