1
0

simulation.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. """Definition of the Simulation class and the Galaxy constructor."""
  2. import os
  3. import pickle
  4. import numpy as np
  5. import matplotlib.pyplot as plt
  6. from utils import random_unit_vectors, cascade_round
  7. from distributions import PLUMMER, HERNQUIST, UNIFORM, EXP, NFW
  8. import acceleration
  9. ##############################################################################
  10. ##############################################################################
  11. class Simulation:
  12. """"Main class for the gravitational simulation.
  13. Attributes:
  14. r_vec (array): position of the particles in the current timestep.
  15. Shape: (number of particles, 3)
  16. rprev_vec (array): position of the particles in the previous timestep.
  17. Shape: (number of particles, 3)
  18. v_vec (array): velocity in the current timestep.
  19. Shape: (number of particles, 3)
  20. a_vec (array): acceleration in the current timestep.
  21. Shape: (number of particles, 3)
  22. mass (array): mass of each particle in the simulation.
  23. Shape: (number of particles,)
  24. type (array): non-unique identifier for each particle.
  25. Shape: (number of particles,)
  26. tracks (array): list of positions through the simulation for central
  27. masses. Shape: (tracked particles, n+1, 3).
  28. CONFIG (array): configuration used to create the simulation.
  29. It will be saved along the state of the simulation.
  30. dt (float): timestep of the simulation
  31. n (int): current timestep. Initialized as n=0.
  32. soft (float): softening length used by the simulation.
  33. verbose (boolean): When True progress statements will be printed.
  34. """
  35. def __init__(self, dt, soft, verbose, CONFIG, method, **kwargs):
  36. """Constructor for the Simulation class.
  37. Arguments:
  38. dt (float): timestep of the simulation
  39. n (int): current timestep. Initialized as n=0.
  40. soft (float): softening length used by the simulation.
  41. verbose (bool): When True progress statements will be printed.
  42. CONFIG (dict): configuration file used to create the simulation.
  43. method (string): Optional. Algorithm to use when computing the
  44. gravitational forces. One of 'bruteForce', 'bruteForce_numba',
  45. 'bruteForce_numbaopt', 'bruteForce_CPP', 'barnesHut_CPP'.
  46. """
  47. self.n = 0
  48. self.t = 0
  49. self.dt = dt
  50. self.soft = soft
  51. self.verbose = verbose
  52. self.CONFIG = CONFIG
  53. # Initialize empty arrays for all necessary properties
  54. self.r_vec = np.empty((0, 3))
  55. self.v_vec = np.empty((0, 3))
  56. self.a_vec = np.empty((0, 3))
  57. self.mass = np.empty((0,))
  58. self.type = np.empty((0, 2))
  59. algorithms = {
  60. 'bruteForce': acceleration.bruteForce,
  61. 'bruteForceNumba': acceleration.bruteForceNumba,
  62. 'bruteForceNumbaOptimized': acceleration.bruteForceNumbaOptimized,
  63. 'bruteForceCPP': acceleration.bruteForceCPP,
  64. 'barnesHutCPP': acceleration.barnesHutCPP
  65. }
  66. try:
  67. self.acceleration = algorithms[method]
  68. except: raise Exception("Method '{}' unknown".format(method))
  69. def add(self, body):
  70. """Add a body to the simulation. It must expose the public attributes
  71. body.r_vec, body.v_vec, body.a_vec, body.type, body.mass.
  72. Arguments:
  73. body: Object to be added to the simulation (e.g. a Galaxy object)
  74. """
  75. # Extend all relevant attributes by concatenating the body
  76. for name in ['r_vec', 'v_vec', 'a_vec', 'type', 'mass']:
  77. simattr, bodyattr = getattr(self, name), getattr(body, name)
  78. setattr(self, name, np.concatenate([simattr, bodyattr], axis=0))
  79. # Order based on mass
  80. order = np.argsort(-self.mass)
  81. for name in ['r_vec', 'v_vec', 'a_vec', 'type', 'mass']:
  82. setattr(self, name, getattr(self, name)[order])
  83. # Update the list of objects to keep track of
  84. self.tracks = np.empty((np.sum(self.type[:,0]=='center'), 0, 3))
  85. def step(self):
  86. """Perform a single step of the simulation.
  87. Makes use of a 4th order Verlet integrator.
  88. """
  89. # Calculate the acceleration
  90. self.a_vec = self.acceleration(self.r_vec, self.mass, soft=self.soft)
  91. # Update the state using the Verlet algorithm
  92. # (A custom algorithm is written mainly for learning purposes)
  93. self.r_vec, self.rprev_vec = (2*self.r_vec - self.rprev_vec
  94. + self.a_vec * self.dt**2, self.r_vec)
  95. self.n += 1
  96. # Update tracks
  97. self.tracks = np.concatenate([self.tracks,
  98. self.r_vec[self.type[:,0]=='center'][:,np.newaxis]], axis=1)
  99. def run(self, tmax, saveEvery, outputFolder, **kwargs):
  100. """Run the galactic simulation.
  101. Attributes:
  102. tmax (float): Time to which the simulation will run to.
  103. This is measured here since the start of the simulation,
  104. not since pericenter.
  105. saveEvery (int): The state is saved every saveEvery steps.
  106. outputFolder (string): It will be saved to /data/outputFolder/
  107. """
  108. # When the simulation starts, intialize self.rprev_vec
  109. self.rprev_vec = self.r_vec - self.v_vec * self.dt
  110. if self.verbose: print('Simulation starting. Bon voyage!')
  111. while(self.t < tmax):
  112. self.step()
  113. if(self.n % saveEvery == 0):
  114. self.save('data/{}'.format(outputFolder))
  115. print('Simulation complete.')
  116. def save(self, outputFolder):
  117. """Save the state of the simulation to the outputFolder.
  118. Two files are saved:
  119. sim{self.n}.pickle: serializing the state.
  120. sim{self.n}.png: a simplified 2D plot of x, y.
  121. """
  122. # Create the output folder if it doesn't exist
  123. if not os.path.exists(outputFolder): os.makedirs(outputFolder)
  124. # Compute some useful quantities
  125. # v_vec is not required by the integrator, but useful
  126. self.v_vec = (self.r_vec - self.rprev_vec)/self.dt
  127. self.t = self.n * self.dt # prevents numerical rounding errors
  128. # Serialize state
  129. file = open(outputFolder+'/data{}.pickle'.format(self.n), "wb")
  130. pickle.dump({'r_vec': self.r_vec, 'v_vec': self.v_vec,
  131. 'type': self.type, 'mass': self.mass,
  132. 'CONFIG': self.CONFIG, 't': self.t,
  133. 'tracks': self.tracks}, file)
  134. # Save simplified plot of the current state.
  135. # Its main use is to detect ill-behaved situations early on.
  136. fig = plt.figure()
  137. plt.xlim(-5, 5); plt.ylim(-5, 5); plt.axis('equal')
  138. # Dark halo is plotted in red, disk in blue, bulge in green
  139. PLTCON = [('dark', 'r', 0.3), ('disk', 'b', 1.0), ('bulge', 'g', 0.5)]
  140. for type_, c, a in PLTCON:
  141. plt.scatter(self.r_vec[self.type[:,0]==type_][:,0],
  142. self.r_vec[self.type[:,0]==type_][:,1], s=0.1, c=c, alpha=a)
  143. # Central mass as a magenta star
  144. plt.scatter(self.r_vec[self.type[:,0]=='center'][:,0],
  145. self.r_vec[self.type[:,0]=='center'][:,1], s=100, marker="*", c='m')
  146. # Save to png file
  147. fig.savefig(outputFolder+'/sim{}.png'.format(self.n), dpi=150)
  148. plt.close(fig)
  149. def project(self, theta, phi, view=0):
  150. """Projects the 3D simulation onto a plane as viewed from the
  151. direction described by the (theta, phi, view). Angles in radians.
  152. (This is used by the simulated annealing algorithm)
  153. Parameters:
  154. theta (float): polar angle.
  155. phi (float): azimuthal angle.
  156. view (float): rotation along line of sight.
  157. """
  158. M1 = np.array([[np.cos(phi), np.sin(phi), 0],
  159. [-np.sin(phi), np.cos(phi), 0],
  160. [0, 0, 1]])
  161. M2 = np.array([[1, 0, 0],
  162. [0, np.cos(theta), np.sin(theta)],
  163. [0, -np.sin(theta), np.cos(theta)]])
  164. M3 = np.array([[np.cos(view), np.sin(view), 0],
  165. [-np.sin(view), np.cos(view), 0],
  166. [0, 0, 1]])
  167. M = np.matmul(M1, np.matmul(M2, M3)) # combine rotations
  168. r = np.tensordot(self.r_vec, M, axes=[1, 0])
  169. return r[:,0:2]
  170. def setOrbit(self, galaxy1, galaxy2, e, rmin, R0):
  171. """Sets the two galaxies galaxy1, galaxy2 in an orbit.
  172. Parameters:
  173. galaxy1 (Galaxy): 1st galaxy (main)
  174. galaxy2 (Galaxy): 2nd galaxy (companion)
  175. e: eccentricity of the orbit
  176. rmin: distance of closest approach
  177. R0: initial separation
  178. """
  179. m1, m2 = np.sum(galaxy1.mass), np.sum(galaxy2.mass)
  180. # Relevant formulae:
  181. # $r_0 = r (1 + e) \cos(\phi)$, where $r_0$ ($\neq R_0$) is the semi-latus rectum
  182. # $r_0 = r_\textup{min} (1 + e)$
  183. # $v^2 = G M (2/r - 1/a)$, where a is the semimajor axis
  184. # Solve the reduced two-body problem with reduced mass $\mu$ (mu)
  185. M = m1 + m2
  186. r0 = rmin * (1 + e)
  187. try:
  188. phi = np.arccos((r0/R0 - 1) / e) # inverting the orbit equation
  189. phi = -np.abs(phi) # Choose negative (incoming) angle
  190. ainv = (1 - e) / rmin # ainv = $1/a$, as a may be infinite
  191. v = np.sqrt(M * (2/R0 - ainv))
  192. # Finally, calculate the angle of motion. angle = tan(dy/dx)
  193. # $dy/dx = ((dr/d\phi) sin(\phi) + r \cos(\phi))/((dr/d\phi) cos(\phi) - r \sin(\phi))$
  194. dy = R0/r0 * e * np.sin(phi)**2 + np.cos(phi)
  195. dx = R0/r0 * e * np.sin(phi) * np.cos(phi) - np.sin(phi)
  196. vangle = np.arctan2(dy, dx)
  197. except: raise Exception('''The orbital parameters cannot be satisfied.
  198. For elliptical orbits check that R0 is posible (<rmax).''')
  199. # We now need the actual motion of each body
  200. R_vec = np.array([[R0*np.cos(phi), R0*np.sin(phi), 0.]])
  201. V_vec = np.array([[v*np.cos(vangle), v*np.sin(vangle), 0.]])
  202. galaxy1.r_vec += m2/M * R_vec
  203. galaxy1.v_vec += m2/M * V_vec
  204. galaxy2.r_vec += -m1/M * R_vec
  205. galaxy2.v_vec += -m1/M * V_vec
  206. # Explicitely add the galaxies to the simulation
  207. self.add(galaxy1)
  208. self.add(galaxy2)
  209. if self.verbose: print('Galaxies set in orbit.')
  210. ##############################################################################
  211. ##############################################################################
  212. class Galaxy():
  213. """"Helper class for creating galaxies.
  214. Attributes:
  215. r_vec (array): position of the particles in the current timestep.
  216. Shape: (number of particles, 3)
  217. v_vec (array): velocity in the current timestep.
  218. Shape: (number of particles, 3)
  219. a_vec (array): acceleration in the current timestep.
  220. Shape: (number of particles, 3)
  221. mass (array): mass of each particle in the simulation.
  222. Shape: (number of particles,)
  223. type (array): non-unique identifier for each particle.
  224. Shape: (number of particles,) """
  225. def __init__(self, orientation, centralMass, bulge, disk, halo, sim):
  226. """Constructor for the Galaxy class.
  227. Parameters:
  228. orientation (tupple): (inclination i, argument of pericenter w)
  229. centralMass (float): mass at the center of the galaxy
  230. bulge (dict): passed to the addBulge method.
  231. disk (dict): passed to the addDisk method.
  232. halo (dict): passed to the addHalo method.
  233. sim (Simulation): simulation object
  234. """
  235. if sim.verbose: print('Initializing galaxy')
  236. # Build the central mass
  237. self.r_vec = np.zeros((1, 3))
  238. self.v_vec = np.zeros((1, 3))
  239. self.a_vec = np.zeros((1, 3))
  240. self.mass = np.array([centralMass])
  241. self.type = np.array([['center', 0]])
  242. # Build the other components
  243. self.addBulge(**bulge)
  244. if sim.verbose: print('Bulge created.')
  245. self.addDisk(**disk)
  246. if sim.verbose: print('Disk created.')
  247. self.addHalo(**halo)
  248. if sim.verbose: print('Halo created.')
  249. # Correct particles velocities to generate circular orbits
  250. # $a_\textup{centripetal} = v^2/r$
  251. a_vec = sim.acceleration(self.r_vec, self.mass, soft=sim.soft)
  252. a = np.linalg.norm(a_vec, axis=-1, keepdims=True)
  253. r = np.linalg.norm(self.r_vec, axis=-1, keepdims=True)
  254. v = np.linalg.norm(self.v_vec[1:], axis=-1, keepdims=True)
  255. direction_unit = self.v_vec[1:]/v
  256. # Set orbital velocities (except for central mass)
  257. self.v_vec[1:] = np.sqrt(a[1:] * r[1:]) * direction_unit
  258. self.a_vec = np.zeros_like(self.r_vec)
  259. # Rotate the galaxy into its correct orientation
  260. self.rotate(*(np.array(orientation)/360 * 2*np.pi))
  261. if sim.verbose: print('Galaxy set in rotation and oriented.')
  262. def addBulge(self, model, totalMass, particles, l):
  263. """Adds a bulge to the galaxy.
  264. Parameters:
  265. model (string): parametrization of the bulge.
  266. 'plummer' and 'hernquist' are supported.
  267. totalMass (float): total mass of the bulge
  268. particles (int): number of particles in the bulge
  269. l (float): characteristic length scale of the model.
  270. """
  271. if particles == 0: return None
  272. # Divide the mass equally among all particles
  273. mass = np.ones(particles) * totalMass/particles
  274. self.mass = np.concatenate([self.mass, mass], axis=0)
  275. # Create particles according to the radial distribution from model
  276. if model == 'plummer':
  277. r = PLUMMER.ppf(np.random.rand(particles), scale=l)
  278. elif model == 'hernquist':
  279. r = HERNQUIST.ppf(np.random.rand(particles), scale=l)
  280. else: raise Exception("""Bulge distribution not allowed.
  281. 'plummer' and 'hernquist' are the supported values""")
  282. r_vec = r[:,np.newaxis] * random_unit_vectors(size=particles)
  283. self.r_vec = np.concatenate([self.r_vec, r_vec], axis=0)
  284. # Set them orbitting along random directions normal to r_vec
  285. v_vec = np.cross(r_vec, random_unit_vectors(size=particles))
  286. self.v_vec = np.concatenate([self.v_vec, v_vec], axis=0)
  287. # Label the particles
  288. type_ = [['bulge', 0]]*particles
  289. self.type = np.concatenate([self.type, type_], axis=0)
  290. def addDisk(self, model, totalMass, particles, l):
  291. """Adds a disk to the galaxy.
  292. Parameters:
  293. model (string): parametrization of the disk.
  294. 'rings', 'uniform' and 'exp' are supported.
  295. totalMass (float): total mass of the bulge
  296. particles (int): number of particles in the bulge
  297. l: fot 'exp' and 'uniform' characteristic length of the
  298. model. For 'rings' tupple of the form (inner radius,
  299. outer radius, number of rings)
  300. """
  301. if particles == 0: return None
  302. # Create particles according to the radial distribution from model
  303. if model == 'uniform':
  304. r = UNIFORM.ppf(np.random.rand(particles), scale=l)
  305. type_ = [['disk', 0]]*particles
  306. r_vec = r[:,np.newaxis] * random_unit_vectors(particles, '2D')
  307. self.type = np.concatenate([self.type, type_], axis=0)
  308. elif model == 'rings':
  309. # l = [inner radius, outter radius, number of rings]
  310. distances = np.linspace(*l)
  311. # Aim for roughly constant areal density
  312. # Cascade rounding preserves the total number of particles
  313. perRing = cascade_round(particles * distances / np.sum(distances))
  314. particles = int(np.sum(perRing)) # prevents numerical errors
  315. r_vec = np.empty((0, 3))
  316. for d, n, i in zip(distances, perRing, range(l[2])):
  317. type_ = [['disk', i+1]]*int(n)
  318. self.type = np.concatenate([self.type, type_], axis=0)
  319. phi = np.linspace(0, 2 * np.pi, n, endpoint=False)
  320. ringr = d * np.array([[np.cos(i), np.sin(i), 0] for i in phi])
  321. r_vec = np.concatenate([r_vec, ringr], axis=0)
  322. elif model == 'exp':
  323. r = EXP.ppf(np.random.rand(particles), scale=l)
  324. r_vec = r[:,np.newaxis] * random_unit_vectors(particles, '2D')
  325. type_ = [['disk', 0]]*particles
  326. self.type = np.concatenate([self.type, type_], axis=0)
  327. else:
  328. raise Exception("""Disk distribution not allowed.
  329. 'uniform', 'rings' and 'exp' are the supported values""")
  330. self.r_vec = np.concatenate([self.r_vec, r_vec], axis=0)
  331. # Divide the mass equally among all particles
  332. mass = np.ones(particles) * totalMass/particles
  333. self.mass = np.concatenate([self.mass, mass], axis=0)
  334. # Set them orbitting along the spin plane
  335. v_vec = np.cross(r_vec, [0, 0, 1])
  336. self.v_vec = np.concatenate([self.v_vec, v_vec], axis=0)
  337. def addHalo(self, model, totalMass, particles, rs):
  338. """Adds a halo to the galaxy.
  339. Parameters:
  340. model (string): parametrization of the halo.
  341. Only 'NFW' is supported.
  342. totalMass (float): total mass of the halo
  343. particles (int): number of particles in the halo
  344. rs (float): characteristic length scale of the NFW profile.
  345. """
  346. if particles == 0: return None
  347. # Divide the mass equally among all particles
  348. mass = np.ones(particles)*totalMass/particles
  349. self.mass = np.concatenate([self.mass, mass], axis=0)
  350. # Create particles according to the radial distribution from model
  351. if model == 'NFW':
  352. r = NFW.ppf(np.random.rand(particles), scale=rs)
  353. else: raise Exception("""Bulge distribution not allowed.
  354. 'plummer' and 'hernquist' are the supported values""")
  355. r_vec = r[:,np.newaxis] * random_unit_vectors(size=particles)
  356. self.r_vec = np.concatenate([self.r_vec, r_vec], axis=0)
  357. # Orbit along random directions normal to the radial vector
  358. v_vec = np.cross(r_vec, random_unit_vectors(size=particles))
  359. self.v_vec = np.concatenate([self.v_vec, v_vec], axis=0)
  360. # Label the particles
  361. type_ = [['dark'], 0]*particles
  362. self.type = np.concatenate([self.type, type_], axis=0)
  363. def rotate(self, theta, phi):
  364. """Rotates the galaxy so that its spin is along the (theta, phi)
  365. direction.
  366. Parameters:
  367. theta (float): polar angle.
  368. phi (float): azimuthal angle.
  369. """
  370. M1 = np.array([[1, 0, 0],
  371. [0, np.cos(theta), np.sin(theta)],
  372. [0, -np.sin(theta), np.cos(theta)]])
  373. M2 = np.array([[np.cos(phi), np.sin(phi), 0],
  374. [-np.sin(phi), np.cos(phi), 0],
  375. [0, 0, 1]])
  376. M = np.matmul(M1, M2) # combine rotations
  377. self.r_vec = np.tensordot(self.r_vec, M, axes=[1, 0])
  378. self.v_vec = np.tensordot(self.v_vec, M, axes=[1, 0])