codex/glycine-titration-mean-charge-model.py

glycine-titration-mean-charge-model.py
"""Draw ideal glycine titration and mean charge models (Python 3.14).

Run from _media. This is not a fit or an experimental data reconstruction.
The solid curve solves charge balance for 20 mL 0.100 M glycine plus one
equivalent HCl, titrated with 0.100 M NaOH. Volumes add, activity coefficients
are one, and Kw=1e-14 is stipulated at 25 C. The dashed curve neglects free
water ions. Each generator is self-contained and writes its own basename.
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


PKA1, PKA2 = 2.35, 9.78
PI = (PKA1 + PKA2) / 2
KW, C0 = 1e-14, 0.100


def mean_charge(ph):
    h = 10.0 ** (-np.asarray(ph))
    k1, k2 = 10.0**-PKA1, 10.0**-PKA2
    return (h * h - k1 * k2) / (h * h + k1 * h + k1 * k2)


def charge_balance(ph, base_equivalents):
    h = 10.0 ** (-ph)
    ct = C0 / (1 + base_equivalents)
    return h - KW / h + ct * (base_equivalents + mean_charge(ph) - 1)


def titration_ph(base_equivalents):
    # The residual decreases monotonically as pH increases.
    lo = np.zeros_like(base_equivalents)
    hi = np.full_like(base_equivalents, 14.0)
    for _ in range(70):
        mid = (lo + hi) / 2
        positive = charge_balance(mid, base_equivalents) > 0
        lo = np.where(positive, mid, lo)
        hi = np.where(positive, hi, mid)
    return (lo + hi) / 2


def equivalents_at_ph(ph):
    h = 10.0**-ph
    q = (KW / h - h) / C0
    return (1 - mean_charge(ph) + q) / (1 - q)


def main():
    plt.rcParams.update({"font.size": 12, "axes.spines.top": False,
                         "axes.spines.right": False, "savefig.facecolor": "white"})
    fig, (ax, charge_ax) = plt.subplots(2, 1, figsize=(11, 8.5), dpi=100, facecolor="white")
    fig.subplots_adjust(left=0.09, right=0.965, top=0.86, bottom=0.11, hspace=0.48)
    fig.suptitle("Glycine titration and mean charge — calculated models", fontsize=18, y=0.969)
    fig.text(0.5, 0.916, "Stipulated pKa₁ = 2.35, pKa₂ = 9.78; pI = 6.065; ideal activities, 25 °C",
             ha="center", fontsize=12)
    base = np.unique(np.r_[np.linspace(0, 2.2, 1401), [0.5, 1, 1.5, 2]])
    ax.plot(base, titration_ph(base), color="#345b94", lw=2.8,
            label="Water ions + dilution + charge balance")
    ph = np.linspace(0, 14, 2001)
    ax.plot(1 - mean_charge(ph), ph, color="#777777", ls="--", lw=1.7,
            label="Glycine proton inventory only: b = 1 − z̄")
    for p, name, offset in ((PKA1, "pKa₁", (30, 12)), (PI, "pI", (32, -7)),
                            (PKA2, "pKa₂", (20, -28))):
        b = float(equivalents_at_ph(p))
        ax.scatter(b, p, s=35, color="#345b94", zorder=5)
        ax.annotate(f"{name} = {p:g}\nb = {b:.3f}", (b, p), xytext=offset,
                    textcoords="offset points", fontsize=10,
                    arrowprops={"arrowstyle": "-", "color": "#555555"},
                    bbox={"facecolor": "white", "edgecolor": "none", "pad": 2})
    ax.set(xlim=(0, 2.2), ylim=(1, 13), xlabel="Added NaOH / initial glycine, b (mol/mol)", ylabel="Calculated pH")
    ax.set_xticks([0, 0.5, 1, 1.5, 2])
    ax.grid(alpha=0.18)
    ax.legend(loc="lower right", fontsize=10, frameon=True, framealpha=1)
    charge = mean_charge(ph)
    charge_ax.fill_between(ph, 0, charge, where=charge >= 0, color="#1764a1", alpha=0.14)
    charge_ax.fill_between(ph, 0, charge, where=charge <= 0, color="#c25823", alpha=0.14)
    charge_ax.plot(ph, charge, color="#303d4f", lw=2.8)
    charge_ax.axhline(0, color="#777777", lw=1)
    charge_ax.axvline(PI, color="#777777", lw=1.2, ls=":")
    charge_ax.scatter([PI], [0], color="#303d4f", zorder=5)
    charge_ax.text(PI, 0.25, "pI = 6.065\nmean charge zero", ha="center", fontsize=11,
                   bbox={"facecolor": "white", "edgecolor": "none"})
    charge_ax.text(1.1, -0.78, "positive mean charge\n→ negative electrode", color="#1764a1", fontsize=11)
    charge_ax.text(10.1, 0.64, "negative mean charge\n→ positive electrode", color="#a0441a", fontsize=11)
    charge_ax.set(xlim=(0, 14), ylim=(-1.1, 1.1), xlabel="pH", ylabel="Mean charge / e, z̄")
    charge_ax.grid(alpha=0.18)
    fig.text(0.5, 0.04, "Solid curve: 20 mL of 0.100 M glycine + 1 eq HCl; 0.100 M NaOH. No measured data.",
             ha="center", fontsize=11)
    fig.savefig(Path(__file__).with_suffix(".png").name, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()