"""Density profiles for use in Pisces models.
The :mod:`~profiles.density` module provides a number of built-in profiles to model density and surface density
profiles in various contexts, including for galaxies, clusters of galaxies, stars, etc.
"""
from abc import ABC
from typing import TYPE_CHECKING, Optional, Union
import numpy as np
import sympy as sp
import unyt
from scipy.integrate import quad, quad_vec
from pisces.math_utils.integration import integrate_mass
from pisces.profiles.base import (
BaseCylindricalDiskProfile,
BaseProfile,
BaseSphericalRadialProfile,
derived_profile,
)
from pisces.utilities.config import pisces_config
# Type Hints
_UnitType = Union[str, unyt.Unit]
_UnitValue = Union[unyt.unyt_array, unyt.unyt_quantity]
if TYPE_CHECKING:
from astropy.cosmology import Cosmology
# ======================================= #
# Radial Profiles (Spherical) #
# ======================================= #
[docs]
class BaseSphericalDensityProfile(BaseSphericalRadialProfile, ABC):
r"""Abstract base class defining the shared behaviors of all spherically symmetric density profiles.
This class provides a unified interface for constructing and analyzing
spherical density profiles common in astrophysics, such as those describing
dark matter halos, stellar distributions, or gas profiles.
Several additional features are implemented to support both symbolic
and numerical computations within a consistent, unit-aware framework.
Features
--------
.. dropdown:: Derived Profiles
The following symbolic derived profiles are automatically constructed and accessible
via :meth:`~profiles.base.BaseProfile.get_derived_profile`:
- ``derivative`` : Radial derivative of the density profile (from parent class).
- ``enclosed_mass`` : The mass enclosed within radius :math:`r`.
.. math::
M(<r) = \int_0^r dr' 4\pi r'^2 \rho(r')
- ``gravitational_field`` : Symbolic gravitational field at radius ``r``.
.. math::
\nabla \Phi = \frac{-GM(<r)}{r^2} = \frac{-G}{r^2} \int_0^r dr' 4\pi r'^2 \rho(r')
- ``gravitational_potential`` : Symbolic gravitational potential at radius ``r``.
.. math::
\Phi = \frac{GM(<r)}{r} = \frac{G}{r} \int_0^r dr' 4\pi r'^2 \rho(r')
.. dropdown:: Numerical Operations
This class provides several numerical methods for common astrophysical computations:
- **Gravitational Operations**:
- :meth:`compute_circular_velocity` : The velocity of a circular orbit at
a given radius.
- :meth:`compute_escape_velocity` : The escape velocity as a function of
the radius.
- **Projection**:
- :meth:`compute_surface_density` : Projected surface density profile.
- **Mathematical Operations**:
- :meth:`compute_total_mass` : Total mass (if the profile converges).
- :meth:`compute_enclosed_mass` : Numerical enclosed mass profile.
- :meth:`compute_fractional_mass_radius` : Radius enclosing a target mass fraction.
- **Cosmology**:
- :meth:`compute_cosmological_overdensity_profile` : Overdensity relative to critical density.
- :meth:`compute_cosmological_overdensity_radius` : Radius enclosing a target overdensity.
- **Lensing**:
- :meth:`compute_deflection_angle` : Gravitational lensing deflection angle.
- :meth:`compute_einstein_radius` : Einstein ring angular radius.
- :meth:`compute_lensing_convergence` : Lensing convergence :math:`\kappa(R)`.
Notes
-----
- This class is abstract; concrete subclasses must set ``__IS_ABSTRACT__ = False``.
- All subclasses define ``__function__`` and ``__function_units__`` to specify
the symbolic density expression and its units.
- Units are handled consistently using :mod:`unyt`.
- Cosmological methods require :mod:`astropy.cosmology` and a valid cosmology.
- Derived profiles follow the standard symbolic setup of :class:`BaseProfile`.
Examples
--------
**Subclassing Example**
.. code-block:: python
from pisces.profiles.density import (
BaseSphericalDensityProfile,
)
import sympy as sp
import unyt
class SimplePowerLawDensityProfile(
BaseSphericalDensityProfile
):
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": 1.0 * unyt.Msun / unyt.kpc**3
}
@classmethod
def __function__(cls, r, rho_0=1.0):
return rho_0 / r**2
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt_quantity("1 Msun/kpc**3"),
):
r, rho_0 = unyt.Unit(r), unyt.Unit(rho_0)
return rho_0 / r**2
**Numerical Usage**
.. code-block:: python
profile = SimplePowerLawDensityProfile(
rho_0=0.1 * unyt.Msun / unyt.kpc**3
)
# Density at radius 1 kpc
rho = profile(1.0 * unyt.kpc)
# Enclosed mass at 5 kpc
M_enc = profile.compute_enclosed_mass(
5 * unyt.kpc
)
# Surface density profile
Sigma = profile.compute_surface_density(
2.0 * unyt.kpc
)
# Gravitational potential as symbolic derived profile
phi_profile = profile.get_derived_profile(
"gravitational_potential"
)
phi_expr = phi_profile.__function__(
sp.Symbol("r"), G=1.0, rho_0=1.0
)
See Also
--------
~profiles.base.BaseSphericalRadialProfile, ~profiles.base.BaseProfile, NFWDensityProfile, HernquistDensityProfile
"""
__IS_ABSTRACT__ = True
# ------------------------------ #
# Numerical Computations #
# ------------------------------ #
[docs]
def compute_gravitational_field(
self,
r: _UnitValue,
units: _UnitType | None = None,
G: unyt.unyt_quantity | None = None,
**kwargs,
) -> _UnitValue:
r"""Numerically compute the gravitational field :math:`g(r)` at radius :math:`r`.
The radial gravitational field in a spherically symmetric potential is
.. math::
g(r) = -\frac{G M(r)}{r^2},
where :math:`M(r)` is the enclosed mass at radius :math:`r` and :math:`G`
is the gravitational constant.
Parameters
----------
r : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Radius or array of radii at which to compute the gravitational field.
Must carry length units (e.g. ``kpc``, ``pc``).
units : str or ~unyt.unit_object.Unit, optional
Desired output units for the gravitational field. If not provided,
the natural units implied by ``G`` and the profile parameters are used.
G : ~unyt.array.unyt_quantity, optional
Gravitational constant to use. Defaults to
:data:`unyt.physical_constants.gravitational_constant`.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
g : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Gravitational field at each input radius with appropriate units.
Notes
-----
- This method always returns the *magnitude* of the inward field (negative sign included).
- Assumes spherical symmetry.
Examples
--------
Compute the gravitational field for an Einasto profile:
.. code-block:: python
import numpy as np
import unyt
from pisces.profiles.density import (
EinastoDensityProfile,
)
profile = EinastoDensityProfile(
rho_0=0.01 * unyt.Msun / unyt.kpc**3,
r_s=20 * unyt.kpc,
alpha=0.2,
)
radii = np.logspace(0, 2, 50) * unyt.kpc
g = profile.compute_gravitational_field(
radii, units="km/s**2"
)
print(g)
See Also
--------
compute_gravitational_potential, compute_enclosed_mass, compute_circular_velocity
"""
G = G if G is not None else unyt.physical_constants.gravitational_constant
r_array = unyt.array.unyt_array(r)
m_enc = self.compute_enclosed_mass(r_array, **kwargs)
g = -G * m_enc / r_array**2
return g.to(units) if units else g
[docs]
def compute_gravitational_potential(
self,
r: _UnitValue,
units: _UnitType | None = None,
G: unyt.unyt_quantity | None = None,
**kwargs,
) -> _UnitValue:
r"""Numerically compute the gravitational potential :math:`\Phi(r)`.
The potential of a spherical mass distribution can be expressed as
.. math::
\Phi(r) = -G \left[
\frac{M(r)}{r} \;+\; 4 \pi \int_r^\infty \rho(r') \, r' \, dr'
\right],
where :math:`M(r)` is the enclosed mass within radius :math:`r` and
the second term accounts for contributions from shells at :math:`r' > r`.
This formulation ensures convergence even for profiles with infinite
total mass (e.g., NFW), provided the density falls off sufficiently
fast at large radii.
Parameters
----------
r : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Radius or array of radii at which to compute the potential.
Must carry length units.
units : str or ~unyt.unit_object.Unit, optional
Desired output units for the potential. Defaults to
``cm**2/s**2`` if not specified.
G : ~unyt.unyt_quantity, optional
Gravitational constant to use. Defaults to
:data:`unyt.physical_constants.gravitational_constant`.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass`
and the integration routine.
Returns
-------
phi : ~unyt.unyt_quantity or ~unyt.unyt_array
Gravitational potential at each radius.
Notes
-----
- The potential is defined to vanish at infinity.
- The boundary term :math:`M(r)/r` is explicitly included.
- Uses the internal :func:`pisces.math_utils.integration.integrate`
utility for stable quadrature.
Examples
--------
.. code-block:: python
import numpy as np, unyt
from pisces.profiles.density import (
EinastoDensityProfile,
)
profile = EinastoDensityProfile(
rho_0=0.01 * unyt.Msun / unyt.kpc**3,
r_s=20 * unyt.kpc,
alpha=0.18,
)
radii = np.logspace(0, 2, 50) * unyt.kpc
phi = profile.compute_gravitational_potential(
radii, units="km**2/s**2"
)
print(phi)
See Also
--------
compute_gravitational_field, compute_enclosed_mass, compute_escape_velocity
"""
from pisces.math_utils.integration import integrate_toinf
# Gravitational constant
G = G if G is not None else unyt.physical_constants.gravitational_constant
# Ensure radius is a unyt_array
r_array = unyt.array.unyt_array(r)
r_unit = r_array.units
# Define integrand: rho(r') * r'
def _integrand(rp):
return self.__call_no_units__(rp) * rp
# Perform integration from r to infinity
integral_vals = integrate_toinf(_integrand, r_array.d)
# Attach units: density * length^2
integral_vals = unyt.unyt_array(
integral_vals * 4 * np.pi,
units=self.get_output_units(r_unit) * r_unit**2,
)
# Boundary term: M(r)/r
M_r = self.compute_enclosed_mass(r_array, **kwargs)
# Potential: -G [ M(r)/r + 4π int ρ(r') r' dr' ]
phi = -G * (M_r / r_array + integral_vals)
# Convert to desired units and scalarize if needed
phi = phi.to(units) if units else phi
if np.isscalar(r):
return phi[0]
return phi
[docs]
def compute_surface_density(self, R: _UnitValue, units: _UnitType | None = None, **kwargs) -> _UnitValue:
r"""Numerically compute the projected surface density at radius R from the origin.
Mathematically, this is the operation
.. math::
\Sigma(R) = 2 \int_0^\infty \rho \left( \sqrt{R^2 + z^2} \right) dz,
where :math:`z` refers to the displacement along the line of sight.
For some value of :math:`R`, this method will perform the relevant integral
of the profile and provide the calculated surface density.
Parameters
----------
R : ~unyt.array.unyt_array or ~unyt.array.unyt_quantity
The projected radius from the origin in physical units with dimension length.
`R` may be either an array of values or a scalar.
units: ~unyt.unit_object.Unit or str, optional
The units in which to return the calculated surface density. By
default, the internal units are preserved.
kwargs:
Additional keyword arguments to pass on to :func:`~scipy.integrate.quad_vec`.
Returns
-------
sigma : ~unyt.array.unyt_array or ~unyt.array.unyt_quantity
Surface density at each `R`, with correct units.
Example
-------
.. code-block:: python
import numpy as np
from pisces.profiles.density import (
NFWDensityProfile,
)
# Create a density profile with characteristic parameters
profile = NFWDensityProfile(rho_0=1.0, r_s=10.0)
# Define projected radii (can be a scalar or array)
R = np.linspace(0.1, 100.0, 50) # kpc
# Compute the surface density at each radius
sigma = profile.compute_surface_density(
R, units="Msun/pc**2"
)
print(sigma)
"""
# Coerce the input R array into a valid unyt_array and
# then extract the raw buffer and units.
R_array = unyt.array.unyt_array(R)
r_arr, r_units = R_array.d, R_array.units
# Begin with the actual integration step before
# concerning ourselves with the units. We define
# the integrand in a unit-less form.
def _integrand_(z, _rr):
xi = np.sqrt(_rr**2 + z**2)
return self.__call_no_units__(xi)
# Perform the integration vis-a-vis the quad_vec function
# from SciPy.
result = 2 * quad_vec(_integrand_, 0, np.inf, args=(r_arr,), full_output=False, **kwargs)[0]
# Determine the units. We have standard propagation
# to get rho, and then we integrate through by z, which
# should ARBITRARILY have the same units as _rr, so we
# just multiply by a factor of the length unit.
sd_unit = self.get_output_units(r_units) * r_units
sd = result * sd_unit
if units is not None:
sd = sd.to(units)
if np.isscalar(R):
return sd[0]
return sd
[docs]
def compute_enclosed_mass(self, r: _UnitValue, units: _UnitType | None = None, **kwargs) -> _UnitValue:
r"""Numerically compute the enclosed mass :math:`M(r)` within radius :math:`r`.
The enclosed mass is given by:
.. math::
M(r) = 4 \pi \int_0^r \rho(r') \, r'^2 \, dr',
where :math:`\rho(r)` is the spherically symmetric density profile.
Parameters
----------
r : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Radius or array of radii at which to compute enclosed mass. Must carry length units (e.g., kpc, pc).
units : str or ~unyt.unit_object.Unit, optional
Desired output units for mass. If not provided, output units follow from the profile parameters.
**kwargs
Additional arguments passed to :func:`scipy.integrate.quad`.
Returns
-------
mass : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Enclosed mass at each radius with appropriate units.
Notes
-----
- Assumes spherical symmetry.
- Uses :func:`scipy.integrate.quad` for numerical integration.
- Units are handled automatically using :mod:`unyt`.
Examples
--------
Compute enclosed mass for a Hernquist profile:
.. code-block:: python
import numpy as np
import unyt
from pisces.profiles.density import (
HernquistDensityProfile,
)
profile = HernquistDensityProfile(
rho_0=0.05 * unyt.Msun / unyt.kpc**3,
r_s=10 * unyt.kpc,
)
radii = np.logspace(-1, 2, 100) * unyt.kpc
mass = profile.compute_enclosed_mass(
radii, units="Msun"
)
print(mass)
See Also
--------
:meth:`compute_total_mass`, :meth:`compute_surface_density`, :func:`scipy.integrate.quad`
"""
r_array = unyt.array.unyt_array(r)
r_values, r_unit = r_array.value, r_array.units
# Units for density and mass
rho_unit = self.get_output_units(r_unit)
mass_unit = rho_unit * r_unit**3
# Unitless density function for integration
def _density_fn(r_):
return self.__call_no_units__(r_)
# Enclosed mass integral for each radius
mass_values = integrate_mass(_density_fn, r_values, **kwargs)
mass = mass_values * mass_unit
if units is not None:
mass = mass.to(units)
if np.isscalar(r):
return mass[0]
return mass
[docs]
def compute_total_mass(self, units: _UnitType | None = None, **kwargs) -> unyt.unyt_quantity:
r"""Numerically compute the total mass of the profile by integrating to infinity.
.. math::
M_{\mathrm{tot}} = 4 \pi \int_0^\infty \rho(r) \, r^2 \, dr
If the profile has finite total mass, this method returns its value. Divergent profiles
will raise a :class:`RuntimeError`.
Parameters
----------
units : str or ~unyt.unit_object.Unit, optional
Desired output units for mass. Defaults to units implied by the profile parameters.
**kwargs
Additional arguments passed to :func:`scipy.integrate.quad`.
Returns
-------
mass : ~unyt.array.unyt_quantity
Total mass with correct units.
Raises
------
RuntimeError
If the integral fails to converge or the result is infinite/NaN.
Examples
--------
.. code-block:: python
import unyt
from pisces.profiles.density import (
PlummerDensityProfile,
)
profile = PlummerDensityProfile(
M=1e11 * unyt.Msun, r_s=5 * unyt.kpc
)
total_mass = profile.compute_total_mass(
units="Msun"
)
print(f"Total mass: {total_mass:.3e}")
See Also
--------
:meth:`compute_enclosed_mass`, :func:`scipy.integrate.quad`
"""
import warnings
from scipy.integrate import IntegrationWarning
# Use arbitrary length scale (units cancel internally)
r_unit = unyt.Unit("kpc")
rho_unit = self.get_output_units(r_unit)
mass_unit = rho_unit * r_unit**3
with warnings.catch_warnings(record=True) as warning_list:
warnings.simplefilter("always", IntegrationWarning)
def _density_fn(r_):
return self(r_).value
result, err = quad(lambda r_: _density_fn(r_) * 4 * np.pi * r_**2, 0, np.inf, **kwargs)[:2]
# Check for divergence (nan/inf result)
if not np.isfinite(result):
raise RuntimeError("Total mass integral did not converge: result is infinite or NaN.")
# Check for integration warnings (e.g., max subdivisions exceeded)
for warn in warning_list:
if issubclass(warn.category, IntegrationWarning):
raise RuntimeError(f"Total mass integral raised an IntegrationWarning: {warn.message}")
# Handle the units.
value = result * mass_unit
if units is not None:
return value.to(units)
else:
return value
[docs]
def compute_fractional_mass_radius(
self,
rmin: float | unyt.unyt_quantity,
rmax: float | unyt.unyt_quantity,
fraction: float = 0.5,
units: _UnitType | None = "kpc",
**kwargs,
):
r"""Find the radius enclosing a given fraction of the total mass.
Solves numerically for radius :math:`r_f` such that:
.. math::
M(r_f) = f \times M_{\mathrm{tot}}
where :math:`f` is the desired mass fraction.
Parameters
----------
rmin : float or ~unyt.array.unyt_quantity
Lower bound for the radius search interval.
rmax : float or ~unyt.array.unyt_quantity
Upper bound for the radius search interval.
fraction : float, optional
Mass fraction to enclose, between 0 and 1. Default is 0.5 (half-mass radius).
units : str or ~unyt.unit_object.Unit, optional
Units for `rmin` and `rmax`. Default is ``kpc``.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass` and :meth:`compute_total_mass`.
Returns
-------
radius : ~unyt.array.unyt_quantity
Radius enclosing the specified mass fraction.
Raises
------
ValueError
If the mass fraction is outside (0, 1) or search bounds are invalid.
Examples
--------
.. code-block:: python
import unyt
from pisces.profiles.density import (
HernquistDensityProfile,
)
profile = HernquistDensityProfile(
rho_0=0.05 * unyt.Msun / unyt.kpc**3,
r_s=10 * unyt.kpc,
)
half_mass_radius = (
profile.compute_fractional_mass_radius(
0.1, 100, fraction=0.5, units="kpc"
)
)
print(f"Half-mass radius: {half_mass_radius:.2f}")
See Also
--------
:meth:`compute_total_mass`, :meth:`compute_enclosed_mass`, :func:`scipy.optimize.brentq`
"""
from scipy.optimize import brentq
if not 0 < fraction < 1:
raise ValueError("Fraction must be between 0 and 1.")
# Establish unit system
unit = unyt.Unit(units)
# Handle rmin and rmax as unit-consistent floats
rmin = unyt.array.unyt_quantity(rmin, unit).to_value(unit)
rmax = unyt.array.unyt_quantity(rmax, unit).to_value(unit)
if rmin <= 0 or rmax <= rmin:
raise ValueError("Require 0 < rmin < rmax for valid search bounds.")
# Compute total mass with error handling
try:
total_mass = self.compute_total_mass(units="g", **kwargs).value
except Exception as e:
raise ValueError(f"Total mass calculation did not converge: {e}") from e
# Residual function for root finding
def _residual(r_):
m_enc = self.compute_enclosed_mass(r_ * unit, **kwargs).to_value("g")
return m_enc - (fraction * total_mass)
# Root finding
r_solution = brentq(_residual, rmin, rmax)
return r_solution * unit
[docs]
def compute_cosmological_overdensity_profile(
self, z: float, R: _UnitValue, cosmology: Optional["Cosmology"] = None, **kwargs
):
r"""Compute the spherical overdensity profile relative to the critical density at redshift :math:`z`.
The overdensity is defined as:
.. math::
\Delta(R) = \frac{3 M(R)}{4 \pi R^3 \rho_{\mathrm{crit}}(z)}
where :math:`M(R)` is the enclosed mass and :math:`\rho_{\mathrm{crit}}(z)` is the critical density.
Parameters
----------
z : float
Redshift at which to compute the critical density.
R : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Radius or array of radii where overdensity is computed (must carry length units).
cosmology : ~astropy.cosmology.Cosmology, optional
Cosmology used to compute :math:`\rho_{\mathrm{crit}}(z)`. Defaults to
``pisces_config['physics.default_cosmology']``.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
overdensity : ~numpy.ndarray
Dimensionless overdensity :math:`\Delta(R)` at each radius.
Raises
------
ValueError
If cosmology cannot compute the critical density.
Examples
--------
.. code-block:: python
import unyt
from astropy.cosmology import Planck18
from pisces.profiles.density import (
NFWDensityProfile,
)
profile = NFWDensityProfile(
rho_0=0.05 * unyt.Msun / unyt.kpc**3,
r_s=10 * unyt.kpc,
)
R = unyt.array.unyt_array([1, 10, 100], "kpc")
delta = profile.compute_cosmological_overdensity_profile(
z=0.5, R=R, cosmology=Planck18
)
print(delta)
See Also
--------
:meth:`compute_enclosed_mass`
:meth:`compute_cosmological_overdensity_radius`
:class:`astropy.cosmology.Cosmology`
"""
# Determine the cosmology and extract the critical
# density from it. This requires accessing the configuration
# and loading astropy cosmology.
import astropy.cosmology as cosmo
if cosmology is None:
_default = pisces_config["physics.default_cosmology"]
try:
cosmology = getattr(cosmo, _default)
except Exception as exp:
raise ValueError(f"Default cosmology `{_default}` is not available in astropy cosmology.") from exp
# Coerce the inputs so that we have the
# relevant length units. We then propagate
# to get the density units so that we have minimal
# FPE issues.
R_array = unyt.array.unyt_array(R)
density_array = self(R_array)
# Try to obtain the critical density from
# the desired cosmology. We force to CGS units
# so that we don't have to worry about unyt / astropy unit
# operation parity.
try:
rho_crit = cosmology.critical_density(z).to_value(str(density_array.units))
except Exception as exp:
raise ValueError(f"Provided cosmology object failed to compute critical density: {exp}") from exp
# Compute the mass and the relevant overdensities.
rho_crit = rho_crit * density_array.units
mass = self.compute_enclosed_mass(R_array, **kwargs)
delta = (3 * mass) / (4 * np.pi * R_array**3 * rho_crit)
return delta.d
[docs]
def compute_cosmological_overdensity_radius(
self,
z: float,
delta_target: float,
rmin: float | unyt.unyt_quantity,
rmax: float | unyt.unyt_quantity,
units: _UnitType | None = "kpc",
cosmology: Optional["Cosmology"] = None,
**kwargs,
):
r"""Find the radius enclosing a target overdensity relative to the critical density at redshift :math:`z`.
Numerically solves for radius :math:`r_{\Delta}` satisfying:
.. math::
\Delta(r_{\Delta}) = \Delta_{\mathrm{target}}
Parameters
----------
z : float
Redshift at which to compute overdensity.
delta_target : float
Desired overdensity value (must be positive).
rmin : float
Lower bound for root-finding search interval, in units specified by ``units``.
rmax : float
Upper bound for root-finding search interval.
units : str or ~unyt.unit_object.Unit, optional
Units for ``rmin`` and ``rmax``. Default is ``kpc``.
cosmology : :class:`~astropy.cosmology.Cosmology`, optional
Cosmology used to compute :math:`\rho_{\mathrm{crit}}(z)`. Defaults to ``pisces_config``.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
r_delta : ~unyt.array.unyt_quantity
Radius enclosing the target overdensity.
Raises
------
ValueError
If inputs are invalid or critical density calculation fails.
Examples
--------
.. code-block:: python
from astropy.cosmology import Planck18
from pisces.profiles.density import (
HernquistDensityProfile,
)
profile = HernquistDensityProfile(
rho_0=0.05, r_s=10
)
r_delta = profile.compute_cosmological_overdensity_radius(
z=0.3,
delta_target=200,
rmin=1,
rmax=500,
units="kpc",
cosmology=Planck18,
)
print(f"r_200 = {r_delta:.2f}")
See Also
--------
:meth:`compute_cosmological_overdensity_profile`, :meth:`compute_enclosed_mass`, :func:`scipy.optimize.brentq`
"""
import astropy.cosmology as cosmo
from scipy.optimize import brentq
# Validate that the delta_target is legitimate and
# coerce the units so that they behave nicely.
if delta_target <= 0:
raise ValueError("Overdensity target must be positive.")
unit = unyt.Unit(units)
rmin = unyt.array.unyt_quantity(rmin, unit).to_value(unit)
rmax = unyt.array.unyt_quantity(rmax, unit).to_value(unit)
if rmin <= 0 or rmax <= rmin:
raise ValueError("Require 0 < rmin < rmax for valid search bounds.")
# Resolve cosmology, fallback to pisces_config
if cosmology is None:
_default = pisces_config["physics.default_cosmology"]
try:
cosmology = getattr(cosmo, _default)
except Exception as exp:
raise ValueError(f"Default cosmology `{_default}` is not available in astropy.cosmology.") from exp
# Get critical density with units consistent to profile density
rho_unit = self.get_output_units(unit)
try:
rho_crit = cosmology.critical_density(z).to_value(str(rho_unit))
except Exception as exp:
raise ValueError(f"Failed to compute critical density with provided cosmology: {exp}") from exp
# Residual function for root-finding
rho_crit = rho_crit * rho_unit
def _residual(r_):
mass = self.compute_enclosed_mass(r_ * unit, **kwargs)
delta_r = (3 * mass) / (4 * np.pi * (r_ * unit) ** 3 * rho_crit)
return delta_r.to_value() - delta_target
# Root finding
r_solution = brentq(_residual, rmin, rmax)
return r_solution * unit
[docs]
def compute_circular_velocity(
self,
r: _UnitValue,
units: _UnitType | None = None,
G: unyt.unyt_quantity | None = None,
**kwargs,
) -> _UnitValue:
r"""Compute the circular velocity at radius :math:`r`.
For a test particle in circular orbit, the gravitational force provides the necessary
centripetal acceleration. The circular velocity is given by:
.. math::
v_c(r) = \sqrt{ \frac{G M(r)}{r} },
where :math:`M(r)` is the enclosed mass at radius :math:`r`.
Parameters
----------
r : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Radius or array of radii at which to compute the circular velocity (must carry length units).
units : str or ~unyt.Unit, optional
Desired output units for the circular velocity. By default, uses the internal
units determined by `G` and the profile parameters.
G : ~unyt.array.unyt_quantity, optional
Gravitational constant to use. Defaults to :data:`~unyt.physical_constants.gravitational_constant`.
kwargs : dict
Additional keyword arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
v_c : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Circular velocity at each input radius, with appropriate units.
Example
-------
.. code-block:: python
import numpy as np
from pisces.profiles.density import (
NFWDensityProfile,
)
profile = NFWDensityProfile(rho_0=1.0, r_s=10.0)
r = np.linspace(0.1, 100.0, 50) # kpc
v_circ = profile.compute_circular_velocity(
r, units="km/s"
)
print(v_circ)
"""
# Extract G.
G = G if G is not None else unyt.physical_constants.gravitational_constant
# Enforce units on the radial array so
# that we know its an unyt_array.
r_array = unyt.array.unyt_array(r)
m_enc = self.compute_enclosed_mass(r_array, **kwargs)
# Now compute the profile.
v_c = np.sqrt(G * m_enc / r_array)
if units is None:
return v_c
else:
return v_c.to(units)
[docs]
def compute_escape_velocity(
self,
r: _UnitValue,
units: _UnitType | None = None,
G: unyt.unyt_quantity | None = None,
**kwargs,
) -> _UnitValue:
r"""Compute the escape velocity at radius :math:`r`.
The escape velocity is the minimum speed required for a test particle to
escape to infinity from radius :math:`r` in a spherically symmetric gravitational potential:
.. math::
v_{\mathrm{esc}}(r) = \sqrt{ \frac{2 G M(r)}{r} },
where :math:`M(r)` is the enclosed mass at radius :math:`r`.
Parameters
----------
r : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Radius or array of radii at which to compute the escape velocity (must carry length units).
units : str or ~unyt.Unit, optional
Desired output units for the escape velocity. By default,
uses the internal units determined by `G` and the profile parameters.
G : ~unyt.array.unyt_quantity, optional
Gravitational constant to use. Defaults to :data:`~unyt.physical_constants.gravitational_constant`.
kwargs : dict
Additional keyword arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
v_esc : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Escape velocity at each input radius, with appropriate units.
Example
-------
.. code-block:: python
import numpy as np
from pisces.profiles.density import (
HernquistDensityProfile,
)
profile = HernquistDensityProfile(
rho_0=1.0, r_s=5.0
)
r = np.logspace(-1, 2, 50) # kpc
v_esc = profile.compute_escape_velocity(
r, units="km/s"
)
print(v_esc)
"""
G = G if G is not None else unyt.physical_constants.gravitational_constant
r_array = unyt.array.unyt_array(r)
m_enc = self.compute_enclosed_mass(r_array, **kwargs)
v_esc = np.sqrt(2 * G * m_enc / r_array)
if units is None:
return v_esc
else:
return v_esc.to(units)
[docs]
def compute_deflection_angle(
self,
R: _UnitValue,
mode: str = "angular",
units: _UnitType | None = "arcsec",
G: unyt.unyt_quantity | None = None,
z_lens: float | None = None,
z_source: float | None = None,
D_l: _UnitValue | None = None,
D_s: _UnitValue | None = None,
D_ls: _UnitValue | None = None,
cosmology: Optional["Cosmology"] = None,
**kwargs,
) -> _UnitValue:
r"""Compute the gravitational lensing deflection angle at projected radius :math:`R`.
Supports both **physical** and **angular** deflection:
- **Physical Deflection** (length units):
.. math::
\alpha_{\mathrm{phys}}(R) = \frac{4 G M(<R)}{c^2 R}
- **Angular Deflection** (default, angular units):
.. math::
\alpha_{\mathrm{ang}}(R) = \alpha_{\mathrm{phys}}(R) \times \frac{D_{ls}}{D_s}
where:
- :math:`M(<R)` is the enclosed mass at projected radius :math:`R`
- :math:`D_l`, :math:`D_s`, :math:`D_{ls}` are angular diameter distances to lens,
source, and between lens and source, respectively.
Parameters
----------
R : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Projected radius in the lens plane with length units.
mode : {"physical", "angular"}, optional
Type of deflection to compute. Default is ``angular``.
units : str or ~unyt.unit_object.Unit, optional
Output units. For angular deflection, defaults to ``arcsec``.
G : ~unyt.array.unyt_quantity, optional
Gravitational constant to use. Defaults to :data:`unyt.physical_constants.gravitational_constant`.
z_lens : float, optional
Redshift of the lens (required for angular mode if distances not provided).
z_source : float, optional
Redshift of the source (required for angular mode if distances not provided).
D_l : ~unyt.array.unyt_quantity, optional
Angular diameter distance to the lens.
D_s : ~unyt.array.unyt_quantity, optional
Angular diameter distance to the source.
D_ls : ~unyt.array.unyt_quantity, optional
Angular diameter distance between lens and source.
cosmology : :class:`astropy.cosmology.Cosmology`, optional
Cosmology used to compute distances if not provided. Defaults to
``pisces_config['physics.default_cosmology']``.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
alpha : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Deflection angle at each radius, in specified units.
Raises
------
ValueError
If necessary distances are missing or redshifts are inconsistent.
Examples
--------
.. code-block:: python
import unyt
from astropy.cosmology import Planck18
from pisces.profiles.density import (
NFWDensityProfile,
)
profile = NFWDensityProfile(rho_0=0.03, r_s=15)
R = unyt.array.unyt_array([1, 5, 10], "kpc")
alpha_arcsec = profile.compute_deflection_angle(
R,
mode="angular",
units="arcsec",
z_lens=0.3,
z_source=2.0,
cosmology=Planck18,
)
print(alpha_arcsec)
See Also
--------
:meth:`compute_enclosed_mass`, :class:`astropy.cosmology.Cosmology`
"""
import astropy.cosmology as cosmo
mode = mode.lower()
if mode not in {"physical", "angular"}:
raise ValueError(f"Invalid mode '{mode}'. Choose 'physical' or 'angular'.")
G = G if G is not None else unyt.physical_constants.gravitational_constant
c = unyt.physical_constants.speed_of_light
R_array = unyt.array.unyt_array(R)
m_enc = self.compute_enclosed_mass(R_array, **kwargs)
# Physical deflection term: length units
alpha_phys = (4 * G * m_enc) / (c**2 * R_array)
if mode == "physical":
return alpha_phys.to(units) if units else alpha_phys
# Angular deflection requires distances
if D_l is None or D_s is None or D_ls is None:
if z_lens is None or z_source is None:
raise ValueError(
"Must provide either (D_l, D_s, D_ls) or (z_lens and z_source) for angular deflection."
)
if cosmology is None:
_default = pisces_config["physics.default_cosmology"]
try:
cosmology = getattr(cosmo, _default)
except Exception as exp:
raise ValueError(f"Default cosmology `{_default}` is not available in astropy.cosmology.") from exp
D_s = cosmology.angular_diameter_distance(z_source).to("kpc")
D_ls = cosmology.angular_diameter_distance_z1z2(z_lens, z_source).to("kpc")
# Apply distance ratio for angular deflection
alpha_ang = alpha_phys * (D_ls / D_s)
return alpha_ang.to(units) if units else alpha_ang
[docs]
def compute_einstein_radius(
self,
z_lens: float,
z_source: float,
units: _UnitType | None = "arcsec",
G: unyt.unyt_quantity | None = None,
cosmology: Optional["Cosmology"] = None,
**kwargs,
) -> _UnitValue:
r"""Compute the Einstein ring angular radius :math:`\\theta_E` for a perfectly aligned source-lens system.
The Einstein radius satisfies:
.. math::
\alpha( \theta_E D_l ) = \theta_E
where:
- :math:`\alpha` is the angular deflection angle,
- :math:`D_l` is the angular diameter distance to the lens.
Parameters
----------
z_lens : float
Redshift of the lens.
z_source : float
Redshift of the source (must satisfy :math:`z_{\mathrm{source}} > z_{\mathrm{lens}}`).
units : str or ~unyt.unit_object.Unit, optional
Desired output units for :math:`\\theta_E`. Default is ``arcsec``.
G : ~unyt.array.unyt_quantity, optional
Gravitational constant to use. Defaults to :data:`unyt.physical_constants.gravitational_constant`.
cosmology : :class:`astropy.cosmology.Cosmology`, optional
Cosmology used to compute distances. Defaults to ``pisces_config['physics.default_cosmology']``.
**kwargs
Additional arguments passed to :meth:`compute_enclosed_mass`.
Returns
-------
theta_E : ~unyt.array.unyt_quantity
Einstein radius in specified angular units.
Raises
------
ValueError
If redshifts are invalid or distances cannot be computed.
Examples
--------
.. code-block:: python
from astropy.cosmology import Planck18
from pisces.profiles.density import (
HernquistDensityProfile,
)
profile = HernquistDensityProfile(
rho_0=0.02, r_s=5
)
theta_E = profile.compute_einstein_radius(
z_lens=0.3, z_source=2.0, cosmology=Planck18
)
print(f"Einstein radius: {theta_E:.3f}")
See Also
--------
:meth:`compute_deflection_angle`, :class:`astropy.cosmology.Cosmology`
"""
import astropy.cosmology as cosmo
from scipy.optimize import brentq
if z_source <= z_lens:
raise ValueError("Source redshift must be greater than lens redshift.")
if cosmology is None:
_default = pisces_config["physics.default_cosmology"]
try:
cosmology = getattr(cosmo, _default)
except Exception as exp:
raise ValueError(f"Default cosmology `{_default}` is not available in astropy.cosmology.") from exp
D_l = cosmology.angular_diameter_distance(z_lens).to("kpc")
# Residual function: deflection angle minus angular radius
def _residual(theta):
R_proj = theta * D_l
alpha = self.compute_deflection_angle(
R_proj,
mode="angular",
units="rad",
G=G,
z_lens=z_lens,
z_source=z_source,
cosmology=cosmology,
**kwargs,
).to_value("rad")
return alpha - theta
# Reasonable bracketing in radians: microarcsecond to 1 rad
theta_min = (1e-6 * unyt.Unit("arcsec")).to_value("rad")
theta_max = (1.0 * unyt.Unit("rad")).to_value("rad")
# Root finding
theta_E_rad = brentq(_residual, theta_min, theta_max)
return (theta_E_rad * unyt.Unit("rad")).to(units)
[docs]
def compute_lensing_convergence(self, R, z_lens, z_source, cosmology=None, units=None, **kwargs):
r"""Compute the lensing convergence :math:`\kappa(R)` at projected radius :math:`R`.
The convergence is defined as:
.. math::
\kappa(R) = \frac{\Sigma(R)}{\Sigma_{\mathrm{crit}}}
where:
- :math:`\Sigma(R)` is the projected surface mass density,
- :math:`\Sigma_{\mathrm{crit}}` is the critical surface density:
.. math::
\Sigma_{\mathrm{crit}} = \frac{c^2}{4 \pi G} \\frac{D_s}{D_l D_{ls}}
Parameters
----------
R : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Projected radius with length units.
z_lens : float
Redshift of the lens.
z_source : float
Redshift of the source (must satisfy :math:`z_{\mathrm{source}} > z_{\mathrm{lens}}`).
cosmology : :class:`astropy.cosmology.Cosmology`, optional
Cosmology to compute distances. Defaults to ``pisces_config['physics.default_cosmology']``.
units : str or ~unyt.unit_object.Unit, optional
Output units for :math:`\\kappa`. Default is dimensionless.
**kwargs
Additional arguments passed to :meth:`compute_surface_density`.
Returns
-------
kappa : ~unyt.array.unyt_quantity or ~unyt.array.unyt_array
Lensing convergence at each radius.
Examples
--------
.. code-block:: python
import unyt
from astropy.cosmology import Planck18
from pisces.profiles.density import (
NFWDensityProfile,
)
profile = NFWDensityProfile(rho_0=0.04, r_s=20)
R = unyt.array.unyt_array([0.5, 1, 2, 5], "kpc")
kappa = profile.compute_lensing_convergence(
R,
z_lens=0.3,
z_source=2.0,
cosmology=Planck18,
)
print(kappa)
See Also
--------
:meth:`compute_surface_density`, :meth:`compute_deflection_angle`, :class:`astropy.cosmology.Cosmology`
"""
import astropy.cosmology as cosmo
Sigma = self.compute_surface_density(R, units="g/cm**2", **kwargs)
if cosmology is None:
_default = pisces_config["physics.default_cosmology"]
cosmology = getattr(cosmo, _default)
D_l = cosmology.angular_diameter_distance(z_lens).to("cm")
D_s = cosmology.angular_diameter_distance(z_source).to("cm")
D_ls = cosmology.angular_diameter_distance_z1z2(z_lens, z_source).to("cm")
c = unyt.physical_constants.speed_of_light.to("cm/s")
G = unyt.physical_constants.gravitational_constant.to("cm**3/g/s**2")
Sigma_crit = (c**2) / (4 * np.pi * G) * (D_s / (D_l * D_ls))
kappa = Sigma / Sigma_crit
return kappa.to(units) if units else kappa
[docs]
class NFWDensityProfile(BaseSphericalDensityProfile):
r"""Navarro-Frenk-White (NFW) Density Profile.
This profile is commonly used in astrophysics to describe the dark matter halo density
in a spherical, isotropic system :footcite:p:`NFWProfile`. It is derived from simulations of structure formation.
.. math::
\rho(r) = \frac{\rho_0}{\frac{r}{r_s} \left(1 + \frac{r}{r_s}\right)^2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`NFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... NFWDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = NFWDensityProfile(rho_0=1.0, r_s=1.0)
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, "k-", label="NFW Profile")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
HernquistDensityProfile, CoredNFWDensityProfile, SingularIsothermalDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/pc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
# ------------------------------ #
# Derived Profile Implementation #
# ------------------------------ #
@derived_profile("enclosed_mass")
@classmethod
def _enclosed_mass(cls):
def _func(r, **params):
rho0, r_s = params["rho_0"], params["r_s"]
return 4 * sp.pi * rho0 * r_s**3 * (sp.log((r + r_s) / r_s) - r / (r + r_s))
def _unit_func(_, **params):
return params["rho_0"].units * params["r_s"].units ** 3
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
@derived_profile("gravitational_potential")
@classmethod
def _gravitational_potential(cls):
def _func(r, **params):
rho0, r_s = params["rho_0"], params["r_s"]
G = params["G"]
return -4 * sp.pi * G * rho0 * r_s**3 * sp.log(1 + r / r_s) / r
def _unit_func(r, **params):
return params["G"].units * params["rho_0"].units * params["r_s"].units ** 3 / r
# Add G to the available parameters.
parameters = cls.__PARAMETERS__.copy()
# noinspection PyUnresolvedReferences
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@derived_profile("gravitational_field")
@classmethod
def _gravitational_field(cls):
def _func(r, **params):
rho0, r_s = params["rho_0"], params["r_s"]
G = params["G"]
term1 = sp.log(1 + r / r_s) / r**2
term2 = 1 / (r * (r + r_s))
return -4 * sp.pi * G * rho0 * r_s**2 * (term1 - term2)
def _unit_func(r, **params):
return params["G"].units * params["rho_0"].units * params["r_s"].units ** 2 / r**2
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0):
return rho_0 / (r / r_s * (1 + r / r_s) ** 2)
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/pc**3"),
r_s=unyt.unyt_quantity(1, "pc"),
):
r, rho_0, r_s = unyt.Unit(r), rho_0.units, r_s.units
return rho_0 / (r / r_s)
[docs]
class HernquistDensityProfile(BaseSphericalDensityProfile):
r"""Hernquist Density Profile.
This profile is often used to model the density distribution of elliptical galaxies and
bulges :footcite:p:`HernquistProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\frac{r}{r_s} \left(1 + \frac{r}{r_s}\right)^3}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Hernquist Profile Parameters
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... HernquistDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = HernquistDensityProfile(
... rho_0=1.0, r_s=1.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Hernquist Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, CoredNFWDensityProfile, SingularIsothermalDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/pc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
# ------------------------------ #
# Derived Profile Implementation #
# ------------------------------ #
@derived_profile("enclosed_mass")
@classmethod
def _enclosed_mass(cls):
def _func(r, **params):
rho0, r_s = params["rho_0"], params["r_s"]
M_tot = 2 * sp.pi * rho0 * r_s**3
return M_tot * r**2 / (r + r_s) ** 2
def _unit_func(_, **params):
return params["rho_0"].units * params["r_s"].units ** 3
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
@derived_profile("gravitational_potential")
@classmethod
def _gravitational_potential(cls):
def _func(r, **params):
rho0, r_s, G = params["rho_0"], params["r_s"], params["G"]
M_tot = 2 * sp.pi * rho0 * r_s**3
return -G * M_tot / (r + r_s)
def _unit_func(r, **params):
return params["G"].units * params["rho_0"].units * params["r_s"].units ** 3 / r
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@derived_profile("gravitational_field")
@classmethod
def _gravitational_field(cls):
def _func(r, **params):
rho0, r_s, G = params["rho_0"], params["r_s"], params["G"]
M_tot = 2 * sp.pi * rho0 * r_s**3
return -G * M_tot / (r + r_s) ** 2
def _unit_func(r, **params):
return params["G"].units * params["rho_0"].units * params["r_s"].units ** 3 / r**2
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0):
return rho_0 / ((r / r_s) * (1 + r / r_s) ** 3)
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/pc**3"),
r_s=unyt.unyt_quantity(1, "pc"),
):
r, rho_0, r_s = unyt.Unit(r), rho_0.units, r_s.units
return rho_0 / (r / r_s)
[docs]
class EinastoDensityProfile(BaseSphericalDensityProfile):
r"""Einasto Density Profile.
This profile provides a flexible model for dark matter halos with a gradual density decline
:footcite:p:`EinastoProfile`.
.. math::
\rho(r) = \rho_0 \exp\left(-2 \alpha \left[\left(\frac{r}{r_s}\right)^\alpha - 1\right]\right)
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
- :math:`\alpha` is a shape parameter that controls the profile steepness.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`EinastoDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
* - ``alpha``
- :math:`\alpha`
- Shape parameter controlling profile steepness
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`EinastoDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... EinastoDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = EinastoDensityProfile(
... rho_0=1.0, r_s=1.0, alpha=0.18
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Einasto Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, HernquistDensityProfile, CoredNFWDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/pc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
"alpha": 0.18,
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0, alpha=0.18):
return rho_0 * sp.exp(-2 * alpha * ((r / r_s) ** alpha - 1))
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/pc**3"),
r_s=unyt.unyt_quantity(1, "pc"),
alpha=0.18,
):
return rho_0.units
[docs]
class SingularIsothermalDensityProfile(BaseSphericalDensityProfile):
r"""Singular Isothermal Sphere (SIS) Density Profile.
The SIS :footcite:p:`BinneyTremaine` profile is a simple model commonly used to describe the density distribution
of dark matter in galaxies and galaxy clusters under the assumption of an isothermal system.
.. math::
\rho(r) = \frac{\rho_0}{r^2}
where:
- :math:`\rho_0` is the central density.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`SingularIsothermalDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`SingularIsothermalDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``spherical_mass``
- :math:`M(r) = 4\pi\,\rho_0\,r`
- Diverges as :math:`r\to\infty`.
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... SingularIsothermalDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = SingularIsothermalDensityProfile(
... rho_0=1.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, "k-", label="SIS Profile")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, HernquistDensityProfile, CoredIsothermalDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc")}
@derived_profile("enclosed_mass")
@classmethod
def _enclosed_mass(cls):
def _func(r, **params):
rho0 = params["rho_0"]
return 4 * sp.pi * rho0 * r
def _unit_func(r, **params):
return params["rho_0"].units * r.units
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
@derived_profile("gravitational_potential")
@classmethod
def _gravitational_potential(cls):
def _func(r, **params):
rho0, G = params["rho_0"], params["G"]
return -4 * sp.pi * G * rho0 * sp.log(r)
def _unit_func(r, **params):
return params["G"].units * params["rho_0"].units * r.units**2 / r
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@derived_profile("gravitational_field")
@classmethod
def _gravitational_field(cls):
def _func(r, **params):
rho0, G = params["rho_0"], params["G"]
return -4 * sp.pi * G * rho0 / r
def _unit_func(r, **params):
return params["G"].units * params["rho_0"].units * r.units**2 / r**2
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@classmethod
def __function__(cls, r, rho_0=1.0):
return rho_0 / r**2
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc")):
r, rho_0 = unyt.Unit(r), rho_0.units
return rho_0 / r**2
[docs]
class CoredIsothermalDensityProfile(BaseSphericalDensityProfile):
r"""Cored Isothermal Sphere Density Profile.
This profile modifies the Singular Isothermal Sphere (SIS) by introducing a core radius
to account for the central flattening of the density distribution :footcite:p:`BinneyTremaine`.
.. math::
\rho(r) = \frac{\rho_0}{1 + \left(\frac{r}{r_c}\right)^2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_c` is the core radius.
.. dropdown:: Parameters
.. list-table:: Cored Isothermal Profile Parameters
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_c``
- :math:`r_c`
- Core radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`CoredIsothermalDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... CoredIsothermalDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = CoredIsothermalDensityProfile(
... rho_0=1.0, r_c=1.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Cored Isothermal Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
SingularIsothermalDensityProfile, NFWDensityProfile, BurkertDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_c": unyt.unyt_quantity(1.0, "kpc"),
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_c=1.0):
return rho_0 / (1 + (r / r_c) ** 2)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class PlummerDensityProfile(BaseSphericalDensityProfile):
r"""Plummer Density Profile.
The Plummer profile is commonly used to model the density distribution of star clusters
or spherical galaxies. It features a central core and a steep falloff at larger radii :footcite:p:`PlummerProfile`.
.. math::
\rho(r) = \frac{3M}{4\pi r_s^3} \left(1 + \left(\frac{r}{r_s}\right)^2\right)^{-5/2}
where:
- :math:`M` is the total mass.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`PlummerDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``M``
- :math:`M`
- Total mass
* - ``r_s``
- :math:`r_s`
- Scale radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`PlummerDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``spherical_mass``
- :math:`M(r) = M\,\bigl(\tfrac{r}{\sqrt{r^2 + r_s^2}}\bigr)^{3}`
- None
* - ``spherical_potential``
- :math:`\Phi(r) = -\,\frac{M}{\sqrt{r^2 + r_s^2}}`
- Multiplicative constants (e.g. `G`) can be included externally
* - ``surface_density``
- :math:`\Sigma(R) = \frac{M\,r_s^2}{\pi\,\bigl(r_s^2 + R^2\bigr)^{2}}`
- On-demand expression for projected (2D) density
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... PlummerDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = PlummerDensityProfile(M=1.0, r_s=1.0)
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Plummer Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
HernquistDensityProfile, NFWDensityProfile, JaffeDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"M": unyt.unyt_quantity(1.0, "Msun"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
@derived_profile("enclosed_mass")
@classmethod
def _enclosed_mass(cls):
def _func(r, **params):
M_tot, r_s = params["M"], params["r_s"]
return M_tot * r**3 / (r**2 + r_s**2) ** (3 / 2)
def _unit_func(_, **params):
return params["M"].units
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
@derived_profile("gravitational_potential")
@classmethod
def _gravitational_potential(cls):
def _func(r, **params):
M_tot, r_s, G = params["M"], params["r_s"], params["G"]
return -G * M_tot / sp.sqrt(r**2 + r_s**2)
def _unit_func(r, **params):
return params["G"].units * params["M"].units / r.units
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@derived_profile("gravitational_field")
@classmethod
def _gravitational_field(cls):
def _func(r, **params):
M_tot, r_s, G = params["M"], params["r_s"], params["G"]
return -G * M_tot * r / (r**2 + r_s**2) ** (3 / 2)
def _unit_func(r, **params):
return params["G"].units * params["M"].units / r.units**2
parameters = cls.__PARAMETERS__.copy()
parameters["G"] = unyt.physical_constants.gravitational_constant
return _func, _unit_func, ["r"], parameters
@classmethod
def __function__(cls, r, M=1.0, r_s=1.0):
return (3 * M) / (4 * sp.pi * r_s**3) * (1 + (r / r_s) ** 2) ** (-5 / 2)
@classmethod
def __function_units__(cls, r, M=unyt.unyt_quantity(1, "Msun"), r_s=unyt.unyt_quantity(1, "pc")):
M, r_s = M.units, r_s.units
return M / r_s**3
[docs]
class DehnenDensityProfile(BaseSphericalDensityProfile):
r"""Dehnen Density Profile.
This profile is widely used in modeling galactic bulges and elliptical galaxies.
It generalizes other profiles like Hernquist and Jaffe with an adjustable inner slope :footcite:p:`DehnenProfile`.
.. math::
\rho(r) = \frac{(3 - \gamma)M}{4\pi r_s^3}
\left(\frac{r}{r_s}\right)^{-\gamma} \left(1 + \frac{r}{r_s}\right)^{\gamma - 4}
where:
- :math:`M` is the total mass.
- :math:`r_s` is the scale radius.
- :math:`\gamma` controls the inner density slope.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`DehnenDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``M``
- :math:`M`
- Total mass
* - ``r_s``
- :math:`r_s`
- Scale radius
* - ``gamma``
- :math:`\gamma`
- Controls the inner density slope
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`DehnenDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``spherical_mass``
- :math:`M(r) = M\,\Bigl(\frac{r}{r + r_s}\Bigr)^{3 - \gamma}`
- Contains Hernquist (gamma=1) and Jaffe (gamma=2) as special cases
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... DehnenDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = DehnenDensityProfile(
... M=1.0, r_s=1.0, gamma=1.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Dehnen Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
HernquistDensityProfile, JaffeDensityProfile, NFWDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"M": unyt.unyt_quantity(1.0, "Msun"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
"gamma": 1.0,
}
@classmethod
def __function__(cls, r, M=1.0, r_s=1.0, gamma=1.0):
return ((3 - gamma) * M) / (4 * sp.pi * r_s**3) * (r / r_s) ** (-gamma) * (1 + r / r_s) ** (gamma - 4)
@classmethod
def __function_units__(
cls,
r,
M=unyt.unyt_quantity(1, "Msun"),
r_s=unyt.unyt_quantity(1, "pc"),
gamma=1.0,
):
M, r_s = M.units, r_s.units
return M / r_s**3
[docs]
class JaffeDensityProfile(BaseSphericalDensityProfile):
r"""Jaffe Density Profile.
This profile is commonly used to describe the density distribution of elliptical galaxies
:footcite:p:`JaffeProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\frac{r}{r_s} \left(1 + \frac{r}{r_s}\right)^2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`JaffeDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`JaffeDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``spherical_mass``
- :math:`M(r) = 4\pi\,\rho_0\,r_s^3\,\Bigl[\ln(r + r_s) + \ln(r_s) \;-\; 1 \;+\; \frac{r_s}{r + r_s}\Bigr]`
- (As coded; can simplify further)
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... JaffeDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = JaffeDensityProfile(rho_0=1.0, r_s=1.0)
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Jaffe Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
HernquistDensityProfile, DehnenDensityProfile, NFWDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
@derived_profile("enclosed_mass")
@classmethod
def _enclosed_mass(cls):
def _func(r, **params):
M_tot, a = params["M_tot"], params["a"]
return M_tot * r / (r + a)
def _unit_func(_, **params):
return params["M_tot"].units
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0):
return rho_0 / ((r / r_s) * (1 + r / r_s) ** 2)
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"),
r_s=unyt.unyt_quantity(1, "pc"),
):
r, rho_0, r_s = unyt.Unit(r), rho_0.units, r_s.units
return rho_0 / (r / r_s)
[docs]
class BurkertDensityProfile(BaseSphericalDensityProfile):
r"""Burkert Density Profile.
This profile describes dark matter halos with a flat density core, often used to
fit rotation curves of dwarf galaxies :footcite:p:`BurkertProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\left(1 + \frac{r}{r_s}\right) \left(1 + \left(\frac{r}{r_s}\right)^2\right)}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`BurkertDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`BurkertDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... BurkertDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = BurkertDensityProfile(
... rho_0=1.0, r_s=1.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Burkert Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, CoredNFWDensityProfile, KingDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
@derived_profile("enclosed_mass")
@classmethod
def _enclosed_mass(cls):
def _func(r, **params):
rho0, r_c = params["rho_0"], params["r_c"]
return (
2 * sp.pi * rho0 * r_c**3 * (sp.log(1 + r / r_c) - sp.atan(r / r_c) + 0.5 * sp.log(1 + (r / r_c) ** 2))
)
def _unit_func(_, **params):
return params["rho_0"].units * params["r_c"].units ** 3
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0):
return rho_0 / ((1 + r / r_s) * (1 + (r / r_s) ** 2))
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class MooreDensityProfile(BaseSphericalDensityProfile):
r"""Moore Density Profile.
This profile describes the density of dark matter halos with a steeper central slope compared to NFW
:footcite:p:`MooreProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\left(\frac{r}{r_s}\right)^{3/2} \left(1 + \frac{r}{r_s}\right)^{3/2}}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`MooreDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`MooreDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... MooreDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = MooreDensityProfile(rho_0=1.0, r_s=1.0)
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Moore Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, CoredNFWDensityProfile, HernquistDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0):
return rho_0 / ((r / r_s) ** (3 / 2) * (1 + r / r_s) ** (3 / 2))
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"),
r_s=unyt.unyt_quantity(1, "pc"),
):
r, rho_0, r_s = unyt.Unit(r), rho_0.units, r_s.units
return rho_0
[docs]
class CoredNFWDensityProfile(BaseSphericalDensityProfile):
r"""Cored Navarro-Frenk-White (NFW) Density Profile.
This profile modifies the standard NFW profile by introducing a core, leading to
a shallower density slope near the center :footcite:p:`CNFWProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\left(1 + \left(\frac{r}{r_s}\right)^2\right) \left(1 + \frac{r}{r_s}\right)^2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Cored NFW Profile Parameters
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`CoredNFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... CoredNFWDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = CoredNFWDensityProfile(
... rho_0=1.0, r_s=1.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Cored NFW Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, HernquistDensityProfile, BurkertDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0):
return rho_0 / ((1 + (r / r_s) ** 2) * (1 + r / r_s) ** 2)
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"),
r_s=unyt.unyt_quantity(1, "pc"),
):
r, rho_0, r_s = unyt.Unit(r), rho_0.units, r_s.units
return rho_0
[docs]
class KingDensityProfile(BaseSphericalDensityProfile):
r"""King Density Profile.
This profile describes the density distribution in globular clusters and galaxy clusters,
accounting for truncation at larger radii :footcite:p:`KingProfile`.
.. math::
\rho(r) = \rho_0 \left[\left(1 + \left(\frac{r}{r_c}\right)^2\right)^{-3/2}
- \left(1 + \left(\frac{r_t}{r_c}\right)^2\right)^{-3/2}\right]
where:
- :math:`\rho_0` is the central density.
- :math:`r_c` is the core radius.
- :math:`r_t` is the truncation radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`KingDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_c``
- :math:`r_c`
- Core radius
* - ``r_t``
- :math:`r_t`
- Truncation radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`KingDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... KingDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = KingDensityProfile(
... rho_0=1.0, r_c=1.0, r_t=5.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, "k-", label="King Profile")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, BurkertDensityProfile, PlummerDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_c": unyt.unyt_quantity(1.0, "kpc"),
"r_t": unyt.unyt_quantity(1.0, "kpc"),
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_c=1.0, r_t=1.0):
return rho_0 * ((1 + (r / r_c) ** 2) ** (-1.5) - (1 + (r_t / r_c) ** 2) ** (-1.5))
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class VikhlininDensityProfile(BaseSphericalDensityProfile):
r"""Vikhlinin Density Profile.
This profile is used to model the density of galaxy clusters, incorporating
a truncation at large radii and additional flexibility for inner slopes :footcite:p:`VikhlininProfile`.
.. math::
\rho(r) = \rho_0 \left(\frac{r}{r_c}\right)^{-0.5 \alpha}
\left(1 + \left(\frac{r}{r_c}\right)^2\right)^{-1.5 \beta + 0.25 \alpha}
\left(1 + \left(\frac{r}{r_s}\right)^{\gamma}\right)^{-0.5 \epsilon / \gamma}
where:
- :math:`\rho_0` is the central density.
- :math:`r_c` is the core radius.
- :math:`r_s` is the truncation radius.
- :math:`\alpha, \beta, \gamma, \epsilon` control the slope and truncation behavior.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`VikhlininDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_c``
- :math:`r_c`
- Core radius
* - ``r_s``
- :math:`r_s`
- Truncation radius
* - ``alpha``
- :math:`\alpha`
- Controls the innermost slope
* - ``beta``
- :math:`\beta`
- Governs the outer slope
* - ``epsilon``
- :math:`\epsilon`
- Steepens the outer decline
* - ``gamma``
- :math:`\gamma`
- Exponent in the truncation factor
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`VikhlininDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... VikhlininDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = VikhlininDensityProfile(
... rho_0=1.0,
... r_c=1.0,
... r_s=5.0,
... alpha=1.0,
... beta=1.0,
... epsilon=1.0,
... gamma=3.0,
... )
>>> rho = profile(r)
>>> _ = plt.loglog(
... r, rho, "k-", label="Vikhlinin Profile"
... )
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
KingDensityProfile, NFWDensityProfile, BurkertDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_c": unyt.unyt_quantity(1.0, "kpc"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
"alpha": 1.0,
"beta": 1.0,
"epsilon": 1.0,
"gamma": 3.0,
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_c=1.0, r_s=1.0, alpha=1.0, beta=1.0, epsilon=1.0, gamma=3.0):
return (
rho_0
* (r / r_c) ** (-0.5 * alpha)
* (1 + (r / r_c) ** 2) ** (-1.5 * beta + 0.25 * alpha)
* (1 + (r / r_s) ** gamma) ** (-0.5 * epsilon / gamma)
)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class AM06DensityProfile(BaseSphericalDensityProfile):
r"""Ascasibar and Markevitch (2006) Density Profile.
This density profile is a generalized model that allows flexibility in fitting
the density distributions of dark matter halos. It includes additional parameters
for controlling inner and outer slopes, truncation, and other scaling properties :footcite:p:`AM06Profile`.
.. math::
\rho(r) = \rho_0 \left(1 + \frac{r}{a_c}\right) \left(1 + \frac{r}{a_c c}\right)^{\alpha}
\left(1 + \frac{r}{a}\right)^{\beta}
where:
- :math:`\rho_0` is the central density.
- :math:`a_c` is the core radius.
- :math:`c` is the concentration parameter.
- :math:`a` is the scale radius.
- :math:`\alpha` controls the slope of the transition near the core.
- :math:`\beta` controls the outer slope.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`AM06DensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``a_c``
- :math:`a_c`
- Core radius
* - ``c``
- :math:`c`
- Concentration parameter
* - ``a``
- :math:`a`
- Scale radius
* - ``alpha``
- :math:`\alpha`
- Controls slope near the core
* - ``beta``
- :math:`\beta`
- Controls outer slope
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`AM06DensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
Use Case
--------
This profile is well-suited for modeling dark matter halos with detailed inner and outer slope behaviors.
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... AM06DensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = AM06DensityProfile(
... rho_0=1.0,
... a_c=1.0,
... c=2.0,
... a=3.0,
... alpha=1.0,
... beta=3.0,
... )
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, "k-", label="AM06 Profile")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, VikhlininDensityProfile, HernquistDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"a": unyt.unyt_quantity(1.0, "kpc"),
"a_c": unyt.unyt_quantity(1.0, "kpc"),
"c": 1.0,
"alpha": 1.0,
"beta": 1.0,
}
@classmethod
def __function__(cls, r, rho_0=1.0, a=1.0, a_c=1.0, c=1.0, alpha=1.0, beta=1.0):
return rho_0 * (1 + r / a_c) * (1 + r / (a_c * c)) ** alpha * (1 + r / a) ** beta
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class SNFWDensityProfile(BaseSphericalDensityProfile):
r"""Simplified Navarro-Frenk-White (SNFW) Density Profile.
This profile is a simplified version of the NFW profile, widely used for modeling
dark matter halos with specific scaling :footcite:p:`SNFWProfile`.
.. math::
\rho(r) = \frac{3M}{16\pi a^3} \frac{1}{\frac{r}{a} \left(1 + \frac{r}{a}\right)^{2.5}}
where:
- :math:`M` is the total mass.
- :math:`a` is the scale radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`SNFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``M``
- :math:`M`
- Total mass
* - ``a``
- :math:`a`
- Scale radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`SNFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base (no direct mass/potential expression coded here)
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... SNFWDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = SNFWDensityProfile(M=1.0, a=1.0)
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, "k-", label="SNFW Profile")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, TNFWDensityProfile, HernquistDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"M": unyt.unyt_quantity(1.0, "Msun"),
"a": unyt.unyt_quantity(1.0, "kpc"),
}
@classmethod
def __function__(cls, r, M=1.0, a=1.0):
return 3 * M / (16 * sp.pi * a**3) / ((r / a) * (1 + r / a) ** 2.5)
@classmethod
def __function_units__(cls, r, M=unyt.unyt_quantity(1, "Msun"), a=unyt.unyt_quantity(1, "kpc")):
M, a = M.units, a.units
return M / a**3
[docs]
class TNFWDensityProfile(BaseSphericalDensityProfile):
r"""Truncated Navarro-Frenk-White (TNFW) Density Profile.
This profile is a modification of the NFW profile with an additional truncation
term to account for finite halo sizes :footcite:p:`TNFWProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\frac{r}{r_s} \left(1 + \frac{r}{r_s}\right)^2}
\frac{1}{1 + \left(\frac{r}{r_t}\right)^2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_s` is the scale radius.
- :math:`r_t` is the truncation radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`TNFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Scale radius
* - ``r_t``
- :math:`r_t`
- Truncation radius
.. dropdown:: Expressions
.. list-table:: Expressions for :py:class:`TNFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Notes**
* - ``ellipsoidal_psi``
- :math:`\psi(r) = 2\int_{0}^{r}\!\xi\,\rho(\xi)\,d\xi`
- Inherited from base
References
----------
.. footbibliography::
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... TNFWDensityProfile,
... )
>>> r = np.linspace(0.1, 10, 100)
>>> profile = TNFWDensityProfile(
... rho_0=1.0, r_s=1.0, r_t=10.0
... )
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, "k-", label="TNFW Profile")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, CoredNFWDensityProfile, SNFWDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
"r_t": unyt.unyt_quantity(1.0, "kpc"),
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0, r_t=1.0):
return rho_0 / ((r / r_s) * (1 + r / r_s) ** 2) / (1 + (r / r_t) ** 2)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class PseudoIsothermalDensityProfile(BaseSphericalDensityProfile):
r"""Pseudo-Isothermal Sphere (PISO) Density Profile.
Commonly used to model dark matter halos with a constant density core, avoiding the central singularity of singular
isothermal models.
.. math::
\rho(r) = \frac{\rho_0}{1 + \left(\frac{r}{r_c}\right)^2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_c` is the core radius.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`PseudoIsothermalDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_c``
- :math:`r_c`
- Core radius
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... PseudoIsothermalDensityProfile,
... )
>>> r = np.logspace(-1, 1, 100)
>>> profile = PseudoIsothermalDensityProfile(
... rho_0=1.0, r_c=1.0
... )
>>> rho = profile(r)
>>> plt.loglog(r, rho, label="PISO Profile")
>>> plt.xlabel("Radius (r)")
>>> plt.ylabel("Density (rho)")
>>> plt.legend()
>>> plt.show()
See Also
--------
CoredIsothermalDensityProfile, SingularIsothermalDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_c": unyt.unyt_quantity(1.0, "kpc"),
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_c=1.0):
return rho_0 / (1 + (r / r_c) ** 2)
@classmethod
def __function_units__(
cls,
r,
rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"),
r_c=unyt.unyt_quantity(1, "kpc"),
):
r, rho_0, r_c = unyt.Unit(r), rho_0.units, r_c.units
return rho_0
[docs]
class DoublePowerLawDensityProfile(BaseSphericalDensityProfile):
r"""Double Power-Law Density Profile.
Flexible, general profile with independent control over inner and outer slopes,
frequently used to describe dark matter halos and galaxies.
.. math::
\rho(r) = \frac{\rho_0}{\left(\frac{r}{r_s}\right)^{\gamma}
\left(1 + \left(\frac{r}{r_s}\right)^{\alpha}\right)^{(\beta - \gamma)/\alpha}}
where:
- :math:`\rho_0` is the normalization density.
- :math:`r_s` is the scale radius.
- :math:`\alpha` governs the sharpness of the transition.
- :math:`\beta` is the outer slope.
- :math:`\gamma` is the inner slope.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`DoublePowerLawDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Normalization density
* - ``r_s``
- :math:`r_s`
- Scale radius
* - ``alpha``
- :math:`\alpha`
- Sharpness of slope transition
* - ``beta``
- :math:`\beta`
- Outer slope
* - ``gamma``
- :math:`\gamma`
- Inner slope
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... DoublePowerLawDensityProfile,
... )
>>> r = np.logspace(-1, 1, 100)
>>> profile = DoublePowerLawDensityProfile(
... rho_0=1.0,
... r_s=1.0,
... alpha=1.0,
... beta=3.0,
... gamma=1.0,
... )
>>> rho = profile(r)
>>> plt.loglog(r, rho, label="Double Power-Law")
>>> plt.xlabel("Radius (r)")
>>> plt.ylabel("Density (rho)")
>>> plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, DehnenDensityProfile, HernquistDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
"alpha": 1.0,
"beta": 3.0,
"gamma": 1.0,
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0, alpha=1.0, beta=3.0, gamma=1.0):
return rho_0 / (r / r_s) ** gamma / (1 + (r / r_s) ** alpha) ** ((beta - gamma) / alpha)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class CoredPowerLawDensityProfile(BaseSphericalDensityProfile):
r"""Cored Power-Law Density Profile.
This profile represents a flattened core transitioning to a power-law decline at large radii,
often used for galaxies or halos with a central density core.
.. math::
\rho(r) = \rho_0 \left(1 + \left(\frac{r}{r_c}\right)^2\right)^{-\gamma/2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_c` is the core radius.
- :math:`\gamma` controls the outer slope.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`CoredPowerLawDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_c``
- :math:`r_c`
- Core radius
* - ``gamma``
- :math:`\gamma`
- Outer slope parameter
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... CoredPowerLawDensityProfile,
... )
>>> r = np.logspace(-1, 1, 100)
>>> profile = CoredPowerLawDensityProfile(
... rho_0=1.0, r_c=1.0, gamma=2.0
... )
>>> rho = profile(r)
>>> plt.loglog(r, rho, label="Cored Power-Law")
>>> plt.xlabel("Radius (r)")
>>> plt.ylabel("Density (rho)")
>>> plt.legend()
>>> plt.show()
See Also
--------
PseudoIsothermalDensityProfile, CoredIsothermalDensityProfile, DoublePowerLawDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_c": unyt.unyt_quantity(1.0, "kpc"),
"gamma": 1.0,
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_c=1.0, gamma=1.0):
return rho_0 * (1 + (r / r_c) ** 2) ** (-gamma / 2)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class GeneralizedNFWDensityProfile(BaseSphericalDensityProfile):
r"""Generalized NFW (GNFW) Density Profile.
Extends the Navarro-Frenk-White (NFW) profile with flexible inner and outer slopes, commonly used to model
dark matter halos and galaxy clusters :footcite:p:`GeneralizedNFWProfile`.
.. math::
\rho(r) = \frac{\rho_0}{\left(\frac{r}{r_s}\right)^{\gamma}
\left(1 + \left(\frac{r}{r_s}\right)^{\alpha}\right)^{(\beta - \gamma)/\alpha}}
where:
- :math:`\rho_0` is the normalization density.
- :math:`r_s` is the scale radius.
- :math:`\alpha` controls the sharpness of the transition.
- :math:`\beta` is the outer slope.
- :math:`\gamma` is the inner slope.
The standard NFW profile corresponds to :math:`\alpha = 1`, :math:`\beta = 3`, :math:`\gamma = 1`.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`GeneralizedNFWDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Normalization density
* - ``r_s``
- :math:`r_s`
- Scale radius
* - ``alpha``
- :math:`\alpha`
- Sharpness of slope transition
* - ``beta``
- :math:`\beta`
- Outer slope
* - ``gamma``
- :math:`\gamma`
- Inner slope
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... GeneralizedNFWDensityProfile,
... )
>>> r = np.logspace(-1, 1, 100)
>>> profile = GeneralizedNFWDensityProfile(
... rho_0=1.0,
... r_s=1.0,
... alpha=1.0,
... beta=3.0,
... gamma=1.0,
... )
>>> rho = profile(r)
>>> plt.loglog(r, rho, label="GNFW Profile")
>>> plt.xlabel("Radius (r)")
>>> plt.ylabel("Density (rho)")
>>> plt.legend()
>>> plt.show()
See Also
--------
NFWDensityProfile, DoublePowerLawDensityProfile, EinastoDensityProfile
References
----------
.. footbibliography::
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "pc"),
"alpha": 1.0,
"beta": 3.0,
"gamma": 1.0,
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_s=1.0, alpha=1.0, beta=3.0, gamma=1.0):
return rho_0 / (r / r_s) ** gamma / (1 + (r / r_s) ** alpha) ** ((beta - gamma) / alpha)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class BetaModelDensityProfile(BaseSphericalDensityProfile):
r"""Beta Model Density Profile.
Frequently used to describe gas density in galaxy clusters, X-ray halos, and intracluster media,
based on an isothermal, spherically symmetric gas distribution :footcite:p:`BetaProfile`.
.. math::
\rho(r) = \rho_0 \left(1 + \left(\frac{r}{r_c}\right)^2 \right)^{-3\beta/2}
where:
- :math:`\rho_0` is the central density.
- :math:`r_c` is the core radius.
- :math:`\beta` controls the outer slope.
This form corresponds to the classic "single-beta" model. More complex systems
sometimes use multiple beta components.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`BetaModelDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_c``
- :math:`r_c`
- Core radius
* - ``beta``
- :math:`\beta`
- Outer slope parameter
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... BetaModelDensityProfile,
... )
>>> r = np.logspace(-1, 1, 100)
>>> profile = BetaModelDensityProfile(
... rho_0=1.0, r_c=1.0, beta=2 / 3
... )
>>> rho = profile(r)
>>> _ = plt.loglog(r, rho, label="Beta Model")
>>> _ = plt.xlabel("Radius (r)")
>>> _ = plt.ylabel("Density (rho)")
>>> _ = plt.legend()
>>> plt.show()
See Also
--------
CoredPowerLawDensityProfile, PseudoIsothermalDensityProfile
References
----------
.. footbibliography::
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_c": unyt.unyt_quantity(1.0, "kpc"),
"beta": 1.0,
}
@classmethod
def __function__(cls, r, rho_0=1.0, r_c=1.0, beta=1.0):
return rho_0 * (1 + (r / r_c) ** 2) ** (-1.5 * beta)
@classmethod
def __function_units__(cls, r, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
# ======================================= #
# Disk Density Profiles #
# ======================================= #
[docs]
class BaseDiskDensityProfile(BaseCylindricalDiskProfile, ABC):
r"""Base class for disk density profiles.
This class provides a foundation for cylindrical disk profiles, defining the basic structure and methods
that all disk profiles should implement. It is not intended to be instantiated directly.
"""
__IS_ABSTRACT__ = True
__VARIABLES__ = ["r", "theta"]
__VARIABLES_LATEX__ = [r"r", r"\theta"]
[docs]
class ExponentialDiskDensityProfile(BaseDiskDensityProfile):
r"""Exponential Disk Density Profile.
Commonly used for stellar and gas disks in spiral galaxies, this profile declines
exponentially with radius and height.
.. math::
\rho(r, z) = \rho_0 \, \exp\left( -\frac{r}{r_s} \right) \, \exp\left( -\frac{|z|}{z_s} \right)
where:
- :math:`\\rho_0` is the central midplane density.
- :math:`r_s` is the radial scale length.
- :math:`z_s` is the vertical scale height.
.. dropdown:: Parameters
.. list-table:: Parameters for :py:class:`ExponentialDiskDensityProfile`
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\\rho_0`
- Central midplane density
* - ``r_s``
- :math:`r_s`
- Radial scale length
* - ``z_s``
- :math:`z_s`
- Vertical scale height
Example
-------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from pisces.profiles.density import (
... ExponentialDiskDensityProfile,
... )
>>> r = np.linspace(0, 15, 200)
>>> z = 0.0 # Midplane
>>> profile = ExponentialDiskDensityProfile(
... rho_0=1.0, r_s=3.0, z_s=0.3
... )
>>> rho = profile(r, z)
>>> plt.semilogy(
... r, rho, label="Exponential Disk (z=0)"
... )
>>> plt.xlabel("Radius (r)")
>>> plt.ylabel("Density (rho)")
>>> plt.legend()
>>> plt.show()
See Also
--------
BaseDiskDensityProfile, DoubleExponentialDiskDensityProfile
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(3.0, "kpc"),
"z_s": unyt.unyt_quantity(0.3, "kpc"),
}
@classmethod
def __function__(cls, r, z, rho_0=1.0, r_s=1.0, z_s=1.0):
return rho_0 * sp.exp(-r / r_s) * sp.exp(-sp.Abs(z) / z_s)
@classmethod
def __function_units__(cls, r, z, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class SechSquaredDiskDensityProfile(BaseDiskDensityProfile):
r"""Sech² Vertical Disk Density Profile.
Models a disk with exponential radial falloff and hyperbolic secant squared vertical structure:
.. math::
\rho(r, z) = \rho_0 \, \exp\left( -\frac{r}{r_s} \right) \, \mathrm{sech}^2 \left( \frac{z}{z_s} \right)
Widely used for isothermal, self-gravitating stellar disks.
.. dropdown:: Parameters
.. list-table::
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Radial scale length
* - ``z_s``
- :math:`z_s`
- Vertical scale height
Example
-------
.. plot::
:include-source:
>>> from pisces.profiles.density import (
... SechSquaredDiskDensityProfile,
... )
>>> r, z = np.meshgrid(
... np.linspace(0, 10, 100),
... np.linspace(-2, 2, 100),
... )
>>> profile = SechSquaredDiskDensityProfile(
... rho_0=1.0, r_s=3.0, z_s=0.3
... )
>>> rho = profile(r, z)
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "kpc"),
"z_s": unyt.unyt_quantity(0.3, "kpc"),
}
@classmethod
def __function__(cls, r, z, rho_0=1.0, r_s=1.0, z_s=0.3):
return rho_0 * sp.exp(-r / r_s) * (1 / sp.cosh(z / z_s)) ** 2
@classmethod
def __function_units__(cls, r, z, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class SechDiskDensityProfile(BaseDiskDensityProfile):
r"""Sech Vertical Disk Density Profile.
Models a disk with exponential radial decline and hyperbolic secant vertical structure:
.. math::
\rho(r, z) = \rho_0 \, \exp\left( -\frac{r}{r_s} \right) \, \mathrm{sech} \left( \frac{z}{z_s} \right)
Compared to the :py:class:`SechSquaredDiskDensityProfile`, this version provides a
thicker vertical profile and is often used for diffuse components or gas layers.
.. dropdown:: Parameters
.. list-table::
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Radial scale length
* - ``z_s``
- :math:`z_s`
- Vertical scale height
Example
-------
.. plot::
:include-source:
>>> from pisces.profiles.density import (
... SechDiskDensityProfile,
... )
>>> r, z = np.meshgrid(
... np.linspace(0, 10, 100),
... np.linspace(-2, 2, 100),
... )
>>> profile = SechDiskDensityProfile(
... rho_0=1.0, r_s=3.0, z_s=0.3
... )
>>> rho = profile(r, z)
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(1.0, "kpc"),
"z_s": unyt.unyt_quantity(0.3, "kpc"),
}
@classmethod
def __function__(cls, r, z, rho_0=1.0, r_s=1.0, z_s=0.3):
return rho_0 * sp.exp(-r / r_s) * (1 / sp.cosh(z / z_s))
@classmethod
def __function_units__(cls, r, z, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
[docs]
class FlaredDiskDensityProfile(BaseDiskDensityProfile):
r"""Flared Exponential Disk Density Profile.
Extends the exponential disk by allowing the vertical scale height to increase with radius:
.. math::
\rho(r, z) = \rho_0 \, \exp\left( -\frac{r}{r_s} \right) \, \exp\left( -\frac{|z|}{z_s(r)} \right)
where the scale height grows with radius as:
.. math::
z_s(r) = z_0 \left( 1 + \frac{r}{r_f} \right)^{\delta}
This profile captures disk thickening ("flaring") at large radii, as seen in stellar and gas disks.
.. dropdown:: Parameters
.. list-table::
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``rho_0``
- :math:`\rho_0`
- Central density
* - ``r_s``
- :math:`r_s`
- Radial scale length
* - ``z_0``
- :math:`z_0`
- Base vertical scale height
* - ``r_f``
- :math:`r_f`
- Flare radius
* - ``delta``
- :math:`\delta`
- Flare steepness
Example
-------
.. plot::
:include-source:
>>> from pisces.profiles.density import (
... FlaredDiskDensityProfile,
... )
>>> r, z = np.meshgrid(
... np.linspace(0, 15, 200),
... np.linspace(-3, 3, 200),
... )
>>> profile = FlaredDiskDensityProfile(
... rho_0=1.0,
... r_s=3.0,
... z_0=0.3,
... r_f=5.0,
... delta=0.5,
... )
>>> rho = profile(r, z)
"""
__IS_ABSTRACT__ = False
__PARAMETERS__ = {
"rho_0": unyt.unyt_quantity(1.0, "Msun/kpc**3"),
"r_s": unyt.unyt_quantity(3.0, "kpc"),
"z_0": unyt.unyt_quantity(0.3, "kpc"),
"r_f": unyt.unyt_quantity(5.0, "kpc"),
"delta": 0.5,
}
@classmethod
def __function__(cls, r, z, rho_0=1.0, r_s=1.0, z_0=0.3, r_f=5.0, delta=0.5):
z_s_r = z_0 * (1 + r / r_f) ** delta
return rho_0 * sp.exp(-r / r_s) * sp.exp(-sp.Abs(z) / z_s_r)
@classmethod
def __function_units__(cls, r, z, rho_0=unyt.unyt_quantity(1, "Msun/kpc**3"), **kwargs):
return rho_0.units
# ======================================= #
# Surface Density Profiles #
# ======================================= #
[docs]
class SersicProfile(BaseProfile):
r"""Sérsic Surface Density Profile.
Describes the surface brightness or surface mass density distribution of galaxies, particularly elliptical
galaxies and bulges. The profile is defined as:
.. math::
\Sigma(R) = \Sigma_0 \, \exp\left( -b_n \left[ \left( \frac{R}{R_e} \right)^{1/n} - 1 \right] \right)
where:
- :math:`\Sigma_0` is the central surface density.
- :math:`R_e` is the effective radius enclosing half the total projected mass.
- :math:`n` is the Sérsic index controlling the shape (with :math:`n=1` being exponential,
and :math:`n=4` the de Vaucouleurs profile).
- :math:`b_n` is a normalization constant dependent on :math:`n`.
.. note::
The constant :math:`b_n` is approximately given by:
.. math::
b_n \approx 2n - \frac{1}{3} + \frac{4}{405n} + \frac{46}{25515n^2}
which ensures that :math:`R_e` encloses half the total projected mass.
.. dropdown:: Parameters
.. list-table::
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``Sigma_0``
- :math:`\Sigma_0`
- Central surface density
* - ``R_e``
- :math:`R_e`
- Effective (half-light) radius
* - ``n``
- :math:`n`
- Sérsic index (controls profile shape)
Example
-------
.. plot::
:include-source:
>>> from pisces.profiles.density import SersicProfile
>>> import matplotlib.pyplot as plt
>>> R = np.linspace(0.01, 10, 200)
>>> profile = SersicProfile(
... Sigma_0=1.0, R_e=2.0, n=4.0
... )
>>> Sigma = profile(R)
>>> plt.semilogy(R, Sigma)
>>> plt.xlabel("Radius (R)")
>>> plt.ylabel("Surface Density (Sigma)")
>>> plt.title("Sérsic Surface Profile (n=4)")
>>> plt.grid(True)
>>> plt.show()
See Also
--------
~profiles.base.BaseProfile,
ExponentialDiskDensityProfile
"""
__IS_ABSTRACT__ = False
__VARIABLES__ = ["R"]
__VARIABLES_LATEX__ = [r"R"]
__PARAMETERS__ = {
"Sigma_0": unyt.unyt_quantity(1.0, "Msun/kpc**2"),
"R_e": unyt.unyt_quantity(2.0, "kpc"),
"n": 4.0,
}
@staticmethod
def _b_n(n):
"""Approximate b_n for given Sérsic index n."""
return 2 * n - 1 / 3 + 4 / (405 * n) + 46 / (25515 * n**2)
@classmethod
def __function__(cls, R, Sigma_0=1.0, R_e=1.0, n=1.0):
b_n = cls._b_n(n)
return Sigma_0 * sp.exp(-b_n * ((R / R_e) ** (1 / n) - 1))
@classmethod
def __function_units__(cls, R, Sigma_0=unyt.unyt_quantity(1, "Msun/kpc**2"), **kwargs):
return Sigma_0.units
[docs]
class GaussianRingProfile(BaseProfile):
r"""Gaussian Ring Surface Density Profile.
Models an annular structure with a peak surface density at a characteristic radius,
declining in both inward and outward directions as a Gaussian:
.. math::
\Sigma(R) = \Sigma_0 \, \exp\left( -\frac{(R - R_0)^2}{2 \sigma^2} \right)
This profile is useful for representing ring galaxies, star-forming rings, or resonance features.
.. dropdown:: Parameters
.. list-table::
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``Sigma_0``
- :math:`\Sigma_0`
- Peak surface density at :math:`R = R_0`
* - ``R_0``
- :math:`R_0`
- Radius of the peak surface density
* - ``sigma``
- :math:`\sigma`
- Width of the ring
Example
-------
.. plot::
:include-source:
>>> from pisces.profiles.density import (
... GaussianRingProfile,
... )
>>> import matplotlib.pyplot as plt
>>> R = np.linspace(0, 10, 200)
>>> profile = GaussianRingProfile(
... Sigma_0=1.0, R_0=5.0, sigma=0.5
... )
>>> Sigma = profile(R)
>>> plt.plot(R, Sigma)
>>> plt.xlabel("Radius (R)")
>>> plt.ylabel("Surface Density (Sigma)")
>>> plt.title("Gaussian Ring Profile")
>>> plt.grid(True)
>>> plt.show()
"""
__IS_ABSTRACT__ = False
__VARIABLES__ = ["R"]
__VARIABLES_LATEX__ = [r"R"]
__PARAMETERS__ = {
"Sigma_0": unyt.unyt_quantity(1.0, "Msun/kpc**2"),
"R_0": unyt.unyt_quantity(5.0, "kpc"),
"sigma": unyt.unyt_quantity(0.5, "kpc"),
}
@classmethod
def __function__(cls, R, Sigma_0=1.0, R_0=1.0, sigma=0.5):
return Sigma_0 * sp.exp(-((R - R_0) ** 2) / (2 * sigma**2))
@classmethod
def __function_units__(cls, R, Sigma_0=unyt.unyt_quantity(1, "Msun/kpc**2"), **kwargs):
return Sigma_0.units
[docs]
class CoreSersicProfile(BaseProfile):
r"""Core-Sérsic Surface Density Profile.
Models galaxies with depleted cores and outer Sérsic-like falloff:
.. math::
\Sigma(R) = \Sigma_b \left[1 + \left( \frac{R_b}{R} \right)^\alpha \right]^{\gamma/\alpha}
\exp\left( -b_n \left( \left( \frac{R^\alpha + R_b^\alpha}{R_e^\alpha} \right)^{1/(\alpha n)}
- \left( \frac{R_b}{R_e} \right)^{1/n} \right) \right)
where:
- :math:`\Sigma_b` is the surface density at the break radius.
- :math:`R_b` is the break radius between core and outer profile.
- :math:`\gamma` controls the inner power-law slope.
- :math:`\alpha` controls sharpness of transition.
- :math:`R_e` is the effective radius of the outer Sérsic portion.
- :math:`n` is the Sérsic index.
- :math:`b_n` is the usual Sérsic constant.
.. dropdown:: Parameters
.. list-table::
:widths: 25 25 50
:header-rows: 1
* - **Name**
- **Symbol**
- **Description**
* - ``Sigma_b``
- :math:`\Sigma_b`
- Surface density at break radius
* - ``R_b``
- :math:`R_b`
- Break radius
* - ``R_e``
- :math:`R_e`
- Sérsic effective radius
* - ``n``
- :math:`n`
- Sérsic index
* - ``gamma``
- :math:`\gamma`
- Inner slope
* - ``alpha``
- :math:`\alpha`
- Transition sharpness
Example
-------
.. plot::
:include-source:
>>> from pisces.profiles.density import (
... CoreSersicProfile,
... )
>>> import matplotlib.pyplot as plt
>>> R = np.linspace(0.1, 20, 300)
>>> profile = CoreSersicProfile(
... Sigma_b=1.0,
... R_b=2.0,
... R_e=5.0,
... n=4.0,
... gamma=0.5,
... alpha=5.0,
... )
>>> Sigma = profile(R)
>>> plt.semilogy(R, Sigma)
>>> plt.xlabel("Radius (R)")
>>> plt.ylabel("Surface Density (Sigma)")
>>> plt.title("Core-Sérsic Surface Profile")
>>> plt.grid(True)
>>> plt.show()
"""
__IS_ABSTRACT__ = False
__VARIABLES__ = ["R"]
__VARIABLES_LATEX__ = [r"R"]
__PARAMETERS__ = {
"Sigma_b": unyt.unyt_quantity(1.0, "Msun/kpc**2"),
"R_b": unyt.unyt_quantity(2.0, "kpc"),
"R_e": unyt.unyt_quantity(5.0, "kpc"),
"n": 4.0,
"gamma": 0.5,
"alpha": 5.0,
}
@staticmethod
def _b_n(n):
return 2 * n - 1 / 3 + 4 / (405 * n) + 46 / (25515 * n**2)
@classmethod
def __function__(cls, R, Sigma_b=1.0, R_b=2.0, R_e=5.0, n=4.0, gamma=0.5, alpha=5.0):
b_n = cls._b_n(n)
x = (R**alpha + R_b**alpha) / R_e**alpha
term1 = (1 + (R_b / R) ** alpha) ** (gamma / alpha)
term2 = sp.exp(-b_n * (x ** (1 / (alpha * n)) - (R_b / R_e) ** (1 / n)))
return Sigma_b * term1 * term2
@classmethod
def __function_units__(cls, R, Sigma_b=unyt.unyt_quantity(1, "Msun/kpc**2"), **kwargs):
return Sigma_b.units