codex/past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2006/ia/paper-2-logistic.py

past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2006/ia/paper-2-logistic.py
"""Logistic phase portrait. Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.
Writes only paper-2-logistic.png to the caller's CWD; honors MPLCONFIGDIR.
"""
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt


def main():
    fig,axes=plt.subplots(2,1,figsize=(8,5.8),gridspec_kw={'height_ratios':[1,2.5]},constrained_layout=True,facecolor='white')
    ax=axes[0]
    ax.axhline(0,color='#666666',lw=1)
    for start,end in [(-.55,-1.05),(.25,.75),(1.85,1.35)]:
        ax.annotate('',xy=(end,0),xytext=(start,0),arrowprops={'arrowstyle':'->','lw':2,'color':'#2563a6'})
    ax.scatter([0],[0],s=80,facecolors='white',edgecolors='#222222',zorder=5)
    ax.scatter([1],[0],s=80,color='#222222',zorder=5)
    ax.text(0,.25,'0: unstable',ha='center',fontsize=10)
    ax.text(1,.25,'1: stable',ha='center',fontsize=10)
    ax.set(xlim=(-1.4,2.2),ylim=(-.2,.7),yticks=[],xlabel='State x',title=r'Logistic phase line: $\dot x=x(1-x)$')
    for side in ['top','right','left']:
        ax.spines[side].set_visible(False)
    t=np.linspace(-6,6,500)
    for x0,color in [(.15,'#2563a6'),(.5,'#ca671d'),(.85,'#3f874e')]:
        x=x0*np.exp(t)/(1-x0+x0*np.exp(t))
        axes[1].plot(t,x,lw=2,color=color,label=f'x(0) = {x0}')
        axes[1].scatter([0],[x0],color=color,s=25)
    axes[1].axhline(0,color='#666666',lw=1,ls=':')
    axes[1].axhline(1,color='#666666',lw=1,ls=':')
    axes[1].set(xlim=(-6,6),ylim=(-.05,1.05),xlabel='Time t',ylabel='State x(t)',title='Trajectories between the two equilibria')
    axes[1].legend(loc='upper left',fontsize=9)
    axes[1].grid(alpha=.2)
    for ax in axes:
        ax.set_facecolor('white')
    fig.savefig(Path.cwd()/'paper-2-logistic.png',dpi=150,facecolor='white',transparent=False)
    plt.close(fig)


if __name__=='__main__':
    main()