codex/infrared-ultraviolet-energy-detector-model.py

infrared-ultraviolet-energy-detector-model.py
"""Original photon-energy plot and abstract detector-substitution diagram.

The band boundaries use CIE S 017:2020. The apparatus abstracts the method
described by Larason and Houston, NIST SP 250-41 (2008), not their drawing.
No measured spectral response or historical data are reproduced.
Run from _media with Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.
"""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
import numpy as np

PLANCK = 6.62607015e-34  # J s, SI exact
LIGHT_SPEED = 299792458.0  # m/s, SI exact


def photon_energy(wavelength_nm):
    return PLANCK * LIGHT_SPEED / (np.asarray(wavelength_nm) * 1e-9)


def box(ax, x, y, width, height, text, color="#edf3f6"):
    ax.add_patch(FancyBboxPatch((x, y), width, height,
                               boxstyle="round,pad=0.04,rounding_size=0.10",
                               facecolor=color, edgecolor="#536575", lw=1.3))
    ax.text(x + width / 2, y + height / 2, text, ha="center", va="center", fontsize=10)


def arrow(ax, start, end, label=None):
    ax.annotate("", end, start, arrowprops={"arrowstyle": "->", "lw": 1.6,
                                           "color": "#405363"})
    if label:
        ax.text((start[0] + end[0]) / 2, start[1] + 0.13, label,
                ha="center", fontsize=9)


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, 8.0), layout="constrained")
    energy, detector = fig.subplots(2, 1, gridspec_kw={"height_ratios": [1.05, 1.0]})
    fig.suptitle("Photon energy and detector readout are different quantities", fontsize=16)
    wavelength = np.geomspace(100, 1e6, 1000)
    energy.axvspan(100, 400, facecolor="#e9e0f2")
    energy.axvspan(400, 780, facecolor="#e4f1e8")
    energy.axvspan(780, 1e6, facecolor="#f6e9df")
    energy.loglog(wavelength, photon_energy(wavelength), color="#253f50", lw=2.2)
    energy.set(xlim=(100, 1e6), ylim=(1e-22, 5e-18),
               ylabel="Energy per photon (J)", xlabel="Vacuum wavelength (logarithmic scale)")
    energy.set_xticks([100, 400, 780, 1e4, 1e5, 1e6],
                      ["100 nm", "400", "780 nm", "10 µm", "100 µm", "1 mm"])
    energy.minorticks_off()
    energy.grid(which="major", alpha=0.2)
    energy.text(190, 2.5e-18, "Ultraviolet", ha="center", color="#5f3c7e")
    energy.text(558, 2.5e-18, "Visible", ha="center", color="#356344", fontsize=10)
    energy.text(20000, 2.5e-18, "Infrared", ha="center", color="#7b4d36")
    for nm, offset, text in [(254, (28, -10), "254 nm UV\n7.82 × 10⁻¹⁹ J"),
                             (940, (24, -34), "940 nm IR\n2.11 × 10⁻¹⁹ J"),
                             (10000, (22, 12), "10 µm IR\n1.99 × 10⁻²⁰ J")]:
        energy.plot(nm, photon_energy(nm), "o", color="#253f50", ms=5)
        energy.annotate(text, (nm, photon_energy(nm)), xytext=offset,
                        textcoords="offset points", fontsize=10,
                        bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.95},
                        arrowprops={"arrowstyle": "-", "color": "#61737e"})
    energy.set_title(r"A. Calculation: $E=hc/\lambda$; bands follow the stated CIE convention", loc="left")
    energy.text(0.98, 0.025, "Boundaries are conventions, not sharp limits of vision.",
                transform=energy.transAxes, ha="right", fontsize=9, color="#4b5961")

    detector.set(xlim=(0, 12), ylim=(0, 5.2))
    detector.axis("off")
    detector.set_title("B. Measuring spectral responsivity — abstract apparatus", loc="left")
    box(detector, 0.1, 3.4, 1.3, 0.9, "Source")
    box(detector, 2.0, 3.4, 2.0, 0.9, "Wavelength\nselection")
    box(detector, 4.5, 3.4, 1.25, 0.9, "Shutter")
    box(detector, 6.35, 3.4, 1.45, 0.9, "Beam\nsplitter")
    box(detector, 8.65, 3.25, 3.1, 1.2, "Standard / test detector\nSame position, successively",
        color="#e8f1ea")
    box(detector, 6.3, 1.8, 1.7, 0.9, "Monitor\ndetector")
    arrow(detector, (1.4, 3.85), (2.0, 3.85))
    arrow(detector, (4.0, 3.85), (4.5, 3.85))
    arrow(detector, (5.75, 3.85), (6.35, 3.85))
    arrow(detector, (7.8, 3.85), (8.65, 3.85))
    arrow(detector, (7.075, 3.4), (7.075, 2.7))
    detector.text(10.2, 2.65, "I: dark-subtracted current", ha="center", fontsize=10)
    detector.text(7.15, 1.35, "M: tracks source drift", ha="center", fontsize=10)
    detector.text(0.15, 2.30, "Controls: dark baseline, wavelength scale,\n"
                  "beam geometry, temperature, unwanted light.", fontsize=10, va="center")
    detector.text(0.15, 0.80, r"$R_{\rm test}=R_{\rm std}\," 
                  r"\frac{I_{\rm test}/M_{\rm test}}{I_{\rm std}/M_{\rm std}}$",
                  fontsize=16)
    detector.text(7.0, 0.65, "Illustrative arithmetic only:\n"
                  "0.15 A/W × (1.6 / 2.0) = 0.12 A/W", fontsize=10)
    detector.text(0.15, 0.02, "No measured detector curves; the top plot is a physical-law calculation.",
                  fontsize=9, color="#4b5961")
    fig.savefig(Path(__file__).with_suffix(".png").name, dpi=120, transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()