Repository: gammapy/gammapy

Issue:
Nearest neighbour interpolation for mass of `PrimaryFlux` produces high fluctuations for ULs
**Problem**
I was trying to compute some upper limits using the `DarkMatterAnnihilationSpectralModel` which uses the PPPC4DM gamma fluxes via the `PrimaryFlux` class. 
The PPPC fluxes are tabular data of flux points for given (mass, energy). 
In the current implementation of `PrimaryFlux` the mass parameter `mDM` is interpolated using nearest neighbour interpolation.

https://github.com/gammapy/gammapy/blob/cecb6f88328e894de1afb374a181f6203e0e9f08/gammapy/astro/darkmatter/spectra.py#L91-L96

This results in high fluctuations when calculating upper limits:

<img width="810" alt="Screenshot 2023-10-04 at 11 45 37" src="https://github.com/gammapy/gammapy/assets/44728015/0be46766-b6d0-48dc-9372-8acc16eb342d">

I took a look a the ratio of the interpolated masses and the masses that are used for this plot and this totally matches.

<img width="650" alt="Screenshot 2023-10-04 at 11 46 53" src="https://github.com/gammapy/gammapy/assets/44728015/6cec6894-8af3-400f-a44c-03088f7e3ff2">

**Suggested Solution**
Find out how to interpolate the values properly



Discussion:
Since `DarkMatterAnnihilationSpectralModel` is `TemplateSpectralModel` this needs to be rewritten as a `SpectralModel` with the interpolation for a specific channel happening at the instantiation of the class and the `evaluate` method would then wrap the interpolation function.

I think the best way to do this is to make it part of the refactoring of the `darkmatter` subpackage (#4776) and maybe moving away from the `DarkMatterAnnihilationSpectralModel` and let `PrimaryFlux` become the base `SpectralModel` for all spectral fluxes and build the model from there with the new introduced `SpatialModels` from the DM profiles

What do you think @registerrier @bkhelifi @jdjuvsla?
For interpolation: maybe try https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.LinearNDInterpolator.html#scipy.interpolate.LinearNDInterpolator
Thanks @StFroese for the issue! I think the best way forward here would be to refactor to use the [`TemplateNDSpectralModel`](https://github.com/gammapy/gammapy/blob/main/gammapy/modeling/models/spectral.py#L1862), which handles nd-interpolation just fine.

Otherwise a simple code fix to use e.g. `np.interp` should also work for now.
@adonath I took a look at it and I'm confused on how to use the `TemplateNDSpectralModel`. I built a `CircleSkyRegion` and took that together with one `MapAxis` for the masses and one `MapAxis` for the `Log[10, x]` energies to create a `RegionNDMap`. I passed that to the `TemplateNDSpectralModel` but I can't figure out how to use the `evaluate` method correctly. It seems that it the energy axis must be named `energy_true` and how do I evaluate on the mass axis?

https://github.com/gammapy/gammapy/blob/b038566e9267a5795f825336f177ad783c3932af/gammapy/modeling/models/spectral.py#L1917-L1926
```python
from gammapy.maps import RegionNDMap, RegionGeom, MapAxis
from gammapy.modeling.models import TemplateNDSpectralModel
from regions import CircleSkyRegion
from astropy.coordinates import SkyCoord
import astropy.units as u

from gammapy.astro.darkmatter import PrimaryFlux
import numpy as np

coord = SkyCoord(0, 0, unit='deg', frame='galactic')
region = CircleSkyRegion(coord, radius=1 * u.deg)

table = PrimaryFlux('1 TeV', 'b').table
masses = np.unique(table['mDM'])
energies = np.unique(table['Log[10,x]'])

mass_axis = MapAxis.from_nodes(masses, name='mDM', interp='log')
energie_axis = MapAxis.from_nodes(energies, name='energy_true')

regionMap = RegionNDMap.create(region, axes=[mass_axis, energie_axis], data=table['b'])

model = TemplateNDSpectralModel(regionMap)
```

(small note: ignored units for now)
ah nvm, I totally missend the kwarg unpacking to the coord dict haha

Resolving patch:
diff --git a/examples/tutorials/api/astro_dark_matter.py b/examples/tutorials/api/astro_dark_matter.py
--- a/examples/tutorials/api/astro_dark_matter.py
+++ b/examples/tutorials/api/astro_dark_matter.py
@@ -154,8 +154,9 @@
     ax.set_ylabel("dN/dE")
 
     for channel in ["tau", "mu", "b", "Z"]:
+        fluxes = PrimaryFlux(mDM=mDM, channel=channel)
         fluxes.channel = channel
-        fluxes.table_model.plot(
+        fluxes.plot(
             energy_bounds=[mDM / 100, mDM],
             ax=ax,
             label=channel,
diff --git a/gammapy/astro/darkmatter/spectra.py b/gammapy/astro/darkmatter/spectra.py
--- a/gammapy/astro/darkmatter/spectra.py
+++ b/gammapy/astro/darkmatter/spectra.py
@@ -3,26 +3,37 @@
 import numpy as np
 import astropy.units as u
 from astropy.table import Table
+from gammapy.maps import MapAxis, RegionNDMap
 from gammapy.modeling import Parameter
 from gammapy.modeling.models import (
     SPECTRAL_MODEL_REGISTRY,
     SpectralModel,
+    TemplateNDSpectralModel,
     TemplateSpectralModel,
 )
+from gammapy.utils.deprecation import deprecated
 from gammapy.utils.interpolation import LogScale
 from gammapy.utils.scripts import make_path
 
 __all__ = ["PrimaryFlux", "DarkMatterAnnihilationSpectralModel"]
 
 
-class PrimaryFlux:
+class PrimaryFlux(TemplateNDSpectralModel):
     """DM-annihilation gamma-ray spectra.
 
     Based on the precomputed models by Cirelli et al. (2016). All available
     annihilation channels can be found there. The dark matter mass will be set
     to the nearest available value. The spectra will be available as
-    `~gammapy.modeling.models.TemplateSpectralModel` for a chosen dark matter mass and
-    annihilation channel.
+    `~gammapy.modeling.models.TemplateNDSpectralModel` for a chosen dark matter mass and
+    annihilation channel. Using a `~gammapy.modeling.models.TemplateNDSpectralModel`
+    allows the interpolation beween different dark matter masses.
+
+    Parameters
+    ----------
+    mDM : `~astropy.units.Quantity`
+        Dark matter particle mass as rest mass energy
+    channel: str
+        Annihilation channel. List available channels with `~gammapy.spectrum.PrimaryFlux.allowed_channels`.
 
     References
     ----------
@@ -63,6 +74,8 @@ class PrimaryFlux:
 
     table_filename = "$GAMMAPY_DATA/dark_matter_spectra/AtProduction_gammas.dat"
 
+    tag = ["PrimaryFlux", "dm-pf"]
+
     def __init__(self, mDM, channel):
 
         self.table_path = make_path(self.table_filename)
@@ -80,20 +93,45 @@ def __init__(self, mDM, channel):
                 delimiter=" ",
             )
 
-        self.mDM = mDM
         self.channel = channel
 
+        # create RegionNDMap for channel
+
+        masses = np.unique(self.table["mDM"])
+        log10x = np.unique(self.table["Log[10,x]"])
+
+        mass_axis = MapAxis.from_nodes(masses, name="mass", interp="log", unit="GeV")
+        log10x_axis = MapAxis.from_nodes(log10x, name="energy_true")
+
+        channel_name = self.channel_registry[self.channel]
+        region_map = RegionNDMap.create(
+            region=None, axes=[log10x_axis, mass_axis], data=self.table[channel_name]
+        )
+
+        super().__init__(region_map)
+        self.mDM = mDM
+        self.mass.frozen = True
+
     @property
     def mDM(self):
         """Dark matter mass."""
-        return self._mDM
+        return u.Quantity(self.mass.value, "GeV")
 
     @mDM.setter
     def mDM(self, mDM):
-        mDM_vals = self.table["mDM"].data
-        mDM_ = u.Quantity(mDM).to_value("GeV")
-        interp_idx = np.argmin(np.abs(mDM_vals - mDM_))
-        self._mDM = u.Quantity(mDM_vals[interp_idx], "GeV")
+        unit = self.mass.unit
+        _mDM = u.Quantity(mDM).to(unit)
+        _mDM_val = _mDM.to_value(unit)
+
+        min_mass = u.Quantity(self.mass.min, unit)
+        max_mass = u.Quantity(self.mass.max, unit)
+
+        if _mDM_val < self.mass.min or _mDM_val > self.mass.max:
+            raise ValueError(
+                f"The mass {_mDM} is out of the bounds of the model. Please choose a mass between {min_mass} < `mDM` < {max_mass}"
+            )
+
+        self.mass.value = _mDM_val
 
     @property
     def allowed_channels(self):
@@ -114,7 +152,18 @@ def channel(self, channel):
         else:
             self._channel = channel
 
+    def evaluate(self, energy, **kwargs):
+        mass = {"mass": self.mDM}
+        kwargs.update(mass)
+
+        log10x = np.log10(energy / self.mDM)
+
+        dN_dlogx = super().evaluate(log10x, **kwargs)
+        dN_dE = dN_dlogx / (energy * np.log(10))
+        return dN_dE
+
     @property
+    @deprecated("1.1", alternative="the `PrimaryFlux` class as the spectral model")
     def table_model(self):
         """Spectrum as `~gammapy.modeling.models.TemplateSpectralModel`."""
         subtable = self.table[self.table["mDM"] == self.mDM.value]
@@ -190,7 +239,7 @@ def __init__(self, mass, channel, scale=scale.quantity, jfactor=1, z=0, k=2):
         self.mass = u.Quantity(mass)
         self.channel = channel
         self.jfactor = u.Quantity(jfactor)
-        self.primary_flux = PrimaryFlux(mass, channel=self.channel).table_model
+        self.primary_flux = PrimaryFlux(mass, channel=self.channel)
         super().__init__(scale=scale)
 
     def evaluate(self, energy, scale):
diff --git a/gammapy/modeling/models/spectral.py b/gammapy/modeling/models/spectral.py
--- a/gammapy/modeling/models/spectral.py
+++ b/gammapy/modeling/models/spectral.py
@@ -1886,14 +1886,15 @@ def __init__(self, map, interp_kwargs=None, meta=None, filename=None):
         has_energy = False
         for axis in map.geom.axes:
             if axis.name not in ["energy_true", "energy"]:
+                unit = axis.unit
                 center = (axis.bounds[1] + axis.bounds[0]) / 2
                 parameter = Parameter(
                     name=axis.name,
-                    value=center,
-                    unit=axis.unit,
+                    value=center.to_value(unit),
+                    unit=unit,
                     scale_method="scale10",
-                    min=axis.bounds[0],
-                    max=axis.bounds[-1],
+                    min=axis.bounds[0].to_value(unit),
+                    max=axis.bounds[-1].to_value(unit),
                     interp=axis.interp,
                 )
                 parameters.append(parameter)


Test patch:
diff --git a/gammapy/astro/darkmatter/tests/test_spectra.py b/gammapy/astro/darkmatter/tests/test_spectra.py
--- a/gammapy/astro/darkmatter/tests/test_spectra.py
+++ b/gammapy/astro/darkmatter/tests/test_spectra.py
@@ -13,11 +13,21 @@ def test_primary_flux():
         PrimaryFlux(channel="Spam", mDM=1 * u.TeV)
 
     primflux = PrimaryFlux(channel="W", mDM=1 * u.TeV)
-    actual = primflux.table_model(500 * u.GeV)
+    actual = primflux(500 * u.GeV)
     desired = 9.328234e-05 / u.GeV
     assert_quantity_allclose(actual, desired)
 
 
+@pytest.mark.parametrize(
+    "mass, expected_flux", [(1.6, 0.00024956), (11, 0.00501605), (75, 0.02027279)]
+)
+@requires_data()
+def test_primary_flux_interpolation(mass, expected_flux):
+    primflux = PrimaryFlux(channel="W", mDM=mass * u.TeV)
+    actual = primflux(500 * u.GeV)
+    assert_quantity_allclose(actual, expected_flux / u.GeV, rtol=1e-5)
+
+
 @requires_data()
 def test_dm_annihilation_spectral_model(tmpdir):
     channel = "b"