History / release 1 · saved , the current version · released as https://doi.org/10.83371/sh.2610.hs8ev
Cite this release
@software{reaction_diffusion_2026, author = {ScienceHub}, title = {Reaction-diffusion}, version = {1}, year = {2026}, publisher = {ScienceHub}, doi = {10.83371/sh.2610.hs8ev} }
ScienceHub (2026). Reaction-diffusion (Version 1) [Computer software]. ScienceHub. https://doi.org/10.83371/sh.2610.hs8evcff-version: 1.2.0 message: "If you use this version, cite it as below." title: "Reaction-diffusion" type: software authors: - name: "ScienceHub" version: "1" date-released: "2026-10-13" doi: "10.83371/sh.2610.hs8ev" url: "https://doi.org/10.83371/sh.2610.hs8ev"
The DOI names exactly this release. The project page cites all releases.
reaction-diffusion.py
# /// script
# requires-python = ">=3.12"
# dependencies = ["marimo>=0.25.1", "numpy", "matplotlib"]
# ///
import marimo
__generated_with = "0.25.1"
app = marimo.App(width="medium")
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
# Spots and stripes from two chemicals
In 1952 Alan Turing proposed that two substances which react and spread
at different rates can turn a uniform tissue into a pattern: the spots
of a leopard, the stripes of a zebrafish. Forty years later John
Pearson showed how rich such patterns can be with a two-line model, the
Gray-Scott reaction:
$$\frac{\partial u}{\partial t} = D_u \nabla^2 u - u v^2 + F(1 - u), \qquad
\frac{\partial v}{\partial t} = D_v \nabla^2 v + u v^2 - (F + k)\, v$$
$u$ is fed in at rate $F$, $v$ consumes it to make more of itself and is
removed at rate $F + k$, and $u$ spreads twice as fast as $v$. Small
changes in $F$ and $k$ give spots that divide, mazes, or coral.
"""
)
return
@app.cell
def _():
import marimo as mo
import numpy as np
import matplotlib.pyplot as plt
return mo, np, plt
@app.cell
def _(mo):
PRESETS = {
"Dividing spots": (0.0367, 0.0649),
"Spots": (0.035, 0.065),
"Coral": (0.0545, 0.062),
"Maze": (0.029, 0.057),
"Worms": (0.078, 0.061),
}
preset = mo.ui.dropdown(list(PRESETS), value="Dividing spots", label="Pattern")
preset
return PRESETS, preset
@app.cell
def _(PRESETS, mo, preset):
feed = mo.ui.slider(0.01, 0.09, step=0.0005, value=PRESETS[preset.value][0], label="Feed rate F")
kill = mo.ui.slider(0.045, 0.07, step=0.0005, value=PRESETS[preset.value][1], label="Removal rate k")
steps = mo.ui.slider(500, 8000, step=500, value=5000, label="Time steps")
mo.vstack([feed, kill, steps])
return feed, kill, steps
@app.cell
def _(np):
def laplacian(X):
return np.roll(X, 1, -2) + np.roll(X, -1, -2) + np.roll(X, 1, -1) + np.roll(X, -1, -1) - 4 * X
def gray_scott(F, k, n=128, steps=5000, Du=0.16, Dv=0.08, seed=1):
"""Explicit Euler, five-point Laplacian, periodic boundaries, unit grid and time step.
F and k may be arrays: each pair then runs as its own dish, all at once.
"""
F = np.asarray(F, dtype=float)[..., None, None]
k = np.asarray(k, dtype=float)[..., None, None]
shape = np.broadcast_shapes(F.shape, k.shape)[:-2] + (n, n)
rng = np.random.default_rng(seed)
U = np.ones(shape)
V = np.zeros(shape)
for _ in range(10):
x, y = rng.integers(0, n - 8, 2)
U[..., x : x + 8, y : y + 8] = 0.5
V[..., x : x + 8, y : y + 8] = 0.25
U += 0.02 * rng.random((n, n))
V += 0.02 * rng.random((n, n))
for _ in range(steps):
Lu, Lv = laplacian(U), laplacian(V)
uvv = U * V * V
U += Du * Lu - uvv + F * (1 - U)
V += Dv * Lv + uvv - (F + k) * V
return U, V
return (gray_scott,)
@app.cell
def _(feed, gray_scott, kill, mo, plt, steps):
with mo.status.spinner(title="Reacting and diffusing"):
_, V = gray_scott(feed.value, kill.value, steps=steps.value)
fig, ax = plt.subplots(figsize=(5.5, 5.5))
ax.imshow(V, cmap="magma", interpolation="bicubic")
ax.set_axis_off()
ax.set_title(f"v after {steps.value:,} steps, F = {feed.value:.4f}, k = {kill.value:.4f}")
fig.tight_layout()
fig
return
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
## Where the patterns live
Pearson mapped which pattern appears for each pair $(F, k)$. Most of the
plane is dull: either $v$ dies out and the dish stays uniform, or it
takes over everywhere. Patterns live in a narrow band between the two,
roughly $0.03 < F < 0.08$ and $0.055 < k < 0.068$. Move a slider a few
thousandths outside it and the dish goes blank; that sensitivity is
part of why biologists still argue about which real patterns are
Turing patterns.
"""
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
---
**Method and limits.** 128 × 128 periodic grid, explicit Euler with
unit time step and grid spacing, $D_u = 0.16$, $D_v = 0.08$; ten seeded
squares of $v$ plus 2 percent noise start the reaction. Patterns depend
on the seed and the grid size, the regimes do not.
**References.** A. M. Turing, *Phil. Trans. R. Soc. B* 237 (1952) 37;
P. Gray and S. K. Scott, *Chem. Eng. Sci.* 38 (1983) 29;
J. E. Pearson, *Science* 261 (1993) 189.
Fork it to grow a pattern on a sphere or to add a third species.
"""
)
return
if __name__ == "__main__":
app.run()