Introduction to the SimPy Discrete-Event Simulation Package [SimPysmaller1.gif] Professor Norm Matloff University of California, Davis Contents: * about SimPy * where to obtain it * how to install it * Simpy overview * introduction to using SimPy * use of cancel() * debugging * more on random number generators * alternate approaches to a problem * monitors * SimPy internals * the GUI package * more information About SimPy: SimPy (rhymes with "Blimpie") is a public-domain package for process-oriented discrete-event simulation. It is written in, and called from, Python. I like the clean manner in which it is designed, and the use of Python generators--and for that matter, Python itself--is a really strong point. If you haven't used Python before, you can learn enough about it to use SimPy quite quickly; see my quick tutorial on Python. Instead of using threads, as is the case for most process-oriented simulation packages, SimPy makes novel use of Python's generators capability. (Python 2.2 or better is required. See my Python generators tutorial if you wish to learn about generators, but you do not need to know about them to use SimPy.) Generators allow the programmer to specify that a function can be prematurely exited and then later re-entered at the point of last exit, enabling coroutines. The exit/re-entry points are marked by Python's yield keyword. Each new call to the function causes a resumption of execution of the function at the point immediately following the last yield executed in that function. For convenience, I will refer to each coroutine (or, more accurately, each instance of a coroutine), as a thread. Where to obtain it: Download it from SimPy's Sourceforge site. How to install it: Create a directory, say /usr/local/SimPy. You need to at least put the code files Simulation.* and __init__.* in that directory, and I will assume here that you also put in the test and documentation subdirectories which come with the package, say as subdirectories of /usr/local/SimPy. You'll need that directory to be in your Python path, which is controlled by the PYTHONPATH environment variable. Set this in whatever manner your OS/shell set environment variable. For example, in a csh/UNIX environment, type setenv PYTHONPATH /usr/local/ Modify accordingly for bash, Windows, etc. One way or the other, you need to be set up so that Python finds the library files correctly. Both the SimPy example programs and our example programs here include the line from SimPy.Simulation import * which instructs the Python interpreter to look for the module Simulation in the package SimPy. Given the setting of PYTHONPATH above, Python would look in /usr/local/ for a directory SimPy, i.e. look for a directory /usr/local/SimPy, and then look for Simulation.py and __init__.py (or their .pyc compiled versions) within that directory. Test by copying testSimPy to some other directory and then running python testSimPy.py Some graphical windows will pop up, and after you remove them, a message like "Run 54 tests..." will appear. SimPy overview: Here are the major SimPy classes: * Process: simulates an entity which evolves in time, e.g. one job which needs to be served by a machine; we will refer to it as a thread, even though it is not a formal Python thread * Resource: simulates something to be queued for, e.g. the machine * Monitor: this is a nice class which optionally makes it more convenient to collect data Here are the major SimPy operations: * activate: used to mark a thread as runnable when it is first created * simulate: starts the simulation * yield hold: used to indicate the passage of a certain amount of time within a thread * yield request: used to cause a thread to join a queue for a given resource * yield release: used to indicate that the thread is done using the given resource, thus enabling the next thread in the queue, if any, to use the resource * yield passivate: used to have a thread wait until "wakened" by some other thread * reactivate: does the "wakening" of a previously-passivated thread * cancel: cancels all the events associated with a previously-passivated thread Here is how the flow of control goes from one function to another: * When the main program calls simulate(), the main program blocks. The simulation itself then begins, and the main program will not run again until the simulation ends. * Anytime a thread executes yield, that thread will pause. SimPy's internal functions will then run, and will restart some thread (possibly the same thread). * When a thread is finally restarted, its execution will resume right after whichever yield statement was executed last in this thread. Note that activate(), reactivate() and cancel() do NOT result in a pause to the calling function. Such a pause occurs only when yield is invoked. In my opinion, this is a huge advantage, for two reasons: * your code is not cluttered up with a lot of lock/unlock operations * execution is deterministic, which makes both writing and debugging the program much easier Introduction to using SimPy: We will demonstrate the usage of SimPy by presenting three variations on a machine-repair model. The three SimPy programs are in my SimPy home directory, http://heather.cs.ucdavis.edu/~matloff/Sim/SimPy: MachRep1.py, MachRep2.py and MachRep3.py . In each case, we are modeling a system consisting of two machines which are subject to breakdown, but with different repair patterns: * MachRep1.py: There are two repairpersons, so that both machines can be repaired simultaneously if they are both down at once. * MachRep2.py: Here there is only one repairperson, so if both machines are down then one machine must queue for the repairperson while the other machine is being repaired. * MachRep3.py: Here there is only one repairperson, and he/she is not summoned until both machines are down. In all cases, the up times and repair times are assumed to be exponentially distributed with means 1.0 and 0.5, respectively. MachRep1.py: Let's look at "main": UpRate = 1/1.0 RepairRate = 1/0.5 Rnd = Random(12345) initialize() for I in range(2): M = Machine() activate(M,M.Run(),delay=0.0) MaxSimtime = 10000.0 simulate(until=MaxSimtime) print "the percentage of up time was", Machine.TotalUpTime/(2*MaxSimtime) The heart of the code is for I in range(2): M = Machine() activate(M,M.Run(),delay=0.0) Here Machine is a class which I wrote, as a subclass of SimPy's built-in class Process. Since we are simulating two machines, we create two objects of our Machine class. These will be the basis for our two machine threads. We call SimPy's built-in function activate() on each of the two Machine objects. We do this to register them as threads, and to state the Machine.Run() is the function each thread will execute. It specifies how the entity evolves in time, in this case how the machine evolves in time. In general, I'll refer to this function (Machine.Run() in this example) as the process execution method (PEM). (Functions in Python are called methods.) In our call to activate() above, the last argument is the delay, typically 0.0. We want our threads to start right away. Note that this is an example of Python's named arguments capability. In SimPy's internal source code for activate(), there is are five arguments, the fourth of which is named delay. (If you wish to see this code, go to the file SimPy/Simulation.py and search for "def activate".) But in our call to activate() here, we only provide three arguments, which happen to be the first, second and fourth in the code for this function. By using Python's named arguments feature, we can skip some, which we did. The ones we skip take default values. There is no need to dwell on this point, but it is important that you are aware of why there are argument names in the midst of the call. The call to SimPy's built-in function simulate() then gets all the threads started. The argument is the amount of time to be simulated. (Again, we make use of a named argument.) Some other introductory comments about this piece of code: * The object Rnd will allow us to generate random numbers, in this case exponentially distributed. We have arbitrarily initialized the seed to 12345. * We will be calling the function Random.expovariate(), which takes as its argument the reciprocal of the mean. So here we have taken the mean up time and repair times to be 1.0 and 0.5, respectively. * The call to initialize() is required for all SimPy programs. Note again that once we make the call to simulate() above, the next line, print "the percentage of up time was", Machine.TotalUpTime/(2*MaxSimtime) won't be executed until simulated time reaches 10000.0. So, let's see what those two invocations of Machine.Run() will do. In order to do this, let's first take a look at the structure of our application-specific class Machine which we have defined in our source code: class Machine(Process): TotalUpTime = 0.0 # total up time for all machines NextID = 0 # next available ID number for Machine objects def __init__(self): Process.__init__(self) # required in any Process subclass self.UpTime = 0.0 # amount of work this machine has done self.StartUpTime = 0.0 # time the current up period stated self.ID = Machine.NextID # ID for this Machine object Machine.NextID += 1 def Run(self): ... First we define two class variables, TotalUpTime and NextID. As the comment shows, TotalUpTime will be used to find the total up time for all machines, so that we can eventually find out what proportion of the time the machines are up. Next, there is the class' constructor function, __init__(). Since our class here, Machine, is a subclass of the SimPy built-in class Process, the first thing we must do is call the latter's constructor; our program will not work if we forget this (it will also fail if we forget the argument self in either constructor). Finally, we set several of the class' instance variables, explained in the comments. Note in particular the ID variable. You should always put in some kind of variable like this, not necessarily because it is used in the simulation code itself, but rather as a debugging aid. Note that we did NOT need to protect the line Machine.NextID += 1 with a lock variable, as we would in pre-emptive thread systems. Again, this is because a SimPy "thread" retains control until voluntarily relinquishing it via a yield; our thread here will NOT be interrupted in the midst of incrementing Machine.NextID. Now let's look at the details of Machine.Run(), where the main action of the simulation takes place: def Run(self): print "starting machine", self.ID while 1: self.StartUpTime = now() yield hold,self,Rnd.expovariate(UpRate) Machine.TotalUpTime += now() - self.StartUpTime yield hold,self,Rnd.expovariate(RepairRate) The function now() yields the current simulated time. We are starting this machine in "up" mode, i.e. no failure has occurred yet. Remember, we want to record how much of the time each machine is up, so we need to have a variable which shows when the current up time for this machine began. With this in mind, we had our code self.StartUpTime = now() record the current time, so that later the code Machine.TotalUpTime += now() - self.StartUpTime will calculate the duration of this latest uptime period, now() - self.StartUpTime and then add it to the cumulative uptime total Machine.TotalUpTime. The two invocations of the Python construct yield produce calls to the SimPy method hold(), which you will recall simulates the passage of a certain amount of time. The first call, yield hold,self,Rnd.expovariate(UpRate) causes this thread to pause for an exponentially-distributed amount of simulated time, simulating an uptime period for this machine, at the end of which a breakdown occurs. The term yield alludes to the fact that this thread physically relinquishes control of the Python interpreter; another thread will be run. Later, control will return to this thread, resuming exactly where the pause occurred. Then the second yield, yield hold,self,Rnd.expovariate(RepairRate) works similarly, pausing execution of the thread for an exponentially-distributed amount of time to simulate the repair time. In other words, the while loop within Run() simulates a repeated cycle of up time, down time, up time, down time, ... for this machine. Important restriction: Some PEMs may be rather lengthy, and thus you will probably want to apply top-down program design and break up one monolithic PEM into smaller functions, i.e. smaller functions within the Process subclass containing the PEM. In other words, you may name your PEM Run(), and then have Run() in turn call some smaller functions. This is of course highly encouraged. However, you must make sure that you do not invoke yield in those subprograms; it must be used only in the PEM itself. Otherwise the Python interpreter would lose track of where to return the next time the PEM were to resume execution. It is very important to understand how control transfers back and forth among the threads. Say for example that machine 0's first uptime lasts 1.2 and its first downtime lasts 0.9, while for machine 1 the corresponding times are 0.6 and 0.8. The simulation of course starts at time 0.0. Then here is what will happen: * The thread for machine 0 will generate the value 1.2, then yield. SimPy's internal event list will now show that the thread for machine 0 is waiting until time 0.0+1.2 = 1.2. (You normally will not see the event list yourself, but it can be inspected, as we discuss in our section on debugging below.) * The thread for machine 1 (the only available choice at this time) will now run, generating the value 0.6, then yielding. SimPy's event list will now show that the thread for machine 0 is waiting until time 0.6. * SimPy advances the simulated time clock to the earliest event in the event list, which is for time 0.6. It removes this event from the event list, and then resumes the thread corresponding to the 0.6 event, i.e. the thread for machine 1. * The latter generates the value 0.8, then yields. SimPy's event list will now show that the thread for machine 0 is waiting until time 0.6+0.8 = 1.4. * SimPy advances the simulated time clock to the earliest event in the event list, which is for time 1.2. It removes this event from the event list, and then resumes the thread corresponding to the 1.2 event, i.e. the thread for machine 0. * Etc. When the simulation ends, control returns to the line following the call to simulate(), where the result is printed out: print "the percentage of up time was", Machine.TotalUpTime/(2*MaxSimtime) We divide by 2 here because there are two machines, and we are finding the overall percentage of up time for them collectively. MachRep2.py: Since this model now includes a queuing element, we add an object of the SimPy class Resource: RepairPerson = Resource(1) with the "1" meaning that there is just 1 repairperson. Then in Machine.Run() we do the following when an uptime period ends: yield request,self,RepairPerson yield hold,self,Rnd.expovariate(RepairRate) yield release,self,RepairPerson Here is what those yield lines do: * The first yield requests access to the repairperson. This will return immediately if the repairperson is not busy now; otherwise, this thread will be suspended until the repairperson is free, at which time the thread will be resumed. * The second yield simulates the passage of time, representing the repair time. * The third yield releases the repairperson. If another machine had been in the queue, awaiting repair--with its thread suspended while executing the first yield--it would now attain access to the repairperson, and its thread would now execute the second yield. Suppose for instance the thread simulating machine 1 reaches the first yield slightly before the thread for machine 0 does. Then the thread for machine 1 will immediately go to the second yield, while the thread for machine 0 will be suspended at the first yield. When the thread for machine 1 finally executes the third yield, then SimPy's internal code will notice that the thread for machine 0 had been queued, waiting for the repairperson, and would now reactivate that thread. Note the line if RepairPerson.n == 1: Here n is a member variable in SimPy's class Resource. It gives the number of items in the resource currently free. In our case here, it enables us to keep a count of how many breakdowns are lucky enough to get immediate access to the repairperson. We later use that count in our output: print "proportion of times repair was immediate:" print float(Machine.NImmedRep)/Machine.NRep The same class contains the member variable waitQ, which is a Python list which contains the queue for the resource. This may be useful in debugging, or if you need to implement a special priority discipline other than the ones offered by SimPy. MachRep3.py: Recall that in this model, the repairperson is not summoned until both machines are down. We add a class variable Machine.NUp which we use to record the number of machines currently up, and then use it in the following code, which is executed when an uptime period for a machine ends: Machine.NUp -= 1 if Machine.NUp == 1: yield passivate,self elif RepairPerson.n == 1: reactivate(M[1-self.ID]) yield request,self,RepairPerson We first update the number of up machines (by decrementing Machine.NUp). Then if we find that there is still one other machine remaining up, this thread must pause, to simulate the fact that this broken machine must wait until the other machine goes down before the repairperson is summoned. The way this pause is implemented is to invoke yield with the operand passivate. Later the other machine's thread will execute the reactivate() statement on this thread, "waking" it. But there is a subtlety here. Suppose the following sequence of events occur: * machine 1 goes down * machine 0 goes down * the repairperson arrives * machine 0 starts repair * machine 0 finishes repair * machine 1 starts repair * machine 0 goes down again The point is that when the thread for machine 0 now executes if Machine.NUp == 1: the answer will be no, since Machine.NUp will be 0. But that is not a situation in which this thread should waken the other one. Hence the need for the elif condition. Use of cancel(): In many simulation programs, a thread is waiting for one of two events; whichever occurs first will trigger a resumption of execution of the thread. The thread will typically want to ignore the other, later-occurring event. We can use cancel() to cancel the latter event. An example of this is in the program TimeOut.py . The model consists of a network node which transmits but also sets a timeout period. If the node times out, it assumes the message it had sent was lost, and will send again. We wish to determine the percentage of attempted transmissions which result in timeouts. The timeout and transmission/acknowledgement times are assumed to be exponentially distributed. (The former is unusual for a timeout period, but I wanted a simple model that could be verified analytically via Markov chain analysis.) The main driver here is a class Node, whose PEM code includes the lines while 1: self.NMsgs += 1 TO = TimeOut() activate(TO,TO.Run(),delay=0.0) ACK = Acknowledge() activate(ACK,ACK.Run(),delay=0.0) yield passivate,self if self.ReactivatedCode == 1: self.NTimeOuts += 1 The node, an object Nd of class Node, sets up a timeout by creating an object TO of TimeOut class, and sets up a transmission and acknowledgement by creating an object ACK of Acknowledge class. Then the node passivates itself, allowing the TO and ACK to do their work. Here's what TO does: yield hold,self,Rnd.expovariate(TORate) Nd.ReactivatedCode = 1 reactivate(Nd) self.cancel(ACK) It holds a random timeout time, then sets a flag in Nd to let the latter know that it was the timeout which occurred first, rather than the acknowledgement. Then it reactivates Nd and cancels ACK. ACK of course has similar code for handling the case in which the acknowledgement occurs before the timeout. Note that in our case here, we want the thread to go out of existence when canceled. The cancel() function does not make that occur. It simply removes the pending events associated with the given thread. The thread is still there, and in some applications we may want the thread to continue execution. We can make this happen by calling reactivate() after cancel(); the thread will then resume execution at the statement following whichever yield it had executed last. Debugging: As usual, do yourself a big favor and use a debugging tool, rather than just adding print statements. Use the debugging tool's own built-in print facility for whatever printing you need. See my debugging slide show for general tips on debugging. I have some points on Python debugging in particular in my quick tutorial on Python. There is one additional debugging action needed in the case of simulation. It is not actually for debugging, but rather for verification that the program is running correctly, but makes use of your debugging tool anyway. In simulation situations, one typically does not have good test cases to use to check our code. After all, the reason we are simulating the system in the first place is because we don't know the quantity we are finding via simulation. So in simulation contexts, the only way to really check whether your code is correct is to use your debugging tool to step through the code for a certain amount of simulated time, verifying that the events which occur jibe with the model being simulated. I recommend doing this first starting at time 0.0, and later again at some fairly large time. The latter is important, as some bugs only show up after the simulation has been running for a long time. I will assume here that you are using the pdb debugging tool. It is rather primitive, but it can be used effectively, if used via DDD. Again, see my quick introduction to Python for details. Your ability to debug SimPy programs will be greatly enhanced by having some degree of familiarity with SimPy's internal operations. You should review the overview section of this SimPy tutorial, concerning how control transfers among various SimPy functions, and always keep this in mind. You may even wish to read the section below on SimPy internals . Note, by the way, that many SimPy internal entity names begin with _ or __. In the former case, you must explicitly ask for access, e.g. from SimPy.Simulation import _e while in the latter case you must demangle the names. With any process-oriented simulation package, you must deal with the context switches, i.e. the sudden changes from one thread to another. Consider for example what happens when you execute your code in pdb, and reach a line like -> yield hold,self,Rnd.expovariate(ArrvRate) Let's see what will now happen with the debugging tool. First let's issue pdb's n ("next") command, which skips over function calls, so as to skip over the call to expovariate. We will still be on the yield line: (Pdb) n --Return-- > /usr/home/matloff/Tmp/tmp6/HwkIII1.py(14)Run()->(1234, yield hold,self,Rnd.ex povariate(ArrvRate) If we issue the n command again, the hold operation will be started, which (as explained in the section on SimPy internals below) causes us to enter SimPy's holdfunc() method: (Pdb) n > /usr/local/SimPy/Simulation.py(388)holdfunc() - . holdfunc(a): This presents a problem. We don't want to traipse through all that SimPy internals code. One way around this would be to put breakpoints after every yield, and then simply issue the continue command, c each time we hit a yield. Another possibility would be to use the debugger's command which allows us to exit a function from within. In the case of pdb, this is the r ("return") command. We issue the command twice: (Pdb) r --Return-- > /usr/local/SimPy/Simulation.py(389)holdfunc()->None -> a[0][1]._hold(a) (Pdb) r > /usr/home/matloff/Tmp/tmp6/HwkIII1.py(29)Run()->(1234, , 0.45785058071658913) -> yield hold,self,Rnd.expovariate(ExpRate) Again, pdb is not a fancy debugging tool, but it really can be effective if used well. Here for instance is something I recommend you use within pdb when debugging a SimPy application: alias c c;;l;;now() This replaces pdb's continue command by the sequence: continue; list the current and neighboring code statements; and print out the current simulated time. Try it! I think you'll find it very useful. If so, you might put it in your .pdbrc startup file, say in each directory in which you are doing SimPy work. Of course, you can also extend the alias temporarily to automatically print out certain variables too each time execution pauses (and then unalias it later when you don't need it). The debugging process will be much easier if it is repeatable, i.e. if successive runs of the program give the same output. In order to have this occur, you need to use random.Random() to initialize the seed for Python's random number generator. See the section on random number generators below. Here is another trick which you may find useful. You can print out SimPy's internal event list with the following code (either from the debugging tool or within your program itself): from SimPy.Simulation import _e ... print _e.events And as mentioned earlier, you can print out the wait queue for a Resource object, etc. The newer versions of SimPy include special versions of the file Simulation.py, called SimulationTrace.py and SimulationEvent.py, which you may find useful in your debugging sessions. Largely, what these do is to formalize and automate some of the tips I've given above. See the documentation in the file Tracing.html. More on random number generators: The Python method random.expovariate(), as seen earlier, generates an exponentially-distributed random variate with the given argument. The latter is the "event rate," i.e. the reciprocal of the mean. The method random.uniform() generates a uniform random variate from the continuous interval (a,b), where a and b are the arguments to the method. The method random.randrange(a,b) generates a random integer in the set {a,a+1,a+2,...,b-1}, each member of the set having equal probability, again where a and b are the arguments to the method. As mentioned earlier, at least for the purposes of debugging, you should not allow SimPy to set the initial random number seed for you. For consistency, set it yourself, using random.Random(), as you see in my examples. I use arguments like 12345, but it really doesn't matter much. Alternate approaches to a problem: It's always good to keep in mind that there may be several different ways to write a SimPy program. For example, consider the program MdMd1.py mentioned above, which models a discrete-time M/M/1 queue. There we had Server as a Process class, and handled the simple queuing on our own. But instead, in the program MdMd1Alt.py, modeling the same discrete-time M/M/1 queue, we have Server as a Resource. We have a new Process class, Job, and each time a job arrives, a Job object is created which then uses the Server Resource. Monitors: Monitors make it more convenient to collect data for your simulation output. They are implemented using the class Monitor, which is a subclass of the Python list type. For example, you have a variable X in some line in your SimPy code and you wish to record all values X takes on during the simulation. Then you would set up an object of type Monitor, say named XMon, in order to remind yourself that this is a monitor for X. Each time you have a value of X to record, you would have a line like XMon.observe(X) which would add the value, and the current simulated time, to the list in XMon. (So, XMon's main data item is an array of pairs.) The Monitor class also includes member functions that operate on the list. For example, you can compute the mean of X: print "the mean of X was", XMon.mean() The function Monitor.timeAverage() takes the time-value product. Suppose for instance you wish to find the long-run average queue length. Say the queue lengths were as follows: 2 between times 0.0 and 1.4, 3 between times 1.4 and 2.1, 2 between times 2.1 and 4.9, and 1 between 4.9 and 5.3. Then the average would be (2 x 1.4 + 3 x 0.7 + 2 x 2.8 + 1 x 0.4)/5.3 = 2.06 How would you arrange for this computation to be done in your program? Each time the queue changes length, you would call Monitor.observe() with the length as argument, resulting in Monitor recording the length and the current simulated time (now()). When the simulation ends, at time 5.3, the monitor will consist of this list of pairs: [ [0.0,2], [1.4,3], [2.1,2], [4.9,1] ] The function timeAverage() would then compute the value 2.06, as desired. That last 0.4 in that average is taken into account by timeAverage() in that the most recent time, say the end of the simulation or at least the time at which timeAverage() is called, is 5.3, and 5.3 - 4.9 = 0.4. SimPy internals: Your programming and debugging of SimPy applications will be greatly enhanced if you know at least a little about how SimPy works internally. SimPy's internals (other than the Monitor material) are in the file Simulation.py. To illustrate how they work, say in a SimPy application program we have an object X with PEM Run(), and that the latter includes a line yield hold,self,1.2 What will happen when that line is executed? To see, let's first take a look at how the PEM gets started in the first place. When the program calls activate(), i.e. activate(X,X.Run(),delay=0.0) activate() creates a dummy event with the current time (stored internally by SimPy in the variable _t) and adds it to the SimPy event list, _e.events. (Addition of an event to the event list is done via the SimPy internal function _post().) Note that X.Run() is NOT running yet at this point. Let's assume for convenience that, as in our examples above, the call to activate() is in our "main" program. Later in the "main" program, there will be a call to simulate() The simulate() function consists mainly of a loop which iterates until the simulation is finished. In pseudocode form, the loop looks like this: while simulation not finished take earliest event from event list update simulated time to the time of that event call the function which produced that event Recall that for our function X.Run(), the call to activate() had resulted in a dummy event for this function being placed in the event list. When simulate() pulls that dummy event from the event list, this will result in X.Run() being called, as you can see in the pseudocode above. Now X.Run() will be running (and simulate() will not be running, of course). When X.Run() reaches its yield statement (shown above, but reproduced here for convenience), yield hold,self,1.2 this causes X.Run() to return the Python tuple (hold,self,1.2) to the caller of X.Run()--which was simulate(). SimPy has an internal dictionary dispatch: dispatch={hold:holdfunc,request:requestfunc,release:releasefunc, \\ passivate:passivatefunc} Since simulate() received the parameter hold when X.Run() invoked yield, it will do the dictionary lookup based on hold, finding holdfunc, and thus will call the SimPy internal function holdfunc(). The latter will call _hold(), which will create an event with time at the indicated time (in our example here, 1.2 amount of time later). It will then use _post() to add this event to the event list. The simulate() function will then continue with its looping as seen above. Eventually it will pull that hold event for X.Run() off the event list, and again as seen in the pseudocode above, it will call the function associated with this event, i.e. X.Run(). That causes X.Run() to resume execution after the yield statement which produced that event. Etc. The GUI package: SimPy now has nice GUI tools, greatly enhancing the usefulness of this excellent simulation package. See the documentation for details. More information: SimPy comes with quite a bit of documentation, including a number of examples. A wealth of further information is available in the SimPy Wiki.