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,763 @@
1
+ """Preparation workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from mdprep import __version__
16
+ from mdprep.config.loader import load_manifest
17
+ from mdprep.external.runner import run_command
18
+ from mdprep.leap.builder import TLeapBuildError, TLeapStageResult, run_tleap_stage
19
+ from mdprep.leap.report import write_tleap_reports
20
+ from mdprep.ligands.report import write_ligand_reports
21
+ from mdprep.ligands.workflow import LigandStageResult, LigandWorkflowError, run_ligand_stage
22
+ from mdprep.metals.report import write_metal_reports
23
+ from mdprep.metals.workflow import (
24
+ MetalStageResult,
25
+ MetalWorkflowError,
26
+ promote_mcpb_resp_ligands,
27
+ run_metal_stage,
28
+ )
29
+ from mdprep.md.roe_brooks import RoeBrooksError, RoeBrooksResult, run_roe_brooks
30
+ from mdprep.protonation.apply import (
31
+ ProtonationApplicationError,
32
+ ProtonationResult,
33
+ apply_protonation_stage,
34
+ reuse_protonation_assignments_after_refinement,
35
+ )
36
+ from mdprep.protonation.report import write_protonation_reports
37
+ from mdprep.refinement.ash import AshRefinementError, capture_ash_refinement_cache
38
+ from mdprep.refinement.report import write_refinement_reports
39
+ from mdprep.refinement.workflow import (
40
+ RefinementResult,
41
+ RefinementWorkflowError,
42
+ run_refinement_stage,
43
+ )
44
+ from mdprep.reports.structure_report import write_structure_reports
45
+ from mdprep.structure.normalize import StructureNormalizationResult, normalize_structure_stage
46
+ from mdprep.structure.writer import write_pdb
47
+ from mdprep.validation.openmm_check import openmm_version
48
+ from mdprep.validation.parmed_check import parmed_version
49
+ from mdprep.qm.pyscf_runner import pyscf_version
50
+ from mdprep.validation.topology import FinalValidationError, validate_final_outputs, write_validation_reports
51
+
52
+
53
+ class PrepareWorkflowError(ValueError):
54
+ """Raised when the requested preparation workflow is not available."""
55
+
56
+
57
+ @dataclass
58
+ class PrepareResult:
59
+ stage: str
60
+ structure_result: StructureNormalizationResult
61
+ protonation_result: ProtonationResult | None = None
62
+ refinement_result: RefinementResult | None = None
63
+ ligand_result: LigandStageResult | None = None
64
+ metal_result: MetalStageResult | None = None
65
+ tleap_result: TLeapStageResult | None = None
66
+ md_result: RoeBrooksResult | None = None
67
+
68
+ @property
69
+ def output_path(self) -> Path | None:
70
+ if self.md_result is not None:
71
+ return self.md_result.final_pdb
72
+ if self.tleap_result is not None:
73
+ return self.tleap_result.final_outputs.pdb
74
+ if self.metal_result is not None:
75
+ if self.metal_result.mcpb_site is not None:
76
+ site = self.metal_result.mcpb_site
77
+ return site.restored_final_pdb_path or site.mcpb_input_pdb_path
78
+ if self.metal_result.structure is not None:
79
+ return self.metal_result.structure.path
80
+ if self.protonation_result is not None:
81
+ return self.protonation_result.output_protonation_pdb_path
82
+ return self.structure_result.output_path
83
+
84
+
85
+ def prepare_system(
86
+ manifest_path: str | Path,
87
+ *,
88
+ stop_after: str | None = None,
89
+ overwrite: bool = False,
90
+ resume: bool = False,
91
+ ) -> PrepareResult:
92
+ manifest_file = Path(manifest_path)
93
+ manifest = load_manifest(manifest_file, resolve_paths=True)
94
+ if stop_after is None:
95
+ stop_after = "md" if manifest.molecular_dynamics.enabled else "tleap"
96
+ if stop_after not in {
97
+ "structure",
98
+ "protonation",
99
+ "refinement",
100
+ "ligands",
101
+ "metals",
102
+ "tleap",
103
+ "md",
104
+ }:
105
+ raise PrepareWorkflowError(
106
+ "Unsupported stop stage; use --stop-after structure, --stop-after protonation, "
107
+ "--stop-after refinement, --stop-after ligands, --stop-after metals, or "
108
+ "--stop-after tleap, or --stop-after md."
109
+ )
110
+ if stop_after == "md" and not manifest.molecular_dynamics.enabled:
111
+ raise PrepareWorkflowError(
112
+ "--stop-after md requires molecular_dynamics.enabled: true in the manifest"
113
+ )
114
+ if stop_after == "refinement" and not manifest.refinement.enabled:
115
+ raise PrepareWorkflowError(
116
+ "--stop-after refinement requires refinement.enabled: true in the manifest"
117
+ )
118
+ output_dir = Path(manifest.project.output_dir)
119
+ if overwrite and resume:
120
+ raise PrepareWorkflowError("--overwrite and --resume are mutually exclusive")
121
+ if resume and not output_dir.exists():
122
+ raise FileNotFoundError(
123
+ f"Cannot resume because the output directory does not exist: {output_dir}"
124
+ )
125
+ if output_dir.exists() and not overwrite and not resume:
126
+ raise FileExistsError(f"Output directory already exists: {output_dir}. Use --overwrite to replace mdprep outputs.")
127
+ cached_ash_run = None
128
+ if resume and manifest.refinement.enabled and stop_after in {
129
+ "refinement",
130
+ "ligands",
131
+ "metals",
132
+ "tleap",
133
+ "md",
134
+ }:
135
+ try:
136
+ cached_ash_run = capture_ash_refinement_cache(
137
+ output_dir / "refinement" / "ash"
138
+ )
139
+ except AshRefinementError as exc:
140
+ raise PrepareWorkflowError(str(exc)) from exc
141
+
142
+ intermediate_dir = output_dir / "intermediate"
143
+ reports_dir = output_dir / "reports"
144
+ intermediate_dir.mkdir(parents=True, exist_ok=True)
145
+ reports_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ manifest_input_path = output_dir / "manifest.input.yaml"
148
+ shutil.copyfile(manifest_file, manifest_input_path)
149
+
150
+ normalized_pdb = intermediate_dir / "00_input_normalized.pdb"
151
+ result = normalize_structure_stage(manifest, output_path=normalized_pdb)
152
+ write_pdb(result.normalized_structure, normalized_pdb)
153
+ result.output_path = normalized_pdb
154
+
155
+ report = write_structure_reports(
156
+ result,
157
+ json_path=reports_dir / "structure_report.json",
158
+ markdown_path=reports_dir / "structure_report.md",
159
+ )
160
+ _write_manifest_lock(
161
+ manifest=manifest,
162
+ structure_report=report,
163
+ path=output_dir / "manifest.lock.yaml",
164
+ input_manifest_path=manifest_file,
165
+ )
166
+ _write_versions(output_dir / "versions.json")
167
+
168
+ protonation_report = None
169
+ protonation_result = None
170
+ refinement_report = None
171
+ refinement_result = None
172
+ ligand_report = None
173
+ ligand_result = None
174
+ metal_report = None
175
+ metal_result = None
176
+ tleap_report = None
177
+ validation_report = None
178
+ tleap_result = None
179
+ if stop_after in {"protonation", "refinement", "ligands", "metals", "tleap", "md"}:
180
+ protonation_pdb = intermediate_dir / "01_protonation_assigned.pdb"
181
+ protonation_result = apply_protonation_stage(
182
+ result.normalized_structure,
183
+ manifest,
184
+ input_normalized_pdb_path=normalized_pdb,
185
+ output_protonation_pdb_path=protonation_pdb,
186
+ )
187
+ write_pdb(protonation_result.structure, protonation_pdb)
188
+ preliminary_report_name = (
189
+ "protonation_pre_refinement_report"
190
+ if manifest.refinement.enabled
191
+ else "protonation_report"
192
+ )
193
+ protonation_report = write_protonation_reports(
194
+ protonation_result,
195
+ json_path=reports_dir / f"{preliminary_report_name}.json",
196
+ csv_path=reports_dir / f"{preliminary_report_name}.csv",
197
+ markdown_path=reports_dir / f"{preliminary_report_name}.md",
198
+ )
199
+ _write_manifest_lock(
200
+ manifest=manifest,
201
+ structure_report=report,
202
+ path=output_dir / "manifest.lock.yaml",
203
+ input_manifest_path=manifest_file,
204
+ protonation_report=protonation_report,
205
+ )
206
+ _write_versions(
207
+ output_dir / "versions.json",
208
+ external_executables=_external_executables_from_protonation_report(protonation_report),
209
+ )
210
+
211
+ if manifest.refinement.enabled and stop_after in {
212
+ "refinement",
213
+ "ligands",
214
+ "metals",
215
+ "tleap",
216
+ "md",
217
+ }:
218
+ assert protonation_result is not None
219
+ refinement_kwargs = {}
220
+ if cached_ash_run is not None:
221
+ refinement_kwargs["cached_ash_run"] = cached_ash_run
222
+ refinement_result = run_refinement_stage(
223
+ protonated_structure=protonation_result.structure,
224
+ reference_structure=result.normalized_structure,
225
+ manifest=manifest,
226
+ output_dir=output_dir,
227
+ protonation_result=protonation_result,
228
+ **refinement_kwargs,
229
+ )
230
+ refinement_report = write_refinement_reports(
231
+ refinement_result,
232
+ json_path=reports_dir / "refinement_report.json",
233
+ markdown_path=reports_dir / "refinement_report.md",
234
+ )
235
+ final_protonation_pdb = intermediate_dir / "03_final_protonation_assigned.pdb"
236
+ if manifest.refinement.post_refinement_protonation == "reuse_initial":
237
+ protonation_result = reuse_protonation_assignments_after_refinement(
238
+ refinement_result.structure,
239
+ protonation_result,
240
+ input_refined_pdb_path=(
241
+ refinement_result.refined_hydrogenated_pdb_path
242
+ ),
243
+ output_protonation_pdb_path=final_protonation_pdb,
244
+ )
245
+ else:
246
+ protonation_result = apply_protonation_stage(
247
+ refinement_result.structure,
248
+ manifest,
249
+ input_normalized_pdb_path=(
250
+ refinement_result.refined_hydrogenated_pdb_path
251
+ ),
252
+ output_protonation_pdb_path=final_protonation_pdb,
253
+ )
254
+ write_pdb(protonation_result.structure, final_protonation_pdb)
255
+ protonation_report = write_protonation_reports(
256
+ protonation_result,
257
+ json_path=reports_dir / "protonation_report.json",
258
+ csv_path=reports_dir / "protonation_report.csv",
259
+ markdown_path=reports_dir / "protonation_report.md",
260
+ )
261
+ _write_manifest_lock(
262
+ manifest=manifest,
263
+ structure_report=report,
264
+ path=output_dir / "manifest.lock.yaml",
265
+ input_manifest_path=manifest_file,
266
+ protonation_report=protonation_report,
267
+ refinement_report=refinement_report,
268
+ )
269
+ external_versions = _external_executables_from_protonation_report(
270
+ protonation_report
271
+ )
272
+ external_versions.update(
273
+ _external_executables_from_refinement_report(refinement_report)
274
+ )
275
+ _write_versions(
276
+ output_dir / "versions.json",
277
+ external_executables=external_versions,
278
+ include_optional_python_packages=True,
279
+ )
280
+
281
+ if stop_after in {"ligands", "metals", "tleap", "md"}:
282
+ assert protonation_result is not None
283
+ ligand_result = run_ligand_stage(
284
+ protonation_result.structure,
285
+ manifest,
286
+ output_dir=output_dir,
287
+ protonation_result=protonation_result,
288
+ )
289
+ ligand_report = write_ligand_reports(
290
+ ligand_result,
291
+ json_path=reports_dir / "ligand_report.json",
292
+ csv_path=reports_dir / "ligand_report.csv",
293
+ markdown_path=reports_dir / "ligand_report.md",
294
+ )
295
+ _write_manifest_lock(
296
+ manifest=manifest,
297
+ structure_report=report,
298
+ path=output_dir / "manifest.lock.yaml",
299
+ input_manifest_path=manifest_file,
300
+ protonation_report=protonation_report,
301
+ refinement_report=refinement_report,
302
+ ligand_report=ligand_report,
303
+ )
304
+ external_versions = {}
305
+ if protonation_report is not None:
306
+ external_versions.update(_external_executables_from_protonation_report(protonation_report))
307
+ if refinement_report is not None:
308
+ external_versions.update(
309
+ _external_executables_from_refinement_report(refinement_report)
310
+ )
311
+ external_versions.update(_external_executables_from_ligand_report(ligand_report))
312
+ _write_versions(
313
+ output_dir / "versions.json",
314
+ external_executables=external_versions,
315
+ include_optional_python_packages=True,
316
+ )
317
+
318
+ if stop_after in {"metals", "tleap", "md"}:
319
+ assert protonation_result is not None
320
+ assert ligand_result is not None
321
+ metal_result = run_metal_stage(
322
+ protonation_result.structure,
323
+ manifest,
324
+ output_dir=output_dir,
325
+ ligand_result=ligand_result,
326
+ protonation_result=protonation_result,
327
+ refinement_result=refinement_result,
328
+ )
329
+ ligand_result = promote_mcpb_resp_ligands(ligand_result, metal_result)
330
+ ligand_report = write_ligand_reports(
331
+ ligand_result,
332
+ json_path=reports_dir / "ligand_report.json",
333
+ csv_path=reports_dir / "ligand_report.csv",
334
+ markdown_path=reports_dir / "ligand_report.md",
335
+ )
336
+ metal_report = write_metal_reports(
337
+ metal_result,
338
+ json_path=reports_dir / "metal_report.json",
339
+ markdown_path=reports_dir / "metal_report.md",
340
+ )
341
+ _write_manifest_lock(
342
+ manifest=manifest,
343
+ structure_report=report,
344
+ path=output_dir / "manifest.lock.yaml",
345
+ input_manifest_path=manifest_file,
346
+ protonation_report=protonation_report,
347
+ refinement_report=refinement_report,
348
+ ligand_report=ligand_report,
349
+ metal_report=metal_report,
350
+ )
351
+ external_versions = {}
352
+ if protonation_report is not None:
353
+ external_versions.update(_external_executables_from_protonation_report(protonation_report))
354
+ if refinement_report is not None:
355
+ external_versions.update(
356
+ _external_executables_from_refinement_report(refinement_report)
357
+ )
358
+ if ligand_report is not None:
359
+ external_versions.update(_external_executables_from_ligand_report(ligand_report))
360
+ external_versions.update(_external_executables_from_metal_report(metal_report))
361
+ _write_versions(
362
+ output_dir / "versions.json",
363
+ external_executables=external_versions,
364
+ include_optional_python_packages=True,
365
+ )
366
+ if stop_after in {"tleap", "md"} and not metal_result.complete:
367
+ assert metal_result.mcpb_site is not None
368
+ required = ", ".join(metal_result.mcpb_site.expected_qm_artifacts)
369
+ raise PrepareWorkflowError(
370
+ "Bonded MCPB.py workflow prepared QM inputs but is not complete. Run the generated "
371
+ f"QM jobs, configure metals[].mcpb.workflow: complete and artifacts ({required}), "
372
+ "then rerun before tleap."
373
+ )
374
+
375
+ if stop_after in {"tleap", "md"}:
376
+ assert protonation_result is not None
377
+ assert ligand_result is not None
378
+ assert metal_result is not None
379
+ tleap_result = run_tleap_stage(
380
+ structure=protonation_result.structure,
381
+ manifest=manifest,
382
+ output_dir=output_dir,
383
+ protonation_result=protonation_result,
384
+ ligand_result=ligand_result,
385
+ metal_result=metal_result,
386
+ )
387
+ tleap_report = write_tleap_reports(
388
+ tleap_result,
389
+ json_path=reports_dir / "tleap_report.json",
390
+ markdown_path=reports_dir / "tleap_report.md",
391
+ )
392
+ # Keep the legacy no-metal call shape so downstream integrations that
393
+ # wrap the validation hook do not need to understand metal metadata.
394
+ if manifest.metals:
395
+ validation_report = validate_final_outputs(
396
+ manifest=manifest,
397
+ prmtop=tleap_result.final_outputs.prmtop,
398
+ inpcrd=tleap_result.final_outputs.inpcrd,
399
+ pdb=tleap_result.final_outputs.pdb,
400
+ metal_result=metal_result,
401
+ )
402
+ else:
403
+ validation_report = validate_final_outputs(
404
+ manifest=manifest,
405
+ prmtop=tleap_result.final_outputs.prmtop,
406
+ inpcrd=tleap_result.final_outputs.inpcrd,
407
+ pdb=tleap_result.final_outputs.pdb,
408
+ )
409
+ write_validation_reports(
410
+ validation_report,
411
+ json_path=reports_dir / "validation_report.json",
412
+ markdown_path=reports_dir / "validation_report.md",
413
+ )
414
+ _write_manifest_lock(
415
+ manifest=manifest,
416
+ structure_report=report,
417
+ path=output_dir / "manifest.lock.yaml",
418
+ input_manifest_path=manifest_file,
419
+ protonation_report=protonation_report,
420
+ refinement_report=refinement_report,
421
+ ligand_report=ligand_report,
422
+ metal_report=metal_report,
423
+ tleap_report=tleap_report,
424
+ validation_report=validation_report,
425
+ )
426
+ external_versions = {}
427
+ if protonation_report is not None:
428
+ external_versions.update(_external_executables_from_protonation_report(protonation_report))
429
+ if refinement_report is not None:
430
+ external_versions.update(
431
+ _external_executables_from_refinement_report(refinement_report)
432
+ )
433
+ if ligand_report is not None:
434
+ external_versions.update(_external_executables_from_ligand_report(ligand_report))
435
+ if metal_report is not None:
436
+ external_versions.update(_external_executables_from_metal_report(metal_report))
437
+ external_versions.update(_external_executables_from_tleap_report(tleap_report))
438
+ _write_versions(
439
+ output_dir / "versions.json",
440
+ external_executables=external_versions,
441
+ include_optional_python_packages=True,
442
+ )
443
+
444
+ md_result = None
445
+ if stop_after == "md":
446
+ assert tleap_result is not None
447
+ try:
448
+ md_result = run_roe_brooks(
449
+ prmtop_path=tleap_result.final_outputs.prmtop,
450
+ inpcrd_path=tleap_result.final_outputs.inpcrd,
451
+ output_dir=output_dir / "md" / "roe_brooks_2020",
452
+ config=manifest.molecular_dynamics,
453
+ )
454
+ except RoeBrooksError as exc:
455
+ raise PrepareWorkflowError(str(exc)) from exc
456
+
457
+ return PrepareResult(
458
+ stage=stop_after,
459
+ structure_result=result,
460
+ protonation_result=protonation_result,
461
+ refinement_result=refinement_result,
462
+ ligand_result=ligand_result,
463
+ metal_result=metal_result,
464
+ tleap_result=tleap_result,
465
+ md_result=md_result,
466
+ )
467
+
468
+
469
+ def _write_manifest_lock(
470
+ *,
471
+ manifest: object,
472
+ structure_report: dict[str, object],
473
+ path: Path,
474
+ input_manifest_path: Path,
475
+ protonation_report: dict[str, object] | None = None,
476
+ refinement_report: dict[str, object] | None = None,
477
+ ligand_report: dict[str, object] | None = None,
478
+ metal_report: dict[str, object] | None = None,
479
+ tleap_report: dict[str, object] | None = None,
480
+ validation_report: dict[str, object] | None = None,
481
+ ) -> None:
482
+ manifest_data = manifest.model_dump(mode="json") # type: ignore[attr-defined]
483
+ lock_data = {
484
+ "mdprep_version": __version__,
485
+ "manifest": manifest_data,
486
+ "resolved": {
487
+ "input_manifest_path": str(input_manifest_path),
488
+ "input_structure": manifest_data["project"]["input_structure"],
489
+ "selected_altloc_policy": manifest_data["structure"]["altloc_policy"],
490
+ "keep_crystal_waters": manifest_data["structure"]["keep_crystal_waters"],
491
+ "remove_unknown_heterogens": manifest_data["structure"]["remove_unknown_heterogens"],
492
+ "configured_ligand_selectors": [
493
+ {"id": ligand["id"], "selector": ligand["selector"]}
494
+ for ligand in manifest_data.get("ligands", [])
495
+ ],
496
+ "unknown_heterogens_removed": structure_report["unknown_heterogens_removed"],
497
+ "unknown_heterogens_causing_failure": structure_report["unknown_heterogens_causing_failure"],
498
+ "possible_disulfides": structure_report["possible_disulfides"],
499
+ "structure_repair": structure_report.get("structure_repair"),
500
+ },
501
+ }
502
+ if protonation_report is not None:
503
+ lock_data["resolved"]["protonation"] = {
504
+ "method": protonation_report["method"],
505
+ "ph": protonation_report["ph"],
506
+ "applied_manual_overrides": protonation_report["manual_overrides_applied"],
507
+ "metal_coordination_assignments": protonation_report.get(
508
+ "metal_coordination_assignments_applied", []
509
+ ),
510
+ "applied_disulfide_assignments": protonation_report["disulfide_assignments_applied"],
511
+ "input_state_assignments": protonation_report["input_state_assignments_applied"],
512
+ "propka_assignments": protonation_report["propka_assignments_applied"],
513
+ "xtb_assignments": protonation_report["xtb_assignments_applied"],
514
+ "hydrogens_removed": protonation_report["hydrogen_atoms_removed"],
515
+ "final_protonation_pdb_path": protonation_report["output_protonation_pdb_path"],
516
+ }
517
+ if protonation_report.get("propka") is not None:
518
+ propka = protonation_report["propka"]
519
+ lock_data["resolved"]["protonation"]["propka"] = {
520
+ "executable": propka["executable"],
521
+ "command": propka["command"],
522
+ "output_pka_path": propka["output_pka_path"],
523
+ "parsed_pkas_path": propka["parsed_pkas_path"],
524
+ }
525
+ if protonation_report.get("xtb_histidines"):
526
+ manifest_data = manifest.model_dump(mode="json") # type: ignore[attr-defined]
527
+ lock_data["resolved"]["protonation"]["xtb"] = {
528
+ "executable": protonation_report["xtb_histidines"][0]["executable"],
529
+ "model": manifest_data["protonation"]["histidine"]["xtb"]["model"],
530
+ "mode": manifest_data["protonation"]["histidine"]["xtb"]["mode"],
531
+ "solvent": manifest_data["protonation"]["histidine"]["xtb"]["solvent"],
532
+ "histidine_selections": protonation_report["xtb_histidines"],
533
+ }
534
+ if refinement_report is not None:
535
+ lock_data["resolved"]["refinement"] = refinement_report
536
+ if ligand_report is not None:
537
+ lock_data["resolved"]["ligands"] = [
538
+ {
539
+ "id": ligand["ligand_id"],
540
+ "selector": ligand["selector"],
541
+ "charge_method": ligand["charge_method"],
542
+ "atom_types": ligand["atom_types"],
543
+ "final_mol2_path": ligand["final_mol2_path"],
544
+ "final_frcmod_path": ligand["final_frcmod_path"],
545
+ "antechamber": ligand["antechamber"],
546
+ "parmchk2": ligand["parmchk2"],
547
+ "provisional_mol2_path": ligand.get("provisional_mol2_path"),
548
+ "qm": ligand.get("qm"),
549
+ }
550
+ for ligand in ligand_report["ligands"]
551
+ ]
552
+ lock_data["resolved"]["qmmesp_provisional_ligands"] = ligand_report.get(
553
+ "qmmesp_provisional_ligands", []
554
+ )
555
+ lock_data["resolved"]["qmmesp_provisional_system"] = ligand_report.get(
556
+ "qmmesp_provisional_system"
557
+ )
558
+ if metal_report is not None:
559
+ lock_data["resolved"]["metals"] = metal_report
560
+ if tleap_report is not None:
561
+ lock_data["resolved"]["tleap"] = {
562
+ "force_fields_sourced": tleap_report["force_fields_sourced"],
563
+ "water_box": tleap_report["water_box"],
564
+ "ligands": tleap_report["ligands"],
565
+ "mcpb_resp_ligands": tleap_report.get("mcpb_resp_ligands", []),
566
+ "disulfide_bond_commands": tleap_report["disulfide_bond_commands"],
567
+ "solvation_enabled": tleap_report["solvation_enabled"],
568
+ "solvation_box_type": tleap_report["solvation_box_type"],
569
+ "buffer_angstrom": tleap_report["buffer_angstrom"],
570
+ "neutralization_requested": tleap_report["neutralization_requested"],
571
+ "neutralizing_ions_added": tleap_report["neutralizing_ions_added"],
572
+ "salt_concentration_molar": tleap_report["salt_concentration_molar"],
573
+ "salt_ion_pairs_requested": tleap_report["salt_ion_pairs_requested"],
574
+ "c4_postprocessing": tleap_report.get("c4_postprocessing", []),
575
+ "final_outputs": tleap_report["final_outputs"],
576
+ }
577
+ if validation_report is not None:
578
+ lock_data["resolved"]["validation"] = {
579
+ "final_prmtop_path": validation_report["final_prmtop_path"],
580
+ "final_inpcrd_path": validation_report["final_inpcrd_path"],
581
+ "final_pdb_path": validation_report["final_pdb_path"],
582
+ "warnings": validation_report["warnings"],
583
+ "errors": validation_report["errors"],
584
+ }
585
+ path.write_text(yaml.safe_dump(lock_data, sort_keys=False), encoding="utf-8")
586
+
587
+
588
+ def _write_versions(
589
+ path: Path,
590
+ *,
591
+ external_executables: dict[str, str] | None = None,
592
+ include_optional_python_packages: bool = False,
593
+ ) -> None:
594
+ versions = {
595
+ "mdprep": __version__,
596
+ "python": sys.version,
597
+ "platform": platform.platform(),
598
+ "external_tools_required": [],
599
+ "external_versions": {
600
+ name: _external_version(executable)
601
+ for name, executable in (external_executables or {}).items()
602
+ },
603
+ }
604
+ if include_optional_python_packages:
605
+ versions["python_packages"] = {
606
+ "parmed": parmed_version(),
607
+ "openmm": openmm_version(),
608
+ "pyscf": pyscf_version(),
609
+ }
610
+ path.write_text(json.dumps(versions, indent=2, sort_keys=True) + "\n", encoding="utf-8")
611
+
612
+
613
+ def _external_executables_from_protonation_report(
614
+ protonation_report: dict[str, object],
615
+ ) -> dict[str, str]:
616
+ executables: dict[str, str] = {}
617
+ propka = protonation_report.get("propka")
618
+ if isinstance(propka, dict) and isinstance(propka.get("executable"), str):
619
+ executables["propka"] = propka["executable"]
620
+ xtb_histidines = protonation_report.get("xtb_histidines")
621
+ if isinstance(xtb_histidines, list) and xtb_histidines:
622
+ first = xtb_histidines[0]
623
+ if isinstance(first, dict) and isinstance(first.get("executable"), str):
624
+ executables["xtb"] = first["executable"]
625
+ return executables
626
+
627
+
628
+ def _external_executables_from_refinement_report(
629
+ refinement_report: dict[str, object],
630
+ ) -> dict[str, str]:
631
+ executables: dict[str, str] = {}
632
+ ash = refinement_report.get("ash")
633
+ if isinstance(ash, dict):
634
+ python_executable = ash.get("python_executable")
635
+ xtb_executable = ash.get("xtb_executable")
636
+ if isinstance(python_executable, str):
637
+ executables["ash_python"] = python_executable
638
+ if isinstance(xtb_executable, str):
639
+ executables["xtb"] = xtb_executable
640
+ provisional_ligands = refinement_report.get("provisional_ligands")
641
+ if isinstance(provisional_ligands, list):
642
+ ligand_shape = {
643
+ "ligands": provisional_ligands,
644
+ "qmmesp_provisional_ligands": [],
645
+ "qmmesp_provisional_system": refinement_report.get("provisional_system"),
646
+ }
647
+ executables.update(_external_executables_from_ligand_report(ligand_shape))
648
+ return executables
649
+
650
+
651
+ def _external_executables_from_ligand_report(
652
+ ligand_report: dict[str, object],
653
+ ) -> dict[str, str]:
654
+ executables: dict[str, str] = {}
655
+ ligand_groups = [ligand_report.get("ligands"), ligand_report.get("qmmesp_provisional_ligands")]
656
+ for ligands in ligand_groups:
657
+ if not isinstance(ligands, list):
658
+ continue
659
+ for ligand in ligands:
660
+ if not isinstance(ligand, dict):
661
+ continue
662
+ antechamber = ligand.get("antechamber")
663
+ if isinstance(antechamber, dict) and isinstance(antechamber.get("command"), list):
664
+ command = antechamber["command"]
665
+ if command:
666
+ executables["antechamber"] = str(command[0])
667
+ parmchk2 = ligand.get("parmchk2")
668
+ if isinstance(parmchk2, dict) and isinstance(parmchk2.get("command"), list):
669
+ command = parmchk2["command"]
670
+ if command:
671
+ executables["parmchk2"] = str(command[0])
672
+ qm = ligand.get("qm")
673
+ fit_result = qm.get("fit_result") if isinstance(qm, dict) else None
674
+ commands = fit_result.get("commands") if isinstance(fit_result, dict) else None
675
+ if isinstance(commands, list):
676
+ for record in commands:
677
+ if not isinstance(record, dict) or not isinstance(record.get("command"), list):
678
+ continue
679
+ command = record["command"]
680
+ if command:
681
+ executables[Path(str(command[0])).name] = str(command[0])
682
+ provisional_system = ligand_report.get("qmmesp_provisional_system")
683
+ if isinstance(provisional_system, dict):
684
+ tleap = provisional_system.get("tleap")
685
+ if isinstance(tleap, dict) and isinstance(tleap.get("command"), list):
686
+ command = tleap["command"]
687
+ if command:
688
+ executables["tleap"] = str(command[0])
689
+ return executables
690
+
691
+
692
+ def _external_executables_from_tleap_report(
693
+ tleap_report: dict[str, object],
694
+ ) -> dict[str, str]:
695
+ executables: dict[str, str] = {}
696
+ dry = tleap_report.get("dry_tleap")
697
+ if isinstance(dry, dict) and isinstance(dry.get("command"), list):
698
+ command = dry["command"]
699
+ if command:
700
+ executables["tleap"] = str(command[0])
701
+ c4_runs = tleap_report.get("c4_postprocessing")
702
+ if isinstance(c4_runs, list):
703
+ for run in c4_runs:
704
+ if not isinstance(run, dict) or not isinstance(run.get("command"), list):
705
+ continue
706
+ command = run["command"]
707
+ if command:
708
+ executables["parmed"] = str(command[0])
709
+ return executables
710
+
711
+
712
+ def _external_executables_from_metal_report(
713
+ metal_report: dict[str, object],
714
+ ) -> dict[str, str]:
715
+ executables: dict[str, str] = {}
716
+ bonded = metal_report.get("bonded_mcpb_site")
717
+ if isinstance(bonded, dict):
718
+ runs = bonded.get("runs")
719
+ if isinstance(runs, list):
720
+ for run in runs:
721
+ if not isinstance(run, dict) or not isinstance(run.get("command"), list):
722
+ continue
723
+ command = run["command"]
724
+ if command:
725
+ executables["MCPB.py"] = str(command[0])
726
+ qm_result = bonded.get("pyscf")
727
+ if isinstance(qm_result, dict):
728
+ backend = qm_result.get("small_model_hessian_backend")
729
+ small_model = qm_result.get("small_model")
730
+ if isinstance(small_model, dict):
731
+ external_commands = small_model.get("external_commands")
732
+ if isinstance(external_commands, list):
733
+ for record in external_commands:
734
+ if not isinstance(record, dict):
735
+ continue
736
+ command = record.get("command")
737
+ if not isinstance(command, list) or not command:
738
+ continue
739
+ if backend in {"gfn2_xtb", "gxtb"}:
740
+ executables[str(backend)] = str(command[0])
741
+ return executables
742
+
743
+
744
+ def _external_version(executable: str) -> dict[str, object]:
745
+ try:
746
+ result = run_command([executable, "--version"], timeout=5.0)
747
+ except (OSError, subprocess.TimeoutExpired, ValueError):
748
+ return {"executable": executable, "version": "unknown"}
749
+ text = (result.stdout or result.stderr).strip()
750
+ version = _version_line(text)
751
+ return {
752
+ "executable": executable,
753
+ "version": version,
754
+ "returncode": result.returncode,
755
+ }
756
+
757
+
758
+ def _version_line(text: str) -> str:
759
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
760
+ for line in lines:
761
+ if "version" in line.lower():
762
+ return line
763
+ return lines[0] if lines else "unknown"