codexweak-acid-dissociation-dilution.py
weak-acid-dissociation-dilution.py
"""Synthetic ideal aqueous HA model; Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.
Run from _media with MPLBACKEND=Agg and a writable MPLCONFIGDIR.
No material data or measured observations are represented.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
KA = 1e-5
KW = 1e-14
def equilibrium(c):
"""Solve charge balance for dimensionless h = [H3O+]/(1 mol/L)."""
lo, hi = np.sqrt(KW), c + np.sqrt(KW)
for _ in range(90):
h = (lo + hi) / 2
if h - KW / h - c * KA / (h + KA) > 0:
hi = h
else:
lo = h
h = (lo + hi) / 2
return h, c * KA / (h + KA), c * h / (h + KA), KW / h
def main():
c = np.logspace(-2, -10, 401)
states = np.array([equilibrium(value) for value in c])
h, a, ha, oh = states.T
np.testing.assert_allclose(a + ha, c, rtol=2e-15)
np.testing.assert_allclose(a + oh, h, rtol=2e-14)
np.testing.assert_allclose(h * a / ha, KA, rtol=3e-15)
np.testing.assert_allclose(h * oh, KW, rtol=2e-15)
# Independent polynomial-root solution, scaled to avoid tiny coefficients.
for ci, hi in zip(c[::5], h[::5], strict=True):
scale = max(np.sqrt(KA * ci), np.sqrt(KW))
roots = np.roots([1, KA / scale, -(KW + KA * ci) / scale**2,
-KA * KW / scale**3])
positive = [r.real * scale for r in roots
if abs(r.imag) < 1e-12 and r.real > 0]
assert len(positive) == 1
np.testing.assert_allclose(positive[0], hi, rtol=2e-12)
assert np.all(np.diff(a / c) > 0)
assert np.all(np.diff(-np.log10(h)) > 0)
assert np.all(h >= np.sqrt(KW))
plt.rcParams.update({"font.size": 11, "figure.facecolor": "white",
"axes.facecolor": "white"})
fig, axes = plt.subplots(1, 2, figsize=(9.6, 4.9), layout="constrained")
fig.suptitle("Dilution: more dissociation, less hydronium", fontsize=16)
axes[0].semilogx(c, 100 * a / c, color="#007f8b", lw=2.5,
label="Full ideal balance")
axes[0].axhline(100 * KA / (KA + np.sqrt(KW)), color="#666666", ls=":",
label="Infinite-dilution limit: 99.01%")
axes[0].set(ylabel=r"Dissociated acid, $100[A^-]/C_T$ (%)", ylim=(0, 105))
no_water = 2 * KA * c / (KA + np.sqrt(KA**2 + 4 * KA * c))
axes[1].semilogx(c, -np.log10(h), lw=2.5, color="#007f8b",
label="Full ideal balance")
axes[1].semilogx(c, -np.log10(no_water), ls="--", color="#aa5a00",
label="Quadratic: neglect water")
axes[1].semilogx(c, -np.log10(np.sqrt(KA * c)), ls=":", color="#8042a0",
label="Square root: also neglect depletion")
axes[1].axhline(7, color="#666666", lw=1)
axes[1].text(2e-10, 7.15, "Neutral water", ha="right", fontsize=10)
axes[1].set(ylabel="Calculated pH", ylim=(3, 10.3))
for ax in axes:
ax.set(xlabel=r"Total acid $C_T$ (mol L$^{-1}$)", xlim=(1e-2, 1e-10))
ax.set_xticks([1e-2, 1e-4, 1e-6, 1e-8, 1e-10])
ax.grid(alpha=0.2)
ax.legend(fontsize=8.5, loc="best")
fig.supxlabel(r"Dilution increases to the right → | Synthetic $K_a=10^{-5}$, $K_w=10^{-14}$; fixed temperature",
fontsize=10)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=120,
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()