codex/planck-spectral-radiance.py

planck-spectral-radiance.py
"""Ideal wavelength spectra at three stipulated temperatures.

Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Writes planck-spectral-radiance.png into the current working directory.
Constants are exact SI values; this is a model, not measured data.
"""

import math

import matplotlib.pyplot as plt
import numpy as np


H = 6.62607015e-34  # J s
K_B = 1.380649e-23  # J/K
C = 299792458.0  # m/s


def positive_peak_root(exponent):
    """Bisect m(1-exp(-x))-x on [1,m], excluding its endpoint root at 0."""
    lo, hi = 1.0, float(exponent)
    for _ in range(100):
        mid = (lo + hi) / 2
        if exponent * (-math.expm1(-mid)) - mid > 0:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2


def b_lambda(wavelength_m, temperature):
    return (2 * H * C**2 / wavelength_m**5
            / np.expm1(H * C / (wavelength_m * K_B * temperature)))


def main():
    x_peak = positive_peak_root(5)
    displacement = H * C / (K_B * x_peak)
    temperatures = (3000, 4500, 6000)
    peaks_um = np.array([displacement / t * 1e6 for t in temperatures])
    assert np.allclose(peaks_um, [0.9659239851, 0.6439493234, 0.4829619925])
    for t, peak_um in zip(temperatures, peaks_um):
        peak = peak_um * 1e-6
        assert b_lambda(peak, t) > b_lambda(peak * 0.99, t)
        assert b_lambda(peak, t) > b_lambda(peak * 1.01, t)
    wavelength_um = np.linspace(0.1, 3.0, 2200)
    wavelength_m = wavelength_um * 1e-6
    plt.rcParams.update({"font.size": 11, "figure.facecolor": "white",
                         "axes.facecolor": "white", "savefig.facecolor": "white",
                         "axes.spines.top": False, "axes.spines.right": False})
    fig, ax = plt.subplots(figsize=(9.4, 5.3), dpi=130)
    fig.subplots_adjust(left=0.115, right=0.965, bottom=0.19, top=0.84)
    fig.suptitle("Planck's law: temperature sets the whole spectrum", fontsize=16)
    for t, peak_um, color in zip(temperatures, peaks_um,
                                ("#a14b15", "#237850", "#245caa")):
        # B_lambda per metre -> per micrometre: multiply by 1e-6.
        radiance_per_um = b_lambda(wavelength_m, t) * 1e-6
        ax.plot(wavelength_um, radiance_per_um / 1e6, color=color, lw=2.6,
                label=f"{t} K  ·  peak {peak_um * 1000:.0f} nm")
        peak_y = b_lambda(peak_um * 1e-6, t) * 1e-6 / 1e6
        ax.plot(peak_um, peak_y, "o", ms=6, color=color)
        ax.vlines(peak_um, 0, peak_y, color=color, linestyle=":", lw=1.2)
    ax.set(xlim=(0.1, 3.0), ylim=(0, 35),
           xlabel="Wavelength λ (μm)",
           ylabel="Bλ (10⁶ W m⁻² sr⁻¹ μm⁻¹)")
    ax.set_xticks([0.1, 0.5, 1, 1.5, 2, 2.5, 3])
    ax.grid(alpha=0.18)
    ax.legend(frameon=False, loc="upper right", fontsize=12)
    fig.text(0.5, 0.035,
             "Ideal equilibrium spectra · markers maximize radiance per unit wavelength",
             ha="center", fontsize=11)
    fig.savefig("planck-spectral-radiance.png", facecolor="white", transparent=False)
    plt.close(fig)
    print(f"x_lambda={x_peak:.12f}; b={displacement:.12g} m K")
    for t, peak_um in zip(temperatures, peaks_um):
        print(f"T={t} K: lambda_peak={peak_um * 1000:.6f} nm; "
              f"B_lambda(0.5 um)={b_lambda(0.5e-6, t) * 1e-6:.6g} per um")


if __name__ == "__main__":
    main()