PFASGroups 3.2.2__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.
@@ -0,0 +1,548 @@
1
+ """
2
+ Fragmentation utilities for HalogenGroups.
3
+
4
+ This module provides functions for fragmenting molecules based on bond dissociation energies.
5
+ """
6
+
7
+ import numpy as np
8
+ from rdkit import Chem
9
+ from rdkit.Chem.rdMolDescriptors import CalcMolFormula
10
+ from rdkit.Chem.Descriptors import ExactMolWt
11
+ import json
12
+ import os
13
+ from itertools import combinations
14
+
15
+ def keysToInt(x):
16
+ """Convert string keys to integers in a dictionary"""
17
+ return {int(k): v for k, v in x.items()}
18
+
19
+ def yield_scheme(name):
20
+ """Yields SMARTS for chains"""
21
+ if name == 'BDE':
22
+ # For standalone use, we would need to provide the bond dissociation energy data
23
+ # This is a simplified version that creates a basic BDE dictionary
24
+ # In a full implementation, this would load from a data file
25
+ data = {
26
+ 6: {6: 346, 1: 414, 9: 485, 17: 339, 35: 285}, # Carbon bonds
27
+ 1: {6: 414, 1: 436, 9: 568, 17: 431, 35: 366}, # Hydrogen bonds
28
+ 9: {6: 485, 1: 568, 9: 159}, # Fluorine bonds
29
+ 17: {6: 339, 1: 431, 17: 243}, # Chlorine bonds
30
+ 35: {6: 285, 1: 366, 35: 193}, # Bromine bonds
31
+ }
32
+ # Make symmetric
33
+ for atom1 in data:
34
+ for atom2 in data[atom1]:
35
+ if atom2 not in data:
36
+ data[atom2] = {}
37
+ data[atom2][atom1] = data[atom1][atom2]
38
+ else:
39
+ raise ValueError(f"Yield Scheme not implemented for {name}")
40
+
41
+ def inner(func):
42
+ def wrapper(*args,**kwargs):
43
+ kwargs["bondWeights"] = data
44
+ return func(*args, **kwargs)
45
+ return wrapper
46
+ return inner
47
+
48
+ def get_fragments(mol, idx, all=False):
49
+ """Fragment a molecule by breaking specified bonds"""
50
+ frags = []
51
+ if all is True:
52
+ with Chem.RWMol(mol) as rwmol:
53
+ for b_idx in idx:
54
+ b = rwmol.GetBondWithIdx(b_idx)
55
+ rwmol.RemoveBond(b.GetBeginAtomIdx(), b.GetEndAtomIdx())
56
+ frags = frags + [x for x in Chem.GetMolFrags(rwmol, asMols=True)]
57
+ else:
58
+ for b_idx in idx:
59
+ with Chem.RWMol(mol) as rwmol:
60
+ b = rwmol.GetBondWithIdx(b_idx)
61
+ rwmol.RemoveBond(b.GetBeginAtomIdx(), b.GetEndAtomIdx())
62
+ frags = frags + [x for x in Chem.GetMolFrags(rwmol, asMols=True)]
63
+ return frags
64
+
65
+ @yield_scheme('BDE')
66
+ def generate_fragments(mol, bondWeights=None, nb_breakingpoints=5):
67
+ """Break molecule on weakest bonds using BDE"""
68
+ bonds = []
69
+ strengths = []
70
+ for i, bond in enumerate(mol.GetBonds()):
71
+ node1 = bond.GetBeginAtom().GetAtomicNum()
72
+ node2 = bond.GetEndAtom().GetAtomicNum()
73
+ try:
74
+ value = bondWeights[node1][node2]
75
+ except:
76
+ try:
77
+ value = bondWeights[node2][node1]
78
+ except:
79
+ value = 300 # Default bond strength
80
+ bonds.append(i)
81
+ strengths.append(value)
82
+
83
+ # Invert strengths for probability calculation (weaker bonds more likely to break)
84
+ inv_strengths = [1.0/s for s in strengths]
85
+ prob = np.array(inv_strengths) / np.sum(inv_strengths)
86
+
87
+ fragments = []
88
+ breaking_points_idx = np.random.choice(range(len(bonds)),
89
+ size=min(nb_breakingpoints, len(bonds)-1),
90
+ replace=False, p=prob)
91
+ frags = get_fragments(mol, [bonds[i] for i in breaking_points_idx])
92
+ fragments += frags
93
+ return fragments
94
+
95
+ def fragment(mol, bde_dict, max_depth=4, nb_breakingpoints=5):
96
+ """Fragment a molecule into smaller fragments by breaking the weakest bonds.
97
+ mol: rdkit.Chem.Mol object
98
+ bde_dict: dictionary with bond dissociation energies"""
99
+ if not isinstance(mol, Chem.Mol):
100
+ raise TypeError("Input mol must be an rdkit.Chem.Mol object")
101
+
102
+ fragments = []
103
+ process_fragments = [mol]
104
+
105
+ for i in range(max_depth):
106
+ next_fragments = []
107
+ for frag in process_fragments:
108
+ if len(frag.GetBonds()) > 1:
109
+ frags = generate_fragments(frag, bondWeights=bde_dict, nb_breakingpoints=nb_breakingpoints)
110
+ fragments = fragments + frags
111
+ next_fragments = next_fragments + frags
112
+ process_fragments = next_fragments
113
+
114
+ fragments_d = {}
115
+ for frag in fragments:
116
+ try:
117
+ inchi_key = Chem.MolToInchiKey(frag)
118
+ formula = CalcMolFormula(frag)
119
+ if inchi_key not in fragments_d:
120
+ fragments_d[inchi_key] = {}
121
+ if formula not in fragments_d[inchi_key]:
122
+ fragments_d[inchi_key][formula] = {'n': 0, 'mol': frag}
123
+ fragments_d[inchi_key][formula]['n'] += 1
124
+ except:
125
+ # Skip problematic fragments
126
+ continue
127
+
128
+ return fragments_d
129
+
130
+ def fragment_to_distribution(mol, bde_dict, max_depth=4, nb_breakingpoints=5):
131
+ """Fragment a molecule into smaller fragments and return mass spectrum-like distribution.
132
+ mol: rdkit.Chem.Mol object
133
+ bde_dict: dictionary with bond dissociation energies"""
134
+ if not isinstance(mol, Chem.Mol):
135
+ raise TypeError("Input mol must be an rdkit.Chem.Mol object")
136
+
137
+ fragments = fragment(mol, bde_dict, max_depth=max_depth, nb_breakingpoints=nb_breakingpoints)
138
+ intensities = {}
139
+
140
+ for key, value in fragments.items():
141
+ for formula, data in value.items():
142
+ try:
143
+ molw = ExactMolWt(data['mol'])
144
+ if molw not in intensities:
145
+ intensities[molw] = 0
146
+ intensities[molw] += data['n']
147
+ except:
148
+ continue
149
+
150
+ mz = list(intensities.keys())
151
+ intensity = list(intensities.values())
152
+
153
+ if len(intensity) > 0:
154
+ intensity_distribution = np.array(intensity) / np.sum(intensity)
155
+ sorted_indices = np.argsort(mz)
156
+ mz = np.array(mz)[sorted_indices]
157
+ intensity_distribution = intensity_distribution[sorted_indices]
158
+ return mz, intensity_distribution
159
+ else:
160
+ return np.array([]), np.array([])
161
+
162
+
163
+ def find_fluorinated_chains(mol, componentSmartsName='Perfluoroalkyl'):
164
+ """
165
+ Find fluorinated chains in a molecule using SMARTS patterns.
166
+ Returns atom indices that are part of fluorinated chains.
167
+ """
168
+ # Load SMARTS paths from component_smarts.json
169
+ MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
170
+ DATA_DIR = os.path.join(MODULE_DIR, 'data')
171
+ COMPONENTS_SMARTS_FILE = os.path.join(DATA_DIR, 'component_smarts.json')
172
+
173
+ try:
174
+ with open(COMPONENTS_SMARTS_FILE, 'r') as f:
175
+ component_smarts = json.load(f)
176
+ except FileNotFoundError:
177
+ # Fallback SMARTS patterns if file not found
178
+ component_smarts = {
179
+ 'Perfluoroalkyl': '[#6]([#9])([#9])[#6]([#9])([#9])',
180
+ 'Perfluoroalkyl_end': '[#6]([#9])([#9])([#6,#9,#17,#35,#53])',
181
+ 'Polyfluoroalkyl': '[#6]([#9,#1,#17,#35,#53])([#9,#1,#17,#35,#53])[#6]([#9,#1,#17,#35,#53])([#9,#1,#17,#35,#53])',
182
+ 'Polyfluoroalkyl_end': '[#6]([#9,#1,#17,#35,#53])([#9,#1,#17,#35,#53])([#6,#9,#1,#17,#35,#53])'
183
+ }
184
+
185
+ # Get SMARTS patterns
186
+ path_smarts = component_smarts.get(componentSmartsName)
187
+ end_smarts = component_smarts.get(componentSmartsName + '_end')
188
+
189
+ if not path_smarts or not end_smarts:
190
+ return set()
191
+
192
+ # Convert SMARTS to molecules
193
+ try:
194
+ path_mol = Chem.MolFromSmarts(path_smarts)
195
+ end_mol = Chem.MolFromSmarts(end_smarts)
196
+ except:
197
+ return set()
198
+
199
+ if not path_mol or not end_mol:
200
+ return set()
201
+
202
+ # Find all atoms that are part of fluorinated chains
203
+ fluorinated_atoms = set()
204
+
205
+ # Get all matches for path and end patterns
206
+ path_matches = mol.GetSubstructMatches(path_mol)
207
+ end_matches = mol.GetSubstructMatches(end_mol)
208
+
209
+ # Add all atoms from path matches
210
+ for match in path_matches:
211
+ fluorinated_atoms.update(match)
212
+
213
+ # Add all atoms from end matches
214
+ for match in end_matches:
215
+ fluorinated_atoms.update(match)
216
+
217
+ return fluorinated_atoms
218
+
219
+
220
+ def get_non_fluorinated_bonds(mol, fluorinated_atoms=None, componentSmartsName='Perfluoroalkyl'):
221
+ """
222
+ Get bond indices that are not part of the fluorinated chain.
223
+
224
+ Args:
225
+ mol: RDKit molecule object
226
+ fluorinated_atoms: Set of atom indices that are part of fluorinated chains
227
+ componentSmartsName: Name of the SMARTS pattern to use for identifying fluorinated chains
228
+
229
+ Returns:
230
+ List of bond indices that are not in the fluorinated chain
231
+ """
232
+ if fluorinated_atoms is None:
233
+ fluorinated_atoms = find_fluorinated_chains(mol, componentSmartsName)
234
+
235
+ non_fluorinated_bonds = []
236
+
237
+ for bond in mol.GetBonds():
238
+ begin_idx = bond.GetBeginAtomIdx()
239
+ end_idx = bond.GetEndAtomIdx()
240
+
241
+ # If both atoms are in the fluorinated chain, keep the bond intact
242
+ if begin_idx in fluorinated_atoms and end_idx in fluorinated_atoms:
243
+ continue
244
+
245
+ # If bond connects fluorinated chain to other parts, it can be broken
246
+ # If bond is between non-fluorinated atoms, it can be broken
247
+ non_fluorinated_bonds.append(bond.GetIdx())
248
+
249
+ return non_fluorinated_bonds
250
+
251
+
252
+ def get_bonds_connected_to_fluorinated_path(mol, fluorinated_atoms=None, componentSmartsName='Perfluoroalkyl'):
253
+ """
254
+ Get bond indices that are connected to (but not part of) the fluorinated path.
255
+
256
+ Args:
257
+ mol: RDKit molecule object
258
+ fluorinated_atoms: Set of atom indices that are part of fluorinated chains
259
+ componentSmartsName: Name of the SMARTS pattern to use for identifying fluorinated chains
260
+
261
+ Returns:
262
+ List of bond indices that are connected to but not part of the fluorinated chain
263
+ """
264
+ if fluorinated_atoms is None:
265
+ fluorinated_atoms = find_fluorinated_chains(mol, componentSmartsName)
266
+
267
+ connected_bonds = []
268
+
269
+ for bond in mol.GetBonds():
270
+ begin_idx = bond.GetBeginAtomIdx()
271
+ end_idx = bond.GetEndAtomIdx()
272
+
273
+ # Skip bonds that are entirely within the fluorinated chain
274
+ if begin_idx in fluorinated_atoms and end_idx in fluorinated_atoms:
275
+ continue
276
+
277
+ # Only include bonds where exactly one atom is in the fluorinated chain
278
+ # This ensures we're breaking bonds that connect the fluorinated path to other parts
279
+ if (begin_idx in fluorinated_atoms and end_idx not in fluorinated_atoms) or \
280
+ (begin_idx not in fluorinated_atoms and end_idx in fluorinated_atoms):
281
+ connected_bonds.append(bond.GetIdx())
282
+
283
+ return connected_bonds
284
+
285
+
286
+ def generate_degradation_products(mol, componentSmartsName='Perfluoroalkyl', max_breaks=3,
287
+ min_fragment_size=2, include_original=True):
288
+ """
289
+ Generate degradation products by breaking bonds that are connected to but not part of the fluorinated chain.
290
+
291
+ Args:
292
+ mol: RDKit molecule object
293
+ componentSmartsName: Name of the SMARTS pattern to use for identifying fluorinated chains
294
+ max_breaks: Maximum number of bonds to break simultaneously
295
+ min_fragment_size: Minimum number of atoms in a fragment to keep it
296
+ include_original: Whether to include the original molecule in results
297
+
298
+ Returns:
299
+ Dictionary with InChI keys mapping to fragment information
300
+ """
301
+ if not isinstance(mol, Chem.Mol):
302
+ raise TypeError("Input mol must be an rdkit.Chem.Mol object")
303
+
304
+ # Find fluorinated chain atoms
305
+ fluorinated_atoms = find_fluorinated_chains(mol, componentSmartsName)
306
+
307
+ # Get bonds that can be broken (connected to but not part of fluorinated chain)
308
+ breakable_bonds = get_bonds_connected_to_fluorinated_path(mol, fluorinated_atoms, componentSmartsName)
309
+
310
+ if not breakable_bonds:
311
+ # No breakable bonds found, return original molecule
312
+ if include_original:
313
+ inchi_key = Chem.MolToInchiKey(mol)
314
+ formula = CalcMolFormula(mol)
315
+ return {inchi_key: {formula: {'mol': mol, 'n_breaks': 0, 'broken_bonds': []}}}
316
+ else:
317
+ return {}
318
+
319
+ degradation_products = {}
320
+
321
+ # Include original molecule if requested
322
+ if include_original:
323
+ try:
324
+ inchi_key = Chem.MolToInchiKey(mol)
325
+ formula = CalcMolFormula(mol)
326
+ degradation_products[inchi_key] = {
327
+ formula: {'mol': mol, 'n_breaks': 0, 'broken_bonds': []}
328
+ }
329
+ except:
330
+ pass
331
+
332
+ # Generate all possible combinations of bond breaks
333
+ for n_breaks in range(1, min(max_breaks + 1, len(breakable_bonds) + 1)):
334
+ for bond_combination in combinations(breakable_bonds, n_breaks):
335
+ try:
336
+ # Break the bonds
337
+ rwmol = Chem.RWMol(mol)
338
+
339
+ # Sort bonds in descending order to avoid index shifting issues
340
+ sorted_bonds = sorted(bond_combination, reverse=True)
341
+
342
+ for bond_idx in sorted_bonds:
343
+ bond = rwmol.GetBondWithIdx(bond_idx)
344
+ rwmol.RemoveBond(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx())
345
+
346
+ # Get fragments
347
+ fragments = Chem.GetMolFrags(rwmol, asMols=True, sanitizeFrags=True)
348
+
349
+ # Process each fragment
350
+ for frag in fragments:
351
+ # Skip fragments that are too small
352
+ if frag.GetNumAtoms() < min_fragment_size:
353
+ continue
354
+
355
+ try:
356
+ inchi_key = Chem.MolToInchiKey(frag)
357
+ formula = CalcMolFormula(frag)
358
+
359
+ if inchi_key not in degradation_products:
360
+ degradation_products[inchi_key] = {}
361
+
362
+ if formula not in degradation_products[inchi_key]:
363
+ degradation_products[inchi_key][formula] = {
364
+ 'mol': frag,
365
+ 'n_breaks': n_breaks,
366
+ 'broken_bonds': list(bond_combination)
367
+ }
368
+ except:
369
+ # Skip problematic fragments
370
+ continue
371
+
372
+ except Exception as e:
373
+ # Skip problematic bond breaking combinations
374
+ continue
375
+
376
+ return degradation_products
377
+
378
+
379
+ def generate_systematic_degradation_products(mol, componentSmartsName='Perfluoroalkyl',
380
+ preserve_fluorinated_chain=True,
381
+ min_fragment_size=2,
382
+ max_combinations=1000):
383
+ """
384
+ Generate systematic degradation products by exploring all possible bond breaking patterns.
385
+
386
+ Args:
387
+ mol: RDKit molecule object
388
+ componentSmartsName: Name of the SMARTS pattern to use for identifying fluorinated chains
389
+ preserve_fluorinated_chain: If True, only break bonds connected to (not part of) fluorinated chain
390
+ min_fragment_size: Minimum number of atoms in a fragment to keep it
391
+ max_combinations: Maximum number of bond combinations to explore
392
+
393
+ Returns:
394
+ Dictionary with degradation products organized by number of breaks
395
+ """
396
+ if not isinstance(mol, Chem.Mol):
397
+ raise TypeError("Input mol must be an rdkit.Chem.Mol object")
398
+
399
+ # Find fluorinated chain atoms
400
+ fluorinated_atoms = find_fluorinated_chains(mol, componentSmartsName)
401
+
402
+ # Get all bonds in the molecule
403
+ all_bonds = list(range(mol.GetNumBonds()))
404
+
405
+ if preserve_fluorinated_chain:
406
+ # Only consider bonds connected to but not part of the fluorinated chain
407
+ breakable_bonds = get_bonds_connected_to_fluorinated_path(mol, fluorinated_atoms, componentSmartsName)
408
+ else:
409
+ # Consider all bonds
410
+ breakable_bonds = all_bonds
411
+
412
+ if not breakable_bonds:
413
+ return {"0_breaks": {Chem.MolToInchiKey(mol): {CalcMolFormula(mol): mol}}}
414
+
415
+ degradation_products = {}
416
+ combinations_explored = 0
417
+
418
+ # Explore different numbers of breaks
419
+ for n_breaks in range(len(breakable_bonds) + 1):
420
+ degradation_products[f"{n_breaks}_breaks"] = {}
421
+
422
+ if n_breaks == 0:
423
+ # Original molecule
424
+ try:
425
+ inchi_key = Chem.MolToInchiKey(mol)
426
+ formula = CalcMolFormula(mol)
427
+ degradation_products[f"{n_breaks}_breaks"][inchi_key] = {formula: mol}
428
+ except:
429
+ pass
430
+ continue
431
+
432
+ # Generate combinations of bonds to break
433
+ for bond_combination in combinations(breakable_bonds, n_breaks):
434
+ if combinations_explored >= max_combinations:
435
+ break
436
+
437
+ combinations_explored += 1
438
+
439
+ try:
440
+ # Create a copy and break the bonds
441
+ rwmol = Chem.RWMol(mol)
442
+
443
+ # Sort bonds in descending order to avoid index shifting
444
+ sorted_bonds = sorted(bond_combination, reverse=True)
445
+
446
+ for bond_idx in sorted_bonds:
447
+ bond = rwmol.GetBondWithIdx(bond_idx)
448
+ rwmol.RemoveBond(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx())
449
+
450
+ # Get fragments
451
+ fragments = Chem.GetMolFrags(rwmol, asMols=True, sanitizeFrags=True)
452
+
453
+ # Store each fragment
454
+ for frag in fragments:
455
+ if frag.GetNumAtoms() < min_fragment_size:
456
+ continue
457
+
458
+ try:
459
+ inchi_key = Chem.MolToInchiKey(frag)
460
+ formula = CalcMolFormula(frag)
461
+
462
+ if inchi_key not in degradation_products[f"{n_breaks}_breaks"]:
463
+ degradation_products[f"{n_breaks}_breaks"][inchi_key] = {}
464
+
465
+ degradation_products[f"{n_breaks}_breaks"][inchi_key][formula] = frag
466
+
467
+ except:
468
+ continue
469
+
470
+ except:
471
+ continue
472
+
473
+ if combinations_explored >= max_combinations:
474
+ break
475
+
476
+ return degradation_products
477
+
478
+
479
+ def analyse_degradation_pathways(mol, componentSmartsName='Perfluoroalkyl', max_breaks=3):
480
+ """
481
+ Analyse possible degradation pathways and return detailed information about fragments.
482
+
483
+ Args:
484
+ mol: RDKit molecule object
485
+ componentSmartsName: Name of the SMARTS pattern to use for identifying fluorinated chains
486
+ max_breaks: Maximum number of bonds to break
487
+
488
+ Returns:
489
+ Dictionary with detailed pathway analysis
490
+ """
491
+ if not isinstance(mol, Chem.Mol):
492
+ raise TypeError("Input mol must be an rdkit.Chem.Mol object")
493
+
494
+ # Get degradation products
495
+ degradation_products = generate_degradation_products(
496
+ mol, componentSmartsName=componentSmartsName, max_breaks=max_breaks
497
+ )
498
+
499
+ # Analyze the results
500
+ analysis = {
501
+ 'original_molecule': {
502
+ 'smiles': Chem.MolToSmiles(mol),
503
+ 'formula': CalcMolFormula(mol),
504
+ 'molecular_weight': ExactMolWt(mol),
505
+ 'fluorinated_atoms': len(find_fluorinated_chains(mol, componentSmartsName))
506
+ },
507
+ 'degradation_summary': {
508
+ 'total_products': len(degradation_products),
509
+ 'products_by_breaks': {}
510
+ },
511
+ 'products': []
512
+ }
513
+
514
+ # Organize products by number of breaks
515
+ for inchi_key, formulas in degradation_products.items():
516
+ for formula, data in formulas.items():
517
+ frag_mol = data['mol']
518
+ n_breaks = data['n_breaks']
519
+
520
+ if n_breaks not in analysis['degradation_summary']['products_by_breaks']:
521
+ analysis['degradation_summary']['products_by_breaks'][n_breaks] = 0
522
+ analysis['degradation_summary']['products_by_breaks'][n_breaks] += 1
523
+
524
+ # Analyze fragment
525
+ frag_fluorinated_atoms = find_fluorinated_chains(frag_mol, componentSmartsName)
526
+
527
+ product_info = {
528
+ 'inchi_key': inchi_key,
529
+ 'smiles': Chem.MolToSmiles(frag_mol),
530
+ 'formula': formula,
531
+ 'molecular_weight': ExactMolWt(frag_mol),
532
+ 'num_atoms': frag_mol.GetNumAtoms(),
533
+ 'num_bonds': frag_mol.GetNumBonds(),
534
+ 'fluorinated_atoms': len(frag_fluorinated_atoms),
535
+ 'contains_fluorinated_chain': len(frag_fluorinated_atoms) > 0,
536
+ 'n_breaks': n_breaks,
537
+ 'broken_bonds': data['broken_bonds']
538
+ }
539
+
540
+ analysis['products'].append(product_info)
541
+
542
+ # Sort products by number of breaks and molecular weight
543
+ analysis['products'].sort(key=lambda x: (x['n_breaks'], x['molecular_weight']))
544
+
545
+ return analysis
546
+
547
+
548
+ analyze_degradation_pathways = analyse_degradation_pathways # US alias