codex/past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2010/iii/paper-64-phase-plane.py

past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2010/iii/paper-64-phase-plane.py
"""Fisher front phase planes; Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.

Write an opaque PNG basename to the caller's CWD. Honour caller MPLCONFIGDIR.
"""

import numpy as np
import matplotlib

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


def trajectory(c, dt=0.02, duration=65):
    r = (-c + np.sqrt(c * c + 4)) / 2
    state = np.array([1 - 1e-5, -r * 1e-5])
    points = [state.copy()]

    def rhs(x):
        w, p = x
        return np.array([p, -c * p - w * (1 - w)])

    for _ in range(round(duration / dt)):
        a = rhs(state)
        b = rhs(state + dt * a / 2)
        d = rhs(state + dt * b / 2)
        e = rhs(state + dt * d)
        state = state + dt * (a + 2 * b + 2 * d + e) / 6
        points.append(state.copy())
    return np.array(points)


def main():
    fig, axes = plt.subplots(1, 2, figsize=(10, 4.6), layout="constrained")
    fig.patch.set_facecolor("white")
    for ax, c in zip(axes, [2.5, 1.0]):
        ax.set_facecolor("white")
        xx, yy = np.meshgrid(np.linspace(-0.16, 1.08, 16),
                             np.linspace(-0.65, 0.18, 13))
        vx, vy = yy, -c * yy - xx * (1 - xx)
        norm = np.maximum(np.hypot(vx, vy), 1e-12)
        ax.quiver(xx, yy, vx / norm, vy / norm, color="#adb5bd",
                  alpha=0.75, scale=30, width=0.003)
        if c > 2:
            a = (c - np.sqrt(c * c - 4)) / 2
            ax.fill([0, 1, 1], [0, 0, -a], color="#e4f2e7",
                    label="Forward-invariant triangle")
            ax.plot([0, 1], [0, -a], "--", color="#35704a",
                    label=r"$p=-aw$, $a=0.5$")
        points = trajectory(c)
        ax.plot(points[:, 0], points[:, 1], color="#125ba3", lw=2.2,
                label="Descending saddle trajectory")
        if c < 2:
            crossing = np.flatnonzero(points[:, 0] < 0)[0]
            ax.scatter(*points[crossing], color="#b42323", s=32, zorder=5,
                       label="Profile changes sign")
        ax.scatter([0, 1], [0, 0], color="black", s=26, zorder=5)
        ax.axhline(0, color="#888888", lw=0.6)
        ax.axvline(0, color="#888888", lw=0.6)
        ax.set(xlim=(-0.17, 1.1), ylim=(-0.67, 0.2), xlabel=r"$w$",
               ylabel=r"$p=w'$", title=f"k = 1, c = {c:g}")
        ax.legend(loc="lower left", fontsize=8, framealpha=1)
    fig.savefig("paper-64-phase-plane.png", dpi=110, facecolor="white",
                transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()