LAB – Interacting Hard Spheres#
Please write your name in this box
We encourage you to work together, and seek whatever help you need in completing this lab. Please list all people who you worked with or received help from
We encourage you to use any online resources that you find useful. Please list any URL’s that you consulted
We encourage you to use AI resources if you find them useful. If you made use of AI, please list the platform and the prompts which you made use of
Load in Packages, and set up logger#
using Plots,Logging,ProgressMeter,LinearAlgebra
Set up Logger
default_logger=global_logger()
debug_logger=ConsoleLogger(stderr, Logging.Debug); # set up logger
To turn on debug logger as the global logger type
global_logger(debug_logger)
to turn it off type
global_logger(default_logger)
Goal#
Here you are going to upgrade our Billiard code to handle interacting hard spheres. By simulating a large number of such spheres we will be able to explore the kinetic theory of gases, and start thinking about statistical mechanics.
We will again do an event driven simulation
. Each ball will have a nextevent
attribute, which tells what it will next bounce off of, and when.
At each step in the main loop we will figure out which ball has the next collision event – which could be bouncing off of a wall, or it could be bouncing off of another ball. We then time evolve up to that point. We then update all of the nextevent
fields, and repeat.
We will first do a naive update method which scales quadratically with the number of spheres, \(O(N^2)\). We will then improve that to a linear method, \(O(N)\). We will discuss a \(O(N^0)\) method, but will not implement it, as it is a bit of a pain, and our \(O(N)\) approach is fast enough for what we want to do.
The simulation we are producing here can be described as “hard sphere molecular dynamics”
You will probably want the Billiards.ipynb
file open while you work on this, as we follow a very similar plan here.
## Activity 1
We will use a Ball
class with the following fields:
mutable struct Ball
x
y
vx
vy
radius
nextevent
end
This is similar to our previous Ball class – but since we are going to have multiple balls, there is no sense having each one keep track of time. We also added two new attributes, radius
and nextevent
. These are the ball’s radius, and the object which describes the next collision event.
For efficiency reason, we are going to hard-code the types for these fields. (We get about a factor of 5 speedup compared to leaving them untyped. This is because the compiler can optimize some of the code.) Thus we need to define a “Null Event” object, which can be our default Event
object.
abstract type Event end
struct NullEvent <: Event
t ::Float64
end
NE=NullEvent(Inf)
mutable struct Ball
x ::Float64
y ::Float64
vx ::Float64
vy ::Float64
radius ::Float64
nextevent ::Event
end
This is similar to our previous Ball class – but since we are going to have multiple balls, there is no sense having each one keep track of time. We also added two new attributes, radius
and nextevent
. These are the ball’s radius, and the object which describes the next collision event.
Based off of the lecture definition, write the following methods:
set_xv!
set_r!
set_v!
get_r
get_v
Also, overload Base.show
, and make convenience constructors for the Ball
class. As a default you can set nextevent
equal to NE
.
Test these methods.
## Activity 2
Here are our Hwall
and Vwall
classes. We will not use Cwall
in this lab, but if we write everything correctly, it should still work. As with the Ball
class, I am hard-coding the type declaration for the positions.
abstract type Wall end
struct Hwall <: Wall
y ::Float64
end
struct Vwall <: Wall
x ::Float64
end
As in lecture need an impact_time
method, which finds the but we will need to change it so that it takes 3 variables impact_time(w::Hwall,b::Ball,t)
– since our Ball
objects do not store the time.
Also, since the balls now have finite radius, you need to change the impact-time
definition so that the collision happens when the edge of the ball hits the wall, not the center. Here is how I implemented it for the horizontal wall:
function impact_time(w::Hwall,b::Ball,t)
dy=w.y-b.y
return (dy-sign(b.vy)*b.radius)/b.vy+t
end
Write the impact-time
method, and test it. Make a method so that you can call impact-time
with the Wall and Ball in arbitrary order.
## Activity 3
Next we need to define a method
impact_time(b1::Ball,b2::Ball,t)
which tells us when 2 balls will collide. You should be able to do this by calling the circ_time
function that we made in class.
We will need to call it with r0=r1-r2
, v=v1-v2
, and r=rad1+rad2
, where r1
and r2
are the positions of the two balls, v1
and v2
are their velocity vectors, and rad1
and rad2
are their radii. Draw some pictures to convince yourself of the geometry. You can also imagine doing the problem in different frames. Convince yourself of the answer when one of the balls is stationary and at the origin. Then shift frames.
We also need to make one change to circ_time
: if the two balls are closer than rad1+rad2
you return Inf
. That way if round-off error overlaps the balls, it will not cause them to collide on the inside.
# Here is my code for this
function circ_time(r0,v,r)
# Calculate the various terms in our expression
r0dotv=r0⋅v
if r0dotv>0 #moving away from one another
return Inf
end
vsq=v⋅v
r0sq=r0⋅r0
if r0sq<=r^2 #balls are interpenetrating
return Inf
end
discriminant=r0dotv^2+(r^2-r0sq)*vsq
if discriminant<0 # balls will never hit one-another
return Inf
end
times=[(-r0dotv-sqrt(discriminant))/(vsq),(-r0dotv+sqrt(discriminant))/(vsq)]
t1,t2=minmax(times...)
if t2<=0
return Inf
end
if t1>0
return t1
end
return t2
end
impact_time(b1::Ball,b2::Ball,t)=
t+circ_time(get_r(b1)-get_r(b2),
get_v(b1)-get_v(b2),b1.radius+b2.radius)
impact_time (generic function with 1 method)
Come up with some test cases, to see if this function is behaving properly.
## Activity 4
We will now create our event object. It will simply store the two objects which are involved in the collision, and the time at which the collision will occur.
We will also create our Billiard object, which stores the walls, balls, current time, and the next event. Lets also store a bounding box there, for visualization.
abstract type Event end
struct Collision <: Event
object1 :: Union{Ball,Wall}
object2 :: Union{Ball,Wall}
t ::Float64
end
struct NullEvent <: Event
t ::Float64
end
NE=NullEvent(Inf)
mutable struct Billiard
walls ::Array{Wall}
balls ::Array{Ball}
t ::Float64
nextevent ::Event
boundingbox ::Array{Float64,1}# x1,y1,x2,y2 = corners of a rectangle
end
Billiard(;walls,balls,t=0.,nextevent=NE,boundingbox)=
Billiard(walls,balls,t,nextevent,boundingbox)
Billiard
Make a function next_event(b::Ball,billiard::Billiard)
which will loop through all of the walls and balls in billiard
, and find their impact time with b
. It then finds which collsion will occur next, and create an event
object corresponding to that collision.
Test with a square billiard containing 2 balls. Put the balls at various places, and make sure it gives the right answer.
Here is a nice trick for simultaneously looping over walls and balls is to use the union
function:
b1=Ball(0,0,1,0,0.1,NE)
b2=Ball(0.5,0.,-1,0,0.1,NE)
billiard1=Billiard(walls=[Hwall(1),Vwall(1),Hwall(-1),Vwall(-1)],
balls=[b1,b2],boundingbox=[-1,-1,1,1])
for x in union(billiard1.walls,billiard1.balls)
@show x
end
x = Hwall(1.0)
x = Vwall(1.0)
x = Hwall(-1.0)
x = Vwall(-1.0)
x = Ball(0.0, 0.0, 1.0, 0.0, 0.1, NullEvent(Inf))
x = Ball(0.5, 0.0, -1.0, 0.0, 0.1, NullEvent(Inf))
## Activity 5
To help debug, at this point it is useful to make a visualize
function, which plots the billiard. This will differ from the one we did in class in two ways: (1) we want to plot multiple balls, and (2) we want the balls to have a well defined radius.
Unfortunately, while the Plots.scatter
command allows us to control the size of the circles, it does so in a slightly complicated way. It will be better to write our own circle drawing function (or use a different plotting program – I think Makie
can do a better job)
function drawcircles!(centers,radii;opts...)
c=[cos(θ) for θ in 0:2*pi/100:2pi]
s=[sin(θ) for θ in 0:2*pi/100:2pi]
makecircle(center,radius)=Shape(radius*c.+center[1],radius*s.+center[2])
circles=[makecircle(c,r) for (c,r) in zip(centers,radii)]
plot!(circles;aspect_ratio=:equal,label="",opts...)
end
function drawcircles!(plt::Plots.Plot,centers,radii;opts...)
c=[cos(θ) for θ in 0:2*pi/100:2pi]
s=[sin(θ) for θ in 0:2*pi/100:2pi]
makecircle(center,radius)=Shape(radius*c.+center[1],radius*s.+center[2])
circles=[makecircle(c,r) for (c,r) in zip(centers,radii)]
plot!(plt,circles;aspect_ratio=:equal,label="",opts...)
end
function drawcircles(centers,radii;opts...)
plt=plot()
drawcircles!(plt,centers,radii;opts...)
end
drawcircles (generic function with 1 method)
c1=drawcircles([[0,0],[1,1],[2,2]],[sqrt(2)/2,sqrt(2)/2,0.25])
Use this command to write a visualize(billiard::Billiard)
function which draws the current state of a billiard. Test.
## Activity 5
Write a function setevents!(billiard::Billiard)
– which will loop over all the Balls
, setting their nextevent
attribute to the output of next_event
. Note that for \(N\) balls, this take of order \(O(N^2)\) time.
As you go, update billiard.nextevent
with the earliest of these.
billiard1.nextevent
NullEvent(Inf)
## Activity 6
We already have code for when a ball bounces off of a wall:
reflect(vec,normal)=vec-2*(vec⋅normal)*normal/(normal⋅normal)
normal(w::Hwall,r)=[0,1]
normal(w::Vwall,r)=[1,0]
function newvelocity(w::Wall,b::Ball)
r=get_r(b)
n=normal(w,r)
v=get_v(b)
return reflect(v,n)
end
function set_newvelocity!(w::Wall,b::Ball)
v=newvelocity(w,b)
set_v!(b,v)
return b
end
set_newvelocity!(b::Ball,w::Wall)=set_newvelocity!(w,b)
set_newvelocity! (generic function with 2 methods)
We need similar code for when two balls bounce off of one-another. Write a function set_newvelocity!(b1::Ball,b1::ball)
which sets the velocities of two colliding balls after a collision. Assume that the balls are in physical contact (so you dont’ need to check if they will collide, or find their positions at that time – we will only call this function when they are at the right locations).
Hint: In the center of mass frame, the velocities are just reflected using the normal vector at the collision point. We will assume all of the balls have the same mass, so the center of mass frame is easy to find
## Activity 7
Make a method update!(billiard::Billiard,event::Event)
, which grabs current_time=billiard.t
and collision_time=event.t
. It updates the positions of the balls as they move in a straight line path between those times. (for that you might want to make a time_evolve(b:Ball,dt)
method. Your update!
method should then calls set_newvelocity!
on the objects in the event. Finally it should update billiard.t
.
A good way to test this is to first generate a Billiard
:
b1=Ball(0,0,1,0,0.1,NE)
b2=Ball(0.5,0.,-1,0,0.1,NE)
billiard1=Billiard(walls=[Hwall(1),Vwall(1),Hwall(-1),Vwall(-1)],
balls=[b1,b2],boundingbox=[-1,-1,1,1])
visualize(billiard1)
Run that, then in a new cell run
setevents!(billiard1)
update!(billiard1,billiard1.nextevent)
visualize(billiard1)
Every time you run that cell it should update the billiard, and the visualization. Do that a bunch of times to see if it is acting correctly.
## Activity 8
Now you have all the ingredients that you need for the simulation. We need to figure out, however, how we will deliver the output. Lets use the following data structure
struct State
xvalues ::Array{Float64,1}
yvalues ::Array{Float64,1}
vxvalues ::Array{Float64,1}
vyvalues ::Array{Float64,1}
radii ::Array{Float64,1}
t ::Float64
end
struct Simulationdata
walls :: Array{Wall}
statelist :: Array{State}
boundingbox ::Array{Float64,1}
end
Base.length(s::Simulationdata)=length(s.statelist)
The object State
will contain the x,y,vx,yv for each of the balls in the current state of the system.
Write a function which creates a state object from a Billiard
function State(b::Billiard)
...
end
Also make a function which can be used to visualizes a state:
function visualize!(plt::Plots.Plot,s::State;opts...)
test the state creation and visualization. Note, this visualize
should only draw the balls – you will separately draw the walls.
## Activity 9
Make your main loop
function simulate(billiard::Billiard,n)
that takes a Billiard
, and time-evolves it for a fixed number of collisions. It then returns the a Simulationdata
object.
Test with a small run with a couple balls, and just a few collisions.
## Activity 10
Now we need some visualization. The first thing we need is a function which draws the configuration for a single frame of our simulation data, something like
function drawframe(s::Simulationdata,j)
walls=s.walls
bbox=s.boundingbox
plt=plot()
for w in walls
visualize!(plt,w,bbox;label="")
end
visualize!(plt,s.statelist[j])
end
drawframe (generic function with 1 method)
Test to see if this works by running your simulate
function for a few collisions, and draw some of the resulting configurations
Now here is a minimal implementation of a eventanimate
function. Here skip
is an integer which lets us skip frames to speed up the animation when we have lots of balls
function eventanimate(s::Simulationdata,skip=1)
# print out a message to let the user know things are starting
@info "Animating Simulation"
len=length(s)
progress_meter=Progress(len,desc="Generating frames: ")
anim = @animate for i in 1:skip:len
ProgressMeter.update!(progress_meter,i)
drawframe(s,i)
end
Plots.gif(anim, fps = 12)
end
eventanimate (generic function with 2 methods)
Test it out with just a couple balls. It should be pretty jumpy, but that is OK – we are going to want to work with many balls, which will smooth it out.
## Activity 11
Now we need to automate initial conditions. For simplicity, lets start the balls on a \(n_x\times n_y\) grid (so there will be \(N=n_x n_y\) balls). We will give each of them speed 1, with a velocity vector pointing in a random direction.
Make a function that takes the \(n_x\), \(n_y\), the radii of the balls, and the dimensions of the Billiard. It should then produce the desired configuration.
Test it with \(L_x=L_y=1\), \(n_x=n_y=10\), and use a radius of \(0.01\). For me running a simulation with 100 collisions took about 0.2s. Use @time
to see how long it takes you.
Look at individual frames (using drawframe
). If all is good, then make an animation. If the animation runs too slow, use the skip
argument in eventanimate
.
To get a uniformly distributed random number you can use
using Random
rand()
0.650364167657448
## Activity 12
We now want to make things faster. 100 collisions in 0.2s is too slow for me. We are going to want to do many thousands of collisions.
The biggest algorithmic bottleneck is that in every step we update the collisions for every single ball.
The current code should look something like this:
function setevents!(billiard::Billiard)
t_global=Inf
for b in billiard.balls
next=next_event(b,billiard)
b.nextevent=next
if next.t<t_global
t_global=next.t
billiard.nextevent=next
end
end
return billiard
end
We are looping through every single ball, and calculating its collision time with every other ball, using the next_event
method. This is wasteful: we only need to calculate times with the balls in the last event.
We want to make a new version of setevents!
, that is called with the billiard, and the last event, and then only updates the things which we know might have changed:
function setevents!(billiard::Billiard,lastevent::Collision)
Here is the algorithm:
The first thing we do is make a list of all the Ball
objects in lastevent
. Suppose there is just one of them – lets call it b0
. We set b0.nextevent
to be equal to the null event NE
. We also set billiard.nextevent
to the null event.
We will then loop through all of the balls in billiard. For each ball bj
we calculate the next collision time between b0
and bj
. If that collision time is shorter than b0.nextevent.t
, we update b0.nextevent
to be the collision between b0
and bj
. Similarly if that collision time is shorter than bj.nextevent.t
we update bj.nextevent
.
We then look at b0.nextevent.t
and bj.nextevent.t
. If either is before billiard.nextevent.t
then we update billiard.nextevent
.
Finally, we need to loop through all of the walls, and if any of their collisions with b0
are shorter than b0.nextevent.t
we update b0.nextevent
. If that collision time is shorter than billiard.nextevent.t
we also update billiard.nextevent
.
After looping through all of the balls (and walls), we will have upddated all of the nextevent
objects – but only required a \(O(N)\) time. For comparison, the previous algorithm required \(O(N^2)\) time.
Thus for 100 balls this will run 100 times faster.
Write this new version of setevents!
, and test it. Here is how I did my tests: In one cell I ran:
rb2=randomgrid(2,2,0.01,1,1)
visualize(rb2)
then in the next cell I repeatedly ran:
setevents!(rb2,rb2.nextevent)
update!(rb2,rb2.nextevent)
visualize(rb2)
## Activity 13
Now write an updated version of simulate
– which calls the new setevents!
. Test
## Activity 14
Just like the Sinai Billiard, this system is chaotic. In fact, it is a specific type of chaotic system which is referred to as Ergodic. If one runs the simulation for enough time, one expects to uniformly sample all possible configurations of the balls which are consistent with the initial conditions.
In practice this means the only constraint is that the total energy is conserved. As we will discuss in lecture, this means that if we have a large number of balls, and all the balls start out with velocity 1, then at long times we expect the velocity distribution of each ball to be drawn randomly from a Maxwell-Boltzmann distribution,
To test this out, what you want to do is run a simulation with ~100 Balls (so a 10x10 grid) for a large number of collisions (say 5000). The ball radius is not so critical here. Something like radius=0.01
works well if the box sides have length 1. Take the last state, and make a list of the speeds of all the particles. Use the histogram
function to plot them. Given that there are only 100 speeds, the histogram will be pretty blocky. Note, histogram
takes an argument normalize=true
, which will normalize it to make a probability distribution function.
Draw the theoretical curve on the histogram.
## Activity 13
To get better resolution on our histogram, we want to bin together data at different times. We would naively expect that after ~\(N\) collisions we should get a statistically independent set of velocities. Here is a function which makes a list of speeds from a frame of the simulation data.
"""
speedlist(sim::Simulationdata,frame::Integer)
Makes a list of the speeds of all of the particles in frame number
`frame` of the simulation data. You can also call it with a list of
frames and it will concatentate those lists.
For example, you can call with
speedlist(s,[1,3,5])
and get the speeds in frames 1, 3, 5. We can even use
speedlist(s,1::10::100)
which will do every 10'th frame between 1 and 100.
"""
speedlist(sim::Simulationdata,frame::Integer) =
sqrt.(sim.statelist[frame].vxvalues.^2
+sim.statelist[frame].vyvalues.^2)
speedlist(sim::Simulationdata,frames)=
vcat([speedlist(sim,j) for j in frames]...)
speedlist (generic function with 2 methods)
Run your simulation for another 50,000 collisions, and make a Histogram that contains the speeds from every 100’th frame. (Which will give you 50,000 speeds). Again compare with the Maxwell-Boltzmann expectation.
## Bonus 1
With some rather small changes, one can incorporate different masses into the simulation. You could then have large heavy particles and small light particles scattering off one-another. It could be fun to do that. This is fully optional
## Bonus 2
It turns out that there are a number of further algorithmic improvements that we can make. Follow this link for a practical review. Another, slightly broader discussion can be found here.
The next improvements take a bit of work – and you only should do them if it interests you. This is fully optional
The first trick is that we can do asynchronous updates. Each ball can have its own clock. In the update!
step we only need to update the positions of the balls that were involved in the collision. We then need to modify the collision_time
method, so that it can act on balls whos positions are given at different time: For example, suppose ball1
is at position x1
at time t1
, and ball2
is at position x2
at time t2
. If these had vanishing radii, the collision time sattisfies
which is readily solved for \(t\). This is easily incorporated into our functions.
The second trick is to divide the billiard
into spatial cells. Each ball is placed in a cell. Possible events include scattering with other particles in the same cell, or hitting the cell boundary – in which case the particle is moved to the neighboring cell. Because we only need to worry about collisions in the same cell, this converts the problem of finding the next collision from \(O(N)\) to \(O(1)\).
Using these two tricks we get a speedup by another factor of \(N\) – so roughly \(100\) for the cases we have been considering.