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
PFASGroups/parser.py
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HalogenGroups core functions.
|
|
3
|
+
|
|
4
|
+
This module provides functions for parsing and plotting halogen groups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import functools
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from rdkit import Chem
|
|
12
|
+
from rdkit.Chem.rdMolDescriptors import CalcMolFormula
|
|
13
|
+
|
|
14
|
+
from .HalogenGroupModel import HalogenGroup
|
|
15
|
+
from .PFASDefinitionModel import PFASDefinition
|
|
16
|
+
from .ComponentsSolverModel import ComponentsSolver
|
|
17
|
+
from .core import (
|
|
18
|
+
fragment_until_valence_is_correct,
|
|
19
|
+
n_from_formula,
|
|
20
|
+
add_componentSmarts,
|
|
21
|
+
PFAS_DEFINITIONS_FILE,
|
|
22
|
+
HALOGEN_GROUPS_FILE,
|
|
23
|
+
rdkit_disable_log,
|
|
24
|
+
)
|
|
25
|
+
from .PFASEmbeddings import PFASEmbeddingSet
|
|
26
|
+
|
|
27
|
+
def preprocess_HalogenGroups(_pfg: list) -> tuple:
|
|
28
|
+
pfg = [HalogenGroup(**x) for x in _pfg if x.get('compute',True)]
|
|
29
|
+
agg_pfg = [HalogenGroup(**x) for x in _pfg if not x.get('compute',True)]
|
|
30
|
+
# list of HalogenGroup names
|
|
31
|
+
pfg_names = {pf.name:pf.id for pf in pfg}
|
|
32
|
+
# list groups aggregated by groups with compute=FALSE
|
|
33
|
+
agg_pfg = {ppf:list(map(pfg_names.get,list(filter(ppf.re_search.search,pfg_names.keys())))) for ppf in agg_pfg}
|
|
34
|
+
return pfg, agg_pfg
|
|
35
|
+
|
|
36
|
+
# --- Load halogen groups from PFAS_groups_smarts.json ---
|
|
37
|
+
def load_HalogenGroups():
|
|
38
|
+
"""
|
|
39
|
+
Adds default HalogenGroups to function
|
|
40
|
+
"""
|
|
41
|
+
with open(HALOGEN_GROUPS_FILE,'r') as f:
|
|
42
|
+
_pfg = json.load(f)
|
|
43
|
+
pfg, agg_pfg = preprocess_HalogenGroups(_pfg)
|
|
44
|
+
def inner(func):
|
|
45
|
+
@functools.wraps(func)
|
|
46
|
+
def wrapper(*args,**kwargs):
|
|
47
|
+
_halogens = kwargs.get('halogens', ['F','Cl','Br','I']) or ['F','Cl','Br','I']
|
|
48
|
+
_halogens_list = [_halogens] if isinstance(_halogens, str) else list(_halogens)
|
|
49
|
+
kwargs['pfas_groups'] = kwargs.get('pfas_groups',[p for p in pfg if p.excludeHalogens is None or any(h not in p.excludeHalogens for h in _halogens_list)])
|
|
50
|
+
kwargs['agg_pfas_groups'] = kwargs.get('agg_pfas_groups',agg_pfg)
|
|
51
|
+
return func(*args, **kwargs)
|
|
52
|
+
return wrapper
|
|
53
|
+
return inner
|
|
54
|
+
|
|
55
|
+
def load_PFASDefinitions():
|
|
56
|
+
"""
|
|
57
|
+
Adds default PFAS definitions to function
|
|
58
|
+
"""
|
|
59
|
+
with open(PFAS_DEFINITIONS_FILE,'r') as f:
|
|
60
|
+
pfg = json.load(f)
|
|
61
|
+
pfg = [PFASDefinition(**x) for x in pfg]
|
|
62
|
+
def inner(func):
|
|
63
|
+
@functools.wraps(func)
|
|
64
|
+
def wrapper(*args,**kwargs):
|
|
65
|
+
kwargs['pfas_definitions'] = kwargs.get('pfas_definitions',pfg)
|
|
66
|
+
return func(*args, **kwargs)
|
|
67
|
+
return wrapper
|
|
68
|
+
return inner
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_componentsSolver(**kwargs):
|
|
72
|
+
"""
|
|
73
|
+
Adds componentsSolver to function (creates it per call with the molecule)
|
|
74
|
+
"""
|
|
75
|
+
def inner(func):
|
|
76
|
+
@functools.wraps(func)
|
|
77
|
+
def wrapper(*args,**kwargs):
|
|
78
|
+
mol = args[0]
|
|
79
|
+
formula = kwargs.get("formula", CalcMolFormula(mol))
|
|
80
|
+
kwargs['formula'] = formula
|
|
81
|
+
# Add hydrogens to molecule before creating ComponentsSolver
|
|
82
|
+
# This ensures atom indices are consistent throughout the analysis
|
|
83
|
+
try:
|
|
84
|
+
mol = Chem.AddHs(mol)
|
|
85
|
+
Chem.SanitizeMol(mol)
|
|
86
|
+
except (Chem.AtomValenceException, Chem.KekulizeException):
|
|
87
|
+
#logger.debug("failed sanitisation, fragmenting")
|
|
88
|
+
frags = fragment_until_valence_is_correct(mol, [])
|
|
89
|
+
formulas = [n_from_formula(CalcMolFormula(frag)) for frag in frags]
|
|
90
|
+
kwargs.setdefault('fragmented_smiles', []).extend([Chem.MolToSmiles(mol)])
|
|
91
|
+
else:
|
|
92
|
+
frags = [mol]
|
|
93
|
+
formulas = [n_from_formula(kwargs['formula'])] # formula as a dictionary
|
|
94
|
+
args = list(args) # Convert to mutable list
|
|
95
|
+
args[0] = mol
|
|
96
|
+
kwargs['formulas'] = formulas
|
|
97
|
+
kwargs['frags'] = frags
|
|
98
|
+
args = tuple(args) # Convert back to tuple
|
|
99
|
+
halogens = kwargs.get('halogens', ['F','Cl','Br','I']) or ['F','Cl','Br','I']
|
|
100
|
+
_smarts = '[{}]'.format(','.join(halogens)) if isinstance(halogens, list) else f'[{halogens}]'
|
|
101
|
+
# check organic halogen:
|
|
102
|
+
if not mol.GetSubstructMatch(Chem.MolFromSmarts(_smarts)):
|
|
103
|
+
#logger.debug("No organic halogens found, skipping componentsSolver")
|
|
104
|
+
return [], mol, CalcMolFormula(mol)
|
|
105
|
+
# Pass through component metric options
|
|
106
|
+
solver_kwargs = {}
|
|
107
|
+
if 'limit_effective_graph_resistance' in kwargs:
|
|
108
|
+
solver_kwargs['limit_effective_graph_resistance'] = kwargs['limit_effective_graph_resistance']
|
|
109
|
+
if 'compute_component_metrics' in kwargs:
|
|
110
|
+
solver_kwargs['compute_component_metrics'] = kwargs['compute_component_metrics']
|
|
111
|
+
if 'resistance_weights' in kwargs:
|
|
112
|
+
solver_kwargs['resistance_weights'] = kwargs['resistance_weights']
|
|
113
|
+
if 'component_expansion' in kwargs:
|
|
114
|
+
solver_kwargs['component_expansion'] = kwargs['component_expansion']
|
|
115
|
+
with ComponentsSolver(mol, halogens=kwargs.get('halogens'), **solver_kwargs) as fluorinated_components_dict:
|
|
116
|
+
kwargs['fluorinated_components_dict'] = kwargs.get('fluorinated_components_dict',fluorinated_components_dict)
|
|
117
|
+
return func(*args, **kwargs)
|
|
118
|
+
return wrapper
|
|
119
|
+
return inner
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@load_PFASDefinitions()
|
|
123
|
+
def parse_definitions_in_mol(mol, **kwargs):
|
|
124
|
+
mol = Chem.AddHs(mol)
|
|
125
|
+
formula = kwargs.get("formula", CalcMolFormula(mol))
|
|
126
|
+
try:
|
|
127
|
+
Chem.SanitizeMol(mol)
|
|
128
|
+
except Chem.AtomValenceException:
|
|
129
|
+
#logger.debug("failed sanitisation, fragmenting")
|
|
130
|
+
frags = fragment_until_valence_is_correct(mol, [])
|
|
131
|
+
else:
|
|
132
|
+
frags = [mol]
|
|
133
|
+
definition_matches = []
|
|
134
|
+
pfas_definitions = kwargs.get('pfas_definitions')
|
|
135
|
+
for pdef in pfas_definitions:
|
|
136
|
+
matched = False
|
|
137
|
+
for mol in frags:
|
|
138
|
+
if pdef.applies_to_molecule(mol_or_smiles=mol, **kwargs) is True:
|
|
139
|
+
matched = True
|
|
140
|
+
break
|
|
141
|
+
if matched is True:
|
|
142
|
+
definition_matches.append(pdef)
|
|
143
|
+
return definition_matches
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- Main halogen group parsing functions ---
|
|
147
|
+
@add_componentSmarts()
|
|
148
|
+
@load_HalogenGroups()
|
|
149
|
+
@load_componentsSolver()
|
|
150
|
+
@rdkit_disable_log(level='warning')
|
|
151
|
+
def parse_groups_in_mol(mol, fluorinated_components_dict=None, pfas_groups = None, **kwargs):
|
|
152
|
+
"""Iterates over halogen groups and finds the ones that match the molecule.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
mol : rdkit.Chem.Mol
|
|
157
|
+
RDKit molecule object
|
|
158
|
+
**kwargs : dict
|
|
159
|
+
Additional parameters (formula, pfas_groups, componentSmartss, etc.)
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
list of tuples
|
|
164
|
+
List of (HalogenGroup, match_count, component_sizes, matched_components) tuples where:
|
|
165
|
+
- HalogenGroup: The matched halogen group object
|
|
166
|
+
- match_count: Number of times this group pattern was matched (int)
|
|
167
|
+
- component_sizes: List of carbon component sizes found (list of int)
|
|
168
|
+
- matched_components: Detailed information about matched components (list of dicts)
|
|
169
|
+
Each dict contains:
|
|
170
|
+
- 'component': list of atom indices
|
|
171
|
+
- 'size': number of atoms in component
|
|
172
|
+
- 'SMARTS': component type (e.g., 'Perfluoroalkyl', 'alkyl', 'cyclic')
|
|
173
|
+
- 'branching': float [0-1], 1.0 = linear, 0.0 = highly branched
|
|
174
|
+
- 'smarts_centrality': float [0-1], 1.0 = functional group at center, 0.0 = at periphery
|
|
175
|
+
- 'mean_eccentricity': float, mean graph eccentricity across nodes in component
|
|
176
|
+
- 'median_eccentricity': float, median graph eccentricity across nodes in component
|
|
177
|
+
|
|
178
|
+
Notes
|
|
179
|
+
-----
|
|
180
|
+
For HalogenGroups
|
|
181
|
+
1. with componentSmarts = 'cyclic', search for connected component matching first smarts
|
|
182
|
+
2. with multiple smarts patterns or counts > 1: Find components containing all required SMARTS matches with minimum counts
|
|
183
|
+
3. with single smarts pattern: Search for connected components of fluorinated atoms (for each pathType) where smarts match is in the component
|
|
184
|
+
4. with smarts defined and formula constraints: search for substructure matches of smarts, and for given componentSmarts for the HalogenGroup, search for connected components of fluorinated atoms where smarts match is in the component
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
formula = kwargs.get("formula", CalcMolFormula(mol))
|
|
188
|
+
formulas = kwargs['formulas']
|
|
189
|
+
frags = kwargs['frags']
|
|
190
|
+
agg_pfas_groups = kwargs.get('agg_pfas_groups',{})
|
|
191
|
+
# halogen groups
|
|
192
|
+
group_matches = []
|
|
193
|
+
# map of group_id -> list of matches for quick lookup
|
|
194
|
+
group_id_to_matches = {}
|
|
195
|
+
for pf in pfas_groups:
|
|
196
|
+
#logger.debug(f"{pf.name}")
|
|
197
|
+
matched1_len = 0
|
|
198
|
+
for fd,mol in zip(formulas,frags):
|
|
199
|
+
# match = pfgroup_obj, match_count, component_sizes, matched_components
|
|
200
|
+
# matched_components = list(dicts) with entries: 'component','size','component_fraction','smarts_matches','smarts_extra_atoms','SMARTS','branching','smarts_centrality','diameter','radius','effective_graph_resistance','eccentricity_values','mean_eccentricity','median_eccentricity','centre','periphery','barycentre','min_dist_to_barycentre','min_resistance_dist_to_barycentre','min_dist_to_centre','min_resistance_dist_to_centre','max_dist_to_periphery','max_resistance_dist_to_periphery'
|
|
201
|
+
match = pf.find_components(mol, fd, fluorinated_components_dict, **kwargs)
|
|
202
|
+
if match is not None and len(match)>0:
|
|
203
|
+
group_matches.extend(match)
|
|
204
|
+
group_id_to_matches.setdefault(pf.id, []).extend(match)
|
|
205
|
+
|
|
206
|
+
# Process aggregate halogen groups efficiently
|
|
207
|
+
if agg_pfas_groups:
|
|
208
|
+
|
|
209
|
+
# For each aggregate group, collect and deduplicate components
|
|
210
|
+
for agg_group, component_group_ids in agg_pfas_groups.items():
|
|
211
|
+
# Check if any component groups were matched
|
|
212
|
+
matched_component_ids = [gid for gid in component_group_ids if gid in group_id_to_matches]
|
|
213
|
+
|
|
214
|
+
if matched_component_ids:
|
|
215
|
+
# Collect all components from matched groups
|
|
216
|
+
all_components = []
|
|
217
|
+
for gid in matched_component_ids:
|
|
218
|
+
for _, match_count, component_sizes, matched_components in group_id_to_matches[gid]:
|
|
219
|
+
all_components.extend(matched_components)
|
|
220
|
+
|
|
221
|
+
# Deduplicate components by atom set while keeping different SMARTS types
|
|
222
|
+
# Filter by componentSmarts if aggregate has one
|
|
223
|
+
unique_components = []
|
|
224
|
+
seen_keys = set() # Track (atom_set, SMARTS_type) combinations
|
|
225
|
+
|
|
226
|
+
for comp in all_components:
|
|
227
|
+
atoms_key = frozenset(comp.get('component', []))
|
|
228
|
+
smarts_type = comp.get('SMARTS')
|
|
229
|
+
|
|
230
|
+
# Filter by componentSmarts if aggregate has one (not None)
|
|
231
|
+
if agg_group.componentSmarts is not None:
|
|
232
|
+
allowed = agg_group.componentSmarts
|
|
233
|
+
if isinstance(allowed, (list, tuple, set)):
|
|
234
|
+
if smarts_type not in allowed:
|
|
235
|
+
continue
|
|
236
|
+
elif smarts_type != allowed:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Deduplicate by (atoms, SMARTS_type) key
|
|
240
|
+
key = (atoms_key, smarts_type)
|
|
241
|
+
if key not in seen_keys:
|
|
242
|
+
seen_keys.add(key)
|
|
243
|
+
unique_components.append(comp)
|
|
244
|
+
|
|
245
|
+
# Create match entry for aggregate group if we have unique components
|
|
246
|
+
if unique_components:
|
|
247
|
+
component_sizes = [comp.get('size', 0) for comp in unique_components]
|
|
248
|
+
match_count = len(unique_components)
|
|
249
|
+
group_matches.append((agg_group, match_count, component_sizes, unique_components))
|
|
250
|
+
|
|
251
|
+
return group_matches, mol, formula
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@rdkit_disable_log(level='warning')
|
|
255
|
+
def parse_smiles(smiles, output_format='list',
|
|
256
|
+
limit_effective_graph_resistance=None, compute_component_metrics=True,
|
|
257
|
+
halogens='F', form=None, saturation=None, progress=False,
|
|
258
|
+
verbose=False,
|
|
259
|
+
**kwargs):
|
|
260
|
+
"""
|
|
261
|
+
Parse SMILES string(s) and return halogen group information.
|
|
262
|
+
|
|
263
|
+
Parameters:
|
|
264
|
+
-----------
|
|
265
|
+
smiles : str or list of str
|
|
266
|
+
Single SMILES string or list of SMILES strings
|
|
267
|
+
output_format : str, default 'list'
|
|
268
|
+
Output format: 'list' (default), 'dataframe', or 'csv'
|
|
269
|
+
- 'list': Returns nested lists of tuples (default behavior)
|
|
270
|
+
- 'dataframe': Returns pandas DataFrame with one row per match
|
|
271
|
+
- 'csv': Returns CSV string
|
|
272
|
+
limit_effective_graph_resistance : int or None, default None
|
|
273
|
+
Maximum component size for computing effective graph resistance.
|
|
274
|
+
- None: Compute for all components (default, may be slow for large molecules)
|
|
275
|
+
- int > 0: Only compute for components with fewer atoms than this limit
|
|
276
|
+
- 0: Skip computation for all components (set to NaN)
|
|
277
|
+
compute_component_metrics : bool, default True
|
|
278
|
+
Whether to compute graph metrics (diameter, radius, etc.) for components.
|
|
279
|
+
- True: Compute all metrics (default)
|
|
280
|
+
- False: Only compute component size, skip all other metrics
|
|
281
|
+
halogens : str or list of str or None, default 'F'
|
|
282
|
+
Filter components by halogen element.
|
|
283
|
+
- 'F' (default): fluorine only
|
|
284
|
+
- str (e.g. 'Cl'): restrict to that single halogen
|
|
285
|
+
- list (e.g. ['F', 'Cl']): restrict to those halogens
|
|
286
|
+
- None: no filter (include all halogens)
|
|
287
|
+
form : str or list of str, optional
|
|
288
|
+
Filter components by form type (e.g., 'alkyl', ['alkyl', 'cyclic'], or None for all)
|
|
289
|
+
saturation : str or list of str, optional
|
|
290
|
+
Filter components by saturation (e.g., 'per', 'poly', or None for all)
|
|
291
|
+
progress : bool, default False
|
|
292
|
+
If True, display a tqdm progress bar during parsing.
|
|
293
|
+
verbose : bool, default False
|
|
294
|
+
If True, collect fragmentation events from
|
|
295
|
+
:func:`~PFASGroups.core.fragment_until_valence_is_correct` for each
|
|
296
|
+
SMILES that could not be sanitised directly. When *True* the
|
|
297
|
+
function returns a 2-tuple ``(result, verbose_info)`` where
|
|
298
|
+
``verbose_info`` is a dict with keys:
|
|
299
|
+
|
|
300
|
+
``fragmented``
|
|
301
|
+
List of per-SMILES dicts ``{'smiles': str, 'events': list,
|
|
302
|
+
'n_fragments': int}`` for every SMILES that triggered
|
|
303
|
+
fragmentation. Each event dict contains ``atom_idx``,
|
|
304
|
+
``atom_symbol``, ``error``, ``n_fragments``, and ``smiles``.
|
|
305
|
+
``n_invalid``
|
|
306
|
+
Count of SMILES that could not be converted to a valid RDKit
|
|
307
|
+
molecule even after attempted fragmentation.
|
|
308
|
+
**kwargs : dict
|
|
309
|
+
Additional parameters (pfas_groups, componentSmartss, etc.)
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
--------
|
|
313
|
+
list, pandas.DataFrame, or str
|
|
314
|
+
Depends on output_format parameter (or 2-tuple when verbose=True)
|
|
315
|
+
"""
|
|
316
|
+
# Convert single input to list for uniform processing
|
|
317
|
+
single_input = isinstance(smiles, str)
|
|
318
|
+
smiles_list = [smiles] if single_input else smiles
|
|
319
|
+
import warnings
|
|
320
|
+
mol_list = []
|
|
321
|
+
_frag_events: list = [] # populated when verbose=True
|
|
322
|
+
_n_invalid: int = 0
|
|
323
|
+
for smi in smiles_list:
|
|
324
|
+
mol = Chem.MolFromSmiles(str(smi)) # normal parse + sanitize
|
|
325
|
+
if mol is None:
|
|
326
|
+
# Try without sanitization and fragment to recover
|
|
327
|
+
mol_raw = Chem.MolFromSmiles(str(smi), sanitize=False)
|
|
328
|
+
if mol_raw is not None:
|
|
329
|
+
try:
|
|
330
|
+
if verbose:
|
|
331
|
+
frags, events = fragment_until_valence_is_correct(mol_raw, [], verbose=True)
|
|
332
|
+
# Enrich each event with the element symbol of the offending atom
|
|
333
|
+
for ev in events:
|
|
334
|
+
try:
|
|
335
|
+
ev['atom_symbol'] = mol_raw.GetAtomWithIdx(ev['atom_idx']).GetSymbol()
|
|
336
|
+
except Exception:
|
|
337
|
+
ev['atom_symbol'] = 'unknown'
|
|
338
|
+
_frag_events.append({'smiles': smi, 'events': events, 'n_fragments': len(frags)})
|
|
339
|
+
else:
|
|
340
|
+
frags = fragment_until_valence_is_correct(mol_raw, [])
|
|
341
|
+
except Exception:
|
|
342
|
+
frags = []
|
|
343
|
+
if len(frags) == 1:
|
|
344
|
+
mol = frags[0]
|
|
345
|
+
elif len(frags) > 1:
|
|
346
|
+
combined = frags[0]
|
|
347
|
+
for frag in frags[1:]:
|
|
348
|
+
combined = Chem.CombineMols(combined, frag)
|
|
349
|
+
mol = combined
|
|
350
|
+
if mol is not None:
|
|
351
|
+
kwargs.setdefault('fragmented_smiles', []).append(smi)
|
|
352
|
+
if mol is None:
|
|
353
|
+
warnings.warn(f"parse_smiles: could not parse SMILES '{smi}' — skipping.", UserWarning, stacklevel=2)
|
|
354
|
+
if verbose:
|
|
355
|
+
_n_invalid += 1
|
|
356
|
+
mol_list.append(mol)
|
|
357
|
+
# Parse all molecules
|
|
358
|
+
kwargs['limit_effective_graph_resistance'] = limit_effective_graph_resistance
|
|
359
|
+
kwargs['compute_component_metrics'] = compute_component_metrics
|
|
360
|
+
kwargs['halogens'] = halogens
|
|
361
|
+
kwargs['form'] = form
|
|
362
|
+
kwargs['saturation'] = saturation
|
|
363
|
+
if len(mol_list)==0:
|
|
364
|
+
raise ValueError("No valid SMILES provided for parsing.")
|
|
365
|
+
result = parse_mols(mol_list, output_format=output_format, progress=progress,
|
|
366
|
+
_smiles_list=smiles_list, **kwargs)
|
|
367
|
+
if verbose:
|
|
368
|
+
return result, {'fragmented': _frag_events, 'n_invalid': _n_invalid}
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def parse_from_database(
|
|
374
|
+
conn,
|
|
375
|
+
query: Optional[str] = None,
|
|
376
|
+
table: Optional[str] = None,
|
|
377
|
+
mol_column: str = 'mol',
|
|
378
|
+
smiles_column: Optional[str] = 'smiles',
|
|
379
|
+
inchi_column: Optional[str] = 'inchi',
|
|
380
|
+
id_column: Optional[str] = 'id',
|
|
381
|
+
batch_size: int = 1000,
|
|
382
|
+
output_table: Optional[str] = None,
|
|
383
|
+
components_table: str = "components",
|
|
384
|
+
groups_table: str = "pfas_groups_in_compound",
|
|
385
|
+
write_results: bool = True,
|
|
386
|
+
progress: bool = False,
|
|
387
|
+
**kwargs
|
|
388
|
+
):
|
|
389
|
+
"""Parse halogen groups from molecules stored in a database.
|
|
390
|
+
|
|
391
|
+
This function reads molecules from a database table, parses them for halogen groups,
|
|
392
|
+
and optionally writes the results back to the database.
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
conn : str or sqlalchemy.engine.Engine
|
|
397
|
+
Database connection. Can be:
|
|
398
|
+
- SQLAlchemy Engine object
|
|
399
|
+
- Connection string (e.g., 'postgresql://user:pass@host:port/db')
|
|
400
|
+
query : str, optional
|
|
401
|
+
SQL query to select molecules. If not provided, selects all from table.
|
|
402
|
+
table : str, optional
|
|
403
|
+
Table name to query (used if query is not provided).
|
|
404
|
+
mol_column : str, default 'mol'
|
|
405
|
+
Column name containing RDKit mol objects (binary format).
|
|
406
|
+
smiles_column : str, optional, default 'smiles'
|
|
407
|
+
Fallback column with SMILES strings if mol parsing fails.
|
|
408
|
+
inchi_column : str, optional, default 'inchi'
|
|
409
|
+
Fallback column with InChI strings if both mol and SMILES fail.
|
|
410
|
+
id_column : str, optional, default 'id'
|
|
411
|
+
Column name for unique identifier (for tracking results).
|
|
412
|
+
batch_size : int, default 1000
|
|
413
|
+
Number of molecules to process in each batch.
|
|
414
|
+
output_table : str, optional
|
|
415
|
+
If provided, creates/updates this table with summary results.
|
|
416
|
+
components_table : str, default 'components'
|
|
417
|
+
Table name for detailed component data.
|
|
418
|
+
groups_table : str, default 'pfas_groups_in_compound'
|
|
419
|
+
Table name for halogen group matches.
|
|
420
|
+
write_results : bool, default True
|
|
421
|
+
Whether to write results back to database.
|
|
422
|
+
progress : bool, default False
|
|
423
|
+
If True, display a tqdm progress bar during parsing.
|
|
424
|
+
**kwargs : dict
|
|
425
|
+
Additional parameters passed to parse_mols (e.g., pfas_groups, componentSmartss).
|
|
426
|
+
|
|
427
|
+
Returns
|
|
428
|
+
-------
|
|
429
|
+
PFASEmbeddingSet
|
|
430
|
+
Parsed results for all molecules.
|
|
431
|
+
|
|
432
|
+
Examples
|
|
433
|
+
--------
|
|
434
|
+
>>> # Using connection string
|
|
435
|
+
>>> results = parse_from_database(
|
|
436
|
+
... conn='postgresql://user:pass@localhost/chem_db',
|
|
437
|
+
... table='molecules',
|
|
438
|
+
... mol_column='rdkit_mol',
|
|
439
|
+
... smiles_column='canonical_smiles'
|
|
440
|
+
... )
|
|
441
|
+
>>> \n>>> # Using SQLAlchemy engine with custom query
|
|
442
|
+
>>> from sqlalchemy import create_engine
|
|
443
|
+
>>> engine = create_engine('sqlite:///chemicals.db')
|
|
444
|
+
>>> results = parse_from_database(
|
|
445
|
+
... conn=engine,
|
|
446
|
+
... query=\"SELECT id, mol, smiles FROM compounds WHERE molecular_weight < 1000\",
|
|
447
|
+
... batch_size=500
|
|
448
|
+
... )
|
|
449
|
+
>>> \n>>> # Process and write back to database
|
|
450
|
+
>>> results = parse_from_database(
|
|
451
|
+
... conn='postgresql://user:pass@localhost/pfas_db',
|
|
452
|
+
... table='test_compounds',
|
|
453
|
+
... write_results=True,
|
|
454
|
+
... components_table='pfas_components',
|
|
455
|
+
... groups_table='pfas_groups'
|
|
456
|
+
... )
|
|
457
|
+
"""
|
|
458
|
+
try:
|
|
459
|
+
import pandas as pd
|
|
460
|
+
import sqlalchemy
|
|
461
|
+
from sqlalchemy import text
|
|
462
|
+
except ImportError as exc:
|
|
463
|
+
raise ImportError("pandas and sqlalchemy are required. Install with: pip install pandas sqlalchemy") from exc
|
|
464
|
+
# Create engine if conn is a string
|
|
465
|
+
if isinstance(conn, str):
|
|
466
|
+
engine = sqlalchemy.create_engine(conn)
|
|
467
|
+
else:
|
|
468
|
+
engine = conn
|
|
469
|
+
|
|
470
|
+
# Build query if not provided
|
|
471
|
+
if query is None:
|
|
472
|
+
if table is None:
|
|
473
|
+
raise ValueError("Either 'query' or 'table' must be provided.")
|
|
474
|
+
query = f"SELECT * FROM {table}"
|
|
475
|
+
|
|
476
|
+
# Read data in batches
|
|
477
|
+
print(f"Reading molecules from database...")
|
|
478
|
+
df = pd.read_sql(query, engine)
|
|
479
|
+
total_rows = len(df)
|
|
480
|
+
print(f"Found {total_rows} molecules to process")
|
|
481
|
+
|
|
482
|
+
all_results = []
|
|
483
|
+
|
|
484
|
+
# Process in batches
|
|
485
|
+
for batch_start in range(0, total_rows, batch_size):
|
|
486
|
+
batch_end = min(batch_start + batch_size, total_rows)
|
|
487
|
+
batch_df = df.iloc[batch_start:batch_end]
|
|
488
|
+
|
|
489
|
+
print(f"Processing batch {batch_start+1}-{batch_end} of {total_rows}...")
|
|
490
|
+
|
|
491
|
+
mols = []
|
|
492
|
+
mol_ids = []
|
|
493
|
+
|
|
494
|
+
for idx, row in batch_df.iterrows():
|
|
495
|
+
mol = None
|
|
496
|
+
mol_id = row.get(id_column) if id_column and id_column in row else idx
|
|
497
|
+
|
|
498
|
+
# Try to parse mol from binary column
|
|
499
|
+
if mol_column in row and row[mol_column] is not None:
|
|
500
|
+
try:
|
|
501
|
+
# Assuming mol is stored as binary or mol block
|
|
502
|
+
if isinstance(row[mol_column], bytes):
|
|
503
|
+
mol = Chem.Mol(row[mol_column])
|
|
504
|
+
elif isinstance(row[mol_column], str):
|
|
505
|
+
mol = Chem.MolFromMolBlock(row[mol_column])
|
|
506
|
+
except Exception as e:
|
|
507
|
+
print(f" Warning: Failed to parse mol for {mol_id}: {e}")
|
|
508
|
+
|
|
509
|
+
# Fallback to SMILES
|
|
510
|
+
if mol is None and smiles_column and smiles_column in row and row[smiles_column]:
|
|
511
|
+
try:
|
|
512
|
+
mol = Chem.MolFromSmiles(row[smiles_column])
|
|
513
|
+
except Exception as e:
|
|
514
|
+
print(f" Warning: Failed to parse SMILES for {mol_id}: {e}")
|
|
515
|
+
|
|
516
|
+
# Fallback to InChI
|
|
517
|
+
if mol is None and inchi_column and inchi_column in row and row[inchi_column]:
|
|
518
|
+
try:
|
|
519
|
+
mol = Chem.MolFromInchi(row[inchi_column])
|
|
520
|
+
except Exception as e:
|
|
521
|
+
print(f" Warning: Failed to parse InChI for {mol_id}: {e}")
|
|
522
|
+
|
|
523
|
+
if mol is not None:
|
|
524
|
+
mols.append(mol)
|
|
525
|
+
mol_ids.append(mol_id)
|
|
526
|
+
else:
|
|
527
|
+
print(f" Error: Could not parse molecule {mol_id}")
|
|
528
|
+
|
|
529
|
+
# Parse this batch
|
|
530
|
+
if mols:
|
|
531
|
+
batch_results = parse_mols(mols, progress=progress, **kwargs)
|
|
532
|
+
all_results.extend(batch_results)
|
|
533
|
+
|
|
534
|
+
# Combine all results
|
|
535
|
+
results = PFASEmbeddingSet(all_results)
|
|
536
|
+
|
|
537
|
+
# Write results to database if requested
|
|
538
|
+
if write_results and len(results) > 0:
|
|
539
|
+
print("Writing results to database...")
|
|
540
|
+
results.to_sql(
|
|
541
|
+
conn=engine,
|
|
542
|
+
components_table=components_table,
|
|
543
|
+
groups_table=groups_table,
|
|
544
|
+
if_exists='append'
|
|
545
|
+
)
|
|
546
|
+
print(f"✅ Successfully wrote {len(results)} results to database")
|
|
547
|
+
|
|
548
|
+
return results
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def setup_halogen_groups_database(
|
|
552
|
+
conn,
|
|
553
|
+
groups_info_table: str = 'halogen_groups_info',
|
|
554
|
+
smarts_table: str = 'halogen_smarts',
|
|
555
|
+
if_exists: str = 'replace'
|
|
556
|
+
):
|
|
557
|
+
"""Set up halogen groups metadata tables in a database.
|
|
558
|
+
|
|
559
|
+
This function creates tables to store halogen group definitions and SMARTS patterns,
|
|
560
|
+
similar to the load_pfas_groups function in zeropmdb.
|
|
561
|
+
|
|
562
|
+
Parameters
|
|
563
|
+
----------
|
|
564
|
+
conn : str or sqlalchemy.engine.Engine
|
|
565
|
+
Database connection.
|
|
566
|
+
groups_info_table : str, default 'halogen_groups_info'
|
|
567
|
+
Table name for halogen group metadata.
|
|
568
|
+
smarts_table : str, default 'halogen_smarts'
|
|
569
|
+
Table name for SMARTS patterns used in groups.
|
|
570
|
+
if_exists : str, default 'replace'
|
|
571
|
+
How to behave if tables exist: 'fail', 'replace', or 'append'.
|
|
572
|
+
|
|
573
|
+
Returns
|
|
574
|
+
-------
|
|
575
|
+
dict
|
|
576
|
+
Statistics about loaded groups.
|
|
577
|
+
|
|
578
|
+
Examples
|
|
579
|
+
--------
|
|
580
|
+
>>> # Set up in PostgreSQL
|
|
581
|
+
>>> setup_halogen_groups_database(
|
|
582
|
+
... conn='postgresql://user:pass@localhost/halogen_db',
|
|
583
|
+
... groups_info_table='halogen_groups',
|
|
584
|
+
... smarts_table='halogen_smarts_patterns'
|
|
585
|
+
... )
|
|
586
|
+
>>>
|
|
587
|
+
>>> # Set up in SQLite
|
|
588
|
+
>>> from sqlalchemy import create_engine
|
|
589
|
+
>>> engine = create_engine('sqlite:///halogen_database.db')
|
|
590
|
+
>>> stats = setup_halogen_groups_database(engine)
|
|
591
|
+
>>> print(f"Loaded {stats['total_groups']} halogen groups")
|
|
592
|
+
"""
|
|
593
|
+
try:
|
|
594
|
+
import pandas as pd
|
|
595
|
+
import sqlalchemy
|
|
596
|
+
except ImportError as exc:
|
|
597
|
+
raise ImportError("pandas and sqlalchemy required. Install with: pip install pandas sqlalchemy") from exc
|
|
598
|
+
# Create engine if conn is a string
|
|
599
|
+
if isinstance(conn, str):
|
|
600
|
+
engine = sqlalchemy.create_engine(conn)
|
|
601
|
+
else:
|
|
602
|
+
engine = conn
|
|
603
|
+
|
|
604
|
+
# Load halogen groups from the module
|
|
605
|
+
from .HalogenGroupModel import HalogenGroup
|
|
606
|
+
import json
|
|
607
|
+
|
|
608
|
+
# Load groups from JSON file
|
|
609
|
+
from .core import HALOGEN_GROUPS_FILE_GROUPS_FILE
|
|
610
|
+
with open(HALOGEN_GROUPS_FILE, 'r') as f:
|
|
611
|
+
groups_data = json.load(f)
|
|
612
|
+
|
|
613
|
+
# Prepare groups info data
|
|
614
|
+
groups_info = []
|
|
615
|
+
all_smarts = set()
|
|
616
|
+
|
|
617
|
+
for group_data in groups_data:
|
|
618
|
+
group_info = {
|
|
619
|
+
'id': group_data['id'],
|
|
620
|
+
'name': group_data['name'],
|
|
621
|
+
'compute': group_data.get('compute', True),
|
|
622
|
+
'componentSmarts': group_data.get('componentSmarts'),
|
|
623
|
+
'pathType': group_data.get('pathType'),
|
|
624
|
+
'constraints': json.dumps(group_data.get('_constraints', {})),
|
|
625
|
+
'smarts_patterns': json.dumps(group_data.get('smarts', {})),
|
|
626
|
+
}
|
|
627
|
+
groups_info.append(group_info)
|
|
628
|
+
|
|
629
|
+
# Collect unique SMARTS patterns
|
|
630
|
+
if 'smarts' in group_data:
|
|
631
|
+
for pattern in group_data['smarts'].keys():
|
|
632
|
+
all_smarts.add(pattern)
|
|
633
|
+
|
|
634
|
+
# Create DataFrames
|
|
635
|
+
df_groups = pd.DataFrame(groups_info)
|
|
636
|
+
df_smarts = pd.DataFrame([
|
|
637
|
+
{'smarts': pattern, 'pattern_id': idx}
|
|
638
|
+
for idx, pattern in enumerate(sorted(all_smarts))
|
|
639
|
+
])
|
|
640
|
+
|
|
641
|
+
# Write to database
|
|
642
|
+
print(f"Writing {len(df_groups)} halogen groups to table '{groups_info_table}'...")
|
|
643
|
+
df_groups.to_sql(groups_info_table, engine, if_exists=if_exists, index=False)
|
|
644
|
+
|
|
645
|
+
print(f"Writing {len(df_smarts)} SMARTS patterns to table '{smarts_table}'...")
|
|
646
|
+
df_smarts.to_sql(smarts_table, engine, if_exists=if_exists, index=False)
|
|
647
|
+
|
|
648
|
+
stats = {
|
|
649
|
+
'total_groups': len(df_groups),
|
|
650
|
+
'compute_groups': len(df_groups[df_groups['compute'].astype(bool)]),
|
|
651
|
+
'aggregate_groups': len(df_groups[~df_groups['compute'].astype(bool)]),
|
|
652
|
+
'total_smarts': len(df_smarts),
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
print("✅ Successfully set up halogen groups database")
|
|
656
|
+
print(f" - {stats['total_groups']} total groups ({stats['compute_groups']} compute, {stats['aggregate_groups']} aggregate)")
|
|
657
|
+
print(f" - {stats['total_smarts']} unique SMARTS patterns")
|
|
658
|
+
|
|
659
|
+
return stats
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def parse_mol(mol, progress=False, **kwargs):
|
|
663
|
+
"""Wrapper for parse_mols to handle single molecule input.
|
|
664
|
+
|
|
665
|
+
Returns a single ``PFASEmbedding`` when ``output_format='list'``
|
|
666
|
+
(default), preserving backwards-compatible dict behaviour while
|
|
667
|
+
enabling richer navigation helpers.
|
|
668
|
+
"""
|
|
669
|
+
return parse_mols([mol], progress=progress, **kwargs)[0]
|
|
670
|
+
|
|
671
|
+
def parse_mols(mols, output_format='list', include_PFAS_definitions=True,
|
|
672
|
+
limit_effective_graph_resistance=None, compute_component_metrics=True,
|
|
673
|
+
halogens='F', form=None, saturation=None, progress=False,
|
|
674
|
+
_smiles_list=None, **kwargs):
|
|
675
|
+
"""
|
|
676
|
+
Parse RDKit molecule(s) and return halogen group information.
|
|
677
|
+
|
|
678
|
+
Parameters:
|
|
679
|
+
-----------
|
|
680
|
+
mols : list of rdkit.Chem.Mol
|
|
681
|
+
Single RDKit molecule or list of molecules
|
|
682
|
+
output_format : str, default 'list'
|
|
683
|
+
Output format: 'list' (default), 'dataframe', or 'csv'
|
|
684
|
+
- 'list': Returns nested lists of tuples (default behavior)
|
|
685
|
+
- 'dataframe': Returns pandas DataFrame with one row per match
|
|
686
|
+
- 'csv': Returns CSV string
|
|
687
|
+
limit_effective_graph_resistance : int or None, default None
|
|
688
|
+
Maximum component size for computing effective graph resistance.
|
|
689
|
+
- None: Compute for all components (default, may be slow for large molecules)
|
|
690
|
+
- int > 0: Only compute for components with fewer atoms than this limit
|
|
691
|
+
- 0: Skip computation for all components (set to NaN)
|
|
692
|
+
compute_component_metrics : bool, default True
|
|
693
|
+
Whether to compute graph metrics (diameter, radius, etc.) for components.
|
|
694
|
+
- True: Compute all metrics (default)
|
|
695
|
+
- False: Only compute component size, skip all other metrics
|
|
696
|
+
halogens : str or list of str or None, default 'F'
|
|
697
|
+
Filter components by halogen element.
|
|
698
|
+
- 'F' (default): fluorine only
|
|
699
|
+
- str (e.g. 'Cl'): restrict to that single halogen
|
|
700
|
+
- list (e.g. ['F', 'Cl']): restrict to those halogens
|
|
701
|
+
- None: no filter (include all halogens)
|
|
702
|
+
form : str or list of str, optional
|
|
703
|
+
Filter components by form type (e.g., 'alkyl', ['alkyl', 'cyclic'], or None for all)
|
|
704
|
+
saturation : str or list of str, optional
|
|
705
|
+
Filter components by saturation (e.g., 'per', 'poly', or None for all)
|
|
706
|
+
progress : bool, default False
|
|
707
|
+
If True, display a tqdm progress bar during parsing.
|
|
708
|
+
**kwargs : dict
|
|
709
|
+
Additional parameters (halogen_groups, componentSmartss, etc.)
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
--------
|
|
713
|
+
list, pandas.DataFrame, or str
|
|
714
|
+
Depends on output_format parameter
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
# Parse all molecules
|
|
718
|
+
import warnings
|
|
719
|
+
results = {}
|
|
720
|
+
_iter = enumerate(mols)
|
|
721
|
+
if progress:
|
|
722
|
+
try:
|
|
723
|
+
from tqdm.auto import tqdm as _tqdm
|
|
724
|
+
except ImportError:
|
|
725
|
+
from tqdm import tqdm as _tqdm
|
|
726
|
+
_iter = _tqdm(list(_iter), desc='parse_mols', total=len(mols))
|
|
727
|
+
for mol_idx, mol in _iter:
|
|
728
|
+
orig_smi = (_smiles_list[mol_idx] if _smiles_list is not None and mol_idx < len(_smiles_list) else None)
|
|
729
|
+
if mol is None:
|
|
730
|
+
# Invalid SMILES — already warned in parse_smiles; insert empty result keyed by index
|
|
731
|
+
_key = f'__invalid_{mol_idx}__'
|
|
732
|
+
results[_key] = {
|
|
733
|
+
'smiles': orig_smi or '',
|
|
734
|
+
'inchikey': None,
|
|
735
|
+
'inchi': None,
|
|
736
|
+
'formula': None,
|
|
737
|
+
'matches': [],
|
|
738
|
+
'error': 'invalid_smiles',
|
|
739
|
+
}
|
|
740
|
+
continue
|
|
741
|
+
# Pass through component metric options and filters
|
|
742
|
+
kwargs['limit_effective_graph_resistance'] = limit_effective_graph_resistance
|
|
743
|
+
kwargs['compute_component_metrics'] = compute_component_metrics
|
|
744
|
+
kwargs['halogens'] = halogens
|
|
745
|
+
kwargs['form'] = form
|
|
746
|
+
kwargs['saturation'] = saturation
|
|
747
|
+
try:
|
|
748
|
+
matches, mol_with_h, formula = parse_groups_in_mol(mol, **kwargs)
|
|
749
|
+
except Exception as exc:
|
|
750
|
+
_smi_repr = orig_smi or Chem.MolToSmiles(mol)
|
|
751
|
+
warnings.warn(f"parse_mols: error parsing '{_smi_repr}' — {exc}. Skipping.", UserWarning, stacklevel=2)
|
|
752
|
+
_key = f'__error_{mol_idx}__'
|
|
753
|
+
results[_key] = {
|
|
754
|
+
'smiles': _smi_repr,
|
|
755
|
+
'inchikey': Chem.MolToInchiKey(mol),
|
|
756
|
+
'inchi': Chem.MolToInchi(mol),
|
|
757
|
+
'formula': None,
|
|
758
|
+
'matches': [],
|
|
759
|
+
'error': str(exc),
|
|
760
|
+
}
|
|
761
|
+
continue
|
|
762
|
+
inchikey = Chem.MolToInchiKey(mol)
|
|
763
|
+
inchi = Chem.MolToInchi(mol)
|
|
764
|
+
smi = Chem.MolToSmiles(mol)
|
|
765
|
+
# Store molblock with explicit H to preserve atom ordering for visualization
|
|
766
|
+
# (MolToSmiles canonicalises atom order, breaking component atom indices)
|
|
767
|
+
molblock_with_h = Chem.MolToMolBlock(mol_with_h)
|
|
768
|
+
results.setdefault(inchikey,{}).update({"smiles": smi,
|
|
769
|
+
"inchikey": inchikey,
|
|
770
|
+
"inchi": inchi,
|
|
771
|
+
"formula": formula,
|
|
772
|
+
"smiles_with_h": Chem.MolToSmiles(mol_with_h), # kept for backward compat
|
|
773
|
+
"molblock_with_h": molblock_with_h})
|
|
774
|
+
|
|
775
|
+
# Build match results with comprehensive summary metrics
|
|
776
|
+
match_results = []
|
|
777
|
+
for group, match_count, components_sizes, matched_components in matches:
|
|
778
|
+
# Calculate summary metrics across all components for this group
|
|
779
|
+
if len(matched_components) > 0:
|
|
780
|
+
# Basic metrics summaries
|
|
781
|
+
mean_branching = sum([c['branching'] for c in matched_components])/len(matched_components)
|
|
782
|
+
mean_smarts_centrality = sum([c['smarts_centrality'] for c in matched_components])/len(matched_components)
|
|
783
|
+
mean_mean_eccentricity = sum([c['mean_eccentricity'] for c in matched_components])/len(matched_components)
|
|
784
|
+
mean_median_eccentricity = sum([c['median_eccentricity'] for c in matched_components])/len(matched_components)
|
|
785
|
+
mean_component_fraction = sum([c['component_fraction'] for c in matched_components])/len(matched_components)
|
|
786
|
+
total_branching = matched_components[0].get('total_branching', 0.0)
|
|
787
|
+
sum_component_branching = sum([c['branching'] for c in matched_components])
|
|
788
|
+
sum_component_branching_ratio = sum_component_branching / total_branching if total_branching > 0 else 0.0
|
|
789
|
+
|
|
790
|
+
# Calculate total fraction covered by union of all carbon atoms in components
|
|
791
|
+
union_carbon_atoms = set()
|
|
792
|
+
# Use molecule with hydrogens for consistent atom indexing
|
|
793
|
+
total_carbons = sum(1 for atom in mol_with_h.GetAtoms() if atom.GetSymbol() == 'C')
|
|
794
|
+
extra_carbons_count = 0
|
|
795
|
+
|
|
796
|
+
for comp_dict in matched_components:
|
|
797
|
+
component = set(comp_dict.get('component', []))
|
|
798
|
+
smarts_matches = comp_dict.get('smarts_matches')
|
|
799
|
+
|
|
800
|
+
# Add carbon atoms from component
|
|
801
|
+
for atom_idx in component:
|
|
802
|
+
if mol_with_h.GetAtomWithIdx(atom_idx).GetSymbol() == 'C':
|
|
803
|
+
union_carbon_atoms.add(atom_idx)
|
|
804
|
+
|
|
805
|
+
# Add carbon atoms from SMARTS matches
|
|
806
|
+
if smarts_matches is not None:
|
|
807
|
+
for atom_idx in smarts_matches:
|
|
808
|
+
if mol_with_h.GetAtomWithIdx(atom_idx).GetSymbol() == 'C':
|
|
809
|
+
union_carbon_atoms.add(atom_idx)
|
|
810
|
+
|
|
811
|
+
# Add extra carbons from functional groups
|
|
812
|
+
if 'smarts_extra_atoms' in comp_dict:
|
|
813
|
+
extra_carbons_count += comp_dict['smarts_extra_atoms']
|
|
814
|
+
|
|
815
|
+
# Total = union of matched carbons + extra functional group carbons
|
|
816
|
+
# Cap at total_carbons to avoid exceeding 1.0
|
|
817
|
+
total_carbon_count = min(len(union_carbon_atoms) + extra_carbons_count, total_carbons)
|
|
818
|
+
total_components_fraction = total_carbon_count / total_carbons if total_carbons > 0 else 0.0
|
|
819
|
+
|
|
820
|
+
# Graph structure metrics summaries
|
|
821
|
+
diameters = [c['diameter'] for c in matched_components if not (isinstance(c['diameter'], float) and (c['diameter'] != c['diameter'] or c['diameter'] == float('inf')))]
|
|
822
|
+
radii = [c['radius'] for c in matched_components if not (isinstance(c['radius'], float) and (c['radius'] != c['radius'] or c['radius'] == float('inf')))]
|
|
823
|
+
resistances = [c['effective_graph_resistance'] for c in matched_components if not (isinstance(c['effective_graph_resistance'], float) and (c['effective_graph_resistance'] != c['effective_graph_resistance'] or c['effective_graph_resistance'] == float('inf')))]
|
|
824
|
+
|
|
825
|
+
mean_diameter = sum(diameters)/len(diameters) if len(diameters) > 0 else float('nan')
|
|
826
|
+
mean_radius = sum(radii)/len(radii) if len(radii) > 0 else float('nan')
|
|
827
|
+
mean_resistance = sum(resistances)/len(resistances) if len(resistances) > 0 else float('nan')
|
|
828
|
+
|
|
829
|
+
def _nanmean(vals):
|
|
830
|
+
clean = [v for v in vals if v == v and v != float('inf')]
|
|
831
|
+
return sum(clean) / len(clean) if clean else float('nan')
|
|
832
|
+
|
|
833
|
+
# BDE-weighted EGR on expanded component
|
|
834
|
+
egr_bde_vals = [c.get('effective_graph_resistance_BDE', float('nan')) for c in matched_components]
|
|
835
|
+
mean_effective_graph_resistance_BDE = _nanmean(egr_bde_vals)
|
|
836
|
+
|
|
837
|
+
# Distance metrics summaries
|
|
838
|
+
min_dists_bc = [c['min_dist_to_barycentre'] for c in matched_components if c['min_dist_to_barycentre'] < float('inf')]
|
|
839
|
+
min_dists_center = [c['min_dist_to_centre'] for c in matched_components if c['min_dist_to_centre'] < float('inf')]
|
|
840
|
+
max_dists_periph = [c['max_dist_to_periphery'] for c in matched_components if c['max_dist_to_periphery'] > 0]
|
|
841
|
+
|
|
842
|
+
mean_dist_to_barycentre = sum(min_dists_bc)/len(min_dists_bc) if len(min_dists_bc) > 0 else 0
|
|
843
|
+
mean_dist_to_centre = sum(min_dists_center)/len(min_dists_center) if len(min_dists_center) > 0 else 0
|
|
844
|
+
mean_dist_to_periphery = sum(max_dists_periph)/len(max_dists_periph) if len(max_dists_periph) > 0 else 0
|
|
845
|
+
|
|
846
|
+
summary_metrics = {
|
|
847
|
+
'mean_branching': mean_branching,
|
|
848
|
+
'total_branching': total_branching,
|
|
849
|
+
'sum_component_branching_ratio': sum_component_branching_ratio,
|
|
850
|
+
'mean_smarts_centrality': mean_smarts_centrality,
|
|
851
|
+
'mean_component_fraction': mean_component_fraction,
|
|
852
|
+
'total_components_fraction': total_components_fraction,
|
|
853
|
+
'mean_eccentricity': mean_mean_eccentricity,
|
|
854
|
+
'median_eccentricity': mean_median_eccentricity,
|
|
855
|
+
'mean_diameter': mean_diameter,
|
|
856
|
+
'mean_radius': mean_radius,
|
|
857
|
+
'mean_effective_graph_resistance': mean_resistance,
|
|
858
|
+
'mean_effective_graph_resistance_BDE': mean_effective_graph_resistance_BDE,
|
|
859
|
+
'mean_dist_to_barycentre': mean_dist_to_barycentre,
|
|
860
|
+
'mean_dist_to_centre': mean_dist_to_centre,
|
|
861
|
+
'mean_dist_to_periphery': mean_dist_to_periphery,
|
|
862
|
+
}
|
|
863
|
+
else:
|
|
864
|
+
summary_metrics = {
|
|
865
|
+
'mean_branching': 0.0,
|
|
866
|
+
'total_branching': 0.0,
|
|
867
|
+
'sum_component_branching_ratio': 0.0,
|
|
868
|
+
'mean_smarts_centrality': 0.0,
|
|
869
|
+
'mean_component_fraction': 0.0,
|
|
870
|
+
'total_components_fraction': 0.0,
|
|
871
|
+
'mean_eccentricity': 0.0,
|
|
872
|
+
'median_eccentricity': 0.0,
|
|
873
|
+
'mean_diameter': float('nan'),
|
|
874
|
+
'mean_radius': float('nan'),
|
|
875
|
+
'mean_effective_graph_resistance': float('nan'),
|
|
876
|
+
'mean_effective_graph_resistance_BDE': float('nan'),
|
|
877
|
+
'mean_dist_to_barycentre': 0,
|
|
878
|
+
'mean_dist_to_centre': 0,
|
|
879
|
+
'mean_dist_to_periphery': 0,
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
match_results.append({
|
|
883
|
+
'match_id': f"G{group.id}",
|
|
884
|
+
'id': group.id,
|
|
885
|
+
'group_name': group.name,
|
|
886
|
+
'match_count': match_count,
|
|
887
|
+
'components_sizes': components_sizes,
|
|
888
|
+
'num_components': len(matched_components),
|
|
889
|
+
'components': matched_components,
|
|
890
|
+
'components_types': list(set(str(c['SMARTS']) if isinstance(c['SMARTS'], list) else c['SMARTS'] for c in matched_components)),
|
|
891
|
+
'type':'HalogenGroup',
|
|
892
|
+
**summary_metrics
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
results[inchikey].setdefault('matches',[]).extend(match_results)
|
|
896
|
+
if include_PFAS_definitions is True:
|
|
897
|
+
formula_dict = n_from_formula(formula)
|
|
898
|
+
definitions = parse_definitions_in_mol(mol, formula=formula_dict, **kwargs)
|
|
899
|
+
inchikey = Chem.MolToInchiKey(mol)
|
|
900
|
+
results.setdefault(inchikey,{
|
|
901
|
+
"smiles": Chem.MolToSmiles(mol),
|
|
902
|
+
"inchikey": inchikey,
|
|
903
|
+
"inchi": Chem.MolToInchi(mol),
|
|
904
|
+
"formula": formula}).setdefault("matches",[]).extend([
|
|
905
|
+
{'match_id': f"D{definition.id}",
|
|
906
|
+
'id': definition.id,
|
|
907
|
+
'definition_name': definition.name,
|
|
908
|
+
'type':'PFASdefinition'} for definition in definitions])
|
|
909
|
+
# Convert results to list format (one entry per molecule)
|
|
910
|
+
results_list = [r for r in results.values()]
|
|
911
|
+
# Format output based on requested format
|
|
912
|
+
if output_format in ['dataframe', 'csv']:
|
|
913
|
+
import pandas as pd
|
|
914
|
+
rows = []
|
|
915
|
+
for entry in results_list:
|
|
916
|
+
for match in entry['matches']:
|
|
917
|
+
if match['type'] == 'HalogenGroup':
|
|
918
|
+
rows.append({
|
|
919
|
+
'smiles': entry['smiles'],
|
|
920
|
+
'match_id': match['match_id'],
|
|
921
|
+
'match_name': match['group_name'],
|
|
922
|
+
'match_count': match['match_count'],
|
|
923
|
+
'components_sizes': match['components_sizes'],
|
|
924
|
+
'num_components': match.get('num_components', len(match.get('components', []))),
|
|
925
|
+
'match_type': match['type']
|
|
926
|
+
})
|
|
927
|
+
elif match['type'] == 'PFASdefinition':
|
|
928
|
+
rows.append({
|
|
929
|
+
'smiles': entry['smiles'],
|
|
930
|
+
'match_id': match['match_id'],
|
|
931
|
+
'match_name': match['definition_name'],
|
|
932
|
+
'match_type': match['type']
|
|
933
|
+
})
|
|
934
|
+
df = pd.DataFrame(rows)
|
|
935
|
+
return df.to_csv(index=False) if output_format == 'csv' else df
|
|
936
|
+
|
|
937
|
+
# Default: list-like container with navigation helpers that
|
|
938
|
+
# remains JSON-serialisable and behaves like a normal list of
|
|
939
|
+
# dicts for existing consumers.
|
|
940
|
+
if kwargs.get('fragmented_smiles',None):
|
|
941
|
+
print(f"Note: {len(kwargs['fragmented_smiles'])} SMILES {'were' if len(kwargs['fragmented_smiles']) > 1 else 'was'} fragmented during parsing due to valence issues. Original SMILES are included in 'smiles' field of results for reference.")
|
|
942
|
+
return PFASEmbeddingSet(results_list)
|