Introduction

I remember, when I was an undergraduate student at the University of Texas Rio Grande Valley, I was working with an astrophysics professor, Dr. Pereyra. After teaching me how to use the IDE VIM and the foundation of the programming language Fortran 90 and after seeing my fascination with the simulation of celestial objects, we decided to dive head first at creating an N-Body Simulation program in Fortran 90. We first created code which simulated physics on a single point mass that we set to the sun, then slowly added granularity by generalizing the code. After, making the code general so that point masses could be added dynamically, without compiling, It was possible to take in the coordinate information from the Jet Propulsion Laboratory's Horizons System and simulate the solar system.

The Physics

Newtonian physics was used to simulate the solar system, meaning that the implications of General and Special relativity are not simulated. That being said, by Newtonian physics, I mean using Newton's laws of motion.
The first law states that an object will remain at rest or in uniform motion in a straight line, unless affected by a force. The second law explains that the sum of the forces is equal to the mass of the object multiplied by the acceleration of the object, $\vec{F}=m\vec{a}$. Finally, the third and last law states that when a force is exerted on an object an equal and opposite equal force is exerted. We will use Newton's law of universal gravitation, $\vec{F}_{21}=-G \frac{m_1 m_2}{|r_{12}|^2} \hat{r}$ And by using the second law, $$\vec{g}(\vec{r})=-G\frac{m_1}{|\vec{r}|^2}\hat{r}$$
Now, when we say that the acceleration is constant we mean that the acceleration is equal to some number and doesn't change, $a=c$ and $da=0$. We know from the definition of acceleration that it basically is the change of velocity, $a=\frac{dv}{dt}$. To get the velocity from the acceleration integration is necessary, $$adt=dv\rightarrow a\int_{t_i}^{t_f}dt=\int_{v_i}^{v_f}dv\rightarrow a(t_f-t_i)=v_f-v_i$$ And, setting the change in time constant $t_f-t_i=t$ $$at+v_i=v_f$$ We integrate again to obtain the change in position $$\frac{1}{2}at^2+v_it+x_i=x_f$$ The algorithm then consists of obtaining the initial positions and velocities from a database, calculating the distances between an $i$th object and all the other objects and using those distances to extrapolate the forces and gravitational acceleration, calculating the new velocities and distances, and, finally, repeating as many times as needed.

Code

Fortran 90's structure generally consists of the main "program", subroutines, functions, types, and modules. We will mostly use subroutines and modules. Modules to keep constant global variables and subroutines to easily organize the code. Subroutines allow us to take apart a problem into smaller manageable pieces. Modules is like a package of variables, functions, and subroutines.
Note: I wrote this code when I was first learning how to code Fortran 90, so there are a lot of "slow code". Like how there is no need to calculate the force but you could calculate the acceleration directly.

Modules

We create three different modules and put them in a folder called "modules". The first, "mod_numeric.f90", will contain the kind for integer and real variables. For instance, int_kind will be 4 if you want the integers of your program to be 32 bit and 8 for 64 bit. We will also have the maximum possible number in a kind 8 real or, in other words, a double.

module mod_numeric
implicit none
public
    integer(kind=4), parameter :: int_kind  = 4 ! kind for Integer vars
    integer(kind=4), parameter :: real_kind = 8 ! kind for Real vars
    real(kind=8),    parameter :: max_general = 1.7D308
    real(kind=8),    parameter :: min_general = -1.7D308
end module mod_numeric

The second, "mod_math.f90", will contain a couple of constants

module mod_math
use mod_numeric, only: int_kind, & ! kind for Integer vars
                       real_kind   ! kind for Real var
implicit none
public
    real(kind=real_kind), parameter :: zero = 0D0 ! Number Zero
    real(kind=real_kind), parameter :: one  = 1D0 ! Number One
    real(kind=real_kind), parameter :: two  = 2D0 ! Number Two
    real(kind=real_kind), parameter :: onehundred  = 1D2 ! Number 100
    real(kind=real_kind), parameter :: onethousand = 1D3 ! Number 1000
end module mod_math

The third and last, mod_astronomy.f90, will contain astronomical constants like the gravitational constant, the mass of the sun, etc...

module mod_astronomy
use mod_numeric, only: int_kind, & ! kind for Integer vars
                       real_kind   ! kind for Real vars
implicit none
public
    real(kind=real_kind), parameter :: G = 6.671D-11
    real(kind=real_kind), parameter :: msun = 1.9891D30
    real(kind=real_kind), parameter :: mearth = 5.9742D24
    real(kind=real_kind), parameter :: AU = 1.4959787066D11
    real(kind=real_kind), parameter :: rsun = 6.955D08
    real(kind=real_kind), parameter :: km = 1D3
    real(kind=real_kind), parameter :: Megam = 1D6
    real(kind=real_kind), parameter :: KPS  = 1D3
    real(kind=real_kind), parameter :: CMPS = 1D-2
    real(kind=real_kind), parameter :: day = 86400D0
    real(kind=real_kind), parameter :: hour = 3600d0
    real(kind=real_kind), parameter :: minute = 60d0
end module mod_astronomy

Note: the explanation for the use of the variables are in the source code.

Subroutines

calcforces

The most important subroutine that will be created is the one which will calculates the forces between the point masses. The subroutine which will be named "calcforces" will take in the multidimensional arrays which contain the masses and positions of the point masses, it will also take in the number of point masses. It will have an output multidimensional array which contain the calculated forces. Also, we will have an option to have a fixed point mass at origin of euclidean space along with it's mass also as an input, for testing purposes.

Inputs:

  • ef_type : 0 - no external force, 1 - point mass at origin
  • ef_mass : Mass of Point Mass if ef_type = 1
  • N : Number of point masses
  • mass : Mass of point masses (will be a 1D array of size N)
  • pos : Positions of point masses in meters (will be 2D array of size $3\times N$)

Outputs:

  • forces : Calculated Forces (will be 3D array of size N)

We will have 4 real local arrays with various functions, 2 integer index variables, and 3 real regular local variables. We will also need to import the gravitational constant and the mass of the sun from mod_astronomy and the number zero and one from mod_math.

use mod_math, only: zero, &
                    one
use mod_astronomy, only: G, &
                         msun

The 4 local arrays will have size 3.

real(kind=real_kind) :: position1(1:3), &
                        position2(1:3), &
                        relpos(1:3),    &
                        univec(1:3)

We will name the 2 index variable $i$ and $j$.

integer(kind=int_kind) :: i, j

The three regular variables will be named as follows.

real(kind=real_kind) :: magpos, magpos2, magfor

For the implementation, Firstly, we need to calculate the external forces. We will use the intrinsic function dot_product to calculate the dot product of the vectors.

if (ef_type == 0) then
  forces = zero
else if (ef_type == 1) then
  do i = 1, N
    realpos = pos(1:3,i)
    magpos2 = dot_product(relpos, relpos)
    magpos = sqrt(magpos2)
    univec = relpos / magpos
    forces(1:3,i) = - G * ef_mass * mass(i) &
                        / magpos2 * univec
  end do
end if

Now, to calculate the forces between point masses

do i = 1, N
  position1(1:3) = pos(1:3,i)
  do j = 1, N
    if (j == i) cycle
    position2(1:3) = pos(1:3,j)
    relpos = position1 - position2
    magpos2 = dot_product(relpos, relpos)
    magpos  = sqrt(magpos2)
    forces(1:3,i) = forces(1:3, i)          &
                    - G * mass(i) * mass(j) &
                    / magpos2 * univec
  end do
end do

Finally, return and end ...

  return
end subroutine calcforces

Evolve

Let's call the subroutine that progresses time a step, evolve. It will use the subroutine the calcforce subroutine then use that information to calculate the acceleration, and the new positions and velocities.

Inputs:

  • ef_type : 0 - no external force, 1 - point mass at origin
  • ef_mass : Mass of Point Mass if ef_type = 1
  • deltat : Time step in seconds
  • N : Number of point masses
  • mass : Masses of the point masses (array size N)
    Input & Outputs
  • pos : Position of point masses in meters (array size $3\times N$)
  • vel : Velocity of point masses in meters per second (array size $3\times N$)

It will have local arrays size $3\times N$ for both the forces and acceleration and a local variable $i$ for iteration. Firstly, we call the calcforces subroutine.

call calcforces(ef_type, ef_mass, N, mass, pos, forces)

Then calculate the acceleration

do i = 1, N
  accel(1:3,i) = forces(1:3,i) / mass(i)
end do

Lastly calculate the new positions and the new velocities

pos = pos + deltat * vel + (deltat * deltat) * accel / two
vel = vel + deltat * accel

The compiler will take care of iterating through the arrays element wise.

fimport

We will need to import the data from a text file which contains the x, y, and z elements of the position and velocities of the point masses. The format for the text will be a line base one where the first line is the number of point masses, the next is the the index of the object, 1, then the mass, then the positional x, y, and z, separated by commas, and finally the x, y, and z of the velocity of the object. It then iterates through the rest of the objects. This subroutine will take in the number by which the file was opened, the number of particles. It will output the mass, position, and velocity of the objects.

Inputs:

  • nunit_particle : the number by which the file was opened
  • N : the number of point masses
    Outputs:
  • mass : the mass array (size $N$)
  • pos : the position array (size $3\times N$)
  • vel : the velocity array (size $3\times N$)

We will have three local integers, one, $i$, will iterate through the lines, i_Object will iterate through the objects, and, Nin, will import the number of objects from the text file. You can see the source code for this subroutine in the link below.

fexport

fexport will output the results in a similar fashion to fimport. It will have a format where the current time will be printed on a text file, then it will iterate through the masses and positions to print them onto the text file. Again, see source code for details.

Putting it all together

The main program

The main program we will call runNBody where we will import a couple of use statements.

program runNBody
use mod_numeric, only: int_kind, & ! kind for Integer vars
                       real_kind   ! kind for Real vars
use mod_math, only: zero, &        ! Number 0
                    onethousand
use mod_astronomy, only: day, &    ! A day in Seconds
                         AU,  &    ! A Astronomical Unit in meters
                         KPS, &    ! A km/s in m/s
                         CMPS, &
                         rsun

Declare the subroutines used as external

external fimport, evolve

Declare the parameters for the numbers used by the Fortran open function that is used to write to text files.

integer(kind=int_kind), parameter :: nunit_indata = 1,   &
                                     nunit_particle = 2, &
                                     nunit_history = 3,  &
                                     nunit_outdata = 4

Declare the maximum number of particle allowed

integer(kind=int_kind), parameter :: Nmax = 999

Declare the variables that will be used

integer(kind=int_kind) :: ef_type
real(kind=real_kind)   :: ef_mass, &
                          deltat
integer(kind=int_kind) :: nTimeSteps, &
                          N,          &
                          file_steps, &
                          scrn_steps

Declare the arrays that will be used

real(kind=real_kind) :: mass(1:N)
real(kind=real_kind) :: pos(1:3,1:Nmax), &
                        vel(1:3,1:Nmax)
real(kind=real_kind)   :: forces(1:3,1:Nmax), &
                          accel(1:3,1:Nmax)

Declare the integers that will be used to iterate

real(kind=real_kind)   :: time
integer(kind=int_kind) :: i, j, k

We will initialize the time to zero

time = zero

and start by reading the text file 'indata.txt' to get the number of particles, the number time steps to take, the size of the time steps, the number of time steps after which to call fexport, the number of time steps after which to output information to the screen, whether to use a point mass at origin, and finally the mass of that point mass.

open( unit = nunit_indata, file = 'indata.t', &
      form='formatted', status='old'
read(nunit_indata,*) N
read(nunit_indata,*) nTimeSteps
read(nunit_indata,*) deltat
read(nunit_indata,*) file_steps
read(nunit_indata,*) scrn_steps
read(nunit_indata,*) ef_type
read(nunit_indata,*)
read(nunit_indata,*) ef_mass
close(nunit_indata)

I output this information for debug purposes in histdata.txt

open( unit = nunit_history, file = 'histdata.txt', &
      form='formatted', status='unknown')
write(nunit_history,*) 'N               = ', N
write(nunit_history,*) 'nTimeSteps      = ', nTimeSteps
write(nunit_history,*) 'deltat          = ', deltat
write(nunit_history,*) 'file_steps      = ', file_steps
write(nunit_history,*) 'screen_steps    = ', scrn_steps
write(nunit_history,*) 'ext. force type = ', ef_type
write(nunit_history,*) 'ext. mass       = ', ef_mass
write(nunit_history,*)
close(nunit_history)

We will now call fimport to import the data and open the file that fexport will use and use it to write the starting data.

call fimport(nunit_particle, N, mass, pos, vel)
open(unit = nunit_outdata, file = 'outdata.txt', &
         form = 'formatted', status = 'unknown')
write(nunit_outdata,*) 'n:(',n,')'
call fexport(nunit_outdata, time, N, mass, pos, vel)

Now for the loop that will iterate through the time steps needed.

do i = 1, nTimeSteps
  call evolve(ef_type, ef_mass, deltat, N, mass, pos, vel, forces, accel
  time = time + deltat
! Prints out status to file every file_steps
  if (i/file_steps*file_steps == i) then
    call fexport(nunit_outdata, time, N, mass, pos, vel)
  end if
! Prints out status to screen every scrn_steps
  if (i/scrn_steps*scrn_steps == i) then
    write(*,"('time=  ', ES21.13, ' Seconds')") time
  end if
end do

Now just close the file and end the program

  close(nunit_outdata)
  stop
end program runNBody

Source Code

Running it

The data file after running is in "FortranNBody/compile/solar system/outdata.txt" in the source code.
In the next part, we will visualize and compute some pretty cool stuff from that data.