Acid-Base Titration Curve — Pygal

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 Pygal

Python source (Pygal)

""" anyplot.ai
titration-curve: Acid-Base Titration Curve
Library: pygal 3.1.3 | Python 3.13.14
Quality: 84/100 | Updated: 2026-06-24
"""

import os
import sys


# Prevent the local pygal.py from shadowing the installed pygal package
sys.path = [p for p in sys.path if p and os.path.abspath(p) != os.path.abspath(os.path.dirname(__file__))]

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — semantic positions for this chart
# Series add() order: pH (1) → dpH/dV (2) → equivalence (3) → pH-7 ref (4) → annotation (5)
CHART_PALETTE = (
    "#009E73",  # pH curve — Imprint brand green (position 1)
    "#C475FD",  # dpH/dV derivative — Imprint lavender (position 2)
    "#AE3030",  # equivalence point — matte red (semantic: critical threshold)
    INK_MUTED,  # pH 7 reference — muted neutral (structural reference)
    "#AE3030",  # equivalence annotation text (matches line color)
)

# Data — 25 mL of 0.1 M HCl titrated with 0.1 M NaOH
ca, va = 0.1, 25.0
cb = 0.1
equivalence_vol = va * ca / cb  # 25.0 mL

volume_ml = np.linspace(0.01, 50.0, 500)
ph = np.empty_like(volume_ml)
for i, v in enumerate(volume_ml):
    moles_h = ca * va - cb * v
    total_vol = va + v
    if moles_h > 1e-10:
        ph[i] = -np.log10(moles_h / total_vol)
    elif moles_h < -1e-10:
        oh_conc = -moles_h / total_vol
        ph[i] = 14.0 + np.log10(oh_conc)
    else:
        ph[i] = 7.0

# Derivative dpH/dV — normalised 0-1 so the right axis has clean labels
dpH_dV = np.gradient(ph, volume_ml)
dpH_dV = np.clip(dpH_dV, 0, None)
dpH_dV = dpH_dV / dpH_dV.max()  # peak = 1.0

eq_vol = equivalence_vol
eq_ph = 7.0

# Title — 45 chars < 67 baseline → full title_font_size=66
title_str = "titration-curve · python · pygal · anyplot.ai"

# Style — Imprint palette, theme-adaptive chrome
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=CHART_PALETTE,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
    font_family="'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
)

# Subsample — 100 points gives a smooth curve with fast render
step = 5
curve_pts = [(float(volume_ml[i]), float(ph[i])) for i in range(0, len(volume_ml), step)]
deriv_pts = [(float(volume_ml[i]), float(dpH_dV[i])) for i in range(0, len(volume_ml), step)]
eq_line_pts = [(float(eq_vol), 0.0), (float(eq_vol), 14.0)]
ref_ph7_pts = [(0.0, 7.0), (50.0, 7.0)]

# Chart — single XY with secondary y-axis for dpH/dV overlay
chart = pygal.XY(
    style=custom_style,
    width=3200,
    height=1800,
    title=title_str,
    x_title="Volume of NaOH added (mL)",
    y_title="pH",
    secondary_range=(0.0, 0.7),
    show_dots=False,
    show_x_guides=False,
    show_y_guides=True,
    range=(0.0, 14.0),
    xrange=(0.0, 50.0),
    legend_at_bottom=True,
    legend_at_bottom_columns=2,
    legend_box_size=30,
    truncate_legend=-1,
    margin=30,
    margin_top=100,
    margin_left=180,
    margin_right=200,
    margin_bottom=160,
    tooltip_fancy_mode=True,
    x_value_formatter=lambda x: f"{x:.1f}",
    y_value_formatter=lambda y: f"{y:.1f}",
)

# Series — color order follows CHART_PALETTE positions
chart.add(
    "pH (0.1 M HCl + NaOH)",
    curve_pts,
    show_dots=False,
    stroke_style={"width": 8, "linecap": "round", "linejoin": "round"},
)

chart.add(
    "dpH/dV (normalised, right axis)",
    deriv_pts,
    secondary=True,
    show_dots=False,
    stroke_style={"width": 6, "linecap": "round", "linejoin": "round"},
)

chart.add(
    f"Equivalence ({eq_vol:.0f} mL, pH {eq_ph:.0f})",
    eq_line_pts,
    show_dots=False,
    stroke_style={"width": 3, "dasharray": "14,8"},
)

chart.add("pH 7 Reference", ref_ph7_pts, show_dots=False, stroke_style={"width": 2, "dasharray": "6,6"})

# Direct text annotation at equivalence point — positioned to the right of the vertical line
ann_label = f"{eq_vol:.0f} mL, pH {eq_ph:.0f}"
chart.add(
    f"Eq. point: {ann_label}",
    [(float(eq_vol + 2.5), float(eq_ph + 2.0))],
    show_dots=True,
    dots_size=10,
    print_values=True,
    value_formatter=lambda _: ann_label,
    stroke=False,
)

# Save
chart.render_to_png(f"plot-{THEME}.png")

with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

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

Other implementations