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.
@@ -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)