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 ADDED
@@ -0,0 +1,2 @@
1
+ from .equilibrium import SLE, LLE
2
+ from .actmodels import COSMOSAC
@@ -0,0 +1,2 @@
1
+ from .actmodel import ActModel
2
+ from .cosmo import COSMOSAC
@@ -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)
@@ -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,2 @@
1
+ from .sle import SLE
2
+ from .lle import LLE
@@ -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)