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).""")
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",
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.
log = 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.Box` 242 input BoundingBox 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 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.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
444 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
446 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
448 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
450 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
451 negIm = self.
makeStarModel(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()
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.
psfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
530 self.
log = 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.
diffim.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.
posImage is not None)
or (self.
negImage is not None)):
582 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
584 posSubim = afwImage.MaskedImageF(self.
posImage.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.
psfSigma * 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.
negImage is not None:
696 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
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.
posImage 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,
757 rel_weight=rel_weight,
759 modelObj=dipoleModel)
766 print(result.fit_report(show_correl=
False))
767 if separateNegParams:
768 print(result.ci_report())
772 def fitDipole(self, source, tol=1e-7, rel_weight=0.1,
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
860 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
862 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
864 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
865 if separateNegParams:
866 fluxValNeg = fitParams[
'fluxNeg']
867 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
869 fluxVarNeg = computeSumVariance(self.
negImage, 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.
log.
warn(
'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.
log = Log.getLogger(name)
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=
"pixel",
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 (len(pks) > 1
and (np.sign(pks[0].getPeakValue()) ==
1089 np.sign(pks[-1].getPeakValue())))
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.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
1109 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1116 self.
log.
debug(
"Dipole fit result: %d %s", measRecord.getId(),
str(result))
1118 if result.posFlux <= 1.:
1119 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_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.
fluxKey] = (
abs(result.posFlux) +
abs(result.negFlux))/2.
1136 measRecord[self.
separationKey] = 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
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.) 1171 passesFluxPos = (
abs(measRecord[self.posFluxKey]) /
1172 (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1173 passesFluxPos &= (
abs(measRecord[self.posFluxKey]) >= 1.0)
1174 passesFluxNeg = (
abs(measRecord[self.negFluxKey]) /
1175 (measRecord[self.
fluxKey]*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
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.
flagKey,
True)
1203 if error
is not None:
1205 self.
log.
warn(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(),
str(error))
1208 self.
log.
warn(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(),
str(error))
1209 measRecord.set(self.
flagKey,
True)
1211 self.
log.
debug(
'DipoleFitPlugin not run on record %d: %s',
1212 measRecord.getId(),
str(error))
1214 measRecord.set(self.
flagKey,
True)
1216 self.
log.
warn(
'DipoleFitPlugin failed on record %d', measRecord.getId())
def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0)
Angle abs(Angle const &a)
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 makeStarModel(self, bbox, psf, xcen, ycen, flux)
def fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
Reports attempts to exceed implementation-defined length limits for some classes. ...
def __init__(self, diffim, posImage=None, negImage=None)
def displayFitResults(self, footprint, result)
def run(self, sources, exposure, posExp=None, negExp=None, kwargs)
def fitFootprintBackground(self, source, posImage, order=1)
def __init__(self, config, name, schema, metadata)
def format(config, name=None, writeSourceLine=True, prefix="", verbose=False)
classificationAttemptedFlagKey
def makeBackgroundModel(self, in_x, pars=None)
def __init__(self, schema, algMetadata=None, kwargs)
def _setupSchema(self, config, name, schema, metadata)
def getExecutionOrder(cls)
def measure(mi, x, y, size, statistic, stats)
def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
Backwards-compatibility support for depersisting the old Calib (FluxMag0/FluxMag0Err) objects...
def doClassify(self, measRecord, chi2val)
def fail(self, measRecord, error=None)
def measure(self, measRecord, exposure, posExp=None, negExp=None)