예제 #1
0
def freq2sb(frequency: u.Quantity):
    r""" Conversion between the frequency :math:`\nu` and the
        NenuFAR sub-band index :math:`n_{\rm SB}`.
        Each NenuFAR sub-band has a bandwidth of
        :math:`\Delta \nu = 195.3125\, \rm{kHz}`:

        .. math::
            n_{\rm SB} = \lfloor*{ \frac{\nu}{\Delta \nu} + \frac{1}{2} \rfloor

        :param frequency:
            Frequency to convert in sub-band index.
        :type frequency:
            :class:`~astropy.units.Quantity`

        :returns:
            Sub-band index, same shape as ``frequency``.
        :rtype:
            `int` or :class:`~numpy.ndarray`

        :example:
            .. code-block:: python
                
                from nenupy.instru import freq2sb
                import astropy.units as u

                freq2sb(frequency=50.5*u.MHz)
                freq2sb(frequency=[50.5, 51]*u.MHz)

    """
    if not isinstance(frequency, u.Quantity):
        raise TypeError(f"`frequency` - {u.Quantity} expected.")
    if (frequency.min() < 0 * u.MHz) or (frequency.max() > 100 * u.MHz):
        raise ValueError("'frequency' should be between 0 and 100 MHz.")
    frequency = frequency.to(u.MHz)
    sb_width = 100. * u.MHz / 512
    sb_idx = np.floor(frequency / sb_width + 0.5)
    return sb_idx.astype(int).value
예제 #2
0
class EnergyDependentTablePSF(object):
    """Energy-dependent radially-symmetric table PSF (``gtpsf`` format).

    TODO: add references and explanations.

    Parameters
    ----------
    energy : `~astropy.units.Quantity`
        Energy (1-dim)
    rad : `~astropy.units.Quantity` with angle units
        Offset angle wrt source position (1-dim)
    exposure : `~astropy.units.Quantity`
        Exposure (1-dim)
    psf_value : `~astropy.units.Quantity`
        PSF (2-dim with axes: psf[energy_index, offset_index]
    """
    def __init__(self, energy, rad, exposure=None, psf_value=None):

        self.energy = Quantity(energy).to("GeV")
        self.rad = Quantity(rad).to("radian")
        if exposure is None:
            self.exposure = Quantity(np.ones(len(energy)), "cm^2 s")
        else:
            self.exposure = Quantity(exposure).to("cm^2 s")

        if psf_value is None:
            self.psf_value = Quantity(np.zeros(len(energy), len(rad)), "sr^-1")
        else:
            self.psf_value = Quantity(psf_value).to("sr^-1")

        # Cache for TablePSF at each energy ... only computed when needed
        self._table_psf_cache = [None] * len(self.energy)

    def __str__(self):
        ss = "EnergyDependentTablePSF\n"
        ss += "-----------------------\n"
        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")
        for fraction in fractions:
            rads = self.containment_radius(energies=energies,
                                           fraction=fraction)
            for energy, rad in zip(energies, rads):
                ss += "  " + "{}% containment radius at {:3.0f}: {:.2f}\n".format(
                    100 * fraction, energy, rad)
        return ss

    @classmethod
    def from_fits(cls, hdu_list):
        """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list.

        Parameters
        ----------
        hdu_list : `~astropy.io.fits.HDUList`
            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")

        return cls(energy, rad, exposure, psf_value)

    def to_fits(self):
        """Convert to FITS HDU list format.

        Returns
        -------
        hdu_list : `~astropy.io.fits.HDUList`
            PSF in HDU list format.
        """
        # TODO: write HEADER keywords as gtpsf

        data = self.rad
        theta_hdu = fits.BinTableHDU(data=data, name="Theta")

        data = [self.energy, self.exposure, self.psf_value]
        psf_hdu = fits.BinTableHDU(data=data, name="PSF")

        hdu_list = fits.HDUList([theta_hdu, psf_hdu])
        return hdu_list

    @classmethod
    def read(cls, filename):
        """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file.

        Parameters
        ----------
        filename : str
            File name
        """
        filename = str(make_path(filename))
        with fits.open(filename, memmap=False) as hdulist:
            psf = cls.from_fits(hdulist)

        return psf

    def write(self, *args, **kwargs):
        """Write to FITS file.

        Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments.
        """
        self.to_fits().writeto(*args, **kwargs)

    def evaluate(self, energy=None, rad=None, interp_kwargs=None):
        """Evaluate the PSF at a given energy and offset

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            energy value
        rad : `~astropy.coordinates.Angle`
            Offset wrt source position
        interp_kwargs : dict
            option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        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):
        """Create `~gammapy.irf.TablePSF` at one given energy.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        interp_kwargs : dict
            Option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        psf : `~gammapy.irf.TablePSF`
            Table PSF
        """
        psf_value = self.evaluate(energy, None, interp_kwargs)[0, :]
        return TablePSF(self.rad, psf_value, **kwargs)

    def table_psf_in_energy_band(self,
                                 energy_band,
                                 spectral_index=2,
                                 spectrum=None,
                                 **kwargs):
        """Average PSF in a given energy band.

        Expected counts in sub energy bands given the given exposure
        and spectrum are used as weights.

        Parameters
        ----------
        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).

        Returns
        -------
        psf : `TablePSF`
            Table PSF
        """
        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)

    def containment_radius(self, energies, fraction, interp_kwargs=None):
        """Containment radius.

        Parameters
        ----------
        energies : `~astropy.units.Quantity`
            Energy
        fraction : float
            Containment fraction in %

        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)

    def integral(self, energy, rad_min, rad_max):
        """Containment fraction.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        rad_min, rad_max : `~astropy.coordinates.Angle`
            Offset

        Returns
        -------
        fraction : array_like
            Containment fraction (in range 0 .. 1)
        """
        # TODO: useless at the moment ... support array inputs or remove!

        psf = self.table_psf_at_energy(energy)
        return psf.integral(rad_min, rad_max)

    def info(self):
        """Print basic info"""
        print(str(self))

    def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]):
        """Plot PSF vs radius.

        Parameters
        ----------
        TODO
        """
        import matplotlib.pyplot as plt

        plt.figure(figsize=(6, 4))

        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()
        plt.legend()
        plt.xlim(-0.2, 0.5)
        plt.xlabel("Offset (deg)")
        plt.ylabel("PSF (1e-6 sr^-1)")
        plt.tight_layout()

    def plot_containment_vs_energy(self,
                                   ax=None,
                                   fractions=[0.63, 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)
            label = "{:.1f}% Containment".format(100 * fraction)
            ax.plot(energy.value, rad.value, label=label, **kwargs)

        ax.semilogx()
        ax.legend(loc="best")
        ax.set_xlabel("Energy (GeV)")
        ax.set_ylabel("Containment radius (deg)")

    def plot_exposure_vs_energy(self):
        """Plot exposure versus energy."""
        import matplotlib.pyplot as plt

        plt.figure(figsize=(4, 3))
        plt.plot(self.energy, self.exposure, color="black", lw=3)
        plt.semilogx()
        plt.xlabel("Energy (MeV)")
        plt.ylabel("Exposure (cm^2 s)")
        plt.xlim(1e4 / 1.3, 1.3 * 1e6)
        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.

        Parameters
        ----------
        energy_index : int
            Energy index

        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

        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

        return self._table_psf_cache[energy_index]
예제 #3
0
파일: psf_table.py 프로젝트: cdeil/gammapy
class EnergyDependentTablePSF(object):
    """Energy-dependent radially-symmetric table PSF (``gtpsf`` format).

    TODO: add references and explanations.

    Parameters
    ----------
    energy : `~astropy.units.Quantity`
        Energy (1-dim)
    rad : `~astropy.units.Quantity` with angle units
        Offset angle wrt source position (1-dim)
    exposure : `~astropy.units.Quantity`
        Exposure (1-dim)
    psf_value : `~astropy.units.Quantity`
        PSF (2-dim with axes: psf[energy_index, offset_index]
    """

    def __init__(self, energy, rad, exposure=None, psf_value=None):

        self.energy = Quantity(energy).to('GeV')
        self.rad = Quantity(rad).to('radian')
        if exposure is None:
            self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s')
        else:
            self.exposure = Quantity(exposure).to('cm^2 s')

        if psf_value is None:
            self.psf_value = Quantity(np.zeros(len(energy), len(rad)), 'sr^-1')
        else:
            self.psf_value = Quantity(psf_value).to('sr^-1')

        # Cache for TablePSF at each energy ... only computed when needed
        self._table_psf_cache = [None] * len(self.energy)

    def __str__(self):
        ss = 'EnergyDependentTablePSF\n'
        ss += '-----------------------\n'
        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')
        for fraction in fractions:
            rads = self.containment_radius(energies=energies, fraction=fraction)
            for energy, rad in zip(energies, rads):
                ss += '  ' + '{}% containment radius at {:3.0f}: {:.2f}\n'.format(100 * fraction, energy, rad)
        return ss

    @classmethod
    def from_fits(cls, hdu_list):
        """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list.

        Parameters
        ----------
        hdu_list : `~astropy.io.fits.HDUList`
            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')

        return cls(energy, rad, exposure, psf_value)

    def to_fits(self):
        """Convert to FITS HDU list format.

        Returns
        -------
        hdu_list : `~astropy.io.fits.HDUList`
            PSF in HDU list format.
        """
        # TODO: write HEADER keywords as gtpsf

        data = self.rad
        theta_hdu = fits.BinTableHDU(data=data, name='Theta')

        data = [self.energy, self.exposure, self.psf_value]
        psf_hdu = fits.BinTableHDU(data=data, name='PSF')

        hdu_list = fits.HDUList([theta_hdu, psf_hdu])
        return hdu_list

    @classmethod
    def read(cls, filename):
        """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file.

        Parameters
        ----------
        filename : str
            File name
        """
        hdu_list = fits.open(filename)
        return cls.from_fits(hdu_list)

    def write(self, *args, **kwargs):
        """Write to FITS file.

        Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments.
        """
        self.to_fits().writeto(*args, **kwargs)

    def evaluate(self, energy=None, rad=None, interp_kwargs=None):
        """Evaluate the PSF at a given energy and offset

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            energy value
        rad : `~astropy.coordinates.Angle`
            Offset wrt source position
        interp_kwargs : dict
            option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        values : `~astropy.units.Quantity`
            Interpolated value
        """
        if interp_kwargs is None:
            interp_kwargs = dict(bounds_error=False, fill_value=None)

        from scipy.interpolate import RegularGridInterpolator
        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, **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):
        """Evaluate the `EnergyOffsetArray` at one given energy.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        interp_kwargs : dict
            Option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        table : `~astropy.table.Table`
            Table with two columns: offset, value
        """
        psf_value = self.evaluate(energy, None, interp_kwargs)[0, :]
        table_psf = TablePSF(self.rad, psf_value, **kwargs)
        return table_psf

    def kernels(self, cube, rad_max, **kwargs):
        """
        Make a set of 2D kernel images, representing the PSF at different energies.

        The kernel image is evaluated on the spatial and energy grid defined by
        the reference sky cube.

        Parameters
        ----------
        cube : `~gammapy.cube.SkyCube`
            Reference sky cube.
        rad_max `~astropy.coordinates.Angle`
            PSF kernel size
        kwargs : dict
            Keyword arguments passed to `EnergyDependentTablePSF.table_psf_in_energy_band()`.

        Returns
        -------
        kernels : list of `~numpy.ndarray`
            List of 2D convolution kernels.
        """
        energies = cube.energies(mode='edges')

        kernels = []
        for emin, emax in zip(energies[:-1], energies[1:]):
            energy_band = Quantity([emin, emax])
            try:
                psf = self.table_psf_in_energy_band(energy_band, **kwargs)
                kernel = psf.kernel(cube.sky_image_ref, rad_max=rad_max)
            except ValueError:
                kernel = np.nan * np.ones((1, 1))  # Dummy, means "no kernel available"
            kernels.append(kernel)
        return kernels

    def table_psf_in_energy_band(self, energy_band, spectral_index=2,
                                 spectrum=None, **kwargs):
        """Average PSF in a given energy band.

        Expected counts in sub energy bands given the given exposure
        and spectrum are used as weights.

        Parameters
        ----------
        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).

        Returns
        -------
        psf : `TablePSF`
            Table PSF
        """
        if spectrum is None:
            def spectrum(energy):
                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)

    def containment_radius(self, energies, fraction, interp_kwargs=None):
        """Containment radius.

        Parameters
        ----------
        energies : `~astropy.units.Quantity`
            Energy
        fraction : float
            Containment fraction in %

        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)

    def integral(self, energy, rad_min, rad_max):
        """Containment fraction.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        rad_min, rad_max : `~astropy.coordinates.Angle`
            Offset

        Returns
        -------
        fraction : array_like
            Containment fraction (in range 0 .. 1)
        """
        # TODO: useless at the moment ... support array inputs or remove!

        psf = self.table_psf_at_energy(energy)
        return psf.integral(rad_min, rad_max)

    def info(self):
        """Print basic info"""
        print(self.__str__)

    def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]):
        """Plot PSF vs radius.

        Parameters
        ----------
        TODO
        """
        import matplotlib.pyplot as plt
        plt.figure(figsize=(6, 4))

        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()
        plt.legend()
        plt.xlim(-0.2, 0.5)
        plt.xlabel('Offset (deg)')
        plt.ylabel('PSF (1e-6 sr^-1)')
        plt.tight_layout()

    def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 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)
            label = '{:.1f}% Containment'.format(100 * fraction)
            ax.plot(energy.value, rad.value, label=label, **kwargs)

        ax.semilogx()
        ax.legend(loc='best')
        ax.set_xlabel('Energy (GeV)')
        ax.set_ylabel('Containment radius (deg)')

    def plot_exposure_vs_energy(self):
        """Plot exposure versus energy."""
        import matplotlib.pyplot as plt
        plt.figure(figsize=(4, 3))
        plt.plot(self.energy, self.exposure, color='black', lw=3)
        plt.semilogx()
        plt.xlabel('Energy (MeV)')
        plt.ylabel('Exposure (cm^2 s)')
        plt.xlim(1e4 / 1.3, 1.3 * 1e6)
        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.

        Parameters
        ----------
        energy_index : int
            Energy index

        Returns
        -------
        psf_values : `~astropy.units.Quantity`
            PSF value array
        """
        psf_values = self.psf_value[energy_index, :].flatten().copy()
        where_are_NaNs = np.isnan(psf_values)
        # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes
        psf_values[where_are_NaNs] = 0
        return psf_values

    def _get_1d_table_psf(self, energy_index, **kwargs):
        """Get 1-dim TablePSF (cached).

        Parameters
        ----------
        energy_index : int
            Energy index

        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

        return self._table_psf_cache[energy_index]
예제 #4
0
class EnergyDependentTablePSF(object):
    """Energy-dependent radially-symmetric table PSF (``gtpsf`` format).

    TODO: add references and explanations.

    Parameters
    ----------
    energy : `~astropy.units.Quantity`
        Energy (1-dim)
    rad : `~astropy.units.Quantity` with angle units
        Offset angle wrt source position (1-dim)
    exposure : `~astropy.units.Quantity`
        Exposure (1-dim)
    psf_value : `~astropy.units.Quantity`
        PSF (2-dim with axes: psf[energy_index, offset_index]
    """

    def __init__(self, energy, rad, exposure=None, psf_value=None):

        self.energy = Quantity(energy).to('GeV')
        self.rad = Quantity(rad).to('radian')
        if not exposure:
            self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s')
        else:
            self.exposure = Quantity(exposure).to('cm^2 s')

        if not psf_value:
            self.psf_value = Quantity(np.zeros(len(energy), len(rad)), 'sr^-1')
        else:
            self.psf_value = Quantity(psf_value).to('sr^-1')

        # Cache for TablePSF at each energy ... only computed when needed
        self._table_psf_cache = [None] * len(self.energy)

    def __str__(self):
        ss = 'EnergyDependentTablePSF\n'
        ss += '-----------------------\n'
        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')
        for fraction in fractions:
            rads = self.containment_radius(energies=energies, fraction=fraction)
            for energy, rad in zip(energies, rads):
                ss += '  ' + '{}% containment radius at {:3.0f}: {:.2f}\n'.format(100 * fraction, energy, rad)
        return ss

    @classmethod
    def from_fits(cls, hdu_list):
        """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list.

        Parameters
        ----------
        hdu_list : `~astropy.io.fits.HDUList`
            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')

        return cls(energy, rad, exposure, psf_value)

    def to_fits(self):
        """Convert to FITS HDU list format.

        Returns
        -------
        hdu_list : `~astropy.io.fits.HDUList`
            PSF in HDU list format.
        """
        # TODO: write HEADER keywords as gtpsf

        data = self.rad
        theta_hdu = fits.BinTableHDU(data=data, name='Theta')

        data = [self.energy, self.exposure, self.psf_value]
        psf_hdu = fits.BinTableHDU(data=data, name='PSF')

        hdu_list = fits.HDUList([theta_hdu, psf_hdu])
        return hdu_list

    @classmethod
    def read(cls, filename):
        """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file.

        Parameters
        ----------
        filename : str
            File name
        """
        hdu_list = fits.open(filename)
        return cls.from_fits(hdu_list)

    def write(self, *args, **kwargs):
        """Write to FITS file.

        Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments.
        """
        self.to_fits().writeto(*args, **kwargs)

    def evaluate(self, energy=None, rad=None, interp_kwargs=None):
        """Evaluate the PSF at a given energy and offset

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            energy value
        rad : `~astropy.coordinates.Angle`
            Offset wrt source position
        interp_kwargs : dict
            option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        values : `~astropy.units.Quantity`
            Interpolated value
        """
        if not interp_kwargs:
            interp_kwargs = dict(bounds_error=False, fill_value=None)

        from scipy.interpolate import RegularGridInterpolator
        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, **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):
        """Evaluate the `EnergyOffsetArray` at one given energy.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        interp_kwargs : dict
            Option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        table : `~astropy.table.Table`
            Table with two columns: offset, value
        """
        psf_value = self.evaluate(energy, None, interp_kwargs)[0, :]
        table_psf = TablePSF(self.rad, psf_value, **kwargs)
        return table_psf

    # TODO: improve this method to work also if there are energy bins where PSF is bad
    # Currently it fails because TablePSF.kernel calls containment, which can be `NaN`.
    # Options:
    # 1. Let the caller specify the kernel size and don't compute containment
    # 2. try-except the TablePSF.kernel call and just return a single pixel with `NaN` as kernel where none is available
    # Don't forget to add a test!
    def kernels(self, cube, **kwargs):
        """
        Make a set of 2D kernel images, representing the PSF at different energies.

        The kernel image is evaluated on the spatial and energy grid defined by
        the reference sky cube.

        Parameters
        ----------
        cube : `~gammapy.cube.SkyCube`
            Reference sky cube.
        kwargs : dict
            Keyword arguments passed to `EnergyDependentTablePSF.table_psf_in_energy_band()`.

        Returns
        -------
        kernels : list of `~numpy.ndarray`
            List of 2D convolution kernels.
        """
        energies = cube.energies(mode='edges')

        kernels = []
        for emin, emax in zip(energies[:-1], energies[1:]):
            energy_band = Quantity([emin, emax])
            try:
                psf = self.table_psf_in_energy_band(energy_band, **kwargs)
                kernel = psf.kernel(cube.sky_image_ref)
            except ValueError:
                kernel = np.nan * np.ones((1, 1))  # Dummy, means "no kernel available"
            kernels.append(kernel)
        return kernels

    def table_psf_in_energy_band(self, energy_band, spectral_index=2,
                                 spectrum=None, **kwargs):
        """Average PSF in a given energy band.

        Expected counts in sub energy bands given the given exposure
        and spectrum are used as weights.

        Parameters
        ----------
        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).

        Returns
        -------
        psf : `TablePSF`
            Table PSF
        """
        if spectrum is None:
            def spectrum(energy):
                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)

    def containment_radius(self, energies, fraction, interp_kwargs=None):
        """Containment radius.

        Parameters
        ----------
        energies : `~astropy.units.Quantity`
            Energy
        fraction : float
            Containment fraction in %

        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)

    def integral(self, energy, rad_min, rad_max):
        """Containment fraction.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        rad_min, rad_max : `~astropy.coordinates.Angle`
            Offset

        Returns
        -------
        fraction : array_like
            Containment fraction (in range 0 .. 1)
        """
        # TODO: useless at the moment ... support array inputs or remove!

        psf = self.table_psf_at_energy(energy)
        return psf.integral(rad_min, rad_max)

    def info(self):
        """Print basic info"""
        print(self.__str__)

    def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]):
        """Plot PSF vs radius.

        Parameters
        ----------
        TODO
        """
        import matplotlib.pyplot as plt
        plt.figure(figsize=(6, 4))

        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()
        plt.legend()
        plt.xlim(-0.2, 0.5)
        plt.xlabel('Offset (deg)')
        plt.ylabel('PSF (1e-6 sr^-1)')
        plt.tight_layout()

    def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 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)
            label = '{:.1f}% Containment'.format(100 * fraction)
            ax.plot(energy.value, rad.value, label=label, **kwargs)

        ax.semilogx()
        ax.legend(loc='best')
        ax.set_xlabel('Energy (GeV)')
        ax.set_ylabel('Containment radius (deg)')

    def plot_exposure_vs_energy(self):
        """Plot exposure versus energy."""
        import matplotlib.pyplot as plt
        plt.figure(figsize=(4, 3))
        plt.plot(self.energy, self.exposure, color='black', lw=3)
        plt.semilogx()
        plt.xlabel('Energy (MeV)')
        plt.ylabel('Exposure (cm^2 s)')
        plt.xlim(1e4 / 1.3, 1.3 * 1e6)
        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.

        Parameters
        ----------
        energy_index : int
            Energy index

        Returns
        -------
        psf_values : `~astropy.units.Quantity`
            PSF value array
        """
        psf_values = self.psf_value[energy_index, :].flatten().copy()
        where_are_NaNs = np.isnan(psf_values)
        # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes
        psf_values[where_are_NaNs] = 0
        return psf_values

    def _get_1d_table_psf(self, energy_index, **kwargs):
        """Get 1-dim TablePSF (cached).

        Parameters
        ----------
        energy_index : int
            Energy index

        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

        return self._table_psf_cache[energy_index]
예제 #5
0
 def _slice_optimize(self, intensity_avg: u.Quantity) -> np.array:
     intensity_avg_norm = intensity_avg / intensity_avg.max(self.axis.time)
     return intensity_avg_norm.mean(self.axis.channel) >= 0.25
예제 #6
0
class EnergyDependentTablePSF(object):
    """Energy-dependent radially-symmetric table PSF (``gtpsf`` format).

    TODO: add references and explanations.

    Parameters
    ----------
    energy : `~astropy.units.Quantity`
        Energy (1-dim)
    offset : `~astropy.units.Quantity` with angle units
        Offset angle (1-dim)
    exposure : `~astropy.units.Quantity`
        Exposure (1-dim)
    psf_value : `~astropy.units.Quantity`
        PSF (2-dim with axes: psf[energy_index, offset_index]
    """

    def __init__(self, energy, offset, exposure=None, psf_value=None):

        self.energy = Quantity(energy).to('GeV')
        self.offset = Quantity(offset).to('radian')
        if not exposure:
            self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s')
        else:
            self.exposure = Quantity(exposure).to('cm^2 s')

        if not psf_value:
            self.psf_value = Quantity(np.zeros(len(energy), len(offset)), 'sr^-1')
        else:
            self.psf_value = Quantity(psf_value).to('sr^-1')

        # Cache for TablePSF at each energy ... only computed when needed
        self._table_psf_cache = [None] * len(self.energy)

    @classmethod
    def from_fits(cls, hdu_list):
        """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list.

        Parameters
        ----------
        hdu_list : `~astropy.io.fits.HDUList`
            HDU list with ``THETA`` and ``PSF`` extensions.
        """
        offset = 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')

        return cls(energy, offset, exposure, psf_value)

    def to_fits(self):
        """Convert to FITS HDU list format.

        Returns
        -------
        hdu_list : `~astropy.io.fits.HDUList`
            PSF in HDU list format.
        """
        # TODO: write HEADER keywords as gtpsf

        data = self.offset
        theta_hdu = fits.BinTableHDU(data=data, name='Theta')

        data = [self.energy, self.exposure, self.psf_value]
        psf_hdu = fits.BinTableHDU(data=data, name='PSF')

        hdu_list = fits.HDUList([theta_hdu, psf_hdu])
        return hdu_list

    @classmethod
    def read(cls, filename):
        """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file.

        Parameters
        ----------
        filename : str
            File name
        """
        hdu_list = fits.open(filename)
        return cls.from_fits(hdu_list)

    def write(self, *args, **kwargs):
        """Write to FITS file.

        Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments.
        """
        self.to_fits().writeto(*args, **kwargs)

    def evaluate(self, energy=None, offset=None,
                 interp_kwargs=None):
        """Interpolate the value of the `EnergyOffsetArray` at a given offset and Energy.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            energy value
        offset : `~astropy.coordinates.Angle`
            offset value
        interp_kwargs : dict
            option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        values : `~astropy.units.Quantity`
            Interpolated value
        """
        if not interp_kwargs:
            interp_kwargs = dict(bounds_error=False, fill_value=None)

        from scipy.interpolate import RegularGridInterpolator
        if energy is None:
            energy = self.energy
        if offset is None:
            offset = self.offset
        energy = Energy(energy).to('TeV')
        offset = Angle(offset).to('deg')
        energy_bin = self.energy.to('TeV')
        offset_bin = self.offset.to('deg')
        points = (energy_bin, offset_bin)
        interpolator = RegularGridInterpolator(points, self.psf_value, **interp_kwargs)
        ee, off = np.meshgrid(energy.value, offset.value, indexing='ij')
        shape = ee.shape
        pix_coords = np.column_stack([ee.flat, off.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):
        """Evaluate the `EnergyOffsetArray` at one given energy.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        interp_kwargs : dict
            Option for interpolation for `~scipy.interpolate.RegularGridInterpolator`

        Returns
        -------
        table : `~astropy.table.Table`
            Table with two columns: offset, value
        """
        psf_value = self.evaluate(energy, None, interp_kwargs)[0, :]
        table_psf = TablePSF(self.offset, psf_value, **kwargs)

        return table_psf

    def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs):
        """Average PSF in a given energy band.

        Expected counts in sub energy bands given the given exposure
        and spectrum are used as weights.

        Parameters
        ----------
        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).

        Returns
        -------
        psf : `TablePSF`
            Table PSF
        """
        if spectrum is None:
            def spectrum(energy):
                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: 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.offset, total_psf_value, **kwargs)

    def containment_radius(self, energies, fraction, interp_kwargs=None):
        """Containment radius.

        Parameters
        ----------
        energies : `~astropy.units.Quantity`
            Energy
        fraction : float
            Containment fraction in %

        Returns
        -------
        radius : `~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]
        radii = [psf.containment_radius(fraction) for psf in psfs]
        return Quantity(radii)

    def integral(self, energy, offset_min, offset_max):
        """Containment fraction.

        Parameters
        ----------
        energy : `~astropy.units.Quantity`
            Energy
        offset_min, offset_max : `~astropy.coordinates.Angle`
            Offset

        Returns
        -------
        fraction : array_like
            Containment fraction (in range 0 .. 1)
        """
        # TODO: useless at the moment ... support array inputs or remove!

        psf = self.table_psf_at_energy(energy)
        return psf.integral(offset_min, offset_max)

    def info(self):
        """Print basic info."""
        # Summarise data members
        ss = array_stats_str(self.offset.to('deg'), 'offset')
        ss += array_stats_str(self.energy, 'energy')
        ss += array_stats_str(self.exposure, 'exposure')

        # ss += 'integral = {0}\n'.format(self.integral())

        # Print some example containment radii
        fractions = [0.68, 0.95]
        energies = Quantity([10, 100], 'GeV')
        for energy in energies:
            for fraction in fractions:
                radius = self.containment_radius(energy=energy, fraction=fraction)
                ss += '{0}% containment radius at {1}: {2}\n'.format(100 * fraction, energy, radius)
        return ss

    def plot_psf_vs_theta(self, filename=None, energies=[1e4, 1e5, 1e6]):
        """Plot PSF vs theta.

        Parameters
        ----------
        TODO
        """
        import matplotlib.pyplot as plt
        plt.figure(figsize=(6, 4))

        for energy in energies:
            energy_index = self._energy_index(energy)
            psf = self.psf_value[energy_index, :]
            label = '{0} GeV'.format(1e-3 * energy)
            x = np.hstack([-self.theta[::-1], self.theta])
            y = 1e-6 * np.hstack([psf[::-1], psf])
            plt.plot(x, y, lw=2, label=label)
        # plt.semilogy()
        # plt.loglog()
        plt.legend()
        plt.xlim(-0.2, 0.5)
        plt.xlabel('Offset (deg)')
        plt.ylabel('PSF (1e-6 sr^-1)')
        plt.tight_layout()

        if filename != None:
            plt.savefig(filename)

    def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 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:
            radius = self.containment_radius(energy, fraction)
            label = '{:.1f}% Containment'.format(100 * fraction)
            ax.plot(energy.value, radius.value, label=label, **kwargs)

        ax.semilogx()
        ax.legend(loc='best')
        ax.set_xlabel('Energy (GeV)')
        ax.set_ylabel('Containment radius (deg)')

    def plot_exposure_vs_energy(self, filename=None):
        """Plot exposure versus energy."""
        import matplotlib.pyplot as plt
        plt.figure(figsize=(4, 3))
        plt.plot(self.energy, self.exposure, color='black', lw=3)
        plt.semilogx()
        plt.xlabel('Energy (MeV)')
        plt.ylabel('Exposure (cm^2 s)')
        plt.xlim(1e4 / 1.3, 1.3 * 1e6)
        plt.ylim(0, 1.5e11)
        plt.tight_layout()

        if filename != None:
            plt.savefig(filename)

    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.

        Parameters
        ----------
        energy_index : int
            Energy index

        Returns
        -------
        psf_values : `~astropy.units.Quantity`
            PSF value array
        """
        psf_values = self.psf_value[energy_index, :].flatten().copy()
        return psf_values

    def _get_1d_table_psf(self, energy_index, **kwargs):
        """Get 1-dim TablePSF (cached).

        Parameters
        ----------
        energy_index : int
            Energy index

        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.offset, psf_value, **kwargs)
            self._table_psf_cache[energy_index] = table_psf

        return self._table_psf_cache[energy_index]