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