Chris Parmer — home

Titration curve

Chemistry — acetic acid + NaOH

Example from the compendium of canonical charts

Chemistry — Titration curve (acetic acid + NaOH)

Python Code

"""Chemistry — Titration curve (acetic acid + NaOH)."""


import numpy as np
import plotly.graph_objects as go


PKA = 4.76
CA = 0.100   # M acetic acid
VA = 50.0    # mL
CB = 0.100   # M NaOH


def ph_at_volume(v_naoh_ml):
    """Compute pH when v_naoh_ml of 0.100 M NaOH has been added to 50 mL of 0.100 M acetic acid."""
    moles_acid = VA / 1000.0 * CA        # 5.0e-3 mol
    moles_base = v_naoh_ml / 1000.0 * CB

    if v_naoh_ml == 0.0:
        # Pure weak acid: pH = ½(pKa - log(Ca))
        return 0.5 * (PKA - np.log10(CA))

    if moles_base < moles_acid:
        # Buffer region (Henderson-Hasselbalch)
        ratio = moles_base / (moles_acid - moles_base)
        return PKA + np.log10(ratio)

    if abs(moles_base - moles_acid) < 1e-9:
        # Equivalence point: sodium acetate hydrolysis
        # pH = 7 + (pKa + log(C)) / 2  where C = concentration of acetate at eq pt
        V_total = (VA + v_naoh_ml) / 1000.0
        C_acetate = moles_acid / V_total
        return 7.0 + (PKA + np.log10(C_acetate)) / 2.0

    # Past equivalence point: excess NaOH
    excess_moles_base = moles_base - moles_acid
    V_total = (VA + v_naoh_ml) / 1000.0
    c_oh = excess_moles_base / V_total
    pOH = -np.log10(c_oh)
    return 14.0 - pOH


def generate():
    # Dense grid with extra resolution near equivalence
    v_pre = np.linspace(0, 49.0, 300)
    v_near = np.linspace(49.0, 51.0, 200)
    v_post = np.linspace(51.0, 100.0, 150)
    volumes = np.concatenate([v_pre, v_near, v_post])
    volumes = np.unique(volumes)

    ph = np.array([ph_at_volume(v) for v in volumes])
    ph = np.clip(ph, 0, 14)

    # Key points
    v_half_eq = 25.0
    ph_half_eq = PKA

    v_eq = 50.0
    ph_eq = ph_at_volume(v_eq)

    print(f"  pH at half-equivalence ({v_half_eq} mL): {ph_half_eq:.2f}")
    print(f"  pH at equivalence ({v_eq} mL): {ph_eq:.2f}")

    fig = go.Figure()

    # Main curve
    fig.add_trace(go.Scatter(
        x=volumes,
        y=ph,
        mode="lines",
        name="Titration curve",
        line=dict(color=VIOLET, width=2.5),
        hovertemplate="V(NaOH)=%{x:.1f} mL<br>pH=%{y:.2f}<extra></extra>",
    ))

    # Half-equivalence marker
    fig.add_trace(go.Scatter(
        x=[v_half_eq],
        y=[ph_half_eq],
        mode="markers",
        name="Half-equivalence",
        marker=dict(color=PINK, size=12, symbol="diamond"),
        hovertemplate="Half-eq: V=%{x} mL, pH=%{y:.2f}<extra></extra>",
    ))

    # Equivalence point marker
    fig.add_trace(go.Scatter(
        x=[v_eq],
        y=[ph_eq],
        mode="markers",
        name="Equivalence point",
        marker=dict(color=TEAL, size=12, symbol="star"),
        hovertemplate="Eq. point: V=%{x} mL, pH=%{y:.2f}<extra></extra>",
    ))

    fig.update_layout(
        xaxis=dict(title="Volume NaOH Added (mL)", range=[-1, 102]),
        yaxis=dict(title="pH", range=[1.5, 13.5], dtick=2),
        legend=dict(orientation="h", y=1.05, x=0),
        annotations=[
            dict(
                x=v_half_eq,
                y=ph_half_eq,
                text=f"Half-equivalence<br>V = {v_half_eq} mL, pH = pKa = {PKA}",
                showarrow=True,
                arrowhead=2,
                ax=-90,
                ay=30,
                font=dict(size=11),
                bgcolor="rgba(255,255,255,0.8)",
            ),
            dict(
                x=v_eq,
                y=ph_eq,
                text=f"Equivalence point<br>V = {v_eq} mL, pH ≈ {ph_eq:.2f}",
                showarrow=True,
                arrowhead=2,
                ax=70,
                ay=-40,
                font=dict(size=11),
                bgcolor="rgba(255,255,255,0.8)",
            ),
        ],
        margin=dict(t=50, b=60, l=70, r=60),
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly