def freq2sb(frequency: u.Quantity): r""" Conversion between the frequency :math:`\nu` and the NenuFAR sub-band index :math:`n_{\rm SB}`. Each NenuFAR sub-band has a bandwidth of :math:`\Delta \nu = 195.3125\, \rm{kHz}`: .. math:: n_{\rm SB} = \lfloor*{ \frac{\nu}{\Delta \nu} + \frac{1}{2} \rfloor :param frequency: Frequency to convert in sub-band index. :type frequency: :class:`~astropy.units.Quantity` :returns: Sub-band index, same shape as ``frequency``. :rtype: `int` or :class:`~numpy.ndarray` :example: .. code-block:: python from nenupy.instru import freq2sb import astropy.units as u freq2sb(frequency=50.5*u.MHz) freq2sb(frequency=[50.5, 51]*u.MHz) """ if not isinstance(frequency, u.Quantity): raise TypeError(f"`frequency` - {u.Quantity} expected.") if (frequency.min() < 0 * u.MHz) or (frequency.max() > 100 * u.MHz): raise ValueError("'frequency' should be between 0 and 100 MHz.") frequency = frequency.to(u.MHz) sb_width = 100. * u.MHz / 512 sb_idx = np.floor(frequency / sb_width + 0.5) return sb_idx.astype(int).value
class EnergyDependentTablePSF(object): """Energy-dependent radially-symmetric table PSF (``gtpsf`` format). TODO: add references and explanations. Parameters ---------- energy : `~astropy.units.Quantity` Energy (1-dim) rad : `~astropy.units.Quantity` with angle units Offset angle wrt source position (1-dim) exposure : `~astropy.units.Quantity` Exposure (1-dim) psf_value : `~astropy.units.Quantity` PSF (2-dim with axes: psf[energy_index, offset_index] """ def __init__(self, energy, rad, exposure=None, psf_value=None): self.energy = Quantity(energy).to("GeV") self.rad = Quantity(rad).to("radian") if exposure is None: self.exposure = Quantity(np.ones(len(energy)), "cm^2 s") else: self.exposure = Quantity(exposure).to("cm^2 s") if psf_value is None: self.psf_value = Quantity(np.zeros(len(energy), len(rad)), "sr^-1") else: self.psf_value = Quantity(psf_value).to("sr^-1") # Cache for TablePSF at each energy ... only computed when needed self._table_psf_cache = [None] * len(self.energy) def __str__(self): ss = "EnergyDependentTablePSF\n" ss += "-----------------------\n" ss += "\nAxis info:\n" ss += " " + array_stats_str(self.rad.to("deg"), "rad") ss += " " + array_stats_str(self.energy, "energy") # ss += ' ' + array_stats_str(self.exposure, 'exposure') # ss += 'integral = {}\n'.format(self.integral()) ss += "\nContainment info:\n" # Print some example containment radii fractions = [0.68, 0.95] energies = Quantity([10, 100], "GeV") for fraction in fractions: rads = self.containment_radius(energies=energies, fraction=fraction) for energy, rad in zip(energies, rads): ss += " " + "{}% containment radius at {:3.0f}: {:.2f}\n".format( 100 * fraction, energy, rad) return ss @classmethod def from_fits(cls, hdu_list): """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list. Parameters ---------- hdu_list : `~astropy.io.fits.HDUList` HDU list with ``THETA`` and ``PSF`` extensions. """ rad = Angle(hdu_list["THETA"].data["Theta"], "deg") energy = Quantity(hdu_list["PSF"].data["Energy"], "MeV") exposure = Quantity(hdu_list["PSF"].data["Exposure"], "cm^2 s") psf_value = Quantity(hdu_list["PSF"].data["PSF"], "sr^-1") return cls(energy, rad, exposure, psf_value) def to_fits(self): """Convert to FITS HDU list format. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # TODO: write HEADER keywords as gtpsf data = self.rad theta_hdu = fits.BinTableHDU(data=data, name="Theta") data = [self.energy, self.exposure, self.psf_value] psf_hdu = fits.BinTableHDU(data=data, name="PSF") hdu_list = fits.HDUList([theta_hdu, psf_hdu]) return hdu_list @classmethod def read(cls, filename): """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file. Parameters ---------- filename : str File name """ filename = str(make_path(filename)) with fits.open(filename, memmap=False) as hdulist: psf = cls.from_fits(hdulist) return psf def write(self, *args, **kwargs): """Write to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(*args, **kwargs) def evaluate(self, energy=None, rad=None, interp_kwargs=None): """Evaluate the PSF at a given energy and offset Parameters ---------- energy : `~astropy.units.Quantity` energy value rad : `~astropy.coordinates.Angle` Offset wrt source position interp_kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- values : `~astropy.units.Quantity` Interpolated value """ if interp_kwargs is None: interp_kwargs = dict(bounds_error=False, fill_value=None) if energy is None: energy = self.energy if rad is None: rad = self.rad energy = Energy(energy).to("TeV") rad = Angle(rad).to("deg") energy_bin = self.energy.to("TeV") rad_bin = self.rad.to("deg") points = (energy_bin, rad_bin) interpolator = RegularGridInterpolator(points, self.psf_value.value, **interp_kwargs) energy_grid, rad_grid = np.meshgrid(energy.value, rad.value, indexing="ij") shape = energy_grid.shape pix_coords = np.column_stack([energy_grid.flat, rad_grid.flat]) data_interp = interpolator(pix_coords) return Quantity(data_interp.reshape(shape), self.psf_value.unit) def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs): """Create `~gammapy.irf.TablePSF` at one given energy. Parameters ---------- energy : `~astropy.units.Quantity` Energy interp_kwargs : dict Option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- psf : `~gammapy.irf.TablePSF` Table PSF """ psf_value = self.evaluate(energy, None, interp_kwargs)[0, :] return TablePSF(self.rad, psf_value, **kwargs) def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs): """Average PSF in a given energy band. Expected counts in sub energy bands given the given exposure and spectrum are used as weights. Parameters ---------- energy_band : `~astropy.units.Quantity` Energy band spectral_index : float Power law spectral index (used if spectrum=None). spectrum : callable Spectrum (callable with energy as parameter). Returns ------- psf : `TablePSF` Table PSF """ if spectrum is None: # This is a false positive error from pylint # See https://github.com/PyCQA/pylint/issues/2410#issuecomment-415026690 def spectrum(energy): # pylint:disable=function-redefined return (energy / energy_band[0])**(-spectral_index) # TODO: warn if `energy_band` is outside available data. energy_idx_min, energy_idx_max = self._energy_index(energy_band) # TODO: improve this, probably by evaluating the PSF (i.e. interpolating in energy) onto a new energy grid # This is a bit of a hack, but makes sure that a PSF is given, by forcing at least one slice: if energy_idx_max - energy_idx_min < 2: # log.warning('Dubious case of PSF energy binning') # Note that below always range stop of `energy_idx_max - 1` is used! # That's why we put +2 here to make sure we have at least one bin. energy_idx_max = max(energy_idx_min + 2, energy_idx_max) # Make sure we don't step out of the energy array (doesn't help much) energy_idx_max = min(energy_idx_max, len(self.energy)) # TODO: extract this into a utility function `npred_weighted_mean()` # Compute weights for energy bins weights = np.zeros_like(self.energy.value, dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): energy_min = self.energy[idx] energy_max = self.energy[idx + 1] exposure = self.exposure[idx] flux = spectrum(energy_min) weights[idx] = (exposure * flux * (energy_max - energy_min)).value # Normalize weights to sum to 1 weights = weights / weights.sum() # Compute weighted PSF value array total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): psf_value = self._get_1d_psf_values(idx) total_psf_value += weights[idx] * psf_value # TODO: add version that returns `total_psf_value` without # making a `TablePSF`. return TablePSF(self.rad, total_psf_value, **kwargs) def containment_radius(self, energies, fraction, interp_kwargs=None): """Containment radius. Parameters ---------- energies : `~astropy.units.Quantity` Energy fraction : float Containment fraction in % Returns ------- rad : `~astropy.units.Quantity` Containment radius in deg """ # TODO: figure out if there's a more efficient implementation to support # arrays of energy energies = np.atleast_1d(energies) psfs = [ self.table_psf_at_energy(energy, interp_kwargs) for energy in energies ] rad = [psf.containment_radius(fraction) for psf in psfs] return Quantity(rad) def integral(self, energy, rad_min, rad_max): """Containment fraction. Parameters ---------- energy : `~astropy.units.Quantity` Energy rad_min, rad_max : `~astropy.coordinates.Angle` Offset Returns ------- fraction : array_like Containment fraction (in range 0 .. 1) """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy) return psf.integral(rad_min, rad_max) def info(self): """Print basic info""" print(str(self)) def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]): """Plot PSF vs radius. Parameters ---------- TODO """ import matplotlib.pyplot as plt plt.figure(figsize=(6, 4)) for energy in energies: energy_index = self._energy_index(energy) psf = self.psf_value[energy_index, :] label = "{} GeV".format(1e-3 * energy) x = np.hstack([-self.rad[::-1], self.rad]) y = 1e-6 * np.hstack([psf[::-1], psf]) plt.plot(x, y, lw=2, label=label) # plt.semilogy() # plt.loglog() plt.legend() plt.xlim(-0.2, 0.5) plt.xlabel("Offset (deg)") plt.ylabel("PSF (1e-6 sr^-1)") plt.tight_layout() def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 0.8, 0.95], **kwargs): """Plot containment versus energy.""" import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = Energy.equal_log_spacing(self.energy.min(), self.energy.max(), 10) for fraction in fractions: rad = self.containment_radius(energy, fraction) label = "{:.1f}% Containment".format(100 * fraction) ax.plot(energy.value, rad.value, label=label, **kwargs) ax.semilogx() ax.legend(loc="best") ax.set_xlabel("Energy (GeV)") ax.set_ylabel("Containment radius (deg)") def plot_exposure_vs_energy(self): """Plot exposure versus energy.""" import matplotlib.pyplot as plt plt.figure(figsize=(4, 3)) plt.plot(self.energy, self.exposure, color="black", lw=3) plt.semilogx() plt.xlabel("Energy (MeV)") plt.ylabel("Exposure (cm^2 s)") plt.xlim(1e4 / 1.3, 1.3 * 1e6) plt.ylim(0, 1.5e11) plt.tight_layout() def _energy_index(self, energy): """Find energy array index. """ # TODO: test with array input return np.searchsorted(self.energy, energy) def _get_1d_psf_values(self, energy_index): """Get 1-dim PSF value array. Parameters ---------- energy_index : int Energy index Returns ------- psf_values : `~astropy.units.Quantity` PSF value array """ psf_values = self.psf_value[energy_index, :].flatten().copy() # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes psf_values[np.isnan(psf_values)] = 0 return psf_values def _get_1d_table_psf(self, energy_index, **kwargs): """Get 1-dim TablePSF (cached). Parameters ---------- energy_index : int Energy index Returns ------- table_psf : `TablePSF` Table PSF """ # TODO: support array_like `energy_index` here? if self._table_psf_cache[energy_index] is None: psf_value = self._get_1d_psf_values(energy_index) table_psf = TablePSF(self.rad, psf_value, **kwargs) self._table_psf_cache[energy_index] = table_psf return self._table_psf_cache[energy_index]
class EnergyDependentTablePSF(object): """Energy-dependent radially-symmetric table PSF (``gtpsf`` format). TODO: add references and explanations. Parameters ---------- energy : `~astropy.units.Quantity` Energy (1-dim) rad : `~astropy.units.Quantity` with angle units Offset angle wrt source position (1-dim) exposure : `~astropy.units.Quantity` Exposure (1-dim) psf_value : `~astropy.units.Quantity` PSF (2-dim with axes: psf[energy_index, offset_index] """ def __init__(self, energy, rad, exposure=None, psf_value=None): self.energy = Quantity(energy).to('GeV') self.rad = Quantity(rad).to('radian') if exposure is None: self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s') else: self.exposure = Quantity(exposure).to('cm^2 s') if psf_value is None: self.psf_value = Quantity(np.zeros(len(energy), len(rad)), 'sr^-1') else: self.psf_value = Quantity(psf_value).to('sr^-1') # Cache for TablePSF at each energy ... only computed when needed self._table_psf_cache = [None] * len(self.energy) def __str__(self): ss = 'EnergyDependentTablePSF\n' ss += '-----------------------\n' ss += '\nAxis info:\n' ss += ' ' + array_stats_str(self.rad.to('deg'), 'rad') ss += ' ' + array_stats_str(self.energy, 'energy') # ss += ' ' + array_stats_str(self.exposure, 'exposure') # ss += 'integral = {}\n'.format(self.integral()) ss += '\nContainment info:\n' # Print some example containment radii fractions = [0.68, 0.95] energies = Quantity([10, 100], 'GeV') for fraction in fractions: rads = self.containment_radius(energies=energies, fraction=fraction) for energy, rad in zip(energies, rads): ss += ' ' + '{}% containment radius at {:3.0f}: {:.2f}\n'.format(100 * fraction, energy, rad) return ss @classmethod def from_fits(cls, hdu_list): """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list. Parameters ---------- hdu_list : `~astropy.io.fits.HDUList` HDU list with ``THETA`` and ``PSF`` extensions. """ rad = Angle(hdu_list['THETA'].data['Theta'], 'deg') energy = Quantity(hdu_list['PSF'].data['Energy'], 'MeV') exposure = Quantity(hdu_list['PSF'].data['Exposure'], 'cm^2 s') psf_value = Quantity(hdu_list['PSF'].data['PSF'], 'sr^-1') return cls(energy, rad, exposure, psf_value) def to_fits(self): """Convert to FITS HDU list format. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # TODO: write HEADER keywords as gtpsf data = self.rad theta_hdu = fits.BinTableHDU(data=data, name='Theta') data = [self.energy, self.exposure, self.psf_value] psf_hdu = fits.BinTableHDU(data=data, name='PSF') hdu_list = fits.HDUList([theta_hdu, psf_hdu]) return hdu_list @classmethod def read(cls, filename): """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file. Parameters ---------- filename : str File name """ hdu_list = fits.open(filename) return cls.from_fits(hdu_list) def write(self, *args, **kwargs): """Write to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(*args, **kwargs) def evaluate(self, energy=None, rad=None, interp_kwargs=None): """Evaluate the PSF at a given energy and offset Parameters ---------- energy : `~astropy.units.Quantity` energy value rad : `~astropy.coordinates.Angle` Offset wrt source position interp_kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- values : `~astropy.units.Quantity` Interpolated value """ if interp_kwargs is None: interp_kwargs = dict(bounds_error=False, fill_value=None) from scipy.interpolate import RegularGridInterpolator if energy is None: energy = self.energy if rad is None: rad = self.rad energy = Energy(energy).to('TeV') rad = Angle(rad).to('deg') energy_bin = self.energy.to('TeV') rad_bin = self.rad.to('deg') points = (energy_bin, rad_bin) interpolator = RegularGridInterpolator(points, self.psf_value, **interp_kwargs) energy_grid, rad_grid = np.meshgrid(energy.value, rad.value, indexing='ij') shape = energy_grid.shape pix_coords = np.column_stack([energy_grid.flat, rad_grid.flat]) data_interp = interpolator(pix_coords) return Quantity(data_interp.reshape(shape), self.psf_value.unit) def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs): """Evaluate the `EnergyOffsetArray` at one given energy. Parameters ---------- energy : `~astropy.units.Quantity` Energy interp_kwargs : dict Option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- table : `~astropy.table.Table` Table with two columns: offset, value """ psf_value = self.evaluate(energy, None, interp_kwargs)[0, :] table_psf = TablePSF(self.rad, psf_value, **kwargs) return table_psf def kernels(self, cube, rad_max, **kwargs): """ Make a set of 2D kernel images, representing the PSF at different energies. The kernel image is evaluated on the spatial and energy grid defined by the reference sky cube. Parameters ---------- cube : `~gammapy.cube.SkyCube` Reference sky cube. rad_max `~astropy.coordinates.Angle` PSF kernel size kwargs : dict Keyword arguments passed to `EnergyDependentTablePSF.table_psf_in_energy_band()`. Returns ------- kernels : list of `~numpy.ndarray` List of 2D convolution kernels. """ energies = cube.energies(mode='edges') kernels = [] for emin, emax in zip(energies[:-1], energies[1:]): energy_band = Quantity([emin, emax]) try: psf = self.table_psf_in_energy_band(energy_band, **kwargs) kernel = psf.kernel(cube.sky_image_ref, rad_max=rad_max) except ValueError: kernel = np.nan * np.ones((1, 1)) # Dummy, means "no kernel available" kernels.append(kernel) return kernels def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs): """Average PSF in a given energy band. Expected counts in sub energy bands given the given exposure and spectrum are used as weights. Parameters ---------- energy_band : `~astropy.units.Quantity` Energy band spectral_index : float Power law spectral index (used if spectrum=None). spectrum : callable Spectrum (callable with energy as parameter). Returns ------- psf : `TablePSF` Table PSF """ if spectrum is None: def spectrum(energy): return (energy / energy_band[0]) ** (-spectral_index) # TODO: warn if `energy_band` is outside available data. energy_idx_min, energy_idx_max = self._energy_index(energy_band) # TODO: improve this, probably by evaluating the PSF (i.e. interpolating in energy) onto a new energy grid # This is a bit of a hack, but makes sure that a PSF is given, by forcing at least one slice: if energy_idx_max - energy_idx_min < 2: # log.warning('Dubious case of PSF energy binning') # Note that below always range stop of `energy_idx_max - 1` is used! # That's why we put +2 here to make sure we have at least one bin. energy_idx_max = max(energy_idx_min + 2, energy_idx_max) # Make sure we don't step out of the energy array (doesn't help much) energy_idx_max = min(energy_idx_max, len(self.energy)) # TODO: extract this into a utility function `npred_weighted_mean()` # Compute weights for energy bins weights = np.zeros_like(self.energy.value, dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): energy_min = self.energy[idx] energy_max = self.energy[idx + 1] exposure = self.exposure[idx] flux = spectrum(energy_min) weights[idx] = (exposure * flux * (energy_max - energy_min)).value # Normalize weights to sum to 1 weights = weights / weights.sum() # Compute weighted PSF value array total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): psf_value = self._get_1d_psf_values(idx) total_psf_value += weights[idx] * psf_value # TODO: add version that returns `total_psf_value` without # making a `TablePSF`. return TablePSF(self.rad, total_psf_value, **kwargs) def containment_radius(self, energies, fraction, interp_kwargs=None): """Containment radius. Parameters ---------- energies : `~astropy.units.Quantity` Energy fraction : float Containment fraction in % Returns ------- rad : `~astropy.units.Quantity` Containment radius in deg """ # TODO: figure out if there's a more efficient implementation to support # arrays of energy energies = np.atleast_1d(energies) psfs = [self.table_psf_at_energy(energy, interp_kwargs) for energy in energies] rad = [psf.containment_radius(fraction) for psf in psfs] return Quantity(rad) def integral(self, energy, rad_min, rad_max): """Containment fraction. Parameters ---------- energy : `~astropy.units.Quantity` Energy rad_min, rad_max : `~astropy.coordinates.Angle` Offset Returns ------- fraction : array_like Containment fraction (in range 0 .. 1) """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy) return psf.integral(rad_min, rad_max) def info(self): """Print basic info""" print(self.__str__) def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]): """Plot PSF vs radius. Parameters ---------- TODO """ import matplotlib.pyplot as plt plt.figure(figsize=(6, 4)) for energy in energies: energy_index = self._energy_index(energy) psf = self.psf_value[energy_index, :] label = '{} GeV'.format(1e-3 * energy) x = np.hstack([-self.rad[::-1], self.rad]) y = 1e-6 * np.hstack([psf[::-1], psf]) plt.plot(x, y, lw=2, label=label) # plt.semilogy() # plt.loglog() plt.legend() plt.xlim(-0.2, 0.5) plt.xlabel('Offset (deg)') plt.ylabel('PSF (1e-6 sr^-1)') plt.tight_layout() def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 0.8, 0.95], **kwargs): """Plot containment versus energy.""" import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = Energy.equal_log_spacing( self.energy.min(), self.energy.max(), 10) for fraction in fractions: rad = self.containment_radius(energy, fraction) label = '{:.1f}% Containment'.format(100 * fraction) ax.plot(energy.value, rad.value, label=label, **kwargs) ax.semilogx() ax.legend(loc='best') ax.set_xlabel('Energy (GeV)') ax.set_ylabel('Containment radius (deg)') def plot_exposure_vs_energy(self): """Plot exposure versus energy.""" import matplotlib.pyplot as plt plt.figure(figsize=(4, 3)) plt.plot(self.energy, self.exposure, color='black', lw=3) plt.semilogx() plt.xlabel('Energy (MeV)') plt.ylabel('Exposure (cm^2 s)') plt.xlim(1e4 / 1.3, 1.3 * 1e6) plt.ylim(0, 1.5e11) plt.tight_layout() def _energy_index(self, energy): """Find energy array index. """ # TODO: test with array input return np.searchsorted(self.energy, energy) def _get_1d_psf_values(self, energy_index): """Get 1-dim PSF value array. Parameters ---------- energy_index : int Energy index Returns ------- psf_values : `~astropy.units.Quantity` PSF value array """ psf_values = self.psf_value[energy_index, :].flatten().copy() where_are_NaNs = np.isnan(psf_values) # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes psf_values[where_are_NaNs] = 0 return psf_values def _get_1d_table_psf(self, energy_index, **kwargs): """Get 1-dim TablePSF (cached). Parameters ---------- energy_index : int Energy index Returns ------- table_psf : `TablePSF` Table PSF """ # TODO: support array_like `energy_index` here? if self._table_psf_cache[energy_index] is None: psf_value = self._get_1d_psf_values(energy_index) table_psf = TablePSF(self.rad, psf_value, **kwargs) self._table_psf_cache[energy_index] = table_psf return self._table_psf_cache[energy_index]
class EnergyDependentTablePSF(object): """Energy-dependent radially-symmetric table PSF (``gtpsf`` format). TODO: add references and explanations. Parameters ---------- energy : `~astropy.units.Quantity` Energy (1-dim) rad : `~astropy.units.Quantity` with angle units Offset angle wrt source position (1-dim) exposure : `~astropy.units.Quantity` Exposure (1-dim) psf_value : `~astropy.units.Quantity` PSF (2-dim with axes: psf[energy_index, offset_index] """ def __init__(self, energy, rad, exposure=None, psf_value=None): self.energy = Quantity(energy).to('GeV') self.rad = Quantity(rad).to('radian') if not exposure: self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s') else: self.exposure = Quantity(exposure).to('cm^2 s') if not psf_value: self.psf_value = Quantity(np.zeros(len(energy), len(rad)), 'sr^-1') else: self.psf_value = Quantity(psf_value).to('sr^-1') # Cache for TablePSF at each energy ... only computed when needed self._table_psf_cache = [None] * len(self.energy) def __str__(self): ss = 'EnergyDependentTablePSF\n' ss += '-----------------------\n' ss += '\nAxis info:\n' ss += ' ' + array_stats_str(self.rad.to('deg'), 'rad') ss += ' ' + array_stats_str(self.energy, 'energy') # ss += ' ' + array_stats_str(self.exposure, 'exposure') # ss += 'integral = {}\n'.format(self.integral()) ss += '\nContainment info:\n' # Print some example containment radii fractions = [0.68, 0.95] energies = Quantity([10, 100], 'GeV') for fraction in fractions: rads = self.containment_radius(energies=energies, fraction=fraction) for energy, rad in zip(energies, rads): ss += ' ' + '{}% containment radius at {:3.0f}: {:.2f}\n'.format(100 * fraction, energy, rad) return ss @classmethod def from_fits(cls, hdu_list): """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list. Parameters ---------- hdu_list : `~astropy.io.fits.HDUList` HDU list with ``THETA`` and ``PSF`` extensions. """ rad = Angle(hdu_list['THETA'].data['Theta'], 'deg') energy = Quantity(hdu_list['PSF'].data['Energy'], 'MeV') exposure = Quantity(hdu_list['PSF'].data['Exposure'], 'cm^2 s') psf_value = Quantity(hdu_list['PSF'].data['PSF'], 'sr^-1') return cls(energy, rad, exposure, psf_value) def to_fits(self): """Convert to FITS HDU list format. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # TODO: write HEADER keywords as gtpsf data = self.rad theta_hdu = fits.BinTableHDU(data=data, name='Theta') data = [self.energy, self.exposure, self.psf_value] psf_hdu = fits.BinTableHDU(data=data, name='PSF') hdu_list = fits.HDUList([theta_hdu, psf_hdu]) return hdu_list @classmethod def read(cls, filename): """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file. Parameters ---------- filename : str File name """ hdu_list = fits.open(filename) return cls.from_fits(hdu_list) def write(self, *args, **kwargs): """Write to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(*args, **kwargs) def evaluate(self, energy=None, rad=None, interp_kwargs=None): """Evaluate the PSF at a given energy and offset Parameters ---------- energy : `~astropy.units.Quantity` energy value rad : `~astropy.coordinates.Angle` Offset wrt source position interp_kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- values : `~astropy.units.Quantity` Interpolated value """ if not interp_kwargs: interp_kwargs = dict(bounds_error=False, fill_value=None) from scipy.interpolate import RegularGridInterpolator if energy is None: energy = self.energy if rad is None: rad = self.rad energy = Energy(energy).to('TeV') rad = Angle(rad).to('deg') energy_bin = self.energy.to('TeV') rad_bin = self.rad.to('deg') points = (energy_bin, rad_bin) interpolator = RegularGridInterpolator(points, self.psf_value, **interp_kwargs) energy_grid, rad_grid = np.meshgrid(energy.value, rad.value, indexing='ij') shape = energy_grid.shape pix_coords = np.column_stack([energy_grid.flat, rad_grid.flat]) data_interp = interpolator(pix_coords) return Quantity(data_interp.reshape(shape), self.psf_value.unit) def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs): """Evaluate the `EnergyOffsetArray` at one given energy. Parameters ---------- energy : `~astropy.units.Quantity` Energy interp_kwargs : dict Option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- table : `~astropy.table.Table` Table with two columns: offset, value """ psf_value = self.evaluate(energy, None, interp_kwargs)[0, :] table_psf = TablePSF(self.rad, psf_value, **kwargs) return table_psf # TODO: improve this method to work also if there are energy bins where PSF is bad # Currently it fails because TablePSF.kernel calls containment, which can be `NaN`. # Options: # 1. Let the caller specify the kernel size and don't compute containment # 2. try-except the TablePSF.kernel call and just return a single pixel with `NaN` as kernel where none is available # Don't forget to add a test! def kernels(self, cube, **kwargs): """ Make a set of 2D kernel images, representing the PSF at different energies. The kernel image is evaluated on the spatial and energy grid defined by the reference sky cube. Parameters ---------- cube : `~gammapy.cube.SkyCube` Reference sky cube. kwargs : dict Keyword arguments passed to `EnergyDependentTablePSF.table_psf_in_energy_band()`. Returns ------- kernels : list of `~numpy.ndarray` List of 2D convolution kernels. """ energies = cube.energies(mode='edges') kernels = [] for emin, emax in zip(energies[:-1], energies[1:]): energy_band = Quantity([emin, emax]) try: psf = self.table_psf_in_energy_band(energy_band, **kwargs) kernel = psf.kernel(cube.sky_image_ref) except ValueError: kernel = np.nan * np.ones((1, 1)) # Dummy, means "no kernel available" kernels.append(kernel) return kernels def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs): """Average PSF in a given energy band. Expected counts in sub energy bands given the given exposure and spectrum are used as weights. Parameters ---------- energy_band : `~astropy.units.Quantity` Energy band spectral_index : float Power law spectral index (used if spectrum=None). spectrum : callable Spectrum (callable with energy as parameter). Returns ------- psf : `TablePSF` Table PSF """ if spectrum is None: def spectrum(energy): return (energy / energy_band[0]) ** (-spectral_index) # TODO: warn if `energy_band` is outside available data. energy_idx_min, energy_idx_max = self._energy_index(energy_band) # TODO: improve this, probably by evaluating the PSF (i.e. interpolating in energy) onto a new energy grid # This is a bit of a hack, but makes sure that a PSF is given, by forcing at least one slice: if energy_idx_max - energy_idx_min < 2: # log.warning('Dubious case of PSF energy binning') # Note that below always range stop of `energy_idx_max - 1` is used! # That's why we put +2 here to make sure we have at least one bin. energy_idx_max = max(energy_idx_min + 2, energy_idx_max) # Make sure we don't step out of the energy array (doesn't help much) energy_idx_max = min(energy_idx_max, len(self.energy)) # TODO: extract this into a utility function `npred_weighted_mean()` # Compute weights for energy bins weights = np.zeros_like(self.energy.value, dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): energy_min = self.energy[idx] energy_max = self.energy[idx + 1] exposure = self.exposure[idx] flux = spectrum(energy_min) weights[idx] = (exposure * flux * (energy_max - energy_min)).value # Normalize weights to sum to 1 weights = weights / weights.sum() # Compute weighted PSF value array total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): psf_value = self._get_1d_psf_values(idx) total_psf_value += weights[idx] * psf_value # TODO: add version that returns `total_psf_value` without # making a `TablePSF`. return TablePSF(self.rad, total_psf_value, **kwargs) def containment_radius(self, energies, fraction, interp_kwargs=None): """Containment radius. Parameters ---------- energies : `~astropy.units.Quantity` Energy fraction : float Containment fraction in % Returns ------- rad : `~astropy.units.Quantity` Containment radius in deg """ # TODO: figure out if there's a more efficient implementation to support # arrays of energy energies = np.atleast_1d(energies) psfs = [self.table_psf_at_energy(energy, interp_kwargs) for energy in energies] rad = [psf.containment_radius(fraction) for psf in psfs] return Quantity(rad) def integral(self, energy, rad_min, rad_max): """Containment fraction. Parameters ---------- energy : `~astropy.units.Quantity` Energy rad_min, rad_max : `~astropy.coordinates.Angle` Offset Returns ------- fraction : array_like Containment fraction (in range 0 .. 1) """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy) return psf.integral(rad_min, rad_max) def info(self): """Print basic info""" print(self.__str__) def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]): """Plot PSF vs radius. Parameters ---------- TODO """ import matplotlib.pyplot as plt plt.figure(figsize=(6, 4)) for energy in energies: energy_index = self._energy_index(energy) psf = self.psf_value[energy_index, :] label = '{} GeV'.format(1e-3 * energy) x = np.hstack([-self.rad[::-1], self.rad]) y = 1e-6 * np.hstack([psf[::-1], psf]) plt.plot(x, y, lw=2, label=label) # plt.semilogy() # plt.loglog() plt.legend() plt.xlim(-0.2, 0.5) plt.xlabel('Offset (deg)') plt.ylabel('PSF (1e-6 sr^-1)') plt.tight_layout() def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 0.8, 0.95], **kwargs): """Plot containment versus energy.""" import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = Energy.equal_log_spacing( self.energy.min(), self.energy.max(), 10) for fraction in fractions: rad = self.containment_radius(energy, fraction) label = '{:.1f}% Containment'.format(100 * fraction) ax.plot(energy.value, rad.value, label=label, **kwargs) ax.semilogx() ax.legend(loc='best') ax.set_xlabel('Energy (GeV)') ax.set_ylabel('Containment radius (deg)') def plot_exposure_vs_energy(self): """Plot exposure versus energy.""" import matplotlib.pyplot as plt plt.figure(figsize=(4, 3)) plt.plot(self.energy, self.exposure, color='black', lw=3) plt.semilogx() plt.xlabel('Energy (MeV)') plt.ylabel('Exposure (cm^2 s)') plt.xlim(1e4 / 1.3, 1.3 * 1e6) plt.ylim(0, 1.5e11) plt.tight_layout() def _energy_index(self, energy): """Find energy array index. """ # TODO: test with array input return np.searchsorted(self.energy, energy) def _get_1d_psf_values(self, energy_index): """Get 1-dim PSF value array. Parameters ---------- energy_index : int Energy index Returns ------- psf_values : `~astropy.units.Quantity` PSF value array """ psf_values = self.psf_value[energy_index, :].flatten().copy() where_are_NaNs = np.isnan(psf_values) # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes psf_values[where_are_NaNs] = 0 return psf_values def _get_1d_table_psf(self, energy_index, **kwargs): """Get 1-dim TablePSF (cached). Parameters ---------- energy_index : int Energy index Returns ------- table_psf : `TablePSF` Table PSF """ # TODO: support array_like `energy_index` here? if self._table_psf_cache[energy_index] is None: psf_value = self._get_1d_psf_values(energy_index) table_psf = TablePSF(self.rad, psf_value, **kwargs) self._table_psf_cache[energy_index] = table_psf return self._table_psf_cache[energy_index]
def _slice_optimize(self, intensity_avg: u.Quantity) -> np.array: intensity_avg_norm = intensity_avg / intensity_avg.max(self.axis.time) return intensity_avg_norm.mean(self.axis.channel) >= 0.25
class EnergyDependentTablePSF(object): """Energy-dependent radially-symmetric table PSF (``gtpsf`` format). TODO: add references and explanations. Parameters ---------- energy : `~astropy.units.Quantity` Energy (1-dim) offset : `~astropy.units.Quantity` with angle units Offset angle (1-dim) exposure : `~astropy.units.Quantity` Exposure (1-dim) psf_value : `~astropy.units.Quantity` PSF (2-dim with axes: psf[energy_index, offset_index] """ def __init__(self, energy, offset, exposure=None, psf_value=None): self.energy = Quantity(energy).to('GeV') self.offset = Quantity(offset).to('radian') if not exposure: self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s') else: self.exposure = Quantity(exposure).to('cm^2 s') if not psf_value: self.psf_value = Quantity(np.zeros(len(energy), len(offset)), 'sr^-1') else: self.psf_value = Quantity(psf_value).to('sr^-1') # Cache for TablePSF at each energy ... only computed when needed self._table_psf_cache = [None] * len(self.energy) @classmethod def from_fits(cls, hdu_list): """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list. Parameters ---------- hdu_list : `~astropy.io.fits.HDUList` HDU list with ``THETA`` and ``PSF`` extensions. """ offset = Angle(hdu_list['THETA'].data['Theta'], 'deg') energy = Quantity(hdu_list['PSF'].data['Energy'], 'MeV') exposure = Quantity(hdu_list['PSF'].data['Exposure'], 'cm^2 s') psf_value = Quantity(hdu_list['PSF'].data['PSF'], 'sr^-1') return cls(energy, offset, exposure, psf_value) def to_fits(self): """Convert to FITS HDU list format. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # TODO: write HEADER keywords as gtpsf data = self.offset theta_hdu = fits.BinTableHDU(data=data, name='Theta') data = [self.energy, self.exposure, self.psf_value] psf_hdu = fits.BinTableHDU(data=data, name='PSF') hdu_list = fits.HDUList([theta_hdu, psf_hdu]) return hdu_list @classmethod def read(cls, filename): """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file. Parameters ---------- filename : str File name """ hdu_list = fits.open(filename) return cls.from_fits(hdu_list) def write(self, *args, **kwargs): """Write to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(*args, **kwargs) def evaluate(self, energy=None, offset=None, interp_kwargs=None): """Interpolate the value of the `EnergyOffsetArray` at a given offset and Energy. Parameters ---------- energy : `~astropy.units.Quantity` energy value offset : `~astropy.coordinates.Angle` offset value interp_kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- values : `~astropy.units.Quantity` Interpolated value """ if not interp_kwargs: interp_kwargs = dict(bounds_error=False, fill_value=None) from scipy.interpolate import RegularGridInterpolator if energy is None: energy = self.energy if offset is None: offset = self.offset energy = Energy(energy).to('TeV') offset = Angle(offset).to('deg') energy_bin = self.energy.to('TeV') offset_bin = self.offset.to('deg') points = (energy_bin, offset_bin) interpolator = RegularGridInterpolator(points, self.psf_value, **interp_kwargs) ee, off = np.meshgrid(energy.value, offset.value, indexing='ij') shape = ee.shape pix_coords = np.column_stack([ee.flat, off.flat]) data_interp = interpolator(pix_coords) return Quantity(data_interp.reshape(shape), self.psf_value.unit) def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs): """Evaluate the `EnergyOffsetArray` at one given energy. Parameters ---------- energy : `~astropy.units.Quantity` Energy interp_kwargs : dict Option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- table : `~astropy.table.Table` Table with two columns: offset, value """ psf_value = self.evaluate(energy, None, interp_kwargs)[0, :] table_psf = TablePSF(self.offset, psf_value, **kwargs) return table_psf def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs): """Average PSF in a given energy band. Expected counts in sub energy bands given the given exposure and spectrum are used as weights. Parameters ---------- energy_band : `~astropy.units.Quantity` Energy band spectral_index : float Power law spectral index (used if spectrum=None). spectrum : callable Spectrum (callable with energy as parameter). Returns ------- psf : `TablePSF` Table PSF """ if spectrum is None: def spectrum(energy): return (energy / energy_band[0]) ** (-spectral_index) # TODO: warn if `energy_band` is outside available data. energy_idx_min, energy_idx_max = self._energy_index(energy_band) # TODO: extract this into a utility function `npred_weighted_mean()` # Compute weights for energy bins weights = np.zeros_like(self.energy.value, dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): energy_min = self.energy[idx] energy_max = self.energy[idx + 1] exposure = self.exposure[idx] flux = spectrum(energy_min) weights[idx] = (exposure * flux * (energy_max - energy_min)).value # Normalize weights to sum to 1 weights = weights / weights.sum() # Compute weighted PSF value array total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): psf_value = self._get_1d_psf_values(idx) total_psf_value += weights[idx] * psf_value # TODO: add version that returns `total_psf_value` without # making a `TablePSF`. return TablePSF(self.offset, total_psf_value, **kwargs) def containment_radius(self, energies, fraction, interp_kwargs=None): """Containment radius. Parameters ---------- energies : `~astropy.units.Quantity` Energy fraction : float Containment fraction in % Returns ------- radius : `~astropy.units.Quantity` Containment radius in deg """ # TODO: figure out if there's a more efficient implementation to support # arrays of energy energies = np.atleast_1d(energies) psfs = [self.table_psf_at_energy(energy, interp_kwargs) for energy in energies] radii = [psf.containment_radius(fraction) for psf in psfs] return Quantity(radii) def integral(self, energy, offset_min, offset_max): """Containment fraction. Parameters ---------- energy : `~astropy.units.Quantity` Energy offset_min, offset_max : `~astropy.coordinates.Angle` Offset Returns ------- fraction : array_like Containment fraction (in range 0 .. 1) """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy) return psf.integral(offset_min, offset_max) def info(self): """Print basic info.""" # Summarise data members ss = array_stats_str(self.offset.to('deg'), 'offset') ss += array_stats_str(self.energy, 'energy') ss += array_stats_str(self.exposure, 'exposure') # ss += 'integral = {0}\n'.format(self.integral()) # Print some example containment radii fractions = [0.68, 0.95] energies = Quantity([10, 100], 'GeV') for energy in energies: for fraction in fractions: radius = self.containment_radius(energy=energy, fraction=fraction) ss += '{0}% containment radius at {1}: {2}\n'.format(100 * fraction, energy, radius) return ss def plot_psf_vs_theta(self, filename=None, energies=[1e4, 1e5, 1e6]): """Plot PSF vs theta. Parameters ---------- TODO """ import matplotlib.pyplot as plt plt.figure(figsize=(6, 4)) for energy in energies: energy_index = self._energy_index(energy) psf = self.psf_value[energy_index, :] label = '{0} GeV'.format(1e-3 * energy) x = np.hstack([-self.theta[::-1], self.theta]) y = 1e-6 * np.hstack([psf[::-1], psf]) plt.plot(x, y, lw=2, label=label) # plt.semilogy() # plt.loglog() plt.legend() plt.xlim(-0.2, 0.5) plt.xlabel('Offset (deg)') plt.ylabel('PSF (1e-6 sr^-1)') plt.tight_layout() if filename != None: plt.savefig(filename) def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 0.8, 0.95], **kwargs): """Plot containment versus energy.""" import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = Energy.equal_log_spacing( self.energy.min(), self.energy.max(), 10) for fraction in fractions: radius = self.containment_radius(energy, fraction) label = '{:.1f}% Containment'.format(100 * fraction) ax.plot(energy.value, radius.value, label=label, **kwargs) ax.semilogx() ax.legend(loc='best') ax.set_xlabel('Energy (GeV)') ax.set_ylabel('Containment radius (deg)') def plot_exposure_vs_energy(self, filename=None): """Plot exposure versus energy.""" import matplotlib.pyplot as plt plt.figure(figsize=(4, 3)) plt.plot(self.energy, self.exposure, color='black', lw=3) plt.semilogx() plt.xlabel('Energy (MeV)') plt.ylabel('Exposure (cm^2 s)') plt.xlim(1e4 / 1.3, 1.3 * 1e6) plt.ylim(0, 1.5e11) plt.tight_layout() if filename != None: plt.savefig(filename) def _energy_index(self, energy): """Find energy array index. """ # TODO: test with array input return np.searchsorted(self.energy, energy) def _get_1d_psf_values(self, energy_index): """Get 1-dim PSF value array. Parameters ---------- energy_index : int Energy index Returns ------- psf_values : `~astropy.units.Quantity` PSF value array """ psf_values = self.psf_value[energy_index, :].flatten().copy() return psf_values def _get_1d_table_psf(self, energy_index, **kwargs): """Get 1-dim TablePSF (cached). Parameters ---------- energy_index : int Energy index Returns ------- table_psf : `TablePSF` Table PSF """ # TODO: support array_like `energy_index` here? if self._table_psf_cache[energy_index] is None: psf_value = self._get_1d_psf_values(energy_index) table_psf = TablePSF(self.offset, psf_value, **kwargs) self._table_psf_cache[energy_index] = table_psf return self._table_psf_cache[energy_index]