codexbuffer-ratio-ph.py
buffer-ratio-ph.py
"""Draw buffer-ratio-ph.png in the current working directory.
Henderson-Hasselbalch concentration approximation with stipulated pKa = 4.76.
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Build from the repository root: make _media/buffer-ratio-ph.png
"""
import math
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
def main():
pka = 4.76
ratio = np.geomspace(0.1, 10, 401)
ph = pka + np.log10(ratio)
marked_ratios = np.array([0.1, 1, 2, 10])
marked_ph = pka + np.log10(marked_ratios)
np.testing.assert_allclose(marked_ph, [3.76, 4.76, 5.061029995663981, 5.76],
rtol=0, atol=1e-14)
np.testing.assert_allclose(pka + np.log10(10 * ratio) - ph, 1,
rtol=0, atol=1e-14)
# Verify the activity-equilibrium identity used to obtain the approximation.
ka = 10.0 ** -pka
np.testing.assert_allclose(np.power(10.0, -ph) * ratio, ka, rtol=1e-14)
# At fixed ratio, scaling both concentrations leaves the approximation unchanged.
assert math.isclose(pka + math.log10(0.020 / 0.010),
pka + math.log10(0.0020 / 0.0010), abs_tol=1e-14)
plt.rcParams.update({"font.size": 14, "savefig.facecolor": "white"})
fig, ax = plt.subplots(figsize=(8.8, 5.6), facecolor="white")
fig.subplots_adjust(left=0.11, right=0.96, top=0.79, bottom=0.24)
fig.suptitle("A buffer's pH depends on the partner ratio", fontsize=21, y=0.96)
fig.text(0.5, 0.86, r"$\mathrm{pH}\approx4.76+\log_{10}([\mathrm{A}^-]/[\mathrm{HA}])$",
ha="center", fontsize=16)
ax.semilogx(ratio, ph, color="#3d7f51", lw=3)
ax.axhline(pka, color="#888888", lw=1.2, ls="--")
ax.scatter(marked_ratios, marked_ph, color="#3d7f51", s=55, zorder=4)
labels = ["Ratio 0.1\npH 3.76", "Equal partners\npH 4.76",
"Ratio 2\npH 5.06103", "Ratio 10\npH 5.76"]
for r, p, label, offset, alignment in zip(
marked_ratios, marked_ph, labels,
[(12, 8), (-12, 18), (20, -48), (-12, -42)],
["left", "right", "left", "right"]):
ax.annotate(label, xy=(r, p), xytext=offset, textcoords="offset points",
ha=alignment, fontsize=13,
arrowprops={"arrowstyle": "-", "color": "#777777"})
ax.set(xlim=(0.085, 11.8), ylim=(3.55, 5.97), xticks=[0.1, 1, 2, 10],
yticks=[3.76, 4.76, 5.76], ylabel="pH",
xlabel=r"Conjugate-base / weak-acid concentration ratio $[\mathrm{A}^-]/[\mathrm{HA}]$")
ax.set_xticklabels(["0.1", "1", "2", "10"])
ax.minorticks_off()
ax.spines[["top", "right"]].set_visible(False)
ax.grid(color="#dddddd", lw=0.7)
fig.text(0.5, 0.10, "Stipulated pKa = 4.76; ideal dilute partners at equilibrium.",
ha="center", fontsize=12, color="#444444")
fig.text(0.5, 0.045, "Water ionization neglected; curve is a calculation, not measured data.",
ha="center", fontsize=12, color="#444444")
fig.savefig(Path(__file__).stem + ".png", dpi=100, facecolor="white", transparent=False)
plt.close(fig)
print(f"Verified ratio 2 → pH {marked_ph[2]:.12f}; ratio 1 → pH {pka:.2f}.")
print("Verified decade change → +1 pH, Ka activity identity, and common-scale invariance.")
if __name__ == "__main__":
main()