codexpast-exam-of-the-algebra-1-and-differential-equations-1-course-of-the-university-of-oxford2015-polynomial-phase-plane.py
past-exam-of-the-algebra-1-and-differential-equations-1-course-of-the-university-of-oxford/2015-polynomial-phase-plane.py
"""Original phase portrait. Python 3.14; NumPy and Matplotlib from pyproject.toml.
Run from the intended PNG output directory. No source-paper image is used.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
def rhs(z):
x, y = z
return np.array([x * (1 - y) * (1 - x - y), y * (2 - x * y)])
def trajectory(start, dt=0.002, steps=10000):
z = np.array(start, dtype=float)
values = [z.copy()]
for _ in range(steps):
k1 = rhs(z)
k2 = rhs(z + dt * k1 / 2)
k3 = rhs(z + dt * k2 / 2)
k4 = rhs(z + dt * k3)
z = z + dt * (k1 + 2 * k2 + 2 * k3 + k4) / 6
if not np.all(np.isfinite(z)) or np.max(np.abs(z)) > 50:
raise RuntimeError("Trajectory left the numerical drawing range")
values.append(z.copy())
return np.array(values)
def main():
fig, ax = plt.subplots(figsize=(9.0, 6.2), facecolor="white")
ax.set_facecolor("white")
xx, yy = np.meshgrid(np.arange(0.18, 5.45, 0.38),
np.arange(0.16, 3.4, 0.29))
vx = xx * (1 - yy) * (1 - xx - yy)
vy = yy * (2 - xx * yy)
speed = np.hypot(vx, vy)
ax.quiver(xx, yy, vx / speed, vy / speed, angles="xy", scale_units="xy",
scale=8, color="#999999", alpha=0.65, width=0.0023, zorder=1)
blue, orange = "#2567a5", "#cc7816"
x = np.linspace(0.04, 5.5, 1000)
ax.axvline(0, color=blue, lw=2.1)
ax.axhline(1, color=blue, lw=1.7, label=r"$\dot{x}=0$")
ax.plot([0, 1], [1, 0], color=blue, lw=1.7)
ax.axhline(0, color=orange, lw=2.1)
ax.plot(x, 2 / x, color=orange, lw=1.7, label=r"$\dot{y}=0$")
starts = [(0.22, 0.12), (0.85, 0.04), (4.7, 0.15), (4.8, 2.8),
(0.2, 2.0), (1.3, 2.8), (3.1, 0.8)]
summaries = []
for i, start in enumerate(starts):
points = trajectory(start)
ax.plot(points[:, 0], points[:, 1], color="#26724b", lw=1.2,
alpha=0.9, label="Forward trajectories" if i == 0 else None)
lengths = np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(points, axis=0), axis=1))])
for fraction in [0.23, 0.63]:
j = min(np.searchsorted(lengths, fraction * lengths[-1]), len(points) - 15)
if 0 < points[j, 0] < 5.45 and 0 < points[j, 1] < 3.35:
direction = rhs(points[j])
direction = direction / np.linalg.norm(direction)
end = points[j] + 0.095 * direction
ax.annotate("", xy=end, xytext=points[j],
arrowprops={"arrowstyle": "->", "color": "#26724b", "lw": 1.4})
summaries.append((start, points[-1].tolist()))
ax.scatter([0, 1, 2], [0, 0, 1], s=[48, 48, 55], color="black", zorder=5,
clip_on=False)
ax.annotate("Unstable node", (0, 0), xytext=(0.12, 0.47),
arrowprops={"arrowstyle": "-", "color": "black"}, fontsize=10)
ax.annotate("Saddle", (1, 0), xytext=(1.15, 0.3),
arrowprops={"arrowstyle": "-", "color": "black"}, fontsize=10)
ax.annotate("Clockwise spiral sink", (2, 1), xytext=(2.65, 1.35),
arrowprops={"arrowstyle": "-", "color": "black"}, fontsize=10)
ax.text(0.25, 0.84, r"$x+y=1$", color=blue, fontsize=10)
ax.text(4.75, 1.07, r"$y=1$", color=blue, fontsize=10)
ax.text(0.75, 2.85, r"$xy=2$", color=orange, fontsize=10)
for start, end in [((0.05, 0.6), (0.05, 0.95)),
((0.25, 0.03), (0.55, 0.03)),
((1.7, 0.03), (1.4, 0.03))]:
ax.annotate("", xy=end, xytext=start,
arrowprops={"arrowstyle": "->", "color": "black", "lw": 1.3})
ax.set(xlim=(-0.02, 5.5), ylim=(-0.02, 3.4), xlabel="$x$", ylabel="$y$")
ax.set_title("Polynomial nullclines and the attracting focus", fontsize=14)
ax.legend(loc="upper right", framealpha=0.95, fontsize=10)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
fig.savefig(Path.cwd() / "2015-polynomial-phase-plane.png", dpi=140,
facecolor="white")
plt.close(fig)
print(summaries)
if __name__ == "__main__":
main()