# -*- 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(),
            )


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


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)

    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

    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


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(),
            )


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),
        )
