codexlogistic-map-dynamics.py
logistic-map-dynamics.py
"""Logistic orbits, cobwebs and bifurcation diagram.
Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Writes logistic-map-dynamics.png to the caller's working directory.
Uses only the dependencies in the repository's pyproject.toml.
"""
from pathlib import Path
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def orbit(r, initial, steps):
values = np.empty(steps + 1)
values[0] = initial
for n in range(steps):
values[n + 1] = r * values[n] * (1 - values[n])
return values
def main():
plt.rcParams.update({"font.size": 11, "axes.titlesize": 12})
fig = plt.figure(figsize=(12, 10), facecolor="white", layout="constrained")
grid = fig.add_gridspec(3, 3, height_ratios=(0.85, 1.35, 1.3))
colors = ("#2166ac", "#b35806", "#387a48")
parameters = (2.8, 3.2, 3.5)
periods = (1, 2, 4)
curve = np.linspace(0, 1, 500)
for col, (r, period, color) in enumerate(zip(parameters, periods, colors)):
values = orbit(r, 0.2, 50)
ax = fig.add_subplot(grid[0, col])
ax.plot(np.arange(51), values, ".-", color=color, lw=1, ms=4)
ax.set(xlim=(0, 50), ylim=(0, 1), xlabel="Iteration n", ylabel=r"State $x_n$")
description = "fixed point" if period == 1 else f"period-{period} cycle"
ax.set_title(f"r = {r}: attracting {description}")
ax.grid(alpha=0.18)
ax = fig.add_subplot(grid[1, col])
ax.plot(curve, r * curve * (1 - curve), color="#333333", lw=1.8,
label=r"$y=r x(1-x)$")
ax.plot(curve, curve, color="#888888", ls="--", lw=1, label="y = x")
web = orbit(r, 0.2, 22)
px, py = [web[0]], [0.0]
for n in range(len(web) - 1):
px.extend((web[n], web[n + 1]))
py.extend((web[n + 1], web[n + 1]))
ax.plot(px, py, color=color, lw=1.2, alpha=0.85)
ax.annotate("", xy=(web[0], web[1]), xytext=(web[0], 0.08),
arrowprops={"arrowstyle": "->", "color": color, "lw": 1.5})
ax.annotate("", xy=(web[1], web[1]), xytext=(web[0] + 0.04, web[1]),
arrowprops={"arrowstyle": "->", "color": color, "lw": 1.5})
fixed = 1 - 1 / r
ax.scatter([fixed], [fixed], s=42, edgecolors="#9e2929",
facecolors="#9e2929" if period == 1 else "white", zorder=5)
if period > 1:
cycle = orbit(r, 0.2, 800)[-period:]
ax.scatter(cycle, r * cycle * (1 - cycle), s=30, color=color, zorder=4)
ax.set(xlim=(0, 1), ylim=(0, 1), xlabel=r"Current state $x_n$",
ylabel=r"Next state $x_{n+1}$", title="Vertical to curve; horizontal to diagonal")
ax.set_aspect("equal", adjustable="box")
ax.grid(alpha=0.12)
if col == 0:
ax.legend(loc="upper left", fontsize=9, framealpha=0.95)
ax = fig.add_subplot(grid[2, :])
# One initial state per parameter, with a long discarded transient.
# Avoid r=4 at the endpoint: finite precision there can have exceptional cycles.
r_grid = np.linspace(2.5, 4, 2800, endpoint=False)
state = np.full_like(r_grid, 0.2)
for _ in range(2000):
state = r_grid * state * (1 - state)
retained = np.empty((240, len(r_grid)))
for n in range(len(retained)):
state = r_grid * state * (1 - state)
retained[n] = state
ax.scatter(np.tile(r_grid, len(retained)), retained.ravel(),
s=0.09, color="#202e48", alpha=0.35, linewidths=0)
for threshold, label in ((3, "first doubling"), (1 + np.sqrt(6), "second doubling")):
ax.axvline(threshold, color="#9e2929", ls="--", lw=0.9)
ax.text(threshold + 0.012, 1.015, label, fontsize=10, color="#9e2929")
ax.axvspan(3.8285, 3.8414, color="#e3b342", alpha=0.3)
ax.annotate("period-3 window", xy=(3.835, 0.15), xytext=(3.58, 0.04),
arrowprops={"arrowstyle": "->", "color": "#8e650c"},
color="#8e650c", fontsize=11)
ax.set(xlim=(2.5, 4), ylim=(0, 1.08), yticks=(0, 0.25, 0.5, 0.75, 1),
xlabel="Parameter r", ylabel=r"Retained states $x_n$",
title="Bifurcation diagram: 240 iterates after discarding 2,000; periodic windows remain")
fig.savefig(Path.cwd() / "logistic-map-dynamics.png", dpi=120,
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()