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,436 @@
|
|
|
1
|
+
"""Deterministic QM and active-region selection for QM/MM refinement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import dist
|
|
7
|
+
|
|
8
|
+
from mdprep.config.models import ManifestConfig, ResidueSelector
|
|
9
|
+
from mdprep.metals.coordination import MetalCoordinationError, resolve_metal_sites
|
|
10
|
+
from mdprep.refinement.mapping import (
|
|
11
|
+
MappedResidue,
|
|
12
|
+
RefinementMappingError,
|
|
13
|
+
is_virtual_site,
|
|
14
|
+
map_residues_by_coordinates,
|
|
15
|
+
)
|
|
16
|
+
from mdprep.structure.classify import (
|
|
17
|
+
is_nonstandard_nonwater_residue,
|
|
18
|
+
is_standard_protein_residue,
|
|
19
|
+
is_water_residue,
|
|
20
|
+
)
|
|
21
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
|
|
22
|
+
from mdprep.structure.selectors import SelectorError, resolve_residue_selector
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RefinementSelectionError(ValueError):
|
|
26
|
+
"""Raised when the requested QM/MM regions cannot be mapped unambiguously."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ChargeComponent:
|
|
31
|
+
label: str
|
|
32
|
+
kind: str
|
|
33
|
+
charge: int
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, object]:
|
|
36
|
+
return {"label": self.label, "kind": self.kind, "charge": self.charge}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class RefinementSelection:
|
|
41
|
+
qm_atom_indices: tuple[int, ...]
|
|
42
|
+
active_atom_indices: tuple[int, ...]
|
|
43
|
+
qm_residue_indices: tuple[int, ...]
|
|
44
|
+
active_residue_indices: tuple[int, ...]
|
|
45
|
+
qm_residues: tuple[dict[str, object], ...]
|
|
46
|
+
active_residues: tuple[dict[str, object], ...]
|
|
47
|
+
charge_components: tuple[ChargeComponent, ...]
|
|
48
|
+
total_qm_charge: int
|
|
49
|
+
total_qm_multiplicity: int
|
|
50
|
+
topology_atom_count: int
|
|
51
|
+
excluded_virtual_site_indices: tuple[int, ...]
|
|
52
|
+
qm_boundary_excluded_atom_indices: tuple[int, ...] = ()
|
|
53
|
+
active_region_cutoff_angstrom: float = 4.0
|
|
54
|
+
active_water_cutoff_angstrom: float = 8.0
|
|
55
|
+
movable_atom_policy: str = "all_active_region"
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, object]:
|
|
58
|
+
return {
|
|
59
|
+
"qm_atom_indices_zero_based": list(self.qm_atom_indices),
|
|
60
|
+
"active_atom_indices_zero_based": list(self.active_atom_indices),
|
|
61
|
+
"qm_residue_indices_zero_based": list(self.qm_residue_indices),
|
|
62
|
+
"active_residue_indices_zero_based": list(self.active_residue_indices),
|
|
63
|
+
"qm_residues": list(self.qm_residues),
|
|
64
|
+
"active_residues": list(self.active_residues),
|
|
65
|
+
"charge_components": [item.to_dict() for item in self.charge_components],
|
|
66
|
+
"total_qm_charge": self.total_qm_charge,
|
|
67
|
+
"total_qm_multiplicity": self.total_qm_multiplicity,
|
|
68
|
+
"topology_atom_count": self.topology_atom_count,
|
|
69
|
+
"excluded_virtual_site_indices_zero_based": list(
|
|
70
|
+
self.excluded_virtual_site_indices
|
|
71
|
+
),
|
|
72
|
+
"qm_boundary_excluded_atom_indices_zero_based": list(
|
|
73
|
+
self.qm_boundary_excluded_atom_indices
|
|
74
|
+
),
|
|
75
|
+
"active_region_cutoff_angstrom": self.active_region_cutoff_angstrom,
|
|
76
|
+
"active_water_cutoff_angstrom": self.active_water_cutoff_angstrom,
|
|
77
|
+
"movable_atom_policy": self.movable_atom_policy,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_PROTONATION_FAMILIES = (
|
|
82
|
+
frozenset({"HIS", "HID", "HIE", "HIP"}),
|
|
83
|
+
frozenset({"ASP", "ASH"}),
|
|
84
|
+
frozenset({"GLU", "GLH"}),
|
|
85
|
+
frozenset({"CYS", "CYM", "CYX"}),
|
|
86
|
+
frozenset({"LYS", "LYN"}),
|
|
87
|
+
frozenset({"HOH", "WAT", "H2O", "TIP3", "OPC"}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_SIDECHAIN_FORMAL_CHARGES = {
|
|
91
|
+
"ARG": 1,
|
|
92
|
+
"ASP": -1,
|
|
93
|
+
"ASH": 0,
|
|
94
|
+
"GLU": -1,
|
|
95
|
+
"GLH": 0,
|
|
96
|
+
"HIS": 0,
|
|
97
|
+
"HID": 0,
|
|
98
|
+
"HIE": 0,
|
|
99
|
+
"HIP": 1,
|
|
100
|
+
"LYS": 1,
|
|
101
|
+
"LYN": 0,
|
|
102
|
+
"CYS": 0,
|
|
103
|
+
"CYM": -1,
|
|
104
|
+
"CYX": 0,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def select_refinement_regions(
|
|
109
|
+
*,
|
|
110
|
+
leap_input_structure: PdbStructure,
|
|
111
|
+
topology_structure: PdbStructure,
|
|
112
|
+
manifest: ManifestConfig,
|
|
113
|
+
) -> RefinementSelection:
|
|
114
|
+
"""Map configured components onto the provisional Amber atom ordering."""
|
|
115
|
+
|
|
116
|
+
if not manifest.refinement.enabled:
|
|
117
|
+
raise RefinementSelectionError("Refinement region selection requires refinement.enabled")
|
|
118
|
+
try:
|
|
119
|
+
pairs = map_residues_by_coordinates(
|
|
120
|
+
leap_input_structure,
|
|
121
|
+
topology_structure,
|
|
122
|
+
)
|
|
123
|
+
except RefinementMappingError as exc:
|
|
124
|
+
raise RefinementSelectionError(str(exc)) from exc
|
|
125
|
+
input_index_by_object = {
|
|
126
|
+
id(residue): index
|
|
127
|
+
for index, residue in enumerate(leap_input_structure.residues)
|
|
128
|
+
}
|
|
129
|
+
atom_index_by_object = {id(atom): index for index, atom in enumerate(topology_structure.atoms)}
|
|
130
|
+
|
|
131
|
+
ligand_by_id = {ligand.id: ligand for ligand in manifest.ligands}
|
|
132
|
+
selected_ligand_residues: dict[int, str] = {}
|
|
133
|
+
for ligand_id in manifest.refinement.qm_components.ligands:
|
|
134
|
+
ligand = ligand_by_id[ligand_id]
|
|
135
|
+
try:
|
|
136
|
+
residue = resolve_residue_selector(
|
|
137
|
+
leap_input_structure,
|
|
138
|
+
ligand.selector.model_dump(),
|
|
139
|
+
)
|
|
140
|
+
except SelectorError as exc:
|
|
141
|
+
raise RefinementSelectionError(
|
|
142
|
+
f"Refinement ligand {ligand_id!r} selector failed: {exc}"
|
|
143
|
+
) from exc
|
|
144
|
+
selected_ligand_residues[input_index_by_object[id(residue)]] = ligand_id
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
resolved_metal_sites = resolve_metal_sites(
|
|
148
|
+
leap_input_structure,
|
|
149
|
+
manifest,
|
|
150
|
+
validate_mcpb_cutoff=False,
|
|
151
|
+
)
|
|
152
|
+
except MetalCoordinationError as exc:
|
|
153
|
+
raise RefinementSelectionError(str(exc)) from exc
|
|
154
|
+
resolved_by_id = {site.config.id: site for site in resolved_metal_sites}
|
|
155
|
+
selected_metal_residues: dict[int, tuple[str, int]] = {}
|
|
156
|
+
coordinator_residue_indices: set[int] = set()
|
|
157
|
+
selected_sites = manifest.refinement.qm_components.metal_sites
|
|
158
|
+
configured_coordinators = (
|
|
159
|
+
manifest.refinement.qm_components.metal_coordinating_residues
|
|
160
|
+
)
|
|
161
|
+
for site_id in selected_sites:
|
|
162
|
+
site = resolved_by_id[site_id]
|
|
163
|
+
for ion_index, ion in enumerate(site.ions):
|
|
164
|
+
residue_index = input_index_by_object[id(ion.residue)]
|
|
165
|
+
selected_metal_residues[residue_index] = (site_id, ion_index)
|
|
166
|
+
for bond in site.bonds:
|
|
167
|
+
coordinator_residue_indices.add(
|
|
168
|
+
input_index_by_object[id(bond.coordinator_residue)]
|
|
169
|
+
)
|
|
170
|
+
for selector in configured_coordinators.get(site_id, []):
|
|
171
|
+
residue = _resolve_protonation_aware(leap_input_structure, selector)
|
|
172
|
+
coordinator_residue_indices.add(input_index_by_object[id(residue)])
|
|
173
|
+
|
|
174
|
+
qm_residue_indices = set(selected_ligand_residues)
|
|
175
|
+
qm_residue_indices.update(selected_metal_residues)
|
|
176
|
+
qm_residue_indices.update(coordinator_residue_indices)
|
|
177
|
+
if not qm_residue_indices:
|
|
178
|
+
raise RefinementSelectionError("The configured QM region contains no residues")
|
|
179
|
+
|
|
180
|
+
topology_atom_indices_by_residue: list[list[int]] = []
|
|
181
|
+
virtual_sites: set[int] = set()
|
|
182
|
+
for pair in pairs:
|
|
183
|
+
topology_residue = pair.topology_residue
|
|
184
|
+
indices = [atom_index_by_object[id(atom)] for atom in topology_residue.atoms]
|
|
185
|
+
topology_atom_indices_by_residue.append(indices)
|
|
186
|
+
virtual_sites.update(
|
|
187
|
+
index
|
|
188
|
+
for index in indices
|
|
189
|
+
if is_virtual_site(topology_structure.atoms[index])
|
|
190
|
+
)
|
|
191
|
+
_validate_protonated_waters(pairs)
|
|
192
|
+
|
|
193
|
+
metal_boundary_exclusions: list[int] = []
|
|
194
|
+
for residue_index, (site_id, ion_index) in selected_metal_residues.items():
|
|
195
|
+
ion = resolved_by_id[site_id].ions[ion_index]
|
|
196
|
+
topology_residue = pairs[residue_index].topology_residue
|
|
197
|
+
candidates = [
|
|
198
|
+
atom
|
|
199
|
+
for atom in topology_residue.atoms
|
|
200
|
+
if atom.name == ion.atom.name
|
|
201
|
+
and (atom.element or "").strip().upper() == ion.element.upper()
|
|
202
|
+
]
|
|
203
|
+
if len(candidates) != 1:
|
|
204
|
+
raise RefinementSelectionError(
|
|
205
|
+
"Could not map the explicitly selected metal ion uniquely into the "
|
|
206
|
+
f"provisional topology: {ion.atom.atom_identity}"
|
|
207
|
+
)
|
|
208
|
+
metal_boundary_exclusions.append(atom_index_by_object[id(candidates[0])])
|
|
209
|
+
|
|
210
|
+
qm_atoms = sorted(
|
|
211
|
+
index
|
|
212
|
+
for residue_index in qm_residue_indices
|
|
213
|
+
for index in topology_atom_indices_by_residue[residue_index]
|
|
214
|
+
if index not in virtual_sites
|
|
215
|
+
)
|
|
216
|
+
if not qm_atoms:
|
|
217
|
+
raise RefinementSelectionError("The configured QM region contains no physical atoms")
|
|
218
|
+
|
|
219
|
+
active_residue_indices = set(qm_residue_indices)
|
|
220
|
+
residue_cutoff = manifest.refinement.active_region_cutoff_angstrom
|
|
221
|
+
water_cutoff = manifest.refinement.active_water_cutoff_angstrom
|
|
222
|
+
qm_atom_records = [topology_structure.atoms[index] for index in qm_atoms]
|
|
223
|
+
for residue_index, pair in enumerate(pairs):
|
|
224
|
+
input_residue = pair.input_residue
|
|
225
|
+
topology_residue = pair.topology_residue
|
|
226
|
+
cutoff = water_cutoff if is_water_residue(input_residue) else residue_cutoff
|
|
227
|
+
contacts_qm = any(
|
|
228
|
+
_distance(atom, qm_atom) <= cutoff
|
|
229
|
+
for atom in topology_residue.atoms
|
|
230
|
+
if not is_virtual_site(atom)
|
|
231
|
+
for qm_atom in qm_atom_records
|
|
232
|
+
)
|
|
233
|
+
if (
|
|
234
|
+
contacts_qm
|
|
235
|
+
and residue_index not in qm_residue_indices
|
|
236
|
+
and is_nonstandard_nonwater_residue(input_residue)
|
|
237
|
+
):
|
|
238
|
+
raise RefinementSelectionError(
|
|
239
|
+
"A non-protein, non-water component contacts the configured QM region but "
|
|
240
|
+
"was not explicitly selected. Configure it as a ligand/cofactor with charge "
|
|
241
|
+
"and multiplicity and add its id to refinement.qm_components.ligands, or "
|
|
242
|
+
f"move it outside the active-site model: {input_residue.id.display()}"
|
|
243
|
+
)
|
|
244
|
+
if contacts_qm:
|
|
245
|
+
active_residue_indices.add(residue_index)
|
|
246
|
+
|
|
247
|
+
active_region_atoms = sorted(
|
|
248
|
+
index
|
|
249
|
+
for residue_index in active_residue_indices
|
|
250
|
+
for index in topology_atom_indices_by_residue[residue_index]
|
|
251
|
+
if index not in virtual_sites
|
|
252
|
+
)
|
|
253
|
+
movable_atom_policy = manifest.refinement.movable_atoms
|
|
254
|
+
if movable_atom_policy == "active_region_hydrogens":
|
|
255
|
+
active_atoms = [
|
|
256
|
+
index
|
|
257
|
+
for index in active_region_atoms
|
|
258
|
+
if (topology_structure.atoms[index].element or "").strip().upper() == "H"
|
|
259
|
+
]
|
|
260
|
+
if not active_atoms:
|
|
261
|
+
raise RefinementSelectionError(
|
|
262
|
+
"refinement.movable_atoms selects active-region hydrogens, but the "
|
|
263
|
+
"provisional topology contains no hydrogen atoms in the active region"
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
active_atoms = active_region_atoms
|
|
267
|
+
charge_components = _charge_components(
|
|
268
|
+
pairs=pairs,
|
|
269
|
+
manifest=manifest,
|
|
270
|
+
selected_ligand_residues=selected_ligand_residues,
|
|
271
|
+
selected_metal_residues=selected_metal_residues,
|
|
272
|
+
coordinator_residue_indices=coordinator_residue_indices,
|
|
273
|
+
)
|
|
274
|
+
multiplicity = _total_multiplicity(manifest)
|
|
275
|
+
return RefinementSelection(
|
|
276
|
+
qm_atom_indices=tuple(qm_atoms),
|
|
277
|
+
active_atom_indices=tuple(active_atoms),
|
|
278
|
+
qm_residue_indices=tuple(sorted(qm_residue_indices)),
|
|
279
|
+
active_residue_indices=tuple(sorted(active_residue_indices)),
|
|
280
|
+
qm_residues=tuple(
|
|
281
|
+
_residue_description(pairs[index], index)
|
|
282
|
+
for index in sorted(qm_residue_indices)
|
|
283
|
+
),
|
|
284
|
+
active_residues=tuple(
|
|
285
|
+
_residue_description(pairs[index], index)
|
|
286
|
+
for index in sorted(active_residue_indices)
|
|
287
|
+
),
|
|
288
|
+
charge_components=tuple(charge_components),
|
|
289
|
+
total_qm_charge=sum(item.charge for item in charge_components),
|
|
290
|
+
total_qm_multiplicity=multiplicity,
|
|
291
|
+
topology_atom_count=len(topology_structure.atoms),
|
|
292
|
+
excluded_virtual_site_indices=tuple(sorted(virtual_sites)),
|
|
293
|
+
qm_boundary_excluded_atom_indices=tuple(sorted(metal_boundary_exclusions)),
|
|
294
|
+
active_region_cutoff_angstrom=residue_cutoff,
|
|
295
|
+
active_water_cutoff_angstrom=water_cutoff,
|
|
296
|
+
movable_atom_policy=movable_atom_policy,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _resolve_protonation_aware(
|
|
301
|
+
structure: PdbStructure,
|
|
302
|
+
selector: ResidueSelector,
|
|
303
|
+
) -> ResidueRecord:
|
|
304
|
+
data = selector.model_dump()
|
|
305
|
+
try:
|
|
306
|
+
return resolve_residue_selector(structure, data)
|
|
307
|
+
except SelectorError as original:
|
|
308
|
+
family = next(
|
|
309
|
+
(item for item in _PROTONATION_FAMILIES if selector.resname in item),
|
|
310
|
+
None,
|
|
311
|
+
)
|
|
312
|
+
if family is None:
|
|
313
|
+
raise RefinementSelectionError(str(original)) from original
|
|
314
|
+
relaxed = dict(data)
|
|
315
|
+
relaxed.pop("resname", None)
|
|
316
|
+
try:
|
|
317
|
+
residue = resolve_residue_selector(structure, relaxed)
|
|
318
|
+
except SelectorError:
|
|
319
|
+
raise RefinementSelectionError(str(original)) from original
|
|
320
|
+
if residue.id.resname not in family:
|
|
321
|
+
raise RefinementSelectionError(str(original)) from original
|
|
322
|
+
return residue
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _charge_components(
|
|
326
|
+
*,
|
|
327
|
+
pairs: list[MappedResidue],
|
|
328
|
+
manifest: ManifestConfig,
|
|
329
|
+
selected_ligand_residues: dict[int, str],
|
|
330
|
+
selected_metal_residues: dict[int, tuple[str, int]],
|
|
331
|
+
coordinator_residue_indices: set[int],
|
|
332
|
+
) -> list[ChargeComponent]:
|
|
333
|
+
ligand_by_id = {ligand.id: ligand for ligand in manifest.ligands}
|
|
334
|
+
site_by_id = {site.id: site for site in manifest.metals}
|
|
335
|
+
components: list[ChargeComponent] = []
|
|
336
|
+
for residue_index, ligand_id in sorted(selected_ligand_residues.items()):
|
|
337
|
+
residue = pairs[residue_index].input_residue
|
|
338
|
+
components.append(
|
|
339
|
+
ChargeComponent(
|
|
340
|
+
label=f"ligand:{ligand_id}:{residue.id.display()}",
|
|
341
|
+
kind="ligand",
|
|
342
|
+
charge=ligand_by_id[ligand_id].net_charge,
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
for residue_index, (site_id, ion_index) in sorted(selected_metal_residues.items()):
|
|
346
|
+
residue = pairs[residue_index].input_residue
|
|
347
|
+
ion = site_by_id[site_id].ions[ion_index]
|
|
348
|
+
components.append(
|
|
349
|
+
ChargeComponent(
|
|
350
|
+
label=f"metal:{site_id}:{residue.id.display()}",
|
|
351
|
+
kind="metal",
|
|
352
|
+
charge=ion.charge,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
for residue_index in sorted(coordinator_residue_indices):
|
|
356
|
+
input_residue = pairs[residue_index].input_residue
|
|
357
|
+
topology_residue = pairs[residue_index].topology_residue
|
|
358
|
+
if residue_index in selected_ligand_residues:
|
|
359
|
+
continue
|
|
360
|
+
if residue_index in selected_metal_residues:
|
|
361
|
+
continue
|
|
362
|
+
if not is_standard_protein_residue(input_residue):
|
|
363
|
+
raise RefinementSelectionError(
|
|
364
|
+
"A non-protein metal coordinator must be configured as a ligand/cofactor "
|
|
365
|
+
f"QM component with explicit charge and multiplicity: {input_residue.id.display()}"
|
|
366
|
+
)
|
|
367
|
+
charge = _SIDECHAIN_FORMAL_CHARGES.get(input_residue.id.resname, 0)
|
|
368
|
+
atom_names = set(topology_residue.atom_names())
|
|
369
|
+
if len(atom_names.intersection({"H1", "H2", "H3"})) >= 2:
|
|
370
|
+
charge += 1
|
|
371
|
+
if "OXT" in atom_names:
|
|
372
|
+
charge -= 1
|
|
373
|
+
components.append(
|
|
374
|
+
ChargeComponent(
|
|
375
|
+
label=f"protein:{input_residue.id.display()}",
|
|
376
|
+
kind="protein_residue",
|
|
377
|
+
charge=charge,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return components
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _total_multiplicity(manifest: ManifestConfig) -> int:
|
|
384
|
+
explicit = manifest.refinement.total_qm_multiplicity
|
|
385
|
+
if explicit is not None:
|
|
386
|
+
return explicit
|
|
387
|
+
ligand_by_id = {ligand.id: ligand for ligand in manifest.ligands}
|
|
388
|
+
site_by_id = {site.id: site for site in manifest.metals}
|
|
389
|
+
multiplicities = [
|
|
390
|
+
ligand_by_id[ligand_id].multiplicity
|
|
391
|
+
for ligand_id in manifest.refinement.qm_components.ligands
|
|
392
|
+
]
|
|
393
|
+
multiplicities.extend(
|
|
394
|
+
ion.multiplicity or 1
|
|
395
|
+
for site_id in manifest.refinement.qm_components.metal_sites
|
|
396
|
+
for ion in site_by_id[site_id].ions
|
|
397
|
+
)
|
|
398
|
+
open_shell = [value for value in multiplicities if value > 1]
|
|
399
|
+
return open_shell[0] if open_shell else 1
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _validate_protonated_waters(
|
|
403
|
+
pairs: list[MappedResidue],
|
|
404
|
+
) -> None:
|
|
405
|
+
for pair in pairs:
|
|
406
|
+
input_residue = pair.input_residue
|
|
407
|
+
topology_residue = pair.topology_residue
|
|
408
|
+
if not is_water_residue(input_residue):
|
|
409
|
+
continue
|
|
410
|
+
elements = [(atom.element or "").strip().upper() for atom in topology_residue.atoms]
|
|
411
|
+
if elements.count("O") != 1 or elements.count("H") < 2:
|
|
412
|
+
raise RefinementSelectionError(
|
|
413
|
+
f"Water {input_residue.id.display()} is not protonated in the provisional "
|
|
414
|
+
"Amber topology; QM/MM refinement requires one oxygen and two hydrogens"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _distance(left: AtomRecord, right: AtomRecord) -> float:
|
|
419
|
+
return dist((left.x, left.y, left.z), (right.x, right.y, right.z))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _residue_description(
|
|
423
|
+
pair: MappedResidue,
|
|
424
|
+
index: int,
|
|
425
|
+
) -> dict[str, object]:
|
|
426
|
+
input_residue = pair.input_residue
|
|
427
|
+
topology_residue = pair.topology_residue
|
|
428
|
+
return {
|
|
429
|
+
"residue_index_zero_based": index,
|
|
430
|
+
"topology_residue_index_zero_based": pair.topology_index,
|
|
431
|
+
"mapping_anchor_rmsd_angstrom": pair.anchor_rmsd_angstrom,
|
|
432
|
+
"input_identity": input_residue.id.to_dict(),
|
|
433
|
+
"topology_identity": topology_residue.id.to_dict(),
|
|
434
|
+
"atom_count": len(topology_residue.atoms),
|
|
435
|
+
"water": is_water_residue(input_residue),
|
|
436
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""High-level provisional-build and ASH refinement workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mdprep.config.models import ManifestConfig
|
|
10
|
+
from mdprep.ligands.workflow import (
|
|
11
|
+
LigandStageResult,
|
|
12
|
+
LigandWorkflowError,
|
|
13
|
+
QmmespProvisionalSystem,
|
|
14
|
+
build_provisional_am1bcc_system,
|
|
15
|
+
)
|
|
16
|
+
from mdprep.protonation.apply import ProtonationResult
|
|
17
|
+
from mdprep.refinement.ash import (
|
|
18
|
+
AshRefinementCache,
|
|
19
|
+
AshRefinementError,
|
|
20
|
+
AshRefinementRun,
|
|
21
|
+
run_ash_refinement,
|
|
22
|
+
)
|
|
23
|
+
from mdprep.refinement.coordinates import (
|
|
24
|
+
CoordinateTransferError,
|
|
25
|
+
CoordinateTransferResult,
|
|
26
|
+
transfer_optimized_coordinates,
|
|
27
|
+
)
|
|
28
|
+
from mdprep.refinement.selection import (
|
|
29
|
+
RefinementSelection,
|
|
30
|
+
RefinementSelectionError,
|
|
31
|
+
select_refinement_regions,
|
|
32
|
+
)
|
|
33
|
+
from mdprep.structure.models import PdbStructure
|
|
34
|
+
from mdprep.structure.pdb import PdbParseError, read_pdb
|
|
35
|
+
from mdprep.structure.writer import write_pdb
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RefinementWorkflowError(ValueError):
|
|
39
|
+
"""Raised when a requested QM/MM refinement cannot complete safely."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RefinementResult:
|
|
44
|
+
provisional_ligands: LigandStageResult
|
|
45
|
+
provisional_system: QmmespProvisionalSystem
|
|
46
|
+
selection: RefinementSelection
|
|
47
|
+
ash_run: AshRefinementRun
|
|
48
|
+
coordinate_transfer: CoordinateTransferResult
|
|
49
|
+
refined_hydrogenated_pdb_path: Path
|
|
50
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def structure(self) -> PdbStructure:
|
|
54
|
+
return self.coordinate_transfer.structure
|
|
55
|
+
|
|
56
|
+
def to_report_dict(self) -> dict[str, object]:
|
|
57
|
+
active_waters = sum(
|
|
58
|
+
bool(item["water"]) for item in self.selection.active_residues
|
|
59
|
+
)
|
|
60
|
+
return {
|
|
61
|
+
"status": "complete",
|
|
62
|
+
"backend": "ash",
|
|
63
|
+
"qm_method": self.ash_run.qm_method,
|
|
64
|
+
"embedding": self.ash_run.embedding,
|
|
65
|
+
"provisional_ligand_charge_method": "am1bcc",
|
|
66
|
+
"provisional_ligands": self.provisional_ligands.to_report_dict()["ligands"],
|
|
67
|
+
"provisional_system": self.provisional_system.to_dict(),
|
|
68
|
+
"selection": self.selection.to_dict(),
|
|
69
|
+
"active_water_residue_count": active_waters,
|
|
70
|
+
"ash": self.ash_run.to_dict(),
|
|
71
|
+
"coordinate_transfer": self.coordinate_transfer.to_dict(),
|
|
72
|
+
"refined_hydrogenated_pdb_path": str(self.refined_hydrogenated_pdb_path),
|
|
73
|
+
"warnings": list(self.warnings),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_refinement_stage(
|
|
78
|
+
*,
|
|
79
|
+
protonated_structure: PdbStructure,
|
|
80
|
+
reference_structure: PdbStructure,
|
|
81
|
+
manifest: ManifestConfig,
|
|
82
|
+
output_dir: str | Path,
|
|
83
|
+
protonation_result: ProtonationResult,
|
|
84
|
+
cached_ash_run: AshRefinementCache | None = None,
|
|
85
|
+
) -> RefinementResult:
|
|
86
|
+
"""Build provisional AM1-BCC parameters and refine the configured active site."""
|
|
87
|
+
|
|
88
|
+
if not manifest.refinement.enabled:
|
|
89
|
+
raise RefinementWorkflowError("Refinement stage requested but refinement.enabled is false")
|
|
90
|
+
assert manifest.refinement.ash is not None # validated by ManifestConfig
|
|
91
|
+
output = Path(output_dir)
|
|
92
|
+
_remove_stale_refinement_failure(output)
|
|
93
|
+
refinement_dir = output / "refinement"
|
|
94
|
+
provisional_dir = refinement_dir / "provisional"
|
|
95
|
+
ash_dir = refinement_dir / "ash"
|
|
96
|
+
refined_path = output / "intermediate" / "02_qmmm_refined_hydrogenated.pdb"
|
|
97
|
+
try:
|
|
98
|
+
provisional_ligands, provisional_system = build_provisional_am1bcc_system(
|
|
99
|
+
protonated_structure,
|
|
100
|
+
manifest,
|
|
101
|
+
output_dir=provisional_dir,
|
|
102
|
+
protonation_result=protonation_result,
|
|
103
|
+
)
|
|
104
|
+
leap_input_structure = read_pdb(provisional_system.leap_input_path)
|
|
105
|
+
topology_structure = read_pdb(provisional_system.pdb_path)
|
|
106
|
+
selection = select_refinement_regions(
|
|
107
|
+
leap_input_structure=leap_input_structure,
|
|
108
|
+
topology_structure=topology_structure,
|
|
109
|
+
manifest=manifest,
|
|
110
|
+
)
|
|
111
|
+
ash_run = run_ash_refinement(
|
|
112
|
+
prmtop_path=provisional_system.prmtop_path,
|
|
113
|
+
inpcrd_path=provisional_system.inpcrd_path,
|
|
114
|
+
selection=selection,
|
|
115
|
+
config=manifest.refinement.ash,
|
|
116
|
+
work_dir=ash_dir,
|
|
117
|
+
qm_method=manifest.refinement.qm_method,
|
|
118
|
+
embedding=manifest.refinement.embedding,
|
|
119
|
+
mace_polar1=manifest.refinement.mace_polar1,
|
|
120
|
+
cached_run=cached_ash_run,
|
|
121
|
+
)
|
|
122
|
+
coordinate_transfer = transfer_optimized_coordinates(
|
|
123
|
+
reference_structure=reference_structure,
|
|
124
|
+
leap_input_structure=leap_input_structure,
|
|
125
|
+
topology_structure=topology_structure,
|
|
126
|
+
coordinates_angstrom=ash_run.optimized_coordinates_angstrom,
|
|
127
|
+
output_path=refined_path,
|
|
128
|
+
)
|
|
129
|
+
write_pdb(coordinate_transfer.structure, refined_path)
|
|
130
|
+
except (
|
|
131
|
+
LigandWorkflowError,
|
|
132
|
+
PdbParseError,
|
|
133
|
+
RefinementSelectionError,
|
|
134
|
+
AshRefinementError,
|
|
135
|
+
CoordinateTransferError,
|
|
136
|
+
FileNotFoundError,
|
|
137
|
+
OSError,
|
|
138
|
+
) as exc:
|
|
139
|
+
_write_refinement_failure(output, exc)
|
|
140
|
+
raise RefinementWorkflowError(str(exc)) from exc
|
|
141
|
+
warnings = [
|
|
142
|
+
"The optimized structure is an intermediate model only; final ligand/cofactor "
|
|
143
|
+
"charges and parameters are regenerated with each ligand's requested method.",
|
|
144
|
+
]
|
|
145
|
+
if manifest.refinement.embedding == "mechanical":
|
|
146
|
+
warnings.append(
|
|
147
|
+
"Mechanical embedding does not polarize the QM method with MM point charges; "
|
|
148
|
+
"QM-MM electrostatics and van der Waals interactions are evaluated by OpenMM."
|
|
149
|
+
)
|
|
150
|
+
if manifest.refinement.qm_method == "mace_polar1":
|
|
151
|
+
warnings.append(
|
|
152
|
+
"MACE-POLAR-1 is a machine-learned high-layer potential rather than an "
|
|
153
|
+
"electronic-structure QM calculation; this run is mechanically embedded ML/MM."
|
|
154
|
+
)
|
|
155
|
+
return RefinementResult(
|
|
156
|
+
provisional_ligands=provisional_ligands,
|
|
157
|
+
provisional_system=provisional_system,
|
|
158
|
+
selection=selection,
|
|
159
|
+
ash_run=ash_run,
|
|
160
|
+
coordinate_transfer=coordinate_transfer,
|
|
161
|
+
refined_hydrogenated_pdb_path=refined_path,
|
|
162
|
+
warnings=tuple(warnings),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _write_refinement_failure(output_dir: Path, exc: Exception) -> None:
|
|
167
|
+
path = output_dir / "reports" / "refinement_failure.json"
|
|
168
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
path.write_text(
|
|
170
|
+
json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"status": "failed",
|
|
173
|
+
"exception_type": type(exc).__name__,
|
|
174
|
+
"errors": [str(exc)],
|
|
175
|
+
},
|
|
176
|
+
indent=2,
|
|
177
|
+
sort_keys=True,
|
|
178
|
+
)
|
|
179
|
+
+ "\n",
|
|
180
|
+
encoding="utf-8",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _remove_stale_refinement_failure(output_dir: Path) -> None:
|
|
185
|
+
"""Ensure a successful overwrite cannot retain a failure from an older run."""
|
|
186
|
+
path = output_dir / "reports" / "refinement_failure.json"
|
|
187
|
+
if path.exists():
|
|
188
|
+
path.unlink()
|