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,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}"