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,541 @@
1
+ """Execute the ASH QM/MM refinement in an explicitly selected Python environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from math import isfinite
9
+ from pathlib import Path
10
+
11
+ from mdprep.config.models import AshRefinementConfig, MacePolar1RefinementConfig
12
+ from mdprep.external.discovery import which_executable
13
+ from mdprep.external.runner import CommandResult, run_command
14
+ from mdprep.refinement.selection import RefinementSelection
15
+
16
+
17
+ class AshRefinementError(RuntimeError):
18
+ """Raised when the requested ASH QM/MM refinement cannot complete safely."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AshRefinementCache:
23
+ """Validated inputs and outputs captured before a run is resumed."""
24
+
25
+ input_payload: dict[str, object]
26
+ prmtop_sha256: str
27
+ inpcrd_sha256: str
28
+ output_text: str
29
+ stdout: str
30
+ stderr: str
31
+ command_record: dict[str, object]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AshRefinementRun:
36
+ command_result: CommandResult
37
+ driver_path: Path
38
+ input_path: Path
39
+ output_path: Path
40
+ stdout_path: Path
41
+ stderr_path: Path
42
+ command_record_path: Path
43
+ python_executable: str
44
+ qm_method: str
45
+ embedding: str
46
+ xtb_executable: str | None
47
+ mace_model_path: str | None
48
+ mace_model_sha256: str | None
49
+ optimized_coordinates_angstrom: tuple[tuple[float, float, float], ...]
50
+ final_energy_hartree: float | None
51
+ reused_cached_result: bool = False
52
+
53
+ def to_dict(self) -> dict[str, object]:
54
+ return {
55
+ "command": list(self.command_result.command),
56
+ "cwd": self.command_result.cwd,
57
+ "returncode": self.command_result.returncode,
58
+ "stdout": self.command_result.stdout,
59
+ "stderr": self.command_result.stderr,
60
+ "runtime_seconds": self.command_result.runtime_seconds,
61
+ "driver_path": str(self.driver_path),
62
+ "input_path": str(self.input_path),
63
+ "output_path": str(self.output_path),
64
+ "stdout_path": str(self.stdout_path),
65
+ "stderr_path": str(self.stderr_path),
66
+ "command_record_path": str(self.command_record_path),
67
+ "python_executable": self.python_executable,
68
+ "qm_method": self.qm_method,
69
+ "embedding": self.embedding,
70
+ "xtb_executable": self.xtb_executable,
71
+ "mace_model_path": self.mace_model_path,
72
+ "mace_model_sha256": self.mace_model_sha256,
73
+ "final_energy_hartree": self.final_energy_hartree,
74
+ "optimized_atom_count": len(self.optimized_coordinates_angstrom),
75
+ "reused_cached_result": self.reused_cached_result,
76
+ }
77
+
78
+
79
+ def capture_ash_refinement_cache(work_dir: str | Path) -> AshRefinementCache:
80
+ """Capture a converged result and hashes of the exact topology it used."""
81
+
82
+ work = Path(work_dir)
83
+ metadata_path = work / "ash_refinement_cache.json"
84
+ output_path = work / "ash_refinement_output.json"
85
+ command_record_path = work / "ash_command.json"
86
+ stdout_path = work / "ash_stdout.txt"
87
+ stderr_path = work / "ash_stderr.txt"
88
+ required = (metadata_path, output_path, command_record_path, stdout_path, stderr_path)
89
+ missing = [str(path) for path in required if not path.is_file()]
90
+ if missing:
91
+ raise AshRefinementError(
92
+ "Cannot resume ASH refinement because cached artifacts are missing: "
93
+ f"{missing}"
94
+ )
95
+ try:
96
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
97
+ if metadata.get("schema_version") != 1:
98
+ raise AshRefinementError(
99
+ "Cannot resume ASH refinement because its immutable cache metadata "
100
+ "has an unsupported schema"
101
+ )
102
+ payload = metadata["input_payload"]
103
+ output_text = output_path.read_text(encoding="utf-8")
104
+ output = json.loads(output_text)
105
+ command_record = json.loads(command_record_path.read_text(encoding="utf-8"))
106
+ except (KeyError, TypeError, json.JSONDecodeError) as exc:
107
+ raise AshRefinementError("Cached ASH artifacts are malformed and cannot be resumed") from exc
108
+ if output.get("status") != "converged":
109
+ raise AshRefinementError(
110
+ "Cannot resume ASH refinement because the cached output is not marked converged"
111
+ )
112
+ artifact_hashes = {
113
+ "output_sha256": _sha256(output_path),
114
+ "stdout_sha256": _sha256(stdout_path),
115
+ "stderr_sha256": _sha256(stderr_path),
116
+ "command_record_sha256": _sha256(command_record_path),
117
+ }
118
+ for label, observed in artifact_hashes.items():
119
+ if metadata.get(label) != observed:
120
+ raise AshRefinementError(
121
+ "Cannot resume ASH refinement because a cached artifact no longer "
122
+ f"matches immutable metadata: {label}"
123
+ )
124
+ return AshRefinementCache(
125
+ input_payload=payload,
126
+ prmtop_sha256=str(metadata["prmtop_scientific_sha256"]),
127
+ inpcrd_sha256=str(metadata["inpcrd_sha256"]),
128
+ output_text=output_text,
129
+ stdout=stdout_path.read_text(encoding="utf-8"),
130
+ stderr=stderr_path.read_text(encoding="utf-8"),
131
+ command_record=command_record,
132
+ )
133
+
134
+
135
+ def run_ash_refinement(
136
+ *,
137
+ prmtop_path: str | Path,
138
+ inpcrd_path: str | Path,
139
+ selection: RefinementSelection,
140
+ config: AshRefinementConfig,
141
+ work_dir: str | Path,
142
+ qm_method: str = "gfn2_xtb",
143
+ embedding: str = "electrostatic",
144
+ mace_polar1: MacePolar1RefinementConfig | None = None,
145
+ cached_run: AshRefinementCache | None = None,
146
+ ) -> AshRefinementRun:
147
+ """Run a validated ASH/OpenMM active-region QM/MM optimization."""
148
+
149
+ work = Path(work_dir)
150
+ work.mkdir(parents=True, exist_ok=True)
151
+ python_executable = _resolve_executable(
152
+ config.python_executable,
153
+ label="ASH Python executable",
154
+ )
155
+ if qm_method not in {"gfn2_xtb", "gxtb", "mace_polar1"}:
156
+ raise AshRefinementError(f"Unsupported refinement QM method: {qm_method}")
157
+ expected_embedding = "electrostatic" if qm_method == "gfn2_xtb" else "mechanical"
158
+ if embedding != expected_embedding:
159
+ raise AshRefinementError(
160
+ f"Refinement method {qm_method} requires {expected_embedding} embedding"
161
+ )
162
+ xtb_executable: Path | None = None
163
+ mace_model_path: Path | None = None
164
+ mace_model_sha256: str | None = None
165
+ if qm_method in {"gfn2_xtb", "gxtb"}:
166
+ xtb_executable = Path(
167
+ _resolve_executable(config.xtb_executable, label="xTB executable")
168
+ ).resolve()
169
+ ash_xtb = xtb_executable.parent / "xtb"
170
+ if not ash_xtb.is_file():
171
+ raise AshRefinementError(
172
+ "ASH's xTB interface requires an executable named 'xtb' in its configured "
173
+ f"directory, but none exists at {ash_xtb}"
174
+ )
175
+ else:
176
+ if mace_polar1 is None:
177
+ raise AshRefinementError(
178
+ "MACE-POLAR-1 refinement requires explicit model settings"
179
+ )
180
+ mace_model_path = Path(mace_polar1.model_path).expanduser().resolve()
181
+ if not mace_model_path.is_file() or mace_model_path.stat().st_size == 0:
182
+ raise AshRefinementError(
183
+ f"MACE-POLAR-1 checkpoint is missing or empty: {mace_model_path}"
184
+ )
185
+ mace_model_sha256 = _sha256(mace_model_path)
186
+ if mace_model_sha256.lower() != mace_polar1.expected_model_sha256.lower():
187
+ raise AshRefinementError(
188
+ "MACE-POLAR-1 checkpoint checksum mismatch: expected "
189
+ f"{mace_polar1.expected_model_sha256.lower()}, found {mace_model_sha256}"
190
+ )
191
+ missing_search_paths = [
192
+ str(Path(item).expanduser())
193
+ for item in mace_polar1.python_search_paths
194
+ if not Path(item).expanduser().is_dir()
195
+ ]
196
+ if missing_search_paths:
197
+ raise AshRefinementError(
198
+ "MACE-POLAR-1 Python search paths do not exist or are not directories: "
199
+ f"{missing_search_paths}"
200
+ )
201
+
202
+ prmtop = Path(prmtop_path).resolve()
203
+ inpcrd = Path(inpcrd_path).resolve()
204
+ for label, path in (("prmtop", prmtop), ("inpcrd", inpcrd)):
205
+ if not path.is_file() or path.stat().st_size == 0:
206
+ raise AshRefinementError(f"Provisional {label} file is missing or empty: {path}")
207
+ if not selection.active_atom_indices:
208
+ raise AshRefinementError("QM/MM refinement requires at least one movable atom")
209
+ invalid_indices = sorted(
210
+ {
211
+ index
212
+ for index in (*selection.qm_atom_indices, *selection.active_atom_indices)
213
+ if index < 0 or index >= selection.topology_atom_count
214
+ }
215
+ )
216
+ if invalid_indices:
217
+ raise AshRefinementError(
218
+ "QM or movable atom indices fall outside the provisional topology: "
219
+ f"{invalid_indices}"
220
+ )
221
+
222
+ driver_path = work / "run_ash_refinement.py"
223
+ input_path = work / "ash_refinement_input.json"
224
+ output_path = work / "ash_refinement_output.json"
225
+ stdout_path = work / "ash_stdout.txt"
226
+ stderr_path = work / "ash_stderr.txt"
227
+ command_record_path = work / "ash_command.json"
228
+ driver_path.write_text(_ASH_DRIVER, encoding="utf-8")
229
+ payload = {
230
+ "prmtop_path": str(prmtop),
231
+ "inpcrd_path": str(inpcrd),
232
+ "qm_method": qm_method,
233
+ "embedding": embedding,
234
+ "xtb_directory": None if xtb_executable is None else str(xtb_executable.parent),
235
+ "mace": None
236
+ if mace_polar1 is None
237
+ else {
238
+ "model": mace_polar1.model,
239
+ "model_path": str(mace_model_path),
240
+ "model_sha256": mace_model_sha256,
241
+ "device": mace_polar1.device,
242
+ "python_search_paths": [
243
+ str(Path(item).expanduser().resolve())
244
+ for item in mace_polar1.python_search_paths
245
+ ],
246
+ },
247
+ "qm_atom_indices": list(selection.qm_atom_indices),
248
+ "active_atom_indices": list(selection.active_atom_indices),
249
+ "qm_boundary_excluded_atom_indices": list(
250
+ selection.qm_boundary_excluded_atom_indices
251
+ ),
252
+ "qm_charge": selection.total_qm_charge,
253
+ "qm_multiplicity": selection.total_qm_multiplicity,
254
+ "allow_unusual_link_boundaries": config.allow_unusual_link_boundaries,
255
+ "max_iterations": config.max_iterations,
256
+ "num_cores": config.num_cores,
257
+ "platform": config.platform,
258
+ "xtb_max_iterations": config.xtb_max_iterations,
259
+ "electronic_temperature_kelvin": config.electronic_temperature_kelvin,
260
+ "accuracy": config.accuracy,
261
+ }
262
+ input_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
263
+ reused_cached_result = cached_run is not None
264
+ if cached_run is not None:
265
+ mismatches: list[str] = []
266
+ if payload != cached_run.input_payload:
267
+ mismatches.append("ASH input payload")
268
+ if _amber_prmtop_scientific_sha256(prmtop) != cached_run.prmtop_sha256:
269
+ mismatches.append("provisional prmtop")
270
+ if _sha256(inpcrd) != cached_run.inpcrd_sha256:
271
+ mismatches.append("provisional inpcrd")
272
+ if mismatches:
273
+ raise AshRefinementError(
274
+ "Refusing to reuse cached ASH refinement because regenerated inputs differ: "
275
+ + ", ".join(mismatches)
276
+ )
277
+ output_path.write_text(cached_run.output_text, encoding="utf-8")
278
+ stdout_path.write_text(cached_run.stdout, encoding="utf-8")
279
+ stderr_path.write_text(cached_run.stderr, encoding="utf-8")
280
+ command_record_path.write_text(
281
+ json.dumps(cached_run.command_record, indent=2, sort_keys=True) + "\n",
282
+ encoding="utf-8",
283
+ )
284
+ try:
285
+ command_result = CommandResult(
286
+ command=tuple(str(item) for item in cached_run.command_record["command"]),
287
+ cwd=str(cached_run.command_record["cwd"]),
288
+ returncode=int(cached_run.command_record["returncode"]),
289
+ stdout=str(cached_run.command_record["stdout"]),
290
+ stderr=str(cached_run.command_record["stderr"]),
291
+ runtime_seconds=float(cached_run.command_record["runtime_seconds"]),
292
+ )
293
+ except (KeyError, TypeError, ValueError) as exc:
294
+ raise AshRefinementError("Cached ASH command record is malformed") from exc
295
+ else:
296
+ command_result = run_command(
297
+ [python_executable, driver_path.name, input_path.name, output_path.name],
298
+ cwd=work,
299
+ )
300
+ stdout_path.write_text(command_result.stdout, encoding="utf-8")
301
+ stderr_path.write_text(command_result.stderr, encoding="utf-8")
302
+ command_record_path.write_text(
303
+ json.dumps(
304
+ {
305
+ "command": list(command_result.command),
306
+ "cwd": command_result.cwd,
307
+ "returncode": command_result.returncode,
308
+ "stdout": command_result.stdout,
309
+ "stderr": command_result.stderr,
310
+ "runtime_seconds": command_result.runtime_seconds,
311
+ },
312
+ indent=2,
313
+ sort_keys=True,
314
+ )
315
+ + "\n",
316
+ encoding="utf-8",
317
+ )
318
+ if command_result.returncode != 0:
319
+ raise AshRefinementError(
320
+ "ASH QM/MM refinement failed with exit code "
321
+ f"{command_result.returncode}. See {stdout_path} and {stderr_path}. "
322
+ f"stderr tail:\n{_tail(command_result.stderr)}"
323
+ )
324
+ if not output_path.is_file():
325
+ raise AshRefinementError(
326
+ f"ASH returned successfully but did not write {output_path}"
327
+ )
328
+ try:
329
+ output = json.loads(output_path.read_text(encoding="utf-8"))
330
+ raw_coordinates = output["coordinates_angstrom"]
331
+ coordinates = tuple(
332
+ (float(item[0]), float(item[1]), float(item[2]))
333
+ for item in raw_coordinates
334
+ )
335
+ except (KeyError, TypeError, ValueError, IndexError, json.JSONDecodeError) as exc:
336
+ raise AshRefinementError(
337
+ f"ASH produced an invalid optimized-coordinate file: {output_path}"
338
+ ) from exc
339
+ if len(coordinates) != selection.topology_atom_count:
340
+ raise AshRefinementError(
341
+ "ASH optimized-coordinate count does not match the provisional topology: "
342
+ f"{len(coordinates)} != {selection.topology_atom_count}"
343
+ )
344
+ if any(not isfinite(value) for coordinate in coordinates for value in coordinate):
345
+ raise AshRefinementError("ASH optimized coordinates contain NaN or infinity")
346
+ if output.get("qm_method") not in {None, qm_method}:
347
+ raise AshRefinementError("ASH output QM method does not match the request")
348
+ if output.get("embedding") not in {None, embedding}:
349
+ raise AshRefinementError("ASH output embedding does not match the request")
350
+ energy_value = output.get("final_energy_hartree")
351
+ final_energy = float(energy_value) if energy_value is not None else None
352
+ if final_energy is not None and not isfinite(final_energy):
353
+ raise AshRefinementError("ASH final energy is NaN or infinity")
354
+ if not reused_cached_result:
355
+ cache_metadata = {
356
+ "schema_version": 1,
357
+ "input_payload": payload,
358
+ "prmtop_scientific_sha256": _amber_prmtop_scientific_sha256(prmtop),
359
+ "inpcrd_sha256": _sha256(inpcrd),
360
+ "output_sha256": _sha256(output_path),
361
+ "stdout_sha256": _sha256(stdout_path),
362
+ "stderr_sha256": _sha256(stderr_path),
363
+ "command_record_sha256": _sha256(command_record_path),
364
+ }
365
+ (work / "ash_refinement_cache.json").write_text(
366
+ json.dumps(cache_metadata, indent=2, sort_keys=True) + "\n",
367
+ encoding="utf-8",
368
+ )
369
+ return AshRefinementRun(
370
+ command_result=command_result,
371
+ driver_path=driver_path,
372
+ input_path=input_path,
373
+ output_path=output_path,
374
+ stdout_path=stdout_path,
375
+ stderr_path=stderr_path,
376
+ command_record_path=command_record_path,
377
+ python_executable=python_executable,
378
+ qm_method=qm_method,
379
+ embedding=embedding,
380
+ xtb_executable=None if xtb_executable is None else str(xtb_executable),
381
+ mace_model_path=None if mace_model_path is None else str(mace_model_path),
382
+ mace_model_sha256=mace_model_sha256,
383
+ optimized_coordinates_angstrom=coordinates,
384
+ final_energy_hartree=final_energy,
385
+ reused_cached_result=reused_cached_result,
386
+ )
387
+
388
+
389
+ def _resolve_executable(configured: str, *, label: str) -> str:
390
+ path = Path(configured).expanduser()
391
+ if path.is_file():
392
+ return str(path.resolve())
393
+ discovered = which_executable(configured)
394
+ if discovered is not None:
395
+ return discovered
396
+ raise AshRefinementError(f"{label} not found: {configured}")
397
+
398
+
399
+ def _amber_prmtop_scientific_sha256(path: Path) -> str:
400
+ """Hash prmtop scientific content while excluding Amber's wall-clock stamp."""
401
+
402
+ data = path.read_bytes()
403
+ lines = data.splitlines(keepends=True)
404
+ if lines and lines[0].startswith(b"%VERSION"):
405
+ data = b"".join(lines[1:])
406
+ return hashlib.sha256(data).hexdigest()
407
+
408
+
409
+ def _tail(text: str, *, lines: int = 25, characters: int = 5000) -> str:
410
+ if not text:
411
+ return "<empty>"
412
+ value = "\n".join(text.rstrip().splitlines()[-lines:])
413
+ return value[-characters:]
414
+
415
+
416
+ def _sha256(path: Path) -> str:
417
+ digest = hashlib.sha256()
418
+ with path.open("rb") as handle:
419
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
420
+ digest.update(chunk)
421
+ return digest.hexdigest()
422
+
423
+
424
+ _ASH_DRIVER = r'''#!/usr/bin/env python3
425
+ """Generated mdprep ASH driver. Inputs and outputs are JSON for auditability."""
426
+
427
+ import hashlib
428
+ import json
429
+ import sys
430
+
431
+ from ash import Fragment, OpenMMTheory, QMMMTheory, geomeTRICOptimizer
432
+
433
+
434
+ def main() -> None:
435
+ if len(sys.argv) != 3:
436
+ raise SystemExit("usage: run_ash_refinement.py INPUT.json OUTPUT.json")
437
+ input_path, output_path = sys.argv[1:]
438
+ with open(input_path, encoding="utf-8") as handle:
439
+ data = json.load(handle)
440
+
441
+ mace = data.get("mace")
442
+ if mace is not None:
443
+ for path in mace["python_search_paths"]:
444
+ if path not in sys.path:
445
+ sys.path.append(path)
446
+
447
+ fragment = Fragment(
448
+ amber_prmtopfile=data["prmtop_path"],
449
+ amber_inpcrdfile=data["inpcrd_path"],
450
+ conncalc=False,
451
+ )
452
+ mm_theory = OpenMMTheory(
453
+ Amberfiles=True,
454
+ amberprmtopfile=data["prmtop_path"],
455
+ periodic=False,
456
+ platform=data["platform"],
457
+ numcores=data["num_cores"],
458
+ applyconstraints_in_run=False,
459
+ autoconstraints=None,
460
+ rigidwater=False,
461
+ hydrogenmass=None,
462
+ printlevel=2,
463
+ )
464
+ if data["qm_method"] in {"gfn2_xtb", "gxtb"}:
465
+ from ash import xTBTheory
466
+
467
+ qm_theory = xTBTheory(
468
+ xtbdir=data["xtb_directory"],
469
+ xtbmethod="GFN2" if data["qm_method"] == "gfn2_xtb" else "G-XTB",
470
+ runmode="inputfile",
471
+ numcores=data["num_cores"],
472
+ maxiter=data["xtb_max_iterations"],
473
+ electronic_temp=data["electronic_temperature_kelvin"],
474
+ accuracy=data["accuracy"],
475
+ printlevel=2,
476
+ )
477
+ elif data["qm_method"] == "mace_polar1":
478
+ from ash import MACETheory
479
+
480
+ with open(mace["model_path"], "rb") as handle:
481
+ model_sha256 = hashlib.file_digest(handle, "sha256").hexdigest()
482
+ if model_sha256 != mace["model_sha256"]:
483
+ raise RuntimeError(
484
+ "MACE-POLAR-1 checkpoint changed after mdprep validation: "
485
+ f"expected {mace['model_sha256']}, found {model_sha256}"
486
+ )
487
+ qm_theory = MACETheory(
488
+ model_file=mace["model_path"],
489
+ platform=mace["device"],
490
+ default_dtype="float64",
491
+ numcores=data["num_cores"],
492
+ printlevel=2,
493
+ )
494
+ else:
495
+ raise RuntimeError(f"Unsupported refinement QM method: {data['qm_method']}")
496
+ qmmm_theory = QMMMTheory(
497
+ fragment=fragment,
498
+ qm_theory=qm_theory,
499
+ mm_theory=mm_theory,
500
+ qmatoms=data["qm_atom_indices"],
501
+ embedding="Elstat" if data["embedding"] == "electrostatic" else "Mechanical",
502
+ qm_charge=data["qm_charge"],
503
+ qm_mult=data["qm_multiplicity"],
504
+ unusualboundary=data["allow_unusual_link_boundaries"],
505
+ excludeboundaryatomlist=data["qm_boundary_excluded_atom_indices"],
506
+ numcores=data["num_cores"],
507
+ printlevel=2,
508
+ )
509
+ result = geomeTRICOptimizer(
510
+ theory=qmmm_theory,
511
+ fragment=fragment,
512
+ charge=data["qm_charge"],
513
+ mult=data["qm_multiplicity"],
514
+ coordsystem="hdlc",
515
+ maxiter=data["max_iterations"],
516
+ ActiveRegion=True,
517
+ actatoms=data["active_atom_indices"],
518
+ # ASH always writes full-system and QM-region XYZ trajectories for an
519
+ # active-region optimization. Its optional PDB writer depends on an MD
520
+ # ``simulation`` object that OpenMMTheory does not create for QM/MM
521
+ # energy/gradient calls, so it must remain disabled here.
522
+ MM_PDB_traj_write=False,
523
+ result_write_to_disk=True,
524
+ printlevel=2,
525
+ )
526
+ energy = getattr(result, "energy", None)
527
+ payload = {
528
+ "status": "converged",
529
+ "qm_method": data["qm_method"],
530
+ "embedding": data["embedding"],
531
+ "coordinates_angstrom": fragment.coords.tolist(),
532
+ "final_energy_hartree": None if energy is None else float(energy),
533
+ }
534
+ with open(output_path, "w", encoding="utf-8") as handle:
535
+ json.dump(payload, handle, indent=2, sort_keys=True)
536
+ handle.write("\n")
537
+
538
+
539
+ if __name__ == "__main__":
540
+ main()
541
+ '''