forcefill 1.0.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.
- forcefill/__init__.py +88 -0
- forcefill/_pipeline.py +440 -0
- forcefill/_residue_names.py +344 -0
- forcefill/_spec.py +449 -0
- forcefill/amber.py +280 -0
- forcefill/charmm.py +371 -0
- forcefill/checks.py +463 -0
- forcefill/clean_structure.py +428 -0
- forcefill/espaloma.py +178 -0
- forcefill/ligand.py +315 -0
- forcefill/ligand_files.py +632 -0
- forcefill/merge.py +164 -0
- forcefill/preflight.py +205 -0
- forcefill/py.typed +0 -0
- forcefill/smirnoff.py +480 -0
- forcefill/structure.py +368 -0
- forcefill/topology.py +223 -0
- forcefill-1.0.0.dist-info/METADATA +208 -0
- forcefill-1.0.0.dist-info/RECORD +22 -0
- forcefill-1.0.0.dist-info/WHEEL +5 -0
- forcefill-1.0.0.dist-info/licenses/LICENSE +21 -0
- forcefill-1.0.0.dist-info/top_level.txt +1 -0
forcefill/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from importlib import metadata as _metadata
|
|
2
|
+
|
|
3
|
+
from ._pipeline import ParameterizationResult
|
|
4
|
+
from ._spec import (
|
|
5
|
+
BACKENDS,
|
|
6
|
+
CHARMM_BASE_FORCEFIELD,
|
|
7
|
+
DEFAULT_BASE_FORCEFIELD,
|
|
8
|
+
DEFAULT_ESPALOMA_FORCEFIELD,
|
|
9
|
+
DEFAULT_SMIRNOFF_FORCEFIELD,
|
|
10
|
+
LigandSpec,
|
|
11
|
+
)
|
|
12
|
+
from .amber import (
|
|
13
|
+
DEFAULT_AMBERTOOLS_TIMEOUT,
|
|
14
|
+
assemble_openmm_ffxml,
|
|
15
|
+
locate_gaff_dat,
|
|
16
|
+
run_antechamber,
|
|
17
|
+
run_parmchk2,
|
|
18
|
+
)
|
|
19
|
+
from .checks import (
|
|
20
|
+
DEFAULT_MINIMIZATION_PLATFORM,
|
|
21
|
+
DEFAULT_MINIMIZATION_TOLERANCE,
|
|
22
|
+
MinimizationResult,
|
|
23
|
+
add_extra_particles,
|
|
24
|
+
minimize_with_forcefield_xml,
|
|
25
|
+
residue_templates_with_virtual_sites,
|
|
26
|
+
validate_forcefield_xml,
|
|
27
|
+
)
|
|
28
|
+
from .clean_structure import (
|
|
29
|
+
ADDITIVE_RESIDUES,
|
|
30
|
+
BULK_ION_RESIDUES,
|
|
31
|
+
STRUCTURAL_METAL_RESIDUES,
|
|
32
|
+
WATER_RESIDUES,
|
|
33
|
+
CleaningResult,
|
|
34
|
+
clean_pdb,
|
|
35
|
+
clean_topology,
|
|
36
|
+
)
|
|
37
|
+
from .ligand import build_ligand_xml
|
|
38
|
+
from .merge import merge_ffxml
|
|
39
|
+
from .structure import build_forcefield_xml
|
|
40
|
+
from .topology import extract_residue_to_pdb, find_nonstandard_residues
|
|
41
|
+
|
|
42
|
+
# The version lives in pyproject.toml and reaches here through the installed
|
|
43
|
+
# metadata; the fallback covers an uninstalled checkout.
|
|
44
|
+
try:
|
|
45
|
+
__version__ = _metadata.version("forcefill")
|
|
46
|
+
except _metadata.PackageNotFoundError: # uninstalled checkout
|
|
47
|
+
__version__ = "0.0.0+unknown"
|
|
48
|
+
|
|
49
|
+
# The reading and conversion helpers are not re-exported: they say where they
|
|
50
|
+
# belong (`forcefill.ligand_files.inspect_ligand_file(...)`,
|
|
51
|
+
# `forcefill.charmm.read_charmm_files(...)`), and the top level stays about the
|
|
52
|
+
# pipeline.
|
|
53
|
+
# `clean_structure` is also the name of a *module* here; it stays out of
|
|
54
|
+
# __all__ because at the top level that name reads as build_forcefield_xml's
|
|
55
|
+
# `clean_structure=` switch. Its public names are re-exported above.
|
|
56
|
+
__all__ = [
|
|
57
|
+
"ADDITIVE_RESIDUES",
|
|
58
|
+
"BACKENDS",
|
|
59
|
+
"BULK_ION_RESIDUES",
|
|
60
|
+
"CHARMM_BASE_FORCEFIELD",
|
|
61
|
+
"DEFAULT_AMBERTOOLS_TIMEOUT",
|
|
62
|
+
"DEFAULT_BASE_FORCEFIELD",
|
|
63
|
+
"DEFAULT_ESPALOMA_FORCEFIELD",
|
|
64
|
+
"DEFAULT_MINIMIZATION_PLATFORM",
|
|
65
|
+
"DEFAULT_MINIMIZATION_TOLERANCE",
|
|
66
|
+
"DEFAULT_SMIRNOFF_FORCEFIELD",
|
|
67
|
+
"STRUCTURAL_METAL_RESIDUES",
|
|
68
|
+
"WATER_RESIDUES",
|
|
69
|
+
"CleaningResult",
|
|
70
|
+
"LigandSpec",
|
|
71
|
+
"MinimizationResult",
|
|
72
|
+
"ParameterizationResult",
|
|
73
|
+
"add_extra_particles",
|
|
74
|
+
"assemble_openmm_ffxml",
|
|
75
|
+
"build_forcefield_xml",
|
|
76
|
+
"build_ligand_xml",
|
|
77
|
+
"clean_pdb",
|
|
78
|
+
"clean_topology",
|
|
79
|
+
"extract_residue_to_pdb",
|
|
80
|
+
"find_nonstandard_residues",
|
|
81
|
+
"locate_gaff_dat",
|
|
82
|
+
"merge_ffxml",
|
|
83
|
+
"minimize_with_forcefield_xml",
|
|
84
|
+
"residue_templates_with_virtual_sites",
|
|
85
|
+
"run_antechamber",
|
|
86
|
+
"run_parmchk2",
|
|
87
|
+
"validate_forcefield_xml",
|
|
88
|
+
]
|
forcefill/_pipeline.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""The machinery :func:`~forcefill.build_forcefield_xml` and :func:`~forcefill.build_ligand_xml` share.
|
|
2
|
+
|
|
3
|
+
The two entry points differ only in where the ligands come from - a PDB's
|
|
4
|
+
unmatched residues, or the caller's own list. Everything after that is the same
|
|
5
|
+
work and lives here: preparing a backend, owning the working directory, running
|
|
6
|
+
one residue through to a per-residue XML, and combining those into one file.
|
|
7
|
+
Each entry point then just resolves its input into ``{name: ResolvedSpec}``,
|
|
8
|
+
calls in here, and assembles a :class:`ParameterizationResult`.
|
|
9
|
+
|
|
10
|
+
AmberTools is reached as ``amber.run_antechamber(...)`` rather than through a
|
|
11
|
+
from-import, deliberately: a from-import binds a *copy* at import time, which a
|
|
12
|
+
test stubbing ``forcefill.amber`` could never reach.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import shutil
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from openmm import app, unit
|
|
27
|
+
|
|
28
|
+
from . import amber, charmm, espaloma, smirnoff
|
|
29
|
+
from ._spec import CHARMM_BASE_FORCEFIELD, DEFAULT_BASE_FORCEFIELD, PathLike, ResolvedSpec
|
|
30
|
+
from .checks import MinimizationResult
|
|
31
|
+
from .clean_structure import CleaningResult
|
|
32
|
+
from .merge import _SCALE_TOLERANCE, merge_ffxml
|
|
33
|
+
from .topology import extract_residue_to_pdb
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from openff.toolkit import ForceField
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
__all__ = ["ParameterizationResult"]
|
|
41
|
+
|
|
42
|
+
#: 1-4 scaling each backend's output declares, compared against the base force
|
|
43
|
+
#: field by :func:`check_backends_match_base`. The Amber pair is what ParmEd
|
|
44
|
+
#: writes from ``gaff*.dat`` and openmmforcefields for SMIRNOFF; the CHARMM pair
|
|
45
|
+
#: is what ParmEd writes from a ``CharmmParameterSet``. The smirnoff entry holds
|
|
46
|
+
#: only for a stock release - a custom OFFXML is measured instead, by
|
|
47
|
+
#: :func:`prepare_smirnoff_backend`. Espaloma predicts valence parameters but
|
|
48
|
+
#: takes its non-bonded convention from the OpenFF force field it is built on
|
|
49
|
+
#: (``openff_unconstrained-2.2.1``), so it declares the Amber pair too.
|
|
50
|
+
_BACKEND_14_SCALES = {
|
|
51
|
+
"gaff": (0.8333333333333334, 0.5),
|
|
52
|
+
"smirnoff": (0.8333333333333334, 0.5),
|
|
53
|
+
"espaloma": (0.8333333333333334, 0.5),
|
|
54
|
+
"charmm": (1.0, 1.0),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ParameterizationResult:
|
|
60
|
+
"""What :func:`~forcefill.build_forcefield_xml` produced.
|
|
61
|
+
|
|
62
|
+
Also the return type of :func:`~forcefill.build_ligand_xml`, where
|
|
63
|
+
``skipped``, ``cleaning`` and ``full_minimization`` are always empty: with no
|
|
64
|
+
input structure there is nothing to skip, clean or minimize as a whole.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
#: Path to the combined ffxml covering every parameterized residue
|
|
68
|
+
#: (``None`` when nothing needed parameterizing).
|
|
69
|
+
forcefield_xml: str | None
|
|
70
|
+
#: Per-residue ffxml files, keyed by residue name (empty after
|
|
71
|
+
#: ``cleanup=True`` - they live in the working directory).
|
|
72
|
+
residue_xmls: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
#: Residue names that were successfully parameterized.
|
|
74
|
+
parameterized: list[str] = field(default_factory=list)
|
|
75
|
+
#: Residue names that were skipped, mapped to the reason.
|
|
76
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
#: Directory holding intermediate files (per-residue PDB/mol2/frcmod);
|
|
78
|
+
#: ``None`` when nothing was parameterized or after ``cleanup=True``.
|
|
79
|
+
workdir: str | None = None
|
|
80
|
+
#: Per-residue vacuum minimizations, keyed by residue name. Empty unless
|
|
81
|
+
#: ``minimize=True``.
|
|
82
|
+
minimizations: dict[str, MinimizationResult] = field(default_factory=dict)
|
|
83
|
+
#: Minimization of the whole input topology - the *cleaned* topology when
|
|
84
|
+
#: ``clean_structure=True``, so reconcile ``n_atoms`` against
|
|
85
|
+
#: ``cleaning.n_atoms_after``. ``None`` unless ``minimize=True`` *and* no
|
|
86
|
+
#: residue was skipped.
|
|
87
|
+
full_minimization: MinimizationResult | None = None
|
|
88
|
+
#: What ``clean_structure=True`` removed from the input. ``None`` when it
|
|
89
|
+
#: was off, in which case nothing was deleted.
|
|
90
|
+
cleaning: CleaningResult | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ResidueArtifacts:
|
|
95
|
+
"""What parameterizing one residue produced.
|
|
96
|
+
|
|
97
|
+
``mol2``/``frcmod`` are None for the smirnoff backend, which has no
|
|
98
|
+
intermediate Amber files - only the finished XML is common to both.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
xml: str
|
|
102
|
+
mol2: str | None = None
|
|
103
|
+
frcmod: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class SmirnoffProfile:
|
|
108
|
+
"""A custom SMIRNOFF force field, loaded once and measured rather than assumed."""
|
|
109
|
+
|
|
110
|
+
#: The loaded force field, reused by the preflight checks rather than reread.
|
|
111
|
+
forcefield: ForceField
|
|
112
|
+
#: ``(coulomb, lj)`` 1-4 scaling it declares.
|
|
113
|
+
scales: tuple[float, float]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def prepare_gaff_backend(specs: Mapping[str, ResolvedSpec], atom_type: str) -> str | None:
|
|
117
|
+
"""Check the gaff backend can run and return the GAFF database, or None if unused.
|
|
118
|
+
|
|
119
|
+
Called before the working directory exists, so a missing AmberTools install
|
|
120
|
+
fails immediately rather than after the first ligand has been read.
|
|
121
|
+
"""
|
|
122
|
+
if not any(spec.backend == "gaff" for spec in specs.values()):
|
|
123
|
+
return None
|
|
124
|
+
amber.require_executable("antechamber")
|
|
125
|
+
amber.require_executable("parmchk2")
|
|
126
|
+
gaff_dat = amber.locate_gaff_dat(atom_type)
|
|
127
|
+
log.info("Using GAFF parameter database: %s", gaff_dat)
|
|
128
|
+
return gaff_dat
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def prepare_smirnoff_backend(specs: Mapping[str, ResolvedSpec]) -> dict[tuple[str, ...], SmirnoffProfile]:
|
|
132
|
+
"""Load every custom SMIRNOFF force field once, before anything expensive runs.
|
|
133
|
+
|
|
134
|
+
Called where :func:`prepare_gaff_backend` is - before the working directory
|
|
135
|
+
exists and before the first ligand is read - so a mistyped OFFXML path fails
|
|
136
|
+
immediately rather than after the ligands ahead of it have had their charges
|
|
137
|
+
assigned. Stock release names are trusted and not loaded.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
``{selection: profile}`` for the custom selections only, empty when every
|
|
141
|
+
smirnoff spec names a release (and when no spec uses the backend at all).
|
|
142
|
+
"""
|
|
143
|
+
profiles: dict[tuple[str, ...], SmirnoffProfile] = {}
|
|
144
|
+
for spec in specs.values():
|
|
145
|
+
if spec.backend != "smirnoff" or not smirnoff.is_custom_forcefield(spec.forcefield):
|
|
146
|
+
continue
|
|
147
|
+
if spec.forcefield in profiles:
|
|
148
|
+
continue
|
|
149
|
+
forcefield = smirnoff.load_forcefield(spec.forcefield)
|
|
150
|
+
profiles[spec.forcefield] = SmirnoffProfile(
|
|
151
|
+
forcefield=forcefield,
|
|
152
|
+
scales=smirnoff.forcefield_14_scales(forcefield),
|
|
153
|
+
)
|
|
154
|
+
log.info("Loaded custom SMIRNOFF force field: %s", ", ".join(spec.forcefield))
|
|
155
|
+
return profiles
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def prepare_espaloma_backend(specs: Mapping[str, ResolvedSpec]) -> None:
|
|
159
|
+
"""Check the espaloma backend can run, before anything expensive happens.
|
|
160
|
+
|
|
161
|
+
The counterpart of :func:`prepare_gaff_backend`: espaloma is an optional
|
|
162
|
+
dependency that openmmforcefields imports only inside the generator
|
|
163
|
+
constructor, so without this the failure arrives after the first ligand has
|
|
164
|
+
been read - and, on a fresh machine, after a model has been downloaded.
|
|
165
|
+
"""
|
|
166
|
+
if not any(spec.backend == "espaloma" for spec in specs.values()):
|
|
167
|
+
return
|
|
168
|
+
espaloma.require_espaloma()
|
|
169
|
+
models = sorted({spec.forcefield[0] for spec in specs.values() if spec.backend == "espaloma"})
|
|
170
|
+
log.info("Using Espaloma model(s): %s", ", ".join(models))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_backends_match_base(
|
|
174
|
+
specs: Mapping[str, ResolvedSpec],
|
|
175
|
+
base_forcefield: Sequence[str],
|
|
176
|
+
smirnoff_profiles: Mapping[tuple[str, ...], SmirnoffProfile] | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Refuse a combination OpenMM could never load, before anything expensive runs.
|
|
179
|
+
|
|
180
|
+
Amber-family force fields scale 1-4 interactions by 0.8333/0.5 and CHARMM by
|
|
181
|
+
1.0/1.0, and OpenMM rejects a ``ForceField`` whose files disagree. Two
|
|
182
|
+
combinations are therefore impossible rather than inadvisable, and are worth
|
|
183
|
+
naming here instead of an hour into a build:
|
|
184
|
+
|
|
185
|
+
* a charmm ligand mixed with a gaff or smirnoff one, whose merged XML
|
|
186
|
+
could not be loaded at all;
|
|
187
|
+
* a backend whose output does not match the base force field it is
|
|
188
|
+
validated against.
|
|
189
|
+
|
|
190
|
+
The base convention is read from the loaded force field, so a custom one is
|
|
191
|
+
checked as accurately as the two presets. So is the backend's, when
|
|
192
|
+
*smirnoff_profiles* carries a custom OFFXML: every stock SMIRNOFF release
|
|
193
|
+
scales 0.8333/0.5, but an arbitrary one need not, and assuming it would put
|
|
194
|
+
the one number this function exists to check back into a literal.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
specs: Resolved specs, keyed by residue name.
|
|
198
|
+
base_forcefield: The base the generated XML will be loaded with.
|
|
199
|
+
smirnoff_profiles: Custom force fields from
|
|
200
|
+
:func:`prepare_smirnoff_backend`, whose measured scales are used in
|
|
201
|
+
place of the stock literal.
|
|
202
|
+
"""
|
|
203
|
+
backends = {spec.backend for spec in specs.values()}
|
|
204
|
+
if not backends:
|
|
205
|
+
return
|
|
206
|
+
charmm_names = sorted(name for name, spec in specs.items() if spec.backend == "charmm")
|
|
207
|
+
amber_names = sorted(name for name, spec in specs.items() if spec.backend != "charmm")
|
|
208
|
+
if charmm_names and amber_names:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"Cannot build one force field from both CHARMM and Amber-family "
|
|
211
|
+
f"parameters: {charmm_names} use the charmm backend and {amber_names} "
|
|
212
|
+
f"use {sorted(backends - {'charmm'})}. The two conventions scale 1-4 "
|
|
213
|
+
"interactions differently (CHARMM 1.0/1.0, Amber 0.8333/0.5) and "
|
|
214
|
+
"OpenMM will not load a force field that says both. Build them "
|
|
215
|
+
"separately, against their own base force fields."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Only one family is in play now, and gaff, smirnoff and espaloma share a
|
|
219
|
+
# convention, so any one backend answers for all of them - unless a custom
|
|
220
|
+
# OFFXML says otherwise, in which case it answers for itself.
|
|
221
|
+
expected = _BACKEND_14_SCALES["charmm" if charmm_names else "gaff"]
|
|
222
|
+
if not charmm_names:
|
|
223
|
+
expected = _custom_smirnoff_scales(specs, smirnoff_profiles or {}, expected)
|
|
224
|
+
actual = charmm.base_14_scales(base_forcefield)
|
|
225
|
+
# None: the base force field declares no non-bonded terms at all, so there is
|
|
226
|
+
# nothing for the generated XML to contradict.
|
|
227
|
+
if actual is None or _scales_agree(expected, actual):
|
|
228
|
+
return
|
|
229
|
+
wanted = CHARMM_BASE_FORCEFIELD if charmm_names else DEFAULT_BASE_FORCEFIELD
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"The {'/'.join(sorted(backends))} backend produces parameters with 1-4 "
|
|
232
|
+
f"scaling {expected[0]:g}/{expected[1]:g} (coulomb/lj), but the base force "
|
|
233
|
+
f"field {list(base_forcefield)} declares {actual[0]:g}/{actual[1]:g}. OpenMM "
|
|
234
|
+
"cannot load the two together, so the generated XML would be unusable "
|
|
235
|
+
f"even though it built. Pass base_forcefield={list(wanted)}, or switch "
|
|
236
|
+
"backend to match the base force field you want."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _custom_smirnoff_scales(
|
|
241
|
+
specs: Mapping[str, ResolvedSpec],
|
|
242
|
+
profiles: Mapping[tuple[str, ...], SmirnoffProfile],
|
|
243
|
+
fallback: tuple[float, float],
|
|
244
|
+
) -> tuple[float, float]:
|
|
245
|
+
"""The 1-4 scaling the smirnoff specs will actually produce, or *fallback*.
|
|
246
|
+
|
|
247
|
+
Custom force fields that disagree with each other, or with the stock scaling
|
|
248
|
+
a gaff or stock-smirnoff ligand in the same build produces, are refused here:
|
|
249
|
+
they would be merged into one XML declaring two different conventions, which
|
|
250
|
+
OpenMM rejects at load time with nothing to say about which ligand caused it.
|
|
251
|
+
"""
|
|
252
|
+
measured = {profiles[spec.forcefield].scales: spec.name for spec in specs.values() if spec.forcefield in profiles}
|
|
253
|
+
if not measured:
|
|
254
|
+
return fallback
|
|
255
|
+
# A stock-scaled ligand in the same build has to be accounted for too.
|
|
256
|
+
if any(spec.forcefield not in profiles for spec in specs.values()):
|
|
257
|
+
measured.setdefault(fallback, "the gaff/stock-smirnoff ligands")
|
|
258
|
+
if len(measured) == 1:
|
|
259
|
+
return next(iter(measured))
|
|
260
|
+
described = ", ".join(f"{name} ({s[0]:g}/{s[1]:g})" for s, name in sorted(measured.items()))
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"The ligands in this build declare more than one 1-4 scaling "
|
|
263
|
+
f"(coulomb/lj): {described}. They would be merged into one XML that says "
|
|
264
|
+
"both, which OpenMM refuses to load. Build them separately, or use force "
|
|
265
|
+
"fields that agree."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _scales_agree(expected: tuple[float, float], actual: tuple[float, float]) -> bool:
|
|
270
|
+
"""Compare 1-4 scales with the tolerance OpenMM itself applies when merging them."""
|
|
271
|
+
return all(abs(a - b) <= _SCALE_TOLERANCE for a, b in zip(expected, actual, strict=True))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@contextmanager
|
|
275
|
+
def working_directory(
|
|
276
|
+
workdir: PathLike | None,
|
|
277
|
+
output_xml: PathLike,
|
|
278
|
+
*,
|
|
279
|
+
prefix: str,
|
|
280
|
+
cleanup: bool,
|
|
281
|
+
) -> Iterator[Path]:
|
|
282
|
+
"""Own the intermediate-file directory for one build.
|
|
283
|
+
|
|
284
|
+
Creates *workdir* (or a fresh temporary directory named *prefix*), yields
|
|
285
|
+
it, then keeps it on failure - ``sqm.out`` and the intermediates are the
|
|
286
|
+
post-mortem - and removes it on success only with ``cleanup=True``.
|
|
287
|
+
|
|
288
|
+
Refuses up front if ``cleanup`` would delete *output_xml* along with the
|
|
289
|
+
directory, which is otherwise a silently empty result.
|
|
290
|
+
"""
|
|
291
|
+
workdir = Path(workdir).resolve() if workdir is not None else Path(tempfile.mkdtemp(prefix=prefix))
|
|
292
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
log.info("Intermediate files in %s", workdir)
|
|
294
|
+
if cleanup and Path(output_xml).resolve().is_relative_to(workdir):
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"cleanup=True would delete the output XML: {output_xml} resolves "
|
|
297
|
+
f"inside the working directory {workdir}. Write it elsewhere or "
|
|
298
|
+
"pass cleanup=False."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
yield workdir
|
|
303
|
+
except Exception:
|
|
304
|
+
log.warning("Intermediate files kept for debugging in %s", workdir)
|
|
305
|
+
raise
|
|
306
|
+
|
|
307
|
+
if cleanup:
|
|
308
|
+
shutil.rmtree(workdir)
|
|
309
|
+
log.info("Removed working directory %s", workdir)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def parameterize_one_residue(
|
|
313
|
+
spec: ResolvedSpec,
|
|
314
|
+
residue: app.topology.Residue | None,
|
|
315
|
+
positions: unit.Quantity | None,
|
|
316
|
+
res_dir: Path,
|
|
317
|
+
*,
|
|
318
|
+
gaff_dat: str | None = None,
|
|
319
|
+
timeout: float | None = amber.DEFAULT_AMBERTOOLS_TIMEOUT,
|
|
320
|
+
base_forcefield: Sequence[str] = DEFAULT_BASE_FORCEFIELD,
|
|
321
|
+
espaloma_charge_method: str = espaloma.DEFAULT_ESPALOMA_CHARGE_METHOD,
|
|
322
|
+
) -> ResidueArtifacts:
|
|
323
|
+
"""Run one residue through its backend to a per-residue XML.
|
|
324
|
+
|
|
325
|
+
For ``gaff`` that is extract -> antechamber -> parmchk2 -> ParmEd, with a
|
|
326
|
+
``spec.file`` (SDF/MOL2 with explicit bonds) replacing the extraction step.
|
|
327
|
+
For ``smirnoff`` and ``espaloma`` it is one call into openmmforcefields; for
|
|
328
|
+
``charmm``, a conversion of the ligand's CGenFF files - the one output that
|
|
329
|
+
is *not* self-contained, since it names atom types *base_forcefield* defines
|
|
330
|
+
rather than redefining them. *residue* and *positions* are None in standalone
|
|
331
|
+
mode, where there is no structure to extract from.
|
|
332
|
+
"""
|
|
333
|
+
res_dir.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
name = spec.name
|
|
335
|
+
|
|
336
|
+
if spec.backend == "smirnoff":
|
|
337
|
+
return ResidueArtifacts(xml=smirnoff.smirnoff_residue_ffxml(spec, res_dir / f"{name}.xml"))
|
|
338
|
+
|
|
339
|
+
if spec.backend == "espaloma":
|
|
340
|
+
return ResidueArtifacts(
|
|
341
|
+
xml=espaloma.espaloma_residue_ffxml(spec, res_dir / f"{name}.xml", charge_method=espaloma_charge_method)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if spec.backend == "charmm":
|
|
345
|
+
return ResidueArtifacts(xml=charmm.charmm_residue_ffxml(spec, res_dir / f"{name}.xml", base_forcefield))
|
|
346
|
+
|
|
347
|
+
if spec.file is None:
|
|
348
|
+
if residue is None or positions is None:
|
|
349
|
+
raise ValueError(f"Residue {name} has no ligand file and no structure to extract it from.")
|
|
350
|
+
antechamber_input: PathLike = extract_residue_to_pdb(positions, residue, res_dir / f"{name}.pdb")
|
|
351
|
+
else:
|
|
352
|
+
log.info("Using the supplied ligand file for %s: %s", name, spec.file)
|
|
353
|
+
antechamber_input = spec.file
|
|
354
|
+
|
|
355
|
+
# Explicitly against None: a net charge of 0 is a real, stated value, and
|
|
356
|
+
# `or` would quietly conflate it with "not determined".
|
|
357
|
+
net_charge = spec.net_charge if spec.net_charge is not None else 0
|
|
358
|
+
log.info("antechamber: %s (net charge %+d, %s/%s)", name, net_charge, spec.atom_type, spec.charge_method)
|
|
359
|
+
mol2 = amber.run_antechamber(
|
|
360
|
+
antechamber_input,
|
|
361
|
+
res_dir / f"{name}.mol2",
|
|
362
|
+
name,
|
|
363
|
+
net_charge=net_charge,
|
|
364
|
+
multiplicity=spec.multiplicity,
|
|
365
|
+
atom_type=spec.atom_type,
|
|
366
|
+
charge_method=spec.charge_method,
|
|
367
|
+
extra_args=spec.antechamber_args,
|
|
368
|
+
timeout=timeout,
|
|
369
|
+
)
|
|
370
|
+
frcmod = amber.run_parmchk2(mol2, res_dir / f"{name}.frcmod", atom_type=spec.atom_type, timeout=timeout)
|
|
371
|
+
# Per-residue template XML (self-contained).
|
|
372
|
+
gaff_dat = gaff_dat or amber.locate_gaff_dat(spec.atom_type)
|
|
373
|
+
xml = amber.assemble_openmm_ffxml({name: mol2}, [gaff_dat, frcmod], res_dir / f"{name}.xml")
|
|
374
|
+
log.info("Wrote per-residue XML: %s", xml)
|
|
375
|
+
return ResidueArtifacts(xml=xml, mol2=mol2, frcmod=frcmod)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def combine_residue_xmls(
|
|
379
|
+
artifacts: Mapping[str, ResidueArtifacts],
|
|
380
|
+
specs: Mapping[str, ResolvedSpec],
|
|
381
|
+
gaff_dat: str | None,
|
|
382
|
+
output_xml: PathLike,
|
|
383
|
+
workdir: Path,
|
|
384
|
+
) -> str:
|
|
385
|
+
"""Write the one XML covering every parameterized residue.
|
|
386
|
+
|
|
387
|
+
All-GAFF goes through ParmEd, which merges at the parameter-set level and
|
|
388
|
+
writes only the atom types the templates actually use. Anything else is
|
|
389
|
+
merged as finished XML instead - gaff, smirnoff, espaloma and charmm share
|
|
390
|
+
nothing upstream of that.
|
|
391
|
+
"""
|
|
392
|
+
gaff = {name for name, spec in specs.items() if spec.backend == "gaff"}
|
|
393
|
+
other_names = sorted(set(specs) - gaff)
|
|
394
|
+
if not other_names:
|
|
395
|
+
return amber.assemble_openmm_ffxml(
|
|
396
|
+
{name: artifacts[name].mol2 for name in sorted(gaff)},
|
|
397
|
+
[gaff_dat, *(artifacts[name].frcmod for name in sorted(gaff))],
|
|
398
|
+
output_xml,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
to_merge: list[PathLike] = []
|
|
402
|
+
if gaff:
|
|
403
|
+
# One ParmEd document for all the GAFF residues, so their shared atom
|
|
404
|
+
# types are written once, then merged with the SMIRNOFF ones.
|
|
405
|
+
to_merge.append(
|
|
406
|
+
amber.assemble_openmm_ffxml(
|
|
407
|
+
{name: artifacts[name].mol2 for name in sorted(gaff)},
|
|
408
|
+
[gaff_dat, *(artifacts[name].frcmod for name in sorted(gaff))],
|
|
409
|
+
workdir / "_gaff_combined.xml",
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
to_merge += [artifacts[name].xml for name in other_names]
|
|
413
|
+
return merge_ffxml(to_merge, output_xml)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def parameterize_all(
|
|
417
|
+
specs: Mapping[str, ResolvedSpec],
|
|
418
|
+
residues: Mapping[str, app.topology.Residue],
|
|
419
|
+
positions: unit.Quantity | None,
|
|
420
|
+
workdir: Path,
|
|
421
|
+
*,
|
|
422
|
+
gaff_dat: str | None,
|
|
423
|
+
timeout: float | None,
|
|
424
|
+
base_forcefield: Sequence[str] = DEFAULT_BASE_FORCEFIELD,
|
|
425
|
+
espaloma_charge_method: str = espaloma.DEFAULT_ESPALOMA_CHARGE_METHOD,
|
|
426
|
+
) -> dict[str, ResidueArtifacts]:
|
|
427
|
+
"""Run every spec through its backend, in a stable order."""
|
|
428
|
+
return {
|
|
429
|
+
name: parameterize_one_residue(
|
|
430
|
+
specs[name],
|
|
431
|
+
residues.get(name),
|
|
432
|
+
positions,
|
|
433
|
+
workdir / name,
|
|
434
|
+
gaff_dat=gaff_dat,
|
|
435
|
+
timeout=timeout,
|
|
436
|
+
base_forcefield=base_forcefield,
|
|
437
|
+
espaloma_charge_method=espaloma_charge_method,
|
|
438
|
+
)
|
|
439
|
+
for name in sorted(specs)
|
|
440
|
+
}
|