cleanup of t1, tried integration in t2, units

This commit is contained in:
Remy Moll 2025-01-10 17:44:02 +01:00
parent 77a4959fe2
commit 9e856bc854
16 changed files with 1388 additions and 1292 deletions

3
.gitignore vendored

@ -1 +1,2 @@
*.pyc
*.pyc
.cache/

@ -9,7 +9,7 @@ ipython = "*"
jupyter = "*"
matplotlib = "*"
scipy = "*"
spacepy = "*"
astropy = "*"
[dev-packages]

1644
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

@ -8,7 +8,21 @@
- [x] compare with the analytical expectation from Newtons 2nd law
- [ ] compute the relaxation time
### Task 2
### Task 2 (particle mesh)
- [ ] Choose reasonable units
- [ ] Implement force computation on mesh
- [ ] Find optimal mesh size
- [ ] Compare with direct nbody simulation
- [ ] Time integration for direct method AND mesh method
### Task 2 (tree code)
- [ ] Implement force computation with multipole expansion
- [ ] Find optimal grouping criterion
- [ ] Compare with direct nbody simulation
- [ ] Time integration for direct method AND tree method
@ -16,4 +30,8 @@
### Questions
- Procedure for each time step of a mesh simulation? Potential on mesh -> forces on particles -> update particle positions -> new mesh potential? or skip the creation of particles in each time step?
- Procedure for each time step of a mesh simulation? Potential on mesh -> forces on particles -> update particle positions -> new mesh potential? or skip the creation of particles in each time step?
- How to represent the time evolution of the system?
- plot total energy vs time
- plot particle positions?
- What is the parameter a of the Hernquist model?

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,9 +1,9 @@
## Import all functions in all the files in the current directory
# Basic helpers for interacting with the data
from .load import *
from .mesh import *
from .model import *
from .particles import *
from .units import *
# Helpers for computing the forces
from .forces_basic import *

@ -16,7 +16,7 @@ def n_body_forces(particles: np.ndarray, G: float, softening: float = 0):
n = particles.shape[0]
forces = np.zeros((n, 3))
logger.debug(f"Computing forces for {n} particles using n^2 algorithm (using {softening=})")
logger.debug(f"Computing forces for {n} particles using n^2 algorithm (using {softening=:.2g})")
for i in range(n):
# the current particle is at x_current

@ -56,19 +56,22 @@ def mesh_forces_v2(particles: np.ndarray, G: float, n_grid: int, mapping: callab
if particles.shape[1] != 4:
raise ValueError("Particles array must have 4 columns: x, y, z, m")
logger.debug(f"Computing forces for {particles.shape[0]} particles using mesh (using mapping={mapping.__name__})")
logger.debug(f"Computing forces for {particles.shape[0]} particles using mesh [mapping={mapping.__name__}, {n_grid=}]")
mesh, axis = to_mesh(particles, n_grid, mapping)
show_mesh_information(mesh, "Mesh")
if logger.level >= logging.DEBUG:
show_mesh_information(mesh, "Density mesh")
spacing = axis[1] - axis[0]
logger.debug(f"Using mesh spacing: {spacing}")
phi = mesh_poisson_v2(mesh, G)
logger.debug(f"Got phi with: {phi.shape}, {np.max(phi)}")
phi_grad = np.stack(np.gradient(phi, spacing), axis=0)
show_mesh_information(phi, "Mesh potential")
show_mesh_information(phi_grad[0], "Mesh potential grad x")
if logger.level >= logging.DEBUG:
show_mesh_information(phi, "Potential mesh")
show_mesh_information(phi_grad[0], "Potential gradient")
logger.debug(f"Got phi_grad with: {phi_grad.shape}, {np.max(phi_grad)}")
forces = np.zeros_like(particles[:, :3])
for i, p in enumerate(particles):
ijk = np.digitize(p, axis) - 1
@ -93,7 +96,7 @@ def mesh_poisson_v2(mesh: np.ndarray, G: float) -> np.ndarray:
return phi
## Helper functions for star mapping
#### Helper functions for star mapping
def to_mesh(particles: np.ndarray, n_grid: int, mapping: callable) -> tuple[np.ndarray, np.ndarray]:
"""
Maps a list of particles to a of mesh of size n_grid x n_grid x n_grid.
@ -108,7 +111,7 @@ def to_mesh(particles: np.ndarray, n_grid: int, mapping: callable) -> tuple[np.n
for p in particles:
m = p[-1]
if logger.level <= logging.DEBUG and m <= 0:
if logger.level >= logging.DEBUG and m <= 0:
logger.warning(f"Particle with negative mass: {p}")
# spread the star onto cells through the shape function, taking into account the mass
ijks, weights = mapping(p, axis)
@ -153,20 +156,20 @@ def particle_to_cells_cic(particle, axis, width):
## Helper functions for mesh plotting
#### Helper functions for mesh plotting
def show_mesh_information(mesh: np.ndarray, name: str):
print(f"Mesh information for {name}")
print(f"Total mapped mass: {np.sum(mesh):.0f}")
print(f"Max cell value: {np.max(mesh)}")
print(f"Min cell value: {np.min(mesh)}")
print(f"Mean cell value: {np.mean(mesh)}")
logger.info(f"Mesh information for {name}")
logger.info(f"Total mapped mass: {np.sum(mesh):.0f}")
logger.info(f"Max cell value: {np.max(mesh)}")
logger.info(f"Min cell value: {np.min(mesh)}")
logger.info(f"Mean cell value: {np.mean(mesh)}")
plot_3d(mesh, name)
plot_2d(mesh, name)
def plot_3d(mesh: np.ndarray, name: str):
fig = plt.figure()
fig.suptitle(name)
fig.suptitle(f"{name} - {mesh.shape}")
ax = fig.add_subplot(111, projection='3d')
ax.scatter(*np.where(mesh), c=mesh[np.where(mesh)], cmap='viridis')
plt.show()
@ -174,7 +177,7 @@ def plot_3d(mesh: np.ndarray, name: str):
def plot_2d(mesh: np.ndarray, name: str):
fig = plt.figure()
fig.suptitle(name)
fig.suptitle(f"{name} - {mesh.shape}")
axs = fig.subplots(1, 3)
axs[0].imshow(np.sum(mesh, axis=0))
axs[0].set_title("Flattened in x")
@ -182,4 +185,4 @@ def plot_2d(mesh: np.ndarray, name: str):
axs[1].set_title("Flattened in y")
axs[2].imshow(np.sum(mesh, axis=2))
axs[2].set_title("Flattened in z")
plt.show()
plt.show()

@ -16,54 +16,53 @@ def ode_setup(particles: np.ndarray, force_function: callable) -> tuple[np.ndarr
if particles.shape[1] != 7:
raise ValueError("Particles array must have 7 columns: x, y, z, vx, vy, vz, m")
n = particles.shape[0]
# for scipy integrators we need to flatten the n 3D positions and n 3D velocities
y0 = np.zeros(6*n)
y0[:3*n] = particles[:, :3].flatten()
y0[3*n:] = particles[:, 3:6].flatten()
# the masses don't change we can define them once
masses = particles[:, 6]
logger.debug(f"Reshaped {particles.shape} to y0 with {y0.shape} and masses with {masses.shape}")
# for scipy integrators we need to flatten array which contains 7 columns for now
# we don't really care how we reshape as long as we unflatten consistently afterwards
particles = particles.reshape(-1, copy=False, order='A')
# this is consistent with the unflattening in to_particles()!
logger.debug(f"Reshaped 7 columns into {particles.shape=}")
def f(y, t):
"""
Computes the right hand side of the ODE system.
The ODE system is linearized around the current positions and velocities.
"""
n = y.size // 6
logger.debug(f"y with shape {y.shape}")
# unsqueeze and unstack to extract the positions and velocities
y = y.reshape((2*n, 3))
x = y[:n, ...]
v = y[n:, ...]
logger.debug(f"Unstacked y into x with shape {x.shape} and v with shape {v.shape}")
y = to_particles(y)
# now y has shape (n, 7), with columns x, y, z, vx, vy, vz, m
# compute the forces
x_with_m = np.zeros((n, 4))
x_with_m[:, :3] = x
x_with_m[:, 3] = masses
forces = force_function(x_with_m)
forces = force_function(y[:, [0, 1, 2, -1]])
# compute the accelerations
masses = y[:, -1]
a = forces / masses[:, None]
a.flatten()
# the [:, None] is to force broadcasting in order to divide each row of forces by the corresponding mass
# a.flatten()
# reshape into a 1D array
return np.vstack((v, a)).flatten()
return y0, f
# replace some values in y:
# the position columns become the velocities
# the velocity columns become the accelerations
y[:, 0:3] = y[:, 3:6]
y[:, 3:6] = a
# the masses remain unchanged
# flatten the array again
y = y.reshape(-1, copy=False, order='A')
return y
return particles, f
def to_particles(y: np.ndarray) -> np.ndarray:
"""
Converts the 1D array y into a 2D array with the shape (n, 6) where n is the number of particles.
The columns are x, y, z, vx, vy, vz
Converts the 1D array y into a 2D array IN PLACE
The new shape is (n, 7) where n is the number of particles.
The columns are x, y, z, vx, vy, vz, m
"""
n = y.size // 6
y = y.reshape((2*n, 3))
x = y[:n, ...]
v = y[n:, ...]
return np.hstack((x, v))
if y.size % 7 != 0:
raise ValueError("The array y should be inflatable to 7 columns")
n = y.size // 7
y = y.reshape((n, 7), copy=False, order='F')
logger.debug(f"Unflattened array into {y.shape=}")
return y

@ -3,7 +3,7 @@ import logging
logging.basicConfig(
## set logging level
# level=logging.INFO,
level=logging.DEBUG,
level=logging.INFO,
format='%(asctime)s - %(name)s - %(message)s',
datefmt='%H:%M:%S'
)
@ -13,3 +13,4 @@ logging.basicConfig(
logging.getLogger('matplotlib.font_manager').setLevel(logging.WARNING)
logging.getLogger('matplotlib.ticker').setLevel(logging.WARNING)
logging.getLogger('matplotlib.pyplot').setLevel(logging.WARNING)
logging.getLogger('matplotlib.colorbar').setLevel(logging.WARNING)

@ -1,12 +1,14 @@
import numpy as np
M = 5
a = 5
def model_density_distribution(r_bins: np.ndarray):
def model_density_distribution(r_bins: np.ndarray, M: float = 5, a: float = 5) -> np.ndarray:
"""
Generate a density distribution for a spherical galaxy model, as per the Hernquist model.
Parameters:
- r_bins: The radial bins to calculate the density distribution for.
- M: The total mass of the galaxy.
- a: The scale radius of the galaxy.
See https://doi.org/10.1086%2F168845 for more information.
"""
rho = M / (2 * np.pi) * a / (r_bins * (r_bins + a)**3)
return rho
return rho

@ -1,5 +1,6 @@
import numpy as np
import logging
from . import forces_basic
logger = logging.getLogger(__name__)
@ -7,20 +8,34 @@ def density_distribution(r_bins: np.ndarray, particles: np.ndarray, ret_error: b
"""
Computes the radial density distribution of a set of particles.
Assumes that the particles array has the following columns: x, y, z, m.
If ret_error is True, it will return the absolute error of the density.
"""
if particles.shape[1] != 4:
raise ValueError("Particles array must have 4 columns: x, y, z, m")
m = particles[:, 3]
r = np.linalg.norm(particles[:, :3], axis=1)
density = [np.sum(m[(r >= r_bins[i]) & (r < r_bins[i + 1])]) for i in range(len(r_bins) - 1)]
m_shells = np.zeros_like(r_bins)
v_shells = np.zeros_like(r_bins)
error_relative = np.zeros_like(r_bins)
r_bins = np.insert(r_bins, 0, 0)
for i in range(len(r_bins) - 1):
mask = (r >= r_bins[i]) & (r < r_bins[i + 1])
m_shells[i] = np.sum(m[mask])
v_shells[i] = 4/3 * np.pi * (r_bins[i + 1]**3 - r_bins[i]**3)
if ret_error:
count = np.count_nonzero(mask)
if count > 0:
error_relative[i] = 1 / np.sqrt(count)
else:
error_relative[i] = 0
density = m_shells / v_shells
# add the first volume which should be wrt 0
volume = 4/3 * np.pi * (r_bins[1:]**3 - r_bins[:-1]**3)
volume = np.insert(volume, 0, 4/3 * np.pi * r_bins[0]**3)
density = r_bins / volume
if ret_error:
return density, density / np.sqrt(r_bins)
return density, density * error_relative
else:
return density
@ -72,7 +87,10 @@ def mean_interparticle_distance(particles: np.ndarray):
rho = n_half_mass / (4/3 * np.pi * r_half_mass**3)
# the mean distance between particles is the inverse of the density
return (1 / rho)**(1/3)
epsilon = (1 / rho)**(1/3)
logger.info(f"Found mean interparticle distance: {epsilon}")
return epsilon
# TODO: check if this is correct
@ -104,21 +122,25 @@ def half_mass_radius(particles: np.ndarray):
def relaxation_timescale(particles: np.ndarray, G:float) -> float:
def total_energy(particles: np.ndarray):
"""
Computes the relaxation timescale of a set of particles using the velocity at the half mass radius.
Assumes that the particles array has the following columns: x, y, z ...
Computes the total energy of a set of particles.
Assumes that the particles array has the following columns: x, y, z, vx, vy, vz, m.
Uses the approximation that the particles are in a central potential as computed in analytical.py
"""
m_half = np.sum(particles[:, 3]) / 2 # enclosed mass at half mass radius
r_half = half_mass_radius(particles)
n_half = np.sum(np.linalg.norm(particles[:, :3], axis=1) < r_half) # number of enclosed particles
v_c = np.sqrt(G * m_half / r_half)
if particles.shape[1] != 7:
raise ValueError("Particles array must have 7 columns: x, y, z, vx, vy, vz, m")
# the crossing time for the half mass system is
t_c = r_half / v_c
logger.debug(f"Crossing time for half mass system: {t_c}")
# compute the kinetic energy
v = particles[:, 3:6]
m = particles[:, 6]
ke = 0.5 * np.sum(m * np.linalg.norm(v, axis=1)**2)
# the relaxation timescale is t_c * N/(10 * log(N))
t_rel = t_c * n_half / (10 * np.ln(n_half))
return t_rel
# # compute the potential energy
# forces = forces_basic.analytical_forces(particles)
# r = np.linalg.norm(particles[:, :3], axis=1)
# pe_particles = -forces[:, 0] * particles[:, 0] - forces[:, 1] * particles[:, 1] - forces[:, 2] * particles[:, 2]
# pe = np.sum(pe_particles)
# # TODO: i am pretty sure this is wrong
pe = 0
return ke + pe

41
nbody/utils/units.py Normal file

@ -0,0 +1,41 @@
import astropy.units as u
import numpy as np
import logging
logger = logging.getLogger(__name__)
M_SCALE: int = None
R_SCALE: int = None
def seed_scales(r_scale: u.Quantity, m_scale: u.Quantity):
"""
Set the scales for the given simulation.
Parameters:
- r_scale: astropy.units.Quantity with units of length - the characteristic length scale of the simulation. Particle positions are expressed in units of this scale.
- m_scale: astropy.units.Quantity with units of mass - the characteristic mass scale of the simulation. Particle masses are expressed in units of this scale.
"""
global M_SCALE, R_SCALE
M_SCALE = m_scale
R_SCALE = r_scale
logger.info(f"Set scales: M_SCALE = {M_SCALE}, R_SCALE = {R_SCALE}")
def apply_units(columns: np.array, quantity: str):
if quantity == "mass":
return columns * M_SCALE
elif quantity == "position":
return columns * R_SCALE
elif quantity == "velocity":
return columns * R_SCALE / u.s
elif quantity == "time":
return columns * u.s
elif quantity == "acceleration":
return columns * R_SCALE / u.s**2
elif quantity == "force":
return columns * M_SCALE * R_SCALE / u.s**2
elif quantity == "volume":
return columns * R_SCALE**3
elif quantity == "density":
return columns * M_SCALE / R_SCALE**3
else:
raise ValueError(f"Unknown quantity: {quantity}")