codex/past-exam-of-the-differential-equations-2-course-of-the-university-of-oxford/2014-quadratic-phase-portrait.py

past-exam-of-the-differential-equations-2-course-of-the-university-of-oxford/2014-quadratic-phase-portrait.py
"""Phase sketch for a quadratic planar system; PNG output is in the CWD.

Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7 as declared in
the repository pyproject.toml. Invariant manifolds are numerical sketches
from small eigenvector displacements, using an adaptive-step RK4 trace.
"""

from pathlib import Path

import matplotlib

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


def field(z):
    x, y = z
    return np.array([x * (1.0 - x - y), 2.0 - 2.0 * x - y])


def trace(seed, direction, bounds):
    z = np.array(seed, dtype=float)
    points = [z.copy()]
    xmin, xmax, ymin, ymax = bounds
    for _ in range(14000):
        speed = np.linalg.norm(field(z))
        if speed < 1.0e-9:
            break
        dt = direction * 0.008 / max(1.0, speed)
        k1 = field(z)
        k2 = field(z + dt * k1 / 2)
        k3 = field(z + dt * k2 / 2)
        k4 = field(z + dt * k3)
        z = z + dt * (k1 + 2 * k2 + 2 * k3 + k4) / 6
        points.append(z.copy())
        if not (xmin <= z[0] <= xmax and ymin <= z[1] <= ymax):
            break
    return np.array(points)


def flow_arrow(ax, points, color):
    length = np.r_[0, np.cumsum(np.linalg.norm(np.diff(points, axis=0), axis=1))]
    if length[-1] < 0.2:
        return
    z = points[np.searchsorted(length, 0.55 * length[-1])]
    velocity = field(z)
    velocity = velocity / np.linalg.norm(velocity)
    ax.annotate("", xy=z + 0.09 * velocity, xytext=z - 0.09 * velocity,
                arrowprops={"arrowstyle": "-|>", "color": color, "lw": 1.8})


def main():
    bounds = (-0.5, 2.2, -1.2, 3.5)
    xmin, xmax, ymin, ymax = bounds
    x = np.linspace(xmin, xmax, 240)
    y = np.linspace(ymin, ymax, 260)
    xx, yy = np.meshgrid(x, y)
    u = xx * (1 - xx - yy)
    v = 2 - 2 * xx - yy
    speed = np.hypot(u, v)
    denominator = np.maximum(speed, 1.0e-12)

    fig, ax = plt.subplots(figsize=(6.5, 6.0), layout="constrained")
    fig.set_facecolor("white")
    ax.set_facecolor("white")
    ax.streamplot(x, y, u / denominator, v / denominator, color="#b7bec4",
                  linewidth=0.65, density=1.35, arrowsize=0.8, zorder=1)
    ax.axvline(0, color="#4c7592", ls="--", lw=1.2,
               label="x nullclines: x=0, y=1-x")
    ax.plot(x, 1 - x, color="#4c7592", ls="--", lw=1.2)
    ax.plot(x, 2 - 2 * x, color="#b17b24", ls="--", lw=1.2,
            label="y nullcline: y=2-2x")

    saddle = np.array([1.0, 0.0])
    for name, eigenvector, direction, color in [
        ("Stable manifold", [1, np.sqrt(2)], -1, "#7e3d9b"),
        ("Unstable manifold", [1, -np.sqrt(2)], 1, "#c94336"),
    ]:
        eig = np.array(eigenvector) / np.linalg.norm(eigenvector)
        for sign in [-1, 1]:
            points = trace(saddle + sign * 1.0e-4 * eig, direction, bounds)
            ax.plot(points[:, 0], points[:, 1], color=color, lw=2.0,
                    label=name if sign == -1 else None, zorder=3)
            flow_arrow(ax, points, color)

    ax.scatter([0], [2], color="#1967ac", s=55, zorder=5)
    ax.scatter([1], [0], color="black", s=55, marker="x", zorder=5)
    ax.annotate("Stable improper node\n(0, 2)", (0, 2), xytext=(12, 12),
                textcoords="offset points", fontsize=10,
                bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.85})
    ax.annotate("Saddle (1, 0)", (1, 0), xytext=(12, 6),
                textcoords="offset points", fontsize=10,
                bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.85})
    ax.set(xlim=(xmin, xmax), ylim=(ymin, ymax), xlabel="x", ylabel="y",
           title="Nullclines, flow directions and saddle manifolds")
    ax.set_aspect("equal")
    ax.legend(loc="upper right", fontsize=8, framealpha=0.95)
    ax.grid(alpha=0.12)
    fig.savefig(Path("2014-quadratic-phase-portrait.png"), dpi=135, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()