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,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