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
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)
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, :])
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
585 weights = 1. / subim.variance.array
587 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
589 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
591 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
593 posSubim = subim.clone()
596 negSubim = posSubim.clone()
599 z = np.append([z], [posSubim.image.array,
600 negSubim.image.array], axis=0)
602 weights = np.append([weights], [1. / posSubim.variance.array * rel_weight,
603 1. / negSubim.variance.array * rel_weight], axis=0)
610 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
611 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
612 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
614 """Generate dipole model with given parameters.
616 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
617 out of `kwargs['modelObj']`.
619 modelObj = kwargs.pop('modelObj')
620 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
621 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
622 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
623 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
627 modelFunctor = dipoleModelFunctor
630 gmod = lmfit.Model(modelFunctor, verbose=verbose)
634 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
635 cenNeg = cenPos = fpCentroid
640 cenPos = pks[0].getF()
642 cenNeg = pks[-1].getF()
646 maxSep = self.
psfSigma * maxSepInSigma
649 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
651 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
657 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
658 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
659 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
660 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
661 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
662 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
663 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
664 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
668 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
669 posFlux = negFlux = startingFlux
672 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
674 if separateNegParams:
676 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
684 bgParsPos = bgParsNeg = (0., 0., 0.)
685 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
689 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
690 order=bgGradientOrder)
693 if fitBackground == 1:
694 in_x = dipoleModel._generateXYGrid(bbox)
695 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
697 z[1, :] -= np.nanmedian(z[1, :])
698 posFlux = np.nansum(z[1, :])
699 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
701 if separateNegParams
and self.
negImage is not None:
702 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
703 order=bgGradientOrder)
704 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
706 z[2, :] -= np.nanmedian(z[2, :])
707 if separateNegParams:
708 negFlux = np.nansum(z[2, :])
709 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
712 if fitBackground == 2:
713 if bgGradientOrder >= 0:
714 gmod.set_param_hint(
'b', value=bgParsPos[0])
715 if separateNegParams:
716 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
717 if bgGradientOrder >= 1:
718 gmod.set_param_hint(
'x1', value=bgParsPos[1])
719 gmod.set_param_hint(
'y1', value=bgParsPos[2])
720 if separateNegParams:
721 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
722 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
723 if bgGradientOrder >= 2:
724 gmod.set_param_hint(
'xy', value=bgParsPos[3])
725 gmod.set_param_hint(
'x2', value=bgParsPos[4])
726 gmod.set_param_hint(
'y2', value=bgParsPos[5])
727 if separateNegParams:
728 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
729 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
730 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
732 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
733 in_x = np.array([x, y]).astype(np.float64)
734 in_x[0, :] -= in_x[0, :].mean()
735 in_x[1, :] -= in_x[1, :].mean()
739 mask = np.ones_like(z, dtype=bool)
744 weights = mask.astype(np.float64)
745 if self.
posImage is not None and rel_weight > 0.:
746 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
747 np.ones_like(diArr)*rel_weight])
754 nans = (np.isnan(z) | np.isnan(weights))
758 z[nans] = np.nanmedian(z)
766 with warnings.catch_warnings():
769 warnings.filterwarnings(
"ignore",
"The keyword argument .* does not match", UserWarning)
770 result = gmod.fit(z, weights=weights, x=in_x, max_nfev=250,
774 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
778 rel_weight=rel_weight,
780 modelObj=dipoleModel)
787 print(result.fit_report(show_correl=
False))
788 if separateNegParams:
789 print(result.ci_report())
794 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
795 bgGradientOrder=1, verbose=False, display=False):
796 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
798 Actually, fits the subimage bounded by the input source's
799 footprint) and optionally constrain the fit using the
800 pre-subtraction images self.
posImage (science)
and
801 self.
negImage (template). Wraps the output into a
802 `pipeBase.Struct` named tuple after computing additional
803 statistics such
as orientation
and SNR.
808 Record containing the (merged) dipole source footprint detected on the diffim
809 tol : `float`, optional
810 Tolerance parameter
for scipy.leastsq() optimization
811 rel_weight : `float`, optional
812 Weighting of posImage/negImage relative to the diffim
in the fit
813 fitBackground : `int`, {0, 1, 2}, optional
814 How to fit linear background gradient
in posImage/negImage
816 - 0: do
not fit background at all
817 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
818 as part of the dipole fitting optimization
819 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
820 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
821 background
as part of the overall dipole fitting optimization.
822 maxSepInSigma : `float`, optional
823 Allowed window of centroid parameters relative to peak
in input source footprint
824 separateNegParams : `bool`, optional
825 Fit separate parameters to the flux
and background gradient
in
826 bgGradientOrder : `int`, {0, 1, 2}, optional
827 Desired polynomial order of background gradient
828 verbose: `bool`, optional
831 Display input data, best fit model(s)
and residuals
in a matplotlib window.
836 `pipeBase.Struct` object containing the fit parameters
and other information.
839 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
843 Parameter `fitBackground` has three options, thus it
is an integer:
848 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
849 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
850 bgGradientOrder=bgGradientOrder, verbose=verbose)
854 fp = source.getFootprint()
857 fitParams = fitResult.best_values
858 if fitParams[
'flux'] <= 1.:
859 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
860 negCentroidX=np.nan, negCentroidY=np.nan,
861 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
862 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
863 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
864 return out, fitResult
866 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
867 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
868 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
869 angle = np.arctan2(dy, dx) / np.pi * 180.
873 def computeSumVariance(exposure, footprint):
874 return np.sqrt(np.nansum(exposure[footprint.getBBox(), afwImage.PARENT].variance.array))
876 fluxVal = fluxVar = fitParams[
'flux']
877 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
879 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
881 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
883 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
884 if separateNegParams:
885 fluxValNeg = fitParams[
'fluxNeg']
886 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
888 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
891 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
892 except ZeroDivisionError:
893 signalToNoise = np.nan
895 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
896 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
897 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
898 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
899 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
902 return out, fitResult
905 """Display data, model fits and residuals (currently uses matplotlib display functions).
909 footprint : TODO: DM-17458
910 Footprint containing the dipole that was fit
911 result : `lmfit.MinimizerResult`
912 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
916 fig : `matplotlib.pyplot.plot`
919 import matplotlib.pyplot
as plt
920 except ImportError
as err:
921 self.
log.warning(
'Unable to import matplotlib: %s', err)
924 def display2dArray(arr, title='Data', extent=None):
925 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
927 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
929 plt.colorbar(fig, cmap=
'gray')
933 fit = result.best_fit
934 bbox = footprint.getBBox()
935 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
937 fig = plt.figure(figsize=(8, 8))
939 plt.subplot(3, 3, i*3+1)
940 display2dArray(z[i, :],
'Data', extent=extent)
941 plt.subplot(3, 3, i*3+2)
942 display2dArray(fit[i, :],
'Model', extent=extent)
943 plt.subplot(3, 3, i*3+3)
944 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
947 fig = plt.figure(figsize=(8, 2.5))
949 display2dArray(z,
'Data', extent=extent)
951 display2dArray(fit,
'Model', extent=extent)
953 display2dArray(z - fit,
'Residual', extent=extent)
959@measBase.register("ip_diffim_DipoleFit")
961 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
963 This measurement plugin accepts up to three input images in
964 its `measure` method. If these are provided, it includes data
965 from the pre-subtraction posImage (science image)
and optionally
966 negImage (template image) to constrain the fit. The meat of the
967 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
971 The motivation behind this plugin
and the necessity
for including more than
972 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
974 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
975 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
976 are deemed to be replaceable by this.
979 ConfigClass = DipoleFitPluginConfig
980 DipoleFitAlgorithmClass = DipoleFitAlgorithm
984 FAILURE_NOT_DIPOLE = 4
988 """Set execution order to `FLUX_ORDER`.
990 This includes algorithms that require both `getShape()` and `getCentroid()`,
991 in addition to a Footprint
and its Peaks.
993 return cls.FLUX_ORDER
995 def __init__(self, config, name, schema, metadata, logName=None):
998 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
1000 self.
log = logging.getLogger(logName)
1012 for pos_neg
in [
'pos',
'neg']:
1014 key = schema.addField(
1015 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
1016 doc=
"Dipole {0} lobe flux".format(pos_neg))
1017 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
1019 key = schema.addField(
1020 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1021 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1022 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1024 for x_y
in [
'x',
'y']:
1025 key = schema.addField(
1026 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1027 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1028 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1030 for x_y
in [
'x',
'y']:
1031 key = schema.addField(
1032 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1033 doc=
"Dipole centroid")
1034 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1037 schema.join(name,
"instFlux"), type=float, units=
"count",
1038 doc=
"Dipole overall flux")
1041 schema.join(name,
"orientation"), type=float, units=
"deg",
1042 doc=
"Dipole orientation")
1045 schema.join(name,
"separation"), type=float, units=
"pixel",
1046 doc=
"Pixel separation between positive and negative lobes of dipole")
1049 schema.join(name,
"chi2dof"), type=float,
1050 doc=
"Chi2 per degree of freedom of dipole fit")
1053 schema.join(name,
"signalToNoise"), type=float,
1054 doc=
"Estimated signal-to-noise of dipole fit")
1057 schema.join(name,
"flag",
"classification"), type=
"Flag",
1058 doc=
"Flag indicating diaSource is classified as a dipole")
1061 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1062 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1065 schema.join(name,
"flag"), type=
"Flag",
1066 doc=
"General failure flag for dipole fit")
1069 schema.join(name,
"flag",
"edge"), type=
"Flag",
1070 doc=
"Flag set when dipole is too close to edge of image")
1072 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1073 """Perform the non-linear least squares minimization on the putative dipole source.
1078 diaSources that will be measured using dipole measurement
1080 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1081 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1082 dipole to just the `exposure`
with no constraint.
1084 "Positive" exposure, typically a science exposure,
or None if unavailable
1085 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1087 "Negative" exposure, typically a template exposure,
or None if unavailable
1088 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1092 The main functionality of this routine was placed outside of
1093 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1094 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1095 testing (
@see `tests/testDipoleFitter.py`)
1099 result : TODO: DM-17458
1104 pks = measRecord.getFootprint().getPeaks()
1111 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1112 == np.sign(pks[-1].getPeakValue())))
1114 or (measRecord.getFootprint().getArea() > self.config.maxFootprintArea)
1119 if not self.config.fitAllDiaSources:
1124 result, _ = alg.fitDipole(
1125 measRecord, rel_weight=self.config.relWeight,
1126 tol=self.config.tolerance,
1127 maxSepInSigma=self.config.maxSeparation,
1128 fitBackground=self.config.fitBackground,
1129 separateNegParams=self.config.fitSeparateNegParams,
1130 verbose=
False, display=
False)
1133 except Exception
as e:
1134 errorMessage = f
"Exception in dipole fit. {e.__class__.__name__}: {e}"
1142 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1144 if result.posFlux <= 1.:
1149 measRecord[self.posFluxKey] = result.posFlux
1150 measRecord[self.posFluxErrKey] = result.signalToNoise
1151 measRecord[self.posCentroidKeyX] = result.posCentroidX
1152 measRecord[self.posCentroidKeyY] = result.posCentroidY
1154 measRecord[self.negFluxKey] = result.negFlux
1155 measRecord[self.negFluxErrKey] = result.signalToNoise
1156 measRecord[self.negCentroidKeyX] = result.negCentroidX
1157 measRecord[self.negCentroidKeyY] = result.negCentroidY
1160 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1162 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1163 + (result.posCentroidY - result.negCentroidY)**2.)
1164 measRecord[self.centroidKeyX] = result.centroidX
1165 measRecord[self.centroidKeyY] = result.centroidY
1173 """Classify a source as a dipole.
1177 measRecord : TODO: DM-17458
1179 chi2val : TODO: DM-17458
1184 Sources are classified as dipoles,
or not, according to three criteria:
1186 1. Does the total signal-to-noise surpass the ``minSn``?
1187 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1188 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1189 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1197 passesFluxPos = (abs(measRecord[self.posFluxKey])
1198 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1199 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1200 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1201 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1202 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1203 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1211 from scipy.stats
import chi2
1213 significance = chi2.cdf(chi2val, ndof)
1214 passesChi2 = significance < self.config.maxChi2DoF
1215 allPass = allPass
and passesChi2
1224 def fail(self, measRecord, error=None):
1225 """Catch failures and set the correct flags.
1228 measRecord.set(self.flagKey, True)
1229 if error
is not None:
1231 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1234 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1235 measRecord.set(self.
flagKey,
True)
1237 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1238 measRecord.getId(), str(error))
1240 measRecord.set(self.
flagKey,
True)
1242 self.
log.warning(
'DipoleFitPlugin failed on record %d', measRecord.getId())
A class to contain the data, WCS, and other information needed to describe an image of the sky.
A class to represent a 2-dimensional array of pixels.
Record class that contains measurements made on a single exposure.
An integer coordinate rectangle.
displayFitResults(self, footprint, result)
fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
__init__(self, diffim, posImage=None, negImage=None)
measure(self, measRecord, exposure, posExp=None, negExp=None)
doClassify(self, measRecord, chi2val)
_setupSchema(self, config, name, schema, metadata)
fail(self, measRecord, error=None)
classificationAttemptedFlagKey
__init__(self, config, name, schema, metadata, logName=None)
__init__(self, schema, algMetadata=None, **kwargs)
makeStarModel(self, bbox, psf, xcen, ycen, flux)
_getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0)
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)
makeBackgroundModel(self, in_x, pars=None)
_generateXYGrid(self, bbox)
fitFootprintBackground(self, source, posImage, order=1)
Reports attempts to exceed implementation-defined length limits for some classes.