"""Definition of the Simulation class and the Galaxy constructor.""" import os import pickle import numpy as np import matplotlib.pyplot as plt from utils import random_unit_vectors, cascade_round from distributions import PLUMMER, HERNQUIST, UNIFORM, EXP, NFW import acceleration ############################################################################## ############################################################################## class Simulation: """"Main class for the gravitational simulation. Attributes: r_vec (array): position of the particles in the current timestep. Shape: (number of particles, 3) rprev_vec (array): position of the particles in the previous timestep. Shape: (number of particles, 3) v_vec (array): velocity in the current timestep. Shape: (number of particles, 3) a_vec (array): acceleration in the current timestep. Shape: (number of particles, 3) mass (array): mass of each particle in the simulation. Shape: (number of particles,) type (array): non-unique identifier for each particle. Shape: (number of particles,) tracks (array): list of positions through the simulation for central masses. Shape: (tracked particles, n+1, 3). CONFIG (array): configuration used to create the simulation. It will be saved along the state of the simulation. dt (float): timestep of the simulation n (int): current timestep. Initialized as n=0. soft (float): softening length used by the simulation. verbose (boolean): When True progress statements will be printed. """ def __init__(self, dt, soft, verbose, CONFIG, method, **kwargs): """Constructor for the Simulation class. Arguments: dt (float): timestep of the simulation n (int): current timestep. Initialized as n=0. soft (float): softening length used by the simulation. verbose (bool): When True progress statements will be printed. CONFIG (dict): configuration file used to create the simulation. method (string): Optional. Algorithm to use when computing the gravitational forces. One of 'bruteForce', 'bruteForce_numba', 'bruteForce_numbaopt', 'bruteForce_CPP', 'barnesHut_CPP'. """ self.n = 0 self.t = 0 self.dt = dt self.soft = soft self.verbose = verbose self.CONFIG = CONFIG # Initialize empty arrays for all necessary properties self.r_vec = np.empty((0, 3)) self.v_vec = np.empty((0, 3)) self.a_vec = np.empty((0, 3)) self.mass = np.empty((0,)) self.type = np.empty((0, 2)) algorithms = { 'bruteForce': acceleration.bruteForce, 'bruteForceNumba': acceleration.bruteForceNumba, 'bruteForceNumbaOptimized': acceleration.bruteForceNumbaOptimized, 'bruteForceCPP': acceleration.bruteForceCPP, 'barnesHutCPP': acceleration.barnesHutCPP } try: self.acceleration = algorithms[method] except: raise Exception("Method '{}' unknown".format(method)) def add(self, body): """Add a body to the simulation. It must expose the public attributes body.r_vec, body.v_vec, body.a_vec, body.type, body.mass. Arguments: body: Object to be added to the simulation (e.g. a Galaxy object) """ # Extend all relevant attributes by concatenating the body for name in ['r_vec', 'v_vec', 'a_vec', 'type', 'mass']: simattr, bodyattr = getattr(self, name), getattr(body, name) setattr(self, name, np.concatenate([simattr, bodyattr], axis=0)) # Order based on mass order = np.argsort(-self.mass) for name in ['r_vec', 'v_vec', 'a_vec', 'type', 'mass']: setattr(self, name, getattr(self, name)[order]) # Update the list of objects to keep track of self.tracks = np.empty((np.sum(self.type[:,0]=='center'), 0, 3)) def step(self): """Perform a single step of the simulation. Makes use of a 4th order Verlet integrator. """ # Calculate the acceleration self.a_vec = self.acceleration(self.r_vec, self.mass, soft=self.soft) # Update the state using the Verlet algorithm # (A custom algorithm is written mainly for learning purposes) self.r_vec, self.rprev_vec = (2*self.r_vec - self.rprev_vec + self.a_vec * self.dt**2, self.r_vec) self.n += 1 # Update tracks self.tracks = np.concatenate([self.tracks, self.r_vec[self.type[:,0]=='center'][:,np.newaxis]], axis=1) def run(self, tmax, saveEvery, outputFolder, **kwargs): """Run the galactic simulation. Attributes: tmax (float): Time to which the simulation will run to. This is measured here since the start of the simulation, not since pericenter. saveEvery (int): The state is saved every saveEvery steps. outputFolder (string): It will be saved to /data/outputFolder/ """ # When the simulation starts, intialize self.rprev_vec self.rprev_vec = self.r_vec - self.v_vec * self.dt if self.verbose: print('Simulation starting. Bon voyage!') while(self.t < tmax): self.step() if(self.n % saveEvery == 0): self.save('data/{}'.format(outputFolder)) print('Simulation complete.') def save(self, outputFolder): """Save the state of the simulation to the outputFolder. Two files are saved: sim{self.n}.pickle: serializing the state. sim{self.n}.png: a simplified 2D plot of x, y. """ # Create the output folder if it doesn't exist if not os.path.exists(outputFolder): os.makedirs(outputFolder) # Compute some useful quantities # v_vec is not required by the integrator, but useful self.v_vec = (self.r_vec - self.rprev_vec)/self.dt self.t = self.n * self.dt # prevents numerical rounding errors # Serialize state file = open(outputFolder+'/data{}.pickle'.format(self.n), "wb") pickle.dump({'r_vec': self.r_vec, 'v_vec': self.v_vec, 'type': self.type, 'mass': self.mass, 'CONFIG': self.CONFIG, 't': self.t, 'tracks': self.tracks}, file) # Save simplified plot of the current state. # Its main use is to detect ill-behaved situations early on. fig = plt.figure() plt.xlim(-5, 5); plt.ylim(-5, 5); plt.axis('equal') # Dark halo is plotted in red, disk in blue, bulge in green PLTCON = [('dark', 'r', 0.3), ('disk', 'b', 1.0), ('bulge', 'g', 0.5)] for type_, c, a in PLTCON: plt.scatter(self.r_vec[self.type[:,0]==type_][:,0], self.r_vec[self.type[:,0]==type_][:,1], s=0.1, c=c, alpha=a) # Central mass as a magenta star plt.scatter(self.r_vec[self.type[:,0]=='center'][:,0], self.r_vec[self.type[:,0]=='center'][:,1], s=100, marker="*", c='m') # Save to png file fig.savefig(outputFolder+'/sim{}.png'.format(self.n), dpi=150) plt.close(fig) def project(self, theta, phi, view=0): """Projects the 3D simulation onto a plane as viewed from the direction described by the (theta, phi, view). Angles in radians. (This is used by the simulated annealing algorithm) Parameters: theta (float): polar angle. phi (float): azimuthal angle. view (float): rotation along line of sight. """ M1 = np.array([[np.cos(phi), np.sin(phi), 0], [-np.sin(phi), np.cos(phi), 0], [0, 0, 1]]) M2 = np.array([[1, 0, 0], [0, np.cos(theta), np.sin(theta)], [0, -np.sin(theta), np.cos(theta)]]) M3 = np.array([[np.cos(view), np.sin(view), 0], [-np.sin(view), np.cos(view), 0], [0, 0, 1]]) M = np.matmul(M1, np.matmul(M2, M3)) # combine rotations r = np.tensordot(self.r_vec, M, axes=[1, 0]) return r[:,0:2] def setOrbit(self, galaxy1, galaxy2, e, rmin, R0): """Sets the two galaxies galaxy1, galaxy2 in an orbit. Parameters: galaxy1 (Galaxy): 1st galaxy (main) galaxy2 (Galaxy): 2nd galaxy (companion) e: eccentricity of the orbit rmin: distance of closest approach R0: initial separation """ m1, m2 = np.sum(galaxy1.mass), np.sum(galaxy2.mass) # Relevant formulae: # $r_0 = r (1 + e) \cos(\phi)$, where $r_0$ ($\neq R_0$) is the semi-latus rectum # $r_0 = r_\textup{min} (1 + e)$ # $v^2 = G M (2/r - 1/a)$, where a is the semimajor axis # Solve the reduced two-body problem with reduced mass $\mu$ (mu) M = m1 + m2 r0 = rmin * (1 + e) try: phi = np.arccos((r0/R0 - 1) / e) # inverting the orbit equation phi = -np.abs(phi) # Choose negative (incoming) angle ainv = (1 - e) / rmin # ainv = $1/a$, as a may be infinite v = np.sqrt(M * (2/R0 - ainv)) # Finally, calculate the angle of motion. angle = tan(dy/dx) # $dy/dx = ((dr/d\phi) sin(\phi) + r \cos(\phi))/((dr/d\phi) cos(\phi) - r \sin(\phi))$ dy = R0/r0 * e * np.sin(phi)**2 + np.cos(phi) dx = R0/r0 * e * np.sin(phi) * np.cos(phi) - np.sin(phi) vangle = np.arctan2(dy, dx) except: raise Exception('''The orbital parameters cannot be satisfied. For elliptical orbits check that R0 is posible (