psdm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
psdm/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Pore and Surface Diffusion Model (PSDM) for fixed-bed adsorbers."""
2
+
3
+ from . import fullscale
4
+ from .capacity import CapacityEstimate, estimate_capacity
5
+ from .fitting import DsFitResult, FitResult, fit_freundlich, fit_surface_diffusivity, sum_of_squares
6
+ from .isotherms import IsothermFit, fit_isotherm
7
+ from .model import Column, Compound
8
+ from .solver import Result, simulate
9
+ from .uncertainty import Uncertainty, model_uncertainty
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "CapacityEstimate",
15
+ "Column",
16
+ "Compound",
17
+ "DsFitResult",
18
+ "FitResult",
19
+ "IsothermFit",
20
+ "Result",
21
+ "Uncertainty",
22
+ "__version__",
23
+ "estimate_capacity",
24
+ "fit_freundlich",
25
+ "fit_isotherm",
26
+ "fit_surface_diffusivity",
27
+ "fullscale",
28
+ "model_uncertainty",
29
+ "simulate",
30
+ "sum_of_squares",
31
+ ]
psdm/capacity.py ADDED
@@ -0,0 +1,279 @@
1
+ """Adsorbent capacity from column breakthrough data when ``K`` is unknown.
2
+
3
+ The loading at breakthrough follows from a mass balance over the column,
4
+
5
+ .. math::
6
+
7
+ q = \\frac{Q}{M} \\int_0^{t_b} \\left(C_{in}(t) - C_{out}(t)\\right) dt,
8
+
9
+ and a Freundlich ``K`` for an assumed ``1/n`` from :math:`K = q / \\bar C^{1/n}` with
10
+ :math:`\\bar C` the mean influent concentration up to breakthrough :math:`t_b`. When the
11
+ effluent has not yet reached the influent concentration, :math:`t_b` is extrapolated with a
12
+ logistic curve or a straight line, as in ``PSDM.__calculate_capacity`` of the US EPA code.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ from scipy.optimize import curve_fit
22
+ from scipy.stats import linregress
23
+
24
+ from . import units
25
+ from .model import Column
26
+
27
+ MAX_BREAKTHROUGH_DAYS = 2000.0
28
+ """Extrapolated breakthrough times are capped at this many days."""
29
+
30
+ MIN_LOADING = 1e-6
31
+ """Smallest loading (ug/g) returned when a breakthrough was found."""
32
+
33
+ METHODS = (
34
+ "breakthrough",
35
+ "exceeds mean influent",
36
+ "logistic",
37
+ "linear, single point",
38
+ "linear regression",
39
+ "minimum",
40
+ )
41
+ """Possible values of :attr:`CapacityEstimate.method`.
42
+
43
+ ``breakthrough``
44
+ The effluent reached the influent concentration in the data.
45
+ ``exceeds mean influent``
46
+ The effluent reached the mean influent concentration in the data.
47
+ ``logistic``
48
+ Breakthrough extrapolated with :func:`logistic` (99 % of the plateau).
49
+ ``linear, single point`` and ``linear regression``
50
+ Breakthrough extrapolated with a straight line through the last effluent points.
51
+ ``minimum``
52
+ Too few non-zero effluent values; the whole data period is used, so ``q`` and ``K``
53
+ are lower bounds.
54
+ """
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CapacityEstimate:
59
+ """Output of :func:`estimate_capacity`.
60
+
61
+ Attributes
62
+ ----------
63
+ k
64
+ Freundlich ``K`` for :attr:`n_inv`, (ug/g)(L/ug)^(1/n).
65
+ n_inv
66
+ The assumed ``1/n``.
67
+ q
68
+ Adsorbent loading at breakthrough, ug/g.
69
+ mean_influent
70
+ Mean influent concentration up to breakthrough, in the data's concentration unit.
71
+ breakthrough_time
72
+ Time of (possibly extrapolated) breakthrough, in the data's time unit.
73
+ method
74
+ How the breakthrough time was found, one of :data:`METHODS`.
75
+ """
76
+
77
+ k: float
78
+ n_inv: float
79
+ q: float
80
+ mean_influent: float
81
+ breakthrough_time: float
82
+ method: str
83
+ _mean_influent_ug: float
84
+
85
+ def k_for(self, n_inv):
86
+ """Freundlich ``K`` that gives the same loading :attr:`q` for another ``1/n``."""
87
+ return self.q / self._mean_influent_ug ** np.asarray(n_inv, dtype=float)
88
+
89
+
90
+ def logistic(t, c0: float, a: float, b: float):
91
+ """Logistic breakthrough curve ``c0 / (1 + exp(a - b t))``."""
92
+ return c0 / (1.0 + np.exp(a - b * np.asarray(t, dtype=float)))
93
+
94
+
95
+ def _interp(t, xp, fp):
96
+ """Linear interpolation with linear extrapolation beyond both ends."""
97
+ t = np.asarray(t, dtype=float)
98
+ y = np.interp(t, xp, fp)
99
+ if len(xp) > 1:
100
+ lo, hi = t < xp[0], t > xp[-1]
101
+ y[lo] = fp[0] + (t[lo] - xp[0]) * (fp[1] - fp[0]) / (xp[1] - xp[0])
102
+ y[hi] = fp[-1] + (t[hi] - xp[-1]) * (fp[-1] - fp[-2]) / (xp[-1] - xp[-2])
103
+ return y
104
+
105
+
106
+ def _with_point(t, c, t_new, c_new):
107
+ """Insert (or replace) the point ``(t_new, c_new)`` keeping ``t`` sorted."""
108
+ keep = t != t_new
109
+ t, c = np.append(t[keep], t_new), np.append(c[keep], c_new)
110
+ order = np.argsort(t, kind="stable")
111
+ return t[order], c[order]
112
+
113
+
114
+ def _integral(t_end, t, c):
115
+ """Integral of the piecewise-linear (extrapolated) curve ``(t, c)`` from 0 to ``t_end``."""
116
+ knots = np.unique(np.concatenate([[0.0, t_end], t[(t > 0) & (t < t_end)]]))
117
+ y = _interp(knots, t, c)
118
+ return float(np.sum(0.5 * (y[1:] + y[:-1]) * np.diff(knots)))
119
+
120
+
121
+ def _last_time(t, below=np.inf):
122
+ """Last positive, finite data time smaller than ``below`` (``0`` if there is none)."""
123
+ ok = t[np.isfinite(t) & (t > 0) & (t < below)]
124
+ return float(ok[-1]) if len(ok) else 0.0
125
+
126
+
127
+ def _breakthrough(t, c_in, c_out, mean_in, grid, cap):
128
+ """Find or extrapolate the breakthrough time (days).
129
+
130
+ Returns ``(time, method, t_in, c_in, t_out, c_out)``; the curves may gain an extra
131
+ extrapolated point at the breakthrough time.
132
+ """
133
+ duration = t[-1]
134
+ t_in, t_out = t.copy(), t.copy()
135
+
136
+ nonzero = np.count_nonzero(c_out)
137
+ if nonzero == 0:
138
+ return duration, "minimum", t_in, c_in, t_out, c_out
139
+ if nonzero == 1 and not (c_out[-1] > 0 and c_out[-1] / mean_in >= 0.25):
140
+ # a single non-zero value that is not a high last point cannot be extrapolated
141
+ return duration, "minimum", t_in, c_in, t_out, c_out
142
+
143
+ if c_out.max() >= c_in.min():
144
+ f_in, f_out = _interp(grid, t, c_in), _interp(grid, t, c_out)
145
+ checks = ((f_out >= f_in, "breakthrough"), (f_out >= mean_in, "exceeds mean influent"))
146
+ for crossed, method in checks:
147
+ if crossed.any():
148
+ time = max(grid[np.argmax(crossed)] - (grid[1] - grid[0]), 0.0) # last before
149
+ return time, method, t_in, c_in, t_out, c_out
150
+
151
+ try:
152
+ lo, hi = min(0.99 * mean_in, c_out[0]), max(mean_in, c_out[0])
153
+ params, _ = curve_fit(
154
+ logistic,
155
+ t,
156
+ c_out,
157
+ p0=(mean_in, 5.0, 0.1),
158
+ bounds=((lo, 0.0, 0.0), (hi, 1e3, 1.0)),
159
+ maxfev=10000,
160
+ )
161
+ except (RuntimeError, ValueError):
162
+ params = None
163
+ if params is not None:
164
+ time = float(np.round((params[1] + np.log(99.0)) / params[2]))
165
+ if time >= duration:
166
+ t_in, c_in = _with_point(t_in, c_in, time, mean_in)
167
+ t_out, c_out = _with_point(t_out, c_out, time, logistic(time, *params))
168
+ return (duration if time > cap else time), "logistic", t_in, c_in, t_out, c_out
169
+ if time > 0.25 * duration:
170
+ return time, "logistic", t_in, c_in, t_out, c_out
171
+
172
+ if c_out[-1] > 0 and c_out[-1] / mean_in >= 0.25 and len(t) > 1:
173
+ method = "linear, single point"
174
+ slope = c_out[-1] / (t[-1] - t[-2])
175
+ intercept = -c_out[-1] * t[-2] / (t[-1] - t[-2])
176
+ else:
177
+ method = "linear regression"
178
+ n = np.count_nonzero(c_out)
179
+ if n < len(c_out) and c_out[-(n + 1)] != 0:
180
+ n += 1
181
+ slope, intercept = linregress(t[-(n + 1) :], c_out[-(n + 1) :])[:2]
182
+ with np.errstate(divide="ignore", invalid="ignore"):
183
+ time = float(np.round((mean_in - intercept) / slope))
184
+ if not np.isfinite(time):
185
+ return _last_time(t), method, t_in, c_in, t_out, c_out
186
+ if time <= 0:
187
+ time = _last_time(t)
188
+ t_in, c_in = _with_point(t_in, c_in, time, mean_in)
189
+ t_out, c_out = _with_point(t_out, c_out, time, mean_in)
190
+ return time, method, t_in, c_in, t_out, c_out
191
+
192
+
193
+ def estimate_capacity(
194
+ column: Column,
195
+ influent: pd.Series,
196
+ effluent: pd.Series,
197
+ *,
198
+ n_inv: float = 0.45,
199
+ time_unit: str = "days",
200
+ conc_unit: str = "ug/L",
201
+ resolution: float = 1.0,
202
+ ) -> CapacityEstimate:
203
+ """Estimate Freundlich ``K`` from a breakthrough curve for an assumed ``1/n``.
204
+
205
+ The breakthrough time is the last time before the effluent reaches the influent (or
206
+ mean influent) concentration. If the effluent never gets there, a logistic curve
207
+ ``C0 / (1 + exp(a - b t))`` (``t`` in days) is fitted to the effluent and breakthrough is
208
+ taken at 99 % of ``C0``; if that fails or predicts breakthrough in the first quarter of
209
+ the data, a straight line through the last effluent points is extrapolated to the mean
210
+ influent concentration. Extrapolated breakthrough beyond :data:`MAX_BREAKTHROUGH_DAYS`
211
+ falls back to the end of the data. The loading ``q`` is the integral of influent minus
212
+ effluent (linearly interpolated, with the extrapolated point appended) up to
213
+ breakthrough, times flow rate over adsorbent mass.
214
+
215
+ This is ``PSDM.__calculate_capacity`` of the US EPA code with ``break_type='calc'``.
216
+
217
+ Parameters
218
+ ----------
219
+ column
220
+ The adsorber; only ``flow_rate`` and ``mass`` are used.
221
+ influent, effluent
222
+ Concentrations of one compound indexed by time (sharing the same time points, e.g.
223
+ :attr:`psdm.io.Dataset.influent` and ``effluent`` columns).
224
+ n_inv
225
+ Assumed Freundlich ``1/n``; use :meth:`CapacityEstimate.k_for` for other values.
226
+ time_unit, conc_unit
227
+ Units of the index and values of ``influent`` and ``effluent``.
228
+ resolution
229
+ Time step, in days, of the search for the breakthrough time.
230
+
231
+ Returns
232
+ -------
233
+ CapacityEstimate
234
+ """
235
+ if not resolution > 0:
236
+ raise ValueError("resolution must be positive")
237
+ infl = influent.astype(float).sort_index()
238
+ effl = effluent.astype(float).sort_index()
239
+ if not infl.index.equals(effl.index):
240
+ effl = effl.reindex(infl.index).interpolate(method="index", limit_direction="both")
241
+ if len(infl) < 2:
242
+ raise ValueError("influent and effluent need at least two time points")
243
+ if infl.isna().any() or effl.isna().any():
244
+ raise ValueError("influent and effluent must not contain missing values")
245
+ c_in = infl.to_numpy().copy()
246
+ c_out = effl.to_numpy().copy()
247
+ if c_in.sum() <= 0:
248
+ raise ValueError("the influent contains no mass")
249
+ c_in[c_in == 0] = 1e-3 # as upstream; avoids a zero mean concentration
250
+
251
+ t_mult = units.factor("time", time_unit) # -> minutes
252
+ c_mult = units.factor("concentration", conc_unit) # -> ug/L
253
+ t = infl.index.to_numpy(dtype=float) * t_mult / units.MIN_PER_DAY # days
254
+ mean_in = float(c_in.mean())
255
+ grid = np.arange(0.0, t[-1] + resolution, resolution)
256
+
257
+ time, method, t_in, c_in, t_out, c_out = _breakthrough(
258
+ t, c_in, c_out, mean_in, grid, MAX_BREAKTHROUGH_DAYS
259
+ )
260
+ if time > MAX_BREAKTHROUGH_DAYS:
261
+ time = _last_time(t, MAX_BREAKTHROUGH_DAYS)
262
+ elif time == 0:
263
+ time = resolution # breakthrough cannot be immediate
264
+
265
+ # loading in ug/g: (conc · day) · (ug/L per conc) · (L/day) / g
266
+ flow_l_per_day = column.flow_rate / 1e3 * units.MIN_PER_DAY
267
+ q = (_integral(time, t_in, c_in) - _integral(time, t_out, c_out)) * c_mult
268
+ q = max(q * flow_l_per_day / column.mass, MIN_LOADING)
269
+ mean = float(np.mean(_interp(grid[grid <= time], t_in, c_in)))
270
+ mean_ug = mean * c_mult
271
+ return CapacityEstimate(
272
+ k=float(q / mean_ug**n_inv),
273
+ n_inv=float(n_inv),
274
+ q=float(q),
275
+ mean_influent=mean,
276
+ breakthrough_time=time * units.MIN_PER_DAY / t_mult,
277
+ method=method,
278
+ _mean_influent_ug=mean_ug,
279
+ )
psdm/collocation.py ADDED
@@ -0,0 +1,144 @@
1
+ """Orthogonal collocation operators for the particle (radial) and bed (axial) coordinates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from scipy import sparse, special
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Collocation:
13
+ """Discretisation operators.
14
+
15
+ Attributes
16
+ ----------
17
+ nr
18
+ Radial collocation points, including the particle surface.
19
+ nz
20
+ Axial points in the whole bed (``ne * nz_element - (ne - 1)``).
21
+ wr
22
+ Radial quadrature weights, shape ``(nr,)``.
23
+ br
24
+ Radial Laplacian operator for a sphere, shape ``(nr, nr)``.
25
+ az
26
+ Axial first-derivative operator, shape ``(nz, nz)``.
27
+ """
28
+
29
+ nr: int
30
+ nz: int
31
+ wr: np.ndarray
32
+ br: np.ndarray
33
+ az: np.ndarray
34
+
35
+
36
+ def _radial(nr: int) -> tuple[np.ndarray, np.ndarray]:
37
+ """Symmetric (sphere) polynomials in ``r**2`` at Jacobi roots plus the surface."""
38
+ a = 3 # spherical geometry
39
+ rr = 0.5 * (special.roots_jacobi(nr - 1, 1.0, 0.5)[0] + 1.0)
40
+ r = np.sqrt(np.append(rr, 1.0))
41
+
42
+ powers = np.arange(nr)
43
+ q = r[:, None] ** (2 * powers)
44
+ d = np.zeros((nr, nr))
45
+ for i in range(1, nr):
46
+ d[:, i] = (2 * i) * (2 * i - 2 + a) * r ** (2 * i - 2)
47
+ f = 1.0 / (2.0 * powers + a)
48
+ qinv = np.linalg.inv(q)
49
+ return f @ qinv, d @ qinv
50
+
51
+
52
+ def _lagrange_derivative(x: np.ndarray) -> np.ndarray:
53
+ """First-derivative collocation matrix on points ``x`` via the monomial basis."""
54
+ n = len(x)
55
+ powers = np.arange(n)
56
+ q = x[:, None] ** powers
57
+ c = np.zeros((n, n))
58
+ c[:, 1:] = powers[1:] * x[:, None] ** (powers[1:] - 1)
59
+ return c @ np.linalg.inv(q)
60
+
61
+
62
+ def _axial_single(nz: int) -> np.ndarray:
63
+ z = np.concatenate([[0.0], special.roots_sh_legendre(nz - 2)[0], [1.0]])
64
+ # monomials in (2z - 1) are better conditioned than in z; the operator is identical
65
+ s = 2.0 * z - 1.0
66
+ return 2.0 * _lagrange_derivative(s)
67
+
68
+
69
+ def _axial_elements(ne: int, nz: int) -> np.ndarray:
70
+ """Orthogonal collocation on ``ne`` finite elements, continuity at shared nodes."""
71
+ a = _lagrange_derivative(np.concatenate([[0.0], special.roots_sh_legendre(nz - 2)[0], [1.0]]))
72
+ z = ne * a
73
+ n_total = ne * nz - (ne - 1)
74
+ op = np.zeros((n_total, n_total))
75
+ for k in range(ne):
76
+ lo = k * (nz - 1)
77
+ op[lo : lo + nz, lo : lo + nz] = z
78
+ # rows where elements meet: enforce continuity of the first derivative
79
+ q = a[-1, -1] - a[0, 0]
80
+ for k in range(ne - 1):
81
+ start = k * (nz - 1)
82
+ shared = (k + 1) * (nz - 1)
83
+ op[shared, :] = 0.0
84
+ op[shared, start:shared] = z[-1, :-1] - z[-1, -1] / q * a[-1, :-1]
85
+ op[shared, shared + 1 : shared + nz] = z[-1, -1] / q * a[0, 1:]
86
+ op[0, :] = 0.0 # constant inlet boundary
87
+ return op
88
+
89
+
90
+ def build(nr: int, nz: int, ne: int = 1) -> Collocation:
91
+ """Build collocation operators.
92
+
93
+ Parameters
94
+ ----------
95
+ nr
96
+ Number of radial collocation points (including the surface).
97
+ nz
98
+ Number of axial collocation points per element (including both ends).
99
+ ne
100
+ Number of axial finite elements.
101
+ """
102
+ if nr < 2 or nz < 3 or ne < 1:
103
+ raise ValueError("need nr >= 2, nz >= 3 and ne >= 1")
104
+ wr, br = _radial(nr)
105
+ az = _axial_single(nz) if ne == 1 else _axial_elements(ne, nz)
106
+ return Collocation(nr=nr, nz=az.shape[0], wr=wr, br=br, az=az)
107
+
108
+
109
+ def jacobian_sparsity(n_comp: int, nr: int, nz: int) -> sparse.csr_matrix:
110
+ """Sparsity pattern of the PSDM right-hand side.
111
+
112
+ The state is ordered ``(compound, radial node + bulk, axial node)``; ``nz`` is the total
113
+ number of axial nodes. Particle nodes couple to every particle node of every compound at
114
+ the same axial position (competitive equilibrium) and to their own bulk concentration;
115
+ bulk nodes couple to the bulk along the axis and to the particle surface.
116
+ """
117
+ shape = (n_comp, nr + 1, nz)
118
+ index = np.arange(np.prod(shape)).reshape(shape)
119
+ rows, cols = [], []
120
+
121
+ def connect(r, c):
122
+ r, c = np.broadcast_arrays(r, c)
123
+ rows.append(r.ravel())
124
+ cols.append(c.ravel())
125
+
126
+ particle = index[:, :nr, :] # (n_comp, nr, nz)
127
+ bulk = index[:, nr, :] # (n_comp, nz)
128
+ # particle <-> all particle nodes (all compounds) at the same z
129
+ for z in range(nz):
130
+ p = particle[:, :, z].ravel()
131
+ connect(p[:, None], p[None, :])
132
+ # surface <-> own bulk
133
+ connect(particle[:, nr - 1, 1:], bulk[:, 1:])
134
+ # bulk <-> bulk along the axis, own compound
135
+ for c in range(n_comp):
136
+ connect(bulk[c, 1:, None], bulk[c, None, 1:])
137
+ # bulk <-> surface of every compound at the same z
138
+ for z in range(1, nz):
139
+ connect(bulk[:, z, None], particle[:, nr - 1, z][None, :])
140
+
141
+ n = index.size
142
+ data = np.ones(sum(len(r) for r in rows), dtype=bool)
143
+ mat = sparse.coo_matrix((data, (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))
144
+ return mat.tocsr()
psdm/correlations.py ADDED
@@ -0,0 +1,70 @@
1
+ """Film mass-transfer correlations.
2
+
3
+ Each correlation returns the Sherwood number ``Sh = kf * d_p / D_l``; :func:`film_transfer`
4
+ turns it into the film transfer coefficient ``kf`` (cm/s). Correlations are collected from
5
+ Xu et al. (2013), *J. Zhejiang Univ. Sci. A* 14(3):155–176.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ import numpy as np
13
+
14
+
15
+ def _ohashi(re, sc, ebed):
16
+ re = np.asarray(re, dtype=float)
17
+ regimes = [re < 5.8, re < 500]
18
+ coef = np.select(regimes, [1.58, 1.21], 0.59)
19
+ exponent = np.select(regimes, [0.4, 0.5], 0.6)
20
+ return 2.0 + coef * re**exponent * sc ** (1 / 3)
21
+
22
+
23
+ def _gnielinski(re, sc, ebed):
24
+ sh_lam = 0.644 * re**0.5 * sc ** (1 / 3)
25
+ sh_turb = 0.037 * re**0.8 * sc / (1.0 + 2.443 * re**-0.1 * (sc ** (2 / 3) - 1.0))
26
+ return (2.0 + np.sqrt(sh_lam**2 + sh_turb**2)) * (1.0 + 1.5 * (1.0 - ebed))
27
+
28
+
29
+ SHERWOOD: dict[str, Callable] = {
30
+ # Chern & Chien (2002), modified Gnielinski -- the PSDM/AdDesignS default
31
+ "Chern and Chien": lambda re, sc, eb: (
32
+ (1 + 1.5 * (1 - eb)) * (2 + 0.644 * re**0.5 * sc ** (1 / 3))
33
+ ),
34
+ "Gnielinski": _gnielinski,
35
+ "Tan et al": lambda re, sc, eb: 1.1 / eb * (re * sc) ** (1 / 3),
36
+ "Wilson and Geankoplis": lambda re, sc, eb: 1.09 / eb * (re * sc) ** (1 / 3),
37
+ "Ohashi et al": _ohashi,
38
+ "Williamson et al": lambda re, sc, eb: 2.4 * eb * re**0.3 * sc**0.42,
39
+ "Ko et al": lambda re, sc, eb: 0.325 / (eb * re**0.36 * sc ** (1 / 3)),
40
+ "Wakao and Funazkri": lambda re, sc, eb: 2 + 1.1 * re**0.6 * sc ** (1 / 3),
41
+ "Kataoka et al": lambda re, sc, eb: 1.85 * ((1 - eb) / eb) ** (1 / 3) * (re * sc) ** (1 / 3),
42
+ }
43
+ """Available Sherwood-number correlations, keyed by name."""
44
+
45
+
46
+ def film_transfer(
47
+ diffusivity, radius: float, re: float, sc, ebed: float, correlation: str = "Chern and Chien"
48
+ ):
49
+ """Film transfer coefficient ``kf`` (cm/s).
50
+
51
+ Parameters
52
+ ----------
53
+ diffusivity
54
+ Liquid diffusivity, cm²/s.
55
+ radius
56
+ Adsorbent particle radius, cm.
57
+ re, sc
58
+ Particle Reynolds and Schmidt numbers.
59
+ ebed
60
+ Bed void fraction.
61
+ correlation
62
+ Key of :data:`SHERWOOD`.
63
+ """
64
+ try:
65
+ sherwood = SHERWOOD[correlation]
66
+ except KeyError:
67
+ raise ValueError(
68
+ f"unknown correlation {correlation!r}; expected one of {sorted(SHERWOOD)}"
69
+ ) from None
70
+ return diffusivity / (2.0 * radius) * sherwood(re, sc, ebed)