Skip to content

Commit c7665bc

Browse files
committed
Fix flash drum holdup dynamics — VLE on drum liquid, not feed
1 parent c6f8800 commit c7665bc

3 files changed

Lines changed: 106 additions & 18 deletions

File tree

src/pathsim_chem/process/flash_drum.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,20 @@ class FlashDrum(DynamicalSystem):
4343
4444
Dynamic States
4545
---------------
46-
The holdup moles of each component in the liquid phase:
46+
The drum holds well-mixed liquid; vapor exits immediately at feed-flash
47+
composition. Liquid component holdup follows a CSTR balance with the
48+
feed-flash liquid as inlet:
4749
4850
.. math::
4951
50-
\\frac{dN_i}{dt} = F z_i - V y_i - L x_i
52+
\\frac{dN_i}{dt} = L_{in} \\, (x_{eq,i} - x_{drum,i})
53+
54+
where :math:`L_{in} = (1-\\beta) F` is the liquid feed to the drum,
55+
:math:`x_{eq}` the Rachford-Rice liquid composition for the feed, and
56+
:math:`x_{drum} = N / \\sum_j N_j` the drum liquid composition. Total
57+
holdup :math:`M = \\sum_i N_i` is preserved exactly. The output ``x_1``
58+
is :math:`x_{drum,1}` (dynamic), while ``y_1``, ``V_rate``, ``L_rate``
59+
follow the instantaneous feed flash.
5160
5261
Parameters
5362
----------
@@ -147,20 +156,24 @@ def _pad_u(u):
147156
return padded
148157
return u
149158

150-
# rhs of flash drum ode
159+
# rhs of flash drum ode: liquid CSTR fed by feed-flash liquid
151160
def _fn_d(x, u, t):
152161
u = _pad_u(u)
153162
F_in, z_1, T, P = u
154163
z = np.array([z_1, 1.0 - z_1])
155164

156-
beta, y, x_eq = _solve_vle(z, T, P)
165+
if F_in <= 0.0:
166+
return np.zeros(2)
157167

158-
V_rate = beta * F_in
159-
L_rate = (1.0 - beta) * F_in
168+
beta, _, x_eq = _solve_vle(z, T, P)
169+
L_in = (1.0 - beta) * F_in
170+
171+
M = x.sum()
172+
x_drum = x / M if M > 1e-30 else x_eq
160173

161-
return F_in * z - V_rate * y - L_rate * x_eq
174+
return L_in * (x_eq - x_drum)
162175

163-
# algebraic output
176+
# algebraic output: V/L/y from feed flash (instantaneous), x from drum
164177
def _fn_a(x, u, t):
165178
u = _pad_u(u)
166179
F_in, z_1, T, P = u
@@ -171,7 +184,10 @@ def _fn_a(x, u, t):
171184
V_rate = beta * F_in
172185
L_rate = (1.0 - beta) * F_in
173186

174-
return np.array([V_rate, L_rate, y[0], x_eq[0]])
187+
M = x.sum()
188+
x_drum = x / M if M > 1e-30 else x_eq
189+
190+
return np.array([V_rate, L_rate, y[0], x_drum[0]])
175191

176192
super().__init__(
177193
func_dyn=_fn_d,

src/pathsim_chem/process/multicomponent_flash.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,20 @@ class MultiComponentFlash(DynamicalSystem):
3535
3636
Dynamic States
3737
---------------
38-
The holdup moles of each component in the liquid phase:
38+
The drum holds well-mixed liquid; vapor exits immediately at feed-flash
39+
composition. Liquid component holdup follows a CSTR balance with the
40+
feed-flash liquid as inlet:
3941
4042
.. math::
4143
42-
\\frac{dN_i}{dt} = F z_i - V y_i - L x_i
44+
\\frac{dN_i}{dt} = L_{in} \\, (x_{eq,i} - x_{drum,i})
45+
46+
where :math:`L_{in} = (1-\\beta) F` is the liquid feed to the drum,
47+
:math:`x_{eq}` the Rachford-Rice liquid composition for the feed, and
48+
:math:`x_{drum} = N / \\sum_j N_j` the drum liquid composition. Total
49+
holdup :math:`M = \\sum_i N_i` is preserved exactly. Outputs ``x_i``
50+
are :math:`x_{drum,i}` (dynamic); ``V_rate``, ``L_rate``, ``y_i`` come
51+
from the instantaneous feed flash.
4352
4453
Parameters
4554
----------
@@ -207,22 +216,26 @@ def _extract_z(u):
207216
z_last = 1.0 - np.sum(z_partial)
208217
return np.append(z_partial, z_last)
209218

210-
# rhs of flash drum ode
219+
# rhs of flash drum ode: liquid CSTR fed by feed-flash liquid
211220
def _fn_d(x, u, t):
212221
u = _pad_u(u)
213222
F_in = u[0]
214223
z = _extract_z(u)
215224
T = u[nc]
216225
P = u[nc + 1]
217226

218-
beta, y, x_eq = _solve_vle(z, T, P)
227+
if F_in <= 0.0:
228+
return np.zeros(nc)
219229

220-
V_rate = beta * F_in
221-
L_rate = (1.0 - beta) * F_in
230+
beta, _, x_eq = _solve_vle(z, T, P)
231+
L_in = (1.0 - beta) * F_in
232+
233+
M = x.sum()
234+
x_drum = x / M if M > 1e-30 else x_eq
222235

223-
return F_in * z - V_rate * y - L_rate * x_eq
236+
return L_in * (x_eq - x_drum)
224237

225-
# algebraic output
238+
# algebraic output: V/L/y from feed flash (instant), x from drum state
226239
def _fn_a(x, u, t):
227240
u = _pad_u(u)
228241
F_in = u[0]
@@ -235,12 +248,15 @@ def _fn_a(x, u, t):
235248
V_rate = beta * F_in
236249
L_rate = (1.0 - beta) * F_in
237250

251+
M = x.sum()
252+
x_drum = x / M if M > 1e-30 else x_eq
253+
238254
# output: V_rate, L_rate, y_1..y_{nc-1}, x_1..x_{nc-1}
239255
result = np.empty(2 + 2 * (nc - 1))
240256
result[0] = V_rate
241257
result[1] = L_rate
242258
result[2:2 + nc - 1] = y[:nc - 1]
243-
result[2 + nc - 1:] = x_eq[:nc - 1]
259+
result[2 + nc - 1:] = x_drum[:nc - 1]
244260

245261
return result
246262

tests/process/test_flash_drum.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,62 @@ def test_no_feed(self):
136136
self.assertAlmostEqual(F.outputs[0], 0.0) # V_rate
137137
self.assertAlmostEqual(F.outputs[1], 0.0) # L_rate
138138

139+
def test_holdup_dynamics_drives_to_equilibrium(self):
140+
"""At steady state the drum liquid composition must equal the RR
141+
equilibrium liquid composition for the feed."""
142+
F = FlashDrum(holdup=100.0, N0=[80.0, 20.0]) # off-equilibrium init
143+
F.set_solver(EUF, parent=None)
144+
145+
# T=370 K gives two-phase region for benzene/toluene defaults at 1 atm
146+
T, P = 370.0, 101325.0
147+
u = np.array([10.0, 0.5, T, P])
148+
149+
# at the RR equilibrium x_eq, dN/dt must vanish
150+
# compute x_eq via direct VLE (binary RR with same Antoine defaults)
151+
Psat = np.exp(F.antoine_A - F.antoine_B / (T + F.antoine_C))
152+
K = Psat / P
153+
z = np.array([0.5, 0.5])
154+
d1, d2 = K[0] - 1, K[1] - 1
155+
beta = -(z[0]*d1 + z[1]*d2) / (d1*d2)
156+
x_eq = z / (1.0 + beta * (K - 1.0))
157+
x_eq = x_eq / x_eq.sum()
158+
159+
# state at equilibrium with same total holdup
160+
N_eq = 100.0 * x_eq
161+
dN = F.op_dyn(N_eq, u, 0.0)
162+
self.assertTrue(np.allclose(dN, 0.0, atol=1e-10))
163+
164+
# state away from equilibrium: dN must be non-zero
165+
dN_off = F.op_dyn(np.array([80.0, 20.0]), u, 0.0)
166+
self.assertGreater(np.linalg.norm(dN_off), 1e-3)
167+
168+
def test_holdup_total_moles_conserved(self):
169+
"""dM/dt = sum(dN/dt) must be exactly zero (perfect level control)."""
170+
F = FlashDrum(holdup=100.0, N0=[70.0, 30.0])
171+
F.set_solver(EUF, parent=None)
172+
173+
u = np.array([5.0, 0.4, 355.0, 101325.0])
174+
for state in (np.array([70.0, 30.0]),
175+
np.array([20.0, 80.0]),
176+
np.array([1.0, 99.0])):
177+
dN = F.op_dyn(state, u, 0.0)
178+
self.assertAlmostEqual(dN.sum(), 0.0, places=10,
179+
msg=f"dM/dt != 0 for state {state}")
180+
181+
def test_x_output_uses_drum_state(self):
182+
"""x_1 output must reflect drum state, not feed composition."""
183+
F = FlashDrum(holdup=100.0, N0=[90.0, 10.0])
184+
F.set_solver(EUF, parent=None)
185+
186+
F.inputs[0] = 10.0
187+
F.inputs[1] = 0.3 # different from drum
188+
F.inputs[2] = 360.0
189+
F.inputs[3] = 101325.0
190+
191+
F.update(None)
192+
# drum state x_1 = 90/100 = 0.9
193+
self.assertAlmostEqual(F.outputs[3], 0.9, places=8)
194+
139195

140196
# RUN TESTS LOCALLY ====================================================================
141197

0 commit comments

Comments
 (0)