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,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."""
|