Source code for A4_2dmd

# /usr/bin/env python
import copy
from math import *
import numpy as np
from numpy.random import default_rng
import matplotlib.pyplot as plt
import matplotlib.animation as ani
import time
import tkinter as tk

# List of keywords for the input files
LATTICE_TAG = "box"
ATOM_TAG = "atom"
FORCE_TAG = "interactions"
TEMP_TAG = "temperature"
TIME_TAG = "time"
ANI_TAG = "recording"
CONST_TAG = "constraint"

X_TAG = "x"
Y_TAG = "y"
Z_TAG = "z"
VX_TAG = "vx"
VY_TAG = "vy"
VZ_TAG = "vz"
MASS_TAG = "mass"
NAME_TAG = "name"
INDEX_TAG = "index"
CONNECT_TAG = "connect"
T_TAG = "t"
TAU_TAG = "friction"
RS_TAG = "random-start"
BK_TAG = "box-k"
CK_TAG = "connect-k"
CR_TAG = "connect-r"
CM_TAG = "connect-range"
SIGMA_TAG = "sigma"
EPS_TAG = "epsilon"
RF_TAG = "random-f"
RN_TAG = "random-n"
DT_TAG = "dt"
RUNTIME_TAG = "total"
PLANE_TAG = "animate"
PLOT_TAG = "plot"
ANIDT_TAG = "anim-dt"
RECDT_TAG = "record-dt"
THERMAL_TAG = "thermalizing-t"
TYPE_TAG = "type"
VALUE_TAG = "value"
FREEZE_TAG = "freeze"
F_TAG = "force"
V_TAG = "velocity"

# Indices for selecting correct pieces of data
CK_INDEX = 0
CR_INDEX = 1
CM_INDEX = 2
SIGMA_INDEX = 3
EPS_INDEX = 4

T_INDEX = 0
TAU_INDEX = 1
RS_INDEX = 2

DT_INDEX = 0
RUNTIME_INDEX = 1

PLANE_INDEX = 0
ANIDT_INDEX = 1
RECDT_INDEX = 2
THERMAL_INDEX = 3
PLOT_INDEX = 4



[docs]def plot_function_and_average(time, values, average, error, ylabel, show=True): """ Plots a time series of a quantity as well as its average and error marginal. Args: time (array): time at each data point values (array): recorded value at each data point average (float): average of the recorded values error (float): error estimate for the average ylabel (str): name of the variable show (bool): Of True, the plot is shown on screen, otherwise only created in memory. """ plt.plot(time, average*np.ones(len(time)), 'b:') plt.fill_between(time, (average-error)*np.ones(len(time)), (average+error)*np.ones(len(time)), color = 'b', alpha=0.1 ) plt.plot(time, values, 'r') plt.xlabel("time") plt.ylabel(ylabel) if show: plt.show()
[docs]def show_system(particles, lattice_parameters, force_parameters, margin=0.1, frame=-1, filename="particles.png"): """ Draws a representation of the particle system as a scatter plot. Args: particles (list): list of :class:`Particle` objects lattice_parameters (list): list of lattice parameters force_parameters (list): interaction parameters margin (float): each axis will be drawn in the range [-margin , lattice parameter + margin] frame (int): index of the frame to show - the last one by default filename (str): name of the file where the system will be drawn """ xs = [] ys = [] zs = [] springs = [] maxr2 = force_parameters[CM_INDEX]**2 Lx = lattice_parameters[0] Ly = lattice_parameters[1] multix = 1 multiy = 1 if Lx > 2.5*Ly: multiy = 3 elif Ly > 2.5*Lx: multix = 3 bounds = np.zeros([3,2]) bounds[0,1] = Lx*multix bounds[1,1] = Ly*multiy bounds[:,0] -= margin bounds[:,1] += margin for p in particles: for ix in range(multix): for iy in range(multiy): xs.append(p.trajectory[frame][0] + ix*Lx) ys.append(p.trajectory[frame][1] + iy*Ly) zs.append(p.mass) for c in p.connected_atoms: if c.index > p.index: dummy1 = Particle(p.trajectory[frame], 0, 0, "", -1, []) dummy2 = Particle(c.trajectory[frame], 0, 0, "", -1, []) dr = dummy1.vector_to(dummy2, lattice_parameters) if dr @ dr < maxr2: springs.append( [p.trajectory[frame][0], p.trajectory[frame][1], dr[0], dr[1]] ) xs=np.array(xs) ys=np.array(ys) zs=np.array(zs) plt.clf() ax = plt.axes() ax.set_xlim(bounds[0]) ax.set_ylim(bounds[1]) ax.set_aspect('equal') size = zs*2 size = np.where( size > 1, size, np.ones( len( zs ) ) ) for s in springs: plt.arrow(s[0], s[1], s[2], s[3], color="k", lw=0.5, head_width=0) plt.scatter(xs, ys, marker='o', s=size, color="k" ) plt.savefig(filename) plt.clf()
[docs]def animate(particles, lattice_parameters, force_parameters): """ Animates the simulation. Args: particles (list): list of :class:`Particle` objects lattice_parameters (list): list of lattice parameters margin (float): each axis will be drawn in the range [-margin , lattice parameter + margin] """ # these set up the graphics window engine = tk.Tk() movie = Animation(engine, particles, lattice_parameters, force_parameters) engine.mainloop()
[docs]class Animation: """ A class for creating an animation based on simulation results. The animation is drawn in a graphical window generated using tkinter. Args: root (tkinter.canvas): graphical engine object from tkinter particles (list): list of :class:`Particle` objects lattice_parameters (list): lattice parameters [Lx, Ly] force_parameters (list): interaction parameters """ def __init__(self, root, particles, lattice_parameters, force_parameters): self.root = root self.root.title("Particle simulation") self.lattice_parameters = lattice_parameters max_L = np.max(lattice_parameters) self.SCALE = int(800/max_L) # scales graphics window size self.DT = 25 # time between animation frames in ms self.frame = 0 # frame counter self.n_frames = len(particles[0].trajectory) # number of frames Lx = lattice_parameters[0] Ly = lattice_parameters[1] self.r0 = force_parameters[CR_INDEX] self.maxr = force_parameters[CM_INDEX] self.maxr2 = force_parameters[CM_INDEX]**2 # create the base for drawing graphics self.canvas = tk.Canvas(root, width=Lx*self.SCALE, height=Ly*self.SCALE) self.canvas.pack() self.particles = particles # initialize lists for storing graphical representations # of particles and spring-like bonds self.atoms = [ ] self.bonds = [ ] # Loop over all particles and their bonds. # We create bonds before particles so that they are drawn # beneath the particles in the graphical representation. for p in self.particles: # coordinates of the particle x = p.trajectory[0][0] y = Ly-p.trajectory[0][1] # loop over all the bonds of this particle for c in p.connected_atoms: # prevent double counting if c.index > p.index: # create a new graphical element (a line) to represent a bond # between two particles dummy1 = Particle(p.trajectory[0], 0, 0, "", -1, []) dummy2 = Particle(c.trajectory[0], 0, 0, "", -1, []) dr = dummy1.vector_to(dummy2, lattice_parameters) new_bond = self.canvas.create_line(self.SCALE*x, self.SCALE*y, self.SCALE*(x+dr[0]), self.SCALE*(y-dr[1]), fill="white", width=0) self.bonds.append(new_bond) # if the bond is short enough, draw it rsq = dr @ dr if rsq < self.maxr2: w, c = self.get_bond_width_and_colour(np.sqrt(rsq)) self.canvas.itemconfig(new_bond, fill=c, width=w) # if the bond is too long, it has broken and will not be drawn else: self.canvas.itemconfig(new_bond, fill="white", width=0) self.canvas.coords( new_bond, -10, -10, -10, -10 ) # loop over all particles for p in self.particles: # coordinate, radius and color for the particle x = p.trajectory[0][0] y = Ly-p.trajectory[0][1] r, c = self.get_particle_size_and_colour(p) # create a new graphical element (a circle) to represent a particle new_atom = self.canvas.create_oval( x*self.SCALE-r, y*self.SCALE-r, x*self.SCALE+r, y*self.SCALE+r, fill=c, outline="black", width=3 ) self.atoms.append( new_atom ) # give focus to the graphical window self.canvas.focus_set() print("\nanimating with...") print("n atoms: ",len(self.atoms)) print("n bonds: ",len(self.bonds)) # start a clock for handling the drawing of # new frames at a constant frequency self.clock()
[docs] def rgb_to_hex(self, rgb): """ Transforms RGB colour to hexadecimal representation. Args: rgb (vector): 3-component vector storing Red-Green-Blue values [0-255] Returns: string: colour hex """ return '#%02x%02x%02x' % (int(rgb[0]), int(rgb[1]), int(rgb[2]))
[docs] def get_bond_width_and_colour(self, r): """ Gives the width and colour of a bond. To visualize bond straining, we draw bonds that are close to breaking as thin red lines. This function calculates the proper width and colour for a bond of given length. Args: r (float): length of the bond Returns: float, string: line width, colour hex """ # balanced: value at equilibrium # strained: value at breaking point balanced_c = np.array( [0,0,0] ) # black in rgb strained_c = np.array( [255,100,100] ) # red in rgb balanced_w = 3.0 strained_w = 1.0 # if r equals equilibrium bond length, ratio = 0 # if r is at maximum bond length, ratio = 1 ratio = np.min( [ (r-self.r0)**2 / (self.maxr-self.r0)**2, 1 ] ) # take linear averages of balanced and strained values rgb = strained_c*ratio + balanced_c*(1-ratio) width = strained_w*ratio + balanced_w*(1-ratio) return width, self.rgb_to_hex(rgb)
[docs] def get_particle_size_and_colour(self, particle): """ Gives the radius and colour of a particle. To make different particles more easily distinguishable, we draw them with various sizes and colours. Particles are distinguished according to their name. This function only knows the names used in this exercise. Args: particle (Particle): the particle Returns: float, string: circle radius, colour string """ if particle.name == "load": return 0.8*self.SCALE, "black" elif particle.name == "fixed": return 0.3*self.SCALE, "gray" elif particle.name == "He": return 0.3*self.SCALE, "gray" elif particle.name == "O": return 0.35*self.SCALE, "white" else: return 0.2*self.SCALE, "red"
[docs] def shift_particles(self,frame): """ Updates the graphical representation. Shifts the graphical elements representing particles and bonds to the positions corresponding to the given frame. If the given frame number is too high, the animation simply loops back to beginning. Args: frame (int): number of the frame to be drawn """ Lx = self.lattice_parameters[0] Ly = self.lattice_parameters[1] # loop over all particles c_index = 0 for p, a in zip(self.particles, self.atoms): # save particle coordinates, radius and colour x = p.trajectory[frame][0] y = Ly-p.trajectory[frame][1] r, c = self.get_particle_size_and_colour(p) # if the simulation exploded, this may not work... try: # if everything is ok # move the graphical representation of the particle # to the correct location self.canvas.coords(a, x*self.SCALE-r, y*self.SCALE-r, x*self.SCALE+r, y*self.SCALE+r ) except: # if something went wrong # jump back to beginning of animation self.frame = 0 return # loop over all the bonds of this particle for c in p.connected_atoms: # prevent double counting if c.index > p.index: dummy1 = Particle(p.trajectory[frame], 0, 0, "", -1, []) dummy2 = Particle(c.trajectory[frame], 0, 0, "", -1, []) dr = dummy1.vector_to(dummy2, self.lattice_parameters) # the graphical representations of bonds are stored in # the list self.bonds - we pick the correct one b = self.bonds[c_index] c_index += 1 # square of bond length rsq = dr @ dr # if the bond is short enough, draw it if rsq < self.maxr2: w, c = self.get_bond_width_and_colour(np.sqrt(rsq)) self.canvas.itemconfig(b, fill=c, width=w) self.canvas.coords( b, self.SCALE*x, self.SCALE*y, self.SCALE*(x+dr[0]), self.SCALE*(y-dr[1]) ) # if the bond is too long, it has broken and will not be drawn else: self.canvas.itemconfig(b, fill="white", width=1) self.canvas.coords(b, -10, -10, -10, -10 )
[docs] def clock(self): """ Updates the animation at a constant frequency. The graphical representation is updated to the next frame using :meth:`Animation.shift_particles()`. This is repeated after a short delay to run the animation. """ self.frame += 1 self.shift_particles(self.frame % self.n_frames) self.canvas.after( self.DT , self.clock )
[docs]class Particle: """ A class representing a point-like particle. Args: position (list): coordinates [x, y] velocity (list): components [vx, vy] mass (float): particle mass name (str): a name for the particle, to help distinguish it index (int): an identifying number connections (list): indices of the atoms that connect to this atom by a spring Attributes: position (list): coordinates [x, y] velocity (list): components [vx, vy] force (list): components [Fx, Fy] mass (float): particle mass name (str): a name for the particle, to help distinguish it index (int): an identifying number connected_atoms (list): list of :class:`Particle` objects connected to this atom constraint_type (str): one of FREEZE_TAG, F_TAG or V_TAG constraint_value (list): value of the force or velocity constraint """ def __init__(self, position, velocity, mass, name, index, connections ): self.position = np.array(position) self.velocity = np.array(velocity) self.mass = mass self.name = name self.index = index self.connections = connections self.connected_atoms = [] self.trajectory = [] self.save_position() self.force = np.zeros(2) self.constraint_type = None self.constraint_value = 0 def __str__(self): info = "Particle "+self.name+"\n" info += "index = "+str(self.index)+"\n" info += "m = "+str(self.mass)+"\n" x = str( round( self.position[0], 2) ) y = str( round( self.position[1], 2) ) vx = str( round( self.velocity[0], 2) ) vy = str( round( self.velocity[1], 2) ) fx = str( round( self.force[0], 2) ) fy = str( round( self.force[1], 2) ) info += "[ x, y ] = [ "+x+", "+y+" ]\n" info += "[ vx, vy ] = [ "+vx+", "+vy+" ]\n" info += "[ Fx, Fy ] = [ "+fx+", "+fy+" ]\n" if len(self.connected_atoms) > 0: for atom in self.connected_atoms: info += "connected to particle "+str(atom.index)+"\n" if self.constraint_type == FREEZE_TAG: info += "frozen\n" elif self.constraint_type == V_TAG: info += "constant velocity "+str(self.constraint_value)+"\n" elif self.constraint_type == F_TAG: info += "external force "+str(self.constraint_value)+"\n" return info
[docs] def move(self, dt): """ Set a new position for the particle as .. math:: \\vec{r}(t+\\Delta t) = \\vec{r}(t) + \\vec{v} \Delta t + \\frac{1}{2m}\\vec{F} (\\Delta t)^2 Args: dt (float): time step :math:`\\Delta t` """ if self.constraint_type == FREEZE_TAG: pass else: self.position += self.velocity * dt + 0.5 * self.force/self.mass * dt*dt
[docs] def move_linearly(self, dt): """ Set a new position for the particle as .. math:: \\vec{r}(t+\\Delta t) = \\vec{r}(t) + \\vec{v} \Delta t Args: dt (float): time step :math:`\\Delta t` """ self.position += self.velocity * dt
[docs] def accelerate(self, dt, gamma=0): """ Set a new velocity for the particle as .. math:: \\vec{v}(t+\\Delta t) = \\vec{v}(t) + \\frac{1}{m}\\vec{F} \Delta t By default, the force :math:`F` is the total force applied on the particle by all other particles. It should be precalculated and stored in the attributes of the particle. If a non-zero gamma is given, a drag force :math:`\\vec{F}_\\text{drag} = - \\gamma m \\vec{v}` is also applied. If the particle is constrained, the constraints are also applied: * A frozen particle always has zero velocity. * A velocity constrained particle has constant velocity. * If an external force is applied, it is added to the total force. Args: dt (float): time step :math:`\\Delta t` gamma (float): coefficient :math:`\\gamma` for the drag force """ # check for constraints first if self.constraint_type == FREEZE_TAG: # static particle self.velocity = np.array([0.0, 0.0]) elif self.constraint_type == V_TAG: # constant velocity self.velocity = self.constraint_value else: # apply acceleration due to force: # dv = a dt = F/m dt if self.constraint_type == F_TAG: # add external force dv = (self.constraint_value + self.force) * dt/self.mass else: # no constraints dv = self.force * dt/self.mass # Note for the Leapfrog algorithm: # # If gamma = 0, update simply with v(i+1/2) = v(i-1/2) + dv. # # If gamma > 0, one must solve for the new velocity v(i+1/2) from # v(i+1/2) = v(i-1/2) + dv - gamma [ v(i+1/2) + v(i-1/2) ]/2 dt. # self.velocity = ( (1 - 0.5*gamma*dt)*self.velocity + dv) / (1 + 0.5*gamma*dt)
[docs] def save_position(self): """ Save the current position of the particle. Note: in a real large-scale simulation one would never save trajectories in memory. Instead, these would be written to a file for later analysis. """ self.trajectory.append( [ self.position[0], self.position[1] ] )
[docs] def kinetic_energy(self): """ Calculates the kinetic energy of the particle. Returns: float: kinetic energy """ return 0.5 * self.mass * (self.velocity @ self.velocity)
[docs] def wrap(self, lattice): """ If the particle is outside of the simulation area, its position is shifted by a suitable multiple of lattice vectors so that the particle ends up back inside the simulation area. Args: lattice (list): lattice parameters [Lx, Ly] """ for i in range(2): if self.position[i] < 0: multi = -self.position[i] // lattice[i] + 1 self.position[i] += multi*lattice[i] if self.position[i] > lattice[i]: multi = self.position[i] // lattice[i] self.position[i] -= multi*lattice[i]
[docs] def vector_to(self, other_particle, lattice): """ Returns the vector pointing from the position of this particle to the position of other_particle. Takes periodic boundary conditions into account. Args: other_particle (Particle): the end point of the vector lattice (list): lattice parameters [Lx, Ly] Returns: array: vector pointing from this to the other particle """ vector_to = other_particle.position - self.position for i in range(2): if vector_to[i] < -lattice[i]/2: multi = (-vector_to[i] - lattice[i]/2) // lattice[i] + 1 vector_to[i] += multi*lattice[i] elif vector_to[i] > lattice[i]/2: multi = (vector_to[i] - lattice[i]/2) // lattice[i] + 1 vector_to[i] -= multi*lattice[i] return vector_to
[docs] def distance_squared_to(self, other_particle, lattice): """ Calculates and returns the square of the distance between this and another particle using :meth:`vector_to`. Args: other_particle (Particle): the end point of the vector lattice (list): lattice parameters [Lx, Ly] Returns: float: squared distance from this to the other particle """ vec = self.vector_to(other_particle, lattice) return vec @ vec
[docs] def distance_to(self, other_particle, lattice): """ Calculates and returns the distance between this and another particle using :meth:`vector_to`. Args: other_particle (Particle): the end point of the vector lattice (list): lattice parameters [Lx, Ly] Returns: float: distance from this to the other particle """ vec = self.vector_to(other_particle,lattice) return sqrt( vec @ vec )
[docs] def unit_vector_to(self, other_particle, lattice): """ Returns the unit vector pointing from the position of this particle towards the position of other_particle using :meth:`vector_to`. Args: other_particle (Particle): the end point of the vector lattice (list): lattice parameters [Lx, Ly] Returns: array: unit vector pointing from this to the other particle """ vec = self.vector_to(other_particle, lattice) return vec / sqrt( vec @ vec )
[docs]def find_info(lines, tag): """ Searches for the information wrapped in the given tag among the given lines of text. If tag is, e.g., "foo", the function searches for the start tag <foo> and the end tag </foo> and returns the lines of information between them. The function only finds the first instance of the given tag. However, in order to catch multiple instances of the tag, the function also returns all the information in lines following the end tag. For instance, if lines contains the strings: .. code-block :: aa <foo> bb cc </foo> dd ee ff the function will return two lists: ["bb", "cc"], ["", "dd", "ee", "ff"]. Args: lines (list): the information as a list of strings tag (str): the tag to search Returns: list, list: the lines between start and end tags, the lines following the end tag """ info = [] is_relevant = False line_number = 0 # go through the data for i in range(len(lines)): line = lines[i] if is_relevant: # if we have found the starting tag, record information info.append(line) contents = line.strip() # remove whitespace at the start and end of the line if len(contents) > 0: # skip empty lines if contents[0] == "<" and contents[-1] == ">": # is this a tag? if contents[1:-1] == tag: # found the starting tag if not is_relevant: # we had not yet found the tag is_relevant = True # the following lines are relevant line_number = i else: # we had already started this tag print("Found tag <"+tag+"> while already reading <"+tag+">") raise Exception("parsing error") if contents[1:-1] == "/"+tag: # found the end tag return info, lines[i+1:] # we end up here, if we reach the end of the file if is_relevant: # the file ends while reading info (start tag was found, but no end tag) print("Reached the end of file while parsing <"+tag+"> from line "+str(line_number+1)) raise Exception("parsing error") elif info == []: # the tag was not found #print("Tag <"+tag+"> was not found") return [], lines
[docs]def parse_line(line): """ Separates tag and info on a line of text. The function also removes extra whitespace and comments separated with #. For instance if line is " x : 1.23 # the x coordinate", the function returns ("x", "1.23"). Args: line (str): a string of information Returns: str, str: tag, info """ parts = line.split(":") tag = "" info = "" if len(parts) > 1: tag = parts[0].strip() info = parts[1].split("#")[0].strip() return tag, info
[docs]def read_box(lines, default=10.0): """ Reads lattice parameter info from given lines. Args: lines (list): information as a list of strings default (float): the default lattice parameter in all directions Returns: list: lattice parameters [Lx, Ly] """ lattice = [default]*2 for line in lines: tag, info = parse_line(line) if tag == X_TAG: lattice[0] = float(info) elif tag == Y_TAG: lattice[1] = float(info) return lattice
[docs]def read_atom(lines): """ Reads the properties of a single particle. Args: lines (list): information as a list of strings Returns: Particle: a new Particle object created from the given information """ i = 0 n = "X" m = 1.0 c = [] x = 0.0 y = 0.0 vx = 0.0 vy = 0.0 for line in lines: tag, info = parse_line(line) if tag == X_TAG: x = float(info) elif tag == Y_TAG: y = float(info) elif tag == VX_TAG: vx = float(info) elif tag == VY_TAG: vy = float(info) elif tag == NAME_TAG: n = info elif tag == MASS_TAG: m = float(info) elif tag == INDEX_TAG: i = int(info) elif tag == CONNECT_TAG: c.append( int(info) ) return Particle(position=[x,y], velocity=[vx,vy], index=i, name=n, mass=m, connections=c)
[docs]def read_temperature(lines): """ Reads temperature parameters. The information is returned as a list with these elements: * params[T_INDEX] : external temperature * params[TAU_INDEX] : thermostat strength (0 for no thermostat) * params[RS_INDEX] : random start switch: If "yes", all atoms will be given new random velocities following the Maxwell-Boltzmann distribution at the start of the simulation. Args: lines (list): information as a list of strings Returns: list: temperature parameters """ t_params = [0]*3 for line in lines: tag, info = parse_line(line) if tag == T_TAG: t_params[T_INDEX] = float(info) elif tag == TAU_TAG: t_params[TAU_INDEX] = float(info) elif tag == RS_TAG: t_params[RS_INDEX] = info return t_params
[docs]def read_interactions(lines): """ Reads interaction parameters. The information is returned as a list with these elements: * params[CK_INDEX] : spring constant * params[CR_INDEX] : spring equilibrium length * params[CM_INDEX] : spring maximum length (no force beyond this separation) * params[SIGMA_INDEX] : Lennard-Jones sigma parameter * params[EPS_INDEX] : Lennard-Jones epsilon parameter Args: lines (list): information as a list of strings Returns: list: interaction parameters """ f_params = [0]*5 for line in lines: tag, info = parse_line(line) if tag == CK_TAG: f_params[CK_INDEX] = float(info) elif tag == CR_TAG: f_params[CR_INDEX] = float(info) elif tag == CM_TAG: f_params[CM_INDEX] = float(info) elif tag == SIGMA_TAG: f_params[SIGMA_INDEX] = float(info) elif tag == EPS_TAG: f_params[EPS_INDEX] = float(info) return f_params
[docs]def read_constraint(lines): """ Reads one constraint. Args: lines (list): information as a list of strings Returns: str, str, float: constraint info as (name, type, value) """ type = "" name = "" value = 0 for line in lines: tag, info = parse_line(line) if tag == TYPE_TAG: type = info elif tag == NAME_TAG: name = info elif tag == VALUE_TAG: vals = info[1:-1].split(",") value = [] for v in vals: value.append(float(v)) value = np.array(value) return name, type, value
[docs]def read_timing(lines): """ Reads timing parameters. The information is returned as a list with these elements: * params[DT_INDEX] : simulation time step * params[RUNTIME_INDEX] : total simulation time Args: lines (list): information as a list of strings Returns: list: timing parameters """ t_params = [0.1, 0.1] for line in lines: tag, info = parse_line(line) if tag == DT_TAG: t_params[DT_INDEX] = float(info) elif tag == RUNTIME_TAG: t_params[RUNTIME_INDEX] = float(info) return t_params
[docs]def read_recording(lines): """ Reads data recording parameters. The information is returned as a list with these elements: * params[PLANE_INDEX] : animation switch: If "yes", an animation will be drawn at the end * params[PLOT_INDEX] : plot switch: If "yes", temperature and pressure will be plotted at the end * params[ANIDT_INDEX] : simulation time between animation frames * params[RECDT_INDEX] : simulation time between recording of physical data * params[THERMAL_INDEX] : simulation time before data collecting begins Args: lines (list): information as a list of strings Returns: list: timing parameters """ a_params = ['no', 1.0, 1.0, 0.0, 'no'] for line in lines: tag, info = parse_line(line) if tag == PLANE_TAG: a_params[PLANE_INDEX] = info elif tag == ANIDT_TAG: a_params[ANIDT_INDEX] = float(info) elif tag == RECDT_TAG: a_params[RECDT_INDEX] = float(info) elif tag == THERMAL_TAG: a_params[THERMAL_INDEX] = float(info) elif tag == PLOT_TAG: a_params[PLOT_INDEX] = info return a_params
[docs]def read_system(filename): """ Read particle and lattice data from a file. Args: filename (str): the file containing system information Returns: list, list: lattice parameters, list of :class:`Particle` objects """ f = open(filename) lines = f.readlines() f.close() lattice_info, dummy = find_info( lines, LATTICE_TAG ) lattice_parameters = read_box( lattice_info ) particles = [] success = True part_lines = lines while success: atom_info, part_lines = find_info( part_lines, ATOM_TAG ) if len(atom_info) > 0: atom = read_atom( atom_info ) particles.append(atom) else: success = False # add connections to Particle objects find_connected_atoms(particles) return lattice_parameters, particles
[docs]def read_physics(filename): """ Read interaction and temperature data from a file. Args: filename (str): the file containing physical information Returns: list, list: interaction parameters, temperature parameters """ f = open(filename) lines = f.readlines() f.close() force_info, dummy = find_info( lines, FORCE_TAG ) interaction_parameters = read_interactions( force_info ) t_info, dummy = find_info( lines, TEMP_TAG ) temperature_parameters = read_temperature( t_info ) return interaction_parameters, temperature_parameters
[docs]def read_constraints(filename): """ Read constraint settings from a file. Args: filename (str): the file containing cosntraint information Returns: list: list of constraints """ f = open(filename) lines = f.readlines() f.close() constraints = [] success = True part_lines = lines while success: c_info, part_lines = find_info( part_lines, CONST_TAG ) if len(c_info) > 0: name, type, value = read_constraint( c_info ) constraints.append([name, type, value]) else: success = False return constraints
[docs]def read_timescale(filename): """ Read simulation and recording timing data from a file. Args: filename (str): the file containing simulation information Returns: list, list: simulation timescale parameters, data recording parameters """ f = open(filename) lines = f.readlines() f.close() time_info, dummy = find_info( lines, TIME_TAG ) time_parameters = read_timing( time_info ) rec_info, dummy = find_info( lines, ANI_TAG ) rec_parameters = read_recording( rec_info ) return time_parameters, rec_parameters
[docs]def find_connected_atoms(particles): """ Builds connections between :class:`Particle` objects. Particles can be defined with a connection to another particle, which means that there is a spring-like bond connecting these two particles. As particle data is read, only the indices of connected particles are read and saved in the :class:`Particle` objects. This function goes through all Particles and adds links to the Particle objects that are connected. After this operation, each Particle has a list named connected_atoms containing the other Particle objects that are connected to it. Connections are always reciprocal: If A is connected to B, then B is connected to A. The original particle data needs not fulfill this condition, but the function will always form connections to both connected Particles even if only one of them orginally declared the connection. Args: particles (list): list of :class:`Particle` objects """ # remove duplicate indices for atom in particles: atom.connections = list( dict.fromkeys(atom.connections) ) # find the atoms whose indices are in the list of connections for atom_A in particles: # go through all particles for index in atom_A.connections: # go through all connected indices for atom_B in particles: # go through all other atoms # are we looking for atom B? if atom_B.index == index and atom_B.index != atom_A.index: # if atoms A and B are not already connected, connect if atom_B not in atom_A.connected_atoms: atom_A.connected_atoms.append(atom_B) atom_B.connected_atoms.append(atom_A) # If A is connected to B, B must be connected to A. # If the reciprocal connection is missing, add it. if not atom_A.index in atom_B.connections: atom_B.connections.append(atom_A.index)
[docs]def apply_constraints(particles, constraints): """ Saves constraint information in :class:`Particle` objects. Args: particles (list): list of :class:`Particle` objects constraints (list): list of constraints """ # go through all constraints for c in constraints: name = c[0] type = c[1] value = c[2] # go through all particles for atom in particles: # apply the constraint iff the name of the atom # matches the name of the constraint if atom.name == name: atom.constraint_type = type atom.constraint_value = value
[docs]def spring_energy(atom_A, atom_B, k, r0, rmax, lattice_parameters): """ Calculate the spring potential energy of two connected atoms. Denote the distance between the atoms as :math:`r` and the maximum spring length as :math:`r_\\max`. If the atoms are close enough, :math:`r < r_\\max`, the energy is .. math :: U = \\frac{1}{2} k (r - r_0)^2, where :math:`k` is the spring constant and :math:`r_0` is the equilibrium distance. If the atoms are too far apart, :math:`r \ge r_\\max`, the energy is .. math :: U = \\frac{1}{2} k (r_\\max - r_0)^2. With these definitions, the potential energy has the minimum :math:`U(r_0) = 0` and increases parabolically. At large separations, :math:`r > r_\\max`, the energy is constant. The function does not check if the particles should interact. It assumes they always do. Args: atom_A (Particle): atom taking part in the interaction atom_B (Particle): atom taking part in the interaction k (float): spring constant :math:`k` r0 (float): spring equilibrium length :math:`r_0` rmax (float): maximum spring length :math:`r_\\max` lattice_parameters (list): lattice parameters [Lx, Ly] Returns: float: potential energy :math:`U` """ dist_AB = atom_A.distance_to(atom_B, lattice_parameters) if dist_AB < rmax: u = 0.5 * k * (dist_AB - r0)**2 else: u = 0.5 * k * (rmax - r0)**2 return u
[docs]def spring_force(atom_A, atom_B, k, r0, rmax, lattice_parameters): """ Calculate the spring force that atom B applies on atom A. Returns the force associated with the potential energy given by the function :meth:`spring_energy`: .. math:: \\vec{F}_A = - \\nabla_A U, where the energy is differentiated with respect to the coordinates of atom A. The function does not check if the particles should interact. It assumes they always do. .. note :: This function is incomplete! Args: atom_A (Particle): atom taking part in the interaction atom_B (Particle): atom taking part in the interaction k (float): spring constant :math:`k` r0 (float): spring equilibrium length :math:`r_0` rmax (float): maximum spring length :math:`r_\\max` lattice_parameters (list): lattice parameters [Lx, Ly] Returns: array: force acting on atom A, [Fx, Fy] """ force_to_A = np.zeros(2) return force_to_A
[docs]def lj_energy(atom_A, atom_B, sigma_sixth, epsilon, lattice_parameters): """ Calculate the Lennard-Jones potential energy of two atoms. Denote the distance between the atoms as :math:`r`. The potential energy is calculated as: .. math :: U = 4 \\epsilon \\left( \\frac{ \\sigma^{12} }{ r^{12} } - \\frac{ \\sigma^6 }{ r^6 } \\right), where :math:`\\sigma` and :math:`\\epsilon` are parameters of the model. Args: atom_A (Particle): atom taking part in the interaction atom_B (Particle): atom taking part in the interaction sigma_sixth (float): parameter :math:`\\sigma^6` epsilon (float): parameter :math:`\\epsilon` lattice_parameters (list): lattice parameters [Lx, Ly] Returns: float: potential energy :math:`U` """ dist_2 = atom_A.distance_squared_to(atom_B, lattice_parameters) dist_6 = dist_2 * dist_2 * dist_2 dist_12 = dist_6 * dist_6 u = 4.0 * epsilon * ( sigma_sixth*sigma_sixth/dist_12 - sigma_sixth/dist_6) return u
[docs]def lj_force(atom_A, atom_B, sigma_sixth, epsilon, lattice_parameters): """ Calculate the Lennard-Jones force that atom B applies on atom A. Returns the force associated with the potential energy U given by the function :meth:`lj_energy`: .. math:: \\vec{F}_A = - \\nabla_A U, where the energy is differentiated with respect to the coordinates of atom A. Args: atom_A (Particle): atom taking part in the interaction atom_B (Particle): atom taking part in the interaction sigma_sixth (float): parameter :math:`\\sigma^6` epsilon (float): parameter :math:`\\epsilon` lattice_parameters (list): lattice parameters [Lx, Ly] Returns: array: force acting on atom A, [Fx, Fy] """ vec_AB = atom_A.vector_to(atom_B, lattice_parameters) dist_2 = vec_AB @ vec_AB dist_6 = dist_2 * dist_2 * dist_2 dist_12 = dist_6 * dist_6 force_to_A = - 4.0 * epsilon * \ (12.0 * sigma_sixth*sigma_sixth / dist_12 - 6.0 * sigma_sixth / dist_6 ) \ * vec_AB / dist_2 return force_to_A
[docs]def calculate_forces(particles, force_parameters, lattice_parameters): """ Calculate the total force acting on every atom. Saves the result to each :class:`Particle` object in particle.force. Args: particles (list): list of :class:`Particle` objects force_parameters (list): interaction parameters lattice_parameters (list): lattice parameters [Lx, Ly] Returns: float: the virial """ connection_k = force_parameters[CK_INDEX] # spring constant for bonds connection_r = force_parameters[CR_INDEX] # equilibrium distance for bonds connection_rmax = force_parameters[CM_INDEX] # max distance for bonds sigma = force_parameters[SIGMA_INDEX] # sigma for Lennard-Jones potential epsilon = force_parameters[EPS_INDEX] # epsilon for Lennard-Jones potential sigma_6 = sigma**6 virial = 0.0 # first loop: reset forces for atom_i in particles: # reset all forces atom_i.force = np.zeros(2) # second loop: add spring and Lennard-Jones forces for i in range(len(particles)): atom_i = particles[i] # apply spring force only if the particles are connected for atom_j in atom_i.connected_atoms: # prevent double counting if atom_i.index > atom_j.index: # calculate force on atom i force_to_i = spring_force(atom_i, atom_j, connection_k, connection_r, connection_rmax, lattice_parameters) atom_i.force += force_to_i # law of action and reaction: opposite force on atom j atom_j.force -= force_to_i virial -= atom_i.vector_to(atom_j, lattice_parameters) @ force_to_i if sigma > 0: # apply LJ force for particles that are not spring-connected for j in range(0,i): # only cases j < i to prevent double counting atom_j = particles[j] if atom_j.index not in atom_i.connections: # calculate force on atom i force_to_i = lj_force(atom_i, atom_j, sigma_6, epsilon, lattice_parameters) atom_i.force += force_to_i # law of action and reaction: opposite force on atom j atom_j.force -= force_to_i virial -= atom_i.vector_to(atom_j, lattice_parameters) @ force_to_i return virial
[docs]def calculate_spring_potential_energy(particles, force_parameters, lattice_parameters): """ Calculate the total potential energy of all spring-like bonds. Args: particles (list): list of :class:`Particle` objects force_parameters (list): interaction parameters lattice_parameters (list): lattice parameters [Lx, Ly] Returns: float: total spring potential energy """ connection_k = force_parameters[CK_INDEX] # spring constant for bonds connection_r = force_parameters[CR_INDEX] # equilibrium distance for bonds connection_rmax = force_parameters[CM_INDEX] # max distance for bonds u = 0.0 for atom_i in particles: # calculate spring energy only if the particles are connected for atom_j in atom_i.connected_atoms: # prevent double counting if atom_i.index > atom_j.index: # calculate force on atom i u += spring_energy(atom_i, atom_j, connection_k, connection_r, connection_rmax, lattice_parameters) return u
[docs]def calculate_lj_potential_energy(particles, force_parameters, lattice_parameters): """ Calculate the total potential energy of all Lennard-Jones interactions. Args: particles (list): list of :class:`Particle` objects force_parameters (list): interaction parameters lattice_parameters (list): lattice parameters [Lx, Ly] Returns: float: total LJ potential energy """ sigma = force_parameters[SIGMA_INDEX] # sigma for Lennard-Jones potential epsilon = force_parameters[EPS_INDEX] # epsilon for Lennard-Jones potential sigma_6 = sigma**6 u = 0.0 for i in range(len(particles)): atom_i = particles[i] # calculate LJ energy for all particle pairs for j in range(0,i): # only cases j < i to prevent double counting atom_j = particles[j] # only if atoms are not connected by springs if atom_j.index not in atom_i.connections: # calculate force on atom i u += lj_energy(atom_i, atom_j, sigma_6, epsilon, lattice_parameters) return u
[docs]def calculate_momentum(particles): """ Calculate the total momentum of the system. Args: particles (list): list of :class:`Particle` objects Returns: array: momentum [px, py] """ p = np.array( [0.0, 0.0] ) for atom in particles: p += atom.mass * atom.velocity return p
[docs]def calculate_kinetic_energy(particles): """ Calculate the total kinetic energy of the system. Args: particles (list): list of :class:`Particle` objects Returns: float: total kinetic energy """ k = 0.0 for atom in particles: k += atom.kinetic_energy() return k
[docs]def calculate_temperature(particles): """ Calculate the current instantaneous temperature. The calculation is based on the kinetic energies of particles. According to the equipartition principle, each quadratic degree of freedom (DOF) stores, on average, the energy .. math :: \\langle E_\\text{DOF} \\rangle = \\frac{1}{2} k_B T, where :math:`k_B` is the Boltzmann constant and :math:`T` is the temperature. In 2D, every unconstrained atom has 2 degrees of freedom for their linear movement, so the total kinetic energy of the system is, on average, .. math :: K = 2 N_\\text{atoms} \\langle E_\\text{DOF} \\rangle = N_\\text{atoms} k_B T. For simplicity, we set :math:`k_B = 1`, so the temperature is .. math:: T = \\frac{1}{ N_\\text{atoms}} K. At the macroscopic scale, the kinetic energy of a microscopic system may be observed either as kinetic energy (movement) or internal energy (hotness). This function assumes there is no macroscopic kinetic energy so that all microscopic kinetic energy can be interpreted as internal energy. That is, this function assumes the system as a whole is at rest and the movement of particles is random. If the system has a moving center of mass or rotation around a center, there is collective motion which is observed as macroscopic movement. In such a case this function systematically reports too high temperatures, because not all of the microscopic energy is internal energy at the macro scale. Args: particles (list): list of :class:`Particle` objects Returns: float: temperature """ dof = 0 # go through all atoms for atom in particles: # ignore constrained atoms - they have no degrees of freedom if atom.constraint_type == FREEZE_TAG: dof += 0 elif atom.constraint_type == V_TAG: dof += 0 else: dof += 2 return 2.0/dof*calculate_kinetic_energy(particles)
[docs]def calculate_pressure(particles, lattice_parameters, virial): """ Calculate the current pressure. For a molecular simulation with constant pressure, volume and temperature, one can derive the relation .. math:: pV = Nk_B T + \\frac{1}{d} \\langle \\sum_i \\vec{F}_i \\cdot \\vec{r}_i \\rangle, where :math:`p, V, N, k_B, T, d, \\vec{F}_i, \\vec{r}_i` are, respectively, pressure, volume, number of atoms, Boltzmann constant, temperature, number of dimensions, force acting on atom :math:`i` and position of atom :math:`i`. The sum of the products of forces and positions is known as the virial and it should be calculated with :meth:`calculate_forces`. The function uses this relation to solve the effective instantaneous pressure as .. math :: p = \\frac{1}{V} Nk_B T + \\frac{1}{dV} \\sum_i \\vec{F}_i \\cdot \\vec{r}_i. This is not necessarily the true instantaneous pressure, but calculating the average of this quantity over an extended simulation should converge towards the true pressure. Args: particles (list): list of :class:`Particle` objects lattice_parameters (list): lattice parameters [Lx, Ly] virial (float): the virial Returns: float: pressure """ volume = lattice_parameters[0]*lattice_parameters[1] n = len(particles) t = calculate_temperature(particles) return (n*t + virial/2) / volume
[docs]def update_positions(particles, dt): """ Update the positions of all particles according to .. math:: \\Delta \\vec{r} = \\vec{v} \\Delta t + \\frac{1}{2m} \\vec{F} (\\Delta t)^2 using :meth:`Particle.move`. Args: particles (list): list of :class:`Particle` objects dt (float): time step :math:`\\Delta t` """ for atom in particles: atom.move(dt)
[docs]def update_positions_without_force(particles, dt): """ Update the positions of all particles according to .. math:: \\Delta \\vec{r} = \\vec{v} \\Delta t using :meth:`Particle.move_linearly`. Args: particles (list): list of :class:`Particle` objects dt (float): time step :math:`\\Delta t` """ for atom in particles: atom.move_linearly(dt)
[docs]def update_velocities(particles, dt, gamma=0): """ Update the velocities of all particles according to .. math:: \\Delta \\vec{v} = \\frac{1}{m} \\vec{F} \\Delta t If a non-zero gamma is given, a drag force :math:`\\vec{F}_\\text{drag} = - \\gamma m \\vec{v}` is also applied. using :meth:`Particle.accelerate`. Args: particles (list): list of :class:`Particle` objects dt (float): time step :math:`\\Delta t` gamma (float): coefficient :math:`\\gamma` for the drag force """ for atom in particles: atom.accelerate(dt, gamma)
[docs]def langevin_force(particles, dt, gamma, t_external): """ Applies a random Gaussian force to all particles. In Langevin dynamics, the Newtonian dynamics are extended by adding a drag force .. math :: \\vec{F}_\\text{drag} = - \\gamma m \\vec{v} and a random Gaussian force :math:`\\vec{F}_\\text{random}` with standard deviation :math:`\\sigma = \\sqrt{ 2 \\gamma m T }` and no correlation between forces at different times. This function adds such random force to all particles. Physically, this could represent a system where the simulated particles are surrounded by an evironment of other particles that are not explicitly included in the simulation. The drag force represents flow resistance from moving through this environment (cf. air resistance). The random force represents random collisions between the simulated particles and the particles of the environment (cf. Brownian motion). This approach also leads to correct sampling of the canonical ensemble at temperature T, so Langevin dynamics can also be used as a thermostat. Args: particles (list): list of :class:`Particle` objects dt (float): time step :math:`\\Delta t` gamma (float): coefficient :math:`\\gamma` for the drag force t_external (float): external temperature """ for atom in particles: scaler = sqrt(2.0 * gamma * atom.mass * t_external / dt) atom.force[0] += scaler * random.standard_normal() atom.force[1] += scaler * random.standard_normal()
[docs]def velocity_verlet(particles, force_parameters, lattice_parameters, time_parameters, rec_parameters, temperature_parameters): """ Leapfrog version of the Verlet algorithm for integrating the equations of motion, i.e., advancing time. The algorithm works as follows: * First, forces are calculated at current time, :math:`\\vec{F}(t)`. * Second, velocities are calculated half a time step in the future, :math:`\\vec{v}(t + \\frac{1}{2}\\Delta t) = \\vec{v}(t) + \\frac{1}{m}\\vec{F}(t) \\frac{1}{2}\\Delta t`. * Then, the following steps are repeated for as long as the simulation runs, - Positions are updated by one time step using the velocities, :math:`\\vec{r}(t + \\Delta t) = \\vec{r}(t) + \\vec{v}(t + \\frac{1}{2}\\Delta t) \\Delta t`. - Forces are calculated using the positions, :math:`\\vec{F}(t + \\Delta t)` - Velocities are updates by one time step using the forces, :math:`\\vec{v}(t + \\frac{3}{2}\\Delta t) = \\vec{v}(t + \\frac{1}{2}\\Delta t) + \\frac{1}{m}\\vec{F}(t + \\Delta t) \\Delta t` Note that the algorithm uses velocities "half a time step from the future" to update positions and forces "half a time step from the future" to update the velocities. This approach effectively averages upcoming and previous values and leads to a stable algorithm that is symmetric with respect to time reversal. Args: particles (list): list of :class:`Particle` objects force_parameters (list): interaction parameters lattice_parameters (list): lattice parameters time_parameters (list): timing parameters rec_parameters (list): recording parameters temperature_parameters (list): temperature parameters Returns: float, float: average temperature, pressure """ # gather needed parameters time = rec_parameters[RECDT_INDEX] dt = time_parameters[DT_INDEX] trajectory_dt = rec_parameters[ANIDT_INDEX] t_external = temperature_parameters[T_INDEX] gamma = temperature_parameters[TAU_INDEX] # run simulation for this many timesteps steps = int(time/dt) # record trajectories after this many timesteps have passed trajectory_wait = int(trajectory_dt / dt) calculate_forces(particles, force_parameters, lattice_parameters) # get velocities at half timestep from beginning for leapfrog update_velocities(particles, 0.5*dt, gamma) t_average = 0.0 p_average = 0.0 # run the leapfrog algorithm for the required time for i in range(steps): update_positions_without_force(particles, dt) virial = calculate_forces(particles, force_parameters, lattice_parameters) t_average += calculate_temperature(particles) p_average += calculate_pressure(particles, lattice_parameters, virial) # apply a thermostat if an external temperature is set if t_external > 0: langevin_force(particles, dt, gamma, t_external) update_velocities(particles, dt, gamma) if i%trajectory_wait == 0: for atom in particles: atom.wrap(lattice_parameters) atom.save_position() # velocities are half a timestep in the future, rewind to get correct time update_velocities(particles, -0.5*dt, gamma) return t_average/steps, p_average/steps
[docs]def randomize_velocities(particles, temperature_parameters): """ Replace the velocities of all particles with random velocities drawn from the Maxwell-Boltzmann velocity distribution. The function makes sure the total momentum from the newly assigned velocities is exactly zero. Args: particles (list): list of :class:`Particle` objects temperature_parameters (list): temperature parameters """ temperature = temperature_parameters[T_INDEX] total_mass = 0.0 total_momentum = np.array( [0.0, 0.0] ) for atom in particles: # ignore velocity-constrained particles if atom.constraint_type != FREEZE_TAG or atom.constraint_type != V_TAG: m = atom.mass total_mass += m # for each velocity component, draw a normally distributed # random velocity with standard deviation sqrt(T/m) for i in range(2): v = random.standard_normal() * sqrt(temperature/m) atom.velocity[i] = v total_momentum[i] += m*v # make sure the total momentum of the system is zero deltav = -total_momentum / total_mass for atom in particles: atom.velocity += deltav
[docs]def write_system_file(particles, lattice_parameters, filename = "new_system.txt"): """ Write system information in a file. Args: particles (list): list of :class:`Particle` objects lattice_parameters (list): lattice parameters filename (str): name of the file to write """ file = open(filename, 'w') file.write("<"+LATTICE_TAG+">\n") file.write(X_TAG+": "+str(lattice_parameters[0])+"\n") file.write(Y_TAG+": "+str(lattice_parameters[1])+"\n") file.write("</"+LATTICE_TAG+">"+"\n"+"\n") for atom in particles: file.write("<"+ATOM_TAG+">\n") file.write(INDEX_TAG+": "+str(atom.index)+"\n") file.write(NAME_TAG+": "+str(atom.name)+"\n") file.write(MASS_TAG+": "+str(atom.mass)+"\n") file.write(X_TAG+": "+str(atom.position[0])+"\n") file.write(Y_TAG+": "+str(atom.position[1])+"\n") file.write(VX_TAG+": "+str(atom.velocity[0])+"\n") file.write(VY_TAG+": "+str(atom.velocity[1])+"\n") for atom_B in atom.connected_atoms: file.write(CONNECT_TAG+": "+str(atom_B.index)+"\n") file.write("</"+ATOM_TAG+">\n"+"\n") file.close()
[docs]def run_simulation(particles, force_parameters, lattice_parameters, time_parameters, rec_parameters, temperature_parameters ): """ Run a molecular dynamics simulation. Args: particles (list): list of :class:`Particle` objects force_parameters (list): interaction parameters lattice_parameters (list): lattice parameters time_parameters (list): timing parameters rec_parameters (list): recording parameters temperature_parameters (list): temperature parameters Returns: tuple: arrays containing measured physical quantities at different times, (time, temperature, pressure, momentum, kinetic energy, spring energy, LJ energy) """ # lists for recording statistics - record starting values times = [ ] temperatures = [ ] pressures = [ ] momenta = [ ] kinetic_energies = [ ] spring_energies = [ ] lj_energies = [ ] # gather needed parameters runtime = time_parameters[RUNTIME_INDEX] # total simulation time sample_interval = rec_parameters[RECDT_INDEX] # simulation time in each sample timestep = time_parameters[DT_INDEX] # timestep used for simulation # simulation will be split in n_samples pieces for statistics n_samples = int(runtime/sample_interval) true_sample_time = int(sample_interval / timestep) * timestep # run the simulation in n_samples pieces for i in range(n_samples): #print("running sample "+str(i+1)+" / "+str(n_samples)) print_progress(i, n_samples) # run the simulation for the required length temp, pres = velocity_verlet(particles, force_parameters, lattice_parameters, time_parameters, rec_parameters, temperature_parameters) # record physical quantities times.append( (i+0.5) * true_sample_time ) momenta.append( calculate_momentum(particles) ) temperatures.append( temp ) pressures.append( pres ) kinetic_energies.append( calculate_kinetic_energy(particles) ) spring_energies.append( calculate_spring_potential_energy(particles, force_parameters, lattice_parameters) ) lj_energies.append( calculate_lj_potential_energy(particles, force_parameters, lattice_parameters) ) print_progress(n_samples, n_samples) times = np.array(times) momenta = np.array(momenta) temperatures = np.array(temperatures) pressures = np.array(pressures) kinetic_energies = np.array(kinetic_energies) spring_energies = np.array(spring_energies) lj_energies = np.array(lj_energies) return times, temperatures, pressures, momenta, kinetic_energies, spring_energies, lj_energies
[docs]def count_connections(particles, printout=False): """ Counts the number of atomic pairs connected via springs. Args: particles (list): list of :class:`Particle` objects printout (bool): if True, all connections are listed """ n_springs = 0 # loop over all atoms for p in particles: # loop over all atoms connected to p for c in p.connected_atoms: if printout: print("connected: ",p.index," - ",c.index) n_springs += 1 # we count each pair twice: A-B and B-A, so divide by 2 in the end return n_springs//2
[docs]def calculate_average_and_error(values, start=0): """ Calculates the average and standard error of mean of a sequence. The values in the sequence are assumed to be uncorrelated. If the beginning of the sequence cannot be used in the analysis (equilibrium has not yet been reached), one can ignore the early values by specifying a starting index. Args: values (array): values to analyse start (int): index of the first value to be included in the analysis """ avr_x = 0.0 avr_sq = 0.0 for x in values[start:]: avr_x += x avr_sq += x*x n = float(len(values)-start) avr_x /= n avr_sq /= n variance = (avr_sq - avr_x*avr_x)*n/(n-1) error = sqrt(variance/n) return avr_x, error
[docs]def main(system_file = "system.txt", simu_file = "simulation.txt"): """ The main program. Reads system and simulation information from files, runs a simulation, and calculates statistics. Possibly also shows an animation of the simulation. Args: system_file (str): name of the file containing system info simu_file (str): name of the file containing physical and simulation info """ lattice_parameters, particles = read_system(system_file) force_parameters, temperature_parameters = read_physics(simu_file) time_parameters, rec_parameters = read_timescale(simu_file) constraints = read_constraints(simu_file) apply_constraints(particles, constraints) # draw the system? ap = rec_parameters[PLANE_INDEX] if ap == 'yes' or ap == 'y': show_system(particles, lattice_parameters, force_parameters) # randomize velocities? random_start = temperature_parameters[RS_INDEX] if random_start == "yes" or random_start == "y": randomize_velocities(particles, temperature_parameters) start_time = time.perf_counter() # run the simulation ts, temps, Ps, ps, ks, uss, uljs = run_simulation( particles, force_parameters, lattice_parameters, time_parameters, rec_parameters, temperature_parameters ) end_time = time.perf_counter() write_system_file( particles, lattice_parameters ) print("simulation time: "+str(end_time-start_time)+" s") # calculate all averages and errors # # ignore the start of the run as a thermalization period thermal_time = rec_parameters[THERMAL_INDEX] total_time = time_parameters[RUNTIME_INDEX] sampling_start = int( len(ts) * thermal_time / total_time ) vavr = lattice_parameters[0]*lattice_parameters[1] tavr, terr = calculate_average_and_error(temps, sampling_start) Pavr, Perr = calculate_average_and_error(Ps, sampling_start) pxavr, pxerr = calculate_average_and_error(ps[:,0], sampling_start) pyavr, pyerr = calculate_average_and_error(ps[:,1], sampling_start) kavr, kerr = calculate_average_and_error(ks, sampling_start) usavr, userr = calculate_average_and_error(uss, sampling_start) uravr, urerr = calculate_average_and_error(uljs, sampling_start) # print measured values, use 2 * error of mean as the confidence interval acc = 3 # decimals to print print("volume = "+str(round(vavr,acc)) ) print("atoms = "+str(len(particles)) ) print("bonds = "+str(count_connections(particles))) print("temperature = "+str(round(tavr,acc))+" +- "+str(round(2*terr,acc)) ) print("pressure = "+str(round(Pavr,2*acc))+" +- "+str(round(2*Perr,2*acc)) ) print("momentum(x) = "+str(round(pxavr,acc))+" +- "+str(round(2*pxerr,acc)) ) print("momentum(y) = "+str(round(pyavr,acc))+" +- "+str(round(2*pyerr,acc)) ) print("kinetic energy = "+str(round(kavr,acc))+" +- "+str(round(2*kerr,acc)) ) print("spring energy = "+str(round(usavr,acc))+" +- "+str(round(2*userr,acc)) ) print("LJ energy = "+str(round(uravr,acc))+" +- "+str(round(2*urerr,acc)) ) # animate? ap = rec_parameters[PLANE_INDEX] if ap == 'yes' or ap == 'y': animate(particles, lattice_parameters, force_parameters) # plot? pl = rec_parameters[PLOT_INDEX] if pl == "yes" or pl == "y": plot_function_and_average(ts, temps, tavr, 2*terr, "temperature") plot_function_and_average(ts, Ps, Pavr, 2*Perr, "pressure") # plot energies plt.plot(ts, ks, label="kinetic energy") plt.plot(ts, uss + uljs, label="potential energy" ) plt.plot(ts, ks + uss + uljs, label="total energy" ) plt.legend() plt.xlabel("t") plt.ylabel("E") plt.show()
if __name__ == "__main__": random = default_rng() main("A4_gas_system.txt", "A4_gas_simulation.txt") #main("A4_bridge_system.txt", "A4_bridge_simulation.txt") else: random = default_rng()