A state-space simulation of pour-over coffee extraction.
PourDynamics is a research platform for building a controllable, physics-based map from brewing inputs to chemical outputs — so that the direction of a flavour change can be predicted before you ever pick up the kettle.
We are not trying to simulate "coffee" to four decimal places. We are simulating control axes. The project succeeds if a simulated input change predicts the sign and ordering of the real-world TDS and sensory change — magnitudes get fitted from brew logs later.
Inputs x |
grind / PSD, dose, flow rate Q(t), temperature T(t), bloom, brewer geometry, paper, water chemistry, cup-pull time |
Outputs y |
TDS, extraction yield (EY), per-compound cup concentrations c_i(t) |
| Deliverable | the local sensitivity Jacobian J = ∂y/∂x — which knobs matter, which are coupled, which independently steer flavour |
Sensory (acidity, sweetness, bitterness, body, clarity…) is a downstream linear projection
sensory = W·c, fit separately from tasting data. The simulator itself is truncated at chemistry.
The skeleton is a 2D-axisymmetric Doyle–Fuller–Newman (DFN) model — the Li-ion battery framework, ported to a coffee bed and extended from 1D to 2D:
2D-axisymmetric bed (r, z) × 1D radial particle (per cell)
advection–dispersion on a Darcy field compound diffusion out of each bean
"where the water goes" "what dissolves"
Two co-equal pillars couple to produce the cup:
EXTRACTION pillar V0 ─ V1 ─ V2 ─ V3 ─ V4 ─ V5 ─ V6 ─ V7 ─ V8 ─ V10 ┐
(what dissolves) radial diff → PSD → compounds → bed → Q → T → ψ │
├─► COUPLED MODEL
HYDRODYNAMICS pillar H0 ─ H1 ─ H1_3D ─ H2 ─ H-trans ─ H4 ─ … ─ H7 ────┘ (2D ADE on the
(where water goes) paper → cone-Darcy field u(r,z,t) → seepage Darcy field →
TDS, EY, c_i(t))
Guiding principles:
- Regimes emerge, never switch. Diffusion, dissolution, and film transfer all stay in the equations; per-compound parameters decide which mechanism dominates. We never hard-code the rate-limiting step.
- The bed is solved as a direct 2D field, not a bundle of parallel 1D streamtubes (that older "multi-stream" approach is retired — independent 1D columns miss the suspended-cone soak and under-extract ~4×).
- Immersion and percolation are the same simulator. A French-press steep is
just a brew with
Q = 0; bloom and inter-pour pauses are zero-flow stretches.
See 01 Physics/00 Modeling Philosophy.md
for the full framing and 02 Model Versions/Model Roadmap.md
for the version-by-version build order.
| Folder | What's in it |
|---|---|
00 Vision/ |
The fixed research goal (Project Thesis.md) — do not modify |
01 Physics/ |
Modeling philosophy, governing equations, assumptions, scope, compound classes, architectural decisions |
02 Model Versions/ |
V0 → V10 roadmap + per-version notes; the Coupled Model and Hydrodynamics branch |
03 Simulations/ |
The simulation code — the engine/ package, the legacy coffee_sim/ package, tests, notebooks, and rendered results/ |
04 Experiments/ |
Brew-log protocol + module-isolation studies |
05 Analysis/ |
Jacobians, sensitivity sweeps, validation against brew logs |
06 References/ |
Literature, parameter sources, glossary |
99 In-Tray.md |
Raw thoughts pending distillation |
The repo is an Obsidian vault — internal navigation uses
[[wiki links]]. It is also a working Python codebase under 03 Simulations/.
The engine lives in 03 Simulations/engine/ and is a
pure function y = brew(x). The fastest way in is the brew-card helper, which
starts from the lab-standard recipe, overrides only the knobs you name, and renders
the standard 8-panel figure with a differential sensory radar.
cd "03 Simulations"
pip install numpy scipy matplotlib # fresh environments only
# CLI — render a brew card to PNG
python -m engine.brew_card out.png \
--temp 70 --flow 1.0 --bloom 30 --bloom-time 30 --water 170 --yield 150# Or from Python — the underlying pure function
from engine import brew, standard_v60_flat
y = brew(standard_v60_flat())
print(f"TDS {y.chemistry.tds_pct:.2f}% EY {y.chemistry.ey_pct:.1f}%")Anything you don't name stays at the lab standard. The full input/output contract,
physics decisions, and explicit exclusions are in
03 Simulations/engine/DESIGN.md.
The standard figure (with streamlines) takes ~1–2 min per brew, so run long sweeps in the background.
No module is trusted until it reproduces a known analytic limit. Every version must clear five gates before the next one starts:
| Check | Pass criterion |
|---|---|
| Analytic limit | < 1 % deviation on the canonical case (Crank series, ADE Gaussian plume) |
| Mass conservation | ∫ source = ∫ sink + Δstorage, < 1e-9 relative drift |
| Reduces to Vn–1 | < 0.5 % deviation when the new feature is switched off |
| Limit checks | D→∞ instant equilibration, D→0 no extraction, Q→∞ no contact, b→1 full bypass |
| Isolation experiment | sign of ∂y/∂x matches measurement |
Tests live in 03 Simulations/tests/. The current
engine validates against the older, separately-validated coffee_sim engine to
< 0.2 % across grind / dose / temperature sweeps.
cd "03 Simulations"
python -m pytest tests/ # run the test suite
python -m engine.validate_vs_old # engine-vs-engine validation harnessThis repo is a living research notebook with a few load-bearing rules,
spelled out in CLAUDE.md:
- Never modify
00 Vision/Project Thesis.md. The vision is fixed. - Truncate at chemistry. The simulator targets
(TDS, EY, c_i(t)); sensory mapping is downstream. - Regimes emerge, never switch. Keep all mechanisms in the equations.
- 2D-axisymmetric DFN is the skeleton — not 0D ODEs, not full CFD.
- "Ignored" vs "Deferred" is sacred — see
01 Physics/05 Scope - Ignored and Deferred.md. Ignored = out of scope forever; Deferred = revisit only when residuals demand it.
Default stack: Python (numpy, scipy, numba, matplotlib).
Non-dimensionalize before coding any numerical scheme, and no new feature ships
without a test that pins its behaviour.
When in doubt, ask: does this change improve our estimate of J, or is it cosmetic?