codexgalvanic-electrodes-load-budget.py
galvanic-electrodes-load-budget.py
"""Toy constant-emf cell model and charge/available-work budget, not data.
Run from _media with MPLCONFIGDIR=/tmp/codex-wiki-matplotlib.
Tested with Python 3.14.4, matplotlib 3.10.7 and numpy 2.3.5.
The PNG basename is written to the current working directory.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
def main():
emf, internal_r, load_r, interval = 1.10, 100.0, 100.0, 600.0
faraday = 6.02214076e23 * 1.602176634e-19
current = emf / (internal_r + load_r)
voltage = current * load_r
charge = current * interval
extent = charge / (2 * faraday)
available = emf * charge
delivered = voltage * charge
dissipated = current**2 * internal_r * interval
assert np.isclose(available, delivered + dissipated)
assert np.isclose(2 * faraday * extent, charge)
plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 12})
fig = plt.figure(figsize=(10.2, 6.1), dpi=110, facecolor="white")
fig.suptitle("Open circuit and load: a calculated toy model",
fontsize=17, weight="bold", y=0.965)
fig.text(0.5, 0.885,
"Chosen inputs: emf = 1.10 V, internal r = 100 Ω, load R = 100 Ω",
ha="center", fontsize=12)
ax = fig.add_axes((0.085, 0.32, 0.39, 0.49))
grid_i = np.linspace(0, emf / internal_r, 200)
ax.plot(1000 * grid_i, emf-internal_r * grid_i, color="#1565a8", lw=2.5)
ax.scatter([0], [emf], facecolor="white", edgecolor="#1565a8",
s=80, linewidth=2, zorder=4)
ax.scatter([1000 * current], [voltage], color="#b7581d", s=65, zorder=4)
ax.annotate("Open circuit\nI = 0, V = 1.10 V\nload power = 0",
xy=(0, emf), xytext=(3.6, 1.03), fontsize=10,
arrowprops={"arrowstyle": "->", "color": "#243649"})
ax.annotate("Chosen load\n5.50 mA, 0.550 V", xy=(1000*current, voltage),
xytext=(0.65, 0.18), fontsize=10,
arrowprops={"arrowstyle": "->", "color": "#243649"})
ax.set(xlim=(-0.35, 11.4), ylim=(0, 1.2),
xlabel="Discharge current I (mA)", ylabel="Terminal voltage V (V)")
ax.set_title("V = emf − Ir", fontsize=13, pad=8)
ax.grid(alpha=0.22)
ax.spines[["top", "right"]].set_visible(False)
budget = fig.add_axes((0.61, 0.32, 0.34, 0.49))
budget.bar([0], [available], color="#7591aa", width=0.55)
budget.bar([1], [delivered], color="#1565a8", width=0.55)
budget.bar([1], [dissipated], bottom=[delivered], color="#d69358", width=0.55)
budget.text(0, available+0.08, f"{available:.3f} J", ha="center", fontsize=11)
budget.text(1, delivered/2, f"Load\n{delivered:.3f} J", color="white",
ha="center", va="center", fontsize=11)
budget.text(1, delivered+dissipated/2, f"Internal loss\n{dissipated:.3f} J",
ha="center", va="center", fontsize=11)
budget.set(ylim=(0, 4.2), ylabel="Available electrical work (J)",
xticks=[0, 1], xticklabels=["emf × Q", "VQ + I²rt"])
budget.set_title("Budget over 600 s", fontsize=13, pad=8)
budget.spines[["top", "right"]].set_visible(False)
fig.text(0.5, 0.19,
f"Q = It = {charge:.2f} C ξ = Q/(2F) = {extent*1e6:.1f} μmol",
ha="center", fontsize=14)
fig.text(0.5, 0.13, "ξ mol Zn consumed = ξ mol Cu deposited (ideal reaction only)",
ha="center", fontsize=11)
fig.text(0.5, 0.055,
"Calculated, not measured. Fixed composition and r; no competing reactions.\n"
"emf × Q is a free-energy budget, not the whole cell thermal budget.",
ha="center", fontsize=10, linespacing=1.5)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"),
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()