# 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 = "C2.1-xu-froment-smr" # 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 pathSteam methane reforming: the Xu-Froment intrinsic kinetics
Steam methane reforming: the Xu-Froment intrinsic kinetics
Catalog ID: C2.1 · Structures: S1 (0D/1D reaction network), S2 (plug flow with reaction) · Tier: T1
Almost every steam-reformer simulation published since 1989 uses the same three rate equations. They come from one paper, and getting them wrong is easy: the scan mangles the exponents, the reference temperature is not the same for all seven parameters, and two different activity factors float around the text. This page rebuilds them from the tables and puts them back against the measurements they were fitted to.
# Imports. The page's model.py is put on sys.path by setup().
import re
from dataclasses import replace
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import Markdown, display
from scipy.optimize import brentq
from pymrm.checks import observed_orders
from gallery_utils import Claims, cite_data, load_data, load_meta
from model import (ATOMS, R_KJ, SPECIES, STOICH, SteamReformer, XuFroment, half_ulp,
partial_pressures, reference_profile, reference_solution, round_trip)
plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3})
claims = Claims(PAGE.split("-")[0])
EPS = np.finfo(float).eps
BREAK = [] # (injected defect, claim, clean value, broken value, caught)
def brk(row, metric, broken):
"""One injected defect: ``broken`` is what claim ``metric`` becomes with it."""
caught = claims.breaks(row, metric, broken)
BREAK.append((row, metric, claims[metric], broken, caught))Background
Steam reforming is how most of the world’s hydrogen is made. Methane and steam go into rows of tubes packed with a nickel catalyst, sitting in a fired furnace at 675-1000 K and 30 bar, and come out as synthesis gas. The tubes are the most expensive part of the plant and they fail by creep, so how fast the reaction actually runs, and therefore how much heat has to cross the tube wall and where, is a materials question as much as a chemical one.
Before 1989 most published kinetics were effective rate equations: fitted on industrial-size pellets, with the (severe) internal diffusion limitation absorbed into the constants. Those cannot be transferred to a different pellet, and they cannot be combined with a diffusion model without double-counting.
Xu and Froment set out to measure the intrinsic kinetics instead, on catalyst crushed to 0.18-0.25 mm, where they show diffusion no longer limits, and to do it mechanistically rather than by curve-fitting a power law. They enumerated 11 candidate reactions, cut them down thermodynamically, built two surface mechanisms, generated 21 candidate sets of three rate equations from them, and discriminated between the sets on 220 steam-reforming runs plus 60 runs on the reverse water-gas shift and methanation.
What survived is the triangular scheme
\[ \mathrm{I}\;\;\mathrm{CH_4 + H_2O \rightleftharpoons CO + 3H_2}, \qquad \mathrm{II}\;\;\mathrm{CO + H_2O \rightleftharpoons CO_2 + H_2}, \qquad \mathrm{III}\;\;\mathrm{CH_4 + 2H_2O \rightleftharpoons CO_2 + 4H_2}, \]
with reaction III a parallel route to CO₂ rather than CO₂ being formed only through CO. That was the paper’s substantive claim, and it came from the data: the ratio \(V(\mathrm{II})\) does not extrapolate to the origin as space time goes to zero, so some CO₂ must be produced directly from methane.
The published model
Rate equations (the paper’s Eqs. 3). All three share one denominator, because all three are assumed to occur on the same active sites:
\[ r_1 = \frac{k_1}{p_{\mathrm{H_2}}^{2.5}} \left( p_{\mathrm{CH_4}} p_{\mathrm{H_2O}} - \frac{p_{\mathrm{H_2}}^{3} p_{\mathrm{CO}}}{K_1} \right) \Big/ \mathrm{DEN}^2 \]
\[ r_2 = \frac{k_2}{p_{\mathrm{H_2}}} \left( p_{\mathrm{CO}} p_{\mathrm{H_2O}} - \frac{p_{\mathrm{H_2}} p_{\mathrm{CO_2}}}{K_2} \right) \Big/ \mathrm{DEN}^2 \]
\[ r_3 = \frac{k_3}{p_{\mathrm{H_2}}^{3.5}} \left( p_{\mathrm{CH_4}} p_{\mathrm{H_2O}}^{2} - \frac{p_{\mathrm{H_2}}^{4} p_{\mathrm{CO_2}}}{K_3} \right) \Big/ \mathrm{DEN}^2 \]
\[ \mathrm{DEN} = 1 + K_{\mathrm{CO}} p_{\mathrm{CO}} + K_{\mathrm{H_2}} p_{\mathrm{H_2}} + K_{\mathrm{CH_4}} p_{\mathrm{CH_4}} + K_{\mathrm{H_2O}} \frac{p_{\mathrm{H_2O}}}{p_{\mathrm{H_2}}} \]
There is no \(K_{\mathrm{CO_2}}\) term. It was never statistically significant, from either data set, and the paper says so explicitly. Adding one is a common way to silently change the model.
Species rates (Eq. 4), for steam reforming:
\[ r_{\mathrm{CO}} = r_1 - r_2, \qquad r_{\mathrm{CO_2}} = r_2 + r_3, \qquad r_{\mathrm{CH_4}} = r_1 + r_3 \]
Reactor (Eq. 1). The kinetic runs were done in an integral fixed bed, isothermal and isobaric, integrated in the space time \(W/F^{0}_{\mathrm{CH_4}}\):
\[ \frac{\mathrm{d}x_{\mathrm{CH_4}}}{\mathrm{d}(W/F^{0}_{\mathrm{CH_4}})} = r_{\mathrm{CH_4}}, \qquad \frac{\mathrm{d}x_{\mathrm{CO_2}}}{\mathrm{d}(W/F^{0}_{\mathrm{CH_4}})} = r_{\mathrm{CO_2}}, \qquad x = 0 \;\text{at}\; W/F^{0}_{\mathrm{CH_4}} = 0 . \]
Two of the three species rates are independent, so two conversions describe the whole composition: \(x_{\mathrm{CH_4}}\), the total methane converted, and \(x_{\mathrm{CO_2}}\), the part of it that ended up as CO₂. Per mol of methane fed, CH₄ is \(1-x_{\mathrm{CH_4}}\), H₂O is \(s - x_{\mathrm{CH_4}} - x_{\mathrm{CO_2}}\), H₂ is \(h + 3x_{\mathrm{CH_4}} + x_{\mathrm{CO_2}}\), CO is \(x_{\mathrm{CH_4}} - x_{\mathrm{CO_2}}\) and CO₂ is \(x_{\mathrm{CO_2}}\), with \(s\) and \(h\) the feed ratios H₂O/CH₄ and H₂/CH₄.
| symbol | name in the code |
|---|---|
| \(x_{\mathrm{CH_4}}, x_{\mathrm{CO_2}}\) | u[:, 0], u[:, 1] of SteamReformer |
| \(W/F^{0}_{\mathrm{CH_4}}\) | tau |
| \(k_i, K_j\) | XuFroment.constants(T) |
| \(K_1, K_2, K_3\) | XuFroment.equilibrium(T) |
| \(r_1, r_2, r_3\) | XuFroment.rates(...) |
| \(s, h, p_t\) | sr, hr, p_t |
Parameters and assumptions
Assumptions: isothermal and isobaric plug flow; intrinsic kinetics (the paper demonstrates no internal limitation at 0.18-0.25 mm and calculates external transport to be negligible); ideal gas; no carbon deposition, which the thermodynamic analysis rules out at these conditions; and the catalyst held at a fixed activity level, which is how the authors corrected their space times.
Three traps in this paper. Each one silently changes the answer:
- The reference temperature is not the same for all parameters. \(T_r = 648\) K for \(k_1, k_2, k_3, K_{\mathrm{CO}}\) and \(K_{\mathrm{H_2}}\), but \(T_r = 823\) K for \(K_{\mathrm{CH_4}}\) and \(K_{\mathrm{H_2O}}\). Using one reference for all seven biases the temperature dependence (Validation, check 1, prints by how much).
- Three activity levels. Table 6 is footnoted reference activity, the partially deactivated catalyst. Multiply the rate coefficients by 1.225 for the reverse water-gas shift and methanation reference level (the curves in the paper’s Figures 4 and 5 were drawn that way), and by 2.246 for fresh catalyst. The steam-reforming data used here need neither.
- The exponents do not survive OCR. In the PDF text layer \(8.664 \times 10^{-7}\) comes out as
8.664 lo-'. Every parameter below was read from a 600 dpi render of the printed page instead, and none was repaired by inference: a misread exponent is a wrong rate constant with no outward sign. The Table 5 to Table 6 round trip in the validation section is the check on that reading.
What the paper does not give: the equilibrium constants. \(K_1\), \(K_2\) and \(K_3\) appear in the rate equations but are tabulated nowhere in it, so they must come from elsewhere. This page uses the correlations usually quoted with these kinetics (Twigg, Catalyst Handbook, 2nd ed., 1989), \(K_1 = \exp(-26830/T + 30.114)\) bar², \(K_2 = \exp(4400/T - 4.036)\) and \(K_3 = K_1 K_2\), and then tests them against the paper’s own Table 7 reaction enthalpies, which is the strongest consistency check available without importing a thermodynamic database.
par = load_data("xu-froment-1989-parameters.csv", page=PAGE)
par_txt = load_data("xu-froment-1989-parameters.csv", page=PAGE, dtype=str) # as printed
obs = load_data("xu-froment-1989-conversion.csv", page=PAGE)
obs_meta = load_meta("xu-froment-1989-conversion.csv", page=PAGE)
cond = obs_meta["conditions"]
P_T = float(cond["pressure_bar"]) # total pressure, bar (figure captions)
SR = float(cond["h2o_ch4_molar"]) # H2O/CH4 in the feed (figure captions)
HR = float(cond["h2_ch4_molar"]) # H2/CH4 in the feed (Table 1)
TEMPS = [float(T) for T in cond["temperatures_K"]] # K, the four curves
TAU_MAX = 0.45 # g_cat h / mol_CH4, beyond the last run
N_TAU = 1000 # cells in tau (Validation, check 4)
kin = XuFroment.from_table(par) # Table 6 A, Table 5 E, the correlation above
print(f"p_t = {P_T} bar, H2O/CH4 = {SR}, H2/CH4 = {HR}, T = {TEMPS} K")p_t = 10.0 bar, H2O/CH4 = 3.0, H2/CH4 = 1.25, T = [773.0, 798.0, 823.0, 848.0] K
The data
Two datasets, both extracted from the paper and both carrying a provenance sidecar.
The parameters are Tables 5 and 6 read off a 600 dpi page render. The measurements are the experimental markers of Figures 2 and 3: total methane conversion and conversion into CO₂ against space time, at the conditions printed above, at four temperatures. The smooth curves on those figures are the authors’ own model and were deliberately not extracted, so what follows compares this implementation against measurements rather than against someone else’s fit. The sidecar records the cross-figure pairing of the two digitisations, the carbon closure \(x_{\mathrm{CO_2}} \le x_{\mathrm{CH_4}}\) and one merged glyph in Figure 2 that was recorded once rather than guessed.
print(par[["symbol", "A", "E_or_dH_kJ_per_mol", "value_at_Tref", "T_ref_K"]]
.to_string(index=False))
print()
print(obs.groupby(["quantity", "temperature_K"]).size().unstack().to_string())
N_OBS = len(obs)
print(f"\n{N_OBS} measurements ({(obs.quantity == 'x_CH4').sum()} x_CH4, "
f"{(obs.quantity == 'x_CO2').sum()} x_CO2), W/F from {obs.W_F.min():.3f} to "
f"{obs.W_F.max():.3f} g_cat h/mol_CH4")
claims.value("W_F_measured_max", obs.W_F.max(), unit="g_cat h/mol_CH4",
note="the last measured space time; the computed bed ends at TAU_MAX")
_err = " ".join(obs_meta["acquisition"]["estimated_error"].split())
brk("the bed outlet TAU_MAX read as the last measurement", "W_F_measured_max", TAU_MAX)
print("digitisation error (sidecar): " + _err)
claims.value("digitisation_error_conversion",
float(re.search(r"\+/-\s*([0-9.]+) in conversion", _err).group(1)),
note="read from the data sidecar's estimated_error")
brk("regular expression matching the W/F error instead of the conversion error",
"digitisation_error_conversion",
float(re.search(r"\+/-\s*[0-9.]+ in conversion and \+/-\s*([0-9.]+) in W/F", _err).group(1)))
print(cite_data(obs_meta))symbol A E_or_dH_kJ_per_mol value_at_Tref T_ref_K
k1 4.225000e+15 240.10 0.000184 648
k2 1.955000e+06 67.13 7.558000 648
k3 1.020000e+15 243.90 0.000022 648
K_CO 8.230000e-05 -70.65 40.910000 648
K_H2 6.120000e-09 -82.90 0.029600 648
K_CH4 6.650000e-04 -38.28 0.179100 823
K_H2O 1.770000e+05 88.68 0.415200 823
temperature_K 773 798 823 848
quantity
x_CH4 6 5 13 6
x_CO2 6 5 14 6
61 measurements (30 x_CH4, 31 x_CO2), W/F from 0.128 to 0.405 g_cat h/mol_CH4
digitisation error (sidecar): +/- 0.0006 in conversion and +/- 0.0007 in W/F, dominated by placing the centre of a ~20 px glyph that overlaps a ~10 px curve, plus the tick residuals quoted above.
Xu, J.; Froment, G. F. (1989) — AIChE Journal 35(1) 88-96 — doi:10.1002/aic.690350109 — digitised from Figure 2 (total methane conversion) and Figure 3 (methane converted to CO2), journal page 89
PyMRM implementation
The reactor is pure convection with a source: \(\mathrm{d}(v\,x)/\mathrm{d}\tau
= r\) with \(v = 1\) and \(\tau = W/F^{0}_{\mathrm{CH_4}}\). That is construct_convflux_upwind followed by construct_div (nu=0), exactly as for a plug flow in space: the coordinate being a space time rather than a length changes nothing about the operators. Both are assembled once in SteamReformer._build_operators.
Boundary conditions, on the outward normal. At the inlet (\(-\tau\)) the conversions are zero, \(x = 0\): {"a": 0, "b": 1, "d": 0}. The outlet is a pure convective outflow, {"outflow": True}: what leaves is what the last cell holds, and no condition is imposed on the profile. The outlet value is read on the outlet face with compute_boundary_values (SteamReformer.outlet).
State and Jacobian. The state is (n_tau, 2), space time first and the two conversions last. The source at a cell depends only on that cell’s two conversions, so the default NumJac(shape) stencil, which couples the last axis in full, has exactly the structure of the true Jacobian: one \(2\times2\) block per cell. Nothing here needs axes_diagonals; the coupling between cells comes in analytically through the convection operator. newton starts from \(x = 0\) and solve raises unless it converges.
What first-order upwind means here. With \(v = 1\) the upwind balance of cell \(i\) is \((u_i - u_{i-1})/\Delta\tau = r(u_i)\): backward Euler in \(\tau\). Reading a measurement at an arbitrary \(\tau\) needs an interpolation, and two readings are natural: between cell centres, or between the upwind face values (each cell value placed at its downstream face). Both are first order; check 4 measures which one is closer to the exact profile.
def solve_all(k, n_tau=N_TAU, **kw):
"""The four isothermal reactors of Figures 2 and 3 for kinetics ``k``."""
return {T: SteamReformer(k, T, P_T, SR, HR, tau_max=TAU_MAX, n_tau=n_tau, **kw).solve()
for T in TEMPS}
models = solve_all(kin)
for T, m in models.items():
print(f"T = {T:.0f} K Newton iterations: {m.result.nit} "
f"backward error (here {m.backward_error():.1e})")T = 773 K Newton iterations: 4 backward error (here 1.9e-16)
T = 798 K Newton iterations: 4 backward error (here 3.1e-16)
T = 823 K Newton iterations: 4 backward error (here 1.3e-15)
T = 848 K Newton iterations: 5 backward error (here 2.2e-16)
Results
The two panels are the paper’s Figures 2 and 3 rebuilt: markers are the digitised measurements, lines are this implementation. Nothing was fitted: every parameter is Table 6 as printed.
COL = {773.0: "tab:blue", 798.0: "tab:green", 823.0: "tab:orange", 848.0: "tab:red"}
MRK = {773.0: "o", 798.0: "^", 823.0: "s", 848.0: "D"}
FIELD = {"x_CH4": 0, "x_CO2": 1}
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.4), sharex=True)
for ax, (q, lab) in zip(axes, [("x_CH4", r"$x_{\mathrm{CH_4}}$, total methane converted"),
("x_CO2", r"$x_{\mathrm{CO_2}}$, methane converted into CO$_2$")]):
for T in TEMPS:
m = models[T]
ax.plot(m.tau_c, m.u[:, FIELD[q]], color=COL[T], lw=1.6)
s = obs[(obs.quantity == q) & (obs.temperature_K == T)]
ax.plot(s.W_F, s.value, MRK[T], color=COL[T], ms=5, mfc="none", mew=1.3,
label=f"{T:.0f} K")
ax.set_xlabel(r"$W/F^{0}_{\mathrm{CH_4}}$ (g$_\mathrm{cat}$ h / mol$_{\mathrm{CH_4}}$)")
ax.set_ylabel(lab + " (-)")
ax.set_xlim(0, TAU_MAX)
ax.set_ylim(0, None)
handles, labels = axes[0].get_legend_handles_labels()
fig.legend(handles, labels, loc="lower center", ncol=4, fontsize=9, frameon=False,
title="markers: measured · lines: pymrm")
fig.suptitle("Xu & Froment (1989) Figures 2 and 3, reproduced from Table 6", fontsize=11)
fig.tight_layout(rect=(0, 0.13, 1, 1))
plt.show()
def deviations(ms, read="centre"):
"""Model minus measurement at every digitised point, in the order of ``obs``."""
return np.array([ms[r.temperature_K].at(r.W_F, FIELD[r.quantity], read=read) - r.value
for r in obs.itertuples()])
res = obs.copy()
res["dev"] = deviations(models)
res["model"] = res.value + res.dev
summary = (res.groupby(["quantity", "temperature_K"])["dev"]
.agg(n="size", MAD=lambda s: s.abs().mean(),
worst=lambda s: s.abs().max(), bias="mean").round(5))
print(summary.to_string())
print(f"\noverall: n = {len(res)} MAD = {res.dev.abs().mean():.5f} "
f"worst = {res.dev.abs().max():.5f} bias = {res.dev.mean():+.5f}") n MAD worst bias
quantity temperature_K
x_CH4 773 6 0.00170 0.00322 0.00170
798 5 0.00146 0.00387 0.00146
823 13 0.00282 0.00582 0.00279
848 6 0.00208 0.00368 0.00137
x_CO2 773 6 0.00073 0.00174 0.00030
798 5 0.00081 0.00206 0.00019
823 14 0.00153 0.00435 0.00104
848 6 0.00137 0.00390 -0.00003
overall: n = 61 MAD = 0.00172 worst = 0.00582 bias = +0.00130
Validation
Five checks. The first two test the parameters, the part of this page that a bad page render would silently corrupt; the third tests the mole bookkeeping; the fourth the discretisation and the solver against an independent integration; the fifth the model against the measurements. Every number is registered as a claim, and each check injects the defects it is meant to catch, through an argument or a parameter, and prints whether the claim’s own check fails. The break table at the end of “What pymrm adds” collects every row.
A blind spot in the obvious summary of check 1. The worst per-row round-trip deviation is a max() over seven rows, and its baseline scatter is set by the printed precision of the activation energies: E is given to 0.1 kJ/mol, and at \(T_r = 648\) K that alone is worth 0.93 % on A. So k1 and k3 carry the largest deviations in the table with nothing wrong at all, and a single-digit slip that lands below that ceiling leaves the worst deviation unchanged. A max() is only as sharp as its own baseline scatter. The page therefore also normalises each row by its own printed precision and sums the seven ratios, a statistic that every row reaches.
# 1. Table 5 -> Table 6 round trip. A = value(T_ref) exp(E / (R T_ref)) for the rate
# coefficients, and the same with dH for the adsorption constants. It tests both
# readings at once, and the split reference temperature, which is in the formula.
# The printed digits come from the CSV read as text, so the precision of every
# entry is the precision it was printed with.
ROWS = [dict(symbol=r.symbol, A=r.A, E=r.E_or_dH_kJ_per_mol, val=r.value_at_Tref,
T_ref=float(r.T_ref_K)) for r in par_txt.itertuples()]
def rt_metrics(rows, T_ref=None):
rt = round_trip(rows, T_ref=T_ref)
return (max(r["rel_pct"] for r in rt), max(r["ratio"] for r in rt),
sum(r["ratio"] for r in rt), rt)
def rows_with(symbol, key, new):
"""The printed rows with one entry of ``symbol`` replaced by the string ``new``."""
out = [dict(r) for r in ROWS]
for r in out:
if r["symbol"] == symbol:
r[key] = new
return out
RT_WORST_PCT, RT_WORST_RATIO, RT_RATIO_SUM, RT = rt_metrics(ROWS)
print("1. Table 5 -> Table 6 round trip, each row against its own printed precision")
print(f" {'symbol':6s} {'T_ref':>6s} {'A':>10s} {'A computed':>11s} {'rel %':>8s} "
f"{'bound %':>8s} {'ratio':>7s}")
for r in RT:
print(f" {r['symbol']:6s} {r['T_ref']:6.0f} {r['A']:10.4g} {r['A_computed']:11.4g} "
f"{r['rel_pct']:8.4f} {r['bound_pct']:8.4f} {r['ratio']:7.3f}")
E_SHARE = half_ulp(ROWS[0]["E"]) / (R_KJ * ROWS[0]["T_ref"]) * 100
print(f" worst deviation {RT_WORST_PCT:.4f} %; half a unit of E = {ROWS[0]['E']} kJ/mol "
f"at {ROWS[0]['T_ref']:.0f} K is {E_SHARE:.2f} % on A by itself")
print(f" worst ratio to printed precision {RT_WORST_RATIO:.2f}, sum over the seven rows "
f"{RT_RATIO_SUM:.3f}")
# The same round trip with 648 K forced on every parameter: the split reference
# temperature is not a detail one can round off.
TREF = {r["symbol"]: r["A_computed"] / r["A"] for r in round_trip(ROWS, T_ref=648.0)}
print(" with 648 K for every parameter: "
+ " ".join(f"{s} off by a factor of {TREF[s]:.3g}" for s in ("K_CH4", "K_H2O")))
claims.value("param_round_trip_worst_pct", RT_WORST_PCT, unit="%")
claims.value("param_round_trip_worst_ratio_to_precision", RT_WORST_RATIO)
claims.value("param_round_trip_ratio_sum", RT_RATIO_SUM)
claims.value("e_precision_share_pct", E_SHARE, unit="%",
note="half a unit of k1's printed E at 648 K, as a share of A")
claims.value("tref_slip_factor_K_CH4", TREF["K_CH4"])
claims.value("tref_slip_factor_K_H2O", TREF["K_H2O"])
print("\n injected transcription defects (each re-runs the round trip):")
print(f" {'injected defect':46s}{'worst %':>10}{'worst ratio':>13}{'sum ratios':>12}")
print(f" {'as printed':46s}{RT_WORST_PCT:10.4f}{RT_WORST_RATIO:13.2f}{RT_RATIO_SUM:12.3f}")
RT_DEFECTS = [("K_CO", "A", "8.32e-5"), ("k3", "A", "1.002e15"), ("K_H2O", "A", "1.71e5"),
("k2", "A", "1.965e6"), ("k1", "A", "4.226e15"), ("k1", "E", "240.2"),
("K_CH4", "val", "0.1719"), ("k1", "E", "241.1"), ("k1", "A", "4.225e14"),
("K_CH4", "T_ref", 648.0)]
for sym, key, new in RT_DEFECTS:
old = next(r[key] for r in ROWS if r["symbol"] == sym)
lab = f"{sym} {key} {old} -> {new} (rows_with('{sym}', '{key}', {new!r}))"
w, wr, sm, _ = rt_metrics(rows_with(sym, key, new))
brk(lab, "param_round_trip_worst_pct", w)
brk(lab, "param_round_trip_worst_ratio_to_precision", wr)
brk(lab, "param_round_trip_ratio_sum", sm)
print(f" {lab[:46]:46s}{w:10.4f}{wr:13.2f}{sm:12.3f}")
_sum_rows = [c for r, mt, _, _, c in BREAK if mt == "param_round_trip_ratio_sum"]
_max_rows = [c for r, mt, _, _, c in BREAK if mt == "param_round_trip_worst_pct"]
print(f" the sum fails its check for {sum(_sum_rows)} of {len(_sum_rows)} rows, the worst "
f"deviation for {sum(_max_rows)} of {len(_max_rows)}")
_r = rows_with("k1", "E", "240.10")
brk("k1 E printed as 240.10 (one more digit)", "e_precision_share_pct",
half_ulp(_r[0]["E"]) / (R_KJ * _r[0]["T_ref"]) * 100)
for sym, new in (("K_CH4", "-38.82"), ("K_H2O", "89.68")):
old = next(r["E"] for r in ROWS if r["symbol"] == sym)
f = {r["symbol"]: r["A_computed"] / r["A"]
for r in round_trip(rows_with(sym, "E", new), T_ref=648.0)}
brk(f"{sym} E {old} -> {new} (rows_with('{sym}', 'E', {new!r}))",
f"tref_slip_factor_{sym}", f[sym])1. Table 5 -> Table 6 round trip, each row against its own printed precision
symbol T_ref A A computed rel % bound % ratio
k1 648 4.225e+15 4.171e+15 1.2844 0.9671 1.328
k2 648 1.955e+06 1.949e+06 0.2900 0.1250 2.320
k3 648 1.02e+15 1.005e+15 1.4432 0.9999 1.443
K_CO 648 8.23e-05 8.253e-05 0.2761 0.1658 1.665
K_H2 648 6.12e-09 6.146e-09 0.4181 0.1914 2.184
K_CH4 823 0.000665 0.0006659 0.1403 0.1762 0.797
K_H2O 823 1.77e+05 1.765e+05 0.2574 0.3676 0.700
worst deviation 1.4432 %; half a unit of E = 240.1 kJ/mol at 648 K is 0.93 % on A by itself
worst ratio to printed precision 2.32, sum over the seven rows 10.438
with 648 K for every parameter: K_CH4 off by a factor of 0.221 K_H2O off by a factor of 33
injected transcription defects (each re-runs the round trip):
injected defect worst % worst ratio sum ratios
as printed 1.4432 2.32 10.438
K_CO A 8.23e-5 -> 8.32e-5 (rows_with('K_CO', ' 1.4432 4.90 13.670
k3 A 1.020e15 -> 1.002e15 (rows_with('k3', 'A' 1.2844 2.32 9.321
K_H2O A 1.77e5 -> 1.71e5 (rows_with('K_H2O', ' 3.2424 8.59 18.326
k2 A 1.955e6 -> 1.965e6 (rows_with('k2', 'A', 1.4432 6.39 14.504
k1 A 4.225e15 -> 4.226e15 (rows_with('k1', 'A' 1.4432 2.32 10.462
k1 E 240.1 -> 240.2 (rows_with('k1', 'E', '240 1.4432 2.32 9.694
K_CH4 val 0.1791 -> 0.1719 (rows_with('K_CH4', 3.8854 21.91 31.550
k1 E 240.1 -> 241.1 (rows_with('k1', 'E', '241 18.8496 19.49 28.601
k1 A 4.225e15 -> 4.225e14 (rows_with('k1', 'A' 887.1560 917.38 926.486
K_CH4 T_ref 823.0 -> 648.0 (rows_with('K_CH4', 77.8971 397.61 407.251
the sum fails its check for 9 of 10 rows, the worst deviation for 6 of 10
The first column of that table is the blind spot made visible: several single-digit slips leave the worst deviation where it is, and the k3 transposition, in the row that sets the maximum, moves it down. The sum of per-row ratios moves by more than its 5 % check for every injection but one, and that one is a change of one unit in the fourth significant figure of k1’s preexponential, below the printed precision, so no round trip built from these digits can resolve it. Compare the sum, not the maximum, when re-reading these tables.
# 2. Equilibrium correlation against the paper's own thermodynamics.
# d ln K / d(1/T) = -dH/R, so the correlation slopes ARE reaction enthalpies and
# can be compared with Table 7 (values at 948 K).
TABLE7 = {"I": 224.0, "II": -37.3, "III": 187.5} # kJ/mol at 948 K, the paper's Table 7
def eq_worst_pct(k):
dh = k.vant_hoff_enthalpies()
return max(abs(dh[r] - TABLE7[r]) / abs(TABLE7[r]) * 100 for r in TABLE7)
print("2. van 't Hoff slopes of K_i against Xu & Froment Table 7 (948 K)")
for r, v in kin.vant_hoff_enthalpies().items():
print(f" dH_{r:<3s} correlation {v:8.2f} paper {TABLE7[r]:8.2f} kJ/mol "
f"{abs(v - TABLE7[r]) / abs(TABLE7[r]) * 100:5.2f} %")
EQ_WORST = eq_worst_pct(kin)
claims.value("equilibrium_vs_table7_worst_pct", EQ_WORST, unit="%")
print(f" worst {EQ_WORST:.2f} %; K3 = K1 K2 closes the triangle by construction")
a1, b1, a2, b2 = kin.eq
for lab, eq in ((f"K1 slope a1 {a1:g} -> 26380 (transposition)", (26380.0, b1, a2, b2)),
(f"K2 slope a2 {a2:g} -> 4040 (transposition)", (a1, b1, 4040.0, b2)),
(f"K1 intercept b1 {b1:g} -> {b1 + 1:g} (K1 x e)", (a1, b1 + 1, a2, b2)),
(f"K2 intercept b2 {b2:g} -> {b2 + 1:g} (K2 x e)", (a1, b1, a2, b2 + 1))):
w = eq_worst_pct(replace(kin, eq=eq))
brk(lab + " (XuFroment(eq=...))", "equilibrium_vs_table7_worst_pct", w)
print(f" {lab:44s} check 2 worst {w:5.2f} %")2. van 't Hoff slopes of K_i against Xu & Froment Table 7 (948 K)
dH_I correlation 223.06 paper 224.00 kJ/mol 0.42 %
dH_II correlation -36.58 paper -37.30 kJ/mol 1.93 %
dH_III correlation 186.48 paper 187.50 kJ/mol 0.54 %
worst 1.93 %; K3 = K1 K2 closes the triangle by construction
K1 slope a1 26830 -> 26380 (transposition) check 2 worst 2.54 %
K2 slope a2 4400 -> 4040 (transposition) check 2 worst 9.95 %
K1 intercept b1 30.114 -> 31.114 (K1 x e) check 2 worst 1.93 %
K2 intercept b2 -4.036 -> -3.036 (K2 x e) check 2 worst 1.93 %
What check 2 cannot see. Only the slopes are compared. The two intercepts drop out of \(\mathrm{d}\ln K/\mathrm{d}(1/T)\) entirely, so a +1 slip in either multiplies that \(K\) by \(e\) at every temperature and check 2 does not move at all: the two intercept rows above are its blind spot, shown rather than assumed. What sees them is check 5, the comparison against the measurements.
# 3. Atom balances along the reactor. The conversions are defined so that C, H and
# O balance identically for the stoichiometry of the three reactions; this checks
# the mole bookkeeping (model.STOICH), not the solver.
def atom_gap(ms, stoich=STOICH):
"""Largest |atoms(profile) - atoms(feed)| over every cell and reactor, mol per mol CH4 fed."""
return max(float(np.abs(ATOMS @ (stoich @ m.u.T)).max()) for m in ms.values())
ATOM_GAP = atom_gap(models)
P_MIN = min(min(float(np.min(p)) for p in partial_pressures(m.u[:, 0], m.u[:, 1], P_T, SR, HR))
for m in models.values()) / P_T
print(f"3. C, H and O balance along all four reactors to (here {ATOM_GAP:.1e}) mol per mol CH4 fed")
print(f" smallest partial pressure over every cell and species: (here {P_MIN:.1e}) of p_t, positive")
claims.bound("atom_balance_worst", ATOM_GAP, max=1e-12, basis="identity",
note="atoms of STOICH @ (x_CH4, x_CO2), every cell of the four reactors")
claims.bound("min_partial_pressure_rel", P_MIN, min=0.0, basis="theory", structural=True,
note="every rate vanishes as one of its reactants does, so no pressure crosses "
"zero; the stoichiometry row below does not make one negative")
_s = STOICH.copy(); _s[2] = (3.0, 0.0)
brk("H2 formed as 3 x_CH4, no x_CO2 term (stoich row H2 = (3, 0))", "atom_balance_worst",
atom_gap(solve_all(replace(kin, stoich=_s)), _s))
_s = STOICH.copy(); _s[3] = (1.0, -2.0)
_ms = solve_all(replace(kin, stoich=_s))
brk("CO formed as x_CH4 - 2 x_CO2 (stoich row CO = (1, -2))", "min_partial_pressure_rel",
min(min(float(np.min(p)) for p in partial_pressures(m.u[:, 0], m.u[:, 1], P_T, SR, HR, _s))
for m in _ms.values()) / P_T)3. C, H and O balance along all four reactors to (here 2.2e-16) mol per mol CH4 fed
smallest partial pressure over every cell and species: (here 2.5e-06) of p_t, positive
Check 4: the discretisation, against an independent integration. The same two ODEs are integrated by scipy.integrate.solve_ivp (Radau, implicit Runge-Kutta with its own step control, at rtol 1e-12), and both routes are read at every one of the measured space times at every temperature. The routes share the rate function and nothing else: no grid, no Jacobian, no linear solver. The finite-volume profile is then refined (500 to 4000 cells) and the observed order is taken from successive differences of all the measured-point values together, with a check that the finest difference is far above round-off.
def ref_at_obs(k=kin, rtol=1e-12):
"""The Radau route at every measured point, in the order of ``obs``."""
out = np.empty(len(obs))
for T in TEMPS:
sel = (obs.temperature_K == T).to_numpy()
prof = reference_profile(k, T, obs.W_F[sel].to_numpy(), P_T, SR, HR, rtol=rtol)
out[sel] = prof[np.arange(sel.sum()), obs.quantity[sel].map(FIELD).to_numpy()]
return out
REF_OBS = ref_at_obs()
REF_TOL_GAP = float(np.abs(ref_at_obs(rtol=1e-10) - REF_OBS).max())
MODEL_OBS = res.model.to_numpy()
def disc_error(ms, read="centre"):
return float(np.abs(obs.value.to_numpy() + deviations(ms, read) - REF_OBS).max())
DISC = disc_error(models)
DISC_FACE = disc_error(models, "face")
MAD = float(res.dev.abs().mean())
print("4. finite volumes against Radau at the measured points, n_tau = "
f"{N_TAU}: max |difference| {DISC:.2e} (centre read), {DISC_FACE:.2e} (face read)")
print(f" = {DISC / MAD * 100:.1f} % of the mean deviation from the data; Radau rtol 1e-10 "
f"against 1e-12 (here {REF_TOL_GAP:.0e})")
claims.value("disc_error_max_n1000", DISC,
note="max over the measured points of |finite volumes - Radau|, centre read")
claims.value("disc_error_share_of_mad_pct", DISC / MAD * 100, unit="%")
claims.bound("radau_reference_tolerance_gap", REF_TOL_GAP, max=1e-8, basis="tolerance",
note="Radau at rtol 1e-10 against 1e-12, at the measured points")
for lab, ms, rd in (("face read instead of centre read (at(..., read='face'))", models, "face"),
("a quarter of the cells (n_tau = 250)", solve_all(kin, 250), "centre")):
brk(lab, "disc_error_max_n1000", disc_error(ms, rd))
brk(lab, "disc_error_share_of_mad_pct", disc_error(ms, rd) / MAD * 100)
brk("Radau reference at rtol 1e-5 (reference_profile(..., rtol=1e-5))",
"radau_reference_tolerance_gap", float(np.abs(ref_at_obs(rtol=1e-5) - REF_OBS).max()))
NS = (500, 1000, 2000, 4000)
LADDER = {n: solve_all(kin, n) for n in (*NS, 4001)}
OO = observed_orders(lambda n: obs.value.to_numpy() + deviations(LADDER[n]), NS)
FINEST = float(np.abs(OO["values"][-1] - OO["values"][-2]).max())
print(" refinement at the measured points: max |change| "
+ ", ".join(f"{np.abs(OO['values'][i + 1] - OO['values'][i]).max():.2e}"
for i in range(len(NS) - 1))
+ "; observed orders " + ", ".join(f"{o:.3f}" for o in OO["orders"]))
print(f" finest change {FINEST:.1e} = {FINEST / (NS[-1] * EPS):.1e} times n eps at {NS[-1]} cells")
claims.bound("grid_order_observed", OO["orders"][-1], min=0.95, max=1.05, basis="theory",
note="first-order upwind, successive differences of all measured-point values, "
"1000 to 4000 cells")
claims.bound("grid_finest_over_roundoff", FINEST / (NS[-1] * EPS), min=1e4, basis="theory",
note="finest ladder change over n eps (the upwind operator's condition grows as n)")
_oo = observed_orders(lambda n: obs.value.to_numpy() + deviations(LADDER[n], "nearest"), NS)
brk("nearest cell value instead of interpolation (at(..., read='nearest'))",
"grid_order_observed", _oo["orders"][-1])
brk("ladder whose last rung is 4001 cells after 4000", "grid_finest_over_roundoff",
float(np.abs(obs.value.to_numpy() + deviations(LADDER[4001])
- OO["values"][-1]).max()) / (4001 * EPS))
BE_WORST = max(m.backward_error() for m in models.values())
claims.bound("newton_backward_error_worst", BE_WORST, max=1e-8, basis="tolerance",
note="pymrm.checks.residual_check over the four reactors; newton backward_tol 1e-8")
brk("Newton stopped after one iteration (solve(maxfev=1, strict=False))",
"newton_backward_error_worst",
max(SteamReformer(kin, T, P_T, SR, HR, TAU_MAX, N_TAU).solve(maxfev=1, strict=False)
.backward_error() for T in TEMPS))4. finite volumes against Radau at the measured points, n_tau = 1000: max |difference| 4.14e-05 (centre read), 1.12e-04 (face read)
= 2.4 % of the mean deviation from the data; Radau rtol 1e-10 against 1e-12 (here 1e-12)
refinement at the measured points: max |change| 4.13e-05, 2.07e-05, 1.04e-05; observed orders 0.999, 0.997
finest change 1.0e-05 = 1.2e+07 times n eps at 4000 cells
The two readings of the same upwind solution differ by a factor printed above, and the centre read is the closer one, at every grid of the ladder: backward Euler places each cell value a little behind the exact profile, and reading it half a cell upstream, at the centre, cancels part of that lag. The page reads at the centres. On 1000 cells the discretisation error at the measured points is a small fraction of the deviation from the data, and below the digitisation error of the sidecar (printed under The data), so it does not decide anything that follows.
# 5. Against the measurements, and the defects only the measurements can see.
LONG = 0.28 # W/F above which the reactions approach equilibrium (bins below)
BINS = [(0.12, 0.20), (0.20, LONG), (LONG, 0.42)]
def data_metrics(dev):
"""Deviation metrics from model-minus-measurement values in the order of ``obs``."""
d = obs.assign(dev=dev)
ch4 = d[d.quantity == "x_CH4"]
out = {"mad_conversion": d.dev.abs().mean(), "max_dev_conversion": d.dev.abs().max(),
"bias_conversion": d.dev.mean(), "mad_x_ch4": ch4.dev.abs().mean(),
"mad_x_co2": d[d.quantity == "x_CO2"].dev.abs().mean(),
"mad_rel_mean_pct": d.dev.abs().mean() / d.value.mean() * 100}
for i, (lo, hi) in enumerate(BINS):
out[f"bias_x_ch4_bin{i + 1}"] = ch4[(ch4.W_F >= lo) & (ch4.W_F < hi)].dev.mean()
out["bias_short_over_long"] = out["bias_x_ch4_bin1"] / out["bias_x_ch4_bin3"]
for T in TEMPS:
s = ch4[(ch4.temperature_K == T) & (ch4.W_F >= LONG)]
out[f"bias_long_{T:.0f}K"] = s.dev.mean()
return {k: float(v) for k, v in out.items()}
DM = data_metrics(res.dev.to_numpy())
print(f"5. deviation from the {N_OBS} digitised measurements")
print(f" MAD {DM['mad_conversion']:.5f} worst {DM['max_dev_conversion']:.5f} "
f"bias {DM['bias_conversion']:+.5f} in conversion")
print(f" relative to the mean measured conversion ({obs.value.mean():.4f}): "
f"{DM['mad_rel_mean_pct']:.1f} %")
for k, v in DM.items():
claims.value(k, v, unit="%" if k.endswith("_pct") else "-")
DATA_CASES = [
(f"K1 intercept b1 {b1:g} -> {b1 + 1:g} (XuFroment(eq=...))", replace(kin, eq=(a1, b1 + 1, a2, b2))),
(f"K2 intercept b2 {b2:g} -> {b2 + 1:g} (XuFroment(eq=...))", replace(kin, eq=(a1, b1, a2, b2 + 1))),
(f"K_CO A {kin.A['K_CO']:g} -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5))", kin.edited("K_CO", A=8.32e-5)),
(f"k1 A {kin.A['k1']:g} -> 4.255e+15 (kin.edited('k1', A=4.255e15))", kin.edited("k1", A=4.255e15)),
(f"k3 A {kin.A['k3']:g} -> 1.002e+15 (kin.edited('k3', A=1.002e15))", kin.edited("k3", A=1.002e15)),
(f"K_H2 A {kin.A['K_H2']:g} -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9))", kin.edited("K_H2", A=6.21e-9)),
]
CASE_MODELS = {}
print(f"\n {'injected defect (re-solves all four reactors)':64s}{'MAD':>9}{'worst':>9}{'bias':>10}"
f"{'MAD x_CH4':>11}{'MAD x_CO2':>11}")
print(f" {'as published':64s}{DM['mad_conversion']:9.5f}{DM['max_dev_conversion']:9.5f}"
f"{DM['bias_conversion']:+10.5f}{DM['mad_x_ch4']:11.5f}{DM['mad_x_co2']:11.5f}")
for lab, k in DATA_CASES:
CASE_MODELS[lab] = solve_all(k)
b = data_metrics(deviations(CASE_MODELS[lab]))
for name in DM:
brk(lab, name, b[name])
print(f" {lab[:64]:64s}{b['mad_conversion']:9.5f}{b['max_dev_conversion']:9.5f}"
f"{b['bias_conversion']:+10.5f}{b['mad_x_ch4']:11.5f}{b['mad_x_co2']:11.5f}")
fig, ax = plt.subplots(figsize=(6.2, 4.4))
for T in TEMPS:
s = res[res.temperature_K == T]
a, c = s[s.quantity == "x_CH4"], s[s.quantity == "x_CO2"]
ax.plot(a.value, a.model, MRK[T], color=COL[T], ms=6, mfc="none", mew=1.3, label=f"{T:.0f} K")
ax.plot(c.value, c.model, MRK[T], color=COL[T], ms=6, alpha=0.45)
lim = [0, 1.05 * res.value.max()]
ax.plot(lim, lim, "k-", lw=0.8)
ax.plot(lim, [v + 0.005 for v in lim], "k--", lw=0.6)
ax.plot(lim, [v - 0.005 for v in lim], "k--", lw=0.6)
ax.set(xlim=lim, ylim=lim, xlabel="measured conversion (-)",
ylabel="pymrm, Table 6 parameters (-)",
title="Parity: open $x_{CH_4}$, filled $x_{CO_2}$; dashed $\\pm$0.005")
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()5. deviation from the 61 digitised measurements
MAD 0.00172 worst 0.00582 bias +0.00130 in conversion
relative to the mean measured conversion (0.0628): 2.7 %
injected defect (re-solves all four reactors) MAD worst bias MAD x_CH4 MAD x_CO2
as published 0.00172 0.00582 +0.00130 0.00222 0.00123
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) 0.01604 0.04721 +0.01604 0.01875 0.01342
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) 0.01479 0.04548 +0.01479 0.01366 0.01588
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) 0.00171 0.00580 +0.00129 0.00221 0.00123
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) 0.00178 0.00595 +0.00141 0.00232 0.00126
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) 0.00166 0.00569 +0.00118 0.00212 0.00122
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) 0.00171 0.00582 +0.00130 0.00222 0.00123

Both equilibrium intercepts, invisible to check 2, fail every deviation claim here. A transposed digit in an adsorption preexponential (K_CO, K_H2) is not caught by the measurements; it is caught by check 1, which is why the round trip has to be sharp enough to see it.
The activity factors quoted in Reuse are exercised here, not validated. The runs they belong to (the paper’s Figures 4 and 5, and fresh catalyst) are different chemistry that this page does not model. The rate coefficients multiplied by each factor are run against these data, the reference-level runs, so the fit can only get worse.
ACT = {}
for f, what in ((1.225, "the paper's Figures 4 and 5"), (2.246, "fresh catalyst")):
ACT[f] = data_metrics(deviations(solve_all(replace(kin, activity=f))))["mad_conversion"]
print(f" k1, k2, k3 x {f:5.3f} ({what:28s}): MAD against this page's data {ACT[f]:.5f}")
claims.value(f"mad_activity_{f:.3f}".replace(".", "p"), ACT[f],
note="exercised, not validated: these data are the reference-level runs")
_k1 = replace(kin, eq=(a1, b1 + 1, a2, b2))
for f in ACT:
brk(DATA_CASES[0][0], f"mad_activity_{f:.3f}".replace(".", "p"),
data_metrics(deviations(solve_all(replace(_k1, activity=f))))["mad_conversion"]) k1, k2, k3 x 1.225 (the paper's Figures 4 and 5 ): MAD against this page's data 0.00628
k1, k2, k3 x 2.246 (fresh catalyst ): MAD against this page's data 0.01695
What pymrm adds
The paper draws four curves through its points and stops. Two questions it leaves open can be answered by running the model rather than reading it.
None of this needs pymrm as such: the same two ODEs integrate with solve_ivp, and check 4 does exactly that. What the pymrm form buys is reuse (see Reuse).
Where does the residual come from? The deviation is not uniform: the bias in methane conversion grows with space time.
print("bias in x_CH4, by space time (all four temperatures)")
for i, (lo, hi) in enumerate(BINS):
n = int(((obs.quantity == "x_CH4") & (obs.W_F >= lo) & (obs.W_F < hi)).sum())
print(f" W/F {lo:.2f}-{hi:.2f}: n = {n:2d} bias = {DM[f'bias_x_ch4_bin{i + 1}']:+.5f}")
print(f" short over long: {DM['bias_short_over_long']:.2f}")
# How far from equilibrium is the gas? beta_i = Q_i / K_i: 1 at equilibrium, where
# the rate of reaction i vanishes.
print("\napproach to equilibrium at 848 K (beta = Q/K; 1 = equilibrated)")
RS = {T: reference_solution(kin, T, TAU_MAX, P_T, SR, HR) for T in TEMPS}
def beta_ref(k, sol, T, tau):
"""beta_I, beta_II, beta_III on the Radau dense solution ``sol`` at space time ``tau``."""
x = sol(tau)
return np.array(k.beta(x[0], x[1], T, P_T, SR, HR))
for tau in (0.13, 0.20, 0.28, 0.35):
b = beta_ref(kin, RS[848.0], 848.0, tau)
print(f" tau = {tau:.2f}: I {b[0]:.3f} II {b[1]:.3f} III {b[2]:.3f}")
b = beta_ref(kin, RS[848.0], 848.0, TAU_MAX)
print(f" outlet {TAU_MAX:.2f}: I {b[0]:.3f} II {b[1]:.3f} III {b[2]:.3f}")
def beta_long(ms, k):
"""beta_I and beta_III at every measured x_CH4 point with W/F >= LONG."""
s = obs[(obs.quantity == "x_CH4") & (obs.W_F >= LONG)]
return np.array([ms[r.temperature_K].beta(r.W_F) for r in s.itertuples()])
BL = beta_long(models, kin)
print(f"\nat the measured points with W/F >= {LONG}: beta_I {BL[:, 0].min():.2f} to "
f"{BL[:, 0].max():.2f}, beta_III {BL[:, 2].min():.2f} to {BL[:, 2].max():.2f}")
claims.value("beta_I_long_min", BL[:, 0].min())
claims.value("beta_I_long_max", BL[:, 0].max())
claims.value("beta_III_long_min", BL[:, 2].min())
claims.value("beta_III_long_max", BL[:, 2].max())
_bl = beta_long(CASE_MODELS[DATA_CASES[0][0]], DATA_CASES[0][1])
for j, name in ((0, "beta_I_long"), (2, "beta_III_long")):
brk(DATA_CASES[0][0], f"{name}_min", _bl[:, j].min())
brk(DATA_CASES[0][0], f"{name}_max", _bl[:, j].max())
# Sensitivity: K1, K2 and K3 each multiplied by 0.95 and 1.05, at the same
# measured long-space-time points the bias is taken over, on the same grid.
def k_halfspan(k, shift=0.05):
out = {}
for T in TEMPS:
s = obs[(obs.quantity == "x_CH4") & (obs.W_F >= LONG) & (obs.temperature_K == T)]
lo, hi = (SteamReformer(replace(k, eq_factor=f), T, P_T, SR, HR, TAU_MAX, N_TAU)
.solve().at(s.W_F.to_numpy(), 0) for f in (1 - shift, 1 + shift))
out[T] = float(np.mean(np.abs(hi - lo)) / 2)
return out
HS = k_halfspan(kin)
print(f"\nsensitivity of x_CH4 to a 5 % shift in every K_i, at the measured points with "
f"W/F >= {LONG}, against the bias there")
for T in TEMPS:
n = int(((obs.quantity == "x_CH4") & (obs.W_F >= LONG) & (obs.temperature_K == T)).sum())
print(f" {T:.0f} K (n = {n:2d}): |half-span| {HS[T]:.4f} observed bias "
f"{DM[f'bias_long_{T:.0f}K']:+.4f} ratio {HS[T] / DM[f'bias_long_{T:.0f}K']:.2f}")
claims.value(f"k_halfspan_long_{T:.0f}K", HS[T])
RATIO = {T: HS[T] / DM[f"bias_long_{T:.0f}K"] for T in TEMPS}
for T in TEMPS:
claims.value(f"k_halfspan_bias_ratio_{T:.0f}K", RATIO[T],
note="half-span of a 5 % shift in every K_i over the observed bias, W/F >= LONG")
# the wording below holds only while the hot pair is comparable and the cold pair is not
HOT, COLD = (823.0, 848.0), (773.0, 798.0)
claims.bound("k_halfspan_ratio_hot_min", min(RATIO[T] for T in HOT), min=0.5, basis="chosen",
note="'the same size' at 823 and 848 K: ratio within a factor of two of 1")
claims.bound("k_halfspan_ratio_hot_max", max(RATIO[T] for T in HOT), max=2.0, basis="chosen",
note="'the same size' at 823 and 848 K: ratio within a factor of two of 1")
claims.bound("k_halfspan_ratio_cold_max", max(RATIO[T] for T in COLD), max=0.5, basis="chosen",
note="at 773 and 798 K the shift is under half the bias, so the argument fails")
for lab, k in DATA_CASES[:2]:
hb = k_halfspan(k)
for T in TEMPS:
brk(lab, f"k_halfspan_long_{T:.0f}K", hb[T])
bb = data_metrics(deviations(CASE_MODELS[lab]))
rb = {T: hb[T] / bb[f"bias_long_{T:.0f}K"] for T in TEMPS}
for T in TEMPS:
brk(lab, f"k_halfspan_bias_ratio_{T:.0f}K", rb[T])
brk(lab, "k_halfspan_ratio_hot_min", min(rb[T] for T in HOT))
brk(lab, "k_halfspan_ratio_hot_max", max(rb[T] for T in HOT))
brk(lab, "k_halfspan_ratio_cold_max", max(rb[T] for T in COLD))
_h25 = k_halfspan(kin, 0.25)
brk("a 25 % shift in every K_i instead of 5 % (k_halfspan(k, shift=0.25))",
"k_halfspan_ratio_hot_max", max(_h25[T] / DM[f"bias_long_{T:.0f}K"] for T in HOT))
brk("a 25 % shift in every K_i instead of 5 % (k_halfspan(k, shift=0.25))",
"k_halfspan_ratio_cold_max", max(_h25[T] / DM[f"bias_long_{T:.0f}K"] for T in COLD))bias in x_CH4, by space time (all four temperatures)
W/F 0.12-0.20: n = 11 bias = +0.00116
W/F 0.20-0.28: n = 8 bias = +0.00174
W/F 0.28-0.42: n = 11 bias = +0.00321
short over long: 0.36
approach to equilibrium at 848 K (beta = Q/K; 1 = equilibrated)
tau = 0.13: I 0.514 II 0.826 III 0.424
tau = 0.20: I 0.709 II 0.920 III 0.653
tau = 0.28: I 0.851 II 0.965 III 0.821
tau = 0.35: I 0.920 II 0.982 III 0.903
outlet 0.45: I 0.968 II 0.993 III 0.961
at the measured points with W/F >= 0.28: beta_I 0.57 to 0.90, beta_III 0.45 to 0.88
sensitivity of x_CH4 to a 5 % shift in every K_i, at the measured points with W/F >= 0.28, against the bias there
773 K (n = 2): |half-span| 0.0005 observed bias +0.0028 ratio 0.19
798 K (n = 1): |half-span| 0.0014 observed bias +0.0039 ratio 0.36
823 K (n = 6): |half-span| 0.0025 observed bias +0.0035 ratio 0.71
848 K (n = 2): |half-span| 0.0035 observed bias +0.0023 ratio 1.54
display(Markdown(
f"A 5 % shift in the equilibrium constants moves the long-space-time predictions by "
f"{HS[823.0]:.4f} at 823 K and {HS[848.0]:.4f} at 848 K, against observed biases of "
f"{DM['bias_long_823K']:+.4f} and {DM['bias_long_848K']:+.4f} there (ratios "
f"{RATIO[823.0]:.2f} and {RATIO[848.0]:.2f}): the same size. Xu and Froment do not report "
f"the $K_i$ they used, so at those two temperatures the residual at long space time is "
f"inside the uncertainty of an ingredient the paper does not supply, and cannot be "
f"charged to the kinetics. **At 773 and 798 K that argument fails.** There the gas is further from "
f"equilibrium ($\\beta_\\mathrm{{I}}$ down to {BL[:, 0].min():.2f} at the measured points), "
f"the same shift moves the prediction by only {HS[773.0]:.4f} and {HS[798.0]:.4f}, "
f"{RATIO[773.0]:.2f} and {RATIO[798.0]:.2f} of the biases ({DM['bias_long_773K']:+.4f} and "
f"{DM['bias_long_798K']:+.4f}), and the equilibrium constants do not explain the residual. "
f"At short space time the bias is {DM['bias_short_over_long']:.2f} of its long-space-time "
f"value."))A 5 % shift in the equilibrium constants moves the long-space-time predictions by 0.0025 at 823 K and 0.0035 at 848 K, against observed biases of +0.0035 and +0.0023 there (ratios 0.71 and 1.54): the same size. Xu and Froment do not report the \(K_i\) they used, so at those two temperatures the residual at long space time is inside the uncertainty of an ingredient the paper does not supply, and cannot be charged to the kinetics. At 773 and 798 K that argument fails. There the gas is further from equilibrium (\(\beta_\mathrm{I}\) down to 0.57 at the measured points), the same shift moves the prediction by only 0.0005 and 0.0014, 0.19 and 0.36 of the biases (+0.0028 and +0.0039), and the equilibrium constants do not explain the residual. At short space time the bias is 0.36 of its long-space-time value.
Where does each reaction stop being kinetically controlled? The \(\beta\) values split the reactor into regimes: the water-gas shift equilibrates first and well before the reforming reactions do, which is the behaviour the paper asserts from its \(V(\mathrm{II})\) plot but never shows against space time. Below, the same thing across all four temperatures; the space time at which each reaction reaches \(\beta = 0.90\) is root-found on the Radau reference profile (dense output), so it carries no grid error.
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.2), sharey=True)
tau = np.linspace(0.005, TAU_MAX, 200)
for T in TEMPS:
b = np.array(models[T].beta(tau)).T
axes[0].plot(tau, b[:, 0], color=COL[T], lw=1.6, label=f"{T:.0f} K")
axes[0].plot(tau, b[:, 2], color=COL[T], lw=1.0, ls=":")
axes[1].plot(tau, b[:, 1], color=COL[T], lw=1.6, label=f"{T:.0f} K")
for ax, ttl in zip(axes, ["reforming: I (solid), III (dotted)", "water-gas shift: II"]):
ax.axhline(1.0, color="k", lw=0.8)
ax.axhline(0.90, color="k", lw=0.6, ls="--")
ax.set(xlabel=r"$W/F^{0}_{\mathrm{CH_4}}$ (g$_\mathrm{cat}$ h / mol$_{\mathrm{CH_4}}$)",
xlim=(0, TAU_MAX), ylim=(0, 1.05), title=ttl)
axes[0].set_ylabel(r"$\beta = Q/K$ (-), 1 = equilibrated")
axes[0].legend(fontsize=8, loc="lower right")
fig.suptitle("Where each reaction stops being kinetically controlled", fontsize=11)
fig.tight_layout()
plt.show()
def tau90(k, sol, T, i):
"""Space time at which beta_i reaches 0.90 on the Radau dense solution ``sol``;
None if the bed ends first."""
f = lambda t: beta_ref(k, sol, T, t)[i] - 0.90 # noqa: E731
return brentq(f, models[T].tau_c[0], TAU_MAX, xtol=1e-12) if f(TAU_MAX) > 0 else None
def tau90_fv(m, i):
"""The same crossing root-found on the finite-volume profile of model ``m``."""
f = lambda t: m.beta(t)[i] - 0.90 # noqa: E731
return brentq(f, m.tau_c[0], TAU_MAX, xtol=1e-12) if f(TAU_MAX) > 0 else None
def beta_outlet(k, sol, T):
return beta_ref(k, sol, T, TAU_MAX)
BO = {T: beta_outlet(kin, RS[T], T) for T in TEMPS}
N90 = claims.count("tau90_crossings_in_bed", [BO[T][i] for T in TEMPS for i in range(3)],
0.90, min_margin=0.005,
note="(T, reaction) pairs with beta > 0.90 at the outlet")
TAU90 = {(T, i): tau90(kin, RS[T], T, i) for T in TEMPS for i in range(3)}
print(f"space time (g_cat h/mol_CH4) at which beta reaches 0.90, and beta at the outlet "
f"tau = {TAU_MAX}; {N90} of 12 crossings lie in the bed")
for T in TEMPS:
hit = [f"{TAU90[T, i]:.3f}" if TAU90[T, i] is not None else ">0.45" for i in range(3)]
print(f" {T:.0f} K: I {hit[0]:6s} II {hit[1]:6s} III {hit[2]:6s} | beta at the outlet "
f"{BO[T][0]:.3f}, {BO[T][1]:.3f}, {BO[T][2]:.3f} beta_II - beta_I {BO[T][1] - BO[T][0]:+.3f}")
for T in (823.0, 848.0):
print(f" {T:.0f} K: the shift reaches 0.90 at {TAU90[T, 1] / TAU90[T, 0]:.2f} of the space "
"time reforming (I) needs")
GAP = [BO[T][1] - BO[T][0] for T in TEMPS]
claims.value("beta_gap_outlet_min", min(GAP))
claims.value("beta_gap_outlet_max", max(GAP))
for T in (823.0, 848.0):
claims.value(f"tau90_ratio_II_I_{T:.0f}K", TAU90[T, 1] / TAU90[T, 0], rtol=1e-3)
for (T, i) in ((798.0, 1), (823.0, 0), (823.0, 1), (848.0, 0), (848.0, 1), (848.0, 2)):
claims.value(f"tau90_{('I', 'II', 'III')[i]}_{T:.0f}K", TAU90[T, i], rtol=1e-3)
# The crossings above are root-found on the Radau profile; the finite-volume
# profile runs late by its first-order grid error. The largest gap, over the
# crossings that lie in the bed, is bounded by the grid error measured above.
CROSS = [(T, i) for (T, i) in TAU90 if TAU90[T, i] is not None]
FV_GAP = max(abs(tau90_fv(models[T], i) - TAU90[T, i]) for T, i in CROSS)
print(f"largest gap between the finite-volume ({N_TAU} cells) and the Radau crossing: {FV_GAP:.1e}")
claims.bound("beta90_fv_vs_radau_max", FV_GAP, max=1e-3, basis="theory",
note="max over the in-bed crossings of |finite volumes - Radau|, first-order grid error")
_coarse = solve_all(kin, 100)
brk("a tenth of the cells (n_tau = 100), crossings root-found on the finite-volume profile",
"beta90_fv_vs_radau_max",
max(abs(tau90_fv(_coarse[T], i) - TAU90[T, i]) for T, i in CROSS))
_lab, _k = DATA_CASES[1] # the K2 intercept: the shift's equilibrium moves
_rs = {T: reference_solution(_k, T, TAU_MAX, P_T, SR, HR) for T in TEMPS}
_bo = {T: beta_outlet(_k, _rs[T], T) for T in TEMPS}
_gap = [_bo[T][1] - _bo[T][0] for T in TEMPS]
brk(_lab, "beta_gap_outlet_min", min(_gap))
brk(_lab, "beta_gap_outlet_max", max(_gap))
brk(_lab, "tau90_crossings_in_bed", int(sum(_bo[T][i] > 0.90 for T in TEMPS for i in range(3))))
for T in (823.0, 848.0):
t2, t1 = tau90(_k, _rs[T], T, 1), tau90(_k, _rs[T], T, 0)
brk(_lab, f"tau90_ratio_II_I_{T:.0f}K", np.nan if t2 is None or t1 is None else t2 / t1)
for (T, i) in ((798.0, 1), (823.0, 0), (823.0, 1), (848.0, 0), (848.0, 1), (848.0, 2)):
t = tau90(_k, _rs[T], T, i)
brk(_lab, f"tau90_{('I', 'II', 'III')[i]}_{T:.0f}K", np.nan if t is None else t)
space time (g_cat h/mol_CH4) at which beta reaches 0.90, and beta at the outlet tau = 0.45; 6 of 12 crossings lie in the bed
773 K: I >0.45 II >0.45 III >0.45 | beta at the outlet 0.694, 0.872, 0.605 beta_II - beta_I +0.177
798 K: I >0.45 II 0.343 III >0.45 | beta at the outlet 0.813, 0.941, 0.765 beta_II - beta_I +0.129
823 K: I 0.430 II 0.240 III >0.45 | beta at the outlet 0.912, 0.978, 0.892 beta_II - beta_I +0.066
848 K: I 0.326 II 0.179 III 0.346 | beta at the outlet 0.968, 0.993, 0.961 beta_II - beta_I +0.025
823 K: the shift reaches 0.90 at 0.56 of the space time reforming (I) needs
848 K: the shift reaches 0.90 at 0.55 of the space time reforming (I) needs
largest gap between the finite-volume (1000 cells) and the Radau crossing: 4.9e-04
The break table
Every number above is registered as a claim; each row injects one defect through an argument or a parameter and reports what the claim becomes, and whether the claim’s own check catches it. A row that does not catch is kept when it shows a check’s blind spot (the intercepts in check 2, the adsorption preexponentials in check 5, the fourth-figure slip in check 1).
print(f"{'injected defect':<70} {'claim':<42} {'clean':>11} {'broken':>11} caught")
for row, metric, clean, broken, caught in BREAK:
if claims.metrics[metric]["kind"] == "bound":
shown, br = f"(here {clean:.2g})", f"{float(broken):.3g}"
else:
shown, br = f"{float(clean):11.5g}", f"{float(broken):11.5g}"
print(f"{row[:70]:<70} {metric[:42]:<42} {shown:>11} {br:>11} {'yes' if caught else 'NO'}")
_caught = {mt for _, mt, _, _, c in BREAK if c}
_open = sorted(k for k in claims.metrics if k not in _caught and not claims.metrics[k].get("structural"))
_struct = sorted(k for k in claims.metrics if claims.metrics[k].get("structural"))
print(f"\n{len(BREAK)} rows; {len(_caught)} of {len(claims.metrics)} claims are failed by at least "
f"one row; structural: {', '.join(_struct) or 'none'}; open: {', '.join(_open) or 'none'}")injected defect claim clean broken caught
the bed outlet TAU_MAX read as the last measurement W_F_measured_max 0.4046 0.45 yes
regular expression matching the W/F error instead of the conversion er digitisation_error_conversion 0.0006 0.0007 yes
K_CO A 8.23e-5 -> 8.32e-5 (rows_with('K_CO', 'A', '8.32e-5')) param_round_trip_worst_pct 1.4432 1.4432 NO
K_CO A 8.23e-5 -> 8.32e-5 (rows_with('K_CO', 'A', '8.32e-5')) param_round_trip_worst_ratio_to_precision 2.3203 4.8973 yes
K_CO A 8.23e-5 -> 8.32e-5 (rows_with('K_CO', 'A', '8.32e-5')) param_round_trip_ratio_sum 10.438 13.67 yes
k3 A 1.020e15 -> 1.002e15 (rows_with('k3', 'A', '1.002e15')) param_round_trip_worst_pct 1.4432 1.2844 yes
k3 A 1.020e15 -> 1.002e15 (rows_with('k3', 'A', '1.002e15')) param_round_trip_worst_ratio_to_precision 2.3203 2.3203 NO
k3 A 1.020e15 -> 1.002e15 (rows_with('k3', 'A', '1.002e15')) param_round_trip_ratio_sum 10.438 9.3214 yes
K_H2O A 1.77e5 -> 1.71e5 (rows_with('K_H2O', 'A', '1.71e5')) param_round_trip_worst_pct 1.4432 3.2424 yes
K_H2O A 1.77e5 -> 1.71e5 (rows_with('K_H2O', 'A', '1.71e5')) param_round_trip_worst_ratio_to_precision 2.3203 8.5888 yes
K_H2O A 1.77e5 -> 1.71e5 (rows_with('K_H2O', 'A', '1.71e5')) param_round_trip_ratio_sum 10.438 18.326 yes
k2 A 1.955e6 -> 1.965e6 (rows_with('k2', 'A', '1.965e6')) param_round_trip_worst_pct 1.4432 1.4432 NO
k2 A 1.955e6 -> 1.965e6 (rows_with('k2', 'A', '1.965e6')) param_round_trip_worst_ratio_to_precision 2.3203 6.3864 yes
k2 A 1.955e6 -> 1.965e6 (rows_with('k2', 'A', '1.965e6')) param_round_trip_ratio_sum 10.438 14.504 yes
k1 A 4.225e15 -> 4.226e15 (rows_with('k1', 'A', '4.226e15')) param_round_trip_worst_pct 1.4432 1.4432 NO
k1 A 4.225e15 -> 4.226e15 (rows_with('k1', 'A', '4.226e15')) param_round_trip_worst_ratio_to_precision 2.3203 2.3203 NO
k1 A 4.225e15 -> 4.226e15 (rows_with('k1', 'A', '4.226e15')) param_round_trip_ratio_sum 10.438 10.462 NO
k1 E 240.1 -> 240.2 (rows_with('k1', 'E', '240.2')) param_round_trip_worst_pct 1.4432 1.4432 NO
k1 E 240.1 -> 240.2 (rows_with('k1', 'E', '240.2')) param_round_trip_worst_ratio_to_precision 2.3203 2.3203 NO
k1 E 240.1 -> 240.2 (rows_with('k1', 'E', '240.2')) param_round_trip_ratio_sum 10.438 9.6939 yes
K_CH4 val 0.1791 -> 0.1719 (rows_with('K_CH4', 'val', '0.1719')) param_round_trip_worst_pct 1.4432 3.8854 yes
K_CH4 val 0.1791 -> 0.1719 (rows_with('K_CH4', 'val', '0.1719')) param_round_trip_worst_ratio_to_precision 2.3203 21.908 yes
K_CH4 val 0.1791 -> 0.1719 (rows_with('K_CH4', 'val', '0.1719')) param_round_trip_ratio_sum 10.438 31.55 yes
k1 E 240.1 -> 241.1 (rows_with('k1', 'E', '241.1')) param_round_trip_worst_pct 1.4432 18.85 yes
k1 E 240.1 -> 241.1 (rows_with('k1', 'E', '241.1')) param_round_trip_worst_ratio_to_precision 2.3203 19.492 yes
k1 E 240.1 -> 241.1 (rows_with('k1', 'E', '241.1')) param_round_trip_ratio_sum 10.438 28.601 yes
k1 A 4.225e15 -> 4.225e14 (rows_with('k1', 'A', '4.225e14')) param_round_trip_worst_pct 1.4432 887.16 yes
k1 A 4.225e15 -> 4.225e14 (rows_with('k1', 'A', '4.225e14')) param_round_trip_worst_ratio_to_precision 2.3203 917.38 yes
k1 A 4.225e15 -> 4.225e14 (rows_with('k1', 'A', '4.225e14')) param_round_trip_ratio_sum 10.438 926.49 yes
K_CH4 T_ref 823.0 -> 648.0 (rows_with('K_CH4', 'T_ref', 648.0)) param_round_trip_worst_pct 1.4432 77.897 yes
K_CH4 T_ref 823.0 -> 648.0 (rows_with('K_CH4', 'T_ref', 648.0)) param_round_trip_worst_ratio_to_precision 2.3203 397.61 yes
K_CH4 T_ref 823.0 -> 648.0 (rows_with('K_CH4', 'T_ref', 648.0)) param_round_trip_ratio_sum 10.438 407.25 yes
k1 E printed as 240.10 (one more digit) e_precision_share_pct 0.92808 0.092808 yes
K_CH4 E -38.28 -> -38.82 (rows_with('K_CH4', 'E', '-38.82')) tref_slip_factor_K_CH4 0.22103 0.19995 yes
K_H2O E 88.68 -> 89.68 (rows_with('K_H2O', 'E', '89.68')) tref_slip_factor_K_H2O 33.033 39.771 yes
K1 slope a1 26830 -> 26380 (transposition) (XuFroment(eq=...)) equilibrium_vs_table7_worst_pct 1.926 2.5377 yes
K2 slope a2 4400 -> 4040 (transposition) (XuFroment(eq=...)) equilibrium_vs_table7_worst_pct 1.926 9.9502 yes
K1 intercept b1 30.114 -> 31.114 (K1 x e) (XuFroment(eq=...)) equilibrium_vs_table7_worst_pct 1.926 1.926 NO
K2 intercept b2 -4.036 -> -3.036 (K2 x e) (XuFroment(eq=...)) equilibrium_vs_table7_worst_pct 1.926 1.926 NO
H2 formed as 3 x_CH4, no x_CO2 term (stoich row H2 = (3, 0)) atom_balance_worst (here 2.2e-16) 0.266 yes
CO formed as x_CH4 - 2 x_CO2 (stoich row CO = (1, -2)) min_partial_pressure_rel (here 2.5e-06) 2.45e-06 NO
face read instead of centre read (at(..., read='face')) disc_error_max_n1000 4.1404e-05 0.00011175 yes
face read instead of centre read (at(..., read='face')) disc_error_share_of_mad_pct 2.4142 6.5158 yes
a quarter of the cells (n_tau = 250) disc_error_max_n1000 4.1404e-05 0.00016504 yes
a quarter of the cells (n_tau = 250) disc_error_share_of_mad_pct 2.4142 9.6228 yes
Radau reference at rtol 1e-5 (reference_profile(..., rtol=1e-5)) radau_reference_tolerance_gap (here 1.1e-12) 7.37e-08 yes
nearest cell value instead of interpolation (at(..., read='nearest')) grid_order_observed (here 1) 0.506 yes
ladder whose last rung is 4001 cells after 4000 grid_finest_over_roundoff (here 1.2e+07) 3.66e+03 yes
Newton stopped after one iteration (solve(maxfev=1, strict=False)) newton_backward_error_worst (here 1.3e-15) 0.000933 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) mad_conversion 0.0017151 0.016043 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) max_dev_conversion 0.005817 0.047213 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_conversion 0.0012962 0.016043 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) mad_x_ch4 0.0022188 0.018748 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) mad_x_co2 0.0012276 0.013425 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) mad_rel_mean_pct 2.7301 25.537 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_x_ch4_bin1 0.0011628 0.010767 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_x_ch4_bin2 0.001736 0.016706 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_x_ch4_bin3 0.0032074 0.028214 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_short_over_long 0.36253 0.38161 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_long_773K 0.0027918 0.0085104 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_long_798K 0.0038698 0.019027 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_long_823K 0.0035402 0.031405 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) bias_long_848K 0.0022932 0.042938 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) mad_conversion 0.0017151 0.014787 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) max_dev_conversion 0.005817 0.045476 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_conversion 0.0012962 0.014787 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) mad_x_ch4 0.0022188 0.013662 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) mad_x_co2 0.0012276 0.015876 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) mad_rel_mean_pct 2.7301 23.539 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_x_ch4_bin1 0.0011628 0.0067588 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_x_ch4_bin2 0.001736 0.01182 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_x_ch4_bin3 0.0032074 0.021905 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_short_over_long 0.36253 0.30855 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_long_773K 0.0027918 0.0061777 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_long_798K 0.0038698 0.01464 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_long_823K 0.0035402 0.024677 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) bias_long_848K 0.0022932 0.032948 yes
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) mad_conversion 0.0017151 0.0017103 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) max_dev_conversion 0.005817 0.0058046 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_conversion 0.0012962 0.0012865 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) mad_x_ch4 0.0022188 0.0022108 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) mad_x_co2 0.0012276 0.0012259 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) mad_rel_mean_pct 2.7301 2.7224 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_x_ch4_bin1 0.0011628 0.001152 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_x_ch4_bin2 0.001736 0.0017255 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_x_ch4_bin3 0.0032074 0.0031977 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_short_over_long 0.36253 0.36025 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_long_773K 0.0027918 0.0027897 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_long_798K 0.0038698 0.0038634 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_long_823K 0.0035402 0.0035292 NO
K_CO A 8.23e-05 -> 8.32e-05 (kin.edited('K_CO', A=8.32e-5)) bias_long_848K 0.0022932 0.0022782 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) mad_conversion 0.0017151 0.0017809 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) max_dev_conversion 0.005817 0.0059506 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_conversion 0.0012962 0.0014103 yes
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) mad_x_ch4 0.0022188 0.002317 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) mad_x_co2 0.0012276 0.0012621 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) mad_rel_mean_pct 2.7301 2.8349 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_x_ch4_bin1 0.0011628 0.0013087 yes
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_x_ch4_bin2 0.001736 0.0018601 yes
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_x_ch4_bin3 0.0032074 0.0033088 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_short_over_long 0.36253 0.39554 yes
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_long_773K 0.0027918 0.002846 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_long_798K 0.0038698 0.0039627 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_long_823K 0.0035402 0.003652 NO
k1 A 4.225e+15 -> 4.255e+15 (kin.edited('k1', A=4.255e15)) bias_long_848K 0.0022932 0.002415 yes
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) mad_conversion 0.0017151 0.0016629 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) max_dev_conversion 0.005817 0.0056851 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_conversion 0.0012962 0.0011783 yes
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) mad_x_ch4 0.0022188 0.0021191 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) mad_x_co2 0.0012276 0.0012214 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) mad_rel_mean_pct 2.7301 2.647 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_x_ch4_bin1 0.0011628 0.001016 yes
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_x_ch4_bin2 0.001736 0.0016117 yes
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_x_ch4_bin3 0.0032074 0.0031069 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_short_over_long 0.36253 0.32702 yes
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_long_773K 0.0027918 0.0027251 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_long_798K 0.0038698 0.003767 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_long_823K 0.0035402 0.0034305 NO
k3 A 1.02e+15 -> 1.002e+15 (kin.edited('k3', A=1.002e15)) bias_long_848K 0.0022932 0.0021881 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) mad_conversion 0.0017151 0.0017145 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) max_dev_conversion 0.005817 0.0058158 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_conversion 0.0012962 0.0012951 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) mad_x_ch4 0.0022188 0.0022178 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) mad_x_co2 0.0012276 0.0012274 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) mad_rel_mean_pct 2.7301 2.7291 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_x_ch4_bin1 0.0011628 0.0011615 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_x_ch4_bin2 0.001736 0.0017347 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_x_ch4_bin3 0.0032074 0.0032063 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_short_over_long 0.36253 0.36225 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_long_773K 0.0027918 0.0027906 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_long_798K 0.0038698 0.0038684 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_long_823K 0.0035402 0.0035391 NO
K_H2 A 6.12e-09 -> 6.21e-09 (kin.edited('K_H2', A=6.21e-9)) bias_long_848K 0.0022932 0.0022925 NO
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) mad_activity_1p225 0.006285 0.024632 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) mad_activity_2p246 0.016945 0.047305 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) beta_I_long_min 0.5694 0.29388 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) beta_I_long_max 0.90233 0.66074 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) beta_III_long_min 0.44654 0.21902 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) beta_III_long_max 0.88236 0.63338 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_long_773K 0.00051695 0.0003062 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_long_798K 0.0013962 0.00089574 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_long_823K 0.0025059 0.0017527 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_long_848K 0.0035392 0.0025887 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_bias_ratio_773K 0.18517 0.03598 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_bias_ratio_798K 0.36079 0.047078 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_bias_ratio_823K 0.70784 0.05581 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_bias_ratio_848K 1.5433 0.060289 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_ratio_hot_min (here 0.71) 0.0558 yes
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_ratio_hot_max (here 1.5) 0.0603 NO
K1 intercept b1 30.114 -> 31.114 (XuFroment(eq=...)) k_halfspan_ratio_cold_max (here 0.36) 0.0471 NO
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_long_773K 0.00051695 0.00035559 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_long_798K 0.0013962 0.00098196 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_long_823K 0.0025059 0.0019039 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_long_848K 0.0035392 0.0028712 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_bias_ratio_773K 0.18517 0.05756 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_bias_ratio_798K 0.36079 0.067076 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_bias_ratio_823K 0.70784 0.077154 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_bias_ratio_848K 1.5433 0.087142 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_ratio_hot_min (here 0.71) 0.0772 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_ratio_hot_max (here 1.5) 0.0871 NO
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) k_halfspan_ratio_cold_max (here 0.36) 0.0671 NO
a 25 % shift in every K_i instead of 5 % (k_halfspan(k, shift=0.25)) k_halfspan_ratio_hot_max (here 1.5) 7.86 yes
a 25 % shift in every K_i instead of 5 % (k_halfspan(k, shift=0.25)) k_halfspan_ratio_cold_max (here 0.36) 1.85 yes
a tenth of the cells (n_tau = 100), crossings root-found on the finite beta90_fv_vs_radau_max (here 0.00049) 0.00489 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) beta_gap_outlet_min 0.025377 0.097067 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) beta_gap_outlet_max 0.17713 0.20552 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_crossings_in_bed 6 1 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_ratio_II_I_823K 0.5587 nan yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_ratio_II_I_848K 0.55038 nan yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_II_798K 0.3431 nan yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_I_823K 0.42963 nan yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_II_823K 0.24003 nan yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_I_848K 0.32559 nan yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_II_848K 0.1792 0.33565 yes
K2 intercept b2 -4.036 -> -3.036 (XuFroment(eq=...)) tau90_III_848K 0.34647 nan yes
175 rows; 59 of 60 claims are failed by at least one row; structural: min_partial_pressure_rel; open: none
Reuse
The kinetics on their own. XuFroment.rates(x_CH4, x_CO2, T, p_t, sr, hr) returns \(r_1, r_2, r_3\) in kmol/(kg\(_\mathrm{cat}\) h) and has no dependence on the reactor model. partial_pressures is the only piece tied to the single-feed parameterisation; swap it for a general mole-fraction vector and the rate equations carry over unchanged.
Three activity levels, and only one of them is tested here. Multiply \(k_1, k_2, k_3\) (XuFroment(activity=...)) by
- 1 for the steam-reforming reference level: what this page uses, what Tables 5 and 6 are, and the only level the measurements on this page belong to;
- 1.225 to reproduce the paper’s Figures 4 and 5 (reverse water-gas shift and methanation);
- 2.246 for fresh catalyst.
The last two are transcriptions from the paper’s text that nothing on this page validates. Check 5 executes them, so they are at least run, and shows what they do to the fit against these data, which is to make it worse, as it must. Reproducing Figures 4 and 5 would need the reverse water-gas shift and methanation feeds, which this page does not model. Treat both factors as unchecked.
The round trip is the check on the parameter reading, and it has a floor. Check 1 measures it: a slip smaller than a row’s own printed precision is invisible, and the worst per-row deviation cannot see anything smaller than the baseline scatter that the 0.1 kJ/mol precision of \(E\) already produces. If you re-read these tables from your own render, compare param_round_trip_ratio_sum, not the worst percentage.
The equilibrium constants are the weak ingredient. They are not from this paper, check 2 cannot see their intercepts, and at the two hotter temperatures the long-space-time residual is the size of a 5 % change in them. A model that replaces them by a thermodynamic database should re-run check 5.
Going further with the same residual. SteamReformer.residual is the whole model. To make the tube non-isothermal, add an enthalpy field to the state (a third entry on the last axis) and a wall-flux term; to resolve the pellet, keep this as the bulk equation and couple a B1.1-style intraparticle model to it, which is D1.4. First-order upwind is enough here because the error is checked at the points used (check 4); a profile with a sharp front would want ConvFluxTVD.
Related pages. B1.1 (intraparticle diffusion and reaction, the resistance these intrinsic kinetics deliberately exclude), D1.4 (fixed bed with resolved particles), C2.2, C2.6, D3.1.
Cite the source, not this page, for the kinetics: Xu, J. and Froment, G. F., Methane steam reforming, methanation and water-gas shift: I. Intrinsic kinetics, AIChE Journal 35(1) 88-96 (1989), doi:10.1002/aic.690350109.
# Prose numbers: each literal as written in the Markdown above, checked against its claim.
claims.says("0.93", "e_precision_share_pct") # Validation intro, Reuse
claims.report(); # the semicolon keeps the notebook from echoing the payloadClaims for C2.1:
W_F_measured_max = 0.4046 value rtol 0.05
digitisation_error_conversion = 0.0006 value rtol 0.05
param_round_trip_worst_pct = 1.4432 value rtol 0.05
param_round_trip_worst_ratio_to_precision = 2.3203 value rtol 0.05
param_round_trip_ratio_sum = 10.438 value rtol 0.05
e_precision_share_pct = 0.92808 value rtol 0.05
tref_slip_factor_K_CH4 = 0.22103 value rtol 0.05
tref_slip_factor_K_H2O = 33.033 value rtol 0.05
equilibrium_vs_table7_worst_pct = 1.926 value rtol 0.05
atom_balance_worst = (here 2.2e-16) bound bound <= 1e-12 [identity]
min_partial_pressure_rel = (here 2.5e-06) bound bound >= 0 [theory]
disc_error_max_n1000 = 4.1404e-05 value rtol 0.05
disc_error_share_of_mad_pct = 2.4142 value rtol 0.05
radau_reference_tolerance_gap = (here 1.1e-12) bound bound <= 1e-08 [tolerance]
grid_order_observed = (here 1.0e+00) bound bound [0.95, 1.05] [theory]
grid_finest_over_roundoff = (here 1.2e+07) bound bound >= 10000 [theory]
newton_backward_error_worst = (here 1.3e-15) bound bound <= 1e-08 [tolerance]
mad_conversion = 0.0017151 value rtol 0.05
max_dev_conversion = 0.005817 value rtol 0.05
bias_conversion = 0.0012962 value rtol 0.05
mad_x_ch4 = 0.0022188 value rtol 0.05
mad_x_co2 = 0.0012276 value rtol 0.05
mad_rel_mean_pct = 2.7301 value rtol 0.05
bias_x_ch4_bin1 = 0.0011628 value rtol 0.05
bias_x_ch4_bin2 = 0.001736 value rtol 0.05
bias_x_ch4_bin3 = 0.0032074 value rtol 0.05
bias_short_over_long = 0.36253 value rtol 0.05
bias_long_773K = 0.0027918 value rtol 0.05
bias_long_798K = 0.0038698 value rtol 0.05
bias_long_823K = 0.0035402 value rtol 0.05
bias_long_848K = 0.0022932 value rtol 0.05
mad_activity_1p225 = 0.006285 value rtol 0.05
mad_activity_2p246 = 0.016945 value rtol 0.05
beta_I_long_min = 0.5694 value rtol 0.05
beta_I_long_max = 0.90233 value rtol 0.05
beta_III_long_min = 0.44654 value rtol 0.05
beta_III_long_max = 0.88236 value rtol 0.05
k_halfspan_long_773K = 0.00051695 value rtol 0.05
k_halfspan_long_798K = 0.0013962 value rtol 0.05
k_halfspan_long_823K = 0.0025059 value rtol 0.05
k_halfspan_long_848K = 0.0035392 value rtol 0.05
k_halfspan_bias_ratio_773K = 0.18517 value rtol 0.05
k_halfspan_bias_ratio_798K = 0.36079 value rtol 0.05
k_halfspan_bias_ratio_823K = 0.70784 value rtol 0.05
k_halfspan_bias_ratio_848K = 1.5433 value rtol 0.05
k_halfspan_ratio_hot_min = (here 7.1e-01) bound bound >= 0.5 [chosen]
k_halfspan_ratio_hot_max = (here 1.5e+00) bound bound <= 2 [chosen]
k_halfspan_ratio_cold_max = (here 3.6e-01) bound bound <= 0.5 [chosen]
tau90_crossings_in_bed = 6 exact exact, margin 0.00876
beta_gap_outlet_min = 0.025377 value rtol 0.05
beta_gap_outlet_max = 0.17713 value rtol 0.05
tau90_ratio_II_I_823K = 0.5587 value rtol 0.001
tau90_ratio_II_I_848K = 0.55038 value rtol 0.001
tau90_II_798K = 0.3431 value rtol 0.001
tau90_I_823K = 0.42963 value rtol 0.001
tau90_II_823K = 0.24003 value rtol 0.001
tau90_I_848K = 0.32559 value rtol 0.001
tau90_II_848K = 0.1792 value rtol 0.001
tau90_III_848K = 0.34647 value rtol 0.001
beta90_fv_vs_radau_max = (here 4.9e-04) bound bound <= 0.001 [theory]