Titration curve drop by drop: two drops move the pH from 4 to 10
Afterwards you can compute a titration curve from the charge balance with brentq, explain its jump and buffer plateau, and pick the right indicator.
- Topic
- Numerical calculus
- Field
- Chemistry
- Prerequisites
- none beyond Python basics
- Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
py-titration-curve.ipynb, executed with the versions above. The download needs a free account
Run it yourself. In a terminal, this installs exactly the versions above:
pip install numpy==2.4.3 scipy==1.18.1 matplotlib==3.11.2 jupyterlabPlay: titrate an acid drop by drop
A beaker holds 25 mL of 0.1 M hydrochloric acid with a few drops of phenolphthalein, and the burette above it holds 0.1 M sodium hydroxide, NaOH, which leaves the tip in drops of 0.05 mL. Tap Drop to let one drop fall. Hold it and the burette streams 20 drops a second; let go near 24 mL and go on tap by tap. Watch the pH meter and the color of the beaker. For 20 mL the meter creeps from 1.00 to 1.95, and then the drop before 25 mL reads 4.00, the drop at 25 mL 7.00, and the drop after it 10.00, the one that turns the beaker pink. Then tap Acid to switch to acetic acid, the acid of vinegar diluted about eightfold, and watch the meter at 12.50 mL, and after that tap Indicator for methyl orange. Reset starts a fresh beaker, and so does every switch. On a computer, Space works as Drop.
Show code
// game.js: a titration. Drops of 0.1 M NaOH fall from a burette into 25 mL of 0.1 M acid with an indicator;
// after every drop the pH meter reads the pH from the charge balance, and the beaker takes the indicator's color.
// Units: mL, mol/L, seconds. The notebook writes every constant into #data.
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const {va: VA, ca: CA, cb: CB, drop: DROP, max_drops: MAX_DROPS, stream: STREAM, hold: HOLD, fall: FALL} = DATA;
const KW = 10 ** -DATA.pkw;
const ACIDS = DATA.acids, INDICATORS = DATA.indicators; // pka null: a strong acid
// ---- the charge balance, positive minus negative charge / M at p = -log10[H+]; it falls as p rises
function excess(p, vb, acid) {
const h = 10 ** -p, c = CA * VA / (VA + vb), na = CB * vb / (VA + vb);
const pka = ACIDS[acid].pka;
const anion = pka === null ? c : c / (1 + h * 10 ** pka); // c α₁ for a weak acid
return h + na - KW / h - anion;
}
// bisection on p: 50 halvings of [-1, 15] leave an interval 1.4e-14 wide
function solvePH(vb, acid) {
let lo = -1, hi = 15;
for (let i = 0; i < 50; i++) {
const mid = (lo + hi) / 2;
if (excess(mid, vb, acid) > 0) lo = mid; else hi = mid;
}
return (lo + hi) / 2;
}
// the indicator's color: its acid and base colors blended by its base fraction
function beakerColor(ph, indicator) {
const ind = INDICATORS[indicator], f = 1 / (1 + 10 ** (ind.pk - ph));
const [r, g, b, a] = ind.acid.map((v, j) => v + f * (ind.base[j] - v));
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${a.toFixed(3)})`;
}
// ---- the state: everything that changes, in one object
const state = {acid: "HCl", indicator: "phenolphthalein", n: 0, released: 0, vb: 0, ph: 0, trace: [],
drops: [], holding: false, hold: 0, owed: 0};
function land() { // a drop reaches the beaker: 0.05 mL more base
state.n += 1;
state.vb = state.n * DROP;
state.ph = solvePH(state.vb, state.acid);
state.trace.push(state.ph);
}
function fill(vb) { // a fresh beaker, then vb mL at once
Object.assign(state, {n: 0, released: 0, vb: 0, drops: [], holding: false, hold: 0, owed: 0});
state.ph = solvePH(0, state.acid);
state.trace = [state.ph];
const n = Math.min(Math.round(vb / DROP), MAX_DROPS);
while (state.n < n) land();
state.released = n;
}
function release() { // one drop leaves the tip; the burette holds 50 mL
if (state.released >= MAX_DROPS) return;
state.released += 1;
state.drops.push(0); // seconds in the air
}
// ---- drawing: the notebook's draw_scene in the same units, a box 10 wide and 10 tall
const $ = (id) => document.getElementById(id);
const canvas = $("scene");
let dpr = 1;
function colors() {
const css = getComputedStyle(document.documentElement);
return Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted", "water"].map((k) => [k, css.getPropertyValue(`--${k}`).trim()]));
}
function layout() {
dpr = window.devicePixelRatio || 1;
const box = canvas.getBoundingClientRect();
canvas.width = Math.round(box.width * dpr);
canvas.height = Math.round(box.height * dpr);
render();
}
const level = (vb) => 1.5 + 2.3 * (VA + vb) / 75; // the liquid's surface in the beaker
function drawScene(ctx, c, x0, y0, w, h) {
const s = state, k = Math.min(w / 10, h / 10);
const X = (x) => x0 + (w - 10 * k) / 2 + x * k, Y = (y) => y0 + (h + 10 * k) / 2 - y * k;
const rect = (xa, ya, xb, yb) => { ctx.beginPath(); ctx.rect(X(xa), Y(yb), (xb - xa) * k, (yb - ya) * k); };
const line = (pts) => { ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(X(x), Y(y)) : ctx.moveTo(X(x), Y(y))); ctx.stroke(); };
const font = Math.max(14, Math.round(0.42 * k));
ctx.lineJoin = "round";
const top = level(s.vb); // the beaker's water, tinted by the indicator
ctx.fillStyle = c.water;
rect(1.2, 0.5, 5.8, top); ctx.fill();
ctx.fillStyle = beakerColor(s.ph, s.indicator);
ctx.fill();
ctx.fillStyle = c.muted; // the stirring bar
rect(3.0, 0.62, 4.0, 0.8); ctx.fill();
ctx.fillStyle = c.water; // the burette's NaOH, its surface sinking with every drop
const bl = 9.5 - 3.6 * s.released * DROP / 50;
rect(3.25, 5.8, 3.75, bl); ctx.fill();
ctx.strokeStyle = c.muted; ctx.lineWidth = 1; // graduations every 5 mL, labeled at 0, 25, 50
ctx.fillStyle = c.muted; ctx.font = `${font}px system-ui, sans-serif`;
ctx.textAlign = "right"; ctx.textBaseline = "middle";
for (let v = 0; v <= 50; v += 5) {
const y = 9.5 - 3.6 * v / 50;
line([[3.25, y], [3.45, y]]);
if (v % 25 === 0) ctx.fillText(v === 50 ? "50 mL" : `${v}`, X(3.1), Y(y));
}
ctx.strokeStyle = c.ink; ctx.lineWidth = 2; // beaker, burette, stopcock, tip
line([[1.0, 4.4], [1.2, 4.2], [1.2, 0.5], [5.8, 0.5], [5.8, 4.4]]);
rect(3.25, 5.8, 3.75, 9.8); ctx.stroke();
line([[3.45, 5.8], [3.45, 5.0], [3.5, 4.8], [3.55, 5.0], [3.55, 5.8]]);
ctx.fillStyle = c.ink;
rect(2.95, 5.35, 4.05, 5.55); ctx.fill();
ctx.fillStyle = c.water; // the drops in the air, falling under gravity
ctx.strokeStyle = c.muted; ctx.lineWidth = 1;
for (const t of s.drops) {
const y = 4.75 - (4.75 - top) * (t / FALL) ** 2;
ctx.beginPath(); ctx.arc(X(3.5), Y(y), 0.1 * k, 0, 2 * Math.PI); ctx.fill(); ctx.stroke();
}
ctx.strokeStyle = c.ink; ctx.lineWidth = 2; // the electrode and its cable to the meter
line([[5.0, 0.9], [5.0, 5.2], [5.6, 6.0], [6.4, 6.0]]);
ctx.fillStyle = c.ink;
ctx.beginPath(); ctx.arc(X(5.0), Y(0.9), 0.17 * k, 0, 2 * Math.PI); ctx.fill();
rect(6.4, 5.0, 9.9, 7.4); ctx.stroke(); // the meter
const labeled = k >= 22; // a small meter shows only the number; the readout names it
if (labeled) {
ctx.fillStyle = c.muted; ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillText("pH", X(6.6), Y(7.25));
}
ctx.fillStyle = c.second; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.font = `600 ${Math.max(14, Math.round(0.85 * k))}px system-ui, sans-serif`;
ctx.fillText(s.ph.toFixed(2), X(8.15), Y(labeled ? 5.95 : 6.2));
}
function drawRecorder(ctx, c, x0, y0, w, h) { // pH after every drop, 0 to 14 over 0 to 50 mL
const pad = 30, font = 14;
const X = (v) => x0 + pad + (w - pad - 8) * v / 50, Y = (p) => y0 + h - 20 - (h - 28) * p / 14;
ctx.font = `${font}px system-ui, sans-serif`;
ctx.strokeStyle = c.muted; ctx.fillStyle = c.muted; ctx.lineWidth = 1;
ctx.strokeRect(X(0), Y(14), X(50) - X(0), Y(0) - Y(14));
ctx.textAlign = "right"; ctx.textBaseline = "middle";
for (const p of [0, 7, 14]) ctx.fillText(`${p}`, X(0) - 4, Y(p));
ctx.textAlign = "center"; ctx.textBaseline = "top";
for (const v of [25, 50]) ctx.fillText(v === 50 ? "50 mL" : `${v}`, Math.min(X(v), x0 + w - 24), Y(0) + 3);
ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillText("pH", X(0) + 4, Y(14) + 3);
ctx.fillStyle = ACIDS[state.acid].pka === null ? c.ink : c.accent; // HCl in ink, acetic acid in accent, as on the page
state.trace.forEach((p, i) => { ctx.beginPath(); ctx.arc(X(i * DROP), Y(p), 1.6, 0, 2 * Math.PI); ctx.fill(); });
}
function render() {
const s = state, c = colors(), ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const W = canvas.width / dpr, H = canvas.height / dpr;
ctx.fillStyle = c.bg;
ctx.fillRect(0, 0, W, H);
if (H > 1.1 * W) { // upright: the scene on top, the recorder under it
const hr = Math.max(90, 0.26 * H);
drawScene(ctx, c, 0, 0, W, H - hr);
drawRecorder(ctx, c, 0, H - hr, W, hr);
} else { // wide or short: the scene left, the recorder right
const ws = Math.min(W * 0.6, H);
drawScene(ctx, c, 0, 0, ws, H);
drawRecorder(ctx, c, ws, H * 0.2, W - ws, H * 0.6);
}
$("vb").textContent = s.vb.toFixed(2);
$("ph").textContent = s.ph.toFixed(2);
$("acid").textContent = `Acid: ${s.acid}`;
$("indicator").textContent = `Indicator: ${s.indicator}`;
}
// ---- controls, keys, and the loop
function set(partial) { // a fresh beaker at vb mL, e.g. game.set({acid: "acetic acid", vb: 12.5})
const {vb = 0, ...rest} = partial;
Object.assign(state, rest);
fill(vb);
render();
}
const cycle = (obj, key) => { const names = Object.keys(obj); return names[(names.indexOf(key) + 1) % names.length]; };
const nextAcid = () => set({acid: cycle(ACIDS, state.acid)});
const nextIndicator = () => set({indicator: cycle(INDICATORS, state.indicator)});
const reset = () => set({});
function press() { if (!state.holding) { release(); Object.assign(state, {holding: true, hold: 0, owed: 0}); } }
const letGo = () => { state.holding = false; };
const dropButton = $("drop");
dropButton.addEventListener("pointerdown", (e) => { e.preventDefault(); press(); });
for (const type of ["pointerup", "pointercancel", "pointerleave"]) dropButton.addEventListener(type, letGo);
dropButton.addEventListener("contextmenu", (e) => e.preventDefault()); // a long press is not a menu
$("acid").addEventListener("click", nextAcid);
$("indicator").addEventListener("click", nextIndicator);
$("reset").addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
const actions = {" ": press, a: nextAcid, i: nextIndicator, r: reset};
const act = actions[e.key.toLowerCase()];
if (act) { e.preventDefault(); if (!e.repeat) act(); }
});
document.addEventListener("keyup", (e) => { if (e.key === " ") letGo(); });
let last = null;
function frame(now) { // called by the browser before each screen redraw
const elapsed = last === null ? 0 : Math.min((now - last) / 1000, 0.05); // capped: a background tab does not jump
last = now;
const s = state;
if (s.holding) { // held past HOLD seconds: a stream of drops
s.hold += elapsed;
if (s.hold > HOLD) for (s.owed += elapsed; s.owed >= 1 / STREAM; s.owed -= 1 / STREAM) release();
}
s.drops = s.drops.map((t) => t + elapsed);
while (s.drops.length && s.drops[0] >= FALL) { s.drops.shift(); land(); }
render();
requestAnimationFrame(frame);
}
fill(0);
window.addEventListener("resize", layout);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};
The pH jumps at the equivalence point
The first 20 mL of base move the pH by less than one unit. Then two drops move it by six, and the second of them turns the beaker pink.
Show code
import inspect
import json
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle
from scipy.optimize import brentq
plt.rcParams.update({
"figure.figsize": (7, 3.6), "figure.dpi": 110,
"axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25,
"font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
WATER = "#d6e6ee" # water and NaOH, colorless, drawn pale
PKW = 14.00 # CRC Handbook: 13.995 at 25 °C, rounded
KW = 10 ** -PKW
VA, CA, CB, DROP = 25.0, 0.100, 0.100, 0.05 # mL of acid, mol/L of acid, mol/L of NaOH, mL per drop
ACIDS = {"HCl": None, "acetic acid": 4.756} # pKa, CRC Handbook; None: a strong acid
INDICATORS = { # CRC Handbook transition ranges; colors as RGB 0 to 255 and opacity
# over the water; colorless phenolphthalein is its pink at opacity 0
"phenolphthalein": dict(range=(8.2, 10.0), acid=(222, 49, 140, 0.0), base=(222, 49, 140, 0.85)),
"methyl orange": dict(range=(3.2, 4.4), acid=(213, 43, 43, 0.85), base=(245, 200, 35, 0.85)),
}
for ind in INDICATORS.values():
ind["pk"] = round(sum(ind["range"]) / 2, 2) # pK_In at the middle of the range
V = np.round(np.arange(1001) * DROP, 2) # the volume after every drop, 0 to 50 mL
def base_fraction(ph, indicator):
"""Fraction of the indicator in its base form at this pH."""
return 1 / (1 + 10 ** (INDICATORS[indicator]["pk"] - ph))
# ---- the game's scene in Matplotlib, in a box 10 wide and 10 tall, the same units as game.js
def draw_scene(ax, acid, indicator, vb, ph, label=None):
ind, f = INDICATORS[indicator], base_fraction(ph, indicator)
r, g, b, a = [u + f * (w - u) for u, w in zip(ind["acid"], ind["base"])]
ax.set_axis_off()
ax.set(xlim=(0, 10), ylim=(0, 10), aspect="equal")
top = 1.5 + 2.3 * (VA + vb) / 75 # the liquid's surface
ax.add_patch(Rectangle((1.2, 0.5), 4.6, top - 0.5, color=WATER, lw=0))
ax.add_patch(Rectangle((1.2, 0.5), 4.6, top - 0.5, color=(r / 255, g / 255, b / 255, a), lw=0))
ax.add_patch(Rectangle((3.0, 0.62), 1.0, 0.18, color=MUTED, lw=0)) # stirring bar
ax.add_patch(Rectangle((3.25, 5.8), 0.5, 3.7 - 3.6 * vb / 50, color=WATER, lw=0))
for v in range(0, 51, 5): # burette graduations
ax.plot([3.25, 3.45], [9.5 - 3.6 * v / 50] * 2, color=MUTED, lw=0.8)
ax.plot([1.0, 1.2, 1.2, 5.8, 5.8], [4.4, 4.2, 0.5, 0.5, 4.4], color=INK, lw=1.4)
ax.add_patch(Rectangle((3.25, 5.8), 0.5, 4.0, fill=False, edgecolor=INK, lw=1.4))
ax.plot([3.45, 3.45, 3.5, 3.55, 3.55], [5.8, 5.0, 4.8, 5.0, 5.8], color=INK, lw=1.4)
ax.add_patch(Rectangle((2.95, 5.35), 1.1, 0.2, color=INK, lw=0)) # stopcock
ax.plot([5.0, 5.0, 5.6, 6.4], [0.9, 5.2, 6.0, 6.0], color=INK, lw=1.4)
ax.add_patch(Circle((5.0, 0.9), 0.17, color=INK)) # electrode
ax.add_patch(Rectangle((6.4, 5.0), 3.5, 2.4, fill=False, edgecolor=INK, lw=1.4))
ax.text(6.6, 7.3, "pH", color=MUTED, va="top")
ax.text(8.15, 5.75, f"{ph:.2f}", color=SECOND, ha="center", va="center", fontsize=13, fontweight="bold")
if label:
ax.text(6.4, 9.8, label, color=MUTED, va="top")
print(f"{len(V)} drop volumes from {V[0]:.2f} to {V[-1]:.2f} mL; "
f"pK_In: phenolphthalein {INDICATORS['phenolphthalein']['pk']}, methyl orange {INDICATORS['methyl orange']['pk']}")
1001 drop volumes from 0.00 to 50.00 mL; pK_In: phenolphthalein 9.1, methyl orange 3.8
Every positive charge in the beaker is matched by a negative one. In hydrochloric acid after \(V_b\) mL of base,
with \([\mathrm{OH^-}] = K_w/[\mathrm{H^+}]\) from water's own equilibrium, \(K_w = 10^{-14}\), and \([\mathrm{Na^+}] = c_b V_b/(V_a + V_b)\) and \([\mathrm{Cl^-}] = c_a V_a/(V_a + V_b)\), both diluted by the growing volume. The equivalence point is where the base added matches the acid, \(c_b V_b = c_a V_a\), at 25.00 mL. At 24.95 mL the acid left over is one drop's worth, 0.005 mmol in 49.95 mL, so [H⁺] is 1.0 × 10⁻⁴ M and the pH 4.00. At 25.00 mL only water's own 10⁻⁷ M is left, and one drop later the same 10⁻⁴ M is OH⁻. The pH is a logarithm, so each of these two drops divides [H⁺] by a thousand.
Written as positive minus negative charge, the balance falls steadily as \(p = -\log_{10}[\mathrm{H^+}]\) rises, because [H⁺] falls and [OH⁻] grows, so it has exactly one root. SciPy's brentq finds it by shrinking a bracket across which a function changes sign, here p from −1 to 15 (Root finding with scipy.optimize shows how).
def charge_excess(p, vb, acid, va=VA, ca=CA, cb=CB):
"""Positive minus negative charge / (mol/L) at p = -log10[H+], after vb mL of base."""
h = 10.0 ** -p
c, na = ca * va / (va + vb), cb * vb / (va + vb) # both diluted by the base added
pka = ACIDS[acid]
anion = c if pka is None else c / (1 + h * 10 ** pka) # a weak acid: only the dissociated part
return h + na - KW / h - anion
def ph_after(vb, acid, **beaker):
"""pH after vb mL of base; beaker may change va, ca, or cb."""
return brentq(lambda p: charge_excess(p, vb, acid, **beaker), -1, 15)
print(" V / mL [H+] / (mol/L) pH ΔpH from the drop before")
for vb in [0, 20, 24.90, 24.95, 25.00, 25.05, 25.10]:
ph = ph_after(vb, "HCl")
step = f"{ph - ph_after(vb - DROP, 'HCl'):+7.3f}" if vb else ""
print(f"{vb:7.2f} {10 ** -ph:14.1e} {ph:5.2f} {step}")
h0, h20 = 10 ** -ph_after(0, "HCl"), 10 ** -ph_after(20, "HCl")
print(f"first 20 mL: [H+] falls by a factor of {h0 / h20:.1f}")
lo, hi = ph_after(25 - DROP / 2, "HCl"), ph_after(25 + DROP / 2, "HCl")
print(f"one drop across the equivalence point, 24.975 to 25.025 mL: pH {lo:.2f} to {hi:.2f}, {hi - lo:.1f} units")
V / mL [H+] / (mol/L) pH ΔpH from the drop before 0.00 1.0e-01 1.00 20.00 1.1e-02 1.95 +0.005 24.90 2.0e-04 3.70 +0.177 24.95 1.0e-04 4.00 +0.301 25.00 1.0e-07 7.00 +3.000 25.05 1.0e-10 10.00 +3.000 25.10 5.0e-11 10.30 +0.301 first 20 mL: [H+] falls by a factor of 9.0 one drop across the equivalence point, 24.975 to 25.025 mL: pH 4.30 to 9.70, 5.4 units
Over the first 20 mL [H⁺] falls by a factor of 9.0, and a drop moves the pH by 0.005 at 20 mL and by 3.000 at 25 mL. A drop that straddles the equivalence point, from 24.975 to 25.025 mL, carries the pH from 4.30 to 9.70, 5.4 units, the most one drop can do here.
The bracket is in p and not in [H⁺] for a reason that shows at 50 mL:
h = brentq(lambda h: charge_excess(-np.log10(h), 50, "HCl"), 1e-15, 1)
xtol = inspect.signature(brentq).parameters["xtol"].default
print(f"brentq on [H+]: pH {-np.log10(h):.2f} brentq on p: pH {ph_after(50, 'HCl'):.2f}")
print(f"true [H+] at 50 mL: {10 ** -ph_after(50, 'HCl'):.1e} mol/L default xtol: {xtol:.0e}")
brentq on [H+]: pH 11.78 brentq on p: pH 12.52 true [H+] at 50 mL: 3.0e-13 mol/L default xtol: 2e-12
brentq stops when the bracket is narrower than its absolute tolerance xtol, 2 × 10⁻¹² by default, and the true [H⁺] at 50 mL is 3.0 × 10⁻¹³ M, smaller than that, so any [H⁺] below it is accepted as the root: pH 11.78 instead of 12.52. In p the same tolerance is 2 × 10⁻¹² of a pH unit. Solve for the logarithm whenever the unknown spans orders of magnitude.
Show code
PH_HCL = np.array([ph_after(v, "HCl") for v in V])
fig = plt.figure(figsize=(7, 5.5))
grid = fig.add_gridspec(2, 3, height_ratios=[1, 1.25], hspace=0.08, wspace=0.05,
left=0.09, right=0.98, top=0.99, bottom=0.09)
for j, vb in enumerate([24.95, 25.00, 25.05]):
draw_scene(fig.add_subplot(grid[0, j]), "HCl", "phenolphthalein", vb, ph_after(vb, "HCl"), f"{vb:.2f} mL")
ax = fig.add_subplot(grid[1, :])
ax.axvline(25, color=MUTED, ls="--", lw=1)
ax.plot(V, PH_HCL, color=INK)
ax.set(xlabel="V(NaOH) / mL", ylabel="pH", xlim=(0, 50), ylim=(0, 14), yticks=[0, 7, 14])
inset = ax.inset_axes([0.6, 0.1, 0.36, 0.5])
near = (V >= 24.5) & (V <= 25.5)
inset.plot(V[near], PH_HCL[near], "o-", ms=4, lw=1, color=INK)
inset.set(xlim=(24.48, 25.52), ylim=(2, 12), xticks=[24.5, 25, 25.5], yticks=[4, 7, 10])
ax.indicate_inset_zoom(inset, edgecolor=MUTED, alpha=1, lw=1)
plt.show()
Free account
The second half of this tutorial needs a free account
Sign in, or create an account with your email address, and the page goes on right here. The account costs nothing and also brings the notebook download, collections, and done marks.
Still to come
- Acetic acid holds its pH at the pKa
- The wrong indicator changes color too early
- How the game computes it
- Variations
- Further reading