Skip to content

Commit f94c6e8

Browse files
committed
implement better bubble point with lle
1 parent 830d7fc commit f94c6e8

6 files changed

Lines changed: 162 additions & 69 deletions

File tree

thermosteam/_stream.py

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2338,7 +2338,7 @@ def bubble_point_at_T(self, T: Optional[float] = None, IDs: Optional[Sequence[st
23382338
z = self.get_normalized_mol(bp.IDs)
23392339
return bp(z, T=T or self.T)
23402340

2341-
def bubble_point_at_P(self, P: Optional[float] = None, IDs: Optional[Sequence[str]] = None, vlle=False):
2341+
def bubble_point_at_P(self, P: Optional[float] = None, IDs: Optional[Sequence[str]] = None, lle=False):
23422342
"""
23432343
Return a BubblePointResults object with all data on the bubble point at constant pressure.
23442344
@@ -2356,27 +2356,9 @@ def bubble_point_at_P(self, P: Optional[float] = None, IDs: Optional[Sequence[st
23562356
BubblePointValues(T=357.14, P=101325, IDs=('Water', 'Ethanol'), z=[0.836 0.164], y=[0.492 0.508])
23572357
23582358
"""
2359-
if vlle:
2360-
phase = self.phase
2361-
try:
2362-
# TODO: Optimize
2363-
vlle = self.vlle
2364-
vlle.top_chemical = self.chemicals.IDs[self.imol.data.sum(axis=0).argmax()]
2365-
if P is None: P = self.P
2366-
vlle.bubble_point_at_P(P)
2367-
BP = self.get_bubble_point(IDs)
2368-
x = self.imol['L', BP.IDs]
2369-
bp = BP(P=P, z=x / x.sum())
2370-
z = self.imol[bp.IDs]
2371-
z /= z.sum()
2372-
bp.z = z / z.sum()
2373-
finally:
2374-
self.phase = phase
2375-
return bp
2376-
else:
2377-
BP = self.get_bubble_point(IDs)
2378-
z = self.get_normalized_mol(BP.IDs)
2379-
return BP(z, P=P or self.P)
2359+
BP = self.get_bubble_point(IDs)
2360+
z = self.get_normalized_mol(BP.IDs)
2361+
return BP(z, P=P or self.P, lle=lle)
23802362

23812363
def dew_point_at_T(self, T: Optional[float] = None, IDs: Optional[Sequence[str]] = None):
23822364
"""

thermosteam/equilibrium/bubble_point.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import flexsolve as flx
1212
from .fugacity_coefficients import IdealFugacityCoefficients
1313
from .domain import vle_domain
14+
from .lle import solve_lle_mol
1415
from ..exceptions import InfeasibleRegion
1516
from .. import functional as fn
1617
from .._settings import settings
@@ -156,6 +157,18 @@ def _T_error(self, T, P, z_over_P, z_norm, y):
156157
y[:] = solve_y(y_phi, self.phi, T, P, y)
157158
return 1. - y.sum()
158159

160+
def _T_error_lle(self, T, P, z, y, x):
161+
if T <= 0: raise InfeasibleRegion('negative temperature')
162+
Psats = np.array([i(T) for i in self.Psats], dtype=float)
163+
mol = solve_lle_mol(self.gamma, z, T, P, sample=x)
164+
x[:] = mol / mol.sum() if mol.any() else z
165+
y_phi = (x / P
166+
* Psats
167+
* self.gamma(x, T, P)
168+
* self.pcf(T, P, Psats))
169+
y[:] = solve_y(y_phi, self.phi, T, P, y)
170+
return 1. - y.sum()
171+
159172
def _P_error(self, P, T, z_Psat_gamma, Psats, y):
160173
if P <= 0: raise InfeasibleRegion('negative pressure')
161174
y_phi = z_Psat_gamma * self.pcf(T, P, Psats) / P
@@ -168,6 +181,14 @@ def _P_error_dep(self, P, T, z, Psats, z_Psats, y):
168181
y[:] = solve_y(y_phi, self.phi, T, P, y)
169182
return 1. - y.sum()
170183

184+
def _P_error_lle(self, P, T, z, Psats, y, x):
185+
if P <= 0: raise InfeasibleRegion('negative pressure')
186+
mol = solve_lle_mol(self.gamma, z, T, P, sample=x)
187+
x[:] = mol / mol.sum() if mol.any() else z
188+
y_phi = Psats * x * self.gamma(x, T, P) * self.pcf(T, P, Psats) / P
189+
y[:] = solve_y(y_phi, self.phi, T, P, y)
190+
return 1. - y.sum()
191+
171192
def _T_error_reactive(self, T, P, z, dz, y, x, liquid_conversion):
172193
if T <= 0: raise InfeasibleRegion('negative temperature')
173194
dz[:] = liquid_conversion(z, T, P, 'l')
@@ -217,21 +238,21 @@ def _Py_ideal(self, z_Psat_gamma_pcf):
217238
y = z_Psat_gamma_pcf / P
218239
return P, y
219240

220-
def __call__(self, z, *, T=None, P=None, liquid_conversion=None):
241+
def __call__(self, z, *, T=None, P=None, liquid_conversion=None, lle=False):
221242
z = np.asarray(z, float)
222243
if T:
223244
if P: raise ValueError("may specify either T or P, not both")
224-
P, *args = self.solve_Py(z, T, liquid_conversion)
245+
P, *args = self.solve_Py(z, T, liquid_conversion, lle)
225246
elif P:
226-
T, *args = self.solve_Ty(z, P, liquid_conversion)
247+
T, *args = self.solve_Ty(z, P, liquid_conversion, lle)
227248
else:
228249
raise ValueError("must specify either T or P")
229250
if liquid_conversion:
230251
return ReactiveBubblePointValues(T, P, self.IDs, z, *args)
231252
else:
232253
return BubblePointValues(T, P, self.IDs, z, *args)
233254

234-
def solve_Ty(self, z, P, liquid_conversion=None, guess=None):
255+
def solve_Ty(self, z, P, liquid_conversion=None, lle=False):
235256
"""
236257
Bubble point at given composition and pressure.
237258
@@ -270,11 +291,15 @@ def solve_Ty(self, z, P, liquid_conversion=None, guess=None):
270291
y = z.copy()
271292
return T, fn.normalize(y)
272293
elif liquid_conversion is None:
273-
f = self._T_error
274-
z_norm = z / z.sum()
275-
z_over_P = z/P
294+
z = z / z.sum()
295+
z_over_P = z / P
276296
T_guess, y = self._Ty_ideal(z_over_P)
277-
args = (P, z_over_P, z_norm, y)
297+
if lle:
298+
f = self._T_error_lle
299+
args = (P, z, y, y.copy())
300+
else:
301+
f = self._T_error
302+
args = (P, z_over_P, z, y)
278303
try:
279304
T = flx.aitken_secant(f, T_guess, T_guess + 1e-3,
280305
self.T_tol, 5e-12, args,
@@ -311,7 +336,7 @@ def solve_Ty(self, z, P, liquid_conversion=None, guess=None):
311336
maxiter=self.maxiter)
312337
return T, dz, fn.normalize(y), x
313338

314-
def solve_Py(self, z, T, liquid_conversion=None):
339+
def solve_Py(self, z, T, liquid_conversion=None, lle=False):
315340
"""
316341
Bubble point at given composition and temperature.
317342
@@ -353,17 +378,23 @@ def solve_Py(self, z, T, liquid_conversion=None):
353378
if T > self.Tmax: T = self.Tmax
354379
elif T < self.Tmin: T = self.Tmin
355380
Psats = np.array([i(T) for i in self.Psats])
356-
z_norm = z / z.sum()
381+
z = z / z.sum()
357382
if self.gamma.P_dependent:
358383
f = self._P_error_dep
359-
z_Psats = z_norm * Psats
384+
z_Psats = z * Psats
360385
P_guess, y = self._Py_ideal(z_Psats)
361-
args = (T, z_norm, Psats, z_Psats, y)
386+
args = (T, z, Psats, z_Psats, y)
362387
else:
363-
z_Psat_gamma = z_norm * Psats * self.gamma(z_norm, T, 101325)
364-
P_guess, y = self._Py_ideal(z_Psat_gamma)
365-
f = self._P_error
366-
args = (T, z_Psat_gamma, Psats, y)
388+
if lle:
389+
z_Psat_gamma = z * Psats * self.gamma(z, T, 101325)
390+
P_guess, y = self._Py_ideal(z_Psat_gamma)
391+
f = self._P_error_lle
392+
args = (P, T, z, Psats, y, y.copy())
393+
else:
394+
z_Psat_gamma = z * Psats * self.gamma(z, T, 101325)
395+
P_guess, y = self._Py_ideal(z_Psat_gamma)
396+
f = self._P_error
397+
args = (T, z_Psat_gamma, Psats, y)
367398
try:
368399
P = flx.aitken_secant(f, P_guess, P_guess-1, self.P_tol, 1e-9,
369400
args, checkiter=False, maxiter=self.maxiter)

thermosteam/equilibrium/fugacities.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def unweighted(self, x, T, P=101325.):
5656
return self.gamma(x, T, P) * self.pcf(T, P, Psats) * Psats
5757

5858
def __call__(self, x, T, P=101325., reduce=False):
59-
f_reduced = x * self.gamma(x, T)
59+
f_reduced = x * self.gamma(x, T, P)
6060
if reduce: return f_reduced
6161
Psats = np.array([i.Psat(T) for i in self.chemicals], dtype=float)
6262
return f_reduced * Psats * self.pcf(T, P, Psats)

thermosteam/equilibrium/lle.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
from .equilibrium import Equilibrium
1313
from ..exceptions import NoEquilibrium
1414
from .binary_phase_fraction import phase_fraction
15-
from .tangential_plane_stability import TangentPlaneStabilityAnalysis
15+
from .tangential_plane_stability import lle_tangential_plan_analysis
1616
from scipy.optimize import shgo, differential_evolution
1717
import flexsolve as flx
1818
import numpy as np
19+
from scipy.optimize import minimize
1920

2021
__all__ = ('LLE', 'LLECache')
2122

@@ -117,6 +118,34 @@ def pseudo_equilibrium(K, phi, z, T, n, f_gamma, gamma_args, inner_loop_options,
117118
else:
118119
return z
119120

121+
# Light weight
122+
def solve_lle_mol(gamma, z, T, P, sample=None, phi=1):
123+
n = z.size
124+
stability = lle_tangential_plan_analysis(gamma, z, T, P, sample=sample)
125+
if stability.unstable:
126+
y = stability.candidate
127+
y[y < 1e-64] = 1e-64
128+
if stability.sample_unstable:
129+
phi = min(0.99 * (z / y).min(), phi)
130+
else:
131+
phi = 0.99 * (z / y).min()
132+
x = z - phi * y
133+
x /= x.sum()
134+
K = gamma(y, T) / gamma(x, T)
135+
else:
136+
return z
137+
K[K <= 0] = 1e-9
138+
mol = pseudo_equilibrium(
139+
K, phi, z, T, n, gamma.f, gamma.args,
140+
LLE.pseudo_equilibrium_inner_loop_options,
141+
LLE.pseudo_equilibrium_outer_loop_options,
142+
)
143+
if mol.any():
144+
return mol / mol.sum()
145+
else:
146+
return z
147+
148+
120149
class LLE(Equilibrium, phases='lL'):
121150
"""
122151
Create a LLE object that performs liquid-liquid equilibrium when called.
@@ -328,8 +357,7 @@ def solve_lle_liquid_mol(self, z, T, lle_chemicals, single_loop):
328357
sample = x0
329358
else:
330359
sample = None
331-
TPSA = TangentPlaneStabilityAnalysis('lL', lle_chemicals, thermo=self.thermo)
332-
stability = TPSA(z, T, 101325, sample=sample)
360+
stability = lle_tangential_plan_analysis(gamma, z, T, 101325, sample=sample)
333361
if stability.unstable:
334362
y = stability.candidate
335363
y[y < 1e-64] = 1e-64

thermosteam/equilibrium/tangential_plane_stability.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ def edge_points_simplex_masked(z: np.ndarray,
6363
t = 0.5 * (1 - np.cos(np.pi * k / (points_per_edge - 1)))
6464

6565
pts = []
66-
6766
# edges only among the selected components
6867
for i, j in combinations(active, 2):
6968
for tau in t:
@@ -90,6 +89,75 @@ def edge_points_simplex_masked(z: np.ndarray,
9089

9190
return np.array(pts)
9291

92+
# Light weight
93+
def lle_tangential_plan_analysis(gamma, z, T, P, sample=None):
94+
MW = np.array([i.MW for i in gamma.chemicals])
95+
logfz = np.log(z * gamma(z, T, P) + 1e-30)
96+
97+
def objective(w, T, P, logfz, softmax=False):
98+
if softmax:
99+
w = np.exp(w - np.max(w)) # Softmax for unconstrained optimization
100+
w /= w.sum()
101+
return np.dot(w, np.log(w * gamma(w, T, P) + 1e-30) - logfz)
102+
103+
args = (T, P, logfz)
104+
if sample is None:
105+
best_val = np.inf
106+
best_result = None
107+
else:
108+
best_result = sample
109+
best_val = objective(sample, *args)
110+
result = minimize(
111+
objective,
112+
best_result,
113+
method="L-BFGS-B",
114+
options=dict(maxiter=20),
115+
args=(*args, True),
116+
)
117+
value = result.fun
118+
if value < best_val:
119+
w = result.x
120+
w = np.exp(w - np.max(w)) # Softmax for unconstrained optimization
121+
w /= w.sum()
122+
best_result = w
123+
best_val = value
124+
unstable = best_val < 0
125+
if unstable:
126+
return StabilityReport(
127+
unstable=unstable,
128+
candidate=sample,
129+
tpd=best_val,
130+
sample_unstable=True,
131+
)
132+
w = z * MW
133+
w /= w.sum()
134+
samples = edge_points_simplex_masked(w)
135+
samples /= MW
136+
samples /= samples.sum(axis=1, keepdims=True)
137+
for sample in samples:
138+
value = objective(sample, *args)
139+
if value < best_val:
140+
best_val = value
141+
best_result = sample
142+
result = minimize(
143+
objective,
144+
best_result,
145+
method="L-BFGS-B",
146+
options=dict(maxiter=20),
147+
args=(*args, True),
148+
)
149+
value = result.fun
150+
if value < best_val:
151+
w = result.x
152+
w = np.exp(w - np.max(w)) # Softmax for unconstrained optimization
153+
w /= w.sum()
154+
best_result = w
155+
best_val = value
156+
return StabilityReport(
157+
unstable=(best_val < 0),
158+
candidate=best_result,
159+
tpd=best_val
160+
)
93161

94162
class TangentPlaneStabilityAnalysis:
95163
__slots__ = ('fugacity_models', 'MW')
@@ -175,6 +243,6 @@ def __call__(self, z, T, P, reference_phase='l', potential_phase='L', sample=Non
175243

176244

177245
if __name__ == '__main__':
178-
tmo.settings.set_thermo(['Water', 'Octanol'])
246+
tmo.settings.set_thermo(['Water', 'Octanol', 'Ethanol'])
179247
TPSA = TangentPlaneStabilityAnalysis('lL', tmo.settings.chemicals)
180-
report = TPSA(np.array([0.2, 0.8]), 298.15, 101325)
248+
report = TPSA(np.array([0.25, 0.8, 0.05]), 298.15, 101325)

0 commit comments

Comments
 (0)