Repository: gammapy/gammapy

Issue:
Create aligned WCS in `WcsGeom.to_binsz()`
**Gammapy version**
`0.16` and earlier

**Bug description**
When cutting out a `WcsGeom` and changing the bin size, the result is not guaranteed to be aligned with the initial geometry.

**Expected behavior**
Return an aligned geometry in `.to_binsz()`.

**To Reproduce**
```python3
from gammapy.maps import WcsGeom
from astropy.coordinates import SkyCoord

geom = WcsGeom.create(width=5, binsz=0.02)
geom_coarse = geom.to_binsz(0.18234)

cutout = geom.cutout(position=SkyCoord("0d", "0.02334d"), width=2)
cutout_coarse = cutout.to_binsz(0.18234)

cutout_coarse_aligned = geom_coarse.cutout(
    position=SkyCoord("0d", "0.02334d"), width=2
)

print(geom.is_aligned(cutout))
print(geom_coarse.is_aligned(cutout_coarse))
print(geom_coarse.is_aligned(cutout_coarse_aligned))
```
Which prints:
```
True
False
True
```
**Other information**
This bug was reported by @lmohrmann in the #help Slack channel, where the following coding pattern (creating the smaller `MapDataset` within the loop) leads to a `ValueError: Can only stack equivalent maps or cutout of the same map.`:

```python3
maker = MapDatasetMaker(background_oversampling=bkg_oversample)
maker_safe_mask = SafeMaskMaker(methods=['offset-max'], offset_max=max_offset)
dataset_stacked = MapDataset.create(geom=geom, name='stacked')
for obs in observations:
    geom_cutout = geom.cutout(position=obs.pointing_radec, width=10*u.deg)
    reference = MapDataset.create(geom=geom_cutout, name=obs.obs_id)
    dataset = maker.run(reference, obs)
    dataset = maker_safe_mask.run(dataset, obs)
    dataset_stacked.stack(dataset)
```



Discussion:
Merci! We will respond to your issue shortly. In the meantime, try `import gammapy; gammapy.song()`
I have looked into this some time ago and and there is no simple solution to this problem. It requires at least to pass on the information on the parent geometry to the cutout geometry. As an alternative pattern we could introduce a `MapDataset.geoms` attribute, which return a dict with all the geometries defined in the dataset. Like "counts", "exposure", "psf" and "edisp".  Then the following works:

```python3
maker = MapDatasetMaker(background_oversampling=bkg_oversample)
dataset_stacked = MapDataset.create(geom=geom, name='stacked')

for obs in observations:
    kwargs = {"position": obs.pointing_radec, "width": 10*u.deg}
    geoms_cutout = {name: geom.cutout(**kwargs) for name, geom in stacked.geoms.items()}

    reference = MapDataset.from_geoms(**geoms, name=obs.obs_id)
    dataset = maker.run(reference, obs)
    dataset_stacked.stack(dataset)

```
 

Resolving patch:
diff --git a/gammapy/datasets/map.py b/gammapy/datasets/map.py
--- a/gammapy/datasets/map.py
+++ b/gammapy/datasets/map.py
@@ -209,6 +209,48 @@ def __str__(self):
 
         return str_.expandtabs(tabsize=2)
 
+    @property
+    def _geom(self):
+        """Main analysis geometry"""
+        if self.counts is not None:
+            return self.counts.geom
+        elif self.background_model is not None:
+            return self.background_model.map.geom
+        elif self.mask_safe is not None:
+            return self.mask_safe.geom
+        elif self.mask_fit is not None:
+            return self.mask_fit.geom
+        else:
+            raise ValueError(
+                "Either 'counts', 'background_model', 'mask_fit'"
+                " or 'mask_safe' must be defined."
+            )
+
+    @property
+    def geoms(self):
+        """Map geometries
+
+        Returns
+        -------
+        geoms : dict
+            Dict of map geometries involved in the dataset.
+        """
+        geoms = {}
+
+        geoms["geom"] = self._geom
+
+        if self.exposure:
+            geoms["geom_exposure"] = self.exposure.geom
+
+        if self.psf:
+            geoms["geom_psf"] = self.psf.psf_map.geom
+
+        if self.edisp:
+            geoms["geom_edisp"] = self.edisp.edisp_map.geom
+
+        return geoms
+
+
     @property
     def models(self):
         """Models (`~gammapy.modeling.models.Models`)."""
@@ -271,23 +313,6 @@ def evaluators(self):
 
         return self._evaluators
 
-    @property
-    def _geom(self):
-        """Main analysis geometry"""
-        if self.counts is not None:
-            return self.counts.geom
-        elif self.background_model is not None:
-            return self.background_model.map.geom
-        elif self.mask_safe is not None:
-            return self.mask_safe.geom
-        elif self.mask_fit is not None:
-            return self.mask_fit.geom
-        else:
-            raise ValueError(
-                "Either 'counts', 'background_model', 'mask_fit'"
-                " or 'mask_safe' must be defined."
-            )
-
     @property
     def data_shape(self):
         """Shape of the counts or background data (tuple)"""
@@ -1687,6 +1712,7 @@ def from_geoms(
             kwargs[key] = Map.from_geom(geom, unit="")
 
         kwargs["exposure"] = Map.from_geom(geom_exposure, unit="m2 s")
+
         if geom_edisp.axes[0].name.lower() == "energy":
             kwargs["edisp"] = EDispKernelMap.from_geom(geom_edisp)
         else:


Test patch:
diff --git a/gammapy/datasets/tests/test_map.py b/gammapy/datasets/tests/test_map.py
--- a/gammapy/datasets/tests/test_map.py
+++ b/gammapy/datasets/tests/test_map.py
@@ -56,8 +56,8 @@ def geom_etrue():
 
 @pytest.fixture
 def geom_image():
-    ebounds_true = np.logspace(-1.0, 1.0, 2)
-    axis = MapAxis.from_edges(ebounds_true, name="energy", unit=u.TeV, interp="log")
+    energy = np.logspace(-1.0, 1.0, 2)
+    axis = MapAxis.from_edges(energy, name="energy", unit=u.TeV, interp="log")
     return WcsGeom.create(
         skydir=(0, 0), binsz=0.02, width=(2, 2), frame="galactic", axes=[axis]
     )
@@ -921,23 +921,46 @@ def test_stack_onoff(images):
     assert_allclose(stacked.exposure.data, 2.0 * dataset.exposure.data)
 
 
-@pytest.mark.xfail
+def test_dataset_cutout_aligned(geom):
+    dataset = MapDataset.create(geom)
+
+    kwargs = {"position": geom.center_skydir, "width": 1 * u.deg}
+    geoms = {name: geom.cutout(**kwargs) for name, geom in dataset.geoms.items()}
+
+    cutout = MapDataset.from_geoms(**geoms, name="cutout")
+
+    assert dataset.counts.geom.is_aligned(cutout.counts.geom)
+    assert dataset.exposure.geom.is_aligned(cutout.exposure.geom)
+    assert dataset.edisp.edisp_map.geom.is_aligned(cutout.edisp.edisp_map.geom)
+    assert dataset.psf.psf_map.geom.is_aligned(cutout.psf.psf_map.geom)
+
+
 def test_stack_onoff_cutout(geom_image):
     # Test stacking of cutouts
-    dataset = MapDatasetOnOff.create(geom_image)
-    gti = GTI.create([0 * u.s], [1 * u.h], reference_time="2010-01-01T00:00:00")
-    dataset.gti = gti
+    energy_axis_true = MapAxis.from_energy_bounds(
+        "1 TeV", "10 TeV", nbin=3, name="energy_true"
+    )
+
+    dataset = MapDatasetOnOff.create(geom_image, energy_axis_true=energy_axis_true)
+    dataset.gti = GTI.create([0 * u.s], [1 * u.h], reference_time="2010-01-01T00:00:00")
+
+    kwargs = {"position": geom_image.center_skydir, "width": 1 * u.deg}
+    geoms = {name: geom.cutout(**kwargs) for name, geom in dataset.geoms.items()}
 
-    geom_cutout = geom_image.cutout(position=geom_image.center_skydir, width=1 * u.deg)
-    dataset_cutout = dataset.create(geom_cutout)
+    dataset_cutout = MapDatasetOnOff.from_geoms(**geoms, name="cutout-dataset")
+    dataset_cutout.gti = GTI.create([0 * u.s], [1 * u.h], reference_time="2010-01-01T00:00:00")
+    dataset_cutout.mask_safe.data += True
+    dataset_cutout.counts.data += 1
+    dataset_cutout.counts_off.data += 1
+    dataset_cutout.exposure.data += 1
 
     dataset.stack(dataset_cutout)
 
-    assert_allclose(dataset.counts.data.sum(), dataset_cutout.counts.data.sum())
-    assert_allclose(dataset.counts_off.data.sum(), dataset_cutout.counts_off.data.sum())
-    assert_allclose(dataset.alpha.data.sum(), dataset_cutout.alpha.data.sum())
-    assert_allclose(dataset.exposure.data.sum(), dataset_cutout.exposure.data.sum())
-    assert dataset_cutout.name != dataset.name
+    assert_allclose(dataset.counts.data.sum(), 2500)
+    assert_allclose(dataset.counts_off.data.sum(), 2500)
+    assert_allclose(dataset.alpha.data.sum(), 0)
+    assert_allclose(dataset.exposure.data.sum(), 7500)
+    assert dataset_cutout.name == "cutout-dataset"
 
 
 def test_datasets_io_no_model(tmpdir):