Molecular Dynamics with Python

Important: This tutorial is only compatible with AMS2023 or later.

To follow along, either

Initial imports and helper function

The show() function can be used inside Jupyter notebooks to show the chemical system. If you do not use Jupyter, the function can be ignored.

from scm.plams import *
import os
import numpy as np
from ase.visualize.plot import plot_atoms
import matplotlib.pyplot as plt

def show(mol, figsize=None, rotation=None, **kwargs):
    """ Show a molecule in a Jupyter notebook """
    plt.figure(figsize=figsize or (2,2))
    plt.axis('off')
    rotation = rotation or ('80x,10y,0z')
    plot_atoms(toASE(mol), rotation=rotation, **kwargs)

Before running AMS jobs, you need to call the PLAMS init() function:

init()
PLAMS working folder: /home/user/adfhome/userdoc/Tutorials/MolecularDynamicsAndMonteCarlo/MDintroPython/plams_workdir

Create a box of methane

First, create a gasphase methane molecule:

single_molecule = from_smiles('C', forcefield='uff') # or use Molecule('your-own-file.xyz')
show(single_molecule)
../../_images/MDintroPython_5_0.png

You can easily create a box with a liquid or gas of a given density. For more advanced options, see the Packmol example.

box = packmol(
    single_molecule,
    n_molecules=8,
    region_names='methane',
    density=0.4, # g/cm^3
)
print("Lattice vectors of the box:\n{}".format(box.lattice))
show(box)
Lattice vectors of the box:
[[8.106820570931148, 0.0, 0.0], [0.0, 8.106820570931148, 0.0], [0.0, 0.0, 8.106820570931148]]
../../_images/MDintroPython_7_1.png

Before running an MD simulation, it is usually a good idea to do a quick geometry optimization (preoptimization).

box = preoptimize(box, model='UFF', maxiterations=50)
show(box)
../../_images/MDintroPython_9_0.png

Run an NVE simulation with UFF

In an NVE simulation, the total energy (kinetic + potential) is constant.

The initial velocities can either be

  • read from a file (a previous simulation), or

  • be generated to follow the Maxwell-Boltzmann distribution at a certain temperature.

Here, we will initialize the velocities corresponding to a temperature of 300 K.

s = Settings()
s.input.ForceField.Type = 'UFF'
s.runscript.nproc = 1 # run in serial

nve_job = AMSNVEJob(
    molecule=box,
    settings=s,
    name='NVE-sim-1',
    temperature=300, # initial velocities from Maxwell-Boltzmann distribution, 300 K
    nsteps=1000,
    samplingfreq=50,
    timestep=1.0 # femtoseconds
)

When calling nve_job.run(), you can set watch=True to interactively see the progress of the MD simulation.

nve_job.run(watch=True);
[16.12|13:57:34] JOB NVE-sim-1 STARTED
[16.12|13:57:34] JOB NVE-sim-1 RUNNING
[16.12|13:57:34] NVE-sim-1: AMS 2022.204  RunTime: Dec16-2022 13:57:34  ShM Nodes: 1  Procs: 1
[16.12|13:57:34] NVE-sim-1: Starting MD calculation:
[16.12|13:57:34] NVE-sim-1: --------------------
[16.12|13:57:34] NVE-sim-1: Molecular Dynamics
[16.12|13:57:34] NVE-sim-1: --------------------
[16.12|13:57:34] NVE-sim-1:          Step       Time    Temp.         E Pot        Pressure        Volume
[16.12|13:57:34] NVE-sim-1:                     (fs)      (K)          (au)           (MPa)         (A^3)
[16.12|13:57:34] NVE-sim-1: GuessUFFBonds
[16.12|13:57:34] NVE-sim-1:             0       0.00     300.      -0.03207         239.478         532.8
[16.12|13:57:34] NVE-sim-1:            50      50.00     177.      -0.00859        -131.982         532.8
[16.12|13:57:34] NVE-sim-1:           100     100.00     271.      -0.02647          80.591         532.8
[16.12|13:57:34] NVE-sim-1:           150     150.00     199.      -0.01271        -347.718         532.8
[16.12|13:57:34] NVE-sim-1:           200     200.00     207.      -0.01446        -164.293         532.8
[16.12|13:57:35] NVE-sim-1:           250     250.00     219.      -0.01660         538.325         532.8
[16.12|13:57:35] NVE-sim-1:           300     300.00     179.      -0.00903         -98.505         532.8
[16.12|13:57:35] NVE-sim-1:           350     350.00     223.      -0.01722        -138.693         532.8
[16.12|13:57:35] NVE-sim-1:           400     400.00     204.      -0.01381        -232.811         532.8
[16.12|13:57:35] NVE-sim-1:           450     450.00     193.      -0.01162         141.163         532.8
[16.12|13:57:35] NVE-sim-1:           500     500.00     193.      -0.01152         555.958         532.8
[16.12|13:57:35] NVE-sim-1:           550     550.00     181.      -0.00924        -316.808         532.8
[16.12|13:57:35] NVE-sim-1:           600     600.00     196.      -0.01199        -269.346         532.8
[16.12|13:57:35] NVE-sim-1:           650     650.00     168.      -0.00672         334.380         532.8
[16.12|13:57:35] NVE-sim-1:           700     700.00     186.      -0.01006         261.506         532.8
[16.12|13:57:35] NVE-sim-1:           750     750.00     179.      -0.00859        -768.880         532.8
[16.12|13:57:35] NVE-sim-1:           800     800.00     188.      -0.01042        -145.587         532.8
[16.12|13:57:36] NVE-sim-1:           850     850.00     185.      -0.00977         425.375         532.8
[16.12|13:57:36] NVE-sim-1:           900     900.00     166.      -0.00634         -96.714         532.8
[16.12|13:57:36] NVE-sim-1:           950     950.00     189.      -0.01046        -395.794         532.8
[16.12|13:57:36] NVE-sim-1:          1000    1000.00     195.      -0.01168         -26.901         532.8
[16.12|13:57:36] NVE-sim-1: MD calculation finished.
[16.12|13:57:36] NVE-sim-1: NORMAL TERMINATION
[16.12|13:57:36] JOB NVE-sim-1 FINISHED
[16.12|13:57:36] JOB NVE-sim-1 SUCCESSFUL

The main output file from an MD simulation is called ams.rkf. It contains the trajectory and other properties (energies, velocities, …). You can open the file

  • in the AMSmovie graphical user interface to play (visualize) the trajectory.

  • in the KFbrowser module (expert mode) to find out where on the file various properties are stored.

Call the job.results.rkfpath() method to find out where the file is:

print(nve_job.results.rkfpath())
/home/user/adfhome/userdoc/Tutorials/MolecularDynamicsAndMonteCarlo/MDintroPython/plams_workdir/NVE-sim-1/ams.rkf

Use job.results.readrkf() to directly read from the ams.rkf file:

n_frames = nve_job.results.readrkf('MDHistory', 'nEntries')
print("There are {} frames in the trajectory".format(n_frames))
There are 21 frames in the trajectory

Let’s plot the potential, kinetic, and total energies:

def plot_energies(job, title=None, kinetic_energy=True, potential_energy=True, total_energy=True, conserved_energy=False):
    time = job.results.get_history_property('Time', 'MDHistory')
    plt.figure(figsize=(5,3));
    if kinetic_energy:
        kin_e = job.results.get_history_property('KineticEnergy', 'MDHistory')
        plt.plot(time, kin_e, label='Kinetic Energy')
    if potential_energy:
        pot_e = job.results.get_history_property('PotentialEnergy', 'MDHistory')
        plt.plot(time, pot_e, label='Potential Energy')
    if total_energy:
        tot_e = job.results.get_history_property('TotalEnergy', 'MDHistory')
        plt.plot(time, tot_e, label='Total Energy')
    if conserved_energy:
        conserved_e = job.results.get_history_property('ConservedEnergy', 'MDHistory')
        plt.plot(time, conserved_e, label='Conserved Energy')
    plt.legend()
    plt.xlabel("Time (fs)")
    plt.ylabel("Energy (Ha)")
    plt.title(title or job.name + " Energies")
    plt.show()
plot_energies(nve_job)
../../_images/MDintroPython_20_0.png

During the NVE simulation, kinetic energy is converted into potential energy and vice versa, but the total energy remains constant.

Let’s also show an image of the final frame. The atoms of some molecules are split across the periodic boundary.

show(nve_job.results.get_main_molecule())
../../_images/MDintroPython_22_0.png

NVT simulation

In an NVT simulation, a thermostat keeps the temperature (kinetic energy) in check. The time constant tau (in femtoseconds) indicates how “strong” the thermostat is. For now, let’s set a Berendsen thermostat with a time constant of 100 fs.

nvt_job = AMSNVTJob(molecule=box,
                    settings=s,
                    name='NVT-sim-1',
                    nsteps=1000,
                    samplingfreq=50,
                    timestep=1.0, # femtoseconds
                    temperature=300,
                    thermostat='Berendsen',
                    tau=100 # femtoseconds
                   )
nvt_job.run();
[16.12|13:57:37] JOB NVT-sim-1 STARTED
[16.12|13:57:37] JOB NVT-sim-1 RUNNING
[16.12|13:57:38] JOB NVT-sim-1 FINISHED
[16.12|13:57:38] JOB NVT-sim-1 SUCCESSFUL
def plot_temperature(job, title=None):
    time = job.results.get_history_property('Time', 'MDHistory')
    temperature = job.results.get_history_property('Temperature', 'MDHistory')
    plt.figure(figsize=(5,3))
    plt.plot(time, temperature)
    plt.xlabel("Time (fs)")
    plt.ylabel("Temperature (K)")
    plt.title(title or job.name + " Temperature")
    plt.show()
plot_temperature(nvt_job)
plot_energies(nvt_job)
../../_images/MDintroPython_26_0.png ../../_images/MDintroPython_26_1.png

In an NVT simulation, the total energy is not conserved. Instead, the quantity known as “Conserved Energy” is conserved:

plot_energies(nvt_job, conserved_energy=True)
../../_images/MDintroPython_28_0.png

NPT simulation

In an NPT simulation, a barostat is used to keep the pressure constant (fluctuating around a given value). The instantaneous pressure can fluctuate quite wildly, even with a small time constant for the barostat.

Let’s run an NPT simulation with the same thermostat as before and with a barostat set at 1 bar.

Note: It is usually good to pre-equilibrate a simulation in NVT before switching on the barostat. Below we demonstrate the restart_from function, which will restart from nvt_job (final frame and velocities). You can also use restart_from together with AMSNVEJob or AMSNVTJob.

npt_job = AMSNPTJob.restart_from(nvt_job,
                                 name='npt',
                                 barostat='Berendsen',
                                 pressure=1e5, # Pa
                                 barostat_tau=1000, # fs
                                 equal='XYZ' # scale all lattice vectors equally
                                )

npt_job.run();
[16.12|13:57:39] JOB npt STARTED
[16.12|13:57:39] JOB npt RUNNING
[16.12|13:57:41] JOB npt FINISHED
[16.12|13:57:41] JOB npt SUCCESSFUL
def moving_average(x, y, window:int):
    if not window:
        return x, y
    window = min(len(x)-1, window)
    if window <= 1:
        return x, y
    ret_x = np.convolve(x, np.ones(window)/window, mode='valid')
    ret_y = np.convolve(y, np.ones(window)/window, mode='valid')
    return ret_x, ret_y

def plot_pressure(job, title=None, window:int=None):
    time = job.results.get_history_property('Time', 'MDHistory')
    pressure = job.results.get_history_property('Pressure', 'MDHistory')
    pressure = Units.convert(pressure, 'au', 'bar') # convert from atomic units to bar
    time, pressure = moving_average(time, pressure, window)
    plt.figure(figsize=(5,3))
    plt.plot(time, pressure)
    plt.xlabel("Time (fs)")
    plt.ylabel("Pressure (bar)")
    plt.title(title or job.name + " Pressure")
    plt.show()
plot_pressure(npt_job)
../../_images/MDintroPython_32_0.png

Those are quite some fluctuations! Especially for the pressure it can be useful to plot a moving average. Here the window parameter gives the number of frames in the trajectory to average over.

plot_pressure(npt_job, window=10, title="npt Pressure moving average")
../../_images/MDintroPython_34_0.png

From this moving average it is easier to see that the pressure is too high compared to the target pressure of 1 bar, so the simulation is not yet in equilibrium.

Let’s also plot the density as a function of time:

def plot_density(job, title=None, window:int=None):
    time = job.results.get_history_property('Time', 'MDHistory')
    density = job.results.get_history_property('Density', 'MDHistory')
    density = np.array(density) * Units.convert(1.0, 'au', 'kg') / Units.convert(1.0, 'bohr', 'm')**3
    time, density = moving_average(time, density, window)
    plt.figure(figsize=(5,3))
    plt.plot(time, density)
    plt.xlabel("Time (fs)")
    plt.ylabel("Density (kg/m^3)")
    plt.title(title or job.name + " Density")
    plt.show()
plot_density(npt_job)
../../_images/MDintroPython_37_0.png

Since the pressure was too high, the barostat increases the size of the simulation box (which decreases the density). Note that the initial density was 400 kg/m^3 which matches the density given to the packmol function at the top (0.4 g/cm^3)!

Equilibration and Mean squared displacement (diffusion coefficient)

The mean squared displacement is in practice often calculated from NVT simulations. This is illustrated here. However, best practice is to instead

  • average over several NVE simulations that have been initialized with velocities from NVT simulations, and to

  • extrapolate the diffusion coefficient to infinite box size

Those points will be covered later. Here, we just illustrate the simplest possible way (that is also the most common in practice).

The mean squared displacement is only meaningful for fully equilibrated simulations. Let’s first run a reasonably long equilibration simulation, and then a production simulation. These simulations may take a few minutes to complete.

nvt_eq_job = AMSNVTJob(
    molecule=box,
    settings=s,
    name='NVT-equilibration-1',
    temperature=300,
    thermostat='Berendsen',
    tau=200,
    timestep=1.0,
    nsteps=40000,
    samplingfreq=500,
)
nvt_eq_job.run()
plot_energies(nvt_eq_job)
[16.12|13:57:41] JOB NVT-equilibration-1 STARTED
[16.12|13:57:41] JOB NVT-equilibration-1 RUNNING
[16.12|13:58:50] JOB NVT-equilibration-1 FINISHED
[16.12|13:58:50] JOB NVT-equilibration-1 SUCCESSFUL
../../_images/MDintroPython_40_1.png

The energies seem equilibrated, so let’s run a production simulation and switch to the NHC (Nose-Hoover) thermostat

nvt_prod_job = AMSNVTJob.restart_from(
    nvt_eq_job,
    name='NVT-production-1',
    nsteps=50000,
    thermostat='NHC',
    samplingfreq=100
)

nvt_prod_job.run();
[16.12|13:58:50] JOB NVT-production-1 STARTED
[16.12|13:58:50] JOB NVT-production-1 RUNNING
[16.12|14:00:17] JOB NVT-production-1 FINISHED
[16.12|14:00:17] JOB NVT-production-1 SUCCESSFUL
plot_energies(nvt_prod_job)
plot_temperature(nvt_prod_job)
../../_images/MDintroPython_43_0.png ../../_images/MDintroPython_43_1.png

The simulation seems to be sampling an equilibrium distribution, so it’s safe to calculate the mean squared displacement. Let’s use the AMSMSDJob for this. We’ll also specify to only calculate the MSD for the carbon atoms of the methane molecules.

atom_indices = [i for i,at in enumerate(nvt_prod_job.results.get_main_molecule(), 1) if at.symbol == 'C']
print("Calculating MSD for carbon atoms with indices {}".format(atom_indices))
msd_job = AMSMSDJob(
    nvt_prod_job,
    name='msd-'+nvt_prod_job.name,
    atom_indices=atom_indices, # indices start with 1 for the first atom
    max_correlation_time_fs=8000,  # max correlation time must be set before running the job
    start_time_fit_fs=3000 # start_time_fit can also be changed later in the postanalysis
)
msd_job.run();
Calculating MSD for carbon atoms with indices [1, 6, 11, 16, 21, 26, 31, 36]
[16.12|14:00:17] JOB msd-NVT-production-1 STARTED
[16.12|14:00:17] JOB msd-NVT-production-1 RUNNING
[16.12|14:00:17] JOB msd-NVT-production-1 FINISHED
[16.12|14:00:18] JOB msd-NVT-production-1 SUCCESSFUL
def plot_msd(job, start_time_fit_fs=None):
    """ job: an AMSMSDJob """
    time, msd = job.results.get_msd()
    fit_result, fit_x, fit_y = job.results.get_linear_fit(start_time_fit_fs=start_time_fit_fs)
    # the diffusion coefficient can also be calculated as fit_result.slope/6 (ang^2/fs)
    diffusion_coefficient = job.results.get_diffusion_coefficient(start_time_fit_fs=start_time_fit_fs) # m^2/s
    plt.figure(figsize=(5,3))
    plt.plot(time, msd, label='MSD')
    plt.plot(fit_x, fit_y, label='Linear fit slope={:.5f} ang^2/fs'.format(fit_result.slope))
    plt.legend()
    plt.xlabel("Correlation time (fs)")
    plt.ylabel("Mean square displacement (ang^2)")
    plt.title("MSD: Diffusion coefficient = {:.2e} m^2/s".format(diffusion_coefficient))
    plt.show()
plot_msd(msd_job)
../../_images/MDintroPython_47_0.png

In the above graph, the MSD is a straight line. That is a good sign. If the line isn’t straight at large correlation times, you would need to run a longer simulation. The diffusion coefficient = slope/6.

The plot ends at 8000 fs (max_correlation_time_fs), and the linear fit is started from 3000 fs (start_time_fit_fs). You can also change the start of the linear fit in the postprocessing:

plot_msd(msd_job, start_time_fit_fs=7000)
../../_images/MDintroPython_49_0.png

Radial distribution function

Similarly to the MSD, a radial distribution function (RDF) should only be calculated for an equilibrated simulation.

Let’s calculate and plot the C-H RDF. Note that the RDF should only be calculated for distances up to half the shortest lattice vector length (for orthorhombic cells).

mol = nvt_prod_job.results.get_main_molecule()
atom_indices = [i for i, at in enumerate(mol, 1) if at.symbol == 'C']
atom_indices_to = [i for i, at in enumerate(mol, 1) if at.symbol == 'H']

# rmax = half the shortest box length for orthorhombic cells
rmax = min([mol.lattice[0][0], mol.lattice[1][1], mol.lattice[2][2]]) / 2

rdf_job = AMSRDFJob(
    nvt_prod_job,
    name='rdf-C-H-'+nvt_prod_job.name,
    atom_indices=atom_indices, # from C
    atom_indices_to=atom_indices_to, # to H
    rmin=0.5,
    rmax=rmax,
    rstep=0.1,
)
rdf_job.run();
[16.12|14:00:18] JOB rdf-C-H-NVT-production-1 STARTED
[16.12|14:00:18] JOB rdf-C-H-NVT-production-1 RUNNING
[16.12|14:00:18] JOB rdf-C-H-NVT-production-1 FINISHED
[16.12|14:00:18] JOB rdf-C-H-NVT-production-1 SUCCESSFUL
def plot_rdf(job, title=""):
    plt.figure(figsize=(5,3))
    r, rdf = job.results.get_rdf()
    plt.plot(r, rdf)
    plt.xlabel("r (angstrom)")
    plt.ylabel("g(r)")
    plt.title(title or job.name + " RDF")
    plt.show()
plot_rdf(rdf_job)
../../_images/MDintroPython_53_0.png

The covalent C-H bonds give rise to the peak slightly above 1.0 angstrom. The RDF then slowly approaches a value of 1, which is the expected value for an ideal gas.

Relation to AMSJob and AMSAnalysisJob

The AMSNVEJob, AMSNVTJob, and AMSNPTJob classes are just like normal AMSJob. The arguments populate the job’s settings.

Similarly, AMSMSDJob, AMSRDFJob, and AMSVACFJob are normal AMSAnalysisJob.

Let’s print the AMS input for the nvt_prod_job. Note that the initial velocities (and the structure) come from the equilibration job.

print(nvt_prod_job.get_input())
MolecularDynamics
  CalcPressure False
  Checkpoint
    Frequency 1000000
  End
  InitialVelocities
    File /home/user/adfhome/userdoc/Tutorials/MolecularDynamicsAndMonteCarlo/MDintroPython/plams_workdir/NVT-equilibration-1/ams.rkf
    Type FromFile
  End
  NSteps 50000
  Thermostat
    Tau 200
    Temperature 300
    Type NHC
  End
  TimeStep 1.0
  Trajectory
    SamplingFreq 100
    WriteBonds True
    WriteCharges True
    WriteEngineGradients False
    WriteMolecules True
    WriteVelocities True
  End
End

Task MolecularDynamics

system
  Atoms
              C       4.8991753975       7.9740278473       6.3775697371 region=methane
              H       4.8683467538       8.2137820200       7.4458000633 region=methane
              H       4.1673367662       7.2744382792       6.0825790746 region=methane
              H       5.9205406013       7.4765392797       6.3581212674 region=methane
              H       4.9558376608       0.7783342389       5.7618450121 region=methane
              C       7.6587724748       5.3828517532       3.9641388590 region=methane
              H       6.8698649734       4.8317313866       3.3778179915 region=methane
              H       0.2738065365       4.6703579393       4.3831550679 region=methane
              H       7.0717146417       6.0707959225       4.7219843397 region=methane
              H       0.0991413414       6.1111419996       3.3039991412 region=methane
              C       5.6671425842       2.7639143055       1.2013928468 region=methane
              H       5.8462523091       3.5840808162       0.4921047085 region=methane
              H       5.1214645109       3.2889365378       2.0376920558 region=methane
              H       6.5837613388       2.3766913449       1.6907559322 region=methane
              H       5.1133883924       1.8857607878       0.8250731793 region=methane
              C       4.1960346321       4.3580201278       6.3056249959 region=methane
              H       3.6542670429       3.4145322927       6.6809545281 region=methane
              H       3.7448499430       4.8589270599       5.3933015692 region=methane
              H       5.2713201496       4.0618477332       6.0444114424 region=methane
              H       4.2868382062       5.1676723523       7.1000894876 region=methane
              C       1.8049036041       2.3129478126       2.6986805196 region=methane
              H       2.7114518958       2.0238584997       3.2447092247 region=methane
              H       1.7764044745       3.4054848982       2.5234918974 region=methane
              H       0.8561739944       2.0576675174       3.2586312190 region=methane
              H       1.7883477865       1.8672415543       1.6600099661 region=methane
              C       0.7559457199       1.2173566947       6.6873947861 region=methane
              H       1.4541146736       0.4854421168       7.1911505341 region=methane
              H       0.9283749739       1.2662409976       5.6516831256 region=methane
              H       0.8270781445       2.1744703897       7.2157820394 region=methane
              H       7.8562679782       0.7411978461       6.7808346203 region=methane
              C       4.5345529082       7.1287265435       3.0988390256 region=methane
              H       3.4574064146       7.1967953478       3.4064816179 region=methane
              H       5.2224156425       7.9563551507       3.3910200754 region=methane
              H       4.7521346240       7.0597973228       2.0034384492 region=methane
              H       4.8918318898       6.2164545574       3.6282340398 region=methane
              C       0.9960251868       5.0584341712       7.6466008887 region=methane
              H       0.3715092607       4.7246524039       6.7830667429 region=methane
              H       0.5455196747       6.0377495075      -0.0708332056 region=methane
              H       2.0482928915       5.3668835200       7.3275293746 region=methane
              H       1.1410307748       4.3577703569       0.3364731756 region=methane
  End
  BondOrders
     1 2 1.0
     1 3 1.0
     1 4 1.0
     1 5 1.0 0 1 0
     6 7 1.0
     6 8 1.0 1 0 0
     6 9 1.0
     6 10 1.0 1 0 0
     11 12 1.0
     11 13 1.0
     11 14 1.0
     11 15 1.0
     16 17 1.0
     16 18 1.0
     16 19 1.0
     16 20 1.0
     21 22 1.0
     21 23 1.0
     21 24 1.0
     21 25 1.0
     26 27 1.0
     26 28 1.0
     26 29 1.0
     26 30 1.0 -1 0 0
     31 32 1.0
     31 33 1.0
     31 34 1.0
     31 35 1.0
     36 37 1.0
     36 38 1.0 0 0 1
     36 39 1.0
     36 40 1.0 0 0 1
  End
  Lattice
         8.1068205709     0.0000000000     0.0000000000
         0.0000000000     8.1068205709     0.0000000000
         0.0000000000     0.0000000000     8.1068205709
  End
End

Engine ForceField
  Type UFF
EndEngine

And similarly the input to the AMS MD postanalysis program for the msd_job:

print(msd_job.get_input())
MeanSquareDisplacement
  Atoms
    Atom 1
    Atom 6
    Atom 11
    Atom 16
    Atom 21
    Atom 26
    Atom 31
    Atom 36
  end
  MaxStep 80
  Property DiffusionCoefficient
  StartTimeSlope 3000
end

Task MeanSquareDisplacement

TrajectoryInfo
  Trajectory
    KFFileName /home/user/adfhome/userdoc/Tutorials/MolecularDynamicsAndMonteCarlo/MDintroPython/plams_workdir/NVT-production-1/ams.rkf
  end
end

Velocity autocorrelation function, power spectrum, AMSNVESpawner

The Fourier transform of the velocity autocorrelation function gives the power spectrum, containing characteristic frequencies of all the vibrations, rotations, and translations of the molecules.

To calculate the velocity autocorrelation function, you need to

  • run several short NVE simulations with equilibrated initial velocities

  • write the velocities to disk every step

  • calculate the autocorrelation function

For the first two steps, we’ll use the AMSNVESpawnerJob class, initializing NVE simulations from evenly spaced frames in the nvt_prod_job job. For the last step, we’ll use the AMSVACFJob.

nvespawner_job = AMSNVESpawnerJob(
    nvt_prod_job,
    name='nvespawner-'+nvt_prod_job.name,
    n_nve=3, # the number of NVE simulations to run
    samplingfreq=1, # write to disk every frame for velocity autocorrelation
    writevelocities=True,
    timestep=1.0, # ideally use smaller timestep
    nsteps=3000 # 3000 steps (here = 3000 fs) is very short, for demonstration purposes only
)
nvespawner_job.run();
[16.12|14:00:18] JOB nvespawner-NVT-production-1 STARTED
[16.12|14:00:18] JOB nvespawner-NVT-production-1 RUNNING
[16.12|14:00:18] JOB nvespawner-NVT-production-1/nve1 STARTED
[16.12|14:00:18] JOB nvespawner-NVT-production-1/nve1 RUNNING
[16.12|14:00:28] JOB nvespawner-NVT-production-1/nve1 FINISHED
[16.12|14:00:28] JOB nvespawner-NVT-production-1/nve1 SUCCESSFUL
[16.12|14:00:28] JOB nvespawner-NVT-production-1/nve2 STARTED
[16.12|14:00:28] JOB nvespawner-NVT-production-1/nve2 RUNNING
[16.12|14:00:38] JOB nvespawner-NVT-production-1/nve2 FINISHED
[16.12|14:00:38] JOB nvespawner-NVT-production-1/nve2 SUCCESSFUL
[16.12|14:00:38] JOB nvespawner-NVT-production-1/nve3 STARTED
[16.12|14:00:38] JOB nvespawner-NVT-production-1/nve3 RUNNING
[16.12|14:00:48] JOB nvespawner-NVT-production-1/nve3 FINISHED
[16.12|14:00:48] JOB nvespawner-NVT-production-1/nve3 SUCCESSFUL
[16.12|14:00:48] JOB nvespawner-NVT-production-1 FINISHED
[16.12|14:00:50] JOB nvespawner-NVT-production-1 SUCCESSFUL

Let’s then create the AMSVACFJobs. The previous NVE jobs are accessed by nvespawner_job.children.values():

vacf_jobs = []
for job in nvespawner_job.children.values():
    vacf_job = AMSVACFJob(
        job,
        name='vacf-'+job.name+'-'+nvespawner_job.name,
        max_correlation_time_fs=1000 # 1000 fs is very short, for demo purposes only
    )
    vacf_jobs.append(vacf_job)
    vacf_jobs[-1].run();
[16.12|14:00:50] JOB vacf-nve1-nvespawner-NVT-production-1 STARTED
[16.12|14:00:50] JOB vacf-nve1-nvespawner-NVT-production-1 RUNNING
[16.12|14:00:51] JOB vacf-nve1-nvespawner-NVT-production-1 FINISHED
[16.12|14:00:51] JOB vacf-nve1-nvespawner-NVT-production-1 SUCCESSFUL
[16.12|14:00:51] JOB vacf-nve2-nvespawner-NVT-production-1 STARTED
[16.12|14:00:51] JOB vacf-nve2-nvespawner-NVT-production-1 RUNNING
[16.12|14:00:52] JOB vacf-nve2-nvespawner-NVT-production-1 FINISHED
[16.12|14:00:52] JOB vacf-nve2-nvespawner-NVT-production-1 SUCCESSFUL
[16.12|14:00:52] JOB vacf-nve3-nvespawner-NVT-production-1 STARTED
[16.12|14:00:52] JOB vacf-nve3-nvespawner-NVT-production-1 RUNNING
[16.12|14:00:53] JOB vacf-nve3-nvespawner-NVT-production-1 FINISHED
[16.12|14:00:54] JOB vacf-nve3-nvespawner-NVT-production-1 SUCCESSFUL

Let’s get and plot the average velocity autocorrelation function and power spectrum

def plot_vacf(vacf_jobs):
    """vacf_jobs is a list of AMSVACFJob, plot the average velocity autocorrelation function"""
    x, y = zip(*[job.results.get_vacf() for job in vacf_jobs])
    x = np.mean(x, axis=0)
    y = np.mean(y, axis=0)
    plt.figure(figsize=(5,3))
    plt.plot(x, y)
    plt.xlabel("Correlation time (fs)")
    plt.ylabel("Normalized velocity autocorrelation function")
    plt.title("Average velocity autocorrelation function")

def plot_power_spectrum(vacf_jobs, newfigure=True):
    x, y = zip(*[job.results.get_power_spectrum() for job in vacf_jobs])
    x = np.mean(x, axis=0)
    y = np.mean(y, axis=0)
    if newfigure:
        plt.figure(figsize=(5,3))
    plt.plot(x, y)
    plt.xlabel("Frequency (cm^-1)")
    plt.ylabel("Intensity (arb. units)")
    plt.title("Average power spectrum")
plot_vacf(vacf_jobs)
plot_power_spectrum(vacf_jobs)
../../_images/MDintroPython_65_0.png ../../_images/MDintroPython_65_1.png

Let’s compare the power spectrum to the calculated frequencies from a gasphase geometry optimization + frequencies calculation (harmonic approximation). First, let’s define and run the job. Remember: s is the UFF settings from before, and single_molecule is the single methane molecule defined before.

sfreq = Settings()
sfreq.input.ams.Task = 'GeometryOptimization'
sfreq.input.ams.Properties.NormalModes = 'Yes'
sfreq += s # UFF engine settings used before
freq_job = AMSJob(settings=sfreq, name='methane-optfreq', molecule=single_molecule) # single_molecule defined at the beginning
freq_job.run()
print("Normal modes stored in {}".format(freq_job.results.rkfpath(file='engine')))
[16.12|14:00:54] JOB methane-optfreq STARTED
[16.12|14:00:54] JOB methane-optfreq RUNNING
[16.12|14:00:54] JOB methane-optfreq FINISHED
[16.12|14:00:54] JOB methane-optfreq SUCCESSFUL
Normal modes stored in /home/user/adfhome/userdoc/Tutorials/MolecularDynamicsAndMonteCarlo/MDintroPython/plams_workdir/methane-optfreq/forcefield.rkf

You can open the forcefield.rkf file in AMSspectra to visualize the vibrational normal modes. You can also access the frequencies in Python:

freqs = freq_job.results.get_frequencies()
print("There are {} frequencies for gasphase methane".format(len(freqs)))
for f in freqs:
    print("{:.1f} cm^-1".format(f))
There are 9 frequencies for gasphase methane
1310.1 cm^-1
1310.1 cm^-1
1310.1 cm^-1
1467.4 cm^-1
1467.4 cm^-1
2783.4 cm^-1
2942.0 cm^-1
2942.0 cm^-1
2942.0 cm^-1

Let’s make something similar to a power spectrum by placing a Gaussian at each normal mode frequency and compare to the power spectrum from the molecular dynamics.

First, define the gaussian and sumgaussians function to sum up Gaussian functions along an axis:

def gaussian(x, a, b, c):
    return a*np.exp(-(x-b)**2/(2*c**2))

def sumgaussians(x, a, b, c):
    # calculate the sum of gaussians centered at b for the different x values
    # use numpy array broadcasting by placing x in a row and b in a column
    x = np.reshape(x, (1,-1)) # row
    b = np.reshape(b, (-1,1)) # column
    return np.sum(gaussian(x, a, b, c), axis=0)

Then, define the x-axis x, and calculate the spectrum from the frequencies by placing gaussians centered at freqs with height controlled by a and width controlled by c:

x = np.arange(5000) # x axis
y = sumgaussians(x, a=2, b=freqs, c=30)  # intensities, use suitable numbers for a and c

Then compare to the power spectrum from MD:

plot_power_spectrum(vacf_jobs)
plt.plot(x, y)
plt.legend(['power spectrum from MD', 'gasphase optimization'])
plt.show()
../../_images/MDintroPython_75_0.png

The most striking difference is the intensity below 500 cm^-1 for the MD simulation. This arises from translational and rotational motion. There are also differences in the other peaks, arising from

  • intermolecular interactions, and

  • anharmonicity, and

  • sampling during the MD simulation

Note that the intensity is arbitrarily scaled for both the MD simulation and the gasphase optimization. The above do not correspond to IR or Raman spectra. They are power spectra.

Density equilibration

Typically for liquids (but also for solids), you’d like to know what density your force field predicts at a given temperature.

For example, you might use the following workflow:

  • packmol: The initial system should have a much lower density than you expect

  • AMSNVTJob: Some initial MD is performed at this low density, which gives an implicit conformer search

  • AMSMDScanDensityJob: The box is then progressively scaled to a density higher than what you expect. Or you could just start directly with an AMSNPTJob (two steps down)

  • AMSNVTJob: The box size for which the energy was the lowest is used to run a short NVT simulation

  • AMSNPTJob: From the NVT simulation an NPT simulation is run

  • The average density from the last third of the NPT simulation is used as the “equilibrated density”.

  • AMSNPTJob.results.get_equilibrated_molecule() returns a molecule from the last third with a density as close as possible to the equilibrated density.

UFF does not give very good densities. Let’s instead try ReaxFF for liquid water. Note: parallelization in ReaxFF uses both MPI and OpenMP. To run in serial, you also need to set the OMP_NUM_THREADS variable to 1.

reaxff_s = Settings()
reaxff_s.input.ReaxFF.ForceField = 'Water2017.ff'
reaxff_s.runscript.nproc = 1
reaxff_s.runscript.preamble_lines = ['export OMP_NUM_THREADS=1']

Step 1: build the low density system with packmol:

low_density_box = packmol(from_smiles('O'), n_molecules=32, region_names='water', density=0.4)
low_density_box = preoptimize(low_density_box, maxiterations=50)
show(low_density_box)
../../_images/MDintroPython_80_0.png
# step 2 : initial NVT at low density

nvt_low_density_job = AMSNVTJob(
    molecule=low_density_box,
    name="nvt_low_density_job",
    settings=reaxff_s,
    nsteps=3000, # should be much larger for production purposes,
    temperature=300,
    tau=100,
    samplingfreq=500,
    timestep=0.5
)
nvt_low_density_job.run();
[16.12|14:00:55] JOB nvt_low_density_job STARTED
[16.12|14:00:55] JOB nvt_low_density_job RUNNING
[16.12|14:01:04] JOB nvt_low_density_job FINISHED
[16.12|14:01:04] JOB nvt_low_density_job SUCCESSFUL
# step 3 : scan to higher density

scan_density_job = AMSMDScanDensityJob.restart_from(
    nvt_low_density_job,
    name="scan_density_job",
    scan_density_upper=1.4,
    nsteps=6000,
    samplingfreq=100
)
scan_density_job.run();
[16.12|14:01:04] JOB scan_density_job STARTED
[16.12|14:01:04] JOB scan_density_job RUNNING
[16.12|14:01:32] JOB scan_density_job FINISHED
[16.12|14:01:32] JOB scan_density_job SUCCESSFUL
plot_energies(scan_density_job, kinetic_energy=False, potential_energy=False)
plot_density(scan_density_job)
../../_images/MDintroPython_83_0.png ../../_images/MDintroPython_83_1.png

Let’s get the molecule where the energy is the lowest:

history_index = scan_density_job.results.get_lowest_energy_index('TotalEnergy', 'MDHistory')
lowest_energy_molecule = scan_density_job.results.get_history_molecule(history_index)
lowest_energy_time = scan_density_job.results.get_property_at_step(history_index, 'Time', 'MDHistory')
print(f'Extracting frame {history_index}')
print(f'At MD-time: {lowest_energy_time:.1f} fs')
print(f'Density: {lowest_energy_molecule.get_density():.1f} kg/m^3')
Extracting frame 53
At MD-time: 2600.0 fs
Density: 1145.9 kg/m^3
# step 4 : short NVT equilibration at new density

nvt_equilibration_job = AMSNVTJob(
    molecule=lowest_energy_molecule,
    name="nvt_equilibration",
    settings=reaxff_s,
    nsteps=3000,
    timestep=0.5,
    temperature=300
)
nvt_equilibration_job.run();
[16.12|14:01:32] JOB nvt_equilibration STARTED
[16.12|14:01:32] JOB nvt_equilibration RUNNING
[16.12|14:01:52] JOB nvt_equilibration FINISHED
[16.12|14:01:52] JOB nvt_equilibration SUCCESSFUL
# step 5 : long NPT equilibration

npt_equilibration = AMSNPTJob.restart_from(
    nvt_equilibration_job,
    name="npt_equilibration",
    nsteps=10000, # should be much longer!
    temperature=300,
    thermostat='NHC',
    tau=100,
    barostat='MTK',
    equal='XYZ',
    pressure=1e5,
    barostat_tau=1000
)

npt_equilibration.run();
[16.12|14:01:52] JOB npt_equilibration STARTED
[16.12|14:01:52] JOB npt_equilibration RUNNING
[16.12|14:02:49] JOB npt_equilibration FINISHED
[16.12|14:02:49] JOB npt_equilibration SUCCESSFUL
plot_density(npt_equilibration)
../../_images/MDintroPython_88_0.png

Let’s get the equilibrated molecule (note: the above simulation was not long enough, there was no proper equilibration! The simulation should have run for longer).

equilibrated_box = npt_equilibration.results.get_equilibrated_molecule()
print("Density: {:.2f} kg/m^3".format(equilibrated_box.get_density()))
equilibrated_box.write("water_equilibrated_box.xyz")
show(equilibrated_box)
Density: 995.76 kg/m^3
../../_images/MDintroPython_90_1.png

Diffusion coefficient supercell dependence

Because of finite-size effects, the calculated diffusion coefficient depends on the supercell size.

The below lines set up Lennard-Jones Argon simulations at a density of 1.0 g/mL and a temperature of 400 K for varying supercell sizes. The calculated diffusion coefficients (D) are plotted vs the inverse cubic box length (1/L), a linear fit is performed, and the extrapolated diffusion coefficient for “infinite” box size is read from the plot where 1/L = 0.

def get_lennard_jones_settings():
    s = Settings()
    s.input.lennardjones.eps = 3.785e-4
    s.input.lennardjones.rmin = 3.81637
    s.input.lennardjones.cutoff = 8.0
    s.runscript.nproc = 1    # to run in serial
    return s

Set up 3 systems with 50, 150, and 450 atoms. They all have the same density.

sizes = [50, 150, 450] # number of atoms
inverted_L_list = [] # inverted cubic box length in angstrom
density = 1.0  # system density in g/cm^3

Ar = Molecule()
Ar.add_atom(Atom(symbol='Ar', coords=(0, 0, 0)))   # a single Ar atom

boxes = []

for size in sizes:
    mol = packmol(Ar, n_molecules=size, density=density)
    mol = preoptimize(mol, settings=get_lennard_jones_settings(), maxiterations=50)
    boxes.append(mol)
    L = mol.lattice[0][0]
    inverted_L_list.append(1.0/L)
    print(f'{size} Ar atoms, density = {density} g/cm^3')
    show(mol, figsize=(L/20, L/20))
    plt.show()
50 Ar atoms, density = 1.0 g/cm^3
../../_images/MDintroPython_94_1.png
150 Ar atoms, density = 1.0 g/cm^3
../../_images/MDintroPython_94_3.png
450 Ar atoms, density = 1.0 g/cm^3
../../_images/MDintroPython_94_5.png

For each system, run

  • equilibration job (eq_job)

  • production job (prod_job)

  • MSD analysis on the production job (msd_job)

Store the calculated diffusion coefficients in D_list

D_list = [] # diffusion coefficients
temperature = 400
max_correlation_time_fs = 6000
start_time_fit_fs = 3000
for size, mol in zip(sizes, boxes):
    eq_job = AMSNVTJob(name=f'eq_{size}', settings=get_lennard_jones_settings(), molecule=mol,
                       temperature=temperature, nsteps=10000, samplingfreq=500,
                       thermostat='Berendsen', tau=100, timestep=1.0, writebonds=False,
                       writecharges=False, writemolecules=False, writevelocities=False)
    eq_job.run()

    prod_job = AMSNVTJob.restart_from(eq_job, name=f'prod_{size}', nsteps=60000,
                                      samplingfreq=25, temperature=temperature, thermostat='NHC')
    prod_job.run()

    msd_job = AMSMSDJob(prod_job, name=f'msd_{size}', max_correlation_time_fs=max_correlation_time_fs)
    msd_job.run()

    D = msd_job.results.get_diffusion_coefficient(start_time_fit_fs=start_time_fit_fs)
    D_list.append(D)
    log(f"{size} atoms: D = {D:.2e} m^2 s^-1")
[16.12|14:02:53] JOB eq_50 STARTED
[16.12|14:02:53] JOB eq_50 RUNNING
[16.12|14:02:55] JOB eq_50 FINISHED
[16.12|14:02:55] JOB eq_50 SUCCESSFUL
[16.12|14:02:55] JOB prod_50 STARTED
[16.12|14:02:55] JOB prod_50 RUNNING
[16.12|14:03:07] JOB prod_50 FINISHED
[16.12|14:03:07] JOB prod_50 SUCCESSFUL
[16.12|14:03:07] JOB msd_50 STARTED
[16.12|14:03:07] JOB msd_50 RUNNING
[16.12|14:03:07] JOB msd_50 FINISHED
[16.12|14:03:07] JOB msd_50 SUCCESSFUL
[16.12|14:03:07] 50 atoms: D = 2.20e-08 m^2 s^-1
[16.12|14:03:07] JOB eq_150 STARTED
[16.12|14:03:07] JOB eq_150 RUNNING
[16.12|14:03:11] JOB eq_150 FINISHED
[16.12|14:03:11] JOB eq_150 SUCCESSFUL
[16.12|14:03:11] JOB prod_150 STARTED
[16.12|14:03:11] JOB prod_150 RUNNING
[16.12|14:03:38] JOB prod_150 FINISHED
[16.12|14:03:38] JOB prod_150 SUCCESSFUL
[16.12|14:03:38] JOB msd_150 STARTED
[16.12|14:03:38] JOB msd_150 RUNNING
[16.12|14:03:38] JOB msd_150 FINISHED
[16.12|14:03:39] JOB msd_150 SUCCESSFUL
[16.12|14:03:39] 150 atoms: D = 2.38e-08 m^2 s^-1
[16.12|14:03:39] JOB eq_450 STARTED
[16.12|14:03:39] JOB eq_450 RUNNING
[16.12|14:03:50] JOB eq_450 FINISHED
[16.12|14:03:50] JOB eq_450 SUCCESSFUL
[16.12|14:03:50] JOB prod_450 STARTED
[16.12|14:03:50] JOB prod_450 RUNNING
[16.12|14:05:12] JOB prod_450 FINISHED
[16.12|14:05:12] JOB prod_450 SUCCESSFUL
[16.12|14:05:12] JOB msd_450 STARTED
[16.12|14:05:12] JOB msd_450 RUNNING
[16.12|14:05:13] JOB msd_450 FINISHED
[16.12|14:05:14] JOB msd_450 SUCCESSFUL
[16.12|14:05:14] 450 atoms: D = 2.49e-08 m^2 s^-1
from scipy.stats import linregress
def linear_fit_extrapolate_to_0(x, y):
    """
        linear regression on x and y.
        Returns 3-tuple fit_x, fit_y, slope.

        Note: fit_x contains an extra x-value at 0 (the final value). This ensures that the point at 0 is included when plotting.
    """
    result = linregress(x, y)
    fit_x = list(x) + [0]    # append a 0
    fit_x = np.array(fit_x)
    fit_y = result.slope * fit_x + result.intercept

    return fit_x, fit_y, result.slope, result.intercept
fit_x, fit_y, slope, intercept = linear_fit_extrapolate_to_0(inverted_L_list, D_list)

plt.figure(figsize=(6,3))
plt.plot(inverted_L_list, D_list, '.', label="Simulation results")
plt.plot(fit_x, fit_y, label="Linear fit")
dilute_D = intercept  # at 0 inverted box length, what is the diffusion coefficient?
s = f"Extrapolated (dilute) D: {dilute_D:.2e} m² s⁻¹"
log(s)
plt.title(s)
plt.legend()
plt.xlabel("1/L (Å⁻¹)")
plt.ylabel("Diffusion coefficient D (m² s⁻¹)");
[16.12|14:05:14] Extrapolated (dilute) D: 2.76e-08 m² s⁻¹
Text(0, 0.5, 'Diffusion coefficient D (m² s⁻¹)')
../../_images/MDintroPython_99_2.png

Diffusion coefficient temperature dependence and running simulations in parallel

The diffusion coefficient follows an Arrhenius behavior with respect to the temperature. The below lines show how to calculate the prefactor \(D_0\) and barrier \(\Delta E\)

\(D(T) = D_0 \exp{\frac{-\Delta E}{kT}}\)

\(\ln D(T) = \ln D_0 - \frac{\Delta E}{k} \frac{1}{T}\)

where \(k = 8.617 \times 10^{-5}\) eV/K (the Boltzmann constant).

In this case, let’s use an Ar box with 50 atoms, and run at temperatures of 50, 100, 200, and 300 K.

T_list = [50, 100, 200, 300]
mol = packmol(Ar, n_molecules=50, density=density)
mol = preoptimize(mol, settings=get_lennard_jones_settings(), maxiterations=50)
show(mol)
../../_images/MDintroPython_101_0.png

We want to run 4 identical simulations except for the temperature. If you have a machine with multiple cores, you can run them in parallel with a parallel PLAMS JobRunner:

maxjobs = 4
config.default_jobrunner = JobRunner(parallel=True, maxjobs=maxjobs)

When using a parallel JobRunner in PLAMS, you should avoid accessing the results of previous jobs until absolutely necessary. This is needed to prevent PLAMS from waiting until a job has finished before continuing with the next one.

In the below loop,

  • AMSNVTJob.restart_from has the argument use_prerun=True - this means that the initial molecule and velocities are read inside that job’s prerun() method. Thus, PLAMS can continue in the loop without waiting for eq_job to finish.

  • The msd_jobs list is created store the MSD jobs and their results without accessing them. Thus, PLAMS can continue with creating the next equilibration job for the next temperature at the top of the loop, without waiting for the msd_job to finish

Only after all the jobs have been run do we access the results of the MSD jobs in the final loop with msd_job.results.get_diffusion_coefficient()

msd_jobs = []
DT_list = []
for T in T_list:
    eq_job = AMSNVTJob(name=f'eq_T_{T}', settings=get_lennard_jones_settings(), molecule=mol,
                       temperature=T, nsteps=10000, samplingfreq=500,
                       thermostat='Berendsen', tau=100, timestep=1.0, writebonds=False,
                       writecharges=False, writemolecules=False, writevelocities=False)
    eq_job.run()

    prod_job = AMSNVTJob.restart_from(
        eq_job,
        name=f'prod_T_{T}',
        nsteps=50000,
        samplingfreq=25,
        temperature=T,
        thermostat='NHC',
        use_prerun=True  # only access the equilibration job results when the production job is about to start
    )
    prod_job.run()

    msd_job = AMSMSDJob(prod_job, name=f'msd_T_{T}', max_correlation_time_fs=max_correlation_time_fs)
    msd_job.run()
    msd_jobs.append(msd_job)  # do not access the msd results yet, wait until all jobs have been started

# now we can access the results
for T, msd_job in zip(T_list, msd_jobs):
    D = msd_job.results.get_diffusion_coefficient(start_time_fit_fs=start_time_fit_fs)
    DT_list.append(D)
    log(f"Temperature {T} K: D = {D:.2e} m^2 s^-1")
[16.12|14:05:15] JOB eq_T_50 STARTED
[16.12|14:05:15] JOB prod_T_50 STARTED
[16.12|14:05:15] JOB eq_T_50 RUNNING
[16.12|14:05:15] Waiting for job eq_T_50 to finish
[16.12|14:05:15] JOB msd_T_50 STARTED
[16.12|14:05:15] JOB eq_T_100 STARTED
[16.12|14:05:15] JOB prod_T_100 STARTED
[16.12|14:05:15] Waiting for job prod_T_50 to finish
[16.12|14:05:15] JOB msd_T_100 STARTED
[16.12|14:05:15] JOB eq_T_100 RUNNING
[16.12|14:05:15] Waiting for job eq_T_100 to finish
[16.12|14:05:15] JOB eq_T_200 STARTED
[16.12|14:05:15] JOB prod_T_200 STARTED
[16.12|14:05:15] Waiting for job prod_T_100 to finish
[16.12|14:05:15] JOB msd_T_200 STARTED
[16.12|14:05:15] JOB eq_T_200 RUNNING
[16.12|14:05:15] JOB eq_T_300 STARTED
[16.12|14:05:15] Waiting for job eq_T_200 to finish
[16.12|14:05:15] Waiting for job prod_T_200 to finish
[16.12|14:05:15] JOB prod_T_300 STARTED
[16.12|14:05:15] JOB msd_T_300 STARTED
[16.12|14:05:15] Waiting for job eq_T_300 to finish
[16.12|14:05:15] Waiting for job msd_T_50 to finish
[16.12|14:05:15] JOB eq_T_300 RUNNING
[16.12|14:05:15] Waiting for job prod_T_300 to finish
[16.12|14:05:16] JOB eq_T_50 FINISHED
[16.12|14:05:16] JOB eq_T_50 SUCCESSFUL
[16.12|14:05:16] JOB prod_T_50 RUNNING
[16.12|14:05:16] JOB eq_T_100 FINISHED
[16.12|14:05:16] JOB eq_T_100 SUCCESSFUL
[16.12|14:05:16] JOB eq_T_200 FINISHED
[16.12|14:05:16] JOB prod_T_100 RUNNING
[16.12|14:05:16] JOB eq_T_200 SUCCESSFUL
[16.12|14:05:16] JOB prod_T_200 RUNNING
[16.12|14:05:16] JOB eq_T_300 FINISHED
[16.12|14:05:16] JOB eq_T_300 SUCCESSFUL
[16.12|14:05:16] JOB prod_T_300 RUNNING
[16.12|14:05:26] JOB prod_T_50 FINISHED
[16.12|14:05:26] JOB prod_T_50 SUCCESSFUL
[16.12|14:05:26] JOB msd_T_50 RUNNING
[16.12|14:05:26] JOB prod_T_100 FINISHED
[16.12|14:05:26] JOB prod_T_100 SUCCESSFUL
[16.12|14:05:26] JOB msd_T_100 RUNNING
[16.12|14:05:26] JOB msd_T_50 FINISHED
[16.12|14:05:26] JOB prod_T_200 FINISHED
[16.12|14:05:27] JOB prod_T_200 SUCCESSFUL
[16.12|14:05:27] JOB msd_T_100 FINISHED
[16.12|14:05:27] JOB msd_T_200 RUNNING
[16.12|14:05:27] JOB prod_T_300 FINISHED
[16.12|14:05:27] JOB prod_T_300 SUCCESSFUL
[16.12|14:05:27] JOB msd_T_300 RUNNING
[16.12|14:05:27] JOB msd_T_200 FINISHED
[16.12|14:05:27] JOB msd_T_50 SUCCESSFUL
[16.12|14:05:27] JOB msd_T_300 FINISHED
[16.12|14:05:27] JOB msd_T_100 SUCCESSFUL
[16.12|14:05:27] Temperature 50 K: D = 1.32e-09 m^2 s^-1
[16.12|14:05:27] Temperature 100 K: D = 6.69e-09 m^2 s^-1
[16.12|14:05:27] Waiting for job msd_T_200 to finish
[16.12|14:05:27] JOB msd_T_200 SUCCESSFUL
[16.12|14:05:27] Temperature 200 K: D = 1.14e-08 m^2 s^-1
[16.12|14:05:27] Waiting for job msd_T_300 to finish
[16.12|14:05:27] JOB msd_T_300 SUCCESSFUL
[16.12|14:05:27] Temperature 300 K: D = 1.77e-08 m^2 s^-1
# fit a straight line when plotting ln(d) vs. 1/T
inverted_T_list = 1.0/np.array(T_list)
ln_D_list = np.log(DT_list)
fit_x, fit_y, slope, intercept = linear_fit_extrapolate_to_0(inverted_T_list, ln_D_list)

D0 = np.exp(intercept)
boltzmann_eV_per_K = 8.617e-5 # eV/K
barrier = -boltzmann_eV_per_K * slope
title = f"D0: {D0:.2e} m² s⁻¹. Barrier: {barrier:.3f} eV"
log(title)

# multiply x-axis by 1000 for friendlier numbers
plt.figure(figsize=(6,3))
plt.plot(inverted_T_list*1000, ln_D_list, '.', label="Simulation results")
plt.plot(fit_x*1000, fit_y, label="Linear fit")
plt.title(title)
plt.legend()
plt.xlabel("1000/T (K⁻¹)")
plt.ylabel("ln D");
[16.12|14:05:27] D0: 2.75e-08 m² s⁻¹. Barrier: 0.013 eV
../../_images/MDintroPython_106_1.png