# Licensed under a 3-clause BSD style license - see LICENSE.rst
import numpy as np
from astropy import units as u
from astropy.coordinates import Angle
from astropy.io import fits
from astropy.table import Table
from astropy.utils import lazyproperty
from gammapy.maps import MapAxes, MapAxis
from gammapy.utils.interpolation import ScaledRegularGridInterpolator
from gammapy.utils.scripts import make_path
from .psf_table import EnergyDependentTablePSF, TablePSF
__all__ = ["PSF3D"]
[docs]class PSF3D:
"""PSF with axes: energy, offset, rad.
Data format specification: :ref:`gadf:psf_table`
Parameters
----------
energy_axis_true : `MapAxis`
True energy axis.
offset_axis : `MapAxis`
Offset axis
rad_axis : `MapAxis`
Rad axis
psf_value : `~astropy.units.Quantity`
PSF (3-dim with axes: psf[rad_index, offset_index, energy_index]
energy_thresh_lo : `~astropy.units.Quantity`
Lower energy threshold.
energy_thresh_hi : `~astropy.units.Quantity`
Upper energy threshold.
"""
tag = "psf_table"
def __init__(
self,
energy_axis_true,
offset_axis,
rad_axis,
psf_value,
energy_thresh_lo=u.Quantity(0.1, "TeV"),
energy_thresh_hi=u.Quantity(100, "TeV"),
interp_kwargs=None,
):
assert energy_axis_true.name == "energy_true"
assert offset_axis.name == "offset"
assert rad_axis.name == "rad"
assert psf_value.shape == (
energy_axis_true.nbin,
offset_axis.nbin,
rad_axis.nbin,
)
self._energy_axis_true = energy_axis_true
self._offset_axis = offset_axis
self._rad_axis = rad_axis
self.psf_value = psf_value.to("sr^-1")
self.energy_thresh_lo = energy_thresh_lo.to("TeV")
self.energy_thresh_hi = energy_thresh_hi.to("TeV")
self._interp_kwargs = interp_kwargs or {}
@property
def energy_axis_true(self):
return self._energy_axis_true
@property
def rad_axis(self):
return self._rad_axis
@property
def offset_axis(self):
return self._offset_axis
@lazyproperty
def _interpolate(self):
energy = self.energy_axis_true.center
offset = self.offset_axis.center
rad = self.rad_axis.center
return ScaledRegularGridInterpolator(
points=(energy, offset, rad), values=self.psf_value, **self._interp_kwargs
)
def __repr__(self):
"""Print some basic info.
"""
info = self.__class__.__name__ + "\n"
info += "-" * len(self.__class__.__name__) + "\n\n"
info += f"\tshape : {self.psf_value.shape}\n"
return info
[docs] @classmethod
def read(cls, filename, hdu="PSF_2D_TABLE"):
"""Create `PSF3D` from FITS file.
Parameters
----------
filename : str
File name
hdu : str
HDU name
"""
table = Table.read(make_path(filename), hdu=hdu)
return cls.from_table(table)
[docs] @classmethod
def from_table(cls, table):
"""Create `PSF3D` from `~astropy.table.Table`.
Parameters
----------
table : `~astropy.table.Table`
Table Table-PSF info.
"""
psf_value = table["RPSF"].quantity[0].transpose()
opts = {}
try:
opts["energy_thresh_lo"] = u.Quantity(table.meta["LO_THRES"], "TeV")
opts["energy_thresh_hi"] = u.Quantity(table.meta["HI_THRES"], "TeV")
except KeyError:
pass
energy_axis_true = MapAxis.from_table(
table, column_prefix="ENERG", format="gadf-dl3"
)
offset_axis = MapAxis.from_table(
table, column_prefix="THETA", format="gadf-dl3"
)
rad_axis = MapAxis.from_table(table, column_prefix="RAD", format="gadf-dl3")
return cls(
energy_axis_true=energy_axis_true,
offset_axis=offset_axis,
rad_axis=rad_axis,
psf_value=psf_value,
**opts,
)
[docs] def to_hdulist(self):
"""Convert PSF table data to FITS HDU list.
Returns
-------
hdu_list : `~astropy.io.fits.HDUList`
PSF in HDU list format.
"""
axes = MapAxes([self.offset_axis, self.energy_axis_true, self.rad_axis])
table = axes.to_table(format="gadf-dl3")
table["RPSF"] = self.psf_value.T[np.newaxis]
hdu = fits.BinTableHDU(table)
hdu.header["LO_THRES"] = self.energy_thresh_lo.value
hdu.header["HI_THRES"] = self.energy_thresh_hi.value
return fits.HDUList([fits.PrimaryHDU(), hdu])
[docs] def write(self, filename, *args, **kwargs):
"""Write PSF to FITS file.
Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments.
"""
self.to_hdulist().writeto(str(make_path(filename)), *args, **kwargs)
[docs] def evaluate(self, energy=None, offset=None, rad=None):
"""Interpolate PSF value at a given offset and energy.
Parameters
----------
energy : `~astropy.units.Quantity`
energy value
offset : `~astropy.coordinates.Angle`
Offset in the field of view
rad : `~astropy.coordinates.Angle`
Offset wrt source position
Returns
-------
values : `~astropy.units.Quantity`
Interpolated value
"""
if energy is None:
energy = self.energy_axis_true.center
if offset is None:
offset = self.offset_axis.center
if rad is None:
rad = self.rad_axis.center
rad = np.atleast_1d(u.Quantity(rad))
offset = np.atleast_1d(u.Quantity(offset))
energy = np.atleast_1d(u.Quantity(energy))
return self._interpolate(
(
energy[np.newaxis, np.newaxis, :],
offset[np.newaxis, :, np.newaxis],
rad[:, np.newaxis, np.newaxis],
)
)
[docs] def to_energy_dependent_table_psf(self, theta="0 deg", rad=None, exposure=None):
"""
Convert PSF3D in EnergyDependentTablePSF.
Parameters
----------
theta : `~astropy.coordinates.Angle`
Offset in the field of view
rad : `~astropy.coordinates.Angle`
Offset from PSF center used for evaluating the PSF on a grid.
Default is the ``rad`` from this PSF.
exposure : `~astropy.units.Quantity`
Energy dependent exposure. Should be in units equivalent to 'cm^2 s'.
Default exposure = 1.
Returns
-------
table_psf : `~gammapy.irf.EnergyDependentTablePSF`
Energy-dependent PSF
"""
theta = Angle(theta)
if rad is not None:
rad_axis = MapAxis.from_edges(rad, name="rad")
else:
rad_axis = self.rad_axis
psf_value = self.evaluate(offset=theta, rad=rad_axis.center).squeeze()
return EnergyDependentTablePSF(
energy_axis_true=self.energy_axis_true,
rad_axis=rad_axis,
exposure=exposure,
psf_value=psf_value.transpose(),
)
[docs] def to_table_psf(self, energy, theta="0 deg", **kwargs):
"""Create `~gammapy.irf.TablePSF` at one given energy.
Parameters
----------
energy : `~astropy.units.Quantity`
Energy
theta : `~astropy.coordinates.Angle`
Offset in the field of view. Default theta = 0 deg
Returns
-------
psf : `~gammapy.irf.TablePSF`
Table PSF
"""
energy = u.Quantity(energy)
theta = Angle(theta)
psf_value = self.evaluate(energy, theta).squeeze()
return TablePSF(rad_axis=self.rad_axis, psf_value=psf_value, **kwargs)
[docs] def containment_radius(
self, energy, theta="0 deg", fraction=0.68, interp_kwargs=None
):
"""Containment radius.
Parameters
----------
energy : `~astropy.units.Quantity`
Energy
theta : `~astropy.coordinates.Angle`
Offset in the field of view. Default theta = 0 deg
fraction : float
Containment fraction. Default fraction = 0.68
Returns
-------
radius : `~astropy.units.Quantity`
Containment radius in deg
"""
energy = np.atleast_1d(u.Quantity(energy))
theta = np.atleast_1d(u.Quantity(theta))
radii = []
for t in theta:
psf = self.to_energy_dependent_table_psf(theta=t)
radii.append(psf.containment_radius(energy, fraction=fraction))
return u.Quantity(radii).T.squeeze()
[docs] def plot_containment_vs_energy(
self, fractions=[0.68, 0.95], thetas=Angle([0, 1], "deg"), ax=None, **kwargs
):
"""Plot containment fraction as a function of energy.
"""
import matplotlib.pyplot as plt
ax = plt.gca() if ax is None else ax
energy = MapAxis.from_energy_bounds(
self.energy_axis_true.edges[0], self.energy_axis_true.edges[-1], 100
).edges
for theta in thetas:
for fraction in fractions:
radius = self.containment_radius(energy, theta, fraction)
kwargs.setdefault("label", f"{theta.deg} deg, {100 * fraction:.1f}%")
ax.plot(energy.value, radius.value, **kwargs)
ax.semilogx()
ax.legend(loc="best")
ax.set_xlabel("Energy (TeV)")
ax.set_ylabel("Containment radius (deg)")
[docs] def plot_psf_vs_rad(self, theta="0 deg", energy=u.Quantity(1, "TeV")):
"""Plot PSF vs rad.
Parameters
----------
energy : `~astropy.units.Quantity`
Energy. Default energy = 1 TeV
theta : `~astropy.coordinates.Angle`
Offset in the field of view. Default theta = 0 deg
"""
theta = Angle(theta)
table = self.to_table_psf(energy=energy, theta=theta)
return table.plot_psf_vs_rad()
[docs] def plot_containment(self, fraction=0.68, ax=None, add_cbar=True, **kwargs):
"""Plot containment image with energy and theta axes.
Parameters
----------
fraction : float
Containment fraction between 0 and 1.
add_cbar : bool
Add a colorbar
"""
import matplotlib.pyplot as plt
ax = plt.gca() if ax is None else ax
energy = self.energy_axis_true.center
offset = self.offset_axis.center
# Set up and compute data
containment = self.containment_radius(energy, offset, fraction)
# plotting defaults
kwargs.setdefault("cmap", "GnBu")
kwargs.setdefault("vmin", np.nanmin(containment.value))
kwargs.setdefault("vmax", np.nanmax(containment.value))
# Plotting
x = energy.value
y = offset.value
caxes = ax.pcolormesh(x, y, containment.value.T, **kwargs)
# Axes labels and ticks, colobar
ax.semilogx()
ax.set_ylabel(f"Offset ({offset.unit})")
ax.set_xlabel(f"Energy ({energy.unit})")
ax.set_xlim(x.min(), x.max())
ax.set_ylim(y.min(), y.max())
try:
self._plot_safe_energy_range(ax)
except KeyError:
pass
if add_cbar:
label = f"Containment radius R{100 * fraction:.0f} ({containment.unit})"
ax.figure.colorbar(caxes, ax=ax, label=label)
return ax
def _plot_safe_energy_range(self, ax):
"""add safe energy range lines to the plot"""
esafe = self.energy_thresh_lo
omin = self.offset_axis.center.value.min()
omax = self.offset_axis.center.value.max()
ax.vlines(x=esafe.value, ymin=omin, ymax=omax)
label = f"Safe energy threshold: {esafe:3.2f}"
ax.text(x=0.1, y=0.9 * esafe.value, s=label, va="top")
[docs] def peek(self, figsize=(15, 5)):
"""Quick-look summary plots."""
import matplotlib.pyplot as plt
fig, axes = plt.subplots(nrows=1, ncols=3, figsize=figsize)
self.plot_containment(fraction=0.68, ax=axes[0])
self.plot_containment(fraction=0.95, ax=axes[1])
self.plot_containment_vs_energy(ax=axes[2])
# TODO: implement this plot
# psf = self.psf_at_energy_and_theta(energy='1 TeV', theta='1 deg')
# psf.plot_components(ax=axes[2])
plt.tight_layout()