Creating a Python Module#

Overview

Questions:

  • What is a Python module?

Objectives:

  • Put Python code into a module (.py file)

  • Import functions from our module.

Moving Code into a module#

For the next section, we will move our code from the Jupyter notebook to a module.
We will be able to import functions from that module into the Jupyter notebook to more cleanly run our simulations.

Create a file called lastname_firstname_monte_carlo.py in day5 folder.

Gather all of your functions from your Jupyter notebook and paste them into the python module you created. In a Python module, most import statements are typically at the top of the module, followed by function definitions. When you are done, your file might look something like the following (this code is missing the random and cubic lattice configuration generators):

import math
import random

def calculate_LJ(r_ij):
    r6_term = math.pow(1/r_ij, 6)
    r12_term = math.pow(r6_term, 2)
    pairwise_energy = 4 * (r12_term - r6_term)
    return pairwise_energy


def calculate_distance(coord1, coord2, box_length=None):
    """
    Calculate the distance between two 3D coordinates.

    Parameters
    ----------
    coord1, coord2: list
        The atomic coordinates

    Returns
    -------
    distance: float
        The distance between the two points.
    """

    distance = 0
    for i in range(3):
        dim_dist = coord1[i] - coord2[i]

        if box_length:
            dim_dist = dim_dist - box_length * round(dim_dist / box_length)

        dim_dist = dim_dist**2
        distance += dim_dist

    distance = math.sqrt(distance)
    return distance
    
def calculate_total_energy(coordinates, box_length, cutoff):
    """
    Calculate the total Lennard Jones energy of a system of particles.

    Parameters
    ----------
    coordinates : list
        Nested list containing particle coordinates.

    Returns
    -------
    total_energy : float
        The total pairwise Lennard Jones energy of the system of particles.
    """

    total_energy = 0

    num_atoms = len(coordinates)

    for i in range(num_atoms):
        for j in range(i + 1, num_atoms):

            dist_ij = calculate_distance(
                coordinates[i], coordinates[j], box_length=box_length
            )

            if dist_ij < cutoff:
                interaction_energy = calculate_LJ(dist_ij)
                total_energy += interaction_energy

    return total_energy

def read_xyz(filepath):
    """
    Reads coordinates from an xyz file.
    
    Parameters
    ----------
    filepath : str
       The path to the xyz file to be processed.
       
    Returns
    -------
    atomic_coordinates : list
        A two dimensional list containing atomic coordinates

    """
    
    with open(filepath) as f:
        box_length = float(f.readline().split()[0])
        num_atoms = float(f.readline())
        coordinates = f.readlines()
    
    atomic_coordinates = []
    
    for atom in coordinates:
        split_atoms = atom.split()
        
        float_coords = []
        
        # We split this way to get rid of the atom label.
        for coord in split_atoms[1:]:
            float_coords.append(float(coord))
            
        atomic_coordinates.append(float_coords)
        
    
    return atomic_coordinates, box_length

def calculate_tail_correction(num_particles, box_length, cutoff):
    """
    Calculate the long range tail correction
    """

    const1 = (8 * math.pi * num_particles**2) / (3 * box_length**3)
    const2 = (1 / 3) * (1 / cutoff) ** 9 - (1 / cutoff) ** 3

    return const1 * const2

def accept_or_reject(delta_e, beta):
    """
    Accept or reject a move based on the Metropolis criterion.

    Parameters
    ----------
    delta_e : float
        The change in energy resulting from a particle movement.
    beta : float
        The inverse of the system temperature.

    Returns
    -------
    accept : bool
        Whether to accept the move.
    """

    if delta_e <= 0:
        accept = True
    else:
        random_number = random.random()
        p_acc = math.exp(-beta*delta_e)

        if random_number < p_acc:
            accept = True
        else:
            accept = False

    return accept

def calculate_pair_energy(coordinates, i_particle, box_length, cutoff):
    """
    Calculate the interaction energy of a particle with its environment (all other particles in the system).
    
    Parameters
    ----------
    coordinates : list
        The coordinates for all particles in the system.
    
    i_particle : int
        The particle number for which to calculate the energy.
    
    cutoff : float
        The simulation cutoff. Beyond this distance, interaction energies are not calculated.
    
    Returns
    -------
    e_total : float
        The pairwise interaction energy of the i_th particle with all other particles.
    """
    
    e_total = 0
    i_position = coordinates[i_particle]
    num_atoms = len(coordinates)
    
    for j_particle in range(num_atoms):
        if i_particle != j_particle:
            j_position = coordinates[j_particle]
            rij = calculate_distance(i_position, j_position, box_length)
    
            if rij < cutoff:
                e_pair = calculate_LJ(rij)
                e_total += e_pair
    
    return e_total
        

Next, add your simulation loop below your function definitions.

# Simulation Parameters
reduced_temperature = 0.9
num_moves = 1
max_displacement = 0.1
cutoff = 3.0
freq = 1000

# Calculated quantities
beta = 1 / reduced_temperature

# Define initial coordinates
coordinates, box_length = read_xyz("../data/sample_config1.txt")
num_particles = len(coordinates)

# Set up energy
delta_energy = 0

total_energy = calculate_total_energy(coordinates, box_length, cutoff)
total_energy += calculate_tail_correction(num_particles, box_length, cutoff)

print(f"The starting energy is {total_energy}.")

# Monte Carlo Simulation Loop

for move in range(num_moves):
    # 1. Randomly pick one of N particles.
    random_particle = random.randrange(num_particles)
    
    # 2. Calculate the interaction energy of the selected particle with the system, and store this value.
    current_energy = calculate_pair_energy(coordinates, random_particle, box_length, cutoff)
    
    # 3. Generate a random x, y, z displacement within max_displacement.
    x_rand = random.uniform(-max_displacement, max_displacement)
    y_rand = random.uniform(-max_displacement, max_displacement)
    z_rand = random.uniform(-max_displacement, max_displacement)
    
    # 4. Modify the coordinate of the selected particle by the generated displacements.
    coordinates[random_particle][0] += x_rand
    coordinates[random_particle][1] += y_rand
    coordinates[random_particle][2] += z_rand
    
    # 5. Calculate the interaction energy of the moved particle with the system, and store this value.
    proposed_energy = calculate_pair_energy(coordinates, random_particle, box_length, cutoff)
    energy_difference = proposed_energy - current_energy
    
    # 6. Based on the energy difference, decide to accept or reject the movement.
    accept = accept_or_reject(energy_difference, beta)
    
    # 7. If we accept, move the particle.
    if accept:
        total_energy += energy_difference
    else:
        coordinates[random_particle][0] -= x_rand
        coordinates[random_particle][1] -= y_rand
        coordinates[random_particle][2] -= z_rand

    if move % freq == 0:
        print(move, total_energy/num_particles)

If you have moved everything without problems, you should be able to run the simulation by typing the following command from your day5 folder in your terminal

python monte_carlo_yourname.py

We now want to make our simulation code importable from another file like a Jupyter notebook. Let’s make our simulation run into a function. For this function, we want to consider what are input simulation parameters. In other words, if we want to run several simulations, what would we change? We will want to make these our function arguments. We will also modify our function to return a list of generated coordinates, so we can analyze them later.

def run_simulation(coordinates, box_length, cutoff, reduced_temperature, num_moves, max_displacement, freq=1000):
    # Calculated quantities
    beta = 1 / reduced_temperature

    num_particles = len(coordinates)
    
    # Set up energy
    delta_energy = 0
    
    total_energy = calculate_total_energy(coordinates, box_length, cutoff)
    total_energy += calculate_tail_correction(num_particles, box_length, cutoff)
    
    print(f"The starting energy is {total_energy}.")
    
    # Monte Carlo Simulation Loop
    
    for move in range(num_moves):
        # 1. Randomly pick one of N particles.
        random_particle = random.randrange(num_particles)
        
        # 2. Calculate the interaction energy of the selected particle with the system, and store this value.
        current_energy = calculate_pair_energy(coordinates, random_particle, box_length, cutoff)
        
        # 3. Generate a random x, y, z displacement within max_displacement.
        x_rand = random.uniform(-max_displacement, max_displacement)
        y_rand = random.uniform(-max_displacement, max_displacement)
        z_rand = random.uniform(-max_displacement, max_displacement)
        
        # 4. Modify the coordinate of the selected particle by the generated displacements.
        coordinates[random_particle][0] += x_rand
        coordinates[random_particle][1] += y_rand
        coordinates[random_particle][2] += z_rand
        
        # 5. Calculate the interaction energy of the moved particle with the system, and store this value.
        proposed_energy = calculate_pair_energy(coordinates, random_particle, box_length, cutoff)
        energy_difference = proposed_energy - current_energy
        
        # 6. Based on the energy difference, decide to accept or reject the movement.
        accept = accept_or_reject(energy_difference, beta)
        
        # 7. If we accept, move the particle.
        if accept:
            total_energy += energy_difference
        else:
            coordinates[random_particle][0] -= x_rand
            coordinates[random_particle][1] -= y_rand
            coordinates[random_particle][2] -= z_rand
    
        if move % freq == 0:
            print(move, total_energy/num_particles)

Now we can try this out in another Python module Jupyter notebook.

Create a Python module called run_sim.py - make sure that you replace yourname with your name! The any time “monte_carlo_yourname” is used in the cell below, it should match the name of the module you created above.

import monte_carlo_yourname

# Set simulation parameters
reduced_temperature = 0.9
num_steps = 50000
max_displacement = 0.1 
cutoff = 3
freq = 1000

# Read initial coordinates
coordinates, box_length = monte_carlo_yourname.read_xyz("../data/sample_config1.txt")

monte_carlo_yourname.run_simulation(coordinates, box_length, reduced_temperature, cutoff, num_steps, max_displacement)

You can now run your simulation from the command line (terminal) by typing:

python run_sim.py

You can also now import your module into a Jupyter notebook.

Key Points

  • When working on a large project, you should organize your code into modules.