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.
- HalogenGroups/__init__.py +246 -0
- PFASGroups/ComponentsSolverModel.py +977 -0
- PFASGroups/HalogenGroupModel.py +810 -0
- PFASGroups/PFASDefinitionModel.py +393 -0
- PFASGroups/PFASEmbeddings.py +3315 -0
- PFASGroups/__init__.py +21 -0
- PFASGroups/cli.py +618 -0
- PFASGroups/core.py +415 -0
- PFASGroups/data/Halogen_groups_smarts.json +9024 -0
- PFASGroups/data/PFAS_definitions_smarts.json +170 -0
- PFASGroups/data/component_smarts.json +4 -0
- PFASGroups/data/component_smarts_halogens.json +142 -0
- PFASGroups/data/diatomic_bonds_dict.json +12802 -0
- PFASGroups/draw_mols.py +374 -0
- PFASGroups/embeddings.py +150 -0
- PFASGroups/fragmentation.py +548 -0
- PFASGroups/generate_homologues.py +256 -0
- PFASGroups/generate_mol.py +656 -0
- PFASGroups/generate_paper_figures.py +266 -0
- PFASGroups/getter.py +111 -0
- PFASGroups/homologue_series.py +473 -0
- PFASGroups/parser.py +942 -0
- PFASGroups/prioritise.py +439 -0
- pfasgroups-3.2.2.dist-info/METADATA +724 -0
- pfasgroups-3.2.2.dist-info/RECORD +28 -0
- pfasgroups-3.2.2.dist-info/WHEEL +5 -0
- pfasgroups-3.2.2.dist-info/entry_points.txt +3 -0
- pfasgroups-3.2.2.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from rdkit import Chem
|
|
2
|
+
from typing import List, Dict, Optional, Union
|
|
3
|
+
|
|
4
|
+
class PFASDefinition:
|
|
5
|
+
"""Model class representing a PFAS definition based on structural criteria.
|
|
6
|
+
|
|
7
|
+
A PFAS definition identifies molecules using SMARTS patterns and/or fluorine ratio thresholds.
|
|
8
|
+
Unlike HalogenGroup which focuses on specific functional groups, PFASDefinition provides
|
|
9
|
+
broader chemical definitions (e.g., "contains at least one perfluoroalkyl moiety").
|
|
10
|
+
|
|
11
|
+
Attributes
|
|
12
|
+
----------
|
|
13
|
+
id : int
|
|
14
|
+
Unique identifier for this PFAS definition
|
|
15
|
+
name : str
|
|
16
|
+
Human-readable name (e.g., "Per- and polyfluoroalkyl substances")
|
|
17
|
+
description : str
|
|
18
|
+
Detailed description of what this definition represents
|
|
19
|
+
fluorineRatio : Optional[float]
|
|
20
|
+
Minimum ratio of fluorine atoms required (None if not applicable)
|
|
21
|
+
smarts_strings : List[str]
|
|
22
|
+
Original SMARTS pattern strings for structural matching
|
|
23
|
+
smarts_patterns : List[Chem.Mol]
|
|
24
|
+
Compiled SMARTS molecule objects for efficient matching
|
|
25
|
+
includeHydrogen : bool
|
|
26
|
+
Whether to include hydrogen atoms in fluorine ratio calculations
|
|
27
|
+
requireBoth : bool
|
|
28
|
+
If True, requires both SMARTS match AND fluorine ratio.
|
|
29
|
+
If False, requires SMARTS match OR fluorine ratio.
|
|
30
|
+
|
|
31
|
+
Examples
|
|
32
|
+
--------
|
|
33
|
+
>>> # Definition requiring perfluoroalkyl chain OR high fluorine ratio
|
|
34
|
+
>>> pfas_def = PFASDefinition(
|
|
35
|
+
... id=1,
|
|
36
|
+
... name="PFAS (OECD definition)",
|
|
37
|
+
... smarts=["[CX4][CX4]([F])([F])[F]"],
|
|
38
|
+
... fluorineRatio=0.4,
|
|
39
|
+
... description="Contains perfluoroalkyl moiety with ≥2 carbons",
|
|
40
|
+
... requireBoth=False
|
|
41
|
+
... )
|
|
42
|
+
"""
|
|
43
|
+
def __init__(self, id: int, name: str, smarts: List[str], fluorineRatio: Optional[float], description: str, **kwargs):
|
|
44
|
+
self.id = id
|
|
45
|
+
self.name = name
|
|
46
|
+
self.description = description
|
|
47
|
+
self.fluorineRatio = fluorineRatio
|
|
48
|
+
self.smarts_strings = smarts
|
|
49
|
+
self.includeHydrogen = kwargs.get('includeHydrogen', True)
|
|
50
|
+
self.requireBoth = kwargs.get('requireBoth', False)
|
|
51
|
+
# Preload SMARTS patterns
|
|
52
|
+
self.smarts_patterns = []
|
|
53
|
+
for smarts_str in smarts:
|
|
54
|
+
try:
|
|
55
|
+
mol = Chem.MolFromSmarts(smarts_str)
|
|
56
|
+
if mol is not None:
|
|
57
|
+
mol.UpdatePropertyCache()
|
|
58
|
+
Chem.GetSymmSSSR(mol)
|
|
59
|
+
mol.GetRingInfo().NumRings()
|
|
60
|
+
self.smarts_patterns.append(mol)
|
|
61
|
+
except:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def __str__(self):
|
|
65
|
+
return self.name
|
|
66
|
+
|
|
67
|
+
def applies_to_molecule(self,
|
|
68
|
+
mol_or_smiles: Union[Chem.Mol, str],
|
|
69
|
+
formula: Optional[Dict[str, int]] = None,
|
|
70
|
+
**kwargs) -> bool:
|
|
71
|
+
"""Check if this PFAS definition applies to a given molecule.
|
|
72
|
+
|
|
73
|
+
This method evaluates whether a molecule meets the structural and/or compositional
|
|
74
|
+
criteria defined by this PFASDefinition. The evaluation logic depends on the
|
|
75
|
+
requireBoth flag:
|
|
76
|
+
|
|
77
|
+
- If requireBoth=False (default): Returns True if EITHER SMARTS matches OR
|
|
78
|
+
fluorine ratio is met (logical OR)
|
|
79
|
+
- If requireBoth=True: Returns True only if BOTH SMARTS matches AND fluorine
|
|
80
|
+
ratio are met (logical AND)
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
mol_or_smiles : Union[Chem.Mol, str]
|
|
85
|
+
Input molecule as RDKit Mol object or SMILES string
|
|
86
|
+
formula : Optional[Dict[str, int]], default=None
|
|
87
|
+
Pre-computed molecular formula as {element: count} dictionary.
|
|
88
|
+
If None, will be computed from the molecule.
|
|
89
|
+
**kwargs : dict
|
|
90
|
+
Additional parameters:
|
|
91
|
+
|
|
92
|
+
- include_hydrogen (bool): Whether to include H in fluorine ratio calculation.
|
|
93
|
+
Defaults to self.includeHydrogen
|
|
94
|
+
- require_both (bool): Override the instance's requireBoth setting
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
bool
|
|
99
|
+
True if the molecule meets the definition criteria, False otherwise
|
|
100
|
+
|
|
101
|
+
Examples
|
|
102
|
+
--------
|
|
103
|
+
>>> pfas_def = PFASDefinition(
|
|
104
|
+
... id=1, name="Test", smarts=["[CX4]F"],
|
|
105
|
+
... fluorineRatio=0.3, description="Test"
|
|
106
|
+
... )
|
|
107
|
+
>>> pfas_def.applies_to_molecule("FC(F)(F)C(F)(F)F") # PFOA-like
|
|
108
|
+
True
|
|
109
|
+
>>> pfas_def.applies_to_molecule("CCCCCC") # No fluorine
|
|
110
|
+
False
|
|
111
|
+
|
|
112
|
+
Notes
|
|
113
|
+
-----
|
|
114
|
+
- SMARTS patterns are checked using substructure matching (HasSubstructMatch)
|
|
115
|
+
- Fluorine ratio is calculated as: F_count / total_atom_count
|
|
116
|
+
- Invalid SMILES strings return False
|
|
117
|
+
"""
|
|
118
|
+
# Convert SMILES to Mol if needed
|
|
119
|
+
if isinstance(mol_or_smiles, str):
|
|
120
|
+
mol = Chem.MolFromSmiles(mol_or_smiles)
|
|
121
|
+
if mol is None:
|
|
122
|
+
return False
|
|
123
|
+
else:
|
|
124
|
+
mol = mol_or_smiles
|
|
125
|
+
|
|
126
|
+
# Check SMARTS matches
|
|
127
|
+
smarts_match = False
|
|
128
|
+
for pattern in self.smarts_patterns:
|
|
129
|
+
if mol.HasSubstructMatch(pattern):
|
|
130
|
+
smarts_match = True
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
# Check fluorine ratio if defined
|
|
134
|
+
ratio_match = True # Default to True if no ratio requirement
|
|
135
|
+
if self.fluorineRatio is not None:
|
|
136
|
+
if formula is None:
|
|
137
|
+
formula = self._compute_formula(mol, kwargs.get("include_hydrogen", self.includeHydrogen))
|
|
138
|
+
|
|
139
|
+
ratio_match = self._check_fluorine_ratio(formula, kwargs.get("include_hydrogen", self.includeHydrogen))
|
|
140
|
+
|
|
141
|
+
# Apply logic based on require_both
|
|
142
|
+
if kwargs.get("require_both", self.requireBoth):
|
|
143
|
+
return smarts_match and ratio_match
|
|
144
|
+
else:
|
|
145
|
+
# If no fluorine ratio is defined, only check SMARTS
|
|
146
|
+
if self.fluorineRatio is None:
|
|
147
|
+
return smarts_match
|
|
148
|
+
# Otherwise, SMARTS OR ratio
|
|
149
|
+
return smarts_match or ratio_match
|
|
150
|
+
|
|
151
|
+
def _compute_formula(self, mol: Chem.Mol, include_hydrogen: bool) -> Dict[str, int]:
|
|
152
|
+
"""Compute molecular formula as element count dictionary.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
mol : Chem.Mol
|
|
157
|
+
RDKit molecule object
|
|
158
|
+
include_hydrogen : bool
|
|
159
|
+
If True, add explicit hydrogens before counting
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
Dict[str, int]
|
|
164
|
+
Dictionary mapping element symbols to their counts, e.g. {'C': 8, 'F': 17, 'O': 2}
|
|
165
|
+
"""
|
|
166
|
+
formula = {}
|
|
167
|
+
|
|
168
|
+
# Add hydrogens if needed
|
|
169
|
+
if include_hydrogen:
|
|
170
|
+
mol = Chem.AddHs(mol)
|
|
171
|
+
|
|
172
|
+
for atom in mol.GetAtoms():
|
|
173
|
+
symbol = atom.GetSymbol()
|
|
174
|
+
formula[symbol] = formula.get(symbol, 0) + 1
|
|
175
|
+
|
|
176
|
+
return formula
|
|
177
|
+
|
|
178
|
+
def _check_fluorine_ratio(self, formula: Dict[str, int], include_hydrogen: bool) -> bool:
|
|
179
|
+
"""Check if the fluorine ratio in a molecular formula meets the threshold.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
formula : Dict[str, int]
|
|
184
|
+
Molecular formula as {element: count} dictionary
|
|
185
|
+
include_hydrogen : bool
|
|
186
|
+
If True, includes hydrogen atoms in total atom count.
|
|
187
|
+
If False, excludes hydrogen from total (heavy atoms only)
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
bool
|
|
192
|
+
True if (F_count / total_atoms) >= self.fluorineRatio, False otherwise
|
|
193
|
+
|
|
194
|
+
Notes
|
|
195
|
+
-----
|
|
196
|
+
- Returns False if total_atoms is 0
|
|
197
|
+
- Formula with no fluorine (F_count=0) will fail unless fluorineRatio=0
|
|
198
|
+
"""
|
|
199
|
+
f_count = formula.get('F', 0)
|
|
200
|
+
|
|
201
|
+
if include_hydrogen:
|
|
202
|
+
total_atoms = sum(formula.values())
|
|
203
|
+
else:
|
|
204
|
+
total_atoms = sum(v for k, v in formula.items() if k != 'H')
|
|
205
|
+
|
|
206
|
+
if total_atoms == 0:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
ratio = f_count / total_atoms
|
|
210
|
+
return ratio >= self.fluorineRatio
|
|
211
|
+
|
|
212
|
+
def test(self, test_data=None):
|
|
213
|
+
"""Test this PFAS definition against test molecules from metadata.
|
|
214
|
+
|
|
215
|
+
Validates that the definition correctly classifies true positives, true negatives,
|
|
216
|
+
false positives, and false negatives based on test metadata in
|
|
217
|
+
PFAS_definitions_smarts.json.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
test_data : dict, optional
|
|
222
|
+
Test metadata dictionary. If None, will be loaded from the definition's
|
|
223
|
+
entry in PFAS_definitions_smarts.json. Expected keys: ``category``,
|
|
224
|
+
``examples`` (dict with keys ``true_positives``, ``true_negatives``,
|
|
225
|
+
``false_positives``, ``false_negatives`` each a list of dicts).
|
|
226
|
+
|
|
227
|
+
Returns
|
|
228
|
+
-------
|
|
229
|
+
dict
|
|
230
|
+
Test results with keys: ``passed`` (bool), ``total_tests`` (int),
|
|
231
|
+
``failures`` (list), ``category`` (str), ``stats`` (dict with
|
|
232
|
+
counts for true/false positives/negatives).
|
|
233
|
+
|
|
234
|
+
Notes
|
|
235
|
+
-----
|
|
236
|
+
- Tests against benchmark test compounds with known PFAS/non-PFAS labels
|
|
237
|
+
- Validates both SMARTS patterns and fluorine ratio criteria
|
|
238
|
+
- Returns detailed failure information for debugging
|
|
239
|
+
"""
|
|
240
|
+
from rdkit import Chem
|
|
241
|
+
|
|
242
|
+
# Load test data if not provided
|
|
243
|
+
if test_data is None:
|
|
244
|
+
import json
|
|
245
|
+
from pathlib import Path
|
|
246
|
+
definitions_file = Path(__file__).parent / 'data' / 'PFAS_definitions_smarts.json'
|
|
247
|
+
with open(definitions_file, 'r') as f:
|
|
248
|
+
all_definitions = json.load(f)
|
|
249
|
+
|
|
250
|
+
# Find this definition's test data
|
|
251
|
+
test_data = None
|
|
252
|
+
for def_data in all_definitions:
|
|
253
|
+
if def_data['id'] == self.id:
|
|
254
|
+
test_data = def_data.get('test', {})
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
if test_data is None or not test_data:
|
|
258
|
+
return {
|
|
259
|
+
'passed': None,
|
|
260
|
+
'total_tests': 0,
|
|
261
|
+
'failures': [],
|
|
262
|
+
'category': 'unknown',
|
|
263
|
+
'error': f'No test data found for definition {self.id}'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
results = {
|
|
267
|
+
'passed': True,
|
|
268
|
+
'total_tests': 0,
|
|
269
|
+
'failures': [],
|
|
270
|
+
'category': test_data.get('category', 'definition'),
|
|
271
|
+
'stats': {
|
|
272
|
+
'true_positives': 0,
|
|
273
|
+
'true_negatives': 0,
|
|
274
|
+
'false_positives': 0,
|
|
275
|
+
'false_negatives': 0
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
examples = test_data.get('examples', {})
|
|
280
|
+
|
|
281
|
+
# Test positives (should match)
|
|
282
|
+
for smiles in examples.get('positives', []):
|
|
283
|
+
results['total_tests'] += 1
|
|
284
|
+
try:
|
|
285
|
+
mol = Chem.MolFromSmiles(smiles)
|
|
286
|
+
if mol is None:
|
|
287
|
+
results['passed'] = False
|
|
288
|
+
results['failures'].append({
|
|
289
|
+
'smiles': smiles,
|
|
290
|
+
'expected': True,
|
|
291
|
+
'got': None,
|
|
292
|
+
'type': 'true_positive',
|
|
293
|
+
'error': 'Invalid SMILES'
|
|
294
|
+
})
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
# Check if definition applies
|
|
298
|
+
applies = self.applies_to_molecule(mol)
|
|
299
|
+
if applies:
|
|
300
|
+
results['stats']['true_positives'] += 1
|
|
301
|
+
else:
|
|
302
|
+
results['passed'] = False
|
|
303
|
+
results['stats']['false_negatives'] += 1
|
|
304
|
+
results['failures'].append({
|
|
305
|
+
'smiles': smiles,
|
|
306
|
+
'expected': True,
|
|
307
|
+
'got': False,
|
|
308
|
+
'type': 'true_positive',
|
|
309
|
+
'error': 'Definition should match but did not'
|
|
310
|
+
})
|
|
311
|
+
except Exception as e:
|
|
312
|
+
results['passed'] = False
|
|
313
|
+
results['failures'].append({
|
|
314
|
+
'smiles': smiles,
|
|
315
|
+
'expected': True,
|
|
316
|
+
'got': None,
|
|
317
|
+
'type': 'positive',
|
|
318
|
+
'error': f'Exception: {str(e)}'
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
# Test negatives (should NOT match)
|
|
322
|
+
for smiles in examples.get('negatives', []):
|
|
323
|
+
results['total_tests'] += 1
|
|
324
|
+
try:
|
|
325
|
+
mol = Chem.MolFromSmiles(smiles)
|
|
326
|
+
if mol is None:
|
|
327
|
+
results['passed'] = False
|
|
328
|
+
results['failures'].append({
|
|
329
|
+
'smiles': smiles,
|
|
330
|
+
'expected': False,
|
|
331
|
+
'got': None,
|
|
332
|
+
'type': 'true_negative',
|
|
333
|
+
'error': 'Invalid SMILES'
|
|
334
|
+
})
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
# Check if definition applies
|
|
338
|
+
applies = self.applies_to_molecule(mol)
|
|
339
|
+
if not applies:
|
|
340
|
+
results['stats']['true_negatives'] += 1
|
|
341
|
+
else:
|
|
342
|
+
results['passed'] = False
|
|
343
|
+
results['stats']['false_positives'] += 1
|
|
344
|
+
results['failures'].append({
|
|
345
|
+
'smiles': smiles,
|
|
346
|
+
'expected': False,
|
|
347
|
+
'got': True,
|
|
348
|
+
'type': 'true_negative',
|
|
349
|
+
'error': 'Definition should not match but did'
|
|
350
|
+
})
|
|
351
|
+
except Exception as e:
|
|
352
|
+
results['passed'] = False
|
|
353
|
+
results['failures'].append({
|
|
354
|
+
'smiles': smiles,
|
|
355
|
+
'expected': False,
|
|
356
|
+
'got': None,
|
|
357
|
+
'type': 'negative',
|
|
358
|
+
'error': f'Exception: {str(e)}'
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
# Test false positives (known to incorrectly match - document these)
|
|
362
|
+
for item in examples.get('false_positives', []):
|
|
363
|
+
smiles = item if isinstance(item, str) else item.get('smiles', '')
|
|
364
|
+
results['total_tests'] += 1
|
|
365
|
+
try:
|
|
366
|
+
mol = Chem.MolFromSmiles(smiles)
|
|
367
|
+
if mol is None:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
# These are expected to match (false positives)
|
|
371
|
+
applies = self.applies_to_molecule(mol)
|
|
372
|
+
if applies:
|
|
373
|
+
results['stats']['false_positives'] += 1
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# Test false negatives (known to incorrectly NOT match - document these)
|
|
378
|
+
for item in examples.get('false_negatives', []):
|
|
379
|
+
smiles = item if isinstance(item, str) else item.get('smiles', '')
|
|
380
|
+
results['total_tests'] += 1
|
|
381
|
+
try:
|
|
382
|
+
mol = Chem.MolFromSmiles(smiles)
|
|
383
|
+
if mol is None:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
# These are expected to NOT match (false negatives)
|
|
387
|
+
applies = self.applies_to_molecule(mol)
|
|
388
|
+
if not applies:
|
|
389
|
+
results['stats']['false_negatives'] += 1
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
return results
|