def __init__( self, energy_lo, energy_hi, offset_lo, offset_hi, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_edges = edges_from_lo_hi(energy_lo, energy_hi) energy_axis = MapAxis.from_edges(e_edges, interp="log", name="energy_true") # TODO: for some reason the H.E.S.S. DL3 files contain the same values for offset_hi and offset_lo if np.allclose(offset_lo.to_value("deg"), offset_hi.to_value("deg")): offset_axis = MapAxis.from_nodes(offset_lo, interp="lin", name="offset") else: offset_edges = edges_from_lo_hi(offset_lo, offset_hi) offset_axis = MapAxis.from_edges(offset_edges, interp="lin", name="offset") self.data = NDDataArray(axes=[energy_axis, offset_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {}
def __init__( self, energy_lo, energy_hi, fov_lon_lo, fov_lon_hi, fov_lat_lo, fov_lat_hi, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_edges = edges_from_lo_hi(energy_lo, energy_hi) energy_axis = MapAxis.from_edges(e_edges, interp="log", name="energy") fov_lon_edges = edges_from_lo_hi(fov_lon_lo, fov_lon_hi) fov_lon_axis = MapAxis.from_edges(fov_lon_edges, interp="lin", name="fov_lon") fov_lat_edges = edges_from_lo_hi(fov_lat_lo, fov_lat_hi) fov_lat_axis = MapAxis.from_edges(fov_lat_edges, interp="lin", name="fov_lat") self.data = NDDataArray( axes=[energy_axis, fov_lon_axis, fov_lat_axis], data=data, interp_kwargs=interp_kwargs, ) self.meta = meta or {}
def __init__( self, e_true_lo, e_true_hi, e_reco_lo, e_reco_hi, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_true_edges = edges_from_lo_hi(e_true_lo, e_true_hi) e_true_axis = MapAxis.from_edges(e_true_edges, interp="log", name="e_true") e_reco_edges = edges_from_lo_hi(e_reco_lo, e_reco_hi) e_reco_axis = MapAxis.from_edges(e_reco_edges, interp="log", name="e_reco") self.data = NDDataArray(axes=[e_true_axis, e_reco_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {}
def __init__( self, energy_lo, energy_hi, offset_lo, offset_hi, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_edges = edges_from_lo_hi(energy_lo, energy_hi) energy_axis = MapAxis.from_edges(e_edges, interp="log", name="energy") offset_edges = edges_from_lo_hi(offset_lo, offset_hi) offset_axis = MapAxis.from_edges(offset_edges, interp="lin", name="offset") self.data = NDDataArray(axes=[energy_axis, offset_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {}
def __init__( self, e_true_lo, e_true_hi, migra_lo, migra_hi, offset_lo, offset_hi, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_true_edges = edges_from_lo_hi(e_true_lo, e_true_hi) e_true_axis = MapAxis.from_edges(e_true_edges, interp="log", name="e_true") migra_edges = edges_from_lo_hi(migra_lo, migra_hi) migra_axis = MapAxis.from_edges( migra_edges, interp="log", name="migra", unit="" ) # TODO: for some reason the H.E.S.S. DL3 files contain the same values for offset_hi and offset_lo if np.allclose(offset_lo.to_value("deg"), offset_hi.to_value("deg")): offset_axis = MapAxis.from_nodes(offset_lo, interp="lin", name="offset") else: offset_edges = edges_from_lo_hi(offset_lo, offset_hi) offset_axis = MapAxis.from_edges(offset_edges, interp="lin", name="offset") axes = [e_true_axis, migra_axis, offset_axis] self.data = NDDataArray(axes=axes, data=data, interp_kwargs=interp_kwargs) self.meta = OrderedDict(meta) if meta else OrderedDict()
def __init__(self, energy_axis_true, data, meta=None): interp_kwargs = {"extrapolate": False, "bounds_error": False} self.data = NDDataArray(axes=[energy_axis_true], data=data, interp_kwargs=interp_kwargs) self.data.axes.assert_names(["energy_true"]) self.meta = meta or {}
def __init__(self, rad_axis, data, interp_kwargs=None): interp_kwargs = interp_kwargs or {} rad_axis.assert_name("rad") self.data = NDDataArray(axes=[rad_axis], data=u.Quantity(data).to("sr^-1"), interp_kwargs=interp_kwargs)
def __init__(self, energy_lo, energy_hi, data): axes = [ BinnedDataAxis(energy_lo, energy_hi, interpolation_mode='log', name='energy'), ] self.data = NDDataArray(axes=axes, data=data)
def __init__( self, e_true, e_reco, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs self.data = NDDataArray( axes=[e_true, e_reco], data=data, interp_kwargs=interp_kwargs ) self.meta = meta or {}
def __init__(self, energy_lo, energy_hi, data, meta=None): e_edges = edges_from_lo_hi(energy_lo, energy_hi) energy_axis = MapAxis.from_edges(e_edges, interp="log", name="energy") interp_kwargs = {"extrapolate": False, "bounds_error": False} self.data = NDDataArray(axes=[energy_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {}
def __init__( self, energy_axis, offset_axis, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs self.data = NDDataArray( axes=[energy_axis, offset_axis], data=data, interp_kwargs=interp_kwargs ) self.data.axes.assert_names(["energy", "offset"]) self.meta = meta or {}
def __init__( self, energy_axis, offset_axis, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs assert offset_axis.name == "offset" self.data = NDDataArray( axes=[energy_axis, offset_axis], data=data, interp_kwargs=interp_kwargs ) self.meta = meta or {}
def __init__( self, energy_axis_true, migra_axis, offset_axis, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs axes = [energy_axis_true, migra_axis, offset_axis] self.data = NDDataArray(axes=axes, data=data, interp_kwargs=interp_kwargs) self.meta = meta or {}
def __init__( self, energy_axis, fov_lon_axis, fov_lat_axis, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs self.data = NDDataArray( axes=[energy_axis, fov_lon_axis, fov_lat_axis], data=data, interp_kwargs=interp_kwargs, ) self.meta = meta or {}
def __init__( self, energy_axis_true, offset_axis, rad_axis, data, meta=None, interp_kwargs=None, ): interp_kwargs = interp_kwargs or {} axes = MapAxes([energy_axis_true, offset_axis, rad_axis]) axes.assert_names(["energy_true", "offset", "rad"]) self.data = NDDataArray(axes=axes, data=u.Quantity(data).to("sr^-1"), interp_kwargs=interp_kwargs) self.meta = meta or {}
def __init__( self, energy_axis_true, rad_axis, exposure=None, data=None, interp_kwargs=None, ): interp_kwargs = interp_kwargs or {} axes = MapAxes([energy_axis_true, rad_axis]) axes.assert_names(["energy_true", "rad"]) self.data = NDDataArray(axes=axes, data=u.Quantity(data).to("sr^-1"), interp_kwargs=interp_kwargs) if exposure is None: self.exposure = u.Quantity(np.ones(self.energy_axis_true.nbin), "cm^2 s") else: self.exposure = u.Quantity(exposure).to("cm^2 s")
def load_irf(self): filename = os.path.join(self.outdir, "irf.fits.gz") with fits.open(filename, memmap=False) as hdulist: aeff = EffectiveAreaTable2D.from_hdulist(hdulist=hdulist) edisp = EnergyDispersion2D.read(filename, hdu="ENERGY DISPERSION") bkg_fits_table = hdulist["BACKGROUND"] bkg_table = Table.read(bkg_fits_table) energy_lo = bkg_table["ENERG_LO"].quantity energy_hi = bkg_table["ENERG_HI"].quantity bkg = bkg_table["BGD"].quantity axes = [ BinnedDataAxis(energy_lo, energy_hi, interpolation_mode="log", name="energy") ] bkg = BkgData(data=NDDataArray(axes=axes, data=bkg)) # Create rmf with appropriate dimensions (e_reco->bkg, e_true->area) e_reco_min = bkg.energy.lo[0] e_reco_max = bkg.energy.hi[-1] e_reco_bin = bkg.energy.nbins e_reco_axis = EnergyBounds.equal_log_spacing(e_reco_min, e_reco_max, e_reco_bin, "TeV") e_true_min = aeff.data.axes[0].lo[0] e_true_max = aeff.data.axes[0].hi[-1] e_true_bin = len(aeff.data.axes[0].bins) - 1 e_true_axis = EnergyBounds.equal_log_spacing(e_true_min, e_true_max, e_true_bin, "TeV") # Fake offset... rmf = edisp.to_energy_dispersion(offset=0.5 * u.deg, e_reco=e_reco_axis, e_true=e_true_axis) # This is required because in gammapy v0.8 # gammapy.spectrum.utils.integrate_model # calls the attribute aeff.energy which is an attribute of # EffectiveAreaTable and not of EffectiveAreaTable2D # WARNING the angle is not important, but only because we started with # on-axis data! TO UPDATE aeff = aeff.to_effective_area_table(Angle("1d")) self.irf = Irf(bkg=bkg, aeff=aeff, rmf=rmf)
def load_irf(self): filename = os.path.join(self.outdir, 'irf.fits.gz') with fits.open(filename, memmap=False) as hdulist: aeff = EffectiveAreaTable.from_hdulist(hdulist=hdulist) edisp = EnergyDispersion2D.read(filename, hdu="ENERGY DISPERSION") bkg_fits_table = hdulist["BACKGROUND"] bkg_table = Table.read(bkg_fits_table) energy_lo = bkg_table["ENERG_LO"].quantity energy_hi = bkg_table["ENERG_HI"].quantity bkg = bkg_table["BGD"].quantity axes = [ BinnedDataAxis( energy_lo, energy_hi, interpolation_mode="log", name="energy" ) ] bkg = BkgData(data=NDDataArray(axes=axes, data=bkg)) # Create rmf with appropriate dimensions (e_reco->bkg, e_true->area) e_reco_min = bkg.energy.lo[0] e_reco_max = bkg.energy.hi[-1] e_reco_bin = bkg.energy.nbins e_reco_axis = EnergyBounds.equal_log_spacing( e_reco_min, e_reco_max, e_reco_bin, "TeV" ) e_true_min = aeff.energy.lo[0] e_true_max = aeff.energy.hi[-1] e_true_bin = aeff.energy.nbins e_true_axis = EnergyBounds.equal_log_spacing( e_true_min, e_true_max, e_true_bin, "TeV" ) # Fake offset... rmf = edisp.to_energy_dispersion( offset=0.5 * u.deg, e_reco=e_reco_axis, e_true=e_true_axis ) self.irf = Irf(bkg=bkg, aeff=aeff, rmf=rmf)
from gammapy.utils.energy import Energy, EnergyBounds import numpy as np import astropy.units as u # ## 1D example # # Let's start with a simple example. A one dimensional array storing an exposure in ``cm-2 s-1`` as a function of energy. The energy axis is log spaced and thus also the interpolation shall take place in log. # In[ ]: energies = Energy.equal_log_spacing(10, 100, 10, unit=u.TeV) x_axis = DataAxis(energies, name="energy", interpolation_mode="log") data = np.arange(20, 0, -2) / u.cm ** 2 / u.s nddata = NDDataArray(axes=[x_axis], data=data) print(nddata) print(nddata.axis("energy")) # In[ ]: eval_energies = np.linspace(2, 6, 20) * 1e4 * u.GeV eval_exposure = nddata.evaluate(energy=eval_energies, method="linear") plt.plot( nddata.axis("energy").nodes.value, nddata.data.value, ".", label="Interpolation nodes",
class BgRateTable(object): """Background rate table. The IRF format should be compliant with the one discussed at http://gamma-astro-data-formats.readthedocs.io/en/latest/irfs/. Work will be done to fix this. Parameters ----------- energy_lo, energy_hi : `~astropy.units.Quantity`, `~gammapy.utils.nddata.BinnedDataAxis` Bin edges of energy axis data : `~astropy.units.Quantity` Background rate """ def __init__(self, energy_lo, energy_hi, data): axes = [ BinnedDataAxis(energy_lo, energy_hi, interpolation_mode='log', name='energy'), ] self.data = NDDataArray(axes=axes, data=data) @property def energy(self): return self.data.axes[0] @classmethod def from_table(cls, table): """Background rate reader""" energy_lo = table['ENERG_LO'].quantity energy_hi = table['ENERG_HI'].quantity data = table['BGD'].quantity return cls(energy_lo=energy_lo, energy_hi=energy_hi, data=data) @classmethod def from_hdulist(cls, hdulist, hdu='BACKGROUND'): fits_table = hdulist[hdu] table = Table.read(fits_table) return cls.from_table(table) @classmethod def read(cls, filename, hdu='BACKGROUND'): filename = make_path(filename) with fits.open(str(filename), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu=hdu) def plot(self, ax=None, energy=None, **kwargs): """Plot background rate. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis energy : `~astropy.units.Quantity` Energy nodes Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = energy or self.energy.nodes values = self.data.evaluate(energy=energy) xerr = ( energy.value - self.energy.lo.value, self.energy.hi.value - energy.value, ) ax.errorbar(energy.value, values.value, xerr=xerr, fmt='o', **kwargs) ax.set_xscale('log') ax.set_yscale('log') ax.set_xlabel('Energy [{}]'.format(self.energy.unit)) ax.set_ylabel('Background rate [{}]'.format(self.data.data.unit)) return ax
class Background2D: """Background 2D. Data format specification: :ref:`gadf:bkg_2d` Parameters ---------- energy_lo, energy_hi : `~astropy.units.Quantity` Energy binning offset_lo, offset_hi : `~astropy.units.Quantity` FOV coordinate offset-axis binning data : `~astropy.units.Quantity` Background rate (usually: ``s^-1 MeV^-1 sr^-1``) """ default_interp_kwargs = dict(bounds_error=False, fill_value=None) """Default Interpolation kwargs for `~gammapy.utils.nddata.NDDataArray`. Extrapolate.""" def __init__( self, energy_lo, energy_hi, offset_lo, offset_hi, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_edges = edges_from_lo_hi(energy_lo, energy_hi) energy_axis = MapAxis.from_edges(e_edges, interp="log", name="energy") offset_edges = edges_from_lo_hi(offset_lo, offset_hi) offset_axis = MapAxis.from_edges(offset_edges, interp="lin", name="offset") self.data = NDDataArray(axes=[energy_axis, offset_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @classmethod def from_table(cls, table): """Read from `~astropy.table.Table`.""" # Spec says key should be "BKG", but there are files around # (e.g. CTA 1DC) that use "BGD". For now we support both if "BKG" in table.colnames: bkg_name = "BKG" elif "BGD" in table.colnames: bkg_name = "BGD" else: raise ValueError('Invalid column names. Need "BKG" or "BGD".') # Currently some files (e.g. CTA 1DC) contain unit in the FITS file # '1/s/MeV/sr', which is invalid ( try: astropy.units.Unit('1/s/MeV/sr') # This should be corrected. # For now, we hard-code the unit here: data_unit = u.Unit("s-1 MeV-1 sr-1") return cls( energy_lo=table["ENERG_LO"].quantity[0], energy_hi=table["ENERG_HI"].quantity[0], offset_lo=table["THETA_LO"].quantity[0], offset_hi=table["THETA_HI"].quantity[0], data=table[bkg_name].data[0] * data_unit, meta=table.meta, ) @classmethod def from_hdulist(cls, hdulist, hdu="BACKGROUND"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="BACKGROUND"): """Read from file.""" with fits.open(make_path(filename), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu=hdu) def to_table(self): """Convert to `~astropy.table.Table`.""" meta = self.meta.copy() table = Table(meta=meta) theta = self.data.axis("offset").edges energy = self.data.axis("energy").edges table["THETA_LO"] = theta[:-1][np.newaxis] table["THETA_HI"] = theta[1:][np.newaxis] table["ENERG_LO"] = energy[:-1][np.newaxis] table["ENERG_HI"] = energy[1:][np.newaxis] table["BKG"] = self.data.data[np.newaxis] return table def to_fits(self, name="BACKGROUND"): """Convert to `~astropy.io.fits.BinTableHDU`.""" return fits.BinTableHDU(self.to_table(), name=name) def evaluate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs): """Evaluate at a given FOV position and energy. The fov_lon, fov_lat, energy_reco has to have the same shape since this is a set of points on which you want to evaluate. To have the same API than background 3D for the background evaluation, the offset is ``fov_altaz_lon``. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame, same shape than energy_reco energy_reco : `~astropy.units.Quantity` Reconstructed energy, same dimension than fov_lat and fov_lat method : str {'linear', 'nearest'}, optional Interpolation method kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- array : `~astropy.units.Quantity` Interpolated values, axis order is the same as for the NDData array """ offset = np.sqrt(fov_lon**2 + fov_lat**2) return self.data.evaluate(offset=offset, energy=energy_reco, method=method, **kwargs) def evaluate_integrate(self, fov_lon, fov_lat, energy_reco, method="linear"): """Evaluate at given FOV position and energy, by integrating over the energy range. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame. energy_reco: `~astropy.units.Quantity` Reconstructed energy edges. method : {'linear', 'nearest'}, optional Interpolation method Returns ------- array : `~astropy.units.Quantity` Returns 2D array with axes offset """ data = self.evaluate(fov_lon, fov_lat, energy_reco, method=method) return trapz_loglog(data, energy_reco, axis=0) def to_3d(self): """Convert to `Background3D`. Fill in a radially symmetric way. """ raise NotImplementedError def plot(self, ax=None, add_cbar=True, **kwargs): """Plot energy offset dependence of the background model. """ import matplotlib.pyplot as plt from matplotlib.colors import LogNorm ax = plt.gca() if ax is None else ax x = self.data.axis("energy").edges.to_value("TeV") y = self.data.axis("offset").edges.to_value("deg") z = self.data.data.T.value kwargs.setdefault("cmap", "GnBu") kwargs.setdefault("edgecolors", "face") caxes = ax.pcolormesh(x, y, z, norm=LogNorm(), **kwargs) ax.set_xscale("log") ax.set_ylabel(f"Offset (deg)") ax.set_xlabel(f"Energy (TeV)") xmin, xmax = x.min(), x.max() ax.set_xlim(xmin, xmax) if add_cbar: label = f"Background rate ({self.data.data.unit})" ax.figure.colorbar(caxes, ax=ax, label=label) def peek(self): from .effective_area import EffectiveAreaTable2D return EffectiveAreaTable2D.peek(self)
class Background3D: """Background 3D. Data format specification: :ref:`gadf:bkg_3d` Parameters ---------- energy_lo, energy_hi : `~astropy.units.Quantity` Energy binning fov_lon_lo, fov_lon_hi : `~astropy.units.Quantity` FOV coordinate X-axis binning. fov_lat_lo, fov_lat_hi : `~astropy.units.Quantity` FOV coordinate Y-axis binning. data : `~astropy.units.Quantity` Background rate (usually: ``s^-1 MeV^-1 sr^-1``) Examples -------- Here's an example you can use to learn about this class: >>> from gammapy.irf import Background3D >>> filename = '$GAMMAPY_DATA/cta-1dc/caldb/data/cta/1dc/bcf/South_z20_50h/irf_file.fits' >>> bkg_3d = Background3D.read(filename, hdu='BACKGROUND') >>> print(bkg_3d) Background3D NDDataArray summary info energy : size = 21, min = 0.016 TeV, max = 158.489 TeV fov_lon : size = 36, min = -5.833 deg, max = 5.833 deg fov_lat : size = 36, min = -5.833 deg, max = 5.833 deg Data : size = 27216, min = 0.000 1 / (MeV s sr), max = 0.421 1 / (MeV s sr) """ default_interp_kwargs = dict(bounds_error=False, fill_value=None, values_scale="log") """Default Interpolation kwargs for `~gammapy.utils.nddata.NDDataArray`. Extrapolate.""" def __init__( self, energy_lo, energy_hi, fov_lon_lo, fov_lon_hi, fov_lat_lo, fov_lat_hi, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_edges = edges_from_lo_hi(energy_lo, energy_hi) energy_axis = MapAxis.from_edges(e_edges, interp="log", name="energy") fov_lon_edges = edges_from_lo_hi(fov_lon_lo, fov_lon_hi) fov_lon_axis = MapAxis.from_edges(fov_lon_edges, interp="lin", name="fov_lon") fov_lat_edges = edges_from_lo_hi(fov_lat_lo, fov_lat_hi) fov_lat_axis = MapAxis.from_edges(fov_lat_edges, interp="lin", name="fov_lat") self.data = NDDataArray( axes=[energy_axis, fov_lon_axis, fov_lat_axis], data=data, interp_kwargs=interp_kwargs, ) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @classmethod def from_table(cls, table): """Read from `~astropy.table.Table`.""" # Spec says key should be "BKG", but there are files around # (e.g. CTA 1DC) that use "BGD". For now we support both if "BKG" in table.colnames: bkg_name = "BKG" elif "BGD" in table.colnames: bkg_name = "BGD" else: raise ValueError('Invalid column names. Need "BKG" or "BGD".') # Currently some files (e.g. CTA 1DC) contain unit in the FITS file # '1/s/MeV/sr', which is invalid ( try: astropy.units.Unit('1/s/MeV/sr') # This should be corrected. # For now, we hard-code the unit here: data_unit = u.Unit("s-1 MeV-1 sr-1") return cls( energy_lo=table["ENERG_LO"].quantity[0], energy_hi=table["ENERG_HI"].quantity[0], fov_lon_lo=table["DETX_LO"].quantity[0], fov_lon_hi=table["DETX_HI"].quantity[0], fov_lat_lo=table["DETY_LO"].quantity[0], fov_lat_hi=table["DETY_HI"].quantity[0], data=table[bkg_name].data[0] * data_unit, meta=table.meta, ) @classmethod def from_hdulist(cls, hdulist, hdu="BACKGROUND"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="BACKGROUND"): """Read from file.""" with fits.open(make_path(filename), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu=hdu) def to_table(self): """Convert to `~astropy.table.Table`.""" meta = self.meta.copy() detx = self.data.axis("fov_lon").edges dety = self.data.axis("fov_lat").edges energy = self.data.axis("energy").edges table = Table(meta=meta) table["DETX_LO"] = detx[:-1][np.newaxis] table["DETX_HI"] = detx[1:][np.newaxis] table["DETY_LO"] = dety[:-1][np.newaxis] table["DETY_HI"] = dety[1:][np.newaxis] table["ENERG_LO"] = energy[:-1][np.newaxis] table["ENERG_HI"] = energy[1:][np.newaxis] table["BKG"] = self.data.data[np.newaxis] return table def to_fits(self, name="BACKGROUND"): """Convert to `~astropy.io.fits.BinTableHDU`.""" return fits.BinTableHDU(self.to_table(), name=name) def evaluate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs): """Evaluate at given FOV position and energy. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame. energy_reco : `~astropy.units.Quantity` energy on which you want to interpolate. Same dimension than fov_lat and fov_lat method : str {'linear', 'nearest'}, optional Interpolation method kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- array : `~astropy.units.Quantity` Interpolated values, axis order is the same as for the NDData array """ values = self.data.evaluate( fov_lon=fov_lon, fov_lat=fov_lat, energy=energy_reco, method=method, **kwargs, ) return values def evaluate_integrate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs): """Integrate in a given energy band. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame. energy_reco: `~astropy.units.Quantity` Reconstructed energy edges. method : {'linear', 'nearest'}, optional Interpolation method Returns ------- array : `~astropy.units.Quantity` Returns 2D array with axes offset """ data = self.evaluate(fov_lon, fov_lat, energy_reco, method=method) return trapz_loglog(data, energy_reco, axis=0) def to_2d(self): """Convert to `Background2D`. This takes the values at Y = 0 and X >= 0. """ idx_lon = self.data.axis("fov_lon").coord_to_idx(0 * u.deg)[0] idx_lat = self.data.axis("fov_lat").coord_to_idx(0 * u.deg)[0] data = self.data.data[:, idx_lon:, idx_lat].copy() energy = self.data.axis("energy").edges offset = self.data.axis("fov_lon").edges[idx_lon:] return Background2D( energy_lo=energy[:-1], energy_hi=energy[1:], offset_lo=offset[:-1], offset_hi=offset[1:], data=data, )
class EffectiveAreaTable: """Effective area table. TODO: Document Parameters ---------- energy_axis_true : `MapAxis` Energy axis data : `~astropy.units.Quantity` Effective area Examples -------- Plot parametrized effective area for HESS, HESS2 and CTA. .. plot:: :include-source: import numpy as np import matplotlib.pyplot as plt import astropy.units as u from gammapy.irf import EffectiveAreaTable energy = np.logspace(-3, 3, 100) * u.TeV for instrument in ['HESS', 'HESS2', 'CTA']: aeff = EffectiveAreaTable.from_parametrization(energy, instrument) ax = aeff.plot(label=instrument) ax.set_yscale('log') ax.set_xlim([1e-3, 1e3]) ax.set_ylim([1e3, 1e12]) plt.legend(loc='best') plt.show() Find energy where the effective area is at 10% of its maximum value >>> import numpy as np >>> import astropy.units as u >>> from gammapy.irf import EffectiveAreaTable >>> energy = np.logspace(-1, 2) * u.TeV >>> aeff_max = aeff.max_area >>> print(aeff_max).to('m2') 156909.413371 m2 >>> energy_threshold = aeff.find_energy(0.1 * aeff_max) >>> print(energy_threshold) 0.185368478744 TeV """ def __init__(self, energy_axis_true, data, meta=None): interp_kwargs = {"extrapolate": False, "bounds_error": False} assert energy_axis_true.name == "energy_true" self.data = NDDataArray(axes=[energy_axis_true], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {} @property def energy(self): return self.data.axes["energy_true"] def plot(self, ax=None, energy=None, show_energy=None, **kwargs): """Plot effective area. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis energy : `~astropy.units.Quantity` Energy nodes show_energy : `~astropy.units.Quantity`, optional Show energy, e.g. threshold, as vertical line Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax kwargs.setdefault("lw", 2) if energy is None: energy = self.energy.center eff_area = self.data.evaluate(energy_true=energy) xerr = ( (energy - self.energy.edges[:-1]).value, (self.energy.edges[1:] - energy).value, ) ax.errorbar(energy.value, eff_area.value, xerr=xerr, **kwargs) if show_energy is not None: ener_val = u.Quantity(show_energy).to_value(self.energy.unit) ax.vlines(ener_val, 0, 1.1 * self.max_area.value, linestyles="dashed") ax.set_xscale("log") ax.set_xlabel(f"Energy [{self.energy.unit}]") ax.set_ylabel(f"Effective Area [{self.data.data.unit}]") return ax @classmethod def from_parametrization(cls, energy, instrument="HESS"): r"""Create parametrized effective area. Parametrizations of the effective areas of different Cherenkov telescopes taken from Appendix B of Abramowski et al. (2010), see https://ui.adsabs.harvard.edu/abs/2010MNRAS.402.1342A . .. math:: A_{eff}(E) = g_1 \left(\frac{E}{\mathrm{MeV}}\right)^{-g_2}\exp{\left(-\frac{g_3}{E}\right)} Parameters ---------- energy : `~astropy.units.Quantity` Energy binning, analytic function is evaluated at log centers instrument : {'HESS', 'HESS2', 'CTA'} Instrument name """ energy = u.Quantity(energy) # Put the parameters g in a dictionary. # Units: g1 (cm^2), g2 (), g3 (MeV) # Note that whereas in the paper the parameter index is 1-based, # here it is 0-based pars = { "HESS": [6.85e9, 0.0891, 5e5], "HESS2": [2.05e9, 0.0891, 1e5], "CTA": [1.71e11, 0.0891, 1e5], } if instrument not in pars.keys(): ss = f"Unknown instrument: {instrument}\n" ss += "Valid instruments: HESS, HESS2, CTA" raise ValueError(ss) energy_axis_true = MapAxis.from_edges(energy, interp="log", name="energy_true") g1 = pars[instrument][0] g2 = pars[instrument][1] g3 = -pars[instrument][2] energy = energy_axis_true.center.to_value("MeV") value = g1 * energy**(-g2) * np.exp(g3 / energy) data = u.Quantity(value, "cm2", copy=False) return cls(energy_axis_true=energy_axis_true, data=data) @classmethod def from_constant(cls, energy, value): """Create constant value effective area. Parameters ---------- energy : `~astropy.units.Quantity` Energy binning, analytic function is evaluated at log centers value : `~astropy.units.Quantity` Effective area """ data = np.ones((len(energy) - 1)) * u.Quantity(value) energy_axis_true = MapAxis.from_energy_edges(energy, name="energy_true") return cls(energy_axis_true=energy_axis_true, data=data) @classmethod def from_table(cls, table): """Create from `~astropy.table.Table` in ARF format. Data format specification: :ref:`gadf:ogip-arf` """ energy_axis_true = MapAxis.from_table(table, format="ogip-arf") data = table["SPECRESP"].quantity return cls(energy_axis_true=energy_axis_true, data=data) @classmethod def from_hdulist(cls, hdulist, hdu="SPECRESP"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="SPECRESP"): """Read from file.""" filename = str(make_path(filename)) with fits.open(filename, memmap=False) as hdulist: try: return cls.from_hdulist(hdulist, hdu=hdu) except KeyError: raise ValueError(f"File {filename} contains no HDU {hdu!r}\n" f"Available: {[_.name for _ in hdulist]}") def to_table(self): """Convert to `~astropy.table.Table` in ARF format. Data format specification: :ref:`gadf:ogip-arf` """ table = Table() table.meta = { "EXTNAME": "SPECRESP", "hduclass": "OGIP", "hduclas1": "RESPONSE", "hduclas2": "SPECRESP", } energy = self.energy.edges table["ENERG_LO"] = energy[:-1] table["ENERG_HI"] = energy[1:] table["SPECRESP"] = self.evaluate_fill_nan() return table def to_region_map(self, region=None): """""" axis = self.data.axes["energy_true"] geom = RegionGeom(region=region, axes=[axis]) return RegionNDMap.from_geom(geom=geom, data=self.data.data.value, unit=self.data.data.unit) def to_hdulist(self, name=None, use_sherpa=False): """Convert to `~astropy.io.fits.HDUList`.""" table = self.to_table() if use_sherpa: table["ENERG_HI"] = table["ENERG_HI"].quantity.to("keV") table["ENERG_LO"] = table["ENERG_LO"].quantity.to("keV") table["SPECRESP"] = table["SPECRESP"].quantity.to("cm2") return fits.HDUList( [fits.PrimaryHDU(), fits.BinTableHDU(table, name=name)]) def write(self, filename, use_sherpa=False, **kwargs): """Write to file.""" filename = str(make_path(filename)) self.to_hdulist(use_sherpa=use_sherpa).writeto(filename, **kwargs) def evaluate_fill_nan(self, **kwargs): """Modified evaluate function. Calls :func:`gammapy.utils.nddata.NDDataArray.evaluate` and replaces possible nan values. Below the finite range the effective area is set to zero and above to value of the last valid note. This is needed since other codes, e.g. sherpa, don't like nan values in FITS files. Make sure that the replacement happens outside of the energy range, where the `~gammapy.irf.EffectiveAreaTable` is used. """ retval = self.data.evaluate(**kwargs) idx = np.where(np.isfinite(retval))[0] retval[np.arange(idx[0])] = 0 retval[np.arange(idx[-1], len(retval))] = retval[idx[-1]] return retval @property def max_area(self): """Maximum effective area.""" cleaned_data = self.data.data[np.where(~np.isnan(self.data.data))] return cleaned_data.max() def find_energy(self, aeff, emin=None, emax=None): """Find energy for a given effective area. In case the solution is not unique, provide the `emin` or `emax` arguments to limit the solution to the given range. By default the peak energy of the effective area is chosen as `emax`. Parameters ---------- aeff : `~astropy.units.Quantity` Effective area value emin : `~astropy.units.Quantity` Lower bracket value in case solution is not unique. emax : `~astropy.units.Quantity` Upper bracket value in case solution is not unique. Returns ------- energy : `~astropy.units.Quantity` Energy corresponding to the given aeff. """ from gammapy.modeling.models import TemplateSpectralModel energy = self.energy.center if emin is None: emin = energy[0] if emax is None: # use the peak effective area as a default for the energy maximum emax = energy[np.argmax(self.data.data)] aeff_spectrum = TemplateSpectralModel(energy, self.data.data) return aeff_spectrum.inverse(aeff, emin=emin, emax=emax)
class EffectiveAreaTable2D: """2D effective area table. Data format specification: :ref:`gadf:aeff_2d` Parameters ---------- energy_axis_true : `MapAxis` True energy axis offset_axis : `MapAxis` Field of view offset axis. data : `~astropy.units.Quantity` Effective area Examples -------- Here's an example you can use to learn about this class: >>> from gammapy.irf import EffectiveAreaTable2D >>> filename = '$GAMMAPY_DATA/cta-1dc/caldb/data/cta/1dc/bcf/South_z20_50h/irf_file.fits' >>> aeff = EffectiveAreaTable2D.read(filename, hdu='EFFECTIVE AREA') >>> print(aeff) EffectiveAreaTable2D NDDataArray summary info energy : size = 42, min = 0.014 TeV, max = 177.828 TeV offset : size = 6, min = 0.500 deg, max = 5.500 deg Data : size = 252, min = 0.000 m2, max = 5371581.000 m2 Here's another one, created from scratch, without reading a file: >>> from gammapy.irf import EffectiveAreaTable2D >>> import astropy.units as u >>> import numpy as np >>> energy = np.logspace(0,1,11) * u.TeV >>> offset = np.linspace(0,1,4) * u.deg >>> data = np.ones(shape=(10,3)) * u.cm * u.cm >>> aeff = EffectiveAreaTable2D(energy_lo=energy[:-1], energy_hi=energy[1:], offset_lo=offset[:-1], >>> offset_hi=offset[1:], data= data) >>> print(aeff) Data array summary info energy : size = 11, min = 1.000 TeV, max = 10.000 TeV offset : size = 4, min = 0.000 deg, max = 1.000 deg Data : size = 30, min = 1.000 cm2, max = 1.000 cm2 """ tag = "aeff_2d" default_interp_kwargs = dict(bounds_error=False, fill_value=None) """Default Interpolation kwargs for `~NDDataArray`. Extrapolate.""" def __init__( self, energy_axis_true, offset_axis, data, meta=None, interp_kwargs=None, ): assert energy_axis_true.name == "energy_true" assert offset_axis.name == "offset" if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs self.data = NDDataArray(axes=[energy_axis_true, offset_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @property def low_threshold(self): """Low energy threshold""" return self.meta["LO_THRES"] * u.TeV @property def high_threshold(self): """High energy threshold""" return self.meta["HI_THRES"] * u.TeV @classmethod def from_table(cls, table): """Read from `~astropy.table.Table`.""" 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") return cls( energy_axis_true=energy_axis_true, offset_axis=offset_axis, data=table["EFFAREA"].quantity[0].transpose(), meta=table.meta, ) @classmethod def from_hdulist(cls, hdulist, hdu="EFFECTIVE AREA"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="EFFECTIVE AREA"): """Read from file.""" with fits.open(str(make_path(filename)), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu=hdu) def to_effective_area_table(self, offset, energy=None): """Evaluate at a given offset and return `~gammapy.irf.EffectiveAreaTable`. Parameters ---------- offset : `~astropy.coordinates.Angle` Offset energy : `~astropy.units.Quantity` Energy axis bin edges """ if energy is None: energy_axis_true = self.data.axes["energy_true"] else: energy_axis_true = MapAxis.from_energy_edges(energy, name="energy_true") area = self.data.evaluate(offset=offset, energy_true=energy_axis_true.center) return EffectiveAreaTable(energy_axis_true=energy_axis_true, data=area) def plot_energy_dependence(self, ax=None, offset=None, energy=None, **kwargs): """Plot effective area versus energy for a given offset. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle` Offset energy : `~astropy.units.Quantity` Energy axis kwargs : dict Forwarded tp plt.plot() Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax if offset is None: off_min, off_max = self.data.axes["offset"].center[[0, -1]] offset = np.linspace(off_min.value, off_max.value, 4) * off_min.unit if energy is None: energy = self.data.axes["energy_true"].center for off in offset: area = self.data.evaluate(offset=off, energy_true=energy) kwargs.setdefault("label", f"offset = {off:.1f}") ax.plot(energy, area.value, **kwargs) ax.set_xscale("log") ax.set_xlabel(f"Energy [{energy.unit}]") ax.set_ylabel(f"Effective Area [{self.data.data.unit}]") ax.set_xlim(min(energy.value), max(energy.value)) return ax def plot_offset_dependence(self, ax=None, offset=None, energy=None, **kwargs): """Plot effective area versus offset for a given energy. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle` Offset axis energy : `~astropy.units.Quantity` Energy Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax if energy is None: energy_axis = self.data.axes["energy_true"] e_min, e_max = np.log10(energy_axis.center.value[[0, -1]]) energy = np.logspace(e_min, e_max, 4) * energy_axis.unit if offset is None: offset = self.data.axes["offset"].center for ee in energy: area = self.data.evaluate(offset=offset, energy_true=ee) area /= np.nanmax(area) if np.isnan(area).all(): continue label = f"energy = {ee:.1f}" ax.plot(offset, area, label=label, **kwargs) ax.set_ylim(0, 1.1) ax.set_xlabel(f"Offset ({self.data.axes['offset'].unit})") ax.set_ylabel("Relative Effective Area") ax.legend(loc="best") return ax def plot(self, ax=None, add_cbar=True, **kwargs): """Plot effective area image.""" import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = self.data.axes["energy_true"].edges offset = self.data.axes["offset"].edges aeff = self.data.evaluate(offset=offset, energy_true=energy[:, np.newaxis]) vmin, vmax = np.nanmin(aeff.value), np.nanmax(aeff.value) kwargs.setdefault("cmap", "GnBu") kwargs.setdefault("edgecolors", "face") kwargs.setdefault("vmin", vmin) kwargs.setdefault("vmax", vmax) caxes = ax.pcolormesh(energy.value, offset.value, aeff.value.T, **kwargs) ax.set_xscale("log") ax.set_ylabel(f"Offset ({offset.unit})") ax.set_xlabel(f"Energy ({energy.unit})") xmin, xmax = energy.value.min(), energy.value.max() ax.set_xlim(xmin, xmax) if add_cbar: label = f"Effective Area ({aeff.unit})" ax.figure.colorbar(caxes, ax=ax, label=label) return ax 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(ax=axes[2]) self.plot_energy_dependence(ax=axes[0]) self.plot_offset_dependence(ax=axes[1]) plt.tight_layout() def to_table(self): """Convert to `~astropy.table.Table`.""" table = self.data.axes.to_table(format="gadf-dl3") table.meta = self.meta.copy() table["EFFAREA"] = self.data.data.T[np.newaxis] return table def to_table_hdu(self, name="EFFECTIVE AREA"): """Convert to `~astropy.io.fits.BinTableHDU`.""" return fits.BinTableHDU(self.to_table(), name=name)
class EnergyDispersion: """Energy dispersion matrix. Data format specification: :ref:`gadf:ogip-rmf` Parameters ---------- e_true_lo, e_true_hi : `~astropy.units.Quantity` True energy axis binning e_reco_lo, e_reco_hi : `~astropy.units.Quantity` Reconstruced energy axis binning data : array_like 2-dim energy dispersion matrix Examples -------- Create a Gaussian energy dispersion matrix:: import numpy as np import astropy.units as u from gammapy.irf import EnergyDispersion energy = np.logspace(0, 1, 101) * u.TeV edisp = EnergyDispersion.from_gauss( e_true=energy, e_reco=energy, sigma=0.1, bias=0, ) Have a quick look: >>> print(edisp) >>> edisp.peek() See Also -------- EnergyDispersion2D """ default_interp_kwargs = dict(bounds_error=False, fill_value=0, method="nearest") """Default Interpolation kwargs for `~NDDataArray`. Fill zeros and do not interpolate""" def __init__( self, e_true_lo, e_true_hi, e_reco_lo, e_reco_hi, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_true_edges = edges_from_lo_hi(e_true_lo, e_true_hi) e_true_axis = MapAxis.from_edges(e_true_edges, interp="log", name="e_true") e_reco_edges = edges_from_lo_hi(e_reco_lo, e_reco_hi) e_reco_axis = MapAxis.from_edges(e_reco_edges, interp="log", name="e_reco") self.data = NDDataArray(axes=[e_true_axis, e_reco_axis], data=data, interp_kwargs=interp_kwargs) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss def apply(self, data): """Apply energy dispersion. Computes the matrix product of ``data`` (which typically is model flux or counts in true energy bins) with the energy dispersion matrix. Parameters ---------- data : array_like 1-dim data array. Returns ------- convolved_data : array 1-dim data array after multiplication with the energy dispersion matrix """ if len(data) != self.e_true.nbin: raise ValueError( f"Input size {len(data)} does not match true energy axis {self.e_true.nbin}" ) return np.dot(data, self.data.data) @property def e_reco(self): """Reconstructed energy axis (`~gammapy.maps.MapAxis`)""" return self.data.axis("e_reco") @property def e_true(self): """True energy axis (`~gammapy.maps.MapAxis`)""" return self.data.axis("e_true") @property def pdf_matrix(self): """Energy dispersion PDF matrix (`~numpy.ndarray`). Rows (first index): True Energy Columns (second index): Reco Energy """ return self.data.data.value def pdf_in_safe_range(self, lo_threshold, hi_threshold): """PDF matrix with bins outside threshold set to 0. Parameters ---------- lo_threshold : `~astropy.units.Quantity` Low reco energy threshold hi_threshold : `~astropy.units.Quantity` High reco energy threshold """ data = self.pdf_matrix.copy() energy = self.e_reco.edges if lo_threshold is None and hi_threshold is None: idx = slice(None) else: idx = (energy[:-1] < lo_threshold) | (energy[1:] > hi_threshold) data[:, idx] = 0 return data @classmethod def from_gauss(cls, e_true, e_reco, sigma, bias, pdf_threshold=1e-6): """Create Gaussian energy dispersion matrix (`EnergyDispersion`). Calls :func:`gammapy.irf.EnergyDispersion2D.from_gauss` Parameters ---------- e_true : `~astropy.units.Quantity` Bin edges of true energy axis e_reco : `~astropy.units.Quantity` Bin edges of reconstructed energy axis bias : float or `~numpy.ndarray` Center of Gaussian energy dispersion, bias sigma : float or `~numpy.ndarray` RMS width of Gaussian energy dispersion, resolution pdf_threshold : float, optional Zero suppression threshold """ migra = np.linspace(1.0 / 3, 3, 200) # A dummy offset axis (need length 2 for interpolation to work) offset = Quantity([0, 1, 2], "deg") edisp = EnergyDispersion2D.from_gauss( e_true=e_true, migra=migra, sigma=sigma, bias=bias, offset=offset, pdf_threshold=pdf_threshold, ) return edisp.to_energy_dispersion(offset=offset[0], e_reco=e_reco) @classmethod def from_diagonal_response(cls, e_true, e_reco=None): """Create energy dispersion from a diagonal response, i.e. perfect energy resolution This creates the matrix corresponding to a perfect energy response. It contains ones where the e_true center is inside the e_reco bin. It is a square diagonal matrix if e_true = e_reco. This is useful in cases where code always applies an edisp, but you don't want it to do anything. Parameters ---------- e_true, e_reco : `~astropy.units.Quantity` Energy bounds for true and reconstructed energy axis Examples -------- If ``e_true`` equals ``e_reco``, you get a diagonal matrix:: e_true = [0.5, 1, 2, 4, 6] * u.TeV edisp = EnergyDispersion.from_diagonal_response(e_true) edisp.plot_matrix() Example with different energy binnings:: e_true = [0.5, 1, 2, 4, 6] * u.TeV e_reco = [2, 4, 6] * u.TeV edisp = EnergyDispersion.from_diagonal_response(e_true, e_reco) edisp.plot_matrix() """ if e_reco is None: e_reco = e_true e_true_center = 0.5 * (e_true[1:] + e_true[:-1]) etrue_2d, ereco_lo_2d = np.meshgrid(e_true_center, e_reco[:-1]) etrue_2d, ereco_hi_2d = np.meshgrid(e_true_center, e_reco[1:]) data = np.logical_and(etrue_2d >= ereco_lo_2d, etrue_2d < ereco_hi_2d) data = np.transpose(data).astype("float") return cls( e_true_lo=e_true[:-1], e_true_hi=e_true[1:], e_reco_lo=e_reco[:-1], e_reco_hi=e_reco[1:], data=data, ) @classmethod def from_hdulist(cls, hdulist, hdu1="MATRIX", hdu2="EBOUNDS"): """Create `EnergyDispersion` object from `~astropy.io.fits.HDUList`. Parameters ---------- hdulist : `~astropy.io.fits.HDUList` HDU list with ``MATRIX`` and ``EBOUNDS`` extensions. hdu1 : str, optional HDU containing the energy dispersion matrix, default: MATRIX hdu2 : str, optional HDU containing the energy axis information, default, EBOUNDS """ matrix_hdu = hdulist[hdu1] ebounds_hdu = hdulist[hdu2] data = matrix_hdu.data header = matrix_hdu.header pdf_matrix = np.zeros([len(data), header["DETCHANS"]], dtype=np.float64) for i, l in enumerate(data): if l.field("N_GRP"): m_start = 0 for k in range(l.field("N_GRP")): pdf_matrix[i, l.field("F_CHAN")[k]:l.field("F_CHAN")[k] + l.field("N_CHAN")[k], ] = l.field( "MATRIX")[m_start:m_start + l.field("N_CHAN")[k]] m_start += l.field("N_CHAN")[k] unit = ebounds_hdu.header.get("TUNIT2") e_reco_lo = Quantity(ebounds_hdu.data["E_MIN"], unit=unit) e_reco_hi = Quantity(ebounds_hdu.data["E_MAX"], unit=unit) unit = matrix_hdu.header.get("TUNIT1") e_true_lo = Quantity(matrix_hdu.data["ENERG_LO"], unit=unit) e_true_hi = Quantity(matrix_hdu.data["ENERG_HI"], unit=unit) return cls( e_true_lo=e_true_lo, e_true_hi=e_true_hi, e_reco_lo=e_reco_lo, e_reco_hi=e_reco_hi, data=pdf_matrix, ) @classmethod def read(cls, filename, hdu1="MATRIX", hdu2="EBOUNDS"): """Read from file. Parameters ---------- filename : `pathlib.Path`, str File to read hdu1 : str, optional HDU containing the energy dispersion matrix, default: MATRIX hdu2 : str, optional HDU containing the energy axis information, default, EBOUNDS """ with fits.open(make_path(filename), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu1=hdu1, hdu2=hdu2) def to_hdulist(self, use_sherpa=False, **kwargs): """Convert RMF to FITS HDU list format. Parameters ---------- header : `~astropy.io.fits.Header` Header to be written in the fits file. energy_unit : str Unit in which the energy is written in the HDU list Returns ------- hdulist : `~astropy.io.fits.HDUList` RMF in HDU list format. Notes ----- For more info on the RMF FITS file format see: https://heasarc.gsfc.nasa.gov/docs/heasarc/caldb/docs/summary/cal_gen_92_002_summary.html """ # Cannot use table_to_fits here due to variable length array # http://docs.astropy.org/en/v1.0.4/io/fits/usage/unfamiliar.html table = self.to_table() name = table.meta.pop("name") header = fits.Header() header.update(table.meta) if use_sherpa: table["ENERG_HI"] = table["ENERG_HI"].quantity.to("keV") table["ENERG_LO"] = table["ENERG_LO"].quantity.to("keV") cols = table.columns c0 = fits.Column(name=cols[0].name, format="E", array=cols[0], unit=str(cols[0].unit)) c1 = fits.Column(name=cols[1].name, format="E", array=cols[1], unit=str(cols[1].unit)) c2 = fits.Column(name=cols[2].name, format="I", array=cols[2]) c3 = fits.Column(name=cols[3].name, format="PI()", array=cols[3]) c4 = fits.Column(name=cols[4].name, format="PI()", array=cols[4]) c5 = fits.Column(name=cols[5].name, format="PE()", array=cols[5]) hdu = fits.BinTableHDU.from_columns([c0, c1, c2, c3, c4, c5], header=header, name=name) energy = self.e_reco.edges if use_sherpa: energy = energy.to("keV") ebounds = energy_axis_to_ebounds(energy) prim_hdu = fits.PrimaryHDU() return fits.HDUList([prim_hdu, hdu, ebounds]) def to_table(self): """Convert to `~astropy.table.Table`. The output table is in the OGIP RMF format. https://heasarc.gsfc.nasa.gov/docs/heasarc/caldb/docs/memos/cal_gen_92_002/cal_gen_92_002.html#Tab:1 """ rows = self.pdf_matrix.shape[0] n_grp = [] f_chan = np.ndarray(dtype=np.object, shape=rows) n_chan = np.ndarray(dtype=np.object, shape=rows) matrix = np.ndarray(dtype=np.object, shape=rows) # Make RMF type matrix for i, row in enumerate(self.data.data.value): pos = np.nonzero(row)[0] borders = np.where(np.diff(pos) != 1)[0] # add 1 to borders for correct behaviour of np.split groups = np.asarray(np.split(pos, borders + 1)) n_grp_temp = groups.shape[0] if groups.size > 0 else 1 n_chan_temp = np.asarray([val.size for val in groups]) try: f_chan_temp = np.asarray([val[0] for val in groups]) except IndexError: f_chan_temp = np.zeros(1) n_grp.append(n_grp_temp) f_chan[i] = f_chan_temp n_chan[i] = n_chan_temp matrix[i] = row[pos] n_grp = np.asarray(n_grp, dtype=np.int16) # Get total number of groups and channel subsets numgrp, numelt = 0, 0 for val, val2 in zip(n_grp, n_chan): numgrp += np.sum(val) numelt += np.sum(val2) table = Table() energy = self.e_true.edges table["ENERG_LO"] = energy[:-1] table["ENERG_HI"] = energy[1:] table["N_GRP"] = n_grp table["F_CHAN"] = f_chan table["N_CHAN"] = n_chan table["MATRIX"] = matrix table.meta = { "name": "MATRIX", "chantype": "PHA", "hduclass": "OGIP", "hduclas1": "RESPONSE", "hduclas2": "RSP_MATRIX", "detchans": self.e_reco.nbin, "numgrp": numgrp, "numelt": numelt, "tlmin4": 0, } return table def write(self, filename, use_sherpa=False, **kwargs): """Write to file.""" filename = make_path(filename) self.to_hdulist(use_sherpa=use_sherpa).writeto(filename, **kwargs) def get_resolution(self, e_true): """Get energy resolution for a given true energy. The resolution is given as a percentage of the true energy Parameters ---------- e_true : `~astropy.units.Quantity` True energy """ var = self._get_variance(e_true) idx_true = self.e_true.coord_to_idx(e_true) e_true_real = self.e_true.center[idx_true] return np.sqrt(var) / e_true_real def get_bias(self, e_true): r"""Get reconstruction bias for a given true energy. Bias is defined as .. math:: \frac{E_{reco}-E_{true}}{E_{true}} Parameters ---------- e_true : `~astropy.units.Quantity` True energy """ e_reco = self.get_mean(e_true) idx_true = self.e_true.coord_to_idx(e_true) e_true_real = self.e_true.center[idx_true] bias = (e_reco - e_true_real) / e_true_real return bias def get_bias_energy(self, bias, emin=None, emax=None): """Find energy corresponding to a given bias. In case the solution is not unique, provide the ``emin`` or ``emax`` arguments to limit the solution to the given range. By default the peak energy of the bias is chosen as ``emin``. Parameters ---------- bias : float Bias value. emin : `~astropy.units.Quantity` Lower bracket value in case solution is not unique. emax : `~astropy.units.Quantity` Upper bracket value in case solution is not unique. Returns ------- bias_energy : `~astropy.units.Quantity` Reconstructed energy corresponding to the given bias. """ from gammapy.modeling.models import TemplateSpectralModel e_true = self.e_true.center values = self.get_bias(e_true) if emin is None: # use the peak bias energy as default minimum emin = e_true[np.nanargmax(values)] if emax is None: emax = e_true[-1] bias_spectrum = TemplateSpectralModel(e_true, values) e_true_bias = bias_spectrum.inverse(Quantity(bias), emin=emin, emax=emax) # return reconstructed energy return e_true_bias * (1 + bias) def get_mean(self, e_true): """Get mean reconstructed energy for a given true energy.""" # find pdf for true energies idx = self.e_true.coord_to_idx(e_true) pdf = self.data.data[idx] # compute sum along reconstructed energy # axis to determine the mean norm = np.sum(pdf, axis=-1) temp = np.sum(pdf * self.e_reco.center, axis=-1) with np.errstate(invalid="ignore"): # corm can be zero mean = np.nan_to_num(temp / norm) return mean def _get_variance(self, e_true): """Get variance of log reconstructed energy.""" # evaluate the pdf at given true energies idx = self.e_true.coord_to_idx(e_true) pdf = self.data.data[idx] # compute mean mean = self.get_mean(e_true) # create array of reconstructed-energy nodes # for each given true energy value # (first axis is reconstructed energy) erec = self.e_reco.center erec = np.repeat(erec, max(np.sum(mean.shape), 1)).reshape(erec.shape + mean.shape) # compute deviation from mean # (and move reconstructed energy axis to last axis) temp_ = (erec - mean)**2 temp = np.rollaxis(temp_, 1) # compute sum along reconstructed energy # axis to determine the variance norm = np.sum(pdf, axis=-1) var = np.sum(temp * pdf, axis=-1) return var / norm def plot_matrix(self, ax=None, show_energy=None, add_cbar=False, **kwargs): """Plot PDF matrix. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis show_energy : `~astropy.units.Quantity`, optional Show energy, e.g. threshold, as vertical line add_cbar : bool Add a colorbar to the plot. Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt from matplotlib.colors import PowerNorm kwargs.setdefault("cmap", "GnBu") norm = PowerNorm(gamma=0.5) kwargs.setdefault("norm", norm) ax = plt.gca() if ax is None else ax e_true = self.e_true.edges e_reco = self.e_reco.edges x = e_true.value y = e_reco.value z = self.pdf_matrix caxes = ax.pcolormesh(x, y, z.T, **kwargs) if show_energy is not None: ener_val = show_energy.to_value(self.reco_energy.unit) ax.hlines(ener_val, 0, 200200, linestyles="dashed") if add_cbar: label = "Probability density (A.U.)" cbar = ax.figure.colorbar(caxes, ax=ax, label=label) ax.set_xlabel(fr"$E_\mathrm{{True}}$ [{e_true.unit}]") ax.set_ylabel(fr"$E_\mathrm{{Reco}}$ [{e_reco.unit}]") ax.set_xscale("log") ax.set_yscale("log") ax.set_xlim(x.min(), x.max()) ax.set_ylim(y.min(), y.max()) return ax def plot_bias(self, ax=None, **kwargs): """Plot reconstruction bias. See `~gammapy.irf.EnergyDispersion.get_bias` method. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax x = self.e_true.center.to_value("TeV") y = self.get_bias(self.e_true.center) ax.plot(x, y, **kwargs) ax.set_xlabel(r"$E_\mathrm{{True}}$ [TeV]") ax.set_ylabel( r"($E_\mathrm{{True}} - E_\mathrm{{Reco}} / E_\mathrm{{True}}$)") ax.set_xscale("log") return ax def peek(self, figsize=(15, 5)): """Quick-look summary plot.""" import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows=1, ncols=2, figsize=figsize) self.plot_bias(ax=axes[0]) self.plot_matrix(ax=axes[1]) plt.tight_layout()
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 data : `~astropy.units.Quantity` PSF (3-dim with axes: psf[rad_index, offset_index, energy_index] meta : dict Meta dict """ tag = "psf_table" def __init__( self, energy_axis_true, offset_axis, rad_axis, data, meta=None, interp_kwargs=None, ): interp_kwargs = interp_kwargs or {} axes = MapAxes([energy_axis_true, offset_axis, rad_axis]) axes.assert_names(["energy_true", "offset", "rad"]) self.data = NDDataArray(axes=axes, data=u.Quantity(data).to("sr^-1"), interp_kwargs=interp_kwargs) self.meta = meta or {} @property def energy_thresh_lo(self): """Low energy threshold""" return self.meta["LO_THRES"] * u.TeV @property def energy_thresh_hi(self): """High energy threshold""" return self.meta["HI_THRES"] * u.TeV @property def energy_axis_true(self): return self.data.axes["energy_true"] @property def rad_axis(self): return self.data.axes["rad"] @property def offset_axis(self): return self.data.axes["offset"] def __repr__(self): """Print some basic info. """ info = self.__class__.__name__ + "\n" info += "-" * len(self.__class__.__name__) + "\n\n" info += f"\tshape : {self.data.data.shape}\n" return info @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) @classmethod def from_table(cls, table): """Create `PSF3D` from `~astropy.table.Table`. Parameters ---------- table : `~astropy.table.Table` Table Table-PSF info. """ axes = MapAxes.from_table(table=table, column_prefixes=["ENERG", "THETA", "RAD"], format="gadf-dl3") data = table["RPSF"].quantity[0].transpose() return cls(energy_axis_true=axes["energy_true"], offset_axis=axes["offset"], rad_axis=axes["rad"], data=data, meta=table.meta) def to_hdulist(self): """Convert PSF table data to FITS HDU list. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ table = self.data.axes.to_table(format="gadf-dl3") table["RPSF"] = self.data.data.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]) 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) 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.data._interpolate(( energy[np.newaxis, np.newaxis, :], offset[np.newaxis, :, np.newaxis], rad[:, np.newaxis, np.newaxis], )) 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, data=psf_value.transpose(), ) 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, data=psf_value, **kwargs) def containment_radius(self, energy, theta="0 deg", fraction=0.68): """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() 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: plot_kwargs = kwargs.copy() radius = self.containment_radius(energy, theta, fraction) plot_kwargs.setdefault( "label", f"{theta.deg} deg, {100 * fraction:.1f}%") ax.plot(energy.value, radius.value, **plot_kwargs) ax.semilogx() ax.legend(loc="best") ax.set_xlabel("Energy (TeV)") ax.set_ylabel("Containment radius (deg)") 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() 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") 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()
class Background2D: """Background 2D. Data format specification: :ref:`gadf:bkg_2d` Parameters ---------- energy_axis : `MapAxis` Energy axis offset_axis : `MapAxis` FOV coordinate offset-axis data : `~astropy.units.Quantity` Background rate (usually: ``s^-1 MeV^-1 sr^-1``) """ tag = "bkg_2d" default_interp_kwargs = dict(bounds_error=False, fill_value=None) """Default Interpolation kwargs for `~gammapy.utils.nddata.NDDataArray`. Extrapolate.""" def __init__( self, energy_axis, offset_axis, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs assert offset_axis.name == "offset" self.data = NDDataArray( axes=[energy_axis, offset_axis], data=data, interp_kwargs=interp_kwargs ) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @classmethod def from_table(cls, table): """Read from `~astropy.table.Table`.""" # Spec says key should be "BKG", but there are files around # (e.g. CTA 1DC) that use "BGD". For now we support both if "BKG" in table.colnames: bkg_name = "BKG" elif "BGD" in table.colnames: bkg_name = "BGD" else: raise ValueError('Invalid column names. Need "BKG" or "BGD".') data_unit = table[bkg_name].unit if data_unit is not None: data_unit = u.Unit(data_unit, parse_strict="silent") if isinstance(data_unit, u.UnrecognizedUnit) or (data_unit is None): data_unit = u.Unit("s-1 MeV-1 sr-1") log.warning( "Invalid unit found in background table! Assuming (s-1 MeV-1 sr-1)" ) energy_axis = MapAxis.from_table( table, column_prefix="ENERG", format="gadf-dl3" ) offset_axis = MapAxis.from_table( table, column_prefix="THETA", format="gadf-dl3" ) # TODO: The present HESS and CTA backgroundfits files # have a reverse order (theta, E) than recommened in GADF(E, theta) # For now, we suport both. data = table[bkg_name].data[0].T * data_unit shape = (energy_axis.nbin, offset_axis.nbin) if shape == shape[::-1]: log.error("Ambiguous axes order in Background fits files!") if np.shape(data) != shape: log.debug("Transposing background table on read") data = data.transpose() return cls( energy_axis=energy_axis, offset_axis=offset_axis, data=data, meta=table.meta, ) @classmethod def from_hdulist(cls, hdulist, hdu="BACKGROUND"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="BACKGROUND"): """Read from file.""" with fits.open(str(make_path(filename)), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu=hdu) def to_table(self): """Convert to `~astropy.table.Table`.""" table = self.data.axes.to_table(format="gadf-dl3") table.meta = self.meta.copy() table["BKG"] = self.data.data.T[np.newaxis] return table def to_table_hdu(self, name="BACKGROUND"): """Convert to `~astropy.io.fits.BinTableHDU`.""" return fits.BinTableHDU(self.to_table(), name=name) def evaluate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs): """Evaluate at a given FOV position and energy. The fov_lon, fov_lat, energy_reco has to have the same shape since this is a set of points on which you want to evaluate. To have the same API than background 3D for the background evaluation, the offset is ``fov_altaz_lon``. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame, same shape than energy_reco energy_reco : `~astropy.units.Quantity` Reconstructed energy, same dimension than fov_lat and fov_lat method : str {'linear', 'nearest'}, optional Interpolation method kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- array : `~astropy.units.Quantity` Interpolated values, axis order is the same as for the NDData array """ offset = np.sqrt(fov_lon ** 2 + fov_lat ** 2) return self.data.evaluate( offset=offset, energy=energy_reco, method=method, **kwargs ) def evaluate_integrate(self, fov_lon, fov_lat, energy_reco, method="linear"): """Evaluate at given FOV position and energy, by integrating over the energy range. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame. energy_reco: `~astropy.units.Quantity` Reconstructed energy edges. method : {'linear', 'nearest'}, optional Interpolation method Returns ------- array : `~astropy.units.Quantity` Returns 2D array with axes offset """ data = self.evaluate(fov_lon, fov_lat, energy_reco, method=method) return trapz_loglog(data, energy_reco, axis=0) def to_3d(self): """Convert to `Background3D`. Fill in a radially symmetric way. """ raise NotImplementedError def plot(self, ax=None, add_cbar=True, **kwargs): """Plot energy offset dependence of the background model. """ import matplotlib.pyplot as plt from matplotlib.colors import LogNorm ax = plt.gca() if ax is None else ax x = self.data.axes["energy"].edges.to_value("TeV") y = self.data.axes["offset"].edges.to_value("deg") z = self.data.data.T.value kwargs.setdefault("cmap", "GnBu") kwargs.setdefault("edgecolors", "face") caxes = ax.pcolormesh(x, y, z, norm=LogNorm(), **kwargs) ax.set_xscale("log") ax.set_ylabel(f"Offset (deg)") ax.set_xlabel(f"Energy (TeV)") xmin, xmax = x.min(), x.max() ax.set_xlim(xmin, xmax) if add_cbar: label = f"Background rate ({self.data.data.unit})" ax.figure.colorbar(caxes, ax=ax, label=label) def plot_offset_dependence(self, ax=None, offset=None, energy=None, **kwargs): """Plot background rate versus offset for a given energy. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle` Offset axis energy : `~astropy.units.Quantity` Energy Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax if energy is None: energy_axis = self.data.axes["energy"] e_min, e_max = np.log10(energy_axis.center.value[[0, -1]]) energy = np.logspace(e_min, e_max, 4) * energy_axis.unit if offset is None: offset = self.data.axes["offset"].center for ee in energy: bkg = self.data.evaluate(offset=offset, energy=ee) if np.isnan(bkg).all(): continue label = f"energy = {ee:.1f}" ax.plot(offset, bkg.value, label=label, **kwargs) ax.set_xlabel(f"Offset ({self.data.axes['offset'].unit})") ax.set_ylabel(f"Background rate ({self.data.data.unit})") ax.set_yscale("log") ax.legend(loc="upper right") return ax def plot_energy_dependence(self, ax=None, offset=None, energy=None, **kwargs): """Plot background rate versus energy for a given offset. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle` Offset energy : `~astropy.units.Quantity` Energy axis kwargs : dict Forwarded tp plt.plot() Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax if offset is None: offset_axis = self.data.axes["offset"] off_min, off_max = offset_axis.center.value[[0, -1]] offset = np.linspace(off_min, off_max, 4) * offset_axis.unit if energy is None: energy = self.data.axes["energy"].center for off in offset: bkg = self.data.evaluate(offset=off, energy=energy) kwargs.setdefault("label", f"offset = {off:.1f}") ax.plot(energy, bkg.value, **kwargs) ax.set_xscale("log") ax.set_yscale("log") ax.set_xlabel(f"Energy [{energy.unit}]") ax.set_ylabel(f"Background rate ({self.data.data.unit})") ax.set_xlim(min(energy.value), max(energy.value)) ax.legend(loc="best") return ax def plot_spectrum(self, ax=None, **kwargs): """Plot angle integrated background rate versus energy. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis kwargs : dict Forwarded tp plt.plot() Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax offset = self.data.axes["offset"].edges energy = self.data.axes["energy"].center bkg = [] for ee in energy: data = self.data.evaluate(offset=offset, energy=ee) val = np.nansum(trapz_loglog(data, offset, axis=0)) bkg.append(val.value) ax.plot(energy, bkg, label="integrated spectrum", **kwargs) unit = self.data.data.unit * offset.unit * offset.unit ax.set_xscale("log") ax.set_yscale("log") ax.set_xlabel(f"Energy [{energy.unit}]") ax.set_ylabel(f"Background rate ({unit})") ax.set_xlim(min(energy.value), max(energy.value)) ax.legend(loc="best") return ax def peek(self, figsize=(10, 8)): """Quick-look summary plots.""" import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows=2, ncols=2, figsize=figsize) self.plot(ax=axes[1][1]) self.plot_offset_dependence(ax=axes[0][0]) self.plot_energy_dependence(ax=axes[1][0]) self.plot_spectrum(ax=axes[0][1]) plt.tight_layout()
class Background3D: """Background 3D. Data format specification: :ref:`gadf:bkg_3d` Parameters ---------- energy_axis : `MapAxis` Energy axis fov_lon_axis: `MapAxis` FOV coordinate X-axis fov_lat_axis : `MapAxis` FOV coordinate Y-axis. data : `~astropy.units.Quantity` Background rate (usually: ``s^-1 MeV^-1 sr^-1``) Examples -------- Here's an example you can use to learn about this class: >>> from gammapy.irf import Background3D >>> filename = '$GAMMAPY_DATA/cta-1dc/caldb/data/cta/1dc/bcf/South_z20_50h/irf_file.fits' >>> bkg_3d = Background3D.read(filename, hdu='BACKGROUND') >>> print(bkg_3d) Background3D NDDataArray summary info energy : size = 21, min = 0.016 TeV, max = 158.489 TeV fov_lon : size = 36, min = -5.833 deg, max = 5.833 deg fov_lat : size = 36, min = -5.833 deg, max = 5.833 deg Data : size = 27216, min = 0.000 1 / (MeV s sr), max = 0.421 1 / (MeV s sr) """ tag = "bkg_3d" default_interp_kwargs = dict( bounds_error=False, fill_value=None, values_scale="log" ) """Default Interpolation kwargs for `~gammapy.utils.nddata.NDDataArray`. Extrapolate.""" def __init__( self, energy_axis, fov_lon_axis, fov_lat_axis, data, meta=None, interp_kwargs=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs self.data = NDDataArray( axes=[energy_axis, fov_lon_axis, fov_lat_axis], data=data, interp_kwargs=interp_kwargs, ) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @classmethod def from_table(cls, table): """Read from `~astropy.table.Table`.""" # Spec says key should be "BKG", but there are files around # (e.g. CTA 1DC) that use "BGD". For now we support both if "BKG" in table.colnames: bkg_name = "BKG" elif "BGD" in table.colnames: bkg_name = "BGD" else: raise ValueError('Invalid column names. Need "BKG" or "BGD".') data_unit = table[bkg_name].unit if data_unit is not None: data_unit = u.Unit(table[bkg_name].unit, parse_strict="silent") if isinstance(data_unit, u.UnrecognizedUnit) or (data_unit is None): data_unit = u.Unit("s-1 MeV-1 sr-1") log.warning( "Invalid unit found in background table! Assuming (s-1 MeV-1 sr-1)" ) energy_axis = MapAxis.from_table( table, column_prefix="ENERG", format="gadf-dl3" ) fov_lon_axis = MapAxis.from_table( table, column_prefix="DETX", format="gadf-dl3" ) fov_lat_axis = MapAxis.from_table( table, column_prefix="DETY", format="gadf-dl3" ) # TODO: The present HESS and CTA backgroundfits files # have a reverse order (lon, lat, E) than recommened in GADF(E, lat, lon) # For now, we suport both. data = table[bkg_name].data[0].T * data_unit shape = (energy_axis.nbin, fov_lon_axis.nbin, fov_lat_axis.nbin) if shape == shape[::-1]: log.error("Ambiguous axes order in Background fits files!") if np.shape(data) != shape: log.debug("Transposing background table on read") data = data.transpose() return cls( energy_axis=energy_axis, fov_lon_axis=fov_lon_axis, fov_lat_axis=fov_lat_axis, data=data, meta=table.meta, ) @classmethod def from_hdulist(cls, hdulist, hdu="BACKGROUND"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="BACKGROUND"): """Read from file.""" with fits.open(str(make_path(filename)), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu=hdu) def to_table(self): """Convert to `~astropy.table.Table`.""" # TODO: fix axis order axes = MapAxes(self.data.axes[::-1]) table = axes.to_table(format="gadf-dl3") table.meta = self.meta.copy() table["BKG"] = self.data.data.T[np.newaxis] return table def to_table_hdu(self, name="BACKGROUND"): """Convert to `~astropy.io.fits.BinTableHDU`.""" return fits.BinTableHDU(self.to_table(), name=name) def evaluate(self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs): """Evaluate at given FOV position and energy. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame. energy_reco : `~astropy.units.Quantity` energy on which you want to interpolate. Same dimension than fov_lat and fov_lat method : str {'linear', 'nearest'}, optional Interpolation method kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- array : `~astropy.units.Quantity` Interpolated values, axis order is the same as for the NDData array """ values = self.data.evaluate( fov_lon=fov_lon, fov_lat=fov_lat, energy=energy_reco, method=method, **kwargs, ) return values def evaluate_integrate( self, fov_lon, fov_lat, energy_reco, method="linear", **kwargs ): """Integrate in a given energy band. Parameters ---------- fov_lon, fov_lat : `~astropy.coordinates.Angle` FOV coordinates expecting in AltAz frame. energy_reco: `~astropy.units.Quantity` Reconstructed energy edges. method : {'linear', 'nearest'}, optional Interpolation method Returns ------- array : `~astropy.units.Quantity` Returns 2D array with axes offset """ data = self.evaluate(fov_lon, fov_lat, energy_reco, method=method) return trapz_loglog(data, energy_reco, axis=0) def to_2d(self): """Convert to `Background2D`. This takes the values at Y = 0 and X >= 0. """ # TODO: this is incorrect as it misses the Jacobian? idx_lon = self.data.axes["fov_lon"].coord_to_idx(0 * u.deg)[0] idx_lat = self.data.axes["fov_lat"].coord_to_idx(0 * u.deg)[0] data = self.data.data[:, idx_lon:, idx_lat].copy() offset = self.data.axes["fov_lon"].edges[idx_lon:] offset_axis = MapAxis.from_edges(offset, name="offset") return Background2D( energy_axis=self.data.axes["energy"], offset_axis=offset_axis, data=data, ) def peek(self, figsize=(10, 8)): return self.to_2d().peek(figsize)
class EnergyDispersion2D: """Offset-dependent energy dispersion matrix. Data format specification: :ref:`gadf:edisp_2d` Parameters ---------- e_true_lo, e_true_hi : `~astropy.units.Quantity` True energy axis binning migra_lo, migra_hi : `~numpy.ndarray` Energy migration axis binning offset_lo, offset_hi : `~astropy.coordinates.Angle` Field of view offset axis binning data : `~numpy.ndarray` Energy dispersion probability density Examples -------- Read energy dispersion IRF from disk: >>> from gammapy.maps import MapAxis >>> from gammapy.irf import EnergyDispersion2D >>> filename = '$GAMMAPY_DATA/hess-dl3-dr1/data/hess_dl3_dr1_obs_id_020136.fits.gz' >>> edisp2d = EnergyDispersion2D.read(filename, hdu="EDISP") Create energy dispersion matrix (`~gammapy.irf.EnergyDispersion`) for a given field of view offset and energy binning: >>> energy = MapAxis.from_bounds(0.1, 20, nbin=60, unit="TeV", interp="log").edges >>> edisp = edisp2d.to_energy_dispersion(offset='1.2 deg', e_reco=energy, e_true=energy) See Also -------- EnergyDispersion """ default_interp_kwargs = dict(bounds_error=False, fill_value=None) """Default Interpolation kwargs for `~gammapy.utils.nddata.NDDataArray`. Extrapolate.""" def __init__( self, e_true_lo, e_true_hi, migra_lo, migra_hi, offset_lo, offset_hi, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs e_true_edges = edges_from_lo_hi(e_true_lo, e_true_hi) e_true_axis = MapAxis.from_edges(e_true_edges, interp="log", name="e_true") migra_edges = edges_from_lo_hi(migra_lo, migra_hi) migra_axis = MapAxis.from_edges(migra_edges, interp="log", name="migra", unit="") # TODO: for some reason the H.E.S.S. DL3 files contain the same values for offset_hi and offset_lo if np.allclose(offset_lo.to_value("deg"), offset_hi.to_value("deg")): offset_axis = MapAxis.from_nodes(offset_lo, interp="lin", name="offset") else: offset_edges = edges_from_lo_hi(offset_lo, offset_hi) offset_axis = MapAxis.from_edges(offset_edges, interp="lin", name="offset") axes = [e_true_axis, migra_axis, offset_axis] self.data = NDDataArray(axes=axes, data=data, interp_kwargs=interp_kwargs) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @classmethod def from_gauss(cls, e_true, migra, bias, sigma, offset, pdf_threshold=1e-6): """Create Gaussian energy dispersion matrix (`EnergyDispersion2D`). The output matrix will be Gaussian in (e_true / e_reco). The ``bias`` and ``sigma`` should be either floats or arrays of same dimension than ``e_true``. ``bias`` refers to the mean value of the ``migra`` distribution minus one, i.e. ``bias=0`` means no bias. Note that, the output matrix is flat in offset. Parameters ---------- e_true : `~astropy.units.Quantity` Bin edges of true energy axis migra : `~astropy.units.Quantity` Bin edges of migra axis bias : float or `~numpy.ndarray` Center of Gaussian energy dispersion, bias sigma : float or `~numpy.ndarray` RMS width of Gaussian energy dispersion, resolution offset : `~astropy.units.Quantity` Bin edges of offset pdf_threshold : float, optional Zero suppression threshold """ e_true = Quantity(e_true) # erf does not work with Quantities true = MapAxis.from_edges(e_true, interp="log").center.to_value("TeV") true2d, migra2d = np.meshgrid(true, migra) migra2d_lo = migra2d[:-1, :] migra2d_hi = migra2d[1:, :] # Analytical formula for integral of Gaussian s = np.sqrt(2) * sigma t1 = (migra2d_hi - 1 - bias) / s t2 = (migra2d_lo - 1 - bias) / s pdf = (scipy.special.erf(t1) - scipy.special.erf(t2)) / 2 pdf_array = pdf.T[:, :, np.newaxis] * np.ones(len(offset) - 1) pdf_array = np.where(pdf_array > pdf_threshold, pdf_array, 0) return cls( e_true[:-1], e_true[1:], migra[:-1], migra[1:], offset[:-1], offset[1:], pdf_array, ) @classmethod def from_table(cls, table): """Create from `~astropy.table.Table`.""" if "ENERG_LO" in table.colnames: e_lo = table["ENERG_LO"].quantity[0] e_hi = table["ENERG_HI"].quantity[0] elif "ETRUE_LO" in table.colnames: e_lo = table["ETRUE_LO"].quantity[0] e_hi = table["ETRUE_HI"].quantity[0] else: raise ValueError( 'Invalid column names. Need "ENERG_LO/ENERG_HI" or "ETRUE_LO/ETRUE_HI"' ) o_lo = table["THETA_LO"].quantity[0] o_hi = table["THETA_HI"].quantity[0] m_lo = table["MIGRA_LO"].quantity[0] m_hi = table["MIGRA_HI"].quantity[0] # TODO Why does this need to be transposed? matrix = table["MATRIX"].quantity[0].transpose() return cls( e_true_lo=e_lo, e_true_hi=e_hi, offset_lo=o_lo, offset_hi=o_hi, migra_lo=m_lo, migra_hi=m_hi, data=matrix, ) @classmethod def from_hdulist(cls, hdulist, hdu="edisp_2d"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="edisp_2d"): """Read from FITS file. Parameters ---------- filename : str File name """ with fits.open(make_path(filename), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu) def to_energy_dispersion(self, offset, e_true=None, e_reco=None): """Detector response R(Delta E_reco, Delta E_true) Probability to reconstruct an energy in a given true energy band in a given reconstructed energy band Parameters ---------- offset : `~astropy.coordinates.Angle` Offset e_true : `~astropy.units.Quantity`, None True energy axis e_reco : `~astropy.units.Quantity` Reconstructed energy axis Returns ------- edisp : `~gammapy.irf.EnergyDispersion` Energy dispersion matrix """ offset = Angle(offset) e_true = self.data.axis("e_true").edges if e_true is None else e_true e_reco = self.data.axis("e_true").edges if e_reco is None else e_reco data = [] for energy in MapAxis.from_edges(e_true, interp="log").center: vec = self.get_response(offset=offset, e_true=energy, e_reco=e_reco) data.append(vec) data = np.asarray(data) e_lo, e_hi = e_true[:-1], e_true[1:] ereco_lo, ereco_hi = (e_reco[:-1], e_reco[1:]) return EnergyDispersion( e_true_lo=e_lo, e_true_hi=e_hi, e_reco_lo=ereco_lo, e_reco_hi=ereco_hi, data=data, ) def get_response(self, offset, e_true, e_reco=None, migra_step=5e-3): """Detector response R(Delta E_reco, E_true) Probability to reconstruct a given true energy in a given reconstructed energy band. In each reco bin, you integrate with a riemann sum over the default migra bin of your analysis. Parameters ---------- e_true : `~astropy.units.Quantity` True energy e_reco : `~astropy.units.Quantity`, None Reconstructed energy axis offset : `~astropy.coordinates.Angle` Offset migra_step : float Integration step in migration Returns ------- rv : `~numpy.ndarray` Redistribution vector """ e_true = Quantity(e_true) if e_reco is None: # Default: e_reco nodes = migra nodes * e_true nodes e_reco = self.data.axis("migra").edges * e_true else: # Translate given e_reco binning to migra at bin center e_reco = Quantity(e_reco) # migration value of e_reco bounds migra_e_reco = e_reco / e_true # Define a vector of migration with mig_step step mrec_min = self.data.axis("migra").edges[0] mrec_max = self.data.axis("migra").edges[-1] mig_array = np.arange(mrec_min, mrec_max, migra_step) # Compute energy dispersion probability dP/dm for each element of migration array vals = self.data.evaluate(offset=offset, e_true=e_true, migra=mig_array) # Compute normalized cumulative sum to prepare integration with np.errstate(invalid="ignore"): tmp = np.nan_to_num(np.cumsum(vals) / np.sum(vals)) # Determine positions (bin indices) of e_reco bounds in migration array pos_mig = np.digitize(migra_e_reco, mig_array) - 1 # We ensure that no negative values are found pos_mig = np.maximum(pos_mig, 0) # We compute the difference between 2 successive bounds in e_reco # to get integral over reco energy bin integral = np.diff(tmp[pos_mig]) return integral def plot_migration(self, ax=None, offset=None, e_true=None, migra=None, **kwargs): """Plot energy dispersion for given offset and true energy. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle`, optional Offset e_true : `~astropy.units.Quantity`, optional True energy migra : `~numpy.ndarray`, optional Migration nodes Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax if offset is None: offset = Angle([1], "deg") else: offset = np.atleast_1d(Angle(offset)) if e_true is None: e_true = Quantity([0.1, 1, 10], "TeV") else: e_true = np.atleast_1d(Quantity(e_true)) migra = self.data.axis("migra").center if migra is None else migra for ener in e_true: for off in offset: disp = self.data.evaluate(offset=off, e_true=ener, migra=migra) label = f"offset = {off:.1f}\nenergy = {ener:.1f}" ax.plot(migra, disp, label=label, **kwargs) ax.set_xlabel(r"$E_\mathrm{{Reco}} / E_\mathrm{{True}}$") ax.set_ylabel("Probability density") ax.legend(loc="upper left") return ax def plot_bias(self, ax=None, offset=None, add_cbar=False, **kwargs): """Plot migration as a function of true energy for a given offset. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle`, optional Offset add_cbar : bool Add a colorbar to the plot. kwargs : dict Keyword arguments passed to `~matplotlib.pyplot.pcolormesh`. Returns ------- ax : `~matplotlib.axes.Axes` Axis """ from matplotlib.colors import PowerNorm import matplotlib.pyplot as plt kwargs.setdefault("cmap", "GnBu") kwargs.setdefault("norm", PowerNorm(gamma=0.5)) ax = plt.gca() if ax is None else ax if offset is None: offset = Angle(1, "deg") e_true = self.data.axis("e_true").edges migra = self.data.axis("migra").edges x = e_true.value y = migra.value z = self.data.evaluate( offset=offset, e_true=e_true.reshape(1, -1, 1), migra=migra.reshape(1, 1, -1), ).value[0] caxes = ax.pcolormesh(x, y, z.T, **kwargs) if add_cbar: label = "Probability density (A.U.)" ax.figure.colorbar(caxes, ax=ax, label=label) ax.set_xlabel(fr"$E_\mathrm{{True}}$ [{e_true.unit}]") ax.set_ylabel(r"$E_\mathrm{{Reco}} / E_\mathrm{{True}}$") ax.set_xlim(x.min(), x.max()) ax.set_ylim(y.min(), y.max()) ax.set_xscale("log") return ax def peek(self, figsize=(15, 5)): """Quick-look summary plots. Parameters ---------- figsize : (float, float) Size of the resulting plot """ import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows=1, ncols=3, figsize=figsize) self.plot_bias(ax=axes[0]) self.plot_migration(ax=axes[1]) edisp = self.to_energy_dispersion(offset="1 deg") edisp.plot_matrix(ax=axes[2]) plt.tight_layout() def to_table(self): """Convert to `~astropy.table.Table`.""" meta = self.meta.copy() energy = self.data.axis("e_true").edges migra = self.data.axis("migra").edges theta = self.data.axis("offset").edges table = Table(meta=meta) table["ENERG_LO"] = energy[:-1][np.newaxis] table["ENERG_HI"] = energy[1:][np.newaxis] table["MIGRA_LO"] = migra[:-1][np.newaxis] table["MIGRA_HI"] = migra[1:][np.newaxis] table["THETA_LO"] = theta[:-1][np.newaxis] table["THETA_HI"] = theta[1:][np.newaxis] table["MATRIX"] = self.data.data.T[np.newaxis] return table def to_fits(self, name="ENERGY DISPERSION"): """Convert to `~astropy.io.fits.BinTable`.""" return fits.BinTableHDU(self.to_table(), name=name)
class EnergyDispersion2D: """Offset-dependent energy dispersion matrix. Data format specification: :ref:`gadf:edisp_2d` Parameters ---------- energy_axis_true : `MapAxis` True energy axis migra_axis : `MapAxis` Energy migration axis offset_axis : `MapAxis` Field of view offset axis data : `~numpy.ndarray` Energy dispersion probability density Examples -------- Read energy dispersion IRF from disk: >>> from gammapy.maps import MapAxis >>> from gammapy.irf import EnergyDispersion2D >>> filename = '$GAMMAPY_DATA/hess-dl3-dr1/data/hess_dl3_dr1_obs_id_020136.fits.gz' >>> edisp2d = EnergyDispersion2D.read(filename, hdu="EDISP") Create energy dispersion matrix (`~gammapy.irf.EnergyDispersion`) for a given field of view offset and energy binning: >>> energy = MapAxis.from_bounds(0.1, 20, nbin=60, unit="TeV", interp="log").edges >>> edisp = edisp2d.to_edisp_kernel(offset='1.2 deg', e_reco=energy, energy_true=energy) See Also -------- EnergyDispersion """ tag = "edisp_2d" default_interp_kwargs = dict(bounds_error=False, fill_value=None) """Default Interpolation kwargs for `~gammapy.utils.nddata.NDDataArray`. Extrapolate.""" def __init__( self, energy_axis_true, migra_axis, offset_axis, data, interp_kwargs=None, meta=None, ): if interp_kwargs is None: interp_kwargs = self.default_interp_kwargs axes = [energy_axis_true, migra_axis, offset_axis] self.data = NDDataArray(axes=axes, data=data, interp_kwargs=interp_kwargs) self.meta = meta or {} def __str__(self): ss = self.__class__.__name__ ss += f"\n{self.data}" return ss @classmethod def from_gauss(cls, energy_true, migra, bias, sigma, offset, pdf_threshold=1e-6): """Create Gaussian energy dispersion matrix (`EnergyDispersion2D`). The output matrix will be Gaussian in (energy_true / energy). The ``bias`` and ``sigma`` should be either floats or arrays of same dimension than ``energy_true``. ``bias`` refers to the mean value of the ``migra`` distribution minus one, i.e. ``bias=0`` means no bias. Note that, the output matrix is flat in offset. Parameters ---------- energy_true : `~astropy.units.Quantity` Bin edges of true energy axis migra : `~astropy.units.Quantity` Bin edges of migra axis bias : float or `~numpy.ndarray` Center of Gaussian energy dispersion, bias sigma : float or `~numpy.ndarray` RMS width of Gaussian energy dispersion, resolution offset : `~astropy.units.Quantity` Bin edges of offset pdf_threshold : float, optional Zero suppression threshold """ energy_true = Quantity(energy_true) # erf does not work with Quantities energy_axis_true = MapAxis.from_energy_edges(energy_true, interp="log", name="energy_true") true2d, migra2d = np.meshgrid(energy_axis_true.center, migra) migra2d_lo = migra2d[:-1, :] migra2d_hi = migra2d[1:, :] # Analytical formula for integral of Gaussian s = np.sqrt(2) * sigma t1 = (migra2d_hi - 1 - bias) / s t2 = (migra2d_lo - 1 - bias) / s pdf = (scipy.special.erf(t1) - scipy.special.erf(t2)) / 2 data = pdf.T[:, :, np.newaxis] * np.ones(len(offset) - 1) data[data < pdf_threshold] = 0 offset_axis = MapAxis.from_edges(offset, name="offset") migra_axis = MapAxis.from_edges(migra, name="migra") return cls( energy_axis_true=energy_axis_true, migra_axis=migra_axis, offset_axis=offset_axis, data=data, ) @classmethod def from_table(cls, table): """Create from `~astropy.table.Table`.""" # TODO: move this to MapAxis.from_table() if "ENERG_LO" in table.colnames: energy_axis_true = MapAxis.from_table(table, column_prefix="ENERG", format="gadf-dl3") elif "ETRUE_LO" in table.colnames: energy_axis_true = MapAxis.from_table(table, column_prefix="ETRUE", format="gadf-dl3") else: raise ValueError( 'Invalid column names. Need "ENERG_LO/ENERG_HI" or "ETRUE_LO/ETRUE_HI"' ) offset_axis = MapAxis.from_table(table, column_prefix="THETA", format="gadf-dl3") migra_axis = MapAxis.from_table(table, column_prefix="MIGRA", format="gadf-dl3") matrix = table["MATRIX"].quantity[0].transpose() return cls( energy_axis_true=energy_axis_true, offset_axis=offset_axis, migra_axis=migra_axis, data=matrix, ) @classmethod def from_hdulist(cls, hdulist, hdu="edisp_2d"): """Create from `~astropy.io.fits.HDUList`.""" return cls.from_table(Table.read(hdulist[hdu])) @classmethod def read(cls, filename, hdu="edisp_2d"): """Read from FITS file. Parameters ---------- filename : str File name """ with fits.open(str(make_path(filename)), memmap=False) as hdulist: return cls.from_hdulist(hdulist, hdu) def to_edisp_kernel(self, offset, energy_true=None, energy=None): """Detector response R(Delta E_reco, Delta E_true) Probability to reconstruct an energy in a given true energy band in a given reconstructed energy band Parameters ---------- offset : `~astropy.coordinates.Angle` Offset energy_true : `~astropy.units.Quantity`, None True energy axis energy : `~astropy.units.Quantity` Reconstructed energy axis Returns ------- edisp : `~gammapy.irf.EDispKernel` Energy dispersion matrix """ offset = Angle(offset) # TODO: expect directly MapAxis here? if energy is None: energy_axis = self.data.axes["energy_true"].copy(name="energy") else: energy_axis = MapAxis.from_energy_edges(energy) if energy_true is None: energy_axis_true = self.data.axes["energy_true"] else: energy_axis_true = MapAxis.from_energy_edges(energy_true, name="energy_true") data = [] for value in energy_axis_true.center: vec = self.get_response(offset=offset, energy_true=value, energy=energy_axis.edges) data.append(vec) return EDispKernel( energy_axis=energy_axis, energy_axis_true=energy_axis_true, data=np.asarray(data), ) def get_response(self, offset, energy_true, energy=None): """Detector response R(Delta E_reco, E_true) Probability to reconstruct a given true energy in a given reconstructed energy band. In each reco bin, you integrate with a riemann sum over the default migra bin of your analysis. Parameters ---------- energy_true : `~astropy.units.Quantity` True energy energy : `~astropy.units.Quantity`, None Reconstructed energy axis offset : `~astropy.coordinates.Angle` Offset Returns ------- rv : `~numpy.ndarray` Redistribution vector """ energy_true = Quantity(energy_true) migra_axis = self.data.axes["migra"] if energy is None: # Default: energy nodes = migra nodes * energy_true nodes energy = migra_axis.edges * energy_true else: # Translate given energy binning to migra at bin center energy = Quantity(energy) # migration value of energy bounds migra = energy / energy_true values = self.data.evaluate(offset=offset, energy_true=energy_true, migra=migra_axis.center) cumsum = np.insert(values, 0, 0).cumsum() with np.errstate(invalid="ignore"): cumsum = np.nan_to_num(cumsum / cumsum[-1]) f = interp1d( migra_axis.edges.value, cumsum, kind="linear", bounds_error=False, fill_value=(0, 1), ) # We compute the difference between 2 successive bounds in energy # to get integral over reco energy bin integral = np.diff(np.clip(f(migra), a_min=0, a_max=1)) return integral def plot_migration(self, ax=None, offset=None, energy_true=None, migra=None, **kwargs): """Plot energy dispersion for given offset and true energy. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle`, optional Offset energy_true : `~astropy.units.Quantity`, optional True energy migra : `~numpy.ndarray`, optional Migration nodes Returns ------- ax : `~matplotlib.axes.Axes` Axis """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax if offset is None: offset = Angle([1], "deg") else: offset = np.atleast_1d(Angle(offset)) if energy_true is None: energy_true = Quantity([0.1, 1, 10], "TeV") else: energy_true = np.atleast_1d(Quantity(energy_true)) migra = self.data.axes["migra"].center if migra is None else migra for ener in energy_true: for off in offset: disp = self.data.evaluate(offset=off, energy_true=ener, migra=migra) label = f"offset = {off:.1f}\nenergy = {ener:.1f}" ax.plot(migra, disp, label=label, **kwargs) ax.set_xlabel(r"$E_\mathrm{{Reco}} / E_\mathrm{{True}}$") ax.set_ylabel("Probability density") ax.legend(loc="upper left") return ax def plot_bias(self, ax=None, offset=None, add_cbar=False, **kwargs): """Plot migration as a function of true energy for a given offset. Parameters ---------- ax : `~matplotlib.axes.Axes`, optional Axis offset : `~astropy.coordinates.Angle`, optional Offset add_cbar : bool Add a colorbar to the plot. kwargs : dict Keyword arguments passed to `~matplotlib.pyplot.pcolormesh`. Returns ------- ax : `~matplotlib.axes.Axes` Axis """ from matplotlib.colors import PowerNorm import matplotlib.pyplot as plt kwargs.setdefault("cmap", "GnBu") kwargs.setdefault("norm", PowerNorm(gamma=0.5)) ax = plt.gca() if ax is None else ax if offset is None: offset = Angle(1, "deg") energy_true = self.data.axes["energy_true"] migra = self.data.axes["migra"] x = energy_true.edges.value y = migra.edges.value z = self.data.evaluate( offset=offset, energy_true=energy_true.center.reshape(1, -1, 1), migra=migra.center.reshape(1, 1, -1), ).value[0] caxes = ax.pcolormesh(x, y, z.T, **kwargs) if add_cbar: label = "Probability density (A.U.)" ax.figure.colorbar(caxes, ax=ax, label=label) ax.set_xlabel(fr"$E_\mathrm{{True}}$ [{energy_true.unit}]") ax.set_ylabel(r"$E_\mathrm{{Reco}} / E_\mathrm{{True}}$") ax.set_xlim(x.min(), x.max()) ax.set_ylim(y.min(), y.max()) ax.set_xscale("log") return ax def peek(self, figsize=(15, 5)): """Quick-look summary plots. Parameters ---------- figsize : (float, float) Size of the resulting plot """ import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows=1, ncols=3, figsize=figsize) self.plot_bias(ax=axes[0]) self.plot_migration(ax=axes[1]) edisp = self.to_edisp_kernel(offset="1 deg") edisp.plot_matrix(ax=axes[2]) plt.tight_layout() def to_table(self): """Convert to `~astropy.table.Table`.""" table = self.data.axes.to_table(format="gadf-dl3") table.meta = self.meta.copy() table["MATRIX"] = self.data.data.T[np.newaxis] return table def to_table_hdu(self, name="ENERGY DISPERSION"): """Convert to `~astropy.io.fits.BinTable`.""" return fits.BinTableHDU(self.to_table(), name=name)