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.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
mdprep/leap/residues.py
ADDED
|
@@ -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
|