"""
Implements functional forms of known density profiles and code to fit them to simulation data.
At present only the NFW profile is implemented, but the code is designed to be easily extensible to other profiles.
"""
import abc
import numpy as np
from .. import util
[docs]
class AbstractBaseProfile(abc.ABC):
"""
Represents an analytic profile of a halo, and provides a method to fit the profile to data.
The class is organised a dictionary, i.e. profile parameters of a given instance can be accessed through
``profile['...']`` and the available parameters are listed in ``profile.keys()``. The parameters are set at
initialisation and cannot be changed afterwards.
To define a new profile, create a new class inheriting from this base class and define your own profile_functional()
method. The static version can be handy to avoid having to create and object every time.
As a example, the NFW functional is implemented.
A generic fitting function is provided (:meth:`fit`). Given a binned quantity as a function of radius, it
uses least-squares to fit the given functional form to the data.
"""
[docs]
def __init__(self):
self._parameters = dict()
[docs]
@classmethod
@abc.abstractmethod
def parameter_bounds(self, r_values, rho_values):
"""Return bounds on the parameter values for the profile fit
Parameters
----------
r_values : array_like
The radii at which the profile is measured
rho_values : array_like
The density values of the profile
Returns
-------
bounds : tuple[array_like]
A 2-tuple containing lower and upper bounds respectively for the parameters of the profile
"""
pass
[docs]
@abc.abstractmethod
def logarithmic_slope(self, radius):
"""Return the logarithmic slope of the profile, d ln rho / d ln r, at a given radius"""
pass
[docs]
@abc.abstractmethod
def enclosed_mass(self, radius):
"""Return the mass, M(r), enclosed within a given radius"""
pass
[docs]
@classmethod
def fit(cls, radial_data, profile_data, profile_err=None, use_analytical_jac=True, guess=None, verbose=0,
return_profile = True):
"""Fit the given profile using a least-squares method.
Parameters
----------
radial_data : array_like
The central radius of the bins in which the profile data is measured
profile_data : array_like
The profile density values
profile_err : array_like, optional
The error on the profile data
use_analytical_jac : bool
Whether to use the analytical jacobian of the profile function. If False, finite differencing is used.
guess : array_like, optional
An initial guess for the parameters of the profile. If None, the initial guess is taken to be all ones,
according to the underlying ``scipy.optimize.curve_fit`` function.
verbose : int
The verbosity level to pass to the underlying ``scipy.optimize.curve_fit`` function.
return_profile : bool
Whether to return the profile object or just the parameters
Returns
-------
fitted_profile : array_like | AbstractBaseProfile
If return_profile is True, the fitted profile object. Otherwise, the fitted parameters.
cov : array_like
The covariance matrix of the fit. The diagonal elements are the variance of the parameters.
"""
import scipy.optimize as so
# Check data is not corrupted. Some are likely check in curve-fit already
if np.isnan(radial_data).any() or np.isnan(profile_data).any():
raise RuntimeError("Provided data contains NaN values")
if np.count_nonzero(radial_data) != radial_data.size or np.count_nonzero(profile_data) != profile_data.size:
raise RuntimeError("Provided data contains zeroes. This is likely to make the fit fail.")
if radial_data.size != profile_data.size != profile_err.size:
raise RuntimeError("Provided data arrays do not match in shape")
if use_analytical_jac:
def jacobian_wrapper(radius, *args):
return cls(*args).jacobian(radius)
jac = jacobian_wrapper
else:
jac = '3-point'
lower_bounds, upper_bounds = cls.parameter_bounds(radial_data, profile_data)
def profile_wrapper(radius, *args):
return cls(*args)(radius)
if guess is None:
guess = (np.asarray(upper_bounds) + np.asarray(lower_bounds)) / 2.0
try:
parameters, cov = so.curve_fit(profile_wrapper,
radial_data,
profile_data,
sigma=profile_err,
p0=guess,
bounds=(lower_bounds, upper_bounds),
check_finite=True,
jac=jac,
method='trf',
ftol=1e-10,
xtol=1e-10,
gtol=1e-10,
x_scale=1.0,
loss='linear',
f_scale=1.0,
max_nfev=None,
diff_step=None,
tr_solver=None,
verbose=verbose)
except so.OptimizeWarning as w:
raise RuntimeError(str(w))
if (guess is None and any(parameters == np.ones(parameters.shape))) or any(parameters == guess):
raise RuntimeError("Fitted parameters are equal to their initial guess. This is likely a failed fit.")
if return_profile:
return cls(*parameters), cov
else:
return parameters, cov
def __getitem__(self, item):
return self._parameters.__getitem__(item)
def __setitem__(self, key, value):
raise KeyError('Cannot change a parameter from the profile once set')
def __delitem__(self, key):
raise KeyError('Cannot delete a parameter from the profile once set')
def __repr__(self):
return "<" + self.__class__.__name__ + str(list(self.keys())) + ">"
[docs]
def keys(self):
"""Return the keys of the profile parameters"""
return list(self._parameters.keys())
def __contains__(self, item):
return item in self._parameters
[docs]
class NFWProfile(AbstractBaseProfile):
"""Represents a Navarro-Frenk-White (NFW) profile."""
[docs]
def __init__(self, density_scale_radius=None, scale_radius=None, halo_radius=None, concentration=None,
halo_mass=None):
"""Represents a Navarro-Frenk-White (NFW) profile.
The profile can then be initialised through one of the following combination of parameters:
* *scale_radius*, *density_scale_radius* and optionally *halo_radius*;
* *halo_radius*, *concentration* and *density_scale_radius*;
* *halo_radius*, *concentration* and *halo_mass*.
From one mode of initialisation, the derived parameters of the others are calculated, e.g. if you initialise
with halo_mass + concentration, the scale_radius and central density will be derived. The exception is if
you initialise with *scale_radius* + *density_scale_radius* without *halo_radius*.
Units may be passed into the parameters by using scalar arrays.
Parameters
----------
scale_radius : float | array-like, optional
The radius at which the slope is equal to -2
density_scale_radius : float | array-like, optional
1/4 of density at r=rs (normalisation).
halo_mass : float | array-like, optional
The mass enclosed inside the outer halo radius
halo_radius : float | array-like
The outer boundary of the halo (r200m, r200c, rvir ... depending on definitions)
concentration : float | array-like, optional
The outer_radius / scale_radius
"""
super().__init__()
if scale_radius is not None and density_scale_radius is not None and concentration is None and halo_mass is None:
self._parameters['scale_radius'] = scale_radius
self._parameters['density_scale_radius'] = density_scale_radius
if halo_radius is not None:
self._parameters['halo_radius'] = halo_radius
self._parameters['concentration'] = halo_radius / scale_radius
self._parameters['halo_mass'] = self.enclosed_mass(halo_radius)
elif (halo_radius is not None and concentration is not None and density_scale_radius is not None
and halo_mass is None):
self._parameters['halo_radius'] = halo_radius
self._parameters['concentration'] = concentration
self._parameters['density_scale_radius'] = density_scale_radius
self._parameters['scale_radius'] = halo_radius / concentration
self._parameters['halo_mass'] = self.enclosed_mass(halo_radius)
elif (halo_radius is not None and concentration is not None and halo_mass is not None
and density_scale_radius is None):
self._parameters['halo_radius'] = halo_radius
self._parameters['concentration'] = concentration
self._parameters['scale_radius'] = halo_radius / concentration
self._parameters['halo_mass'] = halo_mass
self._parameters['density_scale_radius'] = self._derive_central_overdensity()
else:
raise ValueError("Invalid combination of parameters for initializing NFWProfile.")
[docs]
@classmethod
def parameter_bounds(cls, r_values, rho_values):
profile_lower_bound = np.amin(rho_values)
profile_upper_bound = np.amax(rho_values)
radial_lower_bound = np.amin(r_values)
radial_upper_bound = np.amax(r_values)
return ([profile_lower_bound, radial_lower_bound], [profile_upper_bound, radial_upper_bound])
def jacobian(self, radius):
density_scale_radius = self._parameters['density_scale_radius']
scale_radius = self._parameters['scale_radius']
d_scale_radius = density_scale_radius * (3 * radius / scale_radius + 1) / (
radius * (1 + radius / scale_radius) ** 3)
d_central_density = 1 / ((radius / scale_radius) * (1 + radius / scale_radius) ** 2)
return np.transpose([d_central_density, d_scale_radius])
def __call__(self, radius):
density_scale_radius = self._parameters['density_scale_radius']
scale_radius = self._parameters['scale_radius']
return density_scale_radius / ((radius / scale_radius) * (1.0 + (radius / scale_radius)) ** 2)
[docs]
def enclosed_mass(self, radius):
# Eq 7.139 in M vdB W
return self._parameters['density_scale_radius'] * self._parameters['scale_radius'] ** 3 \
* self._integral(radius / self._parameters['scale_radius'])
def _derive_concentration(self):
return self._parameters['halo_radius'] / self._parameters['scale_radius']
def _derive_scale_radius(self):
return self._parameters['halo_radius'] / self._parameters['concentration']
def _derive_central_overdensity(self):
return self._parameters['halo_mass'] / (self._parameters['scale_radius']**3 *
self._integral(self._parameters['concentration']))
[docs]
def logarithmic_slope(self, radius):
scale_radius = self._parameters['scale_radius']
return - (1.0 + 3.0 * radius / scale_radius) / (1.0 + radius / scale_radius)
@staticmethod
def _integral(x):
return 4 * np.pi * (np.log(1.0 + x) - x / (1.0 + x))
[docs]
@util.deprecated("Deprecated alias for NFWProfile. Use NFWProfile instead.")
def NFWprofile(*args, **kwargs):
return NFWProfile(*args, **kwargs)