Installing Atomate2 from source with OpenMM

# setting up our conda environment
>>> conda create -n atomate2 python=3.11
>>> conda activate atomate2

# installing atomate2
>>> pip install "atomate2[openmm]"

Alternatively, if you anticipate regularly updating atomate2 from source, you can clone the repository and install from source.

# installing atomate2
>>> git clone https://github.com/materialsproject/atomate2
>>> cd atomate2
>>> pip install -e '.[openmm]'

To test the openmm installation, you can run the following command. If you intend to run on GPU, make sure that the tests are passing for CUDA.

>>> python -m openmm.testInstallation

Understanding Atomate2 OpenMM

Atomate2 is really just a collection of jobflow workflows relevant to materials science. In all the workflows, we pass our system of interest between different jobs to perform the desired simulation. Representing the intermediate state of a classical molecular dynamics simulation, however, is challenging. While the intermediate representation between stages of a periodic DFT simulation can include just the elements, xyz coordinates, and box vectors, classical molecular dynamics systems must also include velocities and forces. The latter is particularly challenging because all MD engines represent forces differently. Rather than implement our own representation, we use the openff.interchange.Interchange object, which catalogs the necessary system properties and interfaces with a variety of MD engines. This is the object that we pass between stages of a classical MD simulation and it is the starting point of our workflow.

Setting up the system

The first job we need to create generates the Interchange object. To specify the system of interest, we use give it the SMILES strings, counts, and names (optional) of the molecules we want to include.

from atomate2.openff.core import generate_interchange

mol_specs_dicts = [
    {"smiles": "O", "count": 200, "name": "water"},
    {"smiles": "CCO", "count": 10, "name": "ethanol"},
    {"smiles": "C1=C(C=C(C(=C1O)O)O)C(=O)O", "count": 1, "name": "gallic_acid"},
]

gallic_interchange_job = generate_interchange(mol_specs_dicts, 1.3)

If you are wondering what arguments are allowed in the dictionaries, check out the create_mol_spec function in the atomate2.openff.utils module. Under the hood, this is being called on each mol_spec dict. Meaning the code below is functionally identical to the code above.

from atomate2.openff.utils import create_mol_spec

mols_specs = [create_mol_spec(**mol_spec_dict) for mol_spec_dict in mol_specs_dicts]

generate_interchange(mols_specs, 1.3)

In a more complex simulation we might want to scale the ion charges and include custom partial charges. An example with a EC:EMC:LiPF6 electrolyte is shown below. This yields the elyte_interchange_job object, which we can pass to the next stage of the simulation.

NOTE: It’s actually mandatory to include partial charges for PF6- here, the built in partial charge method fails.

import numpy as np
from pymatgen.core.structure import Molecule


pf6 = Molecule(
    ["P", "F", "F", "F", "F", "F", "F"],
    [
        [0.0, 0.0, 0.0],
        [1.6, 0.0, 0.0],
        [-1.6, 0.0, 0.0],
        [0.0, 1.6, 0.0],
        [0.0, -1.6, 0.0],
        [0.0, 0.0, 1.6],
        [0.0, 0.0, -1.6],
    ],
)
pf6_charges = np.array([1.34, -0.39, -0.39, -0.39, -0.39, -0.39, -0.39])

mol_specs_dicts = [
    {"smiles": "C1COC(=O)O1", "count": 100, "name": "EC"},
    {"smiles": "CCOC(=O)OC", "count": 100, "name": "EMC"},
    {
        "smiles": "F[P-](F)(F)(F)(F)F",
        "count": 50,
        "name": "PF6",
        "partial_charges": pf6_charges,
        "geometry": pf6,
        "charge_scaling": 0.8,
        "charge_method": "RESP",
    },
    {"smiles": "[Li+]", "count": 50, "name": "Li", "charge_scaling": 0.8},
]

elyte_interchange_job = generate_interchange(mol_specs_dicts, 1.3)

Running a basic simulation

To run a production simulation, we will create a production flow, link it to our elyte_interchange_job, and then run both locally.

In jobflow, jobs and flows are created by Makers, which can then be linked into more complex flows. Here, OpenMMFlowMaker links together makers for energy minimization, pressure equilibration, annealing, and a nvt simulation. The annealing step is a subflow that saves us from manually instantiating three separate jobs.

Finally, we create our production flow and link to the generate_interchange job, yielding a production ready molecular dynamics workflow.

from atomate2.openmm.flows.core import OpenMMFlowMaker
from atomate2.openmm.jobs.core import (
    EnergyMinimizationMaker,
    NPTMaker,
    NVTMaker,
)
from jobflow import Flow, run_locally


production_maker = OpenMMFlowMaker(
    name="production_flow",
    makers=[
        EnergyMinimizationMaker(traj_interval=10, state_interval=10),
        NPTMaker(n_steps=100),
        OpenMMFlowMaker.anneal_flow(n_steps=150),
        NVTMaker(n_steps=100),
    ],
)

production_flow = production_maker.make(
    elyte_interchange_job.output.interchange,
    prev_dir=elyte_interchange_job.output.dir_name,
    output_dir="./tutorial_system",
)

run_locally(Flow([elyte_interchange_job, production_flow]))

Above, we are running a very short simulation (350 steps total) and reporting out the trajectory and state information very frequently. For a more realistic simulation, see the “Configuring the Simulation” section below.

When the above code is executed, you should expect to see this within the tutorial_system directory:

/tutorial_system
├── state.csv
├── state2.csv
├── state3.csv
├── state4.csv
├── state5.csv
├── state6.csv
├── taskdoc.json
├── trajectory.dcd
├── trajectory2.dcd
├── trajectory3.dcd
├── trajectory4.dcd
├── trajectory5.dcd
├── trajectory6.dcd

Each job saved a separate state and trajectory file. There are 6 because the anneal flow creates 3 sub-jobs and the EnergyMinimizationMaker does not report anything. The taskdoc.json file contains the metadata for the entire workflow.

Awesome! At this point, we’ve run a workflow and could start analyzing our data. Before we get there though, let’s go through some of the other simulation options available.

Digging Deeper

Atomate2 OpenMM supports running a variety of workflows with different configurations. Below we dig in to some of the more advanced options.

Configuring the Simulation

Learn more about the configuration of OpenMM simulations

All OpenMM jobs, i.e. anything in atomate2.openmm.jobs, inherits from the BaseOpenMMMaker class. BaseOpenMMMaker is highly configurable, you can change the timestep, temperature, reporting frequencies, output types, and a range of other properties. See the docstring for the full list of options.

Note that when instantiating the OpenMMFlowMaker above, we only set the traj_interval and state_interval once, inside EnergyMinimizationMaker. This is a key feature: all makers will inherit attributes from the previous maker if they are not explicitly reset. This allows you to set the timestep once and have it apply to all stages of the simulation. The value inheritance is as follows: 1) any explicitly set value, 2) the value from the previous maker, 3) the default value (as shown below).

from atomate2.openmm.jobs.base import OPENMM_MAKER_DEFAULTS

print(OPENMM_MAKER_DEFAULTS)
{
    "step_size": 0.001,
    "temperature": 298,
    "friction_coefficient": 1,
    "platform_name": "CPU",
    "platform_properties": {},
    "state_interval": 1000,
    "state_file_name": "state",
    "traj_interval": 10000,
    "wrap_traj": False,
    "report_velocities": False,
    "traj_file_name": "trajectory",
    "traj_file_type": "dcd",
    "embed_traj": False,
}

Perhaps we want to embed the trajectory in the taskdoc, so that it can be saved to the database, but only for our final run so we don’t waste space. AND we also want to add some tags, so we can identify the simulation in our database more easily. Finally, we want to run for much longer, more appropriate for a real production workflow.

production_maker = OpenMMFlowMaker(
    name="production_flow",
    tags=["tutorial_production_flow"],
    makers=[
        EnergyMinimizationMaker(traj_interval=0),
        NPTMaker(n_steps=1000000),
        OpenMMFlowMaker.anneal_flow(n_steps=1500000),
        NVTMaker(n_steps=5000000, traj_interval=10000, embed_traj=True),
    ],
)

production_flow = production_maker.make(
    elyte_interchange_job.output.interchange,
    prev_dir=elyte_interchange_job.output.dir_name,
    output_dir="./tutorial_system",
)

run_locally(Flow([elyte_interchange_job, production_flow]))

Running with Databases

Learn to upload your MD data to databases

Before trying this, you should have a basic understanding of JobFlow and Stores.

To log OpenMM results to a database, you’ll need to set up both a MongoStore, for taskdocs, and blob storage, for trajectories. Here, I’ll show you the correct jobflow.yaml file to use the MongoDB storage and MinIO S3 storage provided by NERSC. To get this up, you’ll need to contact NERSC to get accounts on their MongoDB and MinIO services. Then you can follow the instructions in the Stores tutorial to link jobflow to your databases. Your jobflow.yaml should look like this:

JOB_STORE:
  docs_store:
    type: MongoStore
    database: DATABASE
    collection_name: atomate2_docs # suggested
    host: mongodb05.nersc.gov
    port: 27017
    username: USERNAME
    password: PASSWORD

  additional_stores:
      data:
          type: S3Store
          index:
              type: MongoStore
              database: DATABASE
              collection_name: atomate2_blobs_index # suggested
              host: mongodb05.nersc.gov
              port: 27017
              username: USERNAME
              password: PASSWORD
              key: blob_uuid
          bucket: oac
          s3_profile: oac
          s3_resource_kwargs:
              verify: false
          endpoint_url: https://next-gen-minio.materialsproject.org/
          key: blob_uuid

NOTE: This can work with any MongoDB and S3 storage, not just NERSC’s.

As shown in the production example above, you’ll need to set the embed_traj property to True in any makers where you want to save the trajectory to the database. Otherwise, the trajectory will only be saved locally.

Rather than use jobflow.yaml, you could also create the stores in Python and pass the stores to the run_locally function. This is a bit more code, so usually the prior method is preferred.

from jobflow import run_locally, JobStore
from maggma.stores import MongoStore, S3Store

mongo_info = {
    "username": "USERNAME",
    "password": "PASSWORD",
    "database": "DATABASE",
    "host": "mongodb05.nersc.gov",
}

md_doc_store = MongoStore(**mongo_info, collection_name="atomate2_docs")

md_blob_index = MongoStore(
    **mongo_info,
    collection_name="atomate2_blobs_index",
    key="blob_uuid",
)

md_blob_store = S3Store(
    index=md_blob_index,
    bucket="BUCKET",
    s3_profile="PROFILE",
    endpoint_url="https://next-gen-minio.materialsproject.org",
    key="blob_uuid",
)

# run our previous flow with the new stores
run_locally(
    Flow([elyte_interchange_job, production_flow]),
    store=JobStore(md_doc_store, additional_stores={"data": md_blob_store}),
    ensure_success=True,
)

Running on GPUs

Learn to accelerate MD simulations with GPUs

Running on a GPU is nearly as simple as running on a CPU. The only difference is that you need to specify the platform_properties argument in the EnergyMinimizationMaker with the DeviceIndex of the GPU you want to use.

production_maker = OpenMMFlowMaker(
    name="test_production",
    makers=[
        EnergyMinimizationMaker(
            platform_name="CUDA",
            platform_properties={"DeviceIndex": "0"},
        ),
        NPTMaker(),
        OpenMMFlowMaker.anneal_flow(),
        NVTMaker(),
    ],
)

Some systems (notably perlmutter) have multiple GPUs available on a single node. To fully leverage the compute, you’ll need to distribute 4 simulations across the 4 GPUs. A simple way to do this is with MPI.

First you’ll need to install mpi4py.

>>> conda install mpi4py

Then you can modify and run the following script to distribute the work across the GPUs.

# other imports

from mpi4py import MPI

comm = MPI.COMM_WORLD
rank = comm.Get_rank()

list_of_mol_spec_lists = []
# logic to add four mol_spec_lists to list_of_mol_spec_lists


flows = []
for i in range(4):
    device_index = i
    mol_specs = list_of_mol_spec_lists[i]

    setup = generate_interchange(mol_specs, 1.0)

    production_maker = OpenMMFlowMaker(
        name="test_production",
        makers=[
            EnergyMinimizationMaker(
                platform_name="CUDA",
                platform_properties={"DeviceIndex": str(device_index)},
            ),
            NPTMaker(),
            OpenMMFlowMaker.anneal_flow(),
            NVTMaker(),
        ],
    )

    production_flow = production_maker.make(
        setup.output.interchange,
        prev_dir=setup.output.dir_name,
        output_dir=f"/pscratch/sd/o/oac/openmm_runs/{i}",
    )
    flows.append(Flow([setup, production_flow]))

# this script will run four times, each with a different rank, thus distributing the work across the four GPUs.
run_locally(flows[rank], ensure_success=True)

Analysis with Emmet

For now, you’ll need to make sure you have a particular emmet branch installed. Later the builders will be integrated into main.

pip install git+https://github.com/orionarcher/emmet@md_builders

Analyzing Local Data

Learn to analyze your data without a database

Emmet will give us a solid head start on analyzing our data even without touching a database. Below, we use emmet to create a MDAnalysis Universe and a SolvationAnalysis Solute. From here, we can do all sorts of very cool analysis, but that’s beyond the scope of this tutorial. Consult the tutorials in SolvationAnalysis and MDAnalysis for more information.

from atomate2.openff.core import ClassicalMDTaskDocument
from emmet.builders.classical_md.utils import create_universe, create_solute
from openff.interchange import Interchange

ec_emc_taskdoc = ClassicalMDTaskDocument.parse_file("tutorial_system/taskdoc.json")
interchange = Interchange.parse_raw(ec_emc_taskdoc.interchange)
mol_specs = ec_emc_taskdoc.mol_specs

u = create_universe(
    interchange,
    mol_specs,
    str("tutorial_system/trajectory5.dcd"),
    traj_format="DCD",
)

solute = create_solute(u, solute_name="Li", networking_solvents=["PF6"])

Setting up builders

Connect with your databases

If you followed the instructions above to set up a database, you can use the ElectrolyteBuilder to perform the same analysis as above.

First, we’ll need to create the stores where are data is located, these should match the stores you used when running your flow.

from maggma.stores import MongoStore, S3Store

mongo_info = {
    "username": "USERNAME",
    "password": "PASSWORD",
    "database": "DATABASE",
    "host": "mongodb05.nersc.gov",
}
s3_info = {
    "bucket": "BUCKET",
    "s3_profile": "PROFILE",
    "endpoint_url": "https://next-gen-minio.materialsproject.org",
}

md_docs = MongoStore(**mongo_info, collection_name="atomate2_docs")
md_blob_index = MongoStore(
    **mongo_info,
    collection_name="atomate2_blobs_index",
    key="blob_uuid",
)
md_blob_store = S3Store(
    **s3_info,
    index=md_blob_index,
    key="blob_uuid",
)

Now we create our Emmet builder and connect to it. We will include a query that will only select jobs with the tag “tutorial_production_flow” that we used earlier.

from emmet.builders.classical_md.openmm.core import ElectrolyteBuilder

builder = ElectrolyteBuilder(
    md_docs, md_blob_store, query={"output.tags": "tutorial_production_flow"}
)
builder.connect()
Here are some more convenient queries.

Here are some more convenient queries we could use!

# query jobs from a specific day
april_16 = {"completed_at": {"$regex": "^2024-04-16"}}
may = {"completed_at": {"$regex": "^2024-05"}}


# query a particular set of jobs
job_uuids = [
    "3d7b4db4-85e5-48a5-9585-07b37910720f",
    "4202b18f-f156-4705-8ca6-ac2a08093174",
    "187d9466-c359-4013-9e25-8b4ece6e3ecf",
]
my_specific_jobs = {"uuid": {"$in": job_uuids}}

Analyzing systems individually

Download and explore systems one-by-one

To analyze a specific system, you’ll need the uuid of the taskdoc you want to analyze. We can find the uuids of all the taskdocs in our builder by retrieving the items and extracting the uuids.

items = builder.get_items()
uuids = [item["uuid"] for item in items]

This, however, can quickly get confusing once you have many jobs. At this point, I would highly recommend starting to use an application that makes it easier to view and navigate MongoDB databases. I recommend Studio3T or DataGrip.

Now we again use our builder to create a Universe and Solute. This time instatiate_universe downloads the trajectory, saves it locally, and uses it to create a Universe.

# a query that will grab
tutorial_query = {"tags": "tutorial_production_flow"}

u = builder.instantiate_universe(uuid, "directory/to/store/trajectory")

solute = create_solute(
    u,
    solute_name="Li",
    networking_solvents=["PF6"],
    fallback_radius=3,
)

Automated analysis with builders

Do it all for all the systems!

Finally, we’ll put the H in high-throughput molecular dynamics. Below, we create Stores to hold our SolvationDocs and CalculationDocs and execute the builder on all of our jobs!

Later, there will also be TransportDocs, EquilibrationDocs and more. Aggregating most of what you might want to know about an MD simulation.

solvation_docs = MongoStore(**mongo_info, collection_name="solvation_docs")
calculation_docs = MongoStore(**mongo_info, collection_name="calculation_docs")
builder = ElectrolyteBuilder(md_docs, md_blob_store, solvation_docs, calculation_docs)

builder.connect()
items = builder.get_items()
processed_docs = builder.process_items(items)
builder.update_targets(processed_docs)