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.
loglog = 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.
loglog.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
443 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
445 self.
loglog.
debug(
' %.2f %.2f %.2f', b, x1, y1)
447 self.
loglog.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
449 posIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenPos, ycenPos, flux)
450 negIm = self.
makeStarModelmakeStarModel(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()
464 gradientNeg = self.
makeBackgroundModelmakeBackgroundModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))
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.
psfSigmapsfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
529 self.
loglog = 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.
diffimdiffim.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.
posImageposImage
is not None)
or (self.
negImagenegImage
is not None)):
580 if self.
negImagenegImage
is not None:
581 negSubim = afwImage.MaskedImageF(self.
negImagenegImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
582 if self.
posImageposImage
is not None:
583 posSubim = afwImage.MaskedImageF(self.
posImageposImage.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.
psfSigmapsfSigma * 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.
negImagenegImage
is not None:
695 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImagenegImage,
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.
posImageposImage
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,
755 psf=self.
diffimdiffim.getPsf(),
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
858 if self.
posImageposImage
is not None:
859 fluxVar = computeSumVariance(self.
posImageposImage, source.getFootprint())
861 fluxVar = computeSumVariance(self.
diffimdiffim, source.getFootprint())
863 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
864 if separateNegParams:
865 fluxValNeg = fitParams[
'fluxNeg']
866 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
867 if self.
negImagenegImage
is not None:
868 fluxVarNeg = computeSumVariance(self.
negImagenegImage, 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.
loglog.
warning(
'Unable to import matplotlib: %s', err)
904 def display2dArray(arr, title='Data', extent=None):
905 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
907 fig = plt.imshow(arr, origin=
'lower', interpolation=
'none', cmap=
'gray', extent=extent)
909 plt.colorbar(fig, cmap=
'gray')
913 fit = result.best_fit
914 bbox = footprint.getBBox()
915 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
917 fig = plt.figure(figsize=(8, 8))
919 plt.subplot(3, 3, i*3+1)
920 display2dArray(z[i, :],
'Data', extent=extent)
921 plt.subplot(3, 3, i*3+2)
922 display2dArray(fit[i, :],
'Model', extent=extent)
923 plt.subplot(3, 3, i*3+3)
924 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
927 fig = plt.figure(figsize=(8, 2.5))
929 display2dArray(z,
'Data', extent=extent)
931 display2dArray(fit,
'Model', extent=extent)
933 display2dArray(z - fit,
'Residual', extent=extent)
939 @measBase.register("ip_diffim_DipoleFit")
941 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
943 This measurement plugin accepts up to three input images in
944 its `measure` method. If these are provided, it includes data
945 from the pre-subtraction posImage (science image) and optionally
946 negImage (template image) to constrain the fit. The meat of the
947 fitting routines are in the class `~lsst.module.name.DipoleFitAlgorithm`.
951 The motivation behind this plugin and the necessity for including more than
952 one exposure are documented in DMTN-007 (http://dmtn-007.lsst.io).
954 This class is named `ip_diffim_DipoleFit` so that it may be used alongside
955 the existing `ip_diffim_DipoleMeasurement` classes until such a time as those
956 are deemed to be replaceable by this.
959 ConfigClass = DipoleFitPluginConfig
960 DipoleFitAlgorithmClass = DipoleFitAlgorithm
964 FAILURE_NOT_DIPOLE = 4
968 """Set execution order to `FLUX_ORDER`.
970 This includes algorithms that require both `getShape()` and `getCentroid()`,
971 in addition to a Footprint and its Peaks.
973 return cls.FLUX_ORDER
975 def __init__(self, config, name, schema, metadata):
976 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)
978 self.
loglog = Log.getLogger(name)
980 self.
_setupSchema_setupSchema(config, name, schema, metadata)
982 def _setupSchema(self, config, name, schema, metadata):
990 for pos_neg
in [
'pos',
'neg']:
992 key = schema.addField(
993 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
994 doc=
"Dipole {0} lobe flux".
format(pos_neg))
995 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
997 key = schema.addField(
998 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
999 doc=
"1-sigma uncertainty for {0} dipole flux".
format(pos_neg))
1000 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1002 for x_y
in [
'x',
'y']:
1003 key = schema.addField(
1004 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1005 doc=
"Dipole {0} lobe centroid".
format(pos_neg))
1006 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1008 for x_y
in [
'x',
'y']:
1009 key = schema.addField(
1010 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1011 doc=
"Dipole centroid")
1012 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1015 schema.join(name,
"instFlux"), type=float, units=
"count",
1016 doc=
"Dipole overall flux")
1019 schema.join(name,
"orientation"), type=float, units=
"deg",
1020 doc=
"Dipole orientation")
1023 schema.join(name,
"separation"), type=float, units=
"pixel",
1024 doc=
"Pixel separation between positive and negative lobes of dipole")
1027 schema.join(name,
"chi2dof"), type=float,
1028 doc=
"Chi2 per degree of freedom of dipole fit")
1031 schema.join(name,
"signalToNoise"), type=float,
1032 doc=
"Estimated signal-to-noise of dipole fit")
1035 schema.join(name,
"flag",
"classification"), type=
"Flag",
1036 doc=
"Flag indicating diaSource is classified as a dipole")
1039 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1040 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1043 schema.join(name,
"flag"), type=
"Flag",
1044 doc=
"General failure flag for dipole fit")
1047 schema.join(name,
"flag",
"edge"), type=
"Flag",
1048 doc=
"Flag set when dipole is too close to edge of image")
1050 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1051 """Perform the non-linear least squares minimization on the putative dipole source.
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())))
1092 self.
failfail(measRecord, measBase.MeasurementError(
'not a dipole', self.
FAILURE_NOT_DIPOLEFAILURE_NOT_DIPOLE))
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.
failfail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGEFAILURE_EDGE))
1108 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1115 self.
loglog.
debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1117 if result.posFlux <= 1.:
1118 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_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.
fluxKeyfluxKey] = (
abs(result.posFlux) +
abs(result.negFlux))/2.
1134 measRecord[self.
orientationKeyorientationKey] = result.orientation
1135 measRecord[self.
separationKeyseparationKey] = 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
1141 measRecord[self.
chi2dofKeychi2dofKey] = result.redChi2
1143 self.
doClassifydoClassify(measRecord, result.chi2)
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.)
1166 passesSn = measRecord[self.
signalToNoiseKeysignalToNoiseKey] > self.config.minSn
1170 passesFluxPos = (
abs(measRecord[self.posFluxKey])
1171 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1172 passesFluxPos &= (
abs(measRecord[self.posFluxKey]) >= 1.0)
1173 passesFluxNeg = (
abs(measRecord[self.negFluxKey])
1174 / (measRecord[self.
fluxKeyfluxKey]*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
1185 ndof = chi2val / measRecord[self.
chi2dofKeychi2dofKey]
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.
flagKeyflagKey,
True)
1202 if error
is not None:
1203 if error.getFlagBit() == self.
FAILURE_EDGEFAILURE_EDGE:
1204 self.
loglog.
warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1206 if error.getFlagBit() == self.
FAILURE_FITFAILURE_FIT:
1207 self.
loglog.
warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1208 measRecord.set(self.
flagKeyflagKey,
True)
1210 self.
loglog.
debug(
'DipoleFitPlugin not run on record %d: %s',
1211 measRecord.getId(), str(error))
1213 measRecord.set(self.
flagKeyflagKey,
True)
1215 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)