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
mdprep/refinement/ash.py
ADDED
|
@@ -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
|
+
'''
|