aboutsummaryrefslogtreecommitdiff
#!/usr/bin/env python3

# pykepler: Numerically solve the restricted 3-body problem and animate the
# movement of the bodies
#
# Copyright (C) 2025 Thomas Guillermo Albers Raviola
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from functools import partial

import numpy as np
from scipy.integrate import solve_ivp

import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib import rcParams
from matplotlib.animation import FuncAnimation

def unpack_r(rv):
    return rv[0:2], rv[2:4], rv[4:6]

def unpack_v(rv):
    return rv[6:8], rv[8:10], rv[10:12]

def distance(x1, x2):
    return np.sqrt(np.sum((x1 - x2)**2))

def grav_force(r1, r2):
    d = distance(r1, r2)
    return (r2 - r1) / d**3

#
# Initial value problem has form
# x' = F(x, t)
#
# Set x = [r, v], where r is position of body and v = r'
# Thus, x' = [r', v'] = [v, a] = [v, -G*M*m/abs(d)**2 * e]
#

def func(t, rv, mu):
    # Unpack components
    r1, r2, r3 = unpack_r(rv)
    v1, v2, v3 = unpack_v(rv)

    f12 = grav_force(r1, r2)
    f13 = grav_force(r1, r3)
    f23 = grav_force(r2, r3)

    return np.concatenate((v1, v2, v3,
                           mu[1] * f12 + mu[2] * f13,
                           -mu[0] * f12 + mu[2] * f23,
                           -mu[1] * f23 - mu[0] * f13))


def angle(x1, x2):
    ux1 = x1 / np.linalg.norm(x1)
    ux2 = x2 / np.linalg.norm(x2)
    return 180.0 * np.arccos(np.clip(np.dot(ux1, ux2), -1.0, 1.0)) / np.pi


def update(sol, text_artist, tri_artist, pnt_artists, pos_artists, frame):
    '''Update plot of the IVP up to the current timestamp'''

    if frame > 0:
        r1, r2, r3 = unpack_r(sol.y)

        r1 = r1[:, frame-1]
        r2 = r2[:, frame-1]
        r3 = r3[:, frame-1]

        tri_artist.set_data([r1[0], r2[0], r3[0], r1[0]],
                            [r1[1], r2[1], r3[1], r1[1]])

        alpha = angle(r2 - r1, r3 - r1)
        beta = angle(r1 - r2, r3 - r2)
        gamma = angle(r1 - r3, r2 - r3)

        a = distance(r2, r3)
        b = distance(r3, r1)
        c = distance(r2, r1)

        fmt = 'α:{:.2f}, β:{:.2f}, γ:{:.2f}\na: {:.2f}, b:{:.2f}, c:{:.2f}'
        text_artist.set_text(fmt.format(alpha, beta, gamma, a, b, c))


    for artist, r in zip(pos_artists, unpack_r(sol.y)):
        artist.set_data(r[0, 0:frame], r[1, 0:frame])

    for artist, r in zip(pnt_artists, unpack_r(sol.y)):
        artist.set_data([r[0, frame-1]], [r[1, frame-1]])

    return [text_artist, tri_artist] + pos_artists + pnt_artists


def animate_ivp(sol, fps=20):
    def progress(current_frame, total_frames):
        str = '\r  Rendering frame [{}/{}] ...'
        print(str.format(current_frame + 1, total_frames), end='', flush=True)

    fig, ax = plt.subplots(figsize=(10,10), dpi=300)
    ax.set_xlabel('x')
    ax.set_ylabel('y')
    ax.set_xlim(-1, 1)
    ax.set_ylim(-1, 1)

    pos_artists = []
    pnt_artists = []

    tri_artist, = ax.plot([], [], ls='--', color='black')
    for i in range(3):
        pos_artists.append(ax.plot([], [])[0])
        pnt_artists.append(ax.plot([], [], '.', label='m{}'.format(i + 1))[0])

    text_artist = ax.text(0.05, 0.95, '',
                          backgroundcolor='#7F7F7F33',
                          transform=ax.transAxes,
                          verticalalignment='top') # bbox=props

    ax.legend()

    frames = np.arange(0, len(sol.t))
    animation = FuncAnimation(fig,
                              partial(update, sol,
                                      text_artist, tri_artist,
                                      pnt_artists, pos_artists),
                              frames=frames,
                              interval=sol.t[-1], blit=True, repeat=False)

    animation.save('three-body.mp4', fps=fps, progress_callback=progress)
    # Reserve newline for progress
    print('')


def main():
    mu1 = .7
    mu2 = 1 - mu1
    mu = np.array([mu1, mu2, 1e-12])
    omega = 1

    ir1 = mu[1] * np.array([-1, 0])
    ir2 = mu[0] * np.array([1, 0])
    # Trojan satelite at L4 (ahead)
    ir3 = np.array([.5 - mu2, np.sqrt(3) / 2])

    iv1 = mu[1] * np.array([0, -1])
    iv2 = mu[0] * np.array([0, 1])
    # yz - zy, zx - xz
    iv3 = np.array([- ir3[1], ir3[0]])

    irv = np.concatenate((ir1, ir2, ir3, iv1, iv2, iv3))

    t_span = (0.0, 4)
    t_eval = np.linspace(t_span[0], t_span[1], 1024)

    sol = solve_ivp(func, t_span, irv,
                    t_eval=t_eval, method='DOP853',
                    rtol=1e-10, atol=1e-20,
                    args=(mu,))

    animate_ivp(sol)


if __name__ == '__main__':
    main()