codex/bernoulli-slope-field-and-branches.py

bernoulli-slope-field-and-branches.py
"""Bernoulli slope and inverse branches; Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.

Run from _media; writes bernoulli-slope-field-and-branches.png to the CWD.
All curves and field slopes belong to the original analytic model.
"""
from pathlib import Path

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


def main():
    plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
                         "axes.spines.right": False})
    fig, (af, ab) = plt.subplots(1, 2, figsize=(10.4, 4.5), dpi=120,
                                layout="constrained", facecolor="white")
    orange, blue, purple = "#b85116", "#196f9b", "#8153a0"
    xx, yy = np.meshgrid(np.linspace(0, 2.8, 23),
                         np.linspace(-1.15, 2.15, 24))
    slopes = yy*(np.exp(xx)*yy-1)
    dx = 0.043/np.hypot(1, slopes)
    dy = slopes*dx
    starts = np.stack((xx-dx, yy-dy), axis=-1)
    ends = np.stack((xx+dx, yy+dy), axis=-1)
    segments = np.stack((starts, ends), axis=-2).reshape(-1, 2, 2)
    af.add_collection(LineCollection(segments, colors="#99a3aa", linewidths=0.8))
    x = np.linspace(0, 2.8, 800)
    xp = np.linspace(0, 1.999, 1100)
    af.plot(x, np.exp(-x), color="#478641", lw=2, ls="--",
            label=r"moving nullcline $y=e^{-x}$")
    af.axhline(0, color="#444444", lw=1.5, label=r"nullcline / solution $y=0$")
    af.plot(xp, np.exp(-xp)/(2-xp), color=orange, lw=2.6, label=r"$a=1/2$")
    af.plot(x, -np.exp(-x)/(1+x), color=blue, lw=2.6, label=r"$a=-1$")
    af.scatter([1], [np.exp(-1)], color=orange, zorder=4)
    af.axvline(2, color="#777777", ls=":", lw=1.5)
    af.annotate("turning point", (1, np.exp(-1)), xytext=(0.08, 1.55),
                arrowprops={"arrowstyle": "->"})
    af.set(title=r"Slope field: $y'=y(e^x y-1)$", xlabel="x", ylabel="y",
           xlim=(0, 2.8), ylim=(-1.2, 2.2))
    af.legend(loc="upper right", bbox_to_anchor=(1, 0.45), fontsize=8.5,
              framealpha=1)

    xl = np.linspace(0, 1.999, 1200)
    xr = np.linspace(2.001, 4, 1200)
    ab.plot(xl, np.exp(-xl)/(2-xl), color=orange, lw=2.6,
            label=r"positive branch: $x<2$")
    ab.plot(xr, np.exp(-xr)/(2-xr), color=purple, lw=2.6,
            label=r"separate negative branch: $x>2$")
    ab.axvline(2, color="#777777", ls=":", lw=1.7)
    ab.axhline(0, color="#444444", lw=1.5,
               label="zero solution (no reciprocal)")
    ab.scatter([0], [0.5], color=orange, zorder=4)
    ab.text(1.4, 2.3, "blow-up\nno joining value", ha="center", color=orange)
    ab.text(2.2, -2.25, "same formula,\ndifferent interval", color=purple)
    ab.set(title=r"Inverse branches: $y=e^{-x}/(2-x)$", xlabel="x", ylabel="y",
           xlim=(0, 4), ylim=(-3, 3))
    ab.legend(loc="lower left", fontsize=8.5, framealpha=1)
    for ax in (af, ab):
        ax.set_facecolor("white")
        ax.grid(alpha=0.12)
    fig.savefig(Path.cwd()/"bernoulli-slope-field-and-branches.png",
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()