codex/past-exam-of-the-algebra-analysis-and-differential-equations-1-course-of-the-university-of-oxford/2011-phase-plane.py

past-exam-of-the-algebra-analysis-and-differential-equations-1-course-of-the-university-of-oxford/2011-phase-plane.py
"""Oxford AC1 2011: phase portrait, written to the process CWD.

Output: 2011-phase-plane.png, white background.
Tested: Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Dependencies are recorded in the repository root pyproject.toml.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np


def field(v):
    x, y = v
    return np.array([x*(y-x+1), y*(2*y-x-2)])


def trajectory(start, direction):
    v = np.asarray(start, dtype=float)
    points = [v.copy()]
    def rhs(z):
        f = field(z)
        return direction*f/(1+np.linalg.norm(f))
    for _ in range(7000):
        h = .025
        k1 = rhs(v)
        k2 = rhs(v+h*k1/2)
        k3 = rhs(v+h*k2/2)
        k4 = rhs(v+h*k3)
        v = v+h*(k1+2*k2+2*k3+k4)/6
        points.append(v.copy())
        if not (-1.6 < v[0] < 6.6 and -1.6 < v[1] < 6.1):
            break
        if len(points)>100 and np.linalg.norm(field(v))<1e-7:
            break
    return np.asarray(points)


def main():
    fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.8), facecolor="white", gridspec_kw={"width_ratios":[1.35, 1]})
    stable, unstable = "#1767a0", "#b64e17"
    for ax, limits in zip(axes, [(-1.2, 6.2, -1.2, 5.7), (-.25, 1.75, -.25, 1.75)]):
        a, b, c, d = limits
        x, y = np.linspace(a,b,260), np.linspace(c,d,260)
        X,Y = np.meshgrid(x,y)
        U,V = X*(Y-X+1), Y*(2*Y-X-2)
        speed = np.hypot(U,V)
        U,V = U/(1+speed),V/(1+speed)
        ax.streamplot(x,y,U,V,density=1.25,color="#a1aab1",linewidth=.65,arrowsize=.8)
        ax.plot(x,x-1,"--",color="#768238",lw=1.25,label=r"$\dot x=0$: $y=x-1$")
        ax.plot(x,x/2+1,":",color="#98629f",lw=1.5,label=r"$\dot y=0$: $y=x/2+1$")
        ax.axhline(0,color="#596168",lw=.8)
        ax.axvline(0,color="#596168",lw=.8)
        ax.set(xlim=(a,b),ylim=(c,d),xlabel="$x$",ylabel="$y$")
        ax.set_aspect("equal",adjustable="box")
        ax.spines[["top","right"]].set_visible(False)
        ax.set_facecolor("white")
        for point, marker, color in [((0,0),"x","black"),((1,0),"o",stable),((0,1),"s",unstable),((4,3),"x","black")]:
            ax.plot(*point,marker,color=color,ms=6,zorder=6)
    saddle=np.array([4.,3.])
    for direction, vector, color, name in [(-1,np.array([4.,5-np.sqrt(13)]),stable,"Stable separatrix"),(1,np.array([4.,5+np.sqrt(13)]),unstable,"Unstable separatrix")]:
        vector /= np.linalg.norm(vector)
        for sign in [-1,1]:
            line=trajectory(saddle+sign*.002*vector,direction)
            axes[0].plot(line[:,0],line[:,1],color=color,lw=1.8,label=name if sign==-1 else None)
            n=min(110,len(line)-10)
            start,end=(line[n+8],line[n]) if direction<0 else (line[n],line[n+8])
            axes[0].annotate("",xy=end,xytext=start,arrowprops={"arrowstyle":"->","color":color,"lw":1.5})
    for point,text,offset in [((0,0),"Saddle",(-35,-19)),((1,0),"Stable node",(7,-18)),((0,1),"Improper source",(8,8)),((4,3),"Saddle",(9,-15))]:
        axes[0].annotate(text,point,xytext=offset,textcoords="offset points",fontsize=9)
    axes[0].set_title("Full portrait and saddle separatrices")
    axes[1].set_title("Near the three axial equilibria")
    axes[0].legend(loc="upper left",fontsize=8,framealpha=.95)
    fig.suptitle(r"$\dot x=x(y-x+1),\qquad\dot y=y(2y-x-2)$",fontsize=15)
    fig.tight_layout(rect=(0,0,1,.92))
    fig.savefig("2011-phase-plane.png",dpi=150,facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()