codex/heat-pump-energy-and-carnot-bounds.py

heat-pump-energy-and-carnot-bounds.py
"""Generate heat-pump-energy-and-carnot-bounds.png in the current directory.

Run from _media with MPLCONFIGDIR=/tmp/codex-wiki-matplotlib.
Tested with Python 3.14.4, matplotlib 3.10.7 and numpy 2.3.5.
All values are stipulated budgets or calculated reversible bounds, not data.
"""

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrow, FancyBboxPatch
import numpy as np


def main():
    plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
                         "axes.spines.right": False, "savefig.facecolor": "white"})
    fig, (flow, ax) = plt.subplots(1, 2, figsize=(11, 5.4), dpi=110,
                                  gridspec_kw={"width_ratios": [1.08, 1]})
    fig.patch.set_facecolor("white")
    fig.subplots_adjust(left=0.035, right=0.965, top=0.83, bottom=0.15, wspace=0.30)
    fig.suptitle("Moving heat: one budget, two useful outputs", fontsize=16, y=0.97)
    fig.text(0.5, 0.895, "Stipulated cycle at 280 K and 320 K; calculated bounds, not measured performance",
             ha="center", fontsize=10.5, color="#444444")
    cold, hot, work = "#2378aa", "#bd4529", "#8256a3"
    flow.set(xlim=(0, 10), ylim=(0, 10))
    flow.axis("off")
    flow.set_title("Energy per complete cycle", fontsize=12, pad=13)

    def box(x, y, w, h, color, text):
        flow.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.1",
                                     linewidth=1.5, edgecolor=color, facecolor="white"))
        flow.text(x + w / 2, y + h / 2, text, ha="center", va="center", color=color)

    box(0.5, 8.05, 8.8, 1.12, hot, "Hot reservoir: 320 K")
    box(0.5, 0.8, 8.8, 1.12, cold, "Cold reservoir: 280 K")
    box(2.3, 4.5, 4.4, 1.25, "#444444", "Cyclic machine\nno energy stored")
    # Shaft widths are exactly proportional to the three energy magnitudes.
    for x, y, dx, dy, magnitude, color in [
        (4.5, 2.12, 0, 2.2, 3, cold),
        (4.5, 5.93, 0, 1.9, 4, hot),
        (9.45, 5.12, -2.5, 0, 1, work),
    ]:
        flow.add_patch(FancyArrow(x, y, dx, dy, width=0.16 * magnitude,
                                 head_width=0.16 * magnitude + 0.30,
                                 head_length=0.37, length_includes_head=True,
                                 color=color))
    flow.text(0.35, 3.15, "$Q_c=3$ kJ\nuseful cooling", color=cold, va="center")
    flow.text(0.35, 6.95, "$Q_h=4$ kJ\nuseful heating", color=hot, va="center")
    flow.text(8.3, 6.12, "$W=1$ kJ\nwork input", color=work, ha="center")
    flow.text(4.9, 0.05, "$4=3+1$ kJ     COP$_{\\rm r}=3$     COP$_{\\rm heat}=4$",
              ha="center", fontsize=11)

    lift = np.linspace(10.0, 80.0, 351)
    cop_r = 280.0 / lift
    cop_h = (280.0 + lift) / lift
    ax.plot(lift, cop_h, color=hot, lw=2.5, label="Heating bound: (280 + ΔT)/ΔT")
    ax.plot(lift, cop_r, color=cold, lw=2.5, label="Cooling bound: 280/ΔT")
    ax.fill_between(lift, cop_r, cop_h, color="#f3dfd5", zorder=0)
    ax.set(xlim=(10, 80), ylim=(0, 32), xlabel="Reservoir temperature lift ΔT (K)",
           ylabel="Coefficient of performance (dimensionless)")
    ax.set_title("Carnot bounds with cold side fixed at 280 K", fontsize=12, pad=13)
    ax.grid(alpha=0.18)
    ax.axvline(40, color="#777777", ls=":", lw=1)
    ax.scatter([40, 40], [8, 7], c=[hot, cold], s=40, zorder=5)
    ax.annotate("Heating: 8", (40, 8), (48, 14), color=hot,
                arrowprops={"arrowstyle": "-", "color": hot})
    ax.annotate("Cooling: 7", (40, 7), (48, 9.5), color=cold,
                arrowprops={"arrowstyle": "-", "color": cold})
    ax.text(43, 20, "Heating − cooling = 1\nLarger lift → lower bounds", fontsize=10)
    ax.legend(loc="upper right", frameon=False, fontsize=9, handlelength=1.8)
    fig.text(0.5, 0.035, "COP above 1 counts heat extracted elsewhere as well as the supplied work.",
             ha="center", fontsize=11)
    fig.savefig("heat-pump-energy-and-carnot-bounds.png", facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()