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/sle.py
CHANGED
@@ -1,244 +1,244 @@
|
|
1
|
-
import numpy as np
|
2
|
-
import pandas as pd
|
3
|
-
from scipy.optimize import fsolve, root
|
4
|
-
from numpy.typing import NDArray
|
5
|
-
from typing import Literal, Optional, Type, Union, List, Tuple, Generator, Dict
|
6
|
-
|
7
|
-
from ..components import Component
|
8
|
-
from ..actmodels import ActModel
|
9
|
-
from ..utils.spacing import spacing
|
10
|
-
|
11
|
-
NumericOrFrame = Union[float, List[float], Tuple[float, ...], NDArray[np.float64], pd.DataFrame]
|
12
|
-
|
13
|
-
class SLE:
|
14
|
-
def __init__(self,
|
15
|
-
actmodel: Union[ActModel, Type[ActModel]],
|
16
|
-
mixture: Optional[List[Component]] = None) -> None:
|
17
|
-
self.actmodel = actmodel
|
18
|
-
self.mixture = mixture
|
19
|
-
self._validate_arguments()
|
20
|
-
# Assign 'solute' and 'solvent' based on order in 'mixture'
|
21
|
-
# Default assignment can be changed in e.g. 'solubility()'
|
22
|
-
self.solute, self.solvent = self.mixture
|
23
|
-
|
24
|
-
def solubility(self,
|
25
|
-
solute: Optional[Component] = None,
|
26
|
-
solvent: Optional[Component] = None,
|
27
|
-
vary: Literal['T', 'w', 'auto'] = 'auto',
|
28
|
-
mix_type: Literal['ideal', 'real'] = 'real',
|
29
|
-
args: Optional[NumericOrFrame] = None,
|
30
|
-
init: Optional[NumericOrFrame] = None,
|
31
|
-
solver: Literal['root', 'fsolve'] = 'root',
|
32
|
-
show_progress=False, **kwargs):
|
33
|
-
''' Calculate solubility curve of solute in solvent.'''
|
34
|
-
self.solute = solute or self.solute
|
35
|
-
self.solvent = solvent or self.solvent
|
36
|
-
self.vary, self.mix_type = vary, mix_type
|
37
|
-
self.show_progress = show_progress
|
38
|
-
self.config = getattr(self.actmodel, 'config', self.mix_type)
|
39
|
-
if self.vary == 'auto':
|
40
|
-
gen = self.auto_solve(solver)
|
41
|
-
else:
|
42
|
-
self._vary = self.vary
|
43
|
-
args = self.set_args(args)
|
44
|
-
init = self.set_x0(init)
|
45
|
-
gen = self.solve_sle(args, init, solver)
|
46
|
-
try:
|
47
|
-
res = [k for k in gen]
|
48
|
-
res = pd.DataFrame(res, columns=['T', 'x', 'vary', 'w'])
|
49
|
-
res = res[['T', 'w', 'x', 'vary']]
|
50
|
-
return res
|
51
|
-
except self.actmodel.InvalidFreeVolumeParametersException as e:
|
52
|
-
print(f"Warning: {e}") # Inform the user
|
53
|
-
return pd.DataFrame(columns=['T', 'w', 'x', 'vary'])
|
54
|
-
|
55
|
-
|
56
|
-
# =============================================================================
|
57
|
-
# MATHEMATICS
|
58
|
-
# =============================================================================
|
59
|
-
def solve_sle(self, args: NDArray[np.float64], init: NDArray[np.float64],
|
60
|
-
solver: Literal['root', 'fsolve'] = 'root'
|
61
|
-
) -> Generator[Dict[str, Union[float, str]], None, None]:
|
62
|
-
# Check compatibility of the "init" values
|
63
|
-
is_iterable = init.size > 1
|
64
|
-
if is_iterable and not init.size == args.size:
|
65
|
-
msg = 'The length of "init" must be the same as "args".'
|
66
|
-
raise ValueError(msg)
|
67
|
-
x0 = init
|
68
|
-
# Setup solver and handle pure component case
|
69
|
-
key, lock = ['T', 'x'] if self._vary == 'T' else ['x', 'T']
|
70
|
-
solve = self.set_solver(solver=solver)
|
71
|
-
args, pure_component = self._handle_pure_component(args)
|
72
|
-
if pure_component: # no need to calculate pure component
|
73
|
-
yield pure_component
|
74
|
-
for i, arg in enumerate(args):
|
75
|
-
x0 = init[i] if is_iterable else x0
|
76
|
-
out = float(solve(x0, arg))
|
77
|
-
x0 = out if not is_iterable else x0
|
78
|
-
res = {key: arg, lock: out, 'vary': self._vary}
|
79
|
-
res['w'] = self.actmodel._convert(res['x'])[0]
|
80
|
-
text = (f"T={res['T']:.2f}", f"x={res['x']:.4f}", f"w={res['w']:.4f}")
|
81
|
-
if self.show_progress:
|
82
|
-
print(f'SLE ({self.config}): ', *text)
|
83
|
-
yield res
|
84
|
-
|
85
|
-
def auto_solve(self, solver: Literal['root', 'fsolve'] = 'root'):
|
86
|
-
if self.show_progress:
|
87
|
-
print()
|
88
|
-
print(f"Calculating SLE ({self.config})...")
|
89
|
-
# Start with varying 'w' until dTdw > THRESHOLD
|
90
|
-
self._vary = 'w'
|
91
|
-
args = self.set_args()
|
92
|
-
x0 = self.set_x0()
|
93
|
-
gen = self.solve_sle(args, x0, solver)
|
94
|
-
previous = None
|
95
|
-
for i, current in enumerate(gen):
|
96
|
-
yield current
|
97
|
-
if self._should_stop_generator(i, previous, current):
|
98
|
-
break # This will end the generator
|
99
|
-
previous = current
|
100
|
-
# Switch to varying 'T'
|
101
|
-
self._vary = 'T'
|
102
|
-
T0, x0 = current['T'], current['x']
|
103
|
-
args = self.set_args(xmax=T0)[1:] # exclude initial point
|
104
|
-
gen = self.solve_sle(args, x0)
|
105
|
-
yield from gen
|
106
|
-
|
107
|
-
|
108
|
-
# =============================================================================
|
109
|
-
# THERMODYNAMICS
|
110
|
-
# =============================================================================
|
111
|
-
def ideal_mix(self, T):
|
112
|
-
return np.exp(-self.gibbs_fusion(T))
|
113
|
-
|
114
|
-
def real_mix(self, T, x):
|
115
|
-
lngamma = self.actmodel.lngamma(T, x)[0]
|
116
|
-
return np.log(x) + lngamma + self.gibbs_fusion(T)
|
117
|
-
|
118
|
-
# Gibbs energy of fusion, i.e., the right-hand side of the solubility equation:
|
119
|
-
def gibbs_fusion(self, T):
|
120
|
-
T_fus = self.solute.T_fus
|
121
|
-
H_fus = self.solute.H_fus
|
122
|
-
Cp_fus_A = self.solute.Cp_fus_A
|
123
|
-
Cp_fus_BT = self.solute.Cp_fus_BT
|
124
|
-
|
125
|
-
R = 8.314 # J/(mol K)
|
126
|
-
RT = R*T # J/mol
|
127
|
-
A, B = Cp_fus_A, Cp_fus_BT
|
128
|
-
G1 = H_fus*(1-T/T_fus) # J/mol
|
129
|
-
G2 = A * (T-T_fus) + 0.5*B*(T**2-T_fus**2)
|
130
|
-
G3 = -T * (A * np.log(T/T_fus) + B*(T-T_fus))
|
131
|
-
G_fus = G1 + G2 + G3 # J/mol
|
132
|
-
return G_fus/RT
|
133
|
-
|
134
|
-
|
135
|
-
# =============================================================================
|
136
|
-
# HELPER FUNCTIONS
|
137
|
-
# =============================================================================
|
138
|
-
def set_args(self,
|
139
|
-
args: Optional[NumericOrFrame] = None,
|
140
|
-
xmin: Optional[float] = None,
|
141
|
-
xmax: Optional[float] = None,
|
142
|
-
dx: Optional[float] = None
|
143
|
-
) -> NDArray[np.float64]:
|
144
|
-
vary = self._vary
|
145
|
-
# Determine argument values based on input data or generate
|
146
|
-
# them based on range and type
|
147
|
-
defaults = {
|
148
|
-
'T': {'min': 310, 'max': self.solute.T_fus, 'step': 10},
|
149
|
-
'w': {'min': 0.01, 'max': 1, 'step': 0.08}
|
150
|
-
}
|
151
|
-
mi = defaults[vary]['min'] if xmin is None else xmin
|
152
|
-
ma = defaults[vary]['max'] if xmax is None else xmax
|
153
|
-
dx = defaults[vary]['step'] if dx is None else dx
|
154
|
-
|
155
|
-
if args is None:
|
156
|
-
if self.vary != 'auto': # auto_vary == False
|
157
|
-
args = np.arange(ma, mi-dx, -dx)
|
158
|
-
args[-1] = np.maximum(args[-1], mi)
|
159
|
-
elif vary == 'T': # auto_vary == True
|
160
|
-
num, dT = 16, 175 # How many data points in this T-range
|
161
|
-
num = int((ma-mi)/dT*num) # fraction of points if dT smaller
|
162
|
-
num = max(6, num)
|
163
|
-
kwargs = dict(reverse=True, n=1.5)
|
164
|
-
args = spacing(ma, mi, num, 'poly', **kwargs)
|
165
|
-
else: # vary == 'w'
|
166
|
-
num = 16 if self.mix_type == 'ideal' else 21
|
167
|
-
args = spacing(ma, mi, num, 'quadratic')
|
168
|
-
args = np.asarray(args)
|
169
|
-
args = args if vary != 'w' else self.actmodel._convert(args, to='mole')
|
170
|
-
return args
|
171
|
-
|
172
|
-
def set_x0(self, init: Optional[NumericOrFrame] = None) -> NDArray[np.float64]:
|
173
|
-
vary = self._vary
|
174
|
-
# Set up initial values based on the type of variable ('T' or 'w')
|
175
|
-
if vary == 'T':
|
176
|
-
x0 = 1. if init is None else self.actmodel._convert(init, to='mole')
|
177
|
-
else: # vary == 'w'
|
178
|
-
x0 = self.solute.T_fus if init is None else init
|
179
|
-
x0 = np.asarray(x0)
|
180
|
-
return x0
|
181
|
-
|
182
|
-
def set_solver(self, solver: Literal['root', 'fsolve'] = 'root'):
|
183
|
-
vary, mix = self._vary, self.mix_type
|
184
|
-
# Define the objective function (fobj) and the solver function (solve)
|
185
|
-
# based on the mixture type (mix) and the variable type (vary)
|
186
|
-
if mix == 'ideal' and vary == 'T':
|
187
|
-
def fobj(x, T): return self.ideal_mix(T)
|
188
|
-
def solve(x0, args): return fobj(x0, args)
|
189
|
-
else:
|
190
|
-
if mix == 'ideal':
|
191
|
-
def fobj(T, x): return x - self.ideal_mix(T)
|
192
|
-
elif vary == 'T': # mix != 'ideal'
|
193
|
-
def fobj(x, T): return self.real_mix(T, x)
|
194
|
-
else: # vary == 'w'
|
195
|
-
def fobj(T, x): return self.real_mix(T, x)
|
196
|
-
kwargs = dict(method='krylov', options={'maxiter': 5, 'xtol': 1e-3})
|
197
|
-
if solver == 'fsolve':
|
198
|
-
def solve(x0, args): return fsolve(fobj, x0, args)
|
199
|
-
else:
|
200
|
-
def solve(x0, args): return root(fobj, x0, args, **kwargs).x
|
201
|
-
# Problem: "fsolve" and "root" return different types of np.arrays
|
202
|
-
# (1) fsolve returns (1,) 1D array
|
203
|
-
# (2) root returns () 0D array
|
204
|
-
# Therefore, it is necessary to use float(solve(...)) to extract the
|
205
|
-
# single value from the array, since solve()[0] does not work for root.
|
206
|
-
return solve
|
207
|
-
|
208
|
-
|
209
|
-
# =============================================================================
|
210
|
-
# AUXILLIARY FUNCTIONS
|
211
|
-
# =============================================================================
|
212
|
-
def _should_stop_generator(self, i, previous, current):
|
213
|
-
THRESHOLD = 60
|
214
|
-
if i > 1: # ensuring there was a previous result
|
215
|
-
dT = current['T'] - previous['T']
|
216
|
-
dw = current['w'] - previous['w']
|
217
|
-
return (dT / dw) > THRESHOLD
|
218
|
-
return False # If not enough elements, continue the generator
|
219
|
-
|
220
|
-
def _handle_pure_component(self, args):
|
221
|
-
res = {'T': self.solute.T_fus, 'x': 1, 'vary': self._vary, 'w': 1}
|
222
|
-
if self._vary == 'T' and self.solute.T_fus in args:
|
223
|
-
args = args[args != self.solute.T_fus]
|
224
|
-
return args, res
|
225
|
-
elif self._vary == 'w' and 1 in args:
|
226
|
-
args = args[args != 1]
|
227
|
-
return args, res
|
228
|
-
return args, None
|
229
|
-
|
230
|
-
def _validate_arguments(self):
|
231
|
-
"""Validate the arguments for the SLE class."""
|
232
|
-
# TODO: Insert case where both actmodel and mixture are provided (check if acmodel.mixture == mixture, if not raise warning)
|
233
|
-
if isinstance(self.actmodel, ActModel):
|
234
|
-
# If actmodel is an instance of ActModel
|
235
|
-
self.mixture: List[Component] = self.mixture or self.actmodel.mixture
|
236
|
-
elif isinstance(self.actmodel, type) and issubclass(self.actmodel, ActModel):
|
237
|
-
# If actmodel is a class (subclass of ActModel)
|
238
|
-
if self.mixture is None:
|
239
|
-
raise ValueError("Please provide a valid mixture:Mixture.")
|
240
|
-
self.actmodel: ActModel = self.actmodel(self.mixture)
|
241
|
-
else:
|
242
|
-
# If actmodel is neither an instance nor a subclass of ActModel
|
243
|
-
err = "'actmodel' must be an instance or a subclass of 'ActModel'"
|
244
|
-
raise ValueError(err)
|
1
|
+
import numpy as np
|
2
|
+
import pandas as pd
|
3
|
+
from scipy.optimize import fsolve, root
|
4
|
+
from numpy.typing import NDArray
|
5
|
+
from typing import Literal, Optional, Type, Union, List, Tuple, Generator, Dict
|
6
|
+
|
7
|
+
from ..components import Component
|
8
|
+
from ..actmodels import ActModel
|
9
|
+
from ..utils.spacing import spacing
|
10
|
+
|
11
|
+
NumericOrFrame = Union[float, List[float], Tuple[float, ...], NDArray[np.float64], pd.DataFrame]
|
12
|
+
|
13
|
+
class SLE:
|
14
|
+
def __init__(self,
|
15
|
+
actmodel: Union[ActModel, Type[ActModel]],
|
16
|
+
mixture: Optional[List[Component]] = None) -> None:
|
17
|
+
self.actmodel = actmodel
|
18
|
+
self.mixture = mixture
|
19
|
+
self._validate_arguments()
|
20
|
+
# Assign 'solute' and 'solvent' based on order in 'mixture'
|
21
|
+
# Default assignment can be changed in e.g. 'solubility()'
|
22
|
+
self.solute, self.solvent = self.mixture
|
23
|
+
|
24
|
+
def solubility(self,
|
25
|
+
solute: Optional[Component] = None,
|
26
|
+
solvent: Optional[Component] = None,
|
27
|
+
vary: Literal['T', 'w', 'auto'] = 'auto',
|
28
|
+
mix_type: Literal['ideal', 'real'] = 'real',
|
29
|
+
args: Optional[NumericOrFrame] = None,
|
30
|
+
init: Optional[NumericOrFrame] = None,
|
31
|
+
solver: Literal['root', 'fsolve'] = 'root',
|
32
|
+
show_progress=False, **kwargs):
|
33
|
+
''' Calculate solubility curve of solute in solvent.'''
|
34
|
+
self.solute = solute or self.solute
|
35
|
+
self.solvent = solvent or self.solvent
|
36
|
+
self.vary, self.mix_type = vary, mix_type
|
37
|
+
self.show_progress = show_progress
|
38
|
+
self.config = getattr(self.actmodel, 'config', self.mix_type)
|
39
|
+
if self.vary == 'auto':
|
40
|
+
gen = self.auto_solve(solver)
|
41
|
+
else:
|
42
|
+
self._vary = self.vary
|
43
|
+
args = self.set_args(args)
|
44
|
+
init = self.set_x0(init)
|
45
|
+
gen = self.solve_sle(args, init, solver)
|
46
|
+
try:
|
47
|
+
res = [k for k in gen]
|
48
|
+
res = pd.DataFrame(res, columns=['T', 'x', 'vary', 'w'])
|
49
|
+
res = res[['T', 'w', 'x', 'vary']]
|
50
|
+
return res
|
51
|
+
except self.actmodel.InvalidFreeVolumeParametersException as e:
|
52
|
+
print(f"Warning: {e}") # Inform the user
|
53
|
+
return pd.DataFrame(columns=['T', 'w', 'x', 'vary'])
|
54
|
+
|
55
|
+
|
56
|
+
# =============================================================================
|
57
|
+
# MATHEMATICS
|
58
|
+
# =============================================================================
|
59
|
+
def solve_sle(self, args: NDArray[np.float64], init: NDArray[np.float64],
|
60
|
+
solver: Literal['root', 'fsolve'] = 'root'
|
61
|
+
) -> Generator[Dict[str, Union[float, str]], None, None]:
|
62
|
+
# Check compatibility of the "init" values
|
63
|
+
is_iterable = init.size > 1
|
64
|
+
if is_iterable and not init.size == args.size:
|
65
|
+
msg = 'The length of "init" must be the same as "args".'
|
66
|
+
raise ValueError(msg)
|
67
|
+
x0 = init
|
68
|
+
# Setup solver and handle pure component case
|
69
|
+
key, lock = ['T', 'x'] if self._vary == 'T' else ['x', 'T']
|
70
|
+
solve = self.set_solver(solver=solver)
|
71
|
+
args, pure_component = self._handle_pure_component(args)
|
72
|
+
if pure_component: # no need to calculate pure component
|
73
|
+
yield pure_component
|
74
|
+
for i, arg in enumerate(args):
|
75
|
+
x0 = init[i] if is_iterable else x0
|
76
|
+
out = float(solve(x0, arg))
|
77
|
+
x0 = out if not is_iterable else x0
|
78
|
+
res = {key: arg, lock: out, 'vary': self._vary}
|
79
|
+
res['w'] = self.actmodel._convert(res['x'])[0]
|
80
|
+
text = (f"T={res['T']:.2f}", f"x={res['x']:.4f}", f"w={res['w']:.4f}")
|
81
|
+
if self.show_progress:
|
82
|
+
print(f'SLE ({self.config}): ', *text)
|
83
|
+
yield res
|
84
|
+
|
85
|
+
def auto_solve(self, solver: Literal['root', 'fsolve'] = 'root'):
|
86
|
+
if self.show_progress:
|
87
|
+
print()
|
88
|
+
print(f"Calculating SLE ({self.config})...")
|
89
|
+
# Start with varying 'w' until dTdw > THRESHOLD
|
90
|
+
self._vary = 'w'
|
91
|
+
args = self.set_args()
|
92
|
+
x0 = self.set_x0()
|
93
|
+
gen = self.solve_sle(args, x0, solver)
|
94
|
+
previous = None
|
95
|
+
for i, current in enumerate(gen):
|
96
|
+
yield current
|
97
|
+
if self._should_stop_generator(i, previous, current):
|
98
|
+
break # This will end the generator
|
99
|
+
previous = current
|
100
|
+
# Switch to varying 'T'
|
101
|
+
self._vary = 'T'
|
102
|
+
T0, x0 = current['T'], current['x']
|
103
|
+
args = self.set_args(xmax=T0)[1:] # exclude initial point
|
104
|
+
gen = self.solve_sle(args, x0)
|
105
|
+
yield from gen
|
106
|
+
|
107
|
+
|
108
|
+
# =============================================================================
|
109
|
+
# THERMODYNAMICS
|
110
|
+
# =============================================================================
|
111
|
+
def ideal_mix(self, T):
|
112
|
+
return np.exp(-self.gibbs_fusion(T))
|
113
|
+
|
114
|
+
def real_mix(self, T, x):
|
115
|
+
lngamma = self.actmodel.lngamma(T, x)[0]
|
116
|
+
return np.log(x) + lngamma + self.gibbs_fusion(T)
|
117
|
+
|
118
|
+
# Gibbs energy of fusion, i.e., the right-hand side of the solubility equation:
|
119
|
+
def gibbs_fusion(self, T):
|
120
|
+
T_fus = self.solute.T_fus
|
121
|
+
H_fus = self.solute.H_fus
|
122
|
+
Cp_fus_A = self.solute.Cp_fus_A
|
123
|
+
Cp_fus_BT = self.solute.Cp_fus_BT
|
124
|
+
|
125
|
+
R = 8.314 # J/(mol K)
|
126
|
+
RT = R*T # J/mol
|
127
|
+
A, B = Cp_fus_A, Cp_fus_BT
|
128
|
+
G1 = H_fus*(1-T/T_fus) # J/mol
|
129
|
+
G2 = A * (T-T_fus) + 0.5*B*(T**2-T_fus**2)
|
130
|
+
G3 = -T * (A * np.log(T/T_fus) + B*(T-T_fus))
|
131
|
+
G_fus = G1 + G2 + G3 # J/mol
|
132
|
+
return G_fus/RT
|
133
|
+
|
134
|
+
|
135
|
+
# =============================================================================
|
136
|
+
# HELPER FUNCTIONS
|
137
|
+
# =============================================================================
|
138
|
+
def set_args(self,
|
139
|
+
args: Optional[NumericOrFrame] = None,
|
140
|
+
xmin: Optional[float] = None,
|
141
|
+
xmax: Optional[float] = None,
|
142
|
+
dx: Optional[float] = None
|
143
|
+
) -> NDArray[np.float64]:
|
144
|
+
vary = self._vary
|
145
|
+
# Determine argument values based on input data or generate
|
146
|
+
# them based on range and type
|
147
|
+
defaults = {
|
148
|
+
'T': {'min': 310, 'max': self.solute.T_fus, 'step': 10},
|
149
|
+
'w': {'min': 0.01, 'max': 1, 'step': 0.08}
|
150
|
+
}
|
151
|
+
mi = defaults[vary]['min'] if xmin is None else xmin
|
152
|
+
ma = defaults[vary]['max'] if xmax is None else xmax
|
153
|
+
dx = defaults[vary]['step'] if dx is None else dx
|
154
|
+
|
155
|
+
if args is None:
|
156
|
+
if self.vary != 'auto': # auto_vary == False
|
157
|
+
args = np.arange(ma, mi-dx, -dx)
|
158
|
+
args[-1] = np.maximum(args[-1], mi)
|
159
|
+
elif vary == 'T': # auto_vary == True
|
160
|
+
num, dT = 16, 175 # How many data points in this T-range
|
161
|
+
num = int((ma-mi)/dT*num) # fraction of points if dT smaller
|
162
|
+
num = max(6, num)
|
163
|
+
kwargs = dict(reverse=True, n=1.5)
|
164
|
+
args = spacing(ma, mi, num, 'poly', **kwargs)
|
165
|
+
else: # vary == 'w'
|
166
|
+
num = 16 if self.mix_type == 'ideal' else 21
|
167
|
+
args = spacing(ma, mi, num, 'quadratic')
|
168
|
+
args = np.asarray(args)
|
169
|
+
args = args if vary != 'w' else self.actmodel._convert(args, to='mole')
|
170
|
+
return args
|
171
|
+
|
172
|
+
def set_x0(self, init: Optional[NumericOrFrame] = None) -> NDArray[np.float64]:
|
173
|
+
vary = self._vary
|
174
|
+
# Set up initial values based on the type of variable ('T' or 'w')
|
175
|
+
if vary == 'T':
|
176
|
+
x0 = 1. if init is None else self.actmodel._convert(init, to='mole')
|
177
|
+
else: # vary == 'w'
|
178
|
+
x0 = self.solute.T_fus if init is None else init
|
179
|
+
x0 = np.asarray(x0)
|
180
|
+
return x0
|
181
|
+
|
182
|
+
def set_solver(self, solver: Literal['root', 'fsolve'] = 'root'):
|
183
|
+
vary, mix = self._vary, self.mix_type
|
184
|
+
# Define the objective function (fobj) and the solver function (solve)
|
185
|
+
# based on the mixture type (mix) and the variable type (vary)
|
186
|
+
if mix == 'ideal' and vary == 'T':
|
187
|
+
def fobj(x, T): return self.ideal_mix(T)
|
188
|
+
def solve(x0, args): return fobj(x0, args)
|
189
|
+
else:
|
190
|
+
if mix == 'ideal':
|
191
|
+
def fobj(T, x): return x - self.ideal_mix(T)
|
192
|
+
elif vary == 'T': # mix != 'ideal'
|
193
|
+
def fobj(x, T): return self.real_mix(T, x)
|
194
|
+
else: # vary == 'w'
|
195
|
+
def fobj(T, x): return self.real_mix(T, x)
|
196
|
+
kwargs = dict(method='krylov', options={'maxiter': 5, 'xtol': 1e-3})
|
197
|
+
if solver == 'fsolve':
|
198
|
+
def solve(x0, args): return fsolve(fobj, x0, args)
|
199
|
+
else:
|
200
|
+
def solve(x0, args): return root(fobj, x0, args, **kwargs).x
|
201
|
+
# Problem: "fsolve" and "root" return different types of np.arrays
|
202
|
+
# (1) fsolve returns (1,) 1D array
|
203
|
+
# (2) root returns () 0D array
|
204
|
+
# Therefore, it is necessary to use float(solve(...)) to extract the
|
205
|
+
# single value from the array, since solve()[0] does not work for root.
|
206
|
+
return solve
|
207
|
+
|
208
|
+
|
209
|
+
# =============================================================================
|
210
|
+
# AUXILLIARY FUNCTIONS
|
211
|
+
# =============================================================================
|
212
|
+
def _should_stop_generator(self, i, previous, current):
|
213
|
+
THRESHOLD = 60
|
214
|
+
if i > 1: # ensuring there was a previous result
|
215
|
+
dT = current['T'] - previous['T']
|
216
|
+
dw = current['w'] - previous['w']
|
217
|
+
return (dT / dw) > THRESHOLD
|
218
|
+
return False # If not enough elements, continue the generator
|
219
|
+
|
220
|
+
def _handle_pure_component(self, args):
|
221
|
+
res = {'T': self.solute.T_fus, 'x': 1, 'vary': self._vary, 'w': 1}
|
222
|
+
if self._vary == 'T' and self.solute.T_fus in args:
|
223
|
+
args = args[args != self.solute.T_fus]
|
224
|
+
return args, res
|
225
|
+
elif self._vary == 'w' and 1 in args:
|
226
|
+
args = args[args != 1]
|
227
|
+
return args, res
|
228
|
+
return args, None
|
229
|
+
|
230
|
+
def _validate_arguments(self):
|
231
|
+
"""Validate the arguments for the SLE class."""
|
232
|
+
# TODO: Insert case where both actmodel and mixture are provided (check if acmodel.mixture == mixture, if not raise warning)
|
233
|
+
if isinstance(self.actmodel, ActModel):
|
234
|
+
# If actmodel is an instance of ActModel
|
235
|
+
self.mixture: List[Component] = self.mixture or self.actmodel.mixture
|
236
|
+
elif isinstance(self.actmodel, type) and issubclass(self.actmodel, ActModel):
|
237
|
+
# If actmodel is a class (subclass of ActModel)
|
238
|
+
if self.mixture is None:
|
239
|
+
raise ValueError("Please provide a valid mixture:Mixture.")
|
240
|
+
self.actmodel: ActModel = self.actmodel(self.mixture)
|
241
|
+
else:
|
242
|
+
# If actmodel is neither an instance nor a subclass of ActModel
|
243
|
+
err = "'actmodel' must be an instance or a subclass of 'ActModel'"
|
244
|
+
raise ValueError(err)
|
cosmopharm/utils/__init__.py
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
from .helpers import read_params, create_components
|
2
|
-
from .convert import convert
|
3
|
-
from .spacing import spacing
|
1
|
+
from .helpers import read_params, create_components
|
2
|
+
from .convert import convert
|
3
|
+
from .spacing import spacing
|
cosmopharm/utils/convert.py
CHANGED
@@ -1,58 +1,58 @@
|
|
1
|
-
import numpy as np
|
2
|
-
|
3
|
-
|
4
|
-
def convert(x: np.ndarray, Mw: np.ndarray, to: str = 'weight') -> np.ndarray:
|
5
|
-
"""
|
6
|
-
Convert between mole fraction (x) and mass/weight fraction (w) for a mixture.
|
7
|
-
|
8
|
-
Parameters:
|
9
|
-
x (numpy.ndarray): A 1D or 2D NumPy array representing mole fractions of components in the mixture.
|
10
|
-
- If 1D, it should have the shape (n,) with 'n' as the number of components.
|
11
|
-
- If 2D, it should have the shape (n, m) where 'n' is the number of components
|
12
|
-
and 'm' is the number of data points.
|
13
|
-
to (str, optional): The conversion direction. Should be either 'weight' (default) to convert from mole
|
14
|
-
fraction to weight fraction, or 'mole' to convert from weight fraction to mole fraction.
|
15
|
-
|
16
|
-
Returns:
|
17
|
-
numpy.ndarray: The converted fractions, with the same shape as 'x'.
|
18
|
-
|
19
|
-
Example:
|
20
|
-
>>> sle = SLE(mix=mix) # Replace with the actual mixture object
|
21
|
-
>>> x = np.array([0.4, 0.6]) # Mole fractions of two components
|
22
|
-
>>> w = mix.convert(x, to='weight') # Convert to weight fractions
|
23
|
-
>>> print(w)
|
24
|
-
array([[0.01373165],
|
25
|
-
[0.98626835]])
|
26
|
-
"""
|
27
|
-
|
28
|
-
# Check if x is a NumPy array
|
29
|
-
if not isinstance(x, np.ndarray):
|
30
|
-
raise ValueError("Input 'x' must be a 1D or 2D NumPy array.")
|
31
|
-
|
32
|
-
# Check if x is a scalar (0-dimensional)
|
33
|
-
if x.shape == ():
|
34
|
-
raise ValueError(
|
35
|
-
"Input 'x' must be a 1D or 2D NumPy array, not a scalar.")
|
36
|
-
|
37
|
-
if to not in ['weight', 'mole']:
|
38
|
-
raise ValueError("Invalid 'to' argument. Use 'weight' or 'mole'.")
|
39
|
-
|
40
|
-
# Check and reshape Mw if needed
|
41
|
-
Mw = Mw[:, np.newaxis] if Mw.ndim == 1 else Mw # Reshape to (n, 1)
|
42
|
-
# Check and reshape x if needed
|
43
|
-
x = x[:, np.newaxis] if x.ndim == 1 else x # Reshape to (n, 1)
|
44
|
-
|
45
|
-
# Check if Mw and x have the same number of components
|
46
|
-
if Mw.shape[0] != x.shape[0]:
|
47
|
-
raise ValueError(
|
48
|
-
"Number of components in 'Mw' and 'x' must match.")
|
49
|
-
|
50
|
-
# Calculate the numerator for conversion based on 'to' argument
|
51
|
-
num = x * Mw if to == 'weight' else x / Mw
|
52
|
-
# Calculate the denominator for conversion
|
53
|
-
den = np.sum(num, axis=0)
|
54
|
-
# Calculate the final conversion using the numerator and denominator
|
55
|
-
res = num / den
|
56
|
-
# Replace nan values with 0
|
57
|
-
res = np.nan_to_num(res)
|
58
|
-
return res
|
1
|
+
import numpy as np
|
2
|
+
|
3
|
+
|
4
|
+
def convert(x: np.ndarray, Mw: np.ndarray, to: str = 'weight') -> np.ndarray:
|
5
|
+
"""
|
6
|
+
Convert between mole fraction (x) and mass/weight fraction (w) for a mixture.
|
7
|
+
|
8
|
+
Parameters:
|
9
|
+
x (numpy.ndarray): A 1D or 2D NumPy array representing mole fractions of components in the mixture.
|
10
|
+
- If 1D, it should have the shape (n,) with 'n' as the number of components.
|
11
|
+
- If 2D, it should have the shape (n, m) where 'n' is the number of components
|
12
|
+
and 'm' is the number of data points.
|
13
|
+
to (str, optional): The conversion direction. Should be either 'weight' (default) to convert from mole
|
14
|
+
fraction to weight fraction, or 'mole' to convert from weight fraction to mole fraction.
|
15
|
+
|
16
|
+
Returns:
|
17
|
+
numpy.ndarray: The converted fractions, with the same shape as 'x'.
|
18
|
+
|
19
|
+
Example:
|
20
|
+
>>> sle = SLE(mix=mix) # Replace with the actual mixture object
|
21
|
+
>>> x = np.array([0.4, 0.6]) # Mole fractions of two components
|
22
|
+
>>> w = mix.convert(x, to='weight') # Convert to weight fractions
|
23
|
+
>>> print(w)
|
24
|
+
array([[0.01373165],
|
25
|
+
[0.98626835]])
|
26
|
+
"""
|
27
|
+
|
28
|
+
# Check if x is a NumPy array
|
29
|
+
if not isinstance(x, np.ndarray):
|
30
|
+
raise ValueError("Input 'x' must be a 1D or 2D NumPy array.")
|
31
|
+
|
32
|
+
# Check if x is a scalar (0-dimensional)
|
33
|
+
if x.shape == ():
|
34
|
+
raise ValueError(
|
35
|
+
"Input 'x' must be a 1D or 2D NumPy array, not a scalar.")
|
36
|
+
|
37
|
+
if to not in ['weight', 'mole']:
|
38
|
+
raise ValueError("Invalid 'to' argument. Use 'weight' or 'mole'.")
|
39
|
+
|
40
|
+
# Check and reshape Mw if needed
|
41
|
+
Mw = Mw[:, np.newaxis] if Mw.ndim == 1 else Mw # Reshape to (n, 1)
|
42
|
+
# Check and reshape x if needed
|
43
|
+
x = x[:, np.newaxis] if x.ndim == 1 else x # Reshape to (n, 1)
|
44
|
+
|
45
|
+
# Check if Mw and x have the same number of components
|
46
|
+
if Mw.shape[0] != x.shape[0]:
|
47
|
+
raise ValueError(
|
48
|
+
"Number of components in 'Mw' and 'x' must match.")
|
49
|
+
|
50
|
+
# Calculate the numerator for conversion based on 'to' argument
|
51
|
+
num = x * Mw if to == 'weight' else x / Mw
|
52
|
+
# Calculate the denominator for conversion
|
53
|
+
den = np.sum(num, axis=0)
|
54
|
+
# Calculate the final conversion using the numerator and denominator
|
55
|
+
res = num / den
|
56
|
+
# Replace nan values with 0
|
57
|
+
res = np.nan_to_num(res)
|
58
|
+
return res
|