Wakao-Funazkri: the correlation that came from putting axial dispersion back in

sec:A
struct:S3
tier:T1
data:tier4
phase:gas-solid
Sh = 2 + 1.1 Sc^(1/3) Re^0.6 is what the packed-bed mass-transfer data say once the axial dispersion coefficient is no longer assumed to be zero. This page rebuilds the re-analysis that produced it, and tests the two fitted constants against the figure they were fitted on.
Published

August 2, 2026

Modified

November 9, 2026

Wakao-Funazkri: the correlation that came from putting axial dispersion back in

Catalog ID: A3.4 · Structures: S3 (steady 1-D convection-diffusion-reaction) · Tier: T1

Almost every packed-bed model in use contains

\[\mathrm{Sh} = 2 + 1.1\,\mathrm{Sc}^{1/3}\,\mathrm{Re}^{0.6}\]

and almost nobody remembers what it replaced. Wakao and Funazkri did not measure anything. They took thirty-five years of published mass-transfer data, noticed that the people who produced it had inverted their measurements with the wrong bed model, and inverted them again with the right one.

The wrong bed model was plug flow. A packed-bed transfer experiment measures one number, how much of the transferring species leaves the bed. Turning that into a coefficient needs a model of the bed, and if the model has no axial dispersion in it then all of the observed depletion is charged to the film. At low flow rates that error is large, and it always runs the same way: it makes the coefficient too small.

This page rebuilds that re-analysis. The bed model is a linear 1-D convection-diffusion-reaction problem with Danckwerts boundary conditions, which pymrm discretises; the paper’s eq. (7) is its closed-form solution and becomes a validation target. Then it tests the two fitted constants against the digitised figure they were fitted on.

# 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.4.0,<4"  # equals meta.yaml pymrm_min_version, up to the next major
PAGE = "A3.4-wakao-funazkri"  # 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 path
import time

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
from scipy.stats import theilslopes
from pymrm.checks import find_roots, observed_orders
from gallery_utils import load_data, load_meta, cite_data, Claims
from model import (theta_pymrm, theta_eq7, theta_eq8, pe_particle, theta_of_Sh,
                   Sh_of_theta, eq12, eq9_ranz, eq11_petrovic)

plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3})
claims = Claims(PAGE.split("-")[0])
T_START = time.time()

Background

By 1978 the j-factor correlations for particle-to-fluid mass transfer had a well-known and embarrassing property: extrapolated to low Reynolds number they predict \(\mathrm{Sh} \to 0\). A sphere sitting motionless in a stagnant fluid has \(\mathrm{Sh} = 2\). The correlations said the coefficient falls through that floor and keeps going.

Wakao and Funazkri’s answer is that the low-Re numbers are wrong because of how they were extracted from the raw measurements, not because of anything that happens in the bed. In a steady evaporation, sublimation or dissolution experiment you measure the inlet and outlet concentrations. The mass balance along the bed is

\[U \frac{\mathrm{d}C}{\mathrm{d}x} - D_{ax}\frac{\mathrm{d}^2 C}{\mathrm{d}x^2} + \frac{a}{\varepsilon_b}k_f\,(C - C_s) = 0 ,\]

which has two transport parameters, \(D_{ax}\) and \(k_f\), and only one measurement. To get \(k_f\) you must supply \(D_{ax}\) from somewhere. Most of the literature supplied \(D_{ax} = 0\). Petrovic and Thodos supplied the packed-bed asymptote \(\mathrm{Pe} = d_p U / D_{ax} = 2\).

What Wakao et al. had shown in the companion paper is that a mass transferring species does not have the same axial dispersion coefficient as an inert tracer, and that its stagnant contribution is far larger. Put that number in and the low-Re coefficients move: a lot for gases, hardly at all for liquids. The result is a correlation with the stagnant-sphere limit built in.

The published model

The dispersion coefficient (their eqs. 1 to 3; \(D_v\) is the molecular diffusivity, \(E^0\) the stagnant contribution):

\[D_{ax} = \frac{E^0}{\varepsilon_b} + \tfrac12 d_p U \qquad\Longrightarrow\qquad \frac{\varepsilon_b D_{ax}}{D_v} = \frac{E^0}{D_v} + \tfrac12\,\mathrm{Sc}\,\mathrm{Re}.\]

For a species transferred at the particle surface the companion paper gives \(E^0/D_v = 20\), so (eq. 2)

\[\frac{\varepsilon_b D_{ax}}{D_v} = 20 + 0.5\,\mathrm{Sc}\,\mathrm{Re},\]

whereas under inert conditions (eq. 3) the stagnant term is only \((0.6\text{ to }0.8)\,\varepsilon_b\). The ratio of the two stagnant terms is printed in result 1; what moves the data, though, is the smaller ratio against the \(\mathrm{Pe}=2\) assumption Petrovic and Thodos used.

The bed model (eq. 4) with Danckwerts boundary conditions (eq. 5):

\[U \frac{\mathrm{d}C}{\mathrm{d}x} - D_{ax}\frac{\mathrm{d}^2 C}{\mathrm{d}x^2} + \frac{a}{\varepsilon_b}k_f (C - C_s) = 0,\qquad U(C-C_{in}) = D_{ax}\frac{\mathrm{d}C}{\mathrm{d}x}\Big|_{0},\qquad \frac{\mathrm{d}C}{\mathrm{d}x}\Big|_{L} = 0 .\]

Its closed-form solution (eq. 7), the classical closed-vessel first-order result:

\[\frac{C_s - C_{exit}}{C_s - C_{in}} = \frac{4A\exp\!\left[\dfrac{UL}{2D_{ax}}\right]} {(1+A)^2\exp\!\left[A\dfrac{UL}{2D_{ax}}\right]-(1-A)^2\exp\!\left[-A\dfrac{UL}{2D_{ax}}\right]}, \qquad A = \sqrt{1 + \frac{4\,a\,k_f D_{ax}}{\varepsilon_b U^2}} .\]

Its plug-flow limit (eq. 8), which is what the original authors inverted:

\[\frac{C_s - C_{exit}}{C_s - C_{in}} = \exp\!\left[-\frac{\mathrm{Sh}'}{\mathrm{Sc}\,\mathrm{Re}}aL\right].\]

\(\mathrm{Sh}'\) is the Sherwood number you get by assuming \(D_{ax}=0\); \(\mathrm{Sh}\) is the one you get from eq. (7) with \(D_{ax}\) from eq. (2). The paper is the map from \(\mathrm{Sh}'\) to \(\mathrm{Sh}\).

The three correlations this page compares:

eq. (12), this paper \(\mathrm{Sh} = 2 + 1.1\,\mathrm{Sc}^{1/3}\mathrm{Re}^{0.6}\), \(3 \lesssim \mathrm{Re} \le 10^4\)
eq. (9), Ranz-Marshall, single spheres \(\mathrm{Sh} = 2 + 0.6\,\mathrm{Sc}^{1/3}\mathrm{Re}^{1/2}\)
eq. (11), Petrovic-Thodos, what eq. (12) replaced \(\varepsilon_b j_D = 0.357\,\mathrm{Re}^{-0.359}\), \(3 < \mathrm{Re} < 900\)

All Reynolds numbers are \(\mathrm{Re} = d_p U \varepsilon_b/\nu\), built on the superficial velocity, as the paper’s notation list specifies (\(U\) itself is interstitial).

Parameters and assumptions

Everything on this page is dimensionless, as the paper’s own argument is; reconstructing particle diameters and bed heights for thirty-eight literature sources would be inventing data. Three groups carry all of it. With \(a = 6(1-\varepsilon_b)/d_p\) for spheres and \(L/d_p\) the number of particle layers:

\[N = \frac{a k_f L}{\varepsilon_b U} = \frac{6(1-\varepsilon_b)\,\mathrm{Sh}\,(L/d_p)}{\mathrm{Sc}\,\mathrm{Re}}, \qquad \mathrm{Pe}_L = \frac{UL}{D_{ax}} = \frac{\mathrm{Sc}\,\mathrm{Re}}{20 + 0.5\,\mathrm{Sc}\,\mathrm{Re}}\cdot\frac{L}{d_p}, \qquad \theta = \frac{C_s - C_{exit}}{C_s - C_{in}} .\]

\(N\) is the number of transfer units and \(\mathrm{Pe}_L\) the bed Péclet number. \(\mathrm{Pe}_L/(L/d_p)\), the particle Péclet number, tends to 2 as \(\mathrm{Sc}\,\mathrm{Re}\to\infty\), which is the value Petrovic and Thodos assumed everywhere.

Assumptions taken from the paper. All resistance in the fluid film, constant surface concentration, steady state, spheres, all particles active, more than two particle layers. The bed void fraction is the value that puts eq. (11) on the line drawn in Figure 2 (it is in the parameter file, and is not stated in the paper); \(L/d_p\) is swept, because it is not reported per data set.

What the data collection excludes, because it is part of the argument: all non-steady measurements (pulse chromatography), liquid data below the \(\mathrm{Re}\) where natural convection matters, Hurt’s and Resnick’s naphthalene work (the vapour pressures disagree), Hobson and Thodos’ extraction runs, and Selke’s ion exchange.

The data

Four files, each with a data/*.meta.yaml sidecar that records the source page, the figure, the method and the reading precision. The parameter table holds every constant read off 600 dpi page renders, plus both figures’ axis calibrations, so that everything below is recomputed from pixel coordinates: a mis-read calibration constant then enters every number that depends on it.

Figure 2 is the gas-phase water-evaporation data after re-evaluation, at the Schmidt number printed inside the figure: data the correlation was checked against. Its file keeps one row per marker position ever recorded (a first crosshair pass and a shape-fit pass); marker_id groups rows that are one glyph.

Figure 3 is the liquid-phase data, plotted as \((\mathrm{Sh}-2)/\mathrm{Sc}^{1/3}\) against \(\mathrm{Re}\): the data \(\alpha\) and \(\beta\) were fitted on. The paper says so: “In liquid-phase system Sh values are large and good to be used for the determination of α and β.” Its ordinate is already the reduced group, so the correlation there is a plain power law \(y = \alpha\,\mathrm{Re}^{\beta}\) with no Schmidt number. Detecting markers on it means first erasing the drawn correlation, and the width of the band erased around it is a free knob, so the file carries one marker set per band half-width (band_hw), and result 5 computes the systematic uncertainty from the whole sweep.

Figure 3’s drawn line is the ink of the correlation the figure prints inside itself, traced column by column: a calibration control whose answer is known to be \(1.1\,\mathrm{Re}^{0.6}\).

Markers were located by fitting the marker shape and taking the centre of the fitted shape. Neither extraction has been confirmed by a maintainer: the review status of each sidecar is printed below. Results 1, 2 and 3 depend on neither figure. They are algebra and a solved boundary-value problem.

fig2 = load_data("wakao-funazkri-1978-fig2.csv", page=PAGE)
fig3 = load_data("wakao-funazkri-1978-fig3.csv", page=PAGE)
line3 = load_data("wakao-funazkri-1978-fig3-line.csv", page=PAGE)
par = load_data("wakao-funazkri-1978-parameters.csv", page=PAGE)
META = {k: load_meta(f"wakao-funazkri-1978-{k}.csv", page=PAGE)
        for k in ("fig2", "fig3", "fig3-line", "parameters")}
P = dict(zip(par.quantity, par.value))

# The printed constants: every function that uses one takes this dictionary.
C0 = dict(alpha=P["alpha_eq12"], beta=P["beta_eq12"],         # eq. (12)
          E0=P["E0_over_Dv"], slope=P["dispersion_slope"],    # eq. (2)
          E0_inert=0.5 * (P["E0_over_Dv_inert_lo"] + P["E0_over_Dv_inert_hi"]),  # eq. (3), mid
          pe_pt=P["Pe_petrovic"],                             # Petrovic-Thodos' Pe = 2
          eps_b=P["eps_b_figure2"],                           # voidage of Figure 2's eq. (11) line
          alpha_ranz=P["alpha_ranz"], beta_ranz=P["beta_ranz"],  # eq. (9)
          jd_pt=P["jd_petrovic"], jd_exp_pt=P["jd_exp_petrovic"],  # eq. (11)
          sc_fig2=P["Sc_figure2"],                            # printed inside Figure 2
          re_lo=P["Re_lo_correlation"], re_hi=P["Re_hi_correlation"],
          re_hi_pt=P["Re_hi_petrovic"])
# Axis calibrations, in pixels of the 600 dpi renders.
CAL3 = dict(col0=P["col0_fig3"], pxdec_x=P["pxdec_x_fig3"],
            row0_l=P["row0_fig3_left"], row0_r=P["row0_fig3_right"],
            pxdec_l=P["pxdec_y_fig3_left"], pxdec_r=P["pxdec_y_fig3_right"],
            tick_l=P["tickcol_fig3_left"], tick_r=P["tickcol_fig3_right"],
            row0_flat=P["row0_fig3"], pxdec_flat=P["pxdec_y_fig3"])
CAL2 = dict(col0=P["col0_fig2"], pxdec_x=P["pxdec_x_fig2"],
            row0=P["row0_fig2"], pxdec_y=P["pxdec_y_fig2"])
BAND_SHIPPED = 5           # erasure-band half-width (px) of the shipped Figure 3 set
SC_LIQ = 1000.0            # a representative liquid Schmidt number for results 1 and 2

for k, m in META.items():
    print(f"{k:>10}: {cite_data(m)}")
    print(f"{'':>10}  {m['acquisition']['method']}; review status: "
          f"{(m.get('review') or {}).get('status', m.get('status'))}")
print(f"\nFigure 2: {fig2.marker_id.nunique()} distinct glyphs in {len(fig2)} rows; "
      f"Figure 3: band half-widths {sorted(fig3.band_hw.unique())} px; "
      f"drawn line: {len(line3)} traced columns")
      fig2: Wakao, N.; Funazkri, T. (1978) — Chemical Engineering Science 33(10) 1375-1384 — doi:10.1016/0009-2509(78)85120-3 — digitised from Figure 2, journal page 1382 (PDF page 8)
            digitised; review status: pending
      fig3: Wakao, N.; Funazkri, T. (1978) — Chemical Engineering Science 33(10) 1375-1384 — doi:10.1016/0009-2509(78)85120-3 — digitised from Figure 3, journal page 1383 (PDF page 9)
            digitised; review status: pending
 fig3-line: Wakao, N.; Funazkri, T. (1978) — Chemical Engineering Science 33(10) 1375-1384 — doi:10.1016/0009-2509(78)85120-3 — digitised from Figure 3, journal page 1383 (PDF page 9), the drawn correlation only
            digitised; review status: pending
parameters: Wakao, N.; Funazkri, T. (1978) — Chemical Engineering Science 33(10) 1375-1384 — doi:10.1016/0009-2509(78)85120-3 — read from 600 dpi page renders
            read from 600 dpi page renders; review status: complete

Figure 2: 79 distinct glyphs in 90 rows; Figure 3: band half-widths [np.int64(0), np.int64(3), np.int64(5), np.int64(7), np.int64(10), np.int64(14)] px; drawn line: 410 traced columns

Pixel-to-data conversion. Figure 3’s two vertical axes disagree: the row of \(y = 1\) differs between the left and right tick columns, so the shipped calibration interpolates the ordinate origin and the decade height linearly in the column (skew); the averaged single origin (flat) is kept to show what the skew correction moves. The value columns of every CSV are these conversions, rounded; the cell checks that.

def re_of_col(c, cal):
    """Re from a column on a log axis."""
    return 10.0 ** ((np.asarray(c, float) - cal["col0"]) / cal["pxdec_x"])


def _yscale(c, cal):
    t = (np.asarray(c, float) - cal["tick_l"]) / (cal["tick_r"] - cal["tick_l"])
    return (cal["row0_l"] + (cal["row0_r"] - cal["row0_l"]) * t,
            cal["pxdec_l"] + (cal["pxdec_r"] - cal["pxdec_l"]) * t)


def y_skew(r, c, cal):
    """Figure 3 ordinate, origin and decade height linear in the column (shipped)."""
    r0, py = _yscale(c, cal)
    return 10.0 ** ((r0 - np.asarray(r, float)) / py)


def row_skew(y, c, cal):
    r0, py = _yscale(c, cal)
    return r0 - py * np.log10(np.asarray(y, float))


def y_flat(r, cal):
    """Figure 3 ordinate with one origin averaged over the two sides."""
    return 10.0 ** ((cal["row0_flat"] - np.asarray(r, float)) / cal["pxdec_flat"])


def sh_fig2(r, cal):
    return 10.0 ** ((cal["row0"] - np.asarray(r, float)) / cal["pxdec_y"])


def row_fig2(sh, cal):
    return cal["row0"] - cal["pxdec_y"] * np.log10(np.asarray(sh, float))


def csv_round(cal2, cal3):
    """Largest relative gap between a CSV value column and its pixel conversion."""
    return max(
        np.abs(re_of_col(fig3.col_px, cal3) / fig3.Re - 1).max(),
        np.abs(y_skew(fig3.row_px, fig3.col_px, cal3) / fig3.Sh_minus_2_over_Sc13 - 1).max(),
        np.abs(re_of_col(line3.col_px, cal3) / line3.Re - 1).max(),
        np.abs(re_of_col(fig2.col_px, cal2) / fig2.Re - 1).max(),
        np.abs(sh_fig2(fig2.row_px, cal2) / fig2.Sh - 1).max(),
        np.abs(re_of_col(fig2.col_px_prev, cal2) / fig2.Re_prev - 1).max(),
        np.abs(sh_fig2(fig2.row_px_prev, cal2) / fig2.Sh_prev - 1).max())


CSV_ROUND = csv_round(CAL2, CAL3)
print(f"every value column equals its pixel conversion to {CSV_ROUND:.1e} relative "
      "(the CSV rounding)")
every value column equals its pixel conversion to 8.1e-05 relative (the CSV rounding)

PyMRM implementation

Two pieces. The first is the bed model, eq. (4), in model.py. On \(z = x/L\) with \(\theta = (C-C_s)/(C_{in}-C_s)\) it reads \(\theta' - \mathrm{Pe}_L^{-1}\theta'' + N\theta = 0\). pymrm’s bc dictionaries use the outward normal, \(a\,\partial\theta/\partial n + b\,\theta = d\). The Danckwerts inlet \(U(C - C_{in}) = D_{ax}\,\mathrm{d}C/\mathrm{d}x\) is \(\theta - \mathrm{Pe}_L^{-1}\theta' = 1\) at \(z = 0\), where the outward normal points in \(-z\), so \(a = 1/\mathrm{Pe}_L\), \(b = 1\), \(d = 1\); the outlet \(\theta'(1) = 0\) is \(a = 1\), \(b = 0\), \(d = 0\). Convection is first-order upwind, dispersion central, and the outlet value is read with compute_boundary_values under the outlet condition. The problem is linear, so it is one sparse solve, no Newton iteration.

The second piece is the inversion the paper performs by hand: given an outlet \(\theta\) and a choice of dispersion coefficient, the \(\mathrm{Sh}\) that reproduces it. It runs on eq. (7), the exact solution of the same problem, and is a root of \(\theta_7(\mathrm{Sh}) - \theta\) found with pymrm.checks.find_roots over the whole physical range, which raises unless there is exactly one. Validation check 1 shows that eq. (7) and the pymrm solve are the same problem; result 3 measures what the choice of route does to the re-analysis.

assumed dispersion \(\mathrm{Pe}_L\) who used it
none, \(D_{ax}=0\) \(\infty\) most of the literature; gives \(\mathrm{Sh}'\)
\(\mathrm{Pe} = d_pU/D_{ax} = 2\) \(2(L/d_p)\) Petrovic and Thodos
eq. (2) \(\dfrac{\mathrm{Sc}\,\mathrm{Re}}{20+0.5\,\mathrm{Sc}\,\mathrm{Re}}\dfrac{L}{d_p}\) this paper; gives \(\mathrm{Sh}\)
CASES = ((1.0, 40.0), (5.0, 10.0), (0.5, 4.0), (20.0, 2.0))   # (N, Pe_L) for the checks
N_PROD = 1600
print(f"eq. (4) solved with pymrm on {N_PROD} cells against eq. (7)")
print(f"   {'N':>6}{'Pe_L':>7}{'eq. 7':>14}{'pymrm':>14}{'rel. diff':>12}")
for N, Pe in CASES:
    ex, num = theta_eq7(N, Pe), theta_pymrm(N, Pe, N_PROD)
    print(f"   {N:6.1f}{Pe:7.1f}{ex:14.6e}{num:14.6e}{num / ex - 1:+12.2e}")
for law in ("none", "pe2", "eq2"):
    print(f"{law:5s}: particle Pe at Sc Re = 1, 10, 100, 1e4 -> " +
          ", ".join(f"{pe_particle(v, law, C0):.3g}" for v in (1, 10, 100, 1e4)))
eq. (4) solved with pymrm on 1600 cells against eq. (7)
        N   Pe_L         eq. 7         pymrm   rel. diff
      1.0   40.0  3.765349e-01  3.766366e-01   +2.70e-04
      5.0   10.0  2.387893e-02  2.393003e-02   +2.14e-03
      0.5    4.0  6.314912e-01  6.315108e-01   +3.10e-05
     20.0    2.0  2.104144e-03  2.106682e-03   +1.21e-03
none : particle Pe at Sc Re = 1, 10, 100, 1e4 -> inf, inf, inf, inf
pe2  : particle Pe at Sc Re = 1, 10, 100, 1e4 -> 2, 2, 2, 2
eq2  : particle Pe at Sc Re = 1, 10, 100, 1e4 -> 0.0488, 0.4, 1.43, 1.99

The studies

Every number the page states comes out of one of the functions below, each a function of the constants dictionary, the axis calibrations and the data choices it uses. The break table at the end calls them again with one defect injected, so a defect reaches every number it can move.

  • algebra(C): results 1 to 3 and the conditioning limit; no data.
  • bed_study(...): validation check 1, the pymrm solve on a grid ladder.
  • gas_study(C, cal2, ...): Figure 2, results 4 and checks 4 to 6.
  • recentre_study(...): check 7, the two marker passes of Figure 2.
  • liquid_study(C, cal3, ...): Figure 3, result 5.
def correction_factor(Re, Sc, L_dp, C):
    """Sh/Sh' for a bed obeying eq. (12): forward through eq. (7) with eq. (2),
    back through eq. (8). nan when the outlet is saturated to 1e-10."""
    sh = eq12(Re, Sc, C)
    th = theta_of_Sh(sh, Re, Sc, L_dp, "eq2", C)
    return np.nan if th < 1e-10 else sh / Sh_of_theta(th, Re, Sc, L_dp, "none", C)


def reanalysis(Re, Sc, L_dp, C, route="eq7", n=N_PROD, bc_in=None):
    """Eq. (11) pushed forward at Pe = 2 and inverted with eq. (2): Sh / eq12 - 1.

    ``route="pymrm"`` runs the forward solve and the inversion on the pymrm
    discretisation instead of eq. (7), with the same root finder. ``bc_in`` is
    the inlet condition of that route (default Danckwerts); given only to inject
    a defect."""
    if route == "eq7":
        th = theta_of_Sh(eq11_petrovic(Re, Sc, C), Re, Sc, L_dp, "pe2", C)
        sh = Sh_of_theta(th, Re, Sc, L_dp, "eq2", C)
    else:
        k = 6.0 * (1.0 - C["eps_b"]) * L_dp / (Sc * Re)
        th = theta_pymrm(eq11_petrovic(Re, Sc, C) * k, C["pe_pt"] * L_dp, n, bc_in=bc_in)
        pe = pe_particle(Sc * Re, "eq2", C) * L_dp
        r = find_roots(lambda s: theta_pymrm(s * k, pe, n, bc_in=bc_in) - th, 1e-2, 1e3, n_scan=24,
                       log=True, xtol=1e-10)
        if len(r["roots"]) != 1:
            raise ValueError(r["message"])
        sh = r["roots"][0]
    return sh / eq12(Re, Sc, C) - 1.0


N_RE_LIQ = 200                                     # log-spaced Re over eq. (12)'s range
RE_RE = np.logspace(2.0, np.log10(900.0), 120)     # 100 <= Re <= 900
L_SWEEP = (2.0, 5.0, 10.0, 20.0, 40.0, 100.0)
SAT = 1e-3                                         # an outlet resolution, see the conditioning cell


def eq7_rate(theta7=theta_eq7, N=2.0, Pe=1e5):
    """Check 2: ln(theta7/theta8) Pe_L / N^2, whose limit for large Pe_L is 1."""
    return np.log(theta7(N, Pe) / theta_eq8(N)) * Pe / N ** 2


def algebra(C, route_bc=None):
    """Results 1 to 3, the eq. (7) -> eq. (8) limit and the conditioning limit."""
    out = {}
    sc_re = C["E0"] / C["slope"]                     # D_ax(eq. 2) = 2 D_ax(Pe = 2) here
    out["dax_double_ScRe"] = sc_re
    out["dax_double_Re_gas"] = sc_re / C["sc_fig2"]
    out["dax_double_Re_liquid"] = sc_re / SC_LIQ
    out["inert_stagnant_ratio"] = C["E0"] / (C["E0_inert"] * C["eps_b"])
    # result 2 and check 2: the liquid correction over the whole range, every L/d_p
    re_liq = np.logspace(np.log10(C["re_lo"]), np.log10(C["re_hi"]), N_RE_LIQ)
    cmax = {}
    for L in L_SWEEP:
        cf = np.array([correction_factor(x, SC_LIQ, L, C) for x in re_liq])
        cmax[L] = (np.abs(cf - 1).max(), re_liq[np.abs(cf - 1).argmax()])
    out["liquid_correction_max"] = cmax[10.0][0]
    out["liquid_correction_max_all_Ldp"] = max(v[0] for v in cmax.values())
    out["_cmax"] = cmax
    out["gas_correction_Re3_Ldp5"] = correction_factor(3.0, C["sc_fig2"], 5.0, C)
    # result 3: Petrovic-Thodos re-analysed
    dev = {L: np.array([reanalysis(x, C["sc_fig2"], L, C) for x in RE_RE]) for L in (10.0, 40.0)}
    out["eq11_reanalysed_vs_eq12_mean_abs_dev_logRe"] = np.abs(dev[10.0]).mean()
    out["eq11_reanalysed_vs_eq12_worst"] = np.abs(dev[10.0]).max()
    out["_worst_at_Re"] = RE_RE[np.abs(dev[10.0]).argmax()]
    out["eq11_reanalysis_Ldp_effect"] = np.abs(dev[40.0] - dev[10.0]).max()
    out["eq11_reanalysis_Ldp_effect_mean"] = abs(np.abs(dev[40.0]).mean() - np.abs(dev[10.0]).mean())
    out["eq11_reanalysis_Ldp_effect_worst"] = abs(np.abs(dev[40.0]).max() - np.abs(dev[10.0]).max())
    re5 = np.array([100.0, 200.0, 300.0, 500.0, 900.0])
    d_m = np.array([reanalysis(x, C["sc_fig2"], 10.0, C, route="pymrm", bc_in=route_bc) for x in re5])
    d_7 = np.array([reanalysis(x, C["sc_fig2"], 10.0, C) for x in re5])
    out["reanalysis_route_gap"] = np.abs(d_m - d_7).max()
    # check 3: where eq. (11) crosses the stagnant floor Sh = 2
    r = find_roots(lambda x: eq11_petrovic(x, C["sc_fig2"], C) - 2.0, 1e-6, 1e6, log=True)
    out["eq11_crosses_sh2_Re"] = r["roots"][0] if len(r["roots"]) == 1 else np.nan
    # the conditioning limit: where a gas bed obeying eq. (12) saturates to SAT
    for L in (5.0, 10.0):
        g = lambda x: np.log(theta_of_Sh(eq12(x, C["sc_fig2"], C), x, C["sc_fig2"], L, "eq2", C) / SAT)
        rr = find_roots(g, C["re_lo"], C["re_hi"], n_scan=96, log=True)
        out[f"_sat_roots_L{L:g}"] = rr["roots"]
        out[f"_sat_min_L{L:g}"] = min(g(x) for x in np.logspace(np.log10(C["re_lo"]), 4, 400))
    out["gas_saturation_Re_Ldp10"] = (out["_sat_roots_L10"][0]
                                      if len(out["_sat_roots_L10"]) == 1 else np.nan)
    out["gas_saturates_in_range_Ldp5"] = len(out["_sat_roots_L5"]) > 0
    return out


LADDER = (200, 400, 800, 1600, 3200)


def bed_study(bc_in=None, nu=0, disp=1.0, sink=1.0, ladder=LADDER):
    """Validation check 1: the pymrm solve on a nested ladder against eq. (7)."""
    out, rows = {}, []
    for N, Pe in CASES:
        oo = observed_orders(lambda n: theta_pymrm(sink * N, Pe, n, bc_in=bc_in, nu=nu,
                                                   disp=disp), ladder)
        ex = theta_eq7(N, Pe)
        v = np.asarray(oo["values"])
        resid = theta_pymrm(sink * N, Pe, ladder[-1], bc_in=bc_in, nu=nu, disp=disp,
                            profile=True)[3]
        rows.append(dict(N=N, Pe=Pe, orders=np.asarray(oo["orders"]), err=v / ex - 1,
                         extrap=oo["extrapolated"] / ex - 1,
                         finest=abs(v[-1] - v[-2]) / abs(ex), resid=resid))
    o = np.concatenate([r["orders"] for r in rows])
    out["pymrm_order_min"] = np.nanmin(o) if np.isfinite(o).all() else np.nan
    out["pymrm_order_max"] = np.nanmax(o) if np.isfinite(o).all() else np.nan
    out["pymrm_vs_eq7_n1600"] = (max(abs(r["err"][ladder.index(N_PROD)]) for r in rows)
                                 if N_PROD in ladder else np.nan)
    out["pymrm_vs_eq7_richardson"] = max(abs(r["extrap"]) for r in rows)
    out["pymrm_finest_diff_over_residual"] = min(r["finest"] / max(r["resid"], 1e-16)
                                                 for r in rows)
    out["_rows"] = rows
    return out


def ols(x, y, deg=1):
    """Unweighted least squares of a polynomial: coefficients (highest power
    first), standard errors with s^2 = RSS / (n - deg - 1), degrees of freedom,
    residuals."""
    X = np.vander(np.asarray(x, float), deg + 1)
    coef, *_ = np.linalg.lstsq(X, y, rcond=None)
    res = y - X @ coef
    dof = len(y) - deg - 1
    cov = (res @ res / dof) * np.linalg.inv(X.T @ X)
    return coef, np.sqrt(np.diag(cov)), dof, res


def powerlaw_fit(re, y):
    """y = alpha Re^beta as a straight line in log10-log10, every point weighted
    equally (a relative-error weighting). Returns alpha, beta and their standard
    errors (alpha's by the delta method)."""
    coef, se, _, _ = ols(np.log10(re), np.log10(y))
    alpha = 10.0 ** coef[1]
    return alpha, coef[0], alpha * np.log(10.0) * se[1], se[0]


def powerlaw_fit_linear(re, y, C):
    """The same power law by unweighted least squares in linear y, which weights
    the large values (the high-Re end); the printed pair is the start."""
    p, cov = curve_fit(lambda r, a, b: a * r ** b, re, y, p0=[C["alpha"], C["beta"]],
                       maxfev=20000)
    return p[0], p[1]


def alpha_at_fixed_beta(re, y, beta, how="log"):
    """alpha with beta held: geometric mean of y/Re^beta (log), linear-y least
    squares (lin) or the median ratio (med)."""
    if how == "log":
        return 10.0 ** np.mean(np.log10(y) - beta * np.log10(re))
    if how == "lin":
        return np.sum(y * re ** beta) / np.sum(re ** (2 * beta))
    return np.median(y / re ** beta)


def mean_se(d):
    """Mean and its standard error, s / sqrt(n)."""
    d = np.asarray(d, float)
    return d.mean(), d.std(ddof=1) / np.sqrt(len(d))



GAS_BANDS = ((1, 30), (30, 100), (100, 300), (300, 1100))
DASH_HW = 9.0      # half-width (px) of the band erased about eq. (9) at extraction


def gas_points(cal2, data=fig2, dedupe=True, second_pass=True):
    """Figure 2 markers from their pixels: one row per glyph unless ``dedupe`` is off."""
    d = data if second_pass else data[data["pass"] == "2026-07-30"]
    d = d.drop_duplicates("marker_id") if dedupe else d
    return d, re_of_col(d.col_px.values, cal2), sh_fig2(d.row_px.values, cal2)


def gas_fit(re, sh, C, sigma=True):
    """eq. (12)'s form fitted freely to Sh; ``sigma=Sh`` weights each residual by
    1/Sh, i.e. least squares in the relative error. Standard errors from the
    covariance scaled by the residual variance, n - 2 degrees of freedom."""
    form = lambda r, a, b: 2.0 + a * C["sc_fig2"] ** (1 / 3) * r ** b
    p, cov = curve_fit(form, re, sh, p0=[C["alpha"], C["beta"]],
                       sigma=sh if sigma else None, maxfev=20000)
    return p, np.sqrt(np.diag(cov))


def gas_study(C, cal2, dedupe=True, second_pass=True, sigma=True):
    """Result 4 and checks 4 to 6, from Figure 2's pixels."""
    out = {}
    obs, re, sh = gas_points(cal2, dedupe=dedupe, second_pass=second_pass)
    sc = C["sc_fig2"]
    out["n_fig2_markers"] = len(obs)
    d = {"eq12": eq12(re, sc, C) / sh - 1, "eq11": eq11_petrovic(re, sc, C) / sh - 1,
         "eq9": eq9_ranz(re, sc, C) / sh - 1}
    out["eq12_vs_fig2_mean_abs_dev"] = np.abs(d["eq12"]).mean()
    for k in d:
        out[f"{k}_vs_fig2_bias"], out[f"{k}_vs_fig2_bias_se"] = mean_se(d[k])
    out["eq11_vs_fig2_mean_abs_dev"] = np.abs(d["eq11"]).mean()
    out["eq9_vs_fig2_mean_abs_dev"] = np.abs(d["eq9"]).mean()
    bands = {}
    for k in d:
        bands[k] = [(int(((re >= lo) & (re < hi)).sum()), d[k][(re >= lo) & (re < hi)].mean())
                    for lo, hi in GAS_BANDS]
    out["_bands"], out["_d"], out["_re"], out["_sh"] = bands, d, re, sh
    span = {k: bands[k][-1][1] - bands[k][0][1] for k in d}
    out["fig2_span_eq12"], out["fig2_span_eq11"] = span["eq12"], span["eq11"]
    out["fig2_span_ratio_eq11_over_eq12"] = span["eq11"] / span["eq12"]
    out["eq9_bands_negative"] = sum(m < 0 for _, m in bands["eq9"])
    out["_eq9_band_margin"] = min(abs(m) for _, m in bands["eq9"])
    # check 4: an ordinate origin off by one pixel, and the eq. (9) erasure band
    out["fig2_sh_per_px"] = 10.0 ** (1.0 / cal2["pxdec_y"]) - 1.0
    d9 = np.abs(obs.row_px.values - row_fig2(eq9_ranz(re, sc, C), cal2))
    out["_d9"] = d9
    out["fig2_eq9_nearest_px"] = d9.min()
    lowre = (re >= 1) & (re <= 30)
    out["fig2_markers_Re_1_30"] = int(lowre.sum())
    out["_lowre_margin_px"] = cal2["pxdec_x"] * np.abs(np.log10(re)[:, None]
                                                       - np.log10([1.0, 30.0])[None, :]).min()
    out["fig2_eq9_within_band"] = int((d9 <= DASH_HW).sum())
    out["_n_within_50"] = int((d9 <= 50).sum())
    out["fig2_eq9_expected_in_band"] = out["_n_within_50"] * DASH_HW / 50.0
    # check 5: the free refit
    p, se = gas_fit(re, sh, C, sigma=sigma)
    out["refit_gas_alpha"], out["refit_gas_beta"] = p
    out["refit_gas_alpha_se"], out["refit_gas_beta_se"] = se
    out["refit_gas_max_z"] = max(abs(p[0] - C["alpha"]) / se[0], abs(p[1] - C["beta"]) / se[1])
    out["_gas_dof"] = len(re) - 2
    return out


def recentre_study(C, cal2, moved_only=True, dedupe_ids=True, second_pass=True,
                   keep_crosshair_rows=True):
    """Check 7: the shape-fit centres against the first-pass crosshairs."""
    out = {}
    pair = fig2[fig2["pass"] == "2026-07-30"]
    if not keep_crosshair_rows:
        pair = pair[pair.recentred.astype(bool)]
    n_pair = len(pair)
    mv = pair.recentred.values.astype(bool) if moved_only else np.ones(n_pair, bool)
    out["n_paired"] = n_pair
    out["n_recentred"] = int(mv.sum())
    out["n_crosshair_kept"] = n_pair - out["n_recentred"]
    dcol = (pair.col_px - pair.col_px_prev).values[mv]
    drow = (pair.row_px - pair.row_px_prev).values[mv]
    out["recentre_median_shift_px"] = np.median(np.hypot(dcol, drow))
    out["_max_shift_px"] = np.hypot(dcol, drow).max()
    mc, sc_ = mean_se(dcol)
    mr, sr = mean_se(drow)
    out["recentre_col_shift_px"], out["recentre_col_shift_z"] = mc, abs(mc) / sc_
    out["recentre_row_shift_px"], out["recentre_row_shift_z"] = mr, abs(mr) / sr
    sh_new = sh_fig2(pair.row_px.values, cal2)[mv]
    sh_old = sh_fig2(pair.row_px_prev.values, cal2)[mv]
    out["recentre_sh_shift"] = np.mean(sh_new / sh_old - 1)
    re_n, re_o = re_of_col(pair.col_px.values, cal2), re_of_col(pair.col_px_prev.values, cal2)
    pa, sa = gas_fit(re_o, sh_fig2(pair.row_px_prev.values, cal2), C)
    pb, sb = gas_fit(re_n, sh_fig2(pair.row_px.values, cal2), C)
    out["_fits"] = (pa, sa, pb, sb)
    out["recentre_alpha_shift_in_se"] = abs(pb[0] - pa[0]) / sa[0]
    out["recentre_beta_shift_in_se"] = abs(pb[1] - pa[1]) / sa[1]
    ids = pair.marker_id if dedupe_ids else pd.Series(np.arange(n_pair))
    out["n_double_counts"] = n_pair - ids.nunique()
    out["n_second_pass"] = int((fig2["pass"] == "2026-08-02").sum()) if second_pass else 0
    sep = []
    for _, g in pair.assign(mid=ids.values).groupby("mid"):
        for i in range(len(g)):
            for j in range(i + 1, len(g)):
                sep.append(np.hypot(g.col_px_prev.values[i] - g.col_px_prev.values[j],
                                    g.row_px_prev.values[i] - g.row_px_prev.values[j]))
    out["_sep"] = np.sort(np.array(sep))
    out["n_merged_pairs_below_6px"] = int((out["_sep"] < 6.0).sum())
    out["_sep_margin_px"] = np.abs(out["_sep"] - 6.0).min() if len(sep) else np.inf
    return out


CL_EDGES = np.array([3, 6, 12, 25, 50, 100, 200, 500, 1200, 3000], float)
CUTS = (3, 5, 10, 20, 30, 50)
SEED, DRAWS = 20260802, 20000
ON_LINE = 5.0     # px: a fitted centre this close to the computed line is "on" it
MATCH = 6.7       # px: two centres closer than this are one glyph (a gap in the distances)


def liquid_points(cal3, hw):
    g = fig3[fig3.band_hw == hw]
    return g, re_of_col(g.col_px.values, cal3), y_skew(g.row_px.values, g.col_px.values, cal3)


def line_control(cal3, flat):
    """Fit the drawn correlation's ink: one median per 100 px column bin (the
    traced columns are not evenly spread), then the log-log fit to the medians."""
    bc, by = [], []
    for lo in range(300, 1600, 100):
        m = ((line3.col_px >= lo) & (line3.col_px < lo + 100)).values
        if m.sum() >= 5:
            r, c = line3.row_px.values[m], line3.col_px.values[m]
            bc.append(c.mean())
            by.append(np.median(y_flat(r, cal3) if flat else y_skew(r, c, cal3)))
    a, b, _, _ = powerlaw_fit(re_of_col(np.array(bc), cal3), np.array(by))
    return a, b, len(bc)


def liquid_study(C, cal3, shipped=BAND_SHIPPED, bands=None, seed=SEED, draws=DRAWS,
                 cluster=True, hac_lags=(5, 10, 20)):
    """Result 5, from Figure 3's pixels."""
    out = {}
    bands = sorted(int(h) for h in fig3.band_hw.unique()) if bands is None else bands
    out["fig3_tick_skew_px"] = cal3["row0_r"] - cal3["row0_l"]
    out["fig3_rotation_deg"] = np.degrees(np.arctan(out["fig3_tick_skew_px"]
                                                    / (cal3["tick_r"] - cal3["tick_l"])))
    # 5a: the drawn line against where each calibration computes it, and its refit
    re_line = re_of_col(line3.col_px.values, cal3)
    comp = C["alpha"] * re_line ** C["beta"]
    off_f = line3.row_px.values - (cal3["row0_flat"] - cal3["pxdec_flat"] * np.log10(comp))
    off_s = line3.row_px.values - row_skew(comp, line3.col_px.values, cal3)
    out["_line_off"] = (re_line, off_f, off_s)
    out["fig3_line_offset_sd_flat_px"], out["fig3_line_offset_sd_skew_px"] = off_f.std(), off_s.std()
    (out["fig3_drawn_line_alpha_flat"], out["fig3_drawn_line_beta_flat"],
     out["_nbin"]) = line_control(cal3, True)
    out["fig3_drawn_line_alpha_skew"], out["fig3_drawn_line_beta_skew"], _ = line_control(cal3, False)
    # 5b: the erasure-band sweep
    sweep, af_all, a_all = {}, [], []
    for hw in bands:
        g, re, y = liquid_points(cal3, hw)
        yf = y_flat(g.row_px.values, cal3)
        a, b, _, _ = powerlaw_fit(re, y)
        la, lb = powerlaw_fit_linear(re, y, C)
        on = np.abs(g.row_px.values - row_skew(C["alpha"] * re ** C["beta"], g.col_px.values, cal3))
        sweep[hw] = dict(n=len(g), a=a, b=b, la=la, lb=lb,
                         af=alpha_at_fixed_beta(re, y, C["beta"]),
                         aff=alpha_at_fixed_beta(re, yf, C["beta"]), on=int((on < ON_LINE).sum()))
        for yy in (y, yf):
            af_all += [alpha_at_fixed_beta(re, yy, C["beta"], h) for h in ("log", "lin", "med")]
            a_all += [powerlaw_fit(re, yy)[0], powerlaw_fit_linear(re, yy, C)[0],
                      10.0 ** theilslopes(np.log10(yy), np.log10(re))[1]]
    out["_sweep"] = sweep
    out["n_fig3_markers"] = sweep[shipped]["n"]
    out["n_fig3_markers_band_off"] = sweep[min(bands)]["n"]
    out["refit_liquid_alpha_band_lo"] = min(v["a"] for v in sweep.values())
    out["refit_liquid_alpha_band_hi"] = max(v["a"] for v in sweep.values())
    out["refit_liquid_beta_band_lo"] = min(v["b"] for v in sweep.values())
    out["refit_liquid_beta_band_hi"] = max(v["b"] for v in sweep.values())
    out["refit_liquid_alpha_beta_fixed_logmetric_lo"] = min(min(v["af"], v["aff"]) for v in sweep.values())
    out["refit_liquid_alpha_beta_fixed_logmetric_hi"] = max(max(v["af"], v["aff"]) for v in sweep.values())
    out["refit_liquid_alpha_beta_fixed_grid_lo"] = min(af_all)
    out["refit_liquid_alpha_beta_fixed_grid_hi"] = max(af_all)
    out["refit_liquid_alpha_free_grid_lo"] = min(a_all)
    out["refit_liquid_alpha_free_grid_hi"] = max(a_all)
    # what switching the band off adds: centres more than MATCH from every shipped one
    off, _, _ = liquid_points(cal3, min(bands))
    shp, re_s, y_s = liquid_points(cal3, shipped)
    dmin = np.array([np.sqrt(((shp.col_px.values - r.col_px) ** 2
                              + (shp.row_px.values - r.row_px) ** 2).min())
                     for r in off.itertuples()])
    gained = dmin > MATCH
    out["_match_margin_px"] = np.abs(dmin - MATCH).min()
    d_line = np.abs(off.row_px.values - row_skew(C["alpha"] * re_of_col(off.col_px.values, cal3)
                                                 ** C["beta"], off.col_px.values, cal3))
    out["band_off_gained"] = int(gained.sum())
    out["band_off_gained_median_line_px"] = np.median(d_line[gained])
    out["band_off_gained_median_score"] = np.median(off.fit_score.values[gained])
    out["shipped_median_score"] = np.median(shp.fit_score.values)
    # the fit family on the shipped set, both calibrations
    fam = {}
    for cal_name, yy in (("flat", y_flat(shp.row_px.values, cal3)), ("skew", y_s)):
        fam[(cal_name, "log")] = powerlaw_fit(re_s, yy)
        fam[(cal_name, "lin")] = powerlaw_fit_linear(re_s, yy, C)
        ts = theilslopes(np.log10(yy), np.log10(re_s))
        fam[(cal_name, "ts")] = (10.0 ** ts[1], ts[0])
        for h in ("log", "lin", "med"):
            fam[(cal_name, "fix", h)] = alpha_at_fixed_beta(re_s, yy, C["beta"], h)
    out["_fam"] = fam
    a, b, sa, sb = fam[("skew", "log")]
    out["refit_liquid_alpha"], out["refit_liquid_beta"] = a, b
    out["refit_liquid_alpha_se"], out["refit_liquid_beta_se"] = sa, sb
    out["refit_liquid_alpha_linear_metric"], out["refit_liquid_beta_linear_metric"] = fam[("skew", "lin")]
    out["refit_liquid_alpha_theil_sen"], out["refit_liquid_beta_theil_sen"] = fam[("skew", "ts")]
    out["refit_liquid_alpha_flat_calibration"] = fam[("flat", "log")][0]
    out["refit_liquid_beta_flat_calibration"] = fam[("flat", "log")][1]
    out["refit_liquid_alpha_beta_fixed"] = fam[("skew", "fix", "log")]
    out["refit_liquid_alpha_beta_fixed_linear_metric"] = fam[("skew", "fix", "lin")]
    out["refit_liquid_alpha_beta_fixed_median"] = fam[("skew", "fix", "med")]
    out["liquid_free_beta_max_shipped"] = max(fam[(c, k)][1] for c in ("flat", "skew")
                                              for k in ("log", "lin", "ts"))
    out["liquid_free_beta_below_printed"] = sum(fam[(c, k)][1] < C["beta"] for c in ("flat", "skew")
                                                for k in ("log", "lin", "ts"))
    # the fit started at increasing Re
    cut = {}
    for c0 in CUTS:
        m = re_s >= c0
        a_c, b_c, _, _ = powerlaw_fit(re_s[m], y_s[m])
        cut[c0] = (int(m.sum()), a_c, b_c, alpha_at_fixed_beta(re_s[m], y_s[m], C["beta"]))
    out["_cut"] = cut
    out["refit_liquid_alpha_beta_fixed_Re_ge_50"] = cut[50][3]
    out["refit_liquid_alpha_Re_ge_10"], out["refit_liquid_beta_Re_ge_10"] = cut[10][1:3]
    out["refit_liquid_alpha_Re_ge_20"], out["refit_liquid_beta_Re_ge_20"] = cut[20][1:3]
    out["refit_liquid_beta_rise_3_to_50"] = cut[50][2] - cut[3][2]
    # 5c/5d: the shape of the residual and what the error bar is worth
    lx, ly = np.log10(re_s), np.log10(y_s)
    o = np.argsort(lx)
    lxs, lys = lx[o], ly[o]
    c2, se2, dof2, _ = ols(lxs, lys, 2)
    out["refit_liquid_quadratic_term"] = c2[0]
    out["refit_liquid_quadratic_term_z"] = abs(c2[0]) / se2[0]
    out["_quad"] = (c2[0], se2[0], dof2)
    out["refit_liquid_quadratic_dof"] = dof2
    c1, _, dof1, res = ols(lxs, lys, 1)
    out["_dof1"] = dof1
    out["_acf"] = {L: np.corrcoef(res[:-L], res[L:])[0, 1] for L in (1, 2, 3, 5, 10)}
    X = np.column_stack([np.ones(len(lxs)), lxs])
    XtXi = np.linalg.inv(X.T @ X)
    hac = {}
    for L in hac_lags:
        S = (res[:, None, None] ** 2 * X[:, :, None] * X[:, None, :]).sum(0)
        for l in range(1, L + 1):
            w = 1.0 - l / (L + 1.0)
            u = ((res[l:] * res[:-l])[:, None, None] * X[l:, :, None] * X[:-l, None, :]).sum(0)
            S = S + w * (u + u.T)
        hs = np.sqrt(np.diag(XtXi @ S @ XtXi))
        hac[L] = (a * np.log(10.0) * hs[0], hs[1])
    out["_hac"] = hac
    out["hac_inflation_alpha_min"] = min(v[0] for v in hac.values()) / sa
    out["hac_inflation_alpha_max"] = max(v[0] for v in hac.values()) / sa
    cid = np.digitize(re_s, CL_EDGES) if cluster else np.arange(len(re_s))
    uq = np.unique(cid)
    members = [np.where(cid == u)[0] for u in uq]
    rng = np.random.default_rng(seed)
    bs = np.empty((draws, 2))
    for i in range(draws):
        idx = np.concatenate([members[j] for j in rng.integers(0, len(uq), len(uq))])
        q = np.polyfit(lx[idx], ly[idx], 1)
        bs[i] = 10.0 ** q[1], q[0]
    out["_n_clusters"] = len(uq)
    out["refit_liquid_alpha_cluster_ci_lo"], out["refit_liquid_alpha_cluster_ci_hi"] = np.percentile(bs[:, 0], [2.5, 97.5])
    out["refit_liquid_beta_cluster_ci_lo"], out["refit_liquid_beta_cluster_ci_hi"] = np.percentile(bs[:, 1], [2.5, 97.5])
    se_a_cl, se_b_cl = bs.std(axis=0, ddof=1)
    out["refit_liquid_alpha_se_cluster"], out["refit_liquid_beta_se_cluster"] = se_a_cl, se_b_cl
    out["refit_liquid_cluster_se_inflation_alpha"] = se_a_cl / sa
    out["refit_liquid_z_alpha_iid"] = abs(a - C["alpha"]) / sa
    out["refit_liquid_z_alpha_cluster"] = abs(a - C["alpha"]) / se_a_cl
    out["refit_liquid_z_beta_cluster"] = abs(b - C["beta"]) / se_b_cl
    out["refit_liquid_beta_ci_hi_gap_in_se"] = (C["beta"] - out["refit_liquid_beta_cluster_ci_hi"]) / se_b_cl
    half = 0.5 * (out["refit_liquid_alpha_band_hi"] - out["refit_liquid_alpha_band_lo"])
    out["band_halfwidth_over_iid_se"] = half / sa
    out["band_halfwidth_over_cluster_se"] = half / se_a_cl
    # eq. (12) against its own data, and the binned shape
    dev = C["alpha"] * re_s ** C["beta"] / y_s - 1.0
    out["eq12_vs_fig3_mean_abs_dev"] = np.abs(dev).mean()
    out["eq12_vs_fig3_bias"], out["eq12_vs_fig3_bias_se"] = mean_se(dev)
    binned = []
    for lo, hi in zip(CL_EDGES[:-1], CL_EDGES[1:]):
        m = (re_s >= lo) & (re_s < hi)
        if m.sum() >= 3:
            binned.append((lo, hi, int(m.sum()), *mean_se(dev[m])))
    out["_binned"], out["_dev"], out["_re_s"], out["_y_s"] = binned, dev, re_s, y_s
    out["fig3_excess_3_6"] = 1.0 / (1.0 + binned[0][3]) - 1.0     # measured over drawn line
    out["fig3_excess_6_12"] = 1.0 / (1.0 + binned[1][3]) - 1.0
    out["fig3_excess_3_6_z"] = abs(binned[0][3]) / binned[0][4]
    out["fig3_excess_6_12_z"] = abs(binned[1][3]) / binned[1][4]
    return out



A0 = algebra(C0)
B0 = bed_study()
G0 = gas_study(C0, CAL2)
R0 = recentre_study(C0, CAL2)
L0 = liquid_study(C0, CAL3)
print(f"studies computed (here {time.time() - T_START:.1f} s)")
studies computed (here 2.2 s)

Results

1. The whole paper is one ratio

Divide eq. (2) by the Péclet-of-two assumption. Both carry the same \(0.5\,\mathrm{Sc}\,\mathrm{Re}\) convective term; the difference is the stagnant contribution, a pure function of \(\mathrm{Sc}\,\mathrm{Re}\):

\[\frac{D_{ax}^{\,\text{eq. 2}}}{D_{ax}^{\,\mathrm{Pe}=2}} = \frac{20 + 0.5\,\mathrm{Sc}\,\mathrm{Re}}{0.5\,\mathrm{Sc}\,\mathrm{Re}} = 1 + \frac{40}{\mathrm{Sc}\,\mathrm{Re}} .\]

So the re-analysis can only matter at small \(\mathrm{Sc}\,\mathrm{Re}\). The cell prints where the coefficient doubles for a gas and for a liquid: for a gas that is most of the range the old correlations were fitted over, for a liquid it is far below anything the paper accepts. That single line predicts both halves of the paper’s first conclusion.

def dax_ratio(Sc_Re, C=C0):
    """eq. (2) over the Pe = 2 assumption: 1 + (E0/slope)/(Sc Re)."""
    return 1.0 + C["E0"] / (C["slope"] * np.asarray(Sc_Re, float))


ScRe = np.logspace(-1, 5, 400)
fig, axes = plt.subplots(1, 2, figsize=(11.8, 4.2))
ax = axes[0]
ax.loglog(ScRe, dax_ratio(ScRe), "k-", lw=2, label="eq. (2) / Pe = 2")
ax.loglog(ScRe, (C0["E0"] + C0["slope"] * ScRe) / (C0["E0_inert"] * C0["eps_b"] + C0["slope"] * ScRe),
          "--", color="tab:orange", lw=2, label="eq. (2) / eq. (3), inert")
ax.axhline(1.0, color="grey", lw=1)
ax.axvline(A0["dax_double_ScRe"], color="tab:red", lw=1.2, ls=":")
ax.set(xlabel="Sc Re", ylabel=r"$D_{ax}$ ratio", ylim=(0.9, 1e3),
       title="how much bigger the new dispersion coefficient is")
ax.legend(fontsize=8)
ax = axes[1]
Re = np.logspace(np.log10(0.3), 4, 300)
for sc, col in ((C0["sc_fig2"], "tab:red"), (10.0, "tab:orange"), (SC_LIQ, "tab:blue")):
    ax.loglog(Re, dax_ratio(sc * Re), color=col, lw=2, label=f"Sc = {sc:g}")
ax.axvspan(C0["re_lo"], C0["re_hi"], color="grey", alpha=0.12, label="range of eq. (12)")
ax.set(xlabel="Re", ylabel="eq. (2) / Pe = 2", ylim=(0.9, 3e3), title="and where in Re that lands")
ax.legend(fontsize=8)
fig.tight_layout(); plt.show()

print(f"D_ax(eq. 2) = 2 D_ax(Pe = 2) at Sc Re = {A0['dax_double_ScRe']:.0f}: "
      f"Re = {A0['dax_double_Re_gas']:.1f} for a gas at Sc = {C0['sc_fig2']}, "
      f"Re = {A0['dax_double_Re_liquid']:.2f} for a liquid at Sc = {SC_LIQ:g}")
print(f"against the INERT coefficient of eq. (3) the stagnant terms differ by a factor "
      f"{A0['inert_stagnant_ratio']:.1f} ({C0['E0']:.0f} against "
      f"{C0['E0_inert'] * C0['eps_b']:.2f} at eps_b = {C0['eps_b']})")

D_ax(eq. 2) = 2 D_ax(Pe = 2) at Sc Re = 40: Re = 66.7 for a gas at Sc = 0.6, Re = 0.04 for a liquid at Sc = 1000
against the INERT coefficient of eq. (3) the stagnant terms differ by a factor 71.4 (20 against 0.28 at eps_b = 0.4)

2. What that does to the reported Sherwood number

Take a bed that obeys eq. (12), run it forward through eq. (7) with the eq. (2) dispersion coefficient to get the outlet such a bed would produce, then invert eq. (8) on that outlet, which is what the original investigators did. The ratio \(\mathrm{Sh}/\mathrm{Sh}'\) is the error the paper corrects; its only free parameter is the bed height.

fig, axes = plt.subplots(1, 2, figsize=(11.8, 4.3))
Re_g = np.logspace(np.log10(3), 4, 70)
ax = axes[0]
for L_dp, ls in ((2.0, ":"), (5.0, "--"), (10.0, "-")):
    for sc, col, ph in ((C0["sc_fig2"], "tab:red", "gas"), (SC_LIQ, "tab:blue", "liquid")):
        ax.semilogx(Re_g, [correction_factor(x, sc, L_dp, C0) for x in Re_g], ls, color=col,
                    lw=1.8, label=f"{ph}, $L/d_p$ = {L_dp:g}")
ax.axhline(1.0, color="k", lw=1.2)
ax.set(xlabel="Re", ylabel=r"$\mathrm{Sh}/\mathrm{Sh}'$", ylim=(0.9, 4),
       title=f"the size of the correction (Sc = {C0['sc_fig2']} red, {SC_LIQ:g} blue)")
ax.legend(fontsize=7.5, ncol=2)
ax = axes[1]
Re = np.logspace(0, 4, 300)
ax.loglog(Re, eq12(Re, C0["sc_fig2"], C0), "k-", lw=2.2, label="eq. (12)")
ax.loglog(Re, eq9_ranz(Re, C0["sc_fig2"], C0), "--", color="tab:green", lw=1.8,
          label="eq. (9) Ranz-Marshall, single spheres")
ax.loglog(Re, eq11_petrovic(Re, C0["sc_fig2"], C0), "-.", color="tab:blue", lw=1.8,
          label="eq. (11) Petrovic-Thodos")
ax.axhline(2.0, color="tab:red", lw=1.4, ls=":", label="Sh = 2, the stagnant sphere")
ax.set(xlabel="Re", ylabel="Sh", ylim=(0.5, 300), title=f"each correlation as Re falls, Sc = {C0['sc_fig2']}")
ax.legend(fontsize=8, loc="upper left")
fig.tight_layout(); plt.show()

print("Sh/Sh' for a bed obeying eq. (12), L/d_p = 5:")
print(f"   {'Re':>8}{'gas':>10}{'liquid':>12}")
for x in (3, 10, 30, 100, 300, 1000, 3000):
    print(f"   {x:8}{correction_factor(x, C0['sc_fig2'], 5.0, C0):10.2f}"
          f"{correction_factor(x, SC_LIQ, 5.0, C0):12.4f}")
print(f"\nliquid (Sc = {SC_LIQ:g}): largest |Sh/Sh' - 1| over {C0['re_lo']:g} <= Re <= "
      f"{C0['re_hi']:g} ({N_RE_LIQ} log-spaced Re), by bed length:")
for L, (m, at) in A0["_cmax"].items():
    print(f"   L/d_p = {L:5g}: {100 * m:.3f} % at Re = {at:g}")
print(f"the largest correction sits at the low end of the range: "
      f"{100 * A0['liquid_correction_max']:.2f} % at L/d_p = 10, and at most "
      f"{100 * A0['liquid_correction_max_all_Ldp']:.2f} % for L/d_p up to {max(L_SWEEP):g}.")

Sh/Sh' for a bed obeying eq. (12), L/d_p = 5:
         Re       gas      liquid
          3      6.92      1.0126
         10      3.44      1.0074
         30      2.09      1.0047
        100      1.48      1.0028
        300      1.26      1.0018
       1000      1.15      1.0011
       3000      1.10      1.0007

liquid (Sc = 1000): largest |Sh/Sh' - 1| over 3 <= Re <= 10000 (200 log-spaced Re), by bed length:
   L/d_p =     2: 1.054 % at Re = 3
   L/d_p =     5: 1.256 % at Re = 3
   L/d_p =    10: 1.325 % at Re = 3
   L/d_p =    20: 1.360 % at Re = 3
   L/d_p =    40: 1.378 % at Re = 3
   L/d_p =   100: 1.388 % at Re = 3
the largest correction sits at the low end of the range: 1.33 % at L/d_p = 10, and at most 1.39 % for L/d_p up to 100.

3. Redoing the paper’s own recalculation

Petrovic and Thodos’ eq. (11) is their data, already fitted, under \(\mathrm{Pe}=2\). Run it forward through eq. (7) at \(\mathrm{Pe}=2\) to recover the outlet, then invert eq. (7) again with eq. (2)’s dispersion coefficient. If Wakao and Funazkri’s arithmetic is right, what comes out should be eq. (12). Nothing is fitted: both correlations, both dispersion laws and the bed model are printed in the paper. The statistics are over a log-spaced grid of \(100 \le \mathrm{Re} \le 900\), where both correlations are inside their stated ranges.

Re_t = np.array([30, 50, 100, 200, 300, 500, 900])
print("eq. (11) re-analysed with eq. (2), against eq. (12)")
print(f"   {'Re':>6}{'eq. 11':>9}{'eq. 12':>9}" + "".join(f"{'L/dp=' + str(L):>11}" for L in (10, 20, 40)))
for x in Re_t:
    row = [(1 + reanalysis(x, C0["sc_fig2"], float(L), C0)) * eq12(x, C0["sc_fig2"], C0) for L in (10, 20, 40)]
    print(f"   {x:6}{eq11_petrovic(x, C0['sc_fig2'], C0):9.2f}{eq12(x, C0['sc_fig2'], C0):9.2f}"
          + "".join(f"{v:11.2f}" for v in row))
print(f"\nover 100 <= Re <= 900 at L/d_p = 10: mean |dev| "
      f"{100 * A0['eq11_reanalysed_vs_eq12_mean_abs_dev_logRe']:.2f} %, worst "
      f"{100 * A0['eq11_reanalysed_vs_eq12_worst']:.2f} % at Re = {A0['_worst_at_Re']:.0f}, the top of eq. (11)'s range")
print(f"L/d_p = 10 -> 40 moves the mean |dev| by {100 * A0['eq11_reanalysis_Ldp_effect_mean']:.2f} "
      f"and the worst by {100 * A0['eq11_reanalysis_Ldp_effect_worst']:.2f} percentage points; "
      f"the deviation at a single Re by at most {100 * A0['eq11_reanalysis_Ldp_effect']:.2f}")
print(f"the same re-analysis with the forward solve and the inversion both on the pymrm "
      f"discretisation ({N_PROD} cells) instead of eq. (7): largest difference "
      f"{100 * A0['reanalysis_route_gap']:.3f} percentage points at the five tabulated Re >= 100")
eq. (11) re-analysed with eq. (2), against eq. (12)
       Re   eq. 11   eq. 12    L/dp=10    L/dp=20    L/dp=40
       30     6.66     9.14      10.68      10.98      11.14
       50     9.24    11.70      12.34      12.53      12.64
      100    14.41    16.70      16.54      16.65      16.71
      200    22.47    24.29      23.90      23.97      24.01
      300    29.14    30.43      30.27      30.32      30.35
      500    40.43    40.62      41.26      41.29      41.31
      900    58.93    56.95      59.50      59.53      59.54

over 100 <= Re <= 900 at L/d_p = 10: mean |dev| 1.67 %, worst 4.48 % at Re = 900, the top of eq. (11)'s range
L/d_p = 10 -> 40 moves the mean |dev| by 0.24 and the worst by 0.07 percentage points; the deviation at a single Re by at most 1.05
the same re-analysis with the forward solve and the inversion both on the pymrm discretisation (1600 cells) instead of eq. (7): largest difference 0.036 percentage points at the five tabulated Re >= 100

The second route shares the correlations, the dispersion laws and the root finder with the first; it differs only in solving eq. (4) on the grid rather than using eq. (7), so its gap is the first-order discretisation error of the grid, of the size validation check 1 measures.

4. The digitised gas-phase data

Figure 2 is the paper’s own before-and-after picture: re-evaluated \(\mathrm{Sh}\) for water evaporation, with eq. (12) drawn through it, Ranz-Marshall below it and the Petrovic-Thodos line across it. Deviation is \((\text{model}-\text{measured})/\text{measured}\) throughout; a mean deviation is printed with its standard error, \(s/\sqrt{n}\).

re2, sh2, d2 = G0["_re"], G0["_sh"], G0["_d"]
Re_c = np.logspace(0, 3, 300)
fig, axes = plt.subplots(1, 2, figsize=(11.8, 4.4))
ax = axes[0]
ax.loglog(re2, sh2, "o", ms=5, mfc="none", mew=1.2, color="tab:grey",
          label=f"Figure 2, digitised ({len(re2)})")
ax.loglog(Re_c, eq12(Re_c, C0["sc_fig2"], C0), "k-", lw=2.2, label="eq. (12)")
ax.loglog(Re_c, eq9_ranz(Re_c, C0["sc_fig2"], C0), "--", color="tab:green", lw=1.7, label="eq. (9)")
ax.loglog(Re_c, eq11_petrovic(Re_c, C0["sc_fig2"], C0), "-.", color="tab:blue", lw=1.7, label="eq. (11)")
ax.set(xlabel="Re", ylabel="Sh", title="Figure 2 rebuilt from the extracted points", ylim=(1, 300))
ax.legend(fontsize=8, loc="upper left")
ax = axes[1]
for k, c, m in (("eq12", "k", "o"), ("eq11", "tab:blue", "s"), ("eq9", "tab:green", "^")):
    ax.semilogx(re2, d2[k], m, ms=4.5, mfc="none", mew=1.1, color=c, label=k.replace("eq", "eq. ("))
ax.axhline(0, color="k", lw=1.2)
ax.set(xlabel="Re", ylabel="(model - measured) / measured", ylim=(-0.9, 1.2),
       title="deviation, one convention throughout")
ax.legend(fontsize=8)
fig.tight_layout(); plt.show()

print(f"against the {G0['n_fig2_markers']} digitised markers:")
for k, lab in (("eq12", "eq. (12) this paper"), ("eq11", "eq. (11) Petrovic-T"), ("eq9", "eq.  (9) Ranz-Marsh")):
    print(f"   {lab}: mean |dev| {100 * np.abs(d2[k]).mean():5.1f} %   bias "
          f"{100 * G0[k + '_vs_fig2_bias']:+6.1f} +/- {100 * G0[k + '_vs_fig2_bias_se']:.1f} %")
print("\nbias by Reynolds band:")
print(f"   {'Re band':>14}{'n':>4}{'eq. 12':>10}{'eq. 11':>10}{'eq. 9':>10}")
for i, (lo, hi) in enumerate(GAS_BANDS):
    n = G0["_bands"]["eq12"][i][0]
    print(f"   {str(lo) + '-' + str(hi):>14}{n:4d}" +
          "".join(f"{100 * G0['_bands'][k][i][1]:+9.1f}%" for k in ("eq12", "eq11", "eq9")))

against the 79 digitised markers:
   eq. (12) this paper: mean |dev|  27.2 %   bias   +9.7 +/- 3.6 %
   eq. (11) Petrovic-T: mean |dev|  28.7 %   bias   -3.1 +/- 3.9 %
   eq.  (9) Ranz-Marsh: mean |dev|  55.1 %   bias  -55.1 +/- 1.6 %

bias by Reynolds band:
          Re band   n    eq. 12    eq. 11     eq. 9
             1-30   7     +6.7%    -31.9%    -37.2%
           30-100  27     +1.4%    -19.3%    -52.2%
          100-300  23     +8.5%     -0.3%    -58.7%
         300-1100  22    +22.2%    +22.8%    -60.7%

5. The correlation against the data it was fitted to

Figure 2 is a check; Figure 3 is the fit. Its ordinate is already \((\mathrm{Sh}-2)/\mathrm{Sc}^{1/3}\), so fitting \(y = \alpha\,\mathrm{Re}^{\beta}\) to the digitised markers is a direct test of the two printed constants. A refit of a digitised figure has three uncertainties beyond the standard error a regression prints:

  1. the axes may be mis-calibrated, and this figure carries its own control, the correlation drawn inside it;
  2. the marker set depends on how much of that drawn line is erased before detection;
  3. the estimator: a straight line in log-log (equal weight to every relative deviation) and a least-squares fit in linear \(y\) (dominated by the large values, the high-\(\mathrm{Re}\) end) answer different questions.

5a. The calibration, checked against a curve whose answer is known. Trace the drawn line’s ink, invert the axis calibration, fit a power law to it: the answer must be \(1.1\) and \(0.6\), and anything else is calibration error measured on an object with no scientific content. The two vertical axes of the render disagree (the decade ticks on the left sit higher than those on the right), so a single averaged origin is right in the middle of the plot and wrong at both ends; the shipped calibration interpolates the origin and the decade height in the column.

re_line, off_f, off_s = L0["_line_off"]
print(f"tick skew: y = 1 sits {L0['fig3_tick_skew_px']:+.2f} px lower under the right axis than "
      f"under the left, a rotation of {L0['fig3_rotation_deg']:.3f} degrees")
print(f"\ndrawn ink minus computed position of {C0['alpha']} Re^{C0['beta']}, {len(line3)} traced columns")
print(f"   {'Re band':>14}{'n':>5}{'flat cal.':>12}{'skew cal.':>12}")
for lo, hi in ((3, 10), (10, 100), (100, 1000), (1000, 10000)):
    m = (re_line >= lo) & (re_line < hi)
    if m.sum() >= 5:
        print(f"   {str(lo) + '-' + str(hi):>14}{m.sum():5d}{off_f[m].mean():+10.2f} px{off_s[m].mean():+10.2f} px")
print(f"   spread about the mean: {L0['fig3_line_offset_sd_flat_px']:.2f} px flat, "
      f"{L0['fig3_line_offset_sd_skew_px']:.2f} px skew-corrected")
print(f"\nthe drawn correlation refitted from its own ink ({L0['_nbin']} column bins); "
      f"the answer is alpha = {C0['alpha']}, beta = {C0['beta']}")
for lab, k in (("flat", "flat"), ("skew-corrected", "skew")):
    a, b = L0[f"fig3_drawn_line_alpha_{k}"], L0[f"fig3_drawn_line_beta_{k}"]
    print(f"   {lab:>16}: alpha {a:.4f} ({100 * (a / C0['alpha'] - 1):+.1f} %), "
          f"beta {b:.4f} ({100 * (b / C0['beta'] - 1):+.1f} %)")
tick skew: y = 1 sits +5.64 px lower under the right axis than under the left, a rotation of 0.203 degrees

drawn ink minus computed position of 1.1 Re^0.6, 410 traced columns
          Re band    n   flat cal.   skew cal.
             3-10   98     -3.37 px     -1.53 px
           10-100   34     +0.67 px     +0.97 px
         100-1000  131     +2.34 px     +1.98 px
       1000-10000  128     +0.12 px     -1.10 px
   spread about the mean: 3.83 px flat, 3.54 px skew-corrected

the drawn correlation refitted from its own ink (10 column bins); the answer is alpha = 1.1, beta = 0.6
               flat: alpha 1.1262 (+2.4 %), beta 0.5949 (-0.9 %)
     skew-corrected: alpha 1.1073 (+0.7 %), beta 0.5984 (-0.3 %)

With the skew corrected the control comes back close to the printed pair; what is left is of the order of the drawn stroke’s own scatter about a power law, which the sidecar measures. The correction moves the marker fit in the same direction (the next cells).

5b. The erasure band. Figure 3’s markers merge into chains along the drawn correlation, and the detector cannot see them until that line is erased. Most of it goes by morphology, but the remainder is removed by computed position, in a band of chosen half-width around \(1.1\,\mathrm{Re}^{0.6}\). That band is the one step that can destroy a real marker, and its width is a free choice, so the answer is reported as a function of it. “on line” counts fitted centres within 5 px of the computed line.

print(f"   {'band':>6}{'n':>5}{'alpha':>9}{'beta':>9}{'a (lin.)':>10}{'b (lin.)':>10}"
      f"{'a | b fixed':>12}{'(flat)':>9}{'on line':>9}")
for hw, v in L0["_sweep"].items():
    star = " <- shipped" if hw == BAND_SHIPPED else ""
    print(f"   {hw:6d}{v['n']:5d}{v['a']:9.4f}{v['b']:9.4f}{v['la']:10.4f}{v['lb']:10.4f}"
          f"{v['af']:12.4f}{v['aff']:9.4f}{v['on']:9d}{star}")
print(f"\nswitching the band off gains {L0['band_off_gained']} centres more than {MATCH} px from every "
      f"shipped one; their median distance from the drawn line is "
      f"{L0['band_off_gained_median_line_px']:.1f} px and their median fit score "
      f"{L0['band_off_gained_median_score']:.3f}, against {L0['shipped_median_score']:.3f} for the shipped set")
print(f"\nacross the sweep: free alpha {L0['refit_liquid_alpha_band_lo']:.3f} to "
      f"{L0['refit_liquid_alpha_band_hi']:.3f}, free beta {L0['refit_liquid_beta_band_lo']:.4f} to "
      f"{L0['refit_liquid_beta_band_hi']:.4f}")
print(f"alpha at beta = {C0['beta']}, six bands x two calibrations, log metric: "
      f"{L0['refit_liquid_alpha_beta_fixed_logmetric_lo']:.4f} to {L0['refit_liquid_alpha_beta_fixed_logmetric_hi']:.4f}")
print(f"   and over three estimators as well: {L0['refit_liquid_alpha_beta_fixed_grid_lo']:.4f} to "
      f"{L0['refit_liquid_alpha_beta_fixed_grid_hi']:.4f} "
      f"({100 * (L0['refit_liquid_alpha_beta_fixed_grid_lo'] / C0['alpha'] - 1):+.1f} % to "
      f"{100 * (L0['refit_liquid_alpha_beta_fixed_grid_hi'] / C0['alpha'] - 1):+.1f} % of {C0['alpha']})")
print(f"free alpha over the same grid: {L0['refit_liquid_alpha_free_grid_lo']:.4f} to "
      f"{L0['refit_liquid_alpha_free_grid_hi']:.4f} "
      f"({100 * (L0['refit_liquid_alpha_free_grid_lo'] / C0['alpha'] - 1):+.1f} % to "
      f"{100 * (L0['refit_liquid_alpha_free_grid_hi'] / C0['alpha'] - 1):+.1f} %)")
     band    n    alpha     beta  a (lin.)  b (lin.) a | b fixed   (flat)  on line
        0  210   1.2959   0.5588    1.0513    0.6013      1.0981   1.1008       28
        3  195   1.3275   0.5526    1.1616    0.5837      1.0977   1.1004       17
        5  182   1.3437   0.5496    1.1567    0.5844      1.1000   1.1029        3 <- shipped
        7  166   1.3596   0.5466    1.1651    0.5822      1.1010   1.1040        0
       10  147   1.4169   0.5361    1.2146    0.5727      1.1061   1.1094        0
       14  126   1.4401   0.5328    1.2287    0.5718      1.1143   1.1179        0

switching the band off gains 32 centres more than 6.7 px from every shipped one; their median distance from the drawn line is 2.9 px and their median fit score 0.692, against 0.793 for the shipped set

across the sweep: free alpha 1.296 to 1.440, free beta 0.5328 to 0.5588
alpha at beta = 0.6, six bands x two calibrations, log metric: 1.0977 to 1.1179
   and over three estimators as well: 1.0067 to 1.1179 (-8.5 % to +1.6 % of 1.1)
free alpha over the same grid: 1.0513 to 1.4648 (-4.4 % to +33.2 %)

Erasing more of the line removes the markers lying on it, the ones nearest the printed correlation, so \(\alpha\) climbs and \(\beta\) falls as the band widens. The shipped setting is mid-range: switching the band off finds more centres, but they sit on the drawn line and score worse than the shipped set (both medians above), so they are more likely surviving fragments of the line than rescued markers. That is a judgement; its cost if wrong is the width of the sweep.

One number barely moves: \(\alpha\) with \(\beta\) held at the printed value, across the whole sweep and both calibrations, while the free \(\alpha\) moves by tens of per cent. It is not a fixed point: the estimator and the starting \(\mathrm{Re}\) both move it by a few per cent, and the next cell prints both.

fam = L0["_fam"]
print(f"the fit family on the shipped set: {L0['n_fig3_markers']} markers; printed alpha = "
      f"{C0['alpha']}, beta = {C0['beta']}")
print(f"   {'calibration':>12}{'estimator':>28}{'alpha':>10}{'beta':>10}")
for cal_name in ("flat", "skew"):
    for k, lab in (("log", "least squares in log y"), ("lin", "least squares in linear y"),
                   ("ts", "Theil-Sen in log y")):
        print(f"   {cal_name:>12}{lab:>28}{fam[(cal_name, k)][0]:10.4f}{fam[(cal_name, k)][1]:10.4f}")
    for h, lab in (("log", "beta fixed: geometric mean"), ("lin", "beta fixed: linear-y LS"),
                   ("med", "beta fixed: median ratio")):
        print(f"   {cal_name:>12}{lab:>28}{fam[(cal_name, 'fix', h)]:10.4f}{C0['beta']:10.4f}")
print("\nthe log-space fit, started at increasing Reynolds number (skew calibration):")
print(f"   {'Re >=':>7}{'n':>5}{'alpha':>9}{'beta':>9}{'a | b fixed':>13}")
for c0, (n, a, b, af) in L0["_cut"].items():
    print(f"   {c0:7d}{n:5d}{a:9.3f}{b:9.4f}{af:13.4f}")
the fit family on the shipped set: 182 markers; printed alpha = 1.1, beta = 0.6
    calibration                   estimator     alpha      beta
           flat      least squares in log y    1.3667    0.5460
           flat   least squares in linear y    1.1723    0.5815
           flat          Theil-Sen in log y    1.2118    0.5521
           flat  beta fixed: geometric mean    1.1029    0.6000
           flat     beta fixed: linear-y LS    1.0411    0.6000
           flat    beta fixed: median ratio    1.0529    0.6000
           skew      least squares in log y    1.3437    0.5496
           skew   least squares in linear y    1.1567    0.5844
           skew          Theil-Sen in log y    1.1899    0.5558
           skew  beta fixed: geometric mean    1.1000    0.6000
           skew     beta fixed: linear-y LS    1.0469    0.6000
           skew    beta fixed: median ratio    1.0518    0.6000

the log-space fit, started at increasing Reynolds number (skew calibration):
     Re >=    n    alpha     beta  a | b fixed
         3  182    1.344   0.5496       1.1000
         5  164    1.181   0.5753       1.0629
        10  145    1.112   0.5866       1.0464
        20  125    1.009   0.6045       1.0315
        30  114    0.995   0.6070       1.0304
        50   91    0.861   0.6316       1.0199

5c. The estimator. Within each block of that table the rows are the same markers and the same model under different loss functions, and they disagree by more than either differs from the printed pair. The paper does not say which it used. Its Figure 4, where it pools gas and liquid data against \((\mathrm{Sc}^{1/3}\mathrm{Re}^{0.6})^2\) and writes “it is seen that the data are well correlated by eqn (12)”, is drawn on log-log axes like every figure in the paper, and the square is taken “to enlarge the plot in the x-axis direction”: a presentational choice, not a weighting. A two-significant-figure correlation drawn through data pooled from thirty-eight sources does not have a recoverable loss function.

5d. The standard errors. The ordinary least-squares errors assume independent, identically distributed residuals. The markers come from eleven laboratories, each covering a window in \(\mathrm{Re}\) with its own offset, and a straight line in log-log may not be the right mean function. Both are measurable: a quadratic term with its \(t\) value, the residual autocorrelation in \(\mathrm{Re}\) order (its standard error is about \(1/\sqrt{n}\)), Newey-West errors, and a bootstrap that resamples whole \(\mathrm{Re}\) bins.

q, qse, qdof = L0["_quad"]
n_l = L0["n_fig3_markers"]
print(f"quadratic term in log-log: {q:+.4f} +/- {qse:.4f}, t = {L0['refit_liquid_quadratic_term_z']:.1f} "
      f"on {qdof} degrees of freedom ({n_l} markers, three coefficients)")
print("residual autocorrelation in log-Re order: " +
      ", ".join(f"lag {L} {v:+.2f}" for L, v in L0["_acf"].items()) +
      f"   (1/sqrt(n) = {1 / np.sqrt(n_l):.2f})")
a, b = L0["refit_liquid_alpha"], L0["refit_liquid_beta"]
print(f"\nthe free log-log fit, alpha = {a:.3f} / beta = {b:.4f}, {n_l} markers, {L0['_dof1']} degrees "
      f"of freedom; z is the distance from the printed value in standard errors")
print(f"   {'error model':>34}{'SE(alpha)':>11}{'SE(beta)':>11}{'z(alpha)':>10}{'z(beta)':>9}")
rows_err = [("ordinary least squares (iid)", L0["refit_liquid_alpha_se"], L0["refit_liquid_beta_se"])]
rows_err += [(f"Newey-West, {L} lags", *v) for L, v in L0["_hac"].items()]
rows_err += [(f"bootstrap of {L0['_n_clusters']} Re bins", L0["refit_liquid_alpha_se_cluster"],
              L0["refit_liquid_beta_se_cluster"])]
for lab, sa, sb in rows_err:
    print(f"   {lab:>34}{sa:11.4f}{sb:11.4f}{abs(a - C0['alpha']) / sa:10.1f}{abs(b - C0['beta']) / sb:9.1f}")
print(f"\n   bin bootstrap ({DRAWS} draws) 95 % interval: alpha {L0['refit_liquid_alpha_cluster_ci_lo']:.3f} "
      f"to {L0['refit_liquid_alpha_cluster_ci_hi']:.3f}, beta {L0['refit_liquid_beta_cluster_ci_lo']:.4f} "
      f"to {L0['refit_liquid_beta_cluster_ci_hi']:.4f}")
print(f"   the erasure band alone spans a half-width of "
      f"{0.5 * (L0['refit_liquid_alpha_band_hi'] - L0['refit_liquid_alpha_band_lo']):.3f} in alpha: "
      f"{L0['band_halfwidth_over_iid_se']:.1f} iid standard errors, "
      f"{L0['band_halfwidth_over_cluster_se']:.2f} bin-bootstrap ones")
quadratic term in log-log: +0.0558 +/- 0.0111, t = 5.0 on 179 degrees of freedom (182 markers, three coefficients)
residual autocorrelation in log-Re order: lag 1 +0.04, lag 2 +0.09, lag 3 +0.11, lag 5 +0.12, lag 10 +0.12   (1/sqrt(n) = 0.07)

the free log-log fit, alpha = 1.344 / beta = 0.5496, 182 markers, 180 degrees of freedom; z is the distance from the printed value in standard errors
                          error model  SE(alpha)   SE(beta)  z(alpha)  z(beta)
         ordinary least squares (iid)     0.0520     0.0090       4.7      5.6
                   Newey-West, 5 lags     0.0740     0.0121       3.3      4.2
                  Newey-West, 10 lags     0.0882     0.0145       2.8      3.5
                  Newey-West, 20 lags     0.0995     0.0169       2.5      3.0
               bootstrap of 9 Re bins     0.1319     0.0229       1.8      2.2

   bin bootstrap (20000 draws) 95 % interval: alpha 1.041 to 1.531, beta 0.5082 to 0.5988
   the erasure band alone spans a half-width of 0.072 in alpha: 1.4 iid standard errors, 0.55 bin-bootstrap ones

So what survives?

The printed pair is not rejected by this figure. The bin-bootstrap interval on \(\alpha\) contains the printed value; the one on \(\beta\) ends at it, to a tenth of a bootstrap standard error, and the linear-metric fit returns a \(\beta\) inside it. The extraction band alone moves \(\alpha\) by more than the iid standard error and by about half the bootstrap one.

What survives is that the figure returns the printed \(\alpha\) once \(\beta\) is held at its printed value, within a few per cent under every band, calibration and estimator tried, while the free \(\alpha\) runs up to a third high. Two things move the \(\beta\)-fixed \(\alpha\), and both are printed: the estimator, and the starting \(\mathrm{Re}\).

And a weaker statement about shape. The free exponent is below the printed one under every estimator on the shipped set, because the markers at the lowest \(\mathrm{Re}\) sit above the drawn line; as they are dropped the fit walks through the printed pair (the starting-\(\mathrm{Re}\) table). The paper sets aside liquid data below \(\mathrm{Re}\approx 3\) as possibly affected by natural convection, which inflates the coefficient; the binned deviations below show the excess has not finished by \(\mathrm{Re}=3\). The quadratic term says a two-parameter power law is not the right description of this cloud in the first place.

dev3, re_s, y_s = L0["_dev"], L0["_re_s"], L0["_y_s"]
fig, axes = plt.subplots(1, 2, figsize=(11.8, 4.4))
Re_l = np.logspace(np.log10(2.5), np.log10(3500), 300)
ax = axes[0]
ax.loglog(re_s, y_s, "o", ms=4, mfc="none", mew=1.0, color="tab:blue", label=f"Figure 3, digitised ({len(re_s)})")
ax.loglog(Re_l, C0["alpha"] * Re_l ** C0["beta"], "k-", lw=2.2, label="printed")
ax.loglog(Re_l, L0["refit_liquid_alpha_beta_fixed"] * Re_l ** C0["beta"], "-", color="tab:orange", lw=1.6,
          label="beta held, alpha fitted")
ax.loglog(Re_l, a * Re_l ** b, "--", color="tab:red", lw=1.8, label="free, log metric")
ax.loglog(Re_l, L0["refit_liquid_alpha_linear_metric"] * Re_l ** L0["refit_liquid_beta_linear_metric"],
          ":", color="tab:green", lw=2.0, label="free, linear metric")
ax.set(xlabel="Re", ylabel=r"$(\mathrm{Sh}-2)/\mathrm{Sc}^{1/3}$",
       title="Figure 3 rebuilt: the data alpha and beta were fitted on")
ax.legend(fontsize=7.5, loc="upper left")
ax = axes[1]
ax.semilogx(re_s, dev3, "o", ms=4, mfc="none", mew=1.0, color="tab:blue")
ax.axhline(0, color="k", lw=1.2)
bn = L0["_binned"]
ax.errorbar([np.sqrt(lo * hi) for lo, hi, *_ in bn], [m for *_, m, s in bn], [s for *_, s in bn],
            fmt="s-", color="tab:red", lw=2, ms=6, label="binned mean +/- standard error")
ax.set(xlabel="Re", ylabel="(model - measured) / measured", ylim=(-0.6, 0.6),
       title="deviation of the PRINTED correlation from its own data")
ax.legend(fontsize=8)
fig.tight_layout(); plt.show()

print(f"eq. (12) against the {len(re_s)} liquid markers: mean |dev| {100 * L0['eq12_vs_fig3_mean_abs_dev']:.1f} %, "
      f"bias {100 * L0['eq12_vs_fig3_bias']:+.1f} +/- {100 * L0['eq12_vs_fig3_bias_se']:.1f} %")
print(f"eq. (12) against the {G0['n_fig2_markers']} gas markers: mean |dev| "
      f"{100 * G0['eq12_vs_fig2_mean_abs_dev']:.1f} %, bias {100 * G0['eq12_vs_fig2_bias']:+.1f} +/- "
      f"{100 * G0['eq12_vs_fig2_bias_se']:.1f} %")
print(f"\n   {'Re band':>14}{'n':>5}{'mean dev':>11}{'SE':>7}")
for lo, hi, n, m, s in bn:
    print(f"   {str(int(lo)) + '-' + str(int(hi)):>14}{n:5d}{100 * m:+10.1f}%{100 * s:6.1f}%")
print(f"the markers lie {100 * L0['fig3_excess_3_6']:.0f} % above the drawn line for 3 <= Re < 6 "
      f"({L0['fig3_excess_3_6_z']:.1f} standard errors) and {100 * L0['fig3_excess_6_12']:.0f} % for "
      f"6 <= Re < 12 ({L0['fig3_excess_6_12_z']:.1f})")

eq. (12) against the 182 liquid markers: mean |dev| 17.6 %, bias +2.3 +/- 1.6 %
eq. (12) against the 79 gas markers: mean |dev| 27.2 %, bias +9.7 +/- 3.6 %

          Re band    n   mean dev     SE
              3-6   23     -19.6%   4.1%
             6-12   17     -10.3%   4.7%
            12-25   21      +3.3%   4.6%
            25-50   30      +4.2%   3.4%
           50-100   25     +16.4%   4.3%
          100-200   23     +10.7%   3.9%
          200-500   23      +3.6%   3.3%
         500-1200   16      +5.5%   3.1%
        1200-3000    4      +5.0%   7.0%
the markers lie 24 % above the drawn line for 3 <= Re < 6 (4.7 standard errors) and 11 % for 6 <= Re < 12 (2.2)

Validation

Seven checks. The first three involve no data. A break table at the end injects a defect into every input the claims depend on and records which claims catch it.

1. pymrm against eq. (7). The paper’s closed form is the exact solution of the problem pymrm discretises, so refinement must converge onto it. The cases are solved on a nested ladder of uniform grids; orders come from successive differences of three levels (pymrm.checks.observed_orders), which need no exact solution, and the Richardson value extrapolated with the observed order of the finest triple is compared with eq. (7). Upwind convection is first order. The round-off companion is the finest successive difference divided by the relative residual of the linear solve: the orders are read far above round-off.

print(f"ladder {LADDER} cells")
for r in B0["_rows"]:
    print(f"   N = {r['N']:5.1f}, Pe_L = {r['Pe']:5.1f}: error {r['err'][0]:+.2e} -> {r['err'][-1]:+.2e}, "
          f"orders {', '.join(f'{o:.4f}' for o in r['orders'])}, extrapolated {r['extrap']:+.1e}, "
          f"solve residual below 1e-11 (here {r['resid']:.0e})")
print(f"orders {B0['pymrm_order_min']:.4f} to {B0['pymrm_order_max']:.4f} (first order); "
      f"largest error at {N_PROD} cells {B0['pymrm_vs_eq7_n1600']:.2e}; the extrapolated value is "
      f"below 1e-5 from eq. (7) (here {B0['pymrm_vs_eq7_richardson']:.1e}); the finest difference is "
      f"more than 1e3 times the solve residual (here {B0['pymrm_finest_diff_over_residual']:.0e})")
ladder (200, 400, 800, 1600, 3200) cells
   N =   1.0, Pe_L =  40.0: error +2.16e-03 -> +1.35e-04, orders 0.9969, 0.9984, 0.9992, extrapolated -9.8e-08, solve residual below 1e-11 (here 3e-14)
   N =   5.0, Pe_L =  10.0: error +1.72e-02 -> +1.07e-03, orders 1.0074, 1.0038, 1.0019, extrapolated +1.9e-06, solve residual below 1e-11 (here 1e-13)
   N =   0.5, Pe_L =   4.0: error +2.48e-04 -> +1.55e-05, orders 0.9988, 0.9994, 0.9997, extrapolated -3.8e-09, solve residual below 1e-11 (here 3e-13)
   N =  20.0, Pe_L =   2.0: error +9.87e-03 -> +6.02e-04, orders 1.0280, 1.0142, 1.0071, extrapolated +3.9e-06, solve residual below 1e-11 (here 1e-13)
orders 0.9969 to 1.0280 (first order); largest error at 1600 cells 2.14e-03; the extrapolated value is below 1e-5 from eq. (7) (here 3.9e-06); the finest difference is more than 1e3 times the solve residual (here 5e+07)

2. Eq. (7) collapses onto eq. (8) when the dispersion is switched off. The paper says “eqn (7) reduces to eqn (8), when not only \(D_{ax}=0\) but also Sc Re is sufficiently large”, and that is why the liquid data survive. Expanding eq. (7) for large \(\mathrm{Pe}_L\) gives \(\ln(\theta_7/\theta_8) \approx N^2/\mathrm{Pe}_L\), so the scaled gap \(\ln(\theta_7/\theta_8)\,\mathrm{Pe}_L/N^2\) must tend to one. Result 2 already printed what it means for a liquid bed.

N = 2.0
for Pe in (2, 10, 50, 200, 1e3, 1e4, 1e5):
    r = theta_eq7(N, Pe) / theta_eq8(N)
    print(f"   Pe_L = {Pe:8.0f}: eq. 7 / eq. 8 = {r:.6f}   ln(ratio) Pe_L / N^2 = {np.log(r) * Pe / N ** 2:.5f}")
   Pe_L =        2: eq. 7 / eq. 8 = 1.836562   ln(ratio) Pe_L / N^2 = 0.30395
   Pe_L =       10: eq. 7 / eq. 8 = 1.310331   ln(ratio) Pe_L / N^2 = 0.67570
   Pe_L =       50: eq. 7 / eq. 8 = 1.075515   ln(ratio) Pe_L / N^2 = 0.90999
   Pe_L =      200: eq. 7 / eq. 8 = 1.019705   ln(ratio) Pe_L / N^2 = 0.97568
   Pe_L =     1000: eq. 7 / eq. 8 = 1.003988   ln(ratio) Pe_L / N^2 = 0.99503
   Pe_L =    10000: eq. 7 / eq. 8 = 1.000400   ln(ratio) Pe_L / N^2 = 0.99950
   Pe_L =   100000: eq. 7 / eq. 8 = 1.000040   ln(ratio) Pe_L / N^2 = 0.99995

3. The stagnant-sphere limit. Eq. (12) was written as \(2 + \alpha\,\mathrm{Sc}^{1/3}\mathrm{Re}^{\beta}\) so that it cannot fall below 2, for any \(\alpha\) and positive \(\beta\): an identity of the form, which cannot fail. Eq. (11) has no such floor; where it crosses \(\mathrm{Sh} = 2\) is a root over \(10^{-6} \le \mathrm{Re} \le 10^6\).

print(f"   {'Re':>10}{'eq. 12':>10}{'eq. 9':>10}{'eq. 11':>10}")
for x in (10.0, 1.0, 0.1, 0.01, 1e-3, 1e-6):
    print(f"   {x:10.0e}{eq12(x, C0['sc_fig2'], C0):10.4f}{eq9_ranz(x, C0['sc_fig2'], C0):10.4f}"
          f"{eq11_petrovic(x, C0['sc_fig2'], C0):10.4f}")
SH_LIMIT = max(abs(eq12(1e-30, C0["sc_fig2"], C0) - 2.0), abs(eq9_ranz(1e-30, C0["sc_fig2"], C0) - 2.0))
print(f"eq. (12) and eq. (9) at Re = 1e-30 differ from 2 by below 1e-12 (here {SH_LIMIT:.1e}); "
      f"eq. (11) crosses Sh = 2 at Re = {A0['eq11_crosses_sh2_Re']:.2f} and keeps falling")
           Re    eq. 12     eq. 9    eq. 11
        1e+01    5.6935    3.6003    3.2935
        1e+00    2.9278    2.5061    0.7528
        1e-01    2.2330    2.1600    0.1721
        1e-02    2.0585    2.0506    0.0393
        1e-03    2.0147    2.0160    0.0090
        1e-06    2.0002    2.0005    0.0001
eq. (12) and eq. (9) at Re = 1e-30 differ from 2 by below 1e-12 (here 4.4e-16); eq. (11) crosses Sh = 2 at Re = 4.59 and keeps falling

4. Figure 2’s calibration. All three curves on Figure 2 are known functions, so recomputing them on the source image tests the axes (the sidecar records the overlay). Two of the three are genuinely known; eq. (11) contains \(\varepsilon_b\), which was fitted to the very line it is checked against, so it constrains the calibration in slope only. The sidecar also records the ink of all three curves sitting slightly above their computed rows, all the same way; that offset is not corrected, because the only evidence for it is those three curves, one of which supplied \(\varepsilon_b\). The cell prints the sidecar’s measurement and what one pixel of ordinate origin is worth.

Eq. (9) is drawn dashed, and its dashes are compact ink a shape test cannot tell from a marker, so they were erased by computed position: the one step that can destroy a Figure 2 marker. How many surviving markers lie near that band is countable.

print(META["fig2"]["calibration_check"]["residual_offset"])
print(f"one pixel of Figure 2's ordinate origin is {100 * G0['fig2_sh_per_px']:.2f} % in every Sh")
d9 = G0["_d9"]
print(f"\nmarkers by distance from the computed eq. (9) curve (erased band +/- {DASH_HW:.0f} px):")
for w in (DASH_HW, 15.0, 20.0, 30.0, 50.0):
    print(f"   within {w:4.0f} px: {int((d9 <= w).sum()):3d} of {len(d9)}")
print(f"nearest marker {G0['fig2_eq9_nearest_px']:.1f} px away; {G0['fig2_markers_Re_1_30']} markers in "
      f"1 <= Re <= 30, where the cloud crosses that curve. With the {G0['_n_within_50']} markers inside "
      f"50 px spread evenly in distance, the band would hold {G0['fig2_eq9_expected_in_band']:.2f} on "
      f"average, so finding none is the likelier outcome even if the band destroyed nothing.")
Measured 2026-08-02 by `setup2.trace_curves`, which takes the ink centroid column by column wherever the straight ink near the computed curve is a single run no wider than 8 px (that filter is what keeps markers sitting on a curve out of the measurement). Drawn ink minus computed row, negative meaning the ink is ABOVE the computed position:
  eq. (12)  n = 701   -4.06 +/- 2.30 px   = +2.80 % in Sh
  eq. (11)  n = 582   -5.06 +/- 1.89 px   = +3.51 % in Sh
  eq.  (9)  n = 206   -2.08 +/- 6.27 px   = +1.43 % in Sh
Each is inside a line width on its own; all three the SAME WAY is an ordinate origin about 4 px low, which makes every extracted Sh in this file roughly 2.7 % high and moves the page's +9.7 % eq. (12) bias to about +12.7 %. NOT CORRECTED, deliberately: the only evidence for it is those three drawn curves, one of which supplied eps_b, so correcting the ordinate on it would make this whole check circular. Contrast Figure 3, where the analogous skew is measured on eight printed decade ticks and the drawn line is an independent control - there it IS corrected.

one pixel of Figure 2's ordinate origin is 0.68 % in every Sh

markers by distance from the computed eq. (9) curve (erased band +/- 9 px):
   within    9 px:   0 of 79
   within   15 px:   1 of 79
   within   20 px:   1 of 79
   within   30 px:   2 of 79
   within   50 px:   3 of 79
nearest marker 11.7 px away; 7 markers in 1 <= Re <= 30, where the cloud crosses that curve. With the 3 markers inside 50 px spread evenly in distance, the band would hold 0.54 on average, so finding none is the likelier outcome even if the band destroyed nothing.

5. The correlation refitted freely from both figures. On Figure 2 the form \(\mathrm{Sh} = 2 + a\,\mathrm{Sc}^{1/3}\mathrm{Re}^{b}\) is fitted with each residual divided by the measured \(\mathrm{Sh}\) (sigma = Sh), which is least squares in the relative deviation, the same weighting as the log-log fit on Figure 3. Both standard errors are iid ones, scaled by the residual variance on \(n-2\) degrees of freedom, and optimistic for the reason 5d measures; the ratio of the two is what says which figure is the sharper instrument.

print(f"Figure 2, {G0['n_fig2_markers']} gas markers, {G0['_gas_dof']} degrees of freedom: "
      f"a = {G0['refit_gas_alpha']:.3f} +/- {G0['refit_gas_alpha_se']:.3f}, "
      f"b = {G0['refit_gas_beta']:.4f} +/- {G0['refit_gas_beta_se']:.4f}; the printed pair is within "
      f"{G0['refit_gas_max_z']:.2f} standard errors")
print(f"Figure 3, {L0['n_fig3_markers']} liquid markers, {L0['_dof1']} degrees of freedom: "
      f"a = {L0['refit_liquid_alpha']:.3f} +/- {L0['refit_liquid_alpha_se']:.3f}, "
      f"b = {L0['refit_liquid_beta']:.4f} +/- {L0['refit_liquid_beta_se']:.4f}")
SE_RATIO_A = G0["refit_gas_alpha_se"] / L0["refit_liquid_alpha_se"]
SE_RATIO_B = G0["refit_gas_beta_se"] / L0["refit_liquid_beta_se"]
print(f"the liquid figure's standard errors are {SE_RATIO_A:.1f} times smaller in alpha and "
      f"{SE_RATIO_B:.1f} times in beta")
Figure 2, 79 gas markers, 77 degrees of freedom: a = 1.185 +/- 0.202, b = 0.5467 +/- 0.0326; the printed pair is within 1.64 standard errors
Figure 3, 182 liquid markers, 180 degrees of freedom: a = 1.344 +/- 0.052, b = 0.5496 +/- 0.0090
the liquid figure's standard errors are 3.9 times smaller in alpha and 3.6 times in beta

6. The shape of the argument. The paper says of Figure 2 that “at higher Reynolds numbers the data are satisfactorily correlated by the Petrovic-Thodos equation. However, at lower Reynolds numbers the data are obviously higher than their correlation.” That is a claim about a trend in the residual, which a digitised figure can test even when the marker count is short. The cell reduces the banded table of result 4 to one number per correlation: the change of the binned bias from the lowest Reynolds band to the highest.

for k in ("eq12", "eq11", "eq9"):
    lo_, hi_ = G0["_bands"][k][0][1], G0["_bands"][k][-1][1]
    print(f"   {k}: {100 * lo_:+6.1f} % -> {100 * hi_:+6.1f} %, change {100 * (hi_ - lo_):+6.1f} points")
print(f"eq. (11)'s bias changes {G0['fig2_span_ratio_eq11_over_eq12']:.1f} times as much across the range as "
      f"eq. (12)'s; eq. (9) is negative in {G0['eq9_bands_negative']} of {len(GAS_BANDS)} bands")
   eq12:   +6.7 % ->  +22.2 %, change  +15.5 points
   eq11:  -31.9 % ->  +22.8 %, change  +54.7 points
   eq9:  -37.2 % ->  -60.7 %, change  -23.5 points
eq. (11)'s bias changes 3.5 times as much across the range as eq. (12)'s; eq. (9) is negative in 4 of 4 bands

7. What the re-centring did. The maintainer’s audit of the first extraction was “mostly symbols are identified well, but often the marker is off centre”; the shape fit followed. Figure 2’s file keeps both the first-pass crosshair and the fitted centre for every first-pass marker, so the displacement is a paired sample. A marker whose shape fit scored below the acceptance threshold keeps its crosshair (recentred = False) and has zero displacement by construction, so the displacement statistics use only the re-centred ones; the paired refit uses all.

pa, sa, pb, sb = R0["_fits"]
print(f"{R0['n_paired']} paired rows, {R0['n_recentred']} re-centred")
print(f"   along Re (columns): {R0['recentre_col_shift_px']:+.2f} px, {R0['recentre_col_shift_z']:.1f} "
      f"standard errors from zero")
print(f"   along Sh (rows):    {R0['recentre_row_shift_px']:+.2f} px, {R0['recentre_row_shift_z']:.1f} "
      f"standard errors from zero, {100 * R0['recentre_sh_shift']:+.2f} % in Sh")
print(f"   distance moved: median {R0['recentre_median_shift_px']:.2f} px, max {R0['_max_shift_px']:.2f} px")
print(f"refit on the same {R0['n_paired']} markers: crosshairs a = {pa[0]:.3f} +/- {sa[0]:.3f}, "
      f"b = {pa[1]:.4f}; fitted centres a = {pb[0]:.3f} +/- {sb[0]:.3f}, b = {pb[1]:.4f}: "
      f"{R0['recentre_alpha_shift_in_se']:.2f} and {R0['recentre_beta_shift_in_se']:.2f} standard errors apart")
sep = R0["_sep"]
print(f"\n{R0['n_double_counts']} of the {R0['n_paired']} crosshairs are a second or third mark on a glyph "
      f"another crosshair already had; {R0['n_second_pass']} markers were added by the second pass")
print("separations of the merged crosshairs: " + "  ".join(f"{v:.1f}" for v in sep) + " px")
print(f"{R0['n_merged_pairs_below_6px']} of {len(sep)} pairs are closer than 6 px, inside the 8 px non-maximum suppression "
      "the first pass documents, so that suppression does not explain the double counts; the cause is not known.")
81 paired rows, 65 re-centred
   along Re (columns): -0.01 px, 0.0 standard errors from zero
   along Sh (rows):    +0.78 px, 2.6 standard errors from zero, -0.52 % in Sh
   distance moved: median 3.29 px, max 9.88 px
refit on the same 81 markers: crosshairs a = 1.168 +/- 0.218, b = 0.5550; fitted centres a = 1.152 +/- 0.214, b = 0.5572: 0.08 and 0.06 standard errors apart

11 of the 81 crosshairs are a second or third mark on a glyph another crosshair already had; 9 markers were added by the second pass
separations of the merged crosshairs: 1.4  1.5  2.0  2.0  2.2  3.0  3.2  5.0  8.0  8.3  9.0  9.1 px
8 of 12 pairs are closer than 6 px, inside the 8 px non-maximum suppression the first pass documents, so that suppression does not explain the double counts; the cause is not known.

The row displacement is a few standard errors from zero, so it is a small bias, not noise; in \(\mathrm{Sh}\) it is well under the scatter of the data, and the fitted constants move by a small fraction of a standard error. The shape fit’s other effect is that two crosshairs on one glyph produce two fits at the same point, which exposes double counting.

The conditioning limit

\(\theta_{out}\) is what the experiment measures. Where it falls below the resolution of an outlet analysis the measurement no longer constrains \(\mathrm{Sh}\), whatever dispersion coefficient is assumed. The cell takes \(10^{-3}\) as that resolution (an assumption, not a measured number) and finds where a gas bed obeying eq. (12) reaches it, as a root over the whole range of eq. (12), for two bed lengths.

print(f"   {'L/d_p':>7}{'Re':>8}{'theta_out':>13}{'d ln theta / d ln Sh':>23}")
for L_dp in (5.0, 10.0):
    for x in (3.0, 10.0, 30.0, 100.0):
        sh = eq12(x, C0["sc_fig2"], C0)
        th = theta_of_Sh(sh, x, C0["sc_fig2"], L_dp, "eq2", C0)
        th2 = theta_of_Sh(1.01 * sh, x, C0["sc_fig2"], L_dp, "eq2", C0)
        print(f"   {L_dp:7.0f}{x:8.0f}{th:13.2e}{(np.log(th2) - np.log(th)) / np.log(1.01):23.2f}")
print(f"theta_out = {SAT:g}: at L/d_p = 10 for Re below {A0['gas_saturation_Re_Ldp10']:.1f}; at L/d_p = 5 "
      f"{'nowhere' if not A0['gas_saturates_in_range_Ldp5'] else 'somewhere'} in "
      f"{C0['re_lo']:g} <= Re <= {C0['re_hi']:g} (the outlet stays at least "
      f"{np.exp(A0['_sat_min_L5']):.1f} times {SAT:g})")
     L/d_p      Re    theta_out   d ln theta / d ln Sh
         5       3     4.16e-03                  -2.47
         5      10     6.93e-03                  -2.72
         5      30     1.26e-02                  -2.82
         5     100     3.40e-02                  -2.55
        10       3     9.00e-05                  -4.49
        10      10     1.13e-04                  -5.06
        10      30     2.31e-04                  -5.39
        10     100     1.32e-03                  -4.96
theta_out = 0.001: at L/d_p = 10 for Re below 85.0; at L/d_p = 5 nowhere in 3 <= Re <= 10000 (the outlet stays at least 4.2 times 0.001)

Claims

Every number the page states, with how it is checked. Closed-form algebra and least-squares fits solved exactly are held to FIXED; results of an iterative fit (curve_fit, Theil-Sen) to ITER; the bin bootstrap to three times the spread of its value over twelve seeds, measured here. Residuals, identities, orders and two-route gaps are bounds with their basis. A count decided against a threshold carries its margin, in pixels where the threshold is one.

FIXED, ITER = 1e-6, 1e-4
BOOT = ["refit_liquid_alpha_cluster_ci_lo", "refit_liquid_alpha_cluster_ci_hi",
        "refit_liquid_beta_cluster_ci_lo", "refit_liquid_beta_cluster_ci_hi",
        "refit_liquid_alpha_se_cluster", "refit_liquid_beta_se_cluster",
        "refit_liquid_cluster_se_inflation_alpha", "refit_liquid_z_alpha_cluster",
        "refit_liquid_z_beta_cluster", "band_halfwidth_over_cluster_se"]
SEEDS = [liquid_study(C0, CAL3, seed=s) for s in range(12)]
BOOT_RTOL = {k: 3 * np.std([r[k] for r in SEEDS], ddof=1) / abs(np.mean([r[k] for r in SEEDS]))
             for k in BOOT}
print("bootstrap seed spread (relative, x3): " +
      ", ".join(f"{k.replace('refit_liquid_', '')} {v:.1e}" for k, v in BOOT_RTOL.items()))

ITERATIVE = {"refit_liquid_alpha_linear_metric", "refit_liquid_beta_linear_metric",
             "refit_liquid_alpha_theil_sen", "refit_liquid_beta_theil_sen",
             "refit_liquid_alpha_free_grid_lo", "refit_liquid_alpha_free_grid_hi",
             "liquid_free_beta_max_shipped", "refit_gas_alpha", "refit_gas_beta",
             "refit_gas_alpha_se", "refit_gas_beta_se", "refit_gas_max_z",
             "recentre_alpha_shift_in_se", "recentre_beta_shift_in_se"}
VALUES = {
    "A": ["dax_double_ScRe", "dax_double_Re_gas", "dax_double_Re_liquid", "inert_stagnant_ratio",
          "liquid_correction_max", "liquid_correction_max_all_Ldp", "gas_correction_Re3_Ldp5",
          "eq11_reanalysed_vs_eq12_mean_abs_dev_logRe", "eq11_reanalysed_vs_eq12_worst",
          "eq11_reanalysis_Ldp_effect", "eq11_reanalysis_Ldp_effect_mean",
          "eq11_reanalysis_Ldp_effect_worst", "eq11_crosses_sh2_Re",
          "gas_saturation_Re_Ldp10"],
    "B": ["pymrm_vs_eq7_n1600"],
    "E": ["eq7_eq8_rate"],
    "G": ["eq12_vs_fig2_mean_abs_dev", "eq12_vs_fig2_bias", "eq12_vs_fig2_bias_se",
          "eq11_vs_fig2_bias", "eq11_vs_fig2_bias_se", "eq9_vs_fig2_bias", "eq9_vs_fig2_bias_se",
          "fig2_span_eq12", "fig2_span_eq11", "fig2_span_ratio_eq11_over_eq12", "fig2_sh_per_px",
          "fig2_eq9_nearest_px", "fig2_eq9_expected_in_band", "refit_gas_alpha", "refit_gas_beta",
          "refit_gas_alpha_se", "refit_gas_beta_se", "refit_gas_max_z"],
    "R": ["recentre_median_shift_px", "recentre_row_shift_px",
          "recentre_row_shift_z", "recentre_sh_shift", "recentre_alpha_shift_in_se",
          "recentre_beta_shift_in_se"],
    "L": ["fig3_tick_skew_px", "fig3_rotation_deg", "fig3_line_offset_sd_flat_px",
          "fig3_line_offset_sd_skew_px", "fig3_drawn_line_alpha_flat", "fig3_drawn_line_beta_flat",
          "fig3_drawn_line_alpha_skew", "fig3_drawn_line_beta_skew",
          "refit_liquid_alpha_band_lo", "refit_liquid_alpha_band_hi",
          "refit_liquid_beta_band_lo", "refit_liquid_beta_band_hi",
          "refit_liquid_alpha_beta_fixed_logmetric_lo", "refit_liquid_alpha_beta_fixed_logmetric_hi",
          "refit_liquid_alpha_beta_fixed_grid_lo", "refit_liquid_alpha_beta_fixed_grid_hi",
          "refit_liquid_alpha_free_grid_lo", "refit_liquid_alpha_free_grid_hi",
          "band_off_gained_median_line_px", "band_off_gained_median_score", "shipped_median_score",
          "refit_liquid_alpha", "refit_liquid_beta", "refit_liquid_alpha_se", "refit_liquid_beta_se",
          "refit_liquid_alpha_linear_metric", "refit_liquid_beta_linear_metric",
          "refit_liquid_alpha_theil_sen", "refit_liquid_beta_theil_sen",
          "refit_liquid_alpha_flat_calibration", "refit_liquid_beta_flat_calibration",
          "refit_liquid_alpha_beta_fixed", "refit_liquid_alpha_beta_fixed_linear_metric",
          "refit_liquid_alpha_beta_fixed_median", "liquid_free_beta_max_shipped",
          "refit_liquid_alpha_beta_fixed_Re_ge_50", "refit_liquid_alpha_Re_ge_20",
          "refit_liquid_beta_Re_ge_20", "refit_liquid_beta_rise_3_to_50",
          "refit_liquid_quadratic_term", "refit_liquid_quadratic_term_z",
          "hac_inflation_alpha_min", "hac_inflation_alpha_max", "refit_liquid_z_alpha_iid",
          "band_halfwidth_over_iid_se", "eq12_vs_fig3_mean_abs_dev", "eq12_vs_fig3_bias",
          "eq12_vs_fig3_bias_se", "fig3_excess_3_6", "fig3_excess_6_12", "fig3_excess_3_6_z",
          "fig3_excess_6_12_z"] + BOOT,
}
BASE = {"A": A0, "B": B0, "G": G0, "R": R0, "L": L0, "E": {"eq7_eq8_rate": eq7_rate()}}
for grp, names in VALUES.items():
    for k in names:
        rtol = BOOT_RTOL.get(k, ITER if k in ITERATIVE else FIXED)
        claims.value(k, BASE[grp][k], rtol=rtol)
claims.value("gas_vs_liquid_se_ratio_alpha", SE_RATIO_A, rtol=ITER)
claims.value("gas_vs_liquid_se_ratio_beta", SE_RATIO_B, rtol=ITER)
# bounds
claims.bound("csv_vs_pixel_conversion", CSV_ROUND, max=5e-4, basis="tolerance",
             note="the CSV value columns are rounded to four or five significant figures")
claims.bound("pymrm_order_min", B0["pymrm_order_min"], min=0.95, max=1.1, basis="theory",
             note="first-order upwind convection; the ladder sits in the asymptotic range")
claims.bound("pymrm_order_max", B0["pymrm_order_max"], min=0.95, max=1.1, basis="theory",
             note="first-order upwind convection; the ladder sits in the asymptotic range")
claims.bound("pymrm_vs_eq7_richardson", B0["pymrm_vs_eq7_richardson"], max=1e-5, basis="theory",
             note="the remainder after removing the first-order term at 3200 cells by Richardson with the observed order")
claims.bound("pymrm_finest_diff_over_residual", B0["pymrm_finest_diff_over_residual"], min=1e3,
             basis="theory", note="round-off companion: the finest ladder difference over the solve residual")
claims.bound("reanalysis_route_gap", A0["reanalysis_route_gap"], max=1e-3, basis="theory",
             note="first-order discretisation error of the 1600-cell solve, check 1")
claims.bound("sh_limit_eq12_eq9", SH_LIMIT, max=1e-12, basis="identity", structural=True,
             note="2 + a Re^b -> 2 for any a and b > 0: the form chosen, it cannot fail")
claims.bound("refit_liquid_beta_ci_hi_gap_in_se", L0["refit_liquid_beta_ci_hi_gap_in_se"], min=-0.1,
             max=0.1, basis="chosen", note="the upper end of the beta interval sits at the printed beta "
             "to a tenth of a bin-bootstrap standard error")
# counts and labels
claims.exact("n_fig2_markers", G0["n_fig2_markers"])
claims.exact("n_fig3_markers", L0["n_fig3_markers"])
claims.exact("n_fig3_markers_band_off", L0["n_fig3_markers_band_off"])
claims.exact("refit_liquid_quadratic_dof", L0["refit_liquid_quadratic_dof"])
claims.exact("band_off_gained", L0["band_off_gained"], margin=L0["_match_margin_px"], min_margin=0.5,
             note="centres more than MATCH px from every shipped one; margin in px")
claims.exact("gas_saturates_in_range_Ldp5", A0["gas_saturates_in_range_Ldp5"],
             margin=A0["_sat_min_L5"], min_margin=np.log(2.0),
             note="ln of the smallest outlet over the threshold, over the whole range")
claims.exact("eq9_bands_negative", G0["eq9_bands_negative"], margin=G0["_eq9_band_margin"],
             min_margin=0.1, note="smallest |band bias| of eq. (9)")
claims.exact("fig2_eq9_within_band", G0["fig2_eq9_within_band"],
             margin=G0["fig2_eq9_nearest_px"] - DASH_HW, min_margin=1.0,
             note="nearest marker beyond the erased band, px")
claims.exact("fig2_markers_Re_1_30", G0["fig2_markers_Re_1_30"], margin=G0["_lowre_margin_px"],
             min_margin=1.0, note="nearest marker to the window ends, px")
claims.exact("n_paired", R0["n_paired"])
claims.exact("n_recentred", R0["n_recentred"])
claims.exact("n_crosshair_kept", R0["n_crosshair_kept"])
claims.exact("n_double_counts", R0["n_double_counts"])
claims.exact("n_second_pass", R0["n_second_pass"])
claims.exact("n_merged_pairs_below_6px", R0["n_merged_pairs_below_6px"], margin=R0["_sep_margin_px"],
             min_margin=0.5, note="nearest separation to 6 px")
claims.exact("liquid_free_beta_below_printed", L0["liquid_free_beta_below_printed"],
             margin=(C0["beta"] - L0["liquid_free_beta_max_shipped"]) / L0["refit_liquid_beta_se"],
             min_margin=1.0, note="six free fits; margin in iid standard errors of beta")
print(f"{len(claims.metrics)} claims registered")
bootstrap seed spread (relative, x3): alpha_cluster_ci_lo 7.6e-03, alpha_cluster_ci_hi 4.6e-03, beta_cluster_ci_lo 4.8e-03, beta_cluster_ci_hi 1.9e-03, alpha_se_cluster 2.1e-02, beta_se_cluster 2.4e-02, cluster_se_inflation_alpha 2.1e-02, z_alpha_cluster 2.1e-02, z_beta_cluster 2.4e-02, band_halfwidth_over_cluster_se 2.1e-02
128 claims registered

The defect-injection table

Every claim above needs a row that fails its own check. Each row injects one defect through an argument of the studies, and every study the defect can reach is rerun in full, so every claim it moves is tested: a mis-read printed constant (the constants dictionary), a wrong boundary condition, geometry, sign or sink in the solver, a mis-read axis calibration, and a wrong data choice (erasure band, duplicate rows, the bootstrap’s resampling unit). A quantity that cannot be computed under a defect records nan, which fails its claim.

BREAK = []


def brk(row, name, broken):
    if isinstance(broken, (float, np.floating)) and not np.isfinite(broken):
        broken = np.nan
    BREAK.append((row, name, claims.breaks(row, name, broken)))


def with_(d, **kw):
    return {**d, **kw}


def run_row(label, C=C0, cal2=CAL2, cal3=CAL3, gas_kw={}, rec_kw={}, liq_kw={}, alg_kw={}):
    """Rerun every data and algebra study under one defect; register every claim."""
    out = {**algebra(C, **alg_kw), **gas_study(C, cal2, **gas_kw), **recentre_study(C, cal2, **rec_kw),
           **liquid_study(C, cal3, **liq_kw)}
    out["gas_vs_liquid_se_ratio_alpha"] = out["refit_gas_alpha_se"] / out["refit_liquid_alpha_se"]
    out["gas_vs_liquid_se_ratio_beta"] = out["refit_gas_beta_se"] / out["refit_liquid_beta_se"]
    out["csv_vs_pixel_conversion"] = csv_round(cal2, cal3)
    for name in claims.metrics:
        if name in out:
            brk(label, name, out[name])


def bed_row(label, **kw):
    out = bed_study(**kw)
    for name in claims.metrics:
        if name in out:
            brk(label, name, out[name])


run_row("eq. (2) stagnant term 20 read as the inert 0.28", C=with_(C0, E0=C0["E0_inert"] * C0["eps_b"]))
run_row("dispersion slope 0.5 read as 0.05", C=with_(C0, slope=0.05))
run_row("Petrovic-Thodos Pe = 2 read as 20", C=with_(C0, pe_pt=20.0))
run_row("Figure 2 Sc 0.6 read as 6.0", C=with_(C0, sc_fig2=6.0))
run_row("alpha 1.1 read as 1.2", C=with_(C0, alpha=1.2))
run_row("beta 0.6 read as 0.65", C=with_(C0, beta=0.65))
run_row("eps_b 0.40 read as 0.50", C=with_(C0, eps_b=0.5))
run_row("Ranz-Marshall 0.6 read as 6 (decimal point lost)", C=with_(C0, alpha_ranz=6.0))
run_row("Petrovic-Thodos 0.357 read as 0.457", C=with_(C0, jd_pt=0.457))
run_row("Petrovic-Thodos exponent -0.359 read as -0.459", C=with_(C0, jd_exp_pt=-0.459))
run_row("inert stagnant term (0.6 to 0.8) read as (6 to 8)", C=with_(C0, E0_inert=7.0))
run_row("range of eq. (12) starts at Re = 10, not 3", C=with_(C0, re_lo=10.0))
bed_row("Dirichlet inlet theta = 1, not Danckwerts", bc_in={"a": 0.0, "b": 1.0, "d": 1.0})
bed_row("cylindrical divergence, nu = 1", nu=1)
bed_row("dispersion term sign flipped", disp=-1.0)
bed_row("sink term dropped from the solve", sink=0.0)
bed_row("ladder started at 4 cells, before the asymptotic range", ladder=(4, 8, 16, 32, 64))
run_row("Figure 3 Re origin 10 px off", cal3=with_(CAL3, col0=CAL3["col0"] + 10))
run_row("Figure 3 ordinate origin 10 px off", cal3=with_(CAL3, row0_l=CAL3["row0_l"] + 10,
                                                         row0_r=CAL3["row0_r"] + 10,
                                                         row0_flat=CAL3["row0_flat"] + 10))
run_row("Figure 3 decade heights swapped x <-> y",
        cal3=with_(CAL3, pxdec_x=CAL3["pxdec_flat"], pxdec_l=CAL3["pxdec_x"],
                   pxdec_r=CAL3["pxdec_x"], pxdec_flat=CAL3["pxdec_x"]))
run_row("Figure 3 left and right tick rows swapped",
        cal3=with_(CAL3, row0_l=CAL3["row0_r"], row0_r=CAL3["row0_l"]))
run_row("Figure 2 ordinate origin 10 px off", cal2=with_(CAL2, row0=CAL2["row0"] - 10))
run_row("Figure 2 Re origin 10 px off", cal2=with_(CAL2, col0=CAL2["col0"] + 10))
run_row("erasure band 0 px shipped instead of 5", liq_kw=dict(shipped=0))
run_row("band sweep without its band-off block", liq_kw=dict(bands=[3, 5, 7, 10, 14]))
run_row("Figure 2 rows not grouped by marker_id", gas_kw=dict(dedupe=False),
        rec_kw=dict(dedupe_ids=False))
run_row("Figure 2 second-pass markers dropped", gas_kw=dict(second_pass=False),
        rec_kw=dict(second_pass=False))
run_row("Figure 2 decade heights swapped x <-> y",
        cal2=with_(CAL2, pxdec_x=CAL2["pxdec_y"], pxdec_y=CAL2["pxdec_x"]))
def theta_eq7_wrong_A(N, Pe):
    """eq. (7) with A = sqrt(1 + 2 N / Pe_L) instead of 4 N / Pe_L."""
    A, B = np.sqrt(1.0 + 2.0 * N / Pe), 0.5 * Pe
    return 4 * A * np.exp(B - A * B) / ((1 + A) ** 2 - (1 - A) ** 2 * np.exp(-2 * A * B))


brk(theta_eq7_wrong_A.__doc__.rstrip("."), "eq7_eq8_rate", eq7_rate(theta_eq7_wrong_A))
run_row("re-centring statistics over all paired rows", rec_kw=dict(moved_only=False))
run_row("paired sample without the markers that keep the crosshair",
        rec_kw=dict(keep_crosshair_rows=False))
run_row("re-analysis route with a Dirichlet inlet theta = 1, not Danckwerts",
        alg_kw=dict(route_bc={"a": 0.0, "b": 1.0, "d": 1.0}))
run_row("bootstrap resamples markers, not Re bins", liq_kw=dict(cluster=False))
run_row("Newey-West without lags", liq_kw=dict(hac_lags=(0,)))
run_row("Figure 2 refit unweighted", gas_kw=dict(sigma=False))

bt = pd.DataFrame(BREAK, columns=["row", "claim", "caught"])
caught = bt[bt.caught].groupby("row", sort=False).claim.apply(list)
print(f"{len(bt.row.unique())} rows; number of claims each row fails:")
for row, names in caught.items():
    print(f"  {len(names):3d}  {row}")
idle = [r for r in bt.row.unique() if r not in caught.index]
print(f"\nrows that fail no claim: {idle if idle else 'none'}")
missing = [n for n in claims.metrics
           if not claims.metrics[n].get("structural") and not bt[(bt.claim == n) & bt.caught].shape[0]]
print(f"claims no row fails: {missing if missing else 'none'}")
print(f"\npage computed (here {time.time() - T_START:.0f} s)")
/tmp/claude-1000/-home-eajfpeters-Code-pymrm-suite-pymrm-gallery/87e352fb-4054-4878-884f-1f9fd5ae35e7/scratchpad/wt/pages/A3.4-wakao-funazkri/model.py:78: RuntimeWarning: invalid value encountered in scalar divide
  resid = float(np.abs(jac @ th - rhs).max() / np.abs(rhs).max())
/tmp/claude-1000/-home-eajfpeters-Code-pymrm-suite-pymrm-gallery/87e352fb-4054-4878-884f-1f9fd5ae35e7/scratchpad/venv34/lib/python3.13/site-packages/numpy/_core/fromnumeric.py:3862: RuntimeWarning: Mean of empty slice
  return _methods._mean(a, axis=axis, dtype=dtype,
/tmp/claude-1000/-home-eajfpeters-Code-pymrm-suite-pymrm-gallery/87e352fb-4054-4878-884f-1f9fd5ae35e7/scratchpad/venv34/lib/python3.13/site-packages/numpy/_core/_methods.py:142: RuntimeWarning: invalid value encountered in scalar divide
  ret = ret.dtype.type(ret / rcount)
35 rows; number of claims each row fails:
   14  eq. (2) stagnant term 20 read as the inert 0.28
   12  dispersion slope 0.5 read as 0.05
    6  Petrovic-Thodos Pe = 2 read as 20
   26  Figure 2 Sc 0.6 read as 6.0
   25  alpha 1.1 read as 1.2
   35  beta 0.6 read as 0.65
   15  eps_b 0.40 read as 0.50
    5  Ranz-Marshall 0.6 read as 6 (decimal point lost)
   10  Petrovic-Thodos 0.357 read as 0.457
   10  Petrovic-Thodos exponent -0.359 read as -0.459
    1  inert stagnant term (0.6 to 0.8) read as (6 to 8)
    2  range of eq. (12) starts at Re = 10, not 3
    2  Dirichlet inlet theta = 1, not Danckwerts
    5  cylindrical divergence, nu = 1
    4  dispersion term sign flipped
    5  sink term dropped from the solve
    4  ladder started at 4 cells, before the asymptotic range
   47  Figure 3 Re origin 10 px off
   40  Figure 3 ordinate origin 10 px off
   61  Figure 3 decade heights swapped x <-> y
   55  Figure 3 left and right tick rows swapped
   22  Figure 2 ordinate origin 10 px off
   17  Figure 2 Re origin 10 px off
   48  erasure band 0 px shipped instead of 5
    9  band sweep without its band-off block
   21  Figure 2 rows not grouped by marker_id
   20  Figure 2 second-pass markers dropped
   27  Figure 2 decade heights swapped x <-> y
    1  eq. (7) with A = sqrt(1 + 2 N / Pe_L) instead of 4 N / Pe_L
    6  re-centring statistics over all paired rows
    4  paired sample without the markers that keep the crosshair
    1  re-analysis route with a Dirichlet inlet theta = 1, not Danckwerts
   11  bootstrap resamples markers, not Re bins
    2  Newey-West without lags
    7  Figure 2 refit unweighted

rows that fail no claim: none
claims no row fails: none

page computed (here 81 s)

What pymrm adds

Not the correlation, and not the figure analysis. Eq. (12) is three constants and the refits of Figures 2 and 3 are NumPy and SciPy statistics; pymrm has no part in them.

What pymrm adds is the bed model as a solved boundary-value problem. It discretises eq. (4) with the Danckwerts conditions written on the outward normal and reads the outlet through the same boundary condition, and on a ladder of grids it shows that the paper’s eq. (7) is that problem’s solution (validation check 1). The page’s inversions then run on eq. (7), which is exact and cheap; result 3 runs the same re-analysis on the pymrm solve, with the same root finder, and agrees to the discretisation error. That route is the one a bed model with no closed form (a nonlinear rate, a varying velocity) would need. The root finding and the convergence orders use pymrm.checks.

The claims this page does not make.

  • It does not claim the printed \(\alpha\) and \(\beta\) are wrong: the estimator, the axis calibration, the erasure band and the error model each move the free fit by about as much as its distance from the printed pair (result 5).
  • It does not claim a marker count. Figure 2’s recall in the dense band at high \(\mathrm{Re}\) is short; Figure 3’s markers merge into chains along the drawn correlation, and markers lying on that line are the ones most likely to be missed, which biases a refit away from the printed values. The band sweep bounds that from one side only.
  • It does not correct Figure 2’s ordinate, for the reason in check 4.
  • It does not claim series identity on either figure. The fitted marker shape is recorded in both CSVs but is advisory, and nothing on the page reads it.
  • It does not claim the extractions are reviewed; the sidecars say they are not.
  • The bed voidage is inferred from the drawn eq. (11) line, not stated; \(L/d_p\) is swept because the paper does not tabulate bed heights per source.

Reuse

The bed model is the reusable object. theta_pymrm in model.py is a steady 1-D convection-diffusion-reaction solve with Danckwerts boundary conditions, the S3 structure, written in \((N, \mathrm{Pe}_L)\) only, so it applies unchanged to a first-order catalytic bed, an adsorber, or any closed vessel with a linear sink. theta_eq7 is its analytical solution and a free regression test.

Sh_of_theta converts between dispersion assumptions. Every packed-bed transfer coefficient carries an implicit axial dispersion assumption; a pre-1978 correlation and a modern one are usually not the same quantity.

Use eq. (2) with eq. (12), not one without the other. Conclusion 3 of the paper: the correlation was extracted under a particular dispersion coefficient and is self-consistent only with it. Pairing eq. (12) with \(D_{ax}=0\), or with the inert-tracer coefficient of eq. (3), puts back the error it was built to remove.

Range. \(3 \lesssim \mathrm{Re} \le 10^4\), spheres, all particles active, more than two particle layers, resistance in the fluid film only. Below the range the correlation’s value is the limit \(\mathrm{Sh}\to 2\), a physical statement rather than a fit.

Related pages. A3.5, D1.3 (packed-bed transport), B1.1 (the intraparticle problem this one excludes), A2.3 (where axial dispersion coefficients come from).

Cite the source, not this page: Wakao, N. and Funazkri, T., Effect of fluid dispersion coefficients on particle-to-fluid mass transfer coefficients in packed beds, Chemical Engineering Science 33(10) 1375-1384 (1978), doi:10.1016/0009-2509(78)85120-3.

# 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 payload
Claims for A3.4:
  dax_double_ScRe                             = 40               value  rtol 1e-06
  dax_double_Re_gas                           = 66.667           value  rtol 1e-06
  dax_double_Re_liquid                        = 0.04             value  rtol 1e-06
  inert_stagnant_ratio                        = 71.429           value  rtol 1e-06
  liquid_correction_max                       = 0.013254         value  rtol 1e-06
  liquid_correction_max_all_Ldp               = 0.013881         value  rtol 1e-06
  gas_correction_Re3_Ldp5                     = 6.9187           value  rtol 1e-06
  eq11_reanalysed_vs_eq12_mean_abs_dev_logRe  = 0.016733         value  rtol 1e-06
  eq11_reanalysed_vs_eq12_worst               = 0.044761         value  rtol 1e-06
  eq11_reanalysis_Ldp_effect                  = 0.010518         value  rtol 1e-06
  eq11_reanalysis_Ldp_effect_mean             = 0.0023859        value  rtol 1e-06
  eq11_reanalysis_Ldp_effect_worst            = 0.00072627       value  rtol 1e-06
  eq11_crosses_sh2_Re                         = 4.5925           value  rtol 1e-06
  gas_saturation_Re_Ldp10                     = 85.008           value  rtol 1e-06
  pymrm_vs_eq7_n1600                          = 0.0021399        value  rtol 1e-06
  eq7_eq8_rate                                = 0.99995          value  rtol 1e-06
  eq12_vs_fig2_mean_abs_dev                   = 0.27207          value  rtol 1e-06
  eq12_vs_fig2_bias                           = 0.09716          value  rtol 1e-06
  eq12_vs_fig2_bias_se                        = 0.036324         value  rtol 1e-06
  eq11_vs_fig2_bias                           = -0.031436        value  rtol 1e-06
  eq11_vs_fig2_bias_se                        = 0.038517         value  rtol 1e-06
  eq9_vs_fig2_bias                            = -0.55106         value  rtol 1e-06
  eq9_vs_fig2_bias_se                         = 0.01635          value  rtol 1e-06
  fig2_span_eq12                              = 0.15484          value  rtol 1e-06
  fig2_span_eq11                              = 0.54691          value  rtol 1e-06
  fig2_span_ratio_eq11_over_eq12              = 3.5321           value  rtol 1e-06
  fig2_sh_per_px                              = 0.0068423        value  rtol 1e-06
  fig2_eq9_nearest_px                         = 11.652           value  rtol 1e-06
  fig2_eq9_expected_in_band                   = 0.54             value  rtol 1e-06
  refit_gas_alpha                             = 1.1847           value  rtol 0.0001
  refit_gas_beta                              = 0.54669          value  rtol 0.0001
  refit_gas_alpha_se                          = 0.2023           value  rtol 0.0001
  refit_gas_beta_se                           = 0.032595         value  rtol 0.0001
  refit_gas_max_z                             = 1.6356           value  rtol 0.0001
  recentre_median_shift_px                    = 3.2882           value  rtol 1e-06
  recentre_row_shift_px                       = 0.77692          value  rtol 1e-06
  recentre_row_shift_z                        = 2.5798           value  rtol 1e-06
  recentre_sh_shift                           = -0.0051501       value  rtol 1e-06
  recentre_alpha_shift_in_se                  = 0.075466         value  rtol 0.0001
  recentre_beta_shift_in_se                   = 0.05787          value  rtol 0.0001
  fig3_tick_skew_px                           = 5.64             value  rtol 1e-06
  fig3_rotation_deg                           = 0.20336          value  rtol 1e-06
  fig3_line_offset_sd_flat_px                 = 3.8287           value  rtol 1e-06
  fig3_line_offset_sd_skew_px                 = 3.5415           value  rtol 1e-06
  fig3_drawn_line_alpha_flat                  = 1.1262           value  rtol 1e-06
  fig3_drawn_line_beta_flat                   = 0.59486          value  rtol 1e-06
  fig3_drawn_line_alpha_skew                  = 1.1073           value  rtol 1e-06
  fig3_drawn_line_beta_skew                   = 0.59836          value  rtol 1e-06
  refit_liquid_alpha_band_lo                  = 1.2959           value  rtol 1e-06
  refit_liquid_alpha_band_hi                  = 1.4401           value  rtol 1e-06
  refit_liquid_beta_band_lo                   = 0.53282          value  rtol 1e-06
  refit_liquid_beta_band_hi                   = 0.55885          value  rtol 1e-06
  refit_liquid_alpha_beta_fixed_logmetric_lo  = 1.0977           value  rtol 1e-06
  refit_liquid_alpha_beta_fixed_logmetric_hi  = 1.1179           value  rtol 1e-06
  refit_liquid_alpha_beta_fixed_grid_lo       = 1.0067           value  rtol 1e-06
  refit_liquid_alpha_beta_fixed_grid_hi       = 1.1179           value  rtol 1e-06
  refit_liquid_alpha_free_grid_lo             = 1.0513           value  rtol 0.0001
  refit_liquid_alpha_free_grid_hi             = 1.4648           value  rtol 0.0001
  band_off_gained_median_line_px              = 2.8673           value  rtol 1e-06
  band_off_gained_median_score                = 0.6925           value  rtol 1e-06
  shipped_median_score                        = 0.793            value  rtol 1e-06
  refit_liquid_alpha                          = 1.3437           value  rtol 1e-06
  refit_liquid_beta                           = 0.5496           value  rtol 1e-06
  refit_liquid_alpha_se                       = 0.052049         value  rtol 1e-06
  refit_liquid_beta_se                        = 0.0090012        value  rtol 1e-06
  refit_liquid_alpha_linear_metric            = 1.1567           value  rtol 0.0001
  refit_liquid_beta_linear_metric             = 0.58444          value  rtol 0.0001
  refit_liquid_alpha_theil_sen                = 1.1899           value  rtol 0.0001
  refit_liquid_beta_theil_sen                 = 0.55579          value  rtol 0.0001
  refit_liquid_alpha_flat_calibration         = 1.3667           value  rtol 1e-06
  refit_liquid_beta_flat_calibration          = 0.54599          value  rtol 1e-06
  refit_liquid_alpha_beta_fixed               = 1.1              value  rtol 1e-06
  refit_liquid_alpha_beta_fixed_linear_metric = 1.0469           value  rtol 1e-06
  refit_liquid_alpha_beta_fixed_median        = 1.0518           value  rtol 1e-06
  liquid_free_beta_max_shipped                = 0.58444          value  rtol 0.0001
  refit_liquid_alpha_beta_fixed_Re_ge_50      = 1.0199           value  rtol 1e-06
  refit_liquid_alpha_Re_ge_20                 = 1.0093           value  rtol 1e-06
  refit_liquid_beta_Re_ge_20                  = 0.60448          value  rtol 1e-06
  refit_liquid_beta_rise_3_to_50              = 0.081986         value  rtol 1e-06
  refit_liquid_quadratic_term                 = 0.055827         value  rtol 1e-06
  refit_liquid_quadratic_term_z               = 5.0128           value  rtol 1e-06
  hac_inflation_alpha_min                     = 1.4225           value  rtol 1e-06
  hac_inflation_alpha_max                     = 1.9109           value  rtol 1e-06
  refit_liquid_z_alpha_iid                    = 4.683            value  rtol 1e-06
  band_halfwidth_over_iid_se                  = 1.3848           value  rtol 1e-06
  eq12_vs_fig3_mean_abs_dev                   = 0.17615          value  rtol 1e-06
  eq12_vs_fig3_bias                           = 0.022926         value  rtol 1e-06
  eq12_vs_fig3_bias_se                        = 0.015774         value  rtol 1e-06
  fig3_excess_3_6                             = 0.24338          value  rtol 1e-06
  fig3_excess_6_12                            = 0.11434          value  rtol 1e-06
  fig3_excess_3_6_z                           = 4.7268           value  rtol 1e-06
  fig3_excess_6_12_z                          = 2.1732           value  rtol 1e-06
  refit_liquid_alpha_cluster_ci_lo            = 1.0408           value  rtol 0.0076043
  refit_liquid_alpha_cluster_ci_hi            = 1.5315           value  rtol 0.0045644
  refit_liquid_beta_cluster_ci_lo             = 0.5082           value  rtol 0.00482024
  refit_liquid_beta_cluster_ci_hi             = 0.59876          value  rtol 0.00190326
  refit_liquid_alpha_se_cluster               = 0.13186          value  rtol 0.0207886
  refit_liquid_beta_se_cluster                = 0.022903         value  rtol 0.0241239
  refit_liquid_cluster_se_inflation_alpha     = 2.5335           value  rtol 0.0207886
  refit_liquid_z_alpha_cluster                = 1.8484           value  rtol 0.0206669
  refit_liquid_z_beta_cluster                 = 2.2007           value  rtol 0.023885
  band_halfwidth_over_cluster_se              = 0.54661          value  rtol 0.0206669
  gas_vs_liquid_se_ratio_alpha                = 3.8868           value  rtol 0.0001
  gas_vs_liquid_se_ratio_beta                 = 3.6212           value  rtol 0.0001
  csv_vs_pixel_conversion                     = (here 8.1e-05)   bound  bound <= 0.0005 [tolerance]
  pymrm_order_min                             = (here 1.0e+00)   bound  bound [0.95, 1.1] [theory]
  pymrm_order_max                             = (here 1.0e+00)   bound  bound [0.95, 1.1] [theory]
  pymrm_vs_eq7_richardson                     = (here 3.9e-06)   bound  bound <= 1e-05 [theory]
  pymrm_finest_diff_over_residual             = (here 5.3e+07)   bound  bound >= 1000 [theory]
  reanalysis_route_gap                        = (here 3.6e-04)   bound  bound <= 0.001 [theory]
  sh_limit_eq12_eq9                           = (here 4.4e-16)   bound  bound <= 1e-12 [identity]
  refit_liquid_beta_ci_hi_gap_in_se           = (here 5.4e-02)   bound  bound [-0.1, 0.1] [chosen]
  n_fig2_markers                              = 79               exact  exact
  n_fig3_markers                              = 182              exact  exact
  n_fig3_markers_band_off                     = 210              exact  exact
  refit_liquid_quadratic_dof                  = 179              exact  exact
  band_off_gained                             = 32               exact  exact, margin 0.659
  gas_saturates_in_range_Ldp5                 = False            exact  exact, margin 1.42
  eq9_bands_negative                          = 4                exact  exact, margin 0.372
  fig2_eq9_within_band                        = 0                exact  exact, margin 2.65
  fig2_markers_Re_1_30                        = 7                exact  exact, margin 2.97
  n_paired                                    = 81               exact  exact
  n_recentred                                 = 65               exact  exact
  n_crosshair_kept                            = 16               exact  exact
  n_double_counts                             = 11               exact  exact
  n_second_pass                               = 9                exact  exact
  n_merged_pairs_below_6px                    = 8                exact  exact, margin 0.975
  liquid_free_beta_below_printed              = 6                exact  exact, margin 1.73