There are 3 steps to a correctly looking plot.
First and most important, get the implementation of the physical model right. r3
is supposed to contain the third powers of the distances, thus the third power of the square root has exponent 1.5
r3 = np.nan_to_num(np.sum(np.abs(dr)**2, axis=-1)**(1.5)).reshape((pos.shape[0],pos.shape[0],1))
This then give the cleaned up plot
Note the differences in the scales, one would have to horizontally compress the image to get the same scale in both directions.
Second, this means that the initial velocity is too large, the stars flee from each other. These velocities might be right in the position where the stars are closest together. As a quick fix, divide the velocities by 10. This gives the plot
Better initial values could be obtained by evaluating and transforming the supposedly more realistic data from https://towardsdatascience.com/modelling-the-three-body-problem-in-classical-mechanics-using-python-9dc270ad7767 or use the Kepler laws with the more general data from http://www.solstation.com/orbits/ac-absys.htm
Third, the mass center is not at the origin and has a non-zero velocity. Normalize the initial values for that
# center of mass
com_p = np.sum(np.multiply(mass, pos),axis=0) / np.sum(mass,axis=0)
com_v = np.sum(np.multiply(mass, vel),axis=0) / np.sum(mass,axis=0)
for p in pos: p -= com_p
for v in vel: v -= com_v
(or apply suitable broadcasting magic instead of the last two lines) to get the plot that you were probably expecting.
That the orbits spiral outwards is typical for the Euler method, as the individual steps move along the tangents to the convex ellipses of the exact solution.
The same only using RK4 with 5-day time steps gives prefect looking ellipses
For the RK4 implementation the most important step is to package the non-trivial derivatives computation into a separate sub-procedure
def run_sim(bodies, t, dt, method = "RK4"):
...
def acc(pos):
dr = np.nan_to_num(pos[None,:] - pos[:,None])
r3 = np.nan_to_num(np.sum(np.abs(dr)**2, axis=-1)**(1.5)).reshape((pos.shape[0],pos.shape[0],1))
return G * np.sum((np.nan_to_num(np.divide(dr, r3)) * np.tile(mass,(pos.shape[0],1)).reshape(pos.shape[0],pos.shape[0],1)), axis=1)
Then one can take the Euler step out of the time loop
def Euler_step(pos, vel, dt):
a = acc(pos);
return pos+vel*dt, vel+a*dt
and similarly implement the RK4 step
def RK4_step(pos, vel, dt):
v1 = vel
a1 = acc(pos)
v2 = vel + a1*0.5*dt
a2 = acc(pos+v1*0.5*dt)
v3 = vel + a2*0.5*dt
a3 = acc(pos+v2*0.5*dt)
v4 = vel + a3*dt
a4 = acc(pos+v3*dt)
return pos+(v1+2*v2+2*v3+v4)/6*dt, vel+(a1+2*a2+2*a3+a4)/6*dt
Select the method like
stepper = RK4_step if method == "RK4" else Euler_step
and then the time loop takes the generic form
N = floor(t/dt)
...
for i in range(1,N+1):
pos, vel = stepper(pos, vel, dt)
plt_pos[i] = pos
plt_vel[i] = vel