codex/past-exam-of-the-further-mathematical-biology-course-of-the-university-of-oxford/oxford-2023-further-mathematical-biology-phase-portraits.py

past-exam-of-the-further-mathematical-biology-course-of-the-university-of-oxford/oxford-2023-further-mathematical-biology-phase-portraits.py
"""Generate representative phase portraits in cwd; Python 3.14, NumPy, Matplotlib."""
import os
os.environ.setdefault('MPLCONFIGDIR', '/tmp/oxford-2023-fmb-matplotlib')
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D

alpha = 0.2
cases = [(0.5, .05, -.1, 'One stable node'),
         (.4, .5, .112, 'One stable focus'),
         (.4, .15, .112, 'One unstable focus; sustained oscillation'),
         (.2, .2, .032, 'Two sinks and a saddle'),
         (.2, .04, .035, 'One source, one saddle, one sink'),
         (.25, .1, .052, 'Two sources and a saddle')]
fig, axes = plt.subplots(2, 3, figsize=(12, 7), layout='constrained')
vv = np.linspace(-.45, 1.25, 61)
ww = np.linspace(-.18, .56, 57)
V, W = np.meshgrid(vv, ww)
curve_v = np.linspace(-.45, 1.25, 700)
def f(v):
    return v*(alpha-v)*(v-1)
for ix, (ax, (k, gamma, current, title)) in enumerate(zip(axes.flat, cases)):
    beta = k*gamma
    U, Z = f(V)-W+current, beta*V-gamma*W
    ax.streamplot(vv, ww, U, Z, color='0.81', density=.8, linewidth=.6,
                  arrowsize=.6, integration_direction='both')
    ax.plot(curve_v, f(curve_v)+current, color='#2463a4', lw=1.4)
    ax.plot(curve_v, k*curve_v, color='#cb6a23', lw=1.4)
    roots = np.roots([1, -1-alpha, alpha+k, -current])
    roots = np.sort(roots.real[np.abs(roots.imag)<1e-9])
    for root in roots:
        p = -3*root**2+2*(1+alpha)*root-alpha
        det = gamma*(k-p)
        if det < 0:
            ax.scatter(root, k*root, marker='x', s=50, color='black', zorder=6)
        else:
            ax.scatter(root, k*root, marker='o', s=40, edgecolor='black',
                       facecolor='black' if p<gamma else 'white', zorder=6)
    seeds = np.array([[-.2, -.02], [.9, .27], [.42, .02]])
    if ix == 2:
        seeds = np.array([[.401, k*.4], [.9, .3], [-.15, -.05]])
    if ix == 5:
        seeds = np.array([[roots[0]+.002, k*roots[0]],
                          [roots[-1]-.002, k*roots[-1]], [-.2, -.02]])
    def rhs(state):
        v, w = state[:, 0], state[:, 1]
        return np.column_stack((f(v)-w+current, beta*v-gamma*w))
    dt = .04
    state = seeds.copy()
    track = np.empty((12001, len(seeds), 2))
    track[0] = state
    for j in range(12000):
        a = rhs(state)
        b = rhs(state+dt*a/2)
        c = rhs(state+dt*b/2)
        d = rhs(state+dt*c)
        state = state+dt*(a+2*b+2*c+d)/6
        track[j+1] = state
    for j, color in enumerate(['#29794c', '#89428c', '#b63a43']):
        ax.plot(track[:, j, 0], track[:, j, 1], color=color, lw=.9, alpha=.8)
        ax.scatter(*track[0, j], color=color, s=9, zorder=5)
    ax.set(xlim=(-.45, 1.25), ylim=(-.18, .56), xlabel='v', ylabel='w',
           title=title+'\n'+rf'$k={k:g},\ \gamma={gamma:g},\ I={current:g}$')
    ax.tick_params(labelsize=8)
    ax.title.set_fontsize(9)
legend = [Line2D([], [], color='#2463a4', label='v nullcline'),
          Line2D([], [], color='#cb6a23', label='w nullcline'),
          Line2D([], [], color='black', marker='o', ls='', label='sink'),
          Line2D([], [], color='black', marker='o', mfc='white', ls='', label='source'),
          Line2D([], [], color='black', marker='x', ls='', label='saddle')]
fig.legend(handles=legend, loc='outside lower center', ncol=5, fontsize=9)
fig.suptitle(r'FitzHugh–Nagumo phase portraits ($\alpha=0.2$); colored curves are trajectories', fontsize=12)
fig.savefig('oxford-2023-further-mathematical-biology-phase-portraits.png', dpi=145, facecolor='white')