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/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)