Previous ToC Up Next

2. N-Body Integrators

2.1. Inspecting the Leapfrog

Alice: Yes, I get the idea, and that all makes a lot of sense. And now that we understand how the data get read in, let's see what will happen with them.

In rknbody1.rb, I see that you have shifted all the integration methods from the Body to the Nbody class, as well as the evolve function that calls them.

Bob: The evolve function orchestrates the whole integration process, and it is called by the driver, which only knows about the one Nbody instance that it has created. So it is logical to put the evolve method inside the Nbody class. And since evolve calls the various integration methods, it also seemed logical to have leapfrog, rk2, and so on, reside there.

Alice: I could imagine an alternative, where each particle is given the freedom to use its own integration method, in which case you would want to shift those methods back into the Body class, but that would make more sense when you use an individual time step algorithm, where each particle has its own time step length. For the simple shared time step case that we are starting with, your choice is surely the best.

Bob: I could imagine many things, but coding them takes more time than imagining them! I do like the idea of relatively autonomous particles, integrating themselves as they want, with stars in denser regions having perhaps more specialized integrators, but not today.

Alice: Looking at evolve, I see almost exactly the same function that we used for the two-body problem. The only difference is that now the time is an instance variable for the Nbody class, which means that we don't have to pass the time as an argument to the write_diagnostics method.

Bob: Yes. If I would have left the time as a normal variable that would be passed around, the evolve method would have been exactly the same. A nice example of recycling code: whether you are dealing with one pseudo particle or with N particles, the top level instructions are basically the same.

Alice: But of course the actual work is different, and in our case more complicated. The forward Euler implementation is a bit hard to recognize, at first sight. Let me start with the new leapfrog method, which looks more familiar. The two-body version was:

   def leapfrog(dt)
     @vel += acc*0.5*dt
     @pos += @vel*dt
     @vel += acc*0.5*dt
   end

while now we have

   def leapfrog(dt)
     @body.each{|b| b.vel += b.acc*0.5*dt}
     @body.each{|b| b.pos += b.vel*dt}
     @body.each{|b| b.vel += b.acc*0.5*dt}
   end

This is easy to understand: for each body, basically the same actions are taken as was the case for our single pseudo-body, containing the relative position information for the two-body case.

Bob: The difference being that, invisibly at this level, the Body method acc, which computes the acceleration, has to ask all other particles for their position.

2.2. Acceleration

Alice: Indeed, acc has grown quite a bit bigger. In the two-body case, we started with

   def acc
     r2 = @pos*@pos
     r3 = r2*sqrt(r2)
     @pos*(-@mass/r3)
   end    

and your new N-body version reads:

   def acc
     a = @pos*0                    # null vector of the correct length        
     @nb.body.each do |b|
       unless b == self                                                       
         r = b.pos - @pos                                                     
         r2 = r*r
         r3 = r2*sqrt(r2)
         a += r*(b.mass/r3)                                                   
       end
     end
     a
   end    

Bob: The main difference is the loop that our body has to execute over all other bodies. It is here that I am using my backpointer @nb that links back to the parent Nbody instance. In that way, the array of bodies becomes visible for our particular body as @nb.body, and it is this array over which we iterate using the familiar each construct.

Alice: And you are excluding the body itself from the loop, to avoid getting an infinitely large self interaction, through the line:

       unless b == self                                                       

But what exactly are you comparing? I am used to the C notation where == compares two numbers. In Ruby too, when both numbers are equal, the statement returns true, and if not, it returns false. But what are the two numbers being compared here?

Bob: In Ruby, each object, that is each instance of any class, has a unique id number, a machine-defined number that is guaranteed to be different for two different objects. We don't have to know anything about what that number is, or how it is represented. All we need to know is that we can rely on it being different for two different particles.

Alice: But this unique identification number has a different status from that of normal numbers, such as integers or floating point numbers, I presume. If I write a == b for two variables, Ruby compares the values of these two variables, not their id numbers. If Ruby would always use the == operator to compare object id numbers, then a == b would always result in false, whenever the two variables would be different, whether they have the same values or not.

Bob: Yes, you are right. I had not thought about that. In the case of numbers, or strings for that matter, the == operator must be overloaded so as to override the default behavior, which is comparing id numbers. Interesting! I had just used this expression, since it seemed reasonable, and does the right thing. But now that you ask me, yes, there must be different types of overloading going on for different classes. In other words, many different classes must define their own == method.

Alice: The good thing about Ruby is that everything happens so naturally, in such a transparent way. But a consequence is that you often don't appreciate all that is going on behind the scenes. Coming back to the statement above, this line is filtering out particle pair combinations where both particles have the same identity.

Bob: Indeed. particles are not allowed to interact with themselves. For all other particle pairs, we compute the acceleration in a similar way as before. The main difference is that the vector connecting the two bodies is not given, as was the case for the two-body problem, where there was only one relative vector. Here we compute the vector pointing from the calling particle to the called particle first, as follows:

         r = b.pos - @pos                                                     

Alice: And the acceleration seems to have the same mass dependence in both cases, the two-body and the N-body case, but here appearances deceive: in the two-body case we had an equation of moment for our pseudo particle, while here we are now dealing with real particles.

Bob: Yes, I thought about that carefully. Actually, the tricky thing is to get the two-body case right, where it is easy to make a mistake, as we saw when I was a bit too quick in coding up the diagnostics there. For the N-body case, in contrast, it is all a piece of cake. The line

         a += r*(b.mass/r3)                                                   

Directly implements Newton's law of gravity.

2.3. Newtonian Gravity

Alice: When we present this to our students, it would be good to summarize the connection specifically. To wit: the expression for the acceleration felt by particle i is given by summing together the Newtonian gravitational attraction of all other particles j, where both i and j take on values from 1 up to and including N, according to the text books. In our case, of course, we label particles with numbers starting from 0 and running up to and including N-1, since that is Ruby's default way of numbering arrays. Let's write the equations accordingly:

Here and are the mass and position vector of particle j, and G is the gravitational constant.

When I write this equation on a black board in front of a class, there is always someone who asks me where the power of 3 in the denominator comes from, given that Newtonian gravity is an inverse square law, and therefore should be proportional to the power 2 of the distance, in the denominator. To bring out the inverse square nature of gravity, I then write , with , after which I define the unit vector . This allows the above equation to be written as:

with the expected power of 2 in the denominator.

Finally, I note that the summation excludes self-interactions: every particle feels the forces of the other particles, but not its own force, which, as we already mentioned, would be infinitely large in case of a point mass.

Bob: That's a nicely crisp summary.

Alice: What is really nice in our Ruby implementation, is that we never have to introduce the counters i and j that are so ubiquitous in any N-body code I have ever seen. Just as we could dispense with the k variable for the components of a vector, we can avoid the other two counters by asking arrays and vectors to just loop over themselves.

And this is one of the features that makes Ruby eminently suited for prototyping and development work in general. Whether Ruby will be used eventually for an industrial-strength production-type code, that remains to be seen.

Bob: If so, we'll have to do some very serious speed-up. My impression so far has been that Ruby is at least a couple orders of magnitude slower than the equivalent C or Fortran implementation.

Alice: In addition, our leapfrog calculates the acceleration twice on the Nbody level, and for each particle pair, the relative acceleration is also computed twice.

Bob: If we had been a little more clever, we would have saved a factor of four there too. I bet we can speed up our code by a stunning factor of a thousand or so, if we pull all stops!

Alice: Maybe, we'll see in due time. For now, I think we are being clever, by not worrying at all about optimization. The point is to bring out the underlying structure, which is complex enough all by itself. Once we really see that clearly, we can start optimizing while avoiding confusing clutter.

2.4. A Matter of Taste

Bob: Note, by the way, one more difference between the 2-body and N-body case: in the latter case we have to accumulate the results, through the summation you just showed. Before traversing the loop over particles, we have to clear the vector where the acceleration a on our particle is being stored. I experimented with various ways to do so, but the most compact notation I found was what I wrote on the top of the acc method:

     a = @pos*0                    # null vector of the correct length        

Isn't that a nifty and compact expression?

Alice: I see. In order to provide a null vector for the acceleration with the right number of components, you use the position as a template, and after copying the position, you fill all entries with zeroes. I'm glad you put a comment line in, since otherwise the meaning wouldn't have been so obvious at first reading.

Hmmm. While I agree that it is compact, perhaps a longer expression would have been a bit more clear. How about

    a = ([0]*@pos.size).to_v            # null vector of the correct length
Bob: Yes, that would bring out the fact that you use the position vector only because you want to extract its size, and not for any other reason. And you explicitly show how you start with an array of length 1, filled with a single 0, and then extend that array to contain @pos.size components. But then you still have to convert it into a vector. You see, I avoided the last step by starting with a copy of @pos, which was already a vector.

Alice: Yes, your construction was clever, but I'm still wondering about the unsuspecting reader, who has to make sense of your cleverness. In fact, in my more lengthy alternative, notice that I left your comment line in, since upon first reading, even my longer line would still not be fully clear, I'm afraid.

If I really wanted to be self-explanatory, I would write:

    vector_size = @pos.size
    a = ([0]*vector_size).to_v
That way I would express the fact that a is a vector, that it needs to be of the right size, that it should contain all zeroes, and that it should be converted to a proper vector at the end of the day.

Bob: A long day, if you ask me. I would never have guessed your explanation completely just from looking at that piece of code, so I would insert a comment there as well -- which makes your alternative longer than mine. I prefer to stick with my

     a = @pos*0                    # null vector of the correct length        

Alice: Fine with me. This is really a matter of taste.

2.5. Potential Energy

Bob: While we're at it, let me walk you through the rest of the Body class definition. The potential is constructed in a very similar way as the acceleration, by doing a body walk through the whole system. In the two-body case, we started with:

   def epot                        # potential energy
     -@mass/sqrt(@pos*@pos)        # per unit of reduced mass
   end

My new N-body version reads:

   def epot                        # potential energy
     p = 0
     @nb.body.each do |b|
       unless b == self
         r = b.pos - @pos
         p += -@mass*b.mass/sqrt(r*r)
       end
     end
     p
   end

The difference with respect to acc is that the potential energy includes a product of the mass of the calling particle i and the mass of the called particle j:

Alice: Yes, of course, and that is something that is easy to leave out. If I would have written the potential method epot, using the acceleration method acc as a template, I might have forgotten to include the factor @mass for the calling particle. And such a bug might be hard to find at first, since we tend to test a code with simple values for the masses, often just unity. In general, it is important to run tests with masses that are not all unity.

2.6. Local Arrays

Bob: The rest of the input and output routines are unchanged, compared to our earlier two-body code. Let's return to the Nbody class. You mentioned that the leapfrog method was almost the same as before. Unfortunately, that is the only one of our four integration methods that remained quite simple to read. The other three have become a bit more crowded, I'm afraid.

Alice: I'll start with the forward Euler case again. In forward, you have replaced the previous form

   def forward(dt)
     old_acc = acc
     @pos += @vel*dt
     @vel += old_acc*dt
   end

by:

   def forward(dt)
     old_acc = []
     @body.each_index{|i| old_acc[i] = @body[i].acc}
     @body.each{|b| b.pos += b.vel*dt}
     @body.each_index{|i| @body[i].vel += old_acc[i]*dt}
   end

Bob: This is a tricky point. Before we stored the original acceleration in the vector old_acc, which was a single physical vector, containing the relative acceleration between our two particles. In the N-dimensional analogue that we have here, we need to store N initial acceleration vectors, one for each particle.

The most straightforward solution would be to define a new instance variable for the Body class, @old_acc, but I rejected that solution. As you will be happy to hear, I wanted to keep the code modular, without letting the Body class know what the Nbody class might decide to be good algorithms. The alternative would be to saddle the poor Body class simultaneously with all the possible variables that would be needed in all the algorithms you could choose from.

Alice: I indeed applaud your desire for modularity. However, in this particular case I'm not so sure whether we should insist on such a strong separation. Let's get back to that in a moment.

Bob: Since I wanted to keep the auxiliary variables, such as old_acc, local, I could not loop over them using the @body.each construct used in leapfrog. Of course, you can loop over old_acc alone easily enough, in a old_acc.each construction, but that would in turn not allow the @body array to be traversed.

The only solution I saw was to introduce an index i -- yes, I know, we just celebrated the lack of indices i and j in Ruby, and I'm not happy with it, but at least for now, it works. In that way, I could use the Array method @body.each_index, which does what it says it does, namely traversing the @body array. Now since old_acc and @body have the same number of components, equal to the number of particles N, this one construct can simultaneously traverse both arrays. The i index is the glue that connects both traversals, keeping them in lock step.

In addition, I had to introduce the array old_acc, which I did here in the first line of forward. The third line did not contain any local variables, so there at least I could avoid the use of an i variable.

Alice: That is a reasonable solution. You are trading modularity for readability. And while I'm sure there are several alternatives, let's first complete our guided tour here. What is left to visit is the energy diagnostics part of the code.

2.7. Energy Diagnostics

Bob: That turned out to be really simple. For each Body method ekin there is a corresponding Nbody method ekin that gathers all the individual results, and sums them up to find the total kinetic energy. Here is the Body version:

   def ekin                        # kinetic energy
     0.5*(@vel*@vel)
   end

and here is the Nbody version:

   def ekin                        # kinetic energy
     e = 0
     @body.each{|b| e += b.ekin}
     e
   end

In order to sum it all up, I introduce a variable e, initialize it to zero, add the various contributions, and then I list e again, in the final line. In that way, the method ekin returns the correct value e.

Alice: Just curious: couldn't you have left out the last line, with the single e? At the last time that the statement in the previous line will be executed, some particle's kinetic energy will be added to e, so e will be what is going to be returned anyway, no?

Bob: I'm not sure. You're talking about the last action in a loop, and then control is being returned to the each method. But it is easy enough to find out whether that would work. Let's call irb for help:

  |gravity> irb
  irb(main):001:0> a = [1, 2, 3]
  => [1, 2, 3]
  irb(main):002:0> e = 2
  => 2
  irb(main):003:0> a.each{|b| e += b}
  => [0, 1, 2, 3]
  irb(main):004:0> e
  => 8
Alice: I see. You were correct in worrying about the control coming back to the array. Actually, that makes sense: it was the array a in this example that called the each method. And frankly, even if it would have worked, it might have been better to leave the final e line in there, at the end of your ekin, for clarity.

Bob: Well, I wasn't sure either. Learn something new everyday. And it is certainly nice to work with an interpreted, rather than a compiled language: this type of checking you can do extremely quickly and easily.

Alice: The story for the potential energy must be similar. For each particle we have a method epot associated with the Body class, as you just showed us already, and a method with the same name, but associated with the Nbody class:

   def epot                        # potential energy
     e = 0
     @body.each{|b| e += b.epot}
     e/2                           # pairwise potentials were counted twice
   end

Just like for the kinetic energy, the Nbody method epot gathers all the contributions to the potential energy of the various bodies -- with one twist: you are now dividing by a factor two. Ah, of course: for each particle pair, the contribution is counted once when the one particle computes its potential energy, and once again when the other particle computes its potential energy. Therefore, every particle pair contribution gets counted twice, and at the end we have to correct for that.

Bob: Yes, indeed. And yes, I had left that factor of two out, the first time I ran the program. Diagnostics are wonderful; they sure keep you honest: of course I could get no good energy conservation no matter what I tried, until I realized what was going wrong. I found it by computing the initial kinetic energy and potential energy for a two-body system, which was easy enough to do on paper. Comparing it with the numerical result, it was immediately clear that the potential energy was counted twice.
Previous ToC Up Next