cosmopharm 0.0.0__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 +2 -0
- cosmopharm/actmodels/__init__.py +2 -0
- cosmopharm/actmodels/actmodel.py +118 -0
- cosmopharm/actmodels/cosmo.py +152 -0
- cosmopharm/components.py +31 -0
- cosmopharm/equilibrium/__init__.py +2 -0
- cosmopharm/equilibrium/lle.py +178 -0
- cosmopharm/equilibrium/sle.py +244 -0
- cosmopharm/utils/__init__.py +3 -0
- cosmopharm/utils/convert.py +58 -0
- cosmopharm/utils/helpers.py +36 -0
- cosmopharm/utils/lle_scanner.py +188 -0
- cosmopharm/utils/spacing.py +246 -0
- cosmopharm-0.0.0.dist-info/LICENSE +21 -0
- cosmopharm-0.0.0.dist-info/METADATA +38 -0
- cosmopharm-0.0.0.dist-info/RECORD +18 -0
- cosmopharm-0.0.0.dist-info/WHEEL +5 -0
- cosmopharm-0.0.0.dist-info/top_level.txt +1 -0
cosmopharm/__init__.py
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import numbers
|
3
|
+
from numpy.typing import NDArray
|
4
|
+
from typing import List, Literal
|
5
|
+
|
6
|
+
from ..components import Component
|
7
|
+
from ..utils.convert import convert
|
8
|
+
|
9
|
+
class ActModel:
|
10
|
+
|
11
|
+
def __init__(self, components: List[Component]):
|
12
|
+
self.mixture = components
|
13
|
+
|
14
|
+
def lngamma(self, T, x):
|
15
|
+
raise NotImplementedError("lngamma() hasn't been implemented yet.")
|
16
|
+
|
17
|
+
def activity(self, T, x):
|
18
|
+
act = np.log(x) + self.lngamma(T, x)
|
19
|
+
act[(act == np.inf) | (act == -np.inf)] = np.nan
|
20
|
+
return act
|
21
|
+
|
22
|
+
def gmix(self, T, x):
|
23
|
+
is_scalar = np.isscalar(x)
|
24
|
+
# Convert input as needed
|
25
|
+
x = self._convert_input(x)
|
26
|
+
# Create mask to identify columns that don't contain 0 or 1
|
27
|
+
mask = np.any((x != 0) & (x != 1), axis=0)
|
28
|
+
# Apply the mask to filter x
|
29
|
+
_x = x[:, mask]
|
30
|
+
# Calculate gmix for the x values
|
31
|
+
_gmix = _x * (np.log(_x) + self.lngamma(T, _x))
|
32
|
+
_gmix = np.sum(_gmix, axis=0)
|
33
|
+
# Initialize gmix array with zeros
|
34
|
+
gmix = np.zeros(1 if x.ndim==1 else x.shape[1])
|
35
|
+
# Fill gmix with calculated values where the mask is True
|
36
|
+
gmix[mask] = _gmix
|
37
|
+
return gmix[0] if is_scalar else gmix
|
38
|
+
|
39
|
+
def thermofac(self, T, x):
|
40
|
+
""" Approximate thermodynamic factor
|
41
|
+
Simple derivative form, when no analytical equation is available.
|
42
|
+
"""
|
43
|
+
def f(x1):
|
44
|
+
x = np.array([x1, 1-x1])
|
45
|
+
return self.lngamma(T, x)[0]
|
46
|
+
h, x = 0.0001, x[0]
|
47
|
+
dy = (f(x+h)-f(x-h))/(2*h)
|
48
|
+
return 1 + x * dy
|
49
|
+
|
50
|
+
|
51
|
+
# =============================================================================
|
52
|
+
# Wrapper functions (Decorators)
|
53
|
+
# =============================================================================
|
54
|
+
@staticmethod
|
55
|
+
def vectorize(func):
|
56
|
+
''' Intended vor ActModels where only single mole fractions can be
|
57
|
+
handled, like e.g. COSMO-SAC. This function vectorizes the lngamma()
|
58
|
+
to make it work with arrays of mole fractions.
|
59
|
+
'''
|
60
|
+
def wrapper(self, T, x):
|
61
|
+
# Convert input to appropriate format
|
62
|
+
x = self._convert_input(x)
|
63
|
+
# Process based on the dimensionality of x
|
64
|
+
if x.ndim == 1:
|
65
|
+
return func(self, T, x)
|
66
|
+
elif x.ndim == 2:
|
67
|
+
results = [func(self, T, x[:, col]) for col in range(x.shape[1])]
|
68
|
+
return np.array(results).T
|
69
|
+
else:
|
70
|
+
raise ValueError("Input must be either a scalar, 0D, 1D or 2D array")
|
71
|
+
return wrapper
|
72
|
+
|
73
|
+
|
74
|
+
# =============================================================================
|
75
|
+
# Auxilliary functions
|
76
|
+
# =============================================================================
|
77
|
+
def _convert_input(self, x):
|
78
|
+
"""Converts input to a 1-dim ndarray if it's a number or 0-dim ndarray."""
|
79
|
+
if isinstance(x, numbers.Number) or (isinstance(x, np.ndarray) and x.ndim == 0):
|
80
|
+
return np.array([float(x), 1 - float(x)])
|
81
|
+
elif isinstance(x, np.ndarray) and x.ndim == 1 and len(x) != len(self.mixture):
|
82
|
+
return np.array([x, 1 - x])
|
83
|
+
return x
|
84
|
+
|
85
|
+
def _convert(self,
|
86
|
+
x : NDArray[np.float64],
|
87
|
+
to : Literal['weight', 'mole'] ='weight'
|
88
|
+
) -> NDArray[np.float64]:
|
89
|
+
"""
|
90
|
+
Convert the fraction of a binary mixture between mole fraction and weight fraction.
|
91
|
+
|
92
|
+
This method is designed for internal use with binary mixtures, where the mixture is defined by two components.
|
93
|
+
It uses the 'convert' function to perform the conversion by creating an array with the fractions of both
|
94
|
+
components and the molecular weights from the mixture's attributes.
|
95
|
+
|
96
|
+
Parameters:
|
97
|
+
x (NDArray[np.float64]): The mole or weight fraction of the first component of the mixture.
|
98
|
+
If converting 'to' weight, 'x' represents mole fractions; if converting 'to' mole,
|
99
|
+
'x' represents weight fractions. This should be a single value or a 1D array of values.
|
100
|
+
to (Literal['weight', 'mole'], optional): The target type for the conversion. Defaults to 'weight'.
|
101
|
+
Use 'weight' to convert mole fractions to weight fractions,
|
102
|
+
and 'mole' to convert weight fractions to mole fractions.
|
103
|
+
|
104
|
+
Returns:
|
105
|
+
NDArray[np.float64]: The converted fraction(s) of the first component in the same shape as 'x'.
|
106
|
+
If 'x' is a single value, the return will be a single converted value;
|
107
|
+
if 'x' is a 1D array, the return will be a 1D array of converted values.
|
108
|
+
|
109
|
+
Example:
|
110
|
+
>>> mixture = Mixture(components=[component1, component2], Mw=np.array([18.01528, 46.06844]))
|
111
|
+
>>> sle = SLE(mix=mixture)
|
112
|
+
>>> x_mole_fraction = np.array([0.4]) # Mole fraction of the first component
|
113
|
+
>>> x_weight_fraction = sle._convert(x_mole_fraction, to='weight')
|
114
|
+
>>> print(x_weight_fraction)
|
115
|
+
array([0.01373165])
|
116
|
+
"""
|
117
|
+
Mw = np.array([c.Mw for c in self.mixture])
|
118
|
+
return convert(x=np.array([x, 1-x], dtype=np.float64), Mw=Mw, to=to)[0]
|
@@ -0,0 +1,152 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import cCOSMO
|
3
|
+
|
4
|
+
from typing import List, Union, Literal
|
5
|
+
from .actmodel import ActModel
|
6
|
+
from ..components import Component
|
7
|
+
|
8
|
+
class COSMOSAC(ActModel):
|
9
|
+
|
10
|
+
# Handle invalid values for free volume calculation
|
11
|
+
class InvalidFreeVolumeParametersException(Exception):
|
12
|
+
pass
|
13
|
+
|
14
|
+
def __init__(self,
|
15
|
+
COSMO: Union[cCOSMO.COSMO1, cCOSMO.COSMO3],
|
16
|
+
mixture: List[Component],
|
17
|
+
combinatorial: Union[Literal['sg', 'fv'], bool] = 'sg',
|
18
|
+
dispersion: bool = False,
|
19
|
+
) -> None:
|
20
|
+
self.COSMO = COSMO
|
21
|
+
self.mixture = mixture
|
22
|
+
# Flexible assignment of 'get_lngamma_comb' and 'get_lngamma_dsp'
|
23
|
+
# that changes dynamically if the values for 'combinatorial' or
|
24
|
+
# 'dispersion' are changed after initialization of an instance.
|
25
|
+
self._combinatorial = combinatorial
|
26
|
+
self._dispersion = dispersion
|
27
|
+
|
28
|
+
@ActModel.vectorize
|
29
|
+
def lngamma(self, T, x):
|
30
|
+
resid = self.get_lngamma_resid(T, x)
|
31
|
+
comb = self.get_lngamma_comb(x)
|
32
|
+
disp = self.get_lngamma_disp(x)
|
33
|
+
lngamma = resid + comb + disp
|
34
|
+
return lngamma
|
35
|
+
|
36
|
+
def get_lngamma_fv(self, x):
|
37
|
+
"""
|
38
|
+
Calculates the free-volume term of the activity coefficient for a mixture.
|
39
|
+
|
40
|
+
This implementation uses a formula to avoid numerical instability when
|
41
|
+
`x_i` approaches zero, which is important in asymmetric API-polymer
|
42
|
+
mixtures. The formula used is:
|
43
|
+
|
44
|
+
```
|
45
|
+
phi_i^FV / x_i = v_i^F / sum_j(x_j * v_j^F)
|
46
|
+
```
|
47
|
+
|
48
|
+
where
|
49
|
+
- `phi_i^FV` is the free-volume fraction of component `i`,
|
50
|
+
- `x_i` is the mole fraction of component `i`,
|
51
|
+
- `v_i^F` is the free volume of component `i`,
|
52
|
+
and the summation is over all components `j` in the mixture.
|
53
|
+
|
54
|
+
Parameters
|
55
|
+
----------
|
56
|
+
x : array_like
|
57
|
+
Mole fractions of the components in the mixture.
|
58
|
+
|
59
|
+
Returns
|
60
|
+
-------
|
61
|
+
np.ndarray
|
62
|
+
Logarithm of the free-volume term of the activity coefficient.
|
63
|
+
|
64
|
+
Note:
|
65
|
+
Free-volume term of the activity coefficient according to Elbro et al.
|
66
|
+
(can replace ln_gamma_comb of normal COSMO-SAC) - Kuo2013
|
67
|
+
x, v_298, v_hc are 1D arrays (number of elements = number of components)
|
68
|
+
"""
|
69
|
+
self.validate_free_volume_parameters() # Ensure components are valid before proceeding
|
70
|
+
v_298 = np.array([comp.v_298 for comp in self.mixture])
|
71
|
+
v_hc = np.array([comp.v_hc for comp in self.mixture])
|
72
|
+
vf = v_298-v_hc
|
73
|
+
sum_vf = np.sum(x*vf)
|
74
|
+
phix = vf/sum_vf
|
75
|
+
return np.log(phix) + 1 - phix
|
76
|
+
|
77
|
+
def get_lngamma_sg(self, x):
|
78
|
+
return self.COSMO.get_lngamma_comb(0, x)
|
79
|
+
|
80
|
+
def get_lngamma_resid(self, T, x):
|
81
|
+
return self.COSMO.get_lngamma_resid(T, x)
|
82
|
+
|
83
|
+
def get_lngamma_comb(self, x):
|
84
|
+
if self._combinatorial is False:
|
85
|
+
return np.zeros(len(x))
|
86
|
+
elif self._combinatorial.lower() == 'sg':
|
87
|
+
return self.get_lngamma_sg(x)
|
88
|
+
elif self._combinatorial.lower() == 'fv':
|
89
|
+
return self.get_lngamma_fv(x)
|
90
|
+
|
91
|
+
def get_lngamma_disp(self, x):
|
92
|
+
if self._dispersion:
|
93
|
+
return self.COSMO.get_lngamma_disp(x)
|
94
|
+
else:
|
95
|
+
return np.zeros(len(x))
|
96
|
+
|
97
|
+
@property
|
98
|
+
def dispersion(self):
|
99
|
+
return self._dispersion
|
100
|
+
|
101
|
+
@dispersion.setter
|
102
|
+
def dispersion(self, value):
|
103
|
+
self._dispersion = value
|
104
|
+
|
105
|
+
@property
|
106
|
+
def combinatorial(self):
|
107
|
+
return self._combinatorial
|
108
|
+
|
109
|
+
@combinatorial.setter
|
110
|
+
def combinatorial(self, value: Union[str, bool]):
|
111
|
+
is_valid_string = isinstance(value, str) and value.lower() in ('sg', 'fv')
|
112
|
+
is_False = value is False
|
113
|
+
if is_valid_string or is_False:
|
114
|
+
self._combinatorial = value
|
115
|
+
else:
|
116
|
+
msg = "Invalid value for combinatorial term. Please choose 'sg', 'fv', or set to False."
|
117
|
+
raise ValueError(msg)
|
118
|
+
|
119
|
+
# =============================================================================
|
120
|
+
# Auxilliary functions
|
121
|
+
# =============================================================================
|
122
|
+
def configuration(self,
|
123
|
+
comb: Union[Literal['sg', 'fv'], bool] = 'sg',
|
124
|
+
dsp: bool = False, **kwargs
|
125
|
+
):
|
126
|
+
""" Convenience function to quickly configure COSMO parameters """
|
127
|
+
self._combinatorial = comb
|
128
|
+
self._dispersion = dsp
|
129
|
+
|
130
|
+
|
131
|
+
def validate_free_volume_parameters(self):
|
132
|
+
# List of parameters to validate
|
133
|
+
parameters_to_check = ["v_298", "v_hc"]
|
134
|
+
|
135
|
+
for comp in self.mixture:
|
136
|
+
invalid_params = [] # List to accumulate names of invalid parameters for this component
|
137
|
+
for param in parameters_to_check:
|
138
|
+
value = getattr(comp, param, None)
|
139
|
+
# Check if value is None, not a number (np.nan), less than or equal to 0
|
140
|
+
if value is None or np.isnan(value) or value <= 0:
|
141
|
+
invalid_params.append((param, value)) # Append parameter name and value tuple
|
142
|
+
|
143
|
+
# Check if any errors were found for this component
|
144
|
+
if invalid_params:
|
145
|
+
# If errors were found, construct the warning message
|
146
|
+
error_message = f"Invalid FV parameters for component {comp}: {invalid_params}"
|
147
|
+
raise self.InvalidFreeVolumeParametersException(error_message)
|
148
|
+
|
149
|
+
# Additionally check if v_298 and v_hc are equal
|
150
|
+
if comp.v_298 == comp.v_hc:
|
151
|
+
msg = f"v_298 and v_hc are equal for component {comp}: v_298={comp.v_298}, v_hc={comp.v_hc}"
|
152
|
+
raise self.InvalidFreeVolumeParametersException(msg)
|
cosmopharm/components.py
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
from numbers import Number
|
3
|
+
|
4
|
+
class Component:
|
5
|
+
def __init__(self,
|
6
|
+
name: Optional[str] = None,
|
7
|
+
Mw: Optional[Number] = None, # Positive number expected
|
8
|
+
T_fus: Optional[Number] = None, # Positive number expected
|
9
|
+
H_fus: Number = 0,
|
10
|
+
Cp_fus_a_fit: Number = 0,
|
11
|
+
Cp_fus_bT_fit: Number = 0,
|
12
|
+
v_298: Optional[Number] = None,
|
13
|
+
v_hc: Optional[Number] = None,
|
14
|
+
):
|
15
|
+
|
16
|
+
self.name = name
|
17
|
+
self.Mw = Mw # molar weight in g/mol
|
18
|
+
|
19
|
+
# for SLE calculations
|
20
|
+
self.T_fus = T_fus
|
21
|
+
self.H_fus = H_fus
|
22
|
+
self.Cp_fus_a_fit = Cp_fus_a_fit
|
23
|
+
self.Cp_fus_bT_fit = Cp_fus_bT_fit
|
24
|
+
self.v_298 = v_298
|
25
|
+
self.v_hc = v_hc
|
26
|
+
|
27
|
+
def __repr__(self):
|
28
|
+
return f"<Component('{self.name}')>"
|
29
|
+
|
30
|
+
def __str__(self):
|
31
|
+
return f"{self.name}"
|
@@ -0,0 +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)
|