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.
PFASGroups/core.py ADDED
@@ -0,0 +1,415 @@
1
+ import os
2
+ import json
3
+ import functools
4
+ import re
5
+ from typing import Union
6
+
7
+ import networkx as nx
8
+ from rdkit import Chem
9
+ from rdkit import rdBase
10
+
11
+ # --- Load SMARTS paths from component_smarts.json ---
12
+ MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
13
+ DATA_DIR = os.path.join(MODULE_DIR, 'data')
14
+ HALOGEN_GROUPS_FILE = os.path.join(DATA_DIR, 'Halogen_groups_smarts.json')
15
+ PFAS_DEFINITIONS_FILE = os.path.join(DATA_DIR, 'PFAS_definitions_smarts.json')
16
+ COMPONENTS_FILE = os.path.join(DATA_DIR, 'component_smarts_halogens.json')
17
+
18
+ def rdkit_disable_log(level='warning'):
19
+ """Disable RDKit warnings and errors logging to stderr"""
20
+ def disable_logs():
21
+ if level == 'error':
22
+ rdBase.DisableLog('rdApp.error')
23
+ rdBase.DisableLog('rdApp.warning')
24
+ elif level == 'warning':
25
+ rdBase.DisableLog('rdApp.warning')
26
+ else:
27
+ rdBase.DisableLog('rdApp.*')
28
+ def enable_logs():
29
+ rdBase.EnableLog('rdApp.*')
30
+ def inner(func):
31
+ @functools.wraps(func)
32
+ def wrapper(*args, **kwargs):
33
+ disable_logs()
34
+ func_ret = func(*args, **kwargs)
35
+ enable_logs()
36
+ return func_ret
37
+ return wrapper
38
+ return inner
39
+ rdBase.DisableLog('rdApp.error')
40
+ rdBase.DisableLog('rdApp.warning')
41
+
42
+ def remove_atoms(mol, idxs, removable = ['H','F','Cl','Br','I'], show_on_error = False):
43
+ """Remove atoms by indices and maintain connectivity.
44
+
45
+ This function removes the specified atoms and their removable neighbors,
46
+ then reconnects the remaining structure to maintain molecular integrity.
47
+ """
48
+ if not idxs:
49
+ return mol
50
+
51
+ to_remove = set()
52
+ # Map each removed atom to its non-removable neighbors
53
+ removed_to_neighbors = {}
54
+
55
+ # First pass: identify all atoms to remove and their connections
56
+ for idx in idxs:
57
+ atom = mol.GetAtomWithIdx(idx)
58
+ neighbors_r = [x.GetIdx() for x in atom.GetNeighbors() if x.GetSymbol() in removable]
59
+ neighbors_c = [x.GetIdx() for x in atom.GetNeighbors() if x.GetSymbol() not in removable]
60
+
61
+ # Add the atom and its removable neighbors to removal list
62
+ to_remove.add(idx)
63
+ to_remove.update(neighbors_r)
64
+
65
+ # Store non-removable neighbors for reconnection
66
+ if neighbors_c:
67
+ removed_to_neighbors[idx] = neighbors_c
68
+
69
+ # Build a graph of connectivity between removed atoms and their neighbors
70
+ # to determine how to reconnect the structure
71
+ new_bonds = []
72
+ processed_chains = set()
73
+
74
+ # Process each chain of consecutive removed atoms
75
+ for start_idx in idxs:
76
+ if start_idx in processed_chains:
77
+ continue
78
+
79
+ # Find the chain of consecutive removed atoms containing start_idx
80
+ chain = [start_idx]
81
+ processed_chains.add(start_idx)
82
+
83
+ # Extend chain in both directions
84
+ queue = [start_idx]
85
+ while queue:
86
+ current = queue.pop(0)
87
+ for neighbor_idx in [n.GetIdx() for n in mol.GetAtomWithIdx(current).GetNeighbors()]:
88
+ if neighbor_idx in idxs and neighbor_idx not in processed_chains:
89
+ chain.append(neighbor_idx)
90
+ processed_chains.add(neighbor_idx)
91
+ queue.append(neighbor_idx)
92
+
93
+ # Find the endpoints of this chain (atoms that connect to non-removable parts)
94
+ chain_endpoints = []
95
+ for atom_idx in chain:
96
+ if atom_idx in removed_to_neighbors:
97
+ non_removable_neighbors = [n for n in removed_to_neighbors[atom_idx] if n not in to_remove]
98
+ if non_removable_neighbors:
99
+ chain_endpoints.extend(non_removable_neighbors)
100
+
101
+ # Connect the endpoints if there are exactly 2
102
+ if len(chain_endpoints) == 2 and chain_endpoints[0] != chain_endpoints[1]:
103
+ new_bonds.append((chain_endpoints[0], chain_endpoints[1]))
104
+ elif len(chain_endpoints) > 2:
105
+ # For branched structures, don't create connections that would change topology
106
+ # This prevents fragmentation but may not be chemically meaningful
107
+ pass
108
+
109
+ # Create new molecule without removed atoms
110
+ rwm = Chem.RWMol()
111
+ _rwm = Chem.RWMol(mol)
112
+ Chem.Kekulize(_rwm)
113
+
114
+ # Map from old atom indices to new atom indices
115
+ old_to_new = {}
116
+ charged_atoms = []
117
+
118
+ # Add all atoms except those to be removed
119
+ for i, atom in enumerate(_rwm.GetAtoms()):
120
+ if i not in to_remove:
121
+ new_atom = Chem.Atom(atom.GetAtomicNum())
122
+ new_idx = rwm.AddAtom(new_atom)
123
+ # Copy formal charge if present
124
+ if atom.GetFormalCharge() != 0:
125
+ charged_atoms.append(atom.GetIdx())
126
+ old_to_new[i] = new_idx
127
+
128
+ # Copy existing bonds that don't involve removed atoms
129
+ for bond in _rwm.GetBonds():
130
+ a1 = bond.GetBeginAtomIdx()
131
+ a2 = bond.GetEndAtomIdx()
132
+ if a1 in old_to_new and a2 in old_to_new:
133
+ rwm.AddBond(old_to_new[a1], old_to_new[a2], bond.GetBondType())
134
+
135
+ # Add new bonds to maintain connectivity
136
+ for a, b in new_bonds:
137
+ if a in old_to_new and b in old_to_new and old_to_new[a] != old_to_new[b]:
138
+ # Check if bond already exists to avoid duplication
139
+ existing_bond = rwm.GetBondBetweenAtoms(old_to_new[a], old_to_new[b])
140
+ if existing_bond is None:
141
+ rwm.AddBond(old_to_new[a], old_to_new[b], Chem.BondType.SINGLE)
142
+
143
+ # Restore formal charges
144
+ for idx in charged_atoms:
145
+ if idx in old_to_new:
146
+ atom = rwm.GetAtomWithIdx(old_to_new[idx])
147
+ atom.SetFormalCharge(_rwm.GetAtomWithIdx(idx).GetFormalCharge())
148
+
149
+ try:
150
+ Chem.SanitizeMol(rwm)
151
+ except Exception as e:
152
+ if show_on_error is True:
153
+ _mol = rwm.GetMol()
154
+ try:
155
+ from .draw_mols import plot_mols # pylint: disable=import-outside-toplevel
156
+ img, _, _ = plot_mols([Chem.MolToSmiles(_mol)], subwidth=600, subheight=600, svg=False, addAtomIndices=True, bondLineWidth=0.5, fixedBondLength=15, minFontSize=12)
157
+ img.show()
158
+ except Exception: # pylint: disable=broad-exception-caught
159
+ pass
160
+ raise e
161
+
162
+ return rwm.GetMol()
163
+
164
+
165
+ def n_from_formula(formula:str, element=None)->Union[int,dict]:
166
+ """
167
+ Compute the number of elements (any or one specific)
168
+
169
+ :params formula: Formula to parse
170
+ :params element: Element's symbol to find
171
+ :return: Number of elements in the formula
172
+ """
173
+ if element is not None:
174
+ PAT = f"([{element}])"+r"(\d*)"
175
+ else:
176
+ PAT = r"([A-Z][a-z]?)(\d*)"
177
+ mat = re.findall(PAT,formula)
178
+ formula_dict = {}
179
+ for sym,nb in mat:
180
+ if nb != '':
181
+ formula_dict[sym] = formula_dict.setdefault(sym,0) + int(nb)
182
+ else:
183
+ formula_dict[sym] =formula_dict.setdefault(sym,0) + 1
184
+ if element is not None:
185
+ return formula_dict[element]
186
+ return formula_dict
187
+
188
+ def fragment_on_bond(mol, a1, a2):
189
+ """Fragment a molecule on a bond between atoms a1 and a2 (indices)"""
190
+ bond = mol.GetBondBetweenAtoms(a1, a2)
191
+ mms = Chem.FragmentOnBonds(mol, [bond.GetIdx()], addDummies=False)
192
+ return [x for x in Chem.GetMolFrags(mms, asMols=True, sanitizeFrags = False)]
193
+
194
+ def fragment_until_valence_is_correct(mol, frags, verbose=False, _events=None):
195
+ """Iterate over the molecule and fragment it until valence is corrected.
196
+
197
+ Parameters
198
+ ----------
199
+ mol : rdkit.Chem.Mol
200
+ Molecule to sanitize / fragment.
201
+ frags : list
202
+ Accumulator for sanitized fragment mols (pass ``[]`` on first call).
203
+ verbose : bool, optional
204
+ When *True* the function returns a 2-tuple ``(frags, events)`` instead
205
+ of just ``frags``. *events* is a list of dicts, one per fragmentation
206
+ step, each containing:
207
+ ``atom_idx`` – index of the offending atom,
208
+ ``error`` – the original :class:`~rdkit.Chem.AtomValenceException`
209
+ message,
210
+ ``n_fragments`` – how many pieces the bond-cut produced,
211
+ ``smiles`` – SMILES of the fragment being cut (may be ``None`` if
212
+ RDKit cannot generate one for an unsanitized mol).
213
+ _events : list or None
214
+ Internal accumulator used during recursion; do **not** pass explicitly.
215
+
216
+ Returns
217
+ -------
218
+ list or tuple
219
+ * ``verbose=False``: list of sanitized :class:`rdkit.Chem.Mol` fragments.
220
+ * ``verbose=True``: ``(frags, events)`` 2-tuple.
221
+ """
222
+ if verbose and _events is None:
223
+ _events = []
224
+ try:
225
+ Chem.SanitizeMol(mol)
226
+ except (Chem.AtomValenceException, Chem.KekulizeException, Chem.AtomKekulizeException) as e:
227
+ e_str = str(e)
228
+ # Kekulization failures (KekulizeException / AtomKekulizeException) mean the ring
229
+ # system has no valid Kekulé form. Fragmenting on ring bonds does not help because
230
+ # the open-chain fragment retains AROMATIC bond types that still can't be kekulized.
231
+ # Instead, re-sanitize while skipping only the kekulization step so the molecule
232
+ # keeps its aromatic bonds — sufficient for SMARTS matching. If even that fails,
233
+ # skip the fragment entirely ("ignore the ring") rather than crashing.
234
+ if 'Kekulize' in type(e).__name__:
235
+ try:
236
+ _skip_kekule = Chem.SanitizeFlags.SANITIZE_ALL ^ Chem.SanitizeFlags.SANITIZE_KEKULIZE
237
+ Chem.SanitizeMol(mol, _skip_kekule)
238
+ if verbose:
239
+ return frags + [mol], _events
240
+ return frags + [mol]
241
+ except Exception:
242
+ # Truly unresolvable — skip this fragment silently
243
+ if verbose:
244
+ return frags, _events
245
+ return frags
246
+ # AtomValenceException format: "atom # 6 N, ..." → digit after "# "
247
+ all = [int(x) for x in re.findall(r"(?<=#\s)(\d+)", e_str)]
248
+ if not all:
249
+ # KekulizeException format: "Unkekulized atoms: 1 2 3 5 6"
250
+ if 'atoms:' in e_str:
251
+ all = [int(x) for x in re.findall(r'\d+', e_str.split('atoms:', 1)[-1])]
252
+ if len(all) == 0:
253
+ if verbose:
254
+ return frags, _events
255
+ raise e
256
+ neighbours = mol.GetAtomWithIdx(all[0]).GetNeighbors()
257
+ if not neighbours:
258
+ # Isolated atom with no bonds — cannot fragment; skip this fragment.
259
+ if verbose:
260
+ return frags, _events
261
+ return frags
262
+ bond_order_pairs = [(mol.GetBondBetweenAtoms(all[0], x.GetIdx()).GetBondType(), x.GetIdx()) for x in neighbours]
263
+ atom = sorted(bond_order_pairs, reverse=True)[0][1] # neighbour with bond of highest degree
264
+ mols = fragment_on_bond(mol, all[0], atom)
265
+ if verbose:
266
+ try:
267
+ smi = Chem.MolToSmiles(mol)
268
+ except Exception:
269
+ smi = None
270
+ _events.append({
271
+ 'atom_idx': all[0],
272
+ 'error': str(e),
273
+ 'n_fragments': len(mols),
274
+ 'smiles': smi,
275
+ })
276
+ for m in mols:
277
+ try:
278
+ if verbose:
279
+ frags, _events = fragment_until_valence_is_correct(m, frags, verbose=True, _events=_events)
280
+ else:
281
+ frags = fragment_until_valence_is_correct(m, frags)
282
+ except Exception:
283
+ # Fragment could not be processed (isolated atom, bad valence, etc.) — skip it.
284
+ if verbose:
285
+ return frags, _events
286
+ return frags
287
+ if verbose:
288
+ return frags, _events
289
+ return frags
290
+ else:
291
+ if verbose:
292
+ return frags + [mol], _events
293
+ return frags + [mol]
294
+
295
+
296
+ def mol_to_nx(mol):
297
+ """Construct a networkx graph from a molecule"""
298
+ G = nx.Graph()
299
+ for n, atom in enumerate(mol.GetAtoms()):
300
+ element_Z = atom.GetAtomicNum()
301
+ node_params = {"element" : element_Z,
302
+ "symbol" : atom.GetSymbol()}
303
+ G.add_node(atom.GetIdx(),
304
+ **node_params
305
+ )
306
+ for bond in mol.GetBonds():
307
+ edgeOrder=bond.GetBondTypeAsDouble()
308
+ node1 = bond.GetBeginAtom()
309
+ node1 = node1.GetAtomicNum()
310
+ node2 = bond.GetEndAtom()
311
+ node2 = node2.GetAtomicNum()
312
+ G.add_edge(bond.GetBeginAtomIdx(),
313
+ bond.GetEndAtomIdx(),
314
+ order=edgeOrder)
315
+ return G
316
+
317
+ def get_substruct(_mol:Chem.Mol,struct:Chem.Mol):
318
+ """Returns the indices of the atoms in the molecule that match the substructure"""
319
+ return set([x[0] for x in _mol.GetSubstructMatches(struct)])
320
+
321
+
322
+ def preprocess_componentsSmarts(components):
323
+ """Preprocess component SMARTS to be ready to use in the algorithm
324
+ Used by default for groups in Halogen_groups_smarts.json
325
+ In case you submit your own components, they should follow the same structure, and you should preprocess your dictionary with this function before passing it to parse_mol"""
326
+ _paths={}
327
+ for halogen, parts in components.items():
328
+ for form, saturations in parts.items():
329
+ for saturation, names in saturations.items():
330
+ s = names['component']
331
+ n = names['name']
332
+ smol = Chem.MolFromSmarts(s)
333
+ smol.UpdatePropertyCache()
334
+ Chem.GetSymmSSSR(smol)
335
+ smol.GetRingInfo().NumRings()
336
+ _paths[n] = {
337
+ "component": smol,
338
+ "halogen": halogen,
339
+ "form": form,
340
+ "saturation": saturation,
341
+ "constraints": names.get("constraints", {}),
342
+ }
343
+ return _paths
344
+
345
+ # --- Add SMARTS paths to function ---
346
+ def add_componentSmarts(filename = COMPONENTS_FILE):
347
+ """Yields SMARTS for chains
348
+
349
+ Supports filtering by halogen, form, and saturation via kwargs:
350
+ - halogens: str or list of str, element symbols (e.g., 'F', ['F', 'Cl'])
351
+ - form: str or list of str, form types (e.g., 'alkyl', ['alkyl', 'cyclic'])
352
+ - saturation: str or list of str, saturation types (e.g., 'per', 'poly')
353
+ """
354
+ paths = {}
355
+ with open(filename,'r') as f:
356
+ Hcomponents = json.load(f)
357
+ paths = preprocess_componentsSmarts(Hcomponents)
358
+ def inner(func):
359
+ def wrapper(*args,**kwargs):
360
+ # Extract filter parameters. Pop halogens so it doesn't conflict with
361
+ # inner function signatures, but restore it so load_componentsSolver
362
+ # can still read it after add_componentSmarts runs.
363
+ halogens = kwargs.pop('halogens', None)
364
+ form = kwargs.pop('form', None)
365
+ saturation = kwargs.pop('saturation', None)
366
+ # Restore halogens for downstream decorators (e.g. load_componentsSolver)
367
+ if halogens is not None:
368
+ kwargs['halogens'] = halogens
369
+
370
+ # Normalize filters to lists
371
+ if halogens is not None:
372
+ halogens = [halogens] if isinstance(halogens, str) else list(halogens)
373
+ if form is not None:
374
+ form = [form] if isinstance(form, str) else list(form)
375
+ if saturation is not None:
376
+ saturation = [saturation] if isinstance(saturation, str) else list(saturation)
377
+
378
+ # Filter paths if any filters are specified
379
+ filtered_paths = paths
380
+ if halogens or form or saturation:
381
+ filtered_paths = {}
382
+ for name, path_info in paths.items():
383
+ # Check halogen filter
384
+ if halogens is not None and path_info['halogen'] not in halogens:
385
+ continue
386
+ # Check form filter
387
+ if form is not None and path_info['form'] not in form:
388
+ continue
389
+ # Check saturation filter
390
+ if saturation is not None and path_info['saturation'] not in saturation:
391
+ continue
392
+ filtered_paths[name] = path_info
393
+ kwargs["componentSmartss"] = kwargs.get("componentSmartss", filtered_paths)
394
+ return func(*args, **kwargs)
395
+ return wrapper
396
+ return inner
397
+
398
+ def add_smarts(name = 'smarts'):
399
+ """Yields preprocessed SMARTS to decorated function"""
400
+ smarts = {}
401
+ def add(s):
402
+ smol = Chem.MolFromSmarts(s)
403
+ smol.UpdatePropertyCache()
404
+ Chem.GetSymmSSSR(smol)
405
+ smol.GetRingInfo().NumRings()
406
+ smarts[s]=smol
407
+ return smol
408
+ def get(s):
409
+ return smarts.get(s,add(s))
410
+ def inner(func):
411
+ def wrapper(*args,**kwargs):
412
+ kwargs[name] = get(kwargs.get(name))
413
+ return func(*args, **kwargs)
414
+ return wrapper
415
+ return inner