codexsensible-and-latent-heat.py
sensible-and-latent-heat.py
"""Plot a constructed fixed-pressure heating curve and its phase mass fraction.
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Run through Make; writes sensible-and-latent-heat.png to the current directory.
Stipulated values model no named substance; accumulated heat starts at zero.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def heating_state(heat, mass, cs, latent, cl, initial_t, transition_t):
"""Return temperature and liquid mass fraction; heat is in kJ."""
solid_heat = mass * cs * (transition_t - initial_t)
plateau_end = solid_heat + mass * latent
temperature = np.where(heat < solid_heat, initial_t + heat / (mass * cs),
np.where(heat <= plateau_end, transition_t,
transition_t + (heat - plateau_end) / (mass * cl)))
fraction = np.clip((heat - solid_heat) / (mass * latent), 0, 1)
return temperature, fraction
def main():
mass, cs, latent, cl = 1.0, 2.0, 100.0, 3.0 # kg; kJ/(kg K); kJ/kg; kJ/(kg K)
initial_t, transition_t, final_t = 280.0, 300.0, 320.0
solid_heat = mass * cs * (transition_t - initial_t)
plateau_end = solid_heat + mass * latent
total_heat = plateau_end + mass * cl * (final_t - transition_t)
heat = np.linspace(0.0, total_heat, 1001)
temperature, fraction = heating_state(heat, mass, cs, latent, cl, initial_t, transition_t)
assert np.allclose([solid_heat, plateau_end, total_heat], [40.0, 140.0, 200.0])
sample_t, sample_fraction = heating_state(np.array([0, 40, 90, 140, 200]),
mass, cs, latent, cl, initial_t, transition_t)
assert np.allclose(sample_t, [280, 300, 300, 300, 320])
assert np.allclose(sample_fraction, [0, 0, 0.5, 1, 1])
plateau = (heat >= solid_heat) & (heat <= plateau_end)
assert np.all(temperature[plateau] == transition_t)
assert np.all(np.diff(temperature) >= 0)
assert np.all(np.diff(fraction) >= 0)
# Reconstruct the entire supplied-heat budget from sensible and latent parts.
recovered_heat = (mass * cs * (np.minimum(temperature, transition_t) - initial_t)
+ mass * latent * fraction
+ mass * cl * np.maximum(temperature - transition_t, 0))
assert np.allclose(recovered_heat, heat)
assert np.isclose(latent / transition_t, 1 / 3) # kJ/(kg K)
plt.rcParams.update({"font.size": 11, "figure.facecolor": "white",
"axes.facecolor": "white", "savefig.facecolor": "white",
"axes.spines.top": False, "axes.spines.right": False})
fig, (temp_ax, fraction_ax) = plt.subplots(2, 1, figsize=(9.6, 6.0), dpi=120,
sharex=True, height_ratios=[2.7, 1.0])
fig.subplots_adjust(left=0.09, right=0.97, bottom=0.22, top=0.83, hspace=0.16)
fig.suptitle("Sensible heat warms; latent heat converts the phase", fontsize=15)
fig.text(0.5, 0.9, "Constructed 1 kg model at fixed pressure; transition at 300 K",
ha="center", fontsize=11)
blue, orange, green = "#216a99", "#b8531c", "#24765c"
for low, high, color in [(0, solid_heat, blue), (solid_heat, plateau_end, orange),
(plateau_end, total_heat, green)]:
selected = (heat >= low) & (heat <= high)
temp_ax.plot(heat[selected], temperature[selected], lw=3, color=color)
temp_ax.set_ylim(275, 329)
temp_ax.set_yticks([280, 290, 300, 310, 320])
temp_ax.set_ylabel("Temperature T (K)")
temp_ax.text(6, 318, "Solid warming\nΔT = 20 K\nQ = 40 kJ", color=blue, fontsize=11)
temp_ax.text(90, 313, "Solid + liquid\nΔT = 0; Q = 100 kJ", ha="center", color=orange)
temp_ax.text(151, 283, "Liquid warming\nΔT = 20 K\nQ = 60 kJ", color=green)
temp_ax.plot([90], [300], "o", color=orange, ms=6)
temp_ax.annotate("90 kJ total: half liquid by mass", (90, 300),
xytext=(90, 280), ha="center", fontsize=10,
arrowprops={"arrowstyle": "->", "color": orange})
fraction_ax.plot(heat, fraction, color=orange, lw=2.5)
fraction_ax.plot([90], [0.5], "o", color=orange, ms=5)
fraction_ax.set_ylim(-0.08, 1.18)
fraction_ax.set_yticks([0, 0.5, 1])
fraction_ax.set_ylabel("Liquid mass\nfraction x")
fraction_ax.set_xlabel("Total supplied heat Q = ΔH from the initial solid (kJ)", labelpad=8)
for ax in (temp_ax, fraction_ax):
ax.axvspan(solid_heat, plateau_end, color=orange, alpha=0.07)
ax.set_xlim(0, total_heat)
ax.set_xticks([0, 40, 90, 140, 200])
ax.grid(alpha=0.18)
fig.text(0.5, 0.105, "Stipulated: cₛ = 2 kJ/(kg K); L = 100 kJ/kg; cₗ = 3 kJ/(kg K)",
ha="center", fontsize=11)
fig.text(0.5, 0.055, "Pure substance model; equilibrium coexistence; only pressure–volume work; "
"total Q = 200 kJ", ha="center", fontsize=10)
fig.savefig(Path(__file__).with_suffix(".png").name, transparent=False)
plt.close(fig)
print(f"Heating stages: {solid_heat:g} + {mass * latent:g} + "
f"{mass * cl * (final_t - transition_t):g} = {total_heat:g} kJ; "
"Q=90 kJ: T=300 K, x=0.5; Δs=1/3 kJ/(kg K)")
if __name__ == "__main__":
main()