34from lsst.pipe.base
import Struct, timeMethod
36__all__ = (
"DipoleFitTask",
"DipoleFitPlugin",
"DipoleFitTaskConfig",
"DipoleFitPluginConfig",
45 """Configuration for DipoleFitPlugin
48 fitAllDiaSources = pexConfig.Field(
49 dtype=float, default=False,
50 doc=
"""Attempte dipole fit of all diaSources (otherwise just the ones consisting of overlapping
51 positive and negative footprints)
""")
53 maxSeparation = pexConfig.Field(
54 dtype=float, default=5.,
55 doc="Assume dipole is not separated by more than maxSeparation * psfSigma")
57 relWeight = pexConfig.Field(
58 dtype=float, default=0.5,
59 doc=
"""Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub.
62 tolerance = pexConfig.Field(
63 dtype=float, default=1e-7,
66 fitBackground = pexConfig.Field(
68 doc=
"Set whether and how to fit for linear gradient in pre-sub. images. Possible values:"
69 "0: do not fit background at all"
70 "1 (default): pre-fit the background using linear least squares and then do not fit it as part"
71 "of the dipole fitting optimization"
72 "2: pre-fit the background using linear least squares (as in 1), and use the parameter"
73 "estimates from that fit as starting parameters for an integrated re-fit of the background")
75 fitSeparateNegParams = pexConfig.Field(
76 dtype=bool, default=
False,
77 doc=
"Include parameters to fit for negative values (flux, gradient) separately from pos.")
80 minSn = pexConfig.Field(
81 dtype=float, default=np.sqrt(2) * 5.0,
82 doc=
"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole")
84 maxFluxRatio = pexConfig.Field(
85 dtype=float, default=0.65,
86 doc=
"Maximum flux ratio in either lobe to be considered a dipole")
88 maxChi2DoF = pexConfig.Field(
89 dtype=float, default=0.05,
90 doc=
"""Maximum Chi2/DoF significance of fit to be considered a dipole.
91 Default value means \"Choose a chi2DoF corresponding to a significance level of at most 0.05\"
92 (note this is actually a significance,
not a chi2 value).
""")
95class DipoleFitTaskConfig(measBase.SingleFrameMeasurementConfig):
96 """Measurement of detected diaSources as dipoles
98 Currently we keep the "old" DipoleMeasurement algorithms turned on.
102 measBase.SingleFrameMeasurementConfig.setDefaults(self)
104 self.plugins.names = [
"base_CircularApertureFlux",
111 "base_PeakLikelihoodFlux",
113 "base_NaiveCentroid",
114 "ip_diffim_NaiveDipoleCentroid",
115 "ip_diffim_NaiveDipoleFlux",
116 "ip_diffim_PsfDipoleFlux",
117 "ip_diffim_ClassificationDipole",
120 self.slots.calibFlux =
None
121 self.slots.modelFlux =
None
122 self.slots.gaussianFlux =
None
123 self.slots.shape =
"base_SdssShape"
124 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
129 """A task that fits a dipole to a difference image, with an optional separate detection image.
131 Because it subclasses SingleFrameMeasurementTask, and calls
132 SingleFrameMeasurementTask.run()
from its
run() method, it still
133 can be used identically to a standard SingleFrameMeasurementTask.
136 ConfigClass = DipoleFitTaskConfig
137 _DefaultName = "ip_diffim_DipoleFit"
139 def __init__(self, schema, algMetadata=None, **kwargs):
141 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
143 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
146 schema=schema, metadata=algMetadata)
149 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
150 """Run dipole measurement and classification
155 ``diaSources`` that will be measured using dipole measurement
157 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
158 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
159 fitted directly to this difference image.
161 "Positive" exposure, typically a science exposure,
or None if unavailable
162 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
164 "Negative" exposure, typically a template exposure,
or None if unavailable
165 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
170 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
175 for source
in sources:
176 self.
dipoleFitter.measure(source, exposure, posExp, negExp)
180 """Lightweight class containing methods for generating a dipole model for fitting
181 to sources in diffims, used by DipoleFitAlgorithm.
184 `DMTN-007: Dipole characterization
for image differencing <https://dmtn-007.lsst.io>`_.
190 self.
log = Log.getLogger(__name__)
193 """Generate gradient model (2-d array) with up to 2nd-order polynomial
198 (2, w, h)-dimensional `numpy.array`, containing the
199 input x,y meshgrid providing the coordinates upon which to
200 compute the gradient. This will typically be generated via
201 `_generateXYGrid()`. `w` and `h` correspond to the width
and
202 height of the desired grid.
203 pars : `list` of `float`, optional
204 Up to 6 floats
for up
205 to 6 2nd-order 2-d polynomial gradient parameters,
in the
206 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
207 is emtpy
or `
None`, do nothing
and return `
None` (
for speed).
211 result : `
None`
or `numpy.array`
212 return None,
or 2-d numpy.array of width/height matching
213 input bbox, containing computed gradient values.
217 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
220 y, x = in_x[0, :], in_x[1, :]
221 gradient = np.full_like(x, pars[0], dtype=
'float64')
222 if len(pars) > 1
and pars[1]
is not None:
223 gradient += pars[1] * x
224 if len(pars) > 2
and pars[2]
is not None:
225 gradient += pars[2] * y
226 if len(pars) > 3
and pars[3]
is not None:
227 gradient += pars[3] * (x * y)
228 if len(pars) > 4
and pars[4]
is not None:
229 gradient += pars[4] * (x * x)
230 if len(pars) > 5
and pars[5]
is not None:
231 gradient += pars[5] * (y * y)
235 def _generateXYGrid(self, bbox):
236 """Generate a meshgrid covering the x,y coordinates bounded by bbox
241 input Bounding Box defining the coordinate limits
246 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
250 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
251 in_x = np.array([y, x]).astype(np.float64)
252 in_x[0, :] -= np.mean(in_x[0, :])
253 in_x[1, :] -= np.mean(in_x[1, :])
256 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
257 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
258 as an `lsst.afw.image.ImageF`.
263 HeavyFootprint to use to generate the subimage
264 badfill : `float`, optional
265 Value to fill
in pixels
in extracted image that are outside the footprint
267 Optionally grow the footprint by this amount before extraction
271 subim2 : `lsst.afw.image.ImageF`
272 An `~lsst.afw.image.ImageF` containing the subimage.
278 subim2 = afwImage.ImageF(bbox, badfill)
279 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
283 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
285 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
290 SourceRecord, the footprint of which is to be fit
292 The exposure
from which to extract the footprint subimage
294 Polynomial order of background gradient to fit.
298 pars : `tuple` of `float`
299 `tuple` of length (1
if order==0; 3
if order==1; 6
if order == 2),
300 containing the resulting fit parameters
305 fp = source.getFootprint()
308 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
313 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
316 isBg = np.isnan(posFpImg.getArray()).ravel()
318 data = posImg.getArray().ravel()
322 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
323 x = x.astype(np.float64).ravel()
326 y = y.astype(np.float64).ravel()
329 b = np.ones_like(x, dtype=np.float64)
333 M = np.vstack([b, x, y]).T
335 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
337 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
341 """Generate a 2D image model of a single PDF centered at the given coordinates.
345 bbox : `lsst.geom.Box`
346 Bounding box marking pixel coordinates for generated model
348 Psf model used to generate the
'star'
350 Desired x-centroid of the
'star'
352 Desired y-centroid of the
'star'
354 Desired flux of the
'star'
359 2-d stellar image of width/height matching input ``bbox``,
360 containing PSF
with given centroid
and flux
364 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
365 psf_img_sum = np.nansum(psf_img.getArray())
366 psf_img *= (flux/psf_img_sum)
369 psf_box = psf_img.getBBox()
371 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
377 p_Im = afwImage.ImageF(bbox)
378 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
383 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
384 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
385 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
387 """Generate dipole model with given parameters.
389 This is the function whose sum-of-squared difference
from data
390 is minimized by `lmfit`.
393 Input independent variable. Used here
as the grid on
394 which to compute the background gradient model.
396 Desired flux of the positive lobe of the dipole
398 Desired x-centroid of the positive lobe of the dipole
400 Desired y-centroid of the positive lobe of the dipole
402 Desired x-centroid of the negative lobe of the dipole
404 Desired y-centroid of the negative lobe of the dipole
405 fluxNeg : `float`, optional
406 Desired flux of the negative lobe of the dipole, set to
'flux' if None
407 b, x1, y1, xy, x2, y2 : `float`
408 Gradient parameters
for positive lobe.
409 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
410 Gradient parameters
for negative lobe.
411 They are set to the corresponding positive values
if None.
414 Keyword arguments passed through ``lmfit``
and
415 used by this function. These must include:
417 - ``psf`` Psf model used to generate the
'star'
418 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
419 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
420 - ``bbox`` Bounding box containing region to be modelled
425 Has width
and height matching the input bbox,
and
426 contains the dipole model
with given centroids
and flux(es). If
427 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
428 those of bbox; otherwise a stack of three such arrays,
429 representing the dipole (diffim), positive
and negative images
433 psf = kwargs.get('psf')
434 rel_weight = kwargs.get(
'rel_weight')
435 fp = kwargs.get(
'footprint')
442 self.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
443 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
445 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
447 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
449 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
450 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
454 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
455 in_x = np.array([x, y]) * 1.
456 in_x[0, :] -= in_x[0, :].mean()
457 in_x[1, :] -= in_x[1, :].mean()
466 gradientNeg = gradient
468 posIm.getArray()[:, :] += gradient
469 negIm.getArray()[:, :] += gradientNeg
472 diffIm = afwImage.ImageF(bbox)
476 zout = diffIm.getArray()
478 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
484 """Fit a dipole model using an image difference.
487 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
492 _private_version_ =
'0.0.5'
509 def __init__(self, diffim, posImage=None, negImage=None):
510 """Algorithm to run dipole measurement on a diaSource
515 Exposure on which the diaSources were detected
517 "Positive" exposure
from which the template was subtracted
519 "Negative" exposure which was subtracted
from the posImage
526 if diffim
is not None:
527 self.
psfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
529 self.
log = Log.getLogger(__name__)
535 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
536 separateNegParams=True, verbose=False):
537 """Fit a dipole model to an input difference image.
539 Actually, fits the subimage bounded by the input source's
540 footprint) and optionally constrain the fit using the
541 pre-subtraction images posImage
and negImage.
545 source : TODO: DM-17458
547 tol : float, optional
549 rel_weight : `float`, optional
551 fitBackground : `int`, optional
553 bgGradientOrder : `int`, optional
555 maxSepInSigma : `float`, optional
557 separateNegParams : `bool`, optional
559 verbose : `bool`, optional
564 result : `lmfit.MinimizerResult`
565 return `lmfit.MinimizerResult` object containing the fit
566 parameters
and other information.
572 fp = source.getFootprint()
574 subim = afwImage.MaskedImageF(self.
diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
576 z = diArr = subim.getArrays()[0]
577 weights = 1. / subim.getArrays()[2]
579 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
581 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
583 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
585 posSubim = subim.clone()
588 negSubim = posSubim.clone()
591 z = np.append([z], [posSubim.getArrays()[0],
592 negSubim.getArrays()[0]], axis=0)
594 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
595 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
602 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
603 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
604 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
606 """Generate dipole model with given parameters.
608 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
609 out of `kwargs['modelObj']`.
611 modelObj = kwargs.pop('modelObj')
612 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
613 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
614 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
615 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
619 modelFunctor = dipoleModelFunctor
622 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
627 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
628 cenNeg = cenPos = fpCentroid
633 cenPos = pks[0].getF()
635 cenNeg = pks[-1].getF()
639 maxSep = self.
psfSigma * maxSepInSigma
642 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
644 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
650 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
651 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
652 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
653 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
654 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
655 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
656 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
657 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
661 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
662 posFlux = negFlux = startingFlux
665 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
667 if separateNegParams:
669 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
677 bgParsPos = bgParsNeg = (0., 0., 0.)
678 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
682 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
683 order=bgGradientOrder)
686 if fitBackground == 1:
687 in_x = dipoleModel._generateXYGrid(bbox)
688 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
690 z[1, :] -= np.nanmedian(z[1, :])
691 posFlux = np.nansum(z[1, :])
692 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
694 if separateNegParams
and self.
negImage is not None:
695 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
696 order=bgGradientOrder)
697 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
699 z[2, :] -= np.nanmedian(z[2, :])
700 if separateNegParams:
701 negFlux = np.nansum(z[2, :])
702 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
705 if fitBackground == 2:
706 if bgGradientOrder >= 0:
707 gmod.set_param_hint(
'b', value=bgParsPos[0])
708 if separateNegParams:
709 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
710 if bgGradientOrder >= 1:
711 gmod.set_param_hint(
'x1', value=bgParsPos[1])
712 gmod.set_param_hint(
'y1', value=bgParsPos[2])
713 if separateNegParams:
714 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
715 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
716 if bgGradientOrder >= 2:
717 gmod.set_param_hint(
'xy', value=bgParsPos[3])
718 gmod.set_param_hint(
'x2', value=bgParsPos[4])
719 gmod.set_param_hint(
'y2', value=bgParsPos[5])
720 if separateNegParams:
721 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
722 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
723 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
725 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
726 in_x = np.array([x, y]).astype(np.float)
727 in_x[0, :] -= in_x[0, :].mean()
728 in_x[1, :] -= in_x[1, :].mean()
732 mask = np.ones_like(z, dtype=bool)
737 weights = mask.astype(np.float64)
738 if self.
posImage is not None and rel_weight > 0.:
739 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
740 np.ones_like(diArr)*rel_weight])
749 with warnings.catch_warnings():
750 warnings.simplefilter(
"ignore")
751 result = gmod.fit(z, weights=weights, x=in_x,
753 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
756 rel_weight=rel_weight,
758 modelObj=dipoleModel)
765 print(result.fit_report(show_correl=
False))
766 if separateNegParams:
767 print(result.ci_report())
772 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
773 bgGradientOrder=1, verbose=False, display=False):
774 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
776 Actually, fits the subimage bounded by the input source's
777 footprint) and optionally constrain the fit using the
778 pre-subtraction images self.
posImage (science)
and
779 self.
negImage (template). Wraps the output into a
780 `pipeBase.Struct` named tuple after computing additional
781 statistics such
as orientation
and SNR.
786 Record containing the (merged) dipole source footprint detected on the diffim
787 tol : `float`, optional
788 Tolerance parameter
for scipy.leastsq() optimization
789 rel_weight : `float`, optional
790 Weighting of posImage/negImage relative to the diffim
in the fit
791 fitBackground : `int`, {0, 1, 2}, optional
792 How to fit linear background gradient
in posImage/negImage
794 - 0: do
not fit background at all
795 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
796 as part of the dipole fitting optimization
797 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
798 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
799 background
as part of the overall dipole fitting optimization.
800 maxSepInSigma : `float`, optional
801 Allowed window of centroid parameters relative to peak
in input source footprint
802 separateNegParams : `bool`, optional
803 Fit separate parameters to the flux
and background gradient
in
804 bgGradientOrder : `int`, {0, 1, 2}, optional
805 Desired polynomial order of background gradient
806 verbose: `bool`, optional
809 Display input data, best fit model(s)
and residuals
in a matplotlib window.
814 `pipeBase.Struct` object containing the fit parameters
and other information.
817 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
821 Parameter `fitBackground` has three options, thus it
is an integer:
826 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
827 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
828 bgGradientOrder=bgGradientOrder, verbose=verbose)
832 fp = source.getFootprint()
835 fitParams = fitResult.best_values
836 if fitParams[
'flux'] <= 1.:
837 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
838 negCentroidX=np.nan, negCentroidY=np.nan,
839 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
840 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
841 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
842 return out, fitResult
844 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
845 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
846 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
847 angle = np.arctan2(dy, dx) / np.pi * 180.
851 def computeSumVariance(exposure, footprint):
852 box = footprint.getBBox()
853 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
854 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
856 fluxVal = fluxVar = fitParams[
'flux']
857 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
859 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
861 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
863 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
864 if separateNegParams:
865 fluxValNeg = fitParams[
'fluxNeg']
866 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
868 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
871 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
872 except ZeroDivisionError:
873 signalToNoise = np.nan
875 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
876 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
877 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
878 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
879 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
882 return out, fitResult
885 """Display data, model fits and residuals (currently uses matplotlib display functions).
889 footprint : TODO: DM-17458
890 Footprint containing the dipole that was fit
891 result : `lmfit.MinimizerResult`
892 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
896 fig : `matplotlib.pyplot.plot`
899 import matplotlib.pyplot
as plt
900 except ImportError
as err:
901 self.
log.warning(
'Unable to import matplotlib: %s', err)
904 def display2dArray(arr, title='Data', extent=None):
905 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
907 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
909 plt.colorbar(fig, cmap=
'gray')
913 fit = result.best_fit
914 bbox = footprint.getBBox()
915 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
917 fig = plt.figure(figsize=(8, 8))
919 plt.subplot(3, 3, i*3+1)
920 display2dArray(z[i, :],
'Data', extent=extent)
921 plt.subplot(3, 3, i*3+2)
922 display2dArray(fit[i, :],
'Model', extent=extent)
923 plt.subplot(3, 3, i*3+3)
924 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
927 fig = plt.figure(figsize=(8, 2.5))
929 display2dArray(z,
'Data', extent=extent)
931 display2dArray(fit,
'Model', extent=extent)
933 display2dArray(z - fit,
'Residual', extent=extent)
939@measBase.register("ip_diffim_DipoleFit")
941 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
943 This measurement plugin accepts up to three input images in
944 its `measure` method. If these are provided, it includes data
945 from the pre-subtraction posImage (science image)
and optionally
946 negImage (template image) to constrain the fit. The meat of the
947 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
951 The motivation behind this plugin
and the necessity
for including more than
952 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
954 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
955 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
956 are deemed to be replaceable by this.
959 ConfigClass = DipoleFitPluginConfig
960 DipoleFitAlgorithmClass = DipoleFitAlgorithm
964 FAILURE_NOT_DIPOLE = 4
968 """Set execution order to `FLUX_ORDER`.
970 This includes algorithms that require both `getShape()` and `getCentroid()`,
971 in addition to a Footprint
and its Peaks.
973 return cls.FLUX_ORDER
975 def __init__(self, config, name, schema, metadata):
976 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)
978 self.
log = Log.getLogger(name)
982 def _setupSchema(self, config, name, schema, metadata):
990 for pos_neg
in [
'pos',
'neg']:
992 key = schema.addField(
993 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
994 doc=
"Dipole {0} lobe flux".format(pos_neg))
995 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
997 key = schema.addField(
998 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
999 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1000 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1002 for x_y
in [
'x',
'y']:
1003 key = schema.addField(
1004 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1005 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1006 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1008 for x_y
in [
'x',
'y']:
1009 key = schema.addField(
1010 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1011 doc=
"Dipole centroid")
1012 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1015 schema.join(name,
"instFlux"), type=float, units=
"count",
1016 doc=
"Dipole overall flux")
1019 schema.join(name,
"orientation"), type=float, units=
"deg",
1020 doc=
"Dipole orientation")
1023 schema.join(name,
"separation"), type=float, units=
"pixel",
1024 doc=
"Pixel separation between positive and negative lobes of dipole")
1027 schema.join(name,
"chi2dof"), type=float,
1028 doc=
"Chi2 per degree of freedom of dipole fit")
1031 schema.join(name,
"signalToNoise"), type=float,
1032 doc=
"Estimated signal-to-noise of dipole fit")
1035 schema.join(name,
"flag",
"classification"), type=
"Flag",
1036 doc=
"Flag indicating diaSource is classified as a dipole")
1039 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1040 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1043 schema.join(name,
"flag"), type=
"Flag",
1044 doc=
"General failure flag for dipole fit")
1047 schema.join(name,
"flag",
"edge"), type=
"Flag",
1048 doc=
"Flag set when dipole is too close to edge of image")
1050 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1051 """Perform the non-linear least squares minimization on the putative dipole source.
1056 diaSources that will be measured using dipole measurement
1058 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1059 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1060 dipole to just the `exposure`
with no constraint.
1062 "Positive" exposure, typically a science exposure,
or None if unavailable
1063 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1065 "Negative" exposure, typically a template exposure,
or None if unavailable
1066 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1070 The main functionality of this routine was placed outside of
1071 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1072 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1073 testing (
@see `tests/testDipoleFitter.py`)
1077 result : TODO: DM-17458
1082 pks = measRecord.getFootprint().getPeaks()
1087 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1088 == np.sign(pks[-1].getPeakValue())))
1093 if not self.config.fitAllDiaSources:
1098 result, _ = alg.fitDipole(
1099 measRecord, rel_weight=self.config.relWeight,
1100 tol=self.config.tolerance,
1101 maxSepInSigma=self.config.maxSeparation,
1102 fitBackground=self.config.fitBackground,
1103 separateNegParams=self.config.fitSeparateNegParams,
1104 verbose=
False, display=
False)
1106 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
1108 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1115 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1117 if result.posFlux <= 1.:
1118 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1122 measRecord[self.posFluxKey] = result.posFlux
1123 measRecord[self.posFluxErrKey] = result.signalToNoise
1124 measRecord[self.posCentroidKeyX] = result.posCentroidX
1125 measRecord[self.posCentroidKeyY] = result.posCentroidY
1127 measRecord[self.negFluxKey] = result.negFlux
1128 measRecord[self.negFluxErrKey] = result.signalToNoise
1129 measRecord[self.negCentroidKeyX] = result.negCentroidX
1130 measRecord[self.negCentroidKeyY] = result.negCentroidY
1133 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1135 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1136 + (result.posCentroidY - result.negCentroidY)**2.)
1137 measRecord[self.centroidKeyX] = result.centroidX
1138 measRecord[self.centroidKeyY] = result.centroidY
1146 """Classify a source as a dipole.
1150 measRecord : TODO: DM-17458
1152 chi2val : TODO: DM-17458
1157 Sources are classified as dipoles,
or not, according to three criteria:
1159 1. Does the total signal-to-noise surpass the ``minSn``?
1160 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1161 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1162 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1170 passesFluxPos = (abs(measRecord[self.posFluxKey])
1171 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1172 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1173 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1174 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1175 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1176 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1184 from scipy.stats
import chi2
1186 significance = chi2.cdf(chi2val, ndof)
1187 passesChi2 = significance < self.config.maxChi2DoF
1188 allPass = allPass
and passesChi2
1197 def fail(self, measRecord, error=None):
1198 """Catch failures and set the correct flags.
1201 measRecord.set(self.flagKey, True)
1202 if error
is not None:
1204 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1207 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1208 measRecord.set(self.
flagKey,
True)
1210 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1211 measRecord.getId(), str(error))
1213 measRecord.set(self.
flagKey,
True)
1215 self.
log.warning(
'DipoleFitPlugin failed on record %d', measRecord.getId())
def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
def fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
def displayFitResults(self, footprint, result)
def __init__(self, diffim, posImage=None, negImage=None)
def fail(self, measRecord, error=None)
def getExecutionOrder(cls)
def measure(self, measRecord, exposure, posExp=None, negExp=None)
def doClassify(self, measRecord, chi2val)
classificationAttemptedFlagKey
def _setupSchema(self, config, name, schema, metadata)
def __init__(self, config, name, schema, metadata)
def __init__(self, schema, algMetadata=None, **kwargs)
def run(self, sources, exposure, posExp=None, negExp=None, **kwargs)
def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0)
def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None, b=None, x1=None, y1=None, xy=None, x2=None, y2=None, bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None, **kwargs)
def makeBackgroundModel(self, in_x, pars=None)
def fitFootprintBackground(self, source, posImage, order=1)
def makeStarModel(self, bbox, psf, xcen, ycen, flux)