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
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
"""Apply safe protonation-stage residue-name changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mdprep.config.models import ManifestConfig
|
|
10
|
+
from mdprep.metals.coordination import MetalCoordinationError, resolve_metal_sites
|
|
11
|
+
from mdprep.protonation.disulfide_states import (
|
|
12
|
+
DisulfideAssignmentError,
|
|
13
|
+
DisulfideResidueAssignment,
|
|
14
|
+
resolve_disulfide_assignments,
|
|
15
|
+
)
|
|
16
|
+
from mdprep.protonation.histidine_xtb import (
|
|
17
|
+
HistidineXtbError,
|
|
18
|
+
HistidineXtbSelection,
|
|
19
|
+
select_histidine_tautomer,
|
|
20
|
+
)
|
|
21
|
+
from mdprep.protonation.overrides import ManualOverrideError, resolve_manual_overrides
|
|
22
|
+
from mdprep.protonation.pka_rules import PkaDecision, PkaRuleError, decide_residue_state
|
|
23
|
+
from mdprep.protonation.propka import (
|
|
24
|
+
PropkaExecutionError,
|
|
25
|
+
PropkaWorkflowResult,
|
|
26
|
+
run_propka_workflow,
|
|
27
|
+
)
|
|
28
|
+
from mdprep.protonation.propka_parser import PropkaParseError, PropkaRecord, map_propka_records
|
|
29
|
+
from mdprep.protonation.temporary_hydrogenation import (
|
|
30
|
+
TemporaryHydrogenationError,
|
|
31
|
+
TemporaryHydrogenationResult,
|
|
32
|
+
add_temporary_protein_hydrogens,
|
|
33
|
+
)
|
|
34
|
+
from mdprep.structure.classify import (
|
|
35
|
+
is_histidine,
|
|
36
|
+
is_standard_protein_residue,
|
|
37
|
+
is_titratable_residue,
|
|
38
|
+
is_water_residue,
|
|
39
|
+
)
|
|
40
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
|
|
41
|
+
from mdprep.structure.selectors import SelectorError, resolve_residue_selector
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProtonationApplicationError(ValueError):
|
|
45
|
+
"""Raised when the protonation stage cannot be applied safely."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ProtonationRecord:
|
|
50
|
+
chain: str
|
|
51
|
+
resid: int
|
|
52
|
+
icode: str | None
|
|
53
|
+
original_resname: str
|
|
54
|
+
final_resname: str
|
|
55
|
+
source: str
|
|
56
|
+
reason: str
|
|
57
|
+
selector: dict[str, object] | None = None
|
|
58
|
+
pka: float | None = None
|
|
59
|
+
ph: float | None = None
|
|
60
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def changed(self) -> bool:
|
|
64
|
+
return self.original_resname != self.final_resname
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, object]:
|
|
67
|
+
return {
|
|
68
|
+
"chain": self.chain,
|
|
69
|
+
"resid": self.resid,
|
|
70
|
+
"icode": self.icode,
|
|
71
|
+
"original_resname": self.original_resname,
|
|
72
|
+
"final_resname": self.final_resname,
|
|
73
|
+
"source": self.source,
|
|
74
|
+
"reason": self.reason,
|
|
75
|
+
"changed": self.changed,
|
|
76
|
+
"selector": self.selector,
|
|
77
|
+
"pka": self.pka,
|
|
78
|
+
"ph": self.ph,
|
|
79
|
+
"metadata": self.metadata,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ProtonationResult:
|
|
85
|
+
input_normalized_pdb_path: Path
|
|
86
|
+
output_protonation_pdb_path: Path
|
|
87
|
+
method: str
|
|
88
|
+
ph: float
|
|
89
|
+
structure: PdbStructure
|
|
90
|
+
manual_overrides_applied: list[ProtonationRecord] = field(default_factory=list)
|
|
91
|
+
metal_coordination_assignments_applied: list[ProtonationRecord] = field(default_factory=list)
|
|
92
|
+
disulfide_assignments_applied: list[ProtonationRecord] = field(default_factory=list)
|
|
93
|
+
input_state_assignments_applied: list[ProtonationRecord] = field(default_factory=list)
|
|
94
|
+
propka_assignments_applied: list[ProtonationRecord] = field(default_factory=list)
|
|
95
|
+
xtb_assignments_applied: list[ProtonationRecord] = field(default_factory=list)
|
|
96
|
+
propka_result: PropkaWorkflowResult | None = None
|
|
97
|
+
xtb_selections: list[HistidineXtbSelection] = field(default_factory=list)
|
|
98
|
+
xtb_temporary_hydrogenation: TemporaryHydrogenationResult | None = None
|
|
99
|
+
hydrogen_atoms_removed: int = 0
|
|
100
|
+
unresolved_histidines: list[dict[str, object]] = field(default_factory=list)
|
|
101
|
+
titratable_residues_not_explicitly_assigned: list[dict[str, object]] = field(default_factory=list)
|
|
102
|
+
warnings: list[str] = field(default_factory=list)
|
|
103
|
+
reused_initial_assignments_after_refinement: bool = False
|
|
104
|
+
preserved_refined_hydrogen_count: int = 0
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def records(self) -> list[ProtonationRecord]:
|
|
108
|
+
return (
|
|
109
|
+
self.manual_overrides_applied
|
|
110
|
+
+ self.metal_coordination_assignments_applied
|
|
111
|
+
+ self.disulfide_assignments_applied
|
|
112
|
+
+ self.input_state_assignments_applied
|
|
113
|
+
+ self.propka_assignments_applied
|
|
114
|
+
+ self.xtb_assignments_applied
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def to_report_dict(self) -> dict[str, object]:
|
|
118
|
+
changed = [record.to_dict() for record in self.records if record.changed]
|
|
119
|
+
unchanged = [record.to_dict() for record in self.records if not record.changed]
|
|
120
|
+
propka = self.propka_result.to_dict() if self.propka_result is not None else None
|
|
121
|
+
parsed_pkas = (
|
|
122
|
+
[record.to_dict() for record in self.propka_result.records]
|
|
123
|
+
if self.propka_result is not None
|
|
124
|
+
else []
|
|
125
|
+
)
|
|
126
|
+
return {
|
|
127
|
+
"input_normalized_pdb_path": str(self.input_normalized_pdb_path),
|
|
128
|
+
"output_protonation_pdb_path": str(self.output_protonation_pdb_path),
|
|
129
|
+
"method": self.method,
|
|
130
|
+
"ph": self.ph,
|
|
131
|
+
"propka": propka,
|
|
132
|
+
"parsed_pkas": parsed_pkas,
|
|
133
|
+
"xtb_histidines": [selection.to_dict() for selection in self.xtb_selections],
|
|
134
|
+
"xtb_temporary_protein_hydrogenation": (
|
|
135
|
+
self.xtb_temporary_hydrogenation.to_dict()
|
|
136
|
+
if self.xtb_temporary_hydrogenation is not None
|
|
137
|
+
else None
|
|
138
|
+
),
|
|
139
|
+
"temporary_water_hydrogens_for_xtb_clusters": [
|
|
140
|
+
{
|
|
141
|
+
"histidine": _histidine_label(selection.residue),
|
|
142
|
+
"temporary_water_hydrogens_added": selection.temporary_water_hydrogens_added,
|
|
143
|
+
"waters_modified_for_xtb_only": selection.waters_modified_for_xtb_only,
|
|
144
|
+
"final_pdb_modified": selection.final_pdb_modified_by_temporary_water_hydrogens,
|
|
145
|
+
}
|
|
146
|
+
for selection in self.xtb_selections
|
|
147
|
+
if selection.temporary_water_hydrogens_added
|
|
148
|
+
],
|
|
149
|
+
"manual_overrides_applied": [record.to_dict() for record in self.manual_overrides_applied],
|
|
150
|
+
"metal_coordination_assignments_applied": [
|
|
151
|
+
record.to_dict() for record in self.metal_coordination_assignments_applied
|
|
152
|
+
],
|
|
153
|
+
"disulfide_assignments_applied": [
|
|
154
|
+
record.to_dict() for record in self.disulfide_assignments_applied
|
|
155
|
+
],
|
|
156
|
+
"input_state_assignments_applied": [
|
|
157
|
+
record.to_dict() for record in self.input_state_assignments_applied
|
|
158
|
+
],
|
|
159
|
+
"propka_assignments_applied": [
|
|
160
|
+
record.to_dict() for record in self.propka_assignments_applied
|
|
161
|
+
],
|
|
162
|
+
"xtb_assignments_applied": [
|
|
163
|
+
record.to_dict() for record in self.xtb_assignments_applied
|
|
164
|
+
],
|
|
165
|
+
"hydrogen_atoms_removed": self.hydrogen_atoms_removed,
|
|
166
|
+
"residues_changed": changed,
|
|
167
|
+
"residues_unchanged_but_explicitly_assigned": unchanged,
|
|
168
|
+
"unresolved_histidines_remaining_as_his": self.unresolved_histidines,
|
|
169
|
+
"titratable_residues_not_explicitly_assigned": self.titratable_residues_not_explicitly_assigned,
|
|
170
|
+
"warnings": self.warnings,
|
|
171
|
+
"reused_initial_assignments_after_refinement": (
|
|
172
|
+
self.reused_initial_assignments_after_refinement
|
|
173
|
+
),
|
|
174
|
+
"preserved_refined_hydrogen_count": self.preserved_refined_hydrogen_count,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def apply_protonation_stage(
|
|
179
|
+
structure: PdbStructure,
|
|
180
|
+
manifest: ManifestConfig,
|
|
181
|
+
*,
|
|
182
|
+
input_normalized_pdb_path: str | Path,
|
|
183
|
+
output_protonation_pdb_path: str | Path,
|
|
184
|
+
) -> ProtonationResult:
|
|
185
|
+
try:
|
|
186
|
+
manual_assignments = resolve_manual_overrides(structure, manifest)
|
|
187
|
+
disulfide_assignments = resolve_disulfide_assignments(structure, manifest)
|
|
188
|
+
metal_sites = resolve_metal_sites(
|
|
189
|
+
structure,
|
|
190
|
+
manifest,
|
|
191
|
+
validate_mcpb_cutoff=False,
|
|
192
|
+
)
|
|
193
|
+
except (ManualOverrideError, DisulfideAssignmentError, MetalCoordinationError) as exc:
|
|
194
|
+
raise ProtonationApplicationError(str(exc)) from exc
|
|
195
|
+
|
|
196
|
+
final_by_residue: dict[int, str] = {}
|
|
197
|
+
manual_records: list[ProtonationRecord] = []
|
|
198
|
+
manual_state_by_residue = {id(item.residue): item.requested_state for item in manual_assignments}
|
|
199
|
+
|
|
200
|
+
for assignment in manual_assignments:
|
|
201
|
+
final_by_residue[id(assignment.residue)] = assignment.requested_state
|
|
202
|
+
manual_records.append(
|
|
203
|
+
_record(
|
|
204
|
+
assignment.residue,
|
|
205
|
+
final_resname=assignment.requested_state,
|
|
206
|
+
source="manual_override",
|
|
207
|
+
reason=assignment.reason,
|
|
208
|
+
selector=assignment.selector,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
metal_records = _apply_metal_histidine_constraints(
|
|
213
|
+
metal_sites,
|
|
214
|
+
final_by_residue=final_by_residue,
|
|
215
|
+
manual_state_by_residue=manual_state_by_residue,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
disulfide_records: list[ProtonationRecord] = []
|
|
219
|
+
for assignment in disulfide_assignments:
|
|
220
|
+
_validate_disulfide_manual_compatibility(assignment, manual_state_by_residue)
|
|
221
|
+
final_by_residue[id(assignment.residue)] = "CYX"
|
|
222
|
+
disulfide_records.append(
|
|
223
|
+
_record(
|
|
224
|
+
assignment.residue,
|
|
225
|
+
final_resname="CYX",
|
|
226
|
+
source=assignment.source,
|
|
227
|
+
reason=assignment.reason,
|
|
228
|
+
selector=None,
|
|
229
|
+
metadata={
|
|
230
|
+
"partner": assignment.partner.id.to_dict(),
|
|
231
|
+
"distance_angstrom": assignment.distance_angstrom,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
warnings = list(structure.warnings)
|
|
237
|
+
input_state_records: list[ProtonationRecord] = []
|
|
238
|
+
propka_records: list[ProtonationRecord] = []
|
|
239
|
+
xtb_records: list[ProtonationRecord] = []
|
|
240
|
+
propka_result: PropkaWorkflowResult | None = None
|
|
241
|
+
xtb_selections: list[HistidineXtbSelection] = []
|
|
242
|
+
xtb_temporary_hydrogenation: TemporaryHydrogenationResult | None = None
|
|
243
|
+
if manifest.protonation.method in {"propka", "propka_xtb_his"}:
|
|
244
|
+
try:
|
|
245
|
+
propka_result = run_propka_workflow(
|
|
246
|
+
structure,
|
|
247
|
+
manifest,
|
|
248
|
+
work_dir=_protonation_work_dir(output_protonation_pdb_path) / "propka",
|
|
249
|
+
)
|
|
250
|
+
mapped_pkas = map_propka_records(structure, propka_result.records)
|
|
251
|
+
pka_decisions = _decide_propka_states(
|
|
252
|
+
structure,
|
|
253
|
+
mapped_pkas=mapped_pkas,
|
|
254
|
+
manifest=manifest,
|
|
255
|
+
final_by_residue=final_by_residue,
|
|
256
|
+
)
|
|
257
|
+
except (PropkaExecutionError, PropkaParseError, PkaRuleError) as exc:
|
|
258
|
+
raise ProtonationApplicationError(str(exc)) from exc
|
|
259
|
+
|
|
260
|
+
xtb_needed: list[PkaDecision] = []
|
|
261
|
+
for decision in pka_decisions:
|
|
262
|
+
warnings.extend(decision.warnings)
|
|
263
|
+
if decision.needs_xtb:
|
|
264
|
+
xtb_needed.append(decision)
|
|
265
|
+
continue
|
|
266
|
+
if decision.final_state is None:
|
|
267
|
+
continue
|
|
268
|
+
final_by_residue[id(decision.residue)] = decision.final_state
|
|
269
|
+
record = _record(
|
|
270
|
+
decision.residue,
|
|
271
|
+
final_resname=decision.final_state,
|
|
272
|
+
source=decision.source,
|
|
273
|
+
reason=decision.reason,
|
|
274
|
+
selector=None,
|
|
275
|
+
pka=decision.pka,
|
|
276
|
+
ph=manifest.protonation.ph,
|
|
277
|
+
)
|
|
278
|
+
if decision.source == "input_state":
|
|
279
|
+
input_state_records.append(record)
|
|
280
|
+
else:
|
|
281
|
+
propka_records.append(record)
|
|
282
|
+
|
|
283
|
+
if xtb_needed and manifest.protonation.histidine.neutral_tautomer_method != "xtb":
|
|
284
|
+
raise ProtonationApplicationError(
|
|
285
|
+
"Neutral HIS residues require HID/HIE assignment; set "
|
|
286
|
+
"protonation.histidine.neutral_tautomer_method: xtb or add manual overrides."
|
|
287
|
+
)
|
|
288
|
+
xtb_structure = structure
|
|
289
|
+
xtb_config = manifest.protonation.histidine.xtb
|
|
290
|
+
if (
|
|
291
|
+
xtb_needed
|
|
292
|
+
and xtb_config.add_missing_protein_hydrogens
|
|
293
|
+
and _protein_hydrogenation_is_incomplete(structure)
|
|
294
|
+
):
|
|
295
|
+
try:
|
|
296
|
+
xtb_temporary_hydrogenation = add_temporary_protein_hydrogens(
|
|
297
|
+
structure,
|
|
298
|
+
ph=manifest.protonation.ph,
|
|
299
|
+
random_seed=xtb_config.temporary_hydrogen_random_seed,
|
|
300
|
+
work_dir=(
|
|
301
|
+
_protonation_work_dir(output_protonation_pdb_path)
|
|
302
|
+
/ "histidine_xtb"
|
|
303
|
+
/ "temporary_environment_hydrogenation"
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
except TemporaryHydrogenationError as exc:
|
|
307
|
+
raise ProtonationApplicationError(str(exc)) from exc
|
|
308
|
+
xtb_structure = xtb_temporary_hydrogenation.structure
|
|
309
|
+
warnings.append(
|
|
310
|
+
"PDBFixer added temporary protein hydrogens for xTB HID/HIE "
|
|
311
|
+
"ranking; these hydrogens are not written to the protonation or "
|
|
312
|
+
"final prepared PDB."
|
|
313
|
+
)
|
|
314
|
+
pending_xtb_states = _initial_xtb_environment_states(xtb_needed, warnings)
|
|
315
|
+
for decision in xtb_needed:
|
|
316
|
+
try:
|
|
317
|
+
selection_states = dict(pending_xtb_states)
|
|
318
|
+
selection_states.update(final_by_residue)
|
|
319
|
+
selection_residue = _matching_residue(
|
|
320
|
+
xtb_structure,
|
|
321
|
+
decision.residue,
|
|
322
|
+
)
|
|
323
|
+
selection = select_histidine_tautomer(
|
|
324
|
+
xtb_structure,
|
|
325
|
+
selection_residue,
|
|
326
|
+
manifest,
|
|
327
|
+
work_dir=_protonation_work_dir(output_protonation_pdb_path) / "histidine_xtb",
|
|
328
|
+
planned_states=_map_residue_states(
|
|
329
|
+
selection_states,
|
|
330
|
+
source_structure=structure,
|
|
331
|
+
target_structure=xtb_structure,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
except HistidineXtbError as exc:
|
|
335
|
+
raise ProtonationApplicationError(str(exc)) from exc
|
|
336
|
+
selection = replace(selection, residue=decision.residue)
|
|
337
|
+
warnings.extend(selection.warnings)
|
|
338
|
+
xtb_selections.append(selection)
|
|
339
|
+
final_by_residue[id(decision.residue)] = selection.selected_state
|
|
340
|
+
pending_xtb_states[id(decision.residue)] = selection.selected_state
|
|
341
|
+
xtb_records.append(
|
|
342
|
+
_record(
|
|
343
|
+
decision.residue,
|
|
344
|
+
final_resname=selection.selected_state,
|
|
345
|
+
source="propka_xtb_his",
|
|
346
|
+
reason=(
|
|
347
|
+
"Neutral HIS assigned by xTB HID/HIE comparison; "
|
|
348
|
+
f"delta(HID-HIE)={selection.delta_kcal_mol:.3f} kcal/mol"
|
|
349
|
+
),
|
|
350
|
+
selector=None,
|
|
351
|
+
pka=decision.pka,
|
|
352
|
+
ph=manifest.protonation.ph,
|
|
353
|
+
metadata=selection.to_dict(),
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
elif manifest.protonation.method != "manual_only":
|
|
357
|
+
raise ProtonationApplicationError(
|
|
358
|
+
f"Unsupported protonation method: {manifest.protonation.method}"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
renamed_atoms = _rename_atoms(structure, final_by_residue)
|
|
362
|
+
hydrogen_atoms_removed = 0
|
|
363
|
+
if manifest.structure.remove_input_hydrogens:
|
|
364
|
+
before = len(renamed_atoms)
|
|
365
|
+
protected_ligand_keys = _configured_ligand_atom_keys(structure, manifest)
|
|
366
|
+
renamed_atoms = [
|
|
367
|
+
atom
|
|
368
|
+
for atom in renamed_atoms
|
|
369
|
+
if not (is_hydrogen_atom(atom) and _atom_residue_key(atom) not in protected_ligand_keys)
|
|
370
|
+
]
|
|
371
|
+
hydrogen_atoms_removed = before - len(renamed_atoms)
|
|
372
|
+
if protected_ligand_keys:
|
|
373
|
+
warnings.append(
|
|
374
|
+
"Configured ligand residues were excluded from structure.remove_input_hydrogens so ligand "
|
|
375
|
+
"parameterization sees the submitted ligand hydrogenation."
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
protonated_structure = PdbStructure(
|
|
379
|
+
path=Path(output_protonation_pdb_path),
|
|
380
|
+
atoms=renamed_atoms,
|
|
381
|
+
residues=_build_residues(renamed_atoms),
|
|
382
|
+
model_count=structure.model_count,
|
|
383
|
+
used_model=structure.used_model,
|
|
384
|
+
warnings=list(structure.warnings),
|
|
385
|
+
conect_bonds={
|
|
386
|
+
bond
|
|
387
|
+
for bond in structure.conect_bonds
|
|
388
|
+
if bond[0] in {atom.serial for atom in renamed_atoms}
|
|
389
|
+
and bond[1] in {atom.serial for atom in renamed_atoms}
|
|
390
|
+
},
|
|
391
|
+
ter_after_serials={
|
|
392
|
+
serial
|
|
393
|
+
for serial in structure.ter_after_serials
|
|
394
|
+
if serial in {atom.serial for atom in renamed_atoms}
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
explicit_keys = {
|
|
398
|
+
(record.chain, record.resid, record.icode)
|
|
399
|
+
for record in (
|
|
400
|
+
manual_records
|
|
401
|
+
+ metal_records
|
|
402
|
+
+ disulfide_records
|
|
403
|
+
+ input_state_records
|
|
404
|
+
+ propka_records
|
|
405
|
+
+ xtb_records
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
unresolved_his = [
|
|
409
|
+
_residue_dict(residue)
|
|
410
|
+
for residue in protonated_structure.residues
|
|
411
|
+
if is_histidine(residue) and residue.id.resname == "HIS"
|
|
412
|
+
]
|
|
413
|
+
unassigned_titratable = [
|
|
414
|
+
_residue_dict(residue)
|
|
415
|
+
for residue in protonated_structure.residues
|
|
416
|
+
if is_titratable_residue(residue)
|
|
417
|
+
and (residue.id.chain_id, residue.id.resid, residue.id.icode) not in explicit_keys
|
|
418
|
+
]
|
|
419
|
+
return ProtonationResult(
|
|
420
|
+
input_normalized_pdb_path=Path(input_normalized_pdb_path),
|
|
421
|
+
output_protonation_pdb_path=Path(output_protonation_pdb_path),
|
|
422
|
+
method=manifest.protonation.method,
|
|
423
|
+
ph=manifest.protonation.ph,
|
|
424
|
+
structure=protonated_structure,
|
|
425
|
+
manual_overrides_applied=manual_records,
|
|
426
|
+
metal_coordination_assignments_applied=metal_records,
|
|
427
|
+
disulfide_assignments_applied=disulfide_records,
|
|
428
|
+
input_state_assignments_applied=input_state_records,
|
|
429
|
+
propka_assignments_applied=propka_records,
|
|
430
|
+
xtb_assignments_applied=xtb_records,
|
|
431
|
+
propka_result=propka_result,
|
|
432
|
+
xtb_selections=xtb_selections,
|
|
433
|
+
xtb_temporary_hydrogenation=xtb_temporary_hydrogenation,
|
|
434
|
+
hydrogen_atoms_removed=hydrogen_atoms_removed,
|
|
435
|
+
unresolved_histidines=unresolved_his,
|
|
436
|
+
titratable_residues_not_explicitly_assigned=unassigned_titratable,
|
|
437
|
+
warnings=warnings,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def reuse_protonation_assignments_after_refinement(
|
|
442
|
+
structure: PdbStructure,
|
|
443
|
+
previous: ProtonationResult,
|
|
444
|
+
*,
|
|
445
|
+
input_refined_pdb_path: str | Path,
|
|
446
|
+
output_protonation_pdb_path: str | Path,
|
|
447
|
+
) -> ProtonationResult:
|
|
448
|
+
"""Restore initial residue states while retaining every refined hydrogen.
|
|
449
|
+
|
|
450
|
+
Hydrogen-only refinement is meaningful only if its optimized proton
|
|
451
|
+
coordinates reach the MCPB/charge/final-tleap stages. This function
|
|
452
|
+
reapplies the already reviewed pre-refinement assignments to the
|
|
453
|
+
coordinate-transfer structure and deliberately does not rerun PropKa/xTB.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
final_by_key: dict[tuple[str, int, str | None], str] = {}
|
|
457
|
+
for record in previous.records:
|
|
458
|
+
key = (record.chain, record.resid, record.icode)
|
|
459
|
+
existing = final_by_key.get(key)
|
|
460
|
+
if existing is not None and existing != record.final_resname:
|
|
461
|
+
raise ProtonationApplicationError(
|
|
462
|
+
"Pre-refinement protonation records contain conflicting final "
|
|
463
|
+
f"states for {key}: {existing} and {record.final_resname}."
|
|
464
|
+
)
|
|
465
|
+
final_by_key[key] = record.final_resname
|
|
466
|
+
|
|
467
|
+
observed_keys = {
|
|
468
|
+
(residue.id.chain_id, residue.id.resid, residue.id.icode)
|
|
469
|
+
for residue in structure.residues
|
|
470
|
+
}
|
|
471
|
+
missing = sorted(key for key in final_by_key if key not in observed_keys)
|
|
472
|
+
if missing:
|
|
473
|
+
raise ProtonationApplicationError(
|
|
474
|
+
"QM/MM coordinate transfer lost residues with reviewed protonation "
|
|
475
|
+
f"assignments: {missing}"
|
|
476
|
+
)
|
|
477
|
+
atoms = [
|
|
478
|
+
replace(
|
|
479
|
+
atom,
|
|
480
|
+
resname=final_by_key.get(
|
|
481
|
+
(atom.chain_id, atom.resid, atom.icode),
|
|
482
|
+
atom.resname,
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
for atom in structure.atoms
|
|
486
|
+
]
|
|
487
|
+
output = Path(output_protonation_pdb_path)
|
|
488
|
+
reused_structure = PdbStructure(
|
|
489
|
+
path=output,
|
|
490
|
+
atoms=atoms,
|
|
491
|
+
residues=_build_residues(atoms),
|
|
492
|
+
model_count=structure.model_count,
|
|
493
|
+
used_model=structure.used_model,
|
|
494
|
+
warnings=list(structure.warnings),
|
|
495
|
+
conect_bonds=set(structure.conect_bonds),
|
|
496
|
+
ter_after_serials=set(structure.ter_after_serials),
|
|
497
|
+
)
|
|
498
|
+
_validate_reused_refined_hydrogens(reused_structure)
|
|
499
|
+
hydrogen_count = sum(is_hydrogen_atom(atom) for atom in reused_structure.atoms)
|
|
500
|
+
if hydrogen_count == 0:
|
|
501
|
+
raise ProtonationApplicationError(
|
|
502
|
+
"post_refinement_protonation: reuse_initial selected a refined "
|
|
503
|
+
"structure containing no hydrogens."
|
|
504
|
+
)
|
|
505
|
+
warning = (
|
|
506
|
+
"Initial PropKa/xTB/manual protonation assignments were reused after "
|
|
507
|
+
"QM/MM refinement so all refined protein, ligand, and water hydrogen "
|
|
508
|
+
"coordinates remain available to MCPB.py, QMMESP, and final tleap."
|
|
509
|
+
)
|
|
510
|
+
return ProtonationResult(
|
|
511
|
+
input_normalized_pdb_path=Path(input_refined_pdb_path),
|
|
512
|
+
output_protonation_pdb_path=output,
|
|
513
|
+
method=previous.method,
|
|
514
|
+
ph=previous.ph,
|
|
515
|
+
structure=reused_structure,
|
|
516
|
+
manual_overrides_applied=list(previous.manual_overrides_applied),
|
|
517
|
+
metal_coordination_assignments_applied=list(
|
|
518
|
+
previous.metal_coordination_assignments_applied
|
|
519
|
+
),
|
|
520
|
+
disulfide_assignments_applied=list(previous.disulfide_assignments_applied),
|
|
521
|
+
input_state_assignments_applied=list(previous.input_state_assignments_applied),
|
|
522
|
+
propka_assignments_applied=list(previous.propka_assignments_applied),
|
|
523
|
+
xtb_assignments_applied=list(previous.xtb_assignments_applied),
|
|
524
|
+
propka_result=previous.propka_result,
|
|
525
|
+
xtb_selections=list(previous.xtb_selections),
|
|
526
|
+
xtb_temporary_hydrogenation=previous.xtb_temporary_hydrogenation,
|
|
527
|
+
hydrogen_atoms_removed=0,
|
|
528
|
+
unresolved_histidines=list(previous.unresolved_histidines),
|
|
529
|
+
titratable_residues_not_explicitly_assigned=list(
|
|
530
|
+
previous.titratable_residues_not_explicitly_assigned
|
|
531
|
+
),
|
|
532
|
+
warnings=[*previous.warnings, warning],
|
|
533
|
+
reused_initial_assignments_after_refinement=True,
|
|
534
|
+
preserved_refined_hydrogen_count=hydrogen_count,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _validate_reused_refined_hydrogens(structure: PdbStructure) -> None:
|
|
539
|
+
for residue in structure.residues:
|
|
540
|
+
state = residue.id.resname
|
|
541
|
+
if state in {"HID", "HIE", "HIP"}:
|
|
542
|
+
nd1 = _hydrogen_count_near_any(residue, ("ND1",))
|
|
543
|
+
ne2 = _hydrogen_count_near_any(residue, ("NE2",))
|
|
544
|
+
expected = {"HID": (1, 0), "HIE": (0, 1), "HIP": (1, 1)}[state]
|
|
545
|
+
if (nd1, ne2) != expected:
|
|
546
|
+
raise ProtonationApplicationError(
|
|
547
|
+
f"Refined {residue.id.display()} hydrogen pattern is {(nd1, ne2)} "
|
|
548
|
+
f"at ND1/NE2; expected {expected} for {state}."
|
|
549
|
+
)
|
|
550
|
+
elif state in {"ASP", "GLU"}:
|
|
551
|
+
names = ("OD1", "OD2") if state == "ASP" else ("OE1", "OE2")
|
|
552
|
+
if _hydrogen_count_near_any(residue, names) != 0:
|
|
553
|
+
raise ProtonationApplicationError(
|
|
554
|
+
f"Refined {residue.id.display()} retains a carboxyl proton "
|
|
555
|
+
f"in deprotonated state {state}."
|
|
556
|
+
)
|
|
557
|
+
elif state in {"ASH", "GLH"}:
|
|
558
|
+
names = ("OD1", "OD2") if state == "ASH" else ("OE1", "OE2")
|
|
559
|
+
if _hydrogen_count_near_any(residue, names) != 1:
|
|
560
|
+
raise ProtonationApplicationError(
|
|
561
|
+
f"Refined {residue.id.display()} does not contain exactly one "
|
|
562
|
+
f"carboxyl proton for {state}."
|
|
563
|
+
)
|
|
564
|
+
elif state == "LYN" and _hydrogen_count_near_any(residue, ("NZ",)) != 2:
|
|
565
|
+
raise ProtonationApplicationError(
|
|
566
|
+
f"Refined {residue.id.display()} does not contain two NZ "
|
|
567
|
+
"hydrogens for LYN."
|
|
568
|
+
)
|
|
569
|
+
elif state == "LYS" and _hydrogen_count_near_any(residue, ("NZ",)) != 3:
|
|
570
|
+
raise ProtonationApplicationError(
|
|
571
|
+
f"Refined {residue.id.display()} does not contain three NZ "
|
|
572
|
+
"hydrogens for LYS."
|
|
573
|
+
)
|
|
574
|
+
elif state in {"CYS", "CYM", "CYX"}:
|
|
575
|
+
# A normal S-H bond is longer than N-H/O-H. The general 1.25 A
|
|
576
|
+
# proximity cutoff used above would therefore reject a chemically
|
|
577
|
+
# intact CYS thiol after coordinate rounding or QM/MM relaxation.
|
|
578
|
+
observed = _hydrogen_count_near_any(
|
|
579
|
+
residue,
|
|
580
|
+
("SG",),
|
|
581
|
+
cutoff_angstrom=1.50,
|
|
582
|
+
)
|
|
583
|
+
expected = 1 if state == "CYS" else 0
|
|
584
|
+
if observed != expected:
|
|
585
|
+
raise ProtonationApplicationError(
|
|
586
|
+
f"Refined {residue.id.display()} has {observed} SG hydrogens; "
|
|
587
|
+
f"expected {expected} for {state}."
|
|
588
|
+
)
|
|
589
|
+
elif is_water_residue(residue):
|
|
590
|
+
water_hydrogens = sum(is_hydrogen_atom(atom) for atom in residue.atoms)
|
|
591
|
+
if water_hydrogens != 2:
|
|
592
|
+
raise ProtonationApplicationError(
|
|
593
|
+
f"Refined water {residue.id.display()} contains "
|
|
594
|
+
f"{water_hydrogens} hydrogens; TIP3P requires two."
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _decide_propka_states(
|
|
599
|
+
structure: PdbStructure,
|
|
600
|
+
*,
|
|
601
|
+
mapped_pkas: dict[int, PropkaRecord],
|
|
602
|
+
manifest: ManifestConfig,
|
|
603
|
+
final_by_residue: dict[int, str],
|
|
604
|
+
) -> list[PkaDecision]:
|
|
605
|
+
decisions: list[PkaDecision] = []
|
|
606
|
+
for residue in structure.residues:
|
|
607
|
+
if id(residue) in final_by_residue:
|
|
608
|
+
continue
|
|
609
|
+
if not is_titratable_residue(residue):
|
|
610
|
+
continue
|
|
611
|
+
decision = decide_residue_state(
|
|
612
|
+
residue,
|
|
613
|
+
record=mapped_pkas.get(id(residue)), # type: ignore[arg-type]
|
|
614
|
+
ph=manifest.protonation.ph,
|
|
615
|
+
method=manifest.protonation.method, # type: ignore[arg-type]
|
|
616
|
+
)
|
|
617
|
+
if decision is not None:
|
|
618
|
+
decisions.append(decision)
|
|
619
|
+
return decisions
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _initial_xtb_environment_states(
|
|
623
|
+
decisions: list[PkaDecision],
|
|
624
|
+
warnings: list[str],
|
|
625
|
+
) -> dict[int, str]:
|
|
626
|
+
states: dict[int, str] = {}
|
|
627
|
+
for decision in decisions:
|
|
628
|
+
residue = decision.residue
|
|
629
|
+
inferred = _input_histidine_state_from_n_hydrogens(residue)
|
|
630
|
+
if inferred in {"HID", "HIE"}:
|
|
631
|
+
states[id(residue)] = inferred
|
|
632
|
+
warnings.append(
|
|
633
|
+
f"Neutral histidine {residue.id.display()} is pending xTB assignment; "
|
|
634
|
+
f"using input-like {inferred} as its temporary environment state until its own HID/HIE comparison runs."
|
|
635
|
+
)
|
|
636
|
+
else:
|
|
637
|
+
states[id(residue)] = "HIE"
|
|
638
|
+
warnings.append(
|
|
639
|
+
f"Neutral histidine {residue.id.display()} is pending xTB assignment and has ambiguous or missing "
|
|
640
|
+
"imidazole hydrogens in the input; using HIE as a deterministic temporary environment state until "
|
|
641
|
+
"its own HID/HIE comparison runs."
|
|
642
|
+
)
|
|
643
|
+
return states
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _protein_hydrogenation_is_incomplete(structure: PdbStructure) -> bool:
|
|
647
|
+
"""Return whether any standard residue has no explicit hydrogen at all."""
|
|
648
|
+
|
|
649
|
+
return any(
|
|
650
|
+
not any(is_hydrogen_atom(atom) for atom in residue.atoms)
|
|
651
|
+
for residue in structure.residues
|
|
652
|
+
if is_standard_protein_residue(residue)
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _matching_residue(
|
|
657
|
+
structure: PdbStructure,
|
|
658
|
+
reference: ResidueRecord,
|
|
659
|
+
) -> ResidueRecord:
|
|
660
|
+
matches = [
|
|
661
|
+
residue
|
|
662
|
+
for residue in structure.residues
|
|
663
|
+
if (
|
|
664
|
+
residue.id.chain_id,
|
|
665
|
+
residue.id.resid,
|
|
666
|
+
residue.id.icode,
|
|
667
|
+
)
|
|
668
|
+
== (
|
|
669
|
+
reference.id.chain_id,
|
|
670
|
+
reference.id.resid,
|
|
671
|
+
reference.id.icode,
|
|
672
|
+
)
|
|
673
|
+
]
|
|
674
|
+
if len(matches) != 1:
|
|
675
|
+
raise ProtonationApplicationError(
|
|
676
|
+
"Temporary histidine environment mapping did not resolve exactly one "
|
|
677
|
+
f"copy of {reference.id.display()}."
|
|
678
|
+
)
|
|
679
|
+
if matches[0].id.resname != reference.id.resname:
|
|
680
|
+
raise ProtonationApplicationError(
|
|
681
|
+
"Temporary histidine environment changed residue identity: "
|
|
682
|
+
f"{reference.id.display()} -> {matches[0].id.display()}."
|
|
683
|
+
)
|
|
684
|
+
return matches[0]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _map_residue_states(
|
|
688
|
+
states: dict[int, str],
|
|
689
|
+
*,
|
|
690
|
+
source_structure: PdbStructure,
|
|
691
|
+
target_structure: PdbStructure,
|
|
692
|
+
) -> dict[int, str]:
|
|
693
|
+
if source_structure is target_structure:
|
|
694
|
+
return states
|
|
695
|
+
state_by_key = {
|
|
696
|
+
(residue.id.chain_id, residue.id.resid, residue.id.icode): states[id(residue)]
|
|
697
|
+
for residue in source_structure.residues
|
|
698
|
+
if id(residue) in states
|
|
699
|
+
}
|
|
700
|
+
mapped: dict[int, str] = {}
|
|
701
|
+
for residue in target_structure.residues:
|
|
702
|
+
key = (residue.id.chain_id, residue.id.resid, residue.id.icode)
|
|
703
|
+
if key in state_by_key:
|
|
704
|
+
mapped[id(residue)] = state_by_key[key]
|
|
705
|
+
if len(mapped) != len(state_by_key):
|
|
706
|
+
raise ProtonationApplicationError(
|
|
707
|
+
"Temporary histidine environment did not preserve every planned "
|
|
708
|
+
"protonation-state residue."
|
|
709
|
+
)
|
|
710
|
+
return mapped
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _input_histidine_state_from_n_hydrogens(residue: ResidueRecord) -> str | None:
|
|
714
|
+
nd1_h = _hydrogen_count_near_any(residue, ("ND1",))
|
|
715
|
+
ne2_h = _hydrogen_count_near_any(residue, ("NE2",))
|
|
716
|
+
if nd1_h == 1 and ne2_h == 0:
|
|
717
|
+
return "HID"
|
|
718
|
+
if nd1_h == 0 and ne2_h == 1:
|
|
719
|
+
return "HIE"
|
|
720
|
+
if nd1_h == 1 and ne2_h == 1:
|
|
721
|
+
return "HIP"
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _hydrogen_count_near_any(
|
|
726
|
+
residue: ResidueRecord,
|
|
727
|
+
atom_names: tuple[str, ...],
|
|
728
|
+
*,
|
|
729
|
+
cutoff_angstrom: float = 1.25,
|
|
730
|
+
) -> int:
|
|
731
|
+
return sum(
|
|
732
|
+
1
|
|
733
|
+
for atom in residue.atoms
|
|
734
|
+
if is_hydrogen_atom(atom) and _hydrogen_is_near_any(atom, residue, atom_names, cutoff_angstrom=cutoff_angstrom)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _hydrogen_is_near_any(
|
|
739
|
+
hydrogen: AtomRecord,
|
|
740
|
+
residue: ResidueRecord,
|
|
741
|
+
atom_names: tuple[str, ...],
|
|
742
|
+
*,
|
|
743
|
+
cutoff_angstrom: float = 1.25,
|
|
744
|
+
) -> bool:
|
|
745
|
+
for atom in residue.atoms:
|
|
746
|
+
if atom.name.strip() not in atom_names:
|
|
747
|
+
continue
|
|
748
|
+
distance = (
|
|
749
|
+
(hydrogen.x - atom.x) ** 2
|
|
750
|
+
+ (hydrogen.y - atom.y) ** 2
|
|
751
|
+
+ (hydrogen.z - atom.z) ** 2
|
|
752
|
+
) ** 0.5
|
|
753
|
+
if distance <= cutoff_angstrom:
|
|
754
|
+
return True
|
|
755
|
+
return False
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _protonation_work_dir(output_protonation_pdb_path: str | Path) -> Path:
|
|
759
|
+
output_path = Path(output_protonation_pdb_path)
|
|
760
|
+
output_dir = output_path.parent.parent if output_path.parent.name == "intermediate" else output_path.parent
|
|
761
|
+
return output_dir / "protonation"
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def is_hydrogen_atom(atom: AtomRecord) -> bool:
|
|
765
|
+
element_field = atom.original_line[76:78].strip() if len(atom.original_line) >= 78 else ""
|
|
766
|
+
if element_field:
|
|
767
|
+
return element_field.upper() == "H"
|
|
768
|
+
stripped = atom.name.strip()
|
|
769
|
+
while stripped and stripped[0].isdigit():
|
|
770
|
+
stripped = stripped[1:]
|
|
771
|
+
return stripped.upper().startswith("H")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _configured_ligand_atom_keys(
|
|
775
|
+
structure: PdbStructure,
|
|
776
|
+
manifest: ManifestConfig,
|
|
777
|
+
) -> set[tuple[str, str, int, str | None]]:
|
|
778
|
+
keys: set[tuple[str, str, int, str | None]] = set()
|
|
779
|
+
for ligand in manifest.ligands:
|
|
780
|
+
try:
|
|
781
|
+
residue = resolve_residue_selector(structure, ligand.selector.model_dump())
|
|
782
|
+
except SelectorError as exc:
|
|
783
|
+
raise ProtonationApplicationError(
|
|
784
|
+
f"Ligand {ligand.id} selector did not resolve exactly one residue before hydrogen removal: {exc}"
|
|
785
|
+
) from exc
|
|
786
|
+
keys.add((residue.id.chain_id, residue.id.resname, residue.id.resid, residue.id.icode))
|
|
787
|
+
return keys
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _atom_residue_key(atom: AtomRecord) -> tuple[str, str, int, str | None]:
|
|
791
|
+
return (atom.chain_id, atom.resname, atom.resid, atom.icode)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _validate_disulfide_manual_compatibility(
|
|
795
|
+
assignment: DisulfideResidueAssignment,
|
|
796
|
+
manual_state_by_residue: dict[int, str],
|
|
797
|
+
) -> None:
|
|
798
|
+
manual_state = manual_state_by_residue.get(id(assignment.residue))
|
|
799
|
+
if manual_state is not None and manual_state != "CYX":
|
|
800
|
+
residue = assignment.residue.id.display()
|
|
801
|
+
raise ProtonationApplicationError(
|
|
802
|
+
f"Manual protonation override for {residue} requests {manual_state}, "
|
|
803
|
+
f"but {assignment.source} requires CYX."
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _apply_metal_histidine_constraints(
|
|
808
|
+
metal_sites: list[object],
|
|
809
|
+
*,
|
|
810
|
+
final_by_residue: dict[int, str],
|
|
811
|
+
manual_state_by_residue: dict[int, str],
|
|
812
|
+
) -> list[ProtonationRecord]:
|
|
813
|
+
"""Apply safe protonation constraints from explicit MCPB bonds.
|
|
814
|
+
|
|
815
|
+
A bond through ND1 requires ND1 to be unprotonated (HIE), while a bond
|
|
816
|
+
through NE2 requires NE2 to be unprotonated (HID). Coordination does not
|
|
817
|
+
uniquely determine the protonation of other titratable sidechains, so they
|
|
818
|
+
require a user override instead of inheriting a metal-blind prediction.
|
|
819
|
+
"""
|
|
820
|
+
|
|
821
|
+
required_by_residue: dict[int, tuple[ResidueRecord, str, list[dict[str, object]]]] = {}
|
|
822
|
+
for site in metal_sites:
|
|
823
|
+
if site.config.model != "bonded_mcpb": # type: ignore[attr-defined]
|
|
824
|
+
continue
|
|
825
|
+
for bond in site.bonds: # type: ignore[attr-defined]
|
|
826
|
+
residue = bond.coordinator_residue
|
|
827
|
+
if not is_histidine(residue):
|
|
828
|
+
if is_titratable_residue(residue):
|
|
829
|
+
if id(residue) not in manual_state_by_residue:
|
|
830
|
+
raise ProtonationApplicationError(
|
|
831
|
+
f"Metal-coordinating titratable residue {residue.id.display()}@"
|
|
832
|
+
f"{bond.coordinator.name} requires an explicit protonation override. "
|
|
833
|
+
"Coordination alone does not safely distinguish neutral and ionized "
|
|
834
|
+
"ASP/GLU/CYS/LYS/ARG chemistry."
|
|
835
|
+
)
|
|
836
|
+
elif residue.id.resname == "TYR":
|
|
837
|
+
raise ProtonationApplicationError(
|
|
838
|
+
f"Metal coordination through {residue.id.display()}@{bond.coordinator.name} "
|
|
839
|
+
"is unsupported because mdprep does not currently expose neutral/phenolate "
|
|
840
|
+
"TYR protonation states."
|
|
841
|
+
)
|
|
842
|
+
continue
|
|
843
|
+
donor = bond.coordinator.name
|
|
844
|
+
if donor == "ND1":
|
|
845
|
+
required = "HIE"
|
|
846
|
+
elif donor == "NE2":
|
|
847
|
+
required = "HID"
|
|
848
|
+
else:
|
|
849
|
+
raise ProtonationApplicationError(
|
|
850
|
+
f"Histidine metal coordinator {residue.id.display()}@{donor} is not ND1 or NE2; "
|
|
851
|
+
"add a manual protonation override and correct the declared coordination atom."
|
|
852
|
+
)
|
|
853
|
+
metadata = {
|
|
854
|
+
"metal_site_id": site.config.id, # type: ignore[attr-defined]
|
|
855
|
+
"metal_atom": bond.ion.atom.atom_identity,
|
|
856
|
+
"coordinator_atom": bond.coordinator.atom_identity,
|
|
857
|
+
"distance_angstrom": bond.distance_angstrom,
|
|
858
|
+
}
|
|
859
|
+
existing = required_by_residue.get(id(residue))
|
|
860
|
+
if existing is not None and existing[1] != required:
|
|
861
|
+
raise ProtonationApplicationError(
|
|
862
|
+
f"Metal bonds require incompatible HID and HIE states for {residue.id.display()}. "
|
|
863
|
+
"A histidine cannot coordinate through both neutral imidazole nitrogens."
|
|
864
|
+
)
|
|
865
|
+
if existing is None:
|
|
866
|
+
required_by_residue[id(residue)] = (residue, required, [metadata])
|
|
867
|
+
else:
|
|
868
|
+
existing[2].append(metadata)
|
|
869
|
+
|
|
870
|
+
records: list[ProtonationRecord] = []
|
|
871
|
+
for residue_id, (residue, required, bonds) in required_by_residue.items():
|
|
872
|
+
manual = manual_state_by_residue.get(residue_id)
|
|
873
|
+
if manual is not None:
|
|
874
|
+
if manual != required:
|
|
875
|
+
raise ProtonationApplicationError(
|
|
876
|
+
f"Manual protonation override for {residue.id.display()} requests {manual}, "
|
|
877
|
+
f"but its explicitly declared metal bond requires {required}. Correct either "
|
|
878
|
+
"the override or the coordinating atom; mdprep will not silently replace a manual choice."
|
|
879
|
+
)
|
|
880
|
+
continue
|
|
881
|
+
final_by_residue[residue_id] = required
|
|
882
|
+
donor = "ND1" if required == "HIE" else "NE2"
|
|
883
|
+
records.append(
|
|
884
|
+
_record(
|
|
885
|
+
residue,
|
|
886
|
+
final_resname=required,
|
|
887
|
+
source="metal_coordination",
|
|
888
|
+
reason=(
|
|
889
|
+
f"Explicit metal coordination through {donor} requires that donor to be "
|
|
890
|
+
f"unprotonated; assigned {required}."
|
|
891
|
+
),
|
|
892
|
+
selector=None,
|
|
893
|
+
metadata={"bonds": bonds},
|
|
894
|
+
)
|
|
895
|
+
)
|
|
896
|
+
return records
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _rename_atoms(structure: PdbStructure, final_by_residue: dict[int, str]) -> list[AtomRecord]:
|
|
900
|
+
final_by_key: dict[tuple[str, str, int, str | None], str] = {}
|
|
901
|
+
for residue in structure.residues:
|
|
902
|
+
final = final_by_residue.get(id(residue))
|
|
903
|
+
if final is not None:
|
|
904
|
+
final_by_key[(residue.id.chain_id, residue.id.resname, residue.id.resid, residue.id.icode)] = final
|
|
905
|
+
|
|
906
|
+
renamed: list[AtomRecord] = []
|
|
907
|
+
for atom in structure.atoms:
|
|
908
|
+
final_resname = final_by_key.get(atom.residue_key, atom.resname)
|
|
909
|
+
renamed.append(replace(atom, resname=final_resname))
|
|
910
|
+
return renamed
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
|
|
914
|
+
grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
|
|
915
|
+
for atom in atoms:
|
|
916
|
+
grouped.setdefault(atom.residue_key, []).append(atom)
|
|
917
|
+
residues: list[ResidueRecord] = []
|
|
918
|
+
for index, ((chain_id, resname, resid, icode), residue_atoms) in enumerate(grouped.items()):
|
|
919
|
+
residues.append(
|
|
920
|
+
ResidueRecord(
|
|
921
|
+
id=ResidueId(chain_id=chain_id, resname=resname, resid=resid, icode=icode),
|
|
922
|
+
atoms=residue_atoms,
|
|
923
|
+
record_names={atom.record_name for atom in residue_atoms},
|
|
924
|
+
original_index=index,
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
return residues
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _record(
|
|
931
|
+
residue: ResidueRecord,
|
|
932
|
+
*,
|
|
933
|
+
final_resname: str,
|
|
934
|
+
source: str,
|
|
935
|
+
reason: str,
|
|
936
|
+
selector: dict[str, object] | None,
|
|
937
|
+
pka: float | None = None,
|
|
938
|
+
ph: float | None = None,
|
|
939
|
+
metadata: dict[str, object] | None = None,
|
|
940
|
+
) -> ProtonationRecord:
|
|
941
|
+
return ProtonationRecord(
|
|
942
|
+
chain=residue.id.chain_id,
|
|
943
|
+
resid=residue.id.resid,
|
|
944
|
+
icode=residue.id.icode,
|
|
945
|
+
original_resname=residue.id.resname,
|
|
946
|
+
final_resname=final_resname,
|
|
947
|
+
source=source,
|
|
948
|
+
reason=reason,
|
|
949
|
+
selector=selector,
|
|
950
|
+
pka=pka,
|
|
951
|
+
ph=ph,
|
|
952
|
+
metadata={} if metadata is None else metadata,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _residue_dict(residue: ResidueRecord) -> dict[str, object]:
|
|
957
|
+
return {
|
|
958
|
+
**residue.id.to_dict(),
|
|
959
|
+
"atom_count": len(residue.atoms),
|
|
960
|
+
"record_names": sorted(residue.record_names),
|
|
961
|
+
"original_index": residue.original_index,
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _histidine_label(residue: ResidueRecord) -> str:
|
|
966
|
+
chain = residue.id.chain_id if residue.id.chain_id else "<blank>"
|
|
967
|
+
icode = residue.id.icode or ""
|
|
968
|
+
return f"{chain}:{residue.id.resname}{residue.id.resid}{icode}"
|