mirror of
https://github.com/Richard-Sti/csiborgtools_public.git
synced 2025-06-08 18:01:11 +00:00
Improving halo fits (#76)
* Add periodic distances * Little corrections * Fix little bug * Modernise the script * Small updates * Remove clump * Add new halo routines * Fix weights * Modernise the script * Add check ups on convergence * More convergence check ups * Edit bounds * Add default argument * Update fit heuristic and NaNs * Change maxiter * Switch NFW minimization to log-sapce * Remove print statement * Turn convert_from_box abstract property required for all boxes. * Move files * Simplify script * Improve the argument parser * Remove optinal argument * Improve argument parser * Add a minimum concentration limit
This commit is contained in:
parent
eb8d070fff
commit
e08c741fc8
13 changed files with 460 additions and 735 deletions
|
@ -12,7 +12,6 @@
|
|||
# You should have received a copy of the GNU General Public License along
|
||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
from .halo import (Clump, Halo, delta2ncells, dist_centmass, # noqa
|
||||
number_counts)
|
||||
from .haloprofile import NFWPosterior, NFWProfile # noqa
|
||||
from .halo import (Halo, delta2ncells, center_of_mass, # noqa
|
||||
periodic_distance, shift_to_center_of_box, number_counts) # noqa
|
||||
from .utils import split_jobs # noqa
|
||||
|
|
|
@ -12,12 +12,12 @@
|
|||
# You should have received a copy of the GNU General Public License along
|
||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
"""A clump object."""
|
||||
"""A halo object."""
|
||||
from abc import ABC
|
||||
|
||||
import numpy
|
||||
|
||||
from numba import jit
|
||||
from scipy.optimize import minimize
|
||||
|
||||
|
||||
class BaseStructure(ABC):
|
||||
|
@ -26,7 +26,6 @@ class BaseStructure(ABC):
|
|||
"""
|
||||
|
||||
_particles = None
|
||||
_info = None
|
||||
_box = None
|
||||
|
||||
@property
|
||||
|
@ -45,29 +44,14 @@ class BaseStructure(ABC):
|
|||
assert particles.ndim == 2 and particles.shape[1] == 7
|
||||
self._particles = particles
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
"""
|
||||
Array containing information from the clump finder.
|
||||
|
||||
Returns
|
||||
-------
|
||||
info : structured array
|
||||
"""
|
||||
return self._info
|
||||
|
||||
@info.setter
|
||||
def info(self, info):
|
||||
self._info = info
|
||||
|
||||
@property
|
||||
def box(self):
|
||||
"""
|
||||
CSiBORG box object handling unit conversion.
|
||||
Box object handling unit conversion.
|
||||
|
||||
Returns
|
||||
-------
|
||||
box : :py:class:`csiborgtools.units.CSiBORGBox`
|
||||
box : Object derived from :py:class:`csiborgtools.units.BaseBox`
|
||||
"""
|
||||
return self._box
|
||||
|
||||
|
@ -82,14 +66,13 @@ class BaseStructure(ABC):
|
|||
@property
|
||||
def pos(self):
|
||||
"""
|
||||
Cartesian particle coordinates centered at the object.
|
||||
Cartesian particle coordinates in the box coordinate system.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pos : 2-dimensional array of shape `(n_particles, 3)`.
|
||||
"""
|
||||
ps = ("x", "y", "z")
|
||||
return numpy.vstack([self[p] - self.info[p] for p in ps]).T
|
||||
return numpy.vstack([self[p] for p in ('x', 'y', 'z')]).T
|
||||
|
||||
@property
|
||||
def vel(self):
|
||||
|
@ -102,98 +85,130 @@ class BaseStructure(ABC):
|
|||
"""
|
||||
return numpy.vstack([self[p] for p in ("vx", "vy", "vz")]).T
|
||||
|
||||
@property
|
||||
def r(self):
|
||||
"""
|
||||
Radial separation of particles from the centre of the object.
|
||||
|
||||
Returns
|
||||
-------
|
||||
r : 1-dimensional array of shape `(n_particles, )`.
|
||||
"""
|
||||
return self._get_r(self.pos)
|
||||
|
||||
@staticmethod
|
||||
@jit(nopython=True)
|
||||
def _get_r(pos):
|
||||
return (pos[:, 0]**2 + pos[:, 1]**2 + pos[:, 2]**2)**0.5
|
||||
|
||||
def cmass(self, rmax, rmin):
|
||||
"""
|
||||
Calculate Cartesian position components of the object's centre of mass.
|
||||
Note that this is already in a frame centered at the clump's potential
|
||||
minimum, so its distance from origin indicates the separation of the
|
||||
centre of mass and potential minimum.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rmax : float
|
||||
Maximum radius for particles to be included in the calculation.
|
||||
rmin : float
|
||||
Minimum radius for particles to be included in the calculation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
cm : 1-dimensional array of shape `(3, )`
|
||||
"""
|
||||
r = self.r
|
||||
mask = (r >= rmin) & (r <= rmax)
|
||||
return numpy.average(self.pos[mask], axis=0, weights=self["M"][mask])
|
||||
|
||||
def angular_momentum(self, rmax, rmin=0):
|
||||
"""
|
||||
Calculate angular momentum in the box coordinates.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rmax : float
|
||||
Maximum radius for particles to be included in the calculation.
|
||||
rmin : float
|
||||
Minimum radius for particles to be included in the calculation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
J : 1-dimensional array or shape `(3, )`
|
||||
"""
|
||||
r = self.r
|
||||
mask = (r >= rmin) & (r <= rmax)
|
||||
pos = self.pos[mask] - self.cmass(rmax, rmin)
|
||||
# Velocitities in the object CM frame
|
||||
vel = self.vel[mask]
|
||||
vel -= numpy.average(self.vel[mask], axis=0, weights=self["M"][mask])
|
||||
return numpy.einsum("i,ij->j", self["M"][mask], numpy.cross(pos, vel))
|
||||
|
||||
def enclosed_mass(self, rmax, rmin=0):
|
||||
"""
|
||||
Sum of particle masses between two radii.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rmax : float
|
||||
Maximum radial distance.
|
||||
rmin : float, optional
|
||||
Minimum radial distance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
enclosed_mass : float
|
||||
"""
|
||||
r = self.r
|
||||
return numpy.sum(self["M"][(r >= rmin) & (r <= rmax)])
|
||||
|
||||
def lambda_bullock(self, radmax, npart_min=10):
|
||||
def spherical_overdensity_mass(self, delta_mult, kind="crit", tol=1e-8,
|
||||
maxiter=100, npart_min=10):
|
||||
r"""
|
||||
Bullock spin, see Eq. 5 in [1], in a radius of `radius`, which should
|
||||
define to some overdensity radius.
|
||||
Calculate spherical overdensity mass and radius via the iterative
|
||||
shrinking sphere method.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
radmax : float
|
||||
Radius in which to calculate the spin.
|
||||
npart_min : int
|
||||
delta_mult : int or float
|
||||
Overdensity multiple.
|
||||
kind : str, optional
|
||||
Either `crit` or `matter`, for critical or matter overdensity
|
||||
tol : float, optional
|
||||
Tolerance for the change in the center of mass or radius.
|
||||
maxiter : int, optional
|
||||
Maximum number of iterations.
|
||||
npart_min : int, optional
|
||||
Minimum number of enclosed particles to reset the iterator.
|
||||
|
||||
Returns
|
||||
-------
|
||||
mass : float
|
||||
The requested spherical overdensity mass.
|
||||
rad : float
|
||||
The radius of the sphere enclosing the requested overdensity.
|
||||
cm : 1-dimensional array of shape `(3, )`
|
||||
The center of mass of the sphere enclosing the requested
|
||||
overdensity.
|
||||
"""
|
||||
assert kind in ["crit", "matter"]
|
||||
rho = delta_mult * self.box.box_rhoc
|
||||
if kind == "matter":
|
||||
rho *= self.box.box_Om
|
||||
pos = self.pos
|
||||
mass = self["M"]
|
||||
|
||||
# Initial guesses
|
||||
init_cm = center_of_mass(pos, mass, boxsize=1)
|
||||
init_rad = mass_to_radius(numpy.sum(mass), rho) * 1.5
|
||||
|
||||
rad = init_rad
|
||||
cm = numpy.copy(init_cm)
|
||||
|
||||
success = False
|
||||
for __ in range(maxiter):
|
||||
# Calculate the distance of each particle from the current guess.
|
||||
dist = periodic_distance(pos, cm, boxsize=1)
|
||||
within_rad = dist <= rad
|
||||
# Heuristic reset if there are too few enclosed particles.
|
||||
if numpy.sum(within_rad) < npart_min:
|
||||
js = numpy.random.choice(len(self), len(self), replace=True)
|
||||
cm = center_of_mass(pos[js], mass[js], boxsize=1)
|
||||
rad = init_rad * (0.75 + numpy.random.rand())
|
||||
dist = periodic_distance(pos, cm, boxsize=1)
|
||||
within_rad = dist <= rad
|
||||
|
||||
# Calculate the enclosed mass for the current CM and radius.
|
||||
enclosed_mass = numpy.sum(mass[within_rad])
|
||||
|
||||
# Calculate the new CM and radius from this mass.
|
||||
new_rad = mass_to_radius(enclosed_mass, rho)
|
||||
new_cm = center_of_mass(pos[within_rad], mass[within_rad],
|
||||
boxsize=1)
|
||||
|
||||
# Update the CM and radius
|
||||
prev_cm, cm = cm, new_cm
|
||||
prev_rad, rad = rad, new_rad
|
||||
|
||||
# Check if the change in CM and radius is small enough.
|
||||
dcm = numpy.linalg.norm(cm - prev_cm)
|
||||
drad = abs(rad - prev_rad)
|
||||
if dcm < tol or drad < tol:
|
||||
success = True
|
||||
break
|
||||
|
||||
if not success:
|
||||
return numpy.nan, numpy.nan, numpy.full(3, numpy.nan)
|
||||
|
||||
return enclosed_mass, rad, cm
|
||||
|
||||
def angular_momentum(self, ref, rad, npart_min=10):
|
||||
"""
|
||||
Calculate angular momentum around a reference point using all particles
|
||||
within a radius. The angular momentum is returned in box units.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ref : 1-dimensional array of shape `(3, )`
|
||||
Reference point.
|
||||
rad : float
|
||||
Radius around the reference point.
|
||||
npart_min : int, optional
|
||||
Minimum number of enclosed particles for a radius to be
|
||||
considered trustworthy.
|
||||
|
||||
Returns
|
||||
-------
|
||||
angmom : 1-dimensional array or shape `(3, )`
|
||||
"""
|
||||
pos = self.pos
|
||||
mask = periodic_distance(pos, ref, boxsize=1) < rad
|
||||
if numpy.sum(mask) < npart_min:
|
||||
return numpy.full(3, numpy.nan)
|
||||
|
||||
mass = self["M"][mask]
|
||||
pos = pos[mask]
|
||||
vel = self.vel[mask]
|
||||
# Velocitities in the object CM frame
|
||||
vel -= numpy.average(vel, axis=0, weights=mass)
|
||||
return numpy.sum(mass[:, numpy.newaxis] * numpy.cross(pos, vel),
|
||||
axis=0)
|
||||
|
||||
def lambda_bullock(self, ref, rad):
|
||||
r"""
|
||||
Bullock spin, see Eq. 5 in [1], in a given radius around a reference
|
||||
point.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ref : 1-dimensional array of shape `(3, )`
|
||||
Reference point.
|
||||
rad : float
|
||||
Radius around the reference point.
|
||||
|
||||
Returns
|
||||
-------
|
||||
lambda_bullock : float
|
||||
|
@ -204,61 +219,64 @@ class BaseStructure(ABC):
|
|||
Bullock, J. S.; Dekel, A.; Kolatt, T. S.; Kravtsov, A. V.;
|
||||
Klypin, A. A.; Porciani, C.; Primack, J. R.
|
||||
"""
|
||||
mask = self.r <= radmax
|
||||
if numpy.sum(mask) < npart_min:
|
||||
return numpy.nan
|
||||
mass = self.enclosed_mass(radmax)
|
||||
circvel = numpy.sqrt(self.box.box_G * mass / radmax)
|
||||
angmom_norm = numpy.linalg.norm(self.angular_momentum(radmax))
|
||||
return angmom_norm / (numpy.sqrt(2) * mass * circvel * radmax)
|
||||
pos = self.pos
|
||||
mask = periodic_distance(pos, ref, boxsize=1) < rad
|
||||
mass = numpy.sum(self["M"][mask])
|
||||
circvel = numpy.sqrt(self.box.box_G * mass / rad)
|
||||
angmom_norm = numpy.linalg.norm(self.angular_momentum(ref, rad))
|
||||
return angmom_norm / (numpy.sqrt(2) * mass * circvel * rad)
|
||||
|
||||
def spherical_overdensity_mass(self, delta_mult, npart_min=10,
|
||||
kind="crit"):
|
||||
r"""
|
||||
Calculate spherical overdensity mass and radius. The mass is defined as
|
||||
the enclosed mass within an outermost radius where the mean enclosed
|
||||
spherical density reaches a multiple of the critical density `delta`
|
||||
(times the matter density if `kind` is `matter`).
|
||||
def nfw_concentration(self, ref, rad, conc_min=1e-3, npart_min=10):
|
||||
"""
|
||||
Calculate the NFW concentration parameter in a given radius around a
|
||||
reference point.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
delta_mult : list of int or float
|
||||
Overdensity multiple.
|
||||
npart_min : int
|
||||
Minimum number of enclosed particles for a radius to be
|
||||
considered trustworthy.
|
||||
kind : str
|
||||
Either `crit` or `matter`, for critical or matter overdensity
|
||||
ref : 1-dimensional array of shape `(3, )`
|
||||
Reference point.
|
||||
rad : float
|
||||
Radius around the reference point.
|
||||
conc_min : float
|
||||
Minimum concentration limit.
|
||||
npart_min : int, optional
|
||||
Minimum number of enclosed particles to calculate the
|
||||
concentration.
|
||||
|
||||
Returns
|
||||
-------
|
||||
rx : float
|
||||
Radius where the enclosed density reaches required value.
|
||||
mx : float
|
||||
Corresponding spherical enclosed mass.
|
||||
conc : float
|
||||
"""
|
||||
# Quick check of inputs
|
||||
assert kind in ["crit", "matter"]
|
||||
pos = self.pos
|
||||
dist = periodic_distance(pos, ref, boxsize=1)
|
||||
mask = dist < rad
|
||||
if numpy.sum(mask) < npart_min:
|
||||
return numpy.nan
|
||||
|
||||
# We first sort the particles in an increasing separation
|
||||
rs = self.r
|
||||
order = numpy.argsort(rs)
|
||||
rs = rs[order]
|
||||
dist = dist[mask]
|
||||
weight = self["M"][mask]
|
||||
weight /= numpy.mean(weight)
|
||||
|
||||
cmass = numpy.cumsum(self["M"][order]) # Cumulative mass
|
||||
# We calculate the enclosed volume and indices where it is above target
|
||||
vol = 4 * numpy.pi / 3 * rs**3
|
||||
# We do the minimization in log space
|
||||
def negll_nfw_concentration(log_c, xs, weight):
|
||||
c = 10**log_c
|
||||
ll = xs / (1 + c * xs)**2 * c**2
|
||||
ll *= (1 + c) / ((1 + c) * numpy.log(1 + c) - c)
|
||||
ll = numpy.sum(numpy.log(weight * ll))
|
||||
return -ll
|
||||
|
||||
target_density = delta_mult * self.box.box_rhoc
|
||||
if kind == "matter":
|
||||
target_density *= self.box.cosmo.Om0
|
||||
ks = numpy.where(cmass > target_density * vol)[0]
|
||||
if ks.size == 0: # Never above the threshold?
|
||||
return numpy.nan, numpy.nan
|
||||
k = numpy.max(ks)
|
||||
if k < npart_min: # Too few particles?
|
||||
return numpy.nan, numpy.nan
|
||||
return rs[k], cmass[k]
|
||||
res = minimize(negll_nfw_concentration, x0=1.5,
|
||||
args=(dist / rad, weight, ), method='Nelder-Mead',
|
||||
bounds=((numpy.log10(conc_min), 5),))
|
||||
|
||||
if not res.success:
|
||||
return numpy.nan
|
||||
|
||||
res = 10**res["x"][0]
|
||||
if res < conc_min or numpy.isclose(res, conc_min):
|
||||
return numpy.nan
|
||||
|
||||
return res
|
||||
|
||||
def __getitem__(self, key):
|
||||
keys = ['x', 'y', 'z', 'vx', 'vy', 'vz', 'M']
|
||||
|
@ -270,44 +288,23 @@ class BaseStructure(ABC):
|
|||
return self.particles.shape[0]
|
||||
|
||||
|
||||
class Clump(BaseStructure):
|
||||
"""
|
||||
Clump object to handle operations on its particles.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
particles : structured array
|
||||
Particle array. Must contain `['x', 'y', 'z', 'vx', 'vy', 'vz', 'M']`.
|
||||
info : structured array
|
||||
Array containing information from the clump finder.
|
||||
box : :py:class:`csiborgtools.read.CSiBORGBox`
|
||||
Box units object.
|
||||
"""
|
||||
|
||||
def __init__(self, particles, info, box):
|
||||
self.particles = particles
|
||||
self.info = info
|
||||
self.box = box
|
||||
|
||||
|
||||
class Halo(BaseStructure):
|
||||
"""
|
||||
Ultimate halo object to handle operations on its particles, i.e. the summed
|
||||
particles halo.
|
||||
Halo object to handle operations on its particles.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
particles : structured array
|
||||
Particle array. Must contain `['x', 'y', 'z', 'vx', 'vy', 'vz', 'M']`.
|
||||
info : structured array
|
||||
Array containing information from the clump finder.
|
||||
Array containing information from the halo finder.
|
||||
box : :py:class:`csiborgtools.read.CSiBORGBox`
|
||||
Box units object.
|
||||
"""
|
||||
|
||||
def __init__(self, particles, info, box):
|
||||
def __init__(self, particles, box):
|
||||
self.particles = particles
|
||||
self.info = info
|
||||
# self.info = info
|
||||
self.box = box
|
||||
|
||||
|
||||
|
@ -315,28 +312,102 @@ class Halo(BaseStructure):
|
|||
# Other, supplementary functions #
|
||||
###############################################################################
|
||||
|
||||
@jit(nopython=True)
|
||||
def dist_centmass(clump):
|
||||
|
||||
def center_of_mass(points, mass, boxsize):
|
||||
"""
|
||||
Calculate the clump (or halo) particles' distance from the centre of mass.
|
||||
Calculate the center of mass of a halo, while assuming for periodic
|
||||
boundary conditions of a cubical box. Assuming that particle positions are
|
||||
in `[0, boxsize)` range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
clump : 2-dimensional array of shape (n_particles, 7)
|
||||
Particle array. The first four columns must be `x`, `y`, `z` and `M`.
|
||||
points : 2-dimensional array of shape (n_particles, 3)
|
||||
Particle position array.
|
||||
mass : 1-dimensional array of shape `(n_particles, )`
|
||||
Particle mass array.
|
||||
boxsize : float
|
||||
Box size in the same units as `parts` coordinates.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dist : 1-dimensional array of shape `(n_particles, )`
|
||||
Particle distance from the centre of mass.
|
||||
cm : len-3 list
|
||||
Center of mass coordinates.
|
||||
cm : 1-dimensional array of shape `(3, )`
|
||||
"""
|
||||
mass = clump[:, 3]
|
||||
x, y, z = clump[:, 0], clump[:, 1], clump[:, 2]
|
||||
cmx, cmy, cmz = [numpy.average(xi, weights=mass) for xi in (x, y, z)]
|
||||
dist = ((x - cmx)**2 + (y - cmy)**2 + (z - cmz)**2)**0.5
|
||||
return dist, [cmx, cmy, cmz]
|
||||
# Convert positions to unit circle coordinates in the complex plane
|
||||
pos = numpy.exp(2j * numpy.pi * points / boxsize)
|
||||
# Compute weighted average of these coordinates, convert it back to
|
||||
# box coordinates and fix any negative positions due to angle calculations.
|
||||
cm = numpy.angle(numpy.average(pos, axis=0, weights=mass))
|
||||
cm *= boxsize / (2 * numpy.pi)
|
||||
cm[cm < 0] += boxsize
|
||||
return cm
|
||||
|
||||
|
||||
def periodic_distance(points, reference, boxsize):
|
||||
"""
|
||||
Compute the periodic distance between multiple points and a reference
|
||||
point.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
points : 2-dimensional array of shape `(n_points, 3)`
|
||||
Points to calculate the distance from the reference point.
|
||||
reference : 1-dimensional array of shape `(3, )`
|
||||
Reference point.
|
||||
boxsize : float
|
||||
Box size.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dist : 1-dimensional array of shape `(n_points, )`
|
||||
"""
|
||||
delta = numpy.abs(points - reference)
|
||||
delta = numpy.where(delta > boxsize / 2, boxsize - delta, delta)
|
||||
return numpy.linalg.norm(delta, axis=1)
|
||||
|
||||
|
||||
def shift_to_center_of_box(points, cm, boxsize, set_cm_to_zero=False):
|
||||
"""
|
||||
Shift the positions such that the CM is at the center of the box, while
|
||||
accounting for periodic boundary conditions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
points : 2-dimensional array of shape `(n_points, 3)`
|
||||
Points to shift.
|
||||
cm : 1-dimensional array of shape `(3, )`
|
||||
Center of mass.
|
||||
boxsize : float
|
||||
Box size.
|
||||
set_cm_to_zero : bool, optional
|
||||
If `True`, set the CM to zero.
|
||||
|
||||
Returns
|
||||
-------
|
||||
shifted_positions : 2-dimensional array of shape `(n_points, 3)`
|
||||
"""
|
||||
pos = (points + (boxsize / 2 - cm)) % boxsize
|
||||
if set_cm_to_zero:
|
||||
pos -= boxsize / 2
|
||||
return pos
|
||||
|
||||
|
||||
def mass_to_radius(mass, rho):
|
||||
"""
|
||||
Compute the radius of a sphere with a given mass and density.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mass : float
|
||||
Mass of the sphere.
|
||||
rho : float
|
||||
Density of the sphere.
|
||||
|
||||
Returns
|
||||
-------
|
||||
rad : float
|
||||
Radius of the sphere.
|
||||
"""
|
||||
return ((3 * mass) / (4 * numpy.pi * rho))**(1./3)
|
||||
|
||||
|
||||
@jit(nopython=True)
|
||||
|
|
|
@ -1,377 +0,0 @@
|
|||
# Copyright (C) 2022 Richard Stiskalek, Deaglan Bartlett
|
||||
# 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, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
"""Halo profiles functions and posteriors."""
|
||||
import numpy
|
||||
from scipy.optimize import minimize_scalar
|
||||
from scipy.stats import uniform
|
||||
|
||||
from .halo import Clump
|
||||
|
||||
|
||||
class NFWProfile:
|
||||
r"""
|
||||
The Navarro-Frenk-White (NFW) density profile.
|
||||
|
||||
.. math::
|
||||
\rho(r) = \frac{\rho_0}{x(1 + x)^2},
|
||||
|
||||
:math:`x = r / R_s` and its free paramaters are :math:`R_s, \rho_0`: scale
|
||||
radius and NFW density parameter.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def profile(r, Rs, rho0):
|
||||
"""
|
||||
Evaluate the halo profile at `r`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
r : 1-dimensional array
|
||||
Radial distance.
|
||||
Rs : float
|
||||
Scale radius.
|
||||
rho0 : float
|
||||
NFW density parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
density : 1-dimensional array
|
||||
"""
|
||||
x = r / Rs
|
||||
return rho0 / (x * (1 + x) ** 2)
|
||||
|
||||
@staticmethod
|
||||
def _logprofile(r, Rs, rho0):
|
||||
"""Natural logarithm of `NFWPprofile.profile(...)`."""
|
||||
x = r / Rs
|
||||
return numpy.log(rho0) - numpy.log(x) - 2 * numpy.log(1 + x)
|
||||
|
||||
@staticmethod
|
||||
def mass(r, Rs, rho0):
|
||||
r"""
|
||||
Calculate the enclosed mass of a NFW profile in radius `r`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
r : 1-dimensional array
|
||||
Radial distance.
|
||||
Rs : float
|
||||
Scale radius.
|
||||
rho0 : float
|
||||
NFW density parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
M : 1-dimensional array
|
||||
The enclosed mass.
|
||||
"""
|
||||
x = r / Rs
|
||||
out = numpy.log(1 + x) - x / (1 + x)
|
||||
return 4 * numpy.pi * rho0 * Rs**3 * out
|
||||
|
||||
def bounded_mass(self, rmin, rmax, Rs, rho0):
|
||||
r"""
|
||||
Calculate the enclosed mass between `rmin` and `rmax`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rmin : float
|
||||
Minimum radius.
|
||||
rmax : float
|
||||
Maximum radius.
|
||||
Rs : float
|
||||
Scale radius :math:`R_s`.
|
||||
rho0 : float
|
||||
NFW density parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
M : float
|
||||
Enclosed mass within the radial range.
|
||||
"""
|
||||
return self.mass(rmax, Rs, rho0) - self.mass(rmin, Rs, rho0)
|
||||
|
||||
def pdf(self, r, Rs, rmin, rmax):
|
||||
r"""
|
||||
Calculate the radial PDF of the NFW profile, defined below.
|
||||
|
||||
.. math::
|
||||
\frac{4\pi r^2 \rho(r)} {M(r_\min, r_\max)},
|
||||
|
||||
where :math:`M(r_\min, r_\max)` is the enclosed mass between
|
||||
:math:`r_\min` and :math:`r_\max'. Note that the dependance on
|
||||
:math:`\rho_0` is cancelled and must be accounted for in the
|
||||
normalisation term to match the total mass.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
r : 1-dimensional array
|
||||
Radial distance.
|
||||
Rs : float
|
||||
Scale radius.
|
||||
rmin : float
|
||||
Minimum radius to evaluate the PDF (denominator term).
|
||||
rmax : float
|
||||
Maximum radius to evaluate the PDF (denominator term).
|
||||
|
||||
Returns
|
||||
-------
|
||||
pdf : 1-dimensional array
|
||||
"""
|
||||
norm = self.bounded_enclosed_mass(rmin, rmax, Rs, 1)
|
||||
return 4 * numpy.pi * r**2 * self.profile(r, Rs, 1) / norm
|
||||
|
||||
def rvs(self, rmin, rmax, Rs, size=1):
|
||||
"""
|
||||
Generate random samples from the NFW profile via rejection sampling.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rmin : float
|
||||
Minimum radius.
|
||||
rmax : float
|
||||
Maximum radius.
|
||||
Rs : float
|
||||
Scale radius.
|
||||
size : int, optional
|
||||
Number of samples to generate. By default 1.
|
||||
|
||||
Returns
|
||||
-------
|
||||
samples : float or 1-dimensional array
|
||||
Samples following the NFW profile.
|
||||
"""
|
||||
gen = uniform(rmin, rmax - rmin)
|
||||
samples = numpy.full(size, numpy.nan)
|
||||
for i in range(size):
|
||||
while True:
|
||||
r = gen.rvs()
|
||||
if self.pdf(r, Rs, rmin, rmax) > numpy.random.rand():
|
||||
samples[i] = r
|
||||
break
|
||||
|
||||
if size == 1:
|
||||
return samples[0]
|
||||
return samples
|
||||
|
||||
|
||||
class NFWPosterior(NFWProfile):
|
||||
r"""
|
||||
Posterior for fitting the NFW profile in the range specified by the
|
||||
closest particle and the :math:`r_{200c}` radius, calculated as below.
|
||||
|
||||
.. math::
|
||||
\frac{4\pi r^2 \rho(r)} {M(r_{\min} r_{200c})} \frac{m}{M / N},
|
||||
|
||||
where :math:`M(r_{\min} r_{200c}))` is the NFW enclosed mass between the
|
||||
closest particle and the :math:`r_{200c}` radius, :math:`m` is the particle
|
||||
mass, :math:`M` is the sum of the particle masses and :math:`N` is the
|
||||
number of particles. Calculated only using particles within the
|
||||
above-mentioned range.
|
||||
|
||||
Paramaters
|
||||
----------
|
||||
clump : `Clump`
|
||||
Clump object containing the particles and clump information.
|
||||
"""
|
||||
|
||||
@property
|
||||
def clump(self):
|
||||
"""
|
||||
Clump object containig all particles, i.e. ones beyond :math:`R_{200c}`
|
||||
as well.
|
||||
|
||||
Returns
|
||||
-------
|
||||
clump : `Clump`
|
||||
"""
|
||||
return self._clump
|
||||
|
||||
@clump.setter
|
||||
def clump(self, clump):
|
||||
assert isinstance(clump, Clump)
|
||||
self._clump = clump
|
||||
|
||||
def rho0_from_Rs(self, Rs, rmin, rmax, mass):
|
||||
r"""
|
||||
Obtain :math:`\rho_0` of the NFW profile from the integral constraint
|
||||
on total mass. Calculated as the ratio between the total particle mass
|
||||
and the enclosed NFW profile mass.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
Rs : float
|
||||
Logarithmic scale factor in units matching the coordinates.
|
||||
rmin : float
|
||||
Minimum radial distance of particles used to fit the profile.
|
||||
rmax : float
|
||||
Maximum radial distance of particles used to fit the profile.
|
||||
mass : float
|
||||
Mass enclosed within the radius used to fit the NFW profile.
|
||||
|
||||
Returns
|
||||
-------
|
||||
rho0: float
|
||||
"""
|
||||
return mass / self.bounded_mass(rmin, rmax, Rs, 1)
|
||||
|
||||
def initlogRs(self, r, rmin, rmax, binsguess=10):
|
||||
r"""
|
||||
Calculate the most often occuring value of :math:`r` used as initial
|
||||
guess of :math:`R_{\rm s}` since :math:`r^2 \rho(r)` peaks at
|
||||
:math:`r = R_{\rm s}`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
r : 1-dimensional array
|
||||
Radial distance of particles used to fit the profile.
|
||||
rmin : float
|
||||
Minimum radial distance of particles used to fit the profile.
|
||||
rmax : float
|
||||
Maximum radial distance of particles used to fit the profile.
|
||||
binsguess : int
|
||||
Number of bins to initially guess :math:`R_{\rm s}`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
initlogRs : float
|
||||
"""
|
||||
bins = numpy.linspace(rmin, rmax, binsguess)
|
||||
counts, edges = numpy.histogram(r, bins)
|
||||
return numpy.log10(edges[numpy.argmax(counts)])
|
||||
|
||||
def logprior(self, logRs, rmin, rmax):
|
||||
r"""
|
||||
Logarithmic uniform prior on :math:`\log R_{\rm s}`. Unnormalised but
|
||||
that does not matter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
logRs : float
|
||||
Logarithmic scale factor.
|
||||
rmin : float
|
||||
Minimum radial distance of particles used to fit the profile.
|
||||
rmax : float
|
||||
Maximum radial distance of particles used to fit the profile.
|
||||
|
||||
Returns
|
||||
-------
|
||||
lp : float
|
||||
"""
|
||||
if not rmin < 10**logRs < rmax:
|
||||
return -numpy.infty
|
||||
return 0.0
|
||||
|
||||
def loglikelihood(self, logRs, r, rmin, rmax, npart):
|
||||
"""
|
||||
Logarithmic likelihood.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
r : 1-dimensional array
|
||||
Radial distance of particles used to fit the profile.
|
||||
logRs : float
|
||||
Logarithmic scale factor in units matching the coordinates.
|
||||
rmin : float
|
||||
Minimum radial distance of particles used to fit the profile.
|
||||
rmax : float
|
||||
Maximum radial distance of particles used to fit the profile.
|
||||
npart : int
|
||||
Number of particles used to fit the profile.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ll : float
|
||||
"""
|
||||
Rs = 10**logRs
|
||||
mnfw = self.bounded_mass(rmin, rmax, Rs, 1)
|
||||
return numpy.sum(self._logprofile(r, Rs, 1)) - npart * numpy.log(mnfw)
|
||||
|
||||
def __call__(self, logRs, r, rmin, rmax, npart):
|
||||
"""
|
||||
Logarithmic posterior. Sum of the logarithmic prior and likelihood.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
logRs : float
|
||||
Logarithmic scale factor in units matching the coordinates.
|
||||
r : 1-dimensional array
|
||||
Radial distance of particles used to fit the profile.
|
||||
rmin : float
|
||||
Minimum radial distance of particles used to fit the profile.
|
||||
rmax : float
|
||||
Maximum radial distance of particles used to fit the profile.
|
||||
npart : int
|
||||
Number of particles used to fit the profile.
|
||||
|
||||
Returns
|
||||
-------
|
||||
lpost : float
|
||||
"""
|
||||
lp = self.logprior(logRs, rmin, rmax)
|
||||
if not numpy.isfinite(lp):
|
||||
return -numpy.infty
|
||||
return self.loglikelihood(logRs, r, rmin, rmax, npart) + lp
|
||||
|
||||
def fit(self, clump, eps=1e-4):
|
||||
r"""
|
||||
Fit the NFW profile. If the fit is not converged returns NaNs.
|
||||
|
||||
Checks whether :math:`log r_{\rm max} / R_{\rm s} > \epsilon`,
|
||||
to ensure that the scale radius is not too close to the boundary which
|
||||
occurs if the fit fails.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
clump : :py:class:`csiborgtools.fits.Clump`
|
||||
Clump being fitted.
|
||||
eps : float
|
||||
Tolerance to ensure we are sufficiently far from math:`R_{200c}`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Rs: float
|
||||
Best fit scale radius.
|
||||
rho0: float
|
||||
Best fit NFW central density.
|
||||
"""
|
||||
assert isinstance(clump, Clump)
|
||||
r = clump.r
|
||||
rmin = numpy.min(r[r > 0]) # First particle that is not at r = 0
|
||||
rmax, mtot = clump.spherical_overdensity_mass(200)
|
||||
mask = (rmin <= r) & (r <= rmax)
|
||||
npart = numpy.sum(mask)
|
||||
r = r[mask]
|
||||
|
||||
def loss(logRs):
|
||||
return -self(logRs, r, rmin, rmax, npart)
|
||||
|
||||
# Define optimisation boundaries. Check whether they are finite and
|
||||
# that rmax > rmin. If not, then return NaN.
|
||||
bounds = (numpy.log10(rmin), numpy.log10(rmax))
|
||||
if not (numpy.all(numpy.isfinite(bounds)) and bounds[0] < bounds[1]):
|
||||
return numpy.nan, numpy.nan
|
||||
|
||||
res = minimize_scalar(loss, bounds=bounds, method="bounded")
|
||||
# Check whether the fit converged to radius sufficienly far from `rmax`
|
||||
# and that its a success. Otherwise return NaNs.
|
||||
if numpy.log10(rmax) - res.x < eps:
|
||||
res.success = False
|
||||
if not res.success:
|
||||
return numpy.nan, numpy.nan
|
||||
# Additionally we also wish to calculate the central density from the
|
||||
# mass (integral) constraint.
|
||||
rho0 = self.rho0_from_Rs(10**res.x, rmin, rmax, mtot)
|
||||
return 10**res.x, rho0
|
|
@ -15,7 +15,7 @@
|
|||
"""
|
||||
Simulation box unit transformations.
|
||||
"""
|
||||
from abc import ABC, abstractproperty
|
||||
from abc import ABC, abstractmethod, abstractproperty
|
||||
|
||||
import numpy
|
||||
from astropy import constants, units
|
||||
|
@ -24,7 +24,7 @@ from astropy.cosmology import LambdaCDM
|
|||
from .readsim import ParticleReader
|
||||
|
||||
# Map of CSiBORG unit conversions
|
||||
CONV_NAME = {
|
||||
CSIBORG_CONV_NAME = {
|
||||
"length": ["x", "y", "z", "peak_x", "peak_y", "peak_z", "Rs", "rmin",
|
||||
"rmax", "r200c", "r500c", "r200m", "r500m", "x0", "y0", "z0",
|
||||
"lagpatch_size"],
|
||||
|
@ -104,6 +104,35 @@ class BaseBox(ABC):
|
|||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def convert_from_box(self, data, names):
|
||||
r"""
|
||||
Convert columns named `names` in array `data` from box units to
|
||||
physical units, such that
|
||||
- length -> :math:`Mpc`,
|
||||
- mass -> :math:`M_\odot`,
|
||||
- velocity -> :math:`\mathrm{km} / \mathrm{s}`,
|
||||
- density -> :math:`M_\odot / \mathrm{Mpc}^3`.
|
||||
|
||||
Any other conversions are currently not implemented. Note that the
|
||||
array is passed by reference and directly modified, even though it is
|
||||
also explicitly returned. Additionally centres the box coordinates on
|
||||
the observer, if they are being transformed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : structured array
|
||||
Input array.
|
||||
names : list of str
|
||||
Columns to be converted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
data : structured array
|
||||
Input array with converted columns.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
###############################################################################
|
||||
# CSiBORG box #
|
||||
|
@ -340,31 +369,6 @@ class CSiBORGBox(BaseBox):
|
|||
/ (units.Mpc.to(units.cm)) ** 3)
|
||||
|
||||
def convert_from_box(self, data, names):
|
||||
r"""
|
||||
Convert columns named `names` in array `data` from box units to
|
||||
physical units, such that
|
||||
- length -> :math:`Mpc`,
|
||||
- mass -> :math:`M_\odot`,
|
||||
- velocity -> :math:`\mathrm{km} / \mathrm{s}`,
|
||||
- density -> :math:`M_\odot / \mathrm{Mpc}^3`.
|
||||
|
||||
Any other conversions are currently not implemented. Note that the
|
||||
array is passed by reference and directly modified, even though it is
|
||||
also explicitly returned. Additionally centres the box coordinates on
|
||||
the observer, if they are being transformed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : structured array
|
||||
Input array.
|
||||
names : list of str
|
||||
Columns to be converted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
data : structured array
|
||||
Input array with converted columns.
|
||||
"""
|
||||
names = [names] if isinstance(names, str) else names
|
||||
transforms = {"length": self.box2mpc,
|
||||
"mass": self.box2solarmass,
|
||||
|
@ -372,13 +376,12 @@ class CSiBORGBox(BaseBox):
|
|||
"density": self.box2dens}
|
||||
|
||||
for name in names:
|
||||
# Check that the name is even in the array
|
||||
if name not in data.dtype.names:
|
||||
raise ValueError(f"Name `{name}` not in `data` array.")
|
||||
continue
|
||||
|
||||
# Convert
|
||||
found = False
|
||||
for unittype, suppnames in CONV_NAME.items():
|
||||
for unittype, suppnames in CSIBORG_CONV_NAME.items():
|
||||
if name in suppnames:
|
||||
data[name] = transforms[unittype](data[name])
|
||||
found = True
|
||||
|
@ -389,7 +392,7 @@ class CSiBORGBox(BaseBox):
|
|||
f"Conversion of `{name}` is not defined.")
|
||||
|
||||
# Center at the observer
|
||||
if name in ["peak_x", "peak_y", "peak_z", "x0", "y0", "z0"]:
|
||||
if name in ["x0", "y0", "z0"]:
|
||||
data[name] -= transforms["length"](0.5)
|
||||
|
||||
return data
|
||||
|
@ -427,3 +430,6 @@ class QuijoteBox(BaseBox):
|
|||
@property
|
||||
def boxsize(self):
|
||||
return 1000. / (self._cosmo.H0.value / 100)
|
||||
|
||||
def convert_from_box(self, data, names):
|
||||
raise NotImplementedError("Conversion not implemented for Quijote.")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue