Repository: gammapy/gammapy

Issue:
Make `RegionFinder` modular for `ReflectedRegionsBackgroundMaker`
**Is your feature request related to a problem? Please describe.**

As discussed e.g. here #3296 , there are multiple strategies to find reflected off regions.


**Describe the solution you'd like**

My proposal would be that the `ReflectedRegionsBackgroundMaker` gets a `region_finder` argument instead of the arguments for `ReflectedRegionsFinder` and that the API for a generic RegionsFinder should be:

```
class RegionsFinder:
     def __init__(**options_for_finder):
         ...

    def run(self, region, center):
         return list_of_regions
```

Like this, the `ReflectedRegionsBackgroundMaker` can receive a number of `Finder` implementations and call them when needed for the specific combination of `region` and `center`.

Currently, the `region` and `center` arguments are given to the `__init__` method of `ReflectedRegionsFinder`, although these are only used once in the `run` method. The rest of the class is a more or less complicated caching structure, that is not really needed, since `run` clears the cache. So only inside `run`, things are cached.

This could be more simply solved with just having local variables in run eliminating the need for storing `region` and `center` in the class.


Discussion:

Resolving patch:
diff --git a/docs/makers/make_reflected_regions.py b/docs/makers/make_reflected_regions.py
--- a/docs/makers/make_reflected_regions.py
+++ b/docs/makers/make_reflected_regions.py
@@ -18,12 +18,13 @@
 
 # One can impose a minimal distance between ON region and first reflected regions
 finder = ReflectedRegionsFinder(
+    min_distance_input="0.2 rad",
+)
+regions, _ = finder.run(
     region=on_region,
     center=center,
     exclusion_mask=exclusion_mask,
-    min_distance_input="0.2 rad",
 )
-regions = finder.run()
 
 fig, axes = plt.subplots(
     ncols=3,
@@ -47,12 +48,13 @@ def plot_regions(ax, regions, on_region, exclusion_mask):
 
 # One can impose a minimal distance between two adjacent regions
 finder = ReflectedRegionsFinder(
+    min_distance="0.1 rad",
+)
+regions, _ = finder.run(
     region=on_region,
     center=center,
     exclusion_mask=exclusion_mask,
-    min_distance="0.1 rad",
 )
-regions = finder.run()
 
 ax = axes[1]
 ax.set_title("Min. distance all regions")
@@ -61,13 +63,14 @@ def plot_regions(ax, regions, on_region, exclusion_mask):
 
 # One can impose a maximal number of regions to be extracted
 finder = ReflectedRegionsFinder(
+    max_region_number=5,
+    min_distance="0.1 rad",
+)
+regions, _ = finder.run(
     region=on_region,
     center=center,
     exclusion_mask=exclusion_mask,
-    max_region_number=5,
-    min_distance="0.1 rad",
 )
-regions = finder.run()
 
 ax = axes[2]
 ax.set_title("Max. number of regions")
diff --git a/gammapy/makers/background/reflected.py b/gammapy/makers/background/reflected.py
--- a/gammapy/makers/background/reflected.py
+++ b/gammapy/makers/background/reflected.py
@@ -1,10 +1,11 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 import logging
 import numpy as np
+from abc import ABCMeta, abstractmethod
+
 from astropy import units as u
 from astropy.coordinates import Angle
-from astropy.utils import lazyproperty
-from regions import PixCoord, PointSkyRegion, CircleSkyRegion
+from regions import PixCoord, PointSkyRegion
 from gammapy.datasets import SpectrumDatasetOnOff
 from gammapy.maps import RegionGeom, RegionNDMap, WcsGeom, WcsNDMap
 from ..core import Maker
@@ -14,8 +15,35 @@
 
 log = logging.getLogger(__name__)
 
+FULL_CIRCLE = Angle(2 * np.pi, "rad")
+
+
+class RegionsFinder(metaclass=ABCMeta):
+    '''Baseclass for regions finders'''
+
+    @abstractmethod
+    def run(self, region, center, exclusion_mask=None):
+        """Find regions to calculate background counts.
 
-class ReflectedRegionsFinder:
+        Parameters
+        ----------
+        region : `~regions.SkyRegion`
+            Region to rotate
+        center : `~astropy.coordinates.SkyCoord`
+            Rotation point
+        exclusion_mask : `~gammapy.maps.WcsNDMap`, optional
+            Exclusion mask
+
+        Returns
+        -------
+        regions : list of `SkyRegion`
+            Reflected regions
+        wcs: `~astropy.wcs.WCS`
+            WCS for the determined regions
+        """
+
+
+class ReflectedRegionsFinder(RegionsFinder):
     """Find reflected regions.
 
     This class is responsible for placing :ref:`region_reflected` for a given
@@ -31,10 +59,6 @@ class ReflectedRegionsFinder:
 
     Parameters
     ----------
-    region : `~regions.SkyRegion`
-        Region to rotate
-    center : `~astropy.coordinates.SkyCoord`
-        Rotation point
     angle_increment : `~astropy.coordinates.Angle`, optional
         Rotation angle applied when a region falls in an excluded region.
     min_distance : `~astropy.coordinates.Angle`, optional
@@ -43,8 +67,6 @@ class ReflectedRegionsFinder:
         Minimal distance from input region
     max_region_number : int, optional
         Maximum number of regions to use
-    exclusion_mask : `~gammapy.maps.WcsNDMap`, optional
-        Exclusion mask
     binsz : `~astropy.coordinates.Angle`
         Bin size of the reference map used for region finding.
 
@@ -57,8 +79,8 @@ class ReflectedRegionsFinder:
     >>> target_position = SkyCoord(80.2, 23.5, unit='deg', frame='icrs')
     >>> theta = Angle(0.4, 'deg')
     >>> on_region = CircleSkyRegion(target_position, theta)
-    >>> finder = ReflectedRegionsFinder(min_distance_input='1 rad', region=on_region, center=pointing)
-    >>> regions = finder.run()
+    >>> finder = ReflectedRegionsFinder(min_distance_input='1 rad')
+    >>> regions, wcs = finder.run(region=on_region, center=pointing)
     >>> print(regions[0])
     Region: CircleSkyRegion
     center: <SkyCoord (ICRS): (ra, dec) in deg
@@ -68,17 +90,12 @@ class ReflectedRegionsFinder:
 
     def __init__(
         self,
-        region,
-        center,
         angle_increment="0.1 rad",
         min_distance="0 rad",
         min_distance_input="0.1 rad",
         max_region_number=10000,
-        exclusion_mask=None,
         binsz="0.01 deg",
     ):
-        self.region = region
-        self.center = center
 
         self.angle_increment = Angle(angle_increment)
 
@@ -88,15 +105,10 @@ def __init__(
         self.min_distance = Angle(min_distance)
         self.min_distance_input = Angle(min_distance_input)
 
-        if exclusion_mask and not exclusion_mask.is_mask:
-            raise ValueError("Exclusion mask must contain boolean values")
-
-        self.exclusion_mask = exclusion_mask
         self.max_region_number = max_region_number
         self.binsz = Angle(binsz)
 
-    @lazyproperty
-    def geom_ref(self):
+    def _create_reference_geometry(self, region, center):
         """Reference geometry
 
         The size of the map is chosen such that all reflected regions are
@@ -110,27 +122,27 @@ def geom_ref(self):
         The WCS of the map is the TAN projection at the `center` in the coordinate
         system used by the `region` center.
         """
-        frame = self.region.center.frame.name
+        frame = region.center.frame.name
 
         # width is the full width of an image (not the radius)
-        width = 4 * self.region.center.separation(self.center) + Angle("0.3 deg")
+        width = 4 * region.center.separation(center) + Angle("0.3 deg")
 
         return WcsGeom.create(
-            skydir=self.center, binsz=self.binsz, width=width, frame=frame, proj="TAN"
+            skydir=center, binsz=self.binsz, width=width, frame=frame, proj="TAN"
         )
 
-    @lazyproperty
-    def center_pix(self):
+    @staticmethod
+    def _get_center_pixel(center, reference_geom):
         """Center pix coordinate"""
-        return PixCoord.from_sky(self.center, self.geom_ref.wcs)
+        return PixCoord.from_sky(center, reference_geom.wcs)
 
-    @lazyproperty
-    def region_pix(self):
+    @staticmethod
+    def _get_region_pixels(region, reference_geom):
         """Pixel region"""
-        return self.region.to_pixel(self.geom_ref.wcs)
+        return region.to_pixel(reference_geom.wcs)
 
-    @lazyproperty
-    def region_angular_size(self):
+    @staticmethod
+    def _region_angular_size(region, reference_geom, center_pix):
         """Compute maximum angular size of a group of pixels as seen from center.
 
         This assumes that the center lies outside the group of pixel
@@ -140,134 +152,129 @@ def region_angular_size(self):
         angular_size : `~astropy.coordinates.Angle`
             the maximum angular size
         """
-        mask = self.geom_ref.region_mask([self.region]).data
-        pix_y, pix_x = np.where(mask)
+        mask = reference_geom.region_mask([region]).data
+        pix_y, pix_x = np.nonzero(mask)
 
         pixels = PixCoord(pix_x, pix_y)
 
-        dx, dy = self.center_pix.x - pixels.x, self.center_pix.y - pixels.y
+        dx, dy = center_pix.x - pixels.x, center_pix.y - pixels.y
         angles = Angle(np.arctan2(dx, dy), "rad")
         angular_size = np.max(angles) - np.min(angles)
 
         if angular_size.value > np.pi:
-            angular_size = np.max(angles.wrap_at(0 * u.rad)) - np.min(
-                angles.wrap_at(0 * u.rad)
-            )
+            angle_wrapped = angles.wrap_at(0 * u.rad)
+            angular_size = np.max(angle_wrapped) - np.min(angle_wrapped)
 
         return angular_size
 
-    @lazyproperty
-    def exclusion_mask_ref(self):
+    @staticmethod
+    def _exclusion_mask_ref(reference_geom, exclusion_mask):
         """Exclusion mask reprojected"""
-        if self.exclusion_mask:
-            mask = self.exclusion_mask.interp_to_geom(self.geom_ref, fill_value=True)
+        if exclusion_mask:
+            mask = exclusion_mask.interp_to_geom(reference_geom, fill_value=True)
         else:
-            mask = WcsNDMap.from_geom(geom=self.geom_ref, data=True)
+            mask = WcsNDMap.from_geom(geom=reference_geom, data=True)
         return mask
 
-    @lazyproperty
-    def excluded_pix_coords(self):
+    @staticmethod
+    def _get_excluded_pixels(reference_geom, exclusion_mask):
         """Excluded pix coords"""
         # find excluded PixCoords
-        pix_y, pix_x = np.where(~self.exclusion_mask_ref.data)
+        exclusion_mask = ReflectedRegionsFinder._exclusion_mask_ref(
+            reference_geom, exclusion_mask,
+        )
+        pix_y, pix_x = np.nonzero(~exclusion_mask.data)
         return PixCoord(pix_x, pix_y)
 
-    @lazyproperty
-    def angle_min(self):
-        """Minimum angle"""
+    def _get_angle_range(self, region, reference_geom, center_pix):
+        """Minimum and maximum angle"""
+        region_angular_size = self._region_angular_size(
+            region=region, reference_geom=reference_geom, center_pix=center_pix
+        )
         # Minimum angle a region has to be moved to not overlap with previous one
         # Add required minimal distance between two off regions
-        return self.region_angular_size + self.min_distance
-
-    @lazyproperty
-    def angle_max(self):
-        """Maximum angle"""
-        return Angle("360deg") - self.angle_min - self.min_distance_input
+        angle_min = region_angular_size + self.min_distance
+        angle_max =  FULL_CIRCLE - angle_min - self.min_distance_input
+        return angle_min, angle_max
 
-    def reset_cache(self):
-        """Reset cached properties"""
-        for name, value in self.__class__.__dict__.items():
-            if isinstance(value, lazyproperty):
-                self.__dict__.pop(name, None)
-
-    def run(self):
+    def run(self, region, center, exclusion_mask=None):
         """Find reflected regions.
 
+        Parameters
+        ----------
+        region : `~regions.SkyRegion`
+            Region to rotate
+        center : `~astropy.coordinates.SkyCoord`
+            Rotation point
+
         Returns
         -------
         regions : list of `SkyRegion`
             Reflected regions
+        wcs: `~astropy.wcs.WCS`
+            WCS for the determined regions
         """
-        self.reset_cache()
         regions = []
 
-        angle = self.angle_min + self.min_distance_input
+        reference_geom = self._create_reference_geometry(region, center)
+        center_pixel = self._get_center_pixel(center, reference_geom)
+        angle_min, angle_max = self._get_angle_range(
+            region=region, reference_geom=reference_geom, center_pix=center_pixel,
+        )
 
-        while angle < self.angle_max:
-            region_test = self.region_pix.rotate(self.center_pix, angle)
+        region_pix = self._get_region_pixels(region, reference_geom)
+        excluded_pixels = self._get_excluded_pixels(reference_geom, exclusion_mask)
 
-            if not np.any(region_test.contains(self.excluded_pix_coords)):
-                region = region_test.to_sky(self.geom_ref.wcs)
+        angle = angle_min + self.min_distance_input
+        while angle < angle_max:
+            region_test = region_pix.rotate(center_pixel, angle)
+
+            if not np.any(region_test.contains(excluded_pixels)):
+                region = region_test.to_sky(reference_geom.wcs)
                 regions.append(region)
 
                 if len(regions) >= self.max_region_number:
                     break
 
-                angle += self.angle_min
+                angle += angle_min
             else:
                 angle += self.angle_increment
 
-        return regions
+        return regions, reference_geom.wcs
 
 
 class ReflectedRegionsBackgroundMaker(Maker):
     """Reflected regions background maker.
 
-    Parameters
+    Attributes
     ----------
-    angle_increment : `~astropy.coordinates.Angle`, optional
-        Rotation angle applied when a region falls in an excluded region.
-    min_distance : `~astropy.coordinates.Angle`, optional
-        Minimal distance between two consecutive reflected regions
-    min_distance_input : `~astropy.coordinates.Angle`, optional
-        Minimal distance from input region
-    max_region_number : int, optional
-        Maximum number of regions to use
+    regions_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
         Exclusion mask
-    binsz : `~astropy.coordinates.Angle`
-        Bin size of the reference map used for region finding.
     """
 
     tag = "ReflectedRegionsBackgroundMaker"
 
     def __init__(
         self,
-        angle_increment="0.1 rad",
-        min_distance="0 rad",
-        min_distance_input="0.1 rad",
-        max_region_number=10000,
+        region_finder=None,
         exclusion_mask=None,
-        binsz="0.01 deg",
+        **kwargs,
     ):
-        self.binsz = binsz
+
+        if exclusion_mask and not exclusion_mask.is_mask:
+            raise ValueError("Exclusion mask must contain boolean values")
+
         self.exclusion_mask = exclusion_mask
-        self.angle_increment = Angle(angle_increment)
-        self.min_distance = Angle(min_distance)
-        self.min_distance_input = Angle(min_distance_input)
-        self.max_region_number = max_region_number
 
-    def _get_finder(self, dataset, observation):
-        return ReflectedRegionsFinder(
-            binsz=self.binsz,
-            exclusion_mask=self.exclusion_mask,
-            center=observation.pointing_radec,
-            region=dataset.counts.geom.region,
-            min_distance=self.min_distance,
-            min_distance_input=self.min_distance_input,
-            max_region_number=self.max_region_number,
-            angle_increment=self.angle_increment,
-        )
+        if region_finder is None:
+            self.region_finder = ReflectedRegionsFinder(**kwargs)
+        else:
+            if len(kwargs) != 0:
+                raise ValueError('No kwargs can be given if providing a region_finder')
+            self.region_finder = region_finder
 
     def make_counts_off(self, dataset, observation):
         """Make off counts.
@@ -300,23 +307,24 @@ def make_counts_off(self, dataset, observation):
                 geom=dataset.counts.geom,
                 rad_max=observation.rad_max,
                 events=observation.events,
-                binsz=self.binsz,
+                region_finder=self.region_finder,
                 exclusion_mask=self.exclusion_mask,
-                min_distance=self.min_distance,
-                min_distance_input=self.min_distance_input,
-                max_region_number=self.max_region_number,
-                angle_increment=self.angle_increment,
             )
 
         else:
-            finder = self._get_finder(dataset, observation)
-            regions = finder.run()
+            regions, wcs = self.region_finder.run(
+                center=observation.pointing_radec,
+                region=dataset.counts.geom.region,
+                exclusion_mask=self.exclusion_mask,
+            )
 
             energy_axis = dataset.counts.geom.axes["energy"]
 
             if len(regions) > 0:
                 geom = RegionGeom.from_regions(
-                    regions=regions, axes=[energy_axis], wcs=finder.geom_ref.wcs
+                    regions=regions,
+                    axes=[energy_axis],
+                    wcs=wcs,
                 )
 
                 counts_off = RegionNDMap.from_geom(geom=geom)
diff --git a/gammapy/makers/utils.py b/gammapy/makers/utils.py
--- a/gammapy/makers/utils.py
+++ b/gammapy/makers/utils.py
@@ -494,12 +494,8 @@ def make_counts_off_rad_max(
     geom,
     rad_max,
     events,
-    binsz,
-    exclusion_mask,
-    min_distance,
-    min_distance_input,
-    max_region_number,
-    angle_increment,
+    region_finder,
+    exclusion_mask=None,
 ):
     """Extract the OFF counts and the ON / OFF acceptance considering for the
     sizes of the ON and OFF regions the values in the `RAD_MAX_2D` table.
@@ -508,24 +504,13 @@ def make_counts_off_rad_max(
 
     Parameters
     ----------
-    geom : `~gammapy.maps.RegionGeom`
+    geom: `~gammapy.maps.RegionGeom`
         reference map geom
-    rad_max : `~gammapy.irf.RadMax2D`
+    rad_max: `~gammapy.irf.RadMax2D`
         the RAD_MAX_2D table IRF
-    events : `~gammapy.data.EventList`
+    events: `~gammapy.data.EventList`
         event list to be used to compute the OFF counts
-    binsz : `~astropy.coordinates.Angle`
-        Bin size of the reference map used for region finding.
-    exclusion_mask : `~gammapy.maps.WcsNDMap`, optional
-        Exclusion mask
-    min_distance : `~astropy.coordinates.Angle`, optional
-        Minimal distance between two consecutive reflected regions
-    min_distance_input : `~astropy.coordinates.Angle`, optional
-        Minimal distance from input region
-    max_region_number : int, optional
-        Maximum number of regions to use
-    angle_increment : `~astropy.coordinates.Angle`, optional
-        Rotation angle applied when a region falls in an excluded region
+    region_finder: `~gammapy.makers.background.reflected.RegionFinder`
 
     Returns
     -------
@@ -534,8 +519,6 @@ def make_counts_off_rad_max(
     acceptance_off : `~gammapy.maps.RegionNDMap`
         ratio of the acceptances of the OFF to ON regions.
     """
-    from .background import ReflectedRegionsFinder
-
     rad_max = get_rad_max_vs_energy(rad_max, events.pointing_radec, geom)
 
     counts_off_list = []
@@ -547,26 +530,21 @@ def make_counts_off_rad_max(
         on_region = CircleSkyRegion(center=geom.center_skydir, radius=rad)
         energy_range = geom.axes["energy"].slice(i)
 
-        finder = ReflectedRegionsFinder(
-            binsz=binsz,
-            exclusion_mask=exclusion_mask,
+        regions, wcs = region_finder.run(
             center=events.pointing_radec,
             region=on_region,
-            min_distance=min_distance,
-            min_distance_input=min_distance_input,
-            max_region_number=max_region_number,
-            angle_increment=angle_increment,
+            exclusion_mask=exclusion_mask,
         )
-        regions = finder.run()
 
         if len(regions) > 0:
             off_region_geom = RegionGeom.from_regions(
-                regions=regions, axes=[energy_range], wcs=finder.geom_ref.wcs
+                regions=regions,
+                axes=[energy_range],
+                wcs=wcs,
             )
             counts_off = RegionNDMap.from_geom(geom=off_region_geom)
             counts_off.fill_events(events)
             counts_off_list.append(counts_off.data[0])
-
         else:
             log.warning(
                 f"ReflectedRegionsBackgroundMaker failed in estimated energy bin {i}."


Test patch:
diff --git a/gammapy/makers/background/tests/test_reflected.py b/gammapy/makers/background/tests/test_reflected.py
--- a/gammapy/makers/background/tests/test_reflected.py
+++ b/gammapy/makers/background/tests/test_reflected.py
@@ -54,7 +54,11 @@ def observations():
 
 @pytest.fixture()
 def reflected_bkg_maker(exclusion_mask):
-    return ReflectedRegionsBackgroundMaker(exclusion_mask=exclusion_mask)
+    finder = ReflectedRegionsFinder()
+    return ReflectedRegionsBackgroundMaker(
+        region_finder=finder,
+        exclusion_mask=exclusion_mask,
+    )
 
 
 region_finder_param = [
@@ -73,29 +77,33 @@ def test_find_reflected_regions(
 ):
     pointing = pointing_pos
     finder = ReflectedRegionsFinder(
-        center=pointing,
-        region=on_region,
-        exclusion_mask=exclusion_mask,
         min_distance_input="0 deg",
     )
-    regions = finder.run()
+    regions, _ = finder.run(
+        center=pointing, region=on_region,
+        exclusion_mask=exclusion_mask,
+    )
     assert len(regions) == nreg1
     assert_quantity_allclose(regions[3].center.icrs.ra, reg3_ra, rtol=1e-2)
 
     # Test without exclusion
-    finder.exclusion_mask = None
-    regions = finder.run()
+    regions, _ = finder.run(center=pointing, region=on_region)
     assert len(regions) == nreg2
 
     # Test with too small exclusion
     small_mask = exclusion_mask.cutout(pointing, Angle("0.1 deg"))
-    finder.exclusion_mask = small_mask
-    regions = finder.run()
+    regions, _ = finder.run(
+        center=pointing, region=on_region,
+        exclusion_mask=small_mask,
+    )
     assert len(regions) == nreg3
 
     # Test with maximum number of regions
     finder.max_region_number = 5
-    regions = finder.run()
+    regions, _ = finder.run(
+        center=pointing, region=on_region,
+        exclusion_mask=small_mask,
+    )
     assert len(regions) == 5
 
     # Test with an other type of region
@@ -107,9 +115,11 @@ def test_find_reflected_regions(
         outer_height=0.6 * u.deg,
         angle=130 * u.deg,
     )
-    finder.region = on_ellipse_annulus
-    finder.reference_map = None
-    regions = finder.run()
+    regions, _ = finder.run(
+        region=on_ellipse_annulus,
+        center=pointing,
+        exclusion_mask=small_mask,
+    )
     assert len(regions) == 5
 
 
@@ -129,9 +139,9 @@ def test_non_circular_regions(region, nreg):
     pointing = SkyCoord(0.0, 0.0, unit="deg")
 
     finder = ReflectedRegionsFinder(
-        center=pointing, region=region, min_distance_input="0 deg"
+        min_distance_input="0 deg"
     )
-    regions = finder.run()
+    regions, _ = finder.run(center=pointing, region=region)
     assert len(regions) == nreg
 
 
@@ -139,12 +149,13 @@ def test_non_circular_regions(region, nreg):
 def test_bad_on_region(exclusion_mask, on_region):
     pointing = SkyCoord(83.63, 22.01, unit="deg", frame="icrs")
     finder = ReflectedRegionsFinder(
+        min_distance_input="0 deg",
+    )
+    regions, _ = finder.run(
         center=pointing,
         region=on_region,
         exclusion_mask=exclusion_mask,
-        min_distance_input="0 deg",
     )
-    regions = finder.run()
     assert len(regions) == 0
 
 
diff --git a/gammapy/makers/tests/test_spectrum.py b/gammapy/makers/tests/test_spectrum.py
--- a/gammapy/makers/tests/test_spectrum.py
+++ b/gammapy/makers/tests/test_spectrum.py
@@ -84,7 +84,8 @@ def reflected_regions_bkg_maker():
     exclusion_mask = ~geom.region_mask([exclusion_region])
 
     return ReflectedRegionsBackgroundMaker(
-        exclusion_mask=exclusion_mask, min_distance_input="0.2 deg"
+        exclusion_mask=exclusion_mask,
+        min_distance_input="0.2 deg"
     )