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,256 @@
1
+ from .core import add_componentSmarts, get_substruct, remove_atoms
2
+ from .homologue_series import HomologueSeries
3
+ from rdkit import Chem
4
+ from rdkit.Chem.rdMolDescriptors import CalcMolFormula
5
+ import networkx as nx
6
+ from itertools import combinations
7
+
8
+
9
+ def _mol_from_input(mol_or_string):
10
+ """Accept a Mol, SMILES string, or InChI string; always return a Chem.Mol.
11
+
12
+ Parameters
13
+ ----------
14
+ mol_or_string :
15
+ ``rdkit.Chem.Mol``, a SMILES string, or an InChI string
16
+ (must start with ``'InChI='``).
17
+
18
+ Returns
19
+ -------
20
+ rdkit.Chem.Mol
21
+
22
+ Raises
23
+ ------
24
+ ValueError
25
+ If the string cannot be parsed as a valid molecule.
26
+ TypeError
27
+ If the input type is not supported.
28
+ """
29
+ if isinstance(mol_or_string, Chem.Mol):
30
+ return mol_or_string
31
+ if isinstance(mol_or_string, str):
32
+ s = mol_or_string.strip()
33
+ if s.startswith('InChI='):
34
+ mol = Chem.MolFromInchi(s)
35
+ else:
36
+ mol = Chem.MolFromSmiles(s)
37
+ if mol is None:
38
+ raise ValueError(
39
+ f"Could not parse input as a molecule: {s!r}"
40
+ )
41
+ return mol
42
+ raise TypeError(
43
+ f"Expected rdkit.Chem.Mol or str, got {type(mol_or_string).__name__}"
44
+ )
45
+
46
+ # Halogen element symbols and their atomic numbers used to build per-halogen SMARTS
47
+ _HALOGEN_ATOMIC_NUM = {'F': 9, 'Cl': 17, 'Br': 35, 'I': 53}
48
+
49
+ # Default componentSmartsName per halogen (per-halo, alkyl chain)
50
+ _DEFAULT_COMPONENT_NAME = {
51
+ 'F': 'Perfluoroalkyl',
52
+ 'Cl': 'Perchloroalkyl',
53
+ 'Br': 'Perbromoalkyl',
54
+ 'I': 'Periodoalkyl',
55
+ }
56
+
57
+
58
+ def find_halogenated_components(mol, component_smarts, halogen='F'):
59
+ """Find connected halogenated subgraphs in *mol* using a component SMARTS.
60
+
61
+ Unlike the old ``find_chain`` approach, this function does **not** require
62
+ ``end`` SMARTS or shortest-path traversal. Instead it:
63
+
64
+ 1. Matches every atom in *mol* against *component_smarts* (the ``component``
65
+ entry from ``component_smarts_halogens.json``).
66
+ 2. Builds a subgraph restricted to the matched atoms.
67
+ 3. Returns the connected components of that subgraph together with the set
68
+ of halogen-bearing carbon indices (candidate repeating-unit carbons)
69
+ within each component.
70
+
71
+ Parameters
72
+ ----------
73
+ mol : rdkit.Chem.Mol
74
+ Input molecule.
75
+ component_smarts : rdkit.Chem.Mol
76
+ Pre-compiled SMARTS mol object for the halogenated component pattern.
77
+ halogen : str, default ``'F'``
78
+ Halogen element symbol (``'F'``, ``'Cl'``, ``'Br'``, ``'I'``).
79
+
80
+ Returns
81
+ -------
82
+ list of dict
83
+ Each entry describes one halogenated component:
84
+
85
+ - ``'component'`` (``frozenset[int]``) – atom indices of all component atoms.
86
+ - ``'cx2_carbons'`` (``list[int]``) – backbone C atoms bearing ≥ 2 halogen
87
+ substituents (i.e., candidate ``CX2`` units for homologue generation).
88
+ """
89
+ component_atom_idxs = get_substruct(mol, component_smarts)
90
+ if not component_atom_idxs:
91
+ return []
92
+
93
+ # Build subgraph restricted to component atoms
94
+ G = nx.Graph()
95
+ G.add_nodes_from(component_atom_idxs)
96
+ for bond in mol.GetBonds():
97
+ a, b = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
98
+ if a in component_atom_idxs and b in component_atom_idxs:
99
+ G.add_edge(a, b)
100
+
101
+ results = []
102
+ for cc in nx.connected_components(G):
103
+ # Identify CX2 backbone carbons: C in component bearing *exactly* 2 halogen
104
+ # neighbours. Terminal CF3 (3 halogens) and mono-halo atoms are excluded —
105
+ # only true -CX2- repeating units qualify.
106
+ cx2 = [
107
+ idx for idx in cc
108
+ if mol.GetAtomWithIdx(idx).GetSymbol() == 'C'
109
+ and sum(
110
+ 1 for nb in mol.GetAtomWithIdx(idx).GetNeighbors()
111
+ if nb.GetSymbol() == halogen
112
+ ) == 2
113
+ ]
114
+ results.append({'component': frozenset(cc), 'cx2_carbons': cx2})
115
+
116
+ return results
117
+
118
+
119
+ @add_componentSmarts()
120
+ def generate_homologues(mol_input, componentSmartsName=None, componentSmartss=None,
121
+ halogen='F', base_repeating=None):
122
+ """Generate a homologous series by removing halogenated repeating units.
123
+
124
+ This implementation uses a **component-based** approach:
125
+
126
+ 1. The ``component`` SMARTS from *componentSmartss* is used to identify all
127
+ atoms belonging to halogenated sub-structures (no ``end`` SMARTS needed).
128
+ 2. Within each connected halogenated component, every carbon bearing ≥ 2
129
+ halogen atoms (a ``CX2`` unit) is a candidate for removal.
130
+ 3. All non-empty subsets of those ``CX2`` units are tried; each subset is
131
+ removed via :func:`~HalogenGroups.core.remove_atoms`, rebuilding
132
+ connectivity across the gap.
133
+ 4. Valid, connected products are collected and deduplicated by InChIKey.
134
+
135
+ Parameters
136
+ ----------
137
+ mol : rdkit.Chem.Mol
138
+ Parent molecule.
139
+ componentSmartsName : str, optional
140
+ Key in *componentSmartss* selecting which component pattern to use.
141
+ Defaults to the per-halogen value in ``_DEFAULT_COMPONENT_NAME``
142
+ (e.g. ``'Perfluoroalkyl'`` for ``halogen='F'``).
143
+ componentSmartss : dict, optional
144
+ Mapping ``{name: {'component': <Mol>, ...}}``.
145
+ Populated automatically by the ``@add_componentSmarts`` decorator.
146
+ halogen : str, default ``'F'``
147
+ Halogen element symbol. Controls which atoms count as the repeating
148
+ substituents and which component pattern is selected by default.
149
+ Accepted values: ``'F'``, ``'Cl'``, ``'Br'``, ``'I'``.
150
+ base_repeating : list of str, optional
151
+ Backbone element symbols that are *kept* when a CX2 unit is removed
152
+ (their halogen substituents are stripped, not the backbone C itself).
153
+ Defaults to ``['C']``.
154
+
155
+ Returns
156
+ -------
157
+ dict
158
+ ``{InChIKey: {formula: rdkit.Chem.Mol}}`` – all unique shorter
159
+ homologues, keyed first by InChIKey and then by molecular formula.
160
+
161
+ Raises
162
+ ------
163
+ ValueError
164
+ If *halogen* is not one of ``'F'``, ``'Cl'``, ``'Br'``, ``'I'``.
165
+ ValueError
166
+ If atom removal yields a fragmented molecule (diagnostic aid).
167
+
168
+ Examples
169
+ --------
170
+ >>> from rdkit import Chem
171
+ >>> from HalogenGroups.generate_homologues import generate_homologues
172
+ >>> # PFOA – generate all shorter perfluoroalkyl chain homologues
173
+ >>> pfoa = Chem.MolFromSmiles('OC(=O)' + 'C(F)(F)' * 7 + 'F')
174
+ >>> homologues = generate_homologues(pfoa)
175
+ >>> print(len(homologues)) # 6 (C2-C7)
176
+
177
+ >>> # Chlorinated analogue
178
+ >>> pca = Chem.MolFromSmiles('OC(=O)' + 'C(Cl)(Cl)' * 5 + 'Cl')
179
+ >>> homologues = generate_homologues(pca, halogen='Cl')
180
+
181
+ >>> # From SMILES string directly
182
+ >>> homologues = generate_homologues('OC(=O)C(F)(F)C(F)(F)C(F)(F)F')
183
+
184
+ >>> # From InChI string
185
+ >>> homologues = generate_homologues('InChI=1S/C3HF5O2/...')
186
+
187
+ Notes
188
+ -----
189
+ * Removing a CX2 unit strips the backbone carbon **and** all directly
190
+ attached halogen atoms; connectivity to the rest of the molecule is
191
+ preserved by :func:`~HalogenGroups.core.remove_atoms`.
192
+ * Branched structures are handled correctly because component detection
193
+ is graph-based, not path-based.
194
+ * The original molecule is *not* included in the output.
195
+ """
196
+ # Accept SMILES / InChI strings in addition to Chem.Mol
197
+ mol = _mol_from_input(mol_input)
198
+
199
+ if halogen not in _HALOGEN_ATOMIC_NUM:
200
+ raise ValueError(
201
+ f"halogen must be one of {list(_HALOGEN_ATOMIC_NUM)}, got {halogen!r}"
202
+ )
203
+ if base_repeating is None:
204
+ base_repeating = ['C']
205
+
206
+ # Resolve component SMARTS name
207
+ if componentSmartsName is None:
208
+ componentSmartsName = _DEFAULT_COMPONENT_NAME[halogen]
209
+
210
+ # Look up the component SMARTS mol object
211
+ entry = componentSmartss.get(componentSmartsName)
212
+ if entry is None:
213
+ raise ValueError(
214
+ f"Component '{componentSmartsName}' not found in componentSmartss. "
215
+ f"Available: {list(componentSmartss)}"
216
+ )
217
+ component_smarts = entry['component'] if isinstance(entry, dict) else entry
218
+
219
+ # Atoms that are removed together with a CX2 backbone carbon
220
+ # (only the halogen neighbours are stripped alongside the backbone C)
221
+
222
+ # Discover halogenated components and their CX2 carbons
223
+ components = find_halogenated_components(mol, component_smarts, halogen=halogen)
224
+
225
+ # Collect all CX2 carbon indices across all components
226
+ all_cx2 = [idx for comp in components for idx in comp['cx2_carbons']]
227
+
228
+ series = HomologueSeries()
229
+
230
+ if not all_cx2:
231
+ series._set_metadata(mol, halogen, componentSmartsName)
232
+ return series
233
+
234
+ # Enumerate every non-empty proper subset of CX2 units
235
+ for r in range(1, len(all_cx2) + 1):
236
+ for subset in combinations(all_cx2, r):
237
+ flat_idx = list(subset)
238
+ try:
239
+ h = remove_atoms(mol, flat_idx, removable=[halogen])
240
+ except Exception as e:
241
+ print(
242
+ f"Error removing atoms {flat_idx} from "
243
+ f"{Chem.MolToSmiles(mol)}: {e}"
244
+ )
245
+ raise e
246
+ if len(Chem.GetMolFrags(h)) > 1:
247
+ raise ValueError(
248
+ f"Fragmented molecule {Chem.MolToSmiles(h)} after removing "
249
+ f"atoms {flat_idx} from {Chem.MolToSmiles(mol)}"
250
+ )
251
+ inchikey = Chem.MolToInchiKey(h)
252
+ formula = CalcMolFormula(h)
253
+ series.setdefault(inchikey, {})[formula] = h
254
+
255
+ series._set_metadata(mol, halogen, componentSmartsName)
256
+ return series