codexbinary-pulsar-kepler-delay-and-period-drift.py
binary-pulsar-kepler-delay-and-period-drift.py
"""Chosen Kepler timing model and secular drift; no observations or historical fit.
Python 3.14, NumPy, Matplotlib. Run from _media. Newton's iteration is
independently checked against bisection and Kepler-equation residuals.
"""
from pathlib import Path
import json
import matplotlib.pyplot as plt
import numpy as np
def eccentric_anomaly(mean, eccentricity):
E = np.asarray(mean, dtype=float).copy()
for _ in range(16):
E -= (E - eccentricity * np.sin(E) - mean) / (1 - eccentricity * np.cos(E))
return E
def delay(mean, eccentricity, omega, x):
E = eccentric_anomaly(mean, eccentricity)
return x * (np.sin(omega) * (np.cos(E) - eccentricity)
+ np.sqrt(1 - eccentricity**2) * np.cos(omega) * np.sin(E))
def checks():
e, x, omega, pb = 0.6, 2.0, np.deg2rad(40), 28800.0
mean = np.linspace(0, 2 * np.pi, 1001)
E = eccentric_anomaly(mean, e)
residual = float(np.max(np.abs(E - e * np.sin(E) - mean)))
# Bracketing is a different root-finding algorithm; f' >= 1-e > 0.
lo, hi = np.zeros_like(mean), np.full_like(mean, 2 * np.pi)
for _ in range(60):
mid = (lo + hi) / 2
left = mid - e * np.sin(mid) < mean
lo = np.where(left, mid, lo); hi = np.where(left, hi, mid)
agreement = float(np.max(np.abs(E - (lo + hi) / 2)))
assert residual < 2e-14 and agreement < 3e-14
assert np.max(np.abs(delay(mean, 0, omega, x) - x * np.sin(mean + omega))) < 2e-15
assert abs(np.ptp(delay(np.array([0, 2 * np.pi]), e, omega, x))) < 2e-14
# Finite differences independently check dz/dt against the anomaly derivative.
test = np.linspace(0.03, 2 * np.pi - 0.03, 301)
Et = eccentric_anomaly(test, e)
h = 1e-5
fd = (delay(test + h, e, omega, x) - delay(test - h, e, omega, x)) / (2*h)
analytic = x * (-np.sin(omega)*np.sin(Et) + np.sqrt(1-e*e)*np.cos(omega)*np.cos(Et)) / (1-e*np.cos(Et))
derivative_error = float(np.max(np.abs(fd - analytic)))
assert derivative_error < 4e-9
# Direct orbital-plane rotation equals the analytic projected delay.
projected = x * (np.sin(omega)*(np.cos(E)-e) + np.cos(omega)*np.sqrt(1-e*e)*np.sin(E))
assert np.max(np.abs(projected - delay(mean, e, omega, x))) < 2e-15
year = 365.25 * 86400
t = 20 * year; pdot = -2e-12
shift = 0.5 * pdot / pb * t*t
# Integrate cycles exactly for P(t)=Pb+pdot*t, then convert excess cycles to seconds.
excess = (np.log1p(pdot*t/pb) - pdot*t/pb) / pdot
exact_shift = -pb * excess
assert abs(exact_shift - shift) < 2e-6
G, c, solar_mass = 6.67430e-11, 299792458.0, 1.98847e30
mass_function = 4*np.pi**2*(c*x)**3/(G*pb**2)/solar_mass
print(json.dumps({"kepler_max_residual_rad": residual, "newton_bisection_max_difference_rad": agreement,
"delay_derivative_max_error_s_per_rad": derivative_error,
"mass_function_solar_masses": mass_function,
"period_change_after_20_years_ms": pdot*t*1000,
"periastron_time_shift_after_20_years_s": shift,
"exact_linear_period_integral_shift_s": exact_shift}))
def main():
checks()
plt.rcParams.update({"font.size": 11, "figure.facecolor": "white"})
fig, axes = plt.subplots(2, 1, figsize=(8.8, 7.1), layout="constrained")
phase = np.linspace(0, 1, 801); mean = 2*np.pi*phase
omega, x = np.deg2rad(40), 2.0
axes[0].plot(phase, delay(mean, 0.6, omega, x), color="#246799", lw=2.5, label=r"eccentricity $e=0.6$")
axes[0].plot(phase, delay(mean, 0, omega, x), color="#9a6125", lw=1.7, ls="--", label=r"circular comparison $e=0$")
axes[0].axhline(0, color="0.7", lw=0.8)
axes[0].set(xlabel=r"mean orbital phase $M/(2\pi)$", ylabel=r"geometric delay $\Delta_R$ (s)", xlim=(0, 1),
title=r"Chosen orbit: $x=2$ s, $P_b=8$ h, $\omega=40^\circ$")
axes[0].legend(loc="upper right", fontsize=10)
years = np.linspace(0, 20, 301); t = years * 365.25 * 86400
shift = 0.5 * (-2e-12) / 28800 * t*t
axes[1].plot(years, shift, color="#246799", lw=2.5)
axes[1].axhline(0, color="0.5", lw=1, ls="--")
axes[1].set(xlabel="elapsed time (Julian years)", ylabel="periastron time shift (s)", xlim=(0, 20),
title=r"Separately chosen secular model: $\dot P_b=-2\times10^{-12}$ s/s")
axes[1].annotate(f"earlier by {abs(shift[-1]):.2f} s\nperiod shorter by 1.26 ms", xy=(20, shift[-1]),
xytext=(8.8, -7), arrowprops={"arrowstyle": "->", "color": "#246799"}, fontsize=10.5)
for ax in axes:
ax.grid(alpha=0.18); ax.spines[["top", "right"]].set_visible(False)
fig.suptitle("Illustrative timing models — no historical data or fit", fontsize=14)
fig.savefig(Path(__file__).stem + ".png", dpi=120, facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()