Skip to content

Commit

Permalink
Force field combining (#146)
Browse files Browse the repository at this point in the history
* add a combine force field CLI input

* update docs

* fix docs
  • Loading branch information
jthorton authored Mar 18, 2022
1 parent f4e11f2 commit 6503de2
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 1 deletion.
15 changes: 14 additions & 1 deletion docs/users/bespoke-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ from openff.bespokefit.executor import BespokeExecutorOutput
output = BespokeExecutorOutput.parse_file("output.json")
```

The class has several attributes that contain information about the on-going (or potentially finished) bespoke
The class has several attributes that contain information about the ongoing (or potentially finished) bespoke
fit, including the current status

```python
Expand All @@ -52,4 +52,17 @@ If the ``bespoke_force_field`` returned is ``None``, it is likely that either th
or an error was raised. You should consult the ``status`` and ``error`` fields for extra details in this
case.

## Combining force fields

Once a set of bespoke fit optimizations have completed you may want to create a single bespoke force field that can be
applied to this set of molecules, this maybe useful for example when studying a congeneric series using relative free energy
calculations. A single force field can be created from a mix of multiple local files and task ids from the command line
interface

```shell
openff-bespoke combine --output "my_forcefield.offxml" \
--ff "bespoke-ff.offxml" \
--id "2"
```

[`BespokeExecutorOutput`]: openff.bespokefit.executor.executor.BespokeExecutorOutput
2 changes: 2 additions & 0 deletions openff/bespokefit/cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import click

from openff.bespokefit.cli.cache import cache_cli
from openff.bespokefit.cli.combine import combine_cli
from openff.bespokefit.cli.executor import executor_cli
from openff.bespokefit.cli.prepare import prepare_cli

Expand All @@ -13,3 +14,4 @@ def cli():
cli.add_command(executor_cli)
cli.add_command(prepare_cli)
cli.add_command(cache_cli)
cli.add_command(combine_cli)
98 changes: 98 additions & 0 deletions openff/bespokefit/cli/combine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import copy
from typing import List, Optional

import click
import rich
from openff.toolkit.typing.engines.smirnoff import ForceField, ParameterLookupError
from rich import pretty
from rich.padding import Padding

from openff.bespokefit.cli.utilities import exit_with_messages, print_header
from openff.bespokefit.executor.utilities import handle_common_errors


@click.command("combine")
@click.option(
"--output",
"output_file",
type=click.STRING,
help="The name of the file the combined force field should be wrote to.",
required=True,
)
@click.option(
"--ff",
"force_field_files",
type=click.Path(exists=True, dir_okay=False),
help="The file name of any local force fields to include in the combined force field.",
required=False,
multiple=True,
)
@click.option(
"--id",
"task_ids",
type=click.STRING,
help="The task ids from which the final force field should be added to the combined force field.",
required=False,
multiple=True,
)
def combine_cli(
output_file: str,
force_field_files: Optional[List[str]],
task_ids: Optional[List[str]],
):
"""
Combine force fields from local files and task ids.
"""
pretty.install()

console = rich.get_console()
print_header(console)

if not force_field_files and not task_ids:

exit_with_messages(
"[[red]ERROR[/red]] At least one of the `--ff` or `--id` should be specified",
console=console,
exit_code=2,
)

all_force_fields = []

if force_field_files:
all_force_fields.extend(
[
ForceField(
force_field, load_plugins=True, allow_cosmetic_attributes=True
)
for force_field in force_field_files
]
)

if task_ids:
from openff.bespokefit.executor import BespokeExecutor

with handle_common_errors(console) as error_state:
results = [BespokeExecutor.retrieve(task_id) for task_id in task_ids]
if error_state["has_errored"]:
raise click.exceptions.Exit(code=2)

all_force_fields.extend([result.bespoke_force_field for result in results])

# Now combine all unique torsions
master_ff = copy.deepcopy(all_force_fields[0])
for ff in all_force_fields[1:]:
for parameter in ff.get_parameter_handler("ProperTorsions").parameters:
try:
_ = master_ff.get_parameter_handler("ProperTorsions")[parameter.smirks]
except ParameterLookupError:
master_ff.get_parameter_handler("ProperTorsions").add_parameter(
parameter=parameter
)

master_ff.to_file(filename=output_file, discard_cosmetic_attributes=True)

message = Padding(
f"The combined force field has been saved to [repr.filename]{output_file}[/repr.filename]",
(1, 0, 1, 0),
)
console.print(message)
72 changes: 72 additions & 0 deletions openff/bespokefit/tests/cli/test_combine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import os

import requests_mock
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.bespokefit.cli.combine import combine_cli
from openff.bespokefit.executor.services import current_settings
from openff.bespokefit.executor.services.coordinator.models import (
CoordinatorGETResponse,
CoordinatorGETStageStatus,
)


def test_combine_no_args(runner):
"""
Make sure an error is raised when we supply no args
"""

output = runner.invoke(combine_cli, args=["--output", "my_ff.offxml"])

assert output.exit_code == 2
assert "At least one of the" in output.stdout


def test_combine_local_and_tasks(tmpdir, runner, bespoke_optimization_results):
"""
Make sure local force field files can be combined with task force fields
"""

# make some local files to work with
for ff_name in ["openff-1.0.0.offxml", "openff-2.0.0.offxml"]:
ForceField(ff_name).to_file(ff_name)

settings = current_settings()

with requests_mock.Mocker() as m:
mock_href = (
f"http://127.0.0.1:"
f"{settings.BEFLOW_GATEWAY_PORT}"
f"{settings.BEFLOW_API_V1_STR}/"
f"{settings.BEFLOW_COORDINATOR_PREFIX}/1"
)
mock_response = CoordinatorGETResponse(
id="1",
self="",
smiles="CC",
stages=[
CoordinatorGETStageStatus(
type="optimization", status="success", error=None, results=None
)
],
results=bespoke_optimization_results,
)
m.get(mock_href, text=mock_response.json(by_alias=True))

output = runner.invoke(
combine_cli,
args=[
"--output",
"my_ff.offxml",
"--ff",
"openff-2.0.0.offxml",
"--ff",
"openff-1.0.0.offxml",
"--id",
"1",
],
)

assert os.path.isfile("my_ff.offxml")
assert output.exit_code == 0
assert "The combined force field has been saved to" in output.stdout

0 comments on commit 6503de2

Please sign in to comment.