Repository: gammapy/gammapy

Issue:
Ignore NaN values in EnergyDependentTablePSF.table_psf_in_energy_band
Since 8085117f9890cddd4769ef7fee66b5e742393989, `EnergyDependentTablePSF.table_psf_in_energy_band` no longer makes sure the computed psf values are not `nan`. This, I think, is because the psf values are now obtained using `evaluate` rather than `_get_1d_psf_values`, which checked for `nan` values and set them to zero. It can happen that the psf values are `nan` in some energy bins if they are queried at energies with zero exposure. Can we include a similar check in `table_psf_in_energy_band`, to make sure `nan` values are set to zero?


Discussion:
Thanks @lmohrmann for reporting this. I must admit I don't really like this hard-coded handling of `NaN` values, because it silently "fixes" computations, where you might want to know, that they are wrong, unstable or not defined. If possible, I'd like avoid it in Gammapy.

Can you alternatively fix it in your script "in place" using:

```
psf = EnergyDependentTablePSF()
psf.psf_values[np.isnan(psf.psf_values)] = 0
```

If this is a re-occuring problem, because we cannot avoid `NaN` values during the PSF export, than I would suggest to describe this in https://gamma-astro-data-formats.readthedocs.io/en/latest/irfs/irf_components/index.html#point-spread-function along with a proposal how to handle this during the evaluation of the exported PSF.

Does this sound reasonable to you?

Thanks for the quick response, @adonath !

I think I actually agree with you, setting the `nan` values to zero is a bit dangerous. Your proposed work-around works, so that fixes the problem for me. However, we might still want to add a check to the `table_psf_in_energy_band` function, since that produces a PSF with `nan` values only if any of the energy bins contains a `nan`. Then, the `_compute_splines` function that is called at the end of `TablePSF.__init__` crashes with a cryptic error message and it's not clear for the user where the error actually occurs (took me a bit to find it...).

What do you think?
Just to give my 2 cents: NaN values in IRFs, often at low energy, are something that has come up often. We have a guideline [here](https://docs.gammapy.org/0.10/development/howto.html#interpolation-and-extrapolation). I'm not sure how the current Gammapy code compares to that guideline. Initially we wanted to use the "very safe" option, but then moved to the "medium safe" option and I think in many cases even the "least safe". 

The problem is that it's an almost impossible problem: at the energy threshold the AEFF is very low, so very few MC events, so PSF and EDISP get extremely bad. As soon as any analytic function gets involved (like probably the PSF splines here) often NaN values occur. Actually the "least safe" option of  putting any PSF that doesn't result in NaN works well in practice. At least currently no-one in their IRF exporters applies safe cuts on IRF production, so IRFs always arrive with bad PSF and EDISP at low energy. Throwing an error and not giving the user a result is a bit mean. Telling users to add IRF modification hacks to their scripts is also not a good solution (99% of users should execute via high-level scripts or config file a year from now and we don't want to expose IRF fix options there).

@adonath @lmohrmann - proceed as you like here. I just wanted to mention that IMO putting the "least safe" option might be OK and the best in practice. Why exactly does the NaN value arise? Is it really something that @lmohrmann should fix in the IRF producer, or is it "normal" to get such IRFs and users asking for analyses in these energy bands?
Thanks @cdeil for the additional information. I completely agree that it is not an option to leave it up to the user to fix this issue.

Speaking only for the Gammapy side, the ideal solution would be if the IRFs were noise free and well behaved (in the sense, that they can be interpolated and possibly extrapolated, not necessarily correct, but in a numerically stable way). I guess this also means, that they are not supposed to
contain `NaN` values either (at least as long as there is no definition of a "valid range"). This is also what is stated here: https://gamma-astro-data-formats.readthedocs.io/en/latest/irfs/irf_components/index.html#comments. I agree with this statement and would prefer if this responsibility was in the hands of the software, that produces the IRFs (i.e. exporters and ctapipe).

@lmohrmann As the code responsibilities for IRF production are not defined 100% yet, I'd be open to have some code in Gammapy  to post-process (fix `NaN`s , smoothing etc.) PSF histograms. Ideally it would be just a single function or class that is used in one place (maybe on `PSF3D.read()`). This avoids that we have to fix `NaN` values and interpolation / extrapolation behaviour in many places and can be easily moved later e.g. to ctapipe, once the responsibilities are clear.

I've actually thought a little bit about the problem of PSF denoising yesterday and wrote up an idea (see https://github.com/adonath/notebooks-public/blob/master/psf-denoising.ipynb). Maybe it's useful... 


Thanks for looking into this, @adonath.

Your notebook on PSF denoising looks very interesting, seems to be a very promising idea!

I also agree with what you wrote about the quality of the IRFs - this should be the responsibility of the producers, but I guess at least at the moment we need to assume that the IRFs are not perfect and certainly not denoised.

Coming back to this particular example: in this case the `NaN` values were not part of the exported PSF but were produced by `irf.irf_reduce.make_mean_psf`, here:
https://github.com/gammapy/gammapy/blob/b01eaa076f0e54e0b2f659c4947662ba6ff21e07/gammapy/irf/irf_reduce.py#L89

So maybe the first thing to do is fix that line, since it's perfectly possible that the exposure is zero, no? Whether we want to add checks for the IRFs as they are read in directly is another matter then.
@lmohrmann I agree this line should be changed, it's bad that the `make_mean_psf` method creates `NaN` values, when there haven't been any before. I'll prepare a PR...


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
@@ -70,26 +70,13 @@ def make_mean_psf(observations, position, energy=None, rad=None):
     psf : `~gammapy.irf.EnergyDependentTablePSF`
         Mean PSF
     """
-    psf = make_psf(observations[0], position, energy, rad)
-
-    if rad is None:
-        rad = psf.rad
-    if energy is None:
-        energy = psf.energy
-
-    exposure = psf.exposure
-    psf_value = psf.psf_value.T * psf.exposure
-
-    for obs in observations[1:]:
-        psf = make_psf(obs, position, energy, rad)
-        exposure += psf.exposure
-        psf_value += psf.psf_value.T * psf.exposure
-
-    psf_value /= exposure
-    psf_tot = EnergyDependentTablePSF(
-        energy=energy, rad=rad, exposure=exposure, psf_value=psf_value.T
-    )
-    return psf_tot
+    for idx, observation in enumerate(observations):
+        psf = make_psf(observation, position, energy, rad)
+        if idx == 0:
+            stacked_psf = psf
+        else:
+            stacked_psf = stacked_psf.stack(psf)
+    return stacked_psf
 
 
 def make_mean_edisp(
diff --git a/gammapy/irf/psf_3d.py b/gammapy/irf/psf_3d.py
--- a/gammapy/irf/psf_3d.py
+++ b/gammapy/irf/psf_3d.py
@@ -305,31 +305,15 @@ def containment_radius(
         radius : `~astropy.units.Quantity`
             Containment radius in deg
         """
-        energy = Quantity(energy)
-        if energy.ndim == 0:
-            energy = Quantity([energy.value], energy.unit)
+        energy = np.atleast_1d(Quantity(energy))
+        theta = np.atleast_1d(Quantity(theta))
 
-        theta = Angle(theta)
-        if theta.ndim == 0:
-            theta = Quantity([theta.value], theta.unit)
-
-        unit = None
-        radius = np.zeros((energy.size, theta.size))
-        for e in range(energy.size):
-            for t in range(theta.size):
-                try:
-                    psf = self.to_table_psf(energy[e], theta[t], interp_kwargs)
-                except:
-                    # This can raise an `error` from scipy UnivariateSpline:
-                    # error: (xb<=x[0]) failed for 2nd keyword xb: fpcurf0:xb=nan
-                    # Not sure what type exactly or how to catch it.
-                    radius[e, t] = np.nan
-                    continue
-                r = psf.containment_radius(fraction)
-                radius[e, t] = r.value
-                unit = r.unit
-
-        return Quantity(radius.squeeze(), unit)
+        radii = []
+        for t in theta:
+            psf = self.to_energy_dependent_table_psf(theta=t)
+            radii.append(psf.containment_radius(energy, fraction=fraction))
+
+        return Quantity(radii).T.squeeze()
 
     def plot_containment_vs_energy(
         self, fractions=[0.68, 0.95], thetas=Angle([0, 1], "deg"), ax=None
@@ -344,7 +328,7 @@ def plot_containment_vs_energy(
 
         for theta in thetas:
             for fraction in fractions:
-                radius = self.containment_radius(energy, theta, fraction).squeeze()
+                radius = self.containment_radius(energy, theta, fraction)
                 label = "{} deg, {:.1f}%".format(theta, 100 * fraction)
                 ax.plot(energy.value, radius.value, label=label)
 
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
@@ -3,7 +3,7 @@
 import numpy as np
 from scipy.interpolate import UnivariateSpline
 from astropy.io import fits
-from astropy.units import Quantity
+from astropy import units as u
 from astropy.coordinates import Angle, SkyCoord
 from ..utils.interpolation import ScaledRegularGridInterpolator
 from ..utils.gauss import Gauss2DPDF
@@ -59,7 +59,7 @@ class TablePSF:
     def __init__(self, rad, dp_domega, spline_kwargs=DEFAULT_PSF_SPLINE_KWARGS):
 
         self._rad = Angle(rad).to("radian")
-        self._dp_domega = Quantity(dp_domega).to("sr^-1")
+        self._dp_domega = u.Quantity(dp_domega).to("sr^-1")
 
         assert self._rad.ndim == self._dp_domega.ndim == 1
         assert self._rad.shape == self._dp_domega.shape
@@ -110,7 +110,7 @@ def from_shape(cls, shape, width, rad):
         else:
             raise ValueError("Invalid shape: {}".format(shape))
 
-        psf_value = Quantity(psf_value, "sr^-1")
+        psf_value = u.Quantity(psf_value, "sr^-1")
 
         return cls(rad, psf_value)
 
@@ -190,7 +190,7 @@ def evaluate(self, rad, quantity="dp_domega"):
             raise ValueError(ss)
 
         y = np.clip(a=y, a_min=0, a_max=None)
-        return Quantity(y, unit).reshape(shape)
+        return u.Quantity(y, unit).reshape(shape)
 
     def integral(self, rad_min=None, rad_max=None):
         """Compute PSF integral, aka containment fraction.
@@ -252,7 +252,7 @@ def normalize(self):
         # Clip to small positive number to avoid divide by 0
         rad = np.clip(self._rad.radian, 1e-6, None)
 
-        rad = Quantity(rad, "radian")
+        rad = u.Quantity(rad, "radian")
         self._dp_domega = self._dp_dr / (2 * np.pi * rad)
         self._compute_splines(self._spline_kwargs)
 
@@ -363,30 +363,36 @@ class EnergyDependentTablePSF:
     psf_value : `~astropy.units.Quantity`
         PSF (2-dim with axes: psf[energy_index, offset_index]
     interp_kwargs : dict
-        Interpolation keyword arguments pass to `SCaledRegularGridInterpolator`.
+        Interpolation keyword arguments pass to `ScaledRegularGridInterpolator`.
     """
 
     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")
+        self.energy = u.Quantity(energy).to("GeV")
+        self.rad = u.Quantity(rad).to("radian")
         if exposure is None:
-            self.exposure = Quantity(np.ones(len(energy)), "cm^2 s")
+            self.exposure = u.Quantity(np.ones(len(energy)), "cm^2 s")
         else:
-            self.exposure = Quantity(exposure).to("cm^2 s")
+            self.exposure = u.Quantity(exposure).to("cm^2 s")
 
         if psf_value is None:
-            self.psf_value = Quantity(np.zeros(len(energy), len(rad)), "sr^-1")
+            self.psf_value = u.Quantity(np.zeros(len(energy), len(rad)), "sr^-1")
         else:
-            self.psf_value = Quantity(psf_value).to("sr^-1")
+            self.psf_value = u.Quantity(psf_value).to("sr^-1")
 
-        # Cache for TablePSF at each energy ... only computed when needed
-        self._table_psf_cache = [None] * len(self.energy)
+        self._interpolate = self._setup_interpolator(self.psf_value, interp_kwargs)
 
+    @property
+    def _rad_axis(self):
+        from ..maps import MapAxis
+
+        rad_axis = MapAxis.from_nodes(self.rad.to_value("deg"), unit="deg")
+        return rad_axis
+
+    def _setup_interpolator(self, values, interp_kwargs=None):
         interp_kwargs = interp_kwargs or {}
         points = (self.energy.value, self.rad.value)
-
-        self._interpolate = ScaledRegularGridInterpolator(
-            points=points, values=self.psf_value, **interp_kwargs
+        return ScaledRegularGridInterpolator(
+            points=points, values=values, **interp_kwargs
         )
 
     def __str__(self):
@@ -395,16 +401,12 @@ def __str__(self):
         ss += "\nAxis info:\n"
         ss += "  " + array_stats_str(self.rad.to("deg"), "rad")
         ss += "  " + array_stats_str(self.energy, "energy")
-        # ss += '  ' + array_stats_str(self.exposure, 'exposure')
-
-        # ss += 'integral = {}\n'.format(self.integral())
-
         ss += "\nContainment info:\n"
         # Print some example containment radii
         fractions = [0.68, 0.95]
-        energies = Quantity([10, 100], "GeV")
+        energies = u.Quantity([10, 100], "GeV")
         for fraction in fractions:
-            rads = self.containment_radius(energies=energies, fraction=fraction)
+            rads = self.containment_radius(energy=energies, fraction=fraction)
             for energy, rad in zip(energies, rads):
                 ss += "  " + "{}% containment radius at {:3.0f}: {:.2f}\n".format(
                     100 * fraction, energy, rad
@@ -421,9 +423,9 @@ def from_fits(cls, hdu_list):
             HDU list with ``THETA`` and ``PSF`` extensions.
         """
         rad = Angle(hdu_list["THETA"].data["Theta"], "deg")
-        energy = Quantity(hdu_list["PSF"].data["Energy"], "MeV")
-        exposure = Quantity(hdu_list["PSF"].data["Exposure"], "cm^2 s")
-        psf_value = Quantity(hdu_list["PSF"].data["PSF"], "sr^-1")
+        energy = u.Quantity(hdu_list["PSF"].data["Energy"], "MeV")
+        exposure = u.Quantity(hdu_list["PSF"].data["Exposure"], "cm^2 s")
+        psf_value = u.Quantity(hdu_list["PSF"].data["PSF"], "sr^-1")
 
         return cls(energy, rad, exposure, psf_value)
 
@@ -491,8 +493,8 @@ def evaluate(self, energy=None, rad=None, method="linear"):
         if rad is None:
             rad = self.rad
 
-        energy = np.atleast_1d(Quantity(energy, "GeV").value)[:, np.newaxis]
-        rad = np.atleast_1d(Quantity(rad, "rad").value)
+        energy = np.atleast_1d(u.Quantity(energy, "GeV").value)[:, np.newaxis]
+        rad = np.atleast_1d(u.Quantity(rad, "rad").value)
         return self._interpolate((energy, rad), clip=True, method=method)
 
     def table_psf_at_energy(self, energy, method="linear", **kwargs):
@@ -552,92 +554,108 @@ def table_psf_in_energy_band(self, energy_band, spectrum=None, n_bins=11, **kwar
         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):
+    def containment_radius(self, energy, fraction=0.68):
         """Containment radius.
 
         Parameters
         ----------
-        energies : `~astropy.units.Quantity`
+        energy : `~astropy.units.Quantity`
             Energy
         fraction : float
-            Containment fraction in %
+            Containment fraction.
 
         Returns
         -------
         rad : `~astropy.units.Quantity`
             Containment radius in deg
         """
-        # TODO: figure out if there's a more efficient implementation to support
-        # arrays of energy
-        energies = np.atleast_1d(energies)
-        psfs = [self.table_psf_at_energy(energy, interp_kwargs) for energy in energies]
-        rad = [psf.containment_radius(fraction) for psf in psfs]
-        return Quantity(rad)
+        # oversamle for better precision
+        rad_max = np.linspace(0, self.rad[-1].to_value("deg"), 10 * len(self.rad)) * u.deg
+        containment = self.containment(energy=energy, rad_max=rad_max)
 
-    def integral(self, energy, rad_min, rad_max):
-        """Containment fraction.
+        # find nearest containment value
+        fraction_idx = np.argmin(np.abs(containment - fraction), axis=1)
+        return rad_max[fraction_idx].to("deg")
+
+    def containment(self, energy, rad_max):
+        """Compute containment of the PSF.
 
         Parameters
         ----------
         energy : `~astropy.units.Quantity`
             Energy
-        rad_min, rad_max : `~astropy.coordinates.Angle`
-            Offset
+        rad_max : `~astropy.coordinates.Angle`
+            Maximum offset angle.
 
         Returns
         -------
         fraction : array_like
             Containment fraction (in range 0 .. 1)
         """
-        # TODO: useless at the moment ... support array inputs or remove!
+        dx = np.diff(self._rad_axis.edges)
+        y = (self.rad * self.psf_value).to_value("deg-1")
+        integral = (y * dx).cumsum(axis=1)
 
-        psf = self.table_psf_at_energy(energy)
-        return psf.integral(rad_min, rad_max)
+        with np.errstate(divide="ignore", invalid="ignore"):
+            integral /= integral[:, [-1]]
+
+        interp_integral = self._setup_interpolator(
+            integral, interp_kwargs={"fill_value": 1}
+        )
+
+        energy = np.atleast_1d(u.Quantity(energy, "GeV").value)[:, np.newaxis]
+        rad_max = np.atleast_1d(u.Quantity(rad_max, "rad").value)
+        return interp_integral((energy, rad_max))
 
     def info(self):
         """Print basic info"""
         print(str(self))
 
-    def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]):
+    def plot_psf_vs_rad(self, energies=None, ax=None, **kwargs):
         """Plot PSF vs radius.
 
         Parameters
         ----------
-        TODO
+        energy : `~astropy.units.Quantity`
+            Energies where to plot the PSF.
+        **kwargs : dict
+            Keyword arguments pass to `~matplotlib.pyplot.plot`.
         """
         import matplotlib.pyplot as plt
 
-        plt.figure(figsize=(6, 4))
+        if energies is None:
+            energies = [100, 1000, 10000] * u.GeV
+
+        ax = plt.gca() if ax is None else ax
 
         for energy in energies:
-            energy_index = self._energy_index(energy)
-            psf = self.psf_value[energy_index, :]
-            label = "{} GeV".format(1e-3 * energy)
-            x = np.hstack([-self.rad[::-1], self.rad])
-            y = 1e-6 * np.hstack([psf[::-1], psf])
-            plt.plot(x, y, lw=2, label=label)
-        # plt.semilogy()
-        # plt.loglog()
+            psf_value = np.squeeze(self.evaluate(energy=energy))
+            label = "{:.0f}".format(energy)
+            ax.plot(
+                self.rad.to_value("deg"),
+                psf_value.to_value("sr-1"),
+                label=label,
+                **kwargs
+            )
+
+        ax.set_yscale("log")
+        ax.set_xlabel("Offset (deg)")
+        ax.set_ylabel("PSF (1 / sr)")
         plt.legend()
-        plt.xlim(-0.2, 0.5)
-        plt.xlabel("Offset (deg)")
-        plt.ylabel("PSF (1e-6 sr^-1)")
-        plt.tight_layout()
+        return ax
 
     def plot_containment_vs_energy(
-        self, ax=None, fractions=[0.63, 0.8, 0.95], **kwargs
+        self, ax=None, fractions=[0.68, 0.8, 0.95], **kwargs
     ):
         """Plot containment versus energy."""
         import matplotlib.pyplot as plt
 
         ax = plt.gca() if ax is None else ax
 
-        energy = Energy.equal_log_spacing(self.energy.min(), self.energy.max(), 10)
-
         for fraction in fractions:
-            rad = self.containment_radius(energy, fraction)
+            rad = self.containment_radius(self.energy, fraction)
             label = "{:.1f}% Containment".format(100 * fraction)
-            ax.plot(energy.value, rad.value, label=label, **kwargs)
+            ax.plot(self.energy.value, rad.value, label=label, **kwargs)
 
         ax.semilogx()
         ax.legend(loc="best")
@@ -657,47 +675,27 @@ def plot_exposure_vs_energy(self):
         plt.ylim(0, 1.5e11)
         plt.tight_layout()
 
-    def _energy_index(self, energy):
-        """Find energy array index.
-        """
-        # TODO: test with array input
-        return np.searchsorted(self.energy, energy)
-
-    def _get_1d_psf_values(self, energy_index):
-        """Get 1-dim PSF value array.
+    def stack(self, psf):
+        """Stack two EnergyDependentTablePSF objects.s
 
         Parameters
         ----------
-        energy_index : int
-            Energy index
+        psf : `EnergyDependentTablePSF`
+            PSF to stack.
 
         Returns
         -------
-        psf_values : `~astropy.units.Quantity`
-            PSF value array
-        """
-        psf_values = self.psf_value[energy_index, :].flatten().copy()
-        # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes
-        psf_values[np.isnan(psf_values)] = 0
-        return psf_values
-
-    def _get_1d_table_psf(self, energy_index, **kwargs):
-        """Get 1-dim TablePSF (cached).
-
-        Parameters
-        ----------
-        energy_index : int
-            Energy index
+        stacked_psf : `EnergyDependentTablePSF`
+            Stacked PSF.
 
-        Returns
-        -------
-        table_psf : `TablePSF`
-            Table PSF
         """
-        # TODO: support array_like `energy_index` here?
-        if self._table_psf_cache[energy_index] is None:
-            psf_value = self._get_1d_psf_values(energy_index)
-            table_psf = TablePSF(self.rad, psf_value, **kwargs)
-            self._table_psf_cache[energy_index] = table_psf
+        exposure = self.exposure + psf.exposure
+        psf_value = self.psf_value.T * self.exposure + psf.psf_value.T * psf.exposure
+
+        with np.errstate(invalid="ignore"):
+            # exposure can be zero
+            psf_value = np.nan_to_num(psf_value / exposure)
 
-        return self._table_psf_cache[energy_index]
+        return self.__class__(
+            energy=self.energy, rad=self.rad, psf_value=psf_value.T, exposure=exposure
+        )


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
@@ -74,7 +74,9 @@ def make_test_psfmap(size, shape="gauss"):
 
     pointing = SkyCoord(0, 0, unit="deg")
     energy_axis = MapAxis(nodes=[0.2, 0.7, 1.5, 2.0, 10.0], unit="TeV", name="energy")
-    rad_axis = MapAxis(nodes=np.linspace(0.0, 0.6, 50), unit="deg", name="theta")
+    rad_axis = MapAxis.from_nodes(
+        nodes=np.linspace(0.0, 0.6, 50), unit="deg", name="theta"
+    )
 
     geom = WcsGeom.create(
         skydir=pointing, binsz=0.2, width=5, axes=[rad_axis, energy_axis]
@@ -93,18 +95,18 @@ def test_psfmap_to_table_psf():
     psfmap = make_test_psfmap(0.15 * u.deg)
     psf = fake_psf3d(0.15 * u.deg)
     # Extract EnergyDependentTablePSF
-    table_psf = psfmap.get_energy_dependent_table_psf(SkyCoord(1, 1, unit="deg"))
+    table_psf = psfmap.get_energy_dependent_table_psf(SkyCoord(0, 0, unit="deg"))
 
     # Check that containment radius is consistent between psf_table and psf3d
     assert_allclose(
         table_psf.containment_radius(1 * u.TeV, 0.9)[0],
-        psf.containment_radius(1 * u.TeV, 0 * u.deg, 0.9),
-        rtol=1e-3,
+        psf.containment_radius(1 * u.TeV, theta=0 * u.deg, fraction=0.9),
+        rtol=1e-2,
     )
     assert_allclose(
         table_psf.containment_radius(1 * u.TeV, 0.5)[0],
-        psf.containment_radius(1 * u.TeV, 0 * u.deg, 0.5),
-        rtol=1e-3,
+        psf.containment_radius(1 * u.TeV, theta=0 * u.deg, fraction=0.5),
+        rtol=1e-2,
     )
 
 
@@ -155,11 +157,10 @@ def test_containment_radius_map(tmpdir):
     )
 
     psfmap = make_psf_map(psf, pointing, geom, 3 * u.deg)
-    m = psfmap.containment_radius_map(2 * u.TeV)
+    m = psfmap.containment_radius_map(1 * u.TeV)
     coord = SkyCoord(0.3, 0, unit="deg")
     val = m.interp_by_coord(coord)
-
-    assert_allclose(val, 0.227463, rtol=1e-3)
+    assert_allclose(val, 0.216275, rtol=1e-3)
 
 
 def test_psfmap_stacking():
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
@@ -93,26 +93,12 @@ def test_make_psf(pars, data_store):
 @requires_data("gammapy-data")
 def test_make_mean_psf(data_store):
     position = SkyCoord(83.63, 22.01, unit="deg")
-    obs1 = data_store.obs(23523)
-    obs2 = data_store.obs(23526)
-    energy = EnergyBounds.equal_log_spacing(1, 10, 100, "TeV")
-    energy_band = Energy([energy[0].value, energy[-1].value], energy.unit)
 
-    psf1 = make_psf(obs1, position=position, energy=energy, rad=None)
-    psf2 = make_psf(obs2, position=position, energy=energy, rad=None)
+    observations = data_store.get_observations([23523, 23526])
+    psf = make_mean_psf(observations, position=position)
 
-    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, 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.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)
+    assert not np.isnan(psf.psf_value.value).any()
+    assert_allclose(psf.psf_value.value[22, 22], 12206.1665)
 
 
 @requires_data("gammapy-data")
diff --git a/gammapy/irf/tests/test_psf_3d.py b/gammapy/irf/tests/test_psf_3d.py
--- a/gammapy/irf/tests/test_psf_3d.py
+++ b/gammapy/irf/tests/test_psf_3d.py
@@ -51,12 +51,12 @@ def test_to_energy_dependent_table_psf(psf_3d):
 @requires_data("gammapy-data")
 def test_psf_3d_containment_radius(psf_3d):
     q = psf_3d.containment_radius(energy="1 TeV")
-    assert_allclose(q.value, 0.171445, rtol=1e-3)
+    assert_allclose(q.value, 0.171025, rtol=1e-2)
     assert q.isscalar
     assert q.unit == "deg"
 
     q = psf_3d.containment_radius(energy=[1, 3] * u.TeV)
-    assert_allclose(q.value, [0.171445, 0.157455], rtol=1e-3)
+    assert_allclose(q.value, [0.171025, 0.155722], rtol=1e-2)
     assert q.shape == (2,)
 
 
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
@@ -94,34 +94,36 @@ def test_to_table_psf(self, psf):
         energy = 1 * u.TeV
         theta = 0 * u.deg
 
-        table_psf = psf.to_energy_dependent_table_psf(theta)
-        table_psf_at_energy = table_psf.table_psf_at_energy(energy, method="nearest")
+        rad = np.linspace(0, 1, 100) * u.deg
+        table_psf = psf.to_energy_dependent_table_psf(theta, rad=rad)
+
         psf_at_energy = psf.psf_at_energy_and_theta(energy, theta)
 
-        containment = np.linspace(0, 0.95, 10)
+        containment = [0.68, 0.8, 0.9]
         desired = [psf_at_energy.containment_radius(_) for _ in containment]
+
+        table_psf_at_energy = table_psf.table_psf_at_energy(energy, method="nearest")
         actual = table_psf_at_energy.containment_radius(containment)
 
-        # TODO: try to improve precision, so that rtol can be lowered
-        assert_allclose(desired, actual.degree, rtol=0.03)
+        assert_allclose(desired, actual.degree, rtol=1e-2)
 
     def test_to_psf3d(self, psf):
-        rads = np.linspace(0.0, 1.0, 301) * u.deg
+        rads = np.linspace(0.0, 1.0, 101) * u.deg
         psf_3d = psf.to_psf3d(rads)
-        assert psf_3d.rad_lo.shape == (300,)
+        assert psf_3d.rad_lo.shape == (100,)
         assert psf_3d.rad_lo.unit == "deg"
 
         theta = 0.5 * u.deg
         energy = 0.5 * u.TeV
 
-        containment = np.linspace(0.1, 0.95, 10)
+        containment = [0.68, 0.8, 0.9]
         desired = np.array(
             [psf.containment_radius(energy, theta, _).value for _ in containment]
         )
         actual = np.array(
             [psf_3d.containment_radius(energy, theta, _).value for _ in containment]
         )
-        assert_allclose(np.squeeze(desired), actual, rtol=0.01)
+        assert_allclose(np.squeeze(desired), actual, atol=0.005)
 
 
 @requires_data("gammapy-data")
diff --git a/gammapy/irf/tests/test_psf_table.py b/gammapy/irf/tests/test_psf_table.py
--- a/gammapy/irf/tests/test_psf_table.py
+++ b/gammapy/irf/tests/test_psf_table.py
@@ -119,11 +119,13 @@ def test_plot(self):
         with mpl_plot_check():
             psf_1GeV.plot_psf_vs_rad()
 
-    # TODO: fix this test (move the code from examples/plot_irfs.py here)
-    @pytest.mark.xfail
     @requires_dependency("matplotlib")
     def test_plot2(self):
         # psf.plot_containment('fermi_psf_containment.pdf')
         # psf.plot_exposure('fermi_psf_exposure.pdf')
         with mpl_plot_check():
             self.psf.plot_psf_vs_rad()
+
+    def test_repr(self):
+        info = str(self.psf)
+        assert "Containment" in info
\ No newline at end of file
diff --git a/gammapy/spectrum/tests/test_extract.py b/gammapy/spectrum/tests/test_extract.py
--- a/gammapy/spectrum/tests/test_extract.py
+++ b/gammapy/spectrum/tests/test_extract.py
@@ -91,6 +91,7 @@ class TestSpectrumExtraction:
             ),
         ],
     )
+    @pytest.mark.xfail
     def test_extract(self, pars, results, observations, bkg_estimate):
         """Test quantitative output for various configs"""
         extraction = SpectrumExtraction(