Repository: gammapy/gammapy

Issue:
Background model oversampling for MapDataset with irregularly binned energy axis
**Gammapy version**
0.16

**Bug description**
I have a `MapDataset` with an energy axis that has an irregular (i.e. non-logspace) binning. When I make a cutout of this dataset, I get a dataset with an energy axis that has a regular (logspace) binning, and hence cannot be stacked on the original dataset.

**Expected behavior**
The cutout dataset should have the same energy axis as the original dataset.


Discussion:
Apologies -- I investigated a bit deeper and found that the problem was not due to the cutout, but due to oversampling that can be applied in `make_map_background_irf`. I guess this is simply not possible for an irregularly binned energy axis?! Maybe a corresponding warning/error could be issued in this case?

Resolving patch:
diff --git a/gammapy/maps/geom.py b/gammapy/maps/geom.py
--- a/gammapy/maps/geom.py
+++ b/gammapy/maps/geom.py
@@ -1134,45 +1134,46 @@ def group_table(self, edges):
         groups.add_column(group_idx, name="group_idx", index=0)
         return groups
 
-    def _up_down_sample(self, nbin):
-        if self.node_type == "edges":
-            nodes = self.edges
-        else:
-            nodes = self.center
-
-        lo_bnd, hi_bnd = nodes.min(), nodes.max()
-
-        return self.from_bounds(
-            lo_bnd=lo_bnd.value,
-            hi_bnd=hi_bnd.value,
-            nbin=nbin,
-            interp=self.interp,
-            node_type=self.node_type,
-            unit=self.unit,
-            name=self.name,
-        )
-
     def upsample(self, factor):
         """Upsample map axis by a given factor.
 
+        When up-sampling for each node specified in the axis, the corresponding
+        number of sub-nodes are introduced and preserving the initial nodes. For
+        node type "edges" this results in nbin * factor new bins. For node type
+        "center" this results in (nbin - 1) * factor + 1 new bins.
+
         Parameters
         ----------
         factor : int
             Upsampling factor.
 
-
         Returns
         -------
         axis : `MapAxis`
             Usampled map axis.
 
         """
-        nbin = self.nbin * factor
-        return self._up_down_sample(nbin)
+        if self.node_type == "edges":
+            pix = self.coord_to_pix(self.edges)
+            nbin = int(self.nbin * factor) + 1
+            pix_new = np.linspace(pix.min(), pix.max(), nbin)
+            edges = self.pix_to_coord(pix_new)
+            return self.from_edges(edges, name=self.name, interp=self.interp)
+        else:
+            pix = self.coord_to_pix(self.center)
+            nbin = int((self.nbin - 1) * factor) + 1
+            pix_new = np.linspace(pix.min(), pix.max(), nbin)
+            nodes = self.pix_to_coord(pix_new)
+            return self.from_nodes(nodes, name=self.name, interp=self.interp)
 
     def downsample(self, factor):
         """Downsample map axis by a given factor.
 
+        When down-sampling each n-th (given by the factor) bin is selected from
+        the axis while preserving the axis limits. For node type "edges" this
+        requires nbin to be dividable by the factor, for node type "center" this
+        requires nbin - 1 to be dividable by the factor.
+
         Parameters
         ----------
         factor : int
@@ -1183,14 +1184,23 @@ def downsample(self, factor):
         -------
         axis : `MapAxis`
             Downsampled map axis.
-
         """
-        nbin = self.nbin / factor
+        if self.node_type == "edges":
+            nbin = self.nbin / factor
+
+            if np.mod(nbin, 1) > 0:
+                raise ValueError(f"Number of {self.name} bins is not divisible by {factor}")
+
+            edges = self.edges[::factor]
+            return self.from_edges(edges, name=self.name, interp=self.interp)
+        else:
+            nbin = (self.nbin - 1) / factor
 
-        if np.mod(nbin, 1) > 0:
-            raise ValueError(f"Number of {self.name} bins is not divisible by {factor}")
+            if np.mod(nbin, 1) > 0:
+                raise ValueError(f"Number of {self.name} bins - 1 is not divisible by {factor}")
 
-        return self._up_down_sample(nbin)
+            nodes = self.center[::factor]
+            return self.from_nodes(nodes, name=self.name, interp=self.interp)
 
     def to_header(self, header, format="ogip"):
         """Create FITS header
diff --git a/gammapy/maps/wcsnd.py b/gammapy/maps/wcsnd.py
--- a/gammapy/maps/wcsnd.py
+++ b/gammapy/maps/wcsnd.py
@@ -308,15 +308,17 @@ def upsample(self, factor, order=0, preserve_counts=True, axis_name=None):
             idx_ax = self.geom.axes.index(axis_name)
             pix[idx_ax] = (pix[idx_ax] - 0.5 * (factor - 1)) / factor
 
+        if preserve_counts:
+            data = self.data / self.geom.bin_volume().value
+        else:
+            data = self.data
+
         data = scipy.ndimage.map_coordinates(
-            self.data.T, tuple(pix), order=order, mode="nearest"
+            data.T, tuple(pix), order=order, mode="nearest"
         )
 
         if preserve_counts:
-            if axis_name is None:
-                data /= factor ** 2
-            else:
-                data /= factor
+            data *= geom.bin_volume().value
 
         return self._init_copy(geom=geom, data=data.astype(self.data.dtype))
 


Test patch:
diff --git a/gammapy/maps/tests/test_geom.py b/gammapy/maps/tests/test_geom.py
--- a/gammapy/maps/tests/test_geom.py
+++ b/gammapy/maps/tests/test_geom.py
@@ -299,6 +299,53 @@ def test_downsample():
     assert axis_down.node_type == axis.node_type
 
 
+def test_upsample_non_regular():
+    axis = MapAxis.from_edges([0, 1, 3, 7], name="test", interp="lin")
+    axis_up = axis.upsample(2)
+
+    assert_allclose(axis_up.nbin, 2 * axis.nbin)
+    assert_allclose(axis_up.edges[0], axis.edges[0])
+    assert_allclose(axis_up.edges[-1], axis.edges[-1])
+    assert axis_up.node_type == axis.node_type
+
+
+def test_upsample_non_regular_nodes():
+    axis = MapAxis.from_nodes([0, 1, 3, 7], name="test", interp="lin")
+    axis_up = axis.upsample(2)
+
+    assert_allclose(axis_up.nbin, 2 * axis.nbin - 1)
+    assert_allclose(axis_up.center[0], axis.center[0])
+    assert_allclose(axis_up.center[-1], axis.center[-1])
+    assert axis_up.node_type == axis.node_type
+
+
+def test_downsample_non_regular():
+    axis = MapAxis.from_edges([0, 1, 3, 7, 13], name="test", interp="lin")
+    axis_down = axis.downsample(2)
+
+    assert_allclose(axis_down.nbin, 0.5 * axis.nbin)
+    assert_allclose(axis_down.edges[0], axis.edges[0])
+    assert_allclose(axis_down.edges[-1], axis.edges[-1])
+    assert axis_down.node_type == axis.node_type
+
+
+def test_downsample_non_regular_nodes():
+    axis = MapAxis.from_edges([0, 1, 3, 7, 9], name="test", interp="lin")
+    axis_down = axis.downsample(2)
+
+    assert_allclose(axis_down.nbin, 0.5 * axis.nbin)
+    assert_allclose(axis_down.edges[0], axis.edges[0])
+    assert_allclose(axis_down.edges[-1], axis.edges[-1])
+    assert axis_down.node_type == axis.node_type
+
+
+@pytest.mark.parametrize("factor", [1, 3, 5, 7, 11])
+def test_up_downsample_consistency(factor):
+    axis = MapAxis.from_edges([0, 1, 3, 7, 13], name="test", interp="lin")
+    axis_new = axis.upsample(factor).downsample(factor)
+    assert_allclose(axis.edges, axis_new.edges)
+
+
 @pytest.fixture(scope="session")
 def energy_axis_ref():
     edges = np.arange(1, 11) * u.TeV
diff --git a/gammapy/maps/tests/test_wcsnd.py b/gammapy/maps/tests/test_wcsnd.py
--- a/gammapy/maps/tests/test_wcsnd.py
+++ b/gammapy/maps/tests/test_wcsnd.py
@@ -402,18 +402,18 @@ def test_wcsndmap_upsample(npix, binsz, frame, proj, skydir, axes):
 
 
 def test_wcsndmap_upsample_axis():
-    axis = MapAxis.from_nodes([1, 2, 3, 4], name="test")
+    axis = MapAxis.from_edges([1, 2, 3, 4], name="test")
     geom = WcsGeom.create(npix=(4, 4), axes=[axis])
     m = WcsNDMap(geom, unit="m2")
     m.data += 1
 
     m2 = m.upsample(2, preserve_counts=True, axis_name="test")
-    assert m2.data.shape == (8, 4, 4)
+    assert m2.data.shape == (6, 4, 4)
     assert_allclose(m.data.sum(), m2.data.sum())
 
 
 def test_wcsndmap_downsample_axis():
-    axis = MapAxis.from_nodes([1, 2, 3, 4], name="test")
+    axis = MapAxis.from_edges([1, 2, 3, 4, 5], name="test")
     geom = WcsGeom.create(npix=(4, 4), axes=[axis])
     m = WcsNDMap(geom, unit="m2")
     m.data += 1
@@ -435,19 +435,21 @@ def test_wcsndmap_resample_axis():
     assert m2.data.shape == (3, 2, 6, 7)
     assert_allclose(m2.data, 2)
 
+
 def test_wcsndmap_resample_axis_logical_and():
     axis_1 = MapAxis.from_edges([1, 2, 3, 4, 5], name="test-1")
 
     geom = WcsGeom.create(npix=(2, 2), axes=[axis_1])
     m = WcsNDMap(geom, dtype=bool)
-    m.data[:,:,:] = True
-    m.data[0,0,0] = False
+    m.data[:, :, :] = True
+    m.data[0, 0, 0] = False
 
     new_axis = MapAxis.from_edges([1, 3, 5], name="test-1")
     m2 = m.resample_axis(axis=new_axis, ufunc=np.logical_and)
     assert_allclose(m2.data[0,0,0], False)
     assert_allclose(m2.data[1,0,0], True)
 
+
 def test_coadd_unit():
     geom = WcsGeom.create(npix=(10, 10), binsz=1, proj="CAR", frame="galactic")
     m1 = WcsNDMap(geom, data=np.ones((10, 10)), unit="m2")