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.
- boltz2_client/__init__.py +153 -0
- boltz2_client/cli.py +1213 -0
- boltz2_client/client.py +986 -0
- boltz2_client/exceptions.py +213 -0
- boltz2_client/models.py +466 -0
- boltz2_client/models_affinity.py +46 -0
- boltz2_client/utils.py +455 -0
- boltz2_client/virtual_screening.py +608 -0
- boltz2_python_client-0.2.dist-info/METADATA +533 -0
- boltz2_python_client-0.2.dist-info/RECORD +14 -0
- boltz2_python_client-0.2.dist-info/WHEEL +5 -0
- boltz2_python_client-0.2.dist-info/entry_points.txt +2 -0
- boltz2_python_client-0.2.dist-info/licenses/LICENSE +21 -0
- boltz2_python_client-0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# ---------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
3
|
+
# ---------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Affinity prediction models for Boltz-2 API.
|
|
7
|
+
|
|
8
|
+
This module defines the affinity-related models for the new affinity
|
|
9
|
+
prediction capabilities in Boltz-2.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional, Dict, Any
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AffinityPrediction(BaseModel):
|
|
17
|
+
"""Affinity prediction results for a ligand."""
|
|
18
|
+
|
|
19
|
+
affinity_pred_value: List[float] = Field(
|
|
20
|
+
...,
|
|
21
|
+
description="The predicted log(IC50) values"
|
|
22
|
+
)
|
|
23
|
+
affinity_probability_binary: List[float] = Field(
|
|
24
|
+
...,
|
|
25
|
+
description="The binary affinity prediction probability (0-1)"
|
|
26
|
+
)
|
|
27
|
+
model_1_affinity_pred_value: List[float] = Field(
|
|
28
|
+
...,
|
|
29
|
+
description="The predicted log(IC50) from Model 1"
|
|
30
|
+
)
|
|
31
|
+
model_1_affinity_probability_binary: List[float] = Field(
|
|
32
|
+
...,
|
|
33
|
+
description="The binary affinity prediction probability from Model 1"
|
|
34
|
+
)
|
|
35
|
+
model_2_affinity_pred_value: List[float] = Field(
|
|
36
|
+
...,
|
|
37
|
+
description="The predicted log(IC50) from Model 2"
|
|
38
|
+
)
|
|
39
|
+
model_2_affinity_probability_binary: List[float] = Field(
|
|
40
|
+
...,
|
|
41
|
+
description="The binary affinity prediction probability from Model 2"
|
|
42
|
+
)
|
|
43
|
+
affinity_pic50: List[float] = Field(
|
|
44
|
+
...,
|
|
45
|
+
description="Predicted pIC50 binding affinity (kcal/mol)"
|
|
46
|
+
)
|
boltz2_client/utils.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
# ---------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
3
|
+
# ---------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Utility functions for the Boltz-2 Python client.
|
|
8
|
+
|
|
9
|
+
This module provides helper functions for sequence validation, file I/O,
|
|
10
|
+
structure manipulation, and other common tasks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Any, Optional, Union, List
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
import aiofiles
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_sequence(sequence: str, molecule_type: str) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Validate a molecular sequence.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
sequence: The sequence string to validate
|
|
28
|
+
molecule_type: Type of molecule ('protein', 'dna', 'rna')
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if sequence is valid
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If sequence is invalid
|
|
35
|
+
"""
|
|
36
|
+
if not sequence or not sequence.strip():
|
|
37
|
+
raise ValueError("Sequence cannot be empty")
|
|
38
|
+
|
|
39
|
+
sequence = sequence.upper().strip()
|
|
40
|
+
|
|
41
|
+
if molecule_type.lower() == "protein":
|
|
42
|
+
# Standard amino acid codes
|
|
43
|
+
valid_chars = set("ACDEFGHIKLMNPQRSTVWY")
|
|
44
|
+
invalid_chars = set(sequence) - valid_chars
|
|
45
|
+
if invalid_chars:
|
|
46
|
+
raise ValueError(f"Invalid amino acid characters: {invalid_chars}")
|
|
47
|
+
|
|
48
|
+
elif molecule_type.lower() == "dna":
|
|
49
|
+
# Standard DNA bases
|
|
50
|
+
valid_chars = set("ATCG")
|
|
51
|
+
invalid_chars = set(sequence) - valid_chars
|
|
52
|
+
if invalid_chars:
|
|
53
|
+
raise ValueError(f"Invalid DNA base characters: {invalid_chars}")
|
|
54
|
+
|
|
55
|
+
elif molecule_type.lower() == "rna":
|
|
56
|
+
# Standard RNA bases
|
|
57
|
+
valid_chars = set("AUCG")
|
|
58
|
+
invalid_chars = set(sequence) - valid_chars
|
|
59
|
+
if invalid_chars:
|
|
60
|
+
raise ValueError(f"Invalid RNA base characters: {invalid_chars}")
|
|
61
|
+
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError(f"Unknown molecule type: {molecule_type}")
|
|
64
|
+
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_mmcif(mmcif_data: str) -> Dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Parse mmCIF data and extract basic information.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
mmcif_data: mmCIF format string
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dictionary with parsed information
|
|
77
|
+
"""
|
|
78
|
+
info = {
|
|
79
|
+
"atoms": [],
|
|
80
|
+
"chains": set(),
|
|
81
|
+
"residues": set(),
|
|
82
|
+
"metadata": {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines = mmcif_data.strip().split('\n')
|
|
86
|
+
|
|
87
|
+
for line in lines:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
|
|
90
|
+
# Parse atom records
|
|
91
|
+
if line.startswith('ATOM') or line.startswith('HETATM'):
|
|
92
|
+
parts = line.split()
|
|
93
|
+
if len(parts) >= 11:
|
|
94
|
+
atom_info = {
|
|
95
|
+
"type": parts[0],
|
|
96
|
+
"id": parts[1],
|
|
97
|
+
"atom_name": parts[2],
|
|
98
|
+
"residue_name": parts[3],
|
|
99
|
+
"chain": parts[4],
|
|
100
|
+
"residue_id": parts[5],
|
|
101
|
+
"x": float(parts[6]),
|
|
102
|
+
"y": float(parts[7]),
|
|
103
|
+
"z": float(parts[8]),
|
|
104
|
+
"occupancy": float(parts[9]) if parts[9] != '?' else 1.0,
|
|
105
|
+
"b_factor": float(parts[10]) if parts[10] != '?' else 0.0,
|
|
106
|
+
}
|
|
107
|
+
info["atoms"].append(atom_info)
|
|
108
|
+
info["chains"].add(atom_info["chain"])
|
|
109
|
+
info["residues"].add(f"{atom_info['chain']}:{atom_info['residue_name']}{atom_info['residue_id']}")
|
|
110
|
+
|
|
111
|
+
# Convert sets to lists for JSON serialization
|
|
112
|
+
info["chains"] = list(info["chains"])
|
|
113
|
+
info["residues"] = list(info["residues"])
|
|
114
|
+
|
|
115
|
+
return info
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def save_structure(structure_data: str, filepath: Union[str, Path]) -> Path:
|
|
119
|
+
"""
|
|
120
|
+
Save structure data to a file.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
structure_data: Structure data (mmCIF format)
|
|
124
|
+
filepath: Output file path
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Path object of saved file
|
|
128
|
+
"""
|
|
129
|
+
filepath = Path(filepath)
|
|
130
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
with open(filepath, 'w') as f:
|
|
133
|
+
f.write(structure_data)
|
|
134
|
+
|
|
135
|
+
return filepath
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def save_structure_async(structure_data: str, filepath: Union[str, Path]) -> Path:
|
|
139
|
+
"""
|
|
140
|
+
Asynchronously save structure data to a file.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
structure_data: Structure data (mmCIF format)
|
|
144
|
+
filepath: Output file path
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Path object of saved file
|
|
148
|
+
"""
|
|
149
|
+
filepath = Path(filepath)
|
|
150
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
async with aiofiles.open(filepath, 'w') as f:
|
|
153
|
+
await f.write(structure_data)
|
|
154
|
+
|
|
155
|
+
return filepath
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_structure(filepath: Union[str, Path]) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Load structure data from a file.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
filepath: Input file path
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Structure data as string
|
|
167
|
+
"""
|
|
168
|
+
filepath = Path(filepath)
|
|
169
|
+
|
|
170
|
+
if not filepath.exists():
|
|
171
|
+
raise FileNotFoundError(f"Structure file not found: {filepath}")
|
|
172
|
+
|
|
173
|
+
with open(filepath, 'r') as f:
|
|
174
|
+
return f.read()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def load_structure_async(filepath: Union[str, Path]) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Asynchronously load structure data from a file.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
filepath: Input file path
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Structure data as string
|
|
186
|
+
"""
|
|
187
|
+
filepath = Path(filepath)
|
|
188
|
+
|
|
189
|
+
if not filepath.exists():
|
|
190
|
+
raise FileNotFoundError(f"Structure file not found: {filepath}")
|
|
191
|
+
|
|
192
|
+
async with aiofiles.open(filepath, 'r') as f:
|
|
193
|
+
return await f.read()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def save_json(data: Dict[str, Any], filepath: Union[str, Path]) -> Path:
|
|
197
|
+
"""
|
|
198
|
+
Save data as JSON file.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
data: Data to save
|
|
202
|
+
filepath: Output file path
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Path object of saved file
|
|
206
|
+
"""
|
|
207
|
+
filepath = Path(filepath)
|
|
208
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
with open(filepath, 'w') as f:
|
|
211
|
+
json.dump(data, f, indent=2, default=str)
|
|
212
|
+
|
|
213
|
+
return filepath
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def load_json(filepath: Union[str, Path]) -> Dict[str, Any]:
|
|
217
|
+
"""
|
|
218
|
+
Load data from JSON file.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
filepath: Input file path
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Loaded data
|
|
225
|
+
"""
|
|
226
|
+
filepath = Path(filepath)
|
|
227
|
+
|
|
228
|
+
if not filepath.exists():
|
|
229
|
+
raise FileNotFoundError(f"JSON file not found: {filepath}")
|
|
230
|
+
|
|
231
|
+
with open(filepath, 'r') as f:
|
|
232
|
+
return json.load(f)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def format_sequence(sequence: str, line_length: int = 80) -> str:
|
|
236
|
+
"""
|
|
237
|
+
Format a sequence with line breaks.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
sequence: Input sequence
|
|
241
|
+
line_length: Maximum line length
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Formatted sequence string
|
|
245
|
+
"""
|
|
246
|
+
sequence = sequence.strip()
|
|
247
|
+
lines = []
|
|
248
|
+
|
|
249
|
+
for i in range(0, len(sequence), line_length):
|
|
250
|
+
lines.append(sequence[i:i + line_length])
|
|
251
|
+
|
|
252
|
+
return '\n'.join(lines)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def calculate_sequence_stats(sequence: str, molecule_type: str) -> Dict[str, Any]:
|
|
256
|
+
"""
|
|
257
|
+
Calculate basic statistics for a sequence.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
sequence: Input sequence
|
|
261
|
+
molecule_type: Type of molecule ('protein', 'dna', 'rna')
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Dictionary with sequence statistics
|
|
265
|
+
"""
|
|
266
|
+
sequence = sequence.upper().strip()
|
|
267
|
+
length = len(sequence)
|
|
268
|
+
|
|
269
|
+
stats = {
|
|
270
|
+
"length": length,
|
|
271
|
+
"composition": {},
|
|
272
|
+
"molecular_weight": 0.0,
|
|
273
|
+
"type": molecule_type.lower()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# Count composition
|
|
277
|
+
for char in set(sequence):
|
|
278
|
+
stats["composition"][char] = sequence.count(char)
|
|
279
|
+
|
|
280
|
+
# Calculate molecular weight (approximate)
|
|
281
|
+
if molecule_type.lower() == "protein":
|
|
282
|
+
# Average amino acid molecular weight
|
|
283
|
+
aa_weights = {
|
|
284
|
+
'A': 89.1, 'R': 174.2, 'N': 132.1, 'D': 133.1, 'C': 121.2,
|
|
285
|
+
'Q': 146.1, 'E': 147.1, 'G': 75.1, 'H': 155.2, 'I': 131.2,
|
|
286
|
+
'L': 131.2, 'K': 146.2, 'M': 149.2, 'F': 165.2, 'P': 115.1,
|
|
287
|
+
'S': 105.1, 'T': 119.1, 'W': 204.2, 'Y': 181.2, 'V': 117.1
|
|
288
|
+
}
|
|
289
|
+
stats["molecular_weight"] = sum(aa_weights.get(aa, 110.0) for aa in sequence)
|
|
290
|
+
|
|
291
|
+
elif molecule_type.lower() in ["dna", "rna"]:
|
|
292
|
+
# Average nucleotide molecular weight
|
|
293
|
+
if molecule_type.lower() == "dna":
|
|
294
|
+
nt_weights = {'A': 331.2, 'T': 322.2, 'C': 307.2, 'G': 347.2}
|
|
295
|
+
else: # RNA
|
|
296
|
+
nt_weights = {'A': 347.2, 'U': 324.2, 'C': 323.2, 'G': 363.2}
|
|
297
|
+
|
|
298
|
+
stats["molecular_weight"] = sum(nt_weights.get(nt, 330.0) for nt in sequence)
|
|
299
|
+
|
|
300
|
+
return stats
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def generate_timestamp() -> str:
|
|
304
|
+
"""
|
|
305
|
+
Generate a timestamp string for file naming.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Timestamp string in format YYYYMMDD_HHMMSS
|
|
309
|
+
"""
|
|
310
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sanitize_filename(filename: str) -> str:
|
|
314
|
+
"""
|
|
315
|
+
Sanitize a filename by removing invalid characters.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
filename: Input filename
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Sanitized filename
|
|
322
|
+
"""
|
|
323
|
+
# Remove invalid characters
|
|
324
|
+
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
|
325
|
+
|
|
326
|
+
# Remove leading/trailing spaces and dots
|
|
327
|
+
filename = filename.strip(' .')
|
|
328
|
+
|
|
329
|
+
# Ensure it's not empty
|
|
330
|
+
if not filename:
|
|
331
|
+
filename = "unnamed"
|
|
332
|
+
|
|
333
|
+
return filename
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def create_output_directory(base_dir: Union[str, Path], prefix: str = "boltz2_output") -> Path:
|
|
337
|
+
"""
|
|
338
|
+
Create a timestamped output directory.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
base_dir: Base directory path
|
|
342
|
+
prefix: Directory name prefix
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Created directory path
|
|
346
|
+
"""
|
|
347
|
+
base_dir = Path(base_dir)
|
|
348
|
+
timestamp = generate_timestamp()
|
|
349
|
+
output_dir = base_dir / f"{prefix}_{timestamp}"
|
|
350
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
|
|
352
|
+
return output_dir
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def validate_smiles(smiles: str) -> bool:
|
|
356
|
+
"""
|
|
357
|
+
Basic SMILES string validation.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
smiles: SMILES string to validate
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
True if SMILES appears valid
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
ValueError: If SMILES is invalid
|
|
367
|
+
"""
|
|
368
|
+
if not smiles or not smiles.strip():
|
|
369
|
+
raise ValueError("SMILES string cannot be empty")
|
|
370
|
+
|
|
371
|
+
smiles = smiles.strip()
|
|
372
|
+
|
|
373
|
+
# Basic character validation
|
|
374
|
+
if any(char in smiles for char in [' ', '\t', '\n']):
|
|
375
|
+
raise ValueError("SMILES string should not contain whitespace")
|
|
376
|
+
|
|
377
|
+
# Check for balanced parentheses
|
|
378
|
+
paren_count = 0
|
|
379
|
+
bracket_count = 0
|
|
380
|
+
|
|
381
|
+
for char in smiles:
|
|
382
|
+
if char == '(':
|
|
383
|
+
paren_count += 1
|
|
384
|
+
elif char == ')':
|
|
385
|
+
paren_count -= 1
|
|
386
|
+
elif char == '[':
|
|
387
|
+
bracket_count += 1
|
|
388
|
+
elif char == ']':
|
|
389
|
+
bracket_count -= 1
|
|
390
|
+
|
|
391
|
+
if paren_count < 0 or bracket_count < 0:
|
|
392
|
+
raise ValueError("Unbalanced parentheses or brackets in SMILES")
|
|
393
|
+
|
|
394
|
+
if paren_count != 0:
|
|
395
|
+
raise ValueError("Unbalanced parentheses in SMILES")
|
|
396
|
+
|
|
397
|
+
if bracket_count != 0:
|
|
398
|
+
raise ValueError("Unbalanced brackets in SMILES")
|
|
399
|
+
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def extract_chains_from_mmcif(mmcif_data: str) -> List[str]:
|
|
404
|
+
"""
|
|
405
|
+
Extract chain IDs from mmCIF data.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
mmcif_data: mmCIF format string
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
List of unique chain IDs
|
|
412
|
+
"""
|
|
413
|
+
chains = set()
|
|
414
|
+
lines = mmcif_data.strip().split('\n')
|
|
415
|
+
|
|
416
|
+
for line in lines:
|
|
417
|
+
line = line.strip()
|
|
418
|
+
if line.startswith('ATOM') or line.startswith('HETATM'):
|
|
419
|
+
parts = line.split()
|
|
420
|
+
if len(parts) >= 5:
|
|
421
|
+
chain_id = parts[4]
|
|
422
|
+
chains.add(chain_id)
|
|
423
|
+
|
|
424
|
+
return sorted(list(chains))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_structure_summary(mmcif_data: str) -> Dict[str, Any]:
|
|
428
|
+
"""
|
|
429
|
+
Get a summary of structure information.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
mmcif_data: mmCIF format string
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Dictionary with structure summary
|
|
436
|
+
"""
|
|
437
|
+
info = parse_mmcif(mmcif_data)
|
|
438
|
+
|
|
439
|
+
summary = {
|
|
440
|
+
"total_atoms": len(info["atoms"]),
|
|
441
|
+
"chains": len(info["chains"]),
|
|
442
|
+
"residues": len(info["residues"]),
|
|
443
|
+
"chain_list": info["chains"],
|
|
444
|
+
"size_estimate_mb": len(mmcif_data) / (1024 * 1024),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
# Count atoms by type
|
|
448
|
+
atom_types = {}
|
|
449
|
+
for atom in info["atoms"]:
|
|
450
|
+
atom_type = atom["type"]
|
|
451
|
+
atom_types[atom_type] = atom_types.get(atom_type, 0) + 1
|
|
452
|
+
|
|
453
|
+
summary["atom_types"] = atom_types
|
|
454
|
+
|
|
455
|
+
return summary
|