MDI Driver Tutorial#

In this setion, we will use the MolSSI Driver Interface to perform molecular dynamics simulations with TorchANI. ANI, or ANAKIN-ME: Accurate NeurAl networK engINe for Molecular Energies, is a deep learning-based method for computing molecular energies and forces. You can read more about ANI here.s

Prerequisites

Before starting this tutorial, you should have already completed the Computer Set Up. It is also suggested that you have completed the Energy Calculation Lesson.

To complete this tutorial, clone the starting repository.

git clone https://github.com/MolSSI-MDI/mdi-ani-workshop.git

Examining Initial Files#

The starting repository contains a set of files we will need to complete this tutorial. We will be using TorchANI to compute forces on atoms given a particular molecular configuration, passing those forces to LAMMPS using MDI, then allowing LAMMPs to perform the dynamics steps.

When you clone the repository, you will see the following directory structure:

├── README.md
├── docker
│   └── Dockerfile
├── lammps
│   └── water
│       ├── lammps.data
│       ├── lammps.in
│       └── outfiles
├── mdi-ani-tutorial
│   ├── mdi-ani-tutorial.py
│   └── util.py
└── mdimechanic.yml

This is our starting set of files. This is roughly the set of files you would obtain from running the MDI CookieCutter. In this case, we have added some starting files to perform a simulation using LAMMPS. We’ve also added several utility functions to util.py that will help use complete this tutorial more easily.

All of the files associated with the LAMMPS simulation are in the lammps directory. If you view these files, you will see a folder called water that contains the LAMMPS input file lammps.in and the LAMMPS data file lammps.data. This will be our starting configuration for our simulation using TorchANI.

Python Driver#

Next, open the file mdi-ani-tutorial.py in the mdi-ani-tutorial directory. This file contains the Python code that will be used to run the simulation.

To start, you will see the following code:

import sys
import warnings

# Import the MDI Library
import mdi

# Import MPI Library
try:
    from mpi4py import MPI

    use_mpi4py = True
    mpi_comm_world = MPI.COMM_WORLD
except ImportError:
    use_mpi4py = False
    mpi_comm_world = None

# Import parser
from util import create_parser, connect_to_engines

if __name__ == "__main__":

    # Read in the command-line options
    args = create_parser().parse_args()

    mdi_options = args.mdi

    if mdi_options is None:
        mdi_options = (
            "-role DRIVER -name driver -method TCP -port 8021 -hostname localhost"
        )
        warnings.warn(f"Warning: -mdi not provided. Using default value: {mdi_options}")

    # Initialize the MDI Library
    mdi.MDI_Init(mdi_options)

    # Get the correct MPI intra-communicator for this code
    mpi_comm_world = mdi.MDI_MPI_get_world_comm()

    engines = connect_to_engines(1)

    ###########################
    # Perform the simulation
    ###########################

    # Send the "EXIT" command to each of the engines
    for comm in engines.values():
        mdi.MDI_Send_Command("EXIT", comm)

The code that is present in the file to start is boilerplate code that initializes the MDI Library and connects to the engines (in this case, LAMMPS). We only need to worry about adding code in the section that says

###########################
# Perform the simulation
###########################

MDI Mechanic Configuration#

The second important file is the mdimechanic.yml file. This file contains the configuration for the MDI Mechanic software. MDI Mechanic is a utility tool that can perform many functions related to MDI, including launching engines and running simulations. Under the hood, MDI Mechanic uses a software called Docker to launch engines and drivers.

We’ve already configured the mdimechanic.yml file to install necessary dependencies for this tutorial and launch the LAMMPS engine. At the end of the file, you will see

lmp -mdi "-role ENGINE -name LAMMPS -method TCP -port 8021 -hostname ani-tutorial" -l outfiles/log.lammps -in lammps.in > outfiles/water_tutorial.out

This is a launch command for LAMMPS. If you’ve used LAMMPS before, you will recognize this as the command to run a LAMMPS simulation with an additional flag -mdi that tells LAMMPS to connect to the MDI driver.

The first time you use this repository, you will need to run mdimechanic build

mdimechanic build

To confirm that your set up is correct, you can run the following command:

mdimechanic run --name ani-tutorial

This command will launch the LAMMPS engine and the MDI driver. Upon successful completion of this command, you should see the following output:

Running a custom calculation with MDI Mechanic.
====================================================
================ Output from Docker ================
====================================================
Attaching to temp-ani-tutorial-1, temp-lammps-1
temp-ani-tutorial-1 exited with code 0
temp-lammps-1 exited with code 0

====================================================
============== End Output from Docker ==============
====================================================
Success: The driver ran to completion.

You will also see several files have appeared in the lammps/water/outfiles directory. You will see log.lammps and water_tutorial.out. Both of these will show that LAMMPS started. However, you will not see any time step information because we have not yet instructed LAMMPS to run any steps through the input file or MDI Driver.

Getting Started with MDI#

Now that we have confirmed that the MDI driver and LAMMPS engine are running correctly, we can write the code that will perform the simulation. We will build this section up slowly. A key concept that we will need to understand for this section is the concept of an “engine” in our driver. If you look at the starting driver code, you will see the following line:

engines = connect_to_engines(1)

The connect_to_engines function is a helper function that will connect to the engines that are running. In our case, we have only one engine running, which is LAMMPS. The engines variable is a dictionary that contains the MDI communicator for each engine.

We can examine the engines that are running by adding the following code to the # Perform the simulation section:

# Print the engines that are running
for name, comm in engines.items():
    print(f"Engine {name} is running.")

If you rerun the driver code using mdimechanic run --name ani-tutorial, you will see the following output:

Running a custom calculation with MDI Mechanic.
====================================================
================ Output from Docker ================
====================================================
Attaching to temp-ani-tutorial-1, temp-lammps-1
temp-ani-tutorial-1  | Engine LAMMPS is running.
temp-ani-tutorial-1 exited with code 0
temp-lammps-1 exited with code 0

====================================================
============== End Output from Docker ==============
====================================================
Success: The driver ran to completion.

For convenience, we will create a variable called lammps to use for the rest of the tutorial.

Add the following after the loop in your code:

lammps = engines["LAMMPS"]

At the end of this section, your code should look like the following:

import sys
import warnings

# Import the MDI Library
import mdi

# Import MPI Library
try:
    from mpi4py import MPI

    use_mpi4py = True
    mpi_comm_world = MPI.COMM_WORLD
except ImportError:
    use_mpi4py = False
    mpi_comm_world = None

# Import parser
from util import create_parser, connect_to_engines

if __name__ == "__main__":

    # Read in the command-line options
    args = create_parser().parse_args()

    mdi_options = args.mdi

    if mdi_options is None:
        mdi_options = (
            "-role DRIVER -name driver -method TCP -port 8021 -hostname localhost"
        )
        warnings.warn(f"Warning: -mdi not provided. Using default value: {mdi_options}")

    # Initialize the MDI Library
    mdi.MDI_Init(mdi_options)

    # Get the correct MPI intra-communicator for this code
    mpi_comm_world = mdi.MDI_MPI_get_world_comm()

    engines = connect_to_engines(1)

    ###########################
    # Perform the simulation
    ###########################

    for name, comm in engines.items():
        print(f"Engine {name} is running.")

    lammps = engines["LAMMPS"]

    # Send the "EXIT" command to each of the engines
    for comm in engines.values():
        mdi.MDI_Send_Command("EXIT", comm)

Sending and Receiving Data#

When using MDI, we retrieve data from the engine by sending a command to the engine and then receiving the data. Data that you can send or receive from an MDI engine is defined by the MDI Standard. You can see a full list of command at the link above, or newer docs that are under development here. The first command we use is the mdi.MDI_Send_Command function. The argument to this function is the command to send and the engine we would like to send the command to. When using MDI_Send_Command, we use < to tell the engine to send data to the driver, and > to tell the driver to send data to the engine. For example, to get the number of atoms in the system, we first call mdi.MDI_Send_Command("<NATOMS", lammps) to tell LAMMPS to send the number of atoms.

After we have sent the command to the engine, we have to retrieve the data. This is done with the mdi.MDI_Recv function. When using mdi.MDI_Recv, we have to tell MDI how many values we expect to get back and the data type for the value or values we expect to receive. They syntax is

mdi.MDI_Recv(EXPECTED_NUMBER_OF_VALUES, DATA_TYPE, ENGINE_COMMUNICATOR)

The expected data types are defined in the MDI library. For example, mdi.MDI_INT is the data type for an integer, and mdi.MDI_DOUBLE is the data type for a double (decimal) value.

Add the following code to the # Perform the simulation section to get the number of atoms in the system:

# Get the number of atoms in the system
mdi.MDI_Send_Command("<NATOMS", lammps)
natoms = mdi.MDI_Recv(1, mdi.MDI_INT, lammps)

print(f"The number of atoms in the system is {natoms}.")

These set of commands first tells LAMMPS to send the number of atoms in the system (mdi.MDI_Send_Command("<NATOMS", lammps)). After that, we receive the number of atoms from LAMMPS. Using mdi.MDI_Recv, we tell lammps we are expecting 1 value for natoms and that its data type is an integer.

Check Your Understanding

Retrieve the box vectors from LAMMPS and print it to the screen.

The command to get the box vectors is <CELL. You expect this to contain 9 values (3 for each dimension). The data type we expect is an mdi.MDI_DOUBLE.

With the above two commands, we have retrieved much of the information we know that we need when using TorchANI to compute forces. Other information we will need are the atomic identities and atomic positions.

We will now add code to get the atomic identities. LAMMPS does not store atomic identities directly, but we can use the MASSES command to get the masses of the atoms.

Add the following code to the # Perform the simulation section to get the atomic masses:

# Get the atomic masses
mdi.MDI_Send_Command("<MASSES", lammps)
masses = mdi.MDI_Recv(natoms, mdi.MDI_DOUBLE, lammps)

To retrieve the coordinates of the atoms, we use the <COORDS command. We expect there to be 3 * natoms values returned, where each set of three values corresponds to the x, y, and z coordinates of an atom.

Add the following code to the # Perform the simulation section to get the atomic coordinates:

# Get the atomic coordinates
mdi.MDI_Send_Command("<COORDS", lammps)
coords = mdi.MDI_Recv(3 * natoms, mdi.MDI_DOUBLE, lammps)
print(f"There are {len(coords)} coordinates in the system.")

Exercise - Units

Check the values printed when you print your box vectors and atomic coordinates. Are they the same as was specified in the LAMMPS input file? What could be happening?

Fixing Units#

MDI Units

When using MDI, it is important to remember that the units used by the engine may not be the same as the units you are using in your driver code. MDI always expects to send and receive data in atomic units. This means that if you are using a different unit system in your engine, you will need to convert the units to atomic units before sending or receiving data.

Let’s convert the units of the box vectors and atomic coordinates to atomic units. We will first retrieve a conversion factor from MDI, then convert the units of the box vectors and atomic coordinates.
It will be simplest to do this if we are using NumPy arrays, so we will convert the coordinates to a NumPy array.

bohr_to_angstrom = mdi.MDI_Conversion_Factor("bohr","angstrom")

box_vects = np.array(box_vects) * bohr_to_angstrom
coords = np.array(coords) * bohr_to_angstrom

print(f"The box size is {box_vects}.")

Driving a Simulation using MDI#

Now that we’ve seen how to retrieve data from LAMMPS, we can start to drive a simulation using MDI. MDI is capable of “driving” a simulation code, which means we can send it to various points in the simulation code to perform tasks that you choose. This is accomplished through the concept of “nodes” in MDI. In an MDI engine, the @INIT_MD node is the point where a molecular dynamics simulation is initialized. At this point, system properties like the number of atoms, the box size, and the atomic positions are set. Note that this initialization is dependent on the input file that you prepare for LAMMPS.

Whenever we want to send a command to an engine, we use the MDI_Send_Command function. This function takes two arguments: the command to send and the communicator for the engine.

Add the following code to the # Perform the simulation section to tell LAMMPS to initialize molecular dynamics:

# Send the INIT_MD command to LAMMPS
mdi.MDI_Send_Command("@INIT_MD", lammps)

Just running this code won’t look like it did anything. However, internally, LAMMPS has initialized the molecular dynamics simulation and moved to the specified node.

To perform dynamics, we will want to send MDI to the @FORCES node for our desired number of steps. We will do this by first setting a variable for the number of steps we want to run and then sending the @FORCES command to LAMMPS for each step.

# Set the number of steps to run    
nsteps = 10

# Run the dynamics  
for i in range(nsteps):    
    mdi.MDI_Send_Command("@FORCES", lammps)

If you check the output of the LAMMPS engine, you will see that it has run the dynamics steps. If you check log.lammps or water_tutorial.out in your lammps/water/outfiles directory, you will see output similar to the following:

Per MPI rank memory allocation (min/avg/max) = 7.902 | 7.902 | 7.902 Mbytes
------------ Step              0 ----- CPU =            0 (sec) -------------
TotEng   =      -122.2700 KinEng   =         0.0000 Temp     =         0.0000 
PotEng   =      -122.2700 E_bond   =         0.0000 E_angle  =         0.0000 
E_dihed  =         0.0000 E_impro  =         0.0000 E_vdwl   =        32.8080 
E_coul   =       285.5475 E_long   =      -440.6255 Press    =      5318.3164
------------ Step              1 ----- CPU =   0.00097576 (sec) -------------
TotEng   =       -41.4920 KinEng   =         0.0432 Temp     =         0.9656 
PotEng   =       -41.5352 E_bond   =         0.0000 E_angle  =         0.0000 
E_dihed  =         0.0000 E_impro  =         0.0000 E_vdwl   =        27.3276 
E_coul   =       371.6457 E_long   =      -440.5085 Press    =     20062.1465
------------ Step              2 ----- CPU =  0.001521911 (sec) -------------
TotEng   =       -41.4923 KinEng   =         0.1493 Temp     =         3.3400 
PotEng   =       -41.6417 E_bond   =         0.0000 E_angle  =         0.0000 
E_dihed  =         0.0000 E_impro  =         0.0000 E_vdwl   =        27.2650 
E_coul   =       371.6024 E_long   =      -440.5091 Press    =     19377.1620

This output shows the energy of the system at each step. Right now, this energy is the energy that is calculated by LAMMPS for the forcefield we’ve specified in our input files. Our task in the next section will be to replace the forces LAMMPS calculates with those calculated by TorchANI.

Calculating Forces using TorchANI#

We now have all of the information we need to compute forces using TorchANI. If you’re interested in understanding more about how to use TorchANI, please see the TorchANI Tutorial.

When performing our MD, we know that we will need the updated coordinates at each step. Modify your for loop to retrieve the coordinates using MDI. We won’t do anything with them yet, but we will need them in the next section.

# Run the dynamics
for i in range(nsteps):
    
    # Send to @FORCES node
    mdi.MDI_Send_Command("@FORCES", lammps) 

    # Get the atomic coordinates
    mdi.MDI_Send_Command("<COORDS", lammps)
    coords = mdi.MDI_Recv(3 * natoms, mdi.MDI_DOUBLE, lammps)
    coords = np.array(coords) * bohr_to_angstrom

For the purposes of this tutorial, we have included a utility function called calculate_ANI_forces that uses the information we have retrieved already and returns the forces calculated by TorchANI. Let’s take a look at the docstring for this function. Open util.py and look at the calculate_ANI_force function.

def calculate_ANI_force(
    masses: Union[np.ndarray, list[float]], coordinates: np.ndarray, cell: np.ndarray
):
    """
    Calculate the ANI2x force for system of atoms.

    Parameters
    ----------
    masses :
        An array or list containing the masses of the atoms.
    coordinates :
        An array containing the 3D coordinates of the atoms.
    cell :
        A 3x3 array representing the periodic boundary conditions of the simulation cell.

    Returns
    -------
    np.ndarray
        A flattened array of forces acting on each atom, reshaped to be 1-dimensional.
    """

This function takes the masses, coordinates, and cell vectors as input and returns the forces calculated by TorchANI.

Here, we are passing in the coordinates, box vectors, and masses that we have retrieved from LAMMPS.

The forces returned by TorchANI are Hartree/Angstrom. MDI requires atomic units (Hartree/Bohr), so we divide by the conversion factor bohr_to_angstrom to convert the forces.

Exercise - Forces

Modify your loop so that you calculate the forces using TorchANI at each step. Then, use MDI to send the forces to LAMMPS. You can read about the >FORCES command using the new API Documentation. The command to send the forces to LAMMPS is >FORCES. Be sure to check the units of the forces you are sending to LAMMPS. MDI expects atomic units (Ha/Bohr)

After you have completed this exercise, you have successfully driven a simulation using MDI and TorchANI. You can increase your number of steps to run longer dynamics or modify the input file to change the simulation conditions.

Exercises#

Adding a Minimization Step (Geometry Optimization)#

When performing molecular dynamics, you would typically do an energy minimization step (or geometry optimization) before performing dynamics. This is to ensure that the system is at a local minimum in the potential energy surface. We can add an energy minimization step to our simulation by sending our driver to the @INIT_OPTG node and running a minimization loop (similar to the dynamics loop). Add the ability to do a geometry optimization to your driver.