codexbuffer-finite-titrant-ph-response.py
buffer-finite-titrant-ph-response.py
"""Constant-volume equilibrium response; run from _media with Python 3.14.
Dependencies: matplotlib 3.10.7, numpy 2.3.5 (root pyproject.toml).
All values are stipulated; curves contain no experimental data.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def charge_inventory(ph, concentration, ka=1e-5, kw=1e-14):
"""A⁻ + OH⁻ − H⁺ in mol/L, with c° = 1 mol/L."""
h = 10.0 ** (-np.asarray(ph))
return concentration * ka / (ka + h) + kw / h - h
def equilibrium_ph(dose, concentration, initial_ph=5.0):
"""Invert strictly increasing charge inventory with bracketed bisection."""
dose = np.asarray(dose)
target = charge_inventory(initial_ph, concentration) + dose
lo = np.full_like(dose, -2.0, dtype=float)
hi = np.full_like(dose, 16.0, dtype=float)
for _ in range(70):
mid = (lo + hi) / 2
below = charge_inventory(mid, concentration) < target
lo = np.where(below, mid, lo)
hi = np.where(below, hi, mid)
return (lo + hi) / 2
def main():
plt.rcParams.update({"font.size": 11, "axes.spines.top": False, "axes.spines.right": False})
fig, axes = plt.subplots(1, 2, figsize=(10, 4.8), layout="constrained", facecolor="white")
dose = np.linspace(-0.006, 0.006, 1201)
for concentration, color in [(0.1, "#2466a8"), (0.01, "#d45d18")]:
ph = equilibrium_ph(dose, concentration)
axes[0].plot(dose * 1000, ph, color=color, lw=2.5, label=f"C = {concentration:.3f} mol/L")
axes[0].scatter([2], equilibrium_ph(np.array([0.002]), concentration), color=color, zorder=3)
axes[0].axvspan(-5, 5, color="#d45d18", alpha=0.07)
axes[0].axvline(-5, color="#d45d18", ls=":", lw=1)
axes[0].axvline(5, color="#d45d18", ls=":", lw=1)
axes[0].set(xlim=(-6, 6), ylim=(2.5, 11.5), title="Finite partner inventories limit the useful range", ylabel="Equilibrium pH", xlabel="Signed added dose (mmol/L)")
axes[0].legend(loc="upper left", frameon=False, fontsize=10)
axes[0].text(-4.6, 9.1, "Dotted lines: dilute sample's\napproximate ±C/2 inventory", fontsize=10)
dose = np.linspace(-0.004, 0.004, 801)
beta0 = np.log(10) * (0.01 / 4 + 1e-5 + 1e-9)
axes[1].plot(dose * 1000, equilibrium_ph(dose, 0.01), color="#d45d18", lw=2.5, label="Full equilibrium, C = 0.010 mol/L")
axes[1].plot(dose * 1000, 5 + dose / beta0, color="#333333", lw=1.8, ls="--", label="Initial local approximation")
axes[1].scatter([2], equilibrium_ph(np.array([0.002]), 0.01), color="#d45d18", zorder=3)
axes[1].annotate("+2 mmol/L → pH 5.3668", xy=(2, float(equilibrium_ph(np.array([0.002]), 0.01)[0])), xytext=(-3.5, 5.65), arrowprops={"arrowstyle": "->", "color": "0.4"}, fontsize=10)
axes[1].set(xlim=(-4, 4), ylim=(3.8, 6.15), title="The initial derivative predicts only small changes", ylabel="Equilibrium pH", xlabel="Signed added dose (mmol/L)")
axes[1].legend(loc="lower right", frameon=False, fontsize=9)
for ax in axes:
ax.axhline(5, color="0.65", lw=0.8, zorder=0)
ax.grid(alpha=0.18)
ax.set_facecolor("white")
fig.suptitle("Calculated response from pH 5: pKa = 5, Kw = 10⁻¹⁴, constant volume", fontsize=13)
fig.savefig(Path(__file__).stem + ".png", dpi=100, facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()