boltz2-python-client 0.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,608 @@
1
+ # ---------------------------------------------------------------
2
+ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
3
+ # ---------------------------------------------------------------
4
+
5
+ """
6
+ Virtual Screening Module for Boltz-2
7
+
8
+ Provides high-level APIs for virtual screening campaigns with
9
+ automatic parallelization, result analysis, and visualization.
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import time
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Dict, List, Optional, Union, Any, Tuple, Callable
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ import pandas as pd
20
+
21
+ from .client import Boltz2Client, Boltz2SyncClient
22
+ from .models import Polymer, Ligand, PredictionRequest, PocketConstraint
23
+ from .exceptions import Boltz2ValidationError
24
+
25
+
26
+ class CompoundLibrary:
27
+ """Represents a chemical library for virtual screening."""
28
+
29
+ def __init__(self, compounds: List[Dict[str, Any]]):
30
+ """
31
+ Initialize compound library.
32
+
33
+ Args:
34
+ compounds: List of compound dictionaries with required fields:
35
+ - name: Compound name
36
+ - smiles: SMILES string OR ccd: CCD code
37
+ - metadata: Optional dict with additional info
38
+ """
39
+ self.compounds = self._validate_compounds(compounds)
40
+
41
+ @classmethod
42
+ def from_csv(cls, csv_path: Union[str, Path],
43
+ name_col: str = "name",
44
+ smiles_col: str = "smiles",
45
+ ccd_col: Optional[str] = None) -> "CompoundLibrary":
46
+ """Load compound library from CSV file."""
47
+ df = pd.read_csv(csv_path)
48
+ compounds = []
49
+
50
+ for _, row in df.iterrows():
51
+ compound = {"name": row[name_col]}
52
+
53
+ if smiles_col in row and pd.notna(row[smiles_col]):
54
+ compound["smiles"] = row[smiles_col]
55
+ elif ccd_col and ccd_col in row and pd.notna(row[ccd_col]):
56
+ compound["ccd"] = row[ccd_col]
57
+ else:
58
+ continue
59
+
60
+ # Add all other columns as metadata
61
+ metadata = {}
62
+ for col in df.columns:
63
+ if col not in [name_col, smiles_col, ccd_col]:
64
+ metadata[col] = row[col]
65
+
66
+ if metadata:
67
+ compound["metadata"] = metadata
68
+
69
+ compounds.append(compound)
70
+
71
+ return cls(compounds)
72
+
73
+ @classmethod
74
+ def from_json(cls, json_path: Union[str, Path]) -> "CompoundLibrary":
75
+ """Load compound library from JSON file."""
76
+ with open(json_path, 'r') as f:
77
+ compounds = json.load(f)
78
+ return cls(compounds)
79
+
80
+ def _validate_compounds(self, compounds: List[Dict]) -> List[Dict]:
81
+ """Validate compound entries."""
82
+ validated = []
83
+ for i, compound in enumerate(compounds):
84
+ if "name" not in compound:
85
+ raise Boltz2ValidationError(f"Compound {i} missing 'name' field")
86
+
87
+ if "smiles" not in compound and "ccd" not in compound:
88
+ raise Boltz2ValidationError(
89
+ f"Compound '{compound['name']}' must have either 'smiles' or 'ccd'"
90
+ )
91
+
92
+ validated.append(compound)
93
+
94
+ return validated
95
+
96
+ def __len__(self) -> int:
97
+ return len(self.compounds)
98
+
99
+ def __iter__(self):
100
+ return iter(self.compounds)
101
+
102
+
103
+ class VirtualScreeningResult:
104
+ """Container for virtual screening results."""
105
+
106
+ def __init__(self,
107
+ target_name: str,
108
+ target_sequence: str,
109
+ results: List[Dict[str, Any]],
110
+ parameters: Dict[str, Any],
111
+ duration_seconds: float):
112
+ self.target_name = target_name
113
+ self.target_sequence = target_sequence
114
+ self.results = results
115
+ self.parameters = parameters
116
+ self.duration_seconds = duration_seconds
117
+ self.timestamp = datetime.now()
118
+
119
+ @property
120
+ def successful_results(self) -> List[Dict]:
121
+ """Get only successful predictions."""
122
+ return [r for r in self.results if "error" not in r]
123
+
124
+ @property
125
+ def failed_results(self) -> List[Dict]:
126
+ """Get only failed predictions."""
127
+ return [r for r in self.results if "error" in r]
128
+
129
+ @property
130
+ def success_rate(self) -> float:
131
+ """Calculate success rate."""
132
+ if not self.results:
133
+ return 0.0
134
+ return len(self.successful_results) / len(self.results)
135
+
136
+ def to_dataframe(self) -> pd.DataFrame:
137
+ """Convert results to pandas DataFrame."""
138
+ return pd.DataFrame(self.successful_results)
139
+
140
+ def save_results(self, output_dir: Union[str, Path],
141
+ save_structures: bool = True) -> Dict[str, Path]:
142
+ """
143
+ Save all results to files.
144
+
145
+ Returns:
146
+ Dictionary of saved file paths
147
+ """
148
+ output_dir = Path(output_dir)
149
+ output_dir.mkdir(parents=True, exist_ok=True)
150
+
151
+ saved_files = {}
152
+
153
+ # Save summary CSV
154
+ df = self.to_dataframe()
155
+ if not df.empty:
156
+ csv_path = output_dir / "screening_results.csv"
157
+ df.to_csv(csv_path, index=False)
158
+ saved_files["results_csv"] = csv_path
159
+
160
+ # Save structures
161
+ if save_structures:
162
+ structures_dir = output_dir / "structures"
163
+ structures_dir.mkdir(exist_ok=True)
164
+
165
+ for result in self.successful_results:
166
+ if "structure_cif" in result:
167
+ cif_path = structures_dir / f"{result['compound_name'].replace(' ', '_')}.cif"
168
+ with open(cif_path, 'w') as f:
169
+ f.write(result["structure_cif"])
170
+
171
+ # Save metadata
172
+ metadata = {
173
+ "target_name": self.target_name,
174
+ "target_sequence_length": len(self.target_sequence),
175
+ "compounds_screened": len(self.results),
176
+ "successful_predictions": len(self.successful_results),
177
+ "failed_predictions": len(self.failed_results),
178
+ "success_rate": self.success_rate,
179
+ "duration_seconds": self.duration_seconds,
180
+ "timestamp": self.timestamp.isoformat(),
181
+ "parameters": self.parameters
182
+ }
183
+
184
+ metadata_path = output_dir / "screening_metadata.json"
185
+ with open(metadata_path, 'w') as f:
186
+ json.dump(metadata, f, indent=2)
187
+ saved_files["metadata"] = metadata_path
188
+
189
+ return saved_files
190
+
191
+ def get_top_hits(self, n: int = 10, by: str = "predicted_pic50") -> pd.DataFrame:
192
+ """Get top N compounds by specified metric."""
193
+ df = self.to_dataframe()
194
+ if df.empty or by not in df.columns:
195
+ return pd.DataFrame()
196
+
197
+ return df.nlargest(n, by)
198
+
199
+ def get_statistics_by_group(self, group_by: str = "compound_type") -> pd.DataFrame:
200
+ """Get statistics grouped by a metadata field."""
201
+ df = self.to_dataframe()
202
+ if df.empty or group_by not in df.columns:
203
+ return pd.DataFrame()
204
+
205
+ stats = df.groupby(group_by).agg({
206
+ 'predicted_pic50': ['mean', 'std', 'count'],
207
+ 'predicted_ic50_nm': 'mean',
208
+ 'binding_probability': 'mean'
209
+ }).round(3)
210
+
211
+ return stats
212
+
213
+
214
+ class VirtualScreening:
215
+ """High-level API for virtual screening campaigns."""
216
+
217
+ def __init__(self,
218
+ client: Optional[Union[Boltz2Client, Boltz2SyncClient]] = None,
219
+ max_workers: int = 4):
220
+ """
221
+ Initialize virtual screening.
222
+
223
+ Args:
224
+ client: Boltz2 client instance (if None, creates default)
225
+ max_workers: Maximum parallel workers for screening
226
+ """
227
+ self.client = client or Boltz2SyncClient()
228
+ self.max_workers = max_workers
229
+ self.is_async = isinstance(self.client, Boltz2Client)
230
+
231
+ def screen(self,
232
+ target_sequence: str,
233
+ compound_library: Union[CompoundLibrary, List[Dict], str, Path],
234
+ target_name: str = "Target",
235
+ predict_affinity: bool = True,
236
+ pocket_residues: Optional[List[int]] = None,
237
+ pocket_radius: float = 10.0,
238
+ recycling_steps: int = 2,
239
+ sampling_steps: int = 30,
240
+ diffusion_samples: int = 1,
241
+ sampling_steps_affinity: int = 100,
242
+ diffusion_samples_affinity: int = 3,
243
+ affinity_mw_correction: bool = True,
244
+ batch_size: Optional[int] = None,
245
+ progress_callback: Optional[Callable] = None) -> VirtualScreeningResult:
246
+ """
247
+ Run virtual screening campaign.
248
+
249
+ Args:
250
+ target_sequence: Protein target sequence
251
+ compound_library: Compounds to screen (CompoundLibrary, list, or path to file)
252
+ target_name: Name of the target protein
253
+ predict_affinity: Enable affinity prediction
254
+ pocket_residues: List of residue indices defining binding pocket
255
+ pocket_radius: Radius for pocket constraint in Angstroms
256
+ recycling_steps: Number of recycling steps
257
+ sampling_steps: Number of sampling steps
258
+ diffusion_samples: Number of diffusion samples
259
+ sampling_steps_affinity: Sampling steps for affinity prediction
260
+ diffusion_samples_affinity: Diffusion samples for affinity
261
+ affinity_mw_correction: Apply molecular weight correction
262
+ batch_size: Process compounds in batches (None = all parallel)
263
+ progress_callback: Function called with (completed, total) after each compound
264
+
265
+ Returns:
266
+ VirtualScreeningResult object with all results
267
+ """
268
+ # Prepare compound library
269
+ if isinstance(compound_library, (str, Path)):
270
+ path = Path(compound_library)
271
+ if path.suffix == '.csv':
272
+ compound_library = CompoundLibrary.from_csv(path)
273
+ elif path.suffix == '.json':
274
+ compound_library = CompoundLibrary.from_json(path)
275
+ else:
276
+ raise ValueError(f"Unsupported file format: {path.suffix}")
277
+ elif isinstance(compound_library, list):
278
+ compound_library = CompoundLibrary(compound_library)
279
+
280
+ # Store parameters
281
+ parameters = {
282
+ "target_name": target_name,
283
+ "predict_affinity": predict_affinity,
284
+ "pocket_residues": pocket_residues,
285
+ "pocket_radius": pocket_radius,
286
+ "recycling_steps": recycling_steps,
287
+ "sampling_steps": sampling_steps,
288
+ "diffusion_samples": diffusion_samples,
289
+ "sampling_steps_affinity": sampling_steps_affinity if predict_affinity else None,
290
+ "diffusion_samples_affinity": diffusion_samples_affinity if predict_affinity else None,
291
+ "affinity_mw_correction": affinity_mw_correction if predict_affinity else None
292
+ }
293
+
294
+ # Run screening
295
+ start_time = time.time()
296
+
297
+ if self.is_async:
298
+ # Use async screening
299
+ results = asyncio.run(self._screen_async(
300
+ target_sequence, compound_library, parameters,
301
+ pocket_residues, pocket_radius, progress_callback
302
+ ))
303
+ else:
304
+ # Use sync screening with thread pool
305
+ results = self._screen_sync(
306
+ target_sequence, compound_library, parameters,
307
+ pocket_residues, pocket_radius, progress_callback, batch_size
308
+ )
309
+
310
+ duration = time.time() - start_time
311
+
312
+ return VirtualScreeningResult(
313
+ target_name=target_name,
314
+ target_sequence=target_sequence,
315
+ results=results,
316
+ parameters=parameters,
317
+ duration_seconds=duration
318
+ )
319
+
320
+ def _screen_sync(self, target_sequence: str, compound_library: CompoundLibrary,
321
+ parameters: Dict, pocket_residues: Optional[List[int]],
322
+ pocket_radius: float, progress_callback: Optional[Callable],
323
+ batch_size: Optional[int]) -> List[Dict]:
324
+ """Synchronous screening implementation."""
325
+ results = []
326
+ total = len(compound_library)
327
+
328
+ # Create protein polymer once
329
+ protein = Polymer(
330
+ id="A",
331
+ molecule_type="protein",
332
+ sequence=target_sequence
333
+ )
334
+
335
+ # Prepare pocket constraint if specified
336
+ constraints = []
337
+ if pocket_residues:
338
+ pocket_constraint = PocketConstraint(
339
+ chain_id="A",
340
+ residue_idxs=pocket_residues,
341
+ radius=pocket_radius
342
+ )
343
+ constraints.append(pocket_constraint)
344
+
345
+ # Process compounds
346
+ if batch_size and batch_size < total:
347
+ # Process in batches
348
+ for i in range(0, total, batch_size):
349
+ batch = list(compound_library.compounds[i:i+batch_size])
350
+ batch_results = self._process_batch_sync(
351
+ protein, batch, parameters, constraints
352
+ )
353
+ results.extend(batch_results)
354
+
355
+ if progress_callback:
356
+ progress_callback(len(results), total)
357
+ else:
358
+ # Process all in parallel
359
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
360
+ futures = []
361
+
362
+ for compound in compound_library:
363
+ future = executor.submit(
364
+ self._screen_single_compound_sync,
365
+ protein, compound, parameters, constraints
366
+ )
367
+ futures.append(future)
368
+
369
+ for i, future in enumerate(futures):
370
+ result = future.result()
371
+ results.append(result)
372
+
373
+ if progress_callback:
374
+ progress_callback(i + 1, total)
375
+
376
+ return results
377
+
378
+ async def _screen_async(self, target_sequence: str, compound_library: CompoundLibrary,
379
+ parameters: Dict, pocket_residues: Optional[List[int]],
380
+ pocket_radius: float, progress_callback: Optional[Callable]) -> List[Dict]:
381
+ """Asynchronous screening implementation."""
382
+ # Create protein polymer
383
+ protein = Polymer(
384
+ id="A",
385
+ molecule_type="protein",
386
+ sequence=target_sequence
387
+ )
388
+
389
+ # Prepare pocket constraint
390
+ constraints = []
391
+ if pocket_residues:
392
+ pocket_constraint = PocketConstraint(
393
+ chain_id="A",
394
+ residue_idxs=pocket_residues,
395
+ radius=pocket_radius
396
+ )
397
+ constraints.append(pocket_constraint)
398
+
399
+ # Create tasks
400
+ tasks = []
401
+ for compound in compound_library:
402
+ task = self._screen_single_compound_async(
403
+ protein, compound, parameters, constraints
404
+ )
405
+ tasks.append(task)
406
+
407
+ # Run with progress updates
408
+ results = []
409
+ total = len(tasks)
410
+
411
+ for i, task in enumerate(asyncio.as_completed(tasks)):
412
+ result = await task
413
+ results.append(result)
414
+
415
+ if progress_callback:
416
+ progress_callback(i + 1, total)
417
+
418
+ return results
419
+
420
+ def _screen_single_compound_sync(self, protein: Polymer, compound: Dict,
421
+ parameters: Dict, constraints: List) -> Dict:
422
+ """Screen a single compound synchronously."""
423
+ try:
424
+ # Create ligand
425
+ if "smiles" in compound:
426
+ ligand = Ligand(
427
+ id="LIG",
428
+ smiles=compound["smiles"],
429
+ predict_affinity=parameters["predict_affinity"]
430
+ )
431
+ else:
432
+ ligand = Ligand(
433
+ id="LIG",
434
+ ccd=compound["ccd"],
435
+ predict_affinity=parameters["predict_affinity"]
436
+ )
437
+
438
+ # Create request
439
+ request = PredictionRequest(
440
+ polymers=[protein],
441
+ ligands=[ligand],
442
+ constraints=constraints if constraints else None,
443
+ recycling_steps=parameters["recycling_steps"],
444
+ sampling_steps=parameters["sampling_steps"],
445
+ diffusion_samples=parameters["diffusion_samples"]
446
+ )
447
+
448
+ # Add affinity parameters if enabled
449
+ if parameters["predict_affinity"]:
450
+ request.sampling_steps_affinity = parameters["sampling_steps_affinity"]
451
+ request.diffusion_samples_affinity = parameters["diffusion_samples_affinity"]
452
+ request.affinity_mw_correction = parameters["affinity_mw_correction"]
453
+
454
+ # Run prediction
455
+ response = self.client.predict(request)
456
+
457
+ # Extract results
458
+ result = {
459
+ "compound_name": compound["name"],
460
+ "compound_smiles": compound.get("smiles", ""),
461
+ "compound_ccd": compound.get("ccd", ""),
462
+ "structure_confidence": response.confidence_scores[0] if response.confidence_scores else None,
463
+ "structure_cif": response.structures[0].structure
464
+ }
465
+
466
+ # Add metadata
467
+ if "metadata" in compound:
468
+ for key, value in compound["metadata"].items():
469
+ result[f"compound_{key}"] = value
470
+
471
+ # Add affinity results if available
472
+ if response.affinities and "LIG" in response.affinities:
473
+ affinity = response.affinities["LIG"]
474
+ result.update({
475
+ "predicted_pic50": affinity.affinity_pic50[0],
476
+ "predicted_ic50_nm": 10 ** (-affinity.affinity_pic50[0]) * 1e9,
477
+ "binding_probability": affinity.affinity_probability_binary[0]
478
+ })
479
+
480
+ return result
481
+
482
+ except Exception as e:
483
+ return {
484
+ "compound_name": compound["name"],
485
+ "error": str(e)
486
+ }
487
+
488
+ async def _screen_single_compound_async(self, protein: Polymer, compound: Dict,
489
+ parameters: Dict, constraints: List) -> Dict:
490
+ """Screen a single compound asynchronously."""
491
+ # Similar to sync version but uses await
492
+ try:
493
+ # Create ligand
494
+ if "smiles" in compound:
495
+ ligand = Ligand(
496
+ id="LIG",
497
+ smiles=compound["smiles"],
498
+ predict_affinity=parameters["predict_affinity"]
499
+ )
500
+ else:
501
+ ligand = Ligand(
502
+ id="LIG",
503
+ ccd=compound["ccd"],
504
+ predict_affinity=parameters["predict_affinity"]
505
+ )
506
+
507
+ # Create request
508
+ request = PredictionRequest(
509
+ polymers=[protein],
510
+ ligands=[ligand],
511
+ constraints=constraints if constraints else None,
512
+ recycling_steps=parameters["recycling_steps"],
513
+ sampling_steps=parameters["sampling_steps"],
514
+ diffusion_samples=parameters["diffusion_samples"]
515
+ )
516
+
517
+ # Add affinity parameters
518
+ if parameters["predict_affinity"]:
519
+ request.sampling_steps_affinity = parameters["sampling_steps_affinity"]
520
+ request.diffusion_samples_affinity = parameters["diffusion_samples_affinity"]
521
+ request.affinity_mw_correction = parameters["affinity_mw_correction"]
522
+
523
+ # Run prediction
524
+ response = await self.client.predict(request)
525
+
526
+ # Extract results (same as sync)
527
+ result = {
528
+ "compound_name": compound["name"],
529
+ "compound_smiles": compound.get("smiles", ""),
530
+ "compound_ccd": compound.get("ccd", ""),
531
+ "structure_confidence": response.confidence_scores[0] if response.confidence_scores else None,
532
+ "structure_cif": response.structures[0].structure
533
+ }
534
+
535
+ # Add metadata
536
+ if "metadata" in compound:
537
+ for key, value in compound["metadata"].items():
538
+ result[f"compound_{key}"] = value
539
+
540
+ # Add affinity results
541
+ if response.affinities and "LIG" in response.affinities:
542
+ affinity = response.affinities["LIG"]
543
+ result.update({
544
+ "predicted_pic50": affinity.affinity_pic50[0],
545
+ "predicted_ic50_nm": 10 ** (-affinity.affinity_pic50[0]) * 1e9,
546
+ "binding_probability": affinity.affinity_probability_binary[0]
547
+ })
548
+
549
+ return result
550
+
551
+ except Exception as e:
552
+ return {
553
+ "compound_name": compound["name"],
554
+ "error": str(e)
555
+ }
556
+
557
+ def _process_batch_sync(self, protein: Polymer, batch: List[Dict],
558
+ parameters: Dict, constraints: List) -> List[Dict]:
559
+ """Process a batch of compounds."""
560
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
561
+ futures = []
562
+
563
+ for compound in batch:
564
+ future = executor.submit(
565
+ self._screen_single_compound_sync,
566
+ protein, compound, parameters, constraints
567
+ )
568
+ futures.append(future)
569
+
570
+ results = [future.result() for future in futures]
571
+
572
+ return results
573
+
574
+
575
+ # Convenience functions
576
+ def quick_screen(target_sequence: str,
577
+ compounds: Union[List[Dict], str, Path],
578
+ target_name: str = "Target",
579
+ output_dir: Optional[Union[str, Path]] = None,
580
+ **kwargs) -> VirtualScreeningResult:
581
+ """
582
+ Quick virtual screening with minimal setup.
583
+
584
+ Args:
585
+ target_sequence: Protein sequence
586
+ compounds: List of compounds or path to CSV/JSON file
587
+ target_name: Name of target
588
+ output_dir: Directory to save results (optional)
589
+ **kwargs: Additional parameters passed to VirtualScreening.screen()
590
+
591
+ Returns:
592
+ VirtualScreeningResult
593
+ """
594
+ screener = VirtualScreening()
595
+ result = screener.screen(
596
+ target_sequence=target_sequence,
597
+ compound_library=compounds,
598
+ target_name=target_name,
599
+ **kwargs
600
+ )
601
+
602
+ if output_dir:
603
+ saved_files = result.save_results(output_dir)
604
+ print(f"Results saved to: {output_dir}")
605
+ for key, path in saved_files.items():
606
+ print(f" - {key}: {path}")
607
+
608
+ return result