workbench 0.8.171__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 (49) hide show
  1. workbench/algorithms/graph/light/proximity_graph.py +2 -1
  2. workbench/api/compound.py +1 -1
  3. workbench/api/feature_set.py +4 -4
  4. workbench/api/monitor.py +1 -16
  5. workbench/core/artifacts/artifact.py +11 -3
  6. workbench/core/artifacts/data_capture_core.py +315 -0
  7. workbench/core/artifacts/endpoint_core.py +9 -3
  8. workbench/core/artifacts/model_core.py +37 -14
  9. workbench/core/artifacts/monitor_core.py +33 -249
  10. workbench/core/cloud_platform/aws/aws_account_clamp.py +4 -1
  11. workbench/core/transforms/data_to_features/light/molecular_descriptors.py +4 -4
  12. workbench/core/transforms/features_to_model/features_to_model.py +4 -4
  13. workbench/model_scripts/custom_models/chem_info/mol_descriptors.py +471 -0
  14. workbench/model_scripts/custom_models/chem_info/mol_standardize.py +428 -0
  15. workbench/model_scripts/custom_models/chem_info/molecular_descriptors.py +7 -9
  16. workbench/model_scripts/custom_models/uq_models/generated_model_script.py +19 -9
  17. workbench/model_scripts/custom_models/uq_models/mapie.template +502 -0
  18. workbench/model_scripts/custom_models/uq_models/meta_uq.template +8 -5
  19. workbench/model_scripts/custom_models/uq_models/requirements.txt +1 -3
  20. workbench/model_scripts/script_generation.py +5 -0
  21. workbench/model_scripts/xgb_model/generated_model_script.py +5 -5
  22. workbench/repl/workbench_shell.py +3 -3
  23. workbench/utils/chem_utils/__init__.py +0 -0
  24. workbench/utils/chem_utils/fingerprints.py +134 -0
  25. workbench/utils/chem_utils/misc.py +194 -0
  26. workbench/utils/chem_utils/mol_descriptors.py +471 -0
  27. workbench/utils/chem_utils/mol_standardize.py +428 -0
  28. workbench/utils/chem_utils/mol_tagging.py +348 -0
  29. workbench/utils/chem_utils/projections.py +209 -0
  30. workbench/utils/chem_utils/salts.py +256 -0
  31. workbench/utils/chem_utils/sdf.py +292 -0
  32. workbench/utils/chem_utils/toxicity.py +250 -0
  33. workbench/utils/chem_utils/vis.py +253 -0
  34. workbench/utils/model_utils.py +1 -1
  35. workbench/utils/monitor_utils.py +49 -56
  36. workbench/utils/pandas_utils.py +3 -3
  37. workbench/utils/workbench_sqs.py +1 -1
  38. workbench/utils/xgboost_model_utils.py +1 -0
  39. workbench/web_interface/components/plugins/generated_compounds.py +1 -1
  40. {workbench-0.8.171.dist-info → workbench-0.8.173.dist-info}/METADATA +1 -1
  41. {workbench-0.8.171.dist-info → workbench-0.8.173.dist-info}/RECORD +45 -34
  42. workbench/model_scripts/custom_models/chem_info/local_utils.py +0 -769
  43. workbench/model_scripts/custom_models/chem_info/tautomerize.py +0 -83
  44. workbench/model_scripts/custom_models/uq_models/mapie_xgb.template +0 -203
  45. workbench/utils/chem_utils.py +0 -1556
  46. {workbench-0.8.171.dist-info → workbench-0.8.173.dist-info}/WHEEL +0 -0
  47. {workbench-0.8.171.dist-info → workbench-0.8.173.dist-info}/entry_points.txt +0 -0
  48. {workbench-0.8.171.dist-info → workbench-0.8.173.dist-info}/licenses/LICENSE +0 -0
  49. {workbench-0.8.171.dist-info → workbench-0.8.173.dist-info}/top_level.txt +0 -0
@@ -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!")