Repository: gammapy/gammapy

Issue:
Improve 3D PSF kernel computation
In a3c9bff38d17504d07cb4f9a5b871194430609cc @robertazanin and added a hack to make the 3D PSF kernel computation work for the CTA example. This is a reminder issue assigned to myself to put in a proper fix and add a test.

The problem is in [EnergyDependentTablePSF.table_psf_in_energy_band](http://docs.gammapy.org/en/latest/_modules/gammapy/irf/psf_table.html#EnergyDependentTablePSF.table_psf_in_energy_band). There, currently the PSF energy binning is used, and for CTA that energy binning is coarse, so not a single energy bin was fully falling into `energy_range` and thus no PSF was computed.

My suggestion is to change the API and implementation of this method like this: add an `n_points` method, and use that in combination with `energy_range` to set up an energy grid, and then to evaluate (i.e. interpolate) the PSF on that energy grid. This would make the computation / result independent of the intrinsic PSF energy grid, and work for any case (very fine or very coarse intrinsic PSF energy binning). Usually the caller decides on the energy grid and should pick energy bins so that the PSF doesn't change drastically.
@adonath - OK or do you  have a different suggestion?
Preference for default `n_points` of 1, 2, 3, 5, 10?


Discussion:
Sounds good to me. Maybe it's possible to make the API consistent with [integrate_spectrum](http://docs.gammapy.org/en/latest/api/gammapy.spectrum.integrate_spectrum.html#gammapy.spectrum.integrate_spectrum), where we used `xmin`, `xmax` and `ndecade`. I don't really care which parametrization to use. I slightly prefer the latter, because `ndecade` allows to set the precision of the integration independent of the energy range...

Reading the code in `EnergyDependentTablePSF.tabel_psf_in_energy_band()` I think there's another problem involved: currently there's only nearest neighbour interpolation in energy (see [EnergyDependentTablePSF._energy_index()](https://github.com/gammapy/gammapy/blob/master/gammapy/irf/psf_table.py#L761)). So the problem is related to https://github.com/gammapy/gammapy/issues/570...  

I'm also not sure what is the best option to offer callers concerning precision. There are use cases where `n_decade` works better and where `n_points` works better (e.g. for cube analysis, where users chose their energy binning so that it's appropriate and exposure, PSF and EDISP don't vary strongly within energy bins).

Some callers will probably need the full power of choosing the energy grid points, so computing the energy grid could be a separate first step, and always also exposed to users via a 
`energy_grid=None` option, and if given `energy_bins` and `n_points` are ignored?

It's a bit tricky how to expose this in a flexible, yet simple way.

> Reading the code in EnergyDependentTablePSF.tabel_psf_in_energy_band() I think there's another problem involved: currently there's only nearest neighbour interpolation in energy (see EnergyDependentTablePSF._energy_index()). So the problem is related to #570...

If we just call `table_psf_at_energy` and that does interpolation in energy (any kind) from `table_psf_in_energy_band`, it's fine, no?
@registerrier - I'm deferring this issue to the v0.9 milestone.

I think the new way to make PSF kernels is mostly [PSFKernel.from_table_psf](http://docs.gammapy.org/dev/api/gammapy.cube.PSFKernel.html#gammapy.cube.PSFKernel.from_table_psf)? Probably what should be done is to add options for spectrum weighting or better integration in energy there?

Note that in Fermi-LAT the PSF (represented by `EnergyDependentTablePSF` in Gammapy) has a 1D exposure array attached. Maybe we should do the same for our reduced IRFs to allow for easy exposure weighting?
I would suggest to add an `energy` argument to the various `to_energy_dependent_table()` that are implemented in `PSF3D` or `EnergyDependentMultiGaussPSF`.

To me spectral weighting of the PSF is only relevant if one performs 2D analysis or wide reco energy bands fitting. This is not the a priori approach for 3D analysis. 

I am not sure I would rely on `PSFKernel.from_table_psf` to do proper energy binning. It should be done at the creation of the `EnergyDependentTablePSF`. For instance, `PSFMap.get_energy_dependent_table_psf`  could have an `energy`  (and even `theta`) optional parameter to use instead of the standard binning.


Resolving patch:
diff --git a/gammapy/irf/irf_reduce.py b/gammapy/irf/irf_reduce.py
--- a/gammapy/irf/irf_reduce.py
+++ b/gammapy/irf/irf_reduce.py
@@ -35,16 +35,9 @@ def make_psf(observation, position, energy=None, rad=None):
     if rad is None:
         rad = observation.psf.to_energy_dependent_table_psf(theta=offset).rad
 
-    if isinstance(observation.psf, PSF3D):
-        # PSF3D is a table PSF, so we use the native RAD binning by default
-        # TODO: should handle this via a uniform caller API
-        psf_value = observation.psf.to_energy_dependent_table_psf(
-            theta=offset
-        ).evaluate(energy)
-    else:
-        psf_value = observation.psf.to_energy_dependent_table_psf(
-            theta=offset, rad=rad
-        ).evaluate(energy)
+    psf_value = observation.psf.to_energy_dependent_table_psf(
+        theta=offset, rad=rad
+    ).evaluate(energy)
 
     arf = observation.aeff.data.evaluate(offset=offset, energy=energy)
     exposure = arf * observation.observation_live_time_duration
diff --git a/gammapy/irf/psf_table.py b/gammapy/irf/psf_table.py
--- a/gammapy/irf/psf_table.py
+++ b/gammapy/irf/psf_table.py
@@ -2,10 +2,11 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 import logging
 import numpy as np
-from scipy.interpolate import UnivariateSpline, RegularGridInterpolator
+from scipy.interpolate import UnivariateSpline
 from astropy.io import fits
 from astropy.units import Quantity
 from astropy.coordinates import Angle, SkyCoord
+from ..utils.interpolation import ScaledRegularGridInterpolator
 from ..utils.gauss import Gauss2DPDF
 from ..utils.scripts import make_path
 from ..utils.array import array_stats_str
@@ -362,10 +363,11 @@ class EnergyDependentTablePSF(object):
         Exposure (1-dim)
     psf_value : `~astropy.units.Quantity`
         PSF (2-dim with axes: psf[energy_index, offset_index]
+    interp_kwargs : dict
+        Interpolation keyword arguments pass to `SCaledRegularGridInterpolator`.
     """
 
-    def __init__(self, energy, rad, exposure=None, psf_value=None):
-
+    def __init__(self, energy, rad, exposure=None, psf_value=None, interp_kwargs=None):
         self.energy = Quantity(energy).to("GeV")
         self.rad = Quantity(rad).to("radian")
         if exposure is None:
@@ -381,6 +383,13 @@ def __init__(self, energy, rad, exposure=None, psf_value=None):
         # Cache for TablePSF at each energy ... only computed when needed
         self._table_psf_cache = [None] * len(self.energy)
 
+        interp_kwargs = interp_kwargs or {}
+        points = (self.energy.value, self.rad.value)
+
+        self._interpolate = ScaledRegularGridInterpolator(
+            points=points, values=self.psf_value, **interp_kwargs
+        )
+
     def __str__(self):
         ss = "EnergyDependentTablePSF\n"
         ss += "-----------------------\n"
@@ -460,65 +469,52 @@ def write(self, *args, **kwargs):
         """
         self.to_fits().writeto(*args, **kwargs)
 
-    def evaluate(self, energy=None, rad=None, interp_kwargs=None):
+    def evaluate(self, energy=None, rad=None, method="linear"):
         """Evaluate the PSF at a given energy and offset
 
         Parameters
         ----------
         energy : `~astropy.units.Quantity`
-            energy value
+            Energy value
         rad : `~astropy.coordinates.Angle`
             Offset wrt source position
-        interp_kwargs : dict
-            option for interpolation for `~scipy.interpolate.RegularGridInterpolator`
+        method : {"linear", "nearest"}
+            Linear or nearest neighbour interpolation.
 
         Returns
         -------
         values : `~astropy.units.Quantity`
             Interpolated value
         """
-        if interp_kwargs is None:
-            interp_kwargs = dict(bounds_error=False, fill_value=None)
-
         if energy is None:
             energy = self.energy
+
         if rad is None:
             rad = self.rad
-        energy = Energy(energy).to("TeV")
-        rad = Angle(rad).to("deg")
-        energy_bin = self.energy.to("TeV")
-        rad_bin = self.rad.to("deg")
-        points = (energy_bin, rad_bin)
-        interpolator = RegularGridInterpolator(
-            points, self.psf_value.value, **interp_kwargs
-        )
-        energy_grid, rad_grid = np.meshgrid(energy.value, rad.value, indexing="ij")
-        shape = energy_grid.shape
-        pix_coords = np.column_stack([energy_grid.flat, rad_grid.flat])
-        data_interp = interpolator(pix_coords)
-        return Quantity(data_interp.reshape(shape), self.psf_value.unit)
 
-    def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs):
+        energy = np.atleast_1d(Quantity(energy, "GeV").value)[:, np.newaxis]
+        rad = np.atleast_1d(Quantity(rad, "rad").value)
+        return self._interpolate((energy, rad), clip=True, method=method)
+
+    def table_psf_at_energy(self, energy, method="linear", **kwargs):
         """Create `~gammapy.irf.TablePSF` at one given energy.
 
         Parameters
         ----------
         energy : `~astropy.units.Quantity`
             Energy
-        interp_kwargs : dict
-            Option for interpolation for `~scipy.interpolate.RegularGridInterpolator`
+        method : {"linear", "nearest"}
+            Linear or nearest neighbour interpolation.
 
         Returns
         -------
         psf : `~gammapy.irf.TablePSF`
             Table PSF
         """
-        psf_value = self.evaluate(energy, None, interp_kwargs)[0, :]
+        psf_value = self.evaluate(energy=energy, method=method)[0, :]
         return TablePSF(self.rad, psf_value, **kwargs)
 
-    def table_psf_in_energy_band(
-        self, energy_band, spectral_index=2, spectrum=None, **kwargs
-    ):
+    def table_psf_in_energy_band(self, energy_band, spectrum=None, n_bins=11, **kwargs):
         """Average PSF in a given energy band.
 
         Expected counts in sub energy bands given the given exposure
@@ -528,58 +524,34 @@ def table_psf_in_energy_band(
         ----------
         energy_band : `~astropy.units.Quantity`
             Energy band
-        spectral_index : float
-            Power law spectral index (used if spectrum=None).
-        spectrum : callable
-            Spectrum (callable with energy as parameter).
+        spectrum : `SpectralModel`
+            Spectral model used for weighting the PSF. Default is a power law
+            with index=2.
+        n_bins : int
+            Number of energy points in the energy band, used to compute the
+            weigthed PSF.
 
         Returns
         -------
         psf : `TablePSF`
             Table PSF
         """
+        from ..spectrum.models import PowerLaw, TableModel
+
         if spectrum is None:
-            # This is a false positive error from pylint
-            # See https://github.com/PyCQA/pylint/issues/2410#issuecomment-415026690
-            def spectrum(energy):  # pylint:disable=function-redefined
-                return (energy / energy_band[0]) ** (-spectral_index)
-
-        # TODO: warn if `energy_band` is outside available data.
-        energy_idx_min, energy_idx_max = self._energy_index(energy_band)
-
-        # TODO: improve this, probably by evaluating the PSF (i.e. interpolating in energy) onto a new energy grid
-        # This is a bit of a hack, but makes sure that a PSF is given, by forcing at least one slice:
-        if energy_idx_max - energy_idx_min < 2:
-            # log.warning('Dubious case of PSF energy binning')
-            # Note that below always range stop of `energy_idx_max - 1` is used!
-            # That's why we put +2 here to make sure we have at least one bin.
-            energy_idx_max = max(energy_idx_min + 2, energy_idx_max)
-            # Make sure we don't step out of the energy array (doesn't help much)
-            energy_idx_max = min(energy_idx_max, len(self.energy))
-
-        # TODO: extract this into a utility function `npred_weighted_mean()`
-
-        # Compute weights for energy bins
-        weights = np.zeros_like(self.energy.value, dtype=np.float64)
-        for idx in range(energy_idx_min, energy_idx_max - 1):
-            energy_min = self.energy[idx]
-            energy_max = self.energy[idx + 1]
-            exposure = self.exposure[idx]
-            flux = spectrum(energy_min)
-            weights[idx] = (exposure * flux * (energy_max - energy_min)).value
-
-        # Normalize weights to sum to 1
-        weights = weights / weights.sum()
-
-        # Compute weighted PSF value array
-        total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64)
-        for idx in range(energy_idx_min, energy_idx_max - 1):
-            psf_value = self._get_1d_psf_values(idx)
-            total_psf_value += weights[idx] * psf_value
-
-        # TODO: add version that returns `total_psf_value` without
-        # making a `TablePSF`.
-        return TablePSF(self.rad, total_psf_value, **kwargs)
+            spectrum = PowerLaw()
+
+        exposure = TableModel(self.energy, self.exposure)
+
+        e_min, e_max = energy_band
+        energy = Energy.equal_log_spacing(emin=e_min, emax=e_max, nbins=n_bins)
+
+        weights = (spectrum * exposure)(energy)
+        weights /= weights.sum()
+
+        psf_value = self.evaluate(energy=energy)
+        psf_value_weighted = (weights[:, np.newaxis] * psf_value)
+        return TablePSF(self.rad, psf_value_weighted.sum(axis=0), **kwargs)
 
     def containment_radius(self, energies, fraction, interp_kwargs=None):
         """Containment radius.


Test patch:
diff --git a/gammapy/cube/tests/test_psf_map.py b/gammapy/cube/tests/test_psf_map.py
--- a/gammapy/cube/tests/test_psf_map.py
+++ b/gammapy/cube/tests/test_psf_map.py
@@ -82,7 +82,7 @@ def test_psfmap(tmpdir):
     psfkernel = psfmap.get_psf_kernel(
         SkyCoord(1, 1, unit="deg"), kern_geom, max_radius=1 * u.deg
     )
-    assert_allclose(psfkernel.psf_kernel_map.data.sum(axis=(1, 2)), 1.0)
+    assert_allclose(psfkernel.psf_kernel_map.data.sum(axis=(1, 2)), 1.0, atol=1e-7)
 
     # test read/write
     filename = str(tmpdir / "psfmap.fits")
diff --git a/gammapy/irf/tests/test_irf_reduce.py b/gammapy/irf/tests/test_irf_reduce.py
--- a/gammapy/irf/tests/test_irf_reduce.py
+++ b/gammapy/irf/tests/test_irf_reduce.py
@@ -8,6 +8,7 @@
 from ...data import DataStore, Observations
 from ...utils.testing import requires_data, assert_quantity_allclose
 from ...utils.energy import Energy, EnergyBounds
+from ...spectrum.models import PowerLaw
 
 
 @pytest.fixture(scope="session")
@@ -49,9 +50,8 @@ def data_store():
             "rad_shape": (1000,),
             "psf_rad": 0.000524,
             "psf_exposure": 3.14711e12,
-            # TODO: should this be psf_value_shape == (32, 1000) ?
-            "psf_value_shape": (32, 144),
-            "psf_value": 4369.96391,
+            "psf_value_shape": (32, 1000),
+            "psf_value": 25888.5047,
         },
         {
             "energy": EnergyBounds.equal_log_spacing(1, 10, 100, "TeV"),
@@ -61,8 +61,8 @@ def data_store():
             "rad_shape": (1000,),
             "psf_rad": 0.000524,
             "psf_exposure": 4.688142e12,
-            "psf_value_shape": (101, 144),
-            "psf_value": 3726.58798,
+            "psf_value_shape": (101, 1000),
+            "psf_value": 22723.879272,
         },
     ],
 )
@@ -101,17 +101,19 @@ def test_make_mean_psf(data_store):
 
     psf1 = make_psf(obs1, position=position, energy=energy, rad=None)
     psf2 = make_psf(obs2, position=position, energy=energy, rad=None)
-    psf1_int = psf1.table_psf_in_energy_band(energy_band, spectral_index=2.3)
-    psf2_int = psf2.table_psf_in_energy_band(energy_band, spectral_index=2.3)
+
+    spectrum = PowerLaw(index=2.3)
+    psf1_int = psf1.table_psf_in_energy_band(energy_band, spectrum=spectrum)
+    psf2_int = psf2.table_psf_in_energy_band(energy_band, spectrum=spectrum)
     observations = Observations([obs1, obs2])
     psf_tot = make_mean_psf(observations, position=position, energy=energy)
-    psf_tot_int = psf_tot.table_psf_in_energy_band(energy_band, spectral_index=2.3)
+    psf_tot_int = psf_tot.table_psf_in_energy_band(energy_band, spectrum=spectrum)
 
     # Check that the mean PSF is consistent with the individual PSFs
     # (in this case the R68 of the mean PSF is in between the R68 of the individual PSFs)
-    assert_allclose(psf1_int.containment_radius(0.68).deg, 0.12307, rtol=1e-3)
-    assert_allclose(psf2_int.containment_radius(0.68).deg, 0.11231, rtol=1e-3)
-    assert_allclose(psf_tot_int.containment_radius(0.68).deg, 0.117803, rtol=1e-3)
+    assert_allclose(psf1_int.containment_radius(0.68).deg, 0.122769, rtol=1e-3)
+    assert_allclose(psf2_int.containment_radius(0.68).deg, 0.111322, rtol=1e-3)
+    assert_allclose(psf_tot_int.containment_radius(0.68).deg, 0.117134, rtol=1e-3)
 
 
 @requires_data("gammapy-extra")
diff --git a/gammapy/irf/tests/test_psf_gauss.py b/gammapy/irf/tests/test_psf_gauss.py
--- a/gammapy/irf/tests/test_psf_gauss.py
+++ b/gammapy/irf/tests/test_psf_gauss.py
@@ -96,8 +96,7 @@ def test_to_table_psf(self, psf):
         theta = 0 * u.deg
 
         table_psf = psf.to_energy_dependent_table_psf(theta)
-        interpol_param = dict(method="nearest", bounds_error=False)
-        table_psf_at_energy = table_psf.table_psf_at_energy(energy, interpol_param)
+        table_psf_at_energy = table_psf.table_psf_at_energy(energy, method="nearest")
         psf_at_energy = psf.psf_at_energy_and_theta(energy, theta)
 
         containment = np.linspace(0, 0.95, 10)