workbench 0.8.172__py3-none-any.whl → 0.8.173__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.
Files changed (36) hide show
  1. workbench/algorithms/graph/light/proximity_graph.py +2 -1
  2. workbench/api/compound.py +1 -1
  3. workbench/api/monitor.py +1 -16
  4. workbench/core/artifacts/data_capture_core.py +315 -0
  5. workbench/core/artifacts/endpoint_core.py +9 -3
  6. workbench/core/artifacts/monitor_core.py +33 -249
  7. workbench/core/transforms/data_to_features/light/molecular_descriptors.py +4 -4
  8. workbench/model_scripts/custom_models/chem_info/mol_descriptors.py +471 -0
  9. workbench/model_scripts/custom_models/chem_info/mol_standardize.py +428 -0
  10. workbench/model_scripts/custom_models/chem_info/molecular_descriptors.py +7 -9
  11. workbench/model_scripts/custom_models/uq_models/generated_model_script.py +95 -204
  12. workbench/model_scripts/xgb_model/generated_model_script.py +5 -5
  13. workbench/repl/workbench_shell.py +3 -3
  14. workbench/utils/chem_utils/__init__.py +0 -0
  15. workbench/utils/chem_utils/fingerprints.py +134 -0
  16. workbench/utils/chem_utils/misc.py +194 -0
  17. workbench/utils/chem_utils/mol_descriptors.py +471 -0
  18. workbench/utils/chem_utils/mol_standardize.py +428 -0
  19. workbench/utils/chem_utils/mol_tagging.py +348 -0
  20. workbench/utils/chem_utils/projections.py +209 -0
  21. workbench/utils/chem_utils/salts.py +256 -0
  22. workbench/utils/chem_utils/sdf.py +292 -0
  23. workbench/utils/chem_utils/toxicity.py +250 -0
  24. workbench/utils/chem_utils/vis.py +253 -0
  25. workbench/utils/monitor_utils.py +49 -56
  26. workbench/utils/pandas_utils.py +3 -3
  27. workbench/web_interface/components/plugins/generated_compounds.py +1 -1
  28. {workbench-0.8.172.dist-info → workbench-0.8.173.dist-info}/METADATA +1 -1
  29. {workbench-0.8.172.dist-info → workbench-0.8.173.dist-info}/RECORD +33 -22
  30. workbench/model_scripts/custom_models/chem_info/local_utils.py +0 -769
  31. workbench/model_scripts/custom_models/chem_info/tautomerize.py +0 -83
  32. workbench/utils/chem_utils.py +0 -1556
  33. {workbench-0.8.172.dist-info → workbench-0.8.173.dist-info}/WHEEL +0 -0
  34. {workbench-0.8.172.dist-info → workbench-0.8.173.dist-info}/entry_points.txt +0 -0
  35. {workbench-0.8.172.dist-info → workbench-0.8.173.dist-info}/licenses/LICENSE +0 -0
  36. {workbench-0.8.172.dist-info → workbench-0.8.173.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,194 @@
1
+ """Miscellaneous processing functions for molecular data."""
2
+
3
+ import logging
4
+ import numpy as np
5
+ import pandas as pd
6
+ from typing import List, Optional
7
+
8
+ # Set up the logger
9
+ log = logging.getLogger("workbench")
10
+
11
+
12
+ def geometric_mean(series: pd.Series) -> float:
13
+ """Computes the geometric mean manually to avoid using scipy."""
14
+ return np.exp(np.log(series).mean())
15
+
16
+
17
+ def rollup_experimental_data(
18
+ df: pd.DataFrame, id: str, time: str, target: str, use_gmean: bool = False
19
+ ) -> pd.DataFrame:
20
+ """
21
+ Rolls up a dataset by selecting the largest time per unique ID and averaging the target value
22
+ if multiple records exist at that time. Supports both arithmetic and geometric mean.
23
+
24
+ Parameters:
25
+ df (pd.DataFrame): Input dataframe.
26
+ id (str): Column representing the unique molecule ID.
27
+ time (str): Column representing the time.
28
+ target (str): Column representing the target value.
29
+ use_gmean (bool): Whether to use the geometric mean instead of the arithmetic mean.
30
+
31
+ Returns:
32
+ pd.DataFrame: Rolled-up dataframe with all original columns retained.
33
+ """
34
+ # Find the max time per unique ID
35
+ max_time_df = df.groupby(id)[time].transform("max")
36
+ filtered_df = df[df[time] == max_time_df]
37
+
38
+ # Define aggregation function
39
+ agg_func = geometric_mean if use_gmean else np.mean
40
+
41
+ # Perform aggregation on all columns
42
+ agg_dict = {col: "first" for col in df.columns if col not in [target, id, time]}
43
+ agg_dict[target] = lambda x: agg_func(x) if len(x) > 1 else x.iloc[0] # Apply mean or gmean
44
+
45
+ rolled_up_df = filtered_df.groupby([id, time]).agg(agg_dict).reset_index()
46
+ return rolled_up_df
47
+
48
+
49
+ def micromolar_to_log(series_µM: pd.Series) -> pd.Series:
50
+ """
51
+ Convert a pandas Series of concentrations in µM (micromolar) to their logarithmic values (log10).
52
+
53
+ Parameters:
54
+ series_uM (pd.Series): Series of concentrations in micromolar.
55
+
56
+ Returns:
57
+ pd.Series: Series of logarithmic values (log10).
58
+ """
59
+ # Replace 0 or negative values with a small number to avoid log errors
60
+ adjusted_series = series_µM.clip(lower=1e-9) # Alignment with another project
61
+
62
+ series_mol_per_l = adjusted_series * 1e-6 # Convert µM/L to mol/L
63
+ log_series = np.log10(series_mol_per_l)
64
+ return log_series
65
+
66
+
67
+ def log_to_micromolar(log_series: pd.Series) -> pd.Series:
68
+ """
69
+ Convert a pandas Series of logarithmic values (log10) back to concentrations in µM (micromolar).
70
+
71
+ Parameters:
72
+ log_series (pd.Series): Series of logarithmic values (log10).
73
+
74
+ Returns:
75
+ pd.Series: Series of concentrations in micromolar.
76
+ """
77
+ series_mol_per_l = 10**log_series # Convert log10 back to mol/L
78
+ series_µM = series_mol_per_l * 1e6 # Convert mol/L to µM
79
+ return series_µM
80
+
81
+
82
+ def feature_resolution_issues(df: pd.DataFrame, features: List[str], show_cols: Optional[List[str]] = None) -> None:
83
+ """
84
+ Identify and print groups in a DataFrame where the given features have more than one unique SMILES,
85
+ sorted by group size (largest number of unique SMILES first).
86
+
87
+ Args:
88
+ df (pd.DataFrame): Input DataFrame containing SMILES strings.
89
+ features (List[str]): List of features to check.
90
+ show_cols (Optional[List[str]]): Columns to display; defaults to all columns.
91
+ """
92
+ # Check for the 'smiles' column (case-insensitive)
93
+ smiles_column = next((col for col in df.columns if col.lower() == "smiles"), None)
94
+ if smiles_column is None:
95
+ raise ValueError("Input DataFrame must have a 'smiles' column")
96
+
97
+ show_cols = show_cols if show_cols is not None else df.columns.tolist()
98
+
99
+ # Drop duplicates to keep only unique SMILES for each feature combination
100
+ unique_df = df.drop_duplicates(subset=[smiles_column] + features)
101
+
102
+ # Find groups with more than one unique SMILES
103
+ group_counts = unique_df.groupby(features).size()
104
+ collision_groups = group_counts[group_counts > 1].sort_values(ascending=False)
105
+
106
+ # Print each group in order of size (largest first)
107
+ for group, count in collision_groups.items():
108
+ # Get the rows for this group
109
+ if isinstance(group, tuple):
110
+ group_mask = (unique_df[features] == group).all(axis=1)
111
+ else:
112
+ group_mask = unique_df[features[0]] == group
113
+
114
+ group_df = unique_df[group_mask]
115
+
116
+ print(f"Feature Group (unique SMILES: {count}):")
117
+ print(group_df[show_cols])
118
+ print("\n")
119
+
120
+
121
+ if __name__ == "__main__":
122
+ print("Running molecular processing and transformation tests...")
123
+ print("Note: This requires the molecular_filters module to be available")
124
+
125
+ # Test 1: Concentration conversions
126
+ print("\n1. Testing concentration conversions...")
127
+
128
+ # Test micromolar to log
129
+ test_conc = pd.Series([1.0, 10.0, 100.0, 1000.0, 0.001])
130
+ log_values = micromolar_to_log(test_conc)
131
+ back_to_uM = log_to_micromolar(log_values)
132
+
133
+ print(" µM → log10 → µM:")
134
+ for orig, log_val, back in zip(test_conc, log_values, back_to_uM):
135
+ print(f" {orig:8.3f} µM → {log_val:6.2f} → {back:8.3f} µM")
136
+
137
+ # Test 2: Geometric mean
138
+ print("\n2. Testing geometric mean...")
139
+ test_series = pd.Series([2, 4, 8, 16])
140
+ geo_mean = geometric_mean(test_series)
141
+ arith_mean = np.mean(test_series)
142
+ print(f" Series: {list(test_series)}")
143
+ print(f" Arithmetic mean: {arith_mean:.2f}")
144
+ print(f" Geometric mean: {geo_mean:.2f}")
145
+
146
+ # Test 3: Experimental data rollup
147
+ print("\n3. Testing experimental data rollup...")
148
+
149
+ # Create test data with multiple timepoints and replicates
150
+ test_data = pd.DataFrame(
151
+ {
152
+ "compound_id": ["A", "A", "A", "B", "B", "C", "C", "C"],
153
+ "time": [1, 2, 2, 1, 2, 1, 1, 2],
154
+ "activity": [10, 20, 22, 5, 8, 100, 110, 200],
155
+ "assay": ["kinase", "kinase", "kinase", "kinase", "kinase", "cell", "cell", "cell"],
156
+ }
157
+ )
158
+
159
+ # Rollup with arithmetic mean
160
+ rolled_arith = rollup_experimental_data(test_data, "compound_id", "time", "activity", use_gmean=False)
161
+ print(" Arithmetic mean rollup:")
162
+ print(rolled_arith[["compound_id", "time", "activity"]])
163
+
164
+ # Rollup with geometric mean
165
+ rolled_geo = rollup_experimental_data(test_data, "compound_id", "time", "activity", use_gmean=True)
166
+ print("\n Geometric mean rollup:")
167
+ print(rolled_geo[["compound_id", "time", "activity"]])
168
+
169
+ # Test 4: Feature resolution issues
170
+ print("\n4. Testing feature resolution identification...")
171
+
172
+ # Create data with some duplicate features but different SMILES
173
+ resolution_df = pd.DataFrame(
174
+ {
175
+ "smiles": ["CCO", "C(C)O", "CC(C)O", "CCC(C)O", "CCCO"],
176
+ "assay_id": ["A1", "A1", "A2", "A2", "A3"],
177
+ "value": [1.0, 1.5, 2.0, 2.2, 3.0],
178
+ }
179
+ )
180
+
181
+ print(" Checking for feature collisions in 'assay_id':")
182
+ feature_resolution_issues(resolution_df, ["assay_id"], show_cols=["smiles", "assay_id", "value"])
183
+
184
+ # Test 7: Edge cases
185
+ print("\n7. Testing edge cases...")
186
+
187
+ # Zero and negative concentrations
188
+ edge_conc = pd.Series([0, -1, 1e-10])
189
+ edge_log = micromolar_to_log(edge_conc)
190
+ print(" Edge concentration handling:")
191
+ for c, l in zip(edge_conc, edge_log):
192
+ print(f" {c:6.2e} µM → {l:6.2f}")
193
+
194
+ print("\n✅ All molecular processing tests completed!")
@@ -0,0 +1,471 @@
1
+ """
2
+ mol_descriptors.py - Molecular descriptor computation for ADMET modeling
3
+
4
+ Purpose:
5
+ Computes comprehensive molecular descriptors for ADMET (Absorption, Distribution,
6
+ Metabolism, Excretion, Toxicity) property prediction. Combines RDKit's full
7
+ descriptor set with selected Mordred descriptors and custom stereochemistry features.
8
+
9
+ Descriptor Categories:
10
+ 1. RDKit Descriptors (~220 descriptors)
11
+ - Constitutional (MW, heavy atom count, rotatable bonds)
12
+ - Topological (Balaban J, Kappa indices, Chi indices)
13
+ - Geometric (radius of gyration, spherocity)
14
+ - Electronic (HOMO/LUMO estimates, partial charges)
15
+ - Lipophilicity (LogP, MolLogP)
16
+ - Pharmacophore (H-bond donors/acceptors, aromatic rings)
17
+ - ADMET-specific (TPSA, QED, Lipinski descriptors)
18
+
19
+ 2. Mordred Descriptors (~80 descriptors from 5 ADMET-relevant modules)
20
+ - AcidBase module: pH-dependent properties (nAcid, nBase)
21
+ - Aromatic module: CYP metabolism features (nAromAtom, nAromBond)
22
+ - Constitutional module: Structural complexity (~40 descriptors including nSpiro, nBridgehead)
23
+ - Chi module: Molecular connectivity indices (~42 descriptors, Chi0-Chi4 variants)
24
+ - CarbonTypes module: Carbon hybridization states for metabolism (~20 descriptors)
25
+
26
+ 3. Stereochemistry Features (10 custom descriptors)
27
+ - Stereocenter counts (R/S, defined/undefined)
28
+ - Stereobond counts (E/Z, defined/undefined)
29
+ - Stereochemical complexity and coverage metrics
30
+ - Critical for distinguishing drug enantiomers/diastereomers
31
+
32
+ Pipeline Integration:
33
+ This module expects standardized SMILES from mol_standardize.py:
34
+
35
+ 1. Standardize structures (mol_standardize.py)
36
+
37
+ 2. Compute descriptors (this module)
38
+
39
+ 3. Feature selection/ML modeling
40
+
41
+ Output:
42
+ Returns input DataFrame with added descriptor columns:
43
+ - ~220 RDKit descriptors
44
+ - ~85 Mordred descriptors (from 5 modules)
45
+ - 10 stereochemistry descriptors
46
+ Total: ~310 descriptors
47
+
48
+ Invalid molecules receive NaN values for all descriptors.
49
+
50
+ Performance Notes:
51
+ - RDKit descriptors: Fast, vectorized computation
52
+ - Mordred descriptors: Moderate speed
53
+ - Stereochemistry: Moderate speed, requires CIP labeling
54
+ - Memory: <1GB per 10,000 molecules with all descriptors
55
+
56
+ Special Considerations:
57
+ - Ipc descriptor excluded due to potential overflow issues
58
+ - Molecules failing descriptor calculation get NaN (not dropped)
59
+ - Stereochemistry features optional for non-chiral datasets
60
+ - Salt information from standardization not included in descriptors
61
+ (use separately as categorical feature if needed)
62
+ - Feature selection recommended due to descriptor redundancy
63
+
64
+ Example Usage:
65
+ import pandas as pd
66
+ from mol_standardize import standardize_dataframe
67
+ from mol_descriptors import compute_descriptors
68
+
69
+ # Standard pipeline
70
+ df = pd.read_csv("molecules.csv")
71
+ df = standardize_dataframe(df) # Standardize first
72
+ df = compute_descriptors(df) # Then compute descriptors
73
+
74
+ # For achiral molecules (faster)
75
+ df = compute_descriptors(df, include_stereo=False)
76
+
77
+ # Custom SMILES column
78
+ df = compute_descriptors(df, smiles_column='canonical_smiles')
79
+
80
+ # The resulting DataFrame is ready for ML modeling
81
+ X = df.select_dtypes(include=[np.number]) # All numeric descriptors
82
+ y = df['activity'] # Your target variable
83
+
84
+ References:
85
+ - RDKit descriptors: https://www.rdkit.org/docs/GettingStartedInPython.html#descriptors
86
+ - Mordred: https://github.com/mordred-descriptor/mordred
87
+ - Stereochemistry in drug discovery: https://doi.org/10.1021/acs.jmedchem.0c00915
88
+ """
89
+
90
+ import logging
91
+ import pandas as pd
92
+ import numpy as np
93
+ import re
94
+ from rdkit import Chem
95
+ from rdkit.Chem import Descriptors, rdCIPLabeler
96
+ from rdkit.ML.Descriptors import MoleculeDescriptors
97
+ from mordred import Calculator as MordredCalculator
98
+ from mordred import AcidBase, Aromatic, Constitutional, Chi, CarbonTypes
99
+
100
+ logger = logging.getLogger("workbench")
101
+ logger.setLevel(logging.DEBUG)
102
+
103
+
104
+ def compute_stereochemistry_features(mol):
105
+ """
106
+ Compute stereochemistry descriptors using modern RDKit methods.
107
+
108
+ Returns dict with 10 stereochemistry descriptors commonly used in ADMET.
109
+ """
110
+ if mol is None:
111
+ return {
112
+ "num_stereocenters": np.nan,
113
+ "num_unspecified_stereocenters": np.nan,
114
+ "num_defined_stereocenters": np.nan,
115
+ "num_r_centers": np.nan,
116
+ "num_s_centers": np.nan,
117
+ "num_stereobonds": np.nan,
118
+ "num_e_bonds": np.nan,
119
+ "num_z_bonds": np.nan,
120
+ "stereo_complexity": np.nan,
121
+ "frac_defined_stereo": np.nan,
122
+ }
123
+
124
+ try:
125
+ # Find all potential stereogenic elements
126
+ stereo_info = Chem.FindPotentialStereo(mol)
127
+
128
+ # Initialize counters
129
+ defined_centers = 0
130
+ undefined_centers = 0
131
+ r_centers = 0
132
+ s_centers = 0
133
+ defined_bonds = 0
134
+ undefined_bonds = 0
135
+ e_bonds = 0
136
+ z_bonds = 0
137
+
138
+ # Assign CIP labels for accurate R/S and E/Z determination
139
+ rdCIPLabeler.AssignCIPLabels(mol)
140
+
141
+ # Process stereogenic elements
142
+ for element in stereo_info:
143
+ if element.type == Chem.StereoType.Atom_Tetrahedral:
144
+ if element.specified == Chem.StereoSpecified.Specified:
145
+ defined_centers += 1
146
+ # Get the atom and check its CIP code
147
+ atom = mol.GetAtomWithIdx(element.centeredOn)
148
+ if atom.HasProp("_CIPCode"):
149
+ cip = atom.GetProp("_CIPCode")
150
+ if cip == "R":
151
+ r_centers += 1
152
+ elif cip == "S":
153
+ s_centers += 1
154
+ else:
155
+ undefined_centers += 1
156
+
157
+ elif element.type == Chem.StereoType.Bond_Double:
158
+ if element.specified == Chem.StereoSpecified.Specified:
159
+ defined_bonds += 1
160
+ # Get the bond and check its CIP code
161
+ bond = mol.GetBondWithIdx(element.centeredOn)
162
+ if bond.HasProp("_CIPCode"):
163
+ cip = bond.GetProp("_CIPCode")
164
+ if cip == "E":
165
+ e_bonds += 1
166
+ elif cip == "Z":
167
+ z_bonds += 1
168
+ else:
169
+ undefined_bonds += 1
170
+
171
+ # Calculate derived metrics
172
+ total_stereocenters = defined_centers + undefined_centers
173
+ total_stereobonds = defined_bonds + undefined_bonds
174
+ total_stereo = total_stereocenters + total_stereobonds
175
+
176
+ # Stereochemical complexity (total stereogenic elements)
177
+ stereo_complexity = total_stereo
178
+
179
+ # Fraction of defined stereochemistry
180
+ if total_stereo > 0:
181
+ frac_defined = (defined_centers + defined_bonds) / total_stereo
182
+ else:
183
+ frac_defined = 1.0 # No stereo elements = fully defined
184
+
185
+ return {
186
+ "num_stereocenters": total_stereocenters,
187
+ "num_unspecified_stereocenters": undefined_centers,
188
+ "num_defined_stereocenters": defined_centers,
189
+ "num_r_centers": r_centers,
190
+ "num_s_centers": s_centers,
191
+ "num_stereobonds": total_stereobonds,
192
+ "num_e_bonds": e_bonds,
193
+ "num_z_bonds": z_bonds,
194
+ "stereo_complexity": stereo_complexity,
195
+ "frac_defined_stereo": frac_defined,
196
+ }
197
+
198
+ except Exception as e:
199
+ logger.warning(f"Stereochemistry calculation failed: {e}")
200
+ return {
201
+ "num_stereocenters": np.nan,
202
+ "num_unspecified_stereocenters": np.nan,
203
+ "num_defined_stereocenters": np.nan,
204
+ "num_r_centers": np.nan,
205
+ "num_s_centers": np.nan,
206
+ "num_stereobonds": np.nan,
207
+ "num_e_bonds": np.nan,
208
+ "num_z_bonds": np.nan,
209
+ "stereo_complexity": np.nan,
210
+ "frac_defined_stereo": np.nan,
211
+ }
212
+
213
+
214
+ def compute_descriptors(df: pd.DataFrame, include_mordred: bool = True, include_stereo: bool = True) -> pd.DataFrame:
215
+ """
216
+ Compute all molecular descriptors for ADMET modeling.
217
+
218
+ Args:
219
+ df: Input DataFrame with SMILES
220
+ include_mordred: Whether to compute Mordred descriptors (default True)
221
+ include_stereo: Whether to compute stereochemistry features (default True)
222
+
223
+ Returns:
224
+ DataFrame with all descriptor columns added
225
+
226
+ Example:
227
+ df = standardize(df) # First standardize
228
+ df = compute_descriptors(df) # Then compute descriptors with stereo
229
+ df = compute_descriptors(df, include_stereo=False) # Without stereo
230
+ df = compute_descriptors(df, include_mordred=False) # RDKit only
231
+ """
232
+
233
+ # Check for the smiles column (any capitalization)
234
+ smiles_column = next((col for col in df.columns if col.lower() == "smiles"), None)
235
+ if smiles_column is None:
236
+ raise ValueError("Input DataFrame must have a 'smiles' column")
237
+
238
+ result = df.copy()
239
+
240
+ # Create molecule objects
241
+ logger.info("Creating molecule objects...")
242
+ molecules = []
243
+ for idx, row in result.iterrows():
244
+ smiles = row[smiles_column]
245
+
246
+ if pd.isna(smiles) or smiles == "":
247
+ molecules.append(None)
248
+ else:
249
+ mol = Chem.MolFromSmiles(smiles)
250
+ molecules.append(mol)
251
+
252
+ # Compute RDKit descriptors
253
+ logger.info("Computing RDKit Descriptors...")
254
+
255
+ # Get all RDKit descriptors
256
+ all_descriptors = [x[0] for x in Descriptors._descList]
257
+
258
+ # Remove IPC descriptor due to overflow issue
259
+ # See: https://github.com/rdkit/rdkit/issues/1527
260
+ if "Ipc" in all_descriptors:
261
+ all_descriptors.remove("Ipc")
262
+
263
+ # Make sure we don't have duplicates
264
+ all_descriptors = list(set(all_descriptors))
265
+
266
+ # Initialize calculator
267
+ calc = MoleculeDescriptors.MolecularDescriptorCalculator(all_descriptors)
268
+
269
+ # Compute descriptors
270
+ descriptor_values = []
271
+ for mol in molecules:
272
+ if mol is None:
273
+ descriptor_values.append([np.nan] * len(all_descriptors))
274
+ else:
275
+ try:
276
+ values = calc.CalcDescriptors(mol)
277
+ descriptor_values.append(values)
278
+ except Exception as e:
279
+ logger.warning(f"RDKit descriptor calculation failed: {e}")
280
+ descriptor_values.append([np.nan] * len(all_descriptors))
281
+
282
+ # Create RDKit features DataFrame
283
+ rdkit_features_df = pd.DataFrame(descriptor_values, columns=calc.GetDescriptorNames(), index=result.index)
284
+
285
+ # Add RDKit features to result
286
+ result = pd.concat([result, rdkit_features_df], axis=1)
287
+
288
+ # Compute Mordred descriptors
289
+ if include_mordred:
290
+ logger.info("Computing Mordred descriptors from relevant modules...")
291
+ calc = MordredCalculator()
292
+
293
+ # Register 5 ADMET-focused modules (avoiding overlap with RDKit)
294
+ calc.register(AcidBase) # ~2 descriptors: nAcid, nBase
295
+ calc.register(Aromatic) # ~2 descriptors: nAromAtom, nAromBond
296
+ calc.register(Constitutional) # ~30 descriptors: structural complexity
297
+ calc.register(Chi) # ~32 descriptors: connectivity indices
298
+ calc.register(CarbonTypes) # ~20 descriptors: carbon hybridization
299
+
300
+ # Compute Mordred descriptors
301
+ valid_mols = [mol if mol is not None else Chem.MolFromSmiles("C") for mol in molecules]
302
+ mordred_df = calc.pandas(valid_mols, nproc=1) # For serverless, use nproc=1
303
+
304
+ # Replace values for invalid molecules with NaN
305
+ for i, mol in enumerate(molecules):
306
+ if mol is None:
307
+ mordred_df.iloc[i] = np.nan
308
+
309
+ # Handle Mordred's special error values
310
+ for col in mordred_df.columns:
311
+ mordred_df[col] = pd.to_numeric(mordred_df[col], errors="coerce")
312
+
313
+ # Set index to match result DataFrame
314
+ mordred_df.index = result.index
315
+
316
+ # Add Mordred features to result
317
+ result = pd.concat([result, mordred_df], axis=1)
318
+
319
+ # Compute stereochemistry features if requested
320
+ if include_stereo:
321
+ logger.info("Computing Stereochemistry Descriptors...")
322
+
323
+ stereo_features = []
324
+ for mol in molecules:
325
+ stereo_dict = compute_stereochemistry_features(mol)
326
+ stereo_features.append(stereo_dict)
327
+
328
+ # Create stereochemistry DataFrame
329
+ stereo_df = pd.DataFrame(stereo_features, index=result.index)
330
+
331
+ # Add stereochemistry features to result
332
+ result = pd.concat([result, stereo_df], axis=1)
333
+
334
+ logger.info(f"Added {len(stereo_df.columns)} stereochemistry descriptors")
335
+
336
+ # Log summary
337
+ valid_mols = sum(1 for m in molecules if m is not None)
338
+ total_descriptors = len(result.columns) - len(df.columns)
339
+ logger.info(f"Computed {total_descriptors} descriptors for {valid_mols}/{len(df)} valid molecules")
340
+
341
+ # Log descriptor breakdown
342
+ rdkit_count = len(rdkit_features_df.columns)
343
+ mordred_count = len(mordred_df.columns) if include_mordred else 0
344
+ stereo_count = len(stereo_df.columns) if include_stereo else 0
345
+ logger.info(f"Descriptor breakdown: RDKit={rdkit_count}, Mordred={mordred_count}, Stereo={stereo_count}")
346
+
347
+ # Sanitize column names for AWS Athena compatibility
348
+ # - Must be lowercase, no special characters except underscore, no spaces
349
+ result.columns = [re.sub(r"_+", "_", re.sub(r"[^a-z0-9_]", "_", col.lower())) for col in result.columns]
350
+
351
+ # Drop duplicate columns if any exist after sanitization
352
+ if result.columns.duplicated().any():
353
+ logger.warning("Duplicate column names after sanitization - dropping duplicates!")
354
+ result = result.loc[:, ~result.columns.duplicated()]
355
+
356
+ return result
357
+
358
+
359
+ if __name__ == "__main__":
360
+ import time
361
+ from mol_standardize import standardize
362
+ from workbench.api import DataSource
363
+
364
+ # Configure pandas display
365
+ pd.set_option("display.max_columns", None)
366
+ pd.set_option("display.max_colwidth", 100)
367
+ pd.set_option("display.width", 1200)
368
+
369
+ # Test data - stereochemistry examples
370
+ stereo_test_data = pd.DataFrame(
371
+ {
372
+ "smiles": [
373
+ "CC(=O)Oc1ccccc1C(=O)O", # Aspirin
374
+ "C[C@H](N)C(=O)O", # L-Alanine
375
+ "C[C@@H](N)C(=O)O", # D-Alanine
376
+ "C/C=C/C=C/C", # E,E-hexadiene
377
+ "CC(F)(Cl)Br", # Unspecified chiral
378
+ "",
379
+ "INVALID", # Invalid cases
380
+ ],
381
+ "name": ["Aspirin", "L-Alanine", "D-Alanine", "E,E-hexadiene", "Unspecified", "Empty", "Invalid"],
382
+ }
383
+ )
384
+
385
+ # Test data - salt handling examples
386
+ salt_test_data = pd.DataFrame(
387
+ {
388
+ "smiles": [
389
+ "CC(=O)O", # Acetic acid
390
+ "[Na+].CC(=O)[O-]", # Sodium acetate
391
+ "CC(C)NCC(O)c1ccc(O)c(O)c1.Cl", # Drug HCl salt
392
+ "Oc1ccccn1", # Tautomer 1
393
+ "O=c1cccc[nH]1", # Tautomer 2
394
+ ],
395
+ "compound_id": [f"C{i:03d}" for i in range(1, 6)],
396
+ }
397
+ )
398
+
399
+ def run_basic_tests():
400
+ """Run basic functionality tests"""
401
+ print("=" * 80)
402
+ print("BASIC FUNCTIONALITY TESTS")
403
+ print("=" * 80)
404
+
405
+ # Test stereochemistry
406
+ result = compute_descriptors(stereo_test_data, include_stereo=True)
407
+
408
+ print("\nStereochemistry features (selected molecules):")
409
+ for idx, name in enumerate(stereo_test_data["name"][:4]):
410
+ print(
411
+ f"{name:15} - centers: {result.iloc[idx]['num_stereocenters']:.0f}, "
412
+ f"R/S: {result.iloc[idx]['num_r_centers']:.0f}/"
413
+ f"{result.iloc[idx]['num_s_centers']:.0f}"
414
+ )
415
+
416
+ # Test salt handling
417
+ print("\nSalt extraction test:")
418
+ std_result = standardize(salt_test_data, extract_salts=True)
419
+ for _, row in std_result.iterrows():
420
+ salt_info = f" → salt: {row['salt']}" if pd.notna(row["salt"]) else ""
421
+ print(f"{row['compound_id']}: {row['smiles'][:30]}{salt_info}")
422
+
423
+ def run_performance_tests():
424
+ """Run performance timing tests"""
425
+ print("\n" + "=" * 80)
426
+ print("PERFORMANCE TESTS on real world molecules")
427
+ print("=" * 80)
428
+
429
+ # Get a real dataset from Workbench
430
+ ds = DataSource("aqsol_data")
431
+ df = ds.pull_dataframe()[["id", "smiles"]][:1000] # Limit to 1000 for testing
432
+ n_mols = df.shape[0]
433
+ print(f"Pulled {n_mols} molecules from DataSource 'aqsol_data'")
434
+
435
+ # Test configurations
436
+ configs = [
437
+ ("Standardize (full)", standardize, {"extract_salts": True, "canonicalize_tautomer": True}),
438
+ ("Standardize (minimal)", standardize, {"extract_salts": False, "canonicalize_tautomer": False}),
439
+ ("Descriptors (all)", compute_descriptors, {"include_mordred": True, "include_stereo": True}),
440
+ ("Descriptors (RDKit only)", compute_descriptors, {"include_mordred": False, "include_stereo": False}),
441
+ ]
442
+
443
+ results = []
444
+ for name, func, params in configs:
445
+ start = time.time()
446
+ _ = func(df, **params)
447
+ elapsed = time.time() - start
448
+ throughput = n_mols / elapsed
449
+ results.append((name, elapsed, throughput))
450
+ print(f"{name:25} {elapsed:6.2f}s ({throughput:6.1f} mol/s)")
451
+
452
+ # Full pipeline test
453
+ print("\nFull pipeline (standardize + all descriptors):")
454
+ start = time.time()
455
+ std_data = standardize(df)
456
+ standardize_time = time.time() - start
457
+ print(f" Standardize: {standardize_time:.2f}s ({n_mols / standardize_time:.1f} mol/s)")
458
+ start = time.time()
459
+ _ = compute_descriptors(std_data)
460
+ descriptor_time = time.time() - start
461
+ print(f" Descriptors: {descriptor_time:.2f}s ({n_mols / descriptor_time:.1f} mol/s)")
462
+ pipeline_time = standardize_time + descriptor_time
463
+ print(f" Total: {pipeline_time:.2f}s ({n_mols / pipeline_time:.1f} mol/s)")
464
+
465
+ return results
466
+
467
+ # Run tests
468
+ run_basic_tests()
469
+ timing_results = run_performance_tests()
470
+
471
+ print("\n✅ All tests completed!")