codexpositive-feedback-switch-hysteresis.py
positive-feedback-switch-hysteresis.py
"""Synthetic saturating feedback model, with equilibrium and fold calculations.
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Run from _media; write matching-basename opaque white PNG to CWD.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def equilibria(u, beta):
roots = np.roots([1, -(u + beta), 1, -u])
return sorted(r.real for r in roots if abs(r.imag) < 1e-8 and r.real >= 0)
def main():
plt.rcParams.update({"font.size": 11, "figure.facecolor": "white"})
beta = 1.8
folds = sorted(z.real for z in np.roots([1, 0, 2, -2 * beta, 1]) if abs(z.imag) < 1e-8 and z.real > 0)
x_on, x_off = folds
u_on, u_off = [x - beta * x * x / (1 + x * x) for x in folds]
fig, axes = plt.subplots(1, 2, figsize=(11, 5.7), dpi=110)
fig.suptitle("SYNTHETIC POSITIVE FEEDBACK — no biological data", weight="bold", y=0.98)
ax = axes[0]
x = np.linspace(0, 1.8, 1201)
u = 0.12
ax.plot(x, u + beta * x * x / (1 + x * x), color="#2468b4", lw=2, label="Production: u + βx²/(1+x²)")
ax.plot(x, x, color="#334155", lw=2, label="Removal: x")
for k, r in enumerate(equilibria(u, beta)):
assert abs(u + beta * r * r / (1 + r * r) - r) < 1e-12
stable = 2 * beta * r / (1 + r * r) ** 2 < 1
ax.plot(r, r, "o", ms=8, mec="#334155", mfc="#187c64" if stable else "white", zorder=5)
ax.text(r + 0.015, r - 0.15, ("stable" if stable else "unstable"), fontsize=10)
ax.set(title="Same input, two stable equilibria\nu = 0.12, β = 1.8", xlabel="Activity x (normalized)", ylabel="Rate (normalized)", xlim=(0, 1.8), ylim=(0, 1.9))
ax.legend(loc="upper left", fontsize=9)
ax = axes[1]
low = np.linspace(0, x_on, 500)
middle = np.linspace(x_on, x_off, 500)
high = np.linspace(x_off, 1.9, 500)
for xx, style, label in ((low, "-", "β = 1.8: stable branches"), (middle, "--", "β = 1.8: unstable branch"), (high, "-", None)):
ax.plot(xx - beta * xx * xx / (1 + xx * xx), xx, style, color="#2468b4", lw=2.4, label=label)
inputs = np.linspace(0, 0.3, 200)
ax.plot(inputs, [equilibria(s, 1)[0] for s in inputs], color="#7c5f9e", lw=2, label="β = 1: one stable equilibrium")
for s, xx, label in ((u_on, x_on, f"ON: {u_on:.4f}"), (u_off, x_off, f"OFF: {u_off:.4f}")):
roots = equilibria(s + (1e-6 if label.startswith("ON") else -1e-6), beta)
end = roots[-1] if label.startswith("ON") else roots[0]
ax.annotate("", (s, end), (s, xx), arrowprops={"arrowstyle": "->", "color": "#ba4a29", "lw": 2})
ax.axvline(s, color="#94a3b8", linestyle=":", lw=1)
ax.text(s + 0.006, 1.65 if label.startswith("ON") else 1.45, label, fontsize=10, color="#9b3c1d")
ax.set(title="Equilibria under a slow input sweep\nArrows mark jumps at folds", xlabel="Sustained input u (normalized)", ylabel="Equilibrium activity x*", xlim=(0, 0.3), ylim=(0, 1.9))
ax.legend(loc="upper left", bbox_to_anchor=(0, -0.19), fontsize=9, frameon=False)
for ax in axes:
ax.grid(alpha=0.18)
fig.subplots_adjust(left=0.07, right=0.98, bottom=0.25, top=0.82, wspace=0.29)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()