unifac 0.1.0__tar.gz

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.
unifac-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emil Kongsbach
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
unifac-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: unifac
3
+ Version: 0.1.0
4
+ Summary: A Python implementation of the UNIFAC-Dortmund group contribution method
5
+ Author-email: Emil Kongsbach <emil.cheme@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Emil-Kongsbach/UNIFAC
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pandas
13
+ Requires-Dist: ugropy
14
+ Requires-Dist: openpyxl
15
+ Requires-Dist: scipy
16
+ Dynamic: license-file
17
+
18
+ # UNIFAC
19
+ An implementation of the group contribution method of the Dortmund modification of UNIFAC. Calculates the activity coeficients of species in a mixture based on functional group parameters
20
+
21
+ """
22
+ from unifac import Unifac_Dortmund
23
+
24
+ # Set system parameters
25
+ smiles_lst = ["CCO", "CC(=O)C","CC=CC"] # NA
26
+ x_lst=[1/3, 1/3, 1/3] # NA
27
+ T=298 # K
28
+
29
+ mixture=Unifac_Dortmund(smiles_lst,x_lst,T)
30
+
31
+ # Returns activity coefficient of component 0 in the mixture, according to the order in which the components were passed.
32
+ print(mixture.gamma_singular(0))
33
+
34
+ # Returns activity coefficient of all components in the mixture according to the order in which componenets were passed
35
+ print(mixture.gamma_total())
36
+
37
+ # stability analysis
38
+
39
+ mixture.is_stable_local() # computes local stability (spinodal)
40
+
41
+ mixture.is_stable_global() # computes global stability (binodal)
42
+
43
+
44
+ mixture.is_stable() # computes first local stability (cheap), and only if that is True it proceeds to calculate global stability (expensive)
45
+
46
+ """
unifac-0.1.0/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # UNIFAC
2
+ An implementation of the group contribution method of the Dortmund modification of UNIFAC. Calculates the activity coeficients of species in a mixture based on functional group parameters
3
+
4
+ """
5
+ from unifac import Unifac_Dortmund
6
+
7
+ # Set system parameters
8
+ smiles_lst = ["CCO", "CC(=O)C","CC=CC"] # NA
9
+ x_lst=[1/3, 1/3, 1/3] # NA
10
+ T=298 # K
11
+
12
+ mixture=Unifac_Dortmund(smiles_lst,x_lst,T)
13
+
14
+ # Returns activity coefficient of component 0 in the mixture, according to the order in which the components were passed.
15
+ print(mixture.gamma_singular(0))
16
+
17
+ # Returns activity coefficient of all components in the mixture according to the order in which componenets were passed
18
+ print(mixture.gamma_total())
19
+
20
+ # stability analysis
21
+
22
+ mixture.is_stable_local() # computes local stability (spinodal)
23
+
24
+ mixture.is_stable_global() # computes global stability (binodal)
25
+
26
+
27
+ mixture.is_stable() # computes first local stability (cheap), and only if that is True it proceeds to calculate global stability (expensive)
28
+
29
+ """
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unifac"
7
+ version = "0.1.0"
8
+ description = "A Python implementation of the UNIFAC-Dortmund group contribution method"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Emil Kongsbach", email = "emil.cheme@gmail.com" }
14
+ ]
15
+ dependencies = [
16
+ "numpy",
17
+ "pandas",
18
+ "ugropy",
19
+ "openpyxl",
20
+ "scipy"
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/Emil-Kongsbach/UNIFAC"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ unifac = ["*.xlsx"]
unifac-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ from .unifac_dortmund import Unifac_Dortmund
@@ -0,0 +1,663 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ #from rdkit import Chem
4
+ from ugropy import Groups
5
+ from scipy.optimize import minimize
6
+ import os
7
+
8
+ script_dir = os.path.dirname(os.path.abspath(__file__))
9
+
10
+ # load from excel
11
+ file_path1 = os.path.join(script_dir, "interaction_parameters.xlsx")
12
+ df_ip = pd.read_excel(file_path1)
13
+ df_ip = df_ip.fillna(0) # replaces nan with 0
14
+
15
+ file_path2 = os.path.join(script_dir, "groupe_surfaces_and_volumes.xlsx")
16
+ df_sv=pd.read_excel(file_path2)
17
+
18
+
19
+ class Unifac_Dortmund:
20
+ _group_cache = {} # class-level cache shared across all instances, keyed by smiles tuple
21
+
22
+ def __init__(self,smiles_lst,mol_lst,T):
23
+ self.smiles_lst=smiles_lst
24
+ self.mol_lst=mol_lst
25
+ self.T=T
26
+
27
+ # making sure here to store the disected groups because of the stability analysis calls it repeatedly
28
+ # but doesent actually change the groups between instances
29
+ cache_key = tuple(smiles_lst)
30
+ if cache_key in Unifac_Dortmund._group_cache: # this is used the rest of the time
31
+ self.list_groups, self.unique_group_no = Unifac_Dortmund._group_cache[cache_key]
32
+ else:
33
+ self.list_groups, self.unique_group_no = self.format_data() # this is used for the very first instances
34
+ Unifac_Dortmund._group_cache[cache_key] = (self.list_groups, self.unique_group_no)
35
+
36
+ # error messages
37
+ if len(mol_lst) != len(smiles_lst):
38
+ raise ValueError(f"phi_lst (len={len(mol_lst)}) and smiles_lst (len={len(smiles_lst)}) must have same length.")
39
+
40
+
41
+ if not np.isclose(sum(mol_lst), 1.0, atol=1e-6):
42
+ raise ValueError(f"molefractions must sum to unity, but where of sum {sum(mol_lst)}")
43
+
44
+ # smiles to groupes
45
+ def disect_smiles(self,smiles):
46
+ molecule = Groups(smiles, identifier_type="smiles")
47
+ unifac_counts = molecule.dortmund.subgroups
48
+ return unifac_counts
49
+
50
+ # making list of dataframes with group number, group type and number of that groupe
51
+ def format_data(self):
52
+ smiles_lst=self.smiles_lst
53
+ data = {"Group_No": pd.Series(dtype=int),
54
+ "Group": pd.Series(dtype=str),
55
+ "number": pd.Series(dtype=int)}
56
+
57
+ list_groups=[]
58
+
59
+ for i in range(len(smiles_lst)):
60
+ df = pd.DataFrame(data)
61
+ smiles=smiles_lst[i]
62
+ group_library=self.disect_smiles(smiles)
63
+
64
+ for j in range(len(group_library)):
65
+ group = list(group_library.keys())[j]
66
+ index = df_sv.index[df_sv["Subgroup Name"] == group][0]
67
+ group_no=df_sv.loc[index,"No."]
68
+ number=group_library[group]
69
+
70
+ df.loc[len(df),"Group_No"]=group_no
71
+ df.loc[len(df)-1,"Group"]=group
72
+ df.loc[len(df)-1,"number"]=number
73
+ list_groups.append(df)
74
+
75
+ # unique groups
76
+ df2=list_groups[0]
77
+ df2=df2["Group_No"]
78
+ for i in range(1,len(list_groups)):
79
+ df3=list_groups[i]["Group_No"]
80
+ df2 = pd.concat([df2, df3], ignore_index=True)
81
+ lst =df2.tolist()
82
+
83
+ unique_group_no = list(dict.fromkeys(lst))
84
+
85
+ return list_groups,unique_group_no
86
+
87
+ # get main group number from subgroup number
88
+ def get_main_group(self, group_no):
89
+ idx = df_sv.index[df_sv["No."] == group_no][0]
90
+ return df_sv.loc[idx, "Main Group No."]
91
+
92
+ #============================fractions based on whole mixture===============================
93
+ def cap_x(self,group_no):
94
+ sum_j1=0
95
+ sum_j2=0
96
+ list_groups=self.list_groups
97
+
98
+ mol_lst=self.mol_lst
99
+ #sum over all chemical species in mixture
100
+ for j in range(len(list_groups)):
101
+ df=list_groups[j] #select species df
102
+
103
+ try:
104
+ index = df.index[df["Group_No"] == group_no][0] # obs, problem her hvis en forbindelse er i et molekyle og ikke det andet, lige nu er den bare defineret sum nul i dataen, man kan evt, sige if none, continue uden at definere den sim 0
105
+ except:
106
+ continue
107
+ #if index==none continue
108
+ v_mj=df.loc[index,"number"]
109
+
110
+ x_j=mol_lst[j]
111
+
112
+ sum_j1+=v_mj*x_j
113
+
114
+ for j in range(len(list_groups)): #sum over all chemical species in mixture
115
+ df=list_groups[j]
116
+ x_j=mol_lst[j]
117
+ sum_n=0
118
+ for n in range(len(df)): # sum over all groups in molecule
119
+
120
+ v_nj=df.loc[n,"number"]
121
+ sum_n+=v_nj*x_j
122
+ sum_j2+=sum_n
123
+
124
+ return sum_j1/sum_j2
125
+
126
+ def cap_theta(self,group_no):
127
+ unique_group_no=self.unique_group_no
128
+
129
+ index = df_sv.index[df_sv["No."] == group_no][0]
130
+ Q_m=df_sv.loc[index,"Q"] #get from group contribution table
131
+
132
+ cap_x_m=self.cap_x(group_no)
133
+
134
+ sum_n=0
135
+ for n in range(len(unique_group_no)): #sum over all groups present in mixture
136
+ group_no2=unique_group_no[n]
137
+
138
+ index = df_sv.index[df_sv["No."] == group_no2][0]
139
+ Q_n=df_sv.loc[index,"Q"] #get from group contribution table
140
+
141
+ cap_x_n= self.cap_x(group_no2)
142
+
143
+ sum_n+=Q_n*cap_x_n
144
+ cap_theta_m=cap_x_m*Q_m/sum_n
145
+ return cap_theta_m
146
+
147
+ #============================fractions based on molecule=============================
148
+ def cap_x_i(self,group_no,species_no):
149
+ list_groups=self.list_groups
150
+
151
+ df=list_groups[species_no] #select species df
152
+
153
+ try:
154
+ index = df.index[df["Group_No"] == group_no][0] # obs, problem her hvis en forbindelse er i et molekyle og ikke det andet, lige nu er den bare defineret sum nul i dataen, man kan evt, sige if none, continue uden at definere den sim 0
155
+ v_mj=df.loc[index,"number"]
156
+ except:
157
+ v_mj=0
158
+
159
+ sum_n=0
160
+ for n in range(len(df)): # sum over all groups in molecule
161
+ v_nj=df.loc[n,"number"]
162
+ sum_n+=v_nj
163
+
164
+ return v_mj/sum_n
165
+
166
+
167
+ def cap_theta_i(self,group_no, species_no):# pillede ved den her
168
+ list_groups=self.list_groups
169
+
170
+ index = df_sv.index[df_sv["No."] == group_no][0]
171
+ Q_m=df_sv.loc[index,"Q"] #get from group contribution table
172
+
173
+ df=list_groups[species_no]
174
+ index2 = df.index[df["Group_No"] == group_no][0]
175
+ #nu_m=df.loc[index2, "number"]
176
+ cap_x_m=self.cap_x_i(group_no,species_no)
177
+
178
+ sum_n=0
179
+ for n in range(len(df)): #sum over all groups present in molecule
180
+ group_no2=df.loc[n,"Group_No"]
181
+
182
+ index = df_sv.index[df_sv["No."] == group_no2][0]
183
+ Q_n=df_sv.loc[index,"Q"] #get from group contribution table
184
+
185
+ index2 = df.index[df["Group_No"] == group_no2][0]
186
+ #nu_n=df.loc[index2, "number"]
187
+ cap_x_n= self.cap_x_i(group_no2,species_no)
188
+ #sum_n+=nu_n*Q_n
189
+ sum_n+=cap_x_n*Q_n
190
+ #cap_theta_m_i=Q_m*nu_m/sum_n
191
+ cap_theta_m_i=Q_m*cap_x_m/sum_n
192
+ return cap_theta_m_i
193
+
194
+ #=======================================ln gamma_k=========================================
195
+ def ln_cap_gamma(self,group_no_1):
196
+ T=self.T
197
+ unique_group_no=self.unique_group_no
198
+
199
+ index = df_sv.index[df_sv["No."] == group_no_1][0]
200
+ cap_q_k=df_sv.loc[index,"Q"] #get from group contribution table
201
+
202
+ sum_m_2=0
203
+ sum_m_1=0
204
+ #==========================sum m 1:===========================
205
+ for m in range(len(unique_group_no)): # sum over all gropues in the mixture
206
+
207
+ group_no_2=unique_group_no[m]
208
+
209
+ cap_theta_m=self.cap_theta(group_no_2)
210
+ # convert subgroup number to main group number for interaction parameters
211
+ mg1 = self.get_main_group(group_no_1) #k
212
+ mg2 = self.get_main_group(group_no_2) #m
213
+
214
+
215
+ if mg1==mg2:
216
+ a_mk,a_km=0,0 # self interaction
217
+ else:
218
+ try:
219
+ # have to do try except as ij is in same row as ji and does not have thei own row
220
+ try:
221
+ index=df_ip.index[(df_ip["i"] == mg2) & (df_ip["j"] == mg1)][0]
222
+ Amk,Bmk,Cmk=df_ip.loc[index,"Aij"],df_ip.loc[index,"Bij"],df_ip.loc[index,"Cij"]
223
+ Akm,Bkm,Ckm=df_ip.loc[index,"Aji"],df_ip.loc[index,"Bji"],df_ip.loc[index,"Cji"]
224
+ except:
225
+ index=df_ip.index[(df_ip["i"] == mg1) & (df_ip["j"] == mg2)][0]
226
+ Amk,Bmk,Cmk=df_ip.loc[index,"Aij"],df_ip.loc[index,"Bij"],df_ip.loc[index,"Cij"]
227
+ Akm,Bkm,Ckm=df_ip.loc[index,"Aji"],df_ip.loc[index,"Bji"],df_ip.loc[index,"Cji"]
228
+ except:
229
+ raise Exception(f"Interaction parameters not found for groups {mg1} and {mg2}") from None
230
+ #print(f"Interaction parameters not found for groups {mg1} and {mg2}")
231
+ #return None
232
+ a_mk=Amk+Bmk*T+Cmk*T**2
233
+ a_km=Akm+Bkm*T+Ckm*T**2
234
+ cap_psi_mk=np.exp(-a_mk/T)
235
+ cap_psi_km=np.exp(-a_km/T)
236
+
237
+ sum_m_1+=cap_theta_m*cap_psi_mk
238
+
239
+
240
+ sum_n_1=0
241
+ #==============================sum m 2:=========================
242
+ #sum n:
243
+ for n in range(len(unique_group_no)):
244
+ group_no_3=unique_group_no[n]
245
+ cap_theta_n=self.cap_theta(group_no_3)
246
+ # convert subgroup number to main group number for interaction parameters
247
+ mg3 = self.get_main_group(group_no_3) #n
248
+
249
+ if mg2==mg3:
250
+ a_nm=0
251
+ else:
252
+ try:
253
+ # have to do try except as ij is in same row as ji and does not have thei own row
254
+ try:
255
+ index=df_ip.index[(df_ip["i"] == mg3) & (df_ip["j"] == mg2)][0]
256
+ Anm,Bnm,Cnm=df_ip.loc[index,"Aij"],df_ip.loc[index,"Bij"],df_ip.loc[index,"Cij"]
257
+
258
+ except:
259
+ index=df_ip.index[(df_ip["i"] == mg2) & (df_ip["j"] == mg3)][0]
260
+ Anm,Bnm,Cnm=df_ip.loc[index,"Aji"],df_ip.loc[index,"Bji"],df_ip.loc[index,"Cji"]
261
+
262
+ except:
263
+ raise Exception(f"Interaction parameters not found for groups {mg2} and {mg3}") from None
264
+ #print(f"Interaction parameters not found for groups {mg2} and {mg3}")
265
+ #return None
266
+ a_nm=Anm+Bnm*T+Cnm*T**2
267
+ cap_psi_nm=np.exp(-a_nm/(T))
268
+
269
+ sum_n_1+=cap_theta_n*cap_psi_nm
270
+
271
+ sum_m_2+=cap_theta_m*cap_psi_km/sum_n_1
272
+
273
+ ln_kap_gamma_k=cap_q_k*(1-np.log(sum_m_1)-sum_m_2)
274
+ return ln_kap_gamma_k
275
+
276
+ #=======================================ln gamma_k^i=========================================
277
+ def ln_cap_gamma_i(self,group_no_1,species_no):
278
+ T=self.T
279
+ list_groups=self.list_groups
280
+
281
+ index = df_sv.index[df_sv["No."] == group_no_1][0]
282
+ cap_q_k=df_sv.loc[index,"Q"] #get from group contribution table
283
+
284
+ df=list_groups[species_no]
285
+
286
+ sum_m_1=0
287
+ sum_m_2=0
288
+ #==========================sum m 1:===========================
289
+ for m in range(len(df)): # sum over all gropues in molecule
290
+
291
+ group_no_2=df.loc[m,"Group_No"]
292
+
293
+ cap_theta_m_i=self.cap_theta_i(group_no_2,species_no)
294
+
295
+ # convert subgroup number to main group number for interaction parameters
296
+ mg1 = self.get_main_group(group_no_1)
297
+ mg2 = self.get_main_group(group_no_2)
298
+
299
+ if mg1==mg2:
300
+ a_mk,a_km=0,0 # self interaction
301
+ else:
302
+ # have to do try except as ij is in same row as ji and does not have thei own row
303
+ try:
304
+ try:
305
+ index=df_ip.index[(df_ip["i"] == mg2) & (df_ip["j"] == mg1)][0]
306
+ Amk,Bmk,Cmk=df_ip.loc[index,"Aij"],df_ip.loc[index,"Bij"],df_ip.loc[index,"Cij"]
307
+ Akm,Bkm,Ckm=df_ip.loc[index,"Aji"],df_ip.loc[index,"Bji"],df_ip.loc[index,"Cji"]
308
+ except:
309
+ index=df_ip.index[(df_ip["i"] == mg1) & (df_ip["j"] == mg2)][0]
310
+ Amk,Bmk,Cmk=df_ip.loc[index,"Aij"],df_ip.loc[index,"Bij"],df_ip.loc[index,"Cij"]
311
+ Akm,Bkm,Ckm=df_ip.loc[index,"Aji"],df_ip.loc[index,"Bji"],df_ip.loc[index,"Cji"]
312
+ except:
313
+ raise Exception(f"Interaction parameters not found for groups {mg1} and {mg2}") from None
314
+ #print(f"Interaction parameters not found for groups {mg1} and {mg2}")
315
+ #return None
316
+ a_mk=Amk+Bmk*T+Cmk*T**2
317
+ a_km=Akm+Bkm*T+Ckm*T**2
318
+ cap_psi_mk=np.exp(-a_mk/T)
319
+ cap_psi_km=np.exp(-a_km/T)
320
+
321
+ sum_m_1+=cap_theta_m_i*cap_psi_mk
322
+
323
+
324
+ sum_n_1=0
325
+ #==============================sum m 2:=========================
326
+ #sum n:
327
+ for n in range(len(df)): # sum over all grupes in molecule
328
+ group_no_3=df.loc[n,"Group_No"]
329
+ cap_theta_n_i=self.cap_theta_i(group_no_3,species_no)
330
+
331
+ # convert subgroup number to main group number for interaction parameters
332
+ mg3 = self.get_main_group(group_no_3)
333
+ if mg2==mg3:
334
+ a_nm=0
335
+ else:
336
+ try:
337
+ # have to do try except as ij is in same row as ji and does not have thei own row
338
+ try:
339
+ index=df_ip.index[(df_ip["i"] == mg3) & (df_ip["j"] == mg2)][0]
340
+ Anm,Bnm,Cnm=df_ip.loc[index,"Aij"],df_ip.loc[index,"Bij"],df_ip.loc[index,"Cij"]
341
+ except:
342
+ index=df_ip.index[(df_ip["i"] == mg2) & (df_ip["j"] == mg3)][0]
343
+ Anm,Bnm,Cnm=df_ip.loc[index,"Aji"],df_ip.loc[index,"Bji"],df_ip.loc[index,"Cji"]
344
+ except:
345
+ raise Exception(f"Interaction parameters not found for groups {mg2} and {mg3}") from None
346
+ #print(f"Interaction parameters not found for groups {mg2} and {mg3}")
347
+ #return None
348
+ a_nm=Anm+Bnm*T+Cnm*T**2
349
+ cap_psi_nm=np.exp(-a_nm/(T))
350
+
351
+ sum_n_1+=cap_theta_n_i*cap_psi_nm
352
+
353
+ sum_m_2+=cap_theta_m_i*cap_psi_km/sum_n_1
354
+
355
+ ln_kap_gamma_k_i=cap_q_k*(1-np.log(sum_m_1)-sum_m_2)
356
+ return ln_kap_gamma_k_i
357
+
358
+ #=======================================residual=============================================
359
+ def ln_gamma_res_i(self,species_no):
360
+ list_groups=self.list_groups
361
+ df=list_groups[species_no]
362
+
363
+ sum_k=0
364
+
365
+ for k in range(len(df)): # sum over all gropupes present in the molecule i
366
+ group_no=df.loc[k,"Group_No"]
367
+
368
+ index2 = df.index[df["Group_No"] == group_no][0]
369
+ nu_m_i=df.loc[index2, "number"]
370
+
371
+
372
+ ln_cap_gamma_k_i=self.ln_cap_gamma_i(group_no,species_no)
373
+ ln_cap_gamma_k=self.ln_cap_gamma(group_no)
374
+ sum_k+=nu_m_i*(ln_cap_gamma_k-ln_cap_gamma_k_i)
375
+ return sum_k
376
+
377
+ #========================================combinatorial====================================0
378
+ def cap_phi(self,species_no):
379
+ mol_lst=self.mol_lst
380
+ list_groups=self.list_groups
381
+
382
+ # r_i
383
+ sum_j=0
384
+ def r(species_no):
385
+ sum_k=0
386
+ df2=list_groups[species_no]
387
+ for k in range(len(df2)): #sum over number of groupes in molecule
388
+ #print(f"her1_{j}")
389
+ group_no=df2.loc[k,"Group_No"]
390
+ try:
391
+ index = df2.index[df2["Group_No"] == group_no][0]
392
+ except:
393
+ continue
394
+ nu_ik=df2.loc[index, "number"]
395
+
396
+ index2 = df_sv.index[df_sv["No."] == group_no][0]
397
+
398
+ cap_r_k=df_sv.loc[index2,"R"]
399
+
400
+ sum_k+=cap_r_k*nu_ik
401
+
402
+ r_i=sum_k
403
+ return r_i
404
+
405
+ for j in range(len(list_groups)): # sum over number of species in mixture
406
+
407
+ x_j=mol_lst[j]
408
+ #print(f"her1_{j}")
409
+ r_j=r(j)
410
+ #print(f"her2_{j}")
411
+ sum_j+=r_j*x_j
412
+
413
+ x_i=mol_lst[species_no]
414
+ r_i=r(species_no)
415
+
416
+ cap_phi_i=r_i*x_i/sum_j
417
+
418
+ return cap_phi_i
419
+
420
+ def phi_mark(self,species_no):
421
+ mol_lst=self.mol_lst
422
+ list_groups=self.list_groups
423
+ # r_i
424
+ sum_j=0
425
+ def r(species_no):
426
+ sum_k=0
427
+ df2=list_groups[species_no]
428
+ for k in range(len(df2)): #sum over number of groupes in molecule
429
+
430
+ group_no=df2.loc[k,"Group_No"]
431
+ index = df2.index[df2["Group_No"] == group_no][0]
432
+ nu_ik=df2.loc[index, "number"]
433
+
434
+ index2 = df_sv.index[df_sv["No."] == group_no][0]
435
+ cap_r_k=df_sv.loc[index2,"R"]
436
+ sum_k+=cap_r_k*nu_ik
437
+
438
+ r_i=sum_k
439
+ return r_i
440
+
441
+ for j in range(len(list_groups)): # sum over number of species in mixture
442
+ x_j=mol_lst[j]
443
+ r_j=r(j)
444
+
445
+ sum_j+=r_j**(3/4)*x_j
446
+
447
+ x_i=mol_lst[species_no]
448
+ r_i=r(species_no)
449
+
450
+ phi_i=r_i**(3/4)*x_i/sum_j
451
+ return phi_i
452
+
453
+ def q(self,species_no):
454
+ list_groups=self.list_groups
455
+ df2=list_groups[species_no]
456
+ sum_k=0
457
+ for k in range(len(df2)): #sum over number of groupes in molecule
458
+
459
+ group_no=df2.loc[k,"Group_No"]
460
+ index = df2.index[df2["Group_No"] == group_no][0]
461
+ nu_ik=df2.loc[index, "number"]
462
+
463
+ index2 = df_sv.index[df_sv["No."] == group_no][0]
464
+ cap_q_k=df_sv.loc[index2,"Q"]
465
+ sum_k+=cap_q_k*nu_ik
466
+
467
+ q_i=sum_k
468
+
469
+ return q_i
470
+
471
+ def theta(self,species_no):
472
+ list_groups=self.list_groups
473
+ mol_lst=self.mol_lst
474
+ # r_i
475
+ sum_j=0
476
+
477
+ for j in range(len(list_groups)): # sum over number of species in mixture
478
+ x_j=mol_lst[j]
479
+ q_j=self.q(j)
480
+
481
+ sum_j+=q_j*x_j
482
+
483
+ x_i=mol_lst[species_no]
484
+ q_i=self.q(species_no)
485
+
486
+ theta_i=q_i*x_i/sum_j
487
+ return theta_i
488
+ #gamma
489
+ def ln_gamma_comb_i(self,species_no):
490
+ mol_lst=self.mol_lst
491
+ z=10
492
+ #df=list_groups[species_no]
493
+ cap_phi_i = self.cap_phi(species_no) # volume fraction
494
+ phi_mark_i= self.phi_mark(species_no) # modified volume fraction
495
+ x_i= mol_lst[species_no] # molefraction
496
+ theta_i= self.theta(species_no) # surface area fraction
497
+ q_i=self.q(species_no)
498
+
499
+ ln_cap_gamma=np.log(phi_mark_i/x_i)+1-phi_mark_i/x_i-z/2*q_i*(np.log(cap_phi_i/theta_i)+1-cap_phi_i/theta_i)
500
+ return ln_cap_gamma
501
+
502
+ #=================================main=================================
503
+ def gamma_singular(self,species_no):
504
+ gamma_i=np.exp(self.ln_gamma_comb_i(species_no)+self.ln_gamma_res_i(species_no))
505
+ return gamma_i
506
+
507
+ def gamma_total(self):
508
+ mol_lst=self.mol_lst
509
+ lst=[]
510
+ for i in range(len(mol_lst)):
511
+ gamma_main_i=self.gamma_singular(i)
512
+ lst.append(gamma_main_i)
513
+ return lst
514
+
515
+
516
+ #=================================stability analysis=================================
517
+ def constrained_hessian(self):
518
+ h = 1e-5
519
+ R_gas = 8.314
520
+ N = len(self.mol_lst)
521
+ T = self.T
522
+ smiles_lst = self.smiles_lst
523
+ x_reduced0 = np.array(self.mol_lst[:-1]) # N-1 independent variables
524
+
525
+ def x_from_reduced(xr):
526
+ xN = 1.0 - np.sum(xr)
527
+ return np.concatenate([xr, [xN]])
528
+
529
+ def G_mix(xr):
530
+ x_vec = x_from_reduced(xr)
531
+ temp = Unifac_Dortmund(smiles_lst, list(x_vec), T)
532
+ gamma = np.array(temp.gamma_total())
533
+
534
+ ideal = R_gas * T * np.sum(x_vec * np.log(np.maximum(x_vec, 1e-300)))
535
+ excess = R_gas * T * np.sum(x_vec * np.log(gamma))
536
+ return ideal + excess
537
+
538
+ Nr = N - 1
539
+ H = np.zeros((Nr, Nr))
540
+ g0 = G_mix(x_reduced0)
541
+
542
+ for i in range(Nr):
543
+ xf = x_reduced0.copy(); xf[i] += h
544
+ xb = x_reduced0.copy(); xb[i] -= h
545
+ H[i, i] = (G_mix(xf) - 2*g0 + G_mix(xb)) / h**2
546
+
547
+ for j in range(i+1, Nr):
548
+ xpp = x_reduced0.copy(); xpp[i] += h; xpp[j] += h
549
+ xpm = x_reduced0.copy(); xpm[i] += h; xpm[j] -= h
550
+ xmp = x_reduced0.copy(); xmp[i] -= h; xmp[j] += h
551
+ xmm = x_reduced0.copy(); xmm[i] -= h; xmm[j] -= h
552
+ H[i, j] = (G_mix(xpp) - G_mix(xpm) - G_mix(xmp) + G_mix(xmm)) / (4*h**2)
553
+ H[j, i] = H[i, j]
554
+
555
+ return H
556
+
557
+ def is_stable_local(self):
558
+ hessian_m = self.constrained_hessian()
559
+ try:
560
+ np.linalg.cholesky(hessian_m) # checking for all eigenvalues positive
561
+ return True
562
+ except np.linalg.LinAlgError:
563
+ return False
564
+
565
+ def tpd(self, w):
566
+ w = np.array(w) * 1 / sum(w) # normalized molefraction
567
+ N = len(self.mol_lst)
568
+ z = self.mol_lst
569
+ gamma_z = np.array(self.gamma_total()) # gamma at feed composition
570
+ gamma_w = Unifac_Dortmund(self.smiles_lst, list(w), self.T).gamma_total()
571
+
572
+ sum_i = 0
573
+ for i in range(N):
574
+ w_i = w[i]
575
+ z_i = z[i]
576
+ sum_i += w_i * (np.log(w_i * gamma_w[i]) - np.log(z_i * gamma_z[i]))
577
+ return sum_i
578
+
579
+ def is_stable_global(self):
580
+ ss_iters = 10
581
+ N = len(self.mol_lst)
582
+ T = self.T
583
+ smiles_lst = self.smiles_lst
584
+ z = np.array(self.mol_lst)
585
+ gamma_z = np.array(self.gamma_total())
586
+ a = z * gamma_z # feed activities
587
+
588
+ def magnussen_guess():
589
+ if a.max() > 1:
590
+ return None, None
591
+
592
+ candidates, F_values = [], []
593
+
594
+ for j in range(N):
595
+ # infinite-dilution-like gammas: composition ~pure in component j
596
+ x_pure = np.full(N, 1e-6)
597
+ x_pure[j] = 1 - 1e-6 * (N - 1)
598
+ gamma_inf_j = np.array(Unifac_Dortmund(smiles_lst, list(x_pure), T).gamma_total())
599
+
600
+ x = a / gamma_inf_j
601
+ x = x / x.sum()
602
+
603
+ for _ in range(ss_iters):
604
+ gamma_i = np.array(Unifac_Dortmund(smiles_lst, list(x), T).gamma_total())
605
+ x = a / gamma_i
606
+ x = x / x.sum()
607
+
608
+ gamma_i = np.array(Unifac_Dortmund(smiles_lst, list(x), T).gamma_total())
609
+ S_j = np.sum(x * (np.log(x * gamma_i) - np.log(a)))
610
+ G_j = np.sum((x - z) * (np.log(x * gamma_i) - np.log(a)))
611
+ F_j = S_j if S_j < 0 else S_j - 0.5 * G_j
612
+
613
+ candidates.append(x)
614
+ F_values.append(F_j)
615
+
616
+ j_star = int(np.argmin(F_values))
617
+ return candidates[j_star], candidates
618
+
619
+ best_guess, all_guesses = magnussen_guess()
620
+ if best_guess is None:
621
+ print("a_max > 1 -> unstable by Magnussen shortcut")
622
+ return False
623
+
624
+ # unconstrained reparametrization: u = ln(w), normalized inside f
625
+ def tpd_u(u):
626
+ w = np.exp(u)
627
+ w = w / w.sum()
628
+ return self.tpd(w)
629
+
630
+ best = None
631
+ for x0 in all_guesses: # multistart over all N Magnussen trial phases
632
+ u0 = np.log(np.clip(x0, 1e-10, None))
633
+ res = minimize(tpd_u, u0, method='BFGS',
634
+ options={'gtol': 1e-8, 'maxiter': 200})
635
+ if best is None or res.fun < best:
636
+ best = res.fun
637
+
638
+ print(f"min TPD={round(best,5)}")
639
+ return bool(round(best, 8) >= 0)
640
+
641
+ def is_stable(self):
642
+ if self.is_stable_local():
643
+ return self.is_stable_global()
644
+ else:
645
+ return False
646
+
647
+
648
+
649
+
650
+
651
+
652
+
653
+
654
+
655
+
656
+
657
+
658
+
659
+
660
+
661
+
662
+
663
+
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: unifac
3
+ Version: 0.1.0
4
+ Summary: A Python implementation of the UNIFAC-Dortmund group contribution method
5
+ Author-email: Emil Kongsbach <emil.cheme@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Emil-Kongsbach/UNIFAC
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pandas
13
+ Requires-Dist: ugropy
14
+ Requires-Dist: openpyxl
15
+ Requires-Dist: scipy
16
+ Dynamic: license-file
17
+
18
+ # UNIFAC
19
+ An implementation of the group contribution method of the Dortmund modification of UNIFAC. Calculates the activity coeficients of species in a mixture based on functional group parameters
20
+
21
+ """
22
+ from unifac import Unifac_Dortmund
23
+
24
+ # Set system parameters
25
+ smiles_lst = ["CCO", "CC(=O)C","CC=CC"] # NA
26
+ x_lst=[1/3, 1/3, 1/3] # NA
27
+ T=298 # K
28
+
29
+ mixture=Unifac_Dortmund(smiles_lst,x_lst,T)
30
+
31
+ # Returns activity coefficient of component 0 in the mixture, according to the order in which the components were passed.
32
+ print(mixture.gamma_singular(0))
33
+
34
+ # Returns activity coefficient of all components in the mixture according to the order in which componenets were passed
35
+ print(mixture.gamma_total())
36
+
37
+ # stability analysis
38
+
39
+ mixture.is_stable_local() # computes local stability (spinodal)
40
+
41
+ mixture.is_stable_global() # computes global stability (binodal)
42
+
43
+
44
+ mixture.is_stable() # computes first local stability (cheap), and only if that is True it proceeds to calculate global stability (expensive)
45
+
46
+ """
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/unifac/__init__.py
5
+ src/unifac/groupe_surfaces_and_volumes.xlsx
6
+ src/unifac/interaction_parameters.xlsx
7
+ src/unifac/unifac_dortmund.py
8
+ src/unifac.egg-info/PKG-INFO
9
+ src/unifac.egg-info/SOURCES.txt
10
+ src/unifac.egg-info/dependency_links.txt
11
+ src/unifac.egg-info/requires.txt
12
+ src/unifac.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ numpy
2
+ pandas
3
+ ugropy
4
+ openpyxl
5
+ scipy
@@ -0,0 +1 @@
1
+ unifac