Skip to content

Commit 624d09f

Browse files
committed
Quality pass: vectorize HX, add HX Jacobian, extract FlashDrum VLE helper, clean up CSTR
1 parent aaec52d commit 624d09f

4 files changed

Lines changed: 103 additions & 125 deletions

File tree

src/pathsim_chem/process/cstr.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,6 @@ def __init__(self, V=1.0, F=0.1, k0=1e6, Ea=50000.0, n=1.0,
108108
self.Cp = Cp
109109
self.UA = UA
110110

111-
# derived
112-
self.tau = V / F
113-
114111
# rhs of CSTR ode system
115112
def _fn_d(x, u, t):
116113
C_A, T = x
@@ -119,35 +116,32 @@ def _fn_d(x, u, t):
119116
tau = self.V / self.F
120117
k = self.k0 * np.exp(-self.Ea / (R_GAS * T))
121118
r = k * C_A**self.n
119+
rcp = (-self.dH_rxn) / (self.rho * self.Cp)
120+
ua_term = self.UA / (self.rho * self.Cp * self.V)
122121

123122
dC_A = (C_A_in - C_A) / tau - r
124-
dT = ((T_in - T) / tau
125-
+ (-self.dH_rxn) / (self.rho * self.Cp) * r
126-
+ self.UA / (self.rho * self.Cp * self.V) * (T_c - T))
123+
dT = (T_in - T) / tau + rcp * r + ua_term * (T_c - T)
127124

128125
return np.array([dC_A, dT])
129126

130127
# jacobian of rhs wrt state [C_A, T]
131128
def _jc_d(x, u, t):
132129
C_A, T = x
130+
133131
tau = self.V / self.F
134132
k = self.k0 * np.exp(-self.Ea / (R_GAS * T))
135-
136-
# partial derivatives
137133
dk_dT = k * self.Ea / (R_GAS * T**2)
138134

139-
# dr/dC_A and dr/dT
140135
dr_dCA = k * self.n * C_A**(self.n - 1) if C_A > 0 else 0.0
141136
dr_dT = dk_dT * C_A**self.n
142137

143138
rcp = (-self.dH_rxn) / (self.rho * self.Cp)
144139
ua_term = self.UA / (self.rho * self.Cp * self.V)
145140

146-
J = np.array([
147-
[-1.0/tau - dr_dCA, -dr_dT],
141+
return np.array([
142+
[-1.0/tau - dr_dCA, -dr_dT],
148143
[rcp * dr_dCA, -1.0/tau + rcp * dr_dT - ua_term]
149144
])
150-
return J
151145

152146
# output function: well-mixed => outlet = state
153147
def _fn_a(x, u, t):

src/pathsim_chem/process/flash_drum.py

Lines changed: 33 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ class FlashDrum(DynamicalSystem):
3535
3636
\\sum_i \\frac{z_i (K_i - 1)}{1 + \\beta (K_i - 1)} = 0
3737
38-
For a binary system this has an analytical solution.
38+
For a binary system this has the analytical solution:
39+
40+
.. math::
41+
42+
\\beta = -\\frac{z_1(K_1 - 1) + z_2(K_2 - 1)}{(K_1 - 1)(K_2 - 1)}
3943
4044
Dynamic States
4145
---------------
@@ -97,108 +101,53 @@ def __init__(self, holdup=100.0,
97101
else:
98102
x0 = np.array([holdup / 2.0, holdup / 2.0])
99103

100-
# rhs of flash drum ode
101-
def _fn_d(x, u, t):
102-
F_in, z_1, T, P = u
103-
104-
z = np.array([z_1, 1.0 - z_1])
105-
N_total = x[0] + x[1]
106-
107-
# liquid composition from holdup
108-
if N_total > 0:
109-
x_liq = x / N_total
110-
else:
111-
x_liq = np.array([0.5, 0.5])
112-
113-
# K-values from Antoine + Raoult
104+
# shared VLE computation
105+
def _solve_vle(z, T, P):
106+
"""Solve binary Rachford-Rice analytically, return (beta, y, x)."""
114107
P_sat = np.exp(self.antoine_A - self.antoine_B / (T + self.antoine_C))
115108
K = P_sat / P
116109

117-
# Rachford-Rice for binary: analytical solution
118-
# beta = (z1*(K1-1) + z2*(K2-1)) ... but simpler:
119-
# beta = (1 - sum(z/K)) / (sum(z*K) - 1) ... use standard form
120-
# For binary: beta = (z1 - 1/K1_eff) / (1 - 1/K1_eff) ...
121-
# Actually use: beta from f(beta) = sum zi(Ki-1)/(1+beta(Ki-1)) = 0
122-
# For binary with z = x_liq (liquid feed to flash):
123-
denom1 = K[0] - 1.0
124-
denom2 = K[1] - 1.0
125-
126-
if abs(denom1) < 1e-12 and abs(denom2) < 1e-12:
127-
# no separation
110+
d1 = K[0] - 1.0
111+
d2 = K[1] - 1.0
112+
113+
if abs(d1) < 1e-12 and abs(d2) < 1e-12:
128114
beta = 0.0
129115
else:
130-
# Solve Rachford-Rice for feed composition z
131-
# f(beta) = z[0]*(K[0]-1)/(1+beta*(K[0]-1)) + z[1]*(K[1]-1)/(1+beta*(K[1]-1)) = 0
132-
# For binary: beta = -z[0]*(K[0]-1)/((K[0]-1)*(K[1]-1)) ...
133-
# Direct: beta = (z[0]*(K[0]-1) + z[1]*(K[1]-1)) isn't right
134-
# Newton or analytical: for 2 components
135-
# z1(K1-1)/(1+b(K1-1)) + z2(K2-1)/(1+b(K2-1)) = 0
136-
# z1(K1-1)(1+b(K2-1)) + z2(K2-1)(1+b(K1-1)) = 0
137-
# z1(K1-1) + z1(K1-1)*b*(K2-1) + z2(K2-1) + z2(K2-1)*b*(K1-1) = 0
138-
# [z1(K1-1) + z2(K2-1)] + b*(K1-1)*(K2-1)*[z1+z2] = 0
139-
# b = -[z1(K1-1) + z2(K2-1)] / [(K1-1)*(K2-1)]
140-
num = z[0] * denom1 + z[1] * denom2
141-
den = denom1 * denom2
142-
if abs(den) < 1e-12:
143-
beta = 0.0
144-
else:
145-
beta = -num / den
146-
147-
# clamp beta to [0, 1]
116+
den = d1 * d2
117+
beta = -(z[0] * d1 + z[1] * d2) / den if abs(den) > 1e-12 else 0.0
118+
148119
beta = np.clip(beta, 0.0, 1.0)
149120

150-
# vapor and liquid compositions
151121
y = z * K / (1.0 + beta * (K - 1.0))
152122
x_eq = z / (1.0 + beta * (K - 1.0))
153123

154-
# normalize for safety
155-
y_sum = y.sum()
156-
x_sum = x_eq.sum()
157-
if y_sum > 0:
158-
y = y / y_sum
159-
if x_sum > 0:
160-
x_eq = x_eq / x_sum
161-
162-
# flow rates
163-
V_rate = beta * F_in
164-
L_rate = (1.0 - beta) * F_in
165-
166-
# holdup dynamics
167-
dN = F_in * z - V_rate * y - L_rate * x_eq
124+
# normalize for numerical safety
125+
y_s, x_s = y.sum(), x_eq.sum()
126+
if y_s > 0:
127+
y = y / y_s
128+
if x_s > 0:
129+
x_eq = x_eq / x_s
168130

169-
return dN
131+
return beta, y, x_eq
170132

171-
# algebraic output: compute VLE and return rates + compositions
172-
def _fn_a(x, u, t):
133+
# rhs of flash drum ode
134+
def _fn_d(x, u, t):
173135
F_in, z_1, T, P = u
174-
175136
z = np.array([z_1, 1.0 - z_1])
176137

177-
# K-values
178-
P_sat = np.exp(self.antoine_A - self.antoine_B / (T + self.antoine_C))
179-
K = P_sat / P
180-
181-
denom1 = K[0] - 1.0
182-
denom2 = K[1] - 1.0
138+
beta, y, x_eq = _solve_vle(z, T, P)
183139

184-
if abs(denom1) < 1e-12 and abs(denom2) < 1e-12:
185-
beta = 0.0
186-
else:
187-
num = z[0] * denom1 + z[1] * denom2
188-
den = denom1 * denom2
189-
beta = -num / den if abs(den) > 1e-12 else 0.0
140+
V_rate = beta * F_in
141+
L_rate = (1.0 - beta) * F_in
190142

191-
beta = np.clip(beta, 0.0, 1.0)
143+
return F_in * z - V_rate * y - L_rate * x_eq
192144

193-
y = z * K / (1.0 + beta * (K - 1.0))
194-
x_eq = z / (1.0 + beta * (K - 1.0))
145+
# algebraic output
146+
def _fn_a(x, u, t):
147+
F_in, z_1, T, P = u
148+
z = np.array([z_1, 1.0 - z_1])
195149

196-
y_sum = y.sum()
197-
x_sum = x_eq.sum()
198-
if y_sum > 0:
199-
y = y / y_sum
200-
if x_sum > 0:
201-
x_eq = x_eq / x_sum
150+
beta, y, x_eq = _solve_vle(z, T, P)
202151

203152
V_rate = beta * F_in
204153
L_rate = (1.0 - beta) * F_in

src/pathsim_chem/process/heat_exchanger.py

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -105,53 +105,90 @@ def __init__(self, N_cells=5, F_h=0.1, F_c=0.1, V_h=0.5, V_c=0.5,
105105
self.rho_c = rho_c
106106
self.Cp_c = Cp_c
107107

108-
# per-cell quantities
109108
N = self.N_cells
110-
V_cell_h = V_h / N
111-
V_cell_c = V_c / N
112-
UA_cell = UA / N
113109

114110
# initial state: interleaved [T_h1, T_c1, T_h2, T_c2, ...]
115111
x0 = np.empty(2 * N)
116-
x0[0::2] = T_h0 # hot side
117-
x0[1::2] = T_c0 # cold side
112+
x0[0::2] = T_h0
113+
x0[1::2] = T_c0
118114

119-
# rhs of heat exchanger ode
115+
# rhs of heat exchanger ode (vectorized)
120116
def _fn_d(x, u, t):
121117
T_h_in, T_c_in = u
122-
dx = np.zeros(2 * N)
118+
N = self.N_cells
123119

124-
_V_cell_h = self.V_h / self.N_cells
125-
_V_cell_c = self.V_c / self.N_cells
126-
_UA_cell = self.UA / self.N_cells
120+
V_cell_h = self.V_h / N
121+
V_cell_c = self.V_c / N
122+
UA_cell = self.UA / N
127123

128-
alpha_h = _UA_cell / (self.rho_h * self.Cp_h * _V_cell_h)
129-
alpha_c = _UA_cell / (self.rho_c * self.Cp_c * _V_cell_c)
130-
flow_h = self.F_h / _V_cell_h
131-
flow_c = self.F_c / _V_cell_c
124+
fh = self.F_h / V_cell_h
125+
fc = self.F_c / V_cell_c
126+
ah = UA_cell / (self.rho_h * self.Cp_h * V_cell_h)
127+
ac = UA_cell / (self.rho_c * self.Cp_c * V_cell_c)
132128

133-
for i in range(self.N_cells):
134-
T_h_i = x[2*i]
135-
T_c_i = x[2*i + 1]
129+
T_h = x[0::2]
130+
T_c = x[1::2]
136131

137-
# hot side: upstream is i-1, or inlet
138-
T_h_prev = x[2*(i-1)] if i > 0 else T_h_in
139-
# cold side: upstream (counter-current) is i+1, or inlet
140-
T_c_next = x[2*(i+1) + 1] if i < self.N_cells - 1 else T_c_in
132+
# upstream temperatures with boundary conditions
133+
T_h_prev = np.empty(N)
134+
T_h_prev[0] = T_h_in
135+
T_h_prev[1:] = T_h[:-1]
141136

142-
dx[2*i] = flow_h * (T_h_prev - T_h_i) - alpha_h * (T_h_i - T_c_i)
143-
dx[2*i + 1] = flow_c * (T_c_next - T_c_i) + alpha_c * (T_h_i - T_c_i)
137+
T_c_next = np.empty(N)
138+
T_c_next[-1] = T_c_in
139+
T_c_next[:-1] = T_c[1:]
140+
141+
dx = np.empty(2 * N)
142+
dx[0::2] = fh * (T_h_prev - T_h) - ah * (T_h - T_c)
143+
dx[1::2] = fc * (T_c_next - T_c) + ac * (T_h - T_c)
144144

145145
return dx
146146

147+
# analytical jacobian (block-tridiagonal structure)
148+
def _jc_d(x, u, t):
149+
N = self.N_cells
150+
151+
V_cell_h = self.V_h / N
152+
V_cell_c = self.V_c / N
153+
UA_cell = self.UA / N
154+
155+
fh = self.F_h / V_cell_h
156+
fc = self.F_c / V_cell_c
157+
ah = UA_cell / (self.rho_h * self.Cp_h * V_cell_h)
158+
ac = UA_cell / (self.rho_c * self.Cp_c * V_cell_c)
159+
160+
dim = 2 * N
161+
J = np.zeros((dim, dim))
162+
163+
for i in range(N):
164+
hi = 2 * i # hot index
165+
ci = 2 * i + 1 # cold index
166+
167+
# dT_h_i/dT_h_i, dT_h_i/dT_c_i
168+
J[hi, hi] = -fh - ah
169+
J[hi, ci] = ah
170+
171+
# dT_c_i/dT_h_i, dT_c_i/dT_c_i
172+
J[ci, hi] = ac
173+
J[ci, ci] = -fc - ac
174+
175+
# dT_h_i/dT_h_{i-1} (hot upstream coupling)
176+
if i > 0:
177+
J[hi, 2*(i-1)] = fh
178+
179+
# dT_c_i/dT_c_{i+1} (cold upstream coupling, counter-current)
180+
if i < N - 1:
181+
J[ci, 2*(i+1) + 1] = fc
182+
183+
return J
184+
147185
# output: hot outlet = last cell hot, cold outlet = first cell cold
148186
def _fn_a(x, u, t):
149-
T_h_out = x[2*(self.N_cells - 1)] # last hot cell
150-
T_c_out = x[1] # first cold cell
151-
return np.array([T_h_out, T_c_out])
187+
return np.array([x[2*(self.N_cells - 1)], x[1]])
152188

153189
super().__init__(
154190
func_dyn=_fn_d,
191+
jac_dyn=_jc_d,
155192
func_alg=_fn_a,
156193
initial_value=x0,
157194
)

tests/process/test_cstr.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ def test_init_default(self):
3232
self.assertEqual(C.rho, 1000.0)
3333
self.assertEqual(C.Cp, 4184.0)
3434
self.assertEqual(C.UA, 500.0)
35-
self.assertAlmostEqual(C.tau, 10.0)
3635

3736
def test_init_custom(self):
3837
"""Test custom initialization."""
@@ -41,7 +40,6 @@ def test_init_custom(self):
4140
C_A0=1.5, T0=350.0)
4241
self.assertEqual(C.V, 2.0)
4342
self.assertEqual(C.F, 0.5)
44-
self.assertAlmostEqual(C.tau, 4.0)
4543

4644
C.set_solver(EUF, parent=None)
4745
self.assertTrue(np.allclose(C.engine.initial_value, [1.5, 350.0]))

0 commit comments

Comments
 (0)