Source code for naima.radiative

# -*- coding: utf-8 -*-
# Licensed under a 3-clause BSD style license - see LICENSE.rst
import logging
import os
import warnings
from collections import OrderedDict

import numpy as np
from astropy import units as u
from astropy.constants import alpha, c, e, hbar, m_e, m_p, sigma_sb
from astropy.utils.data import get_pkg_data_filename

from .extern.validator import (
    validate_array,
    validate_physical_type,
    validate_scalar,
)
from .model_utils import memoize
from .utils import trapz_loglog

__all__ = [
    "Synchrotron",
    "InverseCompton",
    "PionDecay",
    "Bremsstrahlung",
    "PionDecayKelner06",
]

# Get a new logger to avoid changing the level of the astropy logger
log = logging.getLogger("naima.radiative")
log.setLevel(logging.INFO)

e = e.gauss

mec2 = (m_e * c ** 2).cgs
mec2_unit = u.Unit(mec2)

ar = (4 * sigma_sb / c).to("erg/(cm3 K4)")
r0 = (e ** 2 / mec2).to("cm")


def _validate_ene(ene):
    from astropy.table import Table

    if isinstance(ene, dict) or isinstance(ene, Table):
        try:
            ene = validate_array(
                "energy", u.Quantity(ene["energy"]), physical_type="energy"
            )
        except KeyError:
            raise TypeError("Table or dict does not have 'energy' column")
    else:
        if not isinstance(ene, u.Quantity):
            ene = u.Quantity(ene)
        validate_physical_type("energy", ene, physical_type="energy")

    return ene


class BaseRadiative:
    """Base class for radiative models

    This class implements the flux, sed methods and subclasses must implement
    the spectrum method which returns the intrinsic differential spectrum.
    """

    def __init__(self, particle_distribution):
        self.particle_distribution = particle_distribution
        try:
            # Check first for the amplitude attribute, which will be present if
            # the particle distribution is a function from naima.models
            pd = self.particle_distribution.amplitude
            validate_physical_type(
                "Particle distribution",
                pd,
                physical_type="differential energy",
            )
        except (AttributeError, TypeError):
            # otherwise check the output
            pd = self.particle_distribution([0.1, 1, 10] * u.TeV)
            validate_physical_type(
                "Particle distribution",
                pd,
                physical_type="differential energy",
            )

    @memoize
    def flux(self, photon_energy, distance=1 * u.kpc):
        """Differential flux at a given distance from the source.

        Parameters
        ----------
        photon_energy : :class:`~astropy.units.Quantity` float or array
            Photon energy array.

        distance : :class:`~astropy.units.Quantity` float, optional
            Distance to the source. If set to 0, the intrinsic differential
            luminosity will be returned. Default is 1 kpc.
        """

        spec = self._spectrum(photon_energy)

        if distance != 0:
            distance = validate_scalar(
                "distance", distance, physical_type="length"
            )
            spec /= 4 * np.pi * distance.to("cm") ** 2
            out_unit = "1/(s cm2 eV)"
        else:
            out_unit = "1/(s eV)"

        return spec.to(out_unit)

    def sed(self, photon_energy, distance=1 * u.kpc):
        """Spectral energy distribution at a given distance from the source.

        Parameters
        ----------
        photon_energy : :class:`~astropy.units.Quantity` float or array
            Photon energy array.

        distance : :class:`~astropy.units.Quantity` float, optional
            Distance to the source. If set to 0, the intrinsic luminosity will
            be returned. Default is 1 kpc.
        """
        if distance != 0:
            out_unit = "erg/(cm2 s)"
        else:
            out_unit = "erg/s"

        photon_energy = _validate_ene(photon_energy)

        sed = (self.flux(photon_energy, distance) * photon_energy ** 2.0).to(
            out_unit
        )

        return sed


class BaseElectron(BaseRadiative):
    """Implements gam and nelec properties"""

    def __init__(self, particle_distribution):
        super().__init__(particle_distribution)
        self.param_names = ["Eemin", "Eemax", "nEed"]
        self._memoize = True
        self._cache = {}
        self._queue = []

    @property
    def _gam(self):
        """Lorentz factor array"""
        log10gmin = np.log10(self.Eemin / mec2).value
        log10gmax = np.log10(self.Eemax / mec2).value
        return np.logspace(
            log10gmin, log10gmax, int(self.nEed * (log10gmax - log10gmin))
        )

    @property
    def _nelec(self):
        """Particles per unit lorentz factor"""
        pd = self.particle_distribution(self._gam * mec2)
        return pd.to(1 / mec2_unit).value

    @property
    def We(self):
        """Total energy in electrons used for the radiative calculation"""
        We = trapz_loglog(self._gam * self._nelec, self._gam * mec2)
        return We

    def compute_We(self, Eemin=None, Eemax=None):
        """Total energy in electrons between energies Eemin and Eemax

        Parameters
        ----------
        Eemin : :class:`~astropy.units.Quantity` float, optional
            Minimum electron energy for energy content calculation.

        Eemax : :class:`~astropy.units.Quantity` float, optional
            Maximum electron energy for energy content calculation.
        """
        if Eemin is None and Eemax is None:
            We = self.We
        else:
            if Eemax is None:
                Eemax = self.Eemax
            if Eemin is None:
                Eemin = self.Eemin

            log10gmin = np.log10(Eemin / mec2).value
            log10gmax = np.log10(Eemax / mec2).value
            gam = np.logspace(
                log10gmin, log10gmax, int(self.nEed * (log10gmax - log10gmin))
            )
            nelec = (
                self.particle_distribution(gam * mec2).to(1 / mec2_unit).value
            )
            We = trapz_loglog(gam * nelec, gam * mec2)

        return We

    def set_We(self, We, Eemin=None, Eemax=None, amplitude_name=None):
        """Normalize particle distribution so that the total energy in electrons
        between Eemin and Eemax is We

        Parameters
        ----------
        We : :class:`~astropy.units.Quantity` float
            Desired energy in electrons.

        Eemin : :class:`~astropy.units.Quantity` float, optional
            Minimum electron energy for energy content calculation.

        Eemax : :class:`~astropy.units.Quantity` float, optional
            Maximum electron energy for energy content calculation.

        amplitude_name : str, optional
            Name of the amplitude parameter of the particle distribution. It
            must be accesible as an attribute of the distribution function.
            Defaults to ``amplitude``.
        """

        We = validate_scalar("We", We, physical_type="energy")
        oldWe = self.compute_We(Eemin=Eemin, Eemax=Eemax)

        if amplitude_name is None:
            try:
                self.particle_distribution.amplitude *= (
                    We / oldWe
                ).decompose()
            except AttributeError:
                log.error(
                    "The particle distribution does not have an attribute"
                    " called amplitude to modify its normalization: you can"
                    " set the name with the amplitude_name parameter of set_We"
                )
        else:
            oldampl = getattr(self.particle_distribution, amplitude_name)
            setattr(
                self.particle_distribution,
                amplitude_name,
                oldampl * (We / oldWe).decompose(),
            )


[docs]class Synchrotron(BaseElectron): """Synchrotron emission from an electron population. This class uses the approximation of the synchrotron emissivity in a random magnetic field of Aharonian, Kelner, and Prosekin 2010, PhysRev D 82, 3002 (`arXiv:1006.1045 <http://arxiv.org/abs/1006.1045>`_). Parameters ---------- particle_distribution : function Particle distribution function, taking electron energies as a `~astropy.units.Quantity` array or float, and returning the particle energy density in units of number of electrons per unit energy as a `~astropy.units.Quantity` array or float. B : :class:`~astropy.units.Quantity` float instance, optional Isotropic magnetic field strength. Default: equipartition with CMB (3.24e-6 G) Other parameters ---------------- Eemin : :class:`~astropy.units.Quantity` float instance, optional Minimum electron energy for the electron distribution. Default is 1 GeV. Eemax : :class:`~astropy.units.Quantity` float instance, optional Maximum electron energy for the electron distribution. Default is 510 TeV. nEed : scalar Number of points per decade in energy for the electron energy and distribution arrays. Default is 100. """ def __init__(self, particle_distribution, B=3.24e-6 * u.G, **kwargs): super().__init__(particle_distribution) self.B = validate_scalar("B", B, physical_type="magnetic flux density") self.Eemin = 1 * u.GeV self.Eemax = 1e9 * mec2 self.nEed = 100 self.param_names += ["B"] self.__dict__.update(**kwargs) def _spectrum(self, photon_energy): """Compute intrinsic synchrotron differential spectrum for energies in ``photon_energy`` Compute synchrotron for random magnetic field according to approximation of Aharonian, Kelner, and Prosekin 2010, PhysRev D 82, 3002 (`arXiv:1006.1045 <http://arxiv.org/abs/1006.1045>`_). Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` instance Photon energy array. """ outspecene = _validate_ene(photon_energy) from scipy.special import cbrt def Gtilde(x): """ AKP10 Eq. D7 Factor ~2 performance gain in using cbrt(x)**n vs x**(n/3.) Invoking crbt only once reduced time by ~40% """ cb = cbrt(x) gt1 = 1.808 * cb / np.sqrt(1 + 3.4 * cb ** 2.0) gt2 = 1 + 2.210 * cb ** 2.0 + 0.347 * cb ** 4.0 gt3 = 1 + 1.353 * cb ** 2.0 + 0.217 * cb ** 4.0 return gt1 * (gt2 / gt3) * np.exp(-x) log.debug("calc_sy: Starting synchrotron computation with AKB2010...") # strip units, ensuring correct conversion # astropy units do not convert correctly for gyroradius calculation # when using cgs (SI is fine, see # https://github.com/astropy/astropy/issues/1687) CS1_0 = np.sqrt(3) * e.value ** 3 * self.B.to("G").value CS1_1 = ( 2 * np.pi * m_e.cgs.value * c.cgs.value ** 2 * hbar.cgs.value * outspecene.to("erg").value ) CS1 = CS1_0 / CS1_1 # Critical energy, erg Ec = ( 3 * e.value * hbar.cgs.value * self.B.to("G").value * self._gam ** 2 ) Ec /= 2 * (m_e * c).cgs.value EgEc = outspecene.to("erg").value / np.vstack(Ec) dNdE = CS1 * Gtilde(EgEc) # return units spec = ( trapz_loglog(np.vstack(self._nelec) * dNdE, self._gam, axis=0) / u.s / u.erg ) spec = spec.to("1/(s eV)") return spec
def G12(x, a): """ Eqs 20, 24, 25 of Khangulyan et al (2014) """ alpha, a, beta, b = a pi26 = np.pi ** 2 / 6.0 G = (pi26 + x) * np.exp(-x) tmp = 1 + b * x ** beta g = 1.0 / (a * x ** alpha / tmp + 1.0) return G * g def G34(x, a): """ Eqs 20, 24, 25 of Khangulyan et al (2014) """ alpha, a, beta, b, c = a pi26 = np.pi ** 2 / 6.0 tmp = (1 + c * x) / (1 + pi26 * c * x) G = pi26 * tmp * np.exp(-x) tmp = 1 + b * x ** beta g = 1.0 / (a * x ** alpha / tmp + 1.0) return G * g
[docs]class InverseCompton(BaseElectron): """Inverse Compton emission from an electron population. If you use this class in your research, please consult and cite `Khangulyan, D., Aharonian, F.A., & Kelner, S.R. 2014, Astrophysical Journal, 783, 100 <http://adsabs.harvard.edu/abs/2014ApJ...783..100K>`_ Parameters ---------- particle_distribution : function Particle distribution function, taking electron energies as a `~astropy.units.Quantity` array or float, and returning the particle energy density in units of number of electrons per unit energy as a `~astropy.units.Quantity` array or float. seed_photon_fields : string or iterable of strings (optional) A list of gray-body or non-thermal seed photon fields to use for IC calculation. Each of the items of the iterable can be either: * A string equal to ``CMB`` (default), ``NIR``, or ``FIR``, for which radiation fields with temperatures of 2.72 K, 30 K, and 3000 K, and energy densities of 0.261, 0.5, and 1 eV/cm³ will be used (these are the GALPROP values for a location at a distance of 6.5 kpc from the galactic center). * A list of length three (isotropic source) or four (anisotropic source) composed of: 1. A name for the seed photon field. 2. Its temperature (thermal source) or energy (monochromatic or non-thermal source) as a :class:`~astropy.units.Quantity` instance. 3. Its photon field energy density as a :class:`~astropy.units.Quantity` instance. 4. Optional: The angle between the seed photon direction and the scattered photon direction as a :class:`~astropy.units.Quantity` float instance. Other parameters ---------------- Eemin : :class:`~astropy.units.Quantity` float instance, optional Minimum electron energy for the electron distribution. Default is 1 GeV. Eemax : :class:`~astropy.units.Quantity` float instance, optional Maximum electron energy for the electron distribution. Default is 510 TeV. nEed : scalar Number of points per decade in energy for the electron energy and distribution arrays. Default is 300. """ def __init__( self, particle_distribution, seed_photon_fields=["CMB"], **kwargs ): super().__init__(particle_distribution) self.seed_photon_fields = self._process_input_seed(seed_photon_fields) self.Eemin = 1 * u.GeV self.Eemax = 1e9 * mec2 self.nEed = 100 self.param_names += ["seed_photon_fields"] self.__dict__.update(**kwargs) @staticmethod def _process_input_seed(seed_photon_fields): """ take input list of seed_photon_fields and fix them into usable format """ Tcmb = 2.72548 * u.K # 0.00057 K Tfir = 30 * u.K ufir = 0.5 * u.eV / u.cm ** 3 Tnir = 3000 * u.K unir = 1.0 * u.eV / u.cm ** 3 # Allow for seed_photon_fields definitions of the type 'CMB-NIR-FIR' or # 'CMB' if type(seed_photon_fields) != list: seed_photon_fields = seed_photon_fields.split("-") result = OrderedDict() for idx, inseed in enumerate(seed_photon_fields): seed = {} if isinstance(inseed, str): name = inseed seed["type"] = "thermal" if inseed == "CMB": seed["T"] = Tcmb seed["u"] = ar * Tcmb ** 4 seed["isotropic"] = True elif inseed == "FIR": seed["T"] = Tfir seed["u"] = ufir seed["isotropic"] = True elif inseed == "NIR": seed["T"] = Tnir seed["u"] = unir seed["isotropic"] = True else: log.warning( "Will not use seed {0} because it is not " "CMB, FIR or NIR".format(inseed) ) raise TypeError elif type(inseed) == list and ( len(inseed) == 3 or len(inseed) == 4 ): isotropic = len(inseed) == 3 if isotropic: name, T, uu = inseed seed["isotropic"] = True else: name, T, uu, theta = inseed seed["isotropic"] = False seed["theta"] = validate_scalar( "{0}-theta".format(name), theta, physical_type="angle" ) thermal = T.unit.physical_type == "temperature" if thermal: seed["type"] = "thermal" validate_scalar( "{0}-T".format(name), T, domain="positive", physical_type="temperature", ) seed["T"] = T if uu == 0: seed["u"] = ar * T ** 4 else: # pressure has same physical type as energy density validate_scalar( "{0}-u".format(name), uu, domain="positive", physical_type="pressure", ) seed["u"] = uu else: seed["type"] = "array" # Ensure everything is in arrays T = u.Quantity((T,)).flatten() uu = u.Quantity((uu,)).flatten() seed["energy"] = validate_array( "{0}-energy".format(name), T, domain="positive", physical_type="energy", ) if np.isscalar(seed["energy"]) or seed["energy"].size == 1: seed["photon_density"] = validate_scalar( "{0}-density".format(name), uu, domain="positive", physical_type="pressure", ) else: if uu.unit.physical_type == "pressure": uu /= seed["energy"] ** 2 seed["photon_density"] = validate_array( "{0}-density".format(name), uu, domain="positive", physical_type="differential number density", ) else: raise TypeError( "Unable to process seed photon" " field: {0}".format(inseed) ) result[name] = seed return result @staticmethod def _iso_ic_on_planck( electron_energy, soft_photon_temperature, gamma_energy ): """ IC cross-section for isotropic interaction with a blackbody photon spectrum following Eq. 14 of Khangulyan, Aharonian, and Kelner 2014, ApJ 783, 100 (`arXiv:1310.7971 <http://www.arxiv.org/abs/1310.7971>`_). `electron_energy` and `gamma_energy` are in units of m_ec^2 `soft_photon_temperature` is in units of K """ Ktomec2 = 1.6863699549e-10 soft_photon_temperature *= Ktomec2 gamma_energy = np.vstack(gamma_energy) # Parameters from Eqs 26, 27 a3 = [0.606, 0.443, 1.481, 0.540, 0.319] a4 = [0.461, 0.726, 1.457, 0.382, 6.620] z = gamma_energy / electron_energy x = z / (1 - z) / (4.0 * electron_energy * soft_photon_temperature) # Eq. 14 cross_section = z ** 2 / (2 * (1 - z)) * G34(x, a3) + G34(x, a4) tmp = (soft_photon_temperature / electron_energy) ** 2 # r0 = (e**2 / m_e / c**2).to('cm') # (2 * r0 ** 2 * m_e ** 3 * c ** 4 / (pi * hbar ** 3)).cgs tmp *= 2.6318735743809104e16 cross_section = tmp * cross_section cc = (gamma_energy < electron_energy) * (electron_energy > 1) return np.where(cc, cross_section, np.zeros_like(cross_section)) @staticmethod def _ani_ic_on_planck( electron_energy, soft_photon_temperature, gamma_energy, theta ): """ IC cross-section for anisotropic interaction with a blackbody photon spectrum following Eq. 11 of Khangulyan, Aharonian, and Kelner 2014, ApJ 783, 100 (`arXiv:1310.7971 <http://www.arxiv.org/abs/1310.7971>`_). `electron_energy` and `gamma_energy` are in units of m_ec^2 `soft_photon_temperature` is in units of K `theta` is in radians """ Ktomec2 = 1.6863699549e-10 soft_photon_temperature *= Ktomec2 gamma_energy = gamma_energy[:, None] # Parameters from Eqs 21, 22 a1 = [0.857, 0.153, 1.840, 0.254] a2 = [0.691, 1.330, 1.668, 0.534] z = gamma_energy / electron_energy ttheta = ( 2.0 * electron_energy * soft_photon_temperature * (1.0 - np.cos(theta)) ) x = z / (1 - z) / ttheta # Eq. 11 cross_section = z ** 2 / (2 * (1 - z)) * G12(x, a1) + G12(x, a2) tmp = (soft_photon_temperature / electron_energy) ** 2 # r0 = (e**2 / m_e / c**2).to('cm') # (2 * r0 ** 2 * m_e ** 3 * c ** 4 / (pi * hbar ** 3)).cgs tmp *= 2.6318735743809104e16 cross_section = tmp * cross_section cc = (gamma_energy < electron_energy) * (electron_energy > 1) return np.where(cc, cross_section, np.zeros_like(cross_section)) @staticmethod def _iso_ic_on_monochromatic( electron_energy, seed_energy, seed_edensity, gamma_energy ): """ IC cross-section for an isotropic interaction with a monochromatic photon spectrum following Eq. 22 of Aharonian & Atoyan 1981, Ap&SS 79, 321 (`http://adsabs.harvard.edu/abs/1981Ap%26SS..79..321A`_) """ photE0 = (seed_energy / mec2).decompose().value phn = seed_edensity # electron_energy = electron_energy[:, None] gamma_energy = gamma_energy[:, None] photE0 = photE0[:, None, None] phn = phn[:, None, None] b = 4 * photE0 * electron_energy w = gamma_energy / electron_energy q = w / (b * (1 - w)) fic = ( 2 * q * np.log(q) + (1 + 2 * q) * (1 - q) + (1.0 / 2.0) * (b * q) ** 2 * (1 - q) / (1 + b * q) ) gamint = ( fic * heaviside(1 - q) * heaviside(q - 1.0 / (4 * electron_energy ** 2)) ) gamint[np.isnan(gamint)] = 0.0 if phn.size > 1: phn = phn.to(1 / (mec2_unit * u.cm ** 3)).value gamint = trapz_loglog(gamint * phn / photE0, photE0, axis=0) # 1/s else: phn = phn.to(mec2_unit / u.cm ** 3).value gamint *= phn / photE0 ** 2 gamint = gamint.squeeze() # gamint /= mec2.to('erg').value # r0 = (e**2 / m_e / c**2).to('cm') # sigt = ((8 * np.pi) / 3 * r0**2).cgs sigt = 6.652458734983284e-25 c = 29979245800.0 gamint *= (3.0 / 4.0) * sigt * c / electron_energy ** 2 return gamint def _calc_specic(self, seed, outspecene): log.debug( "_calc_specic: Computing IC on {0} seed photons...".format(seed) ) Eph = (outspecene / mec2).decompose().value # Catch numpy RuntimeWarnings of overflowing exp (which are then # discarded anyway) with warnings.catch_warnings(): warnings.simplefilter("ignore") if self.seed_photon_fields[seed]["type"] == "thermal": T = self.seed_photon_fields[seed]["T"] uf = ( self.seed_photon_fields[seed]["u"] / (ar * T ** 4) ).decompose() if self.seed_photon_fields[seed]["isotropic"]: gamint = self._iso_ic_on_planck( self._gam, T.to("K").value, Eph ) else: theta = ( self.seed_photon_fields[seed]["theta"].to("rad").value ) gamint = self._ani_ic_on_planck( self._gam, T.to("K").value, Eph, theta ) else: uf = 1 gamint = self._iso_ic_on_monochromatic( self._gam, self.seed_photon_fields[seed]["energy"], self.seed_photon_fields[seed]["photon_density"], Eph, ) lum = uf * Eph * trapz_loglog(self._nelec * gamint, self._gam) lum = lum * u.Unit("1/s") return lum / outspecene # return differential spectrum in 1/s/eV def _spectrum(self, photon_energy): """Compute differential IC spectrum for energies in ``photon_energy``. Compute IC spectrum using IC cross-section for isotropic interaction with a blackbody photon spectrum following Khangulyan, Aharonian, and Kelner 2014, ApJ 783, 100 (`arXiv:1310.7971 <http://www.arxiv.org/abs/1310.7971>`_). Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` instance Photon energy array. """ outspecene = _validate_ene(photon_energy) self.specic = [] for seed in self.seed_photon_fields: # Call actual computation, detached to allow changes in subclasses self.specic.append( self._calc_specic(seed, outspecene).to("1/(s eV)") ) return np.sum(u.Quantity(self.specic), axis=0)
[docs] def flux(self, photon_energy, distance=1 * u.kpc, seed=None): """Differential flux at a given distance from the source from a single seed photon field Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` float or array Photon energy array. distance : :class:`~astropy.units.Quantity` float, optional Distance to the source. If set to 0, the intrinsic luminosity will be returned. Default is 1 kpc. seed : int, str or None Number or name of seed photon field for which the IC contribution is required. If set to None it will return the sum of all contributions (default). """ model = super().flux(photon_energy, distance=distance) if seed is not None: # Test seed argument if not isinstance(seed, int): if seed not in self.seed_photon_fields: raise ValueError( "Provided seed photon field name is not in" " the definition of the InverseCompton instance" ) else: seed = list(self.seed_photon_fields.keys()).index(seed) elif seed > len(self.seed_photon_fields): raise ValueError( "Provided seed photon field number is larger" " than the number of seed photon fields defined in the" " InverseCompton instance" ) if distance != 0: distance = validate_scalar( "distance", distance, physical_type="length" ) dfac = 4 * np.pi * distance.to("cm") ** 2 out_unit = "1/(s cm2 eV)" else: dfac = 1 out_unit = "1/(s eV)" model = (self.specic[seed] / dfac).to(out_unit) return model
[docs] def sed(self, photon_energy, distance=1 * u.kpc, seed=None): """Spectral energy distribution at a given distance from the source Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` float or array Photon energy array. distance : :class:`~astropy.units.Quantity` float, optional Distance to the source. If set to 0, the intrinsic luminosity will be returned. Default is 1 kpc. seed : int, str or None Number or name of seed photon field for which the IC contribution is required. If set to None it will return the sum of all contributions (default). """ sed = super().sed(photon_energy, distance=distance) if seed is not None: if distance != 0: out_unit = "erg/(cm2 s)" else: out_unit = "erg/s" sed = ( self.flux(photon_energy, distance=distance, seed=seed) * photon_energy ** 2.0 ).to(out_unit) return sed
[docs]class Bremsstrahlung(BaseElectron): """ Bremsstrahlung radiation on a completely ionised gas. This class uses the cross-section approximation of `Baring, M.G., Ellison, D.C., Reynolds, S.P., Grenier, I.A., & Goret, P. 1999, Astrophysical Journal, 513, 311 <http://adsabs.harvard.edu/abs/1999ApJ...513..311B>`_. The default weights are assuming a completely ionised target gas with ISM abundances. If pure electron-electron bremsstrahlung is desired, ``n0`` can be set to the electron density, ``weight_ep`` to 0 and ``weight_ee`` to 1. Parameters ---------- n0 : :class:`~astropy.units.Quantity` float Total ion number density. Other parameters ---------------- weight_ee : float Weight of electron-electron bremsstrahlung. Defined as :math:`\\sum_i Z_i X_i`, default is 1.088. weight_ep : float Weight of electron-proton bremsstrahlung. Defined as :math:`\\sum_i Z_i^2 X_i`, default is 1.263. """ def __init__(self, particle_distribution, n0=1 / u.cm ** 3, **kwargs): super().__init__(particle_distribution) self.n0 = n0 self.Eemin = 100 * u.MeV self.Eemax = 1e9 * mec2 self.nEed = 300 # compute ee and ep weights from H and He abundances in ISM assumin # ionized medium Y = np.array([1.0, 9.59e-2]) Z = np.array([1, 2]) N = np.sum(Y) X = Y / N self.weight_ee = np.sum(Z * X) self.weight_ep = np.sum(Z ** 2 * X) self.param_names += ["n0", "weight_ee", "weight_ep"] self.__dict__.update(**kwargs) @staticmethod def _sigma_1(gam, eps): """ gam and eps in units of m_e c^2 Eq. A2 of Baring et al. (1999) Return in units of cm2 / mec2 """ s1 = 4 * r0 ** 2 * alpha / eps / mec2_unit s2 = 1 + (1.0 / 3.0 - eps / gam) * (1 - eps / gam) s3 = np.log(2 * gam * (gam - eps) / eps) - 1.0 / 2.0 s3[np.where(gam < eps)] = 0.0 return s1 * s2 * s3 @staticmethod def _sigma_2(gam, eps): """ gam and eps in units of m_e c^2 Eq. A3 of Baring et al. (1999) Return in units of cm2 / mec2 """ s0 = r0 ** 2 * alpha / (3 * eps) / mec2_unit s1_1 = 16 * (1 - eps + eps ** 2) * np.log(gam / eps) s1_2 = -1 / eps ** 2 + 3 / eps - 4 - 4 * eps - 8 * eps ** 2 s1_3 = -2 * (1 - 2 * eps) * np.log(1 - 2 * eps) s1_4 = 1 / (4 * eps ** 3) - 1 / (2 * eps ** 2) + 3 / eps - 2 + 4 * eps s1 = s1_1 + s1_2 + s1_3 * s1_4 s2_1 = 2 / eps s2_2 = (4 - 1 / eps + 1 / (4 * eps ** 2)) * np.log(2 * gam) s2_3 = -2 + 2 / eps - 5 / (8 * eps ** 2) s2 = s2_1 * (s2_2 + s2_3) return s0 * np.where(eps <= 0.5, s1, s2) * heaviside(gam - eps) def _sigma_ee_rel(self, gam, eps): """ Eq. A1, A4 of Baring et al. (1999) Use for Ee > 2 MeV """ A = 1 - 8 / 3 * (gam - 1) ** 0.2 / (gam + 1) * (eps / gam) ** ( 1.0 / 3.0 ) return (self._sigma_1(gam, eps) + self._sigma_2(gam, eps)) * A @staticmethod def _F(x, gam): """ Eqs. A6, A7 of Baring et al. (1999) """ beta = np.sqrt(1 - gam ** -2) B = 1 + 0.5 * (gam ** 2 - 1) C = 10 * x * gam * beta * (2 + gam * beta) C /= 1 + x ** 2 * (gam ** 2 - 1) F_1 = (17 - 3 * x ** 2 / (2 - x) ** 2 - C) * np.sqrt(1 - x) F_2 = 12 * (2 - x) - 7 * x ** 2 / (2 - x) - 3 * x ** 4 / (2 - x) ** 3 F_3 = np.log((1 + np.sqrt(1 - x)) / np.sqrt(x)) return B * F_1 + F_2 * F_3 def _sigma_ee_nonrel(self, gam, eps): """ Eq. A5 of Baring et al. (1999) Use for Ee < 2 MeV """ s0 = 4 * r0 ** 2 * alpha / (15 * eps) x = 4 * eps / (gam ** 2 - 1) sigma_nonrel = s0 * self._F(x, gam) sigma_nonrel[np.where(eps >= 0.25 * (gam ** 2 - 1.0))] = 0.0 sigma_nonrel[np.where(gam * np.ones_like(eps) < 1.0)] = 0.0 return sigma_nonrel / mec2_unit def _sigma_ee(self, gam, Eph): eps = (Eph / mec2).decompose().value # initialize shape and units of cross section sigma = np.zeros_like(gam * eps) * u.Unit(u.cm ** 2 / Eph.unit) gam_trans = (2 * u.MeV / mec2).decompose().value # Non relativistic below 2 MeV if np.any(gam <= gam_trans): nr_matrix = np.where(gam * np.ones_like(gam * eps) <= gam_trans) with warnings.catch_warnings(): warnings.simplefilter("ignore") sigma[nr_matrix] = self._sigma_ee_nonrel(gam, eps)[nr_matrix] # Relativistic above 2 MeV if np.any(gam > gam_trans): rel_matrix = np.where(gam * np.ones_like(gam * eps) > gam_trans) with warnings.catch_warnings(): warnings.simplefilter("ignore") sigma[rel_matrix] = self._sigma_ee_rel(gam, eps)[rel_matrix] return sigma.to(u.cm ** 2 / Eph.unit) def _sigma_ep(self, gam, eps): """ Using sigma_1 only applies to the ultrarelativistic regime. Eph > 10 MeV ToDo: add complete e-p cross-section """ with warnings.catch_warnings(): warnings.simplefilter("ignore") return self._sigma_1(gam, eps) def _emiss_ee(self, Eph): """ Electron-electron bremsstrahlung emissivity per unit photon energy """ if self.weight_ee == 0.0: return np.zeros_like(Eph) gam = np.vstack(self._gam) # compute integral with electron distribution emiss = c.cgs * trapz_loglog( np.vstack(self._nelec) * self._sigma_ee(gam, Eph), self._gam, axis=0, ) return emiss def _emiss_ep(self, Eph): """ Electron-proton bremsstrahlung emissivity per unit photon energy """ if self.weight_ep == 0.0: return np.zeros_like(Eph) gam = np.vstack(self._gam) eps = (Eph / mec2).decompose().value # compute integral with electron distribution emiss = ( c.cgs * trapz_loglog( np.vstack(self._nelec) * self._sigma_ep(gam, eps), self._gam, axis=0, ).to(u.cm ** 2 / Eph.unit) ) return emiss def _spectrum(self, photon_energy): """Compute differential bremsstrahlung spectrum for energies in ``photon_energy``. Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` instance Photon energy array. """ Eph = _validate_ene(photon_energy) spec = self.n0 * ( self.weight_ee * self._emiss_ee(Eph) + self.weight_ep * self._emiss_ep(Eph) ) return spec
class BaseProton(BaseRadiative): """Implements compute_Wp at arbitrary energies""" def __init__(self, particle_distribution): super().__init__(particle_distribution) self.param_names = ["Epmin", "Epmax", "nEpd"] self._memoize = True self._cache = {} self._queue = [] @property def _Ep(self): """Proton energy array in GeV""" return np.logspace( np.log10(self.Epmin.to("GeV").value), np.log10(self.Epmax.to("GeV").value), int(self.nEpd * (np.log10(self.Epmax / self.Epmin))), ) @property def _J(self): """Particles per unit proton energy in particles per GeV""" pd = self.particle_distribution(self._Ep * u.GeV) return pd.to("1/GeV").value @property def Wp(self): """Total energy in protons""" Wp = trapz_loglog(self._Ep * self._J, self._Ep) * u.GeV return Wp.to("erg") def compute_Wp(self, Epmin=None, Epmax=None): """Total energy in protons between energies Epmin and Epmax Parameters ---------- Epmin : :class:`~astropy.units.Quantity` float, optional Minimum proton energy for energy content calculation. Epmax : :class:`~astropy.units.Quantity` float, optional Maximum proton energy for energy content calculation. """ if Epmin is None and Epmax is None: Wp = self.Wp else: if Epmax is None: Epmax = self.Epmax if Epmin is None: Epmin = self.Epmin log10Epmin = np.log10(Epmin.to("GeV").value) log10Epmax = np.log10(Epmax.to("GeV").value) Ep = ( np.logspace( log10Epmin, log10Epmax, int(self.nEpd * (log10Epmax - log10Epmin)), ) * u.GeV ) pdist = self.particle_distribution(Ep) Wp = trapz_loglog(Ep * pdist, Ep).to("erg") return Wp def set_Wp(self, Wp, Epmin=None, Epmax=None, amplitude_name=None): """Normalize particle distribution so that the total energy in protons between Epmin and Epmax is Wp Parameters ---------- Wp : :class:`~astropy.units.Quantity` float Desired energy in protons. Epmin : :class:`~astropy.units.Quantity` float, optional Minimum proton energy for energy content calculation. Epmax : :class:`~astropy.units.Quantity` float, optional Maximum proton energy for energy content calculation. amplitude_name : str, optional Name of the amplitude parameter of the particle distribution. It must be accesible as an attribute of the distribution function. Defaults to ``amplitude``. """ Wp = validate_scalar("Wp", Wp, physical_type="energy") oldWp = self.compute_Wp(Epmin=Epmin, Epmax=Epmax) if amplitude_name is None: try: self.particle_distribution.amplitude *= ( Wp / oldWp ).decompose() except AttributeError: log.error( "The particle distribution does not have an attribute" " called amplitude to modify its normalization: you can" " set the name with the amplitude_name parameter of set_Wp" ) else: oldampl = getattr(self.particle_distribution, amplitude_name) setattr( self.particle_distribution, amplitude_name, oldampl * (Wp / oldWp).decompose(), )
[docs]class PionDecay(BaseProton): r"""Pion decay gamma-ray emission from a proton population. Compute gamma-ray spectrum arising from the interaction of a relativistic proton distribution with stationary target protons using the parametrization of Kafexhiu et al. (2014). If you use this class in your research, please consult and cite `Kafexhiu, E., Aharonian, F., Taylor, A.M., & Vila, G.S. 2014, Physical Review D, 90, 123014 <http://adsabs.harvard.edu/abs/2014PhRvD..90l3014K>`_. Parameters ---------- particle_distribution : function Particle distribution function, taking proton energies as a `~astropy.units.Quantity` array or float, and returning the particle energy density in units of number of protons per unit energy as a `~astropy.units.Quantity` array or float. nh : `~astropy.units.Quantity` Number density of the target protons. Default is :math:`1 \mathrm{cm}^{-3}`. nuclear_enhancement : bool Whether to apply the energy-dependent nuclear enhancement factor considering a target gas with local ISM abundances. See Section IV of Kafexhiu et al. (2014) for details. Here the proton-nucleus inelastic cross section of Sihver et al. (1993, PhysRevC 47, 1225) is used. Other parameters ---------------- Epmin : `~astropy.units.Quantity` float Minimum proton energy for the proton distribution. Default is 1.22 GeV, the dynamical threshold for pion production in pp interactions. Epmax : `~astropy.units.Quantity` float Minimum proton energy for the proton distribution. Default is 10 PeV. nEpd : scalar Number of points per decade in energy for the proton energy and distribution arrays. Default is 100. hiEmodel : str Monte Carlo model to use for computation of high-energy differential cross section. Can be one of ``Geant4``, ``Pythia8``, ``SIBYLL``, or ``QGSJET``. See Kafexhiu et al. (2014) for details. Default is ``Pythia8``. useLUT : bool Whether to use a lookup table for the differential cross section. The only lookup table packaged with naima is for the Pythia 8 model and ISM nuclear enhancement factor. """ def __init__( self, particle_distribution, nh=1.0 / u.cm ** 3, nuclear_enhancement=True, **kwargs ): super().__init__(particle_distribution) self.nh = validate_scalar("nh", nh, physical_type="number density") self.nuclear_enhancement = nuclear_enhancement self.useLUT = True self.hiEmodel = "Pythia8" self.Epmin = ( self._m_p + self._Tth + 1e-4 ) * u.GeV # Threshold energy ~1.22 GeV self.Epmax = 10 * u.PeV # 10 PeV self.nEpd = 100 self.param_names += ["nh", "nuclear_enhancement", "useLUT", "hiEmodel"] self.__dict__.update(**kwargs) # define model parameters from tables # yapf: disable # # Table IV _a = {} _a['Geant4'] = [0.728, 0.596, 0.491, 0.2503, 0.117] # Tp > 5 _a['Pythia8'] = [0.652, 0.0016, 0.488, 0.1928, 0.483] # Tp > 50 _a['SIBYLL'] = [5.436, 0.254, 0.072, 0.075, 0.166] # Tp > 100 _a['QGSJET'] = [0.908, 0.0009, 6.089, 0.176, 0.448] # Tp > 100 # # table V data # note that np.nan indicate that functions of Tp are needed and are defined # as need in function F # parameter order is lambda, alpha, beta, gamma _F_mp = {} _F_mp['ExpData'] = [1.0, 1.0, np.nan, 0.0] # Tth <= Tp <= 1.0 _F_mp['Geant4_0'] = [3.0, 1.0, np.nan, np.nan] # 1.0 < Tp <= 4.0 _F_mp['Geant4_1'] = [3.0, 1.0, np.nan, np.nan] # 4.0 < Tp <= 20.0 _F_mp['Geant4_2'] = [3.0, 0.5, 4.2, 1.0] # 20.0 < Tp <= 100 _F_mp['Geant4'] = [3.0, 0.5, 4.9, 1.0] # Tp > 100 _F_mp['Pythia8'] = [3.5, 0.5, 4.0, 1.0] # Tp > 50 _F_mp['SIBYLL'] = [3.55, 0.5, 3.6, 1.0] # Tp > 100 _F_mp['QGSJET'] = [3.55, 0.5, 4.5, 1.0] # Tp > 100 # # Table VII _b = {} _b['Geant4_0'] = [9.53, 0.52, 0.054] # 1 <= Tp < 5 _b['Geant4'] = [9.13, 0.35, 9.7e-3] # Tp >= 5 _b['Pythia8'] = [9.06, 0.3795, 0.01105] # Tp > 50 _b['SIBYLL'] = [10.77, 0.412, 0.01264] # Tp > 100 _b['QGSJET'] = [13.16, 0.4419, 0.01439] # Tp > 100 # yapf: enable # energy at which each of the hiE models start being valid _Etrans = {"Pythia8": 50, "SIBYLL": 100, "QGSJET": 100, "Geant4": 100} # _m_p = (m_p * c ** 2).to("GeV").value _m_pi = 0.1349766 # GeV/c2 _Tth = 0.27966184 def _sigma_inel(self, Tp): """ Inelastic cross-section for p-p interaction. KATV14 Eq. 1 Parameters ---------- Tp : float Kinetic energy of proton (i.e. Ep - m_p*c**2) [GeV] Returns ------- sigma_inel : float Inelastic cross-section for p-p interaction [1/cm2]. """ L = np.log(Tp / self._Tth) sigma = 30.7 - 0.96 * L + 0.18 * L ** 2 sigma *= (1 - (self._Tth / Tp) ** 1.9) ** 3 return sigma * 1e-27 # convert from mbarn to cm-2 def _sigma_pi_loE(self, Tp): """ inclusive cross section for Tth < Tp < 2 GeV Fit from experimental data """ m_p = self._m_p m_pi = self._m_pi Mres = 1.1883 # GeV Gres = 0.2264 # GeV s = 2 * m_p * (Tp + 2 * m_p) # center of mass energy gamma = np.sqrt(Mres ** 2 * (Mres ** 2 + Gres ** 2)) K = np.sqrt(8) * Mres * Gres * gamma K /= np.pi * np.sqrt(Mres ** 2 + gamma) fBW = m_p * K fBW /= ( (np.sqrt(s) - m_p) ** 2 - Mres ** 2 ) ** 2 + Mres ** 2 * Gres ** 2 mu = np.sqrt( (s - m_pi ** 2 - 4 * m_p ** 2) ** 2 - 16 * m_pi ** 2 * m_p ** 2 ) mu /= 2 * m_pi * np.sqrt(s) sigma0 = 7.66e-3 # mb sigma1pi = sigma0 * mu ** 1.95 * (1 + mu + mu ** 5) * fBW ** 1.86 # two pion production sigma2pi = 5.7 # mb sigma2pi /= 1 + np.exp(-9.3 * (Tp - 1.4)) E2pith = 0.56 # GeV sigma2pi[np.where(Tp < E2pith)] = 0.0 return (sigma1pi + sigma2pi) * 1e-27 # return in cm-2 def _sigma_pi_midE(self, Tp): """ Geant 4.10.0 model for 2 GeV < Tp < 5 GeV """ m_p = self._m_p Qp = (Tp - self._Tth) / m_p multip = -6e-3 + 0.237 * Qp - 0.023 * Qp ** 2 return self._sigma_inel(Tp) * multip def _sigma_pi_hiE(self, Tp, a): """ General expression for Tp > 5 GeV (Eq 7) """ m_p = self._m_p csip = (Tp - 3.0) / m_p m1 = a[0] * csip ** a[3] * (1 + np.exp(-a[1] * csip ** a[4])) m2 = 1 - np.exp(-a[2] * csip ** 0.25) multip = m1 * m2 return self._sigma_inel(Tp) * multip def _sigma_pi(self, Tp): sigma = np.zeros_like(Tp) # for E<2GeV idx1 = np.where(Tp < 2.0) sigma[idx1] = self._sigma_pi_loE(Tp[idx1]) # for 2GeV<=E<5GeV idx2 = np.where((Tp >= 2.0) * (Tp < 5.0)) sigma[idx2] = self._sigma_pi_midE(Tp[idx2]) # for 5GeV<=E<Etrans idx3 = np.where((Tp >= 5.0) * (Tp < self._Etrans[self.hiEmodel])) sigma[idx3] = self._sigma_pi_hiE(Tp[idx3], self._a["Geant4"]) # for E>=Etrans idx4 = np.where((Tp >= self._Etrans[self.hiEmodel])) sigma[idx4] = self._sigma_pi_hiE(Tp[idx4], self._a[self.hiEmodel]) return sigma def _b_params(self, Tp): b0 = 5.9 hiE = np.where(Tp >= 1.0) TphiE = Tp[hiE] b1 = np.zeros(TphiE.size) b2 = np.zeros(TphiE.size) b3 = np.zeros(TphiE.size) idx = np.where(TphiE < 5.0) b1[idx], b2[idx], b3[idx] = self._b["Geant4_0"] idx = np.where(TphiE >= 5.0) b1[idx], b2[idx], b3[idx] = self._b["Geant4"] idx = np.where(TphiE >= self._Etrans[self.hiEmodel]) b1[idx], b2[idx], b3[idx] = self._b[self.hiEmodel] return b0, b1, b2, b3 def _calc_EpimaxLAB(self, Tp): m_p = self._m_p m_pi = self._m_pi # Eq 10 s = 2 * m_p * (Tp + 2 * m_p) # center of mass energy EpiCM = (s - 4 * m_p ** 2 + m_pi ** 2) / (2 * np.sqrt(s)) PpiCM = np.sqrt(EpiCM ** 2 - m_pi ** 2) gCM = (Tp + 2 * m_p) / np.sqrt(s) betaCM = np.sqrt(1 - gCM ** -2) EpimaxLAB = gCM * (EpiCM + PpiCM * betaCM) return EpimaxLAB def _calc_Egmax(self, Tp): m_pi = self._m_pi EpimaxLAB = self._calc_EpimaxLAB(Tp) gpiLAB = EpimaxLAB / m_pi betapiLAB = np.sqrt(1 - gpiLAB ** -2) Egmax = (m_pi / 2) * gpiLAB * (1 + betapiLAB) return Egmax def _Amax(self, Tp): m_p = self._m_p loE = np.where(Tp < 1.0) hiE = np.where(Tp >= 1.0) Amax = np.zeros(Tp.size) b = self._b_params(Tp) EpimaxLAB = self._calc_EpimaxLAB(Tp) Amax[loE] = b[0] * self._sigma_pi(Tp[loE]) / EpimaxLAB[loE] thetap = Tp / m_p Amax[hiE] = ( b[1] * thetap[hiE] ** -b[2] * np.exp(b[3] * np.log(thetap[hiE]) ** 2) * self._sigma_pi(Tp[hiE]) / m_p ) return Amax def _F_func(self, Tp, Egamma, modelparams): lamb, alpha, beta, gamma = modelparams m_pi = self._m_pi # Eq 9 Egmax = self._calc_Egmax(Tp) Yg = Egamma + m_pi ** 2 / (4 * Egamma) Ygmax = Egmax + m_pi ** 2 / (4 * Egmax) Xg = (Yg - m_pi) / (Ygmax - m_pi) # zero out invalid fields (Egamma > Egmax -> Xg > 1) Xg[np.where(Xg > 1)] = 1.0 # Eq 11 C = lamb * m_pi / Ygmax F = (1 - Xg ** alpha) ** beta F /= (1 + Xg / C) ** gamma # return F def _kappa(self, Tp): thetap = Tp / self._m_p return 3.29 - thetap ** -1.5 / 5.0 def _mu(self, Tp): q = (Tp - 1.0) / self._m_p x = 5.0 / 4.0 return x * q ** x * np.exp(-x * q) def _F(self, Tp, Egamma): F = np.zeros_like(Tp) # below Tth F[np.where(Tp < self._Tth)] = 0.0 # Tth <= E <= 1GeV: Experimental data idx = np.where((Tp >= self._Tth) * (Tp <= 1.0)) if idx[0].size > 0: kappa = self._kappa(Tp[idx]) mp = self._F_mp["ExpData"] mp[2] = kappa F[idx] = self._F_func(Tp[idx], Egamma, mp) # 1GeV < Tp < 4 GeV: Geant4 model 0 idx = np.where((Tp > 1.0) * (Tp <= 4.0)) if idx[0].size > 0: mp = self._F_mp["Geant4_0"] mu = self._mu(Tp[idx]) mp[2] = mu + 2.45 mp[3] = mu + 1.45 F[idx] = self._F_func(Tp[idx], Egamma, mp) # 4 GeV < Tp < 20 GeV idx = np.where((Tp > 4.0) * (Tp <= 20.0)) if idx[0].size > 0: mp = self._F_mp["Geant4_1"] mu = self._mu(Tp[idx]) mp[2] = 1.5 * mu + 4.95 mp[3] = mu + 1.50 F[idx] = self._F_func(Tp[idx], Egamma, mp) # 20 GeV < Tp < 100 GeV idx = np.where((Tp > 20.0) * (Tp <= 100.0)) if idx[0].size > 0: mp = self._F_mp["Geant4_2"] F[idx] = self._F_func(Tp[idx], Egamma, mp) # Tp > Etrans idx = np.where(Tp > self._Etrans[self.hiEmodel]) if idx[0].size > 0: mp = self._F_mp[self.hiEmodel] F[idx] = self._F_func(Tp[idx], Egamma, mp) return F def _diffsigma(self, Ep, Egamma): """ Differential cross section dsigma/dEg = Amax(Tp) * F(Tp,Egamma) """ Tp = Ep - self._m_p diffsigma = self._Amax(Tp) * self._F(Tp, Egamma) if self.nuclear_enhancement: diffsigma *= self._nuclear_factor(Tp) return diffsigma def _nuclear_factor(self, Tp): """ Compute nuclear enhancement factor """ sigmaRpp = 10 * np.pi * 1e-27 sigmainel = self._sigma_inel(Tp) sigmainel0 = self._sigma_inel(1e3) # at 1e3 GeV f = sigmainel / sigmainel0 f2 = np.where(f > 1, f, 1.0) G = 1.0 + np.log(f2) # epsilon factors computed from Eqs 21 to 23 with local ISM abundances epsC = 1.37 eps1 = 0.29 eps2 = 0.1 epstotal = np.where( Tp > self._Tth, epsC + (eps1 + eps2) * sigmaRpp * G / sigmainel, 0.0, ) if np.any(Tp < 1.0): # nuclear enhancement factor diverges towards Tp = Tth, fix Tp<1 to # eps(1.0) = 1.91 loE = np.where((Tp > self._Tth) * (Tp < 1.0)) epstotal[loE] = 1.9141 return epstotal def _loadLUT(self, LUT_fname): try: filename = get_pkg_data_filename(os.path.join("data", LUT_fname)) self.diffsigma = LookupTable(filename) except IOError: warnings.warn( "LUT {0} not found, reverting to useLUT = False".format( LUT_fname ) ) self.diffsigma = self._diffsigma self.useLUT = False def _spectrum(self, photon_energy): """ Compute differential spectrum from pp interactions using the parametrization of Kafexhiu, E., Aharonian, F., Taylor, A.M., and Vila, G.S. 2014, `arXiv:1406.7369 <http://www.arxiv.org/abs/1406.7369>`_. Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` instance Photon energy array. """ # Load LUT if available, otherwise use self._diffsigma if self.useLUT: LUT_base = "PionDecayKafexhiu14_LUT_" if self.nuclear_enhancement: LUT_base += "NucEnh_" LUT_fname = LUT_base + "{0}.npz".format(self.hiEmodel) # only reload LUT if it has changed or hasn't been loaded yet try: if os.path.basename(self.diffsigma.fname) != LUT_fname: self._loadLUT(LUT_fname) except AttributeError: self._loadLUT(LUT_fname) else: self.diffsigma = self._diffsigma Egamma = _validate_ene(photon_energy).to("GeV") Ep = self._Ep * u.GeV J = self._J * u.Unit("1/GeV") specpp = [] for Eg in Egamma: diffsigma = self.diffsigma(Ep.value, Eg.value) * u.Unit("cm2/GeV") specpp.append(trapz_loglog(diffsigma * J, Ep)) self.specpp = u.Quantity(specpp) self.specpp *= self.nh * c.cgs return self.specpp.to("1/(s eV)")
def heaviside(x): return (np.sign(x) + 1) / 2.0 class PionDecayKelner06(BaseRadiative): r"""Pion decay gamma-ray emission from a proton population. Compute gamma-ray spectrum arising from the interaction of a relativistic proton distribution with stationary target protons. Parameters ---------- particle_distribution : function Particle distribution function, taking proton energies as a `~astropy.units.Quantity` array or float, and returning the particle energy density in units of number of protons per unit energy as a `~astropy.units.Quantity` array or float. nh : `~astropy.units.Quantity` Number density of the target protons. Default is :math:`1 cm^{-3}`. Other parameters ---------------- Etrans : `~astropy.units.Quantity` For photon energies below ``Etrans``, the delta-functional approximation is used for the spectral calculation, and the full calculation is used at higher energies. Default is 0.1 TeV. References ---------- Kelner, S.R., Aharonian, F.A., and Bugayov, V.V., 2006 PhysRevD 74, 034018 (`arXiv:astro-ph/0606058 <http://www.arxiv.org/abs/astro-ph/0606058>`_). """ # This class doesn't inherit from BaseProton param_names = ["nh", "Etrans"] _memoize = True _cache = {} _queue = [] def __init__( self, particle_distribution, nh=1.0 / u.cm ** 3, Etrans=0.1 * u.TeV, **kwargs ): self.particle_distribution = particle_distribution self.nh = validate_scalar("nh", nh, physical_type="number density") self.Etrans = validate_scalar( "Etrans", Etrans, domain="positive", physical_type="energy" ) self.__dict__.update(**kwargs) def _particle_distribution(self, E): return self.particle_distribution(E * u.TeV).to("1/TeV").value def _Fgamma(self, x, Ep): """ KAB06 Eq.58 Note: Quantities are not used in this function Parameters ---------- x : float Egamma/Eprot Ep : float Eprot [TeV] """ L = np.log(Ep) B = 1.30 + 0.14 * L + 0.011 * L ** 2 # Eq59 beta = (1.79 + 0.11 * L + 0.008 * L ** 2) ** -1 # Eq60 k = (0.801 + 0.049 * L + 0.014 * L ** 2) ** -1 # Eq61 xb = x ** beta F1 = B * (np.log(x) / x) * ((1 - xb) / (1 + k * xb * (1 - xb))) ** 4 F2 = ( 1.0 / np.log(x) - (4 * beta * xb) / (1 - xb) - (4 * k * beta * xb * (1 - 2 * xb)) / (1 + k * xb * (1 - xb)) ) return F1 * F2 def _sigma_inel(self, Ep): """ Inelastic cross-section for p-p interaction. KAB06 Eq. 73, 79 Note: Quantities are not used in this function Parameters ---------- Ep : float Eprot [TeV] Returns ------- sigma_inel : float Inelastic cross-section for p-p interaction [1/cm2]. """ L = np.log(Ep) sigma = 34.3 + 1.88 * L + 0.25 * L ** 2 if Ep <= 0.1: Eth = 1.22e-3 sigma *= (1 - (Eth / Ep) ** 4) ** 2 * heaviside(Ep - Eth) return sigma * 1e-27 # convert from mbarn to cm2 def _photon_integrand(self, x, Egamma): """ Integrand of Eq. 72 """ try: return ( self._sigma_inel(Egamma / x) * self._particle_distribution((Egamma / x)) * self._Fgamma(x, Egamma / x) / x ) except ZeroDivisionError: return np.nan def _calc_specpp_hiE(self, Egamma): """ Spectrum computed as in Eq. 42 for Egamma >= 0.1 TeV """ # Fixed quad with n=40 is about 15 times faster and is always within # 0.5% of the result of adaptive quad for Egamma>0.1 # WARNING: It also produces artifacts for steep distributions (e.g. # Maxwellian) at ~500 GeV. Reverting to adaptative quadrature # from scipy.integrate import fixed_quad # result=c*fixed_quad(self._photon_integrand, 0., 1., args = [Egamma, # ], n = 40)[0] from scipy.integrate import quad Egamma = Egamma.to("TeV").value specpp = c.cgs.value * quad( self._photon_integrand, 0.0, 1.0, args=Egamma, epsrel=1e-3, epsabs=0, )[0] return specpp * u.Unit("1/(s TeV)") # variables for delta integrand _c = c.cgs.value _Kpi = 0.17 _mp = (m_p * c ** 2).to("TeV").value _m_pi = 1.349766e-4 # TeV/c2 def _delta_integrand(self, Epi): Ep0 = self._mp + Epi / self._Kpi qpi = ( self._c * (self.nhat / self._Kpi) * self._sigma_inel(Ep0) * self._particle_distribution(Ep0) ) return qpi / np.sqrt(Epi ** 2 - self._m_pi ** 2) def _calc_specpp_loE(self, Egamma): """ Delta-functional approximation for low energies Egamma < 0.1 TeV """ from scipy.integrate import quad Egamma = Egamma.to("TeV").value Epimin = Egamma + self._m_pi ** 2 / (4 * Egamma) result = ( 2 * quad( self._delta_integrand, Epimin, np.inf, epsrel=1e-3, epsabs=0 )[0] ) return result * u.Unit("1/(s TeV)") @property def Wp(self): """Total energy in protons above 1.22 GeV threshold (erg).""" from scipy.integrate import quad Eth = 1.22e-3 with warnings.catch_warnings(): warnings.simplefilter("ignore") Wp = quad( lambda x: x * self._particle_distribution(x), Eth, np.Inf )[0] return (Wp * u.TeV).to("erg") def _spectrum(self, photon_energy): """ Compute differential spectrum from pp interactions using Eq.71 and Eq.58 of Kelner, S.R., Aharonian, F.A., and Bugayov, V.V., 2006 PhysRevD 74, 034018 (`arXiv:astro-ph/0606058 <http://www.arxiv.org/abs/astro-ph/0606058>`_). Parameters ---------- photon_energy : :class:`~astropy.units.Quantity` instance Photon energy array. """ outspecene = _validate_ene(photon_energy) with warnings.catch_warnings(): warnings.simplefilter("ignore") self.nhat = 1.0 # initial value, works for index~2.1 if np.any(outspecene < self.Etrans) and np.any( outspecene >= self.Etrans ): # compute value of nhat so that delta functional matches # accurate calculation at 0.1TeV full = self._calc_specpp_hiE(self.Etrans) delta = self._calc_specpp_loE(self.Etrans) self.nhat *= (full / delta).decompose().value self.specpp = np.zeros(len(outspecene)) * u.Unit("1/(s TeV)") for i, Egamma in enumerate(outspecene): if Egamma >= self.Etrans: self.specpp[i] = self._calc_specpp_hiE(Egamma) else: self.specpp[i] = self._calc_specpp_loE(Egamma) density_factor = (self.nh / (1 * u.Unit("1/cm3"))).decompose().value return density_factor * self.specpp.to("1/(s eV)") class LookupTable: """ Helper class for two-dimensional look up table Lookup table should be saved as an npz file with numpy.savez or numpy.savez_compressed. The file should have three arrays: * X: log10(x) * Y: log10(y) * lut: log10(z) The instantiated object can be called with arguments (x,y), and the interpolated value of z will be returned. The interpolation is done through a cubic spline in semi-logarithmic space. """ def __init__(self, filename): from scipy.interpolate import RectBivariateSpline f_lut = np.load(filename) X = f_lut.f.X Y = f_lut.f.Y lut = f_lut.f.lut self.int_lut = RectBivariateSpline(X, Y, 10 ** lut, kx=3, ky=3, s=0) self.fname = filename def __call__(self, X, Y): return self.int_lut(np.log10(X), np.log10(Y)).flatten() def _calc_lut_pp(args): # pragma: no cover epr, eph, hiEmodel, nuc = args from .models import PowerLaw pl = PowerLaw(1 / u.eV, 1 * u.TeV, 0.0) pp = PionDecay(pl, hiEmodel=hiEmodel, nuclear_enhancement=nuc) diffsigma = pp._diffsigma(epr.to("GeV").value, eph.to("GeV").value) return diffsigma def generate_lut_pp( Ep=np.logspace(0.085623713910610105, 7, 800) * u.GeV, Eg=np.logspace(-5, 3, 1024) * u.TeV, out_base="PionDecayKafexhiu14_LUT_", hiEmodel=None, nuclear_enhancement=True, ): # pragma: no cover from emcee.interruptible_pool import InterruptiblePool as Pool pool = Pool() if hiEmodel is None: hiEmodel = ["Geant4", "Pythia8", "SIBYLL", "QGSJET"] elif type(hiEmodel) is str: hiEmodel = [hiEmodel] if nuclear_enhancement: out_base += "NucEnh_" for model in hiEmodel: out_file = out_base + model + ".npz" print("Saving LUT for model {0} in {1}...".format(model, out_file)) args = [(Ep, eg, model, nuclear_enhancement) for eg in Eg] diffsigma_list = pool.map(_calc_lut_pp, args) diffsigma = np.array(diffsigma_list).T np.savez_compressed( out_file, X=np.log10(Ep.to("GeV").value), Y=np.log10(Eg.to("GeV").value), lut=np.log10(diffsigma), )