codex/kepler-ellipse-equal-time-swept-sectors.py

kepler-ellipse-equal-time-swept-sectors.py
"""Equal-time focus sectors; tested with Python 3.14, Matplotlib 3.10.7, NumPy 2.3.5.

Run from _media; writes the matching PNG basename to the working directory.
The dimensionless ellipse is an illustration, not a fit to planetary measurements.
"""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Polygon


def eccentric_anomaly(mean_anomaly, eccentricity):
    """Monotone bisection, including negative and unwrapped mean anomalies."""
    mean_anomaly = np.asarray(mean_anomaly, dtype=float)
    low, high = mean_anomaly - eccentricity, mean_anomaly + eccentricity
    for _ in range(60):
        middle = (low + high) / 2
        residual = middle - eccentricity * np.sin(middle) - mean_anomaly
        low = np.where(residual < 0, middle, low)
        high = np.where(residual >= 0, middle, high)
    return (low + high) / 2


def main():
    a, e, mu, dt = 1.0, 0.6, 1.0, 0.5
    b = a * np.sqrt(1 - e * e)
    n = np.sqrt(mu / a**3)
    h = np.sqrt(mu * a * (1 - e * e))
    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white", "axes.facecolor": "white"})
    fig, ax = plt.subplots(figsize=(8.4, 5.6), layout="constrained")
    E = np.linspace(0, 2 * np.pi, 1600)
    ax.plot(a * (np.cos(E) - e), b * np.sin(E), color="#343f4b", lw=2)
    colors = ["#d36a25", "#286fae"]
    for center, color in zip([0.0, np.pi], colors, strict=True):
        M = np.linspace(center - n * dt / 2, center + n * dt / 2, 2001)
        E = eccentric_anomaly(M, e)
        xy = np.column_stack((a * (np.cos(E) - e), b * np.sin(E)))
        ax.add_patch(Polygon(np.vstack(([0, 0], xy, [0, 0])), facecolor=color, alpha=0.28, edgecolor="none"))
        ax.plot(xy[:, 0], xy[:, 1], color=color, lw=3)
        for end in [xy[0], xy[-1]]:
            ax.plot([0, end[0]], [0, end[1]], color=color, lw=1.5)
            ax.scatter(*end, color=color, s=28, zorder=5)
        ax.annotate("", xy=xy[1150], xytext=xy[1050], arrowprops={"arrowstyle": "->", "color": color, "lw": 2})
        # Check the actual plotted polygon, independently of the analytic sector formula.
        polygon_area = 0.5 * abs(np.sum(xy[:-1, 0] * xy[1:, 1] - xy[:-1, 1] * xy[1:, 0]))
        assert abs(polygon_area - h * dt / 2) < 2e-7
        exact_area = a * b / 2 * ((E[-1] - e * np.sin(E[-1])) - (E[0] - e * np.sin(E[0])))
        assert abs(exact_area - 0.2) < 1e-14
        print(f"sector centered at M={center:.8f}: polygon area={polygon_area:.9f}; exact={exact_area:.9f}")
    ax.scatter(0, 0, s=90, marker="*", color="#111111", zorder=6)
    ax.text(0.03, -0.12, "force centre\n(focus)", ha="left", va="top", fontsize=11)
    ax.text(0.36, 1.07, "Near periapsis\nshort radius, wide angle\narea = 0.200", color=colors[0], ha="center", fontsize=11)
    ax.text(-1.30, 1.07, "Near apoapsis\nlong radius, narrow angle\narea = 0.200", color=colors[1], ha="center", fontsize=11)
    ax.set(xlim=(-1.95, 0.95), ylim=(-1.00, 1.40), xlabel="x / a", ylabel="y / a")
    ax.set_aspect("equal")
    ax.set_title("Equal-time sectors on a Kepler ellipse", pad=27, fontsize=16)
    ax.text(0.5, 1.025, r"Illustrative units: $a=1,\ e=0.6,\ \mu=1,\ \Delta t=0.5,\ h=0.8$", transform=ax.transAxes, ha="center", fontsize=11)
    ax.spines[["top", "right"]].set_visible(False)
    ax.grid(alpha=0.16)
    fig.savefig(Path(__file__).with_suffix(".png").name, dpi=120, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()