codexjoule-heating-electrical-calorimetry-readout.py
joule-heating-electrical-calorimetry-readout.py
"""Original measurement schematic and synthetic calorimeter readout.
Python 3.14 / matplotlib 3.10.7 / numpy 2.3.5. Run from _media to save the PNG.
The temperature curve solves C dT/dt = P - G(T - T_room), not measured data.
"""
from pathlib import Path
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle
import numpy as np
def main():
plt.rcParams.update({"font.size": 11, "axes.labelsize": 12})
fig = plt.figure(figsize=(11, 6.1), layout="constrained")
gs = fig.add_gridspec(1, 2, width_ratios=[1.1, 1])
ax, trace = fig.add_subplot(gs[0, 0]), fig.add_subplot(gs[0, 1])
fig.suptitle("Electrical calorimetry: heater-terminal input and thermal response", fontsize=16)
ax.set(xlim=(0, 10), ylim=(0, 10))
ax.axis("off")
ink, blue, orange = "#263544", "#17638d", "#bb4e20"
def wire(xs, ys, color=ink, lw=2):
ax.plot(xs, ys, color=color, lw=lw, solid_capstyle="round")
def meter(x, y, label, color=ink):
ax.add_patch(Circle((x, y), .42, facecolor="white", edgecolor=color, lw=2, zorder=4))
ax.text(x, y, label, ha="center", va="center", fontsize=14, color=color, zorder=5)
ax.add_patch(Rectangle((.45, 7.15), 1.65, 1.5, facecolor="#f1f3f5", edgecolor=ink, lw=1.5))
ax.text(1.27, 7.9, "DC\nsupply", ha="center", va="center")
wire([2.1, 7.6, 7.6, 6.15], [8.15, 8.15, 4.8, 4.8])
wire([4.75, 3.8, 3.8, 2.1], [4.8, 4.8, 7.35, 7.35])
meter(4.15, 8.15, "A")
ax.text(4.15, 8.85, "Heater branch: 1 A", ha="center")
ax.annotate("", xy=(6.8, 8.15), xytext=(5.6, 8.15), arrowprops={"arrowstyle": "->", "color": orange, "lw": 2})
ax.text(6.2, 8.5, r"$I$", ha="center", color=orange)
# The blue pair attaches directly at the heater terminals, inside the boundary.
wire([4.75, 4.75, 5.03], [4.8, 6.35, 6.35], blue, 1.6)
wire([5.87, 6.15, 6.15], [6.35, 6.35, 4.8], blue, 1.6)
meter(5.45, 6.35, "V", blue)
ax.text(5.45, 7.0, "Sense at heater: 5 V", ha="center", color=blue)
ax.add_patch(Rectangle((3.3, 2.55), 4.85, 3.0, facecolor="#e6f2f8", edgecolor=ink, lw=2))
ax.add_patch(Rectangle((3.15, 5.45), 5.15, .18, facecolor="#d8dde2", edgecolor=ink))
ax.add_patch(Rectangle((3.05, 2.3), 5.6, 3.75, fill=False, edgecolor=blue, ls="--", lw=1.3))
ax.add_patch(Rectangle((4.75, 4.57), 1.4, .46, facecolor="#f7dbc8", edgecolor=orange, lw=2))
ax.text(5.45, 4.1, r"Immersed resistor: $5\,\Omega$", ha="center", color=orange)
for x in [4.75, 6.15]:
ax.plot(x, 4.8, "o", ms=4, color=ink, zorder=6)
wire([7.0, 7.0], [6.7, 3.35], lw=3)
ax.plot(7, 3.35, "o", color=orange, ms=7)
ax.text(8.2, 6.85, "Calibrated\nthermometer", ha="center",
bbox={"facecolor": "white", "edgecolor": "none", "pad": 1.0})
wire([4.15, 4.15], [5.95, 3.05], lw=1.5)
wire([3.85, 4.45], [3.05, 3.05], lw=2.5)
ax.text(2.1, 3.2, "Gentle\nstirring", ha="center")
ax.text(5.65, 2.9, "Water bath", ha="center")
ax.text(5.65, 1.9, r"Calibrated $C_{\mathrm{eff}}=500$ J/K", ha="center", color=blue)
ax.annotate("", xy=(9.25, 4.05), xytext=(8.4, 4.05), arrowprops={"arrowstyle": "->", "color": orange, "lw": 2})
ax.text(9.15, 3.0, "Room\nleakage", ha="center", color=orange)
ax.text(5.1, .75, "Record V, I, temperature and time.\nExclude losses in supply leads outside the vessel.", ha="center", fontsize=11)
time = np.linspace(0, 360, 721)
C, G, P, room, start, stop = 500., .5, 5., 20., 60., 180.
heating_duration = np.clip(time - start, 0, stop - start)
excess = P / G * (-np.expm1(-G * heating_duration / C))
excess *= np.exp(-G * np.maximum(time - stop, 0) / C)
ideal = room + P / C * heating_duration
trace.axvspan(start, stop, color="#fff0df", label="Heater on: 5 W for 120 s")
trace.plot(time, ideal, "--", color="#7e8791", lw=2, label="Same input, no leakage")
trace.plot(time, room + excess, color=blue, lw=2.5, label="Model with room leakage")
trace.axhline(room, color="#9ba6af", lw=1, ls=":")
endpoint = room + P / G * (-np.expm1(-G * (stop - start) / C))
trace.plot(stop, endpoint, "o", color=blue, ms=6)
trace.annotate("21.1308 °C", (stop, endpoint), xytext=(30, -26), textcoords="offset points", color=blue,
arrowprops={"arrowstyle": "-", "color": blue})
trace.text(.05, .47, "Input: 600 J\nStored: 565.398 J\nLost to room: 34.602 J", transform=trace.transAxes,
bbox={"boxstyle": "round,pad=0.5", "facecolor": "white", "edgecolor": "#d6dce0"})
trace.text(28, 20.14, "Baseline", ha="center", fontsize=10)
trace.text(276, 20.83, "Heater off:\ncooling", ha="center", fontsize=10)
trace.set(xlabel="Time (s)", ylabel="Assembly temperature (°C)", xlim=(0, 360), ylim=(19.96, 21.52))
trace.set_title("Synthetic readout; room held at 20 °C", fontsize=12)
trace.legend(loc="upper left", fontsize=9.5, frameon=False)
trace.grid(alpha=.15)
trace.spines[["top", "right"]].set_visible(False)
fig.savefig(Path(__file__).stem + ".png", dpi=130, facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()