mdprep 0.2.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.
Files changed (100) hide show
  1. mdprep/__init__.py +7 -0
  2. mdprep/_selftest_data/basic_protein.yaml +33 -0
  3. mdprep/_selftest_data/ligand_sub.mol2 +11 -0
  4. mdprep/ambertools/__init__.py +2 -0
  5. mdprep/ambertools/antechamber.py +110 -0
  6. mdprep/ambertools/commands.py +33 -0
  7. mdprep/ambertools/mol2.py +415 -0
  8. mdprep/ambertools/parmchk2.py +89 -0
  9. mdprep/ambertools/resp.py +570 -0
  10. mdprep/charges/__init__.py +1 -0
  11. mdprep/charges/esp_grid.py +190 -0
  12. mdprep/cli.py +451 -0
  13. mdprep/config/__init__.py +9 -0
  14. mdprep/config/loader.py +146 -0
  15. mdprep/config/models.py +1100 -0
  16. mdprep/external/__init__.py +14 -0
  17. mdprep/external/discovery.py +14 -0
  18. mdprep/external/runner.py +55 -0
  19. mdprep/leap/__init__.py +2 -0
  20. mdprep/leap/builder.py +476 -0
  21. mdprep/leap/forcefields.py +93 -0
  22. mdprep/leap/ions.py +112 -0
  23. mdprep/leap/log_parser.py +170 -0
  24. mdprep/leap/report.py +99 -0
  25. mdprep/leap/residues.py +653 -0
  26. mdprep/leap/runner.py +65 -0
  27. mdprep/ligands/__init__.py +2 -0
  28. mdprep/ligands/extract.py +355 -0
  29. mdprep/ligands/identity.py +39 -0
  30. mdprep/ligands/pyscf_charges.py +242 -0
  31. mdprep/ligands/report.py +146 -0
  32. mdprep/ligands/workflow.py +838 -0
  33. mdprep/md/__init__.py +5 -0
  34. mdprep/md/roe_brooks.py +650 -0
  35. mdprep/metals/__init__.py +2 -0
  36. mdprep/metals/c4.py +157 -0
  37. mdprep/metals/coordination.py +313 -0
  38. mdprep/metals/gxtb_hessian.py +467 -0
  39. mdprep/metals/hydrogenate.py +344 -0
  40. mdprep/metals/mace_polar1_hessian.py +357 -0
  41. mdprep/metals/mace_polar1_worker.py +295 -0
  42. mdprep/metals/mcpb.py +949 -0
  43. mdprep/metals/mcpb_compat.py +125 -0
  44. mdprep/metals/nonbonded.py +294 -0
  45. mdprep/metals/parameter_comparison.py +563 -0
  46. mdprep/metals/pyscf_mcpb.py +1388 -0
  47. mdprep/metals/report.py +119 -0
  48. mdprep/metals/workflow.py +320 -0
  49. mdprep/protonation/__init__.py +15 -0
  50. mdprep/protonation/apply.py +968 -0
  51. mdprep/protonation/disulfide_states.py +141 -0
  52. mdprep/protonation/histidine_geometry.py +950 -0
  53. mdprep/protonation/histidine_xtb.py +390 -0
  54. mdprep/protonation/overrides.py +64 -0
  55. mdprep/protonation/pka_rules.py +165 -0
  56. mdprep/protonation/propka.py +174 -0
  57. mdprep/protonation/propka_parser.py +140 -0
  58. mdprep/protonation/report.py +187 -0
  59. mdprep/protonation/states.py +52 -0
  60. mdprep/protonation/temporary_hydrogenation.py +238 -0
  61. mdprep/protonation/xtb_parser.py +79 -0
  62. mdprep/protonation/xtb_runner.py +108 -0
  63. mdprep/qm/__init__.py +2 -0
  64. mdprep/qm/point_charges.py +233 -0
  65. mdprep/qm/pyscf_esp.py +70 -0
  66. mdprep/qm/pyscf_runner.py +570 -0
  67. mdprep/refinement/__init__.py +5 -0
  68. mdprep/refinement/ash.py +541 -0
  69. mdprep/refinement/coordinates.py +210 -0
  70. mdprep/refinement/mapping.py +163 -0
  71. mdprep/refinement/report.py +66 -0
  72. mdprep/refinement/selection.py +436 -0
  73. mdprep/refinement/workflow.py +188 -0
  74. mdprep/reports/__init__.py +8 -0
  75. mdprep/reports/structure_report.py +92 -0
  76. mdprep/structure/__init__.py +14 -0
  77. mdprep/structure/classify.py +92 -0
  78. mdprep/structure/disulfides.py +51 -0
  79. mdprep/structure/inspect.py +105 -0
  80. mdprep/structure/models.py +86 -0
  81. mdprep/structure/normalize.py +363 -0
  82. mdprep/structure/pdb.py +511 -0
  83. mdprep/structure/repair.py +566 -0
  84. mdprep/structure/selectors.py +204 -0
  85. mdprep/structure/writer.py +84 -0
  86. mdprep/validation/__init__.py +2 -0
  87. mdprep/validation/openmm_check.py +86 -0
  88. mdprep/validation/parmed_check.py +43 -0
  89. mdprep/validation/topology.py +239 -0
  90. mdprep/workflows/__init__.py +6 -0
  91. mdprep/workflows/init.py +242 -0
  92. mdprep/workflows/inspect.py +21 -0
  93. mdprep/workflows/prepare.py +763 -0
  94. mdprep/workflows/selftest.py +220 -0
  95. mdprep/workflows/validate.py +30 -0
  96. mdprep-0.2.0.dist-info/METADATA +264 -0
  97. mdprep-0.2.0.dist-info/RECORD +100 -0
  98. mdprep-0.2.0.dist-info/WHEEL +4 -0
  99. mdprep-0.2.0.dist-info/entry_points.txt +2 -0
  100. mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
mdprep/leap/runner.py ADDED
@@ -0,0 +1,65 @@
1
+ """tleap subprocess wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from mdprep.external.discovery import which_executable
10
+ from mdprep.external.runner import CommandResult, run_command
11
+ from mdprep.leap.log_parser import LeapLogSummary, parse_tleap_log
12
+
13
+
14
+ class TLeapRunError(RuntimeError):
15
+ """Raised when tleap cannot be launched."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class TLeapRun:
20
+ command_result: CommandResult
21
+ input_path: Path
22
+ log_path: Path
23
+ summary: LeapLogSummary
24
+
25
+ def to_dict(self) -> dict[str, object]:
26
+ return {
27
+ "command": list(self.command_result.command),
28
+ "cwd": self.command_result.cwd,
29
+ "returncode": self.command_result.returncode,
30
+ "stdout": self.command_result.stdout,
31
+ "stderr": self.command_result.stderr,
32
+ "runtime_seconds": self.command_result.runtime_seconds,
33
+ "input_path": str(self.input_path),
34
+ "log_path": str(self.log_path),
35
+ "summary": self.summary.to_dict(),
36
+ }
37
+
38
+
39
+ def run_tleap(
40
+ input_path: str | Path,
41
+ *,
42
+ work_dir: str | Path,
43
+ executable: str = "tleap",
44
+ ) -> TLeapRun:
45
+ exe = which_executable(executable)
46
+ if exe is None:
47
+ raise TLeapRunError(f"AmberTools executable not found: {executable}")
48
+ work = Path(work_dir)
49
+ script = Path(input_path)
50
+ for stale_log in (work / "leap.log", work / "tleap.log"):
51
+ if stale_log.exists():
52
+ stale_log.unlink()
53
+ result = run_command([exe, "-f", script.name], cwd=work)
54
+ generated_log = work / "leap.log"
55
+ log_path = work / "tleap.log"
56
+ if generated_log.exists():
57
+ shutil.copyfile(generated_log, log_path)
58
+ else:
59
+ log_path.write_text(result.stdout + "\n" + result.stderr, encoding="utf-8")
60
+ return TLeapRun(
61
+ command_result=result,
62
+ input_path=script,
63
+ log_path=log_path,
64
+ summary=parse_tleap_log(log_path, returncode=result.returncode),
65
+ )
@@ -0,0 +1,2 @@
1
+ """Ligand extraction and parameterization workflows."""
2
+
@@ -0,0 +1,355 @@
1
+ """Ligand residue extraction from prepared structures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ import json
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from dataclasses import replace
11
+
12
+ from mdprep.config.models import LigandConfig, ManifestConfig
13
+ from mdprep.ligands.identity import ligand_identity_dict
14
+ from mdprep.structure.models import PdbStructure, ResidueRecord
15
+ from mdprep.structure.pdb import infer_element
16
+ from mdprep.structure.selectors import SelectorError, resolve_residue_selector
17
+ from mdprep.structure.writer import write_pdb
18
+
19
+
20
+ class LigandExtractionError(ValueError):
21
+ """Raised when configured ligands cannot be extracted safely."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ExtractedLigand:
26
+ config: LigandConfig
27
+ residue: ResidueRecord
28
+ pdb_path: Path
29
+ identity_path: Path
30
+ warnings: list[str]
31
+
32
+ @property
33
+ def atoms(self):
34
+ return self.residue.atoms
35
+
36
+ def identity_dict(self) -> dict[str, object]:
37
+ return ligand_identity_dict(
38
+ ligand_id=self.config.id,
39
+ selector=self.config.selector.model_dump(mode="json"),
40
+ residue=self.residue,
41
+ net_charge=self.config.net_charge,
42
+ multiplicity=self.config.multiplicity,
43
+ expected_formula=self.config.expected_formula,
44
+ charge_method=self.config.charge_method,
45
+ atom_types=self.config.atom_types,
46
+ )
47
+
48
+
49
+ def extract_configured_ligands(
50
+ structure: PdbStructure,
51
+ manifest: ManifestConfig,
52
+ *,
53
+ output_dir: str | Path,
54
+ ) -> list[ExtractedLigand]:
55
+ extracted: list[ExtractedLigand] = []
56
+ for ligand in manifest.ligands:
57
+ extracted.append(extract_ligand(structure, ligand, output_dir=output_dir))
58
+ return extracted
59
+
60
+
61
+ def extract_ligand(
62
+ structure: PdbStructure,
63
+ ligand: LigandConfig,
64
+ *,
65
+ output_dir: str | Path,
66
+ ) -> ExtractedLigand:
67
+ try:
68
+ residue = resolve_residue_selector(structure, ligand.selector.model_dump())
69
+ except SelectorError as exc:
70
+ raise LigandExtractionError(
71
+ f"Ligand {ligand.id} selector did not resolve exactly one residue: {exc}"
72
+ ) from exc
73
+ if not residue.atoms:
74
+ raise LigandExtractionError(f"Ligand {ligand.id} selector resolved a residue with zero atoms.")
75
+
76
+ warnings: list[str] = []
77
+ fixed_atoms = []
78
+ for atom in residue.atoms:
79
+ if atom.element is None:
80
+ atom_field = atom.original_line[12:16] if len(atom.original_line) >= 16 else None
81
+ inferred = infer_element(
82
+ atom.name,
83
+ resname=atom.resname,
84
+ record_name=atom.record_name,
85
+ atom_field=atom_field,
86
+ )
87
+ warnings.append(f"Inferred missing element for ligand {ligand.id} atom {atom.name}: {inferred}")
88
+ fixed_atoms.append(atom.__class__(**{**atom.__dict__, "element": inferred}))
89
+ else:
90
+ fixed_atoms.append(atom)
91
+ fixed_atoms, rename_map = _uniquify_duplicate_atom_names(fixed_atoms)
92
+ if rename_map:
93
+ renamed = ", ".join(f"{old}->{new}" for old, new in rename_map)
94
+ warnings.append(
95
+ "Ligand atom names were not unique in the input PDB; generated deterministic "
96
+ f"Amber-safe names for this ligand: {renamed}."
97
+ )
98
+ if fixed_atoms != residue.atoms:
99
+ residue = ResidueRecord(
100
+ id=residue.id,
101
+ atoms=fixed_atoms,
102
+ record_names=residue.record_names,
103
+ original_index=residue.original_index,
104
+ )
105
+ warnings.extend(_validate_ligand_formula(ligand, residue))
106
+
107
+ ligand_dir = Path(output_dir) / "ligands" / ligand.id / "input"
108
+ ligand_dir.mkdir(parents=True, exist_ok=True)
109
+ pdb_path = ligand_dir / f"{ligand.id}.pdb"
110
+ identity_path = ligand_dir / "identity.json"
111
+ ligand_structure = PdbStructure(
112
+ path=pdb_path,
113
+ atoms=list(residue.atoms),
114
+ residues=[residue],
115
+ model_count=1,
116
+ )
117
+ write_pdb(ligand_structure, pdb_path)
118
+ conect_lines = _ligand_conect_lines(structure.path, residue.atoms)
119
+ if conect_lines:
120
+ _insert_conect_before_end(pdb_path, conect_lines)
121
+ warnings.append(
122
+ f"Preserved {len(conect_lines)} ligand CONECT record(s) for {ligand.id} in the extracted PDB."
123
+ )
124
+ elif _requires_pdb_chemistry_perception(ligand):
125
+ warnings.append(
126
+ "No ligand CONECT records were found in the input PDB. AmberTools will infer ligand "
127
+ "connectivity from coordinates; for chemically sensitive or complex substrates, provide "
128
+ "a curated user_mol2/user_frcmod so atom types and bonded terms are not inferred from PDB geometry."
129
+ )
130
+ extracted = ExtractedLigand(
131
+ config=ligand,
132
+ residue=residue,
133
+ pdb_path=pdb_path,
134
+ identity_path=identity_path,
135
+ warnings=warnings,
136
+ )
137
+ identity_path.write_text(
138
+ json.dumps(extracted.identity_dict(), indent=2, sort_keys=True) + "\n",
139
+ encoding="utf-8",
140
+ )
141
+ return extracted
142
+
143
+
144
+ def _uniquify_duplicate_atom_names(atoms: list) -> tuple[list, list[tuple[str, str]]]:
145
+ counts: dict[str, int] = {}
146
+ for atom in atoms:
147
+ counts[atom.name] = counts.get(atom.name, 0) + 1
148
+ duplicate_names = {name for name, count in counts.items() if count > 1}
149
+ if not duplicate_names:
150
+ return atoms, []
151
+
152
+ used = {atom.name for atom in atoms if atom.name not in duplicate_names}
153
+ element_counters: dict[str, int] = {}
154
+ renamed_atoms = []
155
+ rename_map: list[tuple[str, str]] = []
156
+ for atom in atoms:
157
+ if atom.name not in duplicate_names:
158
+ renamed_atoms.append(atom)
159
+ continue
160
+ element = (atom.element or infer_element(atom.name, resname=atom.resname, record_name=atom.record_name) or "X").upper()
161
+ new_name = _next_unique_ligand_atom_name(element, used, element_counters)
162
+ used.add(new_name)
163
+ renamed_atoms.append(replace(atom, name=new_name))
164
+ rename_map.append((atom.name, new_name))
165
+ return renamed_atoms, rename_map
166
+
167
+
168
+ def _next_unique_ligand_atom_name(
169
+ element: str,
170
+ used: set[str],
171
+ counters: dict[str, int],
172
+ ) -> str:
173
+ symbol = "".join(char for char in element.upper() if char.isalnum()) or "X"
174
+ if len(symbol) > 2:
175
+ symbol = symbol[:2]
176
+ while True:
177
+ counters[symbol] = counters.get(symbol, 0) + 1
178
+ suffix = _base36(counters[symbol])
179
+ candidate = f"{symbol}{suffix}"
180
+ if len(candidate) > 4:
181
+ candidate = f"{symbol[:1]}{suffix}"[-4:]
182
+ if candidate not in used:
183
+ return candidate
184
+
185
+
186
+ def _base36(value: int) -> str:
187
+ alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
188
+ if value <= 0:
189
+ return "0"
190
+ digits = []
191
+ current = value
192
+ while current:
193
+ current, remainder = divmod(current, 36)
194
+ digits.append(alphabet[remainder])
195
+ return "".join(reversed(digits))
196
+
197
+
198
+ def _requires_pdb_chemistry_perception(ligand: LigandConfig) -> bool:
199
+ if ligand.charge_method in {"am1bcc", "mcpb_resp_pyscf"}:
200
+ return True
201
+ if ligand.charge_method in {"gas_resp_pyscf", "qmmesp_pyscf"}:
202
+ return ligand.user_mol2 is None
203
+ return False
204
+
205
+
206
+ _FORMULA_TOKEN = re.compile(r"([A-Z][a-z]?)([0-9]*)")
207
+
208
+
209
+ def _validate_ligand_formula(
210
+ ligand: LigandConfig,
211
+ residue: ResidueRecord,
212
+ ) -> list[str]:
213
+ """Require explicit ligand hydrogens and validate an optional exact formula.
214
+
215
+ PDB coordinates do not encode bond orders or protonation. A molecule that
216
+ contains no hydrogen is therefore rejected unless the user explicitly
217
+ declares a hydrogen-free expected formula. An exact formula is the only
218
+ reliable way to detect a partially hydrogenated ligand, so production
219
+ examples should always set expected_formula.
220
+ """
221
+
222
+ observed = Counter((atom.element or "").strip().capitalize() for atom in residue.atoms)
223
+ observed.pop("", None)
224
+ expected = _parse_formula(ligand.expected_formula) if ligand.expected_formula else None
225
+
226
+ if observed.get("H", 0) == 0 and (expected is None or expected.get("H", 0) > 0):
227
+ warning = (
228
+ f"WARNING [LIGAND_HYDROGENS_MISSING] Ligand {ligand.id} "
229
+ f"({residue.id.chain_id}:{residue.id.resname}:{residue.id.resid}) contains no "
230
+ "explicit hydrogen atoms."
231
+ )
232
+ error = (
233
+ f"ERROR [LIGAND_CHEMISTRY_UNRESOLVED] Ligand {ligand.id} cannot be sent to "
234
+ "AM1-BCC or QM charge derivation without an explicit, validated protonation "
235
+ "state. Add ligand hydrogens before running mdprep and set expected_formula; "
236
+ "mdprep will not guess ligand protonation."
237
+ )
238
+ raise LigandExtractionError(f"{warning}\n{error}")
239
+
240
+ warnings: list[str] = []
241
+ if expected is None:
242
+ warnings.append(
243
+ f"WARNING [LIGAND_FORMULA_UNVERIFIED] Ligand {ligand.id} contains explicit "
244
+ "hydrogens, but expected_formula was not supplied; mdprep cannot prove that "
245
+ "the ligand is completely protonated."
246
+ )
247
+ return warnings
248
+
249
+ if observed == expected:
250
+ return warnings
251
+
252
+ observed_heavy = Counter({key: value for key, value in observed.items() if key != "H"})
253
+ expected_heavy = Counter({key: value for key, value in expected.items() if key != "H"})
254
+ if observed_heavy == expected_heavy and observed.get("H", 0) < expected.get("H", 0):
255
+ missing = expected.get("H", 0) - observed.get("H", 0)
256
+ warning = (
257
+ f"WARNING [LIGAND_HYDROGENS_MISSING] Ligand {ligand.id} has "
258
+ f"{observed.get('H', 0)} explicit H atom(s), but expected_formula "
259
+ f"{ligand.expected_formula} requires {expected.get('H', 0)}; {missing} hydrogen "
260
+ "atom(s) are missing."
261
+ )
262
+ error = (
263
+ f"ERROR [LIGAND_CHEMISTRY_UNRESOLVED] Ligand {ligand.id} is only partially "
264
+ "hydrogenated. Add the missing ligand hydrogens before running mdprep; "
265
+ "automatic ligand protonation is intentionally unsupported."
266
+ )
267
+ raise LigandExtractionError(f"{warning}\n{error}")
268
+
269
+ raise LigandExtractionError(
270
+ f"ERROR [LIGAND_FORMULA_MISMATCH] Ligand {ligand.id} has observed formula "
271
+ f"{_format_formula(observed)}, but expected_formula is {ligand.expected_formula}. "
272
+ "Correct the input coordinates or the manifest; mdprep will not alter ligand "
273
+ "composition silently."
274
+ )
275
+
276
+
277
+ def _parse_formula(formula: str) -> Counter[str]:
278
+ counts: Counter[str] = Counter()
279
+ position = 0
280
+ for match in _FORMULA_TOKEN.finditer(formula):
281
+ if match.start() != position:
282
+ raise LigandExtractionError(f"Invalid expected_formula: {formula}")
283
+ element, count_text = match.groups()
284
+ if element in counts:
285
+ raise LigandExtractionError(
286
+ f"Invalid expected_formula {formula}: element {element} appears more than once"
287
+ )
288
+ count = int(count_text) if count_text else 1
289
+ if count <= 0:
290
+ raise LigandExtractionError(
291
+ f"Invalid expected_formula {formula}: element counts must be positive"
292
+ )
293
+ counts[element] = count
294
+ position = match.end()
295
+ if position != len(formula) or not counts:
296
+ raise LigandExtractionError(f"Invalid expected_formula: {formula}")
297
+ return counts
298
+
299
+
300
+ def _format_formula(counts: Counter[str]) -> str:
301
+ ordered = []
302
+ for element in ("C", "H"):
303
+ if element in counts:
304
+ ordered.append(element + (str(counts[element]) if counts[element] != 1 else ""))
305
+ for element in sorted(key for key in counts if key not in {"C", "H"}):
306
+ ordered.append(element + (str(counts[element]) if counts[element] != 1 else ""))
307
+ return "".join(ordered)
308
+
309
+
310
+ def _ligand_conect_lines(input_path: Path, atoms: list) -> list[str]:
311
+ serials = {atom.serial for atom in atoms if atom.serial is not None}
312
+ if not serials or not input_path.exists():
313
+ return []
314
+
315
+ lines: list[str] = []
316
+ seen: set[str] = set()
317
+ for line in input_path.read_text(encoding="utf-8", errors="replace").splitlines():
318
+ if not line.startswith("CONECT"):
319
+ continue
320
+ numbers = _parse_conect_numbers(line)
321
+ if not numbers:
322
+ continue
323
+ source, *targets = numbers
324
+ if source not in serials:
325
+ continue
326
+ selected_targets = [target for target in targets if target in serials]
327
+ for offset in range(0, len(selected_targets), 4):
328
+ conect = _format_conect(source, selected_targets[offset : offset + 4])
329
+ if conect not in seen:
330
+ seen.add(conect)
331
+ lines.append(conect)
332
+ return lines
333
+
334
+
335
+ def _parse_conect_numbers(line: str) -> list[int]:
336
+ numbers: list[int] = []
337
+ for field in line[6:].split():
338
+ try:
339
+ numbers.append(int(field))
340
+ except ValueError:
341
+ continue
342
+ return numbers
343
+
344
+
345
+ def _format_conect(source: int, targets: list[int]) -> str:
346
+ return "CONECT" + f"{source:5d}" + "".join(f"{target:5d}" for target in targets) + "\n"
347
+
348
+
349
+ def _insert_conect_before_end(path: Path, conect_lines: list[str]) -> None:
350
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
351
+ insert_at = len(lines)
352
+ if lines and lines[-1].strip() == "END":
353
+ insert_at = len(lines) - 1
354
+ lines[insert_at:insert_at] = conect_lines
355
+ path.write_text("".join(lines), encoding="utf-8")
@@ -0,0 +1,39 @@
1
+ """Ligand identity helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mdprep.structure.models import AtomRecord, ResidueRecord
6
+ from mdprep.structure.pdb import infer_element
7
+
8
+
9
+ def ligand_identity_dict(
10
+ *,
11
+ ligand_id: str,
12
+ selector: dict[str, object],
13
+ residue: ResidueRecord,
14
+ net_charge: int,
15
+ multiplicity: int,
16
+ expected_formula: str | None,
17
+ charge_method: str,
18
+ atom_types: str,
19
+ ) -> dict[str, object]:
20
+ return {
21
+ "ligand_id": ligand_id,
22
+ "selector": selector,
23
+ "atom_count": len(residue.atoms),
24
+ "atom_names": [atom.name for atom in residue.atoms],
25
+ "elements": [atom_element(atom) for atom in residue.atoms],
26
+ "residue_name": residue.id.resname,
27
+ "residue_number": residue.id.resid,
28
+ "chain_id": residue.id.chain_id,
29
+ "icode": residue.id.icode,
30
+ "net_charge": net_charge,
31
+ "multiplicity": multiplicity,
32
+ "expected_formula": expected_formula,
33
+ "charge_method": charge_method,
34
+ "atom_types": atom_types,
35
+ }
36
+
37
+
38
+ def atom_element(atom: AtomRecord) -> str:
39
+ return atom.element or infer_element(atom.name) or ""