Repository: gammapy/gammapy

Issue:
Offset axis calculated from fov lon/lat axis can contain a negative bin
**Gammapy version**
1.0, 1.

**Bug description**
The transformation of FoV lon/lat to FoV offset axes in the background irfs contains a negative offset bin if there is a bin centered on 0 (meaning the lon/lat axis has an odd number of bins).
Because of this the `to_2d()` method of the `Background3D` produces a bkg with a "broken" offset axes, that then produces an error when plotting the bkg using `plot_at_energy` because that tries to convert back to 3D.

The problem is in the MapAxis code though, not the background directly.

**Expected behavior**
It should work without errors the same way it does when using an even number of bins.
If there is no clear solution for that center bin, leave it empty/nan.

**To Reproduce**
```python
from gammapy.maps import MapAxis
import astropy.units as u
import numpy as np

### Create a sample axis
lon_axis = MapAxis.from_bounds(
        -2 *u.deg,
        2*u.deg,
        21, # works using an even number of bins
)

### Relevant code from to_2d()

# This is arguably off by one causing the issue
# There is a -1 in the function, that causes this to fail
idx_lon = lon_axis.coord_to_idx(0 * u.deg)[0]
# This now contains a negative offset 
offset = lon_axis.edges[idx_lon:]
offset_axis = MapAxis.from_edges(offset, name="offset")

### The relevant code from bkg.to_3d():
# This produces the same edge twice, because one offset is already negative
edges = np.concatenate(
    (
        np.negative(offset_axis.edges)[::-1][:-1],
        offset_axis.edges,
    )
)
# This fails (only with an odd number of bins)
fov_lon = MapAxis.from_edges(edges=edges, name="fov_lon")
```



Discussion:

Resolving patch:
diff --git a/gammapy/irf/background.py b/gammapy/irf/background.py
--- a/gammapy/irf/background.py
+++ b/gammapy/irf/background.py
@@ -134,6 +134,7 @@ def to_2d(self):
         This takes the values at Y = 0 and X >= 0.
         """
         # TODO: this is incorrect as it misses the Jacobian?
+
         idx_lon = self.axes["fov_lon"].coord_to_idx(0 * u.deg)[0]
         idx_lat = self.axes["fov_lat"].coord_to_idx(0 * u.deg)[0]
         data = self.quantity[:, idx_lon:, idx_lat].copy()
@@ -245,12 +246,10 @@ class Background2D(BackgroundIRF):
 
     def to_3d(self):
         """Convert to Background3D."""
-        edges = np.concatenate(
-            (
-                np.negative(self.axes["offset"].edges)[::-1][:-1],
-                self.axes["offset"].edges,
-            )
-        )
+        offsets = self.axes["offset"].edges
+        edges_neg = np.negative(offsets)[::-1]
+        edges_neg = edges_neg[edges_neg <= 0]
+        edges = np.concatenate((edges_neg, offsets[offsets > 0]))
         fov_lat = MapAxis.from_edges(edges=edges, name="fov_lat")
         fov_lon = MapAxis.from_edges(edges=edges, name="fov_lon")
 


Test patch:
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
@@ -137,7 +137,6 @@ def test_plot_at_energy(bkg_3d):
 
 
 def test_background_3d_missing_values(bkg_3d_interp):
-
     res = bkg_3d_interp.evaluate(
         fov_lon=0.5 * u.deg,
         fov_lat=0.5 * u.deg,
@@ -423,3 +422,21 @@ def test_write_bkg_3d():
     bg = Background3D.read("background.fits", hdu="BACKGROUND")
 
     assert bg.fov_alignment.value == "ALTAZ"
+
+
+def test_to2d_to3d():
+    # Test for issue 4950
+    energy = [0.1, 10, 1000] * u.TeV
+    energy_axis = MapAxis.from_energy_edges(energy)
+    nbin = 21
+    fov_lon_axis = MapAxis.from_bounds(-2 * u.deg, 2 * u.deg, nbin=nbin, name="fov_lon")
+    fov_lat_axis = MapAxis.from_bounds(-2 * u.deg, 2 * u.deg, nbin=nbin, name="fov_lat")
+    data = np.ones((2, nbin, nbin)) * 1.5
+    bkg = Background3D(
+        axes=[energy_axis, fov_lon_axis, fov_lat_axis], data=data, unit="s-1 GeV-1 sr-1"
+    )
+    bkg2d = bkg.to_2d()
+    assert_allclose(bkg2d.axes["offset"].center[0].value, 0.0, atol=1e-5)
+    bkg3d = bkg2d.to_3d()
+    assert bkg3d.axes["fov_lon"].is_allclose(fov_lon_axis)
+    assert_allclose(bkg3d.data[0][10], bkg.data[0][10], rtol=1e-5)