codex/thermopile-absorber-step-response.py

thermopile-absorber-step-response.py
"""Plot an original lumped thermopile model, not experimental data.

Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media; the output is written to the current working directory.
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import FancyArrowPatch, Rectangle


def main():
    plt.rcParams.update({"font.size": 11, "font.family": "DejaVu Sans"})
    fig = plt.figure(figsize=(10.5, 5.8), dpi=130, facecolor="white")
    gs = fig.add_gridspec(1, 2, width_ratios=(1, 1.65), wspace=0.30)
    diagram, ax = fig.add_subplot(gs[0]), fig.add_subplot(gs[1])
    fig.suptitle("Thermopile response to a step in absorbed power", fontsize=16, y=0.98)
    fig.text(0.5, 0.915, "Calculated model curves; all parameter values are stipulated", ha="center", color="#555555")
    diagram.set(xlim=(0, 4.3), ylim=(0, 6))
    diagram.axis("off")
    diagram.add_patch(Rectangle((0.4, 3.5), 3.5, 1.15, facecolor="#fff0dd", edgecolor="#c24a20", lw=1.5))
    diagram.text(2.15, 4.08, "Absorber at $T_h$\n$C_{\\rm th}=0.02$ J/K", ha="center", va="center")
    diagram.add_patch(FancyArrowPatch((2.15, 5.7), (2.15, 4.72), arrowstyle="-|>", mutation_scale=18, color="#c24a20", lw=2))
    diagram.text(2.15, 5.88, "$P_0=0.03$ W", ha="center", color="#c24a20")
    diagram.add_patch(FancyArrowPatch((2.15, 3.45), (2.15, 1.7), arrowstyle="-|>", mutation_scale=18, color="#70499b", lw=2.5))
    diagram.text(2.15, 2.72, "Heat loss\n$G_{\\rm th}\\Delta T$", ha="center", va="center", bbox={"facecolor": "white", "edgecolor": "none"})
    diagram.add_patch(Rectangle((0.4, 0.9), 3.5, 0.75, facecolor="#e8f3fb", edgecolor="#1765a1", lw=1.5))
    diagram.text(2.15, 1.27, "Fixed reference $T_c$", ha="center", va="center")
    diagram.text(2.15, 0.12, r"$V_{\rm oc}=N\alpha(T_h-T_c)$" + "\n" + r"$N=20,\quad\alpha=40\ \mu$V/K", ha="center", va="center")
    n, alpha, capacity, power = 20, 40e-6, 0.02, 0.03
    t = np.linspace(0, 16, 500)
    for conductance, style, color, label in (
        (0.01, "-", "#1765a1", "$G_{\\rm th}=0.010$ W/K: $\\tau=2$ s"),
        (0.005, "--", "#c24a20", "$G_{\\rm th}=0.005$ W/K: $\\tau=4$ s"),
    ):
        tau = capacity / conductance
        final_mv = 1e3 * n * alpha * power / conductance
        ax.plot(t, final_mv * (-np.expm1(-t / tau)), style, lw=2.5, color=color, label=label)
        ax.axhline(final_mv, color=color, lw=0.9, alpha=0.5)
        ax.text(15.7, final_mv + 0.06, f"{final_mv:.1f} mV limit", color=color, ha="right", fontsize=10)
    v_tau = 2.4 * (1 - np.exp(-1))
    ax.scatter([2], [v_tau], s=38, color="#1765a1", zorder=5)
    ax.annotate("1 time constant\n1.52 mV = 63.2%", (2, v_tau), xytext=(4.8, 0.55), arrowprops={"arrowstyle": "->", "color": "#1765a1"}, color="#1765a1", fontsize=10)
    ax.set(xlim=(0, 16), ylim=(0, 5.35), xlabel="Time after power step (s)", ylabel="Open-circuit voltage (mV)")
    ax.grid(alpha=0.2)
    ax.legend(loc="upper left", bbox_to_anchor=(0, 0.83), fontsize=9, framealpha=1)
    fig.text(0.64, 0.045, "Half the conductance: twice the final voltage, twice the settling time", ha="center", fontsize=10)
    fig.subplots_adjust(left=0.03, right=0.98, bottom=0.16, top=0.85)
    fig.savefig(Path(__file__).stem + ".png", facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()