# -*- coding: utf-8 -*-
import importlib.util
import numpy as np
from scipy.interpolate import RectBivariateSpline, InterpolatedUnivariateSpline, interp2d
try:
from astropy.io import fits
except ImportError:
import pyfits as fits
try:
import mpdaf
mpdaf_there=True
except ImportError:
mpdaf_there=False
import logging
#
# 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.
"""
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger('GalPaK: PSF')
[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()
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 anti-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 / self.ba ** 2+ dy_p ** 2 )
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_psf):
"""
accepts fits file or ndarray
"""
if isinstance(image_psf, str):
self.filename = image_psf
my_image = fits.open(image_psf)
if my_image['PRIMARY'].data is not None:
image_2d = my_image['PRIMARY'].data
self.header = my_image['PRIMARY'].header
elif my_image['DATA'].data is not None:
image_2d = my_image['DATA'].data
self.header = my_image['DATA'].header
elif isinstance(image_psf, np.ndarray):
image_2d = image_psf
self.filename = str(image_psf.__class__)
self.header = None
elif mpdaf_there:
if isinstance(image_psf, mpdaf.obj.Image):
self.logger.info('Provided image is a Image object')
self.header = image_psf.data_header
image_2d = image_psf.data.data
self.filename = image_psf.filename
else:
raise ValueError(' PSF provided is not a fits file nor an array !!')
self.logger.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')
if len(self.image_2d.shape)!=2:
raise ValueError(' PSF provided is not a 2D image')
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 """[PSF] :
type = custom
image_psf = {i.filename}""".format(i=self)
[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 of major-axis, anti-clockwise rotation from Y-axis, 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 """[PSF] :
type = Gaussian
fwhm = {i.fwhm} "
pa = {i.pa} °
ba = {i.ba}""".format(i=self)
[docs] def as_image(self, for_cube, xo=None, yo=None):
shape = for_cube.shape[1:]
if xo is None:
xo = (shape[1] - 1) / 2 #center of array
#force PSF to be centered on even grid by adding 0.5pixel if even
#because of the upcoming padding in the convolution
xo = xo - (shape[1] % 2 - 1) /2
if yo is None:
yo = (shape[0] - 1) / 2 #center of array
# force PSF to be centered on even grid by adding 0.5pixel if even
# because of the upcoming padding in the convolution
yo = yo - (shape[0] % 2 - 1) /2
self.logger.info("Generating PSF at x0,y0 (%s,%s)" % (xo,yo))
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()
[docs]class MoffatPointSpreadFunction(PointSpreadFunction):
"""
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 of major-axis, the anti-clockwise rotation from Y,
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=0, ba=1):
self.beta = beta
self.pa = pa
self.ba = ba
self.alpha = alpha
self.fwhm = fwhm
if not ((alpha is None) or (fwhm is None)):
self.logger.warning("Moffat psf: alpha and fwhm are both specified. Will use alpha. ")
elif ((alpha is None) and (fwhm is None)):
raise Error("Moffat psf: alpha and fwhm are not specified. Need at least one.")
else:
self.alpha = alpha
self.fwhm = fwhm
if fwhm is None:
self.fwhm = self.calculate_fwhm()
if alpha is None:
self.alpha = self.calculate_alpha()
if self.pa is None:
raise Exception("Moffat error: please set P.A. 'pa'")
if self.ba is None:
raise Exception("Moffat error: please set axis ratio b/a 'ba'")
if self.beta is None:
raise Exception("Moffat error: please set beta parameter")
def __str__(self):
return """[PSF] :
type = Moffat
fwhm = {i.fwhm} "
alpha = {i.alpha} "
beta = {i.beta}
pa = {i.pa} °
ba = {i.ba}""".format(i=self)
[docs] def as_image(self, for_cube, xo=None, yo=None):
shape = for_cube.shape[1:]
if xo is None:
xo = (shape[1] - 1) / 2 #center of array
#force PSF to be centered on even grid by adding 0.5pixel if even
#because of the upcoming padding in the convolution
xo = xo - (shape[1] % 2 - 1) /2
if yo is None:
yo = (shape[0] - 1) / 2 #center of array
# force PSF to be centered on even grid by adding 0.5pixel if even
# because of the upcoming padding in the convolution
yo = yo - (shape[0] % 2 - 1) /2
logger.info("Generating PSF at x0,y0 (%s,%s)" % (xo,yo))
y, x = np.indices(shape)
r = self._radius(xo, yo, x, y)
pix_alpha = self.alpha / for_cube.xy_step
psf = (1. + (r / pix_alpha) ** 2) ** (-self.beta)
return psf / psf.sum()
def calculate_fwhm(self):
fwhm = self.alpha * (2. * np.sqrt(2. ** (1. / self.beta) - 1))
return fwhm
def calculate_alpha(self):
alpha = self.fwhm / (2. * np.sqrt(2. ** (1. / self.beta) - 1))
return alpha
if importlib.util.find_spec('maoppy') is not None:
import maoppy
RAD2ARCSEC = maoppy.utils.RAD2ARCSEC
from maoppy.psfmodel import Psfao, oversample
from maoppy.psffit import psffit
from maoppy.instrument import muse_nfm,muse_wfm
from maoppy.utils import circavgplt,circavg
logging.info("Found package Maoppy version {}".format(maoppy.__version__))
class MAOPPYPointSpreadFunction(Psfao, PointSpreadFunction):
"""
This class uses the MAOPPY PSF profile described by Fetick et al.
(2019).
References
----------
Fetick et al. (2019, A&A, 628, A99)
https://ui.adsabs.harvard.edu/abs/2019A&A...628A..99F/abstract
"""
def __init__(self, r0=0.2, C=1e-7, amp=0.9, alpha=0.05, ba=1, pa=0, beta=2.0, wvl_um=None, mode=None, Lext=10., fixed_k=None):
"""
Initialize a new instance of the MAOPPY PSF.
https://www.aanda.org/articles/aa/pdf/2019/08/aa35830-19.pdf
Notes
-----
There are some differences in the parameter set of the MAOPPY PSF that
is used by PampelMuse compared to the paper by Fetick et al. (2019).:
1) The parameters C (the AO-corrected phase PSD background) and A (the
residual variance) have been renamed to `C` and `amp`.
2) Instead of defining two values for the effective radius of the
Moffat, alpha_x and alpha_y, a single value `alpha` is used in
combination with an ellipticity `e`.
Parameters
----------
r0 : float, optional
The Fried parameter, given in units of [m].
C : float, optional
The AO-corrected phase PSD background, given in units of
[m^2 rad^2].
amp : float, optional
The residual variance, given in units of [rad^2].
alpha : float, optional
The effective radius of the Moffat profile, given in units of
[m^-1].
ba : float, optional
The Moffat ellipticity
pa: float, optional
The Moffat angle [degree]
beta: float, optional
The Moffat beta powerlaw
wvl_um : float, optional
The wavelength of the observation, given in units of [micron].
mode: MUSE_WFM | MUSE_NFM
[ no default ]
Returns
-------
The newly defined instance.
"""
# oversampling of the PSF on the PampelMuse level is not supported, because MAOPPY performs its
# own oversampling when computing a PSF profile.
# call initializer of parent class.
#super(MAOPPYPointSpreadFunction, self).__init__(uc=uc, vc=vc, mag=mag, **kwargs)
self.param_name_gpk = ['r0', 'C', 'amp', 'alpha', 'ba', 'theta', 'beta']
#Maoppy Psfao
# ["r0","bck","amp","alpha","ratio","theta","beta"]
# initial parameter definitions
self.r0 = r0
self.C = C
self.amp = amp
self.alpha = alpha
self.ba = ba
self.pa = pa #in degrees
self.theta = np.radians(pa) #in radians
self.beta = beta
self.flux = flux #in erg/s/cm2/AA
self.mAB = mAB
self.wvl_um = wvl_um
self.mode = mode
self.image_2d = None
npix = (10, 10) # will be modified in 'as_image'
samp = 2.0 # will be modified in 'as_image'
if mode == 'MUSE_WFM':
system = muse_wfm
elif mode == 'MUSE_NFM':
system = muse_nfm
else:
raise ValueError('Maoppy Mode Not valid. Set mode to MUSE_WFM or MUSE_NFM')
super().__init__(npix, system=system, lext=Lext, samp=samp, fixed_k=fixed_k)
self.param = [self.r0, self.C, self.amp, self.alpha, self.ba, self.theta, self.beta]
def __str__(self):
return """[PSF] :
type = Maoppy
mode = {i.mode}
r0 = {i.r0} [m]
C = {i.C}
amp = {i.amp} [rad^2]
fwhm = {i.fwhm} "
alpha = {i.alpha} [m^-1]
beta = {i.beta}
pa = {i.pa} °
ba = {i.ba}
flux = {i.flux} [erg/s/cm2/AA]
mAB = {i.mAB}
wvl_um = {i.wvl_um} [micron]""".format(i=self)
def set_p(self, p ):
r0, C, A, alpha, ratio, theta, beta = p
self.r0=r0
self.C=C
self.amp=A
self.alpha=alpha
self.beta=beta
self.ba=ratio
self.theta=theta
self.pa=np.degrees(theta)
self.param = [self.r0, self.C, self.amp, self.alpha, self.ba, self.theta, self.beta]
def as_image(self, for_cube, xo=None, yo=None):
"""
Return PSFAO 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) -> not used here...
- z_central (in µm)
:rtype: ndarray
"""
self.shape = self.npix = shape = for_cube.shape[1:] ##must be npix for re-use by Maoppy
if xo is None:
xo = (shape[1] - 1) / 2 # center of array
# force PSF to be centered on odd grid by adding 0.5pixel if even
# because of the upcoming padding in the convolution
xo = xo - (shape[1] % 2 - 1) / 2
if yo is None:
yo = (shape[0] - 1) / 2 # center of array
# force PSF to be centered on odd grid by adding 0.5pixel if even
# because of the upcoming padding in the convolution
yo = yo - (shape[0] % 2 - 1) / 2
self.logger.info("Generating PSF at x0,y0 (%s,%s)" % (xo, yo))
self.center = (xo, yo)
self.system._resolution_rad = for_cube.xy_step / RAD2ARCSEC
if 'angstr' in for_cube.z_cunit.lower():
wvl_um = for_cube.z_central / 1e4
elif 'micron' in for_cube.z_cunit.lower():
wvl_um = for_cube.z_central
elif 'nm' in for_cube.z_cunit.lower():
wvl_um = for_cube.z_central / 1e3
else:
raise NotImplementedError("Cube units not supported")
self.samp = self.system.samp(wvl_um * 1e-6)
if self.wvl_um is not None:
self.logger.info("Computing PSF at {} from {}".format(wvl_um,self.wvl_um))
ww = wvl_um/self.wvl_um # wavelength ratio
else:
ww = 1.0
r0,C,A,alpha,ratio,theta,beta = self.param
# see Fetick's papers
p = [r0*ww**(6./5.),C*ww**(-2),A*ww**(-2),alpha,ratio,theta,beta]
self.set_p(p)
self.wvl_um = wvl_um
dx = 0 #-(1 - self.shape[1] % 2) / 2
dy = 0 #-(1 - self.shape[0] % 2) / 2
self.image_2d = self.__call__(p, dx=dx, dy=dy)
return self.image_2d
def calculate_fwhm(self):
"""
Estimate and return the FWHM of the MAOPPY PSF profile for a given
set of parameters.
To estimate the FWHM, the method creates the MAOPPY PSF on a 2dim.
grid, then determines the symmetric radial profile, and tries to
obtain the radii where the intensity drops to half its central value
using univariate spline interpolation.
Returns
-------
fwhm : float
The FWHM of the MAOPPY PSF for the given input parameters.
"""
if self.image_2d is not None:
psf = self.image_2d
r, fr = circavgplt(psf)
f = InterpolatedUnivariateSpline(r, fr - fr.max() / 2.) #why psf? should be fr
roots = f.roots()
if len(roots) != 2:
self.logger.error("Could not estimate FWHM of MAOPPY profile.")
return np.nan
else:
return roots.max() - roots.min()
else:
return None
@property
def fwhm(self):
"""
Returns
-------
fwhm : float
The FWHM of the MAOPPY PSF profile with the given parameters.
"""
fwhm = MAOPPYPointSpreadFunction.calculate_fwhm(self)
if fwhm is not None:
return fwhm * self.system._resolution_rad * RAD2ARCSEC
else:
return fwhm
@property
def strehl(self):
"""
Returns
-------
strehl : float
The Strehl ratio for the current set of parameters.
"""
#psf = self.Psfao(npix=(30, 30), system=self.muse_nfm, samp=self.muse_nfm.samp(self.wvl))
return self.strehlOTF((self.r0, self.C, self.amp, self.alpha, self.ba, self.pa, self.beta))
# Analytical calculation based on eq. 14 from the paper by Fetick et al. (2019). This seems to underestimate
# the Strehl significantly for NFM data.
# f_ao = muse_nfm.Nact/(2.*muse_nfm.D)
# return np.exp(-self.amp - self.bck*np.pi*(f_ao**2) - (0.023*6.*np.pi*(self.r0*f_ao)**(-5./3.))/5.)
def show_psf(self, filename=None):
from matplotlib import pyplot as plt
from matplotlib.colors import LogNorm
if self.image_2d is not None:
fig = plt.figure(1)
plt.subplot(221)
img=self.image_2d
plt.imshow(img,norm=LogNorm())
plt.title("PSF maoppy")
plt.subplot(222)
r,prof = circavgplt(self.image_2d)
plt.semilogy(r,prof)
plt.title("Profile ")
plt.subplot(224)
g=r>=0
plt.loglog(r[g],prof[g])
plt.subplot(223)
psd,_=self.psd(self.param)
plt.loglog(circavg(psd))
plt.title("PSD")
plt.xlabel('f [1/m]')
if filename is not None:
fig.savefig(filename)
return fig
else:
self.logger.error(" Maoppy PSF empty. Must run tthe method as_image(cube) before")
def set_bounds(self, Model, bounds_dict):
"""
:param bounds_dict: dictionnary of tuples
:return:
"""
#looping over parameters as defined in model Class
for i,p in enumerate(self.param_name_gpk):
#assumes parameters in same order!
if p in bounds_dict.keys():
Model.bounds[0][i] = bounds_dict[p][0]
Model.bounds[1][i] = bounds_dict[p][1]
return Model
def fitter(self, psf_image, parameters=['r0', 'amp', 'ba', 'theta'], weights=None, \
fit_background=True, positive_bck = False, guess=None, bounds_dict=None):
"""
psf : 2D-sequence of floats
The psfimage (2D)
parameters : list of strings
The names of the PSF parameters that should be optimized during
the fit.
default ['r0', 'amp', 'ba', 'theta']
for AO use: ['r0', 'amp', 'ba', 'theta', 'alpha', 'beta']
for AO full :['r0', 'amp', 'ba', 'theta, ' alpha', 'beta', 'C']
Mind that the other parameters are taken from self
weights : 2D-sequence of floats
The weights for the pixels on which the PSF profile is
defined.
fit_background : bool, optional
Flag indicating if a constant background component should be
included in the PSF fit.
"""
from .convolution import padding
psf_padded, boxpsf = padding(psf_image) #padding to even box size
import collections
FitResult = collections.namedtuple(
'FitResult',
['names', 'x0', 'best_fit', 'errors', 'cost', 'flux', 'background', 'centroid', 'success', 'psf', 'residuals', 'message'])
ron = self.system.ron
if weights is None:
weights = np.ones_like(psf_image) / ron**2
weights, _ = padding(weights)
#parameters to fit
if parameters is None:
parameters = self.param_name_gpk
self.logger.info("MAOPPY PSF fit: setting up fitting with {:d} parameters {}".format(len(parameters),parameters))
# %% Initialize PSF model
samp = self.system.samp(self.wvl_um * 1e-6) # sampling (2.0 for Shannon-Nyquist)
self.logger.info("MAOPPY PSF fit: instanciation of Psfao")
Pmodel = Psfao(psf_padded.shape, system=self.system, samp=samp)
# define fixed parameters
fixed = np.ones(len(self.param_name_gpk), dtype=bool)
x0 = []
for i, parameter in enumerate(self.param_name_gpk):
if parameter in parameters:
fixed[i] = False
# axis ratio is fitted instead of ellipticity
if guess is None:
self.logger.info("MAOPPY: fitter: using initial x0 from self.parameters")
x0.append(getattr(self, parameter))
else:
x0.append(guess[i])
self.fixed=np.array(fixed)
self.guess=np.array(x0)
not_fitted = np.array(self.param_name_gpk)[self.fixed]
self.logger.info("MAOPPY fit: parameters fixed: {}".format(not_fitted))
if bounds_dict is not None:
self.logger.info("MAOPPY fit: setting bounds with {}".format(bounds_dict))
Pmodel = self.set_bounds(Pmodel, bounds_dict)
# %% Fit the PSF with Psfao
self.logger.info("MAOPPY fitting: mode = {}; option fit_background={} positive={} ".format(self.mode, fit_background, positive_bck ))
fitresult = psffit(psf_padded, Pmodel, self.guess, weights=weights, fixed=self.fixed, \
flux_bck = (True, fit_background), positive_bck = positive_bck, npixfit = None )
J = fitresult.jac
cov = np.linalg.inv(J.T.dot(J))
values={}
errors={}
_idx = (fixed==False).cumsum()
for i, p in enumerate(self.param_name_gpk):
if p in parameters:
values[p] = fitresult.x[self.param_name_gpk.index(p)]
_ii = _idx[self.param_name_gpk.index(p)]
errors[p] = np.sqrt(np.diagonal(cov))[_ii]
elif fixed[i]==True:
values[p] = x0[i]
errors[p] = 0
else:
raise Exception
bestfit_model = fitresult.psf
residuals_2d = psf_padded - fitresult.flux_bck[1] - fitresult.flux_bck[0] * bestfit_model
cost = fitresult.cost
x_center = psf_image.shape[1] // 2.
y_center = psf_image.shape[0] // 2.
# return fitresult
#circularize theta
values['theta'] = values['theta'] % (np.pi)
errors['theta'] = errors['theta'] % (np.pi)
if fitresult.success:
centroid = (x_center + fitresult.dxdy[0] , y_center + fitresult.dxdy[1] )
return FitResult(names=parameters,
x0=guess,
best_fit=values,
errors=errors,
cost=cost,
flux=fitresult.flux_bck[0],
background=fitresult.flux_bck[1] if fit_background else None,
centroid=centroid,
success=fitresult.success,
residuals=residuals_2d[boxpsf],
psf=bestfit_model[boxpsf],
message=fitresult.message)
else:
return FitResult(names=parameters,
x0=guess,
best_fit=np.nan * np.ones_like(guess),
errors=np.nan * np.ones_like(guess),
flux=np.nan,
cost=np.nan,
background=np.nan if fit_background else None,
centroid=(np.nan, np.nan),
success=fitresult.success,
residuals=None,
psf=None,
message=fitresult.message)
#return fitresult
def from_fitter(self,results):
self.set_p(results.best_fit.values())
self.image_2d = results.psf
self.flux = results.flux * 1e-20 #erg/s/cm2/AA
flux_Jy = 3.34e4 * (self.wvl_um*1e4) ** 2 * results.flux * 1e-20
self.mAB = -2.5 * np.log10(flux_Jy / 3631.)
else:
logging.warning('Package "maoppy" is not installed [not required]. Cannot use MAOPPY PSF model.')
## 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(LineSpreadFunction):
"""
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 """[LSF] :\n type = undefined """
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 """[LSF] \n type = Custom """
[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 units of CUNIT3
"""
def __init__(self, fwhm):
self.fwhm = fwhm
def __str__(self):
return """[LSF] :
type = Gaussian
fwhm = {i.fwhm} {i.z_cunit} \n""".format(i=self)
[docs] 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) / 2
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)
[docs] 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()