fugacio-sim 0.0.1__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.
- fugacio/sim/__init__.py +529 -0
- fugacio/sim/column.py +466 -0
- fugacio/sim/control/__init__.py +110 -0
- fugacio/sim/control/blocks.py +159 -0
- fugacio/sim/control/linearize.py +190 -0
- fugacio/sim/control/metrics.py +152 -0
- fugacio/sim/control/pid.py +195 -0
- fugacio/sim/control/tuning.py +174 -0
- fugacio/sim/design.py +312 -0
- fugacio/sim/diagrams.py +346 -0
- fugacio/sim/dynamics/__init__.py +76 -0
- fugacio/sim/dynamics/flowsheet.py +292 -0
- fugacio/sim/dynamics/integrate.py +517 -0
- fugacio/sim/dynamics/optimize.py +255 -0
- fugacio/sim/dynamics/units.py +580 -0
- fugacio/sim/economics.py +384 -0
- fugacio/sim/flowsheet.py +286 -0
- fugacio/sim/integration/__init__.py +89 -0
- fugacio/sim/integration/area.py +447 -0
- fugacio/sim/integration/network.py +429 -0
- fugacio/sim/integration/streams.py +169 -0
- fugacio/sim/integration/targeting.py +348 -0
- fugacio/sim/models.py +221 -0
- fugacio/sim/mpc/__init__.py +116 -0
- fugacio/sim/mpc/estimation.py +413 -0
- fugacio/sim/mpc/linear.py +608 -0
- fugacio/sim/mpc/nonlinear.py +336 -0
- fugacio/sim/mpc/qp.py +409 -0
- fugacio/sim/mpc/riccati.py +279 -0
- fugacio/sim/mpc/simulate.py +302 -0
- fugacio/sim/optimize.py +758 -0
- fugacio/sim/properties.py +186 -0
- fugacio/sim/py.typed +0 -0
- fugacio/sim/reactive.py +398 -0
- fugacio/sim/reactors.py +516 -0
- fugacio/sim/separations.py +146 -0
- fugacio/sim/stream.py +71 -0
- fugacio/sim/units.py +356 -0
- fugacio/sim/utilities.py +358 -0
- fugacio/sim/vle.py +58 -0
- fugacio_sim-0.0.1.dist-info/METADATA +150 -0
- fugacio_sim-0.0.1.dist-info/RECORD +43 -0
- fugacio_sim-0.0.1.dist-info/WHEEL +4 -0
fugacio/sim/column.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Distillation columns: shortcut (Fenske-Underwood-Gilliland) and rigorous stages.
|
|
2
|
+
|
|
3
|
+
Two complementary, fully differentiable column models:
|
|
4
|
+
|
|
5
|
+
* **Shortcut**: the Fenske-Underwood-Gilliland (FUG) method, giving minimum stages
|
|
6
|
+
at total reflux (`fenske_min_stages`), minimum reflux (`underwood_min_reflux`),
|
|
7
|
+
the actual stage count at a working reflux (`gilliland_stages`), and the
|
|
8
|
+
feed-stage location (`kirkbride_feed_stage`), tied together by
|
|
9
|
+
`shortcut_column`. Cheap, robust, and ideal for screening or as an
|
|
10
|
+
initial guess for the rigorous model.
|
|
11
|
+
* **Rigorous**: a multistage equilibrium-stage column solved by the Wang-Henke
|
|
12
|
+
bubble-point method under constant molar overflow, with EOS K-values on every
|
|
13
|
+
stage (`solve_column`). The converged profile is differentiable through
|
|
14
|
+
the fixed-point iteration by implicit differentiation.
|
|
15
|
+
|
|
16
|
+
Both expose exact gradients of their outputs (stage count, reflux, product
|
|
17
|
+
purities) with respect to the design variables, so a column can be embedded in a
|
|
18
|
+
gradient-based optimisation alongside the rest of a flowsheet.
|
|
19
|
+
|
|
20
|
+
Component ordering convention: relative volatilities ``alpha`` are given relative
|
|
21
|
+
to a common reference (any component); the *light key* ``lk`` is more volatile
|
|
22
|
+
than the *heavy key* ``hk`` (``alpha[lk] > alpha[hk]``).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from functools import partial
|
|
29
|
+
from typing import Any, NamedTuple
|
|
30
|
+
|
|
31
|
+
import jax
|
|
32
|
+
import jax.numpy as jnp
|
|
33
|
+
from jax import Array
|
|
34
|
+
|
|
35
|
+
from fugacio.sim.flowsheet import tear_solve
|
|
36
|
+
from fugacio.sim.properties import _resolve
|
|
37
|
+
from fugacio.sim.stream import Stream
|
|
38
|
+
from fugacio.thermo import (
|
|
39
|
+
PR,
|
|
40
|
+
CubicEOS,
|
|
41
|
+
ln_phi_mixture,
|
|
42
|
+
mixture_enthalpy,
|
|
43
|
+
molar_enthalpy,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ArrayLike = Array | float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@partial(jax.custom_jvp, nondiff_argnums=(0, 4, 5))
|
|
50
|
+
def _bracketed_root(
|
|
51
|
+
residual: Callable[[Array, Any], Array],
|
|
52
|
+
params: Any,
|
|
53
|
+
lo: Array,
|
|
54
|
+
hi: Array,
|
|
55
|
+
tol: float,
|
|
56
|
+
max_iter: int,
|
|
57
|
+
) -> Array:
|
|
58
|
+
"""Find the single root of ``residual(., params)`` in ``[lo, hi]`` by bisection.
|
|
59
|
+
|
|
60
|
+
The forward pass uses only residual *values* (robust through poles/kinks at the
|
|
61
|
+
bracket ends); the root is differentiated with respect to ``params`` by the
|
|
62
|
+
implicit function theorem in the ``custom_jvp`` rule (the bracket ``lo, hi`` are
|
|
63
|
+
treated as plain locators and carry no gradient).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def cond(carry: tuple[Array, Array, Array, Array]) -> Array:
|
|
67
|
+
lo_, hi_, _flo, i = carry
|
|
68
|
+
return ((hi_ - lo_) > tol) & (i < max_iter)
|
|
69
|
+
|
|
70
|
+
def body(carry: tuple[Array, Array, Array, Array]) -> tuple[Array, Array, Array, Array]:
|
|
71
|
+
lo_, hi_, flo, i = carry
|
|
72
|
+
mid = 0.5 * (lo_ + hi_)
|
|
73
|
+
fmid = residual(mid, params)
|
|
74
|
+
same = jnp.sign(fmid) == jnp.sign(flo)
|
|
75
|
+
lo_new = jnp.where(same, mid, lo_)
|
|
76
|
+
hi_new = jnp.where(same, hi_, mid)
|
|
77
|
+
flo_new = jnp.where(same, fmid, flo)
|
|
78
|
+
return lo_new, hi_new, flo_new, i + 1
|
|
79
|
+
|
|
80
|
+
flo0 = residual(lo, params)
|
|
81
|
+
init = (lo, hi, flo0, jnp.asarray(0))
|
|
82
|
+
lo_star, hi_star, _, _ = jax.lax.while_loop(cond, body, init)
|
|
83
|
+
return 0.5 * (lo_star + hi_star)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@_bracketed_root.defjvp
|
|
87
|
+
def _bracketed_root_jvp(
|
|
88
|
+
residual: Callable[[Array, Any], Array],
|
|
89
|
+
tol: float,
|
|
90
|
+
max_iter: int,
|
|
91
|
+
primals: tuple[Any, Array, Array],
|
|
92
|
+
tangents: tuple[Any, Array, Array],
|
|
93
|
+
) -> tuple[Array, Array]:
|
|
94
|
+
params, lo, hi = primals
|
|
95
|
+
params_dot, _, _ = tangents
|
|
96
|
+
root = _bracketed_root(residual, params, lo, hi, tol, max_iter)
|
|
97
|
+
r_root = jax.grad(lambda tt: residual(tt, params))(root)
|
|
98
|
+
grad_params = jax.grad(lambda pp: residual(root, pp))(params)
|
|
99
|
+
leaves = jax.tree_util.tree_leaves(
|
|
100
|
+
jax.tree_util.tree_map(lambda g, d: jnp.vdot(g, d), grad_params, params_dot)
|
|
101
|
+
)
|
|
102
|
+
r_dot = sum(leaves, jnp.asarray(0.0))
|
|
103
|
+
return root, -r_dot / r_root
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def relative_volatility(
|
|
107
|
+
eos: CubicEOS,
|
|
108
|
+
t: ArrayLike,
|
|
109
|
+
p: ArrayLike,
|
|
110
|
+
z: Array,
|
|
111
|
+
tc: Array,
|
|
112
|
+
pc: Array,
|
|
113
|
+
omega: Array,
|
|
114
|
+
*,
|
|
115
|
+
ref: int,
|
|
116
|
+
kij: Array | None = None,
|
|
117
|
+
) -> Array:
|
|
118
|
+
"""Relative volatilities ``alpha_i = K_i / K_ref`` at ``(T, P, z)`` from the EOS.
|
|
119
|
+
|
|
120
|
+
K-values are evaluated as ``phi_i^L(z) / phi_i^V(z)`` at the given composition,
|
|
121
|
+
a standard shortcut estimate that is well defined whether or not the feed is
|
|
122
|
+
two-phase. Differentiable in ``(T, P, z)``.
|
|
123
|
+
"""
|
|
124
|
+
n = z.shape[0]
|
|
125
|
+
kij_arr = jnp.zeros((n, n)) if kij is None else jnp.asarray(kij)
|
|
126
|
+
ln_phi_l, _ = ln_phi_mixture(eos, t, p, z, tc, pc, omega, phase="liquid", kij=kij_arr)
|
|
127
|
+
ln_phi_v, _ = ln_phi_mixture(eos, t, p, z, tc, pc, omega, phase="vapor", kij=kij_arr)
|
|
128
|
+
k = jnp.exp(ln_phi_l - ln_phi_v)
|
|
129
|
+
return k / k[ref]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fenske_min_stages(d: Array, b: Array, lk: int, hk: int, alpha: Array) -> Array:
|
|
133
|
+
"""Fenske minimum number of equilibrium stages at total reflux.
|
|
134
|
+
|
|
135
|
+
``N_min = ln[(d_LK/d_HK)(b_HK/b_LK)] / ln(alpha_LK/alpha_HK)`` where ``d`` and
|
|
136
|
+
``b`` are the distillate and bottoms component molar flows. Includes the
|
|
137
|
+
reboiler as a stage (the classic Fenske count).
|
|
138
|
+
"""
|
|
139
|
+
alpha_lk_hk = alpha[lk] / alpha[hk]
|
|
140
|
+
separation = (d[lk] / d[hk]) * (b[hk] / b[lk])
|
|
141
|
+
return jnp.log(separation) / jnp.log(alpha_lk_hk)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def underwood_min_reflux(
|
|
145
|
+
z: Array,
|
|
146
|
+
x_d: Array,
|
|
147
|
+
alpha: Array,
|
|
148
|
+
q: ArrayLike,
|
|
149
|
+
lk: int,
|
|
150
|
+
hk: int,
|
|
151
|
+
*,
|
|
152
|
+
tol: float = 1e-12,
|
|
153
|
+
max_iter: int = 200,
|
|
154
|
+
) -> tuple[Array, Array]:
|
|
155
|
+
"""Underwood minimum reflux ratio ``R_min`` (constant relative volatility).
|
|
156
|
+
|
|
157
|
+
Solves the first Underwood equation ``sum_i alpha_i z_i / (alpha_i - theta) =
|
|
158
|
+
1 - q`` for the common root ``theta`` between ``alpha_HK`` and ``alpha_LK``,
|
|
159
|
+
then evaluates ``R_min + 1 = sum_i alpha_i x_{i,D} / (alpha_i - theta)``.
|
|
160
|
+
|
|
161
|
+
Returns ``(R_min, theta)``; both are differentiable in ``(z, x_d, alpha, q)``.
|
|
162
|
+
"""
|
|
163
|
+
z = jnp.asarray(z)
|
|
164
|
+
x_d = jnp.asarray(x_d)
|
|
165
|
+
alpha = jnp.asarray(alpha)
|
|
166
|
+
|
|
167
|
+
def equation(theta: Array, params: tuple[Array, Array, Array]) -> Array:
|
|
168
|
+
z_, alpha_, q_ = params
|
|
169
|
+
return jnp.sum(alpha_ * z_ / (alpha_ - theta)) - (1.0 - q_)
|
|
170
|
+
|
|
171
|
+
lo = alpha[hk]
|
|
172
|
+
hi = alpha[lk]
|
|
173
|
+
gap = hi - lo
|
|
174
|
+
theta = _bracketed_root(
|
|
175
|
+
equation,
|
|
176
|
+
(z, alpha, jnp.asarray(q, dtype=float)),
|
|
177
|
+
lo + 1e-6 * gap,
|
|
178
|
+
hi - 1e-6 * gap,
|
|
179
|
+
tol,
|
|
180
|
+
max_iter,
|
|
181
|
+
)
|
|
182
|
+
r_min = jnp.sum(alpha * x_d / (alpha - theta)) - 1.0
|
|
183
|
+
return r_min, theta
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def gilliland_stages(n_min: ArrayLike, r_min: ArrayLike, r: ArrayLike) -> Array:
|
|
187
|
+
"""Actual equilibrium-stage count from the Gilliland (Eduljee) correlation.
|
|
188
|
+
|
|
189
|
+
With ``X = (R - R_min)/(R + 1)`` and ``Y = 0.75 (1 - X^0.5668)``, the stage
|
|
190
|
+
count follows from ``(N - N_min)/(N + 1) = Y``, i.e. ``N = (N_min + Y)/(1 - Y)``.
|
|
191
|
+
"""
|
|
192
|
+
x = (jnp.asarray(r) - jnp.asarray(r_min)) / (jnp.asarray(r) + 1.0)
|
|
193
|
+
y = 0.75 * (1.0 - x**0.5668)
|
|
194
|
+
return (jnp.asarray(n_min) + y) / (1.0 - y)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def kirkbride_feed_stage(
|
|
198
|
+
n: ArrayLike,
|
|
199
|
+
z: Array,
|
|
200
|
+
x_d: Array,
|
|
201
|
+
x_b: Array,
|
|
202
|
+
d_total: ArrayLike,
|
|
203
|
+
b_total: ArrayLike,
|
|
204
|
+
lk: int,
|
|
205
|
+
hk: int,
|
|
206
|
+
) -> Array:
|
|
207
|
+
"""Kirkbride correlation for the number of stages *above* the feed.
|
|
208
|
+
|
|
209
|
+
``log10(N_R/N_S) = 0.206 log10[(z_HK/z_LK)(x_{LK,B}/x_{HK,D})^2 (B/D)]``;
|
|
210
|
+
returns ``N_R`` given the total stage count ``N = N_R + N_S``.
|
|
211
|
+
"""
|
|
212
|
+
flow_ratio = jnp.asarray(b_total) / jnp.asarray(d_total)
|
|
213
|
+
ratio = (z[hk] / z[lk]) * (x_b[lk] / x_d[hk]) ** 2 * flow_ratio
|
|
214
|
+
nr_over_ns = ratio**0.206
|
|
215
|
+
n_s = jnp.asarray(n) / (1.0 + nr_over_ns)
|
|
216
|
+
return jnp.asarray(n) - n_s
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ShortcutResult(NamedTuple):
|
|
220
|
+
"""Summary of a Fenske-Underwood-Gilliland shortcut design.
|
|
221
|
+
|
|
222
|
+
Attributes:
|
|
223
|
+
n_min: Minimum equilibrium stages at total reflux (Fenske).
|
|
224
|
+
r_min: Minimum reflux ratio (Underwood).
|
|
225
|
+
theta: Underwood common root.
|
|
226
|
+
r: Working reflux ratio used for the Gilliland step.
|
|
227
|
+
n_stages: Actual equilibrium stages (Gilliland).
|
|
228
|
+
feed_stage: Number of stages above the feed (Kirkbride).
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
n_min: Array
|
|
232
|
+
r_min: Array
|
|
233
|
+
theta: Array
|
|
234
|
+
r: Array
|
|
235
|
+
n_stages: Array
|
|
236
|
+
feed_stage: Array
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def shortcut_column(
|
|
240
|
+
z: Array,
|
|
241
|
+
d: Array,
|
|
242
|
+
b: Array,
|
|
243
|
+
alpha: Array,
|
|
244
|
+
q: ArrayLike,
|
|
245
|
+
lk: int,
|
|
246
|
+
hk: int,
|
|
247
|
+
*,
|
|
248
|
+
reflux: ArrayLike | None = None,
|
|
249
|
+
reflux_factor: ArrayLike = 1.3,
|
|
250
|
+
) -> ShortcutResult:
|
|
251
|
+
"""Full FUG shortcut design from a feed and a specified product split.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
z: Feed mole fractions.
|
|
255
|
+
d: Distillate component molar flows (the chosen split of the feed).
|
|
256
|
+
b: Bottoms component molar flows (``z * F - d`` on a consistent basis).
|
|
257
|
+
alpha: Relative volatilities (to any common reference).
|
|
258
|
+
q: Feed thermal quality (1 = saturated liquid, 0 = saturated vapour).
|
|
259
|
+
lk: Light-key component index.
|
|
260
|
+
hk: Heavy-key component index.
|
|
261
|
+
reflux: Working reflux ratio. If ``None``, ``reflux_factor * R_min`` is used.
|
|
262
|
+
reflux_factor: Multiplier on ``R_min`` when ``reflux`` is not given.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
A `ShortcutResult`; every field is differentiable in the inputs.
|
|
266
|
+
"""
|
|
267
|
+
d = jnp.asarray(d)
|
|
268
|
+
b = jnp.asarray(b)
|
|
269
|
+
d_total = jnp.sum(d)
|
|
270
|
+
b_total = jnp.sum(b)
|
|
271
|
+
x_d = d / d_total
|
|
272
|
+
x_b = b / b_total
|
|
273
|
+
n_min = fenske_min_stages(d, b, lk, hk, alpha)
|
|
274
|
+
r_min, theta = underwood_min_reflux(z, x_d, alpha, q, lk, hk)
|
|
275
|
+
r = reflux_factor * r_min if reflux is None else jnp.asarray(reflux)
|
|
276
|
+
n_stages = gilliland_stages(n_min, r_min, r)
|
|
277
|
+
feed_stage = kirkbride_feed_stage(n_stages, z, x_d, x_b, d_total, b_total, lk, hk)
|
|
278
|
+
return ShortcutResult(
|
|
279
|
+
n_min=n_min,
|
|
280
|
+
r_min=r_min,
|
|
281
|
+
theta=theta,
|
|
282
|
+
r=jnp.asarray(r),
|
|
283
|
+
n_stages=n_stages,
|
|
284
|
+
feed_stage=feed_stage,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class ColumnResult(NamedTuple):
|
|
289
|
+
"""Converged profile and products of a rigorous equilibrium-stage column.
|
|
290
|
+
|
|
291
|
+
Attributes:
|
|
292
|
+
t: Stage temperatures (K), top stage first.
|
|
293
|
+
x: Liquid mole fractions, shape ``(n_stages, n_components)``.
|
|
294
|
+
y: Vapour mole fractions, shape ``(n_stages, n_components)``.
|
|
295
|
+
distillate: Distillate product `Stream`.
|
|
296
|
+
bottoms: Bottoms product `Stream`.
|
|
297
|
+
reflux: Reflux ratio used.
|
|
298
|
+
condenser_duty: Condenser heat removed (W, positive).
|
|
299
|
+
reboiler_duty: Reboiler heat added (W, positive).
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
t: Array
|
|
303
|
+
x: Array
|
|
304
|
+
y: Array
|
|
305
|
+
distillate: Stream
|
|
306
|
+
bottoms: Stream
|
|
307
|
+
reflux: Array
|
|
308
|
+
condenser_duty: Array
|
|
309
|
+
reboiler_duty: Array
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def solve_column(
|
|
313
|
+
feed: Stream,
|
|
314
|
+
n_stages: int,
|
|
315
|
+
feed_stage: int,
|
|
316
|
+
reflux: ArrayLike,
|
|
317
|
+
distillate_rate: ArrayLike,
|
|
318
|
+
*,
|
|
319
|
+
eos: CubicEOS = PR,
|
|
320
|
+
q: ArrayLike = 1.0,
|
|
321
|
+
kij: Array | None = None,
|
|
322
|
+
t_top: ArrayLike | None = None,
|
|
323
|
+
t_bottom: ArrayLike | None = None,
|
|
324
|
+
t_min: float = 100.0,
|
|
325
|
+
t_max: float = 800.0,
|
|
326
|
+
tol: float = 1e-9,
|
|
327
|
+
max_iter: int = 400,
|
|
328
|
+
) -> ColumnResult:
|
|
329
|
+
"""Rigorous multistage column by the Wang-Henke bubble-point method (CMO).
|
|
330
|
+
|
|
331
|
+
A total condenser sits above stage 1 (distillate and reflux share the stage-1
|
|
332
|
+
vapour composition) and a partial reboiler is stage ``n_stages``; a single feed
|
|
333
|
+
of quality ``q`` enters at ``feed_stage`` (1-indexed from the top). Under
|
|
334
|
+
constant molar overflow the section flows are fixed by ``reflux`` and
|
|
335
|
+
``distillate_rate``, and each outer sweep (i) solves the tridiagonal component
|
|
336
|
+
balances for the liquid profile with the current EOS K-values, (ii) takes a
|
|
337
|
+
bubble-point Newton step on every stage temperature, and (iii) refreshes the
|
|
338
|
+
vapour compositions. The sweep is iterated to a fixed point by
|
|
339
|
+
`tear_solve`, so the converged profile, products,
|
|
340
|
+
and duties are all differentiable (implicit differentiation) with respect to
|
|
341
|
+
``reflux``, ``distillate_rate``, the feed, and model parameters.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
feed: Feed `Stream` (its temperature sets the
|
|
345
|
+
feed enthalpy used for the duty balance).
|
|
346
|
+
n_stages: Number of equilibrium stages including the reboiler.
|
|
347
|
+
feed_stage: 1-indexed feed stage (``2 <= feed_stage <= n_stages - 1``).
|
|
348
|
+
reflux: Reflux ratio ``L/D``.
|
|
349
|
+
distillate_rate: Distillate molar flow (mol/s); bottoms is the remainder.
|
|
350
|
+
eos: Cubic equation of state (default Peng-Robinson).
|
|
351
|
+
q: Feed thermal quality (1 = saturated liquid).
|
|
352
|
+
kij: Optional binary interaction matrix.
|
|
353
|
+
t_top: Optional initial top-stage temperature for the linear starting
|
|
354
|
+
profile (default ``feed.t`` minus a small spread).
|
|
355
|
+
t_bottom: Optional initial bottom-stage temperature for the linear starting
|
|
356
|
+
profile (default ``feed.t`` plus a small spread).
|
|
357
|
+
t_min: Lower bracket clamp for the per-stage temperature updates.
|
|
358
|
+
t_max: Upper bracket clamp for the per-stage temperature updates.
|
|
359
|
+
tol: Convergence tolerance for the outer fixed point.
|
|
360
|
+
max_iter: Maximum number of outer sweeps.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
A `ColumnResult`.
|
|
364
|
+
"""
|
|
365
|
+
components = feed.components
|
|
366
|
+
n = n_stages
|
|
367
|
+
f_idx = feed_stage - 1
|
|
368
|
+
tc, pc, omega, _, cp = _resolve(components)
|
|
369
|
+
n_c = len(components)
|
|
370
|
+
kij_arr = jnp.zeros((n_c, n_c)) if kij is None else jnp.asarray(kij)
|
|
371
|
+
p = feed.p
|
|
372
|
+
z = feed.z
|
|
373
|
+
big_f = feed.total
|
|
374
|
+
feed_comp = jnp.zeros((n, n_c)).at[f_idx].set(big_f * z)
|
|
375
|
+
q_arr = jnp.asarray(q, dtype=float)
|
|
376
|
+
idx = jnp.arange(n)
|
|
377
|
+
|
|
378
|
+
def stage_k(t_j: Array, x_j: Array, y_j: Array) -> Array:
|
|
379
|
+
ln_phi_l, _ = ln_phi_mixture(eos, t_j, p, x_j, tc, pc, omega, phase="liquid", kij=kij_arr)
|
|
380
|
+
ln_phi_v, _ = ln_phi_mixture(eos, t_j, p, y_j, tc, pc, omega, phase="vapor", kij=kij_arr)
|
|
381
|
+
return jnp.exp(ln_phi_l - ln_phi_v)
|
|
382
|
+
|
|
383
|
+
def k_profile(t: Array, x: Array, y: Array) -> Array:
|
|
384
|
+
return jax.vmap(stage_k)(t, x, y)
|
|
385
|
+
|
|
386
|
+
def cmo_flows(r: Array, d: Array) -> tuple[Array, Array]:
|
|
387
|
+
b = big_f - d
|
|
388
|
+
v_rect = (r + 1.0) * d
|
|
389
|
+
v_strip = (r + 1.0) * d - (1.0 - q_arr) * big_f
|
|
390
|
+
l_rect = r * d
|
|
391
|
+
l_strip = r * d + q_arr * big_f
|
|
392
|
+
v = jnp.where(idx + 1 <= feed_stage, v_rect, v_strip)
|
|
393
|
+
liq = jnp.where(idx + 1 < feed_stage, l_rect, jnp.where(idx + 1 < n, l_strip, b))
|
|
394
|
+
return v, liq
|
|
395
|
+
|
|
396
|
+
def tridiag_component(k_col: Array, f_col: Array, v: Array, liq: Array, r: Array) -> Array:
|
|
397
|
+
diag = -(1.0 + v * k_col / liq)
|
|
398
|
+
diag = diag.at[0].set(-1.0 - k_col[0] / r)
|
|
399
|
+
sub = jnp.ones(n - 1)
|
|
400
|
+
sup = v[1:] * k_col[1:] / liq[1:]
|
|
401
|
+
mat = jnp.diag(diag) + jnp.diag(sub, -1) + jnp.diag(sup, 1)
|
|
402
|
+
return jnp.linalg.solve(mat, -f_col)
|
|
403
|
+
|
|
404
|
+
def sweep(state: tuple[Array, Array, Array], theta: dict[str, Array]) -> tuple[Array, ...]:
|
|
405
|
+
t, x, y = state
|
|
406
|
+
r, d = theta["R"], theta["D"]
|
|
407
|
+
v, liq_flows = cmo_flows(r, d)
|
|
408
|
+
k = k_profile(t, x, y)
|
|
409
|
+
liq = jax.vmap(tridiag_component, in_axes=(1, 1, None, None, None), out_axes=1)(
|
|
410
|
+
k, feed_comp, v, liq_flows, r
|
|
411
|
+
)
|
|
412
|
+
liq = jnp.maximum(liq, 1e-12)
|
|
413
|
+
x_new = liq / jnp.sum(liq, axis=1, keepdims=True)
|
|
414
|
+
|
|
415
|
+
def bubble_residual(t_j: Array, x_j: Array, y_j: Array) -> Array:
|
|
416
|
+
return jnp.sum(stage_k(t_j, x_j, y_j) * x_j) - 1.0
|
|
417
|
+
|
|
418
|
+
r_bp = jax.vmap(bubble_residual)(t, x_new, y)
|
|
419
|
+
dr_bp = jax.vmap(jax.grad(bubble_residual))(t, x_new, y)
|
|
420
|
+
step = jnp.clip(r_bp / dr_bp, -25.0, 25.0)
|
|
421
|
+
t_new = jnp.clip(t - step, t_min, t_max)
|
|
422
|
+
k_new = k_profile(t_new, x_new, y)
|
|
423
|
+
y_unnorm = k_new * x_new
|
|
424
|
+
y_new = y_unnorm / jnp.sum(y_unnorm, axis=1, keepdims=True)
|
|
425
|
+
return t_new, x_new, y_new
|
|
426
|
+
|
|
427
|
+
t_hi = feed.t + 25.0 if t_bottom is None else jnp.asarray(t_bottom)
|
|
428
|
+
t_lo = feed.t - 5.0 if t_top is None else jnp.asarray(t_top)
|
|
429
|
+
t0 = jnp.linspace(t_lo, t_hi, n)
|
|
430
|
+
x0 = jnp.broadcast_to(z, (n, n_c))
|
|
431
|
+
theta = {"R": jnp.asarray(reflux, dtype=float), "D": jnp.asarray(distillate_rate, dtype=float)}
|
|
432
|
+
t_star, x_star, y_star = tear_solve(
|
|
433
|
+
sweep, (t0, x0, x0), theta, q_min=-5.0, q_max=0.0, tol=tol, max_iter=max_iter
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
big_d = jnp.asarray(distillate_rate, dtype=float)
|
|
437
|
+
big_b = big_f - big_d
|
|
438
|
+
y_dist = y_star[0]
|
|
439
|
+
x_bot = x_star[-1]
|
|
440
|
+
distillate = Stream(n=big_d * y_dist, t=t_star[0], p=p, components=components)
|
|
441
|
+
bottoms = Stream(n=big_b * x_bot, t=t_star[-1], p=p, components=components)
|
|
442
|
+
|
|
443
|
+
r_arr = jnp.asarray(reflux, dtype=float)
|
|
444
|
+
h_vap_top = molar_enthalpy(
|
|
445
|
+
t_star[0], p, y_dist, tc, pc, omega, cp, eos=eos, phase="vapor", kij=kij_arr
|
|
446
|
+
)
|
|
447
|
+
h_liq_dist = molar_enthalpy(
|
|
448
|
+
t_star[0], p, y_dist, tc, pc, omega, cp, eos=eos, phase="liquid", kij=kij_arr
|
|
449
|
+
)
|
|
450
|
+
condenser_duty = (r_arr + 1.0) * big_d * (h_vap_top - h_liq_dist)
|
|
451
|
+
h_feed = mixture_enthalpy(eos, feed.t, p, z, tc, pc, omega, cp, kij=kij_arr)
|
|
452
|
+
h_bottoms = molar_enthalpy(
|
|
453
|
+
t_star[-1], p, x_bot, tc, pc, omega, cp, eos=eos, phase="liquid", kij=kij_arr
|
|
454
|
+
)
|
|
455
|
+
reboiler_duty = big_d * h_liq_dist + big_b * h_bottoms + condenser_duty - big_f * h_feed
|
|
456
|
+
|
|
457
|
+
return ColumnResult(
|
|
458
|
+
t=t_star,
|
|
459
|
+
x=x_star,
|
|
460
|
+
y=y_star,
|
|
461
|
+
distillate=distillate,
|
|
462
|
+
bottoms=bottoms,
|
|
463
|
+
reflux=r_arr,
|
|
464
|
+
condenser_duty=condenser_duty,
|
|
465
|
+
reboiler_duty=reboiler_duty,
|
|
466
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Classical and differentiable process control for Fugacio.
|
|
2
|
+
|
|
3
|
+
This subpackage supplies the control side of dynamic simulation:
|
|
4
|
+
|
|
5
|
+
* `pid`: a realizable, anti-windup `PID`
|
|
6
|
+
controller whose gains are a differentiable pytree (so they can be tuned by
|
|
7
|
+
gradient descent), carried as ODE states inside a dynamic flowsheet;
|
|
8
|
+
* `blocks`: linear blocks (first/second order, FOPDT,
|
|
9
|
+
lead-lag) with both analytic step responses and state-space realizations, plus
|
|
10
|
+
static actuator nonlinearities;
|
|
11
|
+
* `metrics`: step-response performance metrics
|
|
12
|
+
(overshoot, rise/settling time, IAE/ISE/ITAE) for reporting and as tuning
|
|
13
|
+
objectives;
|
|
14
|
+
* `tuning`: FOPDT model identification and the
|
|
15
|
+
classical tuning rules (Ziegler-Nichols, Cohen-Coon, IMC/lambda, AMIGO);
|
|
16
|
+
* `linearize`: exact autodiff linearization of a
|
|
17
|
+
nonlinear plant into a `StateSpace`, with poles, Bode response, and
|
|
18
|
+
controllability/observability.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from fugacio.sim.control.blocks import (
|
|
24
|
+
dead_band,
|
|
25
|
+
first_order_ss,
|
|
26
|
+
first_order_step,
|
|
27
|
+
fopdt_step,
|
|
28
|
+
lead_lag,
|
|
29
|
+
rate_limit,
|
|
30
|
+
saturate,
|
|
31
|
+
second_order_ss,
|
|
32
|
+
second_order_step,
|
|
33
|
+
)
|
|
34
|
+
from fugacio.sim.control.linearize import (
|
|
35
|
+
StateSpace,
|
|
36
|
+
bode,
|
|
37
|
+
controllability,
|
|
38
|
+
dc_gain,
|
|
39
|
+
frequency_response,
|
|
40
|
+
is_controllable,
|
|
41
|
+
is_observable,
|
|
42
|
+
is_stable,
|
|
43
|
+
linearize,
|
|
44
|
+
observability,
|
|
45
|
+
poles,
|
|
46
|
+
)
|
|
47
|
+
from fugacio.sim.control.metrics import (
|
|
48
|
+
StepInfo,
|
|
49
|
+
iae,
|
|
50
|
+
ise,
|
|
51
|
+
itae,
|
|
52
|
+
overshoot,
|
|
53
|
+
peak_time,
|
|
54
|
+
rise_time,
|
|
55
|
+
settling_time,
|
|
56
|
+
steady_state_error,
|
|
57
|
+
step_info,
|
|
58
|
+
)
|
|
59
|
+
from fugacio.sim.control.pid import PID, PIDState, p_only, pi
|
|
60
|
+
from fugacio.sim.control.tuning import (
|
|
61
|
+
FOPDTModel,
|
|
62
|
+
amigo,
|
|
63
|
+
cohen_coon,
|
|
64
|
+
fit_fopdt,
|
|
65
|
+
imc_tuning,
|
|
66
|
+
ziegler_nichols,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"PID",
|
|
71
|
+
"FOPDTModel",
|
|
72
|
+
"PIDState",
|
|
73
|
+
"StateSpace",
|
|
74
|
+
"StepInfo",
|
|
75
|
+
"amigo",
|
|
76
|
+
"bode",
|
|
77
|
+
"cohen_coon",
|
|
78
|
+
"controllability",
|
|
79
|
+
"dc_gain",
|
|
80
|
+
"dead_band",
|
|
81
|
+
"first_order_ss",
|
|
82
|
+
"first_order_step",
|
|
83
|
+
"fit_fopdt",
|
|
84
|
+
"fopdt_step",
|
|
85
|
+
"frequency_response",
|
|
86
|
+
"iae",
|
|
87
|
+
"imc_tuning",
|
|
88
|
+
"is_controllable",
|
|
89
|
+
"is_observable",
|
|
90
|
+
"is_stable",
|
|
91
|
+
"ise",
|
|
92
|
+
"itae",
|
|
93
|
+
"lead_lag",
|
|
94
|
+
"linearize",
|
|
95
|
+
"observability",
|
|
96
|
+
"overshoot",
|
|
97
|
+
"p_only",
|
|
98
|
+
"peak_time",
|
|
99
|
+
"pi",
|
|
100
|
+
"poles",
|
|
101
|
+
"rate_limit",
|
|
102
|
+
"rise_time",
|
|
103
|
+
"saturate",
|
|
104
|
+
"second_order_ss",
|
|
105
|
+
"second_order_step",
|
|
106
|
+
"settling_time",
|
|
107
|
+
"steady_state_error",
|
|
108
|
+
"step_info",
|
|
109
|
+
"ziegler_nichols",
|
|
110
|
+
]
|