MD simulations of fluids#

import numpy as np
from itertools import product
import matplotlib.pyplot as plt

Initial positions, velocities and forces#

Unlike MC simulation where the initial condition is fully specified by positin arrray in MD simulations we must also specify initial velocities which allows us to determine initial forces!

def IC_pos(rho=0.88, N_cell=3):
    """
    Create a lattice of Lennard-Jones particles initially placed in a cubic FCC lattice.

    Parameters:
    rho (float, optional): Number density of particles. Defaults to 0.88.
    N_cell (int, optional): Number of unit cells in one direction. Defaults to 3.

    Returns:
    np.array: Array of particle positions.
    float: Length of the simulation box.
    int: Total number of particles.
    """
    from itertools import product
    
    N = 4 * N_cell ** 3  # Total number of particles in the system
    L = (N / rho) ** (1 / 3)  # Length of the simulation box
    L_cell = L / N_cell  # Length of a unit cell

    # Generate positions within the unit cell
    base_positions = np.array([[0, 0, 0],
                               [0, 0.5, 0.5],
                               [0.5, 0, 0.5],
                               [0.5, 0.5, 0]])

    # Create lattice by translating the base positions
    pos = np.array([base + np.array([x, y, z]) for x, y, z in product(range(N_cell), repeat=3)
                    for base in base_positions])

    # Scale positions to the size of the unit cell
    pos *= L_cell

    return pos, L, N


def IC_vel(N):
    '''Genreate initial distribution of velocities from Maxwell-Botlzmann'''

    vel  = np.random.randn(N, 3)
    vel -= np.average(vel, axis=0)

    return vel 

def IC_F(N):
    '''Generate force matrix objects to be used throughout simulation
    ---
    F:  (N, N, 3) array that contains all the forces between i, j particles in 3D
    ind: arrays of size N and N. These are indices of upper triangular matrix e.g (i, j) such that  i<j. 
    Having indices of all inter-particle forces allows us to avoid using for loops

    '''
    
    F   = np.zeros((N, N, 3))       
    I, J = np.triu_indices(N, k=1)   
    
    return F, I, J

Inspect initial positions, forces and velocities#

pos, L_box, N = IC_pos(rho=0.88, N_cell=3)

plt.scatter(pos[:, 0], pos[:,1])
<matplotlib.collections.PathCollection at 0x165f59f40>
../_images/f6729bac6dde37684e37fd43f40d048bd022a4da183b42c772ccc84bcb123eca.png
F, I, J = IC_F(5)

print(F.shape)
print(I)
print(J)
(5, 5, 3)
[0 0 0 0 1 1 1 2 2 3]
[1 2 3 4 2 3 4 3 4 4]
vel = IC_vel(100)

plt.hist(vel[:,0], alpha=0.5, density=True);
../_images/7c467a5c0a14ea1b0a54fc66b8655847a04822efb6afee0030d186e497da1b69.png

Computation of forces#

  • Most of the computational heavy lifting in MD simulations goes into evaluation of forces.

  • As the number of particles goes so does the need to evaluate the number of interacting pairs

  • In realistic simulations neighbor lists and cutoff distances are used to minimize and keep track of smaller number of particle pairs

def force_update(pos):   
    '''This function is used to update the force matrix F which has been created via IC_F function earlier
    pos: (N, 3) position array is specified as input
    
    returns
    ---
    forces:
    U:    potential energy
    P:    pressure
    hist: histogram of r^2 distances
    '''

    # Compute all unique ij distances
    r_vec = pos[I] - pos[J]

    # Use min image criteria for distances
    r_vec = (r_vec + L_box/2) % L_box - L_box/2

    # Compute r^2
    r_sq = np.sum(r_vec**2, axis=1)

    # Compute forces 
    dUdr = -(48 / r_sq ** 7 - 24 / r_sq ** 4)

    # Compute force maxtirx containing all forces between ij particles dU/dr * dr
    F[I,J] = dUdr[:, np.newaxis] * r_vec

    # Compute total force acting on each particle (i=1,...N) 
    forces = F.sum(axis=0) - F.sum(axis=1)

    # Compute potential energy, pressure and histogram of r^2 pair distances
    U = np.sum(4 / r_sq ** 6 - 4 / r_sq ** 3)

    P = np.sum(F[I,J] * r_vec)

    hist = np.histogram(r_sq, bins=30, range=(0, L_box / 2))[0]
    
    return forces, U, P, hist

Time stepping#

def time_step(pos, vel, F, dt=0.01):
    '''Update velocities, positions and forces after dt time step of integration'''

    vel += 0.5 * F * dt
    
    pos = np.mod(pos + vel * dt, L_box)

    F, pot, P, hist = force_update(pos)
    
    vel += 0.5 * F * dt
    kin = 0.5 * np.sum(vel**2)

    return pos, vel, F, pot, kin, P, hist

MD simulation loop#

def simulate(pos, 
             vel, 
             T=1, 
             freq_out=50, 
             nsteps=10000):
    '''Main MD simulation loop. All of the variables and force matrix must be initialized 
    so that functions can acess them'''

    kins, pots, Ps, hists = [], [], [], []
    
    F = force_update(pos)[0]

    for t in range(nsteps):

        # Update positions, velocities, forces
        pos, vel, F, pot, kin, P, hist = time_step(pos, vel, F)

        # Scaling velocitites to keep T constant
        vel *= np.sqrt(N * 3 * T / (2 * kin))
        
        # Save thermo output
        if t % freq_out == 0:

            kins.append(kin)
            pots.append(pot)
            Ps.append(P)
            hists.append(hist)

    return np.array(kins), np.array(pots), np.array(Ps), np.mean(hists, axis=0)

Running MD simulation#

  • Note that variables pos, L_box, F, I, J, vel are global variables which are used and updated by simulate function.

  • Therefore we collect all the relevant parameters of simulation in once cell to make it easy to do various exploratons.

#------- Initalize global variables: positions, box length, velocities and Forces-------
pos, L_box, N  = IC_pos(rho=0.88, N_cell=3) 
vel            = IC_vel(N)
F, I, J        = IC_F(N)

#Run simulation
kins, pots, Ps, hist = simulate(pos, vel, T=1, freq_out=50, nsteps=10000)

Analysis of MD simulation#

plt.plot(kins, label='KE')
plt.plot(pots, label='PE')
plt.xlabel('Time')
plt.ylabel('Energy')
plt.legend()
<matplotlib.legend.Legend at 0x166006be0>
../_images/b1953161ff279278e66e8433f11ef1b1a1519499d73b268fc0bc077f134529de.png
rho=0.88
r = np.linspace(0, L_box / 2, 30)
pair_correlation = hist / (4 * np.pi * rho * r)

plt.plot(r, pair_correlation, '-o') 
plt.xlabel(r'$r$')  
plt.ylabel(r'$g(r)$')
/var/folders/3h/w4n54dz57dvbhgvz68gdv0f0f3hkmm/T/ipykernel_77253/971943631.py:3: RuntimeWarning: invalid value encountered in divide
  pair_correlation = hist / (4 * np.pi * rho * r)
Text(0, 0.5, '$g(r)$')
../_images/993a5bc249ede2a3efb4ab1abfb913c7df72e9d3e0683bf8695b30c09e011e83.png