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 +31 -0
- psdm/capacity.py +279 -0
- psdm/collocation.py +144 -0
- psdm/correlations.py +70 -0
- psdm/fitting.py +240 -0
- psdm/fouling.py +123 -0
- psdm/fullscale.py +455 -0
- psdm/io.py +212 -0
- psdm/isotherms.py +128 -0
- psdm/model.py +193 -0
- psdm/plotting.py +55 -0
- psdm/properties.py +32 -0
- psdm/py.typed +0 -0
- psdm/solver.py +344 -0
- psdm/uncertainty.py +193 -0
- psdm/units.py +106 -0
- psdm-0.1.0.dist-info/METADATA +118 -0
- psdm-0.1.0.dist-info/RECORD +20 -0
- psdm-0.1.0.dist-info/WHEEL +4 -0
- psdm-0.1.0.dist-info/licenses/LICENSE +27 -0
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)
|