35 from lsst.utils.timer
import timeMethod
37 __all__ = (
"DipoleFitTask",
"DipoleFitPlugin",
"DipoleFitTaskConfig",
"DipoleFitPluginConfig",
46 """Configuration for DipoleFitPlugin
49 fitAllDiaSources = pexConfig.Field(
50 dtype=float, default=
False,
51 doc=
"""Attempte dipole fit of all diaSources (otherwise just the ones consisting of overlapping
52 positive and negative footprints)""")
54 maxSeparation = pexConfig.Field(
55 dtype=float, default=5.,
56 doc=
"Assume dipole is not separated by more than maxSeparation * psfSigma")
58 relWeight = pexConfig.Field(
59 dtype=float, default=0.5,
60 doc=
"""Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub.
63 tolerance = pexConfig.Field(
64 dtype=float, default=1e-7,
67 fitBackground = pexConfig.Field(
69 doc=
"Set whether and how to fit for linear gradient in pre-sub. images. Possible values:"
70 "0: do not fit background at all"
71 "1 (default): pre-fit the background using linear least squares and then do not fit it as part"
72 "of the dipole fitting optimization"
73 "2: pre-fit the background using linear least squares (as in 1), and use the parameter"
74 "estimates from that fit as starting parameters for an integrated re-fit of the background")
76 fitSeparateNegParams = pexConfig.Field(
77 dtype=bool, default=
False,
78 doc=
"Include parameters to fit for negative values (flux, gradient) separately from pos.")
81 minSn = pexConfig.Field(
82 dtype=float, default=np.sqrt(2) * 5.0,
83 doc=
"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole")
85 maxFluxRatio = pexConfig.Field(
86 dtype=float, default=0.65,
87 doc=
"Maximum flux ratio in either lobe to be considered a dipole")
89 maxChi2DoF = pexConfig.Field(
90 dtype=float, default=0.05,
91 doc=
"""Maximum Chi2/DoF significance of fit to be considered a dipole.
92 Default value means \"Choose a chi2DoF corresponding to a significance level of at most 0.05\"
93 (note this is actually a significance, not a chi2 value).""")
97 """Measurement of detected diaSources as dipoles
99 Currently we keep the "old" DipoleMeasurement algorithms turned on.
103 measBase.SingleFrameMeasurementConfig.setDefaults(self)
105 self.plugins.names = [
"base_CircularApertureFlux",
112 "base_PeakLikelihoodFlux",
114 "base_NaiveCentroid",
115 "ip_diffim_NaiveDipoleCentroid",
116 "ip_diffim_NaiveDipoleFlux",
117 "ip_diffim_PsfDipoleFlux",
118 "ip_diffim_ClassificationDipole",
121 self.slots.calibFlux =
None
122 self.slots.modelFlux =
None
123 self.slots.gaussianFlux =
None
124 self.slots.shape =
"base_SdssShape"
125 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
130 """A task that fits a dipole to a difference image, with an optional separate detection image.
132 Because it subclasses SingleFrameMeasurementTask, and calls
133 SingleFrameMeasurementTask.run() from its run() method, it still
134 can be used identically to a standard SingleFrameMeasurementTask.
137 ConfigClass = DipoleFitTaskConfig
138 _DefaultName =
"ip_diffim_DipoleFit"
140 def __init__(self, schema, algMetadata=None, **kwargs):
142 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
144 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
147 schema=schema, metadata=algMetadata)
150 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
151 """Run dipole measurement and classification
155 sources : `lsst.afw.table.SourceCatalog`
156 ``diaSources`` that will be measured using dipole measurement
157 exposure : `lsst.afw.image.Exposure`
158 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
159 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
160 fitted directly to this difference image.
161 posExp : `lsst.afw.image.Exposure`, optional
162 "Positive" exposure, typically a science exposure, or None if unavailable
163 When `posExp` is `None`, will compute `posImage = exposure + negExp`.
164 negExp : `lsst.afw.image.Exposure`, optional
165 "Negative" exposure, typically a template exposure, or None if unavailable
166 When `negExp` is `None`, will compute `negImage = posExp - exposure`.
168 Additional keyword arguments for `lsst.meas.base.sfm.SingleFrameMeasurementTask`.
171 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
176 for source
in sources:
181 """Lightweight class containing methods for generating a dipole model for fitting
182 to sources in diffims, used by DipoleFitAlgorithm.
185 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
191 self.
loglog = Log.getLogger(__name__)
194 """Generate gradient model (2-d array) with up to 2nd-order polynomial
199 (2, w, h)-dimensional `numpy.array`, containing the
200 input x,y meshgrid providing the coordinates upon which to
201 compute the gradient. This will typically be generated via
202 `_generateXYGrid()`. `w` and `h` correspond to the width and
203 height of the desired grid.
204 pars : `list` of `float`, optional
205 Up to 6 floats for up
206 to 6 2nd-order 2-d polynomial gradient parameters, in the
207 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
208 is emtpy or `None`, do nothing and return `None` (for speed).
212 result : `None` or `numpy.array`
213 return None, or 2-d numpy.array of width/height matching
214 input bbox, containing computed gradient values.
218 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
221 y, x = in_x[0, :], in_x[1, :]
222 gradient = np.full_like(x, pars[0], dtype=
'float64')
223 if len(pars) > 1
and pars[1]
is not None:
224 gradient += pars[1] * x
225 if len(pars) > 2
and pars[2]
is not None:
226 gradient += pars[2] * y
227 if len(pars) > 3
and pars[3]
is not None:
228 gradient += pars[3] * (x * y)
229 if len(pars) > 4
and pars[4]
is not None:
230 gradient += pars[4] * (x * x)
231 if len(pars) > 5
and pars[5]
is not None:
232 gradient += pars[5] * (y * y)
236 def _generateXYGrid(self, bbox):
237 """Generate a meshgrid covering the x,y coordinates bounded by bbox
241 bbox : `lsst.geom.Box2I`
242 input Bounding Box defining the coordinate limits
247 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
251 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
252 in_x = np.array([y, x]).astype(np.float64)
253 in_x[0, :] -= np.mean(in_x[0, :])
254 in_x[1, :] -= np.mean(in_x[1, :])
257 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
258 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
259 as an `lsst.afw.image.ImageF`.
263 fp : `lsst.afw.detection.HeavyFootprint`
264 HeavyFootprint to use to generate the subimage
265 badfill : `float`, optional
266 Value to fill in pixels in extracted image that are outside the footprint
268 Optionally grow the footprint by this amount before extraction
272 subim2 : `lsst.afw.image.ImageF`
273 An `~lsst.afw.image.ImageF` containing the subimage.
279 subim2 = afwImage.ImageF(bbox, badfill)
280 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
284 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
286 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
290 source : `lsst.afw.table.SourceRecord`
291 SourceRecord, the footprint of which is to be fit
292 posImage : `lsst.afw.image.Exposure`
293 The exposure from which to extract the footprint subimage
295 Polynomial order of background gradient to fit.
299 pars : `tuple` of `float`
300 `tuple` of length (1 if order==0; 3 if order==1; 6 if order == 2),
301 containing the resulting fit parameters
306 fp = source.getFootprint()
309 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
314 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
317 isBg = np.isnan(posFpImg.getArray()).ravel()
319 data = posImg.getArray().ravel()
323 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
324 x = x.astype(np.float64).ravel()
327 y = y.astype(np.float64).ravel()
330 b = np.ones_like(x, dtype=np.float64)
334 M = np.vstack([b, x, y]).T
336 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
338 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
342 """Generate a 2D image model of a single PDF centered at the given coordinates.
346 bbox : `lsst.geom.Box`
347 Bounding box marking pixel coordinates for generated model
349 Psf model used to generate the 'star'
351 Desired x-centroid of the 'star'
353 Desired y-centroid of the 'star'
355 Desired flux of the 'star'
359 p_Im : `lsst.afw.image.Image`
360 2-d stellar image of width/height matching input ``bbox``,
361 containing PSF with given centroid and flux
365 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
366 psf_img_sum = np.nansum(psf_img.getArray())
367 psf_img *= (flux/psf_img_sum)
370 psf_box = psf_img.getBBox()
372 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
378 p_Im = afwImage.ImageF(bbox)
379 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
384 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
385 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
386 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
388 """Generate dipole model with given parameters.
390 This is the function whose sum-of-squared difference from data
391 is minimized by `lmfit`.
394 Input independent variable. Used here as the grid on
395 which to compute the background gradient model.
397 Desired flux of the positive lobe of the dipole
399 Desired x-centroid of the positive lobe of the dipole
401 Desired y-centroid of the positive lobe of the dipole
403 Desired x-centroid of the negative lobe of the dipole
405 Desired y-centroid of the negative lobe of the dipole
406 fluxNeg : `float`, optional
407 Desired flux of the negative lobe of the dipole, set to 'flux' if None
408 b, x1, y1, xy, x2, y2 : `float`
409 Gradient parameters for positive lobe.
410 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
411 Gradient parameters for negative lobe.
412 They are set to the corresponding positive values if None.
415 Keyword arguments passed through ``lmfit`` and
416 used by this function. These must include:
418 - ``psf`` Psf model used to generate the 'star'
419 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
420 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
421 - ``bbox`` Bounding box containing region to be modelled
426 Has width and height matching the input bbox, and
427 contains the dipole model with given centroids and flux(es). If
428 ``rel_weight`` = 0, this is a 2-d array with dimensions matching
429 those of bbox; otherwise a stack of three such arrays,
430 representing the dipole (diffim), positive and negative images
434 psf = kwargs.get(
'psf')
435 rel_weight = kwargs.get(
'rel_weight')
436 fp = kwargs.get(
'footprint')
443 self.
loglog.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
444 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
446 self.
loglog.
debug(
' %.2f %.2f %.2f', b, x1, y1)
448 self.
loglog.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
450 posIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenPos, ycenPos, flux)
451 negIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
455 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
456 in_x = np.array([x, y]) * 1.
457 in_x[0, :] -= in_x[0, :].mean()
458 in_x[1, :] -= in_x[1, :].mean()
465 gradientNeg = self.
makeBackgroundModelmakeBackgroundModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))
467 gradientNeg = gradient
469 posIm.getArray()[:, :] += gradient
470 negIm.getArray()[:, :] += gradientNeg
473 diffIm = afwImage.ImageF(bbox)
477 zout = diffIm.getArray()
479 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
485 """Fit a dipole model using an image difference.
488 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
493 _private_version_ =
'0.0.5'
510 def __init__(self, diffim, posImage=None, negImage=None):
511 """Algorithm to run dipole measurement on a diaSource
515 diffim : `lsst.afw.image.Exposure`
516 Exposure on which the diaSources were detected
517 posImage : `lsst.afw.image.Exposure`
518 "Positive" exposure from which the template was subtracted
519 negImage : `lsst.afw.image.Exposure`
520 "Negative" exposure which was subtracted from the posImage
527 if diffim
is not None:
528 self.
psfSigmapsfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
530 self.
loglog = Log.getLogger(__name__)
536 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
537 separateNegParams=True, verbose=False):
538 """Fit a dipole model to an input difference image.
540 Actually, fits the subimage bounded by the input source's
541 footprint) and optionally constrain the fit using the
542 pre-subtraction images posImage and negImage.
546 source : TODO: DM-17458
548 tol : float, optional
550 rel_weight : `float`, optional
552 fitBackground : `int`, optional
554 bgGradientOrder : `int`, optional
556 maxSepInSigma : `float`, optional
558 separateNegParams : `bool`, optional
560 verbose : `bool`, optional
565 result : `lmfit.MinimizerResult`
566 return `lmfit.MinimizerResult` object containing the fit
567 parameters and other information.
573 fp = source.getFootprint()
575 subim = afwImage.MaskedImageF(self.
diffimdiffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
577 z = diArr = subim.getArrays()[0]
578 weights = 1. / subim.getArrays()[2]
580 if rel_weight > 0.
and ((self.
posImageposImage
is not None)
or (self.
negImagenegImage
is not None)):
581 if self.
negImagenegImage
is not None:
582 negSubim = afwImage.MaskedImageF(self.
negImagenegImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
583 if self.
posImageposImage
is not None:
584 posSubim = afwImage.MaskedImageF(self.
posImageposImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
586 posSubim = subim.clone()
589 negSubim = posSubim.clone()
592 z = np.append([z], [posSubim.getArrays()[0],
593 negSubim.getArrays()[0]], axis=0)
595 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
596 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
603 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
604 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
605 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
607 """Generate dipole model with given parameters.
609 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
610 out of `kwargs['modelObj']`.
612 modelObj = kwargs.pop(
'modelObj')
613 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
614 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
615 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
616 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
620 modelFunctor = dipoleModelFunctor
623 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
628 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
629 cenNeg = cenPos = fpCentroid
634 cenPos = pks[0].getF()
636 cenNeg = pks[-1].getF()
640 maxSep = self.
psfSigmapsfSigma * maxSepInSigma
643 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
645 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
651 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
652 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
653 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
654 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
655 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
656 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
657 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
658 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
662 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
663 posFlux = negFlux = startingFlux
666 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
668 if separateNegParams:
670 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
678 bgParsPos = bgParsNeg = (0., 0., 0.)
679 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
683 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
684 order=bgGradientOrder)
687 if fitBackground == 1:
688 in_x = dipoleModel._generateXYGrid(bbox)
689 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
691 z[1, :] -= np.nanmedian(z[1, :])
692 posFlux = np.nansum(z[1, :])
693 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
695 if separateNegParams
and self.
negImagenegImage
is not None:
696 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImagenegImage,
697 order=bgGradientOrder)
698 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
700 z[2, :] -= np.nanmedian(z[2, :])
701 if separateNegParams:
702 negFlux = np.nansum(z[2, :])
703 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
706 if fitBackground == 2:
707 if bgGradientOrder >= 0:
708 gmod.set_param_hint(
'b', value=bgParsPos[0])
709 if separateNegParams:
710 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
711 if bgGradientOrder >= 1:
712 gmod.set_param_hint(
'x1', value=bgParsPos[1])
713 gmod.set_param_hint(
'y1', value=bgParsPos[2])
714 if separateNegParams:
715 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
716 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
717 if bgGradientOrder >= 2:
718 gmod.set_param_hint(
'xy', value=bgParsPos[3])
719 gmod.set_param_hint(
'x2', value=bgParsPos[4])
720 gmod.set_param_hint(
'y2', value=bgParsPos[5])
721 if separateNegParams:
722 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
723 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
724 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
726 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
727 in_x = np.array([x, y]).astype(np.float)
728 in_x[0, :] -= in_x[0, :].mean()
729 in_x[1, :] -= in_x[1, :].mean()
733 mask = np.ones_like(z, dtype=bool)
738 weights = mask.astype(np.float64)
739 if self.
posImageposImage
is not None and rel_weight > 0.:
740 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
741 np.ones_like(diArr)*rel_weight])
750 with warnings.catch_warnings():
751 warnings.simplefilter(
"ignore")
752 result = gmod.fit(z, weights=weights, x=in_x,
754 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
756 psf=self.
diffimdiffim.getPsf(),
757 rel_weight=rel_weight,
759 modelObj=dipoleModel)
766 print(result.fit_report(show_correl=
False))
767 if separateNegParams:
768 print(result.ci_report())
773 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
774 bgGradientOrder=1, verbose=False, display=False):
775 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
777 Actually, fits the subimage bounded by the input source's
778 footprint) and optionally constrain the fit using the
779 pre-subtraction images self.posImage (science) and
780 self.negImage (template). Wraps the output into a
781 `pipeBase.Struct` named tuple after computing additional
782 statistics such as orientation and SNR.
786 source : `lsst.afw.table.SourceRecord`
787 Record containing the (merged) dipole source footprint detected on the diffim
788 tol : `float`, optional
789 Tolerance parameter for scipy.leastsq() optimization
790 rel_weight : `float`, optional
791 Weighting of posImage/negImage relative to the diffim in the fit
792 fitBackground : `int`, {0, 1, 2}, optional
793 How to fit linear background gradient in posImage/negImage
795 - 0: do not fit background at all
796 - 1 (default): pre-fit the background using linear least squares and then do not fit it
797 as part of the dipole fitting optimization
798 - 2: pre-fit the background using linear least squares (as in 1), and use the parameter
799 estimates from that fit as starting parameters for an integrated "re-fit" of the
800 background as part of the overall dipole fitting optimization.
801 maxSepInSigma : `float`, optional
802 Allowed window of centroid parameters relative to peak in input source footprint
803 separateNegParams : `bool`, optional
804 Fit separate parameters to the flux and background gradient in
805 bgGradientOrder : `int`, {0, 1, 2}, optional
806 Desired polynomial order of background gradient
807 verbose: `bool`, optional
810 Display input data, best fit model(s) and residuals in a matplotlib window.
815 `pipeBase.Struct` object containing the fit parameters and other information.
818 `lmfit.MinimizerResult` object for debugging and error estimation, etc.
822 Parameter `fitBackground` has three options, thus it is an integer:
827 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
828 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
829 bgGradientOrder=bgGradientOrder, verbose=verbose)
833 fp = source.getFootprint()
836 fitParams = fitResult.best_values
837 if fitParams[
'flux'] <= 1.:
838 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
839 negCentroidX=np.nan, negCentroidY=np.nan,
840 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
841 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
842 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
843 return out, fitResult
845 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
846 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
847 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
848 angle = np.arctan2(dy, dx) / np.pi * 180.
852 def computeSumVariance(exposure, footprint):
853 box = footprint.getBBox()
854 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
855 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
857 fluxVal = fluxVar = fitParams[
'flux']
858 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
859 if self.
posImageposImage
is not None:
860 fluxVar = computeSumVariance(self.
posImageposImage, source.getFootprint())
862 fluxVar = computeSumVariance(self.
diffimdiffim, source.getFootprint())
864 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
865 if separateNegParams:
866 fluxValNeg = fitParams[
'fluxNeg']
867 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
868 if self.
negImagenegImage
is not None:
869 fluxVarNeg = computeSumVariance(self.
negImagenegImage, source.getFootprint())
872 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
873 except ZeroDivisionError:
874 signalToNoise = np.nan
876 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
877 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
878 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
879 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
880 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
883 return out, fitResult
886 """Display data, model fits and residuals (currently uses matplotlib display functions).
890 footprint : TODO: DM-17458
891 Footprint containing the dipole that was fit
892 result : `lmfit.MinimizerResult`
893 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
897 fig : `matplotlib.pyplot.plot`
900 import matplotlib.pyplot
as plt
901 except ImportError
as err:
902 self.
loglog.
warning(
'Unable to import matplotlib: %s', err)
905 def display2dArray(arr, title='Data', extent=None):
906 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
908 fig = plt.imshow(arr, origin=
'lower', interpolation=
'none', cmap=
'gray', extent=extent)
910 plt.colorbar(fig, cmap=
'gray')
914 fit = result.best_fit
915 bbox = footprint.getBBox()
916 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
918 fig = plt.figure(figsize=(8, 8))
920 plt.subplot(3, 3, i*3+1)
921 display2dArray(z[i, :],
'Data', extent=extent)
922 plt.subplot(3, 3, i*3+2)
923 display2dArray(fit[i, :],
'Model', extent=extent)
924 plt.subplot(3, 3, i*3+3)
925 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
928 fig = plt.figure(figsize=(8, 2.5))
930 display2dArray(z,
'Data', extent=extent)
932 display2dArray(fit,
'Model', extent=extent)
934 display2dArray(z - fit,
'Residual', extent=extent)
940 @measBase.register("ip_diffim_DipoleFit")
942 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
944 This measurement plugin accepts up to three input images in
945 its `measure` method. If these are provided, it includes data
946 from the pre-subtraction posImage (science image) and optionally
947 negImage (template image) to constrain the fit. The meat of the
948 fitting routines are in the class `~lsst.module.name.DipoleFitAlgorithm`.
952 The motivation behind this plugin and the necessity for including more than
953 one exposure are documented in DMTN-007 (http://dmtn-007.lsst.io).
955 This class is named `ip_diffim_DipoleFit` so that it may be used alongside
956 the existing `ip_diffim_DipoleMeasurement` classes until such a time as those
957 are deemed to be replaceable by this.
960 ConfigClass = DipoleFitPluginConfig
961 DipoleFitAlgorithmClass = DipoleFitAlgorithm
965 FAILURE_NOT_DIPOLE = 4
969 """Set execution order to `FLUX_ORDER`.
971 This includes algorithms that require both `getShape()` and `getCentroid()`,
972 in addition to a Footprint and its Peaks.
974 return cls.FLUX_ORDER
976 def __init__(self, config, name, schema, metadata):
977 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)
979 self.
loglog = Log.getLogger(name)
981 self.
_setupSchema_setupSchema(config, name, schema, metadata)
983 def _setupSchema(self, config, name, schema, metadata):
991 for pos_neg
in [
'pos',
'neg']:
993 key = schema.addField(
994 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
995 doc=
"Dipole {0} lobe flux".
format(pos_neg))
996 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
998 key = schema.addField(
999 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1000 doc=
"1-sigma uncertainty for {0} dipole flux".
format(pos_neg))
1001 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1003 for x_y
in [
'x',
'y']:
1004 key = schema.addField(
1005 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1006 doc=
"Dipole {0} lobe centroid".
format(pos_neg))
1007 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1009 for x_y
in [
'x',
'y']:
1010 key = schema.addField(
1011 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1012 doc=
"Dipole centroid")
1013 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1016 schema.join(name,
"instFlux"), type=float, units=
"count",
1017 doc=
"Dipole overall flux")
1020 schema.join(name,
"orientation"), type=float, units=
"deg",
1021 doc=
"Dipole orientation")
1024 schema.join(name,
"separation"), type=float, units=
"pixel",
1025 doc=
"Pixel separation between positive and negative lobes of dipole")
1028 schema.join(name,
"chi2dof"), type=float,
1029 doc=
"Chi2 per degree of freedom of dipole fit")
1032 schema.join(name,
"signalToNoise"), type=float,
1033 doc=
"Estimated signal-to-noise of dipole fit")
1036 schema.join(name,
"flag",
"classification"), type=
"Flag",
1037 doc=
"Flag indicating diaSource is classified as a dipole")
1040 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1041 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1044 schema.join(name,
"flag"), type=
"Flag",
1045 doc=
"General failure flag for dipole fit")
1048 schema.join(name,
"flag",
"edge"), type=
"Flag",
1049 doc=
"Flag set when dipole is too close to edge of image")
1051 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1052 """Perform the non-linear least squares minimization on the putative dipole source.
1056 measRecord : `lsst.afw.table.SourceRecord`
1057 diaSources that will be measured using dipole measurement
1058 exposure : `lsst.afw.image.Exposure`
1059 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1060 If both `posExp` and `negExp` are `None`, will attempt to fit the
1061 dipole to just the `exposure` with no constraint.
1062 posExp : `lsst.afw.image.Exposure`, optional
1063 "Positive" exposure, typically a science exposure, or None if unavailable
1064 When `posExp` is `None`, will compute `posImage = exposure + negExp`.
1065 negExp : `lsst.afw.image.Exposure`, optional
1066 "Negative" exposure, typically a template exposure, or None if unavailable
1067 When `negExp` is `None`, will compute `negImage = posExp - exposure`.
1071 The main functionality of this routine was placed outside of
1072 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1073 `DipoleFitAlgorithm.fitDipole()` can be called separately for
1074 testing (@see `tests/testDipoleFitter.py`)
1078 result : TODO: DM-17458
1083 pks = measRecord.getFootprint().getPeaks()
1088 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1089 == np.sign(pks[-1].getPeakValue())))
1093 self.
failfail(measRecord, measBase.MeasurementError(
'not a dipole', self.
FAILURE_NOT_DIPOLEFAILURE_NOT_DIPOLE))
1094 if not self.config.fitAllDiaSources:
1099 result, _ = alg.fitDipole(
1100 measRecord, rel_weight=self.config.relWeight,
1101 tol=self.config.tolerance,
1102 maxSepInSigma=self.config.maxSeparation,
1103 fitBackground=self.config.fitBackground,
1104 separateNegParams=self.config.fitSeparateNegParams,
1105 verbose=
False, display=
False)
1107 self.
failfail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGEFAILURE_EDGE))
1109 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1116 self.
loglog.
debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1118 if result.posFlux <= 1.:
1119 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1123 measRecord[self.posFluxKey] = result.posFlux
1124 measRecord[self.posFluxErrKey] = result.signalToNoise
1125 measRecord[self.posCentroidKeyX] = result.posCentroidX
1126 measRecord[self.posCentroidKeyY] = result.posCentroidY
1128 measRecord[self.negFluxKey] = result.negFlux
1129 measRecord[self.negFluxErrKey] = result.signalToNoise
1130 measRecord[self.negCentroidKeyX] = result.negCentroidX
1131 measRecord[self.negCentroidKeyY] = result.negCentroidY
1134 measRecord[self.
fluxKeyfluxKey] = (
abs(result.posFlux) +
abs(result.negFlux))/2.
1135 measRecord[self.
orientationKeyorientationKey] = result.orientation
1136 measRecord[self.
separationKeyseparationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1137 + (result.posCentroidY - result.negCentroidY)**2.)
1138 measRecord[self.centroidKeyX] = result.centroidX
1139 measRecord[self.centroidKeyY] = result.centroidY
1142 measRecord[self.
chi2dofKeychi2dofKey] = result.redChi2
1144 self.
doClassifydoClassify(measRecord, result.chi2)
1147 """Classify a source as a dipole.
1151 measRecord : TODO: DM-17458
1153 chi2val : TODO: DM-17458
1158 Sources are classified as dipoles, or not, according to three criteria:
1160 1. Does the total signal-to-noise surpass the ``minSn``?
1161 2. Are the pos/neg fluxes greater than 1.0 and no more than 0.65 (``maxFluxRatio``)
1162 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1163 3. Is it a good fit (``chi2dof`` < 1)? (Currently not used.)
1167 passesSn = measRecord[self.
signalToNoiseKeysignalToNoiseKey] > self.config.minSn
1171 passesFluxPos = (
abs(measRecord[self.posFluxKey])
1172 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1173 passesFluxPos &= (
abs(measRecord[self.posFluxKey]) >= 1.0)
1174 passesFluxNeg = (
abs(measRecord[self.negFluxKey])
1175 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1176 passesFluxNeg &= (
abs(measRecord[self.negFluxKey]) >= 1.0)
1177 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1185 from scipy.stats
import chi2
1186 ndof = chi2val / measRecord[self.
chi2dofKeychi2dofKey]
1187 significance = chi2.cdf(chi2val, ndof)
1188 passesChi2 = significance < self.config.maxChi2DoF
1189 allPass = allPass
and passesChi2
1198 def fail(self, measRecord, error=None):
1199 """Catch failures and set the correct flags.
1202 measRecord.set(self.
flagKeyflagKey,
True)
1203 if error
is not None:
1204 if error.getFlagBit() == self.
FAILURE_EDGEFAILURE_EDGE:
1205 self.
loglog.
warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1207 if error.getFlagBit() == self.
FAILURE_FITFAILURE_FIT:
1208 self.
loglog.
warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1209 measRecord.set(self.
flagKeyflagKey,
True)
1211 self.
loglog.
debug(
'DipoleFitPlugin not run on record %d: %s',
1212 measRecord.getId(), str(error))
1214 measRecord.set(self.
flagKeyflagKey,
True)
1216 self.
loglog.
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)
Reports attempts to exceed implementation-defined length limits for some classes.
Backwards-compatibility support for depersisting the old Calib (FluxMag0/FluxMag0Err) objects.
def measure(mi, x, y, size, statistic, stats)
def format(config, name=None, writeSourceLine=True, prefix="", verbose=False)
Angle abs(Angle const &a)