mdprep 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Fixed-geometry GFN2-xTB/g-xTB Hessians for MCPB.py Seminario parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from mdprep.config.models import McpbXtbHessianConfig
|
|
16
|
+
from mdprep.external.discovery import which_executable
|
|
17
|
+
from mdprep.external.runner import CommandResult, run_command
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GxtbHessianError(RuntimeError):
|
|
21
|
+
"""Raised when xTB cannot produce a validated Cartesian Hessian."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class GxtbHessianResult:
|
|
26
|
+
"""Validated result and complete external-command provenance."""
|
|
27
|
+
|
|
28
|
+
hessian_hartree_per_bohr2: np.ndarray
|
|
29
|
+
model: str
|
|
30
|
+
energy_hartree: float
|
|
31
|
+
atom_count: int
|
|
32
|
+
molecular_charge: int
|
|
33
|
+
unpaired_electrons: int
|
|
34
|
+
executable_path: Path
|
|
35
|
+
executable_sha256: str
|
|
36
|
+
release_tag: str | None
|
|
37
|
+
version_text: str
|
|
38
|
+
version_result: CommandResult
|
|
39
|
+
command_result: CommandResult
|
|
40
|
+
hessian_path: Path
|
|
41
|
+
stdout_path: Path
|
|
42
|
+
stderr_path: Path
|
|
43
|
+
version_stdout_path: Path
|
|
44
|
+
version_stderr_path: Path
|
|
45
|
+
report_path: Path
|
|
46
|
+
antisymmetry_max: float
|
|
47
|
+
gradient_norm_hartree_per_bohr: float | None
|
|
48
|
+
raw_hessian_asymmetry_warning_count: int
|
|
49
|
+
unoptimized_geometry_warning: bool
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, object]:
|
|
52
|
+
backend = "GFN2-xTB" if self.model == "gfn2" else "g-xTB"
|
|
53
|
+
return {
|
|
54
|
+
"backend": backend,
|
|
55
|
+
"model": self.model,
|
|
56
|
+
"release_tag": self.release_tag,
|
|
57
|
+
"version_text": self.version_text,
|
|
58
|
+
"executable_path": str(self.executable_path),
|
|
59
|
+
"executable_sha256": self.executable_sha256,
|
|
60
|
+
"energy_hartree": self.energy_hartree,
|
|
61
|
+
"atom_count": self.atom_count,
|
|
62
|
+
"molecular_charge": self.molecular_charge,
|
|
63
|
+
"unpaired_electrons": self.unpaired_electrons,
|
|
64
|
+
"multiplicity": self.unpaired_electrons + 1,
|
|
65
|
+
"hessian_path": str(self.hessian_path),
|
|
66
|
+
"hessian_sha256": _sha256(self.hessian_path),
|
|
67
|
+
"hessian_antisymmetry_max": self.antisymmetry_max,
|
|
68
|
+
"gradient_norm_hartree_per_bohr": self.gradient_norm_hartree_per_bohr,
|
|
69
|
+
"raw_hessian_asymmetry_warning_count": (
|
|
70
|
+
self.raw_hessian_asymmetry_warning_count
|
|
71
|
+
),
|
|
72
|
+
"unoptimized_geometry_warning": self.unoptimized_geometry_warning,
|
|
73
|
+
"version_command": _command_to_dict(self.version_result),
|
|
74
|
+
"hessian_command": _command_to_dict(self.command_result),
|
|
75
|
+
"stdout_path": str(self.stdout_path),
|
|
76
|
+
"stderr_path": str(self.stderr_path),
|
|
77
|
+
"version_stdout_path": str(self.version_stdout_path),
|
|
78
|
+
"version_stderr_path": str(self.version_stderr_path),
|
|
79
|
+
"report_path": str(self.report_path),
|
|
80
|
+
"geometry_optimization_performed": False,
|
|
81
|
+
"hessian_interpretation": (
|
|
82
|
+
"Full unweighted Cartesian Hessian in Hartree/Bohr^2, evaluated "
|
|
83
|
+
"at the input geometry by numerical differentiation of analytic "
|
|
84
|
+
f"{backend} gradients."
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_gxtb_hessian_command(
|
|
90
|
+
*,
|
|
91
|
+
executable: str,
|
|
92
|
+
xyz_name: str,
|
|
93
|
+
charge: int,
|
|
94
|
+
spin: int,
|
|
95
|
+
accuracy: float,
|
|
96
|
+
model: str = "gxtb",
|
|
97
|
+
max_scf_iterations: int | None = None,
|
|
98
|
+
) -> list[str]:
|
|
99
|
+
"""Build the fixed-geometry command without any optimization run type."""
|
|
100
|
+
|
|
101
|
+
if spin < 0:
|
|
102
|
+
raise GxtbHessianError("g-xTB UHF count must be non-negative.")
|
|
103
|
+
if not np.isfinite(accuracy) or accuracy <= 0:
|
|
104
|
+
raise GxtbHessianError("g-xTB accuracy must be positive and finite.")
|
|
105
|
+
if model not in {"gfn2", "gxtb"}:
|
|
106
|
+
raise GxtbHessianError(f"Unsupported xTB Hessian model: {model}")
|
|
107
|
+
if max_scf_iterations is not None and max_scf_iterations < 1:
|
|
108
|
+
raise GxtbHessianError("xTB maximum SCF iterations must be positive.")
|
|
109
|
+
model_args = ["--gfn", "2"] if model == "gfn2" else ["--gxtb"]
|
|
110
|
+
command = [
|
|
111
|
+
executable,
|
|
112
|
+
xyz_name,
|
|
113
|
+
*model_args,
|
|
114
|
+
"--chrg",
|
|
115
|
+
str(charge),
|
|
116
|
+
"--uhf",
|
|
117
|
+
str(spin),
|
|
118
|
+
"--hess",
|
|
119
|
+
"--acc",
|
|
120
|
+
f"{accuracy:.12g}",
|
|
121
|
+
]
|
|
122
|
+
if max_scf_iterations is not None:
|
|
123
|
+
command.extend(["--iterations", str(max_scf_iterations)])
|
|
124
|
+
return command
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_gxtb_hessian(
|
|
128
|
+
*,
|
|
129
|
+
xyz_path: str | Path,
|
|
130
|
+
atom_count: int,
|
|
131
|
+
charge: int,
|
|
132
|
+
spin: int,
|
|
133
|
+
config: McpbXtbHessianConfig,
|
|
134
|
+
work_dir: str | Path,
|
|
135
|
+
) -> GxtbHessianResult:
|
|
136
|
+
"""Run a serial-safe g-xTB Hessian and validate its units and dimensions."""
|
|
137
|
+
|
|
138
|
+
if atom_count < 1:
|
|
139
|
+
raise GxtbHessianError("g-xTB Hessian requires at least one atom.")
|
|
140
|
+
work = Path(work_dir)
|
|
141
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
source_xyz = Path(xyz_path)
|
|
143
|
+
if source_xyz.resolve().parent != work.resolve():
|
|
144
|
+
raise GxtbHessianError(
|
|
145
|
+
"g-xTB input XYZ must already be staged in its dedicated working directory."
|
|
146
|
+
)
|
|
147
|
+
if not source_xyz.is_file() or source_xyz.stat().st_size == 0:
|
|
148
|
+
raise GxtbHessianError(f"g-xTB input XYZ is missing or empty: {source_xyz}")
|
|
149
|
+
backend = "GFN2-xTB" if config.model == "gfn2" else "g-xTB"
|
|
150
|
+
file_prefix = "gfn2_xtb" if config.model == "gfn2" else "gxtb"
|
|
151
|
+
if sys.platform == "darwin" and config.model == "gxtb" and config.num_threads != 1:
|
|
152
|
+
raise GxtbHessianError(
|
|
153
|
+
"The official g-xTB macOS binary does not support reliable parallel "
|
|
154
|
+
"numerical Hessians; set gxtb.num_threads: 1."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
executable = _resolve_executable(config.executable)
|
|
158
|
+
executable_sha256 = _sha256(executable)
|
|
159
|
+
if (
|
|
160
|
+
config.expected_executable_sha256 is not None
|
|
161
|
+
and executable_sha256.lower() != config.expected_executable_sha256.lower()
|
|
162
|
+
):
|
|
163
|
+
raise GxtbHessianError(
|
|
164
|
+
"g-xTB executable checksum mismatch: expected "
|
|
165
|
+
f"{config.expected_executable_sha256}, found {executable_sha256} for {executable}."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
environment = dict(os.environ)
|
|
169
|
+
thread_count = str(config.num_threads)
|
|
170
|
+
environment.update(
|
|
171
|
+
{
|
|
172
|
+
"OMP_NUM_THREADS": thread_count,
|
|
173
|
+
"MKL_NUM_THREADS": thread_count,
|
|
174
|
+
"OPENBLAS_NUM_THREADS": thread_count,
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
version_result = run_command(
|
|
178
|
+
[str(executable), "--version"],
|
|
179
|
+
cwd=work,
|
|
180
|
+
env=environment,
|
|
181
|
+
)
|
|
182
|
+
version_stdout_path = work / f"{file_prefix}.version.stdout.txt"
|
|
183
|
+
version_stderr_path = work / f"{file_prefix}.version.stderr.txt"
|
|
184
|
+
version_stdout_path.write_text(version_result.stdout, encoding="utf-8")
|
|
185
|
+
version_stderr_path.write_text(version_result.stderr, encoding="utf-8")
|
|
186
|
+
if version_result.returncode != 0:
|
|
187
|
+
raise GxtbHessianError(
|
|
188
|
+
f"g-xTB version command failed with exit code {version_result.returncode}. "
|
|
189
|
+
f"See {version_stdout_path} and {version_stderr_path}."
|
|
190
|
+
)
|
|
191
|
+
version_text = _parse_version(version_result.stdout + "\n" + version_result.stderr)
|
|
192
|
+
|
|
193
|
+
_remove_stale_gxtb_outputs(work)
|
|
194
|
+
hessian_path = work / "hessian"
|
|
195
|
+
command = build_gxtb_hessian_command(
|
|
196
|
+
executable=str(executable),
|
|
197
|
+
xyz_name=source_xyz.name,
|
|
198
|
+
charge=charge,
|
|
199
|
+
spin=spin,
|
|
200
|
+
accuracy=config.accuracy,
|
|
201
|
+
model=config.model,
|
|
202
|
+
max_scf_iterations=config.max_scf_iterations,
|
|
203
|
+
)
|
|
204
|
+
command_result = run_command(command, cwd=work, env=environment)
|
|
205
|
+
stdout_path = work / f"{file_prefix}.hessian.stdout.txt"
|
|
206
|
+
stderr_path = work / f"{file_prefix}.hessian.stderr.txt"
|
|
207
|
+
stdout_path.write_text(command_result.stdout, encoding="utf-8")
|
|
208
|
+
stderr_path.write_text(command_result.stderr, encoding="utf-8")
|
|
209
|
+
if command_result.returncode != 0:
|
|
210
|
+
raise GxtbHessianError(
|
|
211
|
+
f"{backend} Hessian failed with exit code {command_result.returncode}. "
|
|
212
|
+
f"See {stdout_path} and {stderr_path}."
|
|
213
|
+
)
|
|
214
|
+
if config.model == "gxtb" and "for g-xTB:" not in command_result.stdout:
|
|
215
|
+
raise GxtbHessianError(
|
|
216
|
+
"The selected executable completed but did not identify g-xTB support. "
|
|
217
|
+
"Install the official g-xTB build and retain the --gxtb command flag."
|
|
218
|
+
)
|
|
219
|
+
if config.model == "gfn2" and "gfn2-xtb" not in command_result.stdout.lower():
|
|
220
|
+
raise GxtbHessianError(
|
|
221
|
+
"The selected executable completed but did not identify GFN2-xTB. "
|
|
222
|
+
"Use an xTB build supporting '--gfn 2'."
|
|
223
|
+
)
|
|
224
|
+
combined_output = command_result.stdout + "\n" + command_result.stderr
|
|
225
|
+
if "normal termination of xtb" not in combined_output.lower():
|
|
226
|
+
raise GxtbHessianError(
|
|
227
|
+
f"{backend} did not report normal termination despite returning exit code zero."
|
|
228
|
+
)
|
|
229
|
+
_validate_open_shell_metadata(
|
|
230
|
+
command_result.stdout,
|
|
231
|
+
expected_charge=charge,
|
|
232
|
+
expected_spin=spin,
|
|
233
|
+
model=config.model,
|
|
234
|
+
)
|
|
235
|
+
hessian = parse_gxtb_hessian(hessian_path, atom_count=atom_count)
|
|
236
|
+
antisymmetry = float(np.max(np.abs(hessian - hessian.T)))
|
|
237
|
+
if antisymmetry > 1.0e-5:
|
|
238
|
+
raise GxtbHessianError(
|
|
239
|
+
"g-xTB Cartesian Hessian is not symmetric "
|
|
240
|
+
f"(maximum antisymmetry {antisymmetry:.3e})."
|
|
241
|
+
)
|
|
242
|
+
hessian = 0.5 * (hessian + hessian.T)
|
|
243
|
+
energy = _parse_total_energy(command_result.stdout)
|
|
244
|
+
gradient_norm = _parse_gradient_norm(command_result.stdout)
|
|
245
|
+
raw_asymmetry_warning_count = len(
|
|
246
|
+
re.findall(
|
|
247
|
+
r"Hessian element\s+\d+\s+\d+\s+is not symmetric",
|
|
248
|
+
combined_output,
|
|
249
|
+
flags=re.IGNORECASE,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
unoptimized_geometry_warning = (
|
|
253
|
+
"hessian on incompletely optimized geometry" in combined_output.lower()
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
report_path = work / f"{file_prefix}_hessian.json"
|
|
257
|
+
result = GxtbHessianResult(
|
|
258
|
+
hessian_hartree_per_bohr2=hessian,
|
|
259
|
+
model=config.model,
|
|
260
|
+
energy_hartree=energy,
|
|
261
|
+
atom_count=atom_count,
|
|
262
|
+
molecular_charge=charge,
|
|
263
|
+
unpaired_electrons=spin,
|
|
264
|
+
executable_path=executable,
|
|
265
|
+
executable_sha256=executable_sha256,
|
|
266
|
+
release_tag=config.release_tag,
|
|
267
|
+
version_text=version_text,
|
|
268
|
+
version_result=version_result,
|
|
269
|
+
command_result=command_result,
|
|
270
|
+
hessian_path=hessian_path,
|
|
271
|
+
stdout_path=stdout_path,
|
|
272
|
+
stderr_path=stderr_path,
|
|
273
|
+
version_stdout_path=version_stdout_path,
|
|
274
|
+
version_stderr_path=version_stderr_path,
|
|
275
|
+
report_path=report_path,
|
|
276
|
+
antisymmetry_max=antisymmetry,
|
|
277
|
+
gradient_norm_hartree_per_bohr=gradient_norm,
|
|
278
|
+
raw_hessian_asymmetry_warning_count=raw_asymmetry_warning_count,
|
|
279
|
+
unoptimized_geometry_warning=unoptimized_geometry_warning,
|
|
280
|
+
)
|
|
281
|
+
report_path.write_text(
|
|
282
|
+
json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n",
|
|
283
|
+
encoding="utf-8",
|
|
284
|
+
)
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def parse_gxtb_hessian(path: str | Path, *, atom_count: int) -> np.ndarray:
|
|
289
|
+
"""Parse xTB's full row-major ``$hessian`` matrix in atomic units."""
|
|
290
|
+
|
|
291
|
+
hessian_path = Path(path)
|
|
292
|
+
if not hessian_path.is_file() or hessian_path.stat().st_size == 0:
|
|
293
|
+
raise GxtbHessianError(f"g-xTB did not produce a non-empty Hessian: {hessian_path}")
|
|
294
|
+
lines = hessian_path.read_text(encoding="utf-8").splitlines()
|
|
295
|
+
try:
|
|
296
|
+
start = next(
|
|
297
|
+
index for index, line in enumerate(lines) if line.strip().lower() == "$hessian"
|
|
298
|
+
)
|
|
299
|
+
except StopIteration as exc:
|
|
300
|
+
raise GxtbHessianError(
|
|
301
|
+
f"g-xTB Hessian file has no $hessian section: {hessian_path}"
|
|
302
|
+
) from exc
|
|
303
|
+
values: list[float] = []
|
|
304
|
+
for line in lines[start + 1 :]:
|
|
305
|
+
stripped = line.strip()
|
|
306
|
+
if not stripped:
|
|
307
|
+
continue
|
|
308
|
+
if stripped.startswith("$"):
|
|
309
|
+
break
|
|
310
|
+
for token in stripped.split():
|
|
311
|
+
try:
|
|
312
|
+
values.append(float(token.replace("D", "E").replace("d", "e")))
|
|
313
|
+
except ValueError as exc:
|
|
314
|
+
raise GxtbHessianError(
|
|
315
|
+
f"Invalid numeric token {token!r} in g-xTB Hessian {hessian_path}."
|
|
316
|
+
) from exc
|
|
317
|
+
dimension = atom_count * 3
|
|
318
|
+
expected = dimension * dimension
|
|
319
|
+
if len(values) != expected:
|
|
320
|
+
raise GxtbHessianError(
|
|
321
|
+
f"g-xTB Hessian contains {len(values)} values; expected {expected} "
|
|
322
|
+
f"for {atom_count} atoms."
|
|
323
|
+
)
|
|
324
|
+
matrix = np.asarray(values, dtype=float).reshape((dimension, dimension))
|
|
325
|
+
if not np.all(np.isfinite(matrix)):
|
|
326
|
+
raise GxtbHessianError("g-xTB Cartesian Hessian contains non-finite values.")
|
|
327
|
+
return matrix
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _resolve_executable(configured: str) -> Path:
|
|
331
|
+
candidate = Path(configured).expanduser()
|
|
332
|
+
if candidate.is_file():
|
|
333
|
+
return candidate.resolve()
|
|
334
|
+
found = which_executable(configured)
|
|
335
|
+
if found is None:
|
|
336
|
+
raise GxtbHessianError(f"g-xTB executable not found: {configured}")
|
|
337
|
+
return Path(found).resolve()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _remove_stale_gxtb_outputs(work_dir: Path) -> None:
|
|
341
|
+
"""Prevent an earlier xTB restart or Hessian from affecting a rerun."""
|
|
342
|
+
|
|
343
|
+
for name in (
|
|
344
|
+
"charges",
|
|
345
|
+
"energy",
|
|
346
|
+
"g98.out",
|
|
347
|
+
"gradient",
|
|
348
|
+
"hessian",
|
|
349
|
+
"vibspectrum",
|
|
350
|
+
"wbo",
|
|
351
|
+
"xtbhess.coord",
|
|
352
|
+
"xtbhess.xyz",
|
|
353
|
+
"xtbrestart",
|
|
354
|
+
"xtbtopo.mol",
|
|
355
|
+
):
|
|
356
|
+
(work_dir / name).unlink(missing_ok=True)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _parse_total_energy(stdout: str) -> float:
|
|
360
|
+
matches = re.findall(
|
|
361
|
+
r"\btotal\s+energy\s+([-+]?\d+(?:\.\d*)?(?:[EeDd][-+]?\d+)?)\s+Eh\b",
|
|
362
|
+
stdout,
|
|
363
|
+
flags=re.IGNORECASE,
|
|
364
|
+
)
|
|
365
|
+
if not matches:
|
|
366
|
+
raise GxtbHessianError("Could not parse the final g-xTB total energy.")
|
|
367
|
+
value = float(matches[-1].replace("D", "E").replace("d", "e"))
|
|
368
|
+
if not np.isfinite(value):
|
|
369
|
+
raise GxtbHessianError("g-xTB total energy is not finite.")
|
|
370
|
+
return value
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_version(text: str) -> str:
|
|
374
|
+
match = re.search(r"\bxtb version\s+([^\r\n]+)", text, flags=re.IGNORECASE)
|
|
375
|
+
if match is None:
|
|
376
|
+
raise GxtbHessianError("Could not parse the xTB executable version.")
|
|
377
|
+
return match.group(1).strip()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _parse_gradient_norm(stdout: str) -> float | None:
|
|
381
|
+
matches = re.findall(
|
|
382
|
+
r"gradient norm\s+([-+]?\d+(?:\.\d*)?(?:[EeDd][-+]?\d+)?)\s+Eh/(?:a0|α)",
|
|
383
|
+
stdout,
|
|
384
|
+
flags=re.IGNORECASE,
|
|
385
|
+
)
|
|
386
|
+
if not matches:
|
|
387
|
+
return None
|
|
388
|
+
value = float(matches[-1].replace("D", "E").replace("d", "e"))
|
|
389
|
+
return value if np.isfinite(value) else None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _validate_open_shell_metadata(
|
|
393
|
+
stdout: str,
|
|
394
|
+
*,
|
|
395
|
+
expected_charge: int,
|
|
396
|
+
expected_spin: int,
|
|
397
|
+
model: str = "gxtb",
|
|
398
|
+
) -> None:
|
|
399
|
+
charge_match = re.search(
|
|
400
|
+
r"molecular charge\s+([-+]?\d+(?:\.\d*)?(?:[EeDd][-+]?\d+)?)\s+e",
|
|
401
|
+
stdout,
|
|
402
|
+
flags=re.IGNORECASE,
|
|
403
|
+
)
|
|
404
|
+
if charge_match is None:
|
|
405
|
+
charge_match = re.search(
|
|
406
|
+
r"net charge\s+([-+]?\d+(?:\.\d*)?(?:[EeDd][-+]?\d+)?)\b",
|
|
407
|
+
stdout,
|
|
408
|
+
flags=re.IGNORECASE,
|
|
409
|
+
)
|
|
410
|
+
spin_match = re.search(
|
|
411
|
+
r"number of unpaired electrons\s+"
|
|
412
|
+
r"([-+]?\d+(?:\.\d*)?(?:[EeDd][-+]?\d+)?)\s+e",
|
|
413
|
+
stdout,
|
|
414
|
+
flags=re.IGNORECASE,
|
|
415
|
+
)
|
|
416
|
+
if spin_match is None:
|
|
417
|
+
spin_match = re.search(
|
|
418
|
+
r"unpaired electrons\s+([-+]?\d+(?:\.\d*)?(?:[EeDd][-+]?\d+)?)\b",
|
|
419
|
+
stdout,
|
|
420
|
+
flags=re.IGNORECASE,
|
|
421
|
+
)
|
|
422
|
+
wavefunction_match = re.search(
|
|
423
|
+
r"wavefunction\s+([A-Za-z-]+)",
|
|
424
|
+
stdout,
|
|
425
|
+
flags=re.IGNORECASE,
|
|
426
|
+
)
|
|
427
|
+
if charge_match is None or spin_match is None:
|
|
428
|
+
raise GxtbHessianError(
|
|
429
|
+
"Could not validate xTB charge and unpaired-electron count."
|
|
430
|
+
)
|
|
431
|
+
actual_charge = float(charge_match.group(1).replace("D", "E").replace("d", "e"))
|
|
432
|
+
actual_spin = float(spin_match.group(1).replace("D", "E").replace("d", "e"))
|
|
433
|
+
if abs(actual_charge - expected_charge) > 1.0e-6:
|
|
434
|
+
raise GxtbHessianError(
|
|
435
|
+
f"g-xTB reported charge {actual_charge}, expected {expected_charge}."
|
|
436
|
+
)
|
|
437
|
+
if abs(actual_spin - expected_spin) > 1.0e-6:
|
|
438
|
+
raise GxtbHessianError(
|
|
439
|
+
f"g-xTB reported {actual_spin} unpaired electrons, expected {expected_spin}."
|
|
440
|
+
)
|
|
441
|
+
if (
|
|
442
|
+
model == "gxtb"
|
|
443
|
+
and wavefunction_match is not None
|
|
444
|
+
and wavefunction_match.group(1).lower() != "unrestricted"
|
|
445
|
+
):
|
|
446
|
+
raise GxtbHessianError(
|
|
447
|
+
"g-xTB did not report the required unrestricted wavefunction."
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _command_to_dict(result: CommandResult) -> dict[str, object]:
|
|
452
|
+
return {
|
|
453
|
+
"command": list(result.command),
|
|
454
|
+
"cwd": result.cwd,
|
|
455
|
+
"returncode": result.returncode,
|
|
456
|
+
"stdout": result.stdout,
|
|
457
|
+
"stderr": result.stderr,
|
|
458
|
+
"runtime_seconds": result.runtime_seconds,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _sha256(path: str | Path) -> str:
|
|
463
|
+
digest = hashlib.sha256()
|
|
464
|
+
with Path(path).open("rb") as handle:
|
|
465
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
466
|
+
digest.update(block)
|
|
467
|
+
return digest.hexdigest()
|