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
@@ -0,0 +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)
|
@@ -0,0 +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
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import pandas as pd
|
2
|
+
import numpy as np
|
3
|
+
from ..components import Component
|
4
|
+
|
5
|
+
def read_params(params_filepath):
|
6
|
+
params = pd.read_excel(params_filepath)
|
7
|
+
cols = [col.split('/')[0] for col in params.columns]
|
8
|
+
params.rename(columns=dict(zip(params.columns, cols)), inplace=True)
|
9
|
+
return params
|
10
|
+
|
11
|
+
def get_params_for_component(component, parameters):
|
12
|
+
"""Retrieve the parameters for the given component from the dataframe."""
|
13
|
+
params = parameters[parameters['Component'] == component.name]
|
14
|
+
if params.empty:
|
15
|
+
raise ValueError(f"No data found for component {component.name}")
|
16
|
+
return params.iloc[0]
|
17
|
+
|
18
|
+
def add_parameters(c, params):
|
19
|
+
"""Add properties of a component based on provided parameters."""
|
20
|
+
KILOJOULE_TO_JOULE = 1e3
|
21
|
+
c.Mw = params['Mw'] # g/mol
|
22
|
+
c.T_fus = params['T_fus'] if params['T_fus'] > 0 else np.nan # K
|
23
|
+
c.H_fus = params['H_fus'] * KILOJOULE_TO_JOULE # J/mol
|
24
|
+
c.Cp_fus_A = np.nan_to_num(params['Cp_fus_a_fit']) # J/(mol K)
|
25
|
+
c.Cp_fus_BT = np.nan_to_num(params['Cp_fus_bT_fit']) # J/(mol K²)
|
26
|
+
c.v_298 = params['v298'] # cm³/mol
|
27
|
+
c.v_hc = params['v_hc'] # cm³/mol
|
28
|
+
|
29
|
+
def create_components(names, parameters):
|
30
|
+
components = []
|
31
|
+
for name in names:
|
32
|
+
component = Component(name)
|
33
|
+
params = get_params_for_component(component, parameters)
|
34
|
+
add_parameters(component, params)
|
35
|
+
components.append(component)
|
36
|
+
return components
|
@@ -0,0 +1,188 @@
|
|
1
|
+
import numpy as np
|
2
|
+
from scipy.optimize import fsolve, root
|
3
|
+
from scipy.signal import argrelextrema
|
4
|
+
|
5
|
+
def find_inflection_points(x, y):
|
6
|
+
"""
|
7
|
+
Determines the inflection points of the function based on second derivative.
|
8
|
+
|
9
|
+
Parameters:
|
10
|
+
- x, y: data points
|
11
|
+
|
12
|
+
Returns:
|
13
|
+
- x and y values of turning points and their indices.
|
14
|
+
"""
|
15
|
+
dy = np.diff(y) / np.diff(x)
|
16
|
+
dx = (x[:-1] + x[1:]) / 2
|
17
|
+
d2y = np.diff(dy) / np.diff(dx)
|
18
|
+
sign_changes = np.diff(np.sign(d2y))
|
19
|
+
i = np.where(sign_changes)[0] + 2
|
20
|
+
return i, x[i], y[i]
|
21
|
+
|
22
|
+
def find_common_tangent(f, g, xL_init, xR_init):
|
23
|
+
"""
|
24
|
+
Determines the common tangent for two functions.
|
25
|
+
|
26
|
+
Parameters:
|
27
|
+
- f, g: Polynomial functions
|
28
|
+
- xL_init, xR_init: Initial guesses for intersection points
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
- Intersection points and y-values.
|
32
|
+
"""
|
33
|
+
def fobj(x):
|
34
|
+
xL, xR = x
|
35
|
+
df = f.deriv()(xL)
|
36
|
+
dg = g.deriv()(xR)
|
37
|
+
dy = (g(xR) - f(xL)) / (xR - xL)
|
38
|
+
return [df - dg, dy - df]
|
39
|
+
xL, xR = fsolve(fobj, x0=[xL_init, xR_init])
|
40
|
+
# [xL, xR], info, *_ = fsolve(fobj, x0=[xL_init, xR_init], full_output=True)
|
41
|
+
# print(info['nfev'])
|
42
|
+
# kwargs = dict(method='krylov', options={
|
43
|
+
# 'maxiter': 20, 'xtol': 1e-3})
|
44
|
+
# res = root(fobj, x0=[xL_init, xR_init], **kwargs)
|
45
|
+
# [xL, xR] = res.x
|
46
|
+
# print(res.nit)
|
47
|
+
return xL, xR, f(xL), g(xR)
|
48
|
+
|
49
|
+
def approximate_between_points(x, y, start, end, deg=5):
|
50
|
+
segment_x = x[start:end+1]
|
51
|
+
segment_y = y[start:end+1]
|
52
|
+
midpoint = (segment_x[0] + segment_x[-1]) / 2
|
53
|
+
params = np.polyfit(segment_x, segment_y, deg=deg)
|
54
|
+
func = np.poly1d(params)
|
55
|
+
return func, segment_x, midpoint
|
56
|
+
|
57
|
+
def get_segment_border_indices(x, start, end, fraction=0.5, min_values=5):
|
58
|
+
# Calculate the intermediate point using the given fraction
|
59
|
+
intermediate_x = x[start] + fraction * (x[end] - x[start])
|
60
|
+
# Find the index in x that is closest to this intermediate point
|
61
|
+
# closest_index = start + np.argmin(np.abs(x[start:end+1] - intermediate_x))
|
62
|
+
closest_index = np.argmin(np.abs(x - intermediate_x))
|
63
|
+
# Ensure that there are at least min_values between start and closest_index
|
64
|
+
if abs(closest_index - start) < min_values:
|
65
|
+
closest_index = end
|
66
|
+
return sorted([start, closest_index])
|
67
|
+
|
68
|
+
def find_binodal(x1, gmix, iL, iR, ax=None):
|
69
|
+
# Approximate segments between pure component and inflection points
|
70
|
+
borderL = get_segment_border_indices(x1, start=iL, end=0)
|
71
|
+
borderR = get_segment_border_indices(x1, start=iR, end=-1)
|
72
|
+
f, xL_range, mL = approximate_between_points(x1, gmix, *borderL, deg=5)
|
73
|
+
g, xR_range, mR = approximate_between_points(x1, gmix, *borderR, deg=5)
|
74
|
+
# Find common tangent
|
75
|
+
xL, xR, yL, yR = find_common_tangent(f, g, mL, mR)
|
76
|
+
|
77
|
+
# Check if results are outside the fitting range
|
78
|
+
adjustL = xL < xL_range[0] or xL > xL_range[-1]
|
79
|
+
adjustR = xR < xR_range[0] or xR > xR_range[-1]
|
80
|
+
|
81
|
+
# If outside, adjust the respective range and recalculate xL and xR
|
82
|
+
if adjustL or adjustR:
|
83
|
+
if adjustL:
|
84
|
+
borderL = get_segment_border_indices(
|
85
|
+
x1, start=iL, end=0, fraction=1)
|
86
|
+
f, xL_range, mL = approximate_between_points(
|
87
|
+
x1, gmix, *borderL, deg=5)
|
88
|
+
if adjustR:
|
89
|
+
borderR = get_segment_border_indices(
|
90
|
+
x1, start=iR, end=-1, fraction=1)
|
91
|
+
g, xR_range, mR = approximate_between_points(
|
92
|
+
x1, gmix, *borderR, deg=10)
|
93
|
+
|
94
|
+
# Find common tangent
|
95
|
+
xL, xR, yL, yR = find_common_tangent(f, g, mL, mR)
|
96
|
+
|
97
|
+
if ax is not None:
|
98
|
+
ax.plot(xL_range, f(xL_range), 'r', lw=1)
|
99
|
+
ax.plot(xR_range, g(xR_range), 'r', lw=1)
|
100
|
+
|
101
|
+
return xL, xR, yL, yR
|
102
|
+
|
103
|
+
def find_local_extremum(y, typ='minimum'):
|
104
|
+
kind = np.less if "min" in typ else np.greater
|
105
|
+
return argrelextrema(y, kind)[0]
|
106
|
+
|
107
|
+
def approx_binodal(x1, gmix, xS, iL, iR):
|
108
|
+
x, y = x1, gmix
|
109
|
+
local_minima = find_local_extremum(y, 'minimum')
|
110
|
+
min_x = x[local_minima]
|
111
|
+
|
112
|
+
# Initialize return values
|
113
|
+
xL = xR = yL = yR = None
|
114
|
+
|
115
|
+
if len(min_x) == 0:
|
116
|
+
pass
|
117
|
+
# Check the number of local minima found
|
118
|
+
if len(min_x) == 1:
|
119
|
+
# Precompute the boolean condition to avoid redundancy
|
120
|
+
is_greater_than_mid_x = min_x > max(xS)
|
121
|
+
# Select indices based on the precomputed condition
|
122
|
+
i = iL if is_greater_than_mid_x else iR
|
123
|
+
slice_ = slice(None, i+1) if is_greater_than_mid_x else slice(i, None)
|
124
|
+
|
125
|
+
# Slicing the arrays based on the selected indices
|
126
|
+
x1, y1 = x[slice_], y[slice_]
|
127
|
+
|
128
|
+
# Fit a line to the boundary points
|
129
|
+
m, n = np.polyfit(x1[[0, -1]], y1[[0, -1]], 1)
|
130
|
+
f = np.poly1d([m, n])
|
131
|
+
|
132
|
+
# Calculate the difference for finding the local minima
|
133
|
+
diff = y1 - f(x1)
|
134
|
+
local_minima = find_local_extremum(diff, 'minimum')
|
135
|
+
|
136
|
+
# Adjust local_minima index based on the original x array
|
137
|
+
if not is_greater_than_mid_x:
|
138
|
+
local_minima += i # Adjust index for the slice offset
|
139
|
+
|
140
|
+
# Check if new local minima were found and sort
|
141
|
+
if local_minima.size > 0:
|
142
|
+
# Sort to ensure xL < xR
|
143
|
+
xL, xR = np.sort([min_x[0], x[local_minima][0]])
|
144
|
+
yL, yR = y[np.where((x == xL) | (x == xR))]
|
145
|
+
|
146
|
+
elif len(min_x) == 2:
|
147
|
+
xL, xR = min_x
|
148
|
+
yL, yR = y[local_minima]
|
149
|
+
|
150
|
+
else:
|
151
|
+
pass
|
152
|
+
|
153
|
+
return xL, xR, yL, yR
|
154
|
+
|
155
|
+
|
156
|
+
def estimate_lle_from_gmix(x1, gmix, rough=True, ax=None):
|
157
|
+
# Initialize return values
|
158
|
+
xL = xR = yL = yR = None
|
159
|
+
|
160
|
+
# Find inflection points
|
161
|
+
try:
|
162
|
+
idx, xS, yS = find_inflection_points(x1, gmix)
|
163
|
+
iL, iR = idx[:2]
|
164
|
+
except ValueError:
|
165
|
+
# print("Warning: No inflection points found for current iteration.")
|
166
|
+
return xL, xR, yL, yR
|
167
|
+
|
168
|
+
# Find binodal
|
169
|
+
if rough:
|
170
|
+
xL, xR, yL, yR = approx_binodal(x1, gmix, xS, iL, iR)
|
171
|
+
else:
|
172
|
+
xL, xR, yL, yR = find_binodal(x1, gmix, iL, iR, ax=ax)
|
173
|
+
|
174
|
+
# Round xL and xR if they are not None
|
175
|
+
xL = round(xL, 8) if xL is not None else None
|
176
|
+
xR = round(xR, 8) if xR is not None else None
|
177
|
+
|
178
|
+
if ax is not None and None not in (xL, xR, yL, yR):
|
179
|
+
# Plot common tangent
|
180
|
+
m = (yR-yL)/(xR-xL)
|
181
|
+
n = yR - m*xR
|
182
|
+
t = np.poly1d([m, n])
|
183
|
+
ylim = ax.get_ylim()
|
184
|
+
ax.plot([xL, xR], [yL, yR], 'ko', mfc='w', zorder=3)
|
185
|
+
ax.plot(x1, t(x1), 'k:')
|
186
|
+
ax.set_ylim(ylim)
|
187
|
+
|
188
|
+
return xL, xR, yL, yR
|