# Bootstrap: the pymrm this page was checked with, and the gallery files it was
# published with. Works in a checkout, in CI and on a fresh Colab VM.
GALLERY_REF = "gallery-2026.11.10.3" # replaced by the release tag at publication
PYMRM = ">=3.3.0,<4" # equals meta.yaml pymrm_min_version, up to the next major
PAGE = "B1.1-thiele-weisz-hicks" # equals the page directory
import os, sys, urllib.request
from pathlib import Path
_shared = next((p / "shared" for p in (Path.cwd(), *Path.cwd().parents)
if (p / "shared" / "gallery_bootstrap.py").is_file()), None)
if _shared is None:
_shared = (Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
/ "pymrm-gallery" / GALLERY_REF)
_shared.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/computational-chemical-engineering/"
f"pymrm-gallery/{GALLERY_REF}/shared/gallery_bootstrap.py",
_shared / "gallery_bootstrap.py")
sys.path.insert(0, str(_shared))
sys.modules.pop("gallery_bootstrap", None)
from gallery_bootstrap import setup
setup(PAGE, PYMRM, GALLERY_REF); # the semicolon stops the cell echoing the pathThiele modulus and the Weisz–Hicks non-isothermal pellet
Thiele modulus and the Weisz–Hicks non-isothermal pellet
Catalog ID: B1.1 + B1.5 · Structures: S3 (1D steady BVP), S10 (constrained/DAE continuation) · Tier: T0
How much of a catalyst pellet actually works? Thiele answered it in 1939 for the isothermal case with a formula still used daily. Weisz and Hicks showed in 1962 that once the pellet is allowed to heat up, the same question has up to three answers at once, and the pellet can run more than a hundred times faster than its own surface conditions suggest.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import Markdown, display
from scipy.special import iv
from gallery_utils import load_data, load_meta, cite_data, Claims
from model import (NU, Pellet, wh_rate, CentrePinned, trace_branch, trace_branch_own,
wh_reference, locate_folds, reference_states)
plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3})
claims = Claims(PAGE.split("-")[0])Background
A porous catalyst pellet consumes reactant faster than diffusion can resupply its interior, so the inside runs at a lower concentration than the surface. The effectiveness factor \(\eta\) is the ratio of the actual rate to the rate the pellet would achieve if its interior were all at surface conditions.
Thiele (1939) solved the isothermal first-order case exactly. The answer depends on a single dimensionless group, now called the Thiele modulus, comparing reaction rate to diffusion rate. Everything collapses onto one curve: \(\eta\to1\) for small \(\phi\) (kinetics-controlled), \(\eta\to\) (const)\(/\phi\) for large \(\phi\) (diffusion-controlled).
Weisz and Hicks (1962) removed the isothermal assumption. An exothermic reaction heats the pellet interior, the rate constant rises exponentially with temperature, and the two effects fight: depletion lowers the rate, heating raises it. Heating can win outright — \(\eta\) can exceed 1 by orders of magnitude. Worse, over a window of \(\phi\) the steady-state equation has three solutions: a cold, nearly-unused pellet; an ignited one running far above surface temperature; and an unstable state in between.
That is not a numerical artefact. It is the pellet-scale version of the ignition behaviour that makes fixed-bed reactors run away, and it is why the two papers belong on one page.
The published model
Isothermal (Thiele). For a first-order reaction in a pellet of characteristic dimension \(R\),
\[ D_{\mathrm{eff}}\,\nabla^2 c = k\,c , \qquad \left.\frac{\mathrm{d}c}{\mathrm{d}r}\right|_{r=0} = 0 , \qquad c(R) = c_s , \]
with \(\phi = R\sqrt{k/D_{\mathrm{eff}}}\). The exact solutions are
\[ \eta_{\text{slab}} = \frac{\tanh\phi}{\phi}, \qquad \eta_{\text{cyl}} = \frac{2 I_1(\phi)}{\phi\,I_0(\phi)}, \qquad \eta_{\text{sph}} = \frac{3}{\phi^2}\left(\phi\coth\phi - 1\right). \]
Non-isothermal (Weisz–Hicks). Add the energy balance. Because both balances have the same source term, temperature and concentration are linked algebraically by the Prater relation, \(\lambda_{\mathrm{eff}}(T-T_s) = (-\Delta H) D_{\mathrm{eff}}(c_s-c)\), which eliminates \(T\) entirely. With \(y = c/c_s\) and \(u = r/R\) the whole problem reduces to one equation in three dimensionless groups:
\[ \nabla^2 y \;=\; \phi^2\, y \,\exp\!\left[\frac{\gamma\beta(1-y)}{1+\beta(1-y)}\right], \qquad y(1) = 1, \qquad \left.\frac{\mathrm{d}y}{\mathrm{d}u}\right|_{0} = 0 , \]
\[ \beta = \frac{(-\Delta H)\,D_{\mathrm{eff}}\,c_s}{\lambda_{\mathrm{eff}}\,T_s} \quad\text{(Prater number, the maximum relative temperature rise)}, \qquad \gamma = \frac{E}{R_g T_s} \quad\text{(Arrhenius number)} . \]
The effectiveness factor follows from the surface flux, \(\eta = \dfrac{\nu+1}{\phi^{2}}\left.\dfrac{\mathrm{d}y}{\mathrm{d}u}\right|_{u=1}\), with \(\nu = 0,1,2\) for slab, cylinder and sphere.
| Symbol | Code | Meaning |
|---|---|---|
| \(y\) | y |
dimensionless concentration \(c/c_s\) |
| \(\phi\) | phi |
Thiele modulus |
| \(\beta\) | beta |
Prater number |
| \(\gamma\) | gamma |
Arrhenius number |
| \(\nu\) | nu |
geometry index passed to construct_div |
| \(\eta\) | eta_from(...) |
effectiveness factor from the surface flux |
Parameters and assumptions
Assumptions: steady state; first-order intrinsic kinetics; constant \(D_{\mathrm{eff}}\) and \(\lambda_{\mathrm{eff}}\); no external film resistance (\(y=1\) at the surface); Prater relation exact, which requires the same \(D_{\mathrm{eff}}\) for the reactant in both balances.
The problem is fully dimensionless, so there are no physical parameters to verify — only \(\phi\), \(\beta\), \(\gamma\) and the geometry. Weisz and Hicks tabulated \(\gamma = 20\), which is typical of real catalysis (\(E \approx 100\,\mathrm{kJ\,mol^{-1}}\) at 600 K), and \(\beta\) from 0 to about 0.8.
A note on what serves as “data” here. Neither source paper reports experimental measurements: Thiele (1939) is analytical and Weisz–Hicks (1962) is computational. This page is therefore validated against exact solutions rather than experiment — provenance tier 6 in docs/data-strategy.md. The isothermal comparison is against closed-form expressions; the non-isothermal one is against an independent shooting solution of the same equation. Both are genuine tests of the discretisation, but neither is a test against nature. Experimental effectiveness-factor measurements do exist elsewhere in the literature and would make a worthwhile companion page.
GEOMS = ["slab", "cylinder", "sphere"]
N_ISO = 200 # cells for the isothermal comparison [-]
GAMMA = 20.0 # Arrhenius number E/(R_g T_s) [-], Weisz and Hicks
BETAS = [0.0, 0.2, 0.4, 0.6, 0.8] # Prater numbers of the diagram [-]
BETA_M, N_R = 0.6, 400 # the multiplicity case: Prater number [-], cells [-]The data
The exact isothermal solutions, tabulated so the validation below is reproducible and checkable by CI.
ref = load_data("isothermal-exact.csv", page=PAGE)
meta = load_meta("isothermal-exact.csv", page=PAGE)
print(cite_data(meta))
print(f"\n{len(ref)} reference values, "
f"{ref['geometry'].nunique()} geometries, "
f"phi from {ref['phi'].min():.2f} to {ref['phi'].max():.0f}")
ref.head()Thiele, E. W. (1939) — Industrial & Engineering Chemistry 31(7) 916-920 — doi:10.1021/ie50355a027 — analytical
180 reference values, 3 geometries, phi from 0.05 to 100
| geometry | phi | eta | |
|---|---|---|---|
| 0 | slab | 0.050000 | 0.999167 |
| 1 | slab | 0.056875 | 0.998923 |
| 2 | slab | 0.064695 | 0.998607 |
| 3 | slab | 0.073590 | 0.998199 |
| 4 | slab | 0.083708 | 0.997671 |
PyMRM implementation
The model code is in this page’s model.py, which the first cell put on the path. One class, Pellet, covers every case. The geometry is a single argument — nu=0, 1, 2 in construct_div for slab, cylinder and sphere — and the reaction is passed in, so the isothermal and Weisz–Hicks problems share all the discretisation. The boundary conditions use pymrm’s outward normal: at the centre {"a": 1, "b": 0, "d": 0} is the symmetry condition \(-\mathrm{d}y/\mathrm{d}u = 0\), at the surface {"a": 0, "b": 1, "d": 1} is \(y = 1\). The operators are assembled once; NumJac works on the shape (n_r, 1), so the Jacobian of the pointwise rate is diagonal.
The effectiveness factor is taken from the surface-flux row of the flux operator, not by re-integrating the source term. That is both cheaper and more accurate, because it is the same discrete flux the solver balanced.
For the non-isothermal case, CentrePinned makes \(\psi = \phi^2\) an unknown and adds one equation that pins the centre value, and trace_branch sweeps that centre value with pymrm.continuation. The Results explain why.
Three choices, each checked below:
- The S-curve is traced by the library’s
continuation. The page’s own warm-start loop,trace_branch_own, traces it a second way, and the two are compared point by point. - The rate is written without clipping at \(y = 0\) and the Newton solves pass no
bounds: the Weisz–Hicks rate is smooth and first order through zero, which is what pymrm’s pitfall P9 asks for, and a bound is meant for unknowns the residual cannot accept, not for physical positivity. No solve on the page reaches a negative value. - The folds are located by refining the turning points of the reference curve, and the steady states at the probe modulus are root-found on the reference and solved by Newton on the pymrm pellet. Reading them off the sampled curves instead is computed beside them in the Results.
# Show the model the page runs (model.py next to this notebook).
import inspect, model
print(inspect.getsource(model.Pellet._build_operators))
print(inspect.getsource(model.CentrePinned.__call__)) def _build_operators(self):
# outward normal: -r at the centre, +r at the surface (pitfalls P1)
bc = ({"a": 1.0, "b": 0.0, "d": 0.0}, # u = 0: -dy/du = 0, symmetry
{"a": 0.0, "b": 1.0, "d": 1.0}) # u = 1: y = 1, surface value
grad_mat, grad_bc = construct_grad(self.shape, self.r_f, self.r_c, bc)
div_mat = construct_div(self.shape, self.r_f, nu=self.nu) # 0 slab, 1 cylinder, 2 sphere
self.flux_mat, self.flux_bc = -grad_mat, -grad_bc
self.jac_diff = (div_mat @ self.flux_mat).tocsc()
self.g_diff = (div_mat @ self.flux_bc).toarray().ravel()
# the surface-flux row: eta is read from the flux the solver balanced
self.surf = np.asarray(self.flux_mat[[-1], :].todense()).ravel()
self.surf_bc = float(self.flux_bc.toarray().ravel()[-1])
# The rate is pointwise in y, so its Jacobian is diagonal; the
# tridiagonal coupling is in jac_diff, added analytically.
self.numjac = NumJac(self.shape)
def __call__(self, x, y_c):
"""Residual and Jacobian at centre value ``y_c``, for :func:`pymrm.continuation`."""
p = self.pellet
y, psi = x[:-1], x[-1]
g_r, jac_r = p.numjac(self.rate, y.reshape(p.shape))
g = np.vstack([
p.jac_diff @ y.reshape((-1, 1)) + p.g_diff.reshape((-1, 1))
+ psi * g_r.reshape((-1, 1)),
np.array([[y[0] - y_c]]),
])
jac = bmat([[p.jac_diff + psi * jac_r, csc_array(g_r.reshape((-1, 1)))],
[self._pin, None]], format="csc")
return g, jac
Results
Isothermal — against Thiele’s exact solution
phi_grid = np.geomspace(0.05, 100.0, 60)
COL = dict(zip(GEOMS, ["tab:blue", "tab:orange", "tab:green"]))
def eta_numeric(geom, n_r=N_ISO, nu=None, phis=phi_grid):
"""Isothermal eta of the pymrm pellet over a range of Thiele moduli."""
pellet = Pellet(geom, n_r=n_r, nu=nu)
return np.array([pellet.solve_linear(f)[1] for f in phis])
def eta_exact_table(geom):
return ref[ref["geometry"] == geom].sort_values("phi")["eta"].values
def iso_errors(num, mask=None):
"""Worst relative error against the exact table, over geometries."""
m = np.ones_like(phi_grid, bool) if mask is None else mask
return max(np.max(np.abs(num[g] - eta_exact_table(g))[m] / eta_exact_table(g)[m])
for g in GEOMS)
num = {g: eta_numeric(g) for g in GEOMS}
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.4))
for g in GEOMS:
ex = ref[ref["geometry"] == g].sort_values("phi")
axes[0].loglog(ex["phi"], ex["eta"], color=COL[g], lw=2, label=f"{g} (exact)")
axes[0].loglog(phi_grid[::3], num[g][::3], "o", color=COL[g], ms=6,
mfc="white", mew=1.6, label="_nolegend_")
rel = np.abs(num[g] - ex["eta"].values) / ex["eta"].values
axes[1].loglog(phi_grid, rel, color=COL[g], lw=2, label=g)
axes[0].set_xlabel(r"Thiele modulus, $\phi$ [-]")
axes[0].set_ylabel(r"effectiveness factor, $\eta$ [-]")
axes[0].set_title("Lines: exact. Markers: pymrm")
axes[0].legend(frameon=False)
axes[1].set_xlabel(r"Thiele modulus, $\phi$ [-]")
axes[1].set_ylabel("relative error [-]")
axes[1].set_title(r"pymrm vs exact, $n_r = 200$")
axes[1].legend(frameon=False)
fig.tight_layout(); plt.show()
LO = phi_grid <= 30.0
iso_err = iso_errors(num)
iso_err_lo = iso_errors(num, LO)
print(f"worst relative error, phi <= 30 : {iso_err_lo:.3e}")
print(f"worst relative error, all phi : {iso_err:.3e}")
print("\nThe error is concentrated at large phi, where the reactant is consumed")
print("in a surface layer of thickness ~1/phi that a uniform grid cannot resolve.")
ETA_EX_100 = (3 / 100.0**2) * (100.0 / np.tanh(100.0) - 1)
def err_phi100(n_r, nu=None):
"""Relative eta error of the sphere at phi = 100 on n_r cells."""
return abs(Pellet("sphere", n_r=n_r, nu=nu).solve_linear(100.0)[1] - ETA_EX_100) / ETA_EX_100
print("\nSame point (sphere, phi = 100) under uniform refinement:")
ERR_100 = {}
prev = None
for n in (200, 400, 800, 1600):
ERR_100[n] = e = err_phi100(n)
rate_txt = f" ({prev/e:.1f}x)" if prev else ""
print(f" n_r = {n:5d}: relative error {e:.3e}{rate_txt}")
prev = e
iso_err_fine = ERR_100[1600]
worst relative error, phi <= 30 : 2.248e-04
worst relative error, all phi : 6.464e-03
The error is concentrated at large phi, where the reactant is consumed
in a surface layer of thickness ~1/phi that a uniform grid cannot resolve.
Same point (sphere, phi = 100) under uniform refinement:
n_r = 200: relative error 6.464e-03
n_r = 400: relative error 9.369e-04 (6.9x)
n_r = 800: relative error 1.324e-04 (7.1x)
n_r = 1600: relative error 1.933e-05 (6.8x)
The Aris collapse
Thiele’s three curves are really one. Using the generalised modulus \(\Lambda = (V_p/A_p)\sqrt{k/D_{\mathrm{eff}}}\) — Aris’s characteristic length, the volume-to-surface ratio — all geometries fall close to \(\tanh\Lambda/\Lambda\). The collapse is exact in both asymptotes and loosest in the transition region around \(\Lambda \approx 1\), where the three geometries still differ by about 15%. That is the accuracy of the “shape doesn’t matter” shortcut, and it is worth knowing the number rather than assuming it is negligible.
VA = {"slab": 1.0, "cylinder": 0.5, "sphere": 1.0 / 3.0} # (V/A)/R
fig, ax = plt.subplots(figsize=(7.5, 4.4))
lam_ref = np.geomspace(0.02, 40, 200)
ax.loglog(lam_ref, np.tanh(lam_ref) / lam_ref, "k--", lw=1.6,
label=r"$\tanh\Lambda/\Lambda$ (slab)")
for g in GEOMS:
ax.loglog(phi_grid * VA[g], num[g], color=COL[g], lw=2, label=g)
ax.set_xlabel(r"generalised modulus, $\Lambda = (V_p/A_p)\sqrt{k/D_{\rm eff}}$ [-]")
ax.set_ylabel(r"$\eta$ [-]")
ax.set_title("All geometries collapse onto one curve")
ax.legend(frameon=False)
fig.tight_layout(); plt.show()
def aris_spread(va, curves=None):
"""Largest relative spread between the geometries at equal Lambda, 0.3 to 3."""
curves = num if curves is None else curves
spread = []
for L in np.geomspace(0.3, 3.0, 25):
vals = [np.interp(L, phi_grid * va[g], curves[g]) for g in GEOMS]
spread.append((max(vals) - min(vals)) / np.mean(vals))
return max(spread)
ARIS_SPREAD = aris_spread(VA)
print(f"max spread between geometries at equal Lambda (0.3-3): {ARIS_SPREAD:.1%}")
max spread between geometries at equal Lambda (0.3-3): 15.4%
Non-isothermal — the Weisz–Hicks diagram
Now switch on the heat. Each curve is one Prater number \(\beta\) at \(\gamma = 20\). \(\beta\) is the maximum relative temperature rise the pellet can generate, so \(\beta = 0.6\) means the centre can run 60% above surface temperature in absolute terms — enough to swamp the depletion effect entirely.
The curves come from the independent shooting reference wh_reference: with \(s = \phi u\) the equation no longer contains \(\phi\), so integrating outward from a chosen centre value until \(y = 1\) gives \(\phi\), and the curve is a single-valued function of the centre value.
Y_DIAGRAM = np.geomspace(1e-6, 0.9995, 170)
def wh_curve(beta, gamma=GAMMA, y_scan=Y_DIAGRAM):
"""Reference (phi, eta) along increasing centre values."""
return np.array([q for v in y_scan for q in [wh_reference(beta, gamma, v)] if q])
def fold_depth(curve):
"""How far phi falls back while the centre value decreases, relative to its
running maximum: 0 for a curve that is a function of phi."""
phi = curve[::-1, 0]
run_max = np.maximum.accumulate(phi)
return float(np.max((run_max - phi) / run_max))
cmap = plt.cm.viridis(np.linspace(0.05, 0.85, len(BETAS)))
fig, ax = plt.subplots(figsize=(8, 5))
wh_curves = {}
for beta, c in zip(BETAS, cmap):
wh_curves[beta] = pts = wh_curve(beta)
ax.loglog(pts[:, 0], pts[:, 1], color=c, lw=2, label=rf"$\beta$ = {beta}")
ax.axhline(1.0, color="k", lw=0.8, ls=":")
ax.set_xlabel(r"Thiele modulus, $\phi$ [-]")
ax.set_ylabel(r"effectiveness factor, $\eta$ [-]")
ax.set_title(rf"Weisz–Hicks diagram, $\gamma$ = {GAMMA:.0f} (sphere)")
ax.legend(frameon=False, title="Prater number")
fig.tight_layout(); plt.show()
FOLD_DEPTH = {beta: fold_depth(wh_curves[beta]) for beta in BETAS}
ETA_MAX = {beta: float(wh_curves[beta][:, 1].max()) for beta in BETAS}
for beta in BETAS:
print(f"beta={beta:.1f}: eta_max = {ETA_MAX[beta]:8.2f} "
f"multivalued in phi: {FOLD_DEPTH[beta] > 1e-3}")
beta=0.0: eta_max = 1.00 multivalued in phi: False
beta=0.2: eta_max = 2.32 multivalued in phi: False
beta=0.4: eta_max = 10.73 multivalued in phi: True
beta=0.6: eta_max = 44.55 multivalued in phi: True
beta=0.8: eta_max = 151.22 multivalued in phi: True
Two things to notice. The \(\beta = 0\) curve is Thiele’s isothermal result. Every other curve rises above \(\eta = 1\) — the pellet outperforms its own surface conditions, because its interior is hotter; at \(\beta = 0.8\) the effectiveness factor reaches 151. And from \(\beta = 0.4\) upward the curve folds back on itself: it is no longer a function of \(\phi\).
Three steady states at one Thiele modulus
Where the curve folds, a single \(\phi\) admits three solutions. Continuation in \(\phi\) cannot pass a fold: the branch turns there, and the corrector either fails or jumps to another branch. Newton at a fixed \(\phi\) finds the middle, unstable state only from a guess already close to it, and a pseudo-time march never settles on it.
The fix costs four lines: make \(\phi^2\) an unknown, and add one algebraic equation pinning the centre concentration. Along the centre value the curve is single valued, so continuation in that parameter traces the whole S-curve, unstable branch included. This is structure S10 — a constrained/DAE system — assembled with scipy.sparse.bmat around the same pymrm operators (CentrePinned in model.py), and swept with pymrm.continuation, which predicts each point from the previous two (a secant) and checks that the corrector did not jump branches.
pellet = Pellet("sphere", n_r=N_R)
rate_m = wh_rate(BETA_M, GAMMA)
problem = CentrePinned(pellet, rate_m)
# Sweep the centre concentration from ~1 (uniform pellet) down to 1e-7 (fully
# ignited); the ignited branch lives at very small centre values. The two
# segments meet at 0.1; the second starts one point later, so that no value is
# visited twice.
YC = np.concatenate([1.0 - np.geomspace(1e-6, 0.9, 120),
np.geomspace(0.1, 1e-7, 200)[1:]])
branch, branch_x, cont = trace_branch(problem, YC)
print(f"pymrm.continuation: {cont.message}")
print(f"traced {len(branch)} points, y_centre from {branch[:,2].max():.3f}"
f" down to {branch[:,2].min():.1e}")
y_min_ignited = float(branch_x[-1][:-1].min())pymrm.continuation: Reached every parameter value
traced 319 points, y_centre from 1.000 down to 1.0e-07
The reference locates the folds: the scan brackets each turning point of \(\phi\) along the centre value and a bounded scalar minimisation refines it, so the result does not depend on the scan spacing. The probe modulus \(\phi^\ast\) is the geometric mean of the two folds. At \(\phi^\ast\) the reference states are root-found along the centre value, and the pymrm states are solved by Newton at fixed \(\phi^\ast\), each starting from the nearest point of the traced branch, the middle one included.
Y_SCAN = np.geomspace(1e-8, 0.9995, 120)
folds = locate_folds(BETA_M, GAMMA, Y_SCAN)
(phi_lo, yc_lo, _), (phi_hi, yc_hi, _) = folds
phi_star = float(np.sqrt(phi_lo * phi_hi))
roots = reference_states(BETA_M, GAMMA, phi_star, Y_SCAN)
def pymrm_states(pel, rate, phi, branch, branch_x, centres, **newton_kwargs):
"""Newton at fixed phi from the branch point nearest to each centre value."""
out = []
for y_c in centres:
k = int(np.argmin(np.abs(branch[:, 2] - y_c)))
y, eta, res = pel.solve_nonlinear(phi, rate, y_init=branch_x[k][:-1],
**newton_kwargs)
out.append((eta, float(y[0]), bool(res.success), int(res.nit)))
return out
states = pymrm_states(pellet, rate_m, phi_star, branch, branch_x, [r[1] for r in roots])
eta_ignited = roots[-1][0]
probe_dev = max(abs(s[0] - r[0]) / r[0] for s, r in zip(states, roots))
# the 900-point reference curve of the figure, and the sampled reading compared
# below: folds read off its samples, states interpolated linearly
ref_m = np.array([(q[0], q[1], v) for v in np.geomspace(1e-8, 0.9995, 900)
for q in [wh_reference(BETA_M, GAMMA, v)] if q])
def sampled_folds(curve):
"""Turning points of phi at the sample points, the two most prominent."""
phi = curve[:, 0]
turn = np.where(np.diff(np.sign(np.diff(phi))) != 0)[0] + 1
prom = [min(abs(phi[i] - phi[:i].min()), abs(phi[i] - phi[i:].max())) for i in turn]
turn = [t for _, t in sorted(zip(prom, turn), reverse=True)[:2]]
return sorted(phi[turn])
def crossings(curve, phi_target):
"""Linearly interpolated eta wherever the curve crosses phi_target."""
out = []
for i in np.where(np.diff(np.sign(curve[:, 0] - phi_target)))[0]:
f = (phi_target - curve[i, 0]) / (curve[i + 1, 0] - curve[i, 0])
out.append(curve[i, 1] + f * (curve[i + 1, 1] - curve[i, 1]))
return sorted(out)
smp_lo, smp_hi = sampled_folds(ref_m)
smp_star = float(np.sqrt(smp_lo * smp_hi))
smp_ref, smp_pym = crossings(ref_m, smp_star), crossings(branch, smp_star)
interp_dev = max(abs(a - b) / b for a, b in zip(smp_ref, smp_pym))
fig, ax = plt.subplots(figsize=(8, 5))
ax.loglog(ref_m[:, 0], ref_m[:, 1], "-", color="0.7", lw=4,
label="shooting reference")
ax.loglog(branch[:, 0], branch[:, 1], color="tab:red", lw=2,
label="pymrm continuation")
ax.axvspan(phi_lo, phi_hi, color="tab:orange", alpha=0.13, lw=0,
label="three steady states")
ax.axvline(phi_star, color="k", lw=0.9, ls="--")
for e, _ in roots:
ax.plot(phi_star, e, "o", color="k", ms=8, mfc="white", mew=2, zorder=6)
ax.set_xlabel(r"Thiele modulus, $\phi$ [-]")
ax.set_ylabel(r"effectiveness factor, $\eta$ [-]")
ax.set_title(rf"$\beta$ = {BETA_M}, $\gamma$ = {GAMMA:.0f}: the fold")
ax.legend(frameon=False, loc="upper left")
fig.tight_layout(); plt.show()
print(f"multiplicity window: {phi_lo:.4f} < phi < {phi_hi:.4f}")
print(f"\nat phi = {phi_star:.4f}, steady states found by each method:")
print(f" {'reference':>12} {'pymrm':>12} pymrm Newton at fixed phi")
for (e_r, _), (e_p, _, ok, nit) in zip(roots, states):
print(f" {e_r:12.3f} {e_p:12.3f} converged {ok} in {nit} iterations")
print(f"largest relative difference: {probe_dev:.1e}")
print(f"\nratio of the ignited to the extinguished branch: "
f"{eta_ignited/roots[0][0]:.0f}x")
multiplicity window: 0.2973 < phi < 0.5719
at phi = 0.4123, steady states found by each method:
reference pymrm pymrm Newton at fixed phi
1.173 1.173 converged True in 4 iterations
6.205 6.205 converged True in 4 iterations
44.450 44.450 converged True in 4 iterations
largest relative difference: 1.4e-05
ratio of the ignited to the extinguished branch: 38x
Why the folds are refined and the states root-found. The cell below reads the folds off the 900 sample points of the reference curve and interpolates both curves linearly at \(\phi^\ast\). The upper fold sits where the samples are sparse, so its sampled value is off by about 1e-4, and the interpolated states differ from each other by up to 3e-3 (6.207 on the reference against 6.225 on pymrm, on the middle branch), which looks like a discretisation error of that size. It is interpolation: at equal centre value, and at fixed \(\phi^\ast\) above, pymrm and the reference agree to about 1e-5.
display(pd.DataFrame({
"sampled, interpolated": [smp_lo, smp_hi, smp_star, *smp_ref, *smp_pym],
"refined, root-found, Newton": [phi_lo, phi_hi, phi_star,
*[r[0] for r in roots], *[s[0] for s in states]],
}, index=["lower fold", "upper fold", "probe phi*", "reference: extinguished",
"reference: unstable", "reference: ignited", "pymrm: extinguished",
"pymrm: unstable", "pymrm: ignited"]).round(6))| sampled, interpolated | refined, root-found, Newton | |
|---|---|---|
| lower fold | 0.297321 | 0.297321 |
| upper fold | 0.571789 | 0.571859 |
| probe phi* | 0.412317 | 0.412342 |
| reference: extinguished | 1.176668 | 1.173073 |
| reference: unstable | 6.206816 | 6.205482 |
| reference: ignited | 44.450209 | 44.449920 |
| pymrm: extinguished | 1.173360 | 1.173074 |
| pymrm: unstable | 6.225159 | 6.205397 |
| pymrm: ignited | 44.450334 | 44.450208 |
A second route: the page’s own warm-start loop
trace_branch_own traces the branch with a plain loop: each centre value starts Newton from the previous solution, and a solve that fails is skipped while the march goes on. pymrm.continuation adds a secant predictor, halves the step when a solve fails, refuses a corrector that jumps to another branch, and stops with a message instead of skipping. Here every point converges with both, and the two branches agree to the Newton tolerance.
own, own_failed = trace_branch_own(problem, YC)
own_gap_psi = float(np.max(np.abs(own[:, 0] ** 2 - branch[:, 0] ** 2)))
def branch_folds(br):
"""Turning points of phi at the traced points."""
phi = br[:, 0]
turn = np.where(np.diff(np.sign(np.diff(phi))) != 0)[0] + 1
return sorted(phi[turn])
lib_lo, lib_hi = branch_folds(branch)
own_lo, own_hi = branch_folds(own)
own_fold_gap = max(abs(own_lo - lib_lo) / lib_lo, abs(own_hi - lib_hi) / lib_hi)
print(f"own loop: {len(own)} points converged, {own_failed} failed")
print(f"largest |phi^2 (own) - phi^2 (library)| over the branch: below 1e-9 "
f"(here {own_gap_psi:.1e})")
print(f"folds at the traced points, library: {lib_lo:.6f} {lib_hi:.6f}")
print(f"folds at the traced points, own : {own_lo:.6f} {own_hi:.6f}")
print(f"folds of the reference, refined : {phi_lo:.6f} {phi_hi:.6f}")own loop: 319 points converged, 0 failed
largest |phi^2 (own) - phi^2 (library)| over the branch: below 1e-9 (here 4.0e-12)
folds at the traced points, library: 0.297321 0.571732
folds at the traced points, own : 0.297321 0.571732
folds of the reference, refined : 0.297321 0.571859
The folds read at the traced points are the extremes of a sampled curve, like the sampled reading above, so the upper one is again low by a similar amount: the sweep’s points are sparse there too. The refined reference value is the one the page reports.
Validation
Four checks, then the register of every number the page states and the defect-injection table.
# 1. Isothermal, against the exact closed form (all three geometries).
print(f"1. worst relative eta error vs exact : {iso_err:.3e}")
# 2. Grid convergence at phi = 5: second order for the scheme.
ETA5 = {"slab": np.tanh(5.0) / 5.0,
"cylinder": 2 * iv(1, 5.0) / (5.0 * iv(0, 5.0)),
"sphere": (3 / 25.0) * (5.0 / np.tanh(5.0) - 1)}
EPS = np.finfo(float).eps
def grid_order(geom, ns=(50, 100, 200, 400), nu=None, phi=5.0):
"""Observed order between the two finest grids, and the finest error over its
round-off estimate n^2 eps (the condition number of the operator times eps)."""
exact = ETA5[geom] if phi == 5.0 else {"slab": np.tanh(phi) / phi}[geom]
e = [abs(Pellet(geom, n_r=n, nu=nu).solve_linear(phi)[1] - exact) / exact
for n in ns]
return float(np.log2(e[-2] / e[-1])), e[-1] / (ns[-1] ** 2 * EPS)
ORDERS, ROUNDOFF = {}, {}
for g in GEOMS:
ORDERS[g], ROUNDOFF[g] = grid_order(g)
order_mean = float(np.mean([ORDERS[g] for g in GEOMS]))
print("2. observed order of convergence at phi=5 : "
+ ", ".join(f"{g} {ORDERS[g]:.2f}" for g in GEOMS) + f" (mean {order_mean:.2f})")
ORDER_SLAB_PHI1 = grid_order("slab", phi=1.0)[0]
print(f" slab at phi = 1 : {ORDER_SLAB_PHI1:.2f}")
print(f" finest error over its round-off estimate: at least 1000 "
f"(here {min(ROUNDOFF.values()):.1e})")
# 3. Non-isothermal: compare at EQUAL centre concentration, which is the
# parameter both methods share, so no interpolation is involved.
def vs_reference(br, beta=BETA_M):
dev_eta, dev_phi = [], []
for phi_b, eta_b, y_c in br[::7]:
q = wh_reference(beta, GAMMA, y_c)
if q:
dev_phi.append(abs(phi_b - q[0]) / q[0])
dev_eta.append(abs(eta_b - q[1]) / q[1])
return float(np.median(dev_eta)), float(np.median(dev_phi)), len(dev_eta)
med_eta, med_phi, n_cmp = vs_reference(branch)
print(f"3. vs shooting at equal y_centre, median |d.eta|/eta : {med_eta:.3e}")
print(f" median |d.phi|/phi : "
f"{med_phi:.3e} ({n_cmp} points)")
# 4. Prater relation: the ignited branch should approach the bound beta.
dT_max = BETA_M * (1.0 - y_min_ignited)
print(f"4. relative temperature rise on the ignited branch : {dT_max:.4f}"
f" (bound is beta = {BETA_M})")1. worst relative eta error vs exact : 6.464e-03
2. observed order of convergence at phi=5 : slab 2.96, cylinder 2.06, sphere 2.03 (mean 2.35)
slab at phi = 1 : 2.01
finest error over its round-off estimate: at least 1000 (here 3.6e+03)
3. vs shooting at equal y_centre, median |d.eta|/eta : 1.007e-05
median |d.phi|/phi : 7.623e-06 (46 points)
4. relative temperature rise on the ignited branch : 0.6000 (bound is beta = 0.6)
All three geometries are second order. The slab’s 2.96 at \(\phi = 5\) is pre-asymptotic: there its \(h^2\) error coefficient happens to be small, so the \(h^3\) term dominates on every grid used here (an independent solve of the same stencil in 34-digit arithmetic shows the observed order falling towards 2 as the grid is refined further). The order depends on \(\phi\): at \(\phi = 1\) the slab gives the second order printed above. The mean of the three geometries, 2.35, mixes the two orders; it is registered as a claim with the range that mixture implies.
Claims: what each number is, and how it is checked
Every number the page stands behind is registered once, in the cell below (docs/claims-design.md in the gallery repository):
- a value is a result whose size is set by physics or by the discretisation. CI holds it to a relative tolerance of the committed baseline, 5 % unless the cell says otherwise;
- a bound is a quantity whose size is not physics: a solver-tolerance gap, a convergence order, a physical limit. It is checked against its stated limits on every run, on every machine, and states its basis;
- an exact result is a count or a label, with its distance to the threshold that decided it where there is one.
Fold locations carry a relative tolerance of 1e-5 and the reference states at the probe one of 1e-3: they are refined extremes and roots of a smooth function integrated to a relative accuracy of 1e-10, identical under three BLAS kernels, and the tolerances have to be that tight for the sampled-fold row and the Arrhenius row to fail them (the extinguished state moves by only 0.5 % when the Arrhenius number rises by 2 %).
# --- isothermal --------------------------------------------------------------
claims.value("isothermal_max_rel_error_phi_le_30", iso_err_lo)
claims.value("isothermal_max_rel_error_all_phi", iso_err)
claims.value("isothermal_err_phi100_n400", ERR_100[400])
claims.value("isothermal_err_phi100_n800", ERR_100[800])
claims.value("isothermal_err_phi100_n1600", iso_err_fine)
claims.value("aris_max_spread", ARIS_SPREAD)
for g, lo_, hi_ in (("slab", 2.7, 3.1), ("cylinder", 1.9, 2.2), ("sphere", 1.9, 2.2)):
claims.bound(f"convergence_order_{g}", ORDERS[g], min=lo_, max=hi_,
basis="theory" if g != "slab" else "chosen",
note=None if g != "slab" else
"pre-asymptotic at phi = 5 (small h^2 coefficient, h^3 dominates on "
"these grids); the scheme is second order")
claims.bound("convergence_order_slab_phi1", ORDER_SLAB_PHI1, min=1.9, max=2.2,
basis="theory")
claims.bound("convergence_order", order_mean, min=2.2, max=2.5, basis="chosen",
note="mean of the slab (about 3) and cylinder and sphere (2)")
claims.bound("convergence_finest_over_roundoff", min(ROUNDOFF.values()), min=1e3,
basis="theory", note="claims-design section 3: the order is not fitted to noise")
# --- the Weisz-Hicks diagram --------------------------------------------------
claims.bound("eta_max_beta0", ETA_MAX[0.0], min=0.99, max=1.0, basis="theory",
note="isothermal first order: eta < 1, approached as phi -> 0")
for beta in BETAS[1:]:
claims.value(f"eta_max_beta{beta:.1f}".replace(".", ""), ETA_MAX[beta])
claims.count("n_multivalued_curves", [FOLD_DEPTH[b] for b in BETAS], threshold=1e-3,
min_margin=0.5, note="curves whose phi falls back by more than 0.1 %")
# --- the fold and the three steady states -------------------------------------
claims.value("phi_fold_lower", phi_lo, rtol=1e-5)
claims.value("phi_fold_upper", phi_hi, rtol=1e-5)
claims.value("phi_probe", phi_star, rtol=1e-5)
claims.exact("n_steady_states_at_probe", len(roots),
margin=min(phi_star / phi_lo - 1, 1 - phi_star / phi_hi), min_margin=0.05)
claims.exact("n_pymrm_states_converged", sum(s[2] for s in states))
for (e_r, _), (e_p, *_), name in zip(roots, states, ("extinguished", "unstable", "ignited")):
if name != "ignited":
claims.value(f"eta_{name}", e_r, rtol=1e-3)
claims.value(f"eta_{name}_pymrm", e_p)
claims.value("eta_ignited", eta_ignited, rtol=1e-3)
claims.value("ignited_over_extinguished", eta_ignited / roots[0][0])
claims.value("probe_pymrm_vs_reference_max_rel", probe_dev)
claims.value("interpolated_states_max_rel_gap", interp_dev)
claims.value("eta_unstable_interpolated_ref", smp_ref[1], rtol=1e-3)
claims.value("eta_unstable_interpolated_pymrm", smp_pym[1], rtol=1e-3)
claims.value("fold_upper_sampled_rel_error", abs(smp_hi - phi_hi) / phi_hi)
# --- continuation: library and the page's own loop ----------------------------
claims.exact("branch_points", len(branch))
claims.value("branch_fold_lower", lib_lo, rtol=1e-6)
claims.value("branch_fold_upper", lib_hi, rtol=1e-6)
claims.bound("own_vs_library_psi_gap", own_gap_psi, max=1e-9, basis="tolerance",
note="both converge each point to a Newton step below 1e-11")
claims.bound("own_vs_library_fold_gap", own_fold_gap, max=1e-8, basis="tolerance",
note="the folds at the traced points, relative")
claims.exact("own_loop_failed_points", own_failed)
# --- validation against the shooting reference ---------------------------------
claims.value("nonisothermal_median_dev", med_eta)
claims.value("nonisothermal_median_dev_phi", med_phi)
claims.exact("nonisothermal_points_compared", n_cmp)
claims.bound("prater_rise_gap", BETA_M - dT_max, min=0.0, max=1e-5, basis="chosen",
note="beta - rise = beta * y_min; the sweep ends at y_centre = 1e-7")
# --- numbers written in the Markdown above ------------------------------------
claims.says("15%", "aris_max_spread") # "differ by about 15%"
claims.says("151", "eta_max_beta08") # "reaches 151"
claims.says("above 100", "eta_max_beta08") # description: "eta exceeds 100"
claims.says("2.35", "convergence_order") # "the mean ..., 2.35"
claims.says("2.96", "convergence_order_slab") # "the slab's 2.96"
claims.says("6.225", "eta_unstable_interpolated_pymrm") # "6.207 ... against 6.225"
claims.says("6.207", "eta_unstable_interpolated_ref") # "6.207 ... against 6.225"
claims.says("2.2e-4", "isothermal_max_rel_error_phi_le_30") # What pymrm adds
claims.says("1e-4", "fold_upper_sampled_rel_error") # "off by about 10^-4"
claims.says("1e-5", "probe_pymrm_vs_reference_max_rel") # "agree to about 10^-5"
claims.says("1e-5", "nonisothermal_median_dev") # "agree to about 10^-5"
claims.says("3e-3", "interpolated_states_max_rel_gap") # "up to 3e-3"
print(f"{len(claims.metrics)} claims registered")45 claims registered
The defect-injection table
Each row breaks one thing on purpose, re-runs the computation behind one claim with the defect in place, and hands the result to claims.breaks, which records whether the claim’s own check (its tolerance or its bound) catches it. A row that moves its claim by less than the claim’s tolerance is kept, marked as not caught, when what it shows is itself a finding. Every claim must be failed by at least one row, and the last cell of the page raises otherwise.
BREAK = []
def brk(name, metric, value, note=""):
"""One injected defect: ``value`` is what claim ``metric`` becomes with it."""
caught = claims.breaks(name, metric, value)
BREAK.append(dict(defect=name, metric=metric, clean=claims[metric], broken=value,
caught=caught, note=note))
# --- isothermal: geometry and grid ------------------------------------------
num_bad = dict(num, cylinder=eta_numeric("cylinder", nu=2))
brk("cylinder assembled with nu = 2", "isothermal_max_rel_error_phi_le_30",
iso_errors(num_bad, LO))
brk("cylinder assembled with nu = 2", "isothermal_max_rel_error_all_phi", iso_errors(num_bad))
num_coarse = {g: eta_numeric(g, n_r=N_ISO // 2) for g in GEOMS}
brk("n_r halved to 100", "isothermal_max_rel_error_phi_le_30", iso_errors(num_coarse, LO))
brk("n_r halved to 100", "isothermal_max_rel_error_all_phi", iso_errors(num_coarse))
for n in (400, 800, 1600):
brk(f"phi = 100 on n_r = {n // 2} instead of {n}", f"isothermal_err_phi100_n{n}",
err_phi100(n // 2))
brk("sphere assembled with nu = 1 at phi = 100", "isothermal_err_phi100_n1600",
err_phi100(1600, nu=1))
brk("cylinder V/A taken as R/3 (the sphere's)", "aris_max_spread",
aris_spread(dict(VA, cylinder=1.0 / 3.0)))
brk("cylinder curve from nu = 2", "aris_max_spread", aris_spread(VA, num_bad))
# --- convergence orders ---------------------------------------------------------
_wrong = {"slab": 1, "cylinder": 2, "sphere": 1}
_bad_orders = {g: grid_order(g, nu=_wrong[g])[0] for g in GEOMS}
for g in GEOMS:
brk(f"{g} assembled with nu = {_wrong[g]}", f"convergence_order_{g}", _bad_orders[g],
"converges to the wrong geometry: the error stops falling")
brk("slab assembled with nu = 1, at phi = 1", "convergence_order_slab_phi1",
grid_order("slab", nu=1, phi=1.0)[0])
brk("every geometry assembled with the wrong nu", "convergence_order",
float(np.mean(list(_bad_orders.values()))))
brk("orders fitted on n_r = 3200, 6400", "convergence_finest_over_roundoff",
min(grid_order(g, ns=(3200, 6400))[1] for g in GEOMS),
"the slab's finest error is at round-off there")
# --- the Weisz-Hicks diagram ----------------------------------------------------
brk("Prater number 0.1 instead of 0", "eta_max_beta0", float(wh_curve(0.1)[:, 1].max()))
for beta in BETAS[1:]:
brk("Arrhenius number 5 % low", f"eta_max_beta{beta:.1f}".replace(".", ""),
float(wh_curve(beta, 0.95 * GAMMA)[:, 1].max()))
_depth_bad = {**FOLD_DEPTH, 0.4: fold_depth(wh_curve(0.4, gamma=12.0))}
brk("Arrhenius number 12 for the beta = 0.4 curve", "n_multivalued_curves",
int(sum(d > 1e-3 for d in _depth_bad.values())), "gamma*beta = 4.8: unique")
# --- the folds and the steady states ------------------------------------------------
_f_bad = locate_folds(BETA_M * 1.02, GAMMA, Y_SCAN)
brk("Prater number 2 % high", "phi_fold_lower", _f_bad[0][0])
brk("Prater number 2 % high", "phi_fold_upper", _f_bad[1][0])
brk("Prater number 2 % high", "phi_probe", np.sqrt(_f_bad[0][0] * _f_bad[1][0]))
brk("fold read off the 900 samples, not refined", "phi_fold_lower", smp_lo,
"the lower fold sits where the samples are dense")
brk("fold read off the 900 samples, not refined", "phi_fold_upper", smp_hi)
brk("probe at phi = 0.25, below the lower fold", "n_steady_states_at_probe",
len(reference_states(BETA_M, GAMMA, 0.25, Y_SCAN)))
_roots_bad = reference_states(BETA_M, GAMMA * 1.02, phi_star, Y_SCAN)
for (e_b, _), name in zip(_roots_bad, ("extinguished", "unstable", "ignited")):
brk("Arrhenius number 2 % high in the reference", f"eta_{name}", e_b)
brk("Arrhenius number 2 % high in the reference", "ignited_over_extinguished",
_roots_bad[-1][0] / _roots_bad[0][0])
brk("Arrhenius number 2 % high in the reference", "probe_pymrm_vs_reference_max_rel",
max(abs(s[0] - r[0]) / r[0] for s, r in zip(states, _roots_bad)))
_pel_bad = Pellet("sphere", n_r=N_R, nu=1)
_states_bad = pymrm_states(_pel_bad, rate_m, phi_star, branch, branch_x,
[r[1] for r in roots])
for (e_b, *_), name in zip(_states_bad, ("extinguished", "unstable", "ignited")):
brk("pymrm pellet assembled with nu = 1", f"eta_{name}_pymrm", e_b)
brk("Newton at fixed phi with maxfev = 2", "n_pymrm_states_converged",
sum(s[2] for s in pymrm_states(pellet, rate_m, phi_star, branch, branch_x,
[r[1] for r in roots], maxfev=2)))
_pel_25 = Pellet("sphere", n_r=25)
_br_25, _x_25, _ = trace_branch(CentrePinned(_pel_25, rate_m), YC)
_states_25 = pymrm_states(_pel_25, rate_m, phi_star, _br_25, _x_25, [r[1] for r in roots])
brk("pymrm pellet on n_r = 25", "probe_pymrm_vs_reference_max_rel",
max(abs(s[0] - r[0]) / r[0] for s, r in zip(_states_25, roots)))
_ll_ref = np.exp(crossings(np.log(ref_m[:, :2]), np.log(smp_star)))
_ll_pym = np.exp(crossings(np.log(branch[:, :2]), np.log(smp_star)))
brk("interpolation in log-log coordinates", "interpolated_states_max_rel_gap",
max(abs(a - b) / b for a, b in zip(_ll_ref, _ll_pym)))
brk("reference interpolated on every tenth sample", "eta_unstable_interpolated_ref",
min(crossings(ref_m[::10], smp_star), key=lambda e: abs(e - smp_ref[1])),
"the nearest crossing: so coarse a sample also loses one of the three")
brk("interpolation in log-log coordinates", "eta_unstable_interpolated_pymrm", _ll_pym[1])
brk("upper fold from a 300-point sample", "fold_upper_sampled_rel_error",
abs(sampled_folds(ref_m[::3])[1] - phi_hi) / phi_hi)
# --- continuation ----------------------------------------------------------------
_br_short, _x_short, _ = trace_branch(problem, YC[YC >= 1e-3])
brk("sweep stopped at y_centre = 1e-3", "branch_points", len(_br_short))
brk("sweep stopped at y_centre = 1e-3", "nonisothermal_points_compared",
vs_reference(_br_short)[2])
brk("sweep stopped at y_centre = 1e-3", "prater_rise_gap",
BETA_M * float(_x_short[-1][:-1].min()))
_problem_b = CentrePinned(pellet, wh_rate(BETA_M + 0.01, GAMMA))
_br_b, _, _ = trace_branch(_problem_b, YC)
_dev_b = vs_reference(_br_b)
brk("Prater number 0.61 in the pymrm pellet only", "nonisothermal_median_dev", _dev_b[0])
brk("Prater number 0.61 in the pymrm pellet only", "nonisothermal_median_dev_phi", _dev_b[1])
_bf_b = branch_folds(_br_b)
brk("Prater number 0.61 in the pymrm pellet only", "branch_fold_lower", _bf_b[0])
brk("Prater number 0.61 in the pymrm pellet only", "branch_fold_upper", _bf_b[1])
_own_loose, _ = trace_branch_own(problem, YC, tol=1e-4)
brk("own loop with Newton tol = 1e-4", "own_vs_library_psi_gap",
float(np.max(np.abs(_own_loose[:, 0] ** 2 - branch[:, 0] ** 2))))
_ol = branch_folds(_own_loose)
brk("own loop with Newton tol = 1e-4", "own_vs_library_fold_gap",
max(abs(_ol[0] - lib_lo) / lib_lo, abs(_ol[1] - lib_hi) / lib_hi),
"not caught: tol 1e-4 moves phi^2 by about 1e-8 (row above), too little to "
"shift which traced point is the extreme")
_ol2 = branch_folds(trace_branch_own(problem, YC[::2])[0])
brk("own loop on every second centre value", "own_vs_library_fold_gap",
max(abs(_ol2[0] - lib_lo) / lib_lo, abs(_ol2[1] - lib_hi) / lib_hi),
"the folds at traced points depend on which points are traced")
brk("own loop with maxfev = 2", "own_loop_failed_points",
trace_branch_own(problem, YC, maxfev=2)[1])
BT = pd.DataFrame(BREAK)
def _shown(metric, v):
# a bound's digits are this run's and differ between machines: shown as such
if claims.metrics[metric]["kind"] == "bound":
return f"(here {float(v):.1e})"
return f"{v:.4g}" if isinstance(v, float) else str(v)
_table = BT.assign(clean=[_shown(m, v) for m, v in zip(BT.metric, BT.clean)],
broken=[_shown(m, v) for m, v in zip(BT.metric, BT.broken)])
with pd.option_context("display.max_colwidth", 70, "display.width", 250,
"display.max_rows", 200):
display(_table[["defect", "metric", "clean", "broken", "caught", "note"]])
_caught = set(BT[BT.caught].metric)
_open = sorted(k for k in claims.metrics if k not in _caught)
print(f"{len(BT)} rows; {int(BT.caught.sum())} fail the claim they target, "
f"{len(BT) - int(BT.caught.sum())} do not (each says why in its note).")
print(f"{len(_caught)} of {len(claims.metrics)} claims are failed by at least one row.")
print("claims with no failing row:", ", ".join(_open) or "none")| defect | metric | clean | broken | caught | note | |
|---|---|---|---|---|---|---|
| 0 | cylinder assembled with nu = 2 | isothermal_max_rel_error_phi_le_30 | 0.0002248 | 0.3332 | True | |
| 1 | cylinder assembled with nu = 2 | isothermal_max_rel_error_all_phi | 0.006464 | 0.3332 | True | |
| 2 | n_r halved to 100 | isothermal_max_rel_error_phi_le_30 | 0.0002248 | 0.001397 | True | |
| 3 | n_r halved to 100 | isothermal_max_rel_error_all_phi | 0.006464 | 0.03926 | True | |
| 4 | phi = 100 on n_r = 200 instead of 400 | isothermal_err_phi100_n400 | 0.0009369 | 0.006464 | True | |
| 5 | phi = 100 on n_r = 400 instead of 800 | isothermal_err_phi100_n800 | 0.0001324 | 0.0009369 | True | |
| 6 | phi = 100 on n_r = 800 instead of 1600 | isothermal_err_phi100_n1600 | 1.933e-05 | 0.0001324 | True | |
| 7 | sphere assembled with nu = 1 at phi = 100 | isothermal_err_phi100_n1600 | 1.933e-05 | 0.005021 | True | |
| 8 | cylinder V/A taken as R/3 (the sphere's) | aris_max_spread | 0.1539 | 0.4473 | True | |
| 9 | cylinder curve from nu = 2 | aris_max_spread | 0.1539 | 0.3731 | True | |
| 10 | slab assembled with nu = 1 | convergence_order_slab | (here 3.0e+00) | (here 8.1e-05) | True | converges to the wrong geometry: the error stops falling |
| 11 | cylinder assembled with nu = 2 | convergence_order_cylinder | (here 2.1e+00) | (here 1.4e-04) | True | converges to the wrong geometry: the error stops falling |
| 12 | sphere assembled with nu = 1 | convergence_order_sphere | (here 2.0e+00) | (here -9.2e-05) | True | converges to the wrong geometry: the error stops falling |
| 13 | slab assembled with nu = 1, at phi = 1 | convergence_order_slab_phi1 | (here 2.0e+00) | (here 2.0e-06) | True | |
| 14 | every geometry assembled with the wrong nu | convergence_order | (here 2.3e+00) | (here 4.4e-05) | True | |
| 15 | orders fitted on n_r = 3200, 6400 | convergence_finest_over_roundoff | (here 3.6e+03) | (here 3.6e-03) | True | the slab's finest error is at round-off there |
| 16 | Prater number 0.1 instead of 0 | eta_max_beta0 | (here 1.0e+00) | (here 1.2e+00) | True | |
| 17 | Arrhenius number 5 % low | eta_max_beta02 | 2.323 | 2.143 | True | |
| 18 | Arrhenius number 5 % low | eta_max_beta04 | 10.73 | 8.883 | True | |
| 19 | Arrhenius number 5 % low | eta_max_beta06 | 44.55 | 33.68 | True | |
| 20 | Arrhenius number 5 % low | eta_max_beta08 | 151.2 | 106.4 | True | |
| 21 | Arrhenius number 12 for the beta = 0.4 curve | n_multivalued_curves | 3 | 2 | True | gamma*beta = 4.8: unique |
| 22 | Prater number 2 % high | phi_fold_lower | 0.2973 | 0.286 | True | |
| 23 | Prater number 2 % high | phi_fold_upper | 0.5719 | 0.5655 | True | |
| 24 | Prater number 2 % high | phi_probe | 0.4123 | 0.4022 | True | |
| 25 | fold read off the 900 samples, not refined | phi_fold_lower | 0.2973 | 0.2973 | False | the lower fold sits where the samples are dense |
| 26 | fold read off the 900 samples, not refined | phi_fold_upper | 0.5719 | 0.5718 | True | |
| 27 | probe at phi = 0.25, below the lower fold | n_steady_states_at_probe | 3 | 1 | True | |
| 28 | Arrhenius number 2 % high in the reference | eta_extinguished | 1.173 | 1.179 | True | |
| 29 | Arrhenius number 2 % high in the reference | eta_unstable | 6.205 | 5.893 | True | |
| 30 | Arrhenius number 2 % high in the reference | eta_ignited | 44.45 | 49.31 | True | |
| 31 | Arrhenius number 2 % high in the reference | ignited_over_extinguished | 37.89 | 41.84 | True | |
| 32 | Arrhenius number 2 % high in the reference | probe_pymrm_vs_reference_max_rel | 1.372e-05 | 0.09864 | True | |
| 33 | pymrm pellet assembled with nu = 1 | eta_extinguished_pymrm | 1.173 | 2.275 | True | |
| 34 | pymrm pellet assembled with nu = 1 | eta_unstable_pymrm | 6.205 | 5.442 | True | |
| 35 | pymrm pellet assembled with nu = 1 | eta_ignited_pymrm | 44.45 | 60.66 | True | |
| 36 | Newton at fixed phi with maxfev = 2 | n_pymrm_states_converged | 3 | 0 | True | |
| 37 | pymrm pellet on n_r = 25 | probe_pymrm_vs_reference_max_rel | 1.372e-05 | 0.003512 | True | |
| 38 | interpolation in log-log coordinates | interpolated_states_max_rel_gap | 0.002947 | 0.003514 | True | |
| 39 | reference interpolated on every tenth sample | eta_unstable_interpolated_ref | 6.207 | 6.224 | True | the nearest crossing: so coarse a sample also loses one of the three |
| 40 | interpolation in log-log coordinates | eta_unstable_interpolated_pymrm | 6.225 | 6.209 | True | |
| 41 | upper fold from a 300-point sample | fold_upper_sampled_rel_error | 0.0001212 | 0.003165 | True | |
| 42 | sweep stopped at y_centre = 1e-3 | branch_points | 319 | 186 | True | |
| 43 | sweep stopped at y_centre = 1e-3 | nonisothermal_points_compared | 46 | 27 | True | |
| 44 | sweep stopped at y_centre = 1e-3 | prater_rise_gap | (here 6.0e-08) | (here 6.1e-04) | True | |
| 45 | Prater number 0.61 in the pymrm pellet only | nonisothermal_median_dev | 1.007e-05 | 0.05855 | True | |
| 46 | Prater number 0.61 in the pymrm pellet only | nonisothermal_median_dev_phi | 7.623e-06 | 0.0336 | True | |
| 47 | Prater number 0.61 in the pymrm pellet only | branch_fold_lower | 0.2973 | 0.2879 | True | |
| 48 | Prater number 0.61 in the pymrm pellet only | branch_fold_upper | 0.5717 | 0.5663 | True | |
| 49 | own loop with Newton tol = 1e-4 | own_vs_library_psi_gap | (here 4.0e-12) | (here 2.4e-08) | True | |
| 50 | own loop with Newton tol = 1e-4 | own_vs_library_fold_gap | (here 2.1e-12) | (here 3.3e-12) | False | not caught: tol 1e-4 moves phi^2 by about 1e-8 (row above), too li... |
| 51 | own loop on every second centre value | own_vs_library_fold_gap | (here 2.1e-12) | (here 9.4e-04) | True | the folds at traced points depend on which points are traced |
| 52 | own loop with maxfev = 2 | own_loop_failed_points | 0 | 319 | True |
53 rows; 51 fail the claim they target, 2 do not (each says why in its note).
45 of 45 claims are failed by at least one row.
claims with no failing row: none
What the checks can and cannot do
- The isothermal claims test the operators against an exact solution, so every assembly defect that matters (wrong
nu, a coarse grid) moves them. They cannot see an error shared by the solver and the exact table, and there is none to share: the table is evaluated from the closed forms. - The non-isothermal claims compare pymrm with a shooting integration of the same equation. A defect in the model that both share (the wrong rate expression, a wrong Prater relation) would pass them; the Arrhenius and Prater rows show that the claims do move when only one side changes.
prater_rise_gapis close to an identity: once the Prater relation is substituted, the temperature rise is \(\beta(1 - y)\), so the check only says that the sweep reached the ignited branch. The row that stops the sweep early is the defect it catches.- The comparison of the library continuation with the page’s own loop is held at the Newton tolerance; it cannot tell which of the two is right, only that they agree. The reference comparison does that.
What pymrm adds
Nothing, for the isothermal case — and that is the point. Thiele’s formula is exact; the value of reproducing it is that it pins the discretisation to 2.2e-4 for \(\phi \le 30\) and confirms second-order convergence. A page that agrees with an exact solution has earned the right to be believed where no exact solution exists.
For the non-isothermal case, the whole solution branch. Weisz and Hicks could report points; the fold between them had to be inferred. Making \(\phi^2\) an unknown and pinning the centre concentration turns the multivalued curve into a single-valued one, and pymrm.continuation traces it in one sweep, unstable branch included, using the same operators as the isothermal solve. Continuation in \(\phi\) cannot get past the folds, and a pseudo-time march never settles on the unstable branch; Newton at fixed \(\phi\) reaches it from a guess close to it, here one taken off the traced branch, as the table above does.
And geometry is a parameter, not a rewrite. nu=0,1,2 covers slab, cylinder and sphere, and nu also accepts a callable for an arbitrary area profile — which is how non-standard pellet shapes are handled without leaving the 1D formulation.
Reuse
| Change | Where |
|---|---|
| Different geometry | Pellet(geom=...), or pass a callable nu to construct_div for an arbitrary area profile |
| Non-first-order kinetics | replace wh_rate; keep the rate smooth and first order through zero (pymrm pitfall P9); NumJac picks up the new derivative automatically |
| External film resistance | change the surface boundary condition from {"a":0,"b":1,"d":1} to a Robin condition |
| Multiple reactants | give the field shape (n_r, n_c); NumJac couples the last (field) axis in full by default |
| Another multiplicity problem | pin a quantity along which the branch is single-valued, make the parameter an unknown (CentrePinned), and sweep with pymrm.continuation |
| Couple to a reactor | this is the inner problem of D1.4; export eta or the surface flux |
Related pages: B1.2 Aris generalised modulus · B1.4 Weisz–Prater criterion · D1.4 fixed bed with resolved pellets · J4.7 immobilised enzyme particle (same equation, Michaelis–Menten kinetics) · A4.9 Duncan–Toor (shares S3).
References
Thiele, E. W. (1939). Relation between catalytic activity and size of particle. Industrial & Engineering Chemistry 31(7), 916–920. doi:10.1021/ie50355a027
Weisz, P. B. and Hicks, J. S. (1962). The behaviour of porous catalyst particles in view of internal mass and heat diffusion effects. Chemical Engineering Science 17(4), 265–275. doi:10.1016/0009-2509(62)85005-2
Aris, R. (1957). On shape factors for irregular particles. Chemical Engineering Science 6(6), 262–268.
Prater, C. D. (1958). The temperature produced by heat of reaction in the interior of porous particles. Chemical Engineering Science 8(3–4), 284–286.
# Claims: prints every registered number, writes agreement.json for CI, and
# raises when a claim fails its own check or has no break row.
claims.report(); # the semicolon keeps the notebook from echoing the payloadClaims for B1.1:
isothermal_max_rel_error_phi_le_30 = 0.00022483 value rtol 0.05
isothermal_max_rel_error_all_phi = 0.006464 value rtol 0.05
isothermal_err_phi100_n400 = 0.00093686 value rtol 0.05
isothermal_err_phi100_n800 = 0.00013241 value rtol 0.05
isothermal_err_phi100_n1600 = 1.9331e-05 value rtol 0.05
aris_max_spread = 0.15394 value rtol 0.05
convergence_order_slab = (here 3.0e+00) bound bound [2.7, 3.1] [chosen]
convergence_order_cylinder = (here 2.1e+00) bound bound [1.9, 2.2] [theory]
convergence_order_sphere = (here 2.0e+00) bound bound [1.9, 2.2] [theory]
convergence_order_slab_phi1 = (here 2.0e+00) bound bound [1.9, 2.2] [theory]
convergence_order = (here 2.3e+00) bound bound [2.2, 2.5] [chosen]
convergence_finest_over_roundoff = (here 3.6e+03) bound bound >= 1000 [theory]
eta_max_beta0 = (here 1.0e+00) bound bound [0.99, 1] [theory]
eta_max_beta02 = 2.3234 value rtol 0.05
eta_max_beta04 = 10.733 value rtol 0.05
eta_max_beta06 = 44.555 value rtol 0.05
eta_max_beta08 = 151.22 value rtol 0.05
n_multivalued_curves = 3 exact exact, margin 1
phi_fold_lower = 0.29732 value rtol 1e-05
phi_fold_upper = 0.57186 value rtol 1e-05
phi_probe = 0.41234 value rtol 1e-05
n_steady_states_at_probe = 3 exact exact, margin 0.279
n_pymrm_states_converged = 3 exact exact
eta_extinguished = 1.1731 value rtol 0.001
eta_extinguished_pymrm = 1.1731 value rtol 0.05
eta_unstable = 6.2055 value rtol 0.001
eta_unstable_pymrm = 6.2054 value rtol 0.05
eta_ignited_pymrm = 44.45 value rtol 0.05
eta_ignited = 44.45 value rtol 0.001
ignited_over_extinguished = 37.892 value rtol 0.05
probe_pymrm_vs_reference_max_rel = 1.3718e-05 value rtol 0.05
interpolated_states_max_rel_gap = 0.0029466 value rtol 0.05
eta_unstable_interpolated_ref = 6.2068 value rtol 0.001
eta_unstable_interpolated_pymrm = 6.2252 value rtol 0.001
fold_upper_sampled_rel_error = 0.00012125 value rtol 0.05
branch_points = 319 exact exact
branch_fold_lower = 0.29732 value rtol 1e-06
branch_fold_upper = 0.57173 value rtol 1e-06
own_vs_library_psi_gap = (here 4.0e-12) bound bound <= 1e-09 [tolerance]
own_vs_library_fold_gap = (here 2.1e-12) bound bound <= 1e-08 [tolerance]
own_loop_failed_points = 0 exact exact
nonisothermal_median_dev = 1.0072e-05 value rtol 0.05
nonisothermal_median_dev_phi = 7.6232e-06 value rtol 0.05
nonisothermal_points_compared = 46 exact exact
prater_rise_gap = (here 6.0e-08) bound bound [0, 1e-05] [chosen]