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 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
+ }