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,473 @@
1
+ """HomologueSeries – result container for :func:`~PFASGroups.generate_homologues`.
2
+
3
+ Backward-compatible with the raw ``{InChIKey: {formula: mol}}`` dict that
4
+ ``generate_homologues`` previously returned. All existing code that iterates
5
+ or indexes the dict continues to work unchanged.
6
+
7
+ The class adds:
8
+
9
+ - :meth:`summary` / :meth:`summarise` – coloured text summary
10
+ - :meth:`show` / :meth:`plot` – grid image of all homologues
11
+ - :meth:`svg` – export to an SVG file
12
+ - :meth:`to_sql` – persist to SQLite or PostgreSQL
13
+
14
+ Example
15
+ -------
16
+ >>> from rdkit import Chem
17
+ >>> from HalogenGroups import generate_homologues
18
+ >>> pfoa = Chem.MolFromSmiles('OC(=O)' + 'C(F)(F)' * 7 + 'F')
19
+ >>> series = generate_homologues(pfoa)
20
+ >>> series.summary()
21
+ >>> img = series.show()
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from io import BytesIO
28
+ from typing import Any, List, Optional, Tuple, Union
29
+
30
+ from PIL import Image
31
+ from rdkit import Chem
32
+ from rdkit.Chem import Draw
33
+ from rdkit.Chem.rdMolDescriptors import CalcMolFormula
34
+
35
+ # Re-use ANSI helpers and grid utility from PFASEmbeddings
36
+ from .PFASEmbeddings import _ansi, _ANSI_BOLD, _ANSI_HALOGEN, _grid_images
37
+
38
+ Colour = Tuple[float, float, float]
39
+ Color = Colour # US alias
40
+
41
+ # Colours for parent vs homologue panels
42
+ _PARENT_COLOUR: Colour = (0.95, 0.50, 0.10) # orange
43
+ _PARENT_COLOR = _PARENT_COLOUR # US alias
44
+ _HOMOLOGUE_COLOUR: Colour = (0.20, 0.60, 0.95) # blue
45
+ _HOMOLOGUE_COLOR = _HOMOLOGUE_COLOUR # US alias
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # HomologueEntry – lightweight descriptor for a single homologue
50
+ # ---------------------------------------------------------------------------
51
+
52
+ @dataclass
53
+ class HomologueEntry:
54
+ """One homologue molecule from a :class:`HomologueSeries`.
55
+
56
+ Parameters
57
+ ----------
58
+ mol :
59
+ RDKit molecule.
60
+ formula :
61
+ Molecular formula string (e.g. ``'C6HF11O2'``).
62
+ inchikey :
63
+ InChIKey of this homologue.
64
+ n_removed :
65
+ Number of CX2 units removed relative to the parent.
66
+ """
67
+
68
+ mol: Chem.Mol
69
+ formula: str
70
+ inchikey: str
71
+ n_removed: int
72
+
73
+ @property
74
+ def smiles(self) -> str:
75
+ return Chem.MolToSmiles(self.mol)
76
+
77
+ @property
78
+ def n_carbons(self) -> int:
79
+ return sum(1 for a in self.mol.GetAtoms() if a.GetSymbol() == 'C')
80
+
81
+ def __repr__(self) -> str: # pragma: no cover
82
+ return (
83
+ f"HomologueEntry(formula={self.formula!r}, "
84
+ f"n_removed={self.n_removed}, smiles={self.smiles!r})"
85
+ )
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # HomologueSeries – dict subclass (backward-compatible) with rich API
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class HomologueSeries(dict):
93
+ """Result container for :func:`~PFASGroups.generate_homologues`.
94
+
95
+ Inherits from :class:`dict` so that ``series[inchikey][formula]`` access
96
+ and ``for inchikey, inner in series.items()`` still work exactly as before.
97
+
98
+ Additional attributes set by :func:`~PFASGroups.generate_homologues`:
99
+
100
+ Attributes
101
+ ----------
102
+ parent_mol : rdkit.Chem.Mol or None
103
+ The original molecule that was shortened.
104
+ parent_smiles : str or None
105
+ Canonical SMILES of the parent.
106
+ parent_formula : str or None
107
+ Molecular formula of the parent.
108
+ halogen : str
109
+ Halogen element symbol used (``'F'``, ``'Cl'``, …).
110
+ component_name : str
111
+ Name of the component SMARTS used (e.g. ``'Perfluoroalkyl'``).
112
+
113
+ These are set post-construction via :meth:`_set_metadata` (called
114
+ internally by ``generate_homologues``).
115
+ """
116
+
117
+ def __init__(self, *args, **kwargs):
118
+ super().__init__(*args, **kwargs)
119
+ # Metadata – populated by generate_homologues after construction
120
+ self.parent_mol: Optional[Chem.Mol] = None
121
+ self.parent_smiles: Optional[str] = None
122
+ self.parent_formula: Optional[str] = None
123
+ self.halogen: str = 'F'
124
+ self.component_name: str = 'Perfluoroalkyl'
125
+
126
+ def _set_metadata(
127
+ self,
128
+ parent_mol: Chem.Mol,
129
+ halogen: str,
130
+ component_name: str,
131
+ ) -> None:
132
+ """Called by ``generate_homologues`` to attach provenance info."""
133
+ self.parent_mol = parent_mol
134
+ self.parent_smiles = Chem.MolToSmiles(parent_mol)
135
+ self.parent_formula = CalcMolFormula(parent_mol)
136
+ self.halogen = halogen
137
+ self.component_name = component_name
138
+
139
+ # ------------------------------------------------------------------
140
+ # Convenience iteration helpers
141
+ # ------------------------------------------------------------------
142
+
143
+ def entries(self) -> List[HomologueEntry]:
144
+ """Return all homologues as a sorted list of :class:`HomologueEntry`.
145
+
146
+ Sorted by the number of CX2 units removed (ascending), i.e. from
147
+ the longest (closest to parent) to the shortest homologue.
148
+ """
149
+ parent_c = (
150
+ sum(1 for a in self.parent_mol.GetAtoms() if a.GetSymbol() == 'C')
151
+ if self.parent_mol is not None else None
152
+ )
153
+ result: List[HomologueEntry] = []
154
+ for inchikey, inner in self.items():
155
+ for formula, mol in inner.items():
156
+ child_c = sum(1 for a in mol.GetAtoms() if a.GetSymbol() == 'C')
157
+ n_removed = (parent_c - child_c) if parent_c is not None else 0
158
+ result.append(
159
+ HomologueEntry(
160
+ mol=mol,
161
+ formula=formula,
162
+ inchikey=inchikey,
163
+ n_removed=n_removed,
164
+ )
165
+ )
166
+ result.sort(key=lambda e: e.n_removed)
167
+ return result
168
+
169
+ def mols(self) -> List[Chem.Mol]:
170
+ """Return all homologue molecules in order (shortest first)."""
171
+ return [e.mol for e in self.entries()]
172
+
173
+ # ------------------------------------------------------------------
174
+ # Text output
175
+ # ------------------------------------------------------------------
176
+
177
+ def summarise(self) -> str:
178
+ """Return a coloured text summary of the homologous series."""
179
+ hal_code = _ANSI_HALOGEN.get(self.halogen, '')
180
+ lines: List[str] = []
181
+ lines.append(_ansi("HomologueSeries summary", _ANSI_BOLD))
182
+ if self.parent_smiles:
183
+ lines.append(
184
+ f"- Parent : {self.parent_smiles} "
185
+ f"({_ansi(self.parent_formula or '?', hal_code)})"
186
+ )
187
+ lines.append(
188
+ f"- Halogen : {_ansi(self.halogen, hal_code)}"
189
+ f" | Component : {self.component_name}"
190
+ )
191
+ if not self:
192
+ lines.append("- No homologues found (no repeating CX2 units).")
193
+ return "\n".join(lines)
194
+
195
+ lines.append(f"- Homologues ({len(self)}):")
196
+ for entry in self.entries():
197
+ line = (
198
+ f" [{entry.n_removed:+d} CX2] "
199
+ f"{_ansi(entry.formula, hal_code)} "
200
+ f"{entry.smiles}"
201
+ )
202
+ lines.append(line)
203
+ return "\n".join(lines)
204
+
205
+ def summary(self) -> None:
206
+ """Print the coloured text summary."""
207
+ print(self.summarise())
208
+
209
+ # ------------------------------------------------------------------
210
+ # Visualisation helpers
211
+ # ------------------------------------------------------------------
212
+
213
+ def _draw_mol_image(
214
+ self,
215
+ mol: Chem.Mol,
216
+ legend: str = "",
217
+ subwidth: int = 300,
218
+ subheight: int = 300,
219
+ highlight_atoms: Optional[List[int]] = None,
220
+ highlight_colour: Optional[Colour] = None,
221
+ ) -> Image.Image:
222
+ """Render *mol* as a PIL image using Cairo."""
223
+ d2d = Draw.MolDraw2DCairo(subwidth, subheight)
224
+ dopts = d2d.drawOptions()
225
+ dopts.useBWAtomPalette()
226
+ dopts.fixedBondLength = 20
227
+ dopts.addAtomIndices = False
228
+ dopts.maxFontSize = 14
229
+ dopts.minFontSize = 11
230
+
231
+ hl_atoms = highlight_atoms or []
232
+ if hl_atoms and highlight_colour:
233
+ atom_colors = {idx: highlight_colour for idx in hl_atoms}
234
+ d2d.DrawMolecule(
235
+ mol,
236
+ legend=legend,
237
+ highlightAtoms=hl_atoms,
238
+ highlightAtomColors=atom_colors,
239
+ )
240
+ else:
241
+ d2d.DrawMolecule(mol, legend=legend, highlightAtoms=hl_atoms)
242
+
243
+ d2d.FinishDrawing()
244
+ return Image.open(BytesIO(d2d.GetDrawingText()))
245
+
246
+ def _draw_mol_svg(
247
+ self,
248
+ mol: Chem.Mol,
249
+ legend: str = "",
250
+ subwidth: int = 300,
251
+ subheight: int = 300,
252
+ ) -> str:
253
+ """Render *mol* as an SVG string."""
254
+ d2d = Draw.MolDraw2DSVG(subwidth, subheight)
255
+ dopts = d2d.drawOptions()
256
+ dopts.useBWAtomPalette()
257
+ dopts.fixedBondLength = 20
258
+ dopts.addAtomIndices = False
259
+ dopts.maxFontSize = 14
260
+ dopts.minFontSize = 11
261
+ d2d.DrawMolecule(mol, legend=legend)
262
+ d2d.FinishDrawing()
263
+ return d2d.GetDrawingText()
264
+
265
+ def show(
266
+ self,
267
+ display: bool = True,
268
+ subwidth: int = 300,
269
+ subheight: int = 300,
270
+ ncols: int = 4,
271
+ show_parent: bool = True,
272
+ ) -> Image.Image:
273
+ """Render all homologues as a grid image.
274
+
275
+ Parameters
276
+ ----------
277
+ display :
278
+ Whether to call ``image.show()`` immediately.
279
+ subwidth, subheight :
280
+ Pixel dimensions of each panel.
281
+ ncols :
282
+ Number of columns in the grid.
283
+ show_parent :
284
+ If ``True``, prepend a panel showing the parent molecule
285
+ (highlighted in orange) before the homologues.
286
+
287
+ Returns
288
+ -------
289
+ PIL.Image.Image
290
+ Grid image.
291
+ """
292
+ if not self and not (show_parent and self.parent_mol is not None):
293
+ raise ValueError("No molecules to display (empty HomologueSeries).")
294
+
295
+ imgs: List[Image.Image] = []
296
+
297
+ if show_parent and self.parent_mol is not None:
298
+ parent_label = f"Parent {self.parent_formula or ''}"
299
+ img = self._draw_mol_image(
300
+ self.parent_mol,
301
+ legend=parent_label,
302
+ subwidth=subwidth,
303
+ subheight=subheight,
304
+ )
305
+ imgs.append(img)
306
+
307
+ for entry in self.entries():
308
+ label = f"-{entry.n_removed} CX2 {entry.formula}"
309
+ img = self._draw_mol_image(
310
+ entry.mol,
311
+ legend=label,
312
+ subwidth=subwidth,
313
+ subheight=subheight,
314
+ )
315
+ imgs.append(img)
316
+
317
+ if not imgs:
318
+ raise ValueError("No molecules to display (empty HomologueSeries).")
319
+
320
+ grid, _, _ = _grid_images(imgs, buffer=4, ncols=ncols)
321
+ if display:
322
+ grid.show()
323
+ return grid
324
+
325
+ # Alias
326
+ plot = show
327
+
328
+ def svg(
329
+ self,
330
+ filename: str,
331
+ subwidth: int = 300,
332
+ subheight: int = 300,
333
+ ncols: int = 4,
334
+ show_parent: bool = True,
335
+ ) -> str:
336
+ """Export all homologues to an SVG file.
337
+
338
+ Parameters
339
+ ----------
340
+ filename :
341
+ Destination path (e.g. ``'series.svg'``).
342
+ subwidth, subheight :
343
+ Pixel dimensions of each panel.
344
+ ncols :
345
+ Number of columns.
346
+ show_parent :
347
+ Prepend a parent-molecule panel.
348
+
349
+ Returns
350
+ -------
351
+ str
352
+ The path written to (*filename*).
353
+ """
354
+ import svgutils.transform as sg
355
+ from .draw_mols import merge_svg
356
+
357
+ svg_strs: List[str] = []
358
+
359
+ if show_parent and self.parent_mol is not None:
360
+ parent_label = f"Parent {self.parent_formula or ''}"
361
+ svg_strs.append(
362
+ self._draw_mol_svg(self.parent_mol, legend=parent_label,
363
+ subwidth=subwidth, subheight=subheight)
364
+ )
365
+
366
+ for entry in self.entries():
367
+ label = f"-{entry.n_removed} CX2 {entry.formula}"
368
+ svg_strs.append(
369
+ self._draw_mol_svg(entry.mol, legend=label,
370
+ subwidth=subwidth, subheight=subheight)
371
+ )
372
+
373
+ if not svg_strs:
374
+ raise ValueError("No molecules to export (empty HomologueSeries).")
375
+
376
+ figs = [sg.fromstring(s) for s in svg_strs]
377
+ grid, _, _ = merge_svg(figs, buffer=4, ncols=ncols)
378
+ grid.save(filename)
379
+ return filename
380
+
381
+ # ------------------------------------------------------------------
382
+ # Persistence
383
+ # ------------------------------------------------------------------
384
+
385
+ def to_sql(
386
+ self,
387
+ conn: Optional[Union[str, Any]] = None,
388
+ filename: Optional[str] = None,
389
+ table_name: str = "homologue_series",
390
+ if_exists: str = "append",
391
+ ) -> None:
392
+ """Save the homologous series to a SQL database.
393
+
394
+ Each row represents one homologue. The parent molecule is stored in
395
+ the ``parent_smiles`` column for grouping.
396
+
397
+ Parameters
398
+ ----------
399
+ conn :
400
+ SQLAlchemy engine, connection string, or ``None``.
401
+ filename :
402
+ SQLite database path (used when *conn* is ``None``).
403
+ table_name :
404
+ Name of the table to write to.
405
+ if_exists :
406
+ ``'append'``, ``'replace'``, or ``'fail'``.
407
+
408
+ Raises
409
+ ------
410
+ ValueError
411
+ If neither *conn* nor *filename* is supplied.
412
+ ImportError
413
+ If ``pandas`` or ``sqlalchemy`` are not installed.
414
+
415
+ Examples
416
+ --------
417
+ >>> series.to_sql(filename='pfas_homologues.db')
418
+ >>> series.to_sql(conn='postgresql://user:pass@localhost/pfasdb')
419
+ """
420
+ try:
421
+ import pandas as pd
422
+ import sqlalchemy
423
+ except ImportError as exc:
424
+ raise ImportError(
425
+ "pandas and sqlalchemy are required for to_sql. "
426
+ "Install with: pip install pandas sqlalchemy"
427
+ ) from exc
428
+
429
+ if conn is None and filename is None:
430
+ raise ValueError("Either 'conn' or 'filename' must be provided.")
431
+
432
+ if conn is not None:
433
+ engine = (
434
+ sqlalchemy.create_engine(conn)
435
+ if isinstance(conn, str)
436
+ else conn
437
+ )
438
+ else:
439
+ engine = sqlalchemy.create_engine(f"sqlite:///{filename}")
440
+
441
+ rows = []
442
+ for entry in self.entries():
443
+ rows.append(
444
+ {
445
+ "parent_smiles": self.parent_smiles or "",
446
+ "parent_formula": self.parent_formula or "",
447
+ "halogen": self.halogen,
448
+ "component_name": self.component_name,
449
+ "inchikey": entry.inchikey,
450
+ "formula": entry.formula,
451
+ "smiles": entry.smiles,
452
+ "n_removed": entry.n_removed,
453
+ "n_carbons": entry.n_carbons,
454
+ }
455
+ )
456
+
457
+ if not rows:
458
+ return # nothing to write
459
+
460
+ df = pd.DataFrame(rows)
461
+ df.to_sql(table_name, engine, if_exists=if_exists, index=False)
462
+
463
+ # ------------------------------------------------------------------
464
+ # Dunder niceties
465
+ # ------------------------------------------------------------------
466
+
467
+ def __repr__(self) -> str: # pragma: no cover
468
+ parent = self.parent_smiles or "?"
469
+ return (
470
+ f"HomologueSeries(parent={parent!r}, "
471
+ f"halogen={self.halogen!r}, "
472
+ f"n_homologues={len(self)})"
473
+ )