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,570 @@
1
+ """Canonical two-stage Amber RESP fitting for PySCF ESP values.
2
+
3
+ The RESP executable consumes a fixed-width ESP file. Keeping that conversion
4
+ here, rather than delegating it to a molecule-conversion fallback, lets mdprep
5
+ fit a PySCF electrostatic potential while using Amber's reference RESP
6
+ implementation and ``respgen`` equivalence rules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import re
14
+ import shutil
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Sequence
18
+
19
+ import numpy as np
20
+
21
+ from mdprep.ambertools.mol2 import read_mol2
22
+ from mdprep.external.discovery import which_executable
23
+ from mdprep.external.runner import CommandResult, run_command
24
+
25
+
26
+ class AmberRespError(RuntimeError):
27
+ """Raised when canonical Amber RESP fitting cannot complete safely."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RespCommandRun:
32
+ """One preserved AmberTools command invocation."""
33
+
34
+ name: str
35
+ command_result: CommandResult
36
+ stdout_path: Path
37
+ stderr_path: Path
38
+ record_path: Path
39
+ output_paths: tuple[Path, ...]
40
+
41
+ def to_dict(self) -> dict[str, object]:
42
+ return {
43
+ "name": self.name,
44
+ "command": list(self.command_result.command),
45
+ "cwd": self.command_result.cwd,
46
+ "returncode": self.command_result.returncode,
47
+ "stdout": self.command_result.stdout,
48
+ "stderr": self.command_result.stderr,
49
+ "runtime_seconds": self.command_result.runtime_seconds,
50
+ "stdout_path": str(self.stdout_path),
51
+ "stderr_path": str(self.stderr_path),
52
+ "record_path": str(self.record_path),
53
+ "output_paths": [str(path) for path in self.output_paths],
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class AmberRespFitResult:
59
+ """Results and audit artifacts from a two-stage Amber RESP fit."""
60
+
61
+ charges: np.ndarray
62
+ stage_1_charges: np.ndarray
63
+ charge_sum_before_correction: float
64
+ charge_correction_applied: float
65
+ charge_sum_final: float
66
+ rms_error: float
67
+ relative_rms_error: float | None
68
+ max_error: float
69
+ iterations: int
70
+ stage_1_iterations: int
71
+ stage_2_iterations: int
72
+ converged: bool
73
+ fitting_mode: str
74
+ equivalence_mode: str
75
+ stage_1_ivary: tuple[int, ...]
76
+ stage_2_ivary: tuple[int, ...]
77
+ esp_path: Path
78
+ ac_path: Path
79
+ stage_1_input_path: Path
80
+ stage_2_input_path: Path
81
+ stage_1_output_path: Path
82
+ stage_2_output_path: Path
83
+ stage_1_qout_path: Path
84
+ stage_2_qout_path: Path
85
+ command_runs: tuple[RespCommandRun, ...]
86
+ warnings: list[str]
87
+
88
+ def to_dict(self) -> dict[str, object]:
89
+ return {
90
+ "charges": [float(value) for value in self.charges],
91
+ "stage_1_charges": [float(value) for value in self.stage_1_charges],
92
+ "charge_sum_before_correction": self.charge_sum_before_correction,
93
+ "charge_correction_applied": self.charge_correction_applied,
94
+ "charge_sum_final": self.charge_sum_final,
95
+ "rms_error": self.rms_error,
96
+ "relative_rms_error": self.relative_rms_error,
97
+ "max_error": self.max_error,
98
+ "iterations": self.iterations,
99
+ "stage_1_iterations": self.stage_1_iterations,
100
+ "stage_2_iterations": self.stage_2_iterations,
101
+ "converged": self.converged,
102
+ "fitting_mode": self.fitting_mode,
103
+ "equivalence_mode": self.equivalence_mode,
104
+ "stage_1_ivary": list(self.stage_1_ivary),
105
+ "stage_2_ivary": list(self.stage_2_ivary),
106
+ "esp_path": str(self.esp_path),
107
+ "ac_path": str(self.ac_path),
108
+ "stage_1_input_path": str(self.stage_1_input_path),
109
+ "stage_2_input_path": str(self.stage_2_input_path),
110
+ "stage_1_output_path": str(self.stage_1_output_path),
111
+ "stage_2_output_path": str(self.stage_2_output_path),
112
+ "stage_1_qout_path": str(self.stage_1_qout_path),
113
+ "stage_2_qout_path": str(self.stage_2_qout_path),
114
+ "commands": [run.to_dict() for run in self.command_runs],
115
+ "warnings": self.warnings,
116
+ }
117
+
118
+
119
+ def amber_resp_available() -> bool:
120
+ """Return whether every executable needed by the RESP pipeline is present."""
121
+
122
+ return all(which_executable(name) is not None for name in ("antechamber", "respgen", "resp"))
123
+
124
+
125
+ def write_amber_esp(
126
+ *,
127
+ atom_coordinates_bohr: np.ndarray,
128
+ grid_coordinates_bohr: np.ndarray,
129
+ esp_values_au: np.ndarray,
130
+ path: str | Path,
131
+ ) -> Path:
132
+ """Write the fixed-width ESP input consumed by Amber ``resp``.
133
+
134
+ Amber reads the first record as ``2I5``, atom coordinates as
135
+ ``17X,3E16.7``, and potential/point coordinates as ``1X,4E16.7``.
136
+ The fixed-width header is particularly important: adding a separator can
137
+ make RESP silently interpret, for example, 430 grid points as 43.
138
+ """
139
+
140
+ atoms = np.asarray(atom_coordinates_bohr, dtype=float)
141
+ points = np.asarray(grid_coordinates_bohr, dtype=float)
142
+ values = np.asarray(esp_values_au, dtype=float)
143
+ if atoms.ndim != 2 or atoms.shape[1] != 3 or len(atoms) == 0:
144
+ raise AmberRespError("RESP atom coordinates must have shape (n_atoms, 3) and be non-empty.")
145
+ if points.ndim != 2 or points.shape[1] != 3 or len(points) == 0:
146
+ raise AmberRespError("RESP grid coordinates must have shape (n_points, 3) and be non-empty.")
147
+ if values.shape != (len(points),):
148
+ raise AmberRespError("RESP ESP values must contain exactly one value per grid point.")
149
+ if len(atoms) > 99999 or len(points) > 99999:
150
+ raise AmberRespError("Amber RESP's 2I5 ESP header supports at most 99999 atoms or grid points.")
151
+ if not np.all(np.isfinite(atoms)) or not np.all(np.isfinite(points)) or not np.all(np.isfinite(values)):
152
+ raise AmberRespError("RESP ESP input contains non-finite coordinates or potential values.")
153
+
154
+ output = Path(path)
155
+ output.parent.mkdir(parents=True, exist_ok=True)
156
+ lines = [f"{len(atoms):5d}{len(points):5d}"]
157
+ lines.extend(" " * 17 + "".join(f"{value:16.7E}" for value in coordinate) for coordinate in atoms)
158
+ lines.extend(
159
+ " " + f"{potential:16.7E}" + "".join(f"{value:16.7E}" for value in coordinate)
160
+ for potential, coordinate in zip(values, points, strict=True)
161
+ )
162
+ output.write_text("\n".join(lines) + "\n", encoding="ascii")
163
+ return output
164
+
165
+
166
+ def parse_resp_qout(path: str | Path, *, atom_count: int) -> np.ndarray:
167
+ """Parse an Amber RESP ``qout`` file and require exact atom coverage."""
168
+
169
+ qout = Path(path)
170
+ try:
171
+ tokens = qout.read_text(encoding="utf-8").split()
172
+ charges = np.asarray([float(token) for token in tokens], dtype=float)
173
+ except (OSError, ValueError) as exc:
174
+ raise AmberRespError(f"Could not parse RESP charge output {qout}: {exc}") from exc
175
+ if charges.shape != (atom_count,):
176
+ raise AmberRespError(
177
+ f"RESP charge output {qout} has {len(charges)} charges; expected exactly {atom_count}."
178
+ )
179
+ if not np.all(np.isfinite(charges)):
180
+ raise AmberRespError(f"RESP charge output {qout} contains non-finite values.")
181
+ return charges
182
+
183
+
184
+ def run_amber_resp_fit(
185
+ *,
186
+ provisional_mol2_path: str | Path,
187
+ atom_coordinates_bohr: np.ndarray,
188
+ grid_coordinates_bohr: np.ndarray,
189
+ esp_values_au: np.ndarray,
190
+ total_charge: int,
191
+ multiplicity: int,
192
+ atom_types: str,
193
+ work_dir: str | Path,
194
+ antechamber_executable: str = "antechamber",
195
+ respgen_executable: str = "respgen",
196
+ resp_executable: str = "resp",
197
+ ) -> AmberRespFitResult:
198
+ """Run Amber's standard ``respgen``/two-stage ``resp`` workflow."""
199
+
200
+ work = Path(work_dir).resolve()
201
+ work.mkdir(parents=True, exist_ok=True)
202
+ mol2_path = Path(provisional_mol2_path).resolve()
203
+ mol2 = read_mol2(mol2_path)
204
+ local_mol2_path = work / "ligand_input.mol2"
205
+ shutil.copyfile(mol2_path, local_mol2_path)
206
+ atom_count = len(mol2.atoms)
207
+ atoms = np.asarray(atom_coordinates_bohr, dtype=float)
208
+ points = np.asarray(grid_coordinates_bohr, dtype=float)
209
+ values = np.asarray(esp_values_au, dtype=float)
210
+ if atoms.shape != (atom_count, 3):
211
+ raise AmberRespError(
212
+ f"RESP atom-coordinate count {len(atoms)} does not match mol2 atom count {atom_count}."
213
+ )
214
+
215
+ executables = {
216
+ "antechamber": _resolve_executable(antechamber_executable),
217
+ "respgen": _resolve_executable(respgen_executable),
218
+ "resp": _resolve_executable(resp_executable),
219
+ }
220
+ ac_path = work / "ligand.ac"
221
+ esp_path = write_amber_esp(
222
+ atom_coordinates_bohr=atoms,
223
+ grid_coordinates_bohr=points,
224
+ esp_values_au=values,
225
+ path=work / "ligand.esp",
226
+ )
227
+ resp1_input = work / "resp1.in"
228
+ resp2_input = work / "resp2.in"
229
+ resp1_output = work / "resp1.out"
230
+ resp2_output = work / "resp2.out"
231
+ qout1 = work / "qout_stage1"
232
+ qout2 = work / "qout_stage2"
233
+ resp1_punch = work / "resp1.pch"
234
+ resp2_punch = work / "resp2.pch"
235
+ resp1_esout = work / "resp1.esout"
236
+ resp2_esout = work / "resp2.esout"
237
+
238
+ runs: list[RespCommandRun] = []
239
+ runs.append(
240
+ _run_checked(
241
+ name="antechamber_mol2_to_ac",
242
+ command=[
243
+ executables["antechamber"],
244
+ "-i",
245
+ local_mol2_path.name,
246
+ "-fi",
247
+ "mol2",
248
+ "-o",
249
+ ac_path.name,
250
+ "-fo",
251
+ "ac",
252
+ "-at",
253
+ atom_types,
254
+ "-nc",
255
+ str(total_charge),
256
+ "-m",
257
+ str(multiplicity),
258
+ "-j",
259
+ "0",
260
+ "-an",
261
+ "n",
262
+ "-du",
263
+ "n",
264
+ "-seq",
265
+ "n",
266
+ "-s",
267
+ "2",
268
+ ],
269
+ work_dir=work,
270
+ output_paths=[ac_path],
271
+ )
272
+ )
273
+ _assert_ac_atom_order(ac_path, [atom.name for atom in mol2.atoms])
274
+
275
+ for stage, output_path in (("resp1", resp1_input), ("resp2", resp2_input)):
276
+ runs.append(
277
+ _run_checked(
278
+ name=f"respgen_{stage}",
279
+ command=[
280
+ executables["respgen"],
281
+ "-i",
282
+ ac_path.name,
283
+ "-o",
284
+ output_path.name,
285
+ "-f",
286
+ stage,
287
+ ],
288
+ work_dir=work,
289
+ output_paths=[output_path],
290
+ )
291
+ )
292
+
293
+ stage_1_ivary = _validate_respin(
294
+ resp1_input,
295
+ atom_count=atom_count,
296
+ total_charge=total_charge,
297
+ expected_qwt=0.0005,
298
+ require_iqopt=False,
299
+ )
300
+ stage_2_ivary = _validate_respin(
301
+ resp2_input,
302
+ atom_count=atom_count,
303
+ total_charge=total_charge,
304
+ expected_qwt=0.001,
305
+ require_iqopt=True,
306
+ )
307
+
308
+ runs.append(
309
+ _run_checked(
310
+ name="resp_stage1",
311
+ command=[
312
+ executables["resp"],
313
+ "-O",
314
+ "-i",
315
+ resp1_input.name,
316
+ "-o",
317
+ resp1_output.name,
318
+ "-p",
319
+ resp1_punch.name,
320
+ "-t",
321
+ qout1.name,
322
+ "-e",
323
+ esp_path.name,
324
+ "-s",
325
+ resp1_esout.name,
326
+ ],
327
+ work_dir=work,
328
+ output_paths=[resp1_output, qout1, resp1_punch, resp1_esout],
329
+ )
330
+ )
331
+ stage_1_charges = parse_resp_qout(qout1, atom_count=atom_count)
332
+ runs.append(
333
+ _run_checked(
334
+ name="resp_stage2",
335
+ command=[
336
+ executables["resp"],
337
+ "-O",
338
+ "-i",
339
+ resp2_input.name,
340
+ "-o",
341
+ resp2_output.name,
342
+ "-p",
343
+ resp2_punch.name,
344
+ "-q",
345
+ qout1.name,
346
+ "-t",
347
+ qout2.name,
348
+ "-e",
349
+ esp_path.name,
350
+ "-s",
351
+ resp2_esout.name,
352
+ ],
353
+ work_dir=work,
354
+ output_paths=[resp2_output, qout2, resp2_punch, resp2_esout],
355
+ )
356
+ )
357
+ charges = parse_resp_qout(qout2, atom_count=atom_count)
358
+ stage_1_iterations = _convergence_iterations(resp1_output)
359
+ stage_2_iterations = _convergence_iterations(resp2_output)
360
+ charge_sum = float(np.sum(charges))
361
+ rounding_tolerance = max(1.0e-5, atom_count * 5.1e-7)
362
+ if not math.isclose(charge_sum, float(total_charge), rel_tol=0.0, abs_tol=rounding_tolerance):
363
+ raise AmberRespError(
364
+ f"Two-stage RESP charge sum is {charge_sum:.8f} e, but the requested ligand charge is "
365
+ f"{total_charge}; qout rounding tolerance is {rounding_tolerance:.2e} e."
366
+ )
367
+ rms, relative_rms, max_error = _fit_statistics(
368
+ atom_coordinates_bohr=atoms,
369
+ grid_coordinates_bohr=points,
370
+ esp_values_au=values,
371
+ charges=charges,
372
+ )
373
+ result = AmberRespFitResult(
374
+ charges=charges,
375
+ stage_1_charges=stage_1_charges,
376
+ charge_sum_before_correction=charge_sum,
377
+ charge_correction_applied=0.0,
378
+ charge_sum_final=charge_sum,
379
+ rms_error=rms,
380
+ relative_rms_error=relative_rms,
381
+ max_error=max_error,
382
+ iterations=stage_1_iterations + stage_2_iterations,
383
+ stage_1_iterations=stage_1_iterations,
384
+ stage_2_iterations=stage_2_iterations,
385
+ converged=True,
386
+ fitting_mode="ambertools_two_stage_resp",
387
+ equivalence_mode="respgen_atomic_paths",
388
+ stage_1_ivary=stage_1_ivary,
389
+ stage_2_ivary=stage_2_ivary,
390
+ esp_path=esp_path,
391
+ ac_path=ac_path,
392
+ stage_1_input_path=resp1_input,
393
+ stage_2_input_path=resp2_input,
394
+ stage_1_output_path=resp1_output,
395
+ stage_2_output_path=resp2_output,
396
+ stage_1_qout_path=qout1,
397
+ stage_2_qout_path=qout2,
398
+ command_runs=tuple(runs),
399
+ warnings=[],
400
+ )
401
+ (work / "resp_fit.json").write_text(
402
+ json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n",
403
+ encoding="utf-8",
404
+ )
405
+ return result
406
+
407
+
408
+ def _resolve_executable(name: str) -> str:
409
+ executable = which_executable(name)
410
+ if executable is None:
411
+ raise AmberRespError(
412
+ f"AmberTools executable not found: {name}. Canonical two-stage RESP requires "
413
+ "antechamber, respgen, and resp; mdprep will not substitute simplified or Mulliken charges."
414
+ )
415
+ return executable
416
+
417
+
418
+ def _run_checked(
419
+ *,
420
+ name: str,
421
+ command: Sequence[str],
422
+ work_dir: Path,
423
+ output_paths: Sequence[Path],
424
+ ) -> RespCommandRun:
425
+ for path in output_paths:
426
+ if path.exists():
427
+ path.unlink()
428
+ result = run_command(command, cwd=work_dir)
429
+ stdout_path = work_dir / f"{name}_stdout.txt"
430
+ stderr_path = work_dir / f"{name}_stderr.txt"
431
+ record_path = work_dir / f"{name}_command.json"
432
+ stdout_path.write_text(result.stdout, encoding="utf-8")
433
+ stderr_path.write_text(result.stderr, encoding="utf-8")
434
+ run = RespCommandRun(
435
+ name=name,
436
+ command_result=result,
437
+ stdout_path=stdout_path,
438
+ stderr_path=stderr_path,
439
+ record_path=record_path,
440
+ output_paths=tuple(output_paths),
441
+ )
442
+ record_path.write_text(json.dumps(run.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
443
+ if result.returncode != 0:
444
+ raise AmberRespError(
445
+ f"{name} failed with exit code {result.returncode}. Command: {' '.join(result.command)}. "
446
+ f"See {stdout_path} and {stderr_path}. stderr tail: {_tail(result.stderr)}"
447
+ )
448
+ missing = [path for path in output_paths if not path.exists() or path.stat().st_size == 0]
449
+ if missing:
450
+ raise AmberRespError(
451
+ f"{name} did not produce non-empty expected output(s): "
452
+ + ", ".join(str(path) for path in missing)
453
+ )
454
+ return run
455
+
456
+
457
+ def _assert_ac_atom_order(path: Path, expected_names: list[str]) -> None:
458
+ names = []
459
+ for line in path.read_text(encoding="utf-8").splitlines():
460
+ fields = line.split()
461
+ if fields and fields[0] == "ATOM" and len(fields) >= 3:
462
+ names.append(fields[2])
463
+ if names != expected_names:
464
+ raise AmberRespError(
465
+ "antechamber mol2-to-AC conversion changed ligand atom count, names, or order: "
466
+ f"expected {expected_names}, observed {names}."
467
+ )
468
+
469
+
470
+ def _validate_respin(
471
+ path: Path,
472
+ *,
473
+ atom_count: int,
474
+ total_charge: int,
475
+ expected_qwt: float,
476
+ require_iqopt: bool,
477
+ ) -> tuple[int, ...]:
478
+ text = path.read_text(encoding="utf-8")
479
+ qwt_match = re.search(r"\bqwt\s*=\s*([0-9.EeDd+-]+)", text, flags=re.IGNORECASE)
480
+ if qwt_match is None or not math.isclose(
481
+ float(qwt_match.group(1).replace("D", "E").replace("d", "e")),
482
+ expected_qwt,
483
+ rel_tol=0.0,
484
+ abs_tol=1.0e-10,
485
+ ):
486
+ raise AmberRespError(f"respgen produced a non-standard restraint weight in {path}.")
487
+ has_iqopt_2 = re.search(r"\biqopt\s*=\s*2\b", text, flags=re.IGNORECASE) is not None
488
+ if has_iqopt_2 != require_iqopt:
489
+ raise AmberRespError(f"respgen produced an unexpected iqopt setting in {path}.")
490
+
491
+ lines = text.splitlines()
492
+ header_index: int | None = None
493
+ parsed_charge: int | None = None
494
+ for index, line in enumerate(lines[:-1]):
495
+ if not line.strip().lower().startswith("resp charges"):
496
+ continue
497
+ next_index = _next_nonempty_line(lines, index + 1)
498
+ if next_index is None:
499
+ continue
500
+ match = re.fullmatch(r"\s*(-?\d+)\s+(\d+)\s*", lines[next_index])
501
+ if match is not None:
502
+ parsed_charge = int(match.group(1))
503
+ if int(match.group(2)) == atom_count:
504
+ header_index = next_index
505
+ break
506
+ if header_index is None or parsed_charge != total_charge:
507
+ raise AmberRespError(
508
+ f"respgen input {path} does not encode requested charge {total_charge} and atom count {atom_count}."
509
+ )
510
+
511
+ ivary: list[int] = []
512
+ for line in lines[header_index + 1 :]:
513
+ if not line.strip():
514
+ if ivary:
515
+ break
516
+ continue
517
+ match = re.fullmatch(r"\s*\d+\s+(-?\d+)\s*", line)
518
+ if match is None:
519
+ if ivary:
520
+ break
521
+ continue
522
+ ivary.append(int(match.group(1)))
523
+ if len(ivary) == atom_count:
524
+ break
525
+ if len(ivary) != atom_count:
526
+ raise AmberRespError(f"respgen input {path} has {len(ivary)} atom constraints; expected {atom_count}.")
527
+ return tuple(ivary)
528
+
529
+
530
+ def _next_nonempty_line(lines: list[str], start: int) -> int | None:
531
+ for index in range(start, len(lines)):
532
+ if lines[index].strip():
533
+ return index
534
+ return None
535
+
536
+
537
+ def _convergence_iterations(path: Path) -> int:
538
+ text = path.read_text(encoding="utf-8", errors="replace")
539
+ matches = re.findall(r"Convergence\s+in\s+(\d+)\s+iterations", text, flags=re.IGNORECASE)
540
+ if not matches:
541
+ raise AmberRespError(f"RESP output {path} does not report convergence.")
542
+ return int(matches[-1])
543
+
544
+
545
+ def _fit_statistics(
546
+ *,
547
+ atom_coordinates_bohr: np.ndarray,
548
+ grid_coordinates_bohr: np.ndarray,
549
+ esp_values_au: np.ndarray,
550
+ charges: np.ndarray,
551
+ ) -> tuple[float, float | None, float]:
552
+ distances = np.linalg.norm(
553
+ grid_coordinates_bohr[:, None, :] - atom_coordinates_bohr[None, :, :],
554
+ axis=2,
555
+ )
556
+ if np.any(distances < 1.0e-10):
557
+ raise AmberRespError("ESP grid contains a point on a fitted atom center.")
558
+ predicted = (1.0 / distances) @ charges
559
+ errors = predicted - esp_values_au
560
+ rms = float(np.sqrt(np.mean(errors * errors)))
561
+ esp_rms = float(np.sqrt(np.mean(esp_values_au * esp_values_au)))
562
+ relative = rms / esp_rms if esp_rms > 0.0 else None
563
+ return rms, relative, float(np.max(np.abs(errors)))
564
+
565
+
566
+ def _tail(text: str, *, lines: int = 20) -> str:
567
+ stripped = text.strip()
568
+ if not stripped:
569
+ return "<empty>"
570
+ return "\n".join(stripped.splitlines()[-lines:])
@@ -0,0 +1 @@
1
+ """Deterministic ESP-grid helpers."""