35from lsst.utils.timer
import timeMethod
37__all__ = (
"DipoleFitTask",
"DipoleFitPlugin",
"DipoleFitTaskConfig",
"DipoleFitPluginConfig",
46 """Configuration for DipoleFitPlugin
49 fitAllDiaSources = pexConfig.Field(
50 dtype=float, default=False,
51 doc=
"""Attempte dipole fit of all diaSources (otherwise just the ones consisting of overlapping
52 positive and negative footprints)
""")
54 maxSeparation = pexConfig.Field(
55 dtype=float, default=5.,
56 doc="Assume dipole is not separated by more than maxSeparation * psfSigma")
58 relWeight = pexConfig.Field(
59 dtype=float, default=0.5,
60 doc=
"""Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub.
63 tolerance = pexConfig.Field(
64 dtype=float, default=1e-7,
67 fitBackground = pexConfig.Field(
69 doc=
"Set whether and how to fit for linear gradient in pre-sub. images. Possible values:"
70 "0: do not fit background at all"
71 "1 (default): pre-fit the background using linear least squares and then do not fit it as part"
72 "of the dipole fitting optimization"
73 "2: pre-fit the background using linear least squares (as in 1), and use the parameter"
74 "estimates from that fit as starting parameters for an integrated re-fit of the background")
76 fitSeparateNegParams = pexConfig.Field(
77 dtype=bool, default=
False,
78 doc=
"Include parameters to fit for negative values (flux, gradient) separately from pos.")
81 minSn = pexConfig.Field(
82 dtype=float, default=np.sqrt(2) * 5.0,
83 doc=
"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole")
85 maxFluxRatio = pexConfig.Field(
86 dtype=float, default=0.65,
87 doc=
"Maximum flux ratio in either lobe to be considered a dipole")
89 maxChi2DoF = pexConfig.Field(
90 dtype=float, default=0.05,
91 doc=
"""Maximum Chi2/DoF significance of fit to be considered a dipole.
92 Default value means \"Choose a chi2DoF corresponding to a significance level of at most 0.05\"
93 (note this is actually a significance,
not a chi2 value).
""")
95 maxFootprintArea = pexConfig.Field(
96 dtype=int, default=1_200,
97 doc=("Maximum area for footprints before they are ignored as large; "
98 "non-positive means no threshold applied"
99 "Threshold chosen for HSC and DECam data, see DM-38741 for details."))
103 """Measurement of detected diaSources as dipoles
105 Currently we keep the "old" DipoleMeasurement algorithms turned on.
109 measBase.SingleFrameMeasurementConfig.setDefaults(self)
111 self.plugins.names = [
"base_CircularApertureFlux",
118 "base_PeakLikelihoodFlux",
120 "base_NaiveCentroid",
121 "ip_diffim_NaiveDipoleCentroid",
122 "ip_diffim_NaiveDipoleFlux",
123 "ip_diffim_PsfDipoleFlux",
124 "ip_diffim_ClassificationDipole",
127 self.slots.calibFlux =
None
128 self.slots.modelFlux =
None
129 self.slots.gaussianFlux =
None
130 self.slots.shape =
"base_SdssShape"
131 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
136 """A task that fits a dipole to a difference image, with an optional separate detection image.
138 Because it subclasses SingleFrameMeasurementTask, and calls
139 SingleFrameMeasurementTask.run()
from its
run() method, it still
140 can be used identically to a standard SingleFrameMeasurementTask.
143 ConfigClass = DipoleFitTaskConfig
144 _DefaultName = "ip_diffim_DipoleFit"
146 def __init__(self, schema, algMetadata=None, **kwargs):
148 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
150 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
153 schema=schema, metadata=algMetadata,
154 logName=self.log.name)
157 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
158 """Run dipole measurement and classification
163 ``diaSources`` that will be measured using dipole measurement
165 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
166 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
167 fitted directly to this difference image.
169 "Positive" exposure, typically a science exposure,
or None if unavailable
170 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
172 "Negative" exposure, typically a template exposure,
or None if unavailable
173 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
178 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
183 for source
in sources:
184 self.
dipoleFitter.measure(source, exposure, posExp, negExp)
188 """Lightweight class containing methods for generating a dipole model for fitting
189 to sources in diffims, used by DipoleFitAlgorithm.
192 `DMTN-007: Dipole characterization
for image differencing <https://dmtn-007.lsst.io>`_.
198 self.
log = logging.getLogger(__name__)
201 """Generate gradient model (2-d array) with up to 2nd-order polynomial
206 (2, w, h)-dimensional `numpy.array`, containing the
207 input x,y meshgrid providing the coordinates upon which to
208 compute the gradient. This will typically be generated via
209 `_generateXYGrid()`. `w` and `h` correspond to the width
and
210 height of the desired grid.
211 pars : `list` of `float`, optional
212 Up to 6 floats
for up
213 to 6 2nd-order 2-d polynomial gradient parameters,
in the
214 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
215 is emtpy
or `
None`, do nothing
and return `
None` (
for speed).
219 result : `
None`
or `numpy.array`
220 return None,
or 2-d numpy.array of width/height matching
221 input bbox, containing computed gradient values.
225 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
228 y, x = in_x[0, :], in_x[1, :]
229 gradient = np.full_like(x, pars[0], dtype=
'float64')
230 if len(pars) > 1
and pars[1]
is not None:
231 gradient += pars[1] * x
232 if len(pars) > 2
and pars[2]
is not None:
233 gradient += pars[2] * y
234 if len(pars) > 3
and pars[3]
is not None:
235 gradient += pars[3] * (x * y)
236 if len(pars) > 4
and pars[4]
is not None:
237 gradient += pars[4] * (x * x)
238 if len(pars) > 5
and pars[5]
is not None:
239 gradient += pars[5] * (y * y)
243 def _generateXYGrid(self, bbox):
244 """Generate a meshgrid covering the x,y coordinates bounded by bbox
249 input Bounding Box defining the coordinate limits
254 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
258 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
259 in_x = np.array([y, x]).astype(np.float64)
260 in_x[0, :] -= np.mean(in_x[0, :])
261 in_x[1, :] -= np.mean(in_x[1, :])
264 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
265 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
266 as an `lsst.afw.image.ImageF`.
271 HeavyFootprint to use to generate the subimage
272 badfill : `float`, optional
273 Value to fill
in pixels
in extracted image that are outside the footprint
275 Optionally grow the footprint by this amount before extraction
279 subim2 : `lsst.afw.image.ImageF`
280 An `~lsst.afw.image.ImageF` containing the subimage.
286 subim2 = afwImage.ImageF(bbox, badfill)
287 fp.getSpans().unflatten(subim2.array, fp.getImageArray(), bbox.getCorners()[0])
291 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
293 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
298 SourceRecord, the footprint of which is to be fit
300 The exposure
from which to extract the footprint subimage
302 Polynomial order of background gradient to fit.
306 pars : `tuple` of `float`
307 `tuple` of length (1
if order==0; 3
if order==1; 6
if order == 2),
308 containing the resulting fit parameters
313 fp = source.getFootprint()
316 posImg = afwImage.ImageF(posImage.image, bbox, afwImage.PARENT)
321 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
324 isBg = np.isnan(posFpImg.array).ravel()
326 data = posImg.array.ravel()
330 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
331 x = x.astype(np.float64).ravel()
334 y = y.astype(np.float64).ravel()
337 b = np.ones_like(x, dtype=np.float64)
341 M = np.vstack([b, x, y]).T
343 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
345 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
349 """Generate a 2D image model of a single PDF centered at the given coordinates.
353 bbox : `lsst.geom.Box`
354 Bounding box marking pixel coordinates for generated model
356 Psf model used to generate the
'star'
358 Desired x-centroid of the
'star'
360 Desired y-centroid of the
'star'
362 Desired flux of the
'star'
367 2-d stellar image of width/height matching input ``bbox``,
368 containing PSF
with given centroid
and flux
372 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
373 psf_img_sum = np.nansum(psf_img.array)
374 psf_img *= (flux/psf_img_sum)
377 psf_box = psf_img.getBBox()
379 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
385 p_Im = afwImage.ImageF(bbox)
386 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
391 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
392 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
393 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
395 """Generate dipole model with given parameters.
397 This is the function whose sum-of-squared difference
from data
398 is minimized by `lmfit`.
401 Input independent variable. Used here
as the grid on
402 which to compute the background gradient model.
404 Desired flux of the positive lobe of the dipole
405 xcenPos, ycenPos : `float`
406 Desired x,y-centroid of the positive lobe of the dipole
407 xcenNeg, ycenNeg : `float`
408 Desired x,y-centroid of the negative lobe of the dipole
409 fluxNeg : `float`, optional
410 Desired flux of the negative lobe of the dipole, set to
'flux' if None
411 b, x1, y1, xy, x2, y2 : `float`
412 Gradient parameters
for positive lobe.
413 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
414 Gradient parameters
for negative lobe.
415 They are set to the corresponding positive values
if None.
417 **kwargs : `dict` [`str`]
418 Keyword arguments passed through ``lmfit``
and
419 used by this function. These must include:
421 - ``psf`` Psf model used to generate the
'star'
422 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
423 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
424 - ``bbox`` Bounding box containing region to be modelled
429 Has width
and height matching the input bbox,
and
430 contains the dipole model
with given centroids
and flux(es). If
431 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
432 those of bbox; otherwise a stack of three such arrays,
433 representing the dipole (diffim), positive,
and negative images
437 psf = kwargs.get('psf')
438 rel_weight = kwargs.get(
'rel_weight')
439 fp = kwargs.get(
'footprint')
446 self.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
447 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
449 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
451 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
453 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
454 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
458 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
459 in_x = np.array([x, y]) * 1.
460 in_x[0, :] -= in_x[0, :].mean()
461 in_x[1, :] -= in_x[1, :].mean()
470 gradientNeg = gradient
472 posIm.array[:, :] += gradient
473 negIm.array[:, :] += gradientNeg
476 diffIm = afwImage.ImageF(bbox)
482 zout = np.append([zout], [posIm.array, negIm.array], axis=0)
488 """Fit a dipole model using an image difference.
491 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
496 _private_version_ =
'0.0.5'
513 def __init__(self, diffim, posImage=None, negImage=None):
514 """Algorithm to run dipole measurement on a diaSource
519 Exposure on which the diaSources were detected
521 "Positive" exposure
from which the template was subtracted
523 "Negative" exposure which was subtracted
from the posImage
530 if diffim
is not None:
531 diffimPsf = diffim.getPsf()
532 diffimAvgPos = diffimPsf.getAveragePosition()
533 self.
psfSigma = diffimPsf.computeShape(diffimAvgPos).getDeterminantRadius()
535 self.
log = logging.getLogger(__name__)
541 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
542 separateNegParams=True, verbose=False):
543 """Fit a dipole model to an input difference image.
545 Actually, fits the subimage bounded by the input source's
546 footprint) and optionally constrain the fit using the
547 pre-subtraction images posImage
and negImage.
551 source : TODO: DM-17458
553 tol : float, optional
555 rel_weight : `float`, optional
557 fitBackground : `int`, optional
559 bgGradientOrder : `int`, optional
561 maxSepInSigma : `float`, optional
563 separateNegParams : `bool`, optional
565 verbose : `bool`, optional
570 result : `lmfit.MinimizerResult`
571 return `lmfit.MinimizerResult` object containing the fit
572 parameters
and other information.
578 fp = source.getFootprint()
580 subim = afwImage.MaskedImageF(self.
diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
582 z = diArr = subim.image.array
583 weights = 1. / subim.variance.array
585 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
587 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
589 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
591 posSubim = subim.clone()
594 negSubim = posSubim.clone()
597 z = np.append([z], [posSubim.image.array,
598 negSubim.image.array], axis=0)
600 weights = np.append([weights], [1. / posSubim.variance.array * rel_weight,
601 1. / negSubim.variance.array * rel_weight], axis=0)
608 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
609 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
610 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
612 """Generate dipole model with given parameters.
614 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
615 out of `kwargs['modelObj']`.
617 modelObj = kwargs.pop('modelObj')
618 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
619 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
620 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
621 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
625 modelFunctor = dipoleModelFunctor
628 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
632 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
633 cenNeg = cenPos = fpCentroid
638 cenPos = pks[0].getF()
640 cenNeg = pks[-1].getF()
644 maxSep = self.
psfSigma * maxSepInSigma
647 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
649 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
655 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
656 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
657 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
658 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
659 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
660 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
661 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
662 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
666 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
667 posFlux = negFlux = startingFlux
670 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
672 if separateNegParams:
674 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
682 bgParsPos = bgParsNeg = (0., 0., 0.)
683 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
687 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
688 order=bgGradientOrder)
691 if fitBackground == 1:
692 in_x = dipoleModel._generateXYGrid(bbox)
693 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
695 z[1, :] -= np.nanmedian(z[1, :])
696 posFlux = np.nansum(z[1, :])
697 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
699 if separateNegParams
and self.
negImage is not None:
700 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
701 order=bgGradientOrder)
702 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
704 z[2, :] -= np.nanmedian(z[2, :])
705 if separateNegParams:
706 negFlux = np.nansum(z[2, :])
707 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
710 if fitBackground == 2:
711 if bgGradientOrder >= 0:
712 gmod.set_param_hint(
'b', value=bgParsPos[0])
713 if separateNegParams:
714 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
715 if bgGradientOrder >= 1:
716 gmod.set_param_hint(
'x1', value=bgParsPos[1])
717 gmod.set_param_hint(
'y1', value=bgParsPos[2])
718 if separateNegParams:
719 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
720 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
721 if bgGradientOrder >= 2:
722 gmod.set_param_hint(
'xy', value=bgParsPos[3])
723 gmod.set_param_hint(
'x2', value=bgParsPos[4])
724 gmod.set_param_hint(
'y2', value=bgParsPos[5])
725 if separateNegParams:
726 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
727 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
728 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
730 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
731 in_x = np.array([x, y]).astype(np.float64)
732 in_x[0, :] -= in_x[0, :].mean()
733 in_x[1, :] -= in_x[1, :].mean()
737 mask = np.ones_like(z, dtype=bool)
742 weights = mask.astype(np.float64)
743 if self.
posImage is not None and rel_weight > 0.:
744 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
745 np.ones_like(diArr)*rel_weight])
754 with warnings.catch_warnings():
757 warnings.filterwarnings(
"ignore",
"The keyword argument .* does not match", UserWarning)
758 result = gmod.fit(z, weights=weights, x=in_x, max_nfev=250,
762 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
766 rel_weight=rel_weight,
768 modelObj=dipoleModel)
775 print(result.fit_report(show_correl=
False))
776 if separateNegParams:
777 print(result.ci_report())
782 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
783 bgGradientOrder=1, verbose=False, display=False):
784 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
786 Actually, fits the subimage bounded by the input source's
787 footprint) and optionally constrain the fit using the
788 pre-subtraction images self.
posImage (science)
and
789 self.
negImage (template). Wraps the output into a
790 `pipeBase.Struct` named tuple after computing additional
791 statistics such
as orientation
and SNR.
796 Record containing the (merged) dipole source footprint detected on the diffim
797 tol : `float`, optional
798 Tolerance parameter
for scipy.leastsq() optimization
799 rel_weight : `float`, optional
800 Weighting of posImage/negImage relative to the diffim
in the fit
801 fitBackground : `int`, {0, 1, 2}, optional
802 How to fit linear background gradient
in posImage/negImage
804 - 0: do
not fit background at all
805 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
806 as part of the dipole fitting optimization
807 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
808 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
809 background
as part of the overall dipole fitting optimization.
810 maxSepInSigma : `float`, optional
811 Allowed window of centroid parameters relative to peak
in input source footprint
812 separateNegParams : `bool`, optional
813 Fit separate parameters to the flux
and background gradient
in
814 bgGradientOrder : `int`, {0, 1, 2}, optional
815 Desired polynomial order of background gradient
816 verbose: `bool`, optional
819 Display input data, best fit model(s)
and residuals
in a matplotlib window.
824 `pipeBase.Struct` object containing the fit parameters
and other information.
827 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
831 Parameter `fitBackground` has three options, thus it
is an integer:
836 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
837 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
838 bgGradientOrder=bgGradientOrder, verbose=verbose)
842 fp = source.getFootprint()
845 fitParams = fitResult.best_values
846 if fitParams[
'flux'] <= 1.:
847 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
848 negCentroidX=np.nan, negCentroidY=np.nan,
849 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
850 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
851 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
852 return out, fitResult
854 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
855 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
856 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
857 angle = np.arctan2(dy, dx) / np.pi * 180.
861 def computeSumVariance(exposure, footprint):
862 return np.sqrt(np.nansum(exposure[footprint.getBBox(), afwImage.PARENT].variance.array))
864 fluxVal = fluxVar = fitParams[
'flux']
865 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
867 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
869 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
871 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
872 if separateNegParams:
873 fluxValNeg = fitParams[
'fluxNeg']
874 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
876 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
879 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
880 except ZeroDivisionError:
881 signalToNoise = np.nan
883 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
884 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
885 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
886 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
887 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
890 return out, fitResult
893 """Display data, model fits and residuals (currently uses matplotlib display functions).
897 footprint : TODO: DM-17458
898 Footprint containing the dipole that was fit
899 result : `lmfit.MinimizerResult`
900 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
904 fig : `matplotlib.pyplot.plot`
907 import matplotlib.pyplot
as plt
908 except ImportError
as err:
909 self.
log.warning(
'Unable to import matplotlib: %s', err)
912 def display2dArray(arr, title='Data', extent=None):
913 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
915 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
917 plt.colorbar(fig, cmap=
'gray')
921 fit = result.best_fit
922 bbox = footprint.getBBox()
923 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
925 fig = plt.figure(figsize=(8, 8))
927 plt.subplot(3, 3, i*3+1)
928 display2dArray(z[i, :],
'Data', extent=extent)
929 plt.subplot(3, 3, i*3+2)
930 display2dArray(fit[i, :],
'Model', extent=extent)
931 plt.subplot(3, 3, i*3+3)
932 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
935 fig = plt.figure(figsize=(8, 2.5))
937 display2dArray(z,
'Data', extent=extent)
939 display2dArray(fit,
'Model', extent=extent)
941 display2dArray(z - fit,
'Residual', extent=extent)
947@measBase.register("ip_diffim_DipoleFit")
949 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
951 This measurement plugin accepts up to three input images in
952 its `measure` method. If these are provided, it includes data
953 from the pre-subtraction posImage (science image)
and optionally
954 negImage (template image) to constrain the fit. The meat of the
955 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
959 The motivation behind this plugin
and the necessity
for including more than
960 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
962 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
963 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
964 are deemed to be replaceable by this.
967 ConfigClass = DipoleFitPluginConfig
968 DipoleFitAlgorithmClass = DipoleFitAlgorithm
972 FAILURE_NOT_DIPOLE = 4
976 """Set execution order to `FLUX_ORDER`.
978 This includes algorithms that require both `getShape()` and `getCentroid()`,
979 in addition to a Footprint
and its Peaks.
981 return cls.FLUX_ORDER
983 def __init__(self, config, name, schema, metadata, logName=None):
986 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
988 self.
log = logging.getLogger(logName)
992 def _setupSchema(self, config, name, schema, metadata):
1000 for pos_neg
in [
'pos',
'neg']:
1002 key = schema.addField(
1003 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
1004 doc=
"Dipole {0} lobe flux".format(pos_neg))
1005 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
1007 key = schema.addField(
1008 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1009 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1010 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1012 for x_y
in [
'x',
'y']:
1013 key = schema.addField(
1014 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1015 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1016 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1018 for x_y
in [
'x',
'y']:
1019 key = schema.addField(
1020 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1021 doc=
"Dipole centroid")
1022 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1025 schema.join(name,
"instFlux"), type=float, units=
"count",
1026 doc=
"Dipole overall flux")
1029 schema.join(name,
"orientation"), type=float, units=
"deg",
1030 doc=
"Dipole orientation")
1033 schema.join(name,
"separation"), type=float, units=
"pixel",
1034 doc=
"Pixel separation between positive and negative lobes of dipole")
1037 schema.join(name,
"chi2dof"), type=float,
1038 doc=
"Chi2 per degree of freedom of dipole fit")
1041 schema.join(name,
"signalToNoise"), type=float,
1042 doc=
"Estimated signal-to-noise of dipole fit")
1045 schema.join(name,
"flag",
"classification"), type=
"Flag",
1046 doc=
"Flag indicating diaSource is classified as a dipole")
1049 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1050 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1053 schema.join(name,
"flag"), type=
"Flag",
1054 doc=
"General failure flag for dipole fit")
1057 schema.join(name,
"flag",
"edge"), type=
"Flag",
1058 doc=
"Flag set when dipole is too close to edge of image")
1060 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1061 """Perform the non-linear least squares minimization on the putative dipole source.
1066 diaSources that will be measured using dipole measurement
1068 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1069 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1070 dipole to just the `exposure`
with no constraint.
1072 "Positive" exposure, typically a science exposure,
or None if unavailable
1073 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1075 "Negative" exposure, typically a template exposure,
or None if unavailable
1076 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1080 The main functionality of this routine was placed outside of
1081 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1082 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1083 testing (
@see `tests/testDipoleFitter.py`)
1087 result : TODO: DM-17458
1092 pks = measRecord.getFootprint().getPeaks()
1099 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1100 == np.sign(pks[-1].getPeakValue())))
1102 or (measRecord.getFootprint().getArea() > self.config.maxFootprintArea)
1107 if not self.config.fitAllDiaSources:
1112 result, _ = alg.fitDipole(
1113 measRecord, rel_weight=self.config.relWeight,
1114 tol=self.config.tolerance,
1115 maxSepInSigma=self.config.maxSeparation,
1116 fitBackground=self.config.fitBackground,
1117 separateNegParams=self.config.fitSeparateNegParams,
1118 verbose=
False, display=
False)
1120 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
1121 except Exception
as e:
1122 self.
fail(measRecord, measBase.MeasurementError(
'Exception in dipole fit', self.
FAILURE_FIT))
1123 self.
log.error(
"Exception in dipole fit. %s: %s", e.__class__.__name__, e)
1130 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(),
str(result))
1132 if result.posFlux <= 1.:
1133 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1137 measRecord[self.posFluxKey] = result.posFlux
1138 measRecord[self.posFluxErrKey] = result.signalToNoise
1139 measRecord[self.posCentroidKeyX] = result.posCentroidX
1140 measRecord[self.posCentroidKeyY] = result.posCentroidY
1142 measRecord[self.negFluxKey] = result.negFlux
1143 measRecord[self.negFluxErrKey] = result.signalToNoise
1144 measRecord[self.negCentroidKeyX] = result.negCentroidX
1145 measRecord[self.negCentroidKeyY] = result.negCentroidY
1148 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1150 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1151 + (result.posCentroidY - result.negCentroidY)**2.)
1152 measRecord[self.centroidKeyX] = result.centroidX
1153 measRecord[self.centroidKeyY] = result.centroidY
1161 """Classify a source as a dipole.
1165 measRecord : TODO: DM-17458
1167 chi2val : TODO: DM-17458
1172 Sources are classified as dipoles,
or not, according to three criteria:
1174 1. Does the total signal-to-noise surpass the ``minSn``?
1175 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1176 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1177 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1185 passesFluxPos = (abs(measRecord[self.posFluxKey])
1186 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1187 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1188 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1189 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1190 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1191 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1199 from scipy.stats
import chi2
1201 significance = chi2.cdf(chi2val, ndof)
1202 passesChi2 = significance < self.config.maxChi2DoF
1203 allPass = allPass
and passesChi2
1212 def fail(self, measRecord, error=None):
1213 """Catch failures and set the correct flags.
1216 measRecord.set(self.flagKey, True)
1217 if error
is not None:
1219 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(),
str(error))
1222 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(),
str(error))
1223 measRecord.set(self.
flagKey,
True)
1225 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1226 measRecord.getId(),
str(error))
1228 measRecord.set(self.
flagKey,
True)
1230 self.
log.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, logName=None)
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)