Volume Moves
The Monte Carlo simulation you have run sampled configurations from the canonical ensemble. This meant that the volume of the periodic box was constant, and it was not possible to specify the pressure acting on the krypton atoms. We live in a constant pressure world, not a constant volume world. To be able to specify pressure, we need to sample from the isothermal-isobaric (NPT) ensemble. This can be achieved by making two changes to the metropolis.py
script;
- The acceptance test must be changed from the NVT test to the NPT test.
- Monte Carlo moves that change the volume of the box need to be added.
Changing the acceptance test
Currently, the script uses the canonical MC acceptance test;
# Now apply the Monte Carlo test - compare
# exp( -(E_new - E_old) / kT ) >= rand(0,1)
= math.exp( -(new_energy - old_energy) / kT ) x
This must be changed to the isothermal-isobaric test;
# Now apply the Monte Carlo test - compare
# exp( -(E_new - E_old + P(V_new - V_old)) / kT
# + N ln (V_new - V_old) ) >= rand(0,1)
= math.exp( -(new_energy - old_energy + pressure * (V_new - V_old)) / kT
x + n_atoms * (math.log(V_new) - math.log(V_old) ) )
(note that natural logarithm in Python is “math.log”)
This uses the old and new volumes of the box, that are calculated and stored in the V_old and V_new variables via;
# calculate the old energy
= calculate_energy()
old_energy
# calculate the old volume of the box
= box_size[0] * box_size[1] * box_size[2] V_old
and
# calculate the new energy
= calculate_energy()
new_energy
# calculate the new volume of the box
= box_size[0] * box_size[1] * box_size[2] V_new
(note here that you can see the old and new volumes calculated after the old and new energies, so that you can work out where in the script these lines should be).
As well as the volumes, the test also requires the pressure, which can go at the top of the script underneath the temperature;
# Simulation temperature
= 298.15 # kelvin
temperature
# Simulation pressure
= 1 # atmospheres
pressure
# Convert pressure to internal units (kcal mol-1 A-3)
= pressure * 1.458397506863647E-005 pressure
(note that pressure is normally given in units of atmospheres, while the internal units of this program are kcal mol-1 for energy, and angstrom for length. This is why the pressure has to be converted to internal units above)
Make the above changes to your metropolis.py script. Set the temperature to 298 K, the pressure to 1 atmosphere and the initial box size to 15x15x15 angstroms. Do your changes make any difference to the simulation? If you get stuck, please download an example metropolis.py.
Once you have made the changes to your script (or downloaded the example sciript) and run the simulation, you should have found that adding in the volume test on its own has not changed the simulation at all. This is because the simulation has only one type of move - the translation of a single atom of krypton. This move does not change the volume of the periodic box, so V_new
will always equal V_old
. This means that V_new - V_old
is always zero, so the NPT acceptance test becomes equal to the NVT acceptance test.
Adding in volume changing moves
To have an NPT simulation, we need to add in a Monte Carlo move that changes the volume of the box. This can be achieved by adding in a move that changes the volume of the box by a random amount. First, we should set the maximum amount by which to change the volume, by adding to the top of the script;
# The maximum amount to change the volume - the
# best value is 10% of the number of atoms
= 0.1 * n_atoms # angstroms**3 max_volume_change
Next, we should add in the move that changes the volume of the periodic box. This move should only occur once for every n_atoms
moves. This can be achieved using this code;
# save the old coordinates
= copy.deepcopy(coords)
old_coords
# save the old box dimensions
= copy.deepcopy(box_size)
old_box_size
# Decide if we are performing an atom move, or a volume move
if random.uniform(0.0,1.0) <= (1.0 / n_atoms):
# 1 in n_atoms chance of being here. Perform a volume move
# by changing the volume for a random amount
= random.uniform(-max_volume_change, max_volume_change)
delta_vol
# calculate the new volume
= V_old + delta_vol
V_new
# The new box length is the cube root of the volume
= V_new**(1.0/3.0)
box_side
# The amount to scale each atom from the center is
# the cube root of the ratio of the new and old volumes
= ( V_new / V_old )**(1.0/3.0)
scale_ratio
# now translate every atom so that it is scaled from the center
# of the box
for i in range(0,n_atoms):
= coords[i][0] - (0.5*box_size[0])
dx = coords[i][1] - (0.5*box_size[1])
dy = coords[i][2] - (0.5*box_size[2])
dz
= math.sqrt(dx*dx + dy*dy + dz*dz)
length
if length > 0.01: # don't scale atoms already near the center
/= length
dx /= length
dy /= length
dz
# now scale the distance from the atom to the box center
# by 'scale_ratio'
*= scale_ratio
length
# move the atom to its new location
0] = (0.5*box_size[0]) + dx*length
coords[i][1] = (0.5*box_size[1]) + dy*length
coords[i][2] = (0.5*box_size[2]) + dz*length
coords[i][
0] = box_side
box_size[1] = box_side
box_size[2] = box_side
box_size[else:
# Make the move - translate by a delta in each dimension
= random.uniform( -max_translate, max_translate )
delta_x = random.uniform( -max_translate, max_translate )
delta_y = random.uniform( -max_translate, max_translate )
delta_z
0] += delta_x
coords[atom][1] += delta_y
coords[atom][2] += delta_z
coords[atom][
# wrap the coordinates back into the box
0] = wrap_into_box(coords[atom][0], box_size[0])
coords[atom][1] = wrap_into_box(coords[atom][1], box_size[1])
coords[atom][2] = wrap_into_box(coords[atom][2], box_size[2]) coords[atom][
Breaking down the above code into bite-sized pieces, we can see that we start saving the old coordinates as before, but now we must save the old box size too, using the code;
# save the old box dimensions
= copy.deepcopy(box_size) old_box_size
We then have to choose between performing an atom move, or a volume move. Each atom should move once between each volume move, so we choose a volume move with a probability of 1 / n_atoms, using the code;
# Decide if we are performing an atom move, or a volume move
if random.uniform(0.0,1.0) <= (1.0 / n_atoms):
# 1 in n_atoms chance of being here. Perform a volume move
# by changing the volume for a random amount
...else:
# Make the move - translate by a delta in each dimension
...
The atom move code is the same as before. The volume move code is straightforward. This adds or subtracts a random volume, delta_vol
chosen to have a maximum value of max_vol_change
to each dimension of the box.
= random.uniform(-max_volume_change, max_volume_change)
delta_vol
# calculate the new volume
= V_old + delta_vol
V_new
# The new box length is the cube root of the volume
= V_new**(1.0/3.0) box_side
As well as changing the volume, we also need to move all of atoms. These are scaled from the center point of the periodic box (at coordinates [0.5*box_size[0], 0.5*box_size[1], 0.5*box_size[2]]
). If the volume decreases, the atoms are moved towards the center point, while if the volume increases, then the atoms are moved away from this center point. The code to do this is here;
# The amount to scale each atom from the center is
# the cube root of the ratio of the new and old volumes
= ( V_new / V_old )**(1.0/3.0)
scale_ratio
# now translate every atom so that it is scaled from the center
# of the box
for i in range(0,n_atoms):
= coords[i][0] - (0.5*box_size[0])
dx = coords[i][1] - (0.5*box_size[1])
dy = coords[i][2] - (0.5*box_size[2])
dz
= math.sqrt(dx*dx + dy*dy + dz*dz)
length
if length > 0.01: # don't scale atoms already near the center
/= length
dx /= length
dy /= length
dz
# now scale the distance from the atom to the box center
# by 'scale_ratio'
*= scale_ratio
length
# move the atom to its new location
0] = (0.5*box_size[0]) + dx*length
coords[i][1] = (0.5*box_size[1]) + dy*length
coords[i][2] = (0.5*box_size[2]) + dz*length coords[i][
Finally, the new box dimensions are calculated as the cube root of the new volume (calculated earlier and saved in the variable box_size
);
0] = box_side
box_size[1] = box_side
box_size[2] = box_side box_size[
As well as adding in the above volume move, we also need to add in code at the end of the script to restore the volume if the move is rejected. This is here;
if accept:
# accept the move
+= 1
naccept = new_energy
total_energy else:
# reject the move - restore the old coordinates
+= 1
nreject
# restore the old conformation
= copy.deepcopy(old_coords)
coords = copy.deepcopy(old_box_size)
box_size
= old_energy total_energy
We also need to add code that will print out information about the box size so we can see how it changes. We can do this by adding this code;
# print the energy every 10 moves
if move % 10 == 0:
print("%s %s %s %s" % (move, total_energy, naccept, nreject))
print(" Box size = %sx%sx%s. Volume = %s A^3" % \
0], box_size[1], box_size[2],
(box_size[0]*box_size[1]*box_size[2])) box_size[
Make the above changes to your metropolis.py
script. Set the temperature to 298 K, the pressure to 1 atmosphere and the initial box size to 15x15x15 angstroms. Do your changes make any difference to the simulation? Do you see the volume change? If you get stuck, don’t have enough time, or don’t want to write python, then please download an example metropolis.py.
ADVANCED: Make the above changes to the metropolis.cpp
C++ program. Alternatively, download an example metropolis.cpp program here. Compile the program and run it using a temperature of 298.15 K and a pressure of 1 atmosphere. Does the volume change much in the simulation? Does the volume equilibrate?