codex/duffing-homoclinic-loops.py

duffing-homoclinic-loops.py
"""Draw the exact separatrices of x'' = x - x**3.

Build: make _media/duffing-homoclinic-loops.png
Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Writes an opaque same-basename PNG to the working directory.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np


def loop(t):
    x = np.sqrt(2) / np.cosh(t)
    return x, -x * np.tanh(t)


def main():
    fig, ax = plt.subplots(figsize=(8, 5), dpi=110, facecolor="white")
    x = np.linspace(-1.85, 1.85, 501)
    v = np.linspace(-1.15, 1.15, 401)
    xx, vv = np.meshgrid(x, v)
    energy = vv**2 / 2 - xx**2 / 2 + xx**4 / 4
    cs = ax.contour(xx, vv, energy, levels=[-.20, -.08, .14],
                    colors="#94a3b8", linewidths=1.0, linestyles="solid")
    ax.clabel(cs, fmt=lambda h: f"H={h:.2f}", fontsize=9)
    t = np.linspace(-9, 9, 1501)
    lx, lv = loop(t)
    for sign in [-1, 1]:
        ax.plot(sign * lx, sign * lv, color="#c75b20", lw=2.5)
        for start in [-1.6, .4]:
            a = np.array(loop(start)) * sign
            b = np.array(loop(start + .24)) * sign
            ax.annotate("", xy=b, xytext=a,
                        arrowprops=dict(arrowstyle="->", color="#c75b20", lw=2))
    ax.plot(0, 0, "x", color="#111827", ms=10, mew=2)
    ax.annotate("saddle", xy=(0, 0), xytext=(0, .37), ha="center",
                arrowprops=dict(arrowstyle="->", color="#111827"))
    ax.plot([-1, 1], [0, 0], "o", color="#2563a0", ms=6)
    ax.text(-1, -.14, "center", ha="center", color="#2563a0")
    ax.text(1, -.14, "center", ha="center", color="#2563a0")
    ax.set(xlim=(-1.85, 1.85), ylim=(-1.15, 1.15), xlabel=r"$x$",
           ylabel=r"$v=\dot x$", title=r"$\ddot x=x-x^3$: two homoclinic loops at $H=0$")
    ax.set_aspect("equal")
    ax.grid(alpha=.15)
    fig.tight_layout()
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()