Acid-Base Titration Curve — Altair

A titration curve plotting pH against volume of titrant added, producing the characteristic S-shaped (sigmoidal) curve used in analytical chemistry. The plot reveals buffer regions, equivalence points, and acid/base strength at a glance. Essential for chemistry education and laboratory analysis, it helps identify when a reaction reaches completion and which indicators are appropriate.

Acid-Base Titration Curve rendered with Altair

Python source (Altair)

""" anyplot.ai
titration-curve: Acid-Base Titration Curve
Library: altair 6.2.2 | Python 3.13.14
Quality: 93/100 | Updated: 2026-06-24
"""

import os
import sys


# Remove the script's own directory from sys.path before importing altair;
# this file is named altair.py which otherwise shadows the installed library.
_this_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _this_dir]

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


THEME = os.getenv("ANYPLOT_THEME", "light")

# Theme-adaptive chrome — Imprint palette
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint categorical palette (positions 1–8, theme-independent)
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Series colors — Imprint categorical order
CLR_CURVE = IMPRINT_PALETTE[0]  # #009E73 brand green — pH titration curve (first series)
CLR_DERIV = IMPRINT_PALETTE[1]  # #C475FD lavender — dpH/dV derivative (second series)
CLR_EQUIV = IMPRINT_PALETTE[4]  # #AE3030 matte red — equivalence point annotation (semantic)

# Strong acid/strong base: 25 mL of 0.1 M HCl titrated with 0.1 M NaOH
C_acid = 0.1
V_acid = 25.0
C_base = 0.1
V_equiv = C_acid * V_acid / C_base  # 25.0 mL

volume = np.unique(
    np.concatenate(
        [
            np.linspace(0.1, V_equiv - 0.5, 60),
            np.linspace(V_equiv - 0.5, V_equiv - 0.01, 30),
            np.linspace(V_equiv + 0.01, V_equiv + 0.5, 30),
            np.linspace(V_equiv + 0.5, 50.0, 50),
        ]
    )
)

ph = np.zeros_like(volume)
for i, v in enumerate(volume):
    total_vol = V_acid + v
    moles_acid = C_acid * V_acid - C_base * v
    if moles_acid > 1e-10:
        ph[i] = -np.log10(moles_acid / total_vol)
    elif moles_acid < -1e-10:
        moles_base_excess = -moles_acid
        ph[i] = 14.0 + np.log10(moles_base_excess / total_vol)
    else:
        ph[i] = 7.0

# Insert the exact equivalence point
equiv_idx = np.searchsorted(volume, V_equiv)
volume = np.insert(volume, equiv_idx, V_equiv)
ph = np.insert(ph, equiv_idx, 7.0)

# Derivative (dpH/dV) using central differences
dph_dv = np.gradient(ph, volume)
dph_dv = np.nan_to_num(dph_dv, nan=0.0, posinf=0.0, neginf=0.0)

df = pd.DataFrame({"volume_ml": volume, "ph": ph, "dph_dv": dph_dv})

# Annotation data
equiv_pt = pd.DataFrame({"volume_ml": [V_equiv], "ph": [7.0]})
equiv_line = pd.DataFrame({"volume_ml": [V_equiv, V_equiv], "ph": [0, 14]})
equiv_label = pd.DataFrame(
    {"volume_ml": [V_equiv + 0.8], "ph": [3.5], "label": [f"Equivalence Point\n{V_equiv:.0f} mL, pH 7.0"]}
)
ref_line_df = pd.DataFrame({"volume_ml": [0, 50], "ph": [7, 7]})

# Scale definitions
x_scale = alt.Scale(domain=[0, 50])
y_scale = alt.Scale(domain=[0, 14])
# Cap the derivative axis so regional gradients remain visible
# (the equivalence-point spike exceeds 100 pH/mL; clipping reveals the pre/post-EP trend)
DERIV_DISPLAY_MAX = 25.0
deriv_scale = alt.Scale(domain=[0, DERIV_DISPLAY_MAX])

# Shared base axis styling using theme-adaptive tokens
axis_props_base = {
    "labelFontSize": 10,
    "titleFontSize": 12,
    "titleFontWeight": "bold",
    "titleColor": INK,
    "labelColor": INK_SOFT,
    "domainColor": INK_SOFT,
    "domainWidth": 1.5,
    "tickColor": INK_SOFT,
    "tickSize": 5,
    "labelPadding": 5,
}
# Y-axis: include subtle horizontal grid; X-axis: no grid (reduces visual noise)
y_axis_props = {**axis_props_base, "gridOpacity": 0.15, "gridWidth": 0.5, "gridColor": INK}
x_axis_props = {**axis_props_base, "gridOpacity": 0}

# pH 7 horizontal reference line
ref_line = (
    alt.Chart(ref_line_df)
    .mark_line(strokeWidth=1, strokeDash=[4, 4], color=INK_MUTED, opacity=0.6)
    .encode(x=alt.X("volume_ml:Q", scale=x_scale), y=alt.Y("ph:Q", scale=y_scale))
)

# Equivalence point: vertical dashed line
equiv_vline = (
    alt.Chart(equiv_line)
    .mark_line(strokeWidth=1.5, strokeDash=[8, 5], color=CLR_EQUIV, opacity=0.7)
    .encode(x=alt.X("volume_ml:Q", scale=x_scale), y=alt.Y("ph:Q", scale=y_scale))
)

# Equivalence point: diamond marker at pH 7
equiv_marker = (
    alt.Chart(equiv_pt)
    .mark_point(size=200, shape="diamond", filled=True, color=CLR_EQUIV, stroke="white", strokeWidth=2.0)
    .encode(x=alt.X("volume_ml:Q", scale=x_scale), y=alt.Y("ph:Q", scale=y_scale))
)

# Equivalence point: text annotation
equiv_annotation = (
    alt.Chart(equiv_label)
    .mark_text(fontSize=11, fontWeight="bold", color=CLR_EQUIV, align="left", dx=8, lineBreak="\n")
    .encode(x=alt.X("volume_ml:Q", scale=x_scale), y=alt.Y("ph:Q", scale=y_scale), text="label:N")
)

# Primary titration curve (pH, left y-axis)
titration_line = (
    alt.Chart(df)
    .mark_line(strokeWidth=3, interpolate="monotone")
    .encode(
        x=alt.X(
            "volume_ml:Q", scale=x_scale, title="Volume of NaOH added (mL)", axis=alt.Axis(tickCount=10, **x_axis_props)
        ),
        y=alt.Y("ph:Q", scale=y_scale, title="pH", axis=alt.Axis(titlePadding=10, **y_axis_props)),
        color=alt.value(CLR_CURVE),
        tooltip=[
            alt.Tooltip("volume_ml:Q", title="Volume (mL)", format=".1f"),
            alt.Tooltip("ph:Q", title="pH", format=".2f"),
        ],
    )
)

# mark_line legend: line swatches rendered at ph=-100 (outside [0,14] domain, clipped out)
legend_df = pd.DataFrame(
    {
        "volume_ml": [0, 50, 0, 50],
        "ph": [-100.0, -100.0, -100.0, -100.0],
        "label": ["pH (titration curve)", "pH (titration curve)", "dpH/dV (derivative)", "dpH/dV (derivative)"],
    }
)

legend_chart = (
    alt.Chart(legend_df)
    .mark_line(clip=True)
    .encode(
        x=alt.X("volume_ml:Q", scale=x_scale),
        y=alt.Y("ph:Q", scale=y_scale),
        color=alt.Color(
            "label:N",
            scale=alt.Scale(domain=["pH (titration curve)", "dpH/dV (derivative)"], range=[CLR_CURVE, CLR_DERIV]),
            legend=alt.Legend(
                title=None,
                orient="top-right",
                labelFontSize=10,
                symbolSize=150,
                symbolStrokeWidth=2.5,
                padding=8,
                cornerRadius=4,
                fillColor=ELEVATED_BG,
                strokeColor=INK_SOFT,
                labelColor=INK_SOFT,
            ),
        ),
    )
)

# Primary layer (left pH axis)
primary_layer = ref_line + titration_line + equiv_vline + equiv_marker + equiv_annotation + legend_chart

# Derivative curve (dpH/dV, right y-axis) — clipped at DERIV_DISPLAY_MAX to surface regional gradients
deriv_line = (
    alt.Chart(df)
    .mark_line(strokeWidth=2, strokeDash=[5, 3], interpolate="monotone", opacity=0.85, clip=True)
    .encode(
        x=alt.X("volume_ml:Q", scale=x_scale),
        y=alt.Y(
            "dph_dv:Q",
            title="dpH/dV (pH/mL, clipped at 25)",
            scale=deriv_scale,
            axis=alt.Axis(
                domain=False,
                titleColor=CLR_DERIV,
                labelColor=CLR_DERIV,
                titleFontSize=12,
                titleFontWeight="bold",
                labelFontSize=10,
                gridOpacity=0,
                tickColor=CLR_DERIV,
                tickSize=5,
                labelPadding=5,
                titlePadding=10,
            ),
        ),
        color=alt.value(CLR_DERIV),
        tooltip=[
            alt.Tooltip("volume_ml:Q", title="Volume (mL)", format=".1f"),
            alt.Tooltip("dph_dv:Q", title="dpH/dV", format=".2f"),
        ],
    )
)

# Dual y-axis chart via resolve_scale
TITLE = "titration-curve · python · altair · anyplot.ai"
chart = (
    alt.layer(primary_layer, deriv_line)
    .resolve_scale(y="independent")
    .properties(
        width=620,
        height=320,
        title=alt.Title(
            TITLE,
            fontSize=16,
            fontWeight="bold",
            color=INK,
            subtitle="HCl (0.1 M, 25 mL) titrated with NaOH (0.1 M)  ·  Strong Acid / Strong Base",
            subtitleFontSize=11,
            subtitleColor=INK_SOFT,
            subtitlePadding=6,
            anchor="start",
            offset=8,
        ),
    )
    .configure_view(strokeWidth=0, strokeOpacity=0, fill=PAGE_BG)
    .configure(background=PAGE_BG, padding={"left": 15, "right": 15, "top": 8, "bottom": 8})
    .interactive()
)

# Save PNG — pad canvas to exactly 3200×1800 (landscape target)
TW, TH = 3200, 1800
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Part of Acid-Base Titration Curve on anyplot.ai.

Other implementations