22 from __future__
import absolute_import, division, print_function
27 from builtins
import str
28 from builtins
import range
29 from builtins
import object
31 import lsst.afw.geom
as afwGeom
32 import lsst.afw.image
as afwImage
33 import lsst.meas.base
as measBase
34 import lsst.afw.table
as afwTable
35 import lsst.afw.detection
as afwDet
36 from lsst.log
import Log
37 import lsst.pex.exceptions
as pexExcept
38 import lsst.pex.config
as pexConfig
39 from lsst.pipe.base
import Struct, timeMethod
41 __all__ = (
"DipoleFitTask",
"DipoleFitPlugin",
"DipoleFitTaskConfig",
"DipoleFitPluginConfig")
49 """!Configuration for DipoleFitPlugin
52 fitAllDiaSources = pexConfig.Field(
53 dtype=float, default=
False,
54 doc=
"""Attempte dipole fit of all diaSources (otherwise just the ones consisting of overlapping
55 positive and negative footprints)""")
57 maxSeparation = pexConfig.Field(
58 dtype=float, default=5.,
59 doc=
"Assume dipole is not separated by more than maxSeparation * psfSigma")
61 relWeight = pexConfig.Field(
62 dtype=float, default=0.5,
63 doc=
"""Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub.
66 tolerance = pexConfig.Field(
67 dtype=float, default=1e-7,
70 fitBackground = pexConfig.Field(
72 doc=
"""Set whether and how to fit for linear gradient in pre-sub. images. Possible values:
73 0: do not fit background at all
74 1 (default): pre-fit the background using linear least squares and then do not fit it as part
75 of the dipole fitting optimization
76 2: pre-fit the background using linear least squares (as in 1), and use the parameter
77 estimates from that fit as starting parameters for an integrated "re-fit" of the background
80 fitSeparateNegParams = pexConfig.Field(
81 dtype=bool, default=
False,
82 doc=
"Include parameters to fit for negative values (flux, gradient) separately from pos.")
85 minSn = pexConfig.Field(
86 dtype=float, default=np.sqrt(2) * 5.0,
87 doc=
"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole")
89 maxFluxRatio = pexConfig.Field(
90 dtype=float, default=0.65,
91 doc=
"Maximum flux ratio in either lobe to be considered a dipole")
93 maxChi2DoF = pexConfig.Field(
94 dtype=float, default=0.05,
95 doc=
"""Maximum Chi2/DoF significance of fit to be considered a dipole.
96 Default value means \"Choose a chi2DoF corresponding to a significance level of at most 0.05\"
97 (note this is actually a significance, not a chi2 value).""")
101 """!Measurement of detected diaSources as dipoles
103 Currently we keep the "old" DipoleMeasurement algorithms turned on.
107 measBase.SingleFrameMeasurementConfig.setDefaults(self)
109 self.plugins.names = [
"base_CircularApertureFlux",
115 "base_GaussianCentroid",
117 "base_PeakLikelihoodFlux",
119 "base_NaiveCentroid",
120 "ip_diffim_NaiveDipoleCentroid",
121 "ip_diffim_NaiveDipoleFlux",
122 "ip_diffim_PsfDipoleFlux",
123 "ip_diffim_ClassificationDipole",
126 self.slots.calibFlux =
None
127 self.slots.modelFlux =
None
128 self.slots.instFlux =
None
129 self.slots.shape =
"base_SdssShape"
130 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
135 """!Subclass of SingleFrameMeasurementTask which accepts up to three input images in its run() method.
137 Because it subclasses SingleFrameMeasurementTask, and calls
138 SingleFrameMeasurementTask.run() from its run() method, it still
139 can be used identically to a standard SingleFrameMeasurementTask.
142 ConfigClass = DipoleFitTaskConfig
143 _DefaultName =
"ip_diffim_DipoleFit"
145 def __init__(self, schema, algMetadata=None, **kwds):
147 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwds)
149 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
152 schema=schema, metadata=algMetadata)
155 def run(self, sources, exposure, posExp=None, negExp=None, **kwds):
156 """!Run dipole measurement and classification
158 @param sources diaSources that will be measured using dipole measurement
159 @param exposure Difference exposure on which the diaSources were detected; exposure = posExp - negExp
160 @param posExp "Positive" exposure, typically a science exposure, or None if unavailable
161 @param negExp "Negative" exposure, typically a template exposure, or None if unavailable
162 @param **kwds Sent to SingleFrameMeasurementTask
164 @note When `posExp` is `None`, will compute `posImage = exposure + negExp`.
165 Likewise, when `negExp` is `None`, will compute `negImage = posExp - exposure`.
166 If both `posExp` and `negExp` are `None`, will attempt to fit the dipole to just the `exposure`
170 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwds)
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.
183 This code is documented in DMTN-007 (http://dmtn-007.lsst.io).
188 self.
debug = lsstDebug.Info(__name__).debug
189 self.
log = Log.getLogger(__name__)
192 """!Generate gradient model (2-d array) with up to 2nd-order polynomial
194 @param in_x (2, w, h)-dimensional `numpy.array`, containing the
195 input x,y meshgrid providing the coordinates upon which to
196 compute the gradient. This will typically be generated via
197 `_generateXYGrid()`. `w` and `h` correspond to the width and
198 height of the desired grid.
199 @param pars Up to 6 floats for up
200 to 6 2nd-order 2-d polynomial gradient parameters, in the
201 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
202 is emtpy or `None`, do nothing and return `None` (for speed).
204 @return None, or 2-d numpy.array of width/height matching
205 input bbox, containing computed gradient values.
209 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
212 y, x = in_x[0, :], in_x[1, :]
213 gradient = np.full_like(x, pars[0], dtype=
'float64')
214 if len(pars) > 1
and pars[1]
is not None:
215 gradient += pars[1] * x
216 if len(pars) > 2
and pars[2]
is not None:
217 gradient += pars[2] * y
218 if len(pars) > 3
and pars[3]
is not None:
219 gradient += pars[3] * (x * y)
220 if len(pars) > 4
and pars[4]
is not None:
221 gradient += pars[4] * (x * x)
222 if len(pars) > 5
and pars[5]
is not None:
223 gradient += pars[5] * (y * y)
228 """!Generate a meshgrid covering the x,y coordinates bounded by bbox
230 @param bbox input BoundingBox defining the coordinate limits
231 @return in_x (2, w, h)-dimensional numpy array containing the grid indexing over x- and
234 @see makeBackgroundModel
237 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
238 in_x = np.array([y, x]).astype(np.float64)
239 in_x[0, :] -= np.mean(in_x[0, :])
240 in_x[1, :] -= np.mean(in_x[1, :])
244 """!Extract the image from a `HeavyFootprint` as an `afwImage.ImageF`.
246 @param fp HeavyFootprint to use to generate the subimage
247 @param badfill Value to fill in pixels in extracted image that are outside the footprint
248 @param grow Optionally grow the footprint by this amount before extraction
250 @return an `afwImage.ImageF` containing the subimage
256 subim2 = afwImage.ImageF(bbox, badfill)
257 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
261 """!Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
263 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
265 @param source SourceRecord, the footprint of which is to be fit
266 @param posImage The exposure from which to extract the footprint subimage
267 @param order Polynomial order of background gradient to fit.
269 @return pars `tuple` of length (1 if order==0; 3 if order==1; 6 if order == 2),
270 containing the resulting fit parameters
272 @todo look into whether to use afwMath background methods -- see
273 http://lsst-web.ncsa.illinois.edu/doxygen/x_masterDoxyDoc/_background_example.html
276 fp = source.getFootprint()
279 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
284 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
287 isBg = np.isnan(posFpImg.getArray()).ravel()
289 data = posImg.getArray().ravel()
293 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
294 x = x.astype(np.float64).ravel()
297 y = y.astype(np.float64).ravel()
300 b = np.ones_like(x, dtype=np.float64)
304 M = np.vstack([b, x, y]).T
306 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
308 pars = np.linalg.lstsq(M, B)[0]
312 """!Generate model (2-d Image) of a 'star' (single PSF) centered at given coordinates
314 @param bbox Bounding box marking pixel coordinates for generated model
315 @param psf Psf model used to generate the 'star'
316 @param xcen Desired x-centroid of the 'star'
317 @param ycen Desired y-centroid of the 'star'
318 @param flux Desired flux of the 'star'
320 @return 2-d stellar `afwImage.Image` of width/height matching input `bbox`,
321 containing PSF with given centroid and flux
325 psf_img = psf.computeImage(afwGeom.Point2D(xcen, ycen)).convertF()
326 psf_img_sum = np.nansum(psf_img.getArray())
327 psf_img *= (flux/psf_img_sum)
330 psf_box = psf_img.getBBox()
332 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
338 p_Im = afwImage.ImageF(bbox)
339 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
344 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
345 b=
None, x1=
None, y1=
None, xy=
None, x2=
None, y2=
None,
346 bNeg=
None, x1Neg=
None, y1Neg=
None, xyNeg=
None, x2Neg=
None, y2Neg=
None,
348 """!Generate dipole model with given parameters.
350 This is the function whose sum-of-squared difference from data
351 is minimized by `lmfit`.
353 @param x Input independent variable. Used here as the grid on
354 which to compute the background gradient model.
355 @param flux Desired flux of the positive lobe of the dipole
356 @param xcenPos Desired x-centroid of the positive lobe of the dipole
357 @param ycenPos Desired y-centroid of the positive lobe of the dipole
358 @param xcenNeg Desired x-centroid of the negative lobe of the dipole
359 @param ycenNeg Desired y-centroid of the negative lobe of the dipole
360 @param fluxNeg Desired flux of the negative lobe of the dipole, set to 'flux' if None
361 @param b, x1, y1, xy, x2, y2 Gradient parameters for positive lobe.
362 @param bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg Gradient parameters for negative lobe.
363 They are set to the corresponding positive values if None.
365 @param **kwargs Keyword arguments passed through `lmfit` and
366 used by this function. These must include:
367 - `psf` Psf model used to generate the 'star'
368 - `rel_weight` Used to signify least-squares weighting of posImage/negImage
369 relative to diffim. If `rel_weight == 0` then posImage/negImage are ignored.
370 - `bbox` Bounding box containing region to be modelled
372 @see `makeBackgroundModel` for further parameter descriptions.
374 @return `numpy.array` of width/height matching input bbox,
375 containing dipole model with given centroids and flux(es). If
376 `rel_weight` = 0, this is a 2-d array with dimensions matching
377 those of bbox; otherwise a stack of three such arrays,
378 representing the dipole (diffim), positive and negative images
382 psf = kwargs.get(
'psf')
383 rel_weight = kwargs.get(
'rel_weight')
384 fp = kwargs.get(
'footprint')
391 self.log.debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
392 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
394 self.log.debug(
' %.2f %.2f %.2f', b, x1, y1)
396 self.log.debug(
' %.2f %.2f %.2f', xy, x2, y2)
398 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
399 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
403 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
404 in_x = np.array([x, y]) * 1.
405 in_x[0, :] -= in_x[0, :].mean()
406 in_x[1, :] -= in_x[1, :].mean()
415 gradientNeg = gradient
417 posIm.getArray()[:, :] += gradient
418 negIm.getArray()[:, :] += gradientNeg
421 diffIm = afwImage.ImageF(bbox)
425 zout = diffIm.getArray()
427 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
433 """!Lightweight class containing methods for fitting a dipole model in
434 a diffim, used by DipoleFitPlugin.
436 This code is documented in DMTN-007 (http://dmtn-007.lsst.io).
438 Below is a (somewhat incomplete) list of improvements
439 that would be worth investigating, given the time:
441 @todo 1. evaluate necessity for separate parameters for pos- and neg- images
442 @todo 2. only fit background OUTSIDE footprint (DONE) and dipole params INSIDE footprint (NOT DONE)?
443 @todo 3. correct normalization of least-squares weights based on variance planes
444 @todo 4. account for PSFs that vary across the exposures (should be happening by default?)
445 @todo 5. correctly account for NA/masks (i.e., ignore!)
446 @todo 6. better exception handling in the plugin
447 @todo 7. better classification of dipoles (e.g. by comparing chi2 fit vs. monopole?)
448 @todo 8. (DONE) Initial fast estimate of background gradient(s) params -- perhaps using numpy.lstsq
449 @todo 9. (NOT NEEDED - see (2)) Initial fast test whether a background gradient needs to be fit
450 @todo 10. (DONE) better initial estimate for flux when there's a strong gradient
451 @todo 11. (DONE) requires a new package `lmfit` -- investiate others? (astropy/scipy/iminuit?)
456 _private_version_ =
'0.0.5'
458 def __init__(self, diffim, posImage=None, negImage=None):
459 """!Algorithm to run dipole measurement on a diaSource
461 @param diffim Exposure on which the diaSources were detected
462 @param posImage "Positive" exposure from which the template was subtracted
463 @param negImage "Negative" exposure which was subtracted from the posImage
470 if diffim
is not None:
471 self.
psfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
473 self.
log = Log.getLogger(__name__)
476 self.
debug = lsstDebug.Info(__name__).debug
479 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
480 separateNegParams=
True, verbose=
False):
481 """!Fit a dipole model to an input difference image.
483 Actually, fits the subimage bounded by the input source's
484 footprint) and optionally constrain the fit using the
485 pre-subtraction images posImage and negImage.
487 @return `lmfit.MinimizerResult` object containing the fit
488 parameters and other information.
496 fp = source.getFootprint()
498 subim = afwImage.MaskedImageF(self.diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
500 z = diArr = subim.getArrays()[0]
501 weights = 1. / subim.getArrays()[2]
503 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
505 negSubim = afwImage.MaskedImageF(self.negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
507 posSubim = afwImage.MaskedImageF(self.posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
509 posSubim = subim.clone()
512 negSubim = posSubim.clone()
515 z = np.append([z], [posSubim.getArrays()[0],
516 negSubim.getArrays()[0]], axis=0)
518 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
519 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
526 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
527 b=
None, x1=
None, y1=
None, xy=
None, x2=
None, y2=
None,
528 bNeg=
None, x1Neg=
None, y1Neg=
None, xyNeg=
None, x2Neg=
None, y2Neg=
None,
530 """!Generate dipole model with given parameters.
532 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
533 out of `kwargs['modelObj']`.
535 @see DipoleModel.makeModel
537 modelObj = kwargs.pop(
'modelObj')
538 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
539 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
540 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
541 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
545 modelFunctor = dipoleModelFunctor
548 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
553 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
554 cenNeg = cenPos = fpCentroid
559 cenPos = pks[0].getF()
561 cenNeg = pks[-1].getF()
565 maxSep = self.
psfSigma * maxSepInSigma
568 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
570 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
576 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
577 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
578 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
579 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
580 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
581 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
582 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
583 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
587 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
588 posFlux = negFlux = startingFlux
591 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
593 if separateNegParams:
595 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
603 bgParsPos = bgParsNeg = (0., 0., 0.)
604 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
608 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
609 order=bgGradientOrder)
612 if fitBackground == 1:
613 in_x = dipoleModel._generateXYGrid(bbox)
614 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
616 z[1, :] -= np.nanmedian(z[1, :])
617 posFlux = np.nansum(z[1, :])
618 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
620 if separateNegParams
and self.
negImage is not None:
621 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
622 order=bgGradientOrder)
623 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
625 z[2, :] -= np.nanmedian(z[2, :])
626 if separateNegParams:
627 negFlux = np.nansum(z[2, :])
628 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
631 if fitBackground == 2:
632 if bgGradientOrder >= 0:
633 gmod.set_param_hint(
'b', value=bgParsPos[0])
634 if separateNegParams:
635 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
636 if bgGradientOrder >= 1:
637 gmod.set_param_hint(
'x1', value=bgParsPos[1])
638 gmod.set_param_hint(
'y1', value=bgParsPos[2])
639 if separateNegParams:
640 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
641 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
642 if bgGradientOrder >= 2:
643 gmod.set_param_hint(
'xy', value=bgParsPos[3])
644 gmod.set_param_hint(
'x2', value=bgParsPos[4])
645 gmod.set_param_hint(
'y2', value=bgParsPos[5])
646 if separateNegParams:
647 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
648 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
649 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
651 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
652 in_x = np.array([x, y]).astype(np.float)
653 in_x[0, :] -= in_x[0, :].mean()
654 in_x[1, :] -= in_x[1, :].mean()
658 mask = np.ones_like(z, dtype=bool)
663 weights = mask.astype(np.float64)
664 if self.
posImage is not None and rel_weight > 0.:
665 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
666 np.ones_like(diArr)*rel_weight])
675 with warnings.catch_warnings():
676 warnings.simplefilter(
"ignore")
677 result = gmod.fit(z, weights=weights, x=in_x,
679 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
681 psf=self.diffim.getPsf(),
682 rel_weight=rel_weight,
684 modelObj=dipoleModel)
691 print(result.fit_report(show_correl=
False))
692 if separateNegParams:
693 print(result.ci_report())
697 def fitDipole(self, source, tol=1e-7, rel_weight=0.1,
698 fitBackground=1, maxSepInSigma=5., separateNegParams=
True,
699 bgGradientOrder=1, verbose=
False, display=
False):
700 """!Wrapper around `fitDipoleImpl()` which performs the fit of a dipole
701 model to an input diaSource.
703 Actually, fits the subimage bounded by the input source's
704 footprint) and optionally constrain the fit using the
705 pre-subtraction images self.posImage (science) and
706 self.negImage (template). Wraps the output into a
707 `pipeBase.Struct` named tuple after computing additional
708 statistics such as orientation and SNR.
710 @param source Record containing the (merged) dipole source footprint detected on the diffim
711 @param tol Tolerance parameter for scipy.leastsq() optimization
712 @param rel_weight Weighting of posImage/negImage relative to the diffim in the fit
713 @param fitBackground How to fit linear background gradient in posImage/negImage (see notes)
714 @param bgGradientOrder Desired polynomial order of background gradient (allowed are [0,1,2])
715 @param maxSepInSigma Allowed window of centroid parameters relative to peak in input source footprint
716 @param separateNegParams Fit separate parameters to the flux and background gradient in
717 the negative images? If true, this adds a separate parameter for the negative flux, and [1, 3, or 6]
718 additional parameters to fit for the background gradient in the negImage. Otherwise, the flux and
719 gradient parameters are constrained to be exactly equal in the fit.
720 @param verbose Be verbose
721 @param display Display input data, best fit model(s) and residuals in a matplotlib window.
723 @return `pipeBase.Struct` object containing the fit parameters and other information.
724 @return `lmfit.MinimizerResult` object for debugging and error estimation, etc.
726 @note Parameter `fitBackground` has three options, thus it is an integer:
727 - 0: do not fit background at all
728 - 1 (default): pre-fit the background using linear least squares and then do not fit it as part
729 of the dipole fitting optimization
730 - 2: pre-fit the background using linear least squares (as in 1), and use the parameter
731 estimates from that fit as starting parameters for an integrated "re-fit" of the background
732 as part of the overall dipole fitting optimization.
736 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
737 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
738 bgGradientOrder=bgGradientOrder, verbose=verbose)
742 fp = source.getFootprint()
745 fitParams = fitResult.best_values
746 if fitParams[
'flux'] <= 1.:
747 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
748 negCentroidX=np.nan, negCentroidY=np.nan,
749 posFlux=np.nan, negFlux=np.nan, posFluxSigma=np.nan, negFluxSigma=np.nan,
750 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
751 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
752 return out, fitResult
754 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
755 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
756 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
757 angle = np.arctan2(dy, dx) / np.pi * 180.
761 def computeSumVariance(exposure, footprint):
762 box = footprint.getBBox()
763 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
764 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
766 fluxVal = fluxVar = fitParams[
'flux']
767 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
769 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
771 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
773 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
774 if separateNegParams:
775 fluxValNeg = fitParams[
'fluxNeg']
776 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
778 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
781 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
783 signalToNoise = np.nan
785 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
786 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
787 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxSigma=fluxErr, negFluxSigma=fluxErrNeg,
788 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
789 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
792 return out, fitResult
795 """!Display data, model fits and residuals (currently uses matplotlib display functions).
797 @param footprint Footprint containing the dipole that was fit
798 @param result `lmfit.MinimizerResult` object returned by `lmfit` optimizer
801 import matplotlib.pyplot
as plt
802 except ImportError
as err:
803 self.log.warn(
'Unable to import matplotlib: %s', err)
806 def display2dArray(arr, title='Data', extent=None):
807 """!Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
809 fig = plt.imshow(arr, origin=
'lower', interpolation=
'none', cmap=
'gray', extent=extent)
811 plt.colorbar(fig, cmap=
'gray')
815 fit = result.best_fit
816 bbox = footprint.getBBox()
817 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
819 fig = plt.figure(figsize=(8, 8))
821 plt.subplot(3, 3, i*3+1)
822 display2dArray(z[i, :],
'Data', extent=extent)
823 plt.subplot(3, 3, i*3+2)
824 display2dArray(fit[i, :],
'Model', extent=extent)
825 plt.subplot(3, 3, i*3+3)
826 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
829 fig = plt.figure(figsize=(8, 2.5))
831 display2dArray(z,
'Data', extent=extent)
833 display2dArray(fit,
'Model', extent=extent)
835 display2dArray(z - fit,
'Residual', extent=extent)
841 @measBase.register(
"ip_diffim_DipoleFit")
843 """!Subclass of SingleFramePlugin which fits dipoles to all merged (two-peak) diaSources
845 Accepts up to three input images in its `measure` method. If these are
846 provided, it includes data from the pre-subtraction posImage
847 (science image) and optionally negImage (template image) to
848 constrain the fit. The meat of the fitting routines are in the
849 class DipoleFitAlgorithm.
851 The motivation behind this plugin and the necessity for including more than
852 one exposure are documented in DMTN-007 (http://dmtn-007.lsst.io).
854 This class is named `ip_diffim_DipoleFit` so that it may be used alongside
855 the existing `ip_diffim_DipoleMeasurement` classes until such a time as those
856 are deemed to be replaceable by this.
859 ConfigClass = DipoleFitPluginConfig
860 DipoleFitAlgorithmClass = DipoleFitAlgorithm
864 FAILURE_NOT_DIPOLE = 4
868 """!Set execution order to `FLUX_ORDER`.
870 This includes algorithms that require both `getShape()` and `getCentroid()`,
871 in addition to a Footprint and its Peaks.
873 return cls.FLUX_ORDER
875 def __init__(self, config, name, schema, metadata):
876 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)
878 self.
log = Log.getLogger(name)
890 for pos_neg
in [
'pos',
'neg']:
892 key = schema.addField(
893 schema.join(name, pos_neg,
"flux"), type=float, units=
"count",
894 doc=
"Dipole {0} lobe flux".format(pos_neg))
895 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
897 key = schema.addField(
898 schema.join(name, pos_neg,
"fluxSigma"), type=float, units=
"pixel",
899 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
900 setattr(self,
''.join((pos_neg,
'FluxSigmaKey')), key)
902 for x_y
in [
'x',
'y']:
903 key = schema.addField(
904 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
905 doc=
"Dipole {0} lobe centroid".format(pos_neg))
906 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
908 for x_y
in [
'x',
'y']:
909 key = schema.addField(
910 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
911 doc=
"Dipole centroid")
912 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
915 schema.join(name,
"flux"), type=float, units=
"count",
916 doc=
"Dipole overall flux")
919 schema.join(name,
"orientation"), type=float, units=
"deg",
920 doc=
"Dipole orientation")
923 schema.join(name,
"separation"), type=float, units=
"pixel",
924 doc=
"Pixel separation between positive and negative lobes of dipole")
927 schema.join(name,
"chi2dof"), type=float,
928 doc=
"Chi2 per degree of freedom of dipole fit")
931 schema.join(name,
"signalToNoise"), type=float,
932 doc=
"Estimated signal-to-noise of dipole fit")
935 schema.join(name,
"flag",
"classification"), type=
"Flag",
936 doc=
"Flag indicating diaSource is classified as a dipole")
939 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
940 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
943 schema.join(name,
"flag"), type=
"Flag",
944 doc=
"General failure flag for dipole fit")
947 schema.join(name,
"flag",
"edge"), type=
"Flag",
948 doc=
"Flag set when dipole is too close to edge of image")
950 def measure(self, measRecord, exposure, posExp=None, negExp=None):
951 """!Perform the non-linear least squares minimization on the putative dipole source.
953 @param measRecord diaSources that will be measured using dipole measurement
954 @param exposure Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
955 @param posExp "Positive" exposure, typically a science exposure, or None if unavailable
956 @param negExp "Negative" exposure, typically a template exposure, or None if unavailable
958 @note When `posExp` is `None`, will compute `posImage = exposure + negExp`.
959 Likewise, when `negExp` is `None`, will compute `negImage = posExp - exposure`.
960 If both `posExp` and `negExp` are `None`, will attempt to fit the dipole to just the `exposure`
963 The main functionality of this routine was placed outside of
964 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
965 `DipoleFitAlgorithm.fitDipole()` can be called separately for
966 testing (@see `tests/testDipoleFitter.py`)
970 pks = measRecord.getFootprint().getPeaks()
975 (len(pks) > 1
and (np.sign(pks[0].getPeakValue()) ==
976 np.sign(pks[-1].getPeakValue())))
981 if not self.config.fitAllDiaSources:
986 result, _ = alg.fitDipole(
987 measRecord, rel_weight=self.config.relWeight,
988 tol=self.config.tolerance,
989 maxSepInSigma=self.config.maxSeparation,
990 fitBackground=self.config.fitBackground,
991 separateNegParams=self.config.fitSeparateNegParams,
992 verbose=
False, display=
False)
993 except pexExcept.LengthError:
994 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
996 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1003 self.log.debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1005 if result.posFlux <= 1.:
1006 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1010 measRecord[self.posFluxKey] = result.posFlux
1011 measRecord[self.posFluxSigmaKey] = result.signalToNoise
1012 measRecord[self.posCentroidKeyX] = result.posCentroidX
1013 measRecord[self.posCentroidKeyY] = result.posCentroidY
1015 measRecord[self.negFluxKey] = result.negFlux
1016 measRecord[self.negFluxSigmaKey] = result.signalToNoise
1017 measRecord[self.negCentroidKeyX] = result.negCentroidX
1018 measRecord[self.negCentroidKeyY] = result.negCentroidY
1021 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1023 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2. +
1024 (result.posCentroidY - result.negCentroidY)**2.)
1025 measRecord[self.centroidKeyX] = result.centroidX
1026 measRecord[self.centroidKeyY] = result.centroidY
1034 """!Determine if source is classified as dipole via three criteria:
1035 - does the total signal-to-noise surpass the minSn?
1036 - are the pos/neg fluxes greater than 1.0 and no more than 0.65 (param `maxFluxRatio`)
1037 of the total flux? By default this will never happen since `posFlux == negFlux`.
1038 - is it a good fit (`chi2dof` < 1)? (Currently not used.)
1046 passesFluxPos = (abs(measRecord[self.posFluxKey]) /
1047 (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1048 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1049 passesFluxNeg = (abs(measRecord[self.negFluxKey]) /
1050 (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1051 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1052 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1060 from scipy.stats
import chi2
1062 significance = chi2.cdf(chi2val, ndof)
1063 passesChi2 = significance < self.config.maxChi2DoF
1064 allPass = allPass
and passesChi2
1073 def fail(self, measRecord, error=None):
1074 """!Catch failures and set the correct flags.
1077 measRecord.set(self.
flagKey,
True)
1078 if error
is not None:
1080 self.log.warn(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1083 self.log.warn(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1084 measRecord.set(self.
flagKey,
True)
1086 self.log.debug(
'DipoleFitPlugin not run on record %d: %s',
1087 measRecord.getId(), str(error))
1089 measRecord.set(self.
flagKey,
True)
1091 self.log.warn(
'DipoleFitPlugin failed on record %d', measRecord.getId())
Subclass of SingleFramePlugin which fits dipoles to all merged (two-peak) diaSources.
Lightweight class containing methods for generating a dipole model for fitting to sources in diffims...
Lightweight class containing methods for fitting a dipole model in a diffim, used by DipoleFitPlugin...
def doClassify
Determine if source is classified as dipole via three criteria:
def displayFitResults
Display data, model fits and residuals (currently uses matplotlib display functions).
def _getHeavyFootprintSubimage
Extract the image from a HeavyFootprint as an afwImage.ImageF.
def fitDipole
Wrapper around fitDipoleImpl() which performs the fit of a dipole model to an input diaSource...
Configuration for DipoleFitPlugin.
def _generateXYGrid
Generate a meshgrid covering the x,y coordinates bounded by bbox.
def makeBackgroundModel
Generate gradient model (2-d array) with up to 2nd-order polynomial.
classificationAttemptedFlagKey
def fitFootprintBackground
Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
def measure
Perform the non-linear least squares minimization on the putative dipole source.
def makeStarModel
Generate model (2-d Image) of a 'star' (single PSF) centered at given coordinates.
def getExecutionOrder
Set execution order to FLUX_ORDER.
Measurement of detected diaSources as dipoles.
def makeModel
Generate dipole model with given parameters.
Subclass of SingleFrameMeasurementTask which accepts up to three input images in its run() method...
def fitDipoleImpl
Fit a dipole model to an input difference image.
def run
Run dipole measurement and classification.
def fail
Catch failures and set the correct flags.
def __init__
Algorithm to run dipole measurement on a diaSource.