Repository: gammapy/gammapy

Issue:
Load global RAD_MAX header keyword into RAD_MAX hdu
* The PR #3515 added support for the `RAD_MAX_2D` IRF, stored as the RAD_MAX table in GADF.

* PR #3684  uses this to for energy dependent on region calculation relying on this RAD_MAX


It should be possible to use the mechanisms introduced in #3684 also when using a fixed RAD_MAX that is stored in the `RAD_MAX` header keyword in GADF.

Possibly the best way would be to read this value if present into a `RAD_MAX_2D` object with a single energy / offset bin covering the same min/max values as the corresponding IRF.



Discussion:

Resolving patch:
diff --git a/gammapy/data/observations.py b/gammapy/data/observations.py
--- a/gammapy/data/observations.py
+++ b/gammapy/data/observations.py
@@ -35,9 +35,9 @@ class Observation:
         Point spread function
     bkg : `~gammapy.irf.Background3D`
         Background rate model
-    rad_max: `~gammapy.irf.RadMax2D` or `~astropy.units.Quantity`
+    rad_max: `~gammapy.irf.RadMax2D`
         Only for point-like IRFs: RAD_MAX table (energy dependent RAD_MAX)
-        or a single angle (global RAD_MAX)
+        For a fixed RAD_MAX, create a RadMax2D with a single bin.
     gti : `~gammapy.data.GTI`
         Table with GTI start and stop time
     events : `~gammapy.data.EventList`
@@ -50,7 +50,7 @@ class Observation:
     edisp = LazyFitsData(cache=False)
     psf = LazyFitsData(cache=False)
     bkg = LazyFitsData(cache=False)
-    rad_max = LazyFitsData(cache=False)
+    _rad_max = LazyFitsData(cache=False)
     _events = LazyFitsData(cache=False)
     _gti = LazyFitsData(cache=False)
 
@@ -73,11 +73,32 @@ def __init__(
         self.edisp = edisp
         self.psf = psf
         self.bkg = bkg
-        self.rad_max = rad_max
+        self._rad_max = rad_max
         self._gti = gti
         self._events = events
         self.obs_filter = obs_filter or ObservationFilter()
 
+
+    @property
+    def rad_max(self):
+        # prevent circular import
+        from gammapy.irf import RadMax2D
+
+        if self._rad_max is not None:
+            return self._rad_max
+
+        # load once to avoid trigger lazy loading it three times
+        aeff = self.aeff
+        if aeff is not None and aeff.is_pointlike:
+            self._rad_max = RadMax2D.from_irf(aeff)
+            return self._rad_max
+
+        edisp = self.edisp
+        if edisp is not None and edisp.is_pointlike:
+            self._rad_max = RadMax2D.from_irf(self.edisp)
+
+        return self._rad_max
+
     @property
     def available_irfs(self):
         """Which irfs are available"""
diff --git a/gammapy/irf/io.py b/gammapy/irf/io.py
--- a/gammapy/irf/io.py
+++ b/gammapy/irf/io.py
@@ -205,18 +205,24 @@ def load_irf_dict_from_file(filename):
         dictionary with instances of the Gammapy objects corresponding
         to the IRF components
     """
+    from .rad_max import RadMax2D
+
     filename = make_path(filename)
 
     hdulist = fits.open(make_path(filename))
 
     irf_dict = {}
 
+    is_pointlike = False
+
     for hdu in hdulist:
         hdu_class = hdu.header.get("HDUCLAS1", "").lower()
 
         if hdu_class == "response":
             hdu_class = hdu.header.get("HDUCLAS4", "").lower()
 
+            is_pointlike |= hdu.header["HDUCLAS3"] == "POINT-LIKE"
+
             loc = HDULocation(
                 hdu_class=hdu_class,
                 hdu_name=hdu.name,
@@ -237,4 +243,8 @@ def load_irf_dict_from_file(filename):
                     irf_dict[name] = data
         else:  # not an IRF component
             continue
+
+    if is_pointlike and "rad_max" not in irf_dict:
+        irf_dict["rad_max"] = RadMax2D.from_irf(irf_dict['aeff'])
+
     return irf_dict
diff --git a/gammapy/irf/rad_max.py b/gammapy/irf/rad_max.py
--- a/gammapy/irf/rad_max.py
+++ b/gammapy/irf/rad_max.py
@@ -1,3 +1,5 @@
+import astropy.units as u
+
 from .core import IRF
 
 __all__ = [
@@ -28,3 +30,48 @@ class RadMax2D(IRF):
 
     tag = "rad_max_2d"
     required_axes = ["energy", "offset"]
+
+
+    @classmethod
+    def from_irf(cls, irf):
+        '''
+        Create a RadMax2D instance from another IRF component.
+
+        This reads the RAD_MAX metadata keyword from the irf and creates
+        a RadMax2D with a single bin in energy and offset using the
+        ranges from the input irf.
+
+        Parameters
+        ----------
+        irf: `~gammapy.irf.EffectiveAreaTable2D` or `~gammapy.irf.EnergyDispersion2D`
+            IRF instance from which to read the RAD_MAX and limit information
+
+        Returns
+        -------
+        rad_max: `RadMax2D`
+            `RadMax2D` object with a single bin corresponding to the fixed
+            RAD_MAX cut.
+
+        Notes
+        -----
+        This assumes the true energy axis limits are also valid for the
+        reco energy limits.
+        '''
+        if not irf.is_pointlike:
+            raise ValueError('RadMax2D.from_irf is only valid for point-like irfs')
+
+        if 'RAD_MAX' not in irf.meta:
+            raise ValueError('irf does not contain RAD_MAX keyword')
+
+        rad_max_value = irf.meta["RAD_MAX"]
+        if not isinstance(rad_max_value, float):
+            raise ValueError('RAD_MAX must be a float')
+
+        energy_axis = irf.axes["energy_true"].squash()
+        energy_axis.name = 'energy'
+        offset_axis = irf.axes["offset"].squash()
+
+        return cls(
+            data=u.Quantity([[rad_max_value]], u.deg),
+            axes=[energy_axis, offset_axis],
+        )


Test patch:
diff --git a/gammapy/data/tests/test_data_store.py b/gammapy/data/tests/test_data_store.py
--- a/gammapy/data/tests/test_data_store.py
+++ b/gammapy/data/tests/test_data_store.py
@@ -4,6 +4,7 @@
 import pytest
 import numpy as np
 from astropy.io import fits
+import astropy.units as u
 from gammapy.data import DataStore
 from gammapy.utils.scripts import make_path
 from gammapy.utils.testing import requires_data
@@ -187,3 +188,29 @@ def test_observation(self, monkeypatch):
         assert obs.bkg.__class__.__name__ == "Background3D"
         assert obs.edisp.__class__.__name__ == "EnergyDispersion2D"
         assert obs.psf.__class__.__name__ == "EnergyDependentMultiGaussPSF"
+
+
+@requires_data('gammapy-data')
+def test_datastore_fixed_rad_max():
+    data_store = DataStore.from_dir("$GAMMAPY_DATA/joint-crab/dl3/magic")
+    observations = data_store.get_observations([5029748], required_irf=['aeff', 'edisp'])
+
+    assert len(observations) == 1
+    obs = observations[0]
+
+    assert obs.rad_max is not None
+    assert obs.rad_max.quantity.shape == (1, 1)
+    assert u.allclose(obs.rad_max.quantity, np.sqrt(0.02) * u.deg)
+
+    # test it also works with edisp (removing aeff)
+    obs = data_store.get_observations([5029748], required_irf=['aeff', 'edisp'])[0]
+    obs.aeff = None
+    assert obs.rad_max is not None
+    assert obs.rad_max.quantity.shape == (1, 1)
+    assert u.allclose(obs.rad_max.quantity, 0.1414213 * u.deg)
+
+    # removing the last irf means we have no rad_max info
+    obs = data_store.get_observations([5029748], required_irf=['aeff', 'edisp'])[0]
+    obs.aeff = None
+    obs.edisp = None
+    assert obs.rad_max is None
diff --git a/gammapy/data/tests/test_observations.py b/gammapy/data/tests/test_observations.py
--- a/gammapy/data/tests/test_observations.py
+++ b/gammapy/data/tests/test_observations.py
@@ -281,6 +281,19 @@ def test_observation_read_single_file():
     assert val.unit == "m2"
 
 
+@requires_data()
+def test_observation_read_single_file_fixed_rad_max():
+    """check that for a point-like observation without the RAD_MAX_2D table
+    a RadMax2D object is generated from the RAD_MAX keyword"""
+    obs = Observation.read(
+        "$GAMMAPY_DATA/joint-crab/dl3/magic/run_05029748_DL3.fits"
+    )
+
+    assert obs.rad_max is not None
+    assert obs.rad_max.quantity.shape == (1, 1)
+    assert u.allclose(obs.rad_max.quantity, 0.1414213 * u.deg)
+
+
 @requires_data()
 class TestObservationChecker:
     def setup(self):
diff --git a/gammapy/irf/tests/test_io.py b/gammapy/irf/tests/test_io.py
--- a/gammapy/irf/tests/test_io.py
+++ b/gammapy/irf/tests/test_io.py
@@ -5,6 +5,7 @@
 from astropy.io import fits
 from astropy.units import Quantity
 from gammapy.irf import (
+    RadMax2D,
     Background3D,
     EffectiveAreaTable2D,
     EnergyDispersion2D,
@@ -150,6 +151,24 @@ def test_irf_dict_from_file_duplicate_irfs(caplog, tmp_path):
     assert "loaded the PSF HDU in the dictionary" in caplog.text
 
 
+@requires_data()
+def test_irf_dict_from_file_fixed_rad_max():
+    """test that for point-like IRF without RAD_MAX_2D HDU a RadMax2D with a
+    single value is generated from the RAD_MAX header keyword"""
+    irf = load_irf_dict_from_file(
+        "$GAMMAPY_DATA/joint-crab/dl3/magic/run_05029748_DL3.fits"
+    )
+
+    assert "RAD_MAX" in irf["aeff"].meta
+    assert "rad_max" in irf
+    assert isinstance(irf["rad_max"], RadMax2D)
+
+    # check that has a single-bin in energy and offset
+    assert irf["rad_max"].axes["energy"].nbin == 1
+    assert irf["rad_max"].axes["offset"].nbin == 1
+    assert irf["rad_max"].quantity.to_value("deg") == irf["aeff"].meta["RAD_MAX"]
+
+
 class TestIRFWrite:
     def setup(self):
         self.energy_lo = np.logspace(0, 1, 10)[:-1] * u.TeV
diff --git a/gammapy/irf/tests/test_rad_max.py b/gammapy/irf/tests/test_rad_max.py
--- a/gammapy/irf/tests/test_rad_max.py
+++ b/gammapy/irf/tests/test_rad_max.py
@@ -1,6 +1,7 @@
 import numpy as np
 import astropy.units as u
 from gammapy.maps import MapAxis
+import pytest
 
 
 def test_rad_max_roundtrip(tmp_path):
@@ -31,3 +32,45 @@ def test_rad_max_roundtrip(tmp_path):
 
     assert np.all(rad_max_read.data.data == rad_max)
     assert np.all(rad_max_read.data.data == rad_max_read.data.data)
+
+
+def test_rad_max_from_irf():
+    from gammapy.irf import RadMax2D, EffectiveAreaTable2D
+
+    e_bins = 3
+    o_bins = 2
+    energy_axis = MapAxis.from_energy_bounds(1 * u.TeV, 10 * u.TeV, nbin=e_bins, name='energy_true')
+    offset_axis = MapAxis.from_bounds(0 * u.deg, 3 * u.deg, nbin=o_bins, name='offset')
+    aeff = EffectiveAreaTable2D(
+        data=u.Quantity(np.ones((e_bins, o_bins)), u.m**2, copy=False),
+        axes=[energy_axis, offset_axis],
+    )
+
+    with pytest.raises(ValueError):
+        # not a point-like IRF
+        RadMax2D.from_irf(aeff)
+
+
+    aeff.meta['is_pointlike'] = True
+
+    with pytest.raises(ValueError):
+        # missing rad_max
+        RadMax2D.from_irf(aeff)
+
+
+    aeff.meta['RAD_MAX'] = '0.2 deg'
+    with pytest.raises(ValueError):
+        # invalid format
+        RadMax2D.from_irf(aeff)
+
+    aeff.meta['RAD_MAX'] = 0.2
+    rad_max = RadMax2D.from_irf(aeff)
+
+    assert rad_max.axes['energy'].nbin == 1
+    assert rad_max.axes['offset'].nbin ==  1
+    assert rad_max.axes['energy'].edges[0] == aeff.axes['energy_true'].edges[0]
+    assert rad_max.axes['energy'].edges[1] == aeff.axes['energy_true'].edges[-1]
+    assert rad_max.axes['offset'].edges[0] == aeff.axes['offset'].edges[0]
+    assert rad_max.axes['offset'].edges[1] == aeff.axes['offset'].edges[-1]
+    assert rad_max.quantity.shape == (1, 1)
+    assert rad_max.quantity[0, 0] == 0.2 * u.deg