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,210 @@
1
+ """Transfer optimized Amber coordinates back to stable input residue identities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+
9
+ from mdprep.structure.classify import is_standard_protein_residue
10
+ from mdprep.refinement.mapping import (
11
+ RefinementMappingError,
12
+ compatible_resnames,
13
+ is_virtual_site,
14
+ map_residues_by_coordinates,
15
+ )
16
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
17
+
18
+
19
+ class CoordinateTransferError(ValueError):
20
+ """Raised when optimized coordinates cannot be mapped without guessing."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CoordinateTransferResult:
25
+ structure: PdbStructure
26
+ restored_residue_names: tuple[dict[str, object], ...]
27
+ generated_hydrogen_count: int
28
+ excluded_virtual_site_count: int
29
+
30
+ def to_dict(self) -> dict[str, object]:
31
+ return {
32
+ "restored_residue_names": list(self.restored_residue_names),
33
+ "generated_hydrogen_count": self.generated_hydrogen_count,
34
+ "excluded_virtual_site_count": self.excluded_virtual_site_count,
35
+ "output_atom_count": len(self.structure.atoms),
36
+ }
37
+
38
+
39
+ def transfer_optimized_coordinates(
40
+ *,
41
+ reference_structure: PdbStructure,
42
+ leap_input_structure: PdbStructure,
43
+ topology_structure: PdbStructure,
44
+ coordinates_angstrom: tuple[tuple[float, float, float], ...],
45
+ output_path: str | Path,
46
+ ) -> CoordinateTransferResult:
47
+ """Return a hydrogenated optimized structure with pre-protonation names.
48
+
49
+ tleap-generated hydrogens are retained here. With
50
+ ``post_refinement_protonation: reuse_initial`` they remain the authoritative
51
+ optimized coordinates for subsequent MCPB/charge/final-tleap stages. With
52
+ the legacy ``rerun`` policy, the normal protonation stage may remove them
53
+ before final parameterization.
54
+ """
55
+
56
+ if len(coordinates_angstrom) != len(topology_structure.atoms):
57
+ raise CoordinateTransferError(
58
+ "Optimized coordinate count does not match provisional tleap PDB atom count: "
59
+ f"{len(coordinates_angstrom)} != {len(topology_structure.atoms)}"
60
+ )
61
+ try:
62
+ reference_to_leap = map_residues_by_coordinates(
63
+ reference_structure,
64
+ leap_input_structure,
65
+ )
66
+ leap_to_topology = map_residues_by_coordinates(
67
+ leap_input_structure,
68
+ topology_structure,
69
+ )
70
+ except RefinementMappingError as exc:
71
+ raise CoordinateTransferError(str(exc)) from exc
72
+ leap_pair_by_input_index = {
73
+ pair.input_index: pair for pair in leap_to_topology
74
+ }
75
+
76
+ topology_atom_index = {
77
+ id(atom): index for index, atom in enumerate(topology_structure.atoms)
78
+ }
79
+ new_atoms: list[AtomRecord] = []
80
+ old_to_new_serial: dict[int, int] = {}
81
+ residue_last_serial: dict[int, int] = {}
82
+ restored_names: list[dict[str, object]] = []
83
+ generated_hydrogens = 0
84
+ excluded_virtual_sites = 0
85
+
86
+ for residue_index, reference_pair in enumerate(reference_to_leap):
87
+ reference = reference_pair.input_residue
88
+ leap_input = reference_pair.topology_residue
89
+ topology_pair = leap_pair_by_input_index[reference_pair.topology_index]
90
+ topology = topology_pair.topology_residue
91
+ if not compatible_resnames(reference.id.resname, leap_input.id.resname):
92
+ raise CoordinateTransferError(
93
+ f"Residue {residue_index + 1} changed identity before tleap: "
94
+ f"{reference.id.display()} -> {leap_input.id.display()}"
95
+ )
96
+ if not compatible_resnames(leap_input.id.resname, topology.id.resname):
97
+ raise CoordinateTransferError(
98
+ f"Residue {residue_index + 1} changed identity in tleap: "
99
+ f"{leap_input.id.display()} -> {topology.id.display()}"
100
+ )
101
+ if reference.id.resname != leap_input.id.resname:
102
+ restored_names.append(
103
+ {
104
+ "chain_id": reference.id.chain_id,
105
+ "resid": reference.id.resid,
106
+ "icode": reference.id.icode,
107
+ "optimized_provisional_resname": leap_input.id.resname,
108
+ "restored_pre_protonation_resname": reference.id.resname,
109
+ "reason": "rerun requested protonation protocol after QM/MM refinement",
110
+ }
111
+ )
112
+ reference_by_name = {atom.name: atom for atom in reference.atoms}
113
+ if len(reference_by_name) != len(reference.atoms):
114
+ raise CoordinateTransferError(
115
+ f"Reference residue {reference.id.display()} has duplicate atom names"
116
+ )
117
+ record_name = "ATOM" if is_standard_protein_residue(reference) else next(
118
+ iter(reference.record_names), "HETATM"
119
+ )
120
+ for topology_atom in topology.atoms:
121
+ atom_index = topology_atom_index[id(topology_atom)]
122
+ if is_virtual_site(topology_atom):
123
+ excluded_virtual_sites += 1
124
+ continue
125
+ x, y, z = coordinates_angstrom[atom_index]
126
+ reference_atom = reference_by_name.get(topology_atom.name)
127
+ serial = len(new_atoms) + 1
128
+ if reference_atom is not None:
129
+ atom_record_name = reference_atom.record_name
130
+ occupancy = reference_atom.occupancy
131
+ bfactor = reference_atom.bfactor
132
+ original_line = reference_atom.original_line
133
+ if reference_atom.serial is not None:
134
+ old_to_new_serial[reference_atom.serial] = serial
135
+ else:
136
+ atom_record_name = record_name
137
+ occupancy = 1.0
138
+ bfactor = 0.0
139
+ original_line = ""
140
+ if (topology_atom.element or "").strip().upper() == "H":
141
+ generated_hydrogens += 1
142
+ new_atoms.append(
143
+ replace(
144
+ topology_atom,
145
+ serial=serial,
146
+ resname=reference.id.resname,
147
+ chain_id=reference.id.chain_id,
148
+ resid=reference.id.resid,
149
+ icode=reference.id.icode,
150
+ x=x,
151
+ y=y,
152
+ z=z,
153
+ occupancy=occupancy,
154
+ bfactor=bfactor,
155
+ record_name=atom_record_name, # type: ignore[arg-type]
156
+ original_line=original_line,
157
+ )
158
+ )
159
+ residue_last_serial[residue_index] = serial
160
+
161
+ conect_bonds = {
162
+ (min(old_to_new_serial[left], old_to_new_serial[right]),
163
+ max(old_to_new_serial[left], old_to_new_serial[right]))
164
+ for left, right in reference_structure.conect_bonds
165
+ if left in old_to_new_serial and right in old_to_new_serial
166
+ }
167
+ reference_residue_index_by_serial = {
168
+ atom.serial: index
169
+ for index, residue in enumerate(reference_structure.residues)
170
+ for atom in residue.atoms
171
+ if atom.serial is not None
172
+ }
173
+ ter_after_serials = {
174
+ residue_last_serial[reference_residue_index_by_serial[serial]]
175
+ for serial in reference_structure.ter_after_serials
176
+ if serial in reference_residue_index_by_serial
177
+ and reference_residue_index_by_serial[serial] in residue_last_serial
178
+ }
179
+ output = Path(output_path)
180
+ structure = PdbStructure(
181
+ path=output,
182
+ atoms=new_atoms,
183
+ residues=_build_residues(new_atoms),
184
+ model_count=1,
185
+ used_model=1,
186
+ warnings=list(reference_structure.warnings),
187
+ conect_bonds=conect_bonds,
188
+ ter_after_serials=ter_after_serials,
189
+ )
190
+ return CoordinateTransferResult(
191
+ structure=structure,
192
+ restored_residue_names=tuple(restored_names),
193
+ generated_hydrogen_count=generated_hydrogens,
194
+ excluded_virtual_site_count=excluded_virtual_sites,
195
+ )
196
+
197
+
198
+ def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
199
+ grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
200
+ for atom in atoms:
201
+ grouped.setdefault(atom.residue_key, []).append(atom)
202
+ return [
203
+ ResidueRecord(
204
+ id=ResidueId(chain_id=chain, resname=resname, resid=resid, icode=icode),
205
+ atoms=residue_atoms,
206
+ record_names={atom.record_name for atom in residue_atoms},
207
+ original_index=index,
208
+ )
209
+ for index, ((chain, resname, resid, icode), residue_atoms) in enumerate(grouped.items())
210
+ ]
@@ -0,0 +1,163 @@
1
+ """Coordinate-anchored residue mapping across tleap reordering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from math import sqrt
7
+
8
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
9
+
10
+
11
+ class RefinementMappingError(ValueError):
12
+ """Raised when two structure representations cannot be mapped uniquely."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MappedResidue:
17
+ input_index: int
18
+ topology_index: int
19
+ input_residue: ResidueRecord
20
+ topology_residue: ResidueRecord
21
+ anchor_rmsd_angstrom: float
22
+
23
+
24
+ _PROTONATION_FAMILIES = (
25
+ frozenset({"HIS", "HID", "HIE", "HIP"}),
26
+ frozenset({"ASP", "ASH"}),
27
+ frozenset({"GLU", "GLH"}),
28
+ frozenset({"CYS", "CYM", "CYX"}),
29
+ frozenset({"LYS", "LYN"}),
30
+ frozenset({"HOH", "WAT", "H2O", "TIP3", "OPC"}),
31
+ )
32
+
33
+
34
+ def map_residues_by_coordinates(
35
+ input_structure: PdbStructure,
36
+ topology_structure: PdbStructure,
37
+ *,
38
+ max_anchor_rmsd_angstrom: float = 0.25,
39
+ ambiguity_tolerance_angstrom: float = 1.0e-5,
40
+ ) -> list[MappedResidue]:
41
+ """Map residues after tleap without relying on output residue order.
42
+
43
+ tleap commonly moves loaded ligand templates ahead of waters. Heavy-atom
44
+ coordinates are preserved in the provisional build, so compatible residue
45
+ names plus same-named atom anchors provide a deterministic mapping. This
46
+ also distinguishes hundreds of otherwise identical water residues.
47
+ """
48
+
49
+ if len(input_structure.residues) != len(topology_structure.residues):
50
+ raise RefinementMappingError(
51
+ "Provisional tleap changed the residue count, so its atom ordering cannot be "
52
+ f"mapped safely ({len(input_structure.residues)} input residues, "
53
+ f"{len(topology_structure.residues)} output residues)"
54
+ )
55
+ unused = set(range(len(topology_structure.residues)))
56
+ result: list[MappedResidue] = []
57
+ for input_index, input_residue in enumerate(input_structure.residues):
58
+ candidates: list[tuple[float, int, ResidueRecord]] = []
59
+ for topology_index in unused:
60
+ topology_residue = topology_structure.residues[topology_index]
61
+ if not compatible_resnames(
62
+ input_residue.id.resname,
63
+ topology_residue.id.resname,
64
+ ):
65
+ continue
66
+ score = _anchor_rmsd(input_residue, topology_residue)
67
+ if score is not None and score <= max_anchor_rmsd_angstrom:
68
+ candidates.append((score, topology_index, topology_residue))
69
+ if not candidates:
70
+ raise RefinementMappingError(
71
+ "No coordinate-preserving provisional residue matched "
72
+ f"{input_residue.id.display()} within {max_anchor_rmsd_angstrom:.3f} A"
73
+ )
74
+ candidates.sort(key=lambda item: (item[0], item[1]))
75
+ best_score, topology_index, topology_residue = candidates[0]
76
+ if (
77
+ len(candidates) > 1
78
+ and abs(candidates[1][0] - best_score) <= ambiguity_tolerance_angstrom
79
+ ):
80
+ raise RefinementMappingError(
81
+ "Coordinate mapping is ambiguous for "
82
+ f"{input_residue.id.display()}: provisional residues "
83
+ f"{topology_index + 1} and {candidates[1][1] + 1} have indistinguishable "
84
+ "anchor coordinates"
85
+ )
86
+ unused.remove(topology_index)
87
+ result.append(
88
+ MappedResidue(
89
+ input_index=input_index,
90
+ topology_index=topology_index,
91
+ input_residue=input_residue,
92
+ topology_residue=topology_residue,
93
+ anchor_rmsd_angstrom=best_score,
94
+ )
95
+ )
96
+ if unused:
97
+ raise RefinementMappingError(
98
+ f"{len(unused)} provisional residues remained unmapped after coordinate matching"
99
+ )
100
+ return result
101
+
102
+
103
+ def compatible_resnames(left: str, right: str) -> bool:
104
+ if left == right:
105
+ return True
106
+ return any(left in family and right in family for family in _PROTONATION_FAMILIES)
107
+
108
+
109
+ def is_virtual_site(atom: AtomRecord) -> bool:
110
+ element = (atom.element or "").strip().upper()
111
+ name = atom.name.strip().upper()
112
+ return element in {"EP", "LP", "X"} or name.startswith("EP") or name.startswith("LP")
113
+
114
+
115
+ def _anchor_rmsd(
116
+ input_residue: ResidueRecord,
117
+ topology_residue: ResidueRecord,
118
+ ) -> float | None:
119
+ input_by_name = _unique_physical_atoms(input_residue)
120
+ topology_by_name = _unique_physical_atoms(topology_residue)
121
+ common_heavy = sorted(
122
+ name
123
+ for name in input_by_name.keys() & topology_by_name.keys()
124
+ if not _is_hydrogen(input_by_name[name])
125
+ and not _is_hydrogen(topology_by_name[name])
126
+ )
127
+ names = common_heavy or sorted(input_by_name.keys() & topology_by_name.keys())
128
+ if not names:
129
+ return None
130
+ squared = 0.0
131
+ for name in names:
132
+ left = input_by_name[name]
133
+ right = topology_by_name[name]
134
+ if _element(left) != _element(right):
135
+ return None
136
+ squared += (
137
+ (left.x - right.x) ** 2
138
+ + (left.y - right.y) ** 2
139
+ + (left.z - right.z) ** 2
140
+ )
141
+ return sqrt(squared / len(names))
142
+
143
+
144
+ def _unique_physical_atoms(residue: ResidueRecord) -> dict[str, AtomRecord]:
145
+ result: dict[str, AtomRecord] = {}
146
+ duplicates: set[str] = set()
147
+ for atom in residue.atoms:
148
+ if is_virtual_site(atom):
149
+ continue
150
+ if atom.name in result:
151
+ duplicates.add(atom.name)
152
+ result[atom.name] = atom
153
+ for name in duplicates:
154
+ result.pop(name, None)
155
+ return result
156
+
157
+
158
+ def _element(atom: AtomRecord) -> str:
159
+ return (atom.element or "").strip().upper()
160
+
161
+
162
+ def _is_hydrogen(atom: AtomRecord) -> bool:
163
+ return _element(atom) == "H"
@@ -0,0 +1,66 @@
1
+ """JSON and Markdown reporting for QM/MM refinement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from mdprep.refinement.workflow import RefinementResult
9
+
10
+
11
+ def write_refinement_reports(
12
+ result: RefinementResult,
13
+ *,
14
+ json_path: str | Path,
15
+ markdown_path: str | Path,
16
+ ) -> dict[str, object]:
17
+ report = result.to_report_dict()
18
+ json_output = Path(json_path)
19
+ markdown_output = Path(markdown_path)
20
+ json_output.parent.mkdir(parents=True, exist_ok=True)
21
+ markdown_output.parent.mkdir(parents=True, exist_ok=True)
22
+ json_output.write_text(
23
+ json.dumps(report, indent=2, sort_keys=True) + "\n",
24
+ encoding="utf-8",
25
+ )
26
+ selection = report["selection"]
27
+ assert isinstance(selection, dict)
28
+ ash = report["ash"]
29
+ assert isinstance(ash, dict)
30
+ lines = [
31
+ "# QM/MM refinement report",
32
+ "",
33
+ "- Status: complete",
34
+ "- Backend: ASH",
35
+ f"- QM method: {report['qm_method']}",
36
+ "- MM theory: provisional Amber/OpenMM",
37
+ f"- Embedding: {report['embedding']}",
38
+ "- Provisional ligand/cofactor charges: AM1-BCC",
39
+ f"- QM atoms: {len(selection['qm_atom_indices_zero_based'])}",
40
+ f"- Active atoms: {len(selection['active_atom_indices_zero_based'])}",
41
+ f"- Movable-atom policy: {selection['movable_atom_policy']}",
42
+ f"- Active waters: {report['active_water_residue_count']}",
43
+ f"- Non-water active-region cutoff (Å): {selection['active_region_cutoff_angstrom']}",
44
+ f"- Active-water cutoff (Å): {selection['active_water_cutoff_angstrom']}",
45
+ f"- Total QM charge: {selection['total_qm_charge']}",
46
+ f"- Total QM multiplicity: {selection['total_qm_multiplicity']}",
47
+ f"- Final energy (Eh): {ash['final_energy_hartree']}",
48
+ f"- Refined hydrogenated PDB: `{report['refined_hydrogenated_pdb_path']}`",
49
+ "",
50
+ "## QM charge derivation",
51
+ "",
52
+ ]
53
+ for component in selection["charge_components"]:
54
+ lines.append(
55
+ f"- {component['label']}: {component['charge']:+d} ({component['kind']})"
56
+ )
57
+ lines.extend(["", "## External command", ""])
58
+ lines.append(f"- Command: `{' '.join(ash['command'])}`")
59
+ lines.append(f"- Working directory: `{ash['cwd']}`")
60
+ lines.append(f"- Return code: {ash['returncode']}")
61
+ lines.append(f"- Runtime (s): {ash['runtime_seconds']}")
62
+ lines.extend(["", "## Notes", ""])
63
+ for warning in report["warnings"]:
64
+ lines.append(f"- {warning}")
65
+ markdown_output.write_text("\n".join(lines) + "\n", encoding="utf-8")
66
+ return report