Source code for gammapy.astro.darkmatter.utils

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""Utilities to compute J-factor maps."""

import html

import astropy.units as u
from gammapy.modeling.models.prior import (
    LogNormalPrior,
)
import numpy as np

__all__ = ["JFactory", "add_factor_prior"]


[docs] class JFactory: """Compute J-Factor or D-Factor maps. J-Factors are computed for annihilation and D-Factors for decay. Set the argument `annihilation` to `False` to compute D-Factors. The assumed dark matter profiles will be centered on the center of the map. Parameters ---------- geom : `~gammapy.maps.WcsGeom` Reference geometry. profile : `~gammapy.astro.darkmatter.profiles.DMProfile` Dark matter profile. distance : `~astropy.units.Quantity` Distance from the observer to the dark matter halo center, used to compute the line-of-sight integration geometry. annihilation : `~astropy.units.Quantity`, optional Decay or annihilation. Default is True. rmax : `~astropy.units.Quantity` Physical size of the dark matter halo (upper limit of the line-of-sight integral). For extragalactic sources, this should be set to the halo radius (~kpc), **not** the distance to the source. Defaults to ``distance`` for backward compatibility, which is only appropriate for Galactic sources. """
[docs] def __init__(self, geom, profile, distance, rmax, annihilation=True): self.geom = geom self.profile = profile self.distance = distance self.annihilation = annihilation self.rmax = rmax
def _repr_html_(self): try: return self.to_html() except AttributeError: return f"<pre>{html.escape(str(self))}</pre>" def _integrate_los_branch(self, impact, radius_min, radius_max, ndecade): """Integrate one radial line-of-sight branch.""" exponent = 2 if self.annihilation else 1 unit = radius_max.unit impact = impact.to(unit) radius_min = radius_min.to(unit) radius_max = radius_max.to(unit) if impact.value == 0: return self.profile.integral( radius_min, radius_max, 0, ndecade, self.annihilation, self.distance ) logmin = np.log10(radius_min.value) logmax = np.log10(radius_max.value) n = max(2, int((logmax - logmin) * ndecade)) t_min = np.arccosh(np.maximum((radius_min / impact).to_value(""), 1)) t_max = np.arccosh(np.maximum((radius_max / impact).to_value(""), 1)) t = np.linspace(t_min, t_max, n) radius = impact * np.cosh(t) values = self.profile(radius) ** exponent * radius return np.trapezoid(values, t) def _integrate_los(self, impact, separation, ndecade): """Integrate the physical forward line of sight.""" distance = self.distance rmax = self.rmax if distance < rmax: integral = self._integrate_los_branch(impact, distance, rmax, ndecade) if separation < np.pi / 2: integral += 2 * self._integrate_los_branch( impact, impact, distance, ndecade ) return integral if separation < np.pi / 2 and impact < rmax: return 2 * self._integrate_los_branch(impact, impact, rmax, ndecade) return 0 * u.Unit("GeV2 cm-5" if self.annihilation else "GeV cm-2")
[docs] def compute_differential_jfactor(self, ndecade=1e4): r"""Compute differential J-Factor. .. math:: \frac{\mathrm d J_\text{ann}}{\mathrm d \Omega} = \int_{\mathrm{LoS}} \mathrm d l \rho(l)^2 .. math:: \frac{\mathrm d J_\text{decay}}{\mathrm d \Omega} = \int_{\mathrm{LoS}} \mathrm d l \rho(l) Parameters ---------- ndecade : float, optional Number of sampling points per decade in radius used for the numerical integration. Default is 1e4. Returns ------- jfactor : `~astropy.units.Quantity` Differential j-factor. Notes ----- The line-of-sight geometry is defined by .. math:: r(l)^2 = D^2 + l^2 - 2 D l \cos\theta, where :math:`D` is the observer-to-halo-center distance and :math:`l \geq 0` is the physical forward line-of-sight coordinate. The impact parameter of the corresponding infinite line is given by: .. math:: r_\perp = D \sin\theta. The integration is split into two regions: 1. :math:`D < r_{\max}`: the observer is inside the integration radius. Directions with :math:`\theta < \pi / 2` cross the inner radial interval twice, while directions with :math:`\theta \geq \pi / 2` contain only the outward branch. 2. :math:`D \geq r_{\max}`: the observer is outside the integration radius. The line of sight contributes only when it points toward the halo and intersects the integration sphere, i.e. when :math:`\theta < \pi / 2` and :math:`r_\perp < r_{\max}`. Each radial branch is evaluated using .. math:: \mathrm dl = \frac{r}{\sqrt{r^2-r_\perp^2}}\,\mathrm dr. The apparent singularity at :math:`r = r_\perp` is integrable. To avoid evaluating it directly, each radial branch is integrated with the substitution :math:`r = r_\perp\cosh t`. """ separation = self.geom.separation(self.geom.center_skydir).rad impact = u.Quantity( value=np.sin(separation) * self.distance, unit=self.distance.unit ) val = [ self._integrate_los(impact_i, separation_i, ndecade) for impact_i, separation_i in zip(impact.ravel(), separation.ravel()) ] integral_unit = u.Unit("GeV2 cm-5") if self.annihilation else u.Unit("GeV cm-2") jfact = u.Quantity(val).to(integral_unit).reshape(impact.shape) return jfact / u.steradian
[docs] def compute_jfactor(self, ndecade=1e4): r"""Compute astrophysical J-Factor. .. math:: J(\Delta\Omega) = \int_{\Delta\Omega} \mathrm d \Omega^{\prime} \frac{\mathrm d J}{\mathrm d \Omega^{\prime}} Parameters ---------- ndecade : float, optional Number of sampling points per decade in radius used for the numerical integration. Default is 1e4. Returns ------- jfactor : `~astropy.units.Quantity` The j-factor. """ diff_jfact = self.compute_differential_jfactor(ndecade) return diff_jfact * self.geom.to_image().solid_angle()
[docs] def add_factor_prior(model, sigma, mu=1.0): """Attach a Log Normal nuisance prior on ``scale`` for J/D-factor uncertainty. The J/D-factor is kept fixed at its nominal value; the associated uncertainty is instead expressed as an equivalent prior on ``scale``, since the predicted flux depends only on the product ``scale * jfactor``. Placing the prior directly on a second parameter (e.g. ``log10_jfactor``) would make it perfectly degenerate with ``scale``. This reparametrisation is a pure shift, so the prior retains the same shape and ``sigma``, centered at ``scale = 1`` instead of at the nominal log10(J). Parameters ---------- model : `~gammapy.astro.darkmatter.DarkMatterSpectralModel` Model whose ``scale`` parameter will get the prior attached. ``scale`` is unfrozen as part of this call. sigma : float Uncertainty on log10(J) (or log10(D)), in dex. mu : float, optional Center of the prior, in units of ``scale``. Default is 1.0, i.e. the nominal J/D-factor value. Returns ------- model : `DarkMatterSpectralModel` The same model instance, with the prior attached, for chaining. """ model.scale.frozen = False model.scale.prior = LogNormalPrior(mu=mu, sigma=sigma * np.log(10)) return model