Repository: gammapy/gammapy
Pull request #5774 (2025-04-08 22:46:57)

Add IO class for eventlist
<!-- These are hidden commments. You can leave them or delete them. -->

<!-- Don't be shy! Github issues or pull requests are welcome any time. -->
<!-- But if you first want to chat, use the Gammapy Slack. -->
<!-- See https://gammapy.org/contact.html -->

**Description**
<!-- What is the motivation for this pull request? -->
<!-- Briefly: what changes are done here? -->
<!-- If this is to address a Github issue, mention its number and use the specific keywords to create a link -->
<!-- See docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue -->
<!-- Example: This PR is to resolve #42 -->

This pull request introduces a simpler IO class for `EventList`

**Dear reviewer**
<!-- Let the reviewer and Gammapy team know what you want: -->
<!-- * Is this ready for review? Or is it work in progress and you want some feedback? -->
<!-- * Do you want to go through review here? Or if someone should just finish this up and merge it? -->
<!-- Do you have any specific questions, e.g. about API or implementation? -->
<!-- Do you include a test executing the new code you're adding (to make sure it runs)? -->
<!-- Do you include some documentation? Is it needed? -->


bkhelifi: One might need to link this PR with the #5779 

Patch:
diff --git a/gammapy/data/event_list.py b/gammapy/data/event_list.py
index cba70cf96..249a06e3f 100644
--- a/gammapy/data/event_list.py
+++ b/gammapy/data/event_list.py
@@ -7,20 +7,16 @@ import warnings
 import numpy as np
 from astropy import units as u
 from astropy.coordinates import AltAz, Angle, SkyCoord, angular_separation
-from astropy.io import fits
-from astropy.table import Table
 from astropy.table import vstack as vstack_tables
 from astropy.visualization import quantity_support
 import matplotlib.pyplot as plt
 from gammapy.maps import MapAxis, MapCoord, RegionGeom, WcsNDMap
 from gammapy.maps.axes import UNIT_STRING_FORMAT
 from gammapy.utils.fits import earth_location_from_dict
-from gammapy.utils.scripts import make_path
 from gammapy.utils.testing import Checker
 from gammapy.utils.time import time_ref_from_dict
 from .metadata import EventListMetaData
 from gammapy.utils.deprecation import deprecated_renamed_argument
-from gammapy.utils.metadata import CreatorMetaData
 
 __all__ = ["EventList"]
 
@@ -116,21 +112,9 @@ class EventList:
         checksum : bool
             If True checks both DATASUM and CHECKSUM cards in the file headers. Default is False.
         """
-        filename = make_path(filename)
-
-        with fits.open(filename) as hdulist:
-            events_hdu = hdulist[hdu]
-            if checksum:
-                if events_hdu.verify_checksum() != 1:
-                    warnings.warn(
-                        f"Checksum verification failed for HDU {hdu} of {filename}.",
-                        UserWarning,
-                    )
-
-            table = Table.read(events_hdu)
-            meta = EventListMetaData.from_header(table.meta)
+        from gammapy.data.io import EventListReader
 
-        return cls(table=table, meta=meta)
+        return EventListReader(hdu, checksum).read(filename)
 
     def to_table_hdu(self, format="gadf"):
         """
@@ -146,20 +130,9 @@ class EventList:
         hdu : `astropy.io.fits.BinTableHDU`
             EventList converted to FITS representation.
         """
-        if format != "gadf":
-            raise ValueError(f"Only the 'gadf' format supported, got {format}")
-
-        bin_table = fits.BinTableHDU(self.table, name="EVENTS")
-
-        # A priori don't change creator information
-        if self.meta.creation is None:
-            self.meta.creation = CreatorMetaData()
-        else:
-            self.meta.creation.update_time()
-
-        bin_table.header.update(self.meta.to_header())
+        from gammapy.data.io import EventListWriter
 
-        return bin_table
+        return EventListWriter().to_hdu(self, format)
 
     # TODO: Pass metadata here. Also check that specific meta contents are consistent
     @classmethod
diff --git a/gammapy/data/io.py b/gammapy/data/io.py
new file mode 100644
index 000000000..8426f5af5
--- /dev/null
+++ b/gammapy/data/io.py
@@ -0,0 +1,109 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+import warnings
+from astropy.io import fits
+from astropy.table import Table
+from gammapy.data import EventListMetaData, EventList
+from gammapy.utils.scripts import make_path
+from gammapy.utils.metadata import CreatorMetaData
+
+
+class EventListReader:
+    """Reader class for EventList.
+
+    Format specification: :ref:`gadf:iact-events`
+
+    Parameters
+    ----------
+    hdu : str
+        Name of events HDU. Default is "EVENTS".
+    checksum : bool
+        If True checks both DATASUM and CHECKSUM cards in the file headers. Default is False.
+    """
+
+    def __init__(self, hdu="EVENTS", checksum=False):
+        self.hdu = hdu
+        self.checksum = checksum
+
+    @staticmethod
+    def from_gadf_hdu(events_hdu):
+        """Create EventList from gadf HDU."""
+        table = Table.read(events_hdu)
+        meta = EventListMetaData.from_header(table.meta)
+        return EventList(table=table, meta=meta)
+
+    @staticmethod
+    def identify_format_from_hduclass(events_hdu):
+        """Identify format for HDU header keywords."""
+        hduclass = events_hdu.header.get("HDUCLASS", "unknown")
+        return hduclass.lower()
+
+    def read(self, filename, format="gadf"):
+        """Read EventList from file.
+
+        Parameters
+        ----------
+        filename : `pathlib.Path`, str
+            Filename
+        format : {"gadf"}, optional
+            format of the EventList. Default is 'gadf'.
+            If None, will try to guess from header.
+        """
+        filename = make_path(filename)
+
+        with fits.open(filename) as hdulist:
+            events_hdu = hdulist[self.hdu]
+
+            if self.checksum:
+                if events_hdu.verify_checksum() != 1:
+                    warnings.warn(
+                        f"Checksum verification failed for HDU {self.hdu} of {filename}.",
+                        UserWarning,
+                    )
+
+            if format is None:
+                format = self.identify_format_from_hduclass(events_hdu)
+
+            if format == "gadf" or format == "ogip":
+                return self.from_gadf_hdu(events_hdu)
+            else:
+                raise ValueError(f"Unknown format :{format}")
+
+
+class EventListWriter:
+    """Writer class for EventList."""
+
+    def __init__(self):
+        pass
+
+    @staticmethod
+    def _to_gadf_table_hdu(event_list):
+        """Convert input event list to a `~astropy.io.fits.BinTableHDU` according gadf."""
+        bin_table = fits.BinTableHDU(event_list.table, name="EVENTS")
+
+        # A priori don't change creator information
+        if event_list.meta.creation is None:
+            event_list.meta.creation = CreatorMetaData()
+        else:
+            event_list.meta.creation.update_time()
+
+        bin_table.header.update(event_list.meta.to_header())
+        return bin_table
+
+    def to_hdu(self, event_list, format="gadf"):
+        """
+        Convert input event list to a `~astropy.io.fits.BinTableHDU` according to format.
+
+        Parameters
+        ----------
+        format : str, optional
+            Output format, currently only "gadf" is supported. Default is "gadf".
+
+        Returns
+        -------
+        hdu : `astropy.io.fits.BinTableHDU`
+            EventList converted to FITS representation.
+        """
+        if format != "gadf":
+            raise ValueError(f"Only the 'gadf' format supported, got {format}")
+
+        return self._to_gadf_table_hdu(event_list)
diff --git a/gammapy/irf/background.py b/gammapy/irf/background.py
index 3f7405cee..e21b6b1ca 100644
--- a/gammapy/irf/background.py
+++ b/gammapy/irf/background.py
@@ -25,25 +25,13 @@ class BackgroundIRF(IRF):
     ----------
     axes : list of `~gammapy.maps.MapAxis` or `~gammapy.maps.MapAxes`
         Axes.
-    data : `~numpy.ndarray`
+    data : `~np.ndarray`
         Data array.
     unit : str or `~astropy.units.Unit`
         Data unit usually ``s^-1 MeV^-1 sr^-1``.
-    is_pointlike : bool, optional
-        Whether the IRF is point-like.
-        True for point-like IRFs, False for full-containment.
-        Default is False.
-    fov_alignment : `~gammapy.irf.FoVAlignment`, optional
-        The orientation of the field of view coordinate system.
-        Default is FoVAlignment.RADEC.
     meta : dict
         Metadata dictionary.
-    interp_kwargs : dict, optional
-        Keyword arguments passed to
-        `~gammapy.utils.interpolation.ScaledRegularGridInterpolator`.
-        If None, the following inputs are used ``bounds_error=False``, ``fill_value=0.0``
-        and ``values_scale="log"``.
-        Default is None.
+
     """
 
     default_interp_kwargs = dict(bounds_error=False, fill_value=0.0, values_scale="log")
@@ -125,25 +113,14 @@ class Background3D(BackgroundIRF):
             * energy (reconstructed energy axis)
             * fov_lon (field of view longitude)
             * fov_lon (field of view latitude)
-    data : `~numpy.ndarray`
+    data : `~np.ndarray`
         Data array.
     unit : str or `~astropy.units.Unit`
         Data unit usually ``s^-1 MeV^-1 sr^-1``.
-    is_pointlike : bool, optional
-        Whether the IRF is point-like.
-        True for point-like IRFs, False for full-containment.
-        Default is False.
-    fov_alignment : `~gammapy.irf.FoVAlignment`, optional
+    fov_alignment : `~gammapy.irf.FoVAlignment`
         The orientation of the field of view coordinate system.
-        Default is FoVAlignment.RADEC.
     meta : dict
         Metadata dictionary.
-    interp_kwargs : dict, optional
-        Keyword arguments passed to
-        `~gammapy.utils.interpolation.ScaledRegularGridInterpolator`.
-        If None, the following inputs are used ``bounds_error=False``, ``fill_value=0.0``
-        and ``values_scale="log"``.
-        Default is None.
 
     Examples
     --------
@@ -290,25 +267,12 @@ class Background2D(BackgroundIRF):
         Required axes (in the given order) are:
             * energy (reconstructed energy axis)
             * offset (field of view offset axis)
-    data : `~numpy.ndarray`
+    data : `~np.ndarray`
         Data array.
     unit : str or `~astropy.units.Unit`
         Data unit usually ``s^-1 MeV^-1 sr^-1``.
-    is_pointlike : bool, optional
-        Whether the IRF is point-like.
-        True for point-like IRFs, False for full-containment.
-        Default is False.
-    fov_alignment : `~gammapy.irf.FoVAlignment`, optional
-        The orientation of the field of view coordinate system.
-        Default is FoVAlignment.RADEC.
     meta : dict
         Metadata dictionary.
-    interp_kwargs : dict, optional
-        Keyword arguments passed to
-        `~gammapy.utils.interpolation.ScaledRegularGridInterpolator`.
-        If None, the following inputs are used ``bounds_error=False``, ``fill_value=0.0``
-        and ``values_scale="log"``.
-        Default is None.
     """
 
     tag = "bkg_2d"
diff --git a/gammapy/irf/core.py b/gammapy/irf/core.py
index 94de83554..37c0fac0f 100644
--- a/gammapy/irf/core.py
+++ b/gammapy/irf/core.py
@@ -58,11 +58,6 @@ class IRF(metaclass=abc.ABCMeta):
     meta : dict, optional
         Metadata dictionary.
         Default is None.
-    interp_kwargs : dict, optional
-        Keyword arguments passed to
-        `~gammapy.utils.interpolation.ScaledRegularGridInterpolator`.
-        If None, the following inputs are used ``bounds_error=False`` and ``fill_value=0.0``.
-        Default is None.
 
     Examples
     --------
@@ -238,7 +233,7 @@ class IRF(metaclass=abc.ABCMeta):
 
         Parameters
         ----------
-        unit : `~astropy.units.Unit` or str
+        unit : `~astropy.unit.Unit` or str
             New unit.
 
         Returns
@@ -253,7 +248,7 @@ class IRF(metaclass=abc.ABCMeta):
 
     @property
     def axes(self):
-        """`~gammapy.maps.MapAxes`."""
+        """`MapAxes`."""
         return self._axes
 
     def __str__(self):
@@ -421,7 +416,7 @@ class IRF(metaclass=abc.ABCMeta):
 
         Parameters
         ----------
-        hdulist : `~astropy.io.fits.HDUList`
+        hdulist : `~astropy.io.HDUList`
             HDU list.
         hdu : str
             HDU name.
@@ -624,7 +619,7 @@ class IRF(metaclass=abc.ABCMeta):
 
         Parameters
         ----------
-        other : `~gammapy.irf.IRF`
+        other : `~gammapy.irfs.IRF`
             The IRF to compare against.
         rtol_axes : float, optional
             Relative tolerance for the axis comparison.
diff --git a/gammapy/makers/background/reflected.py b/gammapy/makers/background/reflected.py
index b060c6669..84e2e5f33 100644
--- a/gammapy/makers/background/reflected.py
+++ b/gammapy/makers/background/reflected.py
@@ -285,20 +285,14 @@ class ReflectedRegionsFinder(RegionsFinder):
     ----------
     angle_increment : `~astropy.coordinates.Angle`, optional
         Rotation angle applied when a region falls in an excluded region.
-        Default is '0.1 rad'.
     min_distance : `~astropy.coordinates.Angle`, optional
-        Minimum rotation angle between two consecutive reflected regions, with respect to the
-        rotation center (typically the pointing position).
-        Default is '0 rad'.
+        Minimum rotation angle between two consecutive reflected regions.
     min_distance_input : `~astropy.coordinates.Angle`, optional
         Minimum rotation angle between the input region and the first reflected region.
-        Default is '0.1 rad'.
     max_region_number : int, optional
         Maximum number of regions to use.
-        Default is 10000.
     binsz : `~astropy.coordinates.Angle`
         Bin size of the reference map used for region finding.
-        Default is '0.01 deg'.
 
     Examples
     --------
@@ -438,7 +432,7 @@ class ReflectedRegionsBackgroundMaker(Maker):
 
     Attributes
     ----------
-    region_finder: `RegionsFinder`
+    region_finder: RegionsFinder
         If not given, a `ReflectedRegionsFinder` will be created and
         any of the ``**kwargs`` will be forwarded to the `ReflectedRegionsFinder`.
     exclusion_mask : `~gammapy.maps.WcsNDMap`, optional
@@ -454,6 +448,7 @@ class ReflectedRegionsBackgroundMaker(Maker):
         exclusion_mask=None,
         **kwargs,
     ):
+
         if exclusion_mask and not exclusion_mask.is_mask:
             raise ValueError("Exclusion mask must contain boolean values")
 
diff --git a/gammapy/utils/interpolation.py b/gammapy/utils/interpolation.py
index 244fdecba..4f0d285ef 100644
--- a/gammapy/utils/interpolation.py
+++ b/gammapy/utils/interpolation.py
@@ -28,9 +28,9 @@ class ScaledRegularGridInterpolator:
     Parameters
     ----------
     points : tuple of `~numpy.ndarray` or `~astropy.units.Quantity`
-        Tuple of points passed to `~scipy.interpolate.RegularGridInterpolator`.
+        Tuple of points passed to `RegularGridInterpolator`.
     values : `~numpy.ndarray`
-        Values passed to `~scipy.interpolate.RegularGridInterpolator`.
+        Values passed to `RegularGridInterpolator`.
     points_scale : tuple of str
         Interpolation scale used for the points.
     values_scale : {'lin', 'log', 'sqrt'}
@@ -42,7 +42,7 @@ class ScaledRegularGridInterpolator:
         Default interpolation method. Can be overwritten when calling the
         `ScaledRegularGridInterpolator`.
     **kwargs : dict
-        Keyword arguments passed to `~scipy.interpolate.RegularGridInterpolator`.
+        Keyword arguments passed to `RegularGridInterpolator`.
     """
 
     def __init__(


Test patch:
diff --git a/gammapy/data/tests/test_io.py b/gammapy/data/tests/test_io.py
new file mode 100644
index 000000000..40e4a3757
--- /dev/null
+++ b/gammapy/data/tests/test_io.py
@@ -0,0 +1,40 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+import astropy.io.fits as fits
+import pytest
+
+from gammapy.utils.scripts import make_path
+from gammapy.utils.testing import requires_data
+from ..io import EventListReader, EventListWriter
+
+
+@requires_data()
+def test_eventlist_reader_no_format():
+    hess_events = "$GAMMAPY_DATA/hess-dl3-dr1/data/hess_dl3_dr1_obs_id_020136.fits.gz"
+    fermi_events = "$GAMMAPY_DATA/fermi-3fhl-gc/fermi-3fhl-gc-events.fits.gz"
+
+    events = EventListReader().read(hess_events, format=None)
+    assert len(events.table) == 11243
+
+    events = EventListReader().read(fermi_events, format=None)
+    assert len(events.table) == 32843
+
+
+@requires_data()
+def test_eventlist_reader_unkwnown_format(tmpdir):
+    hess_events = make_path(
+        "$GAMMAPY_DATA/hess-dl3-dr1/data/hess_dl3_dr1_obs_id_020136.fits.gz"
+    )
+
+    with fits.open(hess_events) as hdulist:
+        hdu_events = hdulist["EVENTS"]
+        hdu_events.header["HDUCLASS"] = "bad"
+
+        hdulist.writeto(tmpdir / "tmp.fits")
+
+    with pytest.raises(ValueError):
+        EventListReader().read(tmpdir / "tmp.fits", format=None)
+
+
+def test_eventlist_writer_unkwnown_format():
+    with pytest.raises(ValueError):
+        EventListWriter().to_hdu("tmp.fits", format="unknown")