cleanup and presentable
This commit is contained in:
parent
da8a7d4574
commit
facb52b33e
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
#import "@preview/based:0.2.0": base64
|
#import "@preview/based:0.2.0": base64
|
||||||
|
|
||||||
#let code_font_scale = 0.6em
|
#let code_font_scale = 0.5em
|
||||||
|
|
||||||
#let cell_matcher(cell, cell_tag) = {
|
#let cell_matcher(cell, cell_tag) = {
|
||||||
// Matching function to check if a cell has a specific tag
|
// Matching function to check if a cell has a specific tag
|
||||||
|
Binary file not shown.
@ -1,8 +1,5 @@
|
|||||||
#import "@preview/diatypst:0.2.0": *
|
#import "@preview/diatypst:0.2.0": *
|
||||||
|
|
||||||
// #set text(font: "Cantarell")
|
|
||||||
// #set heading(numbering: (..nums)=>"")
|
|
||||||
|
|
||||||
#show: slides.with(
|
#show: slides.with(
|
||||||
title: "N-Body project ",
|
title: "N-Body project ",
|
||||||
subtitle: "Computational Astrophysics, HS24",
|
subtitle: "Computational Astrophysics, HS24",
|
||||||
@ -13,15 +10,13 @@
|
|||||||
// ratio: 16/9,
|
// ratio: 16/9,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#show footnote.entry: set text(size: 0.6em)
|
||||||
|
#set footnote.entry(gap: 3pt)
|
||||||
|
#set align(horizon)
|
||||||
|
|
||||||
|
|
||||||
#import "helpers.typ"
|
#import "helpers.typ"
|
||||||
|
|
||||||
// KINDA COOL:
|
|
||||||
// _diatypst_ defines some default styling for elements, e.g Terms created with ```typc / Term: Definition``` will look like this
|
|
||||||
|
|
||||||
// / *Term*: Definition
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Setup of code location
|
// Setup of code location
|
||||||
#let t1 = json("../task1.ipynb")
|
#let t1 = json("../task1.ipynb")
|
||||||
@ -41,10 +36,7 @@
|
|||||||
|
|
||||||
|
|
||||||
== Overview - the system
|
== Overview - the system
|
||||||
Get a feel for the particles and their distribution. [#link(<task1:plot_particle_distribution>)[code]]
|
Get a feel for the particles and their distribution
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#columns(2)[
|
#columns(2)[
|
||||||
#helpers.image_cell(t1, "plot_particle_distribution")
|
#helpers.image_cell(t1, "plot_particle_distribution")
|
||||||
// Note: for visibility the outer particles are not shown.
|
// Note: for visibility the outer particles are not shown.
|
||||||
@ -54,15 +46,24 @@ Get a feel for the particles and their distribution. [#link(<task1:plot_particle
|
|||||||
- a _spherical_ distribution
|
- a _spherical_ distribution
|
||||||
|
|
||||||
$==>$ treat the system as a *globular cluster*
|
$==>$ treat the system as a *globular cluster*
|
||||||
|
#footnote[Unit handling [#link(<task1:function_apply_units>)[code]]]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// It is a small globular cluster with
|
||||||
|
// - 5*10^4 stars => m in terms of msol
|
||||||
|
// - radius - 10 pc
|
||||||
|
// Densities are now expressed in M_sol / pc^3
|
||||||
|
// Forces are now expressed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
== Density
|
== Density
|
||||||
We compare the computed density with the analytical model provided by the _Hernquist_ model:
|
Compare the computed density
|
||||||
|
#footnote[Density sampling [#link(<task1:function_density_distribution>)[code]]]
|
||||||
|
with the analytical model provided by the _Hernquist_ model:
|
||||||
|
|
||||||
#grid(
|
#grid(
|
||||||
columns: (1fr, 2fr),
|
columns: (3fr, 4fr),
|
||||||
inset: 0.5em,
|
inset: 0.5em,
|
||||||
block[
|
block[
|
||||||
$
|
$
|
||||||
@ -72,15 +73,12 @@ We compare the computed density with the analytical model provided by the _Hernq
|
|||||||
$
|
$
|
||||||
r_"hm" = (1 + sqrt(2)) dot a
|
r_"hm" = (1 + sqrt(2)) dot a
|
||||||
$
|
$
|
||||||
|
|
||||||
#text(size: 0.6em)[
|
|
||||||
Density sampling [#link(<task1:function_density_distribution>)[code]];
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
block[
|
block[
|
||||||
#helpers.image_cell(t1, "plot_density_distribution")
|
#helpers.image_cell(t1, "plot_density_distribution")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Note that by construction, the first shell contains no particles
|
// Note that by construction, the first shell contains no particles
|
||||||
// => the numerical density is zero there
|
// => the numerical density is zero there
|
||||||
// Having more bins means to have shells that are nearly empty
|
// Having more bins means to have shells that are nearly empty
|
||||||
@ -89,45 +87,41 @@ We compare the computed density with the analytical model provided by the _Hernq
|
|||||||
|
|
||||||
|
|
||||||
== Force computation
|
== Force computation
|
||||||
// N Body and variations
|
|
||||||
#grid(
|
#grid(
|
||||||
columns: (2fr, 1fr),
|
columns: (3fr, 2fr),
|
||||||
inset: 0.5em,
|
inset: 0.5em,
|
||||||
block[
|
block[
|
||||||
#helpers.image_cell(t1, "plot_force_radial")
|
#helpers.image_cell(t1, "plot_force_radial")
|
||||||
// The radial force is computed as the sum of the forces of all particles in the system.
|
|
||||||
#text(size: 0.6em)[
|
|
||||||
Analytical force [#link(<task1:function_analytical_forces>)[code]];
|
|
||||||
$N^2$ force [#link(<task1:function_n2_forces>)[code]];
|
|
||||||
$epsilon$ computation [#link(<task1:function_interparticle_distance>)[code]];
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
block[
|
block[
|
||||||
Discussion:
|
Discussion:
|
||||||
- the analytical method replicates the behavior accurately
|
- the analytical
|
||||||
- at small softenings the $N^2$ method has noisy artifacts
|
#footnote[Analytical force [#link(<task1:function_analytical_forces>)[code]]]
|
||||||
- a $1 dot epsilon$ softening is a good compromise between accuracy and stability
|
method replicates the behavior accurately
|
||||||
|
- at small softenings the $N^2$
|
||||||
|
#footnote[$N^2$ force [#link(<task1:function_n2_forces>)[code]]]
|
||||||
|
method has noisy artifacts
|
||||||
|
- a $1 dot epsilon$
|
||||||
|
#footnote[$epsilon$ computation [#link(<task1:function_interparticle_distance>)[code]]]
|
||||||
|
softening is a good compromise between accuracy and stability
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
// basic $N^2$ matches analytical solution without dropoff. but: noisy data from "bad" samples
|
// basic $N^2$ matches analytical solution without dropoff. but: noisy data from "bad" samples
|
||||||
// $N^2$ with softening matches analytical solution but has a dropoff. No noisy data.
|
// $N^2$ with softening matches analytical solution but has a dropoff. No noisy data.
|
||||||
|
|
||||||
// => softening $\approx 1 \varepsilon$ is a sweet spot since the dropoff is "late"
|
// => softening $\approx 1 \varepsilon$ is a sweet spot since the dropoff is "late"
|
||||||
|
|
||||||
|
|
||||||
== Relaxation
|
== Relaxation
|
||||||
|
|
||||||
We express system relaxation in terms of the dynamical time of the system.
|
We express system relaxation in terms of the dynamical time of the system.
|
||||||
$
|
$
|
||||||
t_"relax" = overbrace(N / (8 log N), n_"relax") dot t_"crossing"
|
t_"relax" = overbrace(N / (8 log N), n_"relax") dot t_"crossing"
|
||||||
$
|
$
|
||||||
where the crossing time of the system can be estimated through the half-mass velocity $t_"crossing" = v(r_"hm")/r_"hm"$.
|
where the crossing time of the system can be estimated through the half-mass velocity $t_"crossing" = v(r_"hm")/r_"hm"$.
|
||||||
|
|
||||||
We find a relaxation of [#link(<task1:compute_relaxation_time>)[code]].
|
We find a relaxation of $approx 30 "Myr"$ ([#link(<task1:compute_relaxation_time>)[code]])
|
||||||
|
|
||||||
|
|
||||||
// === Discussion
|
|
||||||
#grid(
|
#grid(
|
||||||
columns: (1fr, 1fr),
|
columns: (1fr, 1fr),
|
||||||
inset: 0.5em,
|
inset: 0.5em,
|
||||||
@ -140,6 +134,7 @@ We find a relaxation of [#link(<task1:compute_relaxation_time>)[code]].
|
|||||||
- $=>$ relaxation time increases
|
- $=>$ relaxation time increases
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
// The estimate for $n_{relax}$ comes from the contribution of each star-star encounter to the velocity dispersion. This depends on the perpendicular force
|
// The estimate for $n_{relax}$ comes from the contribution of each star-star encounter to the velocity dispersion. This depends on the perpendicular force
|
||||||
|
|
||||||
// $\implies$ a bigger softening length leads to a smaller $\delta v$.
|
// $\implies$ a bigger softening length leads to a smaller $\delta v$.
|
||||||
@ -164,7 +159,7 @@ We find a relaxation of [#link(<task1:compute_relaxation_time>)[code]].
|
|||||||
)[
|
)[
|
||||||
#helpers.image_cell(t2, "plot_particle_distribution")
|
#helpers.image_cell(t2, "plot_particle_distribution")
|
||||||
|
|
||||||
$=>$ use $M_"sys" approx 10^4 M_"sol" + M_"BH"$
|
$==>$ use $M_"sys" approx 10^4 M_"sol" + M_"BH"$
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -180,55 +175,83 @@ We find a relaxation of [#link(<task1:compute_relaxation_time>)[code]].
|
|||||||
inset: 0.5em,
|
inset: 0.5em,
|
||||||
block[
|
block[
|
||||||
#helpers.image_cell(t2, "plot_force_radial_single")
|
#helpers.image_cell(t2, "plot_force_radial_single")
|
||||||
// The radial force is computed as the sum of the forces of all particles in the system.
|
|
||||||
#text(size: 0.6em)[
|
|
||||||
$N^2$ force [#link(<task1:function_n2_forces>)[code]];
|
|
||||||
$epsilon$ computation [#link(<task1:function_interparticle_distance>)[code]];
|
|
||||||
Mesh force [#link(<task2:function_mesh_force>)[code]];
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
block[
|
block[
|
||||||
Discussion:
|
- using the (established) baseline of $N^2$
|
||||||
- using the (established) baseline of $N^2$ with $1 dot epsilon$ softening
|
#footnote[$N^2$ force [#link(<task1:function_n2_forces>)[code]]]
|
||||||
- small grids are stable but inaccurate at the center
|
with $1 dot epsilon$
|
||||||
|
#footnote[$epsilon$ computation [#link(<task1:function_interparticle_distance>)[code]]]
|
||||||
|
softening
|
||||||
|
- small grids
|
||||||
|
#footnote[Mesh force [#link(<task2:function_mesh_force>)[code]]]
|
||||||
|
are stable but inaccurate at the center
|
||||||
- very large grids have issues with overdiscretization
|
- very large grids have issues with overdiscretization
|
||||||
|
|
||||||
$==> 75 times 75 times 75$ as a good compromise
|
$==> 75 times 75 times 75$ as a good compromise
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
// Some other comments:
|
// Some other comments:
|
||||||
// - see the artifacts because of the even grid numbers (hence the switch to 75)
|
// - see the artifacts because of the even grid numbers (hence the switch to 75)
|
||||||
// overdiscretization for large grids -> vertical spread even though r is constant
|
// overdiscretization for large grids -> vertical spread even though r is constant
|
||||||
// this becomes even more apparent when looking at the data without noise - the artifacts remain
|
// this becomes even more apparent when looking at the data without noise - the artifacts remain
|
||||||
]
|
//
|
||||||
)
|
// We can not rely on the interparticle distance computation for a disk!
|
||||||
|
// Given softening length 0.037 does not match the mean interparticle distance 0.0262396757880128
|
||||||
|
//
|
||||||
|
// Discussion of the discrepancies
|
||||||
|
// TODO
|
||||||
|
|
||||||
|
|
||||||
#helpers.image_cell(t2, "plot_force_computation_time")
|
#helpers.image_cell(t2, "plot_force_computation_time")
|
||||||
|
// Computed for 10^4 particles => mesh will scale better for larger systems
|
||||||
|
|
||||||
== Time integration
|
== Time integration
|
||||||
=== Runge-Kutta
|
*Integration step*
|
||||||
#helpers.code_reference_cell(t2, "function_runge_kutta")
|
#helpers.code_reference_cell(t2, "function_runge_kutta")
|
||||||
|
|
||||||
|
*Timesteps*
|
||||||
|
Chosen such that displacement is small (compared to the inter-particle distance) [#link(<task2:integration_timestep>)[code]]:
|
||||||
|
$
|
||||||
|
op(d)t = 10^(-4) dot S / v_"part"
|
||||||
|
$
|
||||||
|
|
||||||
|
// too large timesteps lead to instable systems <=> integration not accurate enough
|
||||||
|
|
||||||
|
*Full integration*
|
||||||
|
|
||||||
|
[#link(<task2:function_time_integration>)[code]]
|
||||||
|
|
||||||
|
|
||||||
#pagebreak()
|
#pagebreak()
|
||||||
=== Results
|
== First results
|
||||||
#align(center, block(
|
|
||||||
height: 1fr,
|
|
||||||
)[
|
|
||||||
#helpers.image_cell(t2, "plot_system_evolution")
|
#helpers.image_cell(t2, "plot_system_evolution")
|
||||||
])
|
|
||||||
|
|
||||||
|
== Varying the softening
|
||||||
|
|
||||||
|
#helpers.image_cell(t2, "plot_second_system_evolution")
|
||||||
|
|
||||||
|
|
||||||
|
== Stability [#link("../task2_nsquare_integration.gif")[1 epsilon]]
|
||||||
|
#page(
|
||||||
|
columns: 2
|
||||||
|
)[
|
||||||
|
#helpers.image_cell(t2, "plot_integration_stability")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
== Particle mesh solver
|
== Particle mesh solver
|
||||||
sdlsd
|
#helpers.image_cell(t2, "plot_pm_solver_integration")
|
||||||
|
|
||||||
|
|
||||||
|
#helpers.image_cell(t2, "plot_pm_solver_stability")
|
||||||
|
|
||||||
|
|
||||||
= Appendix - Code <appendix>
|
= Appendix - Code <appendix>
|
||||||
|
|
||||||
== Code
|
== Code
|
||||||
#helpers.code_cell(t1, "plot_particle_distribution")
|
#helpers.code_reference_cell(t1, "function_apply_units")
|
||||||
<task1:plot_particle_distribution>
|
<task1:function_apply_units>
|
||||||
|
|
||||||
#pagebreak(weak: true)
|
#pagebreak(weak: true)
|
||||||
|
|
||||||
@ -260,6 +283,15 @@ sdlsd
|
|||||||
#helpers.code_reference_cell(t2, "function_mesh_force")
|
#helpers.code_reference_cell(t2, "function_mesh_force")
|
||||||
<task2:function_mesh_force>
|
<task2:function_mesh_force>
|
||||||
|
|
||||||
|
#pagebreak(weak: true)
|
||||||
|
|
||||||
|
#helpers.code_cell(t2, "integration_timestep")
|
||||||
|
<task2:integration_timestep>
|
||||||
|
|
||||||
|
#pagebreak(weak: true)
|
||||||
|
|
||||||
|
#helpers.code_cell(t2, "function_time_integration")
|
||||||
|
<task2:function_time_integration>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
nbody/task2_nsquare_integration.gif
Normal file
BIN
nbody/task2_nsquare_integration.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 403 KiB |
@ -13,7 +13,7 @@ def cached_forces(cache_path: Path, particles: np.ndarray, force_function:callab
|
|||||||
|
|
||||||
n_particles = particles.shape[0]
|
n_particles = particles.shape[0]
|
||||||
|
|
||||||
kwargs_str = "_".join([f"{k}_{v}" for k, v in func_kwargs.items()])
|
kwargs_str = kwargs_to_str(func_kwargs)
|
||||||
|
|
||||||
force_cache = cache_path / f"forces__{force_function.__name__}__n_{n_particles}__kwargs_{kwargs_str}.npy"
|
force_cache = cache_path / f"forces__{force_function.__name__}__n_{n_particles}__kwargs_{kwargs_str}.npy"
|
||||||
time_cache = cache_path / f"time__{force_function.__name__}__n_{n_particles}__kwargs_{kwargs_str}.npy"
|
time_cache = cache_path / f"time__{force_function.__name__}__n_{n_particles}__kwargs_{kwargs_str}.npy"
|
||||||
@ -26,8 +26,27 @@ def cached_forces(cache_path: Path, particles: np.ndarray, force_function:callab
|
|||||||
force = force_function(particles, **func_kwargs)
|
force = force_function(particles, **func_kwargs)
|
||||||
np.save(force_cache, force)
|
np.save(force_cache, force)
|
||||||
time = 0
|
time = 0
|
||||||
np.info(f"Timing {force_function.__name__} for {n_particles} particles")
|
logger.info(f"Timing {force_function.__name__} for {n_particles} particles")
|
||||||
time = timeit.timeit(lambda: force_function(particles, **func_kwargs), number=10)
|
time = timeit.timeit(lambda: force_function(particles, **func_kwargs), number=10)
|
||||||
np.save(time_cache, time)
|
np.save(time_cache, time)
|
||||||
|
|
||||||
return force, time
|
return force, time
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def kwargs_to_str(kwargs: dict):
|
||||||
|
"""
|
||||||
|
Converts a dictionary of keyword arguments to a string.
|
||||||
|
"""
|
||||||
|
base_str = ""
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
print(type(v))
|
||||||
|
if type(v) == float:
|
||||||
|
base_str += f"{k}_{v:.3f}"
|
||||||
|
elif type(v) == callable:
|
||||||
|
base_str += f"{k}_{v.__name__}"
|
||||||
|
else:
|
||||||
|
base_str += f"{k}_{v}"
|
||||||
|
base_str += "__"
|
||||||
|
|
||||||
|
return base_str
|
||||||
|
@ -71,7 +71,6 @@ def mesh_poisson(mesh: np.ndarray, G: float, spacing: float) -> np.ndarray:
|
|||||||
logger.debug(f"Proceeding to poisson equation with {rho_hat.shape=}, {k_inv.shape=}")
|
logger.debug(f"Proceeding to poisson equation with {rho_hat.shape=}, {k_inv.shape=}")
|
||||||
grad_phi_hat = - 4 * np.pi * G * rho_hat * k_inv * 1j
|
grad_phi_hat = - 4 * np.pi * G * rho_hat * k_inv * 1j
|
||||||
# nabla^2 phi => -i * k * nabla phi = 4 pi G rho => nabla phi = - i * rho * k / k^2
|
# nabla^2 phi => -i * k * nabla phi = 4 pi G rho => nabla phi = - i * rho * k / k^2
|
||||||
# TODO: check minus
|
|
||||||
grad_phi = np.real(fft.ifftn(grad_phi_hat))
|
grad_phi = np.real(fft.ifftn(grad_phi_hat))
|
||||||
return grad_phi
|
return grad_phi
|
||||||
|
|
||||||
@ -133,9 +132,7 @@ def mesh_poisson(mesh: np.ndarray, G: float, spacing: float) -> np.ndarray:
|
|||||||
rho_hat = fft.fftn(mesh)
|
rho_hat = fft.fftn(mesh)
|
||||||
|
|
||||||
# we also need the wave numbers
|
# we also need the wave numbers
|
||||||
spacing_3d = np.linalg.norm([spacing, spacing, spacing])
|
|
||||||
k = fft.fftfreq(mesh.shape[0], spacing) * (2 * np.pi)
|
k = fft.fftfreq(mesh.shape[0], spacing) * (2 * np.pi)
|
||||||
# TODO: check if this is correct
|
|
||||||
# assuming the grid is cubic
|
# assuming the grid is cubic
|
||||||
kx, ky, kz = np.meshgrid(k, k, k)
|
kx, ky, kz = np.meshgrid(k, k, k)
|
||||||
k_sr = kx**2 + ky**2 + kz**2
|
k_sr = kx**2 + ky**2 + kz**2
|
||||||
@ -145,10 +142,9 @@ def mesh_poisson(mesh: np.ndarray, G: float, spacing: float) -> np.ndarray:
|
|||||||
logger.debug(f"Count of ksquare zeros: {np.sum(k_sr == 0)}")
|
logger.debug(f"Count of ksquare zeros: {np.sum(k_sr == 0)}")
|
||||||
show_mesh_information(np.abs(k_sr), "k_square")
|
show_mesh_information(np.abs(k_sr), "k_square")
|
||||||
|
|
||||||
k_sr[k_sr == 0] = np.inf
|
k_sr[k_sr == 0] = 1e-10
|
||||||
# k_inv = k_vec / k_sr # allows for element-wise division
|
# k_inv = k_vec / k_sr # allows for element-wise division
|
||||||
|
|
||||||
# logger.debug(f"Proceeding to poisson equation with {rho_hat.shape=}, {k_inv.shape=}")
|
|
||||||
phi_hat = - 4 * np.pi * G * rho_hat / k_sr
|
phi_hat = - 4 * np.pi * G * rho_hat / k_sr
|
||||||
# nabla^2 phi becomes -i * k * nabla phi_hat = 4 pi G rho_hat
|
# nabla^2 phi becomes -i * k * nabla phi_hat = 4 pi G rho_hat
|
||||||
# => nabla phi = - i * rho * k / k^2
|
# => nabla phi = - i * rho * k / k^2
|
||||||
@ -156,6 +152,7 @@ def mesh_poisson(mesh: np.ndarray, G: float, spacing: float) -> np.ndarray:
|
|||||||
return phi
|
return phi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Helper functions for star mapping
|
#### Helper functions for star mapping
|
||||||
def create_mesh(min_pos: float, max_pos: float, n_grid: int) -> tuple[np.ndarray, np.ndarray, float]:
|
def create_mesh(min_pos: float, max_pos: float, n_grid: int) -> tuple[np.ndarray, np.ndarray, float]:
|
||||||
"""
|
"""
|
||||||
@ -239,3 +236,57 @@ def mesh_plot_2d(mesh: np.ndarray, name: str, only_z: bool = False):
|
|||||||
axs[2].imshow(np.sum(mesh, axis=2), origin='lower')
|
axs[2].imshow(np.sum(mesh, axis=2), origin='lower')
|
||||||
axs[2].set_title("Flattened in z")
|
axs[2].set_title("Flattened in z")
|
||||||
plt.show()
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
##################################
|
||||||
|
# For the presentation - without logging
|
||||||
|
def mesh__forces(particles: np.ndarray, G: float = 1, n_grid: int = 50, mapping: callable = None) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Computes the gravitational force acting on a set of particles using a mesh-based approach.
|
||||||
|
Assumes that the particles array has the following columns: x, y, z, m.
|
||||||
|
"""
|
||||||
|
max_pos = np.max(np.abs(particles[:, :3]))
|
||||||
|
mesh, axis, spacing = create_mesh(-max_pos, max_pos, n_grid)
|
||||||
|
|
||||||
|
fill_mesh(particles, mesh, axis, mapping)
|
||||||
|
# we want a density mesh:
|
||||||
|
cell_volume = spacing**3
|
||||||
|
rho = mesh / cell_volume
|
||||||
|
|
||||||
|
# compute the potential and its gradient
|
||||||
|
phi = mesh_poisson(rho, G, spacing)
|
||||||
|
|
||||||
|
# get the acceleration from finite differences of the potential
|
||||||
|
ax, ay, az = np.gradient(phi, spacing)
|
||||||
|
a_vec = - np.stack([ax, ay, az], axis=0)
|
||||||
|
|
||||||
|
# compute the particle forces from the mesh potential
|
||||||
|
forces = np.zeros_like(particles[:, :3])
|
||||||
|
ijks = np.digitize(particles[:, :3], axis) - 1
|
||||||
|
|
||||||
|
for i in range(particles.shape[0]):
|
||||||
|
m = particles[i, 3]
|
||||||
|
idx = ijks[i]
|
||||||
|
forces[i] = m * a_vec[..., idx[0], idx[1], idx[2]]
|
||||||
|
|
||||||
|
return forces
|
||||||
|
|
||||||
|
|
||||||
|
def mesh__poisson(mesh: np.ndarray, G: float, spacing: float) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Solves the poisson equation for the mesh using the FFT.
|
||||||
|
Returns the the potential - phi
|
||||||
|
"""
|
||||||
|
rho_hat = fft.fftn(mesh)
|
||||||
|
|
||||||
|
# we also need the wave numbers
|
||||||
|
k = fft.fftfreq(mesh.shape[0], spacing) * (2 * np.pi)
|
||||||
|
# assuming the grid is cubic
|
||||||
|
kx, ky, kz = np.meshgrid(k, k, k)
|
||||||
|
k_sr = kx**2 + ky**2 + kz**2
|
||||||
|
|
||||||
|
k_sr[k_sr == 0] = np.inf
|
||||||
|
|
||||||
|
phi_hat = - 4 * np.pi * G * rho_hat / k_sr
|
||||||
|
return np.real(fft.ifftn(phi_hat))
|
||||||
|
@ -77,7 +77,8 @@ def to_particles_3d(y: np.ndarray) -> np.ndarray:
|
|||||||
n_steps = y.shape[0]
|
n_steps = y.shape[0]
|
||||||
n_particles = y.shape[1] // 7
|
n_particles = y.shape[1] // 7
|
||||||
y = y.reshape((n_steps, n_particles, 7))
|
y = y.reshape((n_steps, n_particles, 7))
|
||||||
# logger.debug(f"Unflattened array into {y.shape=}")
|
|
||||||
|
logger.info(f"Unflattened array into {y.shape=}")
|
||||||
return y
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,3 +12,11 @@ def model_density_distribution(r_bins: np.ndarray, M: float = 5, a: float = 5) -
|
|||||||
"""
|
"""
|
||||||
rho = M / (2 * np.pi) * a / (r_bins * (r_bins + a)**3)
|
rho = M / (2 * np.pi) * a / (r_bins * (r_bins + a)**3)
|
||||||
return rho
|
return rho
|
||||||
|
|
||||||
|
|
||||||
|
def model_particle_count(r_bins: np.ndarray, M: float = 5, a: float = 5, mi: float = 1) -> np.ndarray:
|
||||||
|
rho = model_density_distribution(r_bins, M, a)
|
||||||
|
v_shells = 4/3 * np.pi * (r_bins[1:]**3 - r_bins[:-1]**3)
|
||||||
|
v_shells = np.insert(v_shells, 0, 4/3 * np.pi * r_bins[0]**3)
|
||||||
|
n_shells = rho * v_shells / mi
|
||||||
|
return n_shells
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from astropy import units as u
|
from matplotlib.animation import FuncAnimation
|
||||||
|
from pathlib import Path
|
||||||
|
from .units import apply_units
|
||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -42,6 +45,16 @@ def density_distribution(r_bins: np.ndarray, particles: np.ndarray, ret_error: b
|
|||||||
else:
|
else:
|
||||||
return density
|
return density
|
||||||
|
|
||||||
|
def particle_count(r_bins, particles):
|
||||||
|
r = np.linalg.norm(particles[:, :3], axis=1)
|
||||||
|
# r_bins = np.insert(r_bins, 0, 0)
|
||||||
|
count = np.zeros_like(r_bins)
|
||||||
|
|
||||||
|
for i in range(len(r_bins) - 1):
|
||||||
|
mask = (r >= r_bins[i]) & (r < r_bins[i+1])
|
||||||
|
count[i] = np.count_nonzero(mask)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def r_distribution(particles: np.ndarray):
|
def r_distribution(particles: np.ndarray):
|
||||||
@ -94,17 +107,16 @@ def mean_interparticle_distance(particles: np.ndarray):
|
|||||||
epsilon = (1 / rho)**(1/3)
|
epsilon = (1 / rho)**(1/3)
|
||||||
logger.info(f"Found mean interparticle distance: {epsilon}")
|
logger.info(f"Found mean interparticle distance: {epsilon}")
|
||||||
return epsilon
|
return epsilon
|
||||||
# TODO: check if this is correct
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def half_mass_radius(particles: np.ndarray):
|
def half_mass_radius(particles: np.ndarray):
|
||||||
"""
|
"""
|
||||||
Computes the half mass radius of a set of particles.
|
Computes the half mass radius of a set of particles.
|
||||||
Assumes that the particles array has the following columns: x, y, z ...
|
Assumes that the particles array has the following columns: x, y, z, m, ...
|
||||||
"""
|
"""
|
||||||
if particles.shape[1] < 3:
|
if particles.shape[1] < 4:
|
||||||
raise ValueError("Particles array must have at least 3 columns: x, y, z")
|
raise ValueError("Particles array must have at least 4 columns: x, y, z, m")
|
||||||
|
|
||||||
# even though in the simple example, all the masses are the same, we will consider the general case
|
# even though in the simple example, all the masses are the same, we will consider the general case
|
||||||
total_mass = np.sum(particles[:, 3])
|
total_mass = np.sum(particles[:, 3])
|
||||||
@ -125,31 +137,6 @@ def half_mass_radius(particles: np.ndarray):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def total_energy(particles: np.ndarray):
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
if particles.shape[1] != 7:
|
|
||||||
raise ValueError("Particles array must have 7 columns: x, y, z, vx, vy, vz, m")
|
|
||||||
|
|
||||||
# compute the kinetic energy
|
|
||||||
v = particles[:, 3:6]
|
|
||||||
m = particles[:, 6]
|
|
||||||
ke = 0.5 * np.sum(m * np.linalg.norm(v, axis=1)**2)
|
|
||||||
|
|
||||||
# # 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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def particles_plot_3d(positions: np.ndarray, masses: np.ndarray, title: str = "Particle distribution (3D)"):
|
def particles_plot_3d(positions: np.ndarray, masses: np.ndarray, title: str = "Particle distribution (3D)"):
|
||||||
"""
|
"""
|
||||||
Plots a 3D scatter plot of a set of particles.
|
Plots a 3D scatter plot of a set of particles.
|
||||||
@ -214,3 +201,129 @@ def particles_plot_2d(particles: np.ndarray, title: str = "Flattened distributio
|
|||||||
plt.show()
|
plt.show()
|
||||||
else:
|
else:
|
||||||
ax.hist2d(x, y, bins=100, cmap='coolwarm')
|
ax.hist2d(x, y, bins=100, cmap='coolwarm')
|
||||||
|
|
||||||
|
|
||||||
|
def particles_plot_2d_multiframe(particles_3d: np.ndarray, t_range: np.ndarray, title: str):
|
||||||
|
# reduce the font size
|
||||||
|
fig, axs = plt.subplots(4, 6, figsize=(20, 12))
|
||||||
|
fig.suptitle(title)
|
||||||
|
|
||||||
|
# make sure we have enough time steps to show
|
||||||
|
diff = axs.size - particles_3d.shape[0]
|
||||||
|
if diff > 0:
|
||||||
|
logger.debug(f"Adding dummy time steps: {diff=} -> {axs.size=}")
|
||||||
|
plot_t_range = np.concatenate([t_range, np.zeros(diff)])
|
||||||
|
plot_particles_in_time = particles_3d
|
||||||
|
elif diff < 0:
|
||||||
|
logger.debug(f"Too many steps to plot - reducing: {particles_3d.shape[0]} -> {axs.size}")
|
||||||
|
# skip some of the time steps
|
||||||
|
plot_t_range = []
|
||||||
|
plot_particles_in_time = []
|
||||||
|
for i in range(axs.size):
|
||||||
|
idx = int(i / axs.size * particles_3d.shape[0])
|
||||||
|
# make sure we have the first and last time step are included
|
||||||
|
if i == 0:
|
||||||
|
idx = 0
|
||||||
|
elif i == axs.size - 1:
|
||||||
|
idx = particles_3d.shape[0] - 1
|
||||||
|
|
||||||
|
plot_t_range.append(t_range[idx])
|
||||||
|
plot_particles_in_time.append(particles_3d[idx])
|
||||||
|
else:
|
||||||
|
plot_t_range = t_range
|
||||||
|
plot_particles_in_time = particles_3d
|
||||||
|
|
||||||
|
for p, t, a in zip(plot_particles_in_time, plot_t_range, axs.flat):
|
||||||
|
a.set_title(f"t={t:.2g}")
|
||||||
|
particles_plot_2d(p, ax=a)
|
||||||
|
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
def particles_plot_2d_animated(particles_3d: np.ndarray, t_range: np.ndarray, output: Path):
|
||||||
|
# Also: show the 2D evolution as a GIF
|
||||||
|
plt.ioff()
|
||||||
|
fig, ax = plt.subplots()
|
||||||
|
fig.suptitle("Particle evolution (top view)")
|
||||||
|
ax.set_aspect('equal')
|
||||||
|
xmax = np.max(particles_3d[:, :, :3])
|
||||||
|
ax.set_xlim(-xmax, xmax)
|
||||||
|
ax.set_ylim(-xmax, xmax)
|
||||||
|
ax.set_xlabel('x')
|
||||||
|
ax.set_ylabel('y')
|
||||||
|
|
||||||
|
def update(i):
|
||||||
|
ax.set_title(f"t={t_range[i]:.2g}")
|
||||||
|
particles_plot_2d(particles_3d[i], ax=ax)
|
||||||
|
ax.set_xlim(-xmax, xmax) # Ensure x limits remain fixed
|
||||||
|
ax.set_ylim(-xmax, xmax) # Ensure y limits remain fixed
|
||||||
|
|
||||||
|
ani = FuncAnimation(fig, update, frames=range(len(particles_3d)), repeat=False)
|
||||||
|
ani.save(output, writer='ffmpeg', fps=5)
|
||||||
|
plt.close(fig)
|
||||||
|
plt.ion()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def particles_plot_radial_evolution(particles_3d: np.ndarray, t_range: np.ndarray):
|
||||||
|
# radial extrema of the particles - disk surface
|
||||||
|
n_steps = particles_3d.shape[0]
|
||||||
|
r_mins = np.zeros(n_steps)
|
||||||
|
r_maxs = np.zeros(n_steps)
|
||||||
|
r_hms = np.zeros(n_steps)
|
||||||
|
for i in range(n_steps):
|
||||||
|
p = particles_3d[i, ...]
|
||||||
|
# exclude the black hole
|
||||||
|
r = np.linalg.norm(p[1:,:3], axis=1)
|
||||||
|
# plt.plot(r[1::100], alpha=0.5)
|
||||||
|
r_mins[i] = np.min(r)
|
||||||
|
r_maxs[i] = np.max(r)
|
||||||
|
r_hms[i] = half_mass_radius(p)
|
||||||
|
|
||||||
|
r_mins = apply_units(r_mins, "position")
|
||||||
|
r_maxs = apply_units(r_maxs, "position")
|
||||||
|
|
||||||
|
plt.figure()
|
||||||
|
plt.plot(t_range, r_mins, label='$r_{min}$', color=plt.cm.Blues(0.5))
|
||||||
|
plt.plot(t_range, r_maxs, label='$r_{max}$', color=plt.cm.Blues(0.8))
|
||||||
|
plt.fill_between(t_range, r_mins, r_maxs, color=plt.cm.Blues(0.2))
|
||||||
|
plt.plot(t_range, r_hms, label='$r_{hm}$', color=plt.cm.Greens(0.5))
|
||||||
|
|
||||||
|
# show the initial conditions
|
||||||
|
plt.hlines(r_mins[0], t_range[0], t_range[-1], color='black', linestyle='--')
|
||||||
|
plt.hlines(r_maxs[0], t_range[0], t_range[-1], color='black', linestyle='--')
|
||||||
|
|
||||||
|
|
||||||
|
plt.title(f'Radial extrema over {n_steps} timesteps')
|
||||||
|
plt.xlabel('Integration time')
|
||||||
|
plt.ylabel(f'{r_mins.unit:latex}')
|
||||||
|
plt.legend()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def particles_plot_orbits(particles_3d: np.ndarray, t_range: np.ndarray):
|
||||||
|
# particle orbits
|
||||||
|
fig, axs = plt.subplots(2, 1)
|
||||||
|
axs[0].set_position([0, 0.3, 1, 0.6])
|
||||||
|
axs[0].set_xlabel('x')
|
||||||
|
axs[0].set_ylabel('y')
|
||||||
|
|
||||||
|
axs[1].set_position([0, 0, 1, 0.2])
|
||||||
|
axs[1].set_xlabel("t")
|
||||||
|
axs[1].set_ylabel('z')
|
||||||
|
fig.suptitle('Particle orbits')
|
||||||
|
|
||||||
|
print(particles_3d.shape)
|
||||||
|
mid = particles_3d.shape[1] // 2
|
||||||
|
particle_idx = [1, 2, 3, 4, 5, mid-2, mid-1, mid, mid+1, mid+2, -5, -4, -3, -2, -1]
|
||||||
|
colors = plt.cm.Blues(np.linspace(0.2, 0.8, len(particle_idx)))
|
||||||
|
for i, idx in enumerate(particle_idx):
|
||||||
|
x = particles_3d[:, idx, 0]
|
||||||
|
y = particles_3d[:, idx, 1]
|
||||||
|
z = particles_3d[:, idx, 2]
|
||||||
|
axs[0].plot(x, y, label=f'p{idx}', color=colors[i])
|
||||||
|
axs[1].plot(z, label=f'p{idx}', color=colors[i])
|
||||||
|
|
||||||
|
# plt.legend()
|
||||||
|
plt.show()
|
@ -97,34 +97,18 @@ def particles_to_mesh(particles: np.ndarray, mesh: np.ndarray, axis: np.ndarray,
|
|||||||
mesh[ijk[0], ijk[1], ijk[2]] += weight * m
|
mesh[ijk[0], ijk[1], ijk[2]] += weight * m
|
||||||
|
|
||||||
|
|
||||||
'''
|
def pm_ode_setup(particles: np.ndarray, force_function: callable, boundary_condition: str) -> tuple[np.ndarray, callable]:
|
||||||
#### Actually need to patch this
|
|
||||||
def ode_setup(particles: np.ndarray, force_function: callable) -> tuple[np.ndarray, callable]:
|
|
||||||
"""
|
"""
|
||||||
Linearizes the ODE system for the particles interacting gravitationally.
|
Returns a linear ode function that can be integrated by an ODE solver and implements the given boundary conditions.
|
||||||
Returns:
|
|
||||||
- the Y0 array corresponding to the initial conditions (x0 and v0)
|
|
||||||
- the function that computes the right hand side of the ODE with function signature f(t, y)
|
|
||||||
Assumes that the particles array has the following columns: x, y, z, vx, vy, vz, m.
|
|
||||||
"""
|
"""
|
||||||
if particles.shape[1] != 7:
|
if particles.shape[1] != 7:
|
||||||
raise ValueError("Particles array must have 7 columns: x, y, z, vx, vy, vz, m")
|
raise ValueError("Particles array must have 7 columns: x, y, z, vx, vy, vz, m")
|
||||||
|
|
||||||
# for the integrators we need to flatten array which contains 7 columns for now
|
def f(p, t):
|
||||||
# we don't really care how we reshape as long as we unflatten consistently
|
|
||||||
particles = particles.flatten()
|
|
||||||
logger.debug(f"Reshaped 7 columns into {particles.shape=}")
|
|
||||||
|
|
||||||
def f(y, t):
|
|
||||||
"""
|
"""
|
||||||
Computes the right hand side of the ODE system.
|
Computes the right hand side of the ODE system.
|
||||||
The ODE system is linearized around the current positions and velocities.
|
The ODE system is linearized around the current positions and velocities.
|
||||||
"""
|
"""
|
||||||
p = to_particles(y)
|
|
||||||
# this is explicitly a copy, which has shape (n, 7)
|
|
||||||
# columns x, y, z, vx, vy, vz, m
|
|
||||||
# (need to keep y intact since integrators make multiple function calls)
|
|
||||||
|
|
||||||
forces = force_function(p[:, [0, 1, 2, -1]])
|
forces = force_function(p[:, [0, 1, 2, -1]])
|
||||||
|
|
||||||
# compute the accelerations
|
# compute the accelerations
|
||||||
@ -139,12 +123,6 @@ def ode_setup(particles: np.ndarray, force_function: callable) -> tuple[np.ndarr
|
|||||||
# the masses remain unchanged
|
# the masses remain unchanged
|
||||||
# p[:, -1] = p[:, -1]
|
# p[:, -1] = p[:, -1]
|
||||||
|
|
||||||
# flatten the array again
|
|
||||||
# logger.debug(f"As particles: {y}")
|
|
||||||
p = p.reshape(-1, copy=False)
|
|
||||||
|
|
||||||
# logger.debug(f"As column: {y}")
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
return particles, f
|
return f
|
||||||
'''
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user