codex/past-exam-of-the-mathematical-physiology-course-of-the-university-of-oxford/2023-excitable-pulse.py

past-exam-of-the-mathematical-physiology-course-of-the-university-of-oxford/2023-excitable-pulse.py
"""Generate 2023-excitable-pulse.png in the working directory.

Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
The curves solve the stated equations by fourth-order Runge--Kutta.
"""

from pathlib import Path

import matplotlib

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


def simulate(a=0.25, gamma=0.3, epsilon=0.003, step=0.00015, end=9.0):
    time = np.arange(round(end / step) + 1) * step
    state = np.zeros((len(time), 2, 2))
    state[0, :, 0] = [0.15, 0.4]

    def rhs(y):
        v, w = y[:, 0], y[:, 1]
        return np.column_stack((
            (v * (a - v) * (v - 1) - w) / epsilon,
            gamma * v - w,
        ))

    for i in range(len(time) - 1):
        y = state[i]
        k1 = rhs(y)
        k2 = rhs(y + 0.5 * step * k1)
        k3 = rhs(y + 0.5 * step * k2)
        k4 = rhs(y + step * k3)
        state[i + 1] = y + step * (k1 + 2*k2 + 2*k3 + k4) / 6
    return time, state


def main():
    a, gamma, epsilon = 0.25, 0.3, 0.003
    time, state = simulate(a, gamma, epsilon)
    d = np.sqrt(1 - a + a*a)
    right = (1 + a + d) / 3
    landing = 1 + a - 2*right
    f = lambda v: v * (a-v) * (v-1)
    fig, (plane, trace) = plt.subplots(1, 2, figsize=(10.4, 4.0), layout="constrained")
    fig.set_facecolor("white")
    vv = np.linspace(-0.27, 1.08, 700)
    plane.plot(vv, f(vv), color="black", lw=1.5, label=r"$w=f(v)$")
    plane.plot(vv, gamma*vv, color="0.5", ls="--", lw=1.2, label=r"$w=\gamma v$")
    colors = ("#2274a5", "#c85a17")
    for j, color in enumerate(colors):
        v, w = state[:, j, 0], state[:, j, 1]
        plane.plot(v[::5], w[::5], color=color, lw=1.8,
                   label=rf"$v(0)={v[0]:g}$")
        plane.scatter(v[0], w[0], color=color, s=24, zorder=4)
        trace.plot(time[::5], v[::5], color=color, lw=1.8)
    plane.scatter([0, a, 1, right, landing], [0, 0, 0, f(right), f(right)],
                  s=20, color="black", zorder=5)
    plane.annotate(r"$a$", (a, 0), xytext=(0, -14), textcoords="offset points", ha="center")
    plane.annotate(r"$v_R$", (right, f(right)), xytext=(7, 6), textcoords="offset points")
    plane.annotate(r"$v_J$", (landing, f(right)), xytext=(-17, 7), textcoords="offset points")
    for t0 in (0.008, 0.18, 0.8, 1.8):
        i = np.searchsorted(time, t0)
        jump = 12 if t0 < 0.02 else 120
        plane.annotate("", xy=state[i+jump, 1], xytext=state[i, 1],
                       arrowprops={"arrowstyle": "->", "color": colors[1], "lw": 1.7})
    plane.axhline(0, color="0.8", lw=0.6)
    plane.set(xlim=(-0.27, 1.08), ylim=(-0.025, 0.12), xlabel=r"activation $v$",
              ylabel=r"recovery $w$", title="Threshold and phase-plane excursion")
    plane.legend(fontsize=8, loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=2)

    pulse = state[:, 1, 0]
    peak = int(np.argmax(pulse))
    falling = np.flatnonzero((np.arange(len(pulse)) > peak) & (pulse < right))[0]
    trough = int(np.argmin(pulse))
    markers = [(0.007, 0.58, "1: fast activation"),
               (float(time[falling])*0.45, 1.04, "2: slow plateau"),
               (float(time[falling])+0.04, 0.45, "3: fast fall"),
               (float(time[trough])+1.1, -0.24, "4: slow recovery")]
    for x, y, label in markers:
        trace.text(x, y, label, fontsize=8, rotation=90 if label.startswith(("1:", "3:")) else 0,
                   va="center" if label.startswith(("1:", "3:")) else "bottom")
    trace.axhline(0, color="0.65", lw=0.8)
    trace.set(xlim=(0, 5), ylim=(-0.3, 1.15), xlabel=r"time $t$", ylabel=r"activation $v(t)$",
              title="A pulse and a subthreshold response")
    fig.suptitle(r"$a=0.25,\quad \gamma=0.30,\quad \varepsilon=0.003,\quad w(0)=0$", fontsize=12)
    fig.savefig(Path.cwd() / "2023-excitable-pulse.png", dpi=145, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()