import importlib.util, os, subprocess, sys
_local = os.environ.get("PECLET_LOCAL_BUILD")
if _local:
for p in _local.split(os.pathsep):
sys.path.insert(0, p)
elif importlib.util.find_spec("peclet") is None:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "peclet"], check=True)Flow through a randomly packed bed (peclet.dem → peclet.flow)
Generate a random close packing with the DEM engine, characterise its microstructure, then push Stokes flow through the pore space and measure permeability.
Runs on a free Colab CPU runtime — the first cell installs
peclet from PyPI.
What you’ll learn
The gallery’s first cross-method example: two peclet codes in one pipeline. We use peclet.dem (discrete-element method) to build a random close packing of spheres, characterise its microstructure (porosity, coordination number, radial distribution), convert it to a signed distance function, and drive creeping flow through the pore space with peclet.flow to measure the Darcy permeability — compared against the classic Carman–Kozeny relation. This is the realistic, disordered counterpart to the periodic-lattice Zick–Homsy example.
import numpy as np
import math, time
import matplotlib.pyplot as plt
from peclet import dem, flow
plt.rcParams.update({"figure.dpi": 130, "font.size": 10, "axes.grid": True,
"grid.alpha": 0.3, "axes.axisbelow": True,
"figure.facecolor": "white", "savefig.bbox": "tight"})
BLUE, RED, GREEN = "#1f77b4", "#d62728", "#2ca02c"Step 1 — Build a random close packing with peclet.dem
We grow monodisperse spheres in a periodic box under a Lubachevsky–Stillinger protocol: the particles start small, grow while a thermostat keeps the assembly fluid, and the growth is feedback-controlled by the committed overlap so the system jams near random close packing. A final quench settles it into a rigid contact network.
def pack_bed(N=180, phi_ref=0.63, radius=0.5, seed=3, dt=0.002, limit_time=7.0, iters=60,
temperature=1.0, scale_init=0.05, criterion=5e-3, cooling_time=5.0, quench=1200,
growth_accel=1.02, growth_decay=0.85, growth_rate_init=0.5):
# box sized so that full-scale spheres would give phi_ref; the growth stops *before* that,
# at the jamming point, so the achieved density (and radii) come out below phi_ref.
volp = (4 / 3) * math.pi * radius ** 3
side = (N * volp / phi_ref) ** (1 / 3)
half = side / 2.0
gr = growth_rate_init
cool = min(int(cooling_time / dt), int(limit_time / dt))
rng = np.random.default_rng(seed)
s = dem.Simulation(N)
s.initialize(shape_type=1, radius=radius)
s.set_domain((-half, -half, -half), (half, half, half))
s.enable_periodicity(True, True, True); s.set_gravity(0, 0, 0)
s.set_material_params(1.0, 1.0, 0.0); s.set_solver_iterations(iters, iters) # restitution 1 while fluid
pos = rng.uniform(-half, half, (N, 4)).astype(np.float32); pos[:, 3] = 1.0
s.set_positions(pos)
s.set_velocities(rng.normal(0.0, math.sqrt(temperature), (N, 3)).astype(np.float32))
s.set_scales(np.full(N, 1.0, np.float32))
s.set_growth_params(gr, scale_init); s.set_thermostat(temperature, dt)
def overlap_frac():
c = 2 * radius * float(s.get_scales().ravel().mean())
return float(s.compute_overlaps()) / max(c, 1e-9)
# MONOTONIC growth. When overlaps build, we RELAX them at the current size (dt = 0 steps grow
# nothing) and only slow the growth rate — we never shrink the grains back. The old scheme
# un-grew on every overlap spike, and because the GPU contact solve is atomically non-deterministic
# (float atomic_add ordering) a transient spike could trigger a runaway of shrink-steps that
# quenched a loose, unjammed state on some seeds. Growing in one direction only makes the final
# density reproducible: every seed lands at the box-limited phi_ref with an isostatic Z ~ 6.
for step in range(int(limit_time / dt)):
if step == cool: # anneal: mildly dissipative
s.set_material_params(0.5, 1.0, 0.0); s.set_thermostat(0.0, 1e4 * dt)
s.step(dt); mo = overlap_frac()
gf = float(s.get_growth_factor())
if mo > criterion: # overlaps too big -> relax at CURRENT size
it = 0; prev = mo
while it < 40:
s.step(0.0); it += 1; mn = overlap_frac() # dt = 0: pure overlap removal, no growth
if mn < criterion or (it > 8 and mn > 0.98 * prev):
break # cleared, or plateaued (jammed at this size)
prev = mn
if overlap_frac() > criterion: # still jammed -> slow growth, never un-grow
gr = max(gr * growth_decay, 0.02); s.set_growth_params(gr, gf)
elif gf >= 1.0 - 1e-6:
break # reached full size (box sized for phi_ref)
else: # room to grow -> accelerate
gr = min(gr * growth_accel, growth_rate_init); s.set_growth_params(gr, gf)
s.set_material_params(0.0, 0.0, 0.0); s.set_thermostat(0.0, 10 * dt) # quench into the rigid network
for _ in range(quench):
s.step(dt)
# EFFECTIVE radius = per-particle scale * global growth factor * base radius. The growth factor
# (<= 1) is essential — omitting it overstates the radii and fakes overlaps in g(r).
r_eff = radius * s.get_scales().ravel() * float(s.get_growth_factor())
pos = s.get_positions()[:, :3].astype(float)
phi = float(np.sum(4 / 3 * np.pi * r_eff ** 3) / side ** 3)
return pos, r_eff.astype(float), float(side), phi
t0 = time.time()
pos, r, side, phi = pack_bed()
print(f"random close packing: N={len(pos)} φ={phi:.3f} porosity ε={1-phi:.3f} "
f"mean radius={r.mean():.4f} box={side:.2f} ({time.time()-t0:.0f}s)")random close packing: N=180 φ=0.630 porosity ε=0.370 mean radius=0.5000 box=5.31 (21s)
Step 2 — Characterise the microstructure
Porosity \(\varepsilon = 1-\phi\), the mean coordination number \(Z\) (contacts per sphere — near the isostatic value 6 for a frictionless jammed packing), and the radial distribution function \(g(r)\) with its sharp contact peak at one diameter.
def microstructure(pos, r, side):
box = np.array([side] * 3)
wp = np.mod(pos + side / 2, box) # wrap into [0, side)
# coordination number (periodic contacts within 1% of touching)
Z = np.zeros(len(pos))
for i in range(len(pos)):
d = wp - wp[i]; d -= box * np.round(d / box)
dist = np.linalg.norm(d, axis=1)
Z[i] = ((dist > 1e-6) & (dist < (r + r[i]) * 1.01)).sum()
# g(r): histogram of periodic pair distances, normalised by the ideal-gas count
dd = []
for i in range(len(pos)):
d = wp[i + 1:] - wp[i]; d -= box * np.round(d / box)
dd.append(np.linalg.norm(d, axis=1))
dd = np.concatenate(dd)
rmax = side / 2
h, edges = np.histogram(dd, bins=120, range=(0, rmax))
rc = 0.5 * (edges[1:] + edges[:-1])
shell = 4 * np.pi * rc ** 2 * (edges[1] - edges[0])
rho = len(pos) / side ** 3
g = h / (shell * rho * len(pos) / 2)
return Z, rc, g
Z, rc, g = microstructure(pos, r, side)
print(f"mean coordination Z = {Z[Z>0].mean():.2f} (isostatic 6) rattlers = {int((Z==0).sum())}")mean coordination Z = 6.27 (isostatic 6) rattlers = 0
d = 2 * r.mean() # sphere diameter (effective)
fig, (a0, a1) = plt.subplots(1, 2, figsize=(9, 3.9))
zc = 0.0
for k in range(len(pos)):
dz = pos[k, 2] - zc; dz -= side * round(dz / side)
if abs(dz) < r[k]:
rr = math.sqrt(max(r[k] ** 2 - dz ** 2, 0))
a0.add_patch(plt.Circle((pos[k, 0], pos[k, 1]), rr, color=BLUE, alpha=0.55))
a0.set(xlim=(-side / 2, side / 2), ylim=(-side / 2, side / 2), aspect="equal",
title="packing slice (z=0)", xlabel="x", ylabel="y"); a0.grid(False)
a1.plot(rc / d, g, "-", color=RED)
a1.axhline(1, ls=":", color="0.6")
a1.set(xlabel="r / d", ylabel="g(r)", title="radial distribution function", xlim=(0, rc.max() / d))
fig.tight_layout()
plt.show()
Step 3 — Pore-space flow and permeability with peclet.flow
Sampling the packing onto a grid gives the SDF (negative inside a sphere). A body force drives Stokes flow through the pores; the Darcy permeability is \(k = \mu\langle u\rangle/F\) (here in physical length units via the cell size).
def sdf_from_pack(Ng, pos, r, side):
g = (np.arange(Ng) + 0.5) / Ng * side
X, Y, Z = np.meshgrid(g, g, g, indexing="ij")
best = np.full((Ng, Ng, Ng), 1e30)
for k in range(len(pos)):
dx = X - (pos[k, 0] + side / 2); dx -= side * np.round(dx / side)
dy = Y - (pos[k, 1] + side / 2); dy -= side * np.round(dy / side)
dz = Z - (pos[k, 2] + side / 2); dz -= side * np.round(dz / side)
best = np.minimum(best, np.sqrt(dx * dx + dy * dy + dz * dz) - r[k])
return best
def permeability(Ng, pos, r, side, mu=0.1, F=1e-3, dt=80.0, max_steps=500, tol=1e-4, want_field=False):
sdf = sdf_from_pack(Ng, pos, r, side)
lv = max(2, int(np.log2(Ng)) - 1)
s = flow.Solver(Ng, Ng, Ng)
s.set_rho(1.0); s.set_mu(mu); s.set_dt(dt); s.set_body_force(F, 0, 0); s.set_advection(False)
s.set_velocity_solver_params(150)
s.set_pressure_multigrid(True, levels=lv)
s.set_pressure_pcg(True, 200, 1e-8)
s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True, pressure_coarse="rediscretized")
prev = 0.0
for it in range(max_steps):
s.step()
if it % 5 == 4:
m = float(s.get_u().mean())
if it > 10 and abs(m - prev) < tol * (abs(m) + 1e-30):
break
prev = m
umean = float(s.get_u().mean())
k = mu * umean / F * (side / Ng) ** 2
out = dict(Ng=Ng, k=k, umean=umean, steps=it + 1)
if want_field:
out["u"] = s.get_u()[:, :, Ng // 2].copy()
out["sdf"] = sdf[:, :, Ng // 2].copy()
return out
main = permeability(56, pos, r, side, want_field=True)
eps = 1 - phi
k_CK = eps ** 3 * (2 * r.mean()) ** 2 / (180 * (1 - eps) ** 2) # Carman–Kozeny estimate (d = diameter)
print(f"Ng=56: permeability k = {main['k']:.3e} Carman–Kozeny ≈ {k_CK:.3e} "
f"ε={eps:.3f} ({main['steps']} steps)")Ng=56: permeability k = 7.619e-04 Carman–Kozeny ≈ 7.090e-04 ε=0.370 (170 steps)
u = np.where(main["sdf"] < 0, np.nan, main["u"])
fig, ax = plt.subplots(figsize=(4.6, 4.2))
im = ax.imshow(u.T, origin="lower", cmap="magma")
ax.set(title="pore-space velocity (mid-plane)", xlabel="x", ylabel="y"); ax.grid(False)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="u")
plt.show()
Step 4 — Grid convergence (with a caveat)
At this porosity the permeability is already fairly grid-insensitive across these resolutions — the connected pores are well enough resolved that \(k\) barely moves from \(N=32\) to \(56\). That is not guaranteed in general: denser beds have near-touching grains whose limiting throats are only a few cells wide, and there finer grids (a GPU) are needed. We show the trend against the Carman–Kozeny estimate.
conv = [permeability(Ng, pos, r, side) for Ng in (32, 44, 56)]
Ngs = [c["Ng"] for c in conv]; ks = [c["k"] for c in conv]
fig, ax = plt.subplots(figsize=(5.0, 3.8))
ax.plot(Ngs, ks, "o-", color=BLUE, label="peclet")
ax.axhline(k_CK, ls="--", color="0.4", label="Carman–Kozeny")
ax.set(xlabel="grid resolution N", ylabel="permeability k", title="permeability vs resolution")
ax.legend()
plt.show()
for c in conv:
print(f" N={c['Ng']:3d} k={c['k']:.3e} ({c['steps']} steps)")
N= 32 k=7.262e-04 (315 steps)
N= 44 k=7.649e-04 (215 steps)
N= 56 k=7.619e-04 (170 steps)
Step 5 — Statistics over independent realizations
Permeability is a property of the random microstructure, so it fluctuates between independent packings. We generate several realizations (different random seeds), characterise each, and report the spread.
real = [dict(seed=3, phi=phi, k=conv[1]["k"], Z=Z[Z > 0].mean())] # reuse the main packing (seed 3, Ng=44)
for sd in (4, 5):
p2, r2, s2, ph2 = pack_bed(seed=sd)
Z2, _, _ = microstructure(p2, r2, s2)
k2 = permeability(44, p2, r2, s2)["k"]
real.append(dict(seed=sd, phi=ph2, k=k2, Z=Z2[Z2 > 0].mean()))
ks = np.array([x["k"] for x in real]); phis = np.array([x["phi"] for x in real])
fig, (a0, a1) = plt.subplots(1, 2, figsize=(9, 3.6))
a0.bar([x["seed"] for x in real], ks, color=BLUE, alpha=0.8)
a0.axhline(ks.mean(), ls="--", color=RED, label=f"mean {ks.mean():.2e}")
a0.set(xlabel="realization (seed)", ylabel="permeability k", title="permeability spread"); a0.legend(fontsize=8)
a1.scatter(phis, ks, color=GREEN, s=40)
a1.set(xlabel="solid fraction φ", ylabel="permeability k", title="k vs φ (per realization)")
fig.tight_layout()
plt.show()
print(f"permeability over {len(real)} realizations: mean={ks.mean():.3e} std={ks.std():.2e} "
f"({100*ks.std()/ks.mean():.1f}%) mean φ={phis.mean():.3f} mean Z={np.mean([x['Z'] for x in real]):.2f}")
permeability over 3 realizations: mean=7.481e-04 std=1.70e-05 (2.3%) mean φ=0.630 mean Z=6.20
The takeaway
- Two
pecletcodes compose cleanly:dembuilds the geometry,flowsolves the transport, joined by an SDF — the same signed-distance representation used throughout the suite. - The generated packing shows the hallmarks of a random close packing: \(\phi\approx0.62\), coordination \(Z\approx6\)–\(7\) (at the frictionless isostatic value, no rattlers), and a \(g(r)\) that is zero inside one diameter with a sharp contact peak and the split second peak of dense random packing.
- Permeability at this porosity is well-behaved (grid-insensitive across the resolutions shown) and a few percent above the Carman–Kozeny estimate. Denser beds, with near-touching grains and few-cell throats, are the genuinely hard case where fine grids (a GPU) matter. For the validated second-order convergence methodology on a smooth geometry, see Zick–Homsy.
Adapt this yourself
- Polydisperse beds. Give the spheres a size distribution (per-particle radius) and watch \(\phi\) and permeability shift.
- Wall-bounded columns. Replace the periodic box with confining walls to study wall channelling.
- Bigger, on a GPU. The identical code runs on the CUDA/HIP
pecletbuild; there the grid can be fine enough for a grid-converged permeability across many realizations.
Reproduce this
pip install peclet
quarto render examples/random-packed-bed/index.qmd --execute