Skip to content

Commit d24e9e8

Browse files
authored
Merge branch 'main' into maint/pydantic_3.13
2 parents 2c29142 + 1692562 commit d24e9e8

18 files changed

Lines changed: 1320 additions & 50 deletions

File tree

.github/workflows/mypy.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
name: "PR: mypy static type checking"
22
on:
33
pull_request:
4-
branches:
5-
- main
64
push:
75
branches:
86
- main

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
],
149149
"accent_color": "cantina-purple",
150150
"navigation_with_keys": False,
151+
"announcement": "The OpenFE team is conducting user interviews until August 15th! <a href=https://app.reclaim.ai/m/james-omsf/openfe-user-interview>Click here</a> to schedule an interview.",
151152
}
152153
html_logo = "_static/OFE-color-icon.svg"
153154
html_favicon = "_static/OFE-color-icon.svg"

news/fix_unk_resnames.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
**Added:**
2+
3+
* <news item>
4+
5+
**Changed:**
6+
7+
* Small molecules in RelativeHybridTopologyProtocol topologies (including the output PDB) are now named LIG (alchemical ligand) and COF (cofactors) instead of UNK. If a residue name was already assigned, the assigned one is kept.
8+
9+
**Deprecated:**
10+
11+
* <news item>
12+
13+
**Removed:**
14+
15+
* <news item>
16+
17+
**Fixed:**
18+
19+
* Fixed inflated ligand RMSD in the RelativeHybridTopology protocol's structural analysis for systems containing cofactors; the ligand RMSD is now computed for the alchemical ligand alone rather than conflating it with cofactors that shared the UNK residue name.
20+
21+
**Security:**
22+
23+
* <news item>

src/openfe/protocols/openmm_rfe/hybridtop_units.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
from openmmtools import multistate
4747

4848
import openfe
49+
from openfe.protocols.openmm_utils.offmolecule_utils import (
50+
_get_offmol_resname,
51+
_set_offmol_metadata,
52+
_set_offmol_resname,
53+
)
4954
from openfe.protocols.openmm_utils.omm_settings import (
5055
BasePartialChargeSettings,
5156
)
@@ -753,6 +758,46 @@ def run(
753758
alchem_comps = self._inputs["alchemical_components"]
754759
solvent_comp, protein_comp, small_mols = self._get_components(stateA, stateB)
755760

761+
alchemical = set(alchem_comps["stateA"]) | set(alchem_comps["stateB"])
762+
763+
def _unique(stem: str, used: set[str]) -> str:
764+
for i in range(1, 10):
765+
candidate = f"{stem}{i}"
766+
if candidate not in used:
767+
return candidate
768+
raise ValueError(
769+
f"Could not assign a unique residue name with stem {stem!r}; "
770+
"too many colliding names."
771+
)
772+
773+
# Seed with user-provided resnames so auto-assigned names avoid them.
774+
used: set[str] = set()
775+
for offmol in small_mols.values():
776+
name = _get_offmol_resname(offmol)
777+
if name is not None:
778+
used.add(name)
779+
780+
lig_name = "LIG" if "LIG" not in used else _unique("LG", used)
781+
782+
resnum = 1
783+
for smc, offmol in small_mols.items():
784+
if _get_offmol_resname(offmol) is not None:
785+
continue
786+
if smc in alchemical:
787+
name = lig_name
788+
else:
789+
name = "COF" if "COF" not in used else _unique("CO", used)
790+
_set_offmol_resname(offmol, name)
791+
_set_offmol_metadata(offmol, "residue_number", resnum)
792+
resnum += 1
793+
794+
names: set[str] = set()
795+
for comp in alchemical:
796+
name = _get_offmol_resname(small_mols[comp])
797+
assert name is not None
798+
names.add(name)
799+
alchem_resnames = sorted(names)
800+
756801
# Assign partial charges now to avoid any discrepancies later
757802
self._assign_partial_charges(settings["charge_settings"], small_mols)
758803

@@ -810,6 +855,7 @@ def run(
810855
"positions": positions_outfile,
811856
"pdb_structure": self.shared_basepath / settings["output_settings"].output_structure,
812857
"selection_indices": selection_indices,
858+
"alchemical_resnames": alchem_resnames,
813859
}
814860

815861
if dry:
@@ -1505,6 +1551,7 @@ def _structural_analysis(
15051551
trj_file: pathlib.Path,
15061552
output_directory: pathlib.Path,
15071553
dry: bool,
1554+
ligand_resnames: list[str],
15081555
) -> dict[str, str | pathlib.Path]:
15091556
"""
15101557
Run structural analysis using ``openfe-analysis``.
@@ -1520,6 +1567,8 @@ def _structural_analysis(
15201567
will be stored.
15211568
dry : bool
15221569
Whether or not we are running a dry run.
1570+
ligand_resnames: list[str]
1571+
The residue names of the ligands.
15231572
15241573
Returns
15251574
-------
@@ -1535,7 +1584,9 @@ def _structural_analysis(
15351584
from openfe_analysis import rmsd
15361585

15371586
try:
1538-
data = rmsd.gather_rms_data(pdb_file, trj_file)
1587+
data = rmsd.gather_rms_data(
1588+
pdb_file, trj_file, ligand_selection="resname " + " ".join(ligand_resnames)
1589+
)
15391590
# TODO: eventually change this to more specific exception types
15401591
except Exception as e:
15411592
return {"structural_analysis_error": str(e)}
@@ -1573,6 +1624,7 @@ def run(
15731624
pdb_file: pathlib.Path,
15741625
trajectory: pathlib.Path,
15751626
checkpoint: pathlib.Path,
1627+
ligand_resnames: list[str],
15761628
dry: bool = False,
15771629
verbose: bool = True,
15781630
scratch_basepath: pathlib.Path | None = None,
@@ -1588,6 +1640,8 @@ def run(
15881640
Path to the MultiStateReporter generated NetCDF file.
15891641
checkpoint : pathlib.Path
15901642
Path to the checkpoint file generated by MultiStateReporter.
1643+
ligand_resnames: list[str]
1644+
The residue names of the ligands.
15911645
dry : bool
15921646
Do a dry run of the calculation, creating all necessary hybrid
15931647
system components (topology, system, sampler, etc...) but without
@@ -1641,6 +1695,7 @@ def run(
16411695
trj_file=trajectory,
16421696
output_directory=self.shared_basepath,
16431697
dry=dry,
1698+
ligand_resnames=ligand_resnames,
16441699
)
16451700

16461701
# Return relevant things
@@ -1664,11 +1719,13 @@ def _execute(
16641719
selection_indices = setup_results.outputs["selection_indices"]
16651720
trajectory = simulation_results.outputs["nc"]
16661721
checkpoint = simulation_results.outputs["checkpoint"]
1722+
ligand_resnames = setup_results.outputs["alchemical_resnames"]
16671723

16681724
outputs = self.run(
16691725
pdb_file=pdb_file,
16701726
trajectory=trajectory,
16711727
checkpoint=checkpoint,
1728+
ligand_resnames=ligand_resnames,
16721729
scratch_basepath=ctx.scratch,
16731730
shared_basepath=ctx.shared,
16741731
)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# This code is part of OpenFE and is licensed under the MIT license.
2+
# For details, see https://github.com/OpenFreeEnergy/openfe
3+
import logging
4+
from typing import Any
5+
6+
from openff.toolkit import Molecule as OFFMolecule
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def _set_offmol_metadata(
12+
offmol: OFFMolecule,
13+
key: Any,
14+
val: Any | None,
15+
) -> None:
16+
"""
17+
Set a given metadata entry for a whole Molecule.
18+
19+
Parameters
20+
----------
21+
offmol : openff.toolkit.Molecule
22+
The Molecule to set the metadata for.
23+
key : Any
24+
The metadata key.
25+
val : Any
26+
The value to set the metadata entry to.
27+
"""
28+
if val is None:
29+
for a in offmol.atoms:
30+
a.metadata.pop(key, None)
31+
else:
32+
for a in offmol.atoms:
33+
a.metadata[key] = val
34+
35+
36+
def _get_offmol_metadata(offmol: OFFMolecule, key: Any) -> Any | None:
37+
"""
38+
Get an offmol's given metadata entry and make sure it is
39+
consistent across all atoms in the Molecule.
40+
41+
Parameters
42+
----------
43+
offmol : openff.toolkit.Molecule
44+
Molecule to get the metadata value from.
45+
key: Any
46+
The metadata entry key.
47+
48+
Returns
49+
-------
50+
value : Any | None
51+
Metadata for the given key in the molecule. ``None`` if the
52+
Molecule does not have that metadata entry set, or if
53+
the value is inconsistent across all the atoms.
54+
"""
55+
value: Any | None = None
56+
for a in offmol.atoms:
57+
if value is None:
58+
try:
59+
value = a.metadata[key]
60+
except KeyError:
61+
return None
62+
63+
if value != a.metadata[key]:
64+
wmsg = f"Inconsistent metadata {key} in OFFMol: {offmol}"
65+
logger.warning(wmsg)
66+
return None
67+
68+
return value
69+
70+
71+
def _set_offmol_resname(
72+
offmol: OFFMolecule,
73+
resname: str | None,
74+
) -> None:
75+
"""
76+
Helper method to set offmol residue names
77+
78+
Parameters
79+
----------
80+
offmol : openff.toolkit.Molecule
81+
Molecule to assign a residue name to.
82+
resname : str | None
83+
Residue name to be set. Set to None to clear it.
84+
85+
Returns
86+
-------
87+
None
88+
"""
89+
_set_offmol_metadata(offmol, "residue_name", resname)
90+
91+
92+
def _get_offmol_resname(offmol: OFFMolecule) -> str | None:
93+
"""
94+
Helper method to get an offmol's residue name and make sure it is
95+
consistent across all atoms in the Molecule.
96+
97+
Parameters
98+
----------
99+
offmol : openff.toolkit.Molecule
100+
Molecule to get the residue name from.
101+
102+
Returns
103+
-------
104+
resname : Optional[str]
105+
Residue name of the molecule. ``None`` if the Molecule
106+
does not have a residue name, or if the residue name is
107+
inconsistent across all the atoms.
108+
"""
109+
return _get_offmol_metadata(offmol, "residue_name")

src/openfe/tests/conftest.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def benzene_toluene_topology():
223223

224224

225225
@pytest.fixture(scope="session")
226-
def benzene_modifications():
226+
def benzene_modifications_uncharged():
227227
files = {}
228228
with resources.as_file(resources.files("openfe.tests.data")) as d:
229229
fn = str(d / "benzene_modifications.sdf")
@@ -233,6 +233,17 @@ def benzene_modifications():
233233
return files
234234

235235

236+
@pytest.fixture(scope="session")
237+
def benzene_modifications():
238+
files = {}
239+
with resources.as_file(resources.files("openfe.tests.data")) as d:
240+
fn = str(d / "benzene_modifications_am1bcc.sdf")
241+
supp = Chem.SDMolSupplier(str(fn), removeHs=False)
242+
for rdmol in supp:
243+
files[rdmol.GetProp("_Name")] = SmallMoleculeComponent(rdmol)
244+
return files
245+
246+
236247
@pytest.fixture(scope="session")
237248
def charged_benzene_modifications():
238249
files = {}
@@ -266,7 +277,7 @@ def benzene_transforms():
266277
# a dict of Molecules for benzene transformations
267278
mols = {}
268279
with resources.as_file(resources.files("openfe.tests.data")) as d:
269-
fn = str(d / "benzene_modifications.sdf")
280+
fn = str(d / "benzene_modifications_am1bcc.sdf")
270281
supplier = Chem.SDMolSupplier(fn, removeHs=False)
271282
for mol in supplier:
272283
mols[mol.GetProp("_Name")] = SmallMoleculeComponent(mol)
@@ -302,22 +313,36 @@ def eg5_protein_pdb():
302313

303314

304315
@pytest.fixture()
305-
def eg5_ligands_sdf():
316+
def eg5_ligands_uncharged_sdf():
306317
with resources.as_file(resources.files("openfe.tests.data.eg5")) as d:
307318
yield str(d / "eg5_ligands.sdf")
308319

309320

321+
@pytest.fixture()
322+
def eg5_ligands_sdf():
323+
with resources.as_file(resources.files("openfe.tests.data.eg5")) as d:
324+
yield str(d / "eg5_ligands_am1bcc.sdf")
325+
326+
310327
@pytest.fixture()
311328
def eg5_cofactor_sdf():
312329
with resources.as_file(resources.files("openfe.tests.data.eg5")) as d:
313-
yield str(d / "eg5_cofactor.sdf")
330+
yield str(d / "eg5_cofactor_am1bcc.sdf")
314331

315332

316333
@pytest.fixture()
317334
def eg5_protein(eg5_protein_pdb) -> openfe.ProteinComponent:
318335
return openfe.ProteinComponent.from_pdb_file(eg5_protein_pdb)
319336

320337

338+
@pytest.fixture()
339+
def eg5_ligands_uncharged(eg5_ligands_uncharged_sdf) -> list[SmallMoleculeComponent]:
340+
return [
341+
SmallMoleculeComponent(m)
342+
for m in Chem.SDMolSupplier(eg5_ligands_uncharged_sdf, removeHs=False)
343+
]
344+
345+
321346
@pytest.fixture()
322347
def eg5_ligands(eg5_ligands_sdf) -> list[SmallMoleculeComponent]:
323348
return [SmallMoleculeComponent(m) for m in Chem.SDMolSupplier(eg5_ligands_sdf, removeHs=False)]

0 commit comments

Comments
 (0)