Distillation and the azeotrope: no pot still passes 95.6 % ethanol by weight
Afterwards you can compute a vapor-liquid equilibrium with activity coefficients, follow a pot still with the Rayleigh equation, and explain an azeotrope.
- Field
- Chemistry, Engineering
- Prerequisites
- none beyond Python basics
- Libraries
matplotlib 3.11.2numpy 2.5.4scipy 1.18.1
py-distillation-azeotrope.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.5.4 scipy==1.18.1 matplotlib==3.11.2 jupyterlabPlay: distill wine in a pot still
A pot still holds 4 L of wine at 12 % alcohol by volume (ABV) over a 1 kW flame. The clock runs at 120 times real time, two minutes of still time per second. Watch the thermometer climb while the pot warms up, then watch the drops fill glass after glass, each 0.2 L, a twentieth of the charge. The first glass reads 57.0 % ABV, almost five times the wine. Tap Pour back after the first glass, or after the seventh, to boil those glasses again as the next run, and change the flame with − and +. Each run comes out stronger, and none passes the limit line, the azeotrope. Measured, it is 95.6 % ethanol by weight, which a bottle label would call about 97 % ABV; the game's model puts it at 95.63 % by weight, which the limit line shows as 97.2 % ABV. Leave Pour back alone and the run stops by itself when the pot is nearly dry. On a keyboard, H and J turn the flame down and up, P pours back, R resets, and Space pauses.
Show code
// game.js: a pot still. Wine boils over a flame, the vapor condenses, and the drops fill glass after glass.
// Pour back runs the full glasses again. Units: moles, liters, kilowatts, seconds of still time, °C.
// The notebook writes every number into #data: the x-y and boiling-point tables, the density table, the constants.
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const {x_grid: XG, y_table: YG, t_table: TG, w_table: WT, rho_table: RT} = DATA;
const {m_e: ME, m_w: MW, dh_e: DHE, dh_w: DHW, cp_e: CPE, cp_w: CPW} = DATA;
const {dt: DT, speed: SPEED, glasses: GLASSES, dry: DRY, min_ml: MIN_ML, t_room: T_ROOM} = DATA;
// ---- tables: linear interpolation, the same as np.interp in the notebook
function interp(x, xs, ys) {
if (x <= xs[0]) return ys[0];
if (x >= xs[xs.length - 1]) return ys[ys.length - 1];
let lo = 0, hi = xs.length - 1;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (xs[mid] <= x) lo = mid; else hi = mid; }
return ys[lo] + (ys[hi] - ys[lo]) * (x - xs[lo]) / (xs[hi] - xs[lo]);
}
const massFraction = (n, e) => e * ME / (e * ME + (n - e) * MW);
const abv = (w) => 100 * w * interp(w, WT, RT) / RT[RT.length - 1]; // % by volume at 20 °C
const liters = (n, e) => (e * ME + (n - e) * MW) / interp(massFraction(n, e), WT, RT);
const boilingPoint = (n, e) => interp(e / n, XG, TG);
// ---- the state: everything that changes, in one object
const state = {heat: DATA.heat, n: 0, e: 0, n0: 0, glassL: 0, T: T_ROOM, phase: "warming", glass: {n: 0, e: 0},
full: [], run: 1, t: 0, running: true, drip: 0};
function charge(n, e, run) { // a cold pot, its glasses a twentieth of the charge
Object.assign(state, {n, e, n0: n, glassL: liters(n, e) / GLASSES, T: T_ROOM, phase: "warming",
glass: {n: 0, e: 0}, full: [], run, t: 0});
}
const reset = () => { charge(DATA.n0, DATA.e0, 1); state.heat = DATA.heat; render(); };
// ---- the two time equations: dN/dt = -Q/Δh, d(Nx)/dt = -y Q/Δh
function slope(n, e) {
const y = interp(e / n, XG, YG), boil = state.heat / (y * DHE + (1 - y) * DHW); // mol/s
return [-boil, -y * boil];
}
// one Runge-Kutta step: four slopes, averaged; the distillate goes into the glass under the outlet
function step(h) {
const s = state;
if (s.phase === "dry") return; // the flame is out; the clock stops with it
if (s.phase === "warming") {
const w = massFraction(s.n, s.e), kg = (s.e * ME + (s.n - s.e) * MW) / 1000;
s.T += s.heat * h / (kg * (w * CPE + (1 - w) * CPW));
if (s.T >= boilingPoint(s.n, s.e)) s.phase = "boiling";
} else {
h = Math.min(h, (s.n - DRY * s.n0) / -slope(s.n, s.e)[0]); // the last step ends at the dry mark
const k1 = slope(s.n, s.e);
const k2 = slope(s.n + h / 2 * k1[0], s.e + h / 2 * k1[1]);
const k3 = slope(s.n + h / 2 * k2[0], s.e + h / 2 * k2[1]);
const k4 = slope(s.n + h * k3[0], s.e + h * k3[1]);
const dn = h / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]), de = h / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]);
s.n += dn; s.e += de;
collect(-dn, -de);
s.T = boilingPoint(s.n, s.e);
if (s.n <= 1.001 * DRY * s.n0) s.phase = "dry"; // the flame goes out, as the notebook's event stops solve_ivp
}
s.t += h;
}
// A step that overfills the glass is split in proportion to volume: the rest goes on into the next glass.
function collect(dn, de) {
const g = state.glass, before = g.n > 0 ? liters(g.n, g.e) : 0, after = liters(g.n + dn, g.e + de);
if (after < state.glassL) { g.n += dn; g.e += de; return; }
const f = (state.glassL - before) / (after - before);
state.full.push({n: g.n + f * dn, e: g.e + f * de});
state.glass = {n: 0, e: 0};
collect((1 - f) * dn, (1 - f) * de);
}
// Pour back: every full glass into the pot, residue and the half-filled glass away, a cold start
const fullVolume = () => state.full.reduce((v, g) => v + liters(g.n, g.e), 0);
const canPour = () => state.full.length > 0 && 1000 * fullVolume() >= MIN_ML;
function pourBack() {
if (!canPour()) return;
const n = state.full.reduce((a, g) => a + g.n, 0), e = state.full.reduce((a, g) => a + g.e, 0);
charge(n, e, state.run + 1);
render();
}
// ---- colors: the page's palette, and cividis for strength (0 to 100 % ABV)
function colors() {
const css = getComputedStyle(document.documentElement);
return Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted"].map((k) => [k, css.getPropertyValue(`--${k}`).trim()]));
}
function strengthColor(a) {
const stops = DATA.cividis, u = Math.min(Math.max(a / 100, 0), 1) * (stops.length - 1), i = Math.min(Math.floor(u), stops.length - 2);
const mix = stops[i].map((c, j) => Math.round(c + (stops[i + 1][j] - c) * (u - i)));
return `rgb(${mix.join(",")})`;
}
// ---- drawing: the notebook's draw_still in the same units; the still is 6.2 wide and 6.2 tall, the shelf of
// full glasses and the color bar stand to its right in a wide frame and below it in an upright one
const $ = (id) => document.getElementById(id);
const canvas = $("scene");
let dpr = 1;
const WIDE = {box: [0, 10, 0, 6.2], shelf: [6.2, 0.15], bar: [9.5, 2.2, 2.3]}; // shelf: left, bottom
const TALL = {box: [0, 6.2, -2.5, 6.2], shelf: [0.5, -1.2], bar: [5.75, -2.3, 2.0]}; // bar: left, bottom, height
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();
}
function drawScene(c) {
const s = state, ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const W = canvas.width / dpr, H = canvas.height / dpr;
const L = H > 1.1 * W ? TALL : WIDE, [x0, x1, y0, y1] = L.box;
const k = Math.min(W / (x1 - x0), H / (y1 - y0));
ctx.fillStyle = c.bg;
ctx.fillRect(0, 0, W, H);
const X = (x) => (W - (x1 - x0) * k) / 2 + (x - x0) * k, Y = (y) => (H + (y1 - y0) * k) / 2 - (y - y0) * k;
const path = (pts) => { ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(X(x), Y(y)) : ctx.moveTo(X(x), Y(y))); };
const box = (xa, ya, xb, yb) => { ctx.beginPath(); ctx.rect(X(xa), Y(yb), (xb - xa) * k, (yb - ya) * k); };
ctx.lineJoin = "round";
if (s.phase !== "dry") { // the flame grows with the heat
const h = 0.25 + 0.3 * s.heat;
ctx.fillStyle = c.accent;
for (const fx of [1.4, 2.0, 2.6]) { path([[fx - 0.2, 0.15], [fx, 0.15 + h], [fx + 0.2, 0.15]]); ctx.fill(); }
}
ctx.globalAlpha = 0.35; // the pot's liquid, its height relative to the run's charge
ctx.fillStyle = c.second;
box(0.8, 0.9, 3.2, 0.9 + 2.2 * s.n / s.n0); ctx.fill();
ctx.globalAlpha = 0.12; // cooling water in the tub
box(4.6, 2.2, 6.0, 4.5); ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = c.muted; ctx.lineWidth = 1.5;
box(4.6, 2.2, 6.0, 4.5); ctx.stroke();
path([[1.25, 4.3], [1.25, 1.2]]); ctx.stroke(); // the thermometer, its bulb in the liquid
ctx.fillStyle = c.second;
ctx.beginPath(); ctx.arc(X(1.25), Y(1.2), 0.1 * k, 0, 2 * Math.PI); ctx.fill();
ctx.strokeStyle = c.ink; ctx.lineWidth = 2; // pot, dome, swan neck, coil, outlet
path([[0.8, 3.3], [0.8, 0.9], [3.2, 0.9], [3.2, 3.3]]); ctx.stroke();
path(Array.from({length: 21}, (_, i) => { const u = i / 20; return [0.8 + 2.4 * u, 3.3 + 0.8 * (1 - (2 * u - 1) ** 2)]; })); ctx.stroke();
ctx.lineWidth = 3;
path([[2.0, 4.1], [2.0, 4.9], [2.4, 5.3], [4.9, 5.3], [5.3, 4.9], [5.3, 4.5]]); ctx.stroke();
ctx.lineWidth = 1.8;
path(Array.from({length: 49}, (_, i) => [5.3 + 0.45 * Math.sin(i / 48 * 12 * Math.PI), 4.5 - 2.3 * i / 48])); ctx.stroke();
path([[5.3, 2.2], [5.3, 1.85]]); ctx.stroke();
if (s.phase === "boiling") { // three drops, colored by the vapor's strength
const y = interp(s.e / s.n, XG, YG);
ctx.fillStyle = strengthColor(abv(massFraction(1, y)));
ctx.strokeStyle = c.muted; ctx.lineWidth = 1; // a rim, so that a weak, dark drop shows on the dark page
for (let j = 0; j < 3; j++) {
const u = (s.drip + j / 3) % 1;
ctx.beginPath(); ctx.arc(X(5.3), Y(1.75 - 0.5 * u), 0.07 * k, 0, 2 * Math.PI); ctx.fill(); ctx.stroke();
}
}
const glass = (xa, xb, bottom, top, fill, a) => { // a glass, filled from the bottom
if (fill > 0) { ctx.fillStyle = strengthColor(a); box(xa, bottom, xb, bottom + (top - bottom) * Math.min(fill, 1)); ctx.fill(); }
ctx.strokeStyle = c.ink; ctx.lineWidth = 1.2;
path([[xa, top], [xa, bottom], [xb, bottom], [xb, top]]); ctx.stroke();
};
const g = s.glass;
glass(4.95, 5.65, 0.15, 1.15, g.n > 0 ? liters(g.n, g.e) / s.glassL : 0, g.n > 0 ? abv(massFraction(g.n, g.e)) : 0);
const font = Math.max(14, Math.round(0.3 * k)), u = font / k; // u: one line of text in scene units
ctx.font = `${font}px system-ui, sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "bottom";
const [sx, sy] = L.shelf, shown = s.full.slice(-6), first = s.full.length - shown.length;
shown.forEach((fg, i) => { // the last six full glasses, each with its ABV
const xa = sx + 0.62 * i, a = abv(massFraction(fg.n, fg.e));
glass(xa, xa + 0.5, sy, sy + 0.8, 1, a);
ctx.fillStyle = c.fg;
ctx.fillText(a.toFixed(0), X(xa + 0.25), Y(sy + 0.85));
});
ctx.textAlign = "left"; ctx.fillStyle = c.muted;
if (s.full.length) { // which glasses the shelf shows: above it, or under it
const label = first ? `glasses ${first + 1} to ${s.full.length}` : `${s.full.length} full`;
if (L === WIDE) ctx.fillText(label, X(sx), Y(sy + 0.9 + u));
else { ctx.textBaseline = "top"; ctx.fillText(label, X(sx), Y(sy - 0.1)); }
}
const [bx, by, bh] = L.bar; // the color bar, 0 to 100 % ABV, and the azeotrope on it
for (let i = 0; i < 40; i++) {
ctx.fillStyle = strengthColor(100 * (i + 0.5) / 40);
box(bx, by + bh * i / 40, bx + 0.25, by + bh * (i + 1) / 40 + 0.01); ctx.fill();
}
const yl = by + bh * DATA.limit_abv / 100;
ctx.strokeStyle = c.fg; ctx.lineWidth = 2;
path([[bx - 0.12, yl], [bx + 0.37, yl]]); ctx.stroke();
ctx.fillStyle = c.muted; ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText("0 % ABV", X(bx - 0.15), Y(by));
ctx.fillText("50", X(bx - 0.15), Y(by + bh / 2));
ctx.fillStyle = c.fg;
ctx.fillText(`limit ${DATA.limit_abv.toFixed(1)}`, X(bx - 0.15), Y(yl));
ctx.fillStyle = c.second; ctx.textAlign = "left";
ctx.font = `${Math.round(font * 1.25)}px system-ui, sans-serif`;
ctx.fillText(`${s.T.toFixed(1)} °C`, X(0.1), Y(5.5));
}
const clock = (t) => `${Math.floor(t / 3600)}:${String(Math.floor(t / 60) % 60).padStart(2, "0")} h`;
function render() {
const s = state, c = colors();
drawScene(c);
const g = s.glass, w = g.n > 0 ? massFraction(g.n, g.e) : null;
$("pot").textContent = `pot ${s.T.toFixed(1)} °C, ${liters(s.n, s.e).toFixed(2)} L`;
$("cup").textContent = `glass ${s.full.length + 1}: ` + (w === null ? "empty" : `${abv(w).toFixed(1)} % ABV (${(100 * w).toFixed(1)} % by weight)`);
$("run").textContent = `run ${s.run}, ${clock(s.t)} at ×${SPEED}`;
$("status").textContent = s.phase === "dry" ? "pot nearly dry: Pour back or Reset"
: s.full.length && !canPour() ? "too little to distill" : s.phase === "warming" ? "warming up" : "boiling";
$("heat-val").textContent = `${s.heat.toFixed(2)} kW`;
$("pour").disabled = !canPour();
$("play").textContent = s.running ? "Pause" : "Play";
}
// ---- controls, keys, and the loop
function set(partial) { Object.assign(state, partial); render(); }
function advance(seconds) { // fast-forward in still time, for stills and tests
for (let t = 0; t < seconds; t += DT) step(DT);
render();
}
const nudge = (d) => set({heat: Math.min(2, Math.max(0.5, state.heat + 0.25 * d))});
$("limit").textContent = `azeotrope: ${DATA.limit_abv.toFixed(1)} % ABV (${DATA.limit_w.toFixed(1)} % by weight)`;
$("cooler").addEventListener("click", () => nudge(-1));
$("hotter").addEventListener("click", () => nudge(1));
$("pour").addEventListener("click", pourBack);
$("reset").addEventListener("click", reset);
$("play").addEventListener("click", () => set({running: !state.running}));
document.addEventListener("keydown", (e) => {
const actions = {h: () => nudge(-1), j: () => nudge(1), p: pourBack, r: reset, " ": () => set({running: !state.running})};
const act = actions[e.key.toLowerCase()];
if (act && !(e.target instanceof HTMLButtonElement && e.key === " ")) { e.preventDefault(); act(); }
});
let last = null, owed = 0;
function frame(now) { // called by the browser once per screen refresh
const elapsed = last === null ? 0 : Math.min((now - last) / 1000, 0.05); // capped: a background tab does not jump
last = now;
if (state.running && state.phase !== "dry") {
for (owed += elapsed * SPEED; owed >= DT; owed -= DT) step(DT); // fixed steps of DT still seconds
state.drip = (state.drip + elapsed * 1.5 * state.heat) % 1; // drops fall faster on a bigger flame
}
render();
requestAnimationFrame(frame);
}
charge(DATA.n0, DATA.e0, 1);
window.addEventListener("resize", layout);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render, advance};
The first drops are the strongest
The thermometer climbs for 19 minutes and stalls at 91.5 °C, short of water's 100 °C. The first drops are 59 % ABV, five times the wine.
Show code
import json
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import colormaps
from scipy.integrate import solve_ivp
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"
CIVIDIS = colormaps["cividis"] # strength of a glass, 0 to 100 % ABV
P = 760.0 # mmHg, one atmosphere
ANTOINE = {"ethanol": (5.24677, 1598.673, -46.424), # log10(p / bar) = A - B / (T / K + C), NIST Chemistry WebBook
"water": (5.08354, 1663.125, -45.622)}
MMHG_PER_BAR = 750.062
M_E, M_W = 46.069, 18.015 # g/mol
DH_E, DH_W = 38.56, 40.65 # kJ/mol, heat of vaporization at the normal boiling point
CP_E, CP_W = 2.44, 4.18 # kJ/(kg K), liquid heat capacity
W_TABLE = np.linspace(0, 1, 21) # ethanol mass fraction, 5 % steps
RHO_TABLE = np.array([998.20, 989.38, 981.87, 975.14, 968.64, 961.68, 953.82, 944.94, 935.18, 924.72, 913.84,
902.58, 891.13, 879.48, 867.66, 855.64, 843.44, 830.95, 817.97, 804.24, 789.24]) # kg/m³ at 20 °C
CHARGE_L, WINE_ABV, HEAT_KW = 4.0, 12.0, 1.0
GLASSES, T_ROOM, SPEED, DT_GAME = 20, 20.0, 120, 1.0 # glasses a run, °C, still seconds per second of play, game step / s
DRY, MIN_ML = 0.02, 5.0 # a run stops at 2 % of its charge; Pour back needs 5 mL
X_GRID = np.linspace(0, 1, 401) ** 2 # 401 compositions, denser at the dilute end where y(x) bends hardest
W_AZ_MEASURED, T_AZ_MEASURED = 0.956, 78.2 # the measured azeotrope at 1 atm
def antoine(T, species):
"""Vapor pressure / mmHg at T / °C."""
A, B, C = ANTOINE[species]
return MMHG_PER_BAR * 10 ** (A - B / (T + 273.15 + C))
def bubble_point(x, p=P):
"""The temperature / °C at which a liquid of ethanol mole fraction x boils, and the vapor's mole fraction y."""
def excess(T):
g1, g2 = gamma_nrtl(x, T)
return x * g1 * antoine(T, "ethanol") + (1 - x) * g2 * antoine(T, "water") - p
T = brentq(excess, 0, 110)
return T, x * gamma_nrtl(x, T)[0] * antoine(T, "ethanol") / p
def mass_fraction(n, e):
"""Ethanol mass fraction of n moles holding e moles of ethanol."""
return e * M_E / (e * M_E + (n - e) * M_W)
def abv(w):
"""% ABV: the ethanol's volume as pure ethanol at 20 °C over the mixture's volume."""
return 100 * w * np.interp(w, W_TABLE, RHO_TABLE) / RHO_TABLE[-1]
def liters(n, e):
return (e * M_E + (n - e) * M_W) / np.interp(mass_fraction(n, e), W_TABLE, RHO_TABLE)
# the wine: 4 L at 12 % ABV, in moles of everything (N0) and of ethanol (E0)
W_FINE = np.linspace(0, 1, 100_001)
W_WINE = np.interp(WINE_ABV, abv(W_FINE), W_FINE)
KG_WINE = CHARGE_L * np.interp(W_WINE, W_TABLE, RHO_TABLE) / 1000
E0 = 1000 * KG_WINE * W_WINE / M_E
N0 = E0 + 1000 * KG_WINE * (1 - W_WINE) / M_W
# ---- the game's scene in Matplotlib, in a box 10 wide, the same units as game.js; a line for the label on top
def draw_still(ax, fill, T, heat, full=(), current=(0.0, 0.0), drop=None, label=None):
"""fill: pot liquid over the run's charge; full: ABV of the full glasses; current: (fraction, ABV) of the
glass under the outlet; drop: ABV of the falling drops, None when the pot does not boil."""
ax.set_axis_off()
ax.set(xlim=(0, 10), ylim=(0, 7.5), aspect="equal")
if heat:
h = 0.25 + 0.3 * heat
for fx in [1.4, 2.0, 2.6]:
ax.add_patch(plt.Polygon([(fx - 0.2, 0.15), (fx, 0.15 + h), (fx + 0.2, 0.15)], color=ACCENT, lw=0))
ax.add_patch(plt.Rectangle((0.8, 0.9), 2.4, 2.2 * fill, color=SECOND, alpha=0.35, lw=0))
ax.add_patch(plt.Rectangle((4.6, 2.2), 1.4, 2.3, facecolor=SECOND, alpha=0.12, lw=0))
ax.add_patch(plt.Rectangle((4.6, 2.2), 1.4, 2.3, fill=False, edgecolor=MUTED, lw=1))
ax.plot([1.25, 1.25], [4.3, 1.2], color=MUTED, lw=1)
ax.add_patch(plt.Circle((1.25, 1.2), 0.1, color=SECOND))
u = np.linspace(0, 1, 21)
ax.plot([0.8, 0.8, 3.2, 3.2], [3.3, 0.9, 0.9, 3.3], color=INK, lw=1.4)
ax.plot(0.8 + 2.4 * u, 3.3 + 0.8 * (1 - (2 * u - 1) ** 2), color=INK, lw=1.4)
ax.plot([2.0, 2.0, 2.4, 4.9, 5.3, 5.3], [4.1, 4.9, 5.3, 5.3, 4.9, 4.5], color=INK, lw=2)
s = np.linspace(0, 1, 49)
ax.plot(5.3 + 0.45 * np.sin(12 * np.pi * s), 4.5 - 2.3 * s, color=INK, lw=1.2)
ax.plot([5.3, 5.3], [2.2, 1.85], color=INK, lw=1.4)
if drop is not None:
for j in range(3):
ax.add_patch(plt.Circle((5.3, 1.75 - 0.5 * j / 3), 0.07, color=CIVIDIS(drop / 100)))
def glass(x0, x1, top, frac, a):
if frac > 0:
ax.add_patch(plt.Rectangle((x0, 0.15), x1 - x0, (top - 0.15) * min(frac, 1), color=CIVIDIS(a / 100), lw=0))
ax.plot([x0, x0, x1, x1], [top, 0.15, 0.15, top], color=INK, lw=1)
glass(4.95, 5.65, 1.15, *current)
pitch = min(0.62, 3.7 / max(len(full), 1)) # every full glass on the shelf, 6.2 to 9.9
for i, a in enumerate(full):
glass(6.2 + pitch * i, 6.2 + pitch * (i + 0.8), 0.95, 1, a)
if len(full): # the strength of the last full glass, in % ABV
ax.text(6.2 + pitch * (len(full) - 0.6), 1.05, f"{full[-1]:.0f}", ha="center", va="bottom", color=INK)
ax.text(0.1, 5.5, f"{T:.1f} °C", color=SECOND, va="bottom")
if label:
ax.text(0.1, 7.5, label, va="top", color=MUTED)
def strength_bar(cax, side="right"):
"""The cividis scale of the glasses, drops, and bars, 0 to 100 % ABV."""
bar = plt.colorbar(plt.cm.ScalarMappable(norm=plt.Normalize(0, 100), cmap=CIVIDIS), cax=cax, ticks=[0, 50, 100])
bar.outline.set_visible(False)
bar.set_label("% ABV")
cax.yaxis.set_ticks_position(side)
cax.yaxis.set_label_position(side)
print(f"the wine: {CHARGE_L:.0f} L at {WINE_ABV:.0f} % ABV = {100 * W_WINE:.2f} % by weight, "
f"{N0:.1f} mol of which {E0:.2f} mol ethanol, x = {E0 / N0:.4f}")
print(f"boiling points: ethanol {brentq(lambda T: antoine(T, 'ethanol') - P, 50, 100):.1f} °C, "
f"water {brentq(lambda T: antoine(T, 'water') - P, 50, 110):.1f} °C")
the wine: 4 L at 12 % ABV = 9.64 % by weight, 205.3 mol of which 8.22 mol ethanol, x = 0.0400 boiling points: ethanol 78.3 °C, water 100.0 °C
Each liquid pushes molecules into the vapor with its vapor pressure, which rises steeply with temperature. The Antoine equation fits it, \(\log_{10}(p^{\mathrm{sat}}/\mathrm{bar}) = A - B/(T/\mathrm{K} + C)\), with the NIST Chemistry WebBook's constants from Ambrose and Sprake for ethanol (fitted from 19.6 to 93.5 °C) and Bridgeman and Aldrich for water (70.9 to 99.9 °C). They put the boiling points at 78.3 °C and 100.0 °C, and at 91.5 °C ethanol's vapor pressure is 2.25 times water's. From the second glass on, the pot is above 93.5 °C and the ethanol set is extrapolated, by at most 6.5 °C.
In an ideal mixture each component would push in proportion to its mole fraction, which is Raoult's law. Ethanol and water are not: water molecules hold each other harder than they hold ethanol, so diluted ethanol escapes more easily than Raoult predicts. The activity coefficient γ carries that correction, and the vapor's mole fraction y follows from
with \(x_i\) the mole fraction in the liquid, \(p\) = 760 mmHg the pressure, and \(p_i^{\mathrm{sat}}\) the Antoine vapor pressure. The NRTL model of Renon and Prausnitz computes γ from the composition and the temperature:
a12, b12, a21, b21, alpha = -0.8009, 246.18, 3.4578, -586.0809, 0.3 # ethanol (1) and water (2), b in K
def gamma_nrtl(x, T):
"""Activity coefficients of ethanol and water at ethanol mole fraction x and T / °C."""
tau12, tau21 = a12 + b12 / (T + 273.15), a21 + b21 / (T + 273.15)
G12, G21 = np.exp(-alpha * tau12), np.exp(-alpha * tau21)
x1, x2 = x, 1 - x
ln1 = x2**2 * (tau21 * (G21 / (x1 + x2 * G21))**2 + tau12 * G12 / (x2 + x1 * G12)**2)
ln2 = x1**2 * (tau12 * (G12 / (x2 + x1 * G12))**2 + tau21 * G21 / (x1 + x2 * G21)**2)
return np.exp(ln1), np.exp(ln2)
The two τ, each a constant plus a 1/T part, measure how much more each molecule prefers its own kind, and α = 0.3 says how far the mixing departs from random. They come from fits to measurements, not from first principles. This set is Aspen Plus's built-in ethanol-water pair, regressed from the Dortmund Data Bank and printed by Beneke and coauthors (Further reading). For another pair, fit your own measurements or take a set from the DECHEMA Chemistry Data Series, which gives \(A_{12}\), \(A_{21}\), and \(\alpha_{12}\) with \(\tau_{12} = A_{12}/(RT)\): set a12 = a21 = 0, b12 = A12 / R, b21 = A21 / R, and alpha = \(\alpha_{12}\), with \(R\) in the units of \(A\), 1.987 cal/(mol K) for cal/mol.
The pot boils, at its bubble point, when the two partial pressures sum to 760 mmHg. bubble_point in the setup cell finds it with SciPy's brentq, which shrinks a bracket, here 0 to 110 °C, across which the sum minus 760 mmHg changes sign (Root finding with scipy.optimize shows how).
x_wine = E0 / N0
T_wine, y_wine = bubble_point(x_wine)
g_e, g_w = gamma_nrtl(x_wine, T_wine)
w_drop = mass_fraction(1, y_wine)
print(f"wine: x = {x_wine:.3f}, boils at {T_wine:.1f} °C, where p_ethanol / p_water = "
f"{antoine(T_wine, 'ethanol') / antoine(T_wine, 'water'):.2f}")
print(f"γ ethanol = {g_e:.2f} (as a trace in water: {gamma_nrtl(0.0, T_wine)[0]:.2f}), γ water = {g_w:.3f}")
print(f"first drop: y = {y_wine:.3f}, {100 * w_drop:.1f} % by weight, {abv(w_drop):.1f} % ABV")
wine: x = 0.040, boils at 91.5 °C, where p_ethanol / p_water = 2.25 γ ethanol = 4.43 (as a trace in water: 5.58), γ water = 1.005 first drop: y = 0.293, 51.4 % by weight, 59.4 % ABV
Ethanol's γ is 4.43 in the wine and 5.58 as a trace in water, the limit called infinite dilution, while water's is 1.005, ideal to a good approximation. The vapor holds 29.3 mol % ethanol against the liquid's 4.0, far above the diagonal, where vapor and liquid would be alike. Mole fractions count molecules, and ethanol's 46 g/mol against water's 18 makes the same drop 51.4 % by weight. ABV counts volume, and ethanol, the less dense liquid, fills more of it: 59.4 %.
Show code
# y(x) and the boiling point on 401 compositions, once; the Rayleigh integration and the game interpolate them
T_TABLE, Y_TABLE = np.array([bubble_point(x) for x in X_GRID]).T
fig, (ax0, ax) = plt.subplots(1, 2, figsize=(7, 3.4), gridspec_kw=dict(width_ratios=[1.45, 1], wspace=0.2))
draw_still(ax0, fill=1, T=T_wine, heat=HEAT_KW, current=(0.03, abv(w_drop)), drop=abv(w_drop), label="first drop")
strength_bar(ax0.inset_axes([0.9, 0.33, 0.03, 0.32]), side="left")
ax.plot([0, 1], [0, 1], color=MUTED, lw=1, ls="--")
ax.plot(X_GRID, Y_TABLE, color=INK)
ax.plot([x_wine, x_wine, 0], [0, y_wine, y_wine], color=SECOND, lw=1)
ax.plot(x_wine, y_wine, "o", ms=6, color=ACCENT)
ax.annotate(f"wine x = {x_wine:.3f}\nfirst drop y = {y_wine:.3f}", (x_wine, y_wine), xytext=(0.22, 0.2),
color=INK, arrowprops=dict(arrowstyle="->", color=MUTED, lw=1))
ax.set(xlabel="x, ethanol in the liquid / mol fraction", ylabel="y, in the vapor / mol fraction", xlim=(0, 1), ylim=(0, 1))
plt.show()
The pot gets hotter as it runs
Glass after glass comes out weaker, darker on the strength scale, while the thermometer climbs toward 100 °C. The vapor takes more than the pot's share of ethanol, so the pot weakens and its bubble point rises.
Write N for the moles in the pot and x for its ethanol fraction. While the pot boils N falls, so dN is negative: the vapor that leaves takes −dN moles, y(−dN) of them ethanol, and the pot's ethanol changes by d(Nx) = y dN, both sides negative. The flame sets the pace: a heat Q boils Q/Δh moles a second, with Δh the heat of vaporization weighted by the vapor's composition. Divided by dt, the balance gives the two equations that the code and the game integrate:
Expanding d(Nx) = N dx + x dN and dividing out the time gives the Rayleigh equation, \(dN/N = dx/(y - x)\), the same statement without a clock. The code solves it in the time form, because the game needs a clock.
solve_ivp steps the two equations forward from the full pot, adjusting its step to a tolerance (solve_ivp from the ground up covers it). With dense_output=True the solution becomes a function of time that can be read between steps, and an event, a function that solve_ivp watches for a sign change, stops the run when the pot is down to 2 % of its charge. A glass is cut when its contents reach 0.2 L, read off the dense solution with np.interp run backward, from collected volume to time. The warm-up uses the wine's heat capacity; heat losses, the heat that keeps warming the boiling pot, and the drops left in the condenser are neglected.
def distill(n0, e0, heat_kw=HEAT_KW):
"""One run from a cold pot. Returns rows (moles, ethanol moles, end time / s, pot °C at the end) per full glass,
the solution, and the warm-up time / s."""
w0, glass_L = mass_fraction(n0, e0), liters(n0, e0) / GLASSES
kg = (e0 * M_E + (n0 - e0) * M_W) / 1000
t_warm = kg * (w0 * CP_E + (1 - w0) * CP_W) * (np.interp(e0 / n0, X_GRID, T_TABLE) - T_ROOM) / heat_kw
def rhs(t, z):
n, e = z
y = np.interp(e / n, X_GRID, Y_TABLE)
boil = heat_kw / (y * DH_E + (1 - y) * DH_W) # mol/s
return [-boil, -y * boil]
def nearly_dry(t, z):
return z[0] - DRY * n0
nearly_dry.terminal = True
sol = solve_ivp(rhs, (0, 1e6), [n0, e0], rtol=1e-8, atol=1e-10 * n0, dense_output=True, events=nearly_dry)
t = np.linspace(0, sol.t[-1], 4001)
n, e = sol.sol(t)
rows, t_cut = [], 0.0
while True: # the next glass fills from the last cut on
n_cut, e_cut = sol.sol(t_cut)
later = t > t_cut
filled = liters(n_cut - n[later], e_cut - e[later])
if filled[-1] < glass_L:
break
t_cut = np.interp(glass_L, filled, t[later])
n_end, e_end = sol.sol(t_cut)
rows.append((n_cut - n_end, e_cut - e_end, t_warm + t_cut, np.interp(e_end / n_end, X_GRID, T_TABLE)))
return np.array(rows), sol, t_warm
run1, sol1, warm1 = distill(N0, E0)
w1 = mass_fraction(run1[:, 0], run1[:, 1])
print("glass % by weight % ABV pot / °C clock / min")
for k, (w, row) in enumerate(zip(w1, run1), 1):
if k <= 12:
print(f"{k:5d} {100 * w:11.1f} {abv(w):5.1f} {row[3]:8.1f} {row[2] / 60:11.1f}")
print(f"{len(run1)} full glasses; warm-up {warm1 / 60:.0f} min, run {(warm1 + sol1.t[-1]) / 60:.0f} min; "
f"glasses 1 to 7 hold {100 * run1[:7, 1].sum() / E0:.1f} % of the wine's ethanol")
glass % by weight % ABV pot / °C clock / min
1 49.1 57.0 92.8 23.6
2 43.6 51.2 94.1 28.6
3 36.8 43.8 95.6 34.1
4 28.7 34.7 97.0 40.0
5 20.0 24.6 98.2 46.3
6 12.1 15.0 99.0 53.1
7 6.3 7.9 99.6 60.3
8 2.9 3.6 99.8 67.6
9 1.1 1.4 99.9 75.0
10 0.4 0.5 100.0 82.5
11 0.1 0.2 100.0 90.0
12 0.0 0.0 100.0 97.5
19 full glasses; warm-up 19 min, run 155 min; glasses 1 to 7 hold 97.6 % of the wine's ethanol
The first glass is 57.0 % ABV, 2.4 points below the first drop, because the later drops in it are weaker. The seventh, at 7.9 % ABV, is the first below the wine's 12 %, with the pot at 99.6 °C, and the first seven glasses hold 97.6 % of the wine's ethanol. From glass 12 on the glasses read 0.0 % ABV, water, and the run takes 155 minutes at 1 kW, 19 of them warming up. A distiller stops at the seventh glass, an hour and a half earlier.
Show code
def still_at(rows, sol, t_warm, n0, k, label):
"""The scene at the end of glass k of a run, the next glass still empty."""
n, e = sol.sol(rows[k - 1, 2] - t_warm)
y = np.interp(e / n, X_GRID, Y_TABLE)
return dict(fill=n / n0, T=rows[k - 1, 3], heat=HEAT_KW, full=abv(mass_fraction(rows[:k, 0], rows[:k, 1])),
drop=abv(mass_fraction(1, y)), label=label)
fig = plt.figure(figsize=(7, 6.2))
grid = fig.add_gridspec(3, 4, height_ratios=[1.3, 1.6, 1.15], width_ratios=[1, 1, 1, 0.12],
hspace=0.12, wspace=0.04, left=0.1, right=0.97, top=0.99, bottom=0.08)
for j, k in enumerate([1, 4, 7]):
draw_still(fig.add_subplot(grid[0, j]), **still_at(run1, sol1, warm1, N0, k, f"glass {k}, {run1[k - 1, 2] / 60:.0f} min"))
host = fig.add_subplot(grid[0, 3])
host.set_axis_off()
strength_bar(host.inset_axes([0, 0.12, 0.3, 0.7]))
ax = fig.add_subplot(grid[1, :])
starts = np.r_[warm1, run1[:-1, 2]] / 60
ax.bar(starts, abv(w1), width=run1[:, 2] / 60 - starts, align="edge", color=CIVIDIS(abv(w1) / 100), edgecolor=INK, lw=0.6)
ax.axhline(WINE_ABV, color=MUTED, ls="--", lw=1)
ax.text(158, WINE_ABV + 1.5, "wine, 12 %", color=MUTED, ha="right", va="bottom")
ax.set(ylabel="glass / % ABV", xlim=(0, 160), ylim=(0, 65))
ax.tick_params(labelbottom=False)
axT = fig.add_subplot(grid[2, :], sharex=ax)
t = np.linspace(0, sol1.t[-1], 400)
n, e = sol1.sol(t)
axT.plot(np.r_[0, warm1 + t] / 60, np.r_[T_ROOM, np.interp(e / n, X_GRID, T_TABLE)], color=SECOND)
axT.set(xlabel="t / min", ylabel="pot / °C", ylim=(20, 105), yticks=[20, 60, 100])
plt.show()
No pot still passes 95.6 % by weight
Pour back after the seventh glass and the second run boils at 84.9 °C instead of 91.5 °C, from a pot of 1.39 L instead of 4 L. Its first glass reads 77.1 % ABV against the first run's 57.0, the third run's 86.3, and the fourth run's 90.3: each gain is smaller than the one before. The limit is the azeotrope, 95.6 % ethanol by weight as measured, about 97 % ABV on a bottle label.
On the x-y diagram the best a run can do is its first drop: a step from x up to y(x) and across to the diagonal, where y becomes the next run's x. A run that keeps several glasses gets less, because the later glasses are weaker, so the staircase of first drops is an upper bound that the game's Pour back climbs below. The step is y − x, and it shrinks to nothing where the curve crosses the diagonal. There the vapor has the composition of the liquid: that is the azeotrope. brentq finds it as the root of y(x) − x:
x_az = brentq(lambda x: bubble_point(x)[1] - x, 0.6, 0.995)
T_az, w_az = bubble_point(x_az)[0], mass_fraction(1, x_az)
print(f"model: x = {x_az:.4f}, {100 * w_az:.2f} % by weight = {abv(w_az):.1f} % ABV, {T_az:.2f} °C")
print(f"measured: {100 * W_AZ_MEASURED:.1f} % by weight = {abv(W_AZ_MEASURED):.1f} % ABV, {T_AZ_MEASURED:.1f} °C")
model: x = 0.8954, 95.63 % by weight = 97.2 % ABV, 78.15 °C measured: 95.6 % by weight = 97.2 % ABV, 78.2 °C
The model misses the measured azeotrope by 0.03 point by weight and 0.05 °C. Below the azeotrope y(x) rises with x and meets the diagonal there, so every drop from a pot below it is weaker than the azeotrope, and so is any mixture of such drops. At 1 atm, with nothing but ethanol and water, no number of runs gets past it. The runs below follow the game's two policies until what is poured back falls under 5 mL:
def pour_back(keep):
"""Repeated runs, each pouring back its first `keep` glasses. One dict per run."""
n, e, runs = N0, E0, []
while True:
rows, sol, t_warm = distill(n, e)
kept = rows[:keep]
n_k, e_k = kept[:, 0].sum(), kept[:, 1].sum()
runs.append(dict(n0=n, rows=rows, sol=sol, t_warm=t_warm, charge=liters(n, e),
kept=liters(n_k, e_k), w=mass_fraction(n_k, e_k)))
n, e = n_k, e_k
if len(kept) < keep or 1000 * liters(n, e) < MIN_ML:
return runs
policies = {"Pour back after the first glass": pour_back(1), "Pour back after the seventh glass": pour_back(7)}
for name, runs in policies.items():
print(name)
for i, r in enumerate(runs, 1):
T_boil = np.interp(r["sol"].y[1, 0] / r["n0"], X_GRID, T_TABLE)
first = abv(mass_fraction(*r["rows"][0, :2]))
print(f" run {i}: {1000 * r['charge']:6.1f} mL in, boils at {T_boil:4.1f} °C, first glass {first:4.1f} % ABV; "
f"{1000 * r['kept']:6.1f} mL out at {100 * r['w']:4.1f} % by weight = {abv(r['w']):4.1f} % ABV, "
f"{100 * (w_az - r['w']):4.1f} points short")
x, stairs = x_wine, [x_wine]
while mass_fraction(1, x) < w_az - 0.01: # first drops only, until within 1 point by weight
x = np.interp(x, X_GRID, Y_TABLE)
stairs.append(x)
print(f"first drops only: {len(stairs) - 1} runs to {100 * mass_fraction(1, x):.1f} % by weight")
Pour back after the first glass run 1: 4000.0 mL in, boils at 91.5 °C, first glass 57.0 % ABV; 200.0 mL out at 49.1 % by weight = 57.0 % ABV, 46.5 points short run 2: 200.0 mL in, boils at 81.7 °C, first glass 83.4 % ABV; 10.0 mL out at 77.4 % by weight = 83.4 % ABV, 18.2 points short run 3: 10.0 mL in, boils at 79.3 °C, first glass 89.4 % ABV; 0.5 mL out at 84.9 % by weight = 89.4 % ABV, 10.7 points short Pour back after the seventh glass run 1: 4000.0 mL in, boils at 91.5 °C, first glass 57.0 % ABV; 1394.5 mL out at 27.7 % by weight = 33.6 % ABV, 67.9 points short run 2: 1394.5 mL in, boils at 84.9 °C, first glass 77.1 % ABV; 488.0 mL out at 64.4 % by weight = 71.8 % ABV, 31.3 points short run 3: 488.0 mL in, boils at 80.4 °C, first glass 86.3 % ABV; 170.8 mL out at 80.2 % by weight = 85.7 % ABV, 15.4 points short run 4: 170.8 mL in, boils at 79.0 °C, first glass 90.3 % ABV; 59.8 mL out at 85.6 % by weight = 89.9 % ABV, 10.0 points short run 5: 59.8 mL in, boils at 78.6 °C, first glass 92.2 % ABV; 20.9 mL out at 88.3 % by weight = 92.0 % ABV, 7.3 points short run 6: 20.9 mL in, boils at 78.4 °C, first glass 93.4 % ABV; 7.3 mL out at 90.0 % by weight = 93.3 % ABV, 5.7 points short run 7: 7.3 mL in, boils at 78.3 °C, first glass 94.2 % ABV; 2.6 mL out at 91.1 % by weight = 94.1 % ABV, 4.5 points short first drops only: 14 runs to 94.6 % by weight
Pour back after the seventh glass turns 4 L of wine into 2.6 mL at 94.1 % ABV in seven runs, still 4.5 points by weight short of the azeotrope. Pour back after the first glass gets 0.5 mL at 89.4 % ABV in three. Even the staircase, which keeps only one drop a run, needs 14 runs to come within 1 point. In practice the spirit runs out long before the azeotrope. Absolute alcohol takes a third component, cyclohexane today and benzene formerly, which forms a new azeotrope that carries the water away, or molecular sieves that trap the water in their pores.
Show code
seventh = policies["Pour back after the seventh glass"]
fig = plt.figure(figsize=(7, 5.8))
grid = fig.add_gridspec(2, 4, height_ratios=[1, 1.5], width_ratios=[1, 1, 1, 0.12],
hspace=0.12, wspace=0.04, left=0.1, right=0.97, top=0.99, bottom=0.09)
for j, i in enumerate([2, 3, 4]):
r = seventh[i - 1]
draw_still(fig.add_subplot(grid[0, j]),
**still_at(r["rows"], r["sol"], r["t_warm"], r["n0"], 1, f"run {i}, {1000 * r['charge']:.0f} mL"))
host = fig.add_subplot(grid[0, 3])
host.set_axis_off()
strength_bar(host.inset_axes([0, 0.12, 0.3, 0.7]))
bottom = grid[1, :].subgridspec(1, 2, wspace=0.38)
ax = fig.add_subplot(bottom[0])
ax.plot([0, 1], [0, 1], color=MUTED, lw=1, ls="--")
ax.plot(X_GRID, Y_TABLE, color=INK, lw=1.2)
sx = np.repeat(stairs, 2)[:-1] # up from the diagonal to the curve, across to the diagonal
sy = np.r_[stairs[0], np.repeat(stairs[1:], 2)]
ax.plot(sx, sy, color=ACCENT, lw=1.2)
ax.plot(x_az, x_az, "o", ms=6, color=ACCENT)
ax.set(xlabel="x / mol fraction", ylabel="y / mol fraction", xlim=(0, 1), ylim=(0, 1))
inset = ax.inset_axes([0.53, 0.05, 0.44, 0.44]) # the last steps, magnified
inset.plot([0.7, 1], [0.7, 1], color=MUTED, lw=1, ls="--")
inset.plot(X_GRID, Y_TABLE, color=INK, lw=1.2)
inset.plot(sx, sy, color=ACCENT, lw=1.2)
inset.plot(x_az, x_az, "o", ms=6, color=ACCENT)
inset.text(0.805, 0.903, "azeotrope", color=INK, va="top")
inset.set(xlim=(0.8, 0.91), ylim=(0.8, 0.91), xticks=[], yticks=[], aspect="equal")
inset.grid(False)
inset.spines[:].set_visible(True)
inset.spines[:].set_color(MUTED)
ax.indicate_inset_zoom(inset, edgecolor=MUTED, alpha=1, lw=1)
ax2 = fig.add_subplot(bottom[1])
ax2.axhline(100 * w_az, color=MUTED, ls="--", lw=1)
ax2.text(0.6, 100 * w_az + 1, "azeotrope", color=MUTED, ha="left", va="bottom")
for (name, runs), marker, face in zip(policies.items(), ["s", "o"], ["none", ACCENT]):
ax2.plot(range(1, len(runs) + 1), [100 * r["w"] for r in runs], marker + "-", ms=6, color=ACCENT, mfc=face,
lw=1.2)
ax2.text(0.65, 81, "after the\nfirst glass", color=ACCENT, ha="left", va="bottom") # above its curve
ax2.text(7.3, 84, "after the\nseventh glass", color=ACCENT, ha="right", va="top") # under its curve
ax2.set(xlabel="run", ylabel="poured back / % by weight", xlim=(0.5, 7.5), ylim=(20, 106), xticks=range(1, 8))
plt.show()
How the game computes it
The state. The game keeps whatever changes in a single object, state: the pot's moles n and ethanol moles e, the run's charge n0, the size of a glass, the pot temperature T, the heat, the phase (warming, boiling, or dry), the glass under the outlet, the list of full glasses, the run number, and the clock.
One frame. The browser calls frame through requestAnimationFrame each time it repaints. The time since the last call, never more than 50 ms, is multiplied by 120 and cut into steps of 1 s of still time. While the pot warms, a step adds Q dt/(m c_p) to its temperature until it reaches the bubble point. While it boils, a step is one classic Runge-Kutta step of the two time equations: it evaluates the slope once where the step begins, twice halfway through, and once where it ends, and advances with their average, weighted 1, 2, 2, 1. That is accurate enough at a fixed step and short enough to write in a dozen lines:
function slope(n, e) {
const y = interp(e / n, XG, YG), boil = state.heat / (y * DHE + (1 - y) * DHW); // mol/s
return [-boil, -y * boil];
}
// in step(h):
const k1 = slope(s.n, s.e);
const k2 = slope(s.n + h / 2 * k1[0], s.e + h / 2 * k1[1]);
const k3 = slope(s.n + h / 2 * k2[0], s.e + h / 2 * k2[1]);
const k4 = slope(s.n + h * k3[0], s.e + h * k3[1]);
The step's distillate, dn and de moles, goes into the glass under the outlet, g. When it would overfill the glass, it is split in proportion to volume, with before and after the glass's contents in liters without and with it, and the rest starts the next glass:
const f = (state.glassL - before) / (after - before);
state.full.push({n: g.n + f * dn, e: g.e + f * de});
state.glass = {n: 0, e: 0};
collect((1 - f) * dn, (1 - f) * de); // a tiny run's glass can fill within one step
At 2 % of the charge the flame goes out, as the event stops solve_ivp. interp is np.interp in JavaScript, a binary search on the grid and a straight line between neighbors; the pot temperature is looked up in the boiling-point table with it, and the ABV in the density table.
Pour back. Every full glass goes into the pot, the residue and the half-filled glass are thrown away, and the next run starts cold at 20 °C:
const n = state.full.reduce((a, g) => a + g.n, 0), e = state.full.reduce((a, g) => a + g.e, 0);
charge(n, e, state.run + 1);
From the notebook into the game. The notebook writes the 401 compositions with their y and boiling points, the density table, the constants, the wine's moles, 11 colors of cividis, and the limit in both units as JSON into the page, in a <script type="application/json" id="data"> element that game.js reads at startup. Nothing is fetched, so the page also runs from a downloaded file.
The game against the notebook. The same Runge-Kutta loop in NumPy distills the first run at 1 kW, and its glasses are checked one by one against distill:
def slope(n, e, heat_kw=HEAT_KW):
y = np.interp(e / n, X_GRID, Y_TABLE)
boil = heat_kw / (y * DH_E + (1 - y) * DH_W)
return np.array([-boil, -y * boil])
z, glass, glass_L, game_abv = np.array([N0, E0]), np.zeros(2), liters(N0, E0) / GLASSES, []
while z[0] >= DRY * N0:
k1 = slope(*z)
k2 = slope(*(z + DT_GAME / 2 * k1))
k3 = slope(*(z + DT_GAME / 2 * k2))
k4 = slope(*(z + DT_GAME * k3))
dz = DT_GAME / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
z = z + dz
before, after = liters(*glass) if glass[0] > 0 else 0.0, liters(*(glass - dz))
if after < glass_L:
glass = glass - dz
else: # split the step at the brim, as the game does
f = (glass_L - before) / (after - before)
game_abv.append(abv(mass_fraction(*(glass - f * dz))))
glass = -(1 - f) * dz
print(f"{len(game_abv)} glasses in the game, {len(run1)} in the notebook; "
f"largest difference {np.abs(np.array(game_abv) - abv(w1)).max():.1e} % ABV")
19 glasses in the game, 19 in the notebook; largest difference 6.6e-05 % ABV
Game and notebook differ by less than a ten-thousandth of a percentage point, far below the 0.1 % ABV the readout shows.
The cell that writes the game. It rounds every computed number to fixed digits, five significant ones in the tables, and sorts the keys, so two executions write an identical file, and the committed game changes only when the code does. A browser ends a script element at the first </script it meets, even inside JSON, so the data's </ becomes <\/. The HTML around the game, with the controls and both color schemes, sits in the hidden template cell above.
Show code
# The page around the game: the site's light and dark colors, the scene, the readouts, and the controls.
# game.js fills in every number; the notebook fills in DATA and SCRIPT.
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A pot still and the azeotrope</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98; }
@media (prefers-color-scheme: dark) {
:root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg);
font: 15px/1.3 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: grid; gap: 4px; padding: 4px 6px 6px; grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto auto; grid-template-areas: "scene" "readouts" "controls"; }
#view { grid-area: scene; position: relative; min-height: 0; }
#scene { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: manipulation; }
#readouts { grid-area: readouts; display: grid; grid-template-columns: 1fr auto; gap: 0 10px;
font-variant-numeric: tabular-nums; }
#cup { color: var(--accent); font-weight: 600; } #pot { color: var(--second); }
#limit, #status { color: var(--muted); } #cup, #limit { grid-column: 1 / -1; }
#controls { grid-area: controls; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.row { display: flex; align-items: center; gap: 6px; }
#heat-val { min-width: 4.2em; text-align: center; font-variant-numeric: tabular-nums; }
button { font: inherit; color: var(--second); background: none; border: 1.5px solid var(--second); border-radius: 4px;
padding: 0.2em 0.6em; touch-action: manipulation; }
#pour, #play { color: var(--bg); background: var(--second); }
button:disabled { opacity: 0.4; }
button.step { padding: 0; width: 1.9em; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
/* a wide frame: the scene across the top, the readouts and the controls in a strip under it */
@media (min-width: 600px) {
body { gap: 6px; padding: 8px 12px 10px; font-size: clamp(14px, 1.6vw, 17px); }
#readouts { display: flex; flex-wrap: wrap; gap: 2px 20px; }
#controls { gap: 6px 24px; }
}
/* a short frame, a phone held sideways: the scene on the left, readouts and controls beside it */
@media (min-width: 600px) and (min-aspect-ratio: 2 / 1) {
body { grid-template-columns: minmax(0, 62fr) minmax(0, 38fr); grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: "scene readouts" "scene controls"; gap: 8px 12px; padding: 6px 8px; font-size: 14px; }
#readouts { display: grid; grid-template-columns: 1fr; gap: 2px; }
#controls { align-content: start; gap: 6px 10px; }
}
</style>
</head>
<body>
<div id="view"><canvas id="scene" aria-label="A pot still: flame, pot, condenser, and glasses"></canvas></div>
<div id="readouts"><span id="pot"></span><span id="run"></span><span id="cup"></span><span id="status"></span>
<span id="limit"></span></div>
<div id="controls">
<div class="row"><span>Heat</span><button class="step" id="cooler" aria-label="Less heat" title="H">−</button>
<span id="heat-val"></span><button class="step" id="hotter" aria-label="More heat" title="J">+</button></div>
<div class="row"><button id="pour" title="P">Pour back</button><button id="reset" title="R">Reset</button>
<button id="play" title="Space"></button></div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
r5 = lambda a: [float(f"{v:.5g}") for v in np.asarray(a, dtype=float)] # five significant digits
game_data = {
"x_grid": r5(X_GRID), "y_table": r5(Y_TABLE), "t_table": r5(T_TABLE), "w_table": r5(W_TABLE),
"rho_table": r5(RHO_TABLE), "m_e": M_E, "m_w": M_W, "dh_e": DH_E, "dh_w": DH_W, "cp_e": CP_E, "cp_w": CP_W,
"n0": round(N0, 4), "e0": round(E0, 4), "heat": HEAT_KW, "glasses": GLASSES, "dry": DRY, "min_ml": MIN_ML,
"t_room": T_ROOM, "speed": SPEED, "dt": DT_GAME, "limit_w": round(100 * w_az, 2), "limit_abv": round(abv(w_az), 2),
"cividis": [[round(255 * c) for c in CIVIDIS(v)[:3]] for v in np.linspace(0, 1, 11)],
}
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(game_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/still/game.js").read_text(encoding="utf-8")
html = TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script)
Path("assets").mkdir(exist_ok=True)
Path("assets/still.html").write_text(html, encoding="utf-8", newline="\n")
print(f"assets/still.html: {len(data)} bytes of data, {Path('assets/still.html').stat().st_size / 1024:.0f} kB")
assets/still.html: 9847 bytes of data, 26 kB
Variations
- A column with trays. Stack trays above the pot and send all condensate back down the column, none drawn off (total reflux): each tray is a little still, and the first drop climbs one step of the staircase per stage. In the game, look the vapor up in the y table once per stage; 14 stages, the pot and 13 trays, put it within 1 point of the azeotrope, still below it.
- Under vacuum. Recompute the tables at 300 mmHg: the model's azeotrope moves to 96.6 % by weight at 56.4 °C, with the water constants extrapolated below their 70.9 °C. Measured data show it weakening as the pressure falls until it disappears below about 70 mmHg (US patent 4,303,478), but this parameter set, fitted near 1 atm, still has one at 50 mmHg, so trust it only near the pressure of its data.
- Start above the azeotrope. Fill the pot with 99 % ethanol by weight instead of wine: the first drop is 98.9 %, weaker than the pot, so the pot gets purer as it boils and the distillate creeps down toward the azeotrope from the other side.
- Methanol in the first glass. Add methanol as a third component, with its Antoine constants and its NRTL parameters against ethanol and water. It boils at 64.7 °C, so it comes over first: the "heads" a distiller throws away.
Further reading
- J. D. Seader, E. J. Henley, and D. K. Roper, Separation Process Principles (Wiley), for vapor-liquid equilibrium, batch distillation, and the Rayleigh equation.
- J. M. Smith, H. C. Van Ness, and M. M. Abbott, Introduction to Chemical Engineering Thermodynamics (McGraw-Hill), for activity coefficients and the bubble point.
- H. Renon and J. M. Prausnitz, "Local compositions in thermodynamic excess functions for liquid mixtures", AIChE Journal 14, 135 (1968), the NRTL model.
- D. Beneke, M. Peters, D. Glasser, and D. Hildebrandt, Understanding Distillation Using Column Profile Maps (Wiley, 2013), whose Appendix B lists the NRTL parameters used here.
- The NIST Chemistry WebBook's Antoine constants: Ambrose and Sprake (1970) for ethanol, Bridgeman and Aldrich (1964) for water.
- J. Gmehling and U. Onken, Vapor-Liquid Equilibrium Data Collection, DECHEMA Chemistry Data Series, Vol. I, Part 1 (1977), measured data for ethanol and water and fitted parameters.
- SciPy's documentation of
scipy.optimize.brentqandscipy.integrate.solve_ivp. - MDN on the Canvas API, which draws the still in
game.js. - Related tutorials on this site: Root finding with scipy.optimize: Halley's comet from Kepler's equation, for
brentqand its brackets; solve_ivp from the ground up, for tolerances, dense output, and events; and Nonlinear systems with scipy.optimize.root, a reactor with three steady states. - Download the notebook. It was executed with the library versions in the header.
- Download the game, one HTML file that runs offline in any browser.