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.
Files changed (43) hide show
  1. fugacio/sim/__init__.py +529 -0
  2. fugacio/sim/column.py +466 -0
  3. fugacio/sim/control/__init__.py +110 -0
  4. fugacio/sim/control/blocks.py +159 -0
  5. fugacio/sim/control/linearize.py +190 -0
  6. fugacio/sim/control/metrics.py +152 -0
  7. fugacio/sim/control/pid.py +195 -0
  8. fugacio/sim/control/tuning.py +174 -0
  9. fugacio/sim/design.py +312 -0
  10. fugacio/sim/diagrams.py +346 -0
  11. fugacio/sim/dynamics/__init__.py +76 -0
  12. fugacio/sim/dynamics/flowsheet.py +292 -0
  13. fugacio/sim/dynamics/integrate.py +517 -0
  14. fugacio/sim/dynamics/optimize.py +255 -0
  15. fugacio/sim/dynamics/units.py +580 -0
  16. fugacio/sim/economics.py +384 -0
  17. fugacio/sim/flowsheet.py +286 -0
  18. fugacio/sim/integration/__init__.py +89 -0
  19. fugacio/sim/integration/area.py +447 -0
  20. fugacio/sim/integration/network.py +429 -0
  21. fugacio/sim/integration/streams.py +169 -0
  22. fugacio/sim/integration/targeting.py +348 -0
  23. fugacio/sim/models.py +221 -0
  24. fugacio/sim/mpc/__init__.py +116 -0
  25. fugacio/sim/mpc/estimation.py +413 -0
  26. fugacio/sim/mpc/linear.py +608 -0
  27. fugacio/sim/mpc/nonlinear.py +336 -0
  28. fugacio/sim/mpc/qp.py +409 -0
  29. fugacio/sim/mpc/riccati.py +279 -0
  30. fugacio/sim/mpc/simulate.py +302 -0
  31. fugacio/sim/optimize.py +758 -0
  32. fugacio/sim/properties.py +186 -0
  33. fugacio/sim/py.typed +0 -0
  34. fugacio/sim/reactive.py +398 -0
  35. fugacio/sim/reactors.py +516 -0
  36. fugacio/sim/separations.py +146 -0
  37. fugacio/sim/stream.py +71 -0
  38. fugacio/sim/units.py +356 -0
  39. fugacio/sim/utilities.py +358 -0
  40. fugacio/sim/vle.py +58 -0
  41. fugacio_sim-0.0.1.dist-info/METADATA +150 -0
  42. fugacio_sim-0.0.1.dist-info/RECORD +43 -0
  43. 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
+ ]