Skip to content

Commit 04387d7

Browse files
authored
Merge pull request #1043 from OpenFreeEnergy/omm-restraints
Restraints API
2 parents ccf856a + 27447b3 commit 04387d7

27 files changed

+5004
-1
lines changed

news/restraints_api.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
**Added:**
2+
3+
* Adds a new internal API for defining alchemical restraints (PR #1043).
4+
5+
**Changed:**
6+
7+
* <news item>
8+
9+
**Deprecated:**
10+
11+
* <news item>
12+
13+
**Removed:**
14+
15+
* <news item>
16+
17+
**Fixed:**
18+
19+
* <news item>
20+
21+
**Security:**
22+
23+
* <news item>

openfe/protocols/restraint_utils/__init__.py

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .base import BaseRestraintGeometry, HostGuestRestraintGeometry
2+
from .boresch import BoreschRestraintGeometry
3+
from .flatbottom import FlatBottomDistanceGeometry
4+
from .harmonic import DistanceRestraintGeometry
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# This code is part of OpenFE and is licensed under the MIT license.
2+
# For details, see https://github.com/OpenFreeEnergy/openfe
3+
"""
4+
Restraint Geometry classes
5+
6+
TODO
7+
----
8+
* Add relevant duecredit entries.
9+
"""
10+
import abc
11+
12+
from pydantic.v1 import BaseModel, validator
13+
14+
15+
class BaseRestraintGeometry(BaseModel, abc.ABC):
16+
"""
17+
A base class for a restraint geometry.
18+
"""
19+
20+
class Config:
21+
arbitrary_types_allowed = True
22+
23+
24+
class HostGuestRestraintGeometry(BaseRestraintGeometry):
25+
"""
26+
An ordered list of guest atoms to restrain.
27+
28+
Note
29+
----
30+
The order matters! It will be used to define the underlying
31+
force.
32+
"""
33+
34+
guest_atoms: list[int]
35+
"""
36+
An ordered list of host atoms to restrain.
37+
38+
Note
39+
----
40+
The order matters! It will be used to define the underlying
41+
force.
42+
"""
43+
host_atoms: list[int]
44+
45+
@validator("guest_atoms", "host_atoms")
46+
def positive_idxs(cls, v):
47+
if v is not None and any([i < 0 for i in v]):
48+
errmsg = "negative indices passed"
49+
raise ValueError(errmsg)
50+
return v
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .geometry import BoreschRestraintGeometry, find_boresch_restraint
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# This code is part of OpenFE and is licensed under the MIT license.
2+
# For details, see https://github.com/OpenFreeEnergy/openfe
3+
"""
4+
Restraint Geometry classes
5+
6+
TODO
7+
----
8+
* Add relevant duecredit entries.
9+
"""
10+
from typing import Optional
11+
12+
import MDAnalysis as mda
13+
from gufe.vendor.openff.models.types import FloatQuantity
14+
from MDAnalysis.lib.distances import calc_angles, calc_bonds, calc_dihedrals
15+
from openfe.protocols.restraint_utils.geometry.base import HostGuestRestraintGeometry
16+
from openff.units import Quantity, unit
17+
from rdkit import Chem
18+
19+
from .guest import find_guest_atom_candidates
20+
from .host import find_host_anchor, find_host_atom_candidates
21+
22+
23+
class BoreschRestraintGeometry(HostGuestRestraintGeometry):
24+
"""
25+
A class that defines the restraint geometry for a Boresch restraint.
26+
27+
The restraint is defined by the following:
28+
29+
H2 G2
30+
- -
31+
- -
32+
H1 - - H0 -- G0 - - G1
33+
34+
Where HX represents the X index of ``host_atoms`` and GX
35+
the X index of ``guest_atoms``.
36+
"""
37+
38+
r_aA0: FloatQuantity["nanometer"]
39+
"""
40+
The equilibrium distance between H0 and G0.
41+
"""
42+
theta_A0: FloatQuantity["radians"]
43+
"""
44+
The equilibrium angle value between H1, H0, and G0.
45+
"""
46+
theta_B0: FloatQuantity["radians"]
47+
"""
48+
The equilibrium angle value between H0, G0, and G1.
49+
"""
50+
phi_A0: FloatQuantity["radians"]
51+
"""
52+
The equilibrium dihedral value between H2, H1, H0, and G0.
53+
"""
54+
phi_B0: FloatQuantity["radians"]
55+
56+
"""
57+
The equilibrium dihedral value between H1, H0, G0, and G1.
58+
"""
59+
phi_C0: FloatQuantity["radians"]
60+
61+
"""
62+
The equilibrium dihedral value between H0, G0, G1, and G2.
63+
"""
64+
65+
66+
def _get_restraint_distances(
67+
atomgroup: mda.AtomGroup,
68+
) -> tuple[Quantity, Quantity, Quantity, Quantity, Quantity, Quantity]:
69+
"""
70+
Get the bond, angle, and dihedral distances for an input atomgroup
71+
defining the six atoms for a Boresch-like restraint.
72+
73+
The atoms must be in the order of H0, H1, H2, G0, G1, G2.
74+
75+
Parameters
76+
----------
77+
atomgroup : mda.AtomGroup
78+
An AtomGroup defining the restrained atoms in order.
79+
80+
Returns
81+
-------
82+
bond : openff.units.Quantity
83+
The H0-G0 bond value.
84+
angle1 : openff.units.Quantity
85+
The H1-H0-G0 angle value.
86+
angle2 : openff.units.Quantity
87+
The H0-G0-G1 angle value.
88+
dihed1 : openff.units.Quantity
89+
The H2-H1-H0-G0 dihedral value.
90+
dihed2 : openff.units.Quantity
91+
The H1-H0-G0-G1 dihedral value.
92+
dihed3 : openff.units.Quantity
93+
The H0-G0-G1-G2 dihedral value.
94+
"""
95+
bond = (
96+
calc_bonds(
97+
atomgroup.atoms[0].position,
98+
atomgroup.atoms[3].position,
99+
box=atomgroup.dimensions,
100+
)
101+
* unit.angstroms
102+
)
103+
104+
angles = []
105+
for idx_set in [[1, 0, 3], [0, 3, 4]]:
106+
angle = calc_angles(
107+
atomgroup.atoms[idx_set[0]].position,
108+
atomgroup.atoms[idx_set[1]].position,
109+
atomgroup.atoms[idx_set[2]].position,
110+
box=atomgroup.dimensions,
111+
)
112+
angles.append(angle * unit.radians)
113+
114+
dihedrals = []
115+
for idx_set in [[2, 1, 0, 3], [1, 0, 3, 4], [0, 3, 4, 5]]:
116+
dihed = calc_dihedrals(
117+
atomgroup.atoms[idx_set[0]].position,
118+
atomgroup.atoms[idx_set[1]].position,
119+
atomgroup.atoms[idx_set[2]].position,
120+
atomgroup.atoms[idx_set[3]].position,
121+
box=atomgroup.dimensions,
122+
)
123+
dihedrals.append(dihed * unit.radians)
124+
125+
return bond, angles[0], angles[1], dihedrals[0], dihedrals[1], dihedrals[2]
126+
127+
128+
def find_boresch_restraint(
129+
universe: mda.Universe,
130+
guest_rdmol: Chem.Mol,
131+
guest_idxs: list[int],
132+
host_idxs: list[int],
133+
guest_restraint_atoms_idxs: Optional[list[int]] = None,
134+
host_restraint_atoms_idxs: Optional[list[int]] = None,
135+
host_selection: str = "all",
136+
dssp_filter: bool = False,
137+
rmsf_cutoff: Quantity = 0.1 * unit.nanometer,
138+
host_min_distance: Quantity = 1 * unit.nanometer,
139+
host_max_distance: Quantity = 3 * unit.nanometer,
140+
angle_force_constant: Quantity = (
141+
83.68 * unit.kilojoule_per_mole / unit.radians**2
142+
),
143+
temperature: Quantity = 298.15 * unit.kelvin,
144+
) -> BoreschRestraintGeometry:
145+
"""
146+
Find suitable Boresch-style restraints between a host and guest entity
147+
based on the approach of Baumann et al. [1] with some modifications.
148+
149+
Parameters
150+
----------
151+
universe : mda.Universe
152+
An MDAnalysis Universe defining the system and its coordinates.
153+
guest_rdmol : Chem.Mol
154+
An RDKit Mol for the guest molecule.
155+
guest_idxs : list[int]
156+
Indices in the topology for the guest molecule.
157+
host_idxs : list[int]
158+
Indices in the topology for the host molecule.
159+
guest_restraint_atoms_idxs : Optional[list[int]]
160+
User selected indices of the guest molecule itself (i.e. indexed
161+
starting a 0 for the guest molecule). This overrides the
162+
restraint search and a restraint using these indices will
163+
be returned. Must be defined alongside ``host_restraint_atoms_idxs``.
164+
host_restraint_atoms_idxs : Optional[list[int]]
165+
User selected indices of the host molecule itself (i.e. indexed
166+
starting a 0 for the hosts molecule). This overrides the
167+
restraint search and a restraint using these indices will
168+
be returned. Must be defined alongside ``guest_restraint_atoms_idxs``.
169+
host_selection : str
170+
An MDAnalysis selection string to sub-select the host atoms.
171+
dssp_filter : bool
172+
Whether or not to filter the host atoms by their secondary structure.
173+
rmsf_cutoff : openff.units.Quantity
174+
The cutoff value for atom root mean square fluctuation. Atoms with RMSF
175+
values above this cutoff will be disregarded.
176+
Must be in units compatible with nanometer.
177+
host_min_distance : openff.units.Quantity
178+
The minimum distance between any host atom and the guest G0 atom.
179+
Must be in units compatible with nanometer.
180+
host_max_distance : openff.units.Quantity
181+
The maximum distance between any host atom and the guest G0 atom.
182+
Must be in units compatible with nanometer.
183+
angle_force_constant : openff.units.Quantity
184+
The force constant for the G1-G0-H0 and G0-H0-H1 angles. Must be
185+
in units compatible with kilojoule / mole / radians ** 2.
186+
temperature : openff.units.Quantity
187+
The system temperature in units compatible with Kelvin.
188+
189+
Returns
190+
-------
191+
BoreschRestraintGeometry
192+
An object defining the parameters of the Boresch-like restraint.
193+
194+
References
195+
----------
196+
[1] Baumann, Hannah M., et al. "Broadening the scope of binding free energy
197+
calculations using a Separated Topologies approach." (2023).
198+
"""
199+
if (guest_restraint_atoms_idxs is not None) and (host_restraint_atoms_idxs is not None): # fmt: skip
200+
# In this case assume the picked atoms were intentional /
201+
# representative of the input and go with it
202+
guest_ag = universe.atoms[guest_idxs]
203+
guest_atoms = [at.ix for at in guest_ag.atoms[guest_restraint_atoms_idxs]]
204+
host_ag = universe.atoms[host_idxs]
205+
host_atoms = [at.ix for at in host_ag.atoms[host_restraint_atoms_idxs]]
206+
207+
# Set the equilibrium values as those of the final frame
208+
universe.trajectory[-1]
209+
atomgroup = universe.atoms[host_atoms + guest_atoms]
210+
bond, ang1, ang2, dih1, dih2, dih3 = _get_restraint_distances(atomgroup)
211+
212+
# TODO: add checks to warn if this is a badly picked
213+
# set of atoms.
214+
return BoreschRestraintGeometry(
215+
host_atoms=host_atoms,
216+
guest_atoms=guest_atoms,
217+
r_aA0=bond,
218+
theta_A0=ang1,
219+
theta_B0=ang2,
220+
phi_A0=dih1,
221+
phi_B0=dih2,
222+
phi_C0=dih3,
223+
)
224+
225+
if (guest_restraint_atoms_idxs is not None) ^ (host_restraint_atoms_idxs is not None): # fmt: skip
226+
# This is not an intended outcome, crash out here
227+
errmsg = (
228+
"both ``guest_restraints_atoms_idxs`` and "
229+
"``host_restraint_atoms_idxs`` "
230+
"must be set or both must be None. "
231+
f"Got {guest_restraint_atoms_idxs} and {host_restraint_atoms_idxs}"
232+
)
233+
raise ValueError(errmsg)
234+
235+
# 1. Fetch the guest anchors
236+
guest_anchors = find_guest_atom_candidates(
237+
universe=universe,
238+
rdmol=guest_rdmol,
239+
guest_idxs=guest_idxs,
240+
rmsf_cutoff=rmsf_cutoff,
241+
)
242+
243+
if len(guest_anchors) == 0:
244+
errmsg = "No suitable ligand atoms found for the restraint."
245+
raise ValueError(errmsg)
246+
247+
# 2. We then loop through the guest anchors to find suitable host atoms
248+
for guest_anchor in guest_anchors:
249+
# We next fetch the host atom pool
250+
# Note: return is a set, so need to convert it later on
251+
host_pool = find_host_atom_candidates(
252+
universe=universe,
253+
host_idxs=host_idxs,
254+
l1_idx=guest_anchor[0],
255+
host_selection=host_selection,
256+
dssp_filter=dssp_filter,
257+
rmsf_cutoff=rmsf_cutoff,
258+
min_distance=host_min_distance,
259+
max_distance=host_max_distance,
260+
)
261+
262+
host_anchor = find_host_anchor(
263+
guest_atoms=universe.atoms[list(guest_anchor)],
264+
host_atom_pool=universe.atoms[list(host_pool)],
265+
minimum_distance=0.5 * unit.nanometer,
266+
angle_force_constant=angle_force_constant,
267+
temperature=temperature,
268+
)
269+
# continue if it's empty, otherwise stop
270+
if host_anchor is not None:
271+
break
272+
273+
if host_anchor is None:
274+
errmsg = "No suitable host atoms could be found"
275+
raise ValueError(errmsg)
276+
277+
# Set the equilibrium values as those of the final frame
278+
universe.trajectory[-1]
279+
atomgroup = universe.atoms[list(host_anchor) + list(guest_anchor)]
280+
bond, ang1, ang2, dih1, dih2, dih3 = _get_restraint_distances(atomgroup)
281+
282+
return BoreschRestraintGeometry(
283+
host_atoms=list(host_anchor),
284+
guest_atoms=list(guest_anchor),
285+
r_aA0=bond,
286+
theta_A0=ang1,
287+
theta_B0=ang2,
288+
phi_A0=dih1,
289+
phi_B0=dih2,
290+
phi_C0=dih3,
291+
)

0 commit comments

Comments
 (0)