Source code for galpak.lib.spread_functions

# coding=utf-8

import numpy as np


try:
    from astropy.io import fits
except ImportError:
    import pyfits as fits

import logging

logging.basicConfig(level=logging.INFO)

#
# This file contains the PointSpreadFunction and LineSpreadFunction interfaces
# as well as some basic implementations of these interfaces :
#   - Gaussian PSF
#   - Moffat PSF
#   - Gaussian LSF
#   - MUSE LSF (only if mpdaf module is available)
#
# The instrument will use both 2D PSF and 1D LSF
# to create a 3D PSF with which it will convolve the cubes.
#


## POINT SPREAD FUNCTIONS ######################################################


[docs]class PointSpreadFunction: """ This is the interface all Point Spread Functions (PSF) should implement. """ logger = logging.getLogger('GalPaK')
[docs] def as_image(self, for_cube): """ Should return this PSF as a 2D image shaped [for_cube]. for_cube: HyperspectralCube Has additional properties computed and attributed by GalPaK : - xy_step (in ") - z_step (in µm) - z_central (in µm) :rtype: ndarray """ raise NotImplementedError()
class NoPointSpreadFunction(PointSpreadFunction): """ A point spread function that does not spread anything, and returns the cube unchanged. Passing this to the instrument's psf is the same as passing None. """ def __init__(self): pass def as_image(self, for_cube): """ Return the identity PSF, chock-full of ones. """ shape = for_cube.shape[1:] return np.ones(shape) class ImagePointSpreadFunction(PointSpreadFunction): """ A custom point spread function using a provided 2D image that should have the same shape as the cube's (x,y) and centroid should be at xo = (shape[1] - 1) / 2 - (shape[1] % 2 - 1) yo = (shape[0] - 1) / 2 - (shape[0] % 2 - 1) """ def __init__(self, image_2d): """ accepts fits file or ndarray """ if isinstance(image_2d, str): my_image = fits.open(image_2d) if my_image['PRIMARY'].data is not None: image_2d = my_image['PRIMARY'].data elif my_image['DATA'].data is not None: image_2d = my_image['DATA'].data elif isinstance(image_2d,np.ndarray): image_2d = image_2d else: raise ValueError(' PSF provided is not a fits file nor an array !!') logging.info( "Normalizing PSF image") self.image_2d = image_2d / image_2d.sum() if isinstance(self.image_2d,np.ndarray) is False: raise ValueError(' PSF provided could not be stored in an ndarray') def as_image(self, for_cube): #check for size if for_cube.shape[1:] != self.image_2d.shape: raise ValueError(' PSF Image and cube have different sizes: %s vs. %s' % (str(for_cube.shape[1:]),str(self.image_2d.shape)) ) return self.image_2d def __str__(self): return """Custom Image PSF"""
[docs]class GaussianPointSpreadFunction(PointSpreadFunction): """ The default Gaussian Point Spread Function. fwhm: float Full Width Half Maximum in arcsec, aka. "seeing". pa: float [default is 0.] Position Angle, clockwise rotation from Y of ellipse, in angular degrees. ba: float [default is 1.0] Axis ratio of the ellipsis, b/a ratio (y/x). """ def __init__(self, fwhm=None, pa=0, ba=1.0): self.fwhm = fwhm self.pa = pa self.ba = ba def __str__(self): return """Gaussian PSF : fwhm = {i.fwhm} " pa = {i.pa} ° ba = {i.ba}""".format(i=self) def as_image(self, for_cube, xo=None, yo=None): shape = for_cube.shape[1:] if xo is None: xo = (shape[1] - 1) / 2 - (shape[1] % 2 - 1) if yo is None: yo = (shape[0] - 1) / 2 - (shape[0] % 2 - 1) y, x = np.indices(shape) r = self._radius(xo, yo, x, y) fwhm = self.fwhm / for_cube.xy_step #in pixels psf = np.exp(-0.5 * (r / (fwhm / 2.35482)) ** 2) return psf / psf.sum() def _radius(self, xo, yo, x, y): """ Computes the radii, taking into account the variance and the elliptic shape """ dx = xo - x dy = yo - y # Rotation matrix around z axis # R(90)=[[0,-1],[1,0]] so clock-wise y -> -x & x -> y radian_pa = np.radians(self.pa) dx_p = dx * np.cos(radian_pa) - dy * np.sin(radian_pa) dy_p = dx * np.sin(radian_pa) + dy * np.cos(radian_pa) return np.sqrt(dx_p ** 2 + dy_p ** 2 / self.ba ** 2)
[docs]class MoffatPointSpreadFunction(GaussianPointSpreadFunction): """ The Moffat Point Spread Function fwhm if alpha is None: float [in arcsec] Moffat's distribution fwhm variable : http://en.wikipedia.org/wiki/Moffat_distribution alpha if fwhm is None: float [in arcsec] Moffat's distribution alpha variable : http://en.wikipedia.org/wiki/Moffat_distribution beta: float Moffat's distribution beta variable : http://en.wikipedia.org/wiki/Moffat_distribution pa: float [default is 0.] Position Angle, the clockwise rotation from Y of ellipse, in angular degrees. ba: float [default is 1.0] Axis ratio of the ellipsis, b/a ratio (y/x). """ def __init__(self, fwhm=None, alpha=None, beta=None, pa=None, ba=None): self.alpha = alpha self.beta = beta GaussianPointSpreadFunction.__init__(self, fwhm, pa, ba) def __str__(self): return """Moffat PSF : fwhm = {i.fwhm} " alpha = {i.alpha} " beta = {i.beta} pa = {i.pa} ° ba = {i.ba}""".format(i=self) def as_image(self, for_cube, xo=None, yo=None): shape = for_cube.shape[1:] if xo is None: xo = (shape[1] - 1) / 2 - (shape[1] % 2 - 1) if yo is None: yo = (shape[0] - 1) / 2 - (shape[0] % 2 - 1) y, x = np.indices(shape) r = self._radius(xo, yo, x, y) beta = self.beta if self.alpha is None: # Get the FWHM in pixels (we assume the pixels are squares!) fwhm = self.fwhm / for_cube.xy_step alpha = fwhm / ( 2.*np.sqrt(2.**(1./beta)-1) ) if self.fwhm is None: # Get the FWHM in pixels (we assume the pixels are squares!) alpha = self.alpha / for_cube.xy_step fwhm = alpha * ( 2.*np.sqrt(2.**(1./beta)-1) ) psf = (1. + (r / alpha) ** 2) ** (-beta) return psf / psf.sum()
## LINE SPREAD FUNCTIONS #######################################################
[docs]class LineSpreadFunction: """ This is the interface all Line Spread Functions (LSF) should implement. """ z_cunit = 'Undef'
[docs] def as_vector(self, for_cube): """ Should return this LSF as a 1D vector shaped [for_cube]. for_cube: HyperspectralCube :rtype: ndarray """ raise NotImplementedError()
class NoLineSpreadFunction(PointSpreadFunction): """ A point spread function that does not spread anything, and returns the cube unchanged. Passing this to the instrument's lsf is the same as passing None. """ def __init__(self): pass def as_vector(self, for_cube): """ returns the identity LSF with ones :param for_cube: :return: """ shape = for_cube.shape[0] return np.ones(shape) def __str__(self): return """no LSF! """ class VectorLineSpreadFunction(LineSpreadFunction): """ A custom line spread function using a provided 1D `vector` that should have the same length as the cube's (z). Should be centered around zo = (zsize - 1) / 2 - (zsize % 2 - 1) """ def __init__(self, vector): self.vector = vector def as_vector(self, for_cube): return self.vector def __str__(self): return """Custom Vector LSF"""
[docs]class GaussianLineSpreadFunction(LineSpreadFunction): """ A line spread function that spreads as a gaussian. We assume the centroid is in the middle. fwhm: float Full Width Half Maximum, in µm. """ def __init__(self, fwhm): self.fwhm = fwhm def __str__(self): return """Gaussian LSF : fwhm = {i.fwhm} {i.z_cunit} \n""".format(i=self) def as_vector(self, for_cube): # Std deviation from FWHM sigma = self.fwhm / 2.35482 / for_cube.z_step # Resulting vector shape depth = for_cube.shape[0] # Assymmetric range around 0 zo = (depth - 1) / 2 - (depth % 2 - 1) z_range = np.arange(depth) - zo # Compute gaussian (we assume peak is at 0, ie. µ=0) lsf_1d = self.gaussian(z_range, 0, sigma) # Normalize and serve return lsf_1d / lsf_1d.sum() @staticmethod def gaussian(x, mu, sigma): """ Non-normalized gaussian function. x : float|numpy.ndarray Input value(s) mu : float Position of the peak on the x-axis sigma : float Standard deviation :rtype: Float value(s) after transformation, of the same shape as input x. """ return np.exp((x - mu) ** 2 / (-2. * sigma ** 2))
[docs]class MUSELineSpreadFunction(LineSpreadFunction): """ A line spread function that uses MPDAF's LSF. See http://urania1.univ-lyon1.fr/mpdaf/chrome/site/DocCoreLib/user_manual_PSF.html .. warning:: This requires the mpdaf module. Currently, the MPDAF module only works for odd arrays. model: string See ``mpdaf.MUSE.LSF``'s ``typ`` parameter. """ def __init__(self, model="qsim_v1"): self.model = model try: from mpdaf.MUSE import LSF except ImportError: raise ImportError("You need the mpdaf module to use MUSELineSpreadFunction.") self.lsf = LSF(typ=self.model) def __str__(self): return """MUSE LSF : model = '{i.model}'""".format(i=self) def as_vector(self, cube): # Resulting vector shape depth = cube.shape[0] odd_depth = depth if depth % 2 == 1 else depth+1 # Get LSF 1D from MPDAF if 'micron' in cube.z_cunit.lower(): wavelength_aa = cube.z_central * 1e4 # unit conversion from microns to AA z_step_aa = cube.z_step * 1e4 else: wavelength_aa = cube.z_central z_step_aa = cube.z_step lsf_1d = self.lsf.get_LSF(lbda=wavelength_aa, step=z_step_aa, size=odd_depth) x=np.arange(np.size(lsf_1d)) sigma = np.sqrt(np.sum(x**2*lsf_1d)-np.sum(x*lsf_1d)**2) * cube.z_step self.fwhm = sigma * 2.35 # That LSF is of an odd depth, truncate it if necessary # FIXME @nicolas : this is a hotfix, not really pretty, how can we do this better? if depth % 2 == 0: lsf_1d = lsf_1d[:-1] # Normalize and serve return lsf_1d / lsf_1d.sum()