codex/ode-cooling-direction-field.py

ode-cooling-direction-field.py
"""Draw Newton cooling trajectories; tested with Python 3.14, NumPy, Matplotlib."""
from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection
import numpy as np


def cooling(t, initial):
    return 20.0 + (initial - 20.0) * np.exp(-0.1 * t)


plt.rcParams.update({"font.size": 11, "axes.spines.top": False, "axes.spines.right": False})
fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.6), dpi=125, constrained_layout=True)
fig.patch.set_facecolor("white")
t = np.linspace(0.0, 30.0, 601)
colors = ("#bb4b20", "#2176a5", "#258568")

for initial, color in zip((80.0, 50.0, 5.0), colors):
    axes[0].plot(t, cooling(t, initial), color=color, lw=2.5,
                 label=f"y(0) = {initial:g} °C", zorder=3)
    axes[0].scatter([0], [initial], s=32, color=color, zorder=4)
axes[0].axhline(20.0, color="#444444", linestyle="--", lw=1.8,
               label="equilibrium: 20 °C", zorder=3)
axes[0].set(xlim=(-0.6, 30.5), ylim=(-2, 87), xlabel="time t (min)",
            ylabel="temperature y (°C)", title=r"One local law: $y'=-0.1(y-20)$")
axes[0].legend(loc="upper right", fontsize=9, framealpha=1)

axes[1].plot(t, cooling(t, 80.0), color=colors[0], lw=2.8)
axes[1].axhline(20.0, color="#444444", linestyle="--", lw=1.6)
axes[1].set(xlim=(-0.6, 30.5), ylim=(17, 87), xlabel="time t (min)",
            ylabel="temperature y (°C)", title=r"The selected trajectory: $y=20+60e^{-0.1t}$")
for moment, text_y in ((10.0, 63.0), (10.0 * np.log(6.0), 47.0)):
    value = cooling(moment, 80.0)
    axes[1].plot([moment, moment], [20.0, value], color="#aaaaaa", lw=1, linestyle=":")
    axes[1].scatter([moment], [value], color=colors[0], s=40, zorder=4)
    axes[1].annotate(f"({moment:.2f} min, {value:.2f} °C)",
                     xy=(moment, value), xytext=(moment - 5.0, text_y),
                     arrowprops={"arrowstyle": "->", "color": "#555555"}, fontsize=10)
axes[1].text(29.0, 21.6, "ambient: 20 °C", ha="right", fontsize=10, color="#444444")

for ax in axes:
    ax.set_facecolor("white")
    ax.grid(alpha=0.13)

# Normalize segment lengths in display coordinates without changing dy/dt.
# This keeps the field readable even though the two axes have different units.
fig.canvas.draw()
grid_t, grid_y = np.meshgrid(np.linspace(0, 30, 19), np.arange(0, 86, 5))
points = np.column_stack((grid_t.ravel(), grid_y.ravel()))
directions = np.column_stack((np.ones(points.shape[0]), -0.1 * (points[:, 1] - 20.0)))
display = axes[0].transData.transform(points)
vectors = axes[0].transData.transform(points + directions) - display
vectors /= np.linalg.norm(vectors, axis=1)[:, None]
endpoints = np.stack((display - 6.0 * vectors, display + 6.0 * vectors), axis=1)
segments = axes[0].transData.inverted().transform(endpoints.reshape(-1, 2)).reshape(-1, 2, 2)
axes[0].add_collection(LineCollection(segments, colors="#aeb8c0", linewidths=0.9, zorder=1))
fig.savefig(Path.cwd() / "ode-cooling-direction-field.png", facecolor="white", transparent=False)
plt.close(fig)