34from lsst.pipe.base
import Struct
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).
""")
96class DipoleFitTaskConfig(measBase.SingleFrameMeasurementConfig):
97 """Measurement of detected diaSources as dipoles
99 Currently we keep the "old" DipoleMeasurement algorithms turned on.
103 measBase.SingleFrameMeasurementConfig.setDefaults(self)
105 self.plugins.names = [
"base_CircularApertureFlux",
112 "base_PeakLikelihoodFlux",
114 "base_NaiveCentroid",
115 "ip_diffim_NaiveDipoleCentroid",
116 "ip_diffim_NaiveDipoleFlux",
117 "ip_diffim_PsfDipoleFlux",
118 "ip_diffim_ClassificationDipole",
121 self.slots.calibFlux =
None
122 self.slots.modelFlux =
None
123 self.slots.gaussianFlux =
None
124 self.slots.shape =
"base_SdssShape"
125 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
130 """A task that fits a dipole to a difference image, with an optional separate detection image.
132 Because it subclasses SingleFrameMeasurementTask, and calls
133 SingleFrameMeasurementTask.run()
from its
run() method, it still
134 can be used identically to a standard SingleFrameMeasurementTask.
137 ConfigClass = DipoleFitTaskConfig
138 _DefaultName = "ip_diffim_DipoleFit"
140 def __init__(self, schema, algMetadata=None, **kwargs):
142 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
144 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
147 schema=schema, metadata=algMetadata)
150 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
151 """Run dipole measurement and classification
156 ``diaSources`` that will be measured using dipole measurement
158 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
159 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
160 fitted directly to this difference image.
162 "Positive" exposure, typically a science exposure,
or None if unavailable
163 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
165 "Negative" exposure, typically a template exposure,
or None if unavailable
166 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
171 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
176 for source
in sources:
177 self.
dipoleFitterdipoleFitter.measure(source, exposure, posExp, negExp)
181 """Lightweight class containing methods for generating a dipole model for fitting
182 to sources in diffims, used by DipoleFitAlgorithm.
185 `DMTN-007: Dipole characterization
for image differencing <https://dmtn-007.lsst.io>`_.
191 self.
loglog = Log.getLogger(__name__)
194 """Generate gradient model (2-d array) with up to 2nd-order polynomial
199 (2, w, h)-dimensional `numpy.array`, containing the
200 input x,y meshgrid providing the coordinates upon which to
201 compute the gradient. This will typically be generated via
202 `_generateXYGrid()`. `w` and `h` correspond to the width
and
203 height of the desired grid.
204 pars : `list` of `float`, optional
205 Up to 6 floats
for up
206 to 6 2nd-order 2-d polynomial gradient parameters,
in the
207 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
208 is emtpy
or `
None`, do nothing
and return `
None` (
for speed).
212 result : `
None`
or `numpy.array`
213 return None,
or 2-d numpy.array of width/height matching
214 input bbox, containing computed gradient values.
218 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
221 y, x = in_x[0, :], in_x[1, :]
222 gradient = np.full_like(x, pars[0], dtype=
'float64')
223 if len(pars) > 1
and pars[1]
is not None:
224 gradient += pars[1] * x
225 if len(pars) > 2
and pars[2]
is not None:
226 gradient += pars[2] * y
227 if len(pars) > 3
and pars[3]
is not None:
228 gradient += pars[3] * (x * y)
229 if len(pars) > 4
and pars[4]
is not None:
230 gradient += pars[4] * (x * x)
231 if len(pars) > 5
and pars[5]
is not None:
232 gradient += pars[5] * (y * y)
236 def _generateXYGrid(self, bbox):
237 """Generate a meshgrid covering the x,y coordinates bounded by bbox
242 input Bounding Box defining the coordinate limits
247 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
251 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
252 in_x = np.array([y, x]).astype(np.float64)
253 in_x[0, :] -= np.mean(in_x[0, :])
254 in_x[1, :] -= np.mean(in_x[1, :])
257 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
258 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
259 as an `lsst.afw.image.ImageF`.
264 HeavyFootprint to use to generate the subimage
265 badfill : `float`, optional
266 Value to fill
in pixels
in extracted image that are outside the footprint
268 Optionally grow the footprint by this amount before extraction
272 subim2 : `lsst.afw.image.ImageF`
273 An `~lsst.afw.image.ImageF` containing the subimage.
279 subim2 = afwImage.ImageF(bbox, badfill)
280 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
284 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
286 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
291 SourceRecord, the footprint of which is to be fit
293 The exposure
from which to extract the footprint subimage
295 Polynomial order of background gradient to fit.
299 pars : `tuple` of `float`
300 `tuple` of length (1
if order==0; 3
if order==1; 6
if order == 2),
301 containing the resulting fit parameters
306 fp = source.getFootprint()
309 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
314 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
317 isBg = np.isnan(posFpImg.getArray()).ravel()
319 data = posImg.getArray().ravel()
323 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
324 x = x.astype(np.float64).ravel()
327 y = y.astype(np.float64).ravel()
330 b = np.ones_like(x, dtype=np.float64)
334 M = np.vstack([b, x, y]).T
336 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
338 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
342 """Generate a 2D image model of a single PDF centered at the given coordinates.
346 bbox : `lsst.geom.Box`
347 Bounding box marking pixel coordinates for generated model
349 Psf model used to generate the
'star'
351 Desired x-centroid of the
'star'
353 Desired y-centroid of the
'star'
355 Desired flux of the
'star'
360 2-d stellar image of width/height matching input ``bbox``,
361 containing PSF
with given centroid
and flux
365 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
366 psf_img_sum = np.nansum(psf_img.getArray())
367 psf_img *= (flux/psf_img_sum)
370 psf_box = psf_img.getBBox()
372 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
378 p_Im = afwImage.ImageF(bbox)
379 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
384 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
385 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
386 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
388 """Generate dipole model with given parameters.
390 This is the function whose sum-of-squared difference
from data
391 is minimized by `lmfit`.
394 Input independent variable. Used here
as the grid on
395 which to compute the background gradient model.
397 Desired flux of the positive lobe of the dipole
399 Desired x-centroid of the positive lobe of the dipole
401 Desired y-centroid of the positive lobe of the dipole
403 Desired x-centroid of the negative lobe of the dipole
405 Desired y-centroid of the negative lobe of the dipole
406 fluxNeg : `float`, optional
407 Desired flux of the negative lobe of the dipole, set to
'flux' if None
408 b, x1, y1, xy, x2, y2 : `float`
409 Gradient parameters
for positive lobe.
410 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
411 Gradient parameters
for negative lobe.
412 They are set to the corresponding positive values
if None.
415 Keyword arguments passed through ``lmfit``
and
416 used by this function. These must include:
418 - ``psf`` Psf model used to generate the
'star'
419 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
420 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
421 - ``bbox`` Bounding box containing region to be modelled
426 Has width
and height matching the input bbox,
and
427 contains the dipole model
with given centroids
and flux(es). If
428 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
429 those of bbox; otherwise a stack of three such arrays,
430 representing the dipole (diffim), positive
and negative images
434 psf = kwargs.get('psf')
435 rel_weight = kwargs.get(
'rel_weight')
436 fp = kwargs.get(
'footprint')
443 self.
loglog.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
444 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
446 self.
loglog.
debug(
' %.2f %.2f %.2f', b, x1, y1)
448 self.
loglog.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
450 posIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenPos, ycenPos, flux)
451 negIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
455 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
456 in_x = np.array([x, y]) * 1.
457 in_x[0, :] -= in_x[0, :].mean()
458 in_x[1, :] -= in_x[1, :].mean()
465 gradientNeg = self.
makeBackgroundModelmakeBackgroundModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))
467 gradientNeg = gradient
469 posIm.getArray()[:, :] += gradient
470 negIm.getArray()[:, :] += gradientNeg
473 diffIm = afwImage.ImageF(bbox)
477 zout = diffIm.getArray()
479 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
485 """Fit a dipole model using an image difference.
488 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
493 _private_version_ =
'0.0.5'
510 def __init__(self, diffim, posImage=None, negImage=None):
511 """Algorithm to run dipole measurement on a diaSource
516 Exposure on which the diaSources were detected
518 "Positive" exposure
from which the template was subtracted
520 "Negative" exposure which was subtracted
from the posImage
527 if diffim
is not None:
528 self.
psfSigmapsfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
530 self.
loglog = Log.getLogger(__name__)
536 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
537 separateNegParams=True, verbose=False):
538 """Fit a dipole model to an input difference image.
540 Actually, fits the subimage bounded by the input source's
541 footprint) and optionally constrain the fit using the
542 pre-subtraction images posImage
and negImage.
546 source : TODO: DM-17458
548 tol : float, optional
550 rel_weight : `float`, optional
552 fitBackground : `int`, optional
554 bgGradientOrder : `int`, optional
556 maxSepInSigma : `float`, optional
558 separateNegParams : `bool`, optional
560 verbose : `bool`, optional
565 result : `lmfit.MinimizerResult`
566 return `lmfit.MinimizerResult` object containing the fit
567 parameters
and other information.
573 fp = source.getFootprint()
575 subim = afwImage.MaskedImageF(self.
diffimdiffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
577 z = diArr = subim.getArrays()[0]
578 weights = 1. / subim.getArrays()[2]
580 if rel_weight > 0.
and ((self.
posImageposImage
is not None)
or (self.
negImagenegImage
is not None)):
581 if self.
negImagenegImage
is not None:
582 negSubim = afwImage.MaskedImageF(self.
negImagenegImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
583 if self.
posImageposImage
is not None:
584 posSubim = afwImage.MaskedImageF(self.
posImageposImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
586 posSubim = subim.clone()
589 negSubim = posSubim.clone()
592 z = np.append([z], [posSubim.getArrays()[0],
593 negSubim.getArrays()[0]], axis=0)
595 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
596 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
603 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
604 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
605 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
607 """Generate dipole model with given parameters.
609 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
610 out of `kwargs['modelObj']`.
612 modelObj = kwargs.pop('modelObj')
613 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
614 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
615 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
616 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
620 modelFunctor = dipoleModelFunctor
623 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
628 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
629 cenNeg = cenPos = fpCentroid
634 cenPos = pks[0].getF()
636 cenNeg = pks[-1].getF()
640 maxSep = self.
psfSigmapsfSigma * maxSepInSigma
643 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
645 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
651 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
652 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
653 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
654 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
655 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
656 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
657 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
658 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
662 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
663 posFlux = negFlux = startingFlux
666 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
668 if separateNegParams:
670 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
678 bgParsPos = bgParsNeg = (0., 0., 0.)
679 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
683 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
684 order=bgGradientOrder)
687 if fitBackground == 1:
688 in_x = dipoleModel._generateXYGrid(bbox)
689 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
691 z[1, :] -= np.nanmedian(z[1, :])
692 posFlux = np.nansum(z[1, :])
693 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
695 if separateNegParams
and self.
negImagenegImage
is not None:
696 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImagenegImage,
697 order=bgGradientOrder)
698 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
700 z[2, :] -= np.nanmedian(z[2, :])
701 if separateNegParams:
702 negFlux = np.nansum(z[2, :])
703 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
706 if fitBackground == 2:
707 if bgGradientOrder >= 0:
708 gmod.set_param_hint(
'b', value=bgParsPos[0])
709 if separateNegParams:
710 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
711 if bgGradientOrder >= 1:
712 gmod.set_param_hint(
'x1', value=bgParsPos[1])
713 gmod.set_param_hint(
'y1', value=bgParsPos[2])
714 if separateNegParams:
715 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
716 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
717 if bgGradientOrder >= 2:
718 gmod.set_param_hint(
'xy', value=bgParsPos[3])
719 gmod.set_param_hint(
'x2', value=bgParsPos[4])
720 gmod.set_param_hint(
'y2', value=bgParsPos[5])
721 if separateNegParams:
722 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
723 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
724 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
726 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
727 in_x = np.array([x, y]).astype(np.float)
728 in_x[0, :] -= in_x[0, :].mean()
729 in_x[1, :] -= in_x[1, :].mean()
733 mask = np.ones_like(z, dtype=bool)
738 weights = mask.astype(np.float64)
739 if self.
posImageposImage
is not None and rel_weight > 0.:
740 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
741 np.ones_like(diArr)*rel_weight])
750 with warnings.catch_warnings():
751 warnings.simplefilter(
"ignore")
752 result = gmod.fit(z, weights=weights, x=in_x,
754 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
756 psf=self.
diffimdiffim.getPsf(),
757 rel_weight=rel_weight,
759 modelObj=dipoleModel)
766 print(result.fit_report(show_correl=
False))
767 if separateNegParams:
768 print(result.ci_report())
773 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
774 bgGradientOrder=1, verbose=False, display=False):
775 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
777 Actually, fits the subimage bounded by the input source's
778 footprint) and optionally constrain the fit using the
779 pre-subtraction images self.
posImageposImage (science)
and
780 self.
negImagenegImage (template). Wraps the output into a
781 `pipeBase.Struct` named tuple after computing additional
782 statistics such
as orientation
and SNR.
787 Record containing the (merged) dipole source footprint detected on the diffim
788 tol : `float`, optional
789 Tolerance parameter
for scipy.leastsq() optimization
790 rel_weight : `float`, optional
791 Weighting of posImage/negImage relative to the diffim
in the fit
792 fitBackground : `int`, {0, 1, 2}, optional
793 How to fit linear background gradient
in posImage/negImage
795 - 0: do
not fit background at all
796 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
797 as part of the dipole fitting optimization
798 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
799 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
800 background
as part of the overall dipole fitting optimization.
801 maxSepInSigma : `float`, optional
802 Allowed window of centroid parameters relative to peak
in input source footprint
803 separateNegParams : `bool`, optional
804 Fit separate parameters to the flux
and background gradient
in
805 bgGradientOrder : `int`, {0, 1, 2}, optional
806 Desired polynomial order of background gradient
807 verbose: `bool`, optional
810 Display input data, best fit model(s)
and residuals
in a matplotlib window.
815 `pipeBase.Struct` object containing the fit parameters
and other information.
818 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
822 Parameter `fitBackground` has three options, thus it
is an integer:
827 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
828 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
829 bgGradientOrder=bgGradientOrder, verbose=verbose)
833 fp = source.getFootprint()
836 fitParams = fitResult.best_values
837 if fitParams[
'flux'] <= 1.:
838 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
839 negCentroidX=np.nan, negCentroidY=np.nan,
840 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
841 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
842 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
843 return out, fitResult
845 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
846 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
847 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
848 angle = np.arctan2(dy, dx) / np.pi * 180.
852 def computeSumVariance(exposure, footprint):
853 box = footprint.getBBox()
854 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
855 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
857 fluxVal = fluxVar = fitParams[
'flux']
858 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
859 if self.
posImageposImage
is not None:
860 fluxVar = computeSumVariance(self.
posImageposImage, source.getFootprint())
862 fluxVar = computeSumVariance(self.
diffimdiffim, source.getFootprint())
864 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
865 if separateNegParams:
866 fluxValNeg = fitParams[
'fluxNeg']
867 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
868 if self.
negImagenegImage
is not None:
869 fluxVarNeg = computeSumVariance(self.
negImagenegImage, source.getFootprint())
872 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
873 except ZeroDivisionError:
874 signalToNoise = np.nan
876 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
877 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
878 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
879 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
880 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
883 return out, fitResult
886 """Display data, model fits and residuals (currently uses matplotlib display functions).
890 footprint : TODO: DM-17458
891 Footprint containing the dipole that was fit
892 result : `lmfit.MinimizerResult`
893 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
897 fig : `matplotlib.pyplot.plot`
900 import matplotlib.pyplot
as plt
901 except ImportError
as err:
902 self.
loglog.warning(
'Unable to import matplotlib: %s', err)
905 def display2dArray(arr, title='Data', extent=None):
906 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
908 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
910 plt.colorbar(fig, cmap=
'gray')
914 fit = result.best_fit
915 bbox = footprint.getBBox()
916 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
918 fig = plt.figure(figsize=(8, 8))
920 plt.subplot(3, 3, i*3+1)
921 display2dArray(z[i, :],
'Data', extent=extent)
922 plt.subplot(3, 3, i*3+2)
923 display2dArray(fit[i, :],
'Model', extent=extent)
924 plt.subplot(3, 3, i*3+3)
925 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
928 fig = plt.figure(figsize=(8, 2.5))
930 display2dArray(z,
'Data', extent=extent)
932 display2dArray(fit,
'Model', extent=extent)
934 display2dArray(z - fit,
'Residual', extent=extent)
940@measBase.register("ip_diffim_DipoleFit")
942 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
944 This measurement plugin accepts up to three input images in
945 its `measure` method. If these are provided, it includes data
946 from the pre-subtraction posImage (science image)
and optionally
947 negImage (template image) to constrain the fit. The meat of the
948 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
952 The motivation behind this plugin
and the necessity
for including more than
953 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
955 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
956 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
957 are deemed to be replaceable by this.
960 ConfigClass = DipoleFitPluginConfig
961 DipoleFitAlgorithmClass = DipoleFitAlgorithm
965 FAILURE_NOT_DIPOLE = 4
969 """Set execution order to `FLUX_ORDER`.
971 This includes algorithms that require both `getShape()` and `getCentroid()`,
972 in addition to a Footprint
and its Peaks.
974 return cls.FLUX_ORDER
976 def __init__(self, config, name, schema, metadata):
977 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)
979 self.
loglog = Log.getLogger(name)
981 self.
_setupSchema_setupSchema(config, name, schema, metadata)
983 def _setupSchema(self, config, name, schema, metadata):
985 self.
centroidKeycentroidKey = afwTable.Point2DKey(schema[
"slot_Centroid"])
991 for pos_neg
in [
'pos',
'neg']:
993 key = schema.addField(
994 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
995 doc=
"Dipole {0} lobe flux".format(pos_neg))
996 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
998 key = schema.addField(
999 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1000 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1001 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1003 for x_y
in [
'x',
'y']:
1004 key = schema.addField(
1005 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1006 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1007 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1009 for x_y
in [
'x',
'y']:
1010 key = schema.addField(
1011 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1012 doc=
"Dipole centroid")
1013 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1016 schema.join(name,
"instFlux"), type=float, units=
"count",
1017 doc=
"Dipole overall flux")
1020 schema.join(name,
"orientation"), type=float, units=
"deg",
1021 doc=
"Dipole orientation")
1024 schema.join(name,
"separation"), type=float, units=
"pixel",
1025 doc=
"Pixel separation between positive and negative lobes of dipole")
1028 schema.join(name,
"chi2dof"), type=float,
1029 doc=
"Chi2 per degree of freedom of dipole fit")
1032 schema.join(name,
"signalToNoise"), type=float,
1033 doc=
"Estimated signal-to-noise of dipole fit")
1036 schema.join(name,
"flag",
"classification"), type=
"Flag",
1037 doc=
"Flag indicating diaSource is classified as a dipole")
1040 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1041 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1044 schema.join(name,
"flag"), type=
"Flag",
1045 doc=
"General failure flag for dipole fit")
1048 schema.join(name,
"flag",
"edge"), type=
"Flag",
1049 doc=
"Flag set when dipole is too close to edge of image")
1051 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1052 """Perform the non-linear least squares minimization on the putative dipole source.
1057 diaSources that will be measured using dipole measurement
1059 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1060 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1061 dipole to just the `exposure`
with no constraint.
1063 "Positive" exposure, typically a science exposure,
or None if unavailable
1064 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1066 "Negative" exposure, typically a template exposure,
or None if unavailable
1067 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1071 The main functionality of this routine was placed outside of
1072 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1073 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1074 testing (
@see `tests/testDipoleFitter.py`)
1078 result : TODO: DM-17458
1083 pks = measRecord.getFootprint().getPeaks()
1088 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1089 == np.sign(pks[-1].getPeakValue())))
1093 self.
failfail(measRecord, measBase.MeasurementError(
'not a dipole', self.
FAILURE_NOT_DIPOLEFAILURE_NOT_DIPOLE))
1094 if not self.config.fitAllDiaSources:
1099 result, _ = alg.fitDipole(
1100 measRecord, rel_weight=self.config.relWeight,
1101 tol=self.config.tolerance,
1102 maxSepInSigma=self.config.maxSeparation,
1103 fitBackground=self.config.fitBackground,
1104 separateNegParams=self.config.fitSeparateNegParams,
1105 verbose=
False, display=
False)
1107 self.
failfail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGEFAILURE_EDGE))
1109 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1116 self.
loglog.debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1118 if result.posFlux <= 1.:
1119 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1123 measRecord[self.posFluxKey] = result.posFlux
1124 measRecord[self.posFluxErrKey] = result.signalToNoise
1125 measRecord[self.posCentroidKeyX] = result.posCentroidX
1126 measRecord[self.posCentroidKeyY] = result.posCentroidY
1128 measRecord[self.negFluxKey] = result.negFlux
1129 measRecord[self.negFluxErrKey] = result.signalToNoise
1130 measRecord[self.negCentroidKeyX] = result.negCentroidX
1131 measRecord[self.negCentroidKeyY] = result.negCentroidY
1134 measRecord[self.
fluxKeyfluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1135 measRecord[self.
orientationKeyorientationKey] = result.orientation
1136 measRecord[self.
separationKeyseparationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1137 + (result.posCentroidY - result.negCentroidY)**2.)
1138 measRecord[self.centroidKeyX] = result.centroidX
1139 measRecord[self.centroidKeyY] = result.centroidY
1142 measRecord[self.
chi2dofKeychi2dofKey] = result.redChi2
1144 self.
doClassifydoClassify(measRecord, result.chi2)
1147 """Classify a source as a dipole.
1151 measRecord : TODO: DM-17458
1153 chi2val : TODO: DM-17458
1158 Sources are classified as dipoles,
or not, according to three criteria:
1160 1. Does the total signal-to-noise surpass the ``minSn``?
1161 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1162 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1163 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1167 passesSn = measRecord[self.
signalToNoiseKeysignalToNoiseKey] > self.config.minSn
1171 passesFluxPos = (abs(measRecord[self.posFluxKey])
1172 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1173 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1174 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1175 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1176 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1177 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1185 from scipy.stats
import chi2
1186 ndof = chi2val / measRecord[self.
chi2dofKeychi2dofKey]
1187 significance = chi2.cdf(chi2val, ndof)
1188 passesChi2 = significance < self.config.maxChi2DoF
1189 allPass = allPass
and passesChi2
1198 def fail(self, measRecord, error=None):
1199 """Catch failures and set the correct flags.
1202 measRecord.set(self.flagKeyflagKey, True)
1203 if error
is not None:
1204 if error.getFlagBit() == self.
FAILURE_EDGEFAILURE_EDGE:
1205 self.
loglog.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1207 if error.getFlagBit() == self.
FAILURE_FITFAILURE_FIT:
1208 self.
loglog.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1209 measRecord.set(self.
flagKeyflagKey,
True)
1211 self.
loglog.debug(
'DipoleFitPlugin not run on record %d: %s',
1212 measRecord.getId(), str(error))
1214 measRecord.set(self.
flagKeyflagKey,
True)
1216 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)