Skip to content

Commit d3cc4c4

Browse files
authored
Merge pull request #26 from pathsim/fix/audit-findings
Fix four audit findings: flash holdup, UNIQUAC NaN, Redlich-Kister, TCAP stub
2 parents c6f8800 + 104f920 commit d3cc4c4

8 files changed

Lines changed: 164 additions & 50 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

src/pathsim_chem/thermodynamics/activity_coefficients.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -306,17 +306,22 @@ def _eval(self, T):
306306
tau = np.exp(self.a + self.b_param / T + self.c_param * np.log(T) + self.d_param * T)
307307

308308
# volume and surface fractions
309-
V = self.r * x / np.dot(self.r, x)
310-
F = self.q * x / np.dot(self.q, x)
309+
sum_rx = np.dot(self.r, x)
310+
sum_qx = np.dot(self.q, x)
311311
Fp = self.q_prime * x / np.dot(self.q_prime, x)
312312

313313
# combinatorial part
314+
# V_i / x_i = r_i / sum_j(r_j x_j) is well-defined even at x_i=0
315+
# F_i / V_i = (q_i / r_i) * sum_rx / sum_qx (algebraic identity)
316+
V_over_x = self.r / sum_rx
317+
F_over_V = (self.q / self.r) * (sum_rx / sum_qx)
318+
314319
ln_gamma_C = np.zeros(n)
315320
sum_xl = np.dot(x, self.l)
316321
for i in range(n):
317-
ln_gamma_C[i] = (np.log(V[i] / x[i])
318-
+ self.z / 2 * self.q[i] * np.log(F[i] / V[i])
319-
+ self.l[i] - V[i] / x[i] * sum_xl)
322+
ln_gamma_C[i] = (np.log(V_over_x[i])
323+
+ self.z / 2 * self.q[i] * np.log(F_over_V[i])
324+
+ self.l[i] - V_over_x[i] * sum_xl)
320325

321326
# residual part
322327
ln_gamma_R = np.zeros(n)

src/pathsim_chem/thermodynamics/enthalpy.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -301,22 +301,24 @@ class ExcessEnthalpyRedlichKister(Function):
301301
Computes the molar excess enthalpy using a Redlich-Kister polynomial
302302
expansion. This is a flexible, empirical model for representing binary
303303
excess properties. For a multi-component mixture, the excess enthalpy
304-
is computed as a sum of binary pair contributions:
304+
is summed over the unique binary pairs supplied:
305305
306306
.. math::
307307
308-
h^E = \frac{1}{2} \sum_i \sum_j h^E_{ij}
308+
h^E = \sum_{(i,j)} h^E_{ij}
309309
310-
Each binary pair contribution:
310+
Each binary pair contribution follows the standard Redlich-Kister form
311311
312312
.. math::
313313
314314
h^E_{ij} = \frac{x_i x_j}{x_i + x_j}
315-
\left(A(T) x_d + B(T) x_d^2 + C(T) x_d^3 + \ldots\right)
315+
\left(A_0(T) + A_1(T) x_d + A_2(T) x_d^2 + \ldots\right)
316316
317-
where :math:`x_d = x_i - x_j` and the coefficients :math:`A(T)`,
318-
:math:`B(T)`, ... are temperature-dependent polynomials:
319-
:math:`A(T) = a_0 + a_1 T + a_2 T^2 + \ldots`
317+
where :math:`x_d = x_i - x_j` and each :math:`A_k(T)` is a temperature
318+
polynomial :math:`A_k(T) = a_{k,0} + a_{k,1} T + a_{k,2} T^2 + \ldots`.
319+
For a true binary system :math:`x_i + x_j = 1` and the prefactor
320+
reduces to :math:`x_i x_j`; in multicomponent mixtures the
321+
:math:`/(x_i+x_j)` term provides the symmetric extension.
320322
321323
**Input port:** ``T`` -- temperature [K].
322324
@@ -328,12 +330,15 @@ class ExcessEnthalpyRedlichKister(Function):
328330
Mole fractions [N].
329331
coeffs : dict
330332
Redlich-Kister coefficients keyed by binary pair ``(i, j)`` as tuples.
331-
Each value is a list of polynomial coefficient arrays, one per
332-
Redlich-Kister term. Example for a single pair (0,1) with 3 terms::
333+
Provide each unordered pair only once (e.g. ``(0, 1)``, not also
334+
``(1, 0)``). Each value is a list of polynomial coefficient arrays,
335+
one per Redlich-Kister term. Example for a single pair (0,1) with
336+
3 terms::
333337
334338
{(0, 1): [[a0, a1], [b0, b1], [c0]]}
335339
336-
This gives A(T)=a0+a1*T, B(T)=b0+b1*T, C(T)=c0.
340+
This gives A_0(T)=a0+a1*T, A_1(T)=b0+b1*T, A_2(T)=c0, where
341+
:math:`A_k` multiplies :math:`x_d^k`.
337342
"""
338343

339344
input_port_labels = {"T": 0}
@@ -358,9 +363,9 @@ def _eval(self, T):
358363

359364
xd = xi - xj
360365

361-
# evaluate each Redlich-Kister term
366+
# evaluate Redlich-Kister polynomial: A_0 + A_1*xd + A_2*xd^2 + ...
362367
pair_hE = 0.0
363-
xd_power = xd
368+
xd_power = 1.0
364369
for poly_coeffs in terms:
365370
# temperature polynomial: c0 + c1*T + c2*T^2 + ...
366371
coeff_val = 0.0
@@ -373,7 +378,7 @@ def _eval(self, T):
373378

374379
hE += xi * xj / x_sum * pair_hE
375380

376-
return 0.5 * hE
381+
return hE
377382

378383

379384
# ENTHALPY DEPARTURE FROM EOS (9.7) =====================================================

src/pathsim_chem/tritium/tcap.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@
1212
# BLOCKS ================================================================================
1313

1414
class TCAP1D(ODE):
15-
"""This block models the Thermal Cycle Absorption Process (TCAP) in 1d.
15+
"""This block models the Thermal Cycle Absorption Process (TCAP) in 1d.
1616
17-
The model uses a 1d finite difference spatial discretization to construct
17+
The model uses a 1d finite difference spatial discretization to construct
1818
a nonlinear ODE internally as proposed in
1919
2020
https://doi.org/10.1016/j.ijhydene.2023.03.101
2121
22-
2322
"""
24-
raise NotImplementedError("TCAP1D block is currently not impolemented!")
23+
24+
def __init__(self, *args, **kwargs):
25+
raise NotImplementedError("TCAP1D block is not yet implemented")
2526

2627

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

tests/thermodynamics/test_activity_coefficients.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ def test_pure_component_gamma_is_one(self):
174174
)
175175
gamma1, gamma2 = eval_block(U, 350)
176176
self.assertAlmostEqual(gamma1, 1.0, places=10)
177+
# gamma_2 at infinite dilution must be finite (not NaN)
178+
self.assertTrue(np.isfinite(gamma2))
179+
self.assertGreater(gamma2, 0.0)
177180

178181
def test_symmetric_case(self):
179182
# Identical r, q, symmetric a => gamma_1 = gamma_2

tests/thermodynamics/test_enthalpy.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -201,28 +201,40 @@ def test_init(self):
201201
)
202202
self.assertEqual(hE.n, 2)
203203

204-
def test_symmetric_binary(self):
205-
# Simplest case: one-term Redlich-Kister with constant A
206-
# h^E_ij = x_i*x_j/(x_i+x_j) * A * (x_i-x_j)
207-
# For x=[0.5, 0.5]: h^E = 0.5 * 0.5 / 1.0 * A * 0 = 0
204+
def test_constant_coeff_only(self):
205+
# One-term Redlich-Kister with constant A_0:
206+
# h^E = x_i*x_j/(x_i+x_j) * A_0
207+
# For x=[0.5, 0.5], A_0=1000: h^E = 0.25/1.0 * 1000 = 250
208208
hE = ExcessEnthalpyRedlichKister(
209209
x=[0.5, 0.5],
210210
coeffs={(0, 1): [[1000.0]]},
211211
)
212212
result = eval_block_T(hE, 300)
213-
self.assertAlmostEqual(result, 0.0, places=10)
213+
self.assertAlmostEqual(result, 250.0, places=10)
214214

215-
def test_asymmetric_composition(self):
216-
# x=[0.3, 0.7], one-term A=2000
217-
# h^E = 0.5 * 0.3*0.7/1.0 * 2000 * (0.3-0.7) = 0.5 * 0.21 * 2000 * (-0.4) = -84
215+
def test_two_term_polynomial(self):
216+
# x=[0.3, 0.7], coeffs A_0=2000, A_1=500
217+
# h^E = 0.21 * (2000 + 500*(-0.4)) = 0.21 * 1800 = 378
218218
hE = ExcessEnthalpyRedlichKister(
219219
x=[0.3, 0.7],
220-
coeffs={(0, 1): [[2000.0]]},
220+
coeffs={(0, 1): [[2000.0], [500.0]]},
221221
)
222222
result = eval_block_T(hE, 300)
223-
expected = 0.5 * 0.3 * 0.7 / 1.0 * 2000.0 * (0.3 - 0.7)
223+
expected = 0.3 * 0.7 / 1.0 * (2000.0 + 500.0 * (0.3 - 0.7))
224224
self.assertAlmostEqual(result, expected, places=5)
225225

226+
def test_antisymmetric_under_pair_swap(self):
227+
# Odd-order RK terms should flip sign when (i,j) <-> (j,i),
228+
# since x_d = x_i - x_j flips. Verify by swapping x values.
229+
hE_a = ExcessEnthalpyRedlichKister(
230+
x=[0.3, 0.7], coeffs={(0, 1): [[0.0], [1000.0]]}, # A_1 only
231+
)
232+
hE_b = ExcessEnthalpyRedlichKister(
233+
x=[0.7, 0.3], coeffs={(0, 1): [[0.0], [1000.0]]},
234+
)
235+
self.assertAlmostEqual(eval_block_T(hE_a, 300),
236+
-eval_block_T(hE_b, 300), places=8)
237+
226238
def test_temperature_dependent_coeffs(self):
227239
# A(T) = 1000 + 2*T
228240
hE = ExcessEnthalpyRedlichKister(

0 commit comments

Comments
 (0)