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
@@ -0,0 +1,653 @@
1
+ """Residue and PDB helpers for tleap input generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from mdprep.ambertools.mol2 import Mol2Error, read_mol2
12
+ from mdprep.config.models import ManifestConfig
13
+ from mdprep.protonation.apply import ProtonationRecord, ProtonationResult
14
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
15
+ from mdprep.structure.pdb import read_pdb
16
+ from mdprep.structure.selectors import SelectorError, resolve_residue_selector
17
+ from mdprep.structure.writer import write_pdb
18
+
19
+ if TYPE_CHECKING:
20
+ from mdprep.ligands.workflow import LigandStageResult
21
+
22
+
23
+ class LeapResidueError(ValueError):
24
+ """Raised when residues cannot be mapped safely for tleap."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LeapInputResult:
29
+ path: Path
30
+ structure: PdbStructure
31
+ water_renames: list[dict[str, object]]
32
+ ligand_coordinate_anchors: list[dict[str, object]]
33
+ residue_renumberings: list[dict[str, object]]
34
+
35
+ def to_dict(self) -> dict[str, object]:
36
+ return {
37
+ "path": str(self.path),
38
+ "water_renames": self.water_renames,
39
+ "ligand_coordinate_anchors": self.ligand_coordinate_anchors,
40
+ "residue_renumberings": self.residue_renumberings,
41
+ }
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class LigandParameterFiles:
46
+ ligand_id: str
47
+ variable_name: str
48
+ residue_name: str
49
+ final_mol2_path: Path
50
+ final_frcmod_path: Path
51
+ atom_names: list[str]
52
+
53
+ def to_dict(self) -> dict[str, object]:
54
+ return {
55
+ "ligand_id": self.ligand_id,
56
+ "variable_name": self.variable_name,
57
+ "residue_name": self.residue_name,
58
+ "final_mol2_path": str(self.final_mol2_path),
59
+ "final_frcmod_path": str(self.final_frcmod_path),
60
+ "atom_names": self.atom_names,
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class DisulfideBondCommand:
66
+ residue_a: dict[str, object]
67
+ residue_b: dict[str, object]
68
+ index_a: int
69
+ index_b: int
70
+ command: str
71
+ atom_serial_a: int | None = None
72
+ atom_serial_b: int | None = None
73
+ pdb_conect_records: tuple[str, ...] = ()
74
+
75
+ def to_dict(self) -> dict[str, object]:
76
+ return {
77
+ "residue_a": self.residue_a,
78
+ "residue_b": self.residue_b,
79
+ "index_a": self.index_a,
80
+ "index_b": self.index_b,
81
+ "command": self.command,
82
+ "atom_serial_a": self.atom_serial_a,
83
+ "atom_serial_b": self.atom_serial_b,
84
+ "pdb_conect_records": list(self.pdb_conect_records),
85
+ }
86
+
87
+
88
+ def prepare_leap_input_pdb(
89
+ structure: PdbStructure,
90
+ output_path: str | Path,
91
+ *,
92
+ manifest: ManifestConfig | None = None,
93
+ ligand_result: "LigandStageResult | None" = None,
94
+ renumber_residues_sequentially: bool = False,
95
+ ) -> LeapInputResult:
96
+ atoms: list[AtomRecord] = []
97
+ water_renames: list[dict[str, object]] = []
98
+ residue_renumberings: list[dict[str, object]] = []
99
+ sequential_resids: dict[tuple[str, str, int, str | None], int] = {}
100
+ if renumber_residues_sequentially:
101
+ for index, residue in enumerate(structure.residues, start=1):
102
+ key = (
103
+ residue.id.chain_id,
104
+ residue.id.resname,
105
+ residue.id.resid,
106
+ residue.id.icode,
107
+ )
108
+ if key in sequential_resids:
109
+ raise LeapResidueError(
110
+ f"Residue identity {residue.id.display()} is ambiguous for sequential "
111
+ "tleap renumbering."
112
+ )
113
+ sequential_resids[key] = index
114
+ residue_renumberings.append(
115
+ {
116
+ "chain_id": residue.id.chain_id,
117
+ "resname": residue.id.resname,
118
+ "original_resid": residue.id.resid,
119
+ "original_icode": residue.id.icode,
120
+ "tleap_residue_index": index,
121
+ "reason": "MCPB.py bond-command index alignment",
122
+ }
123
+ )
124
+ for atom in structure.atoms:
125
+ new_atom = atom
126
+ if atom.resname in {"HOH", "H2O", "TIP3", "OPC"}:
127
+ new_name = atom.name
128
+ if (atom.element or "").upper() == "O" and atom.name.strip() != "O":
129
+ new_name = "O"
130
+ new_atom = replace(atom, resname="WAT", name=new_name)
131
+ if new_atom.resname != atom.resname or new_atom.name != atom.name:
132
+ water_renames.append(
133
+ {
134
+ "chain_id": atom.chain_id,
135
+ "resid": atom.resid,
136
+ "icode": atom.icode,
137
+ "old_resname": atom.resname,
138
+ "new_resname": new_atom.resname,
139
+ "old_atom_name": atom.name,
140
+ "new_atom_name": new_atom.name,
141
+ }
142
+ )
143
+ if renumber_residues_sequentially:
144
+ key = (atom.chain_id, atom.resname, atom.resid, atom.icode)
145
+ new_atom = replace(
146
+ new_atom,
147
+ resid=sequential_resids[key],
148
+ icode=None,
149
+ )
150
+ atoms.append(new_atom)
151
+ ligand_coordinate_anchors: list[dict[str, object]] = []
152
+ if manifest is not None and ligand_result is not None:
153
+ atoms, ligand_coordinate_anchors = _anchor_ligands_to_extracted_inputs(
154
+ atoms,
155
+ manifest=manifest,
156
+ ligand_result=ligand_result,
157
+ )
158
+ output = Path(output_path)
159
+ leap_structure = PdbStructure(
160
+ path=output,
161
+ atoms=atoms,
162
+ residues=_build_residues(atoms),
163
+ model_count=structure.model_count,
164
+ used_model=structure.used_model,
165
+ warnings=list(structure.warnings),
166
+ )
167
+ write_pdb(leap_structure, output)
168
+ return LeapInputResult(
169
+ path=output,
170
+ structure=leap_structure,
171
+ water_renames=water_renames,
172
+ ligand_coordinate_anchors=ligand_coordinate_anchors,
173
+ residue_renumberings=residue_renumberings,
174
+ )
175
+
176
+
177
+ def residue_index_map(structure: PdbStructure) -> dict[tuple[str, int, str | None], int]:
178
+ mapping: dict[tuple[str, int, str | None], int] = {}
179
+ for index, residue in enumerate(structure.residues, start=1):
180
+ key = _residue_key(residue.id)
181
+ if key in mapping:
182
+ raise LeapResidueError(
183
+ f"Residue identity {residue.id.display()} is ambiguous for tleap residue-index mapping."
184
+ )
185
+ mapping[key] = index
186
+ return mapping
187
+
188
+
189
+ def validate_ligand_parameter_files(
190
+ *,
191
+ manifest: ManifestConfig,
192
+ structure: PdbStructure,
193
+ ligand_result: LigandStageResult,
194
+ excluded_ligand_ids: set[str] | None = None,
195
+ ) -> list[LigandParameterFiles]:
196
+ excluded = excluded_ligand_ids or set()
197
+ items_by_id = {item.ligand_id: item for item in ligand_result.ligands}
198
+ variable_names: set[str] = set()
199
+ residue_parameter_files: dict[str, tuple[bytes, bytes]] = {}
200
+ validated: list[LigandParameterFiles] = []
201
+ for ligand in manifest.ligands:
202
+ if ligand.id in excluded:
203
+ continue
204
+ item = items_by_id.get(ligand.id)
205
+ if item is None:
206
+ raise LeapResidueError(f"Ligand {ligand.id} was not parameterized before tleap.")
207
+ if item.final_mol2_path is None or not item.final_mol2_path.exists():
208
+ raise LeapResidueError(f"Ligand {ligand.id} final mol2 is missing.")
209
+ if item.final_frcmod_path is None or not item.final_frcmod_path.exists():
210
+ raise LeapResidueError(f"Ligand {ligand.id} final frcmod is missing.")
211
+ try:
212
+ residue = resolve_residue_selector(structure, ligand.selector.model_dump())
213
+ except SelectorError as exc:
214
+ raise LeapResidueError(f"Ligand {ligand.id} selector failed during tleap validation: {exc}") from exc
215
+ mol2 = read_mol2(item.final_mol2_path)
216
+ mol2_resnames = {atom.subst_name for atom in mol2.atoms}
217
+ if mol2_resnames != {residue.id.resname}:
218
+ raise LeapResidueError(
219
+ f"Ligand {ligand.id} residue name mismatch: PDB has {residue.id.resname}, "
220
+ f"mol2 has {sorted(mol2_resnames)}."
221
+ )
222
+ pdb_atom_names = [atom.name for atom in residue.atoms]
223
+ _require_unique_atom_names(pdb_atom_names, ligand.id)
224
+ mol2_atom_names = [atom.name for atom in mol2.atoms]
225
+ if pdb_atom_names != mol2_atom_names:
226
+ raise LeapResidueError(
227
+ f"Ligand {ligand.id} atom-name mismatch between PDB and mol2: "
228
+ f"{pdb_atom_names} != {mol2_atom_names}."
229
+ )
230
+ try:
231
+ key = (
232
+ item.final_mol2_path.read_bytes(),
233
+ item.final_frcmod_path.read_bytes(),
234
+ )
235
+ except OSError as exc:
236
+ raise LeapResidueError(f"Could not read ligand parameter files for {ligand.id}: {exc}") from exc
237
+ previous = residue_parameter_files.get(residue.id.resname)
238
+ if previous is not None and previous != key:
239
+ raise LeapResidueError(
240
+ f"Multiple different parameter sets for residue name {residue.id.resname} are not supported yet; "
241
+ "use unique residue names or shared parameter files."
242
+ )
243
+ residue_parameter_files[residue.id.resname] = key
244
+ variable = _unique_variable_name(ligand.id, variable_names)
245
+ validated.append(
246
+ LigandParameterFiles(
247
+ ligand_id=ligand.id,
248
+ variable_name=variable,
249
+ residue_name=residue.id.resname,
250
+ final_mol2_path=item.final_mol2_path,
251
+ final_frcmod_path=item.final_frcmod_path,
252
+ atom_names=pdb_atom_names,
253
+ )
254
+ )
255
+ return validated
256
+
257
+
258
+ def validate_tleap_ligand_coordinates(
259
+ *,
260
+ manifest: ManifestConfig,
261
+ reference_structure: PdbStructure,
262
+ output_pdb: str | Path,
263
+ stage: str,
264
+ tolerance_angstrom: float = 0.10,
265
+ output_resnames_by_ligand_id: dict[str, str] | None = None,
266
+ ) -> list[dict[str, object]]:
267
+ try:
268
+ output_structure = read_pdb(output_pdb)
269
+ except Exception as exc: # pragma: no cover - called after normal tleap output checks
270
+ raise LeapResidueError(f"Could not parse {stage} tleap PDB for ligand coordinate validation: {exc}") from exc
271
+
272
+ checks: list[dict[str, object]] = []
273
+ for ligand in manifest.ligands:
274
+ try:
275
+ reference = resolve_residue_selector(reference_structure, ligand.selector.model_dump())
276
+ except SelectorError as exc:
277
+ raise LeapResidueError(f"Ligand {ligand.id} reference selector failed during {stage} coordinate validation: {exc}") from exc
278
+ expected_output_resname = (output_resnames_by_ligand_id or {}).get(
279
+ ligand.id,
280
+ reference.id.resname,
281
+ )
282
+ observed = _resolve_ligand_in_tleap_output(
283
+ output_structure,
284
+ reference,
285
+ ligand.id,
286
+ stage,
287
+ expected_output_resname=expected_output_resname,
288
+ )
289
+ _require_same_atom_names(reference, observed, ligand.id, context=f"{stage} coordinate validation")
290
+ deviations = [
291
+ _distance((ref.x, ref.y, ref.z), (out.x, out.y, out.z))
292
+ for ref, out in zip(reference.atoms, observed.atoms, strict=True)
293
+ ]
294
+ max_deviation = max(deviations, default=0.0)
295
+ check = {
296
+ "ligand_id": ligand.id,
297
+ "stage": stage,
298
+ "atom_count": len(reference.atoms),
299
+ "reference_resname": reference.id.resname,
300
+ "output_resname": observed.id.resname,
301
+ "max_coordinate_deviation_angstrom": max_deviation,
302
+ "tolerance_angstrom": tolerance_angstrom,
303
+ "ok": max_deviation <= tolerance_angstrom,
304
+ }
305
+ checks.append(check)
306
+ if max_deviation > tolerance_angstrom:
307
+ raise LeapResidueError(
308
+ f"Ligand {ligand.id} moved during {stage} tleap build: maximum coordinate "
309
+ f"deviation is {max_deviation:.3f} A relative to the extracted ligand PDB used for "
310
+ f"parameterization; tolerance is {tolerance_angstrom:.3f} A."
311
+ )
312
+ return checks
313
+
314
+
315
+ def disulfide_bond_commands(
316
+ *,
317
+ structure: PdbStructure,
318
+ protonation_result: ProtonationResult,
319
+ ) -> list[DisulfideBondCommand]:
320
+ assignments = protonation_result.disulfide_assignments_applied
321
+ pair_keys: set[frozenset[tuple[str, int, str | None]]] = set()
322
+ for record in assignments:
323
+ partner = _partner_key(record)
324
+ if partner is None:
325
+ continue
326
+ current = (record.chain, record.resid, record.icode)
327
+ pair = frozenset({current, partner})
328
+ if len(pair) != 2:
329
+ continue
330
+ pair_keys.add(pair)
331
+
332
+ cyx_keys = [
333
+ _residue_key(residue.id)
334
+ for residue in structure.residues
335
+ if residue.id.resname == "CYX"
336
+ ]
337
+ for key in cyx_keys:
338
+ if sum(1 for pair in pair_keys if key in pair) != 1:
339
+ raise LeapResidueError(
340
+ "CYX residue has no unique disulfide pair; add the pair under disulfides.force "
341
+ "or change the residue state to CYS/CYM if it is not disulfide-bonded."
342
+ )
343
+
344
+ residues_by_key: dict[tuple[str, int, str | None], list[ResidueRecord]] = {}
345
+ residue_indices = {id(residue): index for index, residue in enumerate(structure.residues, start=1)}
346
+ for residue in structure.residues:
347
+ residues_by_key.setdefault(_residue_key(residue.id), []).append(residue)
348
+ commands: list[DisulfideBondCommand] = []
349
+ for pair in sorted(pair_keys, key=lambda item: sorted(item)):
350
+ key_a, key_b = sorted(pair)
351
+ residue_a = _resolve_disulfide_residue(residues_by_key, key_a)
352
+ residue_b = _resolve_disulfide_residue(residues_by_key, key_b)
353
+ if "SG" not in residue_a.atom_names() or "SG" not in residue_b.atom_names():
354
+ raise LeapResidueError("Disulfide pair is missing SG atoms required for tleap bond commands.")
355
+ index_a = residue_indices[id(residue_a)]
356
+ index_b = residue_indices[id(residue_b)]
357
+ serial_a = _sg_atom_serial(residue_a)
358
+ serial_b = _sg_atom_serial(residue_b)
359
+ conect_records = (
360
+ _format_conect(serial_a, serial_b).rstrip("\n"),
361
+ _format_conect(serial_b, serial_a).rstrip("\n"),
362
+ )
363
+ commands.append(
364
+ DisulfideBondCommand(
365
+ residue_a=residue_a.id.to_dict(),
366
+ residue_b=residue_b.id.to_dict(),
367
+ index_a=index_a,
368
+ index_b=index_b,
369
+ command=f"CONECT {serial_a} {serial_b}",
370
+ atom_serial_a=serial_a,
371
+ atom_serial_b=serial_b,
372
+ pdb_conect_records=conect_records,
373
+ )
374
+ )
375
+ return commands
376
+
377
+
378
+ def append_disulfide_conect_records(
379
+ pdb_path: str | Path,
380
+ disulfide_bonds: list[DisulfideBondCommand],
381
+ ) -> list[str]:
382
+ records: list[str] = []
383
+ seen: set[str] = set()
384
+ for bond in disulfide_bonds:
385
+ for record in bond.pdb_conect_records:
386
+ line = record if record.endswith("\n") else f"{record}\n"
387
+ if line not in seen:
388
+ seen.add(line)
389
+ records.append(line)
390
+ if not records:
391
+ return []
392
+ path = Path(pdb_path)
393
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
394
+ insert_at = len(lines)
395
+ if lines and lines[-1].strip() == "END":
396
+ insert_at = len(lines) - 1
397
+ existing = {line for line in lines if line.startswith("CONECT")}
398
+ new_records = [line for line in records if line not in existing]
399
+ if not new_records:
400
+ return []
401
+ lines[insert_at:insert_at] = new_records
402
+ path.write_text("".join(lines), encoding="utf-8")
403
+ return [line.rstrip("\n") for line in new_records]
404
+
405
+
406
+ def _partner_key(record: ProtonationRecord) -> tuple[str, int, str | None] | None:
407
+ partner = record.metadata.get("partner")
408
+ if not isinstance(partner, dict):
409
+ return None
410
+ return (
411
+ str(partner.get("chain_id", "")),
412
+ int(partner["resid"]),
413
+ partner.get("icode"), # type: ignore[arg-type]
414
+ )
415
+
416
+
417
+ def _unique_variable_name(ligand_id: str, used: set[str]) -> str:
418
+ base = re.sub(r"\W+", "_", ligand_id.upper()).strip("_")
419
+ if not base:
420
+ base = "LIG"
421
+ if base[0].isdigit():
422
+ base = f"L_{base}"
423
+ candidate = base
424
+ suffix = 2
425
+ while candidate in used:
426
+ candidate = f"{base}_{suffix}"
427
+ suffix += 1
428
+ used.add(candidate)
429
+ return candidate
430
+
431
+
432
+ def _residue_key(residue_id: ResidueId) -> tuple[str, int, str | None]:
433
+ return (residue_id.chain_id, residue_id.resid, residue_id.icode)
434
+
435
+
436
+ def _resolve_disulfide_residue(
437
+ residues_by_key: dict[tuple[str, int, str | None], list[ResidueRecord]],
438
+ key: tuple[str, int, str | None],
439
+ ) -> ResidueRecord:
440
+ candidates = residues_by_key.get(key, [])
441
+ if not candidates:
442
+ raise LeapResidueError("Disulfide residue pair is not present in the leap-input structure.")
443
+ sg_candidates = [residue for residue in candidates if _is_cyx_disulfide_residue(residue)]
444
+ if len(sg_candidates) == 1:
445
+ return sg_candidates[0]
446
+ if len(sg_candidates) > 1:
447
+ formatted = ", ".join(residue.id.display() for residue in sg_candidates)
448
+ raise LeapResidueError(f"Disulfide residue identity is ambiguous in the leap-input structure: {formatted}.")
449
+ formatted = ", ".join(residue.id.display() for residue in candidates)
450
+ raise LeapResidueError(f"Disulfide residue pair is missing CYX SG atoms in the leap-input structure: {formatted}.")
451
+
452
+
453
+ def _is_cyx_disulfide_residue(residue: ResidueRecord) -> bool:
454
+ if "SG" not in residue.atom_names():
455
+ return False
456
+ if residue.id.resname == "CYX":
457
+ return True
458
+ return any(atom.resname == "CYX" for atom in residue.atoms)
459
+
460
+
461
+ def _sg_atom_serial(residue: ResidueRecord) -> int:
462
+ sg_atoms = [atom for atom in residue.atoms if atom.name == "SG"]
463
+ if len(sg_atoms) != 1:
464
+ raise LeapResidueError(f"Disulfide residue {residue.id.display()} must contain exactly one SG atom.")
465
+ serial = sg_atoms[0].serial
466
+ if serial is None:
467
+ raise LeapResidueError(f"Disulfide residue {residue.id.display()} SG atom has no PDB serial.")
468
+ return serial
469
+
470
+
471
+ def _format_conect(source: int, target: int) -> str:
472
+ return "CONECT" + f"{source:5d}{target:5d}" + "\n"
473
+
474
+
475
+ def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
476
+ grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
477
+ for atom in atoms:
478
+ grouped.setdefault(atom.residue_key, []).append(atom)
479
+ residues: list[ResidueRecord] = []
480
+ for index, ((chain_id, resname, resid, icode), residue_atoms) in enumerate(grouped.items()):
481
+ residues.append(
482
+ ResidueRecord(
483
+ id=ResidueId(chain_id=chain_id, resname=resname, resid=resid, icode=icode),
484
+ atoms=residue_atoms,
485
+ record_names={atom.record_name for atom in residue_atoms},
486
+ original_index=index,
487
+ )
488
+ )
489
+ return residues
490
+
491
+
492
+ def _anchor_ligands_to_extracted_inputs(
493
+ atoms: list[AtomRecord],
494
+ *,
495
+ manifest: ManifestConfig,
496
+ ligand_result: "LigandStageResult",
497
+ ) -> tuple[list[AtomRecord], list[dict[str, object]]]:
498
+ items_by_id = {item.ligand_id: item for item in ligand_result.ligands}
499
+ anchored_atoms = list(atoms)
500
+ anchors: list[dict[str, object]] = []
501
+ working_structure = PdbStructure(
502
+ path=Path("<leap-input-in-memory>"),
503
+ atoms=anchored_atoms,
504
+ residues=_build_residues(anchored_atoms),
505
+ model_count=1,
506
+ )
507
+ for ligand in manifest.ligands:
508
+ item = items_by_id.get(ligand.id)
509
+ if item is None:
510
+ continue
511
+ extracted_pdb = getattr(item, "extracted_pdb_path", None)
512
+ if extracted_pdb is None:
513
+ continue
514
+ try:
515
+ target = resolve_residue_selector(working_structure, ligand.selector.model_dump())
516
+ except SelectorError as exc:
517
+ raise LeapResidueError(f"Ligand {ligand.id} selector failed while anchoring leap input coordinates: {exc}") from exc
518
+ reference_structure = read_pdb(extracted_pdb)
519
+ if len(reference_structure.residues) != 1:
520
+ raise LeapResidueError(
521
+ f"Ligand {ligand.id} extracted PDB should contain exactly one residue: {extracted_pdb}"
522
+ )
523
+ reference = reference_structure.residues[0]
524
+ _require_anchor_compatible(reference, target, ligand.id, context="leap input coordinate anchoring")
525
+ target_indices = _residue_atom_indices(anchored_atoms, target.id)
526
+ if len(target_indices) != len(reference.atoms):
527
+ raise LeapResidueError(
528
+ f"Ligand {ligand.id} atom-count mismatch while anchoring leap input coordinates."
529
+ )
530
+ before_deviations = [
531
+ _distance((current.x, current.y, current.z), (ref.x, ref.y, ref.z))
532
+ for current, ref in zip((anchored_atoms[index] for index in target_indices), reference.atoms, strict=True)
533
+ ]
534
+ for index, ref in zip(target_indices, reference.atoms, strict=True):
535
+ current = anchored_atoms[index]
536
+ anchored_atoms[index] = replace(
537
+ current,
538
+ name=ref.name,
539
+ x=ref.x,
540
+ y=ref.y,
541
+ z=ref.z,
542
+ occupancy=ref.occupancy,
543
+ bfactor=ref.bfactor,
544
+ element=ref.element,
545
+ )
546
+ max_delta = max(before_deviations, default=0.0)
547
+ anchors.append(
548
+ {
549
+ "ligand_id": ligand.id,
550
+ "extracted_pdb_path": str(extracted_pdb),
551
+ "atom_count": len(reference.atoms),
552
+ "max_coordinate_delta_applied_angstrom": max_delta,
553
+ }
554
+ )
555
+ working_structure = PdbStructure(
556
+ path=working_structure.path,
557
+ atoms=anchored_atoms,
558
+ residues=_build_residues(anchored_atoms),
559
+ model_count=1,
560
+ )
561
+ return anchored_atoms, anchors
562
+
563
+
564
+ def _resolve_ligand_in_tleap_output(
565
+ structure: PdbStructure,
566
+ reference: ResidueRecord,
567
+ ligand_id: str,
568
+ stage: str,
569
+ *,
570
+ expected_output_resname: str,
571
+ ) -> ResidueRecord:
572
+ exact = [
573
+ residue
574
+ for residue in structure.residues
575
+ if residue.id.chain_id == reference.id.chain_id
576
+ and residue.id.resid == reference.id.resid
577
+ and residue.id.icode == reference.id.icode
578
+ and residue.id.resname == expected_output_resname
579
+ and residue.atom_names() == reference.atom_names()
580
+ ]
581
+ if len(exact) == 1:
582
+ return exact[0]
583
+ same_identity_and_atoms = [
584
+ residue
585
+ for residue in structure.residues
586
+ if residue.id.chain_id == reference.id.chain_id
587
+ and residue.id.resid == reference.id.resid
588
+ and residue.id.icode == reference.id.icode
589
+ and residue.atom_names() == reference.atom_names()
590
+ ]
591
+ if len(same_identity_and_atoms) == 1:
592
+ return same_identity_and_atoms[0]
593
+ candidates = [
594
+ residue
595
+ for residue in structure.residues
596
+ if residue.id.resname == expected_output_resname
597
+ and residue.atom_names() == reference.atom_names()
598
+ ]
599
+ if len(candidates) == 1:
600
+ return candidates[0]
601
+ raise LeapResidueError(
602
+ f"Could not map ligand {ligand_id} uniquely in {stage} tleap PDB for coordinate validation; "
603
+ "use unique residue names and atom-name sequences for independent ligand instances."
604
+ )
605
+
606
+
607
+ def _residue_atom_indices(atoms: list[AtomRecord], residue_id: ResidueId) -> list[int]:
608
+ return [
609
+ index
610
+ for index, atom in enumerate(atoms)
611
+ if atom.chain_id == residue_id.chain_id
612
+ and atom.resname == residue_id.resname
613
+ and atom.resid == residue_id.resid
614
+ and atom.icode == residue_id.icode
615
+ ]
616
+
617
+
618
+ def _require_same_atom_names(reference: ResidueRecord, target: ResidueRecord, ligand_id: str, *, context: str) -> None:
619
+ reference_names = reference.atom_names()
620
+ target_names = target.atom_names()
621
+ if reference_names != target_names:
622
+ raise LeapResidueError(
623
+ f"Ligand {ligand_id} atom-name mismatch during {context}: "
624
+ f"{target_names} != {reference_names}."
625
+ )
626
+
627
+
628
+ def _require_unique_atom_names(atom_names: list[str], ligand_id: str) -> None:
629
+ duplicates = sorted({name for name in atom_names if atom_names.count(name) > 1})
630
+ if duplicates:
631
+ raise LeapResidueError(
632
+ f"Ligand {ligand_id} has duplicate atom names {duplicates}; unique atom names are required "
633
+ "for safe tleap residue-template matching."
634
+ )
635
+
636
+
637
+ def _require_anchor_compatible(reference: ResidueRecord, target: ResidueRecord, ligand_id: str, *, context: str) -> None:
638
+ if len(reference.atoms) != len(target.atoms):
639
+ raise LeapResidueError(
640
+ f"Ligand {ligand_id} atom-count mismatch during {context}: "
641
+ f"{len(target.atoms)} != {len(reference.atoms)}."
642
+ )
643
+ reference_elements = [(atom.element or "").upper() for atom in reference.atoms]
644
+ target_elements = [(atom.element or "").upper() for atom in target.atoms]
645
+ if reference_elements != target_elements:
646
+ raise LeapResidueError(
647
+ f"Ligand {ligand_id} element-order mismatch during {context}: "
648
+ f"{target_elements} != {reference_elements}."
649
+ )
650
+
651
+
652
+ def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
653
+ return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5