boltz2-python-client 0.3.2__py3-none-any.whl → 0.4.0__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.
boltz2_client/__init__.py CHANGED
@@ -26,7 +26,7 @@ Example:
26
26
  >>> print(f"Confidence: {result.confidence_scores[0]:.3f}")
27
27
  """
28
28
 
29
- __version__ = "0.3.2"
29
+ __version__ = "0.4.0"
30
30
  __author__ = "NVIDIA Corporation"
31
31
  __email__ = "bionemo-support@nvidia.com"
32
32
 
@@ -43,6 +43,8 @@ from .models import (
43
43
  AlignmentFormat,
44
44
  HealthStatus,
45
45
  ServiceMetadata,
46
+ StructuralTemplate,
47
+ Modification,
46
48
  )
47
49
  from .models_affinity import AffinityPrediction
48
50
  from .exceptions import (
@@ -81,6 +83,11 @@ from .a3m_to_csv_converter import (
81
83
  create_paired_msa_per_chain,
82
84
  save_prediction_outputs,
83
85
  get_prediction_summary,
86
+ save_pae_matrix,
87
+ save_pde_matrix,
88
+ get_pae_summary,
89
+ convert_cif_to_pdb,
90
+ convert_pdb_to_cif,
84
91
  ConversionResult,
85
92
  GreedyPairingStrategy,
86
93
  CompletePairingStrategy,
@@ -130,6 +137,8 @@ __all__ = [
130
137
  "HealthStatus",
131
138
  "ServiceMetadata",
132
139
  "AffinityPrediction",
140
+ "StructuralTemplate",
141
+ "Modification",
133
142
 
134
143
  # Exceptions
135
144
  "Boltz2Error",
@@ -167,6 +176,11 @@ __all__ = [
167
176
  "create_paired_msa_per_chain",
168
177
  "save_prediction_outputs",
169
178
  "get_prediction_summary",
179
+ "save_pae_matrix",
180
+ "save_pde_matrix",
181
+ "get_pae_summary",
182
+ "convert_cif_to_pdb",
183
+ "convert_pdb_to_cif",
170
184
  "ConversionResult",
171
185
  "GreedyPairingStrategy",
172
186
  "CompletePairingStrategy",
@@ -23,7 +23,7 @@ Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
23
23
  import re
24
24
  import csv
25
25
  import logging
26
- from typing import Dict, List, Optional, Tuple, Set, NamedTuple
26
+ from typing import Dict, List, Optional, Tuple, Set, NamedTuple, Union
27
27
  from pathlib import Path
28
28
  from dataclasses import dataclass, field
29
29
  from collections import defaultdict
@@ -1192,6 +1192,11 @@ class A3MToCSVConverter:
1192
1192
  - Rows with the same 'key' across different chain CSVs are paired
1193
1193
  - This enables the model to identify co-evolved sequences from same organisms
1194
1194
 
1195
+ When include_unpaired=True, also includes:
1196
+ - Unpaired sequences in "block-diagonal" format where only one chain has
1197
+ a sequence and others have gaps. This maximizes MSA depth while still
1198
+ providing pairing information where available.
1199
+
1195
1200
  Args:
1196
1201
  msas: Dictionary mapping chain IDs to parsed A3MMSA objects
1197
1202
  output_path: Optional path to save the combined CSV file
@@ -1204,35 +1209,79 @@ class A3MToCSVConverter:
1204
1209
  # Find paired sequences
1205
1210
  pairs = self.pairing_strategy.find_pairs(msas)
1206
1211
 
1212
+ # Track which sequences have been paired (to find unpaired ones later)
1213
+ paired_sequence_ids: Dict[str, Set[str]] = {chain_id: set() for chain_id in chain_ids}
1214
+ for pair in pairs:
1215
+ for chain_id, seq in pair.items():
1216
+ paired_sequence_ids[chain_id].add(seq.identifier)
1217
+
1207
1218
  if self.max_pairs:
1208
1219
  pairs = pairs[:self.max_pairs]
1209
1220
 
1210
1221
  logger.info(f"Found {len(pairs)} paired sequence sets across {len(chain_ids)} chains")
1211
1222
 
1212
- # Extract query sequences
1223
+ # Extract query sequences and their lengths (for gap filling)
1213
1224
  query_sequences = {}
1225
+ query_lengths = {}
1214
1226
  for chain_id, msa in msas.items():
1215
1227
  query = msa.get_query()
1216
1228
  if query:
1217
- query_sequences[chain_id] = self._clean_sequence(query.sequence)
1229
+ clean_seq = self._clean_sequence(query.sequence)
1230
+ query_sequences[chain_id] = clean_seq
1231
+ query_lengths[chain_id] = len(clean_seq)
1232
+
1233
+ # Collect unpaired sequences if requested
1234
+ unpaired_sequences: Dict[str, List[A3MSequence]] = {chain_id: [] for chain_id in chain_ids}
1235
+ num_unpaired = 0
1236
+
1237
+ if self.include_unpaired:
1238
+ for chain_id, msa in msas.items():
1239
+ for seq in msa.sequences:
1240
+ if seq.is_query:
1241
+ continue
1242
+ if seq.identifier not in paired_sequence_ids[chain_id]:
1243
+ unpaired_sequences[chain_id].append(seq)
1244
+ num_unpaired += 1
1245
+
1246
+ logger.info(f"Found {num_unpaired} unpaired sequences to include in block-diagonal format")
1247
+
1248
+ # Calculate total rows: paired + unpaired (block-diagonal)
1249
+ current_key = len(pairs) # Start unpaired keys after paired ones
1218
1250
 
1219
1251
  # Build per-chain CSVs (for Boltz2 NIM API)
1220
1252
  csv_per_chain: Dict[str, str] = {}
1221
1253
  for chain_id in chain_ids:
1222
1254
  chain_lines = ["key,sequence"]
1255
+
1256
+ # Add paired sequences
1223
1257
  for idx, pair in enumerate(pairs, start=1):
1224
1258
  if chain_id in pair:
1225
1259
  seq = self._clean_sequence(pair[chain_id].sequence)
1226
1260
  chain_lines.append(f"{idx},{seq}")
1227
1261
  else:
1228
1262
  # Use gaps if sequence not found for this chain
1229
- ref_msa = msas[chain_id]
1230
- query = ref_msa.get_query()
1231
- if query:
1232
- gap_seq = '-' * len(self._clean_sequence(query.sequence))
1233
- chain_lines.append(f"{idx},{gap_seq}")
1263
+ gap_seq = '-' * query_lengths.get(chain_id, 0)
1264
+ chain_lines.append(f"{idx},{gap_seq}")
1265
+
1234
1266
  csv_per_chain[chain_id] = '\n'.join(chain_lines)
1235
1267
 
1268
+ # Add unpaired sequences in block-diagonal format
1269
+ if self.include_unpaired:
1270
+ unpaired_key = current_key + 1
1271
+ for source_chain_id in chain_ids:
1272
+ for unpaired_seq in unpaired_sequences[source_chain_id]:
1273
+ # Add this unpaired sequence to all chain CSVs
1274
+ for chain_id in chain_ids:
1275
+ if chain_id == source_chain_id:
1276
+ # This chain has the sequence
1277
+ seq = self._clean_sequence(unpaired_seq.sequence)
1278
+ csv_per_chain[chain_id] += f"\n{unpaired_key},{seq}"
1279
+ else:
1280
+ # Other chains get gaps
1281
+ gap_seq = '-' * query_lengths.get(chain_id, 0)
1282
+ csv_per_chain[chain_id] += f"\n{unpaired_key},{gap_seq}"
1283
+ unpaired_key += 1
1284
+
1236
1285
  # Build combined CSV (for reference/documentation)
1237
1286
  csv_lines = ["key,sequence"]
1238
1287
  for idx, pair in enumerate(pairs, start=1):
@@ -1242,15 +1291,31 @@ class A3MToCSVConverter:
1242
1291
  seq = self._clean_sequence(pair[chain_id].sequence)
1243
1292
  sequences.append(seq)
1244
1293
  else:
1245
- ref_msa = msas[chain_id]
1246
- query = ref_msa.get_query()
1247
- if query:
1248
- sequences.append('-' * len(self._clean_sequence(query.sequence)))
1294
+ sequences.append('-' * query_lengths.get(chain_id, 0))
1249
1295
  concatenated = ':'.join(sequences)
1250
1296
  csv_lines.append(f"{idx},{concatenated}")
1251
1297
 
1298
+ # Add unpaired sequences to combined CSV in block-diagonal format
1299
+ if self.include_unpaired:
1300
+ unpaired_key = current_key + 1
1301
+ for source_chain_id in chain_ids:
1302
+ for unpaired_seq in unpaired_sequences[source_chain_id]:
1303
+ sequences = []
1304
+ for chain_id in chain_ids:
1305
+ if chain_id == source_chain_id:
1306
+ seq = self._clean_sequence(unpaired_seq.sequence)
1307
+ sequences.append(seq)
1308
+ else:
1309
+ sequences.append('-' * query_lengths.get(chain_id, 0))
1310
+ concatenated = ':'.join(sequences)
1311
+ csv_lines.append(f"{unpaired_key},{concatenated}")
1312
+ unpaired_key += 1
1313
+
1252
1314
  csv_content = '\n'.join(csv_lines)
1253
1315
 
1316
+ total_rows = len(pairs) + (num_unpaired if self.include_unpaired else 0)
1317
+ logger.info(f"Total MSA rows: {total_rows} ({len(pairs)} paired + {num_unpaired if self.include_unpaired else 0} unpaired)")
1318
+
1254
1319
  # Save files if path provided
1255
1320
  output_paths_per_chain = None
1256
1321
  if output_path:
@@ -1355,6 +1420,7 @@ def convert_a3m_to_multimer_csv(
1355
1420
  output_path: Optional[Path] = None,
1356
1421
  pairing_strategy: str = "greedy",
1357
1422
  use_tax_id: Optional[bool] = None, # None = auto-detect (ColabFold default)
1423
+ include_unpaired: bool = False,
1358
1424
  max_pairs: Optional[int] = None
1359
1425
  ) -> ConversionResult:
1360
1426
  """
@@ -1374,6 +1440,10 @@ def convert_a3m_to_multimer_csv(
1374
1440
  This is how ColabFold behaves.
1375
1441
  - True: Force TaxID pairing (requires OX= or species codes in headers)
1376
1442
  - False: Force UniRef/organism ID pairing (works with standard ColabFold output)
1443
+ include_unpaired: If True, include sequences without cross-chain matches in
1444
+ "block-diagonal" format (one chain has sequence, others have gaps).
1445
+ This maximizes MSA depth while still providing pairing where available.
1446
+ Default: False (only paired sequences are included)
1377
1447
  max_pairs: Maximum number of pairs to include
1378
1448
 
1379
1449
  Returns:
@@ -1388,6 +1458,12 @@ def convert_a3m_to_multimer_csv(
1388
1458
  ... # use_tax_id=None means auto-detect (default)
1389
1459
  ... )
1390
1460
  >>> print(f"Created {result.num_pairs} paired sequences")
1461
+
1462
+ >>> # Include unpaired sequences for maximum MSA depth
1463
+ >>> result = convert_a3m_to_multimer_csv(
1464
+ ... a3m_files={'A': Path('chain_A.a3m'), 'B': Path('chain_B.a3m')},
1465
+ ... include_unpaired=True # Block-diagonal format
1466
+ ... )
1391
1467
 
1392
1468
  ColabFold Compatibility:
1393
1469
  Standard ColabFold A3M files use UniRef100 cluster IDs without TaxID information.
@@ -1417,6 +1493,7 @@ def convert_a3m_to_multimer_csv(
1417
1493
 
1418
1494
  converter = A3MToCSVConverter(
1419
1495
  pairing_strategy=strategy,
1496
+ include_unpaired=include_unpaired,
1420
1497
  max_pairs=max_pairs
1421
1498
  )
1422
1499
 
@@ -1659,3 +1736,277 @@ def get_prediction_summary(response) -> Dict:
1659
1736
 
1660
1737
  return summary
1661
1738
 
1739
+
1740
+ def save_pae_matrix(
1741
+ pae: List[List[List[float]]],
1742
+ output_path: Union[str, Path],
1743
+ format: str = "json"
1744
+ ) -> Path:
1745
+ """
1746
+ Save PAE (Predicted Aligned Error) matrix to file.
1747
+
1748
+ Args:
1749
+ pae: PAE matrix from PredictionResponse.pae
1750
+ Shape: [num_models, num_residues, num_residues]
1751
+ output_path: Path to save the file
1752
+ format: Output format - "json" or "npy" (numpy)
1753
+
1754
+ Returns:
1755
+ Path to saved file
1756
+
1757
+ Example:
1758
+ >>> if response.pae:
1759
+ ... save_pae_matrix(response.pae, "structure.pae.json")
1760
+ """
1761
+ import json
1762
+
1763
+ output_path = Path(output_path)
1764
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1765
+
1766
+ if format == "json":
1767
+ with open(output_path, "w") as f:
1768
+ json.dump({"pae": pae}, f, indent=2)
1769
+ elif format == "npy":
1770
+ try:
1771
+ import numpy as np
1772
+ np.save(output_path, np.array(pae))
1773
+ except ImportError:
1774
+ raise ImportError("numpy is required for .npy format. Install with: pip install numpy")
1775
+ else:
1776
+ raise ValueError(f"Unsupported format: {format}. Use 'json' or 'npy'")
1777
+
1778
+ return output_path
1779
+
1780
+
1781
+ def save_pde_matrix(
1782
+ pde: List[List[List[float]]],
1783
+ output_path: Union[str, Path],
1784
+ format: str = "json"
1785
+ ) -> Path:
1786
+ """
1787
+ Save PDE (Predicted Distance Error) matrix to file.
1788
+
1789
+ Args:
1790
+ pde: PDE matrix from PredictionResponse.pde
1791
+ Shape: [num_models, num_residues, num_residues]
1792
+ output_path: Path to save the file
1793
+ format: Output format - "json" or "npy" (numpy)
1794
+
1795
+ Returns:
1796
+ Path to saved file
1797
+
1798
+ Example:
1799
+ >>> if response.pde:
1800
+ ... save_pde_matrix(response.pde, "structure.pde.json")
1801
+ """
1802
+ import json
1803
+
1804
+ output_path = Path(output_path)
1805
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1806
+
1807
+ if format == "json":
1808
+ with open(output_path, "w") as f:
1809
+ json.dump({"pde": pde}, f, indent=2)
1810
+ elif format == "npy":
1811
+ try:
1812
+ import numpy as np
1813
+ np.save(output_path, np.array(pde))
1814
+ except ImportError:
1815
+ raise ImportError("numpy is required for .npy format. Install with: pip install numpy")
1816
+ else:
1817
+ raise ValueError(f"Unsupported format: {format}. Use 'json' or 'npy'")
1818
+
1819
+ return output_path
1820
+
1821
+
1822
+ def get_pae_summary(pae: List[List[List[float]]]) -> Dict:
1823
+ """
1824
+ Get summary statistics for PAE matrix.
1825
+
1826
+ Args:
1827
+ pae: PAE matrix from PredictionResponse.pae
1828
+
1829
+ Returns:
1830
+ Dictionary with shape, mean, min, max values
1831
+
1832
+ Example:
1833
+ >>> summary = get_pae_summary(response.pae)
1834
+ >>> print(f"Mean PAE: {summary['mean']:.2f} Å")
1835
+ """
1836
+ if not pae:
1837
+ return {}
1838
+
1839
+ # Flatten to get statistics
1840
+ all_values = []
1841
+ for model_pae in pae:
1842
+ for row in model_pae:
1843
+ all_values.extend(row)
1844
+
1845
+ return {
1846
+ "shape": f"{len(pae)}x{len(pae[0])}x{len(pae[0][0])}",
1847
+ "num_models": len(pae),
1848
+ "num_residues": len(pae[0]),
1849
+ "mean": sum(all_values) / len(all_values),
1850
+ "min": min(all_values),
1851
+ "max": max(all_values),
1852
+ }
1853
+
1854
+
1855
+ def convert_cif_to_pdb(
1856
+ cif_input: Union[str, Path],
1857
+ pdb_output: Union[str, Path],
1858
+ structure_id: str = "PRED"
1859
+ ) -> Path:
1860
+ """
1861
+ Convert mmCIF file to PDB format.
1862
+
1863
+ Uses gemmi (preferred, faster) or BioPython as fallback.
1864
+
1865
+ Args:
1866
+ cif_input: Path to input CIF file or CIF content as string
1867
+ pdb_output: Path for output PDB file
1868
+ structure_id: Structure ID to use in PDB file (default: "PRED")
1869
+
1870
+ Returns:
1871
+ Path to the saved PDB file
1872
+
1873
+ Example:
1874
+ >>> convert_cif_to_pdb("structure.cif", "structure.pdb")
1875
+ >>> # Or from string content
1876
+ >>> convert_cif_to_pdb(cif_content, "structure.pdb")
1877
+
1878
+ Note:
1879
+ Requires either gemmi or BioPython to be installed:
1880
+ - pip install gemmi (recommended, faster)
1881
+ - pip install biopython
1882
+ """
1883
+ import tempfile
1884
+
1885
+ pdb_output = Path(pdb_output)
1886
+ pdb_output.parent.mkdir(parents=True, exist_ok=True)
1887
+
1888
+ # Check if input is a file path or string content
1889
+ cif_path = Path(cif_input) if isinstance(cif_input, (str, Path)) and Path(cif_input).exists() else None
1890
+
1891
+ # If input is string content, write to temp file
1892
+ if cif_path is None:
1893
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.cif', delete=False) as f:
1894
+ f.write(str(cif_input))
1895
+ cif_path = Path(f.name)
1896
+ cleanup_temp = True
1897
+ else:
1898
+ cif_path = Path(cif_input)
1899
+ cleanup_temp = False
1900
+
1901
+ try:
1902
+ # Try gemmi first (faster)
1903
+ try:
1904
+ import gemmi
1905
+ structure = gemmi.read_structure(str(cif_path))
1906
+ structure.write_pdb(str(pdb_output))
1907
+ return pdb_output
1908
+ except ImportError:
1909
+ pass
1910
+
1911
+ # Fallback to BioPython
1912
+ try:
1913
+ from Bio.PDB import MMCIFParser, PDBIO
1914
+ import warnings
1915
+
1916
+ # Suppress BioPython warnings about incomplete structures
1917
+ with warnings.catch_warnings():
1918
+ warnings.simplefilter("ignore")
1919
+
1920
+ parser = MMCIFParser(QUIET=True)
1921
+ structure = parser.get_structure(structure_id, str(cif_path))
1922
+
1923
+ io = PDBIO()
1924
+ io.set_structure(structure)
1925
+ io.save(str(pdb_output))
1926
+
1927
+ return pdb_output
1928
+ except ImportError:
1929
+ raise ImportError(
1930
+ "CIF to PDB conversion requires either gemmi or BioPython. "
1931
+ "Install with: pip install gemmi (recommended) or pip install biopython"
1932
+ )
1933
+ finally:
1934
+ # Clean up temp file if created
1935
+ if cleanup_temp and cif_path.exists():
1936
+ cif_path.unlink()
1937
+
1938
+
1939
+ def convert_pdb_to_cif(
1940
+ pdb_input: Union[str, Path],
1941
+ cif_output: Union[str, Path],
1942
+ structure_id: str = "PRED"
1943
+ ) -> Path:
1944
+ """
1945
+ Convert PDB file to mmCIF format.
1946
+
1947
+ Uses gemmi (preferred, faster) or BioPython as fallback.
1948
+
1949
+ Args:
1950
+ pdb_input: Path to input PDB file or PDB content as string
1951
+ cif_output: Path for output CIF file
1952
+ structure_id: Structure ID to use (default: "PRED")
1953
+
1954
+ Returns:
1955
+ Path to the saved CIF file
1956
+
1957
+ Example:
1958
+ >>> convert_pdb_to_cif("structure.pdb", "structure.cif")
1959
+ """
1960
+ import tempfile
1961
+
1962
+ cif_output = Path(cif_output)
1963
+ cif_output.parent.mkdir(parents=True, exist_ok=True)
1964
+
1965
+ # Check if input is a file path or string content
1966
+ pdb_path = Path(pdb_input) if isinstance(pdb_input, (str, Path)) and Path(pdb_input).exists() else None
1967
+
1968
+ # If input is string content, write to temp file
1969
+ if pdb_path is None:
1970
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.pdb', delete=False) as f:
1971
+ f.write(str(pdb_input))
1972
+ pdb_path = Path(f.name)
1973
+ cleanup_temp = True
1974
+ else:
1975
+ pdb_path = Path(pdb_input)
1976
+ cleanup_temp = False
1977
+
1978
+ try:
1979
+ # Try gemmi first (faster)
1980
+ try:
1981
+ import gemmi
1982
+ structure = gemmi.read_structure(str(pdb_path))
1983
+ structure.write_minimal_cif(str(cif_output))
1984
+ return cif_output
1985
+ except ImportError:
1986
+ pass
1987
+
1988
+ # Fallback to BioPython
1989
+ try:
1990
+ from Bio.PDB import PDBParser, MMCIFIO
1991
+ import warnings
1992
+
1993
+ with warnings.catch_warnings():
1994
+ warnings.simplefilter("ignore")
1995
+
1996
+ parser = PDBParser(QUIET=True)
1997
+ structure = parser.get_structure(structure_id, str(pdb_path))
1998
+
1999
+ io = MMCIFIO()
2000
+ io.set_structure(structure)
2001
+ io.save(str(cif_output))
2002
+
2003
+ return cif_output
2004
+ except ImportError:
2005
+ raise ImportError(
2006
+ "PDB to CIF conversion requires either gemmi or BioPython. "
2007
+ "Install with: pip install gemmi (recommended) or pip install biopython"
2008
+ )
2009
+ finally:
2010
+ if cleanup_temp and pdb_path.exists():
2011
+ pdb_path.unlink()
2012
+