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,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()
@@ -0,0 +1,8 @@
1
+ """Report writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mdprep.reports.structure_report import write_structure_reports
6
+
7
+ __all__ = ["write_structure_reports"]
8
+