Repository: gammapy/gammapy

Issue:
Background computation in MapMaker
I think there is a problem with the background computed in MapMaker.  The number of predicted background events varies with the number of energy bins, and is abnormally large for a single bin. This makes making images with one energy bin very weird. Do you think this is an issue, @registerrier , @cdeil 
For ex: 


`for n in range(2, 15):`

    energy_axis = MapAxis.from_edges(
        np.logspace(-1., 1., n), unit="TeV", name="energy"
    )
    geom = WcsGeom.create(
        skydir=(0, 0),
        binsz=0.02,
        width=(5, 5),
        coordsys="GAL",
        proj="CAR",
        axes=[energy_axis]
    )
    maker = MapMaker(geom, offset_max=4. * u.deg)
    maps = maker.run(obs_list)
    maps=maker.make_images()
    print(maps["background"].data.sum())`

prints `4558460.5
434145.5
203798.08
142040.11
112685.08
105752.22
98714.52
93384.5
88870.47
82596.164
86333.77
85923.91
85119.94`

The number of source counts is constant at `85983.0`


Discussion:
@registerrier - Can you please have a look and try to resolve this with @AtreyeeS ?
Or maybe @JouvinLea could also have a look at this with you next week in Madrid.

(Should we introduce a "Madrid" milestone or label for things we want to work on that week? If yes, please go ahead.)

I don't know if the issue is in the else clause in:
https://github.com/gammapy/gammapy/blob/f6fe37db6aab7969e10c029b29da158bb97b077f/gammapy/cube/background.py#L11
but generally I think maybe we should not have a "double energy grid" in the MapMaker, but only offer the input energy grid as the single option, and then make it convenient to integrate, i.e. sum over groups of bins, on any map. This would avoid duplicaing similar integration code for background and predicted counts.
Without testing I would say that the default option `n_integration_bin=1` in background.py is the likely issue here.
@AtreyeeS - If you have time to work on this, a good course of action would be to replace this test case:

https://github.com/gammapy/gammapy/blob/6370271360cbf02cff6878f6956f8808b8d69d7f/gammapy/cube/tests/test_background.py#L15-L18

with something very simple where we know the expected answer, e.g. a background IRF filled with a step function or power-law, similar to what we do here:

https://github.com/gammapy/gammapy/blob/master/gammapy/irf/tests/test_background.py

The method implemented for the `n_integration_bins=0` case should be value at center times bin width, and for `n_integration_bins>=1` evaluate at new grid points and power-law integral approximation.

Maybe everything is OK, and you're just assuming that background will come out OK with super large energy bins. This is not the case; to get a background image you have to first compute a background cube with reasonably small energy binning (e.g. 5 per decade) and then sum over the energy axis to get the background image.

But the tests should still be improved to have a test case where we know what should come out, to be certain the implementation is OK.

> Maybe everything is OK, and you're just assuming that background will come out OK with super large energy bins. This is not the case; to get a background image you have to first compute a background cube with reasonably small energy binning (e.g. 5 per decade) and then sum over the energy axis to get the background image.
> 

Yes, initially I suspected so as well. But this difference exists even when my total energy band is quite small. 




There is a misunderstanding on how `MapMaker`is working and how globally one should perform map creation. 
I think the code should do what the user thinks it supposed to do. In this case, the default behavior should be that it produces a background map adapted to the input binning. The choice of the integration step should be made inside `MapMaker` or even in `Background3D` depending on the input reco energy binning (e.g. to ensure a sufficient sampling per decade). 
The option of having only f(E)*dE should possible but not the default one. It is a bit absurd to ask the user to make cubes with 15-20 energy bins in reco energy if he only wants a few.
@registerrier and @AtreyeeS - could you please try to prepare a proposal how to handle energy integration in Gammapy maps and IRFs for Monday next week? If you're not sure what to recommend, just bring up the issue and explain the options we have.

As far as I know the Fermi ST just do the super-simple thing: relatively fine equally-spaced log(E) grids and integrations as `f(E) * dE`. So I don't think this is absurd, it's a valid and simple strategy.

Of course, alternatives are possible, where either sub-grids are created on the data energy grid to carry out integration, or nodes are placed depending on the input energy grids that come from the IRFs. This improves precision, at the cost of code complexity and computation speed.

This question how to handle energy is also there for AEFF and PSF and EDISP, not just BKG, but the power-law dependency in shape is only there for AEFF in the exposure computation and BKG in the background computation.

Resolving patch:
diff --git a/gammapy/cube/background.py b/gammapy/cube/background.py
--- a/gammapy/cube/background.py
+++ b/gammapy/cube/background.py
@@ -8,7 +8,7 @@
 __all__ = ["make_map_background_irf"]
 
 
-def make_map_background_irf(pointing, ontime, bkg, geom, n_integration_bins=1):
+def make_map_background_irf(pointing, ontime, bkg, geom):
     """Compute background map from background IRFs.
 
     Parameters
@@ -22,38 +22,26 @@ def make_map_background_irf(pointing, ontime, bkg, geom, n_integration_bins=1):
         Background rate model
     geom : `~gammapy.maps.WcsGeom`
         Reference geometry
-    n_integration_bins : int
-        Number of bins per energy bin in integration
 
     Returns
     -------
     background : `~gammapy.maps.WcsNDMap`
         Background predicted counts sky cube in reco energy
     """
-    energy_axis = geom.axes[0]
-    ebounds = energy_axis.edges * energy_axis.unit
-
     # Compute FOV coordinates; at the moment assume symmetric background model
     # TODO: implement FOV coordinates properly
-    map_coord = geom.get_coord()
+    map_coord = geom.to_image().get_coord()
     fov_lon = map_coord.skycoord.separation(pointing)
     fov_lat = Angle(np.zeros_like(fov_lon), fov_lon.unit)
 
-    if n_integration_bins == 0:
-        energy_reco = map_coord[energy_axis.name] * energy_axis.unit
-        data = bkg.evaluate(fov_lon=fov_lon, fov_lat=fov_lat, energy_reco=energy_reco)
-        d_energy = np.diff(energy_axis.edges) * energy_axis.unit
-        bkg_de = data * d_energy[:, np.newaxis, np.newaxis]
-    else:
-        bkg_de = Quantity(np.zeros_like(fov_lat.value), "s^-1 sr^-1")
-        for idx in range(len(ebounds) - 1):
-            energy_range = ebounds[idx], ebounds[idx + 1]
-            bkg_de[idx, :, :] = bkg.integrate_on_energy_range(
-                fov_lon=fov_lon[0, :, :],
-                fov_lat=fov_lat[0, :, :],
-                energy_range=energy_range,
-                n_integration_bins=n_integration_bins,
-            )
+    energy_axis = geom.get_axis_by_name("energy")
+    energies = energy_axis.edges * energy_axis.unit
+
+    fov_lon, fov_lat, energy_reco = np.broadcast_arrays(
+        fov_lon, fov_lat, energies[:, np.newaxis, np.newaxis],
+        subok=True)
+
+    bkg_de = bkg.evaluate_integrate(fov_lon=fov_lon, fov_lat=fov_lat, energy_reco=energy_reco)
 
     d_omega = geom.solid_angle()
     data = (bkg_de * d_omega * ontime).to("").value
diff --git a/gammapy/irf/background.py b/gammapy/irf/background.py
--- a/gammapy/irf/background.py
+++ b/gammapy/irf/background.py
@@ -166,57 +166,35 @@ def evaluate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs):
         array = self.data.evaluate_at_coord(points=points, method=method, **kwargs)
         return array
 
-    def integrate_on_energy_range(
+    def evaluate_integrate(
         self,
         fov_lon,
         fov_lat,
-        energy_range,
-        n_integration_bins=1,
+        energy_reco,
         method="linear",
         **kwargs
     ):
-        """Integrate over an energy range.
+        """Evaluate at given FOV position and energy edges by integrating over the energy
+        axes.
 
         Parameters
         ----------
         fov_lon, fov_lat : `~astropy.coordinates.Angle`
             FOV coordinates expecting in AltAz frame.
-        energy_range: `~astropy.units.Quantity`
-            Energy range
-        n_integration_bins : int
-            Number of bins in the energy range
+        energy_reco: `~astropy.units.Quantity`
+            Reconstructed energy edges.
         method : {'linear', 'nearest'}, optional
             Interpolation method
-        kwargs : dict
-            Passed to `scipy.interpolate.RegularGridInterpolator`.
 
         Returns
         -------
         array : `~astropy.units.Quantity`
-            Returns 2D array with axes fov_lon, fov_lat
+            Returns 2D array with axes offset
         """
-        fov_lon = np.atleast_2d(fov_lon)
-        fov_lat = np.atleast_2d(fov_lat)
-        energy_edges = EnergyBounds.equal_log_spacing(
-            energy_range[0], energy_range[1], n_integration_bins
-        )
+        from ..spectrum.utils import _trapz_loglog
+        data = self.evaluate(fov_lon, fov_lat, energy_reco, method=method)
+        return _trapz_loglog(data, energy_reco, axis=0, intervals=True)
 
-        # TODO: insert new axes, remove tile and use numpy broadcasting
-        energy_reco = np.tile(energy_edges, reps=fov_lon.shape + (1,))
-        fov_lon = np.tile(fov_lon, reps=energy_edges.shape + (1, 1))
-        fov_lon = np.rollaxis(fov_lon, 0, 3)
-        fov_lat = np.tile(fov_lat, reps=energy_edges.shape + (1, 1))
-        fov_lat = np.rollaxis(fov_lat, 0, 3)
-
-        bkg_evaluated = self.evaluate(
-            fov_lon=fov_lon,
-            fov_lat=fov_lat,
-            energy_reco=energy_reco,
-            method=method,
-            **kwargs
-        )
-        # TODO: use gammapy.spectrum.utils._trapz_loglog for better precision
-        return np.trapz(bkg_evaluated, energy_edges).decompose()
 
     def to_2d(self):
         """Convert to `Background2D`.
@@ -366,57 +344,32 @@ def evaluate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs):
         points = dict(offset=offset, energy=energy_reco)
         return self.data.evaluate_at_coord(points=points, method=method, **kwargs)
 
-    def integrate_on_energy_range(
+    def evaluate_integrate(
         self,
         fov_lon,
         fov_lat,
-        energy_range,
-        n_integration_bins=1,
+        energy_reco,
         method="linear",
-        **kwargs
     ):
-        """Integrate over an energy range.
+        """Evaluate at given FOV position and energy, by integrating over the energy range.
 
         Parameters
         ----------
         fov_lon, fov_lat : `~astropy.coordinates.Angle`
             FOV coordinates expecting in AltAz frame.
-        energy_range: `~astropy.units.Quantity`
-            Energy range
-        n_integration_bins : int
-            Number of bins in the energy range
+        energy_reco: `~astropy.units.Quantity`
+            Reconstructed energy edges.
         method : {'linear', 'nearest'}, optional
             Interpolation method
-        kwargs : dict
-            Passed to `scipy.interpolate.RegularGridInterpolator`.
 
         Returns
         -------
         array : `~astropy.units.Quantity`
             Returns 2D array with axes offset
         """
-        fov_lon = np.atleast_2d(fov_lon)
-        fov_lat = np.atleast_2d(fov_lat)
-        energy_edges = EnergyBounds.equal_log_spacing(
-            energy_range[0], energy_range[1], n_integration_bins
-        )
-        # TODO: insert new axes, remove tile and use numpy broadcasting
-        energy_reco = np.tile(energy_edges, reps=fov_lon.shape + (1,))
-        fov_lon = np.tile(fov_lon, reps=energy_edges.shape + (1, 1))
-        fov_lon = np.rollaxis(fov_lon, 0, 3)
-        fov_lat = np.tile(fov_lat, reps=energy_edges.shape + (1, 1))
-        fov_lat = np.rollaxis(fov_lat, 0, 3)
-
-        bkg_evaluated = self.evaluate(
-            fov_lon=fov_lon,
-            fov_lat=fov_lat,
-            energy_reco=energy_reco,
-            method=method,
-            **kwargs
-        )
-
-        # TODO: use gammapy.spectrum.utils._trapz_loglog for better precision
-        return np.trapz(bkg_evaluated, energy_edges).decompose()
+        from ..spectrum.utils import _trapz_loglog
+        data = self.evaluate(fov_lon, fov_lat, energy_reco, method=method)
+        return _trapz_loglog(data, energy_reco, axis=0, intervals=True)
 
     def to_3d(self):
         """Convert to `Background3D`.


Test patch:
diff --git a/gammapy/cube/tests/test_background.py b/gammapy/cube/tests/test_background.py
--- a/gammapy/cube/tests/test_background.py
+++ b/gammapy/cube/tests/test_background.py
@@ -35,12 +35,12 @@ def geom(map_type, ebounds):
         {
             "geom": geom(map_type="wcs", ebounds=[0.1, 1, 10]),
             "shape": (2, 3, 4),
-            "sum": 4740.864037,
+            "sum": 744.281309,
         },
         {
             "geom": geom(map_type="wcs", ebounds=[0.1, 10]),
             "shape": (1, 3, 4),
-            "sum": 49408.694434,
+            "sum": 799.760344,
         },
         # TODO: make this work for HPX
         # 'HpxGeom' object has no attribute 'separation'
diff --git a/gammapy/cube/tests/test_make.py b/gammapy/cube/tests/test_make.py
--- a/gammapy/cube/tests/test_make.py
+++ b/gammapy/cube/tests/test_make.py
@@ -42,7 +42,7 @@ def geom(ebounds):
             "counts": 34366,
             "exposure": 3.99815e11,
             "exposure_image": 7.921993e10,
-            "background": 191290.11,
+            "background": 27984.78,
         },
         {
             # Test single energy bin
@@ -51,7 +51,7 @@ def geom(ebounds):
             "counts": 34366,
             "exposure": 1.16866e11,
             "exposure_image": 1.16866e11,
-            "background": 2028359.4,
+            "background": 30418.951,
         },
         {
             # Test single energy bin with exclusion mask
@@ -61,7 +61,7 @@ def geom(ebounds):
             "counts": 34366,
             "exposure": 1.16866e11,
             "exposure_image": 1.16866e11,
-            "background": 2028359.4,
+            "background": 30418.951,
         },
         {
             # Test for different e_true and e_reco bins
@@ -70,7 +70,7 @@ def geom(ebounds):
             "counts": 34366,
             "exposure": 5.971096e11,
             "exposure_image": 6.492968e10,
-            "background": 191290.11,
+            "background": 27984.78,
         },
     ],
 )
diff --git a/gammapy/irf/tests/test_background.py b/gammapy/irf/tests/test_background.py
--- a/gammapy/irf/tests/test_background.py
+++ b/gammapy/irf/tests/test_background.py
@@ -108,28 +108,28 @@ def test_background_3d_integrate(bkg_3d):
     # Example has bkg rate = 4 s-1 MeV-1 sr-1 at this node:
     # fov_lon=1.5 deg, fov_lat=1.5 deg, energy=100 TeV
 
-    rate = bkg_3d.integrate_on_energy_range(
-        fov_lon=1.5 * u.deg, fov_lat=1.5 * u.deg, energy_range=[100, 100 + 2e-6] * u.TeV
+    rate = bkg_3d.evaluate_integrate(
+        fov_lon=[1.5, 1.5] * u.deg, fov_lat=[1.5, 1.5] * u.deg, energy_reco=[100, 100 + 2e-6] * u.TeV
     )
-    assert rate.shape == (1, 1)
-    assert rate.unit == "s-1 sr-1"
+    assert rate.shape == (1,)
+
     # Expect approximately `rate * de`
     # with `rate = 4 s-1 sr-1 MeV-1` and `de = 2 MeV`
-    assert_allclose(rate.value, 200, rtol=1e-5)
+    assert_allclose(rate.to("s-1 sr-1").value, 200, rtol=1e-5)
 
-    rate = bkg_3d.integrate_on_energy_range(
-        fov_lon=0.5 * u.deg, fov_lat=0.5 * u.deg, energy_range=[1, 100] * u.TeV
+    rate = bkg_3d.evaluate_integrate(
+        fov_lon=0.5 * u.deg, fov_lat=0.5 * u.deg, energy_reco=[1, 100] * u.TeV
     )
-    assert_allclose(rate.value, 99000000)
+    assert_allclose(rate.to("s-1 sr-1").value, 99000000)
 
-    rate = bkg_3d.integrate_on_energy_range(
+    rate = bkg_3d.evaluate_integrate(
         fov_lon=[[1, 0.5], [1, 0.5]] * u.deg,
         fov_lat=[[1, 1], [0.5, 0.5]] * u.deg,
-        energy_range=[1, 100] * u.TeV,
+        energy_reco=[[1, 1], [100, 100]] * u.TeV,
     )
-    assert rate.shape == (2, 2)
+    assert rate.shape == (1, 2)
     assert_allclose(
-        rate.value, [[2.060327e08, 99000000], [99000000.0, 99000000.0]], rtol=1e-5
+        rate.to("s-1 sr-1").value, [[99000000.0, 99000000.0]], rtol=1e-5
     )
 
 
@@ -207,23 +207,22 @@ def test_background_2d_integrate(bkg_2d):
     # TODO: change test case to something better (with known answer)
     # e.g. constant spectrum or power-law.
 
-    rate = bkg_2d.integrate_on_energy_range(
-        fov_lon=[1, 0.5] * u.deg, fov_lat=0 * u.deg, energy_range=[0.1, 0.5] * u.TeV
+    rate = bkg_2d.evaluate_integrate(
+        fov_lon=[1, 0.5] * u.deg, fov_lat=[0, 0] * u.deg, energy_reco=[0.1, 0.5] * u.TeV
     )
 
-    assert rate.shape == (1, 2)
-    assert rate.unit == "s-1 sr-1"
-    assert_allclose(rate.value[0], [0, 0])
+    assert rate.shape == (1,)
+    assert_allclose(rate.to("s-1 sr-1").value[0], [0, 0])
 
-    rate = bkg_2d.integrate_on_energy_range(
-        fov_lon=[1, 0.5] * u.deg, fov_lat=0 * u.deg, energy_range=[1, 100] * u.TeV
+    rate = bkg_2d.evaluate_integrate(
+        fov_lon=[1, 0.5] * u.deg, fov_lat=[0, 0] * u.deg, energy_reco=[1, 100] * u.TeV
     )
-    assert_allclose(rate.value[0], [1.485e08, 9.900e07])
+    assert_allclose(rate.to("s-1 sr-1").value, 0)
 
-    rate = bkg_2d.integrate_on_energy_range(
+    rate = bkg_2d.evaluate_integrate(
         fov_lon=[[1, 0.5], [1, 0.5]] * u.deg,
         fov_lat=0 * u.deg,
-        energy_range=[1, 100] * u.TeV,
+        energy_reco=[1, 100] * u.TeV,
     )
-    assert rate.shape == (2, 2)
-    assert_allclose(rate.value, [[1.485e08, 9.900e07], [1.485e08, 9.900e07]])
+    assert rate.shape == (1, 2)
+    assert_allclose(rate.value, [[0, 198]])