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,238 @@
|
|
|
1
|
+
"""Temporary PDBFixer hydrogenation for xTB histidine-tautomer clusters.
|
|
2
|
+
|
|
3
|
+
The generated hydrogens are environment atoms for HID/HIE ranking only. They
|
|
4
|
+
are never transferred to the protonation-stage output structure; the normal
|
|
5
|
+
Amber/tleap hydrogenation remains authoritative for the prepared system.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
from math import dist
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import random
|
|
16
|
+
|
|
17
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
|
|
18
|
+
from mdprep.structure.pdb import read_pdb
|
|
19
|
+
from mdprep.structure.writer import write_pdb
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TemporaryHydrogenationError(ValueError):
|
|
23
|
+
"""Raised when a safe temporary hydrogenated environment cannot be built."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TemporaryHydrogenationResult:
|
|
28
|
+
backend: str
|
|
29
|
+
backend_version: str | None
|
|
30
|
+
platform: str
|
|
31
|
+
ph: float
|
|
32
|
+
random_seed: int
|
|
33
|
+
input_path: Path
|
|
34
|
+
raw_output_path: Path
|
|
35
|
+
restored_output_path: Path
|
|
36
|
+
structure: PdbStructure
|
|
37
|
+
added_hydrogen_count: int
|
|
38
|
+
maximum_original_atom_displacement_angstrom: float
|
|
39
|
+
final_prepared_pdb_modified: bool = False
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, object]:
|
|
42
|
+
return {
|
|
43
|
+
"backend": self.backend,
|
|
44
|
+
"backend_version": self.backend_version,
|
|
45
|
+
"platform": self.platform,
|
|
46
|
+
"ph": self.ph,
|
|
47
|
+
"random_seed": self.random_seed,
|
|
48
|
+
"input_path": str(self.input_path),
|
|
49
|
+
"raw_output_path": str(self.raw_output_path),
|
|
50
|
+
"restored_output_path": str(self.restored_output_path),
|
|
51
|
+
"added_hydrogen_count": self.added_hydrogen_count,
|
|
52
|
+
"maximum_original_atom_displacement_angstrom": (
|
|
53
|
+
self.maximum_original_atom_displacement_angstrom
|
|
54
|
+
),
|
|
55
|
+
"final_prepared_pdb_modified": self.final_prepared_pdb_modified,
|
|
56
|
+
"interpretation": (
|
|
57
|
+
"PDBFixer hydrogens complete the local xTB HID/HIE environment only. "
|
|
58
|
+
"They are not copied into the protonation-stage or final prepared PDB."
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_temporary_protein_hydrogens(
|
|
64
|
+
structure: PdbStructure,
|
|
65
|
+
*,
|
|
66
|
+
ph: float,
|
|
67
|
+
work_dir: str | Path,
|
|
68
|
+
random_seed: int = 20260722,
|
|
69
|
+
) -> TemporaryHydrogenationResult:
|
|
70
|
+
"""Add a complete temporary H environment without changing input atoms."""
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from openmm import Platform
|
|
74
|
+
from openmm.app import PDBFile
|
|
75
|
+
from pdbfixer import PDBFixer
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise TemporaryHydrogenationError(
|
|
78
|
+
"protonation.histidine.xtb.add_missing_protein_hydrogens was requested, "
|
|
79
|
+
"but PDBFixer and OpenMM are unavailable. Install PDBFixer or set the "
|
|
80
|
+
"option to false and provide a fully hydrogenated input model."
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
work = Path(work_dir)
|
|
84
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
input_path = work / "histidine_xtb_environment_input.pdb"
|
|
86
|
+
raw_path = work / "histidine_xtb_environment.pdbfixer_raw.pdb"
|
|
87
|
+
restored_path = work / "histidine_xtb_environment.hydrogenated.pdb"
|
|
88
|
+
write_pdb(structure, input_path)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
platform_name = "Reference"
|
|
92
|
+
platform = Platform.getPlatformByName(platform_name)
|
|
93
|
+
fixer = PDBFixer(filename=str(input_path), platform=platform)
|
|
94
|
+
# OpenMM Modeller places hydrogens with Python's global random
|
|
95
|
+
# generator before minimizing them. Restore the caller's RNG state
|
|
96
|
+
# after making this scientific input reproducible.
|
|
97
|
+
random_state = random.getstate()
|
|
98
|
+
random.seed(random_seed)
|
|
99
|
+
try:
|
|
100
|
+
fixer.addMissingHydrogens(float(ph))
|
|
101
|
+
finally:
|
|
102
|
+
random.setstate(random_state)
|
|
103
|
+
with raw_path.open("w", encoding="utf-8") as handle:
|
|
104
|
+
PDBFile.writeFile(fixer.topology, fixer.positions, handle, keepIds=True)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
raise TemporaryHydrogenationError(
|
|
107
|
+
f"PDBFixer could not build the temporary xTB hydrogen environment: {exc}"
|
|
108
|
+
) from exc
|
|
109
|
+
|
|
110
|
+
raw = read_pdb(raw_path)
|
|
111
|
+
restored, maximum_displacement = _restore_and_validate(
|
|
112
|
+
structure,
|
|
113
|
+
raw,
|
|
114
|
+
output_path=restored_path,
|
|
115
|
+
)
|
|
116
|
+
write_pdb(restored, restored_path)
|
|
117
|
+
added = len(restored.atoms) - len(structure.atoms)
|
|
118
|
+
if added <= 0:
|
|
119
|
+
raise TemporaryHydrogenationError(
|
|
120
|
+
"PDBFixer did not add any temporary hydrogens to a dehydrogenated "
|
|
121
|
+
"histidine-ranking environment."
|
|
122
|
+
)
|
|
123
|
+
return TemporaryHydrogenationResult(
|
|
124
|
+
backend="pdbfixer",
|
|
125
|
+
backend_version=_package_version("pdbfixer"),
|
|
126
|
+
platform=platform_name,
|
|
127
|
+
ph=float(ph),
|
|
128
|
+
random_seed=random_seed,
|
|
129
|
+
input_path=input_path,
|
|
130
|
+
raw_output_path=raw_path,
|
|
131
|
+
restored_output_path=restored_path,
|
|
132
|
+
structure=restored,
|
|
133
|
+
added_hydrogen_count=added,
|
|
134
|
+
maximum_original_atom_displacement_angstrom=maximum_displacement,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _restore_and_validate(
|
|
139
|
+
reference: PdbStructure,
|
|
140
|
+
observed: PdbStructure,
|
|
141
|
+
*,
|
|
142
|
+
output_path: Path,
|
|
143
|
+
) -> tuple[PdbStructure, float]:
|
|
144
|
+
if len(observed.residues) != len(reference.residues):
|
|
145
|
+
raise TemporaryHydrogenationError(
|
|
146
|
+
"Temporary PDBFixer hydrogenation changed the residue count: "
|
|
147
|
+
f"{len(reference.residues)} -> {len(observed.residues)}."
|
|
148
|
+
)
|
|
149
|
+
atoms: list[AtomRecord] = []
|
|
150
|
+
maximum_displacement = 0.0
|
|
151
|
+
serial = 0
|
|
152
|
+
for expected, actual in zip(reference.residues, observed.residues, strict=True):
|
|
153
|
+
expected_key = (expected.id.chain_id, expected.id.resid, expected.id.icode)
|
|
154
|
+
actual_key = (actual.id.chain_id, actual.id.resid, actual.id.icode)
|
|
155
|
+
if expected_key != actual_key or expected.id.resname != actual.id.resname:
|
|
156
|
+
raise TemporaryHydrogenationError(
|
|
157
|
+
"Temporary PDBFixer hydrogenation changed residue identity/order: "
|
|
158
|
+
f"expected {expected.id.display()}, found {actual.id.display()}."
|
|
159
|
+
)
|
|
160
|
+
expected_by_name = {atom.name: atom for atom in expected.atoms}
|
|
161
|
+
if len(expected_by_name) != len(expected.atoms):
|
|
162
|
+
raise TemporaryHydrogenationError(
|
|
163
|
+
f"Input residue {expected.id.display()} has duplicate atom names."
|
|
164
|
+
)
|
|
165
|
+
actual_names = {atom.name for atom in actual.atoms}
|
|
166
|
+
missing = sorted(set(expected_by_name) - actual_names)
|
|
167
|
+
if missing:
|
|
168
|
+
raise TemporaryHydrogenationError(
|
|
169
|
+
f"Temporary PDBFixer hydrogenation removed atoms from "
|
|
170
|
+
f"{expected.id.display()}: {missing}"
|
|
171
|
+
)
|
|
172
|
+
residue_record_name = "HETATM" if expected.record_names == {"HETATM"} else "ATOM"
|
|
173
|
+
for atom in actual.atoms:
|
|
174
|
+
serial += 1
|
|
175
|
+
original = expected_by_name.get(atom.name)
|
|
176
|
+
if original is not None:
|
|
177
|
+
displacement = dist(
|
|
178
|
+
(original.x, original.y, original.z),
|
|
179
|
+
(atom.x, atom.y, atom.z),
|
|
180
|
+
)
|
|
181
|
+
maximum_displacement = max(maximum_displacement, displacement)
|
|
182
|
+
if displacement > 0.002:
|
|
183
|
+
raise TemporaryHydrogenationError(
|
|
184
|
+
"Temporary PDBFixer hydrogenation moved an original atom by "
|
|
185
|
+
f"{displacement:.6f} A: {original.atom_identity}."
|
|
186
|
+
)
|
|
187
|
+
elif (atom.element or "").strip().upper() != "H":
|
|
188
|
+
raise TemporaryHydrogenationError(
|
|
189
|
+
"Temporary PDBFixer hydrogenation added a non-hydrogen atom "
|
|
190
|
+
f"to {expected.id.display()}: {atom.name}."
|
|
191
|
+
)
|
|
192
|
+
atoms.append(
|
|
193
|
+
replace(
|
|
194
|
+
atom,
|
|
195
|
+
serial=serial,
|
|
196
|
+
resname=expected.id.resname,
|
|
197
|
+
chain_id=expected.id.chain_id,
|
|
198
|
+
resid=expected.id.resid,
|
|
199
|
+
icode=expected.id.icode,
|
|
200
|
+
record_name=(original.record_name if original else residue_record_name),
|
|
201
|
+
occupancy=(original.occupancy if original else atom.occupancy),
|
|
202
|
+
bfactor=(original.bfactor if original else atom.bfactor),
|
|
203
|
+
element=(original.element if original else atom.element),
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return (
|
|
207
|
+
PdbStructure(
|
|
208
|
+
path=output_path,
|
|
209
|
+
atoms=atoms,
|
|
210
|
+
residues=_build_residues(atoms),
|
|
211
|
+
model_count=1,
|
|
212
|
+
used_model=1,
|
|
213
|
+
warnings=list(reference.warnings),
|
|
214
|
+
),
|
|
215
|
+
maximum_displacement,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
|
|
220
|
+
grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
|
|
221
|
+
for atom in atoms:
|
|
222
|
+
grouped.setdefault(atom.residue_key, []).append(atom)
|
|
223
|
+
return [
|
|
224
|
+
ResidueRecord(
|
|
225
|
+
id=ResidueId(chain_id=key[0], resname=key[1], resid=key[2], icode=key[3]),
|
|
226
|
+
atoms=residue_atoms,
|
|
227
|
+
record_names={atom.record_name for atom in residue_atoms},
|
|
228
|
+
original_index=index,
|
|
229
|
+
)
|
|
230
|
+
for index, (key, residue_atoms) in enumerate(grouped.items())
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _package_version(name: str) -> str | None:
|
|
235
|
+
try:
|
|
236
|
+
return version(name)
|
|
237
|
+
except PackageNotFoundError:
|
|
238
|
+
return None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""xTB output energy parsing and tautomer selection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HARTREE_TO_KCAL_MOL = 627.509474
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class XtbParseError(ValueError):
|
|
14
|
+
"""Raised when xTB output does not contain a parseable energy."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ENERGY_PATTERNS = [
|
|
18
|
+
re.compile(r"TOTAL\s+ENERGY\s+(-?\d+(?:\.\d+)?)", re.IGNORECASE),
|
|
19
|
+
re.compile(r"total\s+energy\s+(-?\d+(?:\.\d+)?)", re.IGNORECASE),
|
|
20
|
+
re.compile(r"::\s*total\s+energy\s+(-?\d+(?:\.\d+)?)", re.IGNORECASE),
|
|
21
|
+
re.compile(r"total\s+E\s*=\s*(-?\d+(?:\.\d+)?)", re.IGNORECASE),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class XtbEnergyComparison:
|
|
27
|
+
hid_energy_hartree: float
|
|
28
|
+
hie_energy_hartree: float
|
|
29
|
+
delta_kcal_mol: float
|
|
30
|
+
selected_state: str
|
|
31
|
+
close_call: bool
|
|
32
|
+
warnings: list[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_xtb_energy_text(text: str, *, source: str = "xTB stdout") -> float:
|
|
36
|
+
energies: list[float] = []
|
|
37
|
+
for line in text.splitlines():
|
|
38
|
+
for pattern in ENERGY_PATTERNS:
|
|
39
|
+
match = pattern.search(line)
|
|
40
|
+
if match:
|
|
41
|
+
energies.append(float(match.group(1)))
|
|
42
|
+
break
|
|
43
|
+
if not energies:
|
|
44
|
+
raise XtbParseError(f"Could not parse xTB total energy from {source}")
|
|
45
|
+
return energies[-1]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_xtb_energy_file(path: str | Path) -> float:
|
|
49
|
+
path_obj = Path(path)
|
|
50
|
+
return parse_xtb_energy_text(path_obj.read_text(encoding="utf-8"), source=str(path_obj))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compare_hid_hie_energies(
|
|
54
|
+
*,
|
|
55
|
+
hid_energy_hartree: float,
|
|
56
|
+
hie_energy_hartree: float,
|
|
57
|
+
close_call_kcal_mol: float,
|
|
58
|
+
) -> XtbEnergyComparison:
|
|
59
|
+
delta = (hid_energy_hartree - hie_energy_hartree) * HARTREE_TO_KCAL_MOL
|
|
60
|
+
warnings: list[str] = []
|
|
61
|
+
if hid_energy_hartree < hie_energy_hartree:
|
|
62
|
+
selected = "HID"
|
|
63
|
+
elif hie_energy_hartree < hid_energy_hartree:
|
|
64
|
+
selected = "HIE"
|
|
65
|
+
else:
|
|
66
|
+
selected = "HIE"
|
|
67
|
+
warnings.append("HID and HIE xTB energies are equal; selected HIE by deterministic tie-break.")
|
|
68
|
+
close_call = abs(delta) <= close_call_kcal_mol
|
|
69
|
+
if close_call:
|
|
70
|
+
warnings.append("HID/HIE xTB energy difference is within the configured close-call threshold.")
|
|
71
|
+
return XtbEnergyComparison(
|
|
72
|
+
hid_energy_hartree=hid_energy_hartree,
|
|
73
|
+
hie_energy_hartree=hie_energy_hartree,
|
|
74
|
+
delta_kcal_mol=delta,
|
|
75
|
+
selected_state=selected,
|
|
76
|
+
close_call=close_call,
|
|
77
|
+
warnings=warnings,
|
|
78
|
+
)
|
|
79
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""xTB command construction and execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mdprep.config.models import HistidineXtbConfig
|
|
9
|
+
from mdprep.external.discovery import which_executable
|
|
10
|
+
from mdprep.external.runner import CommandResult, run_command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class XtbExecutionError(RuntimeError):
|
|
14
|
+
"""Raised when xTB is requested but unavailable or fails."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class XtbRunResult:
|
|
19
|
+
command_result: CommandResult
|
|
20
|
+
stdout_path: Path
|
|
21
|
+
stderr_path: Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_xtb_executable(config: HistidineXtbConfig) -> str:
|
|
25
|
+
configured = config.executable
|
|
26
|
+
if Path(configured).exists():
|
|
27
|
+
return configured
|
|
28
|
+
found = which_executable(configured)
|
|
29
|
+
if found:
|
|
30
|
+
return found
|
|
31
|
+
raise XtbExecutionError(f"xTB executable not found: {configured}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_xtb_command(
|
|
35
|
+
*,
|
|
36
|
+
config: HistidineXtbConfig,
|
|
37
|
+
xyz_path: str | Path,
|
|
38
|
+
cluster_charge: int,
|
|
39
|
+
executable: str | None = None,
|
|
40
|
+
input_path: str | Path | None = None,
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
exe = executable or config.executable
|
|
43
|
+
command = [exe, str(xyz_path)]
|
|
44
|
+
extra_args = list(config.extra_args)
|
|
45
|
+
if config.model == "gfn2":
|
|
46
|
+
command.extend(["--gfn", "2"])
|
|
47
|
+
elif "--gxtb" not in extra_args:
|
|
48
|
+
command.append("--gxtb")
|
|
49
|
+
if config.mode == "opt":
|
|
50
|
+
command.extend(["--opt", config.opt_level])
|
|
51
|
+
command.extend(["--chrg", str(cluster_charge), "--uhf", "0"])
|
|
52
|
+
command.extend(["--iterations", str(config.scf_iterations)])
|
|
53
|
+
if config.electronic_temperature_kelvin is not None:
|
|
54
|
+
command.extend(["--etemp", str(config.electronic_temperature_kelvin)])
|
|
55
|
+
if config.solvent is not None and not _extra_args_define_solvent(extra_args):
|
|
56
|
+
command.extend(["--alpb", config.solvent])
|
|
57
|
+
if input_path is not None:
|
|
58
|
+
command.extend(["--input", str(input_path)])
|
|
59
|
+
command.extend(extra_args)
|
|
60
|
+
return command
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_xtb(
|
|
64
|
+
*,
|
|
65
|
+
config: HistidineXtbConfig,
|
|
66
|
+
xyz_path: str | Path,
|
|
67
|
+
work_dir: str | Path,
|
|
68
|
+
cluster_charge: int,
|
|
69
|
+
stdout_path: str | Path,
|
|
70
|
+
stderr_path: str | Path,
|
|
71
|
+
input_path: str | Path | None = None,
|
|
72
|
+
) -> XtbRunResult:
|
|
73
|
+
executable = resolve_xtb_executable(config)
|
|
74
|
+
command = build_xtb_command(
|
|
75
|
+
config=config,
|
|
76
|
+
xyz_path=Path(xyz_path).name,
|
|
77
|
+
cluster_charge=cluster_charge,
|
|
78
|
+
executable=executable,
|
|
79
|
+
input_path=Path(input_path).name if input_path is not None else None,
|
|
80
|
+
)
|
|
81
|
+
result = run_command(command, cwd=work_dir)
|
|
82
|
+
stdout = Path(stdout_path)
|
|
83
|
+
stderr = Path(stderr_path)
|
|
84
|
+
stdout.write_text(result.stdout, encoding="utf-8")
|
|
85
|
+
stderr.write_text(result.stderr, encoding="utf-8")
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
raise XtbExecutionError(
|
|
88
|
+
"xTB failed with exit code "
|
|
89
|
+
f"{result.returncode}. Command: {' '.join(result.command)}. "
|
|
90
|
+
f"See {stdout} and {stderr}.\n"
|
|
91
|
+
f"stdout tail:\n{_tail(result.stdout)}\n"
|
|
92
|
+
f"stderr tail:\n{_tail(result.stderr)}"
|
|
93
|
+
)
|
|
94
|
+
return XtbRunResult(command_result=result, stdout_path=stdout, stderr_path=stderr)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extra_args_define_solvent(extra_args: list[str]) -> bool:
|
|
98
|
+
return any(arg in {"--alpb", "--gbsa"} for arg in extra_args)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _tail(text: str, *, max_lines: int = 20, max_chars: int = 4000) -> str:
|
|
102
|
+
if not text:
|
|
103
|
+
return "<empty>"
|
|
104
|
+
lines = text.rstrip().splitlines()[-max_lines:]
|
|
105
|
+
tail = "\n".join(lines)
|
|
106
|
+
if len(tail) > max_chars:
|
|
107
|
+
return tail[-max_chars:]
|
|
108
|
+
return tail
|
mdprep/qm/__init__.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""MM point-charge extraction for QMMESP electrostatic embedding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from mdprep.config.models import LigandConfig, ManifestConfig
|
|
13
|
+
from mdprep.structure.classify import STANDARD_PROTEIN_RESIDUES, WATER_RESIDUES
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PointChargeError(ValueError):
|
|
17
|
+
"""Raised when QMMESP point charges cannot be extracted safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PointCharge:
|
|
22
|
+
x: float
|
|
23
|
+
y: float
|
|
24
|
+
z: float
|
|
25
|
+
charge: float
|
|
26
|
+
residue_name: str
|
|
27
|
+
residue_number: int | None
|
|
28
|
+
atom_name: str
|
|
29
|
+
category: str
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, object]:
|
|
32
|
+
return {
|
|
33
|
+
"x": self.x,
|
|
34
|
+
"y": self.y,
|
|
35
|
+
"z": self.z,
|
|
36
|
+
"charge": self.charge,
|
|
37
|
+
"residue_name": self.residue_name,
|
|
38
|
+
"residue_number": self.residue_number,
|
|
39
|
+
"atom_name": self.atom_name,
|
|
40
|
+
"category": self.category,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PointChargeSelection:
|
|
46
|
+
target_atom_indices: list[int]
|
|
47
|
+
point_charges: list[PointCharge]
|
|
48
|
+
total_before_cutoff: int
|
|
49
|
+
total_after_cutoff: int
|
|
50
|
+
net_embedding_charge: float
|
|
51
|
+
min_distance: float | None
|
|
52
|
+
max_distance: float | None
|
|
53
|
+
categories: dict[str, int]
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, object]:
|
|
56
|
+
return {
|
|
57
|
+
"target_atom_indices": self.target_atom_indices,
|
|
58
|
+
"target_atom_count": len(self.target_atom_indices),
|
|
59
|
+
"target_ligand_excluded_from_embedding": True,
|
|
60
|
+
"point_charge_count_before_cutoff": self.total_before_cutoff,
|
|
61
|
+
"point_charge_count_after_cutoff": self.total_after_cutoff,
|
|
62
|
+
"net_embedding_charge": self.net_embedding_charge,
|
|
63
|
+
"min_distance": self.min_distance,
|
|
64
|
+
"max_distance": self.max_distance,
|
|
65
|
+
"categories": self.categories,
|
|
66
|
+
"qmmesp_interpretation": (
|
|
67
|
+
"The target-ligand SCF uses electrostatic QM/MM embedding through "
|
|
68
|
+
"pyscf.qmmm.mm_charge. MM point charges polarize the target-ligand QM density; "
|
|
69
|
+
"they are not fitted and are not written to the ligand mol2."
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def charge_array(self) -> np.ndarray:
|
|
75
|
+
return np.asarray([charge.charge for charge in self.point_charges], dtype=float)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def coordinate_array(self) -> np.ndarray:
|
|
79
|
+
return np.asarray([[charge.x, charge.y, charge.z] for charge in self.point_charges], dtype=float)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parmed_available() -> bool:
|
|
83
|
+
try:
|
|
84
|
+
import parmed # noqa: F401
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_point_charges_from_prmtop(
|
|
91
|
+
*,
|
|
92
|
+
prmtop: str | Path,
|
|
93
|
+
inpcrd: str | Path,
|
|
94
|
+
ligand: LigandConfig,
|
|
95
|
+
manifest: ManifestConfig,
|
|
96
|
+
target_coordinates: np.ndarray,
|
|
97
|
+
target_atom_names: list[str] | None = None,
|
|
98
|
+
) -> PointChargeSelection:
|
|
99
|
+
try:
|
|
100
|
+
import parmed
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
raise PointChargeError("ParmEd is required for qmmesp_pyscf point-charge extraction.") from exc
|
|
103
|
+
try:
|
|
104
|
+
structure = parmed.load_file(str(prmtop), str(inpcrd))
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
raise PointChargeError(f"Could not load provisional Amber topology with ParmEd: {exc}") from exc
|
|
107
|
+
coordinates = np.asarray(structure.coordinates, dtype=float)
|
|
108
|
+
target_indices = _find_target_ligand_atoms(
|
|
109
|
+
structure,
|
|
110
|
+
ligand,
|
|
111
|
+
topology_coordinates=coordinates,
|
|
112
|
+
target_coordinates=target_coordinates,
|
|
113
|
+
target_atom_names=target_atom_names,
|
|
114
|
+
)
|
|
115
|
+
target_set = set(target_indices)
|
|
116
|
+
target_coords = np.asarray(target_coordinates, dtype=float)
|
|
117
|
+
charges: list[PointCharge] = []
|
|
118
|
+
before_cutoff = 0
|
|
119
|
+
distances_kept: list[float] = []
|
|
120
|
+
categories: dict[str, int] = {}
|
|
121
|
+
for index, atom in enumerate(structure.atoms):
|
|
122
|
+
if index in target_set:
|
|
123
|
+
continue
|
|
124
|
+
category = _category(atom)
|
|
125
|
+
env = ligand.qmmesp.environment if ligand.qmmesp is not None else None
|
|
126
|
+
if env is not None:
|
|
127
|
+
if category == "protein" and not env.include_protein:
|
|
128
|
+
continue
|
|
129
|
+
if category == "water" and not env.include_waters:
|
|
130
|
+
continue
|
|
131
|
+
if category == "ligand" and not env.include_other_ligands:
|
|
132
|
+
continue
|
|
133
|
+
before_cutoff += 1
|
|
134
|
+
distance = float(np.min(np.linalg.norm(target_coords - coordinates[index], axis=1)))
|
|
135
|
+
cutoff = ligand.qmmesp.embedding_cutoff_angstrom if ligand.qmmesp is not None else None
|
|
136
|
+
if cutoff is not None and distance > cutoff:
|
|
137
|
+
continue
|
|
138
|
+
residue = atom.residue
|
|
139
|
+
charge = PointCharge(
|
|
140
|
+
x=float(coordinates[index][0]),
|
|
141
|
+
y=float(coordinates[index][1]),
|
|
142
|
+
z=float(coordinates[index][2]),
|
|
143
|
+
charge=float(atom.charge),
|
|
144
|
+
residue_name=str(residue.name),
|
|
145
|
+
residue_number=getattr(residue, "number", None),
|
|
146
|
+
atom_name=str(atom.name),
|
|
147
|
+
category=category,
|
|
148
|
+
)
|
|
149
|
+
charges.append(charge)
|
|
150
|
+
distances_kept.append(distance)
|
|
151
|
+
categories[category] = categories.get(category, 0) + 1
|
|
152
|
+
return PointChargeSelection(
|
|
153
|
+
target_atom_indices=target_indices,
|
|
154
|
+
point_charges=charges,
|
|
155
|
+
total_before_cutoff=before_cutoff,
|
|
156
|
+
total_after_cutoff=len(charges),
|
|
157
|
+
net_embedding_charge=float(sum(charge.charge for charge in charges)),
|
|
158
|
+
min_distance=min(distances_kept) if distances_kept else None,
|
|
159
|
+
max_distance=max(distances_kept) if distances_kept else None,
|
|
160
|
+
categories=categories,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def write_point_charge_files(selection: PointChargeSelection, *, csv_path: str | Path, xyz_path: str | Path, summary_path: str | Path) -> None:
|
|
165
|
+
csv_output = Path(csv_path)
|
|
166
|
+
csv_output.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
with csv_output.open("w", encoding="utf-8", newline="") as handle:
|
|
168
|
+
writer = csv.DictWriter(handle, fieldnames=["x", "y", "z", "charge", "residue_name", "residue_number", "atom_name", "category"])
|
|
169
|
+
writer.writeheader()
|
|
170
|
+
for charge in selection.point_charges:
|
|
171
|
+
writer.writerow(charge.to_dict())
|
|
172
|
+
xyz_output = Path(xyz_path)
|
|
173
|
+
lines = [str(len(selection.point_charges)), "mdprep MM point charges"]
|
|
174
|
+
for charge in selection.point_charges:
|
|
175
|
+
lines.append(f"X {charge.x:.8f} {charge.y:.8f} {charge.z:.8f} {charge.charge:.8f}")
|
|
176
|
+
xyz_output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
177
|
+
Path(summary_path).write_text(json.dumps(selection.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_target_ligand_atoms(
|
|
181
|
+
structure: object,
|
|
182
|
+
ligand: LigandConfig,
|
|
183
|
+
*,
|
|
184
|
+
topology_coordinates: np.ndarray,
|
|
185
|
+
target_coordinates: np.ndarray,
|
|
186
|
+
target_atom_names: list[str] | None,
|
|
187
|
+
coordinate_tolerance_angstrom: float = 0.1,
|
|
188
|
+
) -> list[int]:
|
|
189
|
+
target = np.asarray(target_coordinates, dtype=float)
|
|
190
|
+
if target.ndim != 2 or target.shape[1] != 3 or len(target) == 0:
|
|
191
|
+
raise PointChargeError("Target-ligand coordinates must have shape (n_atoms, 3) and be non-empty.")
|
|
192
|
+
if target_atom_names is not None and len(target_atom_names) != len(target):
|
|
193
|
+
raise PointChargeError("Target-ligand atom-name and coordinate counts do not match.")
|
|
194
|
+
|
|
195
|
+
matches: list[tuple[list[int], float]] = []
|
|
196
|
+
rejected: list[str] = []
|
|
197
|
+
for residue in structure.residues:
|
|
198
|
+
if residue.name != ligand.selector.resname:
|
|
199
|
+
continue
|
|
200
|
+
indices = [atom.idx for atom in residue.atoms]
|
|
201
|
+
if len(indices) != len(target):
|
|
202
|
+
rejected.append(f"residue {getattr(residue, 'number', '?')}: {len(indices)} atoms")
|
|
203
|
+
continue
|
|
204
|
+
names = [str(atom.name) for atom in residue.atoms]
|
|
205
|
+
if target_atom_names is not None and names != target_atom_names:
|
|
206
|
+
rejected.append(f"residue {getattr(residue, 'number', '?')}: atom names {names}")
|
|
207
|
+
continue
|
|
208
|
+
candidate = topology_coordinates[indices]
|
|
209
|
+
max_deviation = float(np.max(np.linalg.norm(candidate - target, axis=1)))
|
|
210
|
+
if max_deviation <= coordinate_tolerance_angstrom:
|
|
211
|
+
matches.append((indices, max_deviation))
|
|
212
|
+
else:
|
|
213
|
+
rejected.append(
|
|
214
|
+
f"residue {getattr(residue, 'number', '?')}: coordinate max deviation "
|
|
215
|
+
f"{max_deviation:.3f} A"
|
|
216
|
+
)
|
|
217
|
+
if len(matches) != 1:
|
|
218
|
+
details = "; ".join(rejected) if rejected else "no residue-name candidates"
|
|
219
|
+
raise PointChargeError(
|
|
220
|
+
f"Could not map ligand {ligand.id} uniquely in provisional topology; "
|
|
221
|
+
"mapping requires residue name, atom count/order/names, and coordinates to agree. "
|
|
222
|
+
f"Candidates: {details}."
|
|
223
|
+
)
|
|
224
|
+
return matches[0][0]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _category(atom: object) -> str:
|
|
228
|
+
name = str(atom.residue.name).upper()
|
|
229
|
+
if name in WATER_RESIDUES:
|
|
230
|
+
return "water"
|
|
231
|
+
if name in STANDARD_PROTEIN_RESIDUES:
|
|
232
|
+
return "protein"
|
|
233
|
+
return "ligand"
|