# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
This module provides mathematical functional PSF models.
"""
import astropy.units as u
import numpy as np
from astropy.modeling import Fittable2DModel, Parameter
from astropy.modeling.utils import ellipse_extent
from astropy.units import UnitsError
from astropy.utils.decorators import deprecated
from scipy.special import erf, j1, jn_zeros
__all__ = [
'AiryDiskPSF',
'CircularGaussianPRF',
'CircularGaussianPSF',
'CircularGaussianSigmaPRF',
'GaussianPRF',
'GaussianPSF',
'IntegratedGaussianPRF',
'MoffatPSF',
]
FLOAT_EPSILON = float(np.finfo(np.float32).tiny)
GAUSSIAN_FWHM_TO_SIGMA = 1.0 / (2.0 * np.sqrt(2.0 * np.log(2.0)))
def _gaussian_amplitude(flux, xsigma, ysigma):
# output units should match the input flux units
if isinstance(xsigma, u.Quantity):
xsigma = xsigma.value
ysigma = ysigma.value
return flux / (2.0 * np.pi * xsigma * ysigma)
[docs]
class GaussianPSF(Fittable2DModel):
r"""
A 2D Gaussian PSF model.
This model is evaluated by sampling the 2D Gaussian at the input
coordinates. The Gaussian is normalized such that the analytical
integral over the entire 2D plane is equal to the total flux.
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak along the x axis.
y_0 : float, optional
Position of the peak along the y axis.
x_fwhm : float, optional
The full width at half maximum (FWHM) of the Gaussian along the
x axis.
y_fwhm : float, optional
FWHM of the Gaussian along the y axis.
theta : float, optional
The counterclockwise rotation angle either as a float (in
degrees) or a `~astropy.units.Quantity` angle (optional).
bbox_factor : float, optional
The multiple of the x and y standard deviations (sigma) used to
define the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` base class.
See Also
--------
CircularGaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF
Notes
-----
The Gaussian function is defined as:
.. math::
f(x, y) = \frac{F}{2 \pi \sigma_{x} \sigma_{y}}
\exp \left( -a\left(x - x_{0}\right)^{2}
- b \left(x - x_{0}\right) \left(y - y_{0}\right)
- c \left(y - y_{0}\right)^{2} \right)
where :math:`F` is the total integrated flux, :math:`(x_{0},
y_{0})` is the position of the peak, and :math:`\sigma_{x}` and
:math:`\sigma_{y}` are the standard deviations along the x and y
axes, respectively.
.. math::
a = \frac{\cos^{2}{\theta}}{2 \sigma_{x}^{2}}
+ \frac{\sin^{2}{\theta}}{2 \sigma_{y}^{2}}
b = \frac{\sin{2 \theta}}{2 \sigma_{x}^{2}}
- \frac{\sin{2 \theta}}{2 \sigma_{y}^{2}}
c = \frac{\sin^{2}{\theta}} {2 \sigma_{x}^{2}}
+ \frac{\cos^{2}{\theta}}{2 \sigma_{y}^{2}}
where :math:`\theta` is the rotation angle of the Gaussian.
The FWHMs of the Gaussian along the x and y axes are given by:
.. math::
\rm{FWHM}_{x} = 2 \sigma_{x} \sqrt{2 \ln{2}}
\rm{FWHM}_{y} = 2 \sigma_{y} \sqrt{2 \ln{2}}
The model is normalized such that:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F
The ``x_fwhm``, ``y_fwhm``, and ``theta`` parameters are fixed by
default. If you wish to fit these parameters, set the ``fixed``
attribute to `False`, e.g.,::
>>> from photutils.psf import GaussianPSF
>>> model = GaussianPSF()
>>> model.x_fwhm.fixed = False
>>> model.y_fwhm.fixed = False
>>> model.theta.fixed = False
By default, the ``x_fwhm`` and ``y_fwhm`` parameters are bounded to
be strictly positive.
References
----------
.. [1] https://en.wikipedia.org/wiki/Gaussian_function
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.psf import GaussianPSF
model = GaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=10.1,
y_fwhm=5.82, theta=21.7)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
plt.imshow(data, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
x_fwhm = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='FWHM of the Gaussian along the x axis')
y_fwhm = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='FWHM of the Gaussian along the y axis')
theta = Parameter(
default=0.0, description=('CCW rotation angle either as a float (in '
'degrees) or a Quantity angle (optional)'),
fixed=True)
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
x_fwhm=x_fwhm.default, y_fwhm=y_fwhm.default,
theta=theta.default, bbox_factor=5.5, **kwargs):
super().__init__(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm,
y_fwhm=y_fwhm, theta=theta, **kwargs)
self.bbox_factor = bbox_factor
@property
def amplitude(self):
"""
The peak amplitude of the Gaussian.
"""
return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma)
@property
def x_sigma(self):
"""
Gaussian sigma (standard deviation) along the x axis.
"""
return self.x_fwhm * GAUSSIAN_FWHM_TO_SIGMA
@property
def y_sigma(self):
"""
Gaussian sigma (standard deviation) along the y axis.
"""
return self.y_fwhm * GAUSSIAN_FWHM_TO_SIGMA
def _calc_bounding_box(self, factor=5.5):
"""
Calculate a bounding box defining the limits of the model.
The limits are adjusted for rotation.
Parameters
----------
factor : float, optional
The multiple of the x and y standard deviations (sigma) used
to define the limits.
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
a = factor * self.x_sigma
b = factor * self.y_sigma
dx, dy = ellipse_extent(a, b, self.theta)
return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import GaussianPSF
>>> model = GaussianPSF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-4.671269901584105, upper=4.671269901584105)
y: Interval(lower=-7.006904852376157, upper=7.006904852376157)
}
model=GaussianPSF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-5.945252602016134, upper=5.945252602016134)
y: Interval(lower=-8.9178789030242, upper=8.9178789030242)
}
model=GaussianPSF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta):
"""
Calculate the value of the 2D Gaussian model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
x_fwhm, y_fwhm : float
FWHM of the Gaussian along the x and y axes.
theta : float
The counterclockwise rotation angle either as a float (in
degrees) or a `~astropy.units.Quantity` angle (optional).
Returns
-------
result : `~numpy.ndarray`
The value of the model evaluated at the input coordinates.
"""
if not isinstance(theta, u.Quantity):
theta = np.deg2rad(theta)
cost2 = np.cos(theta) ** 2
sint2 = np.sin(theta) ** 2
sin2t = np.sin(2.0 * theta)
xstd = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA
ystd = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA
xstd2 = xstd ** 2
ystd2 = ystd ** 2
xdiff = x - x_0
ydiff = y - y_0
a = 0.5 * ((cost2 / xstd2) + (sint2 / ystd2))
b = 0.5 * ((sin2t / xstd2) - (sin2t / ystd2))
c = 0.5 * ((sint2 / xstd2) + (cost2 / ystd2))
# output units should match the input flux units
if isinstance(xstd, u.Quantity):
xstd = xstd.value
ystd = ystd.value
amplitude = flux / (2 * np.pi * xstd * ystd)
return amplitude * np.exp(
-(a * xdiff**2) - (b * xdiff * ydiff) - (c * ydiff**2))
[docs]
@staticmethod
def fit_deriv(x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta):
"""
Calculate the partial derivatives of the 2D Gaussian function
with respect to the parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
x_fwhm, y_fwhm : float
FWHM of the Gaussian along the x and y axes.
theta : float
The counterclockwise rotation angle either as a float (in
degrees) or a `~astropy.units.Quantity` angle (optional).
Returns
-------
result : list of `~numpy.ndarray`
The list of partial derivatives with respect to each
parameter.
"""
if not isinstance(theta, u.Quantity):
theta = np.deg2rad(theta)
cost = np.cos(theta)
sint = np.sin(theta)
cost2 = cost ** 2
sint2 = sint ** 2
cos2t = np.cos(2.0 * theta)
sin2t = np.sin(2.0 * theta)
xstd = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA
ystd = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA
xstd2 = xstd ** 2
ystd2 = ystd ** 2
xstd3 = xstd ** 3
ystd3 = ystd ** 3
xdiff = x - x_0
ydiff = y - y_0
xdiff2 = xdiff ** 2
ydiff2 = ydiff ** 2
a = 0.5 * ((cost2 / xstd2) + (sint2 / ystd2))
b = 0.5 * ((sin2t / xstd2) - (sin2t / ystd2))
c = 0.5 * ((sint2 / xstd2) + (cost2 / ystd2))
amplitude = flux / (2 * np.pi * xstd * ystd)
exp = np.exp(-(a * xdiff2) - (b * xdiff * ydiff) - (c * ydiff2))
g = amplitude * exp
da_dtheta = sint * cost * ((1.0 / ystd2) - (1.0 / xstd2))
db_dtheta = (cos2t / xstd2) - (cos2t / ystd2)
dc_dtheta = -da_dtheta
da_dxstd = -cost2 / xstd3
db_dxstd = -sin2t / xstd3
dc_dxstd = -sint2 / xstd3
da_dystd = -sint2 / ystd3
db_dystd = sin2t / ystd3
dc_dystd = -cost2 / ystd3
dg_dflux = g / flux
dg_dx_0 = g * ((2.0 * a * xdiff) + (b * ydiff))
dg_dy_0 = g * ((b * xdiff) + (2.0 * c * ydiff))
damp_dxstd = -amplitude / xstd
damp_dystd = -amplitude / ystd
dexp_dxstd = -exp * (da_dxstd * xdiff2
+ db_dxstd * xdiff * ydiff
+ dc_dxstd * ydiff2)
dexp_dystd = -exp * (da_dystd * xdiff2
+ db_dystd * xdiff * ydiff
+ dc_dystd * ydiff2)
dg_dxstd = damp_dxstd * exp + amplitude * dexp_dxstd
dg_dystd = damp_dystd * exp + amplitude * dexp_dystd
# chain rule for change of variables from sigma to fwhm
# std => fwhm * GAUSSIAN_FWHM_TO_SIGMA
# dstd/dfwhm => GAUSSIAN_FWHM_TO_SIGMA
dg_dxfwhm = dg_dxstd * GAUSSIAN_FWHM_TO_SIGMA
dg_dyfwhm = dg_dystd * GAUSSIAN_FWHM_TO_SIGMA
dg_dtheta = g * (-(da_dtheta * xdiff2 + db_dtheta * xdiff * ydiff
+ dc_dtheta * ydiff2))
# chain rule for unit change;
# theta[rad] => theta[deg] * pi / 180; drad/dtheta = pi / 180
dg_dtheta *= np.pi / 180.0
return [dg_dflux, dg_dx_0, dg_dy_0, dg_dxfwhm, dg_dyfwhm, dg_dtheta]
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
# Note that here we need to make sure that x and y are in the same
# units otherwise this can lead to issues since rotation is not well
# defined.
if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]:
raise UnitsError("Units of 'x' and 'y' inputs should match")
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'x_fwhm': inputs_unit[self.inputs[0]],
'y_fwhm': inputs_unit[self.inputs[0]],
'theta': u.deg,
'flux': outputs_unit[self.outputs[0]]}
[docs]
class CircularGaussianPSF(Fittable2DModel):
r"""
A circular 2D Gaussian PSF model.
This model is evaluated by sampling the 2D Gaussian at the input
coordinates. The Gaussian is normalized such that the analytical
integral over the entire 2D plane is equal to the total flux.
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak along the x axis.
y_0 : float, optional
Position of the peak along the y axis.
fwhm : float, optional
The full width at half maximum (FWHM) of the Gaussian.
bbox_factor : float, optional
The multiple of the standard deviation (sigma) used to define
the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` base class.
See Also
--------
GaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF
Notes
-----
The circular Gaussian function is defined as:
.. math::
f(x, y) = \frac{F}{2 \pi \sigma^{2}}
\exp \left( {\frac{-(x - x_{0})^{2} - (y - y_{0})^{2}}
{2 \sigma^{2}}} \right)
where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})`
is the position of the peak, and :math:`\sigma` is the standard
deviation, respectively.
The FWHM of the Gaussian is given by:
.. math::
\rm{FWHM} = 2 \sigma \sqrt{2 \ln{2}}
The model is normalized such that:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F
The ``fwhm`` parameter is fixed by default. If you wish to fit this
parameter, set the ``fixed`` attribute to `False`, e.g.,::
>>> from photutils.psf import CircularGaussianPSF
>>> model = CircularGaussianPSF()
>>> model.fwhm.fixed = False
By default, the ``fwhm`` parameter is bounded to be strictly
positive.
References
----------
.. [1] https://en.wikipedia.org/wiki/Gaussian_function
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.psf import CircularGaussianPSF
model = CircularGaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=10.1)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
plt.imshow(data, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
fwhm = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='FWHM of the Gaussian')
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
fwhm=fwhm.default, bbox_factor=5.5, **kwargs):
super().__init__(flux=flux, x_0=x_0, y_0=y_0, fwhm=fwhm, **kwargs)
self.bbox_factor = bbox_factor
@property
def amplitude(self):
"""
The peak amplitude of the Gaussian.
"""
return _gaussian_amplitude(self.flux, self.sigma, self.sigma)
@property
def sigma(self):
"""
Gaussian sigma (standard deviation).
"""
return self.fwhm * GAUSSIAN_FWHM_TO_SIGMA
def _calc_bounding_box(self, factor=5.5):
"""
Calculate a bounding box defining the limits of the model.
Parameters
----------
factor : float, optional
The multiple of the standard deviations (sigma) used to
define the limits.
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
delta = factor * self.sigma
return ((self.y_0 - delta, self.y_0 + delta),
(self.x_0 - delta, self.x_0 + delta))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import CircularGaussianPSF
>>> model = CircularGaussianPSF(x_0=0, y_0=0, fwhm=2)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-4.671269901584105, upper=4.671269901584105)
y: Interval(lower=-4.671269901584105, upper=4.671269901584105)
}
model=CircularGaussianPSF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-5.945252602016134, upper=5.945252602016134)
y: Interval(lower=-5.945252602016134, upper=5.945252602016134)
}
model=CircularGaussianPSF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, fwhm):
"""
Calculate the value of the 2D Gaussian model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
fwhm : float
FWHM of the Gaussian.
Returns
-------
result : `~numpy.ndarray`
The value of the model evaluated at the input coordinates.
"""
sigma2 = (fwhm * GAUSSIAN_FWHM_TO_SIGMA) ** 2
# output units should match the input flux units
sigma2_norm = sigma2
if isinstance(sigma2, u.Quantity):
sigma2_norm = sigma2.value
amplitude = flux / (2 * np.pi * sigma2_norm)
return amplitude * np.exp(-0.5 * ((x - x_0) ** 2 + (y - y_0) ** 2)
/ sigma2)
[docs]
@staticmethod
def fit_deriv(x, y, flux, x_0, y_0, fwhm):
"""
Calculate the partial derivatives of the 2D Gaussian function
with respect to the parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
fwhm : float
FWHM of the Gaussian.
Returns
-------
result : list of `~numpy.ndarray`
The list of partial derivatives with respect to each
parameter.
"""
return GaussianPSF().fit_deriv(x, y, flux, x_0, y_0, fwhm, fwhm,
0.0)[:-2]
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'fwhm': inputs_unit[self.inputs[0]],
'flux': outputs_unit[self.outputs[0]]}
[docs]
class GaussianPRF(Fittable2DModel):
r"""
A 2D Gaussian PSF model integrated over pixels.
This model is evaluated by integrating the 2D Gaussian over the
input coordinate pixels, and is equivalent to assuming the PSF is
2D Gaussian at a *sub-pixel* level. Because it is integrated over
pixels, this model is considered a PRF instead of a PSF.
The Gaussian is normalized such that the analytical integral over
the entire 2D plane is equal to the total flux.
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak along the x axis.
y_0 : float, optional
Position of the peak along the y axis.
x_fwhm : float, optional
The full width at half maximum (FWHM) of the Gaussian along the
x axis.
y_fwhm : float, optional
FWHM of the Gaussian along the y axis.
theta : float, optional
The counterclockwise rotation angle either as a float (in
degrees) or a `~astropy.units.Quantity` angle (optional).
bbox_factor : float, optional
The multiple of the x and y standard deviations (sigma) used to
define the bounding_box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` base class.
See Also
--------
GaussianPSF, CircularGaussianPSF, CircularGaussianPRF, MoffatPSF
Notes
-----
The Gaussian function is defined as:
.. math::
f(x, y) =
\frac{F}{4}
\left[
{\rm erf} \left(
\frac{x^\prime + 0.5}{\sqrt{2} \sigma_{x}} \right) -
{\rm erf} \left(
\frac{x^\prime - 0.5}{\sqrt{2} \sigma_{x}} \right)
\right]
\left[
{\rm erf} \left(
\frac{y^\prime + 0.5}{\sqrt{2} \sigma_{y}} \right) -
{\rm erf} \left(
\frac{y^\prime - 0.5}{\sqrt{2} \sigma_{y}} \right)
\right]
where :math:`F` is the total integrated flux, :math:`\sigma_{x}`
and :math:`\sigma_{y}` are the standard deviations along the x
and y axes, respectively, and :math:`{\rm erf}` denotes the error
function.
.. math::
x^\prime = (x - x_0) \cos(\theta) + (y - y_0) \sin(\theta)
y^\prime = -(x - x_0) \sin(\theta) + (y - y_0) \cos(\theta)
where :math:`(x_{0}, y_{0})` is the position of the peak and
:math:`\theta` is the rotation angle of the Gaussian.
The FWHMs of the Gaussian along the x and y axes are given by:
.. math::
\rm{FWHM}_{x} = 2 \sigma_{x} \sqrt{2 \ln{2}}
\rm{FWHM}_{y} = 2 \sigma_{y} \sqrt{2 \ln{2}}
The model is normalized such that:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F
The ``x_fwhm``, ``y_fwhm``, and ``theta`` parameters are fixed by
default. If you wish to fit these parameters, set the ``fixed``
attribute to `False`, e.g.,::
>>> from photutils.psf import GaussianPRF
>>> model = GaussianPRF()
>>> model.x_fwhm.fixed = False
>>> model.y_fwhm.fixed = False
>>> model.theta.fixed = False
By default, the ``x_fwhm`` and ``y_fwhm`` parameters are bounded to
be strictly positive.
References
----------
.. [1] https://en.wikipedia.org/wiki/Gaussian_function
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.psf import GaussianPRF
model = GaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=10.1,
y_fwhm=5.82, theta=21.7)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
plt.imshow(data, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
x_fwhm = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='FWHM of the Gaussian along the x axis')
y_fwhm = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='FWHM of the Gaussian along the y axis')
theta = Parameter(
default=0.0, description=('CCW rotation angle either as a float (in '
'degrees) or a Quantity angle (optional)'),
fixed=True)
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
x_fwhm=x_fwhm.default, y_fwhm=y_fwhm.default,
theta=theta.default, bbox_factor=5.5, **kwargs):
super().__init__(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm,
y_fwhm=y_fwhm, theta=theta, **kwargs)
self.bbox_factor = bbox_factor
@property
def amplitude(self):
"""
The peak amplitude of the Gaussian.
"""
return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma)
@property
def x_sigma(self):
"""
Gaussian sigma (standard deviation) along the x axis.
"""
return self.x_fwhm * GAUSSIAN_FWHM_TO_SIGMA
@property
def y_sigma(self):
"""
Gaussian sigma (standard deviation) along the y axis.
"""
return self.y_fwhm * GAUSSIAN_FWHM_TO_SIGMA
def _calc_bounding_box(self, factor=5.5):
"""
Calculate a bounding box defining the limits of the model.
The limits are adjusted for rotation.
Parameters
----------
factor : float, optional
The multiple of the x and y FWHMs used to define the limits.
zzzz
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
a = factor * self.x_sigma
b = factor * self.y_sigma
dx, dy = ellipse_extent(a, b, self.theta)
return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import GaussianPRF
>>> model = GaussianPRF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-4.671269901584105, upper=4.671269901584105)
y: Interval(lower=-7.006904852376157, upper=7.006904852376157)
}
model=GaussianPRF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-5.945252602016134, upper=5.945252602016134)
y: Interval(lower=-8.9178789030242, upper=8.9178789030242)
}
model=GaussianPRF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta):
"""
Calculate the value of the 2D Gaussian model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
x_fwhm, y_fwhm : float
FWHM of the Gaussian along the x and y axes.
theta : float
The counterclockwise rotation angle either as a float (in
degrees) or a `~astropy.units.Quantity` angle (optional).
Returns
-------
result : `~numpy.ndarray`
The value of the model evaluated at the input coordinates.
"""
if not isinstance(theta, u.Quantity):
theta = np.deg2rad(theta)
x_sigma = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA
y_sigma = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA
dx = x - x_0
dy = y - y_0
cost = np.cos(theta)
sint = np.sin(theta)
x0 = dx * cost + dy * sint
y0 = -dx * sint + dy * cost
dpix = 0.5
if isinstance(x0, u.Quantity):
dpix <<= x0.unit
return (flux / 4.0
* ((erf((x0 + dpix) / (np.sqrt(2) * x_sigma))
- erf((x0 - dpix) / (np.sqrt(2) * x_sigma)))
* (erf((y0 + dpix) / (np.sqrt(2) * y_sigma))
- erf((y0 - dpix) / (np.sqrt(2) * y_sigma)))))
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
# Note that here we need to make sure that x and y are in the same
# units otherwise this can lead to issues since rotation is not well
# defined.
if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]:
raise UnitsError("Units of 'x' and 'y' inputs should match")
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'x_fwhm': inputs_unit[self.inputs[0]],
'y_fwhm': inputs_unit[self.inputs[0]],
'theta': u.deg,
'flux': outputs_unit[self.outputs[0]]}
[docs]
class CircularGaussianPRF(Fittable2DModel):
r"""
A circular 2D Gaussian PSF model integrated over pixels.
This model is evaluated by integrating the 2D Gaussian over the
input coordinate pixels, and is equivalent to assuming the PSF is
2D Gaussian at a *sub-pixel* level. Because it is integrated over
pixels, this model is considered a PRF instead of a PSF.
The Gaussian is normalized such that the analytical integral over
the entire 2D plane is equal to the total flux.
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak along the x axis.
y_0 : float, optional
Position of the peak along the y axis.
fwhm : float, optional
The full width at half maximum (FWHM) of the Gaussian.
bbox_factor : float, optional
The multiple of the standard deviation (sigma) used to define
the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` base class.
See Also
--------
GaussianPRF, GaussianPSF, CircularGaussianPSF, MoffatPSF
Notes
-----
The circular Gaussian function is defined as:
.. math::
f(x, y) =
\frac{F}{4}
\left[
{\rm erf} \left(
\frac{x - x_0 + 0.5}{\sqrt{2} \sigma} \right) -
{\rm erf} \left(
\frac{x - x_0 - 0.5}{\sqrt{2} \sigma} \right)
\right]
\left[
{\rm erf} \left(
\frac{y - y_0 + 0.5}{\sqrt{2} \sigma} \right) -
{\rm erf} \left(
\frac{y - y_0 - 0.5}{\sqrt{2} \sigma} \right)
\right]
where :math:`F` is the total integrated flux, :math:`(x_{0},
y_{0})` is the position of the peak, :math:`\sigma` is the standard
deviation of the Gaussian, and :math:`{\rm erf}` denotes the error
function.
The FWHM of the Gaussian is given by:
.. math::
\rm{FWHM} = 2 \sigma \sqrt{2 \ln{2}}
The model is normalized such that:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F
The ``fwhm`` parameter is fixed by default. If you wish to fit this
parameter, set the ``fixed`` attribute to `False`, e.g.,::
>>> from photutils.psf import CircularGaussianPRF
>>> model = CircularGaussianPRF()
>>> model.fwhm.fixed = False
By default, the ``fwhm`` parameter is bounded to be strictly
positive.
References
----------
.. [1] https://en.wikipedia.org/wiki/Gaussian_function
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.psf import CircularGaussianPRF
model = CircularGaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=10.1)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
plt.imshow(data, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
fwhm = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='FWHM of the Gaussian')
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
fwhm=fwhm.default, bbox_factor=5.5, **kwargs):
super().__init__(flux=flux, x_0=x_0, y_0=y_0, fwhm=fwhm, **kwargs)
self.bbox_factor = bbox_factor
@property
def amplitude(self):
"""
The peak amplitude of the Gaussian.
"""
return _gaussian_amplitude(self.flux, self.sigma, self.sigma)
@property
def sigma(self):
"""
Gaussian sigma (standard deviation).
"""
return self.fwhm * GAUSSIAN_FWHM_TO_SIGMA
def _calc_bounding_box(self, factor=5.5):
"""
Calculate a bounding box defining the limits of the model.
Parameters
----------
factor : float, optional
The multiple of the standard deviations (sigma) used to
define the limits.
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
delta = factor * self.sigma
return ((self.y_0 - delta, self.y_0 + delta),
(self.x_0 - delta, self.x_0 + delta))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import CircularGaussianPRF
>>> model = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-4.671269901584105, upper=4.671269901584105)
y: Interval(lower=-4.671269901584105, upper=4.671269901584105)
}
model=CircularGaussianPRF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-5.945252602016134, upper=5.945252602016134)
y: Interval(lower=-5.945252602016134, upper=5.945252602016134)
}
model=CircularGaussianPRF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, fwhm):
"""
Calculate the value of the 2D Gaussian model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
fwhm : float
FWHM of the Gaussian.
Returns
-------
result : `~numpy.ndarray`
The value of the model evaluated at the input coordinates.
"""
x0 = x - x_0
y0 = y - y_0
sigma = fwhm * GAUSSIAN_FWHM_TO_SIGMA
dpix = 0.5
if isinstance(x0, u.Quantity):
dpix <<= x0.unit
return (flux / 4.0
* ((erf((x0 + dpix) / (np.sqrt(2) * sigma))
- erf((x0 - dpix) / (np.sqrt(2) * sigma)))
* (erf((y0 + dpix) / (np.sqrt(2) * sigma))
- erf((y0 - dpix) / (np.sqrt(2) * sigma)))))
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'fwhm': inputs_unit[self.inputs[0]],
'flux': outputs_unit[self.outputs[0]]}
[docs]
class CircularGaussianSigmaPRF(Fittable2DModel):
r"""
A circular 2D Gaussian PSF model integrated over pixels.
This model is evaluated by integrating the 2D Gaussian over the
input coordinate pixels, and is equivalent to assuming the PSF is
2D Gaussian at a *sub-pixel* level. Because it is integrated over
pixels, this model is considered a PRF instead of a PSF.
The Gaussian is normalized such that the analytical integral over
the entire 2D plane is equal to the total flux.
This model is equivalent to `CircularGaussianPRF`, but it is
parameterized in terms of the standard deviation (sigma) instead of
the full width at half maximum (FWHM).
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak in x direction.
y_0 : float, optional
Position of the peak in y direction.
sigma : float, optional
Width of the Gaussian PSF.
bbox_factor : float, optional
The multiple of the standard deviation (sigma) used to define
the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` parent class.
See Also
--------
GaussianPSF, GaussianPRF, CircularGaussianPSF, CircularGaussianPRF
Notes
-----
The circular Gaussian function is defined as:
.. math::
f(x, y) =
\frac{F}{4}
\left[
{\rm erf} \left(\frac{x - x_0 + 0.5}
{\sqrt{2} \sigma} \right) -
{\rm erf} \left(\frac{x - x_0 - 0.5}
{\sqrt{2} \sigma} \right)
\right]
\left[
{\rm erf} \left(\frac{y - y_0 + 0.5}
{\sqrt{2} \sigma} \right) -
{\rm erf} \left(\frac{y - y_0 - 0.5}
{\sqrt{2} \sigma} \right)
\right]
where :math:`F` is the total integrated flux, :math:`(x_{0},
y_{0})` is the position of the peak, :math:`\sigma` is the standard
deviation of the Gaussian, and :math:`{\rm erf}` denotes the error
function.
The model is normalized such that:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F
The ``sigma`` parameter is fixed by default. If you wish to fit this
parameter, set the ``fixed`` attribute to `False`, e.g.,::
>>> from photutils.psf import CircularGaussianSigmaPRF
>>> model = CircularGaussianSigmaPRF()
>>> model.sigma.fixed = False
By default, the ``sigma`` parameter is bounded to be strictly
positive.
References
----------
.. [1] https://en.wikipedia.org/wiki/Gaussian_function
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.psf import CircularGaussianSigmaPRF
model = CircularGaussianSigmaPRF(flux=71.4, x_0=24.3, y_0=25.2,
sigma=5.1)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
plt.imshow(data, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
sigma = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='Sigma (standard deviation) of the Gaussian')
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
sigma=sigma.default, bbox_factor=5.5, **kwargs):
super().__init__(sigma=sigma, x_0=x_0, y_0=y_0, flux=flux, **kwargs)
self.bbox_factor = bbox_factor
@property
def amplitude(self):
"""
The peak amplitude of the Gaussian.
"""
return _gaussian_amplitude(self.flux, self.sigma, self.sigma)
@property
def fwhm(self):
"""
Gaussian FWHM.
"""
return self.sigma / GAUSSIAN_FWHM_TO_SIGMA
def _calc_bounding_box(self, factor=5.5):
"""
Calculate a bounding box defining the limits of the model.
Parameters
----------
factor : float, optional
The multiple of the standard deviations (sigma) used to
define the limits.
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
delta = factor * self.sigma
return ((self.y_0 - delta, self.y_0 + delta),
(self.x_0 - delta, self.x_0 + delta))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import CircularGaussianPRF
>>> model = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-4.671269901584105, upper=4.671269901584105)
y: Interval(lower=-4.671269901584105, upper=4.671269901584105)
}
model=CircularGaussianPRF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-5.945252602016134, upper=5.945252602016134)
y: Interval(lower=-5.945252602016134, upper=5.945252602016134)
}
model=CircularGaussianPRF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, sigma):
"""
Calculate the value of the 2D Gaussian model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The coordinates at which to evaluate the model.
flux : float
The total flux of the star.
x_0, y_0 : float
The position of the star.
sigma : float
The width of the Gaussian PRF.
Returns
-------
evaluated_model : `~numpy.ndarray`
The evaluated model.
"""
dpix = 0.5
if isinstance(x_0, u.Quantity):
dpix *= x_0.unit
return (flux / 4
* ((erf((x - x_0 + dpix) / (np.sqrt(2) * sigma))
- erf((x - x_0 - dpix) / (np.sqrt(2) * sigma)))
* (erf((y - y_0 + dpix) / (np.sqrt(2) * sigma))
- erf((y - y_0 - dpix) / (np.sqrt(2) * sigma)))))
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
# Note that here we need to make sure that x and y are in the same
# units otherwise this can lead to issues since rotation is not well
# defined.
if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]:
raise UnitsError("Units of 'x' and 'y' inputs should match")
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'sigma': inputs_unit[self.inputs[0]],
'flux': outputs_unit[self.outputs[0]]}
[docs]
@deprecated('2.0.0', alternative='`CircularGaussianSigmaPRF` or '
'`CircularGaussianPRF`')
class IntegratedGaussianPRF(CircularGaussianSigmaPRF):
r"""
A circular 2D Gaussian PSF model integrated over pixels.
This model is evaluated by integrating the 2D Gaussian over the
input coordinate pixels, and is equivalent to assuming the PSF is
2D Gaussian at a *sub-pixel* level. Because it is integrated over
pixels, this model is considered a PRF instead of a PSF.
The Gaussian is normalized such that the analytical integral over
the entire 2D plane is equal to the total flux.
This model is equivalent to `CircularGaussianPRF`, but it is
parameterized in terms of the standard deviation (sigma) instead of
the full width at half maximum (FWHM).
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak in x direction.
y_0 : float, optional
Position of the peak in y direction.
sigma : float, optional
Width of the Gaussian PSF.
bbox_factor : float, optional
The multiple of the standard deviation (sigma) used to define
the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` parent class.
See Also
--------
GaussianPSF, GaussianPRF, CircularGaussianPSF, CircularGaussianPRF
Notes
-----
The circular Gaussian function is defined as:
.. math::
f(x, y) =
\frac{F}{4}
\left[
{\rm erf} \left(\frac{x - x_0 + 0.5}
{\sqrt{2} \sigma} \right) -
{\rm erf} \left(\frac{x - x_0 - 0.5}
{\sqrt{2} \sigma} \right)
\right]
\left[
{\rm erf} \left(\frac{y - y_0 + 0.5}
{\sqrt{2} \sigma} \right) -
{\rm erf} \left(\frac{y - y_0 - 0.5}
{\sqrt{2} \sigma} \right)
\right]
where :math:`F` is the total integrated flux, :math:`(x_{0},
y_{0})` is the position of the peak, :math:`\sigma` is the standard
deviation of the Gaussian, and :math:`{\rm erf}` denotes the error
function.
The model is normalized such that:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F
References
----------
.. [1] https://en.wikipedia.org/wiki/Gaussian_function
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
sigma = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='Sigma (standard deviation) of the Gaussian')
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
sigma=sigma.default, bbox_factor=5.5, **kwargs):
super().__init__(sigma=sigma, x_0=x_0, y_0=y_0, flux=flux,
bbox_factor=bbox_factor, **kwargs)
[docs]
class MoffatPSF(Fittable2DModel):
r"""
A 2D Moffat PSF model.
This model is evaluated by sampling the 2D Moffat function at the
input coordinates. The Moffat profile is normalized such that the
analytical integral over the entire 2D plane is equal to the total
flux.
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak along the x axis.
y_0 : float, optional
Position of the peak along the y axis.
alpha : float, optional
The characteristic radius of the Moffat profile.
beta : float, optional
The asymptotic power-law slope of the Moffat profile wings at
large radial distances. Larger values provide less flux in the
profile wings. For large ``beta``, this profile approaches a
Gaussian profile. ``beta`` must be greater than 1. If ``beta``
is set to 1, then the Moffat profile is a Lorentz function,
whose integral is infinite. For this normalized model, if
``beta`` is set to 1, then the profile will be zero everywhere.
bbox_factor : float, optional
The multiple of the FWHM used to define the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` base class.
See Also
--------
GaussianPSF, CircularGaussianPSF, GaussianPRF, CircularGaussianPRF
Notes
-----
The Moffat profile is defined as:
.. math::
f(x, y) = F \frac{\beta - 1}{\pi \alpha^2}
\left(1 + \frac{\left(x - x_{0}\right)^{2}
+ \left(y - y_{0}\right)^{2}}{\alpha^{2}}\right)^{-\beta}
where :math:`F` is the total integrated flux and :math:`(x_{0},
y_{0})` is the position of the peak. Note that :math:`\beta` must be
greater than 1.
The FWHM of the Moffat profile is given by:
.. math::
\rm{FWHM} = 2 \alpha \sqrt{2^{1 / \beta} - 1}
The model is normalized such that, for :math:`\beta > 1`:
.. math::
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y)
\,dx \,dy = F
The ``alpha`` and ``beta`` parameters are fixed by default. If
you wish to fit these parameters, set the ``fixed`` attribute to
`False`, e.g.,::
>>> from photutils.psf import MoffatPSF
>>> model = MoffatPSF()
>>> model.alpha.fixed = False
>>> model.beta.fixed = False
By default, the ``alpha`` parameter is bounded to be strictly
positive and the ``beta`` parameter is bounded to be greater than 1.
References
----------
.. [1] https://en.wikipedia.org/wiki/Moffat_distribution
.. [2] https://ui.adsabs.harvard.edu/abs/1969A%26A.....3..455M/abstract
.. [3] https://ned.ipac.caltech.edu/level5/Stetson/Stetson2_2_1.html
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.psf import MoffatPSF
model = MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
plt.imshow(data, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
alpha = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='Characteristic radius of the Moffat profile')
beta = Parameter(
default=2,
bounds=(1.0 + FLOAT_EPSILON, None),
fixed=True,
description='Power-law index of the Moffat profile')
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
alpha=alpha.default, beta=beta.default, bbox_factor=10.0,
**kwargs):
super().__init__(flux=flux, x_0=x_0, y_0=y_0, alpha=alpha, beta=beta,
**kwargs)
self.bbox_factor = bbox_factor
@property
def fwhm(self):
"""
The FWHM of the Moffat profile.
"""
return 2.0 * self.alpha * np.sqrt(2 ** (1.0 / self.beta) - 1)
def _calc_bounding_box(self, factor=10.0):
"""
Calculate a bounding box defining the limits of the model.
Parameters
----------
factor : float, optional
The multiple of the FWHM used to define the limits.
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
delta = factor * self.fwhm
return ((self.y_0 - delta, self.y_0 + delta),
(self.x_0 - delta, self.x_0 + delta))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import MoffatPSF
>>> model = MoffatPSF(x_0=0, y_0=0, alpha=2, beta=3)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-20.39298114135835, upper=20.39298114135835)
y: Interval(lower=-20.39298114135835, upper=20.39298114135835)
}
model=MoffatPSF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-14.27508679895084, upper=14.27508679895084)
y: Interval(lower=-14.27508679895084, upper=14.27508679895084)
}
model=MoffatPSF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, alpha, beta):
"""
Calculate the value of the 2D Moffat model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
alpha : float, optional
The characteristic radius of the Moffat profile.
beta : float, optional
The asymptotic power-law slope of the Moffat profile wings
at large radial distances. Larger values provide less flux
in the profile wings. For large ``beta``, this profile
approaches a Gaussian profile. ``beta`` must be greater
than 1. If ``beta`` is set to 1, then the Moffat profile is
a Lorentz function, whose integral is infinite. For this
normalized model, if ``beta`` is set to 1, then the profile
will be zero everywhere.
Returns
-------
result : `~numpy.ndarray`
The value of the model evaluated at the input coordinates.
"""
# output units should match the input flux units
alpha2 = alpha.copy()
if isinstance(alpha, u.Quantity):
alpha2 = alpha.value
amp = flux * (beta - 1) / (np.pi * alpha2 ** 2)
r2 = (x - x_0) ** 2 + (y - y_0) ** 2
return amp * (1 + (r2 / alpha**2)) ** (-beta)
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'alpha': inputs_unit[self.inputs[0]],
'flux': outputs_unit[self.outputs[0]]}
[docs]
class AiryDiskPSF(Fittable2DModel):
r"""
A 2D Airy disk PSF model.
This model is evaluated by sampling the 2D Airy disk function at the
input coordinates. The Airy disk profile is normalized such that the
analytical integral over the entire 2D plane is equal to the total
flux.
Parameters
----------
flux : float, optional
Total integrated flux over the entire PSF.
x_0 : float, optional
Position of the peak along the x axis.
y_0 : float, optional
Position of the peak along the y axis.
radius : float, optional
The radius of the Airy disk at the first zero.
bbox_factor : float, optional
The multiple of the FWHM used to define the bounding box limits.
**kwargs : dict, optional
Additional optional keyword arguments to be passed to the
`astropy.modeling.Model` base class.
See Also
--------
GaussianPSF, CircularGaussianPSF, MoffatPSF
Notes
-----
The Airy disk profile is defined as:
.. math::
f(r) = \frac{F}{4 \pi (R / R_z)^2}
\left[ \frac{2 J_1\left(\frac{\pi r}{R / R_z}\right)}
{\frac{\pi r}{R / R_z}} \right]^2
where :math:`r` is radial distance from the peak
.. math::
r = \sqrt{(x - x_0)^2 + (y - y_0)^2}
:math:`F` is the total integrated flux,
:math:`J_1` is the first order `Bessel function
<https://en.wikipedia.org/wiki/Bessel_function>`_ of the first
kind, :math:`R` is the input ``radius`` parameter, and :math:`R_z =
1.2196698912665045` is the solution to the equation :math:`J_1(\pi
R_z) = 0`.
For an optical system, the radius of the first zero represents
the limiting angular resolution. The limiting angular resolution
is :math:`R_z \, \lambda / D \approx 1.22 \, \lambda / D`, where
:math:`\lambda` is the wavelength of the light and :math:`D` is the
diameter of the aperture.
The full width at half maximum (FWHM) of the Airy disk profile is
given by:
.. math::
\rm{FWHM} = 1.028993969962188 \, \frac{R}{R_z}
= 0.8436659602162364 \, R
The model is normalized such that:
.. math::
\int_{0}^{2 \pi} \int_{0}^{\infty} f(r) \,r \,dr \,d\theta =
\int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y)
\,dx \,dy = F
The ``radius`` parameter is fixed by default. If you wish to fit
this parameter, set the ``fixed`` attribute to `False`, e.g.,::
>>> from photutils.psf import AiryDiskPSF
>>> model = AiryDiskPSF()
>>> model.radius.fixed = False
By default, the ``radius`` parameter is bounded to be strictly
positive.
References
----------
.. [1] https://en.wikipedia.org/wiki/Airy_disk
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from astropy.visualization import simple_norm
from photutils.psf import AiryDiskPSF
model = AiryDiskPSF(flux=71.4, x_0=24.3, y_0=25.2, radius=5)
yy, xx = np.mgrid[0:51, 0:51]
data = model(xx, yy)
norm = simple_norm(data, 'sqrt')
plt.imshow(data, norm=norm, origin='lower', interpolation='nearest')
"""
flux = Parameter(
default=1, description='Total integrated flux over the entire PSF.')
x_0 = Parameter(
default=0, description='Position of the peak along the x axis')
y_0 = Parameter(
default=0, description='Position of the peak along the y axis')
radius = Parameter(
default=1,
bounds=(FLOAT_EPSILON, None),
fixed=True,
description='Radius of the Airy disk at the first zero')
_rz = jn_zeros(1, 1)[0] / np.pi
def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default,
radius=radius.default, bbox_factor=10.0, **kwargs):
super().__init__(flux=flux, x_0=x_0, y_0=y_0, radius=radius, **kwargs)
self.bbox_factor = bbox_factor
@property
def fwhm(self):
"""
The FWHM of the Airy disk profile.
"""
return 2.0 * 1.616339948310703 * self.radius / self._rz / np.pi
def _calc_bounding_box(self, factor=10.0):
"""
Calculate a bounding box defining the limits of the model.
Parameters
----------
factor : float, optional
The multiple of the FWHM used to define the limits.
Returns
-------
bbox : tuple
A bounding box defining the ((y_min, y_max), (x_min, x_max))
limits of the model.
"""
delta = factor * self.fwhm
return ((self.y_0 - delta, self.y_0 + delta),
(self.x_0 - delta, self.x_0 + delta))
@property
def bounding_box(self):
"""
The bounding box of the model.
Examples
--------
>>> from photutils.psf import AiryDiskPSF
>>> model = AiryDiskPSF(x_0=0, y_0=0, radius=3)
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-25.30997880648709, upper=25.30997880648709)
y: Interval(lower=-25.30997880648709, upper=25.30997880648709)
}
model=AiryDiskPSF(inputs=('x', 'y'))
order='C'
)
>>> model.bbox_factor = 7
>>> model.bounding_box # doctest: +FLOAT_CMP
ModelBoundingBox(
intervals={
x: Interval(lower=-17.71698516454096, upper=17.71698516454096)
y: Interval(lower=-17.71698516454096, upper=17.71698516454096)
}
model=AiryDiskPSF(inputs=('x', 'y'))
order='C'
)
"""
return self._calc_bounding_box(factor=self.bbox_factor)
[docs]
def evaluate(self, x, y, flux, x_0, y_0, radius):
"""
Calculate the value of the 2D Airy disk model at the input
coordinates for the given model parameters.
Parameters
----------
x, y : float or array_like
The x and y coordinates at which to evaluate the model.
flux : float
Total integrated flux over the entire PSF.
x_0, y_0 : float
Position of the peak along the x and y axes.
radius : float, optional
The radius of the Airy disk at the first zero.
Returns
-------
result : `~numpy.ndarray`
The value of the model evaluated at the input coordinates.
"""
r = np.sqrt((x - x_0) ** 2 + (y - y_0) ** 2) / (radius / self._rz)
if isinstance(r, u.Quantity):
# scipy function cannot handle Quantity, so turn into array
r = r.to_value(u.dimensionless_unscaled)
# Since r can be zero, we have to take care to treat that case
# separately so as not to raise a numpy warning
z = np.ones(r.shape)
rt = np.pi * r[r > 0]
z[r > 0] = (2.0 * j1(rt) / rt) ** 2
if isinstance(flux, u.Quantity):
# make z a quantity to allow in-place multiplication
z <<= u.dimensionless_unscaled
normalization = (4.0 / np.pi) * (radius / self._rz) ** 2
if isinstance(normalization, u.Quantity):
normalization = normalization.value
z *= (flux / normalization)
return z
@property
def input_units(self):
"""
The input units of the model.
"""
x_unit = self.x_0.input_unit
y_unit = self.y_0.input_unit
if x_unit is None and y_unit is None:
return None
return {self.inputs[0]: x_unit, self.inputs[1]: y_unit}
def _parameter_units_for_data_units(self, inputs_unit, outputs_unit):
return {'x_0': inputs_unit[self.inputs[0]],
'y_0': inputs_unit[self.inputs[0]],
'radius': inputs_unit[self.inputs[0]],
'flux': outputs_unit[self.outputs[0]]}