codexadaptive-euler-decay-trajectory-and-steps.py
adaptive-euler-decay-trajectory-and-steps.py
"""Two-half-step Euler decay with local tolerance control; output in CWD.
Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
"""
from pathlib import Path
import math
import matplotlib.pyplot as plt
import numpy as np
def integrate(atol=1e-5, rtol=1e-3, end=8.0, initial_step=1.0):
"""Accept the fine value without Richardson extrapolation.
Each trial records signed exact-local error from its numerical start,
and signed global error from the original initial condition.
"""
if atol <= 0 or rtol < 0 or end <= 0 or initial_step <= 0:
raise ValueError("Require positive atol, end and step, and nonnegative rtol")
t, y, h = 0.0, 1.0, initial_step
times, values, trials = [t], [y], []
while t < end:
if len(trials) >= 100000:
raise RuntimeError("Trial limit reached")
h = min(h, end - t)
if t + h == t:
raise RuntimeError("Step cannot advance floating-point time")
coarse = y + h * (-y)
half = y + (h / 2) * (-y)
fine = half + (h / 2) * (-half)
estimate = fine - coarse
scale = atol + rtol * max(abs(y), abs(fine))
scaled = abs(estimate) / scale
accepted = scaled <= 1.0
# expm1 avoids subtracting exp(-h) from a number very near one.
local = y * (math.expm1(-h) + h - h * h / 4)
trials.append(dict(t=t, y=y, h=h, fine=fine, estimate=estimate,
scale=scale, scaled=scaled, accepted=accepted,
local=local, global_error=fine - math.exp(-(t + h))))
if accepted:
t, y = t + h, fine
times.append(t)
values.append(y)
factor = 5.0 if scaled == 0 else min(5.0, max(0.2, 0.9 / math.sqrt(scaled)))
if not accepted:
factor = min(1.0, factor)
h *= factor
return np.array(times), np.array(values), trials
def main():
times, values, trials = integrate()
accepted = [r for r in trials if r["accepted"]]
rejected = [r for r in trials if not r["accepted"]]
plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
"axes.spines.right": False, "figure.facecolor": "white"})
fig, axes = plt.subplots(2, 1, figsize=(8.4, 6.3), sharex=True,
constrained_layout=True)
dense = np.linspace(0, 8, 700)
axes[0].semilogy(dense, np.exp(-dense), color="#222222", label="Exact: exp(−t)")
axes[0].semilogy(times, values, ".-", ms=3, lw=0.8, color="#0072B2",
label=f"Two Euler half steps: {len(accepted)} accepted")
axes[0].set(ylabel="Solution y (log scale)", title="Adaptive Euler: y′ = −y, y(0) = 1")
axes[0].legend(loc="upper right", fontsize=10)
axes[1].semilogy([r["t"] for r in accepted], [r["h"] for r in accepted],
".-", ms=4, color="#0072B2", label="Accepted macro step")
axes[1].semilogy([r["t"] for r in rejected], [r["h"] for r in rejected],
"x", ms=8, mew=2, color="#D55E00", label="Rejected trial")
axes[1].set(xlabel="Trial start time t", ylabel="Macro step h (log scale)", xlim=(-0.1, 8.1))
axes[1].legend(loc="upper right", fontsize=10)
axes[1].annotate("Absolute tolerance permits growth\nas the solution approaches zero",
xy=(6.6, 0.16), xytext=(2.2, 0.35), fontsize=10,
arrowprops={"arrowstyle": "->", "color": "#555555"})
fig.suptitle("atol = 10⁻⁵, rtol = 10⁻³; square-root controller, safety factor 0.9", fontsize=11)
for ax in axes:
ax.grid(alpha=0.22)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=120,
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()