-
Notifications
You must be signed in to change notification settings - Fork 53
Let the force field resolve the partial charge assignment #2152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cd183a8
d1ecb40
46c5bbf
f372a16
7c0575e
00692ec
ebf4e78
3eb3600
d700702
5083dd0
38ff7c6
5fbd1ce
aec1d35
5616dc1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| **Added:** | ||
|
|
||
| * The ``assign_offmol_partial_charges`` and ``bulk_assign_partial_charges`` functions can assign charges from a list of OpenFF SMIRNOFF style force fields. Set method=``forcefield`` and provide a list of force field files via the new keyword argument ``forcefields``. This is also supported in the ``charge-molecules`` CLI command and is set by using a yaml settings file. | ||
|
|
||
| **Changed:** | ||
|
|
||
| * <news item> | ||
|
|
||
| **Deprecated:** | ||
|
|
||
| * <news item> | ||
|
|
||
| **Removed:** | ||
|
|
||
| * <news item> | ||
|
|
||
| **Fixed:** | ||
|
|
||
| * <news item> | ||
|
|
||
| **Security:** | ||
|
|
||
| * <news item> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
|
|
||
| import numpy as np | ||
| from gufe import SmallMoleculeComponent | ||
| from openff.toolkit import ForceField | ||
| from openff.toolkit import Molecule as OFFMol | ||
| from openff.toolkit.utils.base_wrapper import ToolkitWrapper | ||
| from openff.toolkit.utils.toolkit_registry import ToolkitRegistry | ||
|
|
@@ -286,10 +287,11 @@ def _generate_offmol_conformers( | |
| def assign_offmol_partial_charges( | ||
| offmol: OFFMol, | ||
| overwrite: bool, | ||
| method: Literal["am1bcc", "am1bccelf10", "nagl", "espaloma"], | ||
| method: Literal["am1bcc", "am1bccelf10", "nagl", "espaloma", "forcefield"], | ||
| toolkit_backend: Literal["ambertools", "openeye", "rdkit"], | ||
| generate_n_conformers: int | None, | ||
| nagl_model: str | None, | ||
| forcefields: list[str] | None = None, | ||
| ) -> OFFMol: | ||
| """ | ||
| Assign partial charges to an OpenFF Molecule based on a selected method. | ||
|
|
@@ -299,11 +301,11 @@ def assign_offmol_partial_charges( | |
| offmol : openff.toolkit.Molecule | ||
| The Molecule to assign partial charges to. | ||
| overwrite : bool | ||
| Whether or not to overwrite any existing non-zero partial charges. | ||
| Whether to overwrite any existing non-zero partial charges. | ||
| Note that zeroed charges will always be overwritten. | ||
| method : Literal['am1bcc', 'am1bccelf10', 'nagl', 'espaloma'] | ||
| method : Literal['am1bcc', 'am1bccelf10', 'nagl', 'espaloma', 'forcefield'] | ||
| Partial charge assignment method. | ||
| Supported methods include; am1bcc, am1bccelf10, nagl, and espaloma. | ||
| Supported methods include; am1bcc, am1bccelf10, nagl, espaloma and forcefield. | ||
| toolkit_backend : Literal['ambertools', 'openeye', 'rdkit'] | ||
| OpenFF toolkit backend employed for charge generation. | ||
| Supported options: | ||
|
|
@@ -319,6 +321,14 @@ def assign_offmol_partial_charges( | |
| nagl_model : str | None | ||
| The NAGL model to use for charge assignment if method is ``nagl``. | ||
| If ``None``, the latest am1bcc NAGL charge model is used. | ||
| forcefields : list[str] | None, default None | ||
| An optional list of SMIRNOFF style force field offxml paths or strings which should be used to assign partial charges. | ||
|
|
||
| Notes | ||
| ----- | ||
| Charges are applied based on the following source preferences: | ||
| - Charges already present on the ligand are retained if overwrite is ``False``. | ||
| - Charges are applied using the input method and settings. | ||
|
|
||
| Raises | ||
| ------ | ||
|
|
@@ -339,6 +349,42 @@ def assign_offmol_partial_charges( | |
| if not overwrite: | ||
| return offmol | ||
|
|
||
| if method.lower() == "forcefield": | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we have a check for the other way around too? I'm thinking new users might not easily know you need to set both - especially via the CLI. |
||
| if forcefields is None: | ||
| errmsg = ( | ||
| "The forcefield method requires a force field or list of force fields' to be provided " | ||
| "via `forcefields`." | ||
| ) | ||
| raise ValueError(errmsg) | ||
|
|
||
| if isinstance(forcefields, str): | ||
| forcefields = [forcefields] | ||
|
|
||
| try: | ||
| # try to parse what the user has provided, due to supporting dropping the offxml extension, | ||
| # we need to try and catch the OSError and add the extension if needed | ||
| ff = ForceField(*forcefields) | ||
| except OSError: | ||
| # try adding the offxml extension if not present and it's a possible file path | ||
| forcefields_with_ext = [] | ||
| for _ff in forcefields: | ||
| # if the string of the force field is passed it should start with the xml header | ||
| if not _ff.endswith(".offxml") and not _ff.startswith("<?xml"): | ||
| ff_with_ext = f"{_ff}.offxml" | ||
| forcefields_with_ext.append(ff_with_ext) | ||
| else: | ||
| forcefields_with_ext.append(_ff) | ||
|
|
||
| # try again to load the force field with the added extension, if we fail let it raise the error | ||
| ff = ForceField(*forcefields_with_ext) | ||
|
|
||
| # make the toolkit registry based on the selected backend | ||
| toolkits = ToolkitRegistry([i() for i in BACKEND_OPTIONS[toolkit_backend.lower()]]) | ||
| # let the force field resolve the partial charge assignment method | ||
| charges = ff.get_partial_charges(offmol, toolkit_registry=toolkits) | ||
| offmol.partial_charges = charges | ||
| return offmol | ||
|
|
||
| # Dictionary for each available charge method | ||
| # The idea of this pattern is to allow for maximum flexibility by | ||
| # allowing for swapping out method calls as necessary. | ||
|
|
@@ -441,11 +487,12 @@ def assign_offmol_partial_charges( | |
| def bulk_assign_partial_charges( | ||
| molecules: list[SmallMoleculeComponent], | ||
| overwrite: bool, | ||
| method: Literal["am1bcc", "am1bccelf10", "nagl", "espaloma"], | ||
| method: Literal["am1bcc", "am1bccelf10", "nagl", "espaloma", "forcefield"], | ||
| toolkit_backend: Literal["ambertools", "openeye", "rdkit"], | ||
| generate_n_conformers: int | None, | ||
| nagl_model: str | None, | ||
| processors: int = 1, | ||
| forcefields: list[str] | None = None, | ||
| ) -> list[SmallMoleculeComponent]: | ||
| """ | ||
| Assign partial charges to a list of SmallMoleculeComponents using multiprocessing. | ||
|
|
@@ -457,7 +504,7 @@ def bulk_assign_partial_charges( | |
| overwrite : bool | ||
| Whether or not to overwrite any existing non-zero partial charges. | ||
| Note that zeroed charges will always be overwritten. | ||
| method : Literal['am1bcc', 'am1bccelf10', 'nagl', 'espaloma'] | ||
| method : Literal['am1bcc', 'am1bccelf10', 'nagl', 'espaloma', 'forcefield] | ||
| Partial charge assignment method. | ||
| Supported methods include; am1bcc, am1bccelf10, nagl, and espaloma. | ||
| toolkit_backend : Literal['ambertools', 'openeye', 'rdkit'] | ||
|
|
@@ -477,6 +524,8 @@ def bulk_assign_partial_charges( | |
| If ``None``, the latest am1bcc NAGL charge model is used. | ||
| processors: int, default 1 | ||
| The number of processors which should be used to generate the charges. | ||
| forcefields : list[str] | None, default None | ||
| An optional list of SMIRNOFF style force field offxml paths or strings which should be used to assign partial charges. | ||
|
|
||
| Raises | ||
| ------ | ||
|
|
@@ -499,6 +548,7 @@ def bulk_assign_partial_charges( | |
| "toolkit_backend": toolkit_backend, | ||
| "generate_n_conformers": generate_n_conformers, | ||
| "nagl_model": nagl_model, | ||
| "forcefields": forcefields, | ||
| } | ||
|
|
||
| if processors > 1: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -223,6 +223,7 @@ def load_yaml_planner_options(path: Optional[str], context) -> PlanNetworkOption | |
| off_toolkit_backend: ambertools | ||
| number_of_conformers: None | ||
| nagl_model: None | ||
| forcefields: None | ||
|
IAlibay marked this conversation as resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about including an example of this under the settings help section? |
||
| """ | ||
|
|
||
| _yaml_help = """ | ||
|
|
@@ -245,6 +246,7 @@ def load_yaml_planner_options(path: Optional[str], context) -> PlanNetworkOption | |
| - ``am1bccelf10`` (only possible if ``off_toolkit_backend`` is ``openeye``) | ||
| - ``nagl`` (must have openff-nagl installed) | ||
| - ``espaloma`` (must have espaloma_charge installed) | ||
| - ``forcefield`` (must supply the chosen force field files via the ``forcefields`` keyword argument. This is useful to get the correct AshGC model or LibraryCharges for a OpenFF force field.) | ||
|
|
||
| ``settings:`` allows for passing in any keyword arguments of the method's corresponding Python API. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
|
|
||
| import numpy as np | ||
| import pytest | ||
| import yaml | ||
| from click import ClickException | ||
| from click.testing import CliRunner | ||
| from gufe import SmallMoleculeComponent | ||
|
|
@@ -188,3 +189,71 @@ def test_charge_settings( | |
| output_order.append(smc.name) | ||
|
|
||
| assert input_order == output_order | ||
|
|
||
|
|
||
| def test_charge_molecules_missing_force_fields(methane, tmp_path): | ||
| # make sure an error is raised if we try to use forcefield charges without specifying a forcefield | ||
| runner = CliRunner() | ||
| mol_path = tmp_path / "methane.sdf" | ||
| methane.to_file(str(mol_path), "sdf") | ||
| out_path = str(tmp_path / "charged_methane.sdf") | ||
|
|
||
| settings = { | ||
| "partial_charge": { | ||
| "method": "forcefield", | ||
| } | ||
| } | ||
|
|
||
| settings_path = tmp_path / "settings.yaml" | ||
| yaml.safe_dump(settings, open(settings_path, "w")) | ||
|
|
||
| with runner.isolated_filesystem(): | ||
| # # check an error is raised if we try to overwrite the input | ||
| with pytest.raises( | ||
| ValueError, | ||
| match="The forcefield method requires a force field or list of force fields' to be provided via `forcefields`.", | ||
| ): | ||
| _ = runner.invoke( | ||
| charge_molecules, | ||
| ["-M", mol_path, "-o", out_path, "-s", settings_path], | ||
| catch_exceptions=False, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.skipif( | ||
| not HAS_NAGL, | ||
| reason="needs NAGL", | ||
| ) | ||
| @pytest.mark.skipif( | ||
| HAS_OPENEYE, reason="cannot use NAGL with rdkit backend when OpenEye is installed" | ||
| ) | ||
| def test_charge_molecules_from_forcefield(methane, tmp_path): | ||
| # make sure we can use forcefield charges if we specify a forcefield | ||
| runner = CliRunner() | ||
| mol_path = tmp_path / "methane.sdf" | ||
| methane.to_file(str(mol_path), "sdf") | ||
| out_path = str(tmp_path / "charged_methane.sdf") | ||
|
|
||
| settings = { | ||
| "partial_charge": { | ||
| "method": "forcefield", | ||
| "settings": {"forcefields": ["openff_unconstrained-2.3.0"]}, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Any reason for using unconstrained here? Might be better to use the default we use day-to-day. |
||
| } | ||
| } | ||
|
|
||
| settings_path = tmp_path / "settings.yaml" | ||
| yaml.safe_dump(settings, open(settings_path, "w")) | ||
|
|
||
| with runner.isolated_filesystem(): | ||
| result = runner.invoke( | ||
| charge_molecules, | ||
| ["-M", mol_path, "-o", out_path, "-s", settings_path], | ||
| catch_exceptions=False, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| assert "Partial Charge Generation: forcefield" in result.output | ||
|
|
||
| # make sure the charges have been saved | ||
| methane_out = SmallMoleculeComponent.from_sdf_file(filename=out_path) | ||
| off_methane_out = methane_out.to_openff() | ||
| assert off_methane_out.partial_charges is not None | ||
Uh oh!
There was an error while loading. Please reload this page.