codexinfrared-ultraviolet-herschel-thermal-model.py
infrared-ultraviolet-herschel-thermal-model.py
"""Original Herschel-style schematic and illustrative lumped thermal model.
Run from _media with Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.
The diagram is not a historical reconstruction or a measured spectral curve.
"""
from pathlib import Path
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Polygon, Rectangle
import numpy as np
def excess_temperature(time, absorbed_power, capacity=0.020, loss=0.001,
shutter_time=60.0):
"""Kelvin above fixed ambient; a constant beam is blocked at shutter_time."""
tau = capacity / loss
illuminated = absorbed_power / loss * (-np.expm1(-time / tau))
at_shutter = absorbed_power / loss * (-np.expm1(-shutter_time / tau))
cooling = at_shutter * np.exp(-(time - shutter_time) / tau)
return np.where(time <= shutter_time, illuminated, cooling)
def main():
plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
"axes.spines.right": False, "figure.facecolor": "white",
"axes.facecolor": "white", "savefig.facecolor": "white"})
fig = plt.figure(figsize=(9.3, 7.4), layout="constrained")
schematic, model = fig.subplots(2, 1, gridspec_kw={"height_ratios": [1.0, 1.05]})
fig.suptitle("Beyond visible red: detection by heating", fontsize=17)
schematic.set(xlim=(0, 10), ylim=(0, 5))
schematic.axis("off")
schematic.set_title("A. Experiment logic — schematic, not to scale", loc="left")
schematic.add_patch(Polygon([(2.5, 3.5), (3.2, 4.4), (3.5, 3.1)],
facecolor="#e1edf3", edgecolor="#345466", lw=1.8))
schematic.annotate("", (2.8, 3.7), (0.5, 3.7),
arrowprops={"arrowstyle": "->", "lw": 2, "color": "#61594e"})
schematic.text(0.5, 4.05, "Sunlight")
schematic.text(2.5, 4.63, "Glass prism")
schematic.add_patch(Rectangle((4.1, 0.9), 5.5, 0.30,
facecolor="white", edgecolor="#88949a", lw=1.5))
for x, color in [(4.7, "#654aab"), (5.05, "#315da8"), (5.4, "#1a805b"),
(5.75, "#b89d10"), (6.1, "#cb7b23"), (6.45, "#b23638")]:
schematic.plot([3.25, x], [3.45, 1.2], color=color, lw=2)
schematic.add_patch(Rectangle((x - 0.17, 0.95), 0.34, 0.23,
color=color, alpha=0.8))
schematic.plot([3.25, 7.6], [3.45, 1.2], "--", color="#7b4d36", lw=2)
schematic.text(5.25, 0.50, "Visible spectrum", ha="center")
schematic.text(4.48, 1.45, "Violet", color="#654aab", fontsize=10)
schematic.text(6.25, 1.45, "Red", color="#b23638", fontsize=10)
schematic.text(7.4, 0.45, "Beyond red", ha="center", color="#7b4d36")
schematic.text(6.2, 3.6, "Dashed ray: infrared\n(no visible color here)",
color="#7b4d36", ha="center")
for x in [7.6, 9.1]:
schematic.add_patch(Circle((x, 1.22), 0.13, color="#23272b"))
schematic.plot([x, x], [1.32, 2.22], color="#23272b", lw=3)
schematic.text(7.15, 2.35, "Blackened bulb\nmeasures warming", ha="center")
schematic.text(9.12, 2.9, "Shaded ambient\nreference*", ha="center")
schematic.annotate("", (9.1, 2.22), (9.1, 2.75),
arrowprops={"arrowstyle": "->", "color": "#54616a"})
schematic.text(0.05, 0.04, "*Suggested modern control; not a claim about Herschel's layout.",
fontsize=10, color="#46545f")
time = np.linspace(0, 120, 1201)
for power, color in [(0.0010, "#784b2e"), (0.0005, "#176b88")]:
model.plot(time, excess_temperature(time, power), color=color, lw=2.5,
label=f"Absorbed power = {power * 1000:.1f} mW")
model.axvline(60, color="#65727a", linestyle="--", lw=1.4)
model.text(63, 0.82, "Beam blocked\nCooling begins", color="#46545f")
model.set(xlim=(0, 120), ylim=(0, 1.08), xlabel="Time (s)",
ylabel="Bulb temperature above ambient (K)")
model.set_title("B. Analytical model — illustrative parameters, no measured data", loc="left")
model.grid(alpha=0.20)
model.legend(loc="upper left", frameon=False)
model.text(0.98, 0.06, "C = 0.020 J/K G = 0.0010 W/K\n"
r"$\tau=C/G=20$ s; $C\,d\theta/dt=P_{\rm abs}-G\theta$",
transform=model.transAxes, ha="right", fontsize=10,
bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.95})
fig.savefig(Path(__file__).with_suffix(".png").name, dpi=120, transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()