Introduction
In the previous tutorial, we created module, PyParticles, that allowed to us to recreate our particle simulation relatively easily. However, before we can use this module to create a range of different simulations, we need to be able to choose which behaviours the particles exhibit. In this tutorial, we will:
- Introduce lambda for creating anonymous functions
- Give the Environment class a dictionary of functions we can choose from
Like the previous tutorial, this one's not specific for Pygame. It introduces Python's anonymous functions, which are quite an advanced programming technique. It took a while to work out how and when to use them, but I think we now have a prefect example of how they can be useful. As usual, the final code can be found by following the Github link at the top of this post.
Particle functions
At present the Particle's move() function is responsible for changes in velocity due to gravity (or not if you commented it out) and drag. In the next tutorial, we'll model a cloud of gas in space, so we don't want to include drag or a gravitational force pulling all particles in the same direction (nor do we want the particle to bounce off the boundary of the screen). We therefore need to make these functions independent. To start with, move the code controlling gravity out of the move() function into a separate accelerate() function within the Particle class:
def accelerate(self, vector):
(self.angle, self.speed) = addVectors((self.angle, self.speed), vector)
We don't necessarily need to move drag() into a separate function because by setting the mass_of_air variable to 0, drag becomes 1, so there's no reduction in speed. However, it's inefficient to multiply each particle's speed by 1 every tick of the simulation; it would be better if we could ignore drag unless specified otherwise. If we create a separate drag() function, then we could rewrite the Environment update() method like so:
def update(self):
for particle in self.particles:
particle.move()
if self.mass_of_air != 0:
particle.experienceDrag()
if self.acceleration:
particle.accelerate(self.acceleration)
if self.hasBoundaries:
self.bounce(particle)
This update() method tests whether to we should bother calculating drag. It also test whether to accelerate particles due to some constant force, such as gravity, which would be set as a vector belonging to the Environment class called self.acceleration. Finally it tests whether the particles should bounce off the walls or continue off into space. This is determined by a boolean (i.e. true or false), self.hasBoundaries.
The problem is that now we testing whether we should call these various functions for each of the particles, every tick of the simulation. If we have 100 particles, then that's 300 if statements every tick! We could improve the efficiency somewhat (to 3 if statements per tick) by putting each of the calls in a separate loop, with the test outside, like so:
for particle in self.particles:
particle.move()
if self.mass_of_air != 0:
for particle in self.particles:
particle.experienceDrag()
if self.acceleration:
for particle in self.particles:
particle.accelerate(self.acceleration)
if self.hasBoundaries:
for particle in self.particles:
self.bounce(particle)
However, we still have to carry out each of the tests each tick of the simulation and the code is just inelegant. Ideally we should only have to carry out the test once at the start of the simulation.
Variable functions
My solution to this problem (and others certainly exist), is to give the Environment object a list of the particle functions we want to use (move, drag, bounce etc.), and call each of them for each particle:
for particle in self.particles:
for f in self.particle_functions:
f(particle)
But how do we get our functions into a list? We can't just type:
self.particle_functions = [particle.experienceDrag, self.bounce(particle)]
We want to define the function list at the beginning of the simulation, before we've even defined particle. Even if we had defined the Environment object's list of particles, we need some way to refer each specific particle object. Furthermore, if we add self.bounce(particle) to the list, what we actual add is the result of calling self.bounce(particle), which is None because the function doesn't return anything.
The solution is to use the lambda function to create anonymous functions. For convenience, I put the functions in a dictionary, so they can be referred to easily. To the Environment class's __init__() function, add:
self.particle_functions = []
self.function_dict = {
'move': lambda p: p.move(),
'drag': lambda p: p.experienceDrag(),
'bounce': lambda p: self.bounce(p),
'accelerate': lambda p: p.accelerate(self.acceleration)}
Lambda allows us to create a single line function without a name. Just as with normal functions they can take a parameter, which is given before the colon. For example, if we now type:
self.function_dict['move'](self.particles[0])
We will call lambda p: p.move() with the first particle in the Environment object's particle as the parameter. This will take the particle object (referred to as p in the lambda function) and call its move() function.
We can now give the Environment class a function to add specific functions to its particle_functions list.
def addFunctions(self, function_list):
for f in function_list:
if f in self.function_dict:
self.particle_functions.append(self.function_dict[f])
else:
print "No such function: %s" % f
Now, when we start a new simulation, it's incredibly simple to define which functions to include:
env = PyParticles.Environment((width, height))
env.addFunctions(['move', 'accelerate', 'drag'])
env.acceleration = (math.pi, 0.002)
When the Environment's update() function is called, it will now call the move, accelerate and drag functions for each of its particles.
Two-particle functions
Sadly, the situation is not quite so simple, because we should also like to define which two-particle functions are called. For example, we might not want the particles to collide and in the next tutorial we'll want to add an attract() function which will take two particles as parameters. The following adaptation is required:
self.particle_functions1 = []
self.particle_functions2 = []
self.function_dict = {
'move': (1, lambda p: p.move()),
'drag': (1, lambda p: p.experienceDrag()),
'bounce': (1, lambda p: self.bounce(p)),
'accelerate': (1, lambda p: p.accelerate(self.acceleration)),
'collide': (2, lambda p1, p2: collide(p1, p2))}
def addFunctions(self, function_list):
for func in function_list:
(n, f) = self.function_dict.get(func, (-1, None))
if n == 1:
self.particle_functions1.append(f)
elif n == 2:
self.particle_functions2.append(f)
else:
print "No such function: %s" % func
Now the function dictionary values are a tuple that indicates whether the function takes 1 or 2 parameters, which we use to determine which of two function lists to add the function to. The Environment update() function should now be changed to:
def update(self):
for i, particle in enumerate(self.particles):
for f in self.particle_functions1:
f(particle)
for particle2 in self.particles[i+1:]:
for f in self.particle_functions2:
f(particle, particle2)
Now the collide() function can be included or ignored in the same way as for the other particle functions. The following tutorial will have an example of how our updated PyParticles module can be used.
Final note on efficiency
Note, our simulation still isn't as efficient as it could be since if we want to move the particles and make them experience drag, then we must make two function calls instead of one as before. Furthermore, we carry out the nested loop in the update() function regardless of whether we include any two-particle functions. This is a limitation of writing flexible code. If processing speed becomes an issue, then it's best to write a custom program. However, the PyParticles module is a good place to start prototyping a simulation and testing parameters; you can start weeding out further inefficiencies once you've got an interesting simulation running. PyParticles should be efficient enough to get you started.
These last two tutorial may have seemed like a lot of work to essentially get back where we started (and if you made it this far then I'm seriously impressed), but hopefully the following tutorials will demonstrate just how flexible our code now is. We can now use our module to create a range of apparently very different programs with relatively little effort. We'll start in the next tutorial, by making a simulation of a gas cloud collapsing under its own gravity to form a solar system.
Comments (5)
derek on 1 Aug 2012, 4:40 p.m.
The signature of the findParticle function changed here from previous version from sepearate x and y parameters to a (x, y) tuple. Not a big deal, but I would probably just update here or previous to just make it consistent for all remaining parts of tutorial.
Peter on 4 Aug 2012, 9:08 p.m.
Thanks - I've updated it.
Nikolas Moya on 19 Aug 2012, 10:07 a.m.
I belive that the update function can have a better efficienfy if you swap these two lines:
for f in self.particle_functions2:
for particle2 in self.particles[i+1:]:
f(particle, particle2)
On the way it was before:
You are simulating N particles.
The number of comparsions was (only on the second half of the update for loop):
(sum from i to n, of (n - i)) * (n - 1)
Bottom line, You are testing every i+1 particle, if there is a function of 2 parameters to be executed. The simulation might be running without the collide function, and still, you are testing every tick, if the collide function is on the list or not.
By switching those two lines:
If there is no fuction that requires 2 parameters, you won't go into the second for loop (that goes through all i+1 particles).
Bottom line:
If there is a function with 2 parameters. Execute this function in all i+1 partcles.
If there isn't, move on, no need to test with all i+1 particles with there is something to be executed or not.
Correct me if I'm wrong, please.
Nikolas
Christian Langlois on 24 Jan 2013, 4:29 p.m.
Great write up and descriptions, most appreciated thank you.
For beginners, it might be clearer to highlight that using lamba isn't required per se, for storing functions in the dictionary, one can equally store named functions (or objects or class names).
eg. A menu example, which could be adapted to a KEY input "call table"
menu = {
'1': (print_all, "Print Everyone"),
'2': (print_one, "Print One person"),
'3': (add_one, "Add Person"),
'4': (filter_byrole2, "Filter by Role"),
'q': (quit, "Quit"),
}
for k in sorted(menu.keys()): # will not be in key order !
print k, menu[k][1]
choice = raw_input('Select Item: ')
if choice in menu:
menu[choice][0]()
The reasons for using the lamba function are :
Sometimes, you want to call a function on the object p.function_name()
other times you call a class function with the object as a parameter self.function_name(p) and with varying number of parameters.
More importantly though, I would attempt to avoid the one/two parameter issue in the first place.
By moving the collide() function into the Particle class, then call
particle1.collide(particle2)
instead of
collide(particle1, particle2)
A side effect of the two loop implimentation, is that the one parameter functions all get called before the two parameter functions.
ie maybe I want the order int the list to be significant; Here's an idea:
particles = ['p1','p2','p3','p4','p5','p6']
# dict of funcs with number of params
dict_of_funcs = {
'fa':(fa,1),
'fb':(fb,1),
'fc':(fc,2),
'fd':(fd,2),
'fe':(fe,1),
'ff':(ff,1), }
ordered_funcs=[
dict_of_funcs['fa'],
dict_of_funcs['fd'],
dict_of_funcs['fb'],
dict_of_funcs['fc'],
]
for n,p in enumerate(particles):
for fref, nparams in ordered_funcs:
if (nparams == 1):
fref(p)
else:
map(lambda q: fref(q, p), particles[:n])
Thanks for the thought provoking examples, I've learned alot from studying them.
Maylon on 9 Dec 2013, 7:18 p.m.
Very useful tutorial.
I was looking for something to lean how to use pygame and classes, and this pretty much does the trick.
There are some bugs I haven't been able to track down and fix though. I was hoping something has. At tutorial 10, the select and drag function works. Then in tutorial 11, and 14 this stops working. Clicking anywhere in the window (be it particle or background) the window actually closes.
Any hints?