cosmopharm 0.0.26__py3-none-any.whl → 0.0.29__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cosmopharm/__init__.py +3 -2
- cosmopharm/actmodels/actmodel.py +118 -118
- cosmopharm/actmodels/cosmo.py +152 -152
- cosmopharm/components.py +31 -31
- cosmopharm/equilibrium/__init__.py +2 -2
- cosmopharm/equilibrium/lle.py +178 -178
- cosmopharm/equilibrium/sle.py +244 -244
- cosmopharm/utils/__init__.py +3 -3
- cosmopharm/utils/convert.py +58 -58
- cosmopharm/utils/helpers.py +36 -36
- cosmopharm/utils/lle_scanner.py +188 -188
- cosmopharm/utils/spacing.py +246 -246
- cosmopharm/version.py +16 -0
- {cosmopharm-0.0.26.dist-info → cosmopharm-0.0.29.dist-info}/LICENSE +20 -20
- {cosmopharm-0.0.26.dist-info → cosmopharm-0.0.29.dist-info}/METADATA +150 -150
- cosmopharm-0.0.29.dist-info/RECORD +19 -0
- {cosmopharm-0.0.26.dist-info → cosmopharm-0.0.29.dist-info}/WHEEL +1 -1
- cosmopharm-0.0.26.dist-info/RECORD +0 -18
- {cosmopharm-0.0.26.dist-info → cosmopharm-0.0.29.dist-info}/top_level.txt +0 -0
cosmopharm/equilibrium/lle.py
CHANGED
@@ -1,178 +1,178 @@
|
|
1
|
-
import numpy as np
|
2
|
-
import pandas as pd
|
3
|
-
from scipy.optimize import least_squares, root
|
4
|
-
from typing import Union, Optional, Type, List, Dict
|
5
|
-
|
6
|
-
from ..components import Component
|
7
|
-
from ..actmodels import ActModel
|
8
|
-
from ..utils.spacing import spacing
|
9
|
-
from ..utils.lle_scanner import estimate_lle_from_gmix
|
10
|
-
|
11
|
-
|
12
|
-
class LLE:
|
13
|
-
def __init__(self,
|
14
|
-
actmodel: Union[ActModel, Type[ActModel]],
|
15
|
-
mixture: Optional[List[Component]] = None) -> None:
|
16
|
-
self.actmodel = actmodel
|
17
|
-
self.mixture = mixture
|
18
|
-
self._validate_arguments()
|
19
|
-
|
20
|
-
def miscibility(self,
|
21
|
-
T: float,
|
22
|
-
x0: np.ndarray = None,
|
23
|
-
x0_type: str = 'mole',
|
24
|
-
max_gap: float = 0.1,
|
25
|
-
max_gap_type: str = 'mole',
|
26
|
-
max_T: float = 1000,
|
27
|
-
dT: float = 10,
|
28
|
-
exponent: float = 1
|
29
|
-
) -> pd.DataFrame:
|
30
|
-
""" Calculate miscibility """
|
31
|
-
self.config = getattr(self.actmodel, 'config', '')
|
32
|
-
Mw = np.array([c.Mw for c in self.mixture])
|
33
|
-
self.is_valid_Mw = self.is_valid_numpy_array(Mw)
|
34
|
-
res = {'binodal':[], 'spinodal':[]}
|
35
|
-
var = 'x' if max_gap_type == 'mole' else 'w'
|
36
|
-
print()
|
37
|
-
print("Calculating LLE...")
|
38
|
-
|
39
|
-
# Define column names
|
40
|
-
binodal_columns = ['T', 'xL1', 'xL2']
|
41
|
-
if self.is_valid_Mw:
|
42
|
-
binodal_columns += ['wL1', 'wL2']
|
43
|
-
|
44
|
-
# Check for valid molar masses
|
45
|
-
if x0_type == 'weight':
|
46
|
-
if self.is_valid_Mw:
|
47
|
-
x0 = self.convert_to_mole_fractions(x0, self.mixture.Mw)
|
48
|
-
else:
|
49
|
-
raise ValueError("Molar masses are not available for conversion from weight to mole fraction.")
|
50
|
-
# =============================================================================
|
51
|
-
# TODO: Implement all edge cases (no LLE, bad approximation, ....)
|
52
|
-
# TODO: Improve code structure
|
53
|
-
# Approximate initial value for LLE
|
54
|
-
if x0 is None:
|
55
|
-
print("...searching for suitable initial value...")
|
56
|
-
x0 = self.approx_init_x0(T)
|
57
|
-
|
58
|
-
if any(x is None for x in x0):
|
59
|
-
print("...no initial value at T0 was found. Try another T0.")
|
60
|
-
return pd.DataFrame(columns=binodal_columns)
|
61
|
-
|
62
|
-
# Check if initial guess is reasonable - otherwise increase T
|
63
|
-
# TODO: Check whether it might be an LCST if isLower
|
64
|
-
binodal = self.solve_lle(T, x0, show_output=False)
|
65
|
-
isEqual = np.diff(binodal['x'])[0] < 1e-8 # check if both phases have equal composition
|
66
|
-
if isEqual:
|
67
|
-
print("...no initial value at T0 was found. Try another T0.")
|
68
|
-
return pd.DataFrame(columns=binodal_columns)
|
69
|
-
isLower = min(binodal['x']) < min(x0) # lower bound below min(x0)
|
70
|
-
while isLower and T <= max_T:
|
71
|
-
print('LLE: ', f"{T=:.2f}", "...no feasbible initial value found.")
|
72
|
-
T += 10 # Increase T by 10
|
73
|
-
x0 = self.approx_init_x0(T)
|
74
|
-
binodal = self.solve_lle(T, x0, show_output=False)
|
75
|
-
print("Suitable initial value found! Proceed with calculating LLE...")
|
76
|
-
# =============================================================================
|
77
|
-
# First iteration step
|
78
|
-
binodal = self.solve_lle(T, x0)
|
79
|
-
gap = np.diff(binodal[var])[0]
|
80
|
-
res['binodal'].append((T, *[val for vals in binodal.values() for val in vals]))
|
81
|
-
|
82
|
-
# Subsequent iteration steps
|
83
|
-
while gap > max_gap and T <= max_T:
|
84
|
-
T += dT * gap**exponent
|
85
|
-
x0 = binodal['x']
|
86
|
-
binodal = self.solve_lle(T, x0)
|
87
|
-
gap = np.diff(binodal[var])[0]
|
88
|
-
res['binodal'].append((T, *[val for vals in binodal.values() for val in vals]))
|
89
|
-
|
90
|
-
# Convert lists to DataFrames
|
91
|
-
res = pd.DataFrame(res['binodal'], columns=binodal_columns)
|
92
|
-
return res
|
93
|
-
|
94
|
-
# =============================================================================
|
95
|
-
# MATHEMATICS
|
96
|
-
# =============================================================================
|
97
|
-
|
98
|
-
def solve_lle(self, T: float, x0: np.ndarray, show_output=True) -> Dict[str, np.ndarray]:
|
99
|
-
""" Solve for liquid-liquid equilibrium (LLE) at a given temperature and initial composition. """
|
100
|
-
binodal = {'x': self.binodal(T, x0)}
|
101
|
-
output = [f"{k}={v:.4f}" for k,v in zip(['xL1', 'xL2'], binodal['x'])]
|
102
|
-
|
103
|
-
if self.is_valid_Mw:
|
104
|
-
binodal['w'] = self.actmodel._convert(binodal['x'])
|
105
|
-
output += [f"{k}={v:.4f}" for k,v in zip(['wL1', 'wL2'], binodal['w'])]
|
106
|
-
|
107
|
-
if show_output:
|
108
|
-
prefix = f'LLE ({self.config})' if self.config else 'LLE'
|
109
|
-
print(f'{prefix}: ', f"{T=:.2f}", *output)
|
110
|
-
return binodal
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
# =============================================================================
|
116
|
-
# THERMODYNAMICS
|
117
|
-
# =============================================================================
|
118
|
-
def fobj_binodal(self, x1, T):
|
119
|
-
# Equilibrium: Isoactivity criterion (aL1 - aL2 = 0)
|
120
|
-
x = np.array([x1, 1-x1])
|
121
|
-
activity = self.actmodel.activity(T, x)
|
122
|
-
equilibrium = np.diff(activity, axis=1)
|
123
|
-
return equilibrium.ravel() # reshape from (2,1) --> (2,)
|
124
|
-
|
125
|
-
def fobj_spinodal(self, x1):
|
126
|
-
T = 0
|
127
|
-
x = np.array([x1, 1-x1])
|
128
|
-
return self.actmodel.thermofac(T, x)
|
129
|
-
|
130
|
-
def binodal(self, T, x0=None):
|
131
|
-
if x0 is None:
|
132
|
-
x0 = [0.1, 0.999]
|
133
|
-
kwargs = dict(bounds=(0,1), ftol=1e-15, xtol=1e-15)
|
134
|
-
res = least_squares(self.fobj_binodal, x0, args=(T,), **kwargs)
|
135
|
-
return res.x
|
136
|
-
|
137
|
-
def spinodal(self, T, x0=None):
|
138
|
-
if x0 is None:
|
139
|
-
x0 = self.binodal(T, x0)
|
140
|
-
kwargs = dict(bounds=(0,1), ftol=1e-15, xtol=1e-15)
|
141
|
-
res = least_squares(self.fobj_spinodal, x0, args=(T,), **kwargs)
|
142
|
-
return res.x
|
143
|
-
|
144
|
-
|
145
|
-
# =============================================================================
|
146
|
-
# AUXILLIARY FUNCTIONS
|
147
|
-
# =============================================================================
|
148
|
-
def approx_init_x0(self, T):
|
149
|
-
x1 = spacing(0,1,51,'poly',n=3)
|
150
|
-
gmix = self.actmodel.gmix(T, x1)
|
151
|
-
xL, xR, yL, yR = estimate_lle_from_gmix(x1, gmix, rough=True)
|
152
|
-
return xL, xR
|
153
|
-
|
154
|
-
def _validate_arguments(self):
|
155
|
-
"""Validate the arguments for the LLE class."""
|
156
|
-
# TODO: Insert case where both actmodel and mixture are provided
|
157
|
-
# (check if acmodel.mixture == mixture, if not raise warning)
|
158
|
-
if isinstance(self.actmodel, ActModel):
|
159
|
-
# If actmodel is an instance of ActModel
|
160
|
-
self.mixture: List[Component] = self.mixture or self.actmodel.mixture
|
161
|
-
elif isinstance(self.actmodel, type) and issubclass(self.actmodel, ActModel):
|
162
|
-
# If actmodel is a class (subclass of ActModel)
|
163
|
-
if self.mixture is None:
|
164
|
-
raise ValueError("Please provide a valid mixture:Mixture.")
|
165
|
-
self.actmodel: ActModel = self.actmodel(self.mixture)
|
166
|
-
else:
|
167
|
-
# If actmodel is neither an instance nor a subclass of ActModel
|
168
|
-
err = "'actmodel' must be an instance or a subclass of 'ActModel'"
|
169
|
-
raise ValueError(err)
|
170
|
-
|
171
|
-
def is_valid_numpy_array(self, arr: np.ndarray) -> bool:
|
172
|
-
"""Check if a numpy array contains only numbers and no None values."""
|
173
|
-
if not isinstance(arr, np.ndarray):
|
174
|
-
return False
|
175
|
-
if arr.dtype == object: # Check if the array contains objects (which could include None)
|
176
|
-
return not np.any(arr == None)
|
177
|
-
else:
|
178
|
-
return np.issubdtype(arr.dtype, np.number)
|
1
|
+
import numpy as np
|
2
|
+
import pandas as pd
|
3
|
+
from scipy.optimize import least_squares, root
|
4
|
+
from typing import Union, Optional, Type, List, Dict
|
5
|
+
|
6
|
+
from ..components import Component
|
7
|
+
from ..actmodels import ActModel
|
8
|
+
from ..utils.spacing import spacing
|
9
|
+
from ..utils.lle_scanner import estimate_lle_from_gmix
|
10
|
+
|
11
|
+
|
12
|
+
class LLE:
|
13
|
+
def __init__(self,
|
14
|
+
actmodel: Union[ActModel, Type[ActModel]],
|
15
|
+
mixture: Optional[List[Component]] = None) -> None:
|
16
|
+
self.actmodel = actmodel
|
17
|
+
self.mixture = mixture
|
18
|
+
self._validate_arguments()
|
19
|
+
|
20
|
+
def miscibility(self,
|
21
|
+
T: float,
|
22
|
+
x0: np.ndarray = None,
|
23
|
+
x0_type: str = 'mole',
|
24
|
+
max_gap: float = 0.1,
|
25
|
+
max_gap_type: str = 'mole',
|
26
|
+
max_T: float = 1000,
|
27
|
+
dT: float = 10,
|
28
|
+
exponent: float = 1
|
29
|
+
) -> pd.DataFrame:
|
30
|
+
""" Calculate miscibility """
|
31
|
+
self.config = getattr(self.actmodel, 'config', '')
|
32
|
+
Mw = np.array([c.Mw for c in self.mixture])
|
33
|
+
self.is_valid_Mw = self.is_valid_numpy_array(Mw)
|
34
|
+
res = {'binodal':[], 'spinodal':[]}
|
35
|
+
var = 'x' if max_gap_type == 'mole' else 'w'
|
36
|
+
print()
|
37
|
+
print("Calculating LLE...")
|
38
|
+
|
39
|
+
# Define column names
|
40
|
+
binodal_columns = ['T', 'xL1', 'xL2']
|
41
|
+
if self.is_valid_Mw:
|
42
|
+
binodal_columns += ['wL1', 'wL2']
|
43
|
+
|
44
|
+
# Check for valid molar masses
|
45
|
+
if x0_type == 'weight':
|
46
|
+
if self.is_valid_Mw:
|
47
|
+
x0 = self.convert_to_mole_fractions(x0, self.mixture.Mw)
|
48
|
+
else:
|
49
|
+
raise ValueError("Molar masses are not available for conversion from weight to mole fraction.")
|
50
|
+
# =============================================================================
|
51
|
+
# TODO: Implement all edge cases (no LLE, bad approximation, ....)
|
52
|
+
# TODO: Improve code structure
|
53
|
+
# Approximate initial value for LLE
|
54
|
+
if x0 is None:
|
55
|
+
print("...searching for suitable initial value...")
|
56
|
+
x0 = self.approx_init_x0(T)
|
57
|
+
|
58
|
+
if any(x is None for x in x0):
|
59
|
+
print("...no initial value at T0 was found. Try another T0.")
|
60
|
+
return pd.DataFrame(columns=binodal_columns)
|
61
|
+
|
62
|
+
# Check if initial guess is reasonable - otherwise increase T
|
63
|
+
# TODO: Check whether it might be an LCST if isLower
|
64
|
+
binodal = self.solve_lle(T, x0, show_output=False)
|
65
|
+
isEqual = np.diff(binodal['x'])[0] < 1e-8 # check if both phases have equal composition
|
66
|
+
if isEqual:
|
67
|
+
print("...no initial value at T0 was found. Try another T0.")
|
68
|
+
return pd.DataFrame(columns=binodal_columns)
|
69
|
+
isLower = min(binodal['x']) < min(x0) # lower bound below min(x0)
|
70
|
+
while isLower and T <= max_T:
|
71
|
+
print('LLE: ', f"{T=:.2f}", "...no feasbible initial value found.")
|
72
|
+
T += 10 # Increase T by 10
|
73
|
+
x0 = self.approx_init_x0(T)
|
74
|
+
binodal = self.solve_lle(T, x0, show_output=False)
|
75
|
+
print("Suitable initial value found! Proceed with calculating LLE...")
|
76
|
+
# =============================================================================
|
77
|
+
# First iteration step
|
78
|
+
binodal = self.solve_lle(T, x0)
|
79
|
+
gap = np.diff(binodal[var])[0]
|
80
|
+
res['binodal'].append((T, *[val for vals in binodal.values() for val in vals]))
|
81
|
+
|
82
|
+
# Subsequent iteration steps
|
83
|
+
while gap > max_gap and T <= max_T:
|
84
|
+
T += dT * gap**exponent
|
85
|
+
x0 = binodal['x']
|
86
|
+
binodal = self.solve_lle(T, x0)
|
87
|
+
gap = np.diff(binodal[var])[0]
|
88
|
+
res['binodal'].append((T, *[val for vals in binodal.values() for val in vals]))
|
89
|
+
|
90
|
+
# Convert lists to DataFrames
|
91
|
+
res = pd.DataFrame(res['binodal'], columns=binodal_columns)
|
92
|
+
return res
|
93
|
+
|
94
|
+
# =============================================================================
|
95
|
+
# MATHEMATICS
|
96
|
+
# =============================================================================
|
97
|
+
|
98
|
+
def solve_lle(self, T: float, x0: np.ndarray, show_output=True) -> Dict[str, np.ndarray]:
|
99
|
+
""" Solve for liquid-liquid equilibrium (LLE) at a given temperature and initial composition. """
|
100
|
+
binodal = {'x': self.binodal(T, x0)}
|
101
|
+
output = [f"{k}={v:.4f}" for k,v in zip(['xL1', 'xL2'], binodal['x'])]
|
102
|
+
|
103
|
+
if self.is_valid_Mw:
|
104
|
+
binodal['w'] = self.actmodel._convert(binodal['x'])
|
105
|
+
output += [f"{k}={v:.4f}" for k,v in zip(['wL1', 'wL2'], binodal['w'])]
|
106
|
+
|
107
|
+
if show_output:
|
108
|
+
prefix = f'LLE ({self.config})' if self.config else 'LLE'
|
109
|
+
print(f'{prefix}: ', f"{T=:.2f}", *output)
|
110
|
+
return binodal
|
111
|
+
|
112
|
+
|
113
|
+
|
114
|
+
|
115
|
+
# =============================================================================
|
116
|
+
# THERMODYNAMICS
|
117
|
+
# =============================================================================
|
118
|
+
def fobj_binodal(self, x1, T):
|
119
|
+
# Equilibrium: Isoactivity criterion (aL1 - aL2 = 0)
|
120
|
+
x = np.array([x1, 1-x1])
|
121
|
+
activity = self.actmodel.activity(T, x)
|
122
|
+
equilibrium = np.diff(activity, axis=1)
|
123
|
+
return equilibrium.ravel() # reshape from (2,1) --> (2,)
|
124
|
+
|
125
|
+
def fobj_spinodal(self, x1):
|
126
|
+
T = 0
|
127
|
+
x = np.array([x1, 1-x1])
|
128
|
+
return self.actmodel.thermofac(T, x)
|
129
|
+
|
130
|
+
def binodal(self, T, x0=None):
|
131
|
+
if x0 is None:
|
132
|
+
x0 = [0.1, 0.999]
|
133
|
+
kwargs = dict(bounds=(0,1), ftol=1e-15, xtol=1e-15)
|
134
|
+
res = least_squares(self.fobj_binodal, x0, args=(T,), **kwargs)
|
135
|
+
return res.x
|
136
|
+
|
137
|
+
def spinodal(self, T, x0=None):
|
138
|
+
if x0 is None:
|
139
|
+
x0 = self.binodal(T, x0)
|
140
|
+
kwargs = dict(bounds=(0,1), ftol=1e-15, xtol=1e-15)
|
141
|
+
res = least_squares(self.fobj_spinodal, x0, args=(T,), **kwargs)
|
142
|
+
return res.x
|
143
|
+
|
144
|
+
|
145
|
+
# =============================================================================
|
146
|
+
# AUXILLIARY FUNCTIONS
|
147
|
+
# =============================================================================
|
148
|
+
def approx_init_x0(self, T):
|
149
|
+
x1 = spacing(0,1,51,'poly',n=3)
|
150
|
+
gmix = self.actmodel.gmix(T, x1)
|
151
|
+
xL, xR, yL, yR = estimate_lle_from_gmix(x1, gmix, rough=True)
|
152
|
+
return xL, xR
|
153
|
+
|
154
|
+
def _validate_arguments(self):
|
155
|
+
"""Validate the arguments for the LLE class."""
|
156
|
+
# TODO: Insert case where both actmodel and mixture are provided
|
157
|
+
# (check if acmodel.mixture == mixture, if not raise warning)
|
158
|
+
if isinstance(self.actmodel, ActModel):
|
159
|
+
# If actmodel is an instance of ActModel
|
160
|
+
self.mixture: List[Component] = self.mixture or self.actmodel.mixture
|
161
|
+
elif isinstance(self.actmodel, type) and issubclass(self.actmodel, ActModel):
|
162
|
+
# If actmodel is a class (subclass of ActModel)
|
163
|
+
if self.mixture is None:
|
164
|
+
raise ValueError("Please provide a valid mixture:Mixture.")
|
165
|
+
self.actmodel: ActModel = self.actmodel(self.mixture)
|
166
|
+
else:
|
167
|
+
# If actmodel is neither an instance nor a subclass of ActModel
|
168
|
+
err = "'actmodel' must be an instance or a subclass of 'ActModel'"
|
169
|
+
raise ValueError(err)
|
170
|
+
|
171
|
+
def is_valid_numpy_array(self, arr: np.ndarray) -> bool:
|
172
|
+
"""Check if a numpy array contains only numbers and no None values."""
|
173
|
+
if not isinstance(arr, np.ndarray):
|
174
|
+
return False
|
175
|
+
if arr.dtype == object: # Check if the array contains objects (which could include None)
|
176
|
+
return not np.any(arr == None)
|
177
|
+
else:
|
178
|
+
return np.issubdtype(arr.dtype, np.number)
|