In two previous posts we got to know two main things:
- We can analyse the behaviour of groups of animals using statistical tools like correlation functions
- We can simulate this behaviour using for example the Vicsek model, and by analysing it the same way as natural groups we can measure the accurateness of the model in replicating natural phenomena.
We also learned that the standard Vicsek model suffers from one weakness: it lacks an inertial term, which seems to be important in imitating the behaviour of biological systems. So the goal of this post will be to figure out a way to simulate the Vicsek model with time delay. Let's start by
Analysing the equations of motion
So what do we see here? The first main thing to realise is that the first equation signifies calculation of velocity for the next point in time (call it second for example), and the second one is calculating the position for that next point in time. They are both vectors, since we want to generalise these equations to any number of dimensions we're interested in (we'll work in 3D). The index in and signify the index of the member in our group of particles. So we have the left hand side of both equations figured out. As you can see, the model is indeed very simple - just two equations for two things we need to keep track of - position and velocity. We don't even deal directly with accelerations (kind of, implicitly). Eighth grade physics, right?
Now let's have a look at the right hand side. Let's start with the obviously easier one - the position equation (second one). All it says is that the position of particle at the next time step is equal to the position of the particle at the current time step plus the (calculated in the first equation) velocity of that particle for time . Basically just vector addition, nothing to worry about at all.
The velocity equation has a bit more going on in it, but nothing inherently difficult. Let's start with the terms in the brackets. We have the velocity of particle at time plus a sum term. The sum term just adds all the velocities of all the particles (which are not ) that are inside the sphere of influence of . How? Well, the sum of all would add all the velocities of all remaining particles. But the term is a matrix which works as a filter. It takes a value of if the the distance between and is less than the radius of influence of the particles, and otherwise. This means that any particle which is too far away to influence the trajectory of would contribute nothing to the summation term.
Now for the terms outside the brackets. First off, let's tackle . One characteristic of the Vicsek model, is that it keeps the magnitudes of the velocities of particles constant. That means, that the speed at which a particle moves doesn't change. If Alice flies with 100km/h, she will keep doing that forever. To achieve this, after finding the average direction of all the neighbours (which is done by the term in the brackets), we need to normalise that vector. This simply means, that this vector will always be of magnitude (length) . But we said that Alice moves with 100km/h, right? That's the job of . This is simply a scalar value (i.e. a number), which has the value of Alice's speed (100km/h). This way our velocity vector has been normalised by , and after being multiplied by will have the same length as in the previous time step, but with a different orientation.
Lastly, we have . This is where we enable the use of statistics. In order to have any possibility (or use) for statistical analysis, we need randomness. And this is the term that deals with that. Every time that Alice calculates her next direction to be the average of the directions of her neighbours inside her sphere of influence, nudges that trajectory slightly in a random direction. For the purposes of these simulations, we will nudge Alice randomly by a uniformly distributed rotation inside a solid angle . Uniform distribution simply means that any random rotation is as equally likely as any other. A solid angle on the other hand just defines a slice of a sphere around her new direction that she can be nudged in. The size of the slice is defined by . For example defines half a sphere. Below you can see what a solid angle of with looks like, centred around the north pole (so as if Alice was moving straight up).
Well, that's it, right? We've figured out how the equations work, and what they do. What's next? Well, if you remember, what we're trying to achieve here is to add one more thing to the model, to modify it. We need a time delay. We will achieve this very easily. What we can say, is that when Alice is calculating her new trajectory by averaging those of her neighbours, she doesn't take the most current ones, but instead ones from some previous time step. So we simply modify the first equation in the Vicsek model such that
and we keep the second one intact
All we've done is add a tiny little in the brackets there. This simply states that the velocity vectors of the neighbours will be taken from some previous step where can range from (meaning no time delay), to any number we like. Since in our simulations time will progress in steps of , can take values like .
Cool, now we can proceed to actually
Writing a simulator for the time-delayed Vicsek model in 3D
Now that we've analysed the equations, this will be a very easy task to accomplish. I have two versions of this, one is the one I used for analysing the data written in Python with the help of NumPy (super useful for vector and matrix operations), and the other one to just visualise the model in JavaScript. Let's use the Python code as a reference, since it's more accurate in a couple of aspects.
Another thing to mention, is that we need to set some sort of boundaries to our system, since we can't have them run around in an infinite space. So we will use periodic boundary conditions, which simply means that we will define a box of a certain size, and whenever a particle leaves that box, it will simply reappear on the other end of that box. So, as simple as
if (x < 0):
x = x + x_size
if (x > x_size):
x = 0
We also have to be careful about how we calculate distances in periodic boundary conditions . Here's pseudo-code from Wikipedia:
! For a box with the origin at the lower left vertex
! Works for x's lying in any image.
dx = x(j) - x(i)
dx = dx - nint(dx / x_size) * x_size
We also need a function to generate random vectors inside a sphere:
# generate random angle theta between -pi - pi
def rand_vector():
theta = np.random.uniform(0,2*pi)
z = np.random.uniform(-1,1)
x = cos(theta) * sqrt(1 - z**2)
y = sin(theta) * sqrt(1 - z**2)
return np.array([x,y,z])
While we are at it, let's also find a way to implement the operator. After doing a bit of testing, I found that the fastest way to do this is by using quaternions. These are a mathematical curiosity which allows for algebraic operations with numbers extending the complex numbers. But they have one quite popular use in 3D, and that is calculating rotations.
I've used a dedicated numpy-based library for quaternions, and I've documented my code as much as possible. So we can now put these functions together in one file, and call it geometry3d.py
.
#!/usr/bin/python
from __future__ import division
import numpy as np
from math import pi, sin, cos, sqrt
from numba import jit
import quaternion as quat
# generate a noise vector inside a cone of angle nu*pi around the north pole
# [1] https://stackoverflow.com/questions/38997302/create-random-unit-vector-inside-a-defined-conical-region
# rotate the generated noise vector to the axis of the particle vector
# [2] https://stackoverflow.com/questions/6802577/rotation-of-3d-vector
def noise_application(noiseWidth, vector):
# Generate a random vector in solid angle 4*pi*nu around north pole
z = np.random.uniform(0., 1.) * (1 - cos(noiseWidth)) + cos(noiseWidth)
phi = np.random.uniform(0., 1.) * 2 * np.pi
x = sqrt(1 - z**2) * cos( phi )
y = sqrt(1 - z**2) * sin( phi )
# Rotate the noise vector to be in a cone around the directional vector
# rotation axis
vector = vector / sqrt(vector[0]**2 + vector[1]**2 + vector[2]**2)
u = np.cross([0, 0, 1], vector)
# rotation angle
rotTheta = np.arccos(np.dot(vector, [0, 0, 1]))
# prepare rot angle for quaternion
axisAngle = 0.5 * rotTheta * u / sqrt(u[0]**2 + u[1]**2 + u[2]**2)
# Quaternion stuff - pretty fast, compared to rotation matrices
vec = quat.quaternion(x, y, z)
qlog = quat.quaternion(*axisAngle)
q = np.exp(qlog)
vPrime = q * vec * np.conjugate(q)
return vPrime.imag
# generate random angle theta between -pi - pi
def rand_vector():
theta = np.random.uniform(0,2*pi)
z = np.random.uniform(-1,1)
x = cos(theta) * sqrt(1 - z**2)
y = sin(theta) * sqrt(1 - z**2)
return np.array([x,y,z])
# [3] https://en.wikipedia.org/wiki/Periodic_boundary_conditions#(A)_Restrict_particle_coordinates_to_the_simulation_box
@jit(nopython=True)
def get_all_distances(ps, box_size):
m = ps.shape[0]
res = np.zeros((m, m))
for i in range(m):
for j in range(m):
dx = abs( ps[i,0] - ps[j,0] )
dy = abs( ps[i,1] - ps[j,1] )
dz = abs( ps[i,2] - ps[j,2] )
dx = dx - np.rint(dx/box_size) * box_size
dy = dy - np.rint(dy/box_size) * box_size
dz = dz - np.rint(dz/box_size) * box_size
res[i, j] = sqrt(dx*dx + dy*dy + dz*dz)
return res
If you're wondering what's numba
and what is that @jit
decorator doing there, Numba is a Just In Time compiler for Python, which in short does things real fast, and comparable to C in speed. Go read about it to learn more about it, totally worth it for doing repetitive heavy computations in Python (I measured 7-11x increase in speed in certain scenarios).
Now let's look into simulating the model itself. After we import whatever we will use, we need to declare some variables, which we have in the equations
#!/usr/bin/python
import sys
import numpy as np
from collections import deque
from geometry3d import rand_vector, get_all_distances, noise_application
import time
"""Simulation Variables"""
# Set these before running!!!
# number of particles
N = int(sys.argv[1])
# size of system
box_size = float(sys.argv[2])
# length of time delay
timeDelay = int(sys.argv[3])
# noise intensity
eta = 0.45
# make noise equilibration
noiseWidth = eta*np.pi
# neighbour radius
r = 1.
# time step
t = 0
delta_t = 1
# maximum time steps
T = 20000*delta_t
# velocity of particles
vel = 0.05
"""END Sim Vars"""
We need sys
imported, in order to get the arguments passed when running the python script. Now we generate random positions for our particles, and we can use our random vector generator function to initialise random initial velocities for the particles (as well as just initialise an array of zeros for the noise vectors).
"""INITIALISE"""
# initialise random particle positions
particles = np.random.uniform(0,box_size,size=(N,3))
updatePos = particles
prevPos = np.zeros(particles.shape)
# initialise random unit vectors in 3D
rand_vecs = np.zeros((N,3))
for i in range(0,N):
vec = rand_vector()
rand_vecs[i,:] = vec
noiseVecs = np.zeros((N, 3))
updateVecs = rand_vecs
Finally, we are going to use a queue for the time delay. A way to set this up, is to store in an array the average direction of a particles neighbours, and push it onto a queue. When the queue is filled with values of this average direction, we can dequeue that value, and use it to calculate the next direction of the particle of interest.
# init time delay
updtQueue = np.zeros((N), dtype=deque)
for i in range(N):
updtQueue[i] = deque()
"""END INIT"""
Next, let's set up the meat of it all - the timestep function, which will do the following for each particle:
- Find its neighbours (particles within the sphere of influence)
- Put the directions of all neighbours into the queue
- When the queue is as long as the time-delay value :
- Dequeue the neighbours from before the time-delay period
- Calculate the average direction of the neighbours (and normalise that vector)
- Apply random noise to that average direction
- Assign this new vector as the new direction of the particle
- Move the particle to its new position, following its new direction vector
- Apply a periodic boundary conditions check (teleport particle if need be)
- Rinse and repeat for the duration of the simulation
And then we can add some code to save our particles data to text files, and print in the console the time it takes to make a time step. After all of that, we put everythin in one file, and call it whatever we want, like main.py
.
#!/usr/bin/python
import sys
import numpy as np
from collections import deque
from geometry3d import rand_vector, get_all_distances, noise_application
import time
"""INITIALISE"""
"""Simulation Variables"""
# Set these before running!!!
# number of particles
N = int(sys.argv[1])
# size of system
box_size = float(sys.argv[2])
# length of time delay
timeDelay = int(sys.argv[3])
# noise intensity
eta = 0.45
# neighbour radius
r = 1.
# time step
t = 0
delta_t = 1
# maximum time steps
T = 20000*delta_t
# velocity of particles
vel = 0.05
"""END Sim Vars"""
# make noise equilibration
noiseWidth = eta*np.pi
# initialise random particle positions
particles = np.random.uniform(0,box_size,size=(N,3))
updatePos = particles
prevPos = np.zeros(particles.shape)
# initialise random unit vectors in 3D
rand_vecs = np.zeros((N,3))
for i in range(0,N):
vec = rand_vector()
rand_vecs[i,:] = vec
noiseVecs = np.zeros((N, 3))
updateVecs = rand_vecs
timestepTime = time.time()
# init time delay
updtQueue = np.zeros((N), dtype=deque)
for i in range(N):
updtQueue[i] = deque()
"""END INIT"""
def timestep(particles, rand_vecs):
# actual simulation timestep
for i in range(len(particles)):
# get neighbor indices for current particle
neighbours = np.where(distances[i]<r)
neighbours = neighbours[0][ np.where( neighbours[0] != i ) ]
neighsDirs = rand_vecs[neighbours]
# add neighbours' directions to queue to be used after time delay interval
updtQueue[i].append(neighsDirs)
# if the queue is long enough, dequeue and change unit vector accordingly
# otherwise continue on previous trajectory
if(len(updtQueue[i]) > timeDelay):
# get neighbours' directions from before time delay interval
neighsDirs = updtQueue[i].popleft()
# get average direction vector of neighbours
avg = np.mean([rand_vecs[i], *neighsDirs], axis=0)
# apply the noise vector by rotating it to the axis of the particle vector
newVec = noise_application(noiseWidth, avg)
# move to new position
updatePos[i,:] = updatePos[i,:] + delta_t * vel * newVec
# get new unit vector vector
updateVecs[i] = newVec
else:
# move to new position using old unit vector
updatePos[i,:] = updatePos[i,:] + delta_t * vel * rand_vecs[i]
# assure correct boundaries (xmax,ymax) = (box_size, box_size)
if updatePos[i,0] < 0:
updatePos[i,0] = box_size + updatePos[i,0]
if updatePos[i,0] > box_size:
updatePos[i,0] = updatePos[i,0] - box_size
if updatePos[i,1] < 0:
updatePos[i,1] = box_size + updatePos[i,1]
if updatePos[i,1] > box_size:
updatePos[i,1] = updatePos[i,1] - box_size
if updatePos[i,2] < 0:
updatePos[i,2] = box_size + updatePos[i,2]
if updatePos[i,2] > box_size:
updatePos[i,2] = updatePos[i,2] - box_size
particles = updatePos
rand_vecs = updateVecs
return particles, rand_vecs
"""TIMESTEP AND THINGS TO DO WHEN VISITING"""
# Run until time ends
timestr = time.strftime("%Y%m%d-%H%M%S")
f = open( 'N{0}L{1}dt{2}T{3}_{4}.txt'.format(N, box_size, timeDelay, T, timestr), 'a+' )
while t < T:
# print progress update and time spent on n steps
if t%100 == 0:
print ("step {} / {}: avg. time for 10 steps {:.3f}".format(
t, T, (time.time()-timestepTime)/10
))
timestepTime = time.time()
"""Timestep"""
# get all relative distances between particles before looking for neighbours
distances = get_all_distances(particles, box_size)
particles, rand_vecs = timestep(particles, rand_vecs)
# export data and advance to new time step
np.savetxt(f, particles, header='timestep {0}'.format(t) )
t += delta_t
else:
f.close()
And we are done! We can now run our simulator from the terminal with something like python main.py 128 6 2
to get the simulation for 128 particles, a box size of 6, and time delay value of 2. Voila! We have a Vicsek model simulator with added time delay, which will export particle positions. Now we can do all kinds of fun things, like visualise the motion of the particles, or do analysis on the data.
Next time we will see what kind of statistical analysis we can do on those data, and for now I will leave you with a link to a visualisation version of this model, that you can play around with
And here you can see the full code of the simulator, together with the analysis part, and a script to run it, as well as a Jupyter notebook for visualising the analysis. We will discuss all of these in the next post.
Thank you for reading!
The information in this blog, as well as all the tools, apps and libraries I develop are currently open source.
I would love to keep it this way, and you can help!
You can buy me a coffee from here, which will go towards the next all-nighter I pull off!
Or you can support me and my code monthly over at Github Sponsors!
Thanks!