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,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()