Skip to content

Commit c7b83ba

Browse files
authored
Merge pull request #20 from pathsim/feature/extended-process
extended process
2 parents 222adc4 + 069f2e5 commit c7b83ba

File tree

11 files changed

+1368
-0
lines changed

11 files changed

+1368
-0
lines changed

src/pathsim_chem/process/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@
88
from .heat_exchanger import *
99
from .flash_drum import *
1010
from .distillation import *
11+
from .multicomponent_flash import *
12+
from .pfr import *
13+
from .mixer import *
14+
from .valve import *
15+
from .heater import *

src/pathsim_chem/process/heater.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#########################################################################################
2+
##
3+
## Duty-Specified Heater/Cooler Block
4+
##
5+
#########################################################################################
6+
7+
# IMPORTS ===============================================================================
8+
9+
from pathsim.blocks.function import Function
10+
11+
# BLOCKS ================================================================================
12+
13+
class Heater(Function):
14+
"""Algebraic duty-specified heater/cooler with no thermal mass.
15+
16+
Raises or lowers the stream temperature by a specified heat duty.
17+
Flow passes through unchanged.
18+
19+
Mathematical Formulation
20+
-------------------------
21+
.. math::
22+
23+
T_{out} = T_{in} + \\frac{Q}{F \\, \\rho \\, C_p}
24+
25+
.. math::
26+
27+
F_{out} = F_{in}
28+
29+
Parameters
30+
----------
31+
rho : float
32+
Fluid density [kg/m³].
33+
Cp : float
34+
Fluid heat capacity [J/(kg·K)].
35+
"""
36+
37+
input_port_labels = {
38+
"F": 0,
39+
"T_in": 1,
40+
"Q": 2,
41+
}
42+
43+
output_port_labels = {
44+
"F_out": 0,
45+
"T_out": 1,
46+
}
47+
48+
def __init__(self, rho=1000.0, Cp=4184.0):
49+
if rho <= 0:
50+
raise ValueError(f"'rho' must be positive but is {rho}")
51+
if Cp <= 0:
52+
raise ValueError(f"'Cp' must be positive but is {Cp}")
53+
54+
self.rho = rho
55+
self.Cp = Cp
56+
super().__init__(func=self._eval)
57+
58+
def _eval(self, F, T_in, Q):
59+
if F > 0:
60+
T_out = T_in + Q / (F * self.rho * self.Cp)
61+
else:
62+
T_out = T_in
63+
return (F, T_out)

src/pathsim_chem/process/mixer.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#########################################################################################
2+
##
3+
## 2-Stream Mixer Block
4+
##
5+
#########################################################################################
6+
7+
# IMPORTS ===============================================================================
8+
9+
from pathsim.blocks.function import Function
10+
11+
# BLOCKS ================================================================================
12+
13+
class Mixer(Function):
14+
"""Algebraic 2-stream mixer with mass and energy balance.
15+
16+
Mixes two streams by mass balance (additive flows) and energy balance
17+
(flow-weighted temperature mixing). No phase change or reaction.
18+
19+
Mathematical Formulation
20+
-------------------------
21+
.. math::
22+
23+
F_{out} = F_1 + F_2
24+
25+
.. math::
26+
27+
T_{out} = \\frac{F_1 \\, T_1 + F_2 \\, T_2}{F_{out}}
28+
29+
Parameters
30+
----------
31+
None — this is a purely algebraic block.
32+
"""
33+
34+
input_port_labels = {
35+
"F_1": 0,
36+
"T_1": 1,
37+
"F_2": 2,
38+
"T_2": 3,
39+
}
40+
41+
output_port_labels = {
42+
"F_out": 0,
43+
"T_out": 1,
44+
}
45+
46+
def __init__(self):
47+
super().__init__(func=self._eval)
48+
49+
def _eval(self, F_1, T_1, F_2, T_2):
50+
F_out = F_1 + F_2
51+
if F_out > 0:
52+
T_out = (F_1 * T_1 + F_2 * T_2) / F_out
53+
else:
54+
T_out = 0.5 * (T_1 + T_2)
55+
return (F_out, T_out)
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#########################################################################################
2+
##
3+
## Multi-Component Isothermal Flash Drum Block
4+
##
5+
#########################################################################################
6+
7+
# IMPORTS ===============================================================================
8+
9+
import numpy as np
10+
from scipy.optimize import brentq
11+
12+
from pathsim.blocks.dynsys import DynamicalSystem
13+
14+
# BLOCKS ================================================================================
15+
16+
class MultiComponentFlash(DynamicalSystem):
17+
"""Generalized isothermal flash drum for N components with Raoult's law VLE.
18+
19+
Models a flash drum with liquid holdup for an N-component mixture. Feed enters
20+
as liquid, vapor and liquid streams exit. Temperature and pressure are specified
21+
as inputs. VLE is computed using K-values from Raoult's law with Antoine
22+
equation vapor pressures.
23+
24+
The Rachford-Rice equation is solved iteratively using Brent's method:
25+
26+
.. math::
27+
28+
f(\\beta) = \\sum_i \\frac{z_i (K_i - 1)}{1 + \\beta (K_i - 1)} = 0
29+
30+
K-values from Raoult's law:
31+
32+
.. math::
33+
34+
K_i = \\frac{\\exp(A_i - B_i / (T + C_i))}{P}
35+
36+
Dynamic States
37+
---------------
38+
The holdup moles of each component in the liquid phase:
39+
40+
.. math::
41+
42+
\\frac{dN_i}{dt} = F z_i - V y_i - L x_i
43+
44+
Parameters
45+
----------
46+
N_comp : int
47+
Number of components (must be >= 2).
48+
holdup : float
49+
Total liquid holdup [mol]. Assumed approximately constant.
50+
antoine_A : array_like
51+
Antoine A parameters for each component [ln(Pa)].
52+
antoine_B : array_like
53+
Antoine B parameters for each component [K].
54+
antoine_C : array_like
55+
Antoine C parameters for each component [K].
56+
N0 : array_like or None
57+
Initial component holdup moles [mol]. If None, equal split assumed.
58+
"""
59+
60+
def __init__(self, N_comp=3, holdup=100.0,
61+
antoine_A=None, antoine_B=None, antoine_C=None,
62+
N0=None):
63+
64+
# input validation
65+
if N_comp < 2:
66+
raise ValueError(f"'N_comp' must be >= 2 but is {N_comp}")
67+
if holdup <= 0:
68+
raise ValueError(f"'holdup' must be positive but is {holdup}")
69+
70+
self.N_comp = int(N_comp)
71+
self.holdup = holdup
72+
nc = self.N_comp
73+
74+
# default Antoine parameters: benzene / toluene / p-xylene (ln(Pa), K)
75+
if antoine_A is not None:
76+
self.antoine_A = np.array(antoine_A, dtype=float)
77+
else:
78+
self.antoine_A = np.array([20.7936, 20.9064, 20.9891])[:nc]
79+
80+
if antoine_B is not None:
81+
self.antoine_B = np.array(antoine_B, dtype=float)
82+
else:
83+
self.antoine_B = np.array([2788.51, 3096.52, 3346.65])[:nc]
84+
85+
if antoine_C is not None:
86+
self.antoine_C = np.array(antoine_C, dtype=float)
87+
else:
88+
self.antoine_C = np.array([-52.36, -53.67, -57.84])[:nc]
89+
90+
if len(self.antoine_A) != nc:
91+
raise ValueError(f"Antoine parameters must have length {nc}")
92+
if len(self.antoine_B) != nc:
93+
raise ValueError(f"Antoine parameters must have length {nc}")
94+
if len(self.antoine_C) != nc:
95+
raise ValueError(f"Antoine parameters must have length {nc}")
96+
97+
# initial holdup (equal moles by default)
98+
if N0 is not None:
99+
x0 = np.array(N0, dtype=float)
100+
else:
101+
x0 = np.full(nc, holdup / nc)
102+
103+
if len(x0) != nc:
104+
raise ValueError(f"'N0' must have length {nc}")
105+
106+
# dynamic port labels: set before super().__init__()
107+
# inputs: F, z_1, ..., z_{nc-1}, T, P
108+
inp = {"F": 0}
109+
for i in range(1, nc):
110+
inp[f"z_{i}"] = i
111+
inp["T"] = nc
112+
inp["P"] = nc + 1
113+
self.input_port_labels = inp
114+
115+
n_inputs = nc + 2
116+
117+
# outputs: V_rate, L_rate, y_1, ..., y_{nc-1}, x_1, ..., x_{nc-1}
118+
out = {"V_rate": 0, "L_rate": 1}
119+
for i in range(1, nc):
120+
out[f"y_{i}"] = 1 + i
121+
for i in range(1, nc):
122+
out[f"x_{i}"] = nc + i
123+
self.output_port_labels = out
124+
125+
# shared VLE computation
126+
def _solve_vle(z, T, P):
127+
"""Solve N-component Rachford-Rice, return (beta, y, x)."""
128+
P_sat = np.exp(self.antoine_A - self.antoine_B / (T + self.antoine_C))
129+
K = P_sat / P
130+
131+
# bubble/dew point checks
132+
bubble = np.sum(z * K)
133+
K_safe = np.where(K > 1e-12, K, 1e-12)
134+
dew = np.sum(z / K_safe)
135+
136+
if bubble <= 1.0:
137+
# subcooled: all liquid
138+
beta = 0.0
139+
y = z * K
140+
y_s = y.sum()
141+
if y_s > 0:
142+
y = y / y_s
143+
return beta, y, z.copy()
144+
145+
if dew <= 1.0:
146+
# superheated: all vapor
147+
beta = 1.0
148+
x_eq = z / K_safe
149+
x_s = x_eq.sum()
150+
if x_s > 0:
151+
x_eq = x_eq / x_s
152+
return beta, z.copy(), x_eq
153+
154+
# two-phase: solve Rachford-Rice via Brent's method
155+
Km1 = K - 1.0
156+
157+
def rr_func(beta):
158+
return np.sum(z * Km1 / (1.0 + beta * Km1))
159+
160+
# Whitson & Michelsen bounds
161+
K_max = K.max()
162+
K_min = K.min()
163+
beta_min = max(0.0, 1.0 / (1.0 - K_max)) if K_max != 1.0 else 0.0
164+
beta_max = min(1.0, 1.0 / (1.0 - K_min)) if K_min != 1.0 else 1.0
165+
166+
# ensure valid bracket
167+
beta_min = max(beta_min, 0.0)
168+
beta_max = min(beta_max, 1.0)
169+
if beta_min >= beta_max:
170+
beta_min, beta_max = 0.0, 1.0
171+
172+
try:
173+
beta = brentq(rr_func, beta_min, beta_max, xtol=1e-12)
174+
except ValueError:
175+
# fallback: try full [0, 1] bracket
176+
try:
177+
beta = brentq(rr_func, 0.0, 1.0, xtol=1e-12)
178+
except ValueError:
179+
beta = 0.5
180+
181+
beta = np.clip(beta, 0.0, 1.0)
182+
183+
y = z * K / (1.0 + beta * Km1)
184+
x_eq = z / (1.0 + beta * Km1)
185+
186+
# normalize for numerical safety
187+
y_s = y.sum()
188+
x_s = x_eq.sum()
189+
if y_s > 0:
190+
y = y / y_s
191+
if x_s > 0:
192+
x_eq = x_eq / x_s
193+
194+
return beta, y, x_eq
195+
196+
def _pad_u(u):
197+
u = np.atleast_1d(u)
198+
if len(u) < n_inputs:
199+
padded = np.zeros(n_inputs)
200+
padded[:len(u)] = u
201+
return padded
202+
return u
203+
204+
def _extract_z(u):
205+
"""Extract full composition vector from inputs (last component inferred)."""
206+
z_partial = u[1:nc] # z_1 ... z_{nc-1}
207+
z_last = 1.0 - np.sum(z_partial)
208+
return np.append(z_partial, z_last)
209+
210+
# rhs of flash drum ode
211+
def _fn_d(x, u, t):
212+
u = _pad_u(u)
213+
F_in = u[0]
214+
z = _extract_z(u)
215+
T = u[nc]
216+
P = u[nc + 1]
217+
218+
beta, y, x_eq = _solve_vle(z, T, P)
219+
220+
V_rate = beta * F_in
221+
L_rate = (1.0 - beta) * F_in
222+
223+
return F_in * z - V_rate * y - L_rate * x_eq
224+
225+
# algebraic output
226+
def _fn_a(x, u, t):
227+
u = _pad_u(u)
228+
F_in = u[0]
229+
z = _extract_z(u)
230+
T = u[nc]
231+
P = u[nc + 1]
232+
233+
beta, y, x_eq = _solve_vle(z, T, P)
234+
235+
V_rate = beta * F_in
236+
L_rate = (1.0 - beta) * F_in
237+
238+
# output: V_rate, L_rate, y_1..y_{nc-1}, x_1..x_{nc-1}
239+
result = np.empty(2 + 2 * (nc - 1))
240+
result[0] = V_rate
241+
result[1] = L_rate
242+
result[2:2 + nc - 1] = y[:nc - 1]
243+
result[2 + nc - 1:] = x_eq[:nc - 1]
244+
245+
return result
246+
247+
super().__init__(
248+
func_dyn=_fn_d,
249+
func_alg=_fn_a,
250+
initial_value=x0,
251+
)

0 commit comments

Comments
 (0)