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,
148 logName=self.log.name)
151 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
152 """Run dipole measurement and classification
157 ``diaSources`` that will be measured using dipole measurement
159 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
160 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
161 fitted directly to this difference image.
163 "Positive" exposure, typically a science exposure,
or None if unavailable
164 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
166 "Negative" exposure, typically a template exposure,
or None if unavailable
167 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
172 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
177 for source
in sources:
178 self.
dipoleFitter.measure(source, exposure, posExp, negExp)
182 """Lightweight class containing methods for generating a dipole model for fitting
183 to sources in diffims, used by DipoleFitAlgorithm.
186 `DMTN-007: Dipole characterization
for image differencing <https://dmtn-007.lsst.io>`_.
192 self.
log = logging.getLogger(__name__)
195 """Generate gradient model (2-d array) with up to 2nd-order polynomial
200 (2, w, h)-dimensional `numpy.array`, containing the
201 input x,y meshgrid providing the coordinates upon which to
202 compute the gradient. This will typically be generated via
203 `_generateXYGrid()`. `w` and `h` correspond to the width
and
204 height of the desired grid.
205 pars : `list` of `float`, optional
206 Up to 6 floats
for up
207 to 6 2nd-order 2-d polynomial gradient parameters,
in the
208 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
209 is emtpy
or `
None`, do nothing
and return `
None` (
for speed).
213 result : `
None`
or `numpy.array`
214 return None,
or 2-d numpy.array of width/height matching
215 input bbox, containing computed gradient values.
219 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
222 y, x = in_x[0, :], in_x[1, :]
223 gradient = np.full_like(x, pars[0], dtype=
'float64')
224 if len(pars) > 1
and pars[1]
is not None:
225 gradient += pars[1] * x
226 if len(pars) > 2
and pars[2]
is not None:
227 gradient += pars[2] * y
228 if len(pars) > 3
and pars[3]
is not None:
229 gradient += pars[3] * (x * y)
230 if len(pars) > 4
and pars[4]
is not None:
231 gradient += pars[4] * (x * x)
232 if len(pars) > 5
and pars[5]
is not None:
233 gradient += pars[5] * (y * y)
237 def _generateXYGrid(self, bbox):
238 """Generate a meshgrid covering the x,y coordinates bounded by bbox
243 input Bounding Box defining the coordinate limits
248 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
252 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
253 in_x = np.array([y, x]).astype(np.float64)
254 in_x[0, :] -= np.mean(in_x[0, :])
255 in_x[1, :] -= np.mean(in_x[1, :])
258 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
259 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
260 as an `lsst.afw.image.ImageF`.
265 HeavyFootprint to use to generate the subimage
266 badfill : `float`, optional
267 Value to fill
in pixels
in extracted image that are outside the footprint
269 Optionally grow the footprint by this amount before extraction
273 subim2 : `lsst.afw.image.ImageF`
274 An `~lsst.afw.image.ImageF` containing the subimage.
280 subim2 = afwImage.ImageF(bbox, badfill)
281 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
285 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
287 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
292 SourceRecord, the footprint of which is to be fit
294 The exposure
from which to extract the footprint subimage
296 Polynomial order of background gradient to fit.
300 pars : `tuple` of `float`
301 `tuple` of length (1
if order==0; 3
if order==1; 6
if order == 2),
302 containing the resulting fit parameters
307 fp = source.getFootprint()
310 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
315 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
318 isBg = np.isnan(posFpImg.getArray()).ravel()
320 data = posImg.getArray().ravel()
324 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
325 x = x.astype(np.float64).ravel()
328 y = y.astype(np.float64).ravel()
331 b = np.ones_like(x, dtype=np.float64)
335 M = np.vstack([b, x, y]).T
337 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
339 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
343 """Generate a 2D image model of a single PDF centered at the given coordinates.
347 bbox : `lsst.geom.Box`
348 Bounding box marking pixel coordinates for generated model
350 Psf model used to generate the
'star'
352 Desired x-centroid of the
'star'
354 Desired y-centroid of the
'star'
356 Desired flux of the
'star'
361 2-d stellar image of width/height matching input ``bbox``,
362 containing PSF
with given centroid
and flux
366 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
367 psf_img_sum = np.nansum(psf_img.getArray())
368 psf_img *= (flux/psf_img_sum)
371 psf_box = psf_img.getBBox()
373 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
379 p_Im = afwImage.ImageF(bbox)
380 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
385 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
386 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
387 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
389 """Generate dipole model with given parameters.
391 This is the function whose sum-of-squared difference
from data
392 is minimized by `lmfit`.
395 Input independent variable. Used here
as the grid on
396 which to compute the background gradient model.
398 Desired flux of the positive lobe of the dipole
400 Desired x-centroid of the positive lobe of the dipole
402 Desired y-centroid of the positive lobe of the dipole
404 Desired x-centroid of the negative lobe of the dipole
406 Desired y-centroid of the negative lobe of the dipole
407 fluxNeg : `float`, optional
408 Desired flux of the negative lobe of the dipole, set to
'flux' if None
409 b, x1, y1, xy, x2, y2 : `float`
410 Gradient parameters
for positive lobe.
411 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
412 Gradient parameters
for negative lobe.
413 They are set to the corresponding positive values
if None.
416 Keyword arguments passed through ``lmfit``
and
417 used by this function. These must include:
419 - ``psf`` Psf model used to generate the
'star'
420 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
421 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
422 - ``bbox`` Bounding box containing region to be modelled
427 Has width
and height matching the input bbox,
and
428 contains the dipole model
with given centroids
and flux(es). If
429 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
430 those of bbox; otherwise a stack of three such arrays,
431 representing the dipole (diffim), positive
and negative images
435 psf = kwargs.get('psf')
436 rel_weight = kwargs.get(
'rel_weight')
437 fp = kwargs.get(
'footprint')
444 self.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
445 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
447 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
449 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
451 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
452 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
456 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
457 in_x = np.array([x, y]) * 1.
458 in_x[0, :] -= in_x[0, :].mean()
459 in_x[1, :] -= in_x[1, :].mean()
468 gradientNeg = gradient
470 posIm.getArray()[:, :] += gradient
471 negIm.getArray()[:, :] += gradientNeg
474 diffIm = afwImage.ImageF(bbox)
478 zout = diffIm.getArray()
480 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
486 """Fit a dipole model using an image difference.
489 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
494 _private_version_ =
'0.0.5'
511 def __init__(self, diffim, posImage=None, negImage=None):
512 """Algorithm to run dipole measurement on a diaSource
517 Exposure on which the diaSources were detected
519 "Positive" exposure
from which the template was subtracted
521 "Negative" exposure which was subtracted
from the posImage
528 if diffim
is not None:
529 diffimPsf = diffim.getPsf()
530 diffimAvgPos = diffimPsf.getAveragePosition()
531 self.
psfSigma = diffimPsf.computeShape(diffimAvgPos).getDeterminantRadius()
533 self.
log = logging.getLogger(__name__)
539 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
540 separateNegParams=True, verbose=False):
541 """Fit a dipole model to an input difference image.
543 Actually, fits the subimage bounded by the input source's
544 footprint) and optionally constrain the fit using the
545 pre-subtraction images posImage
and negImage.
549 source : TODO: DM-17458
551 tol : float, optional
553 rel_weight : `float`, optional
555 fitBackground : `int`, optional
557 bgGradientOrder : `int`, optional
559 maxSepInSigma : `float`, optional
561 separateNegParams : `bool`, optional
563 verbose : `bool`, optional
568 result : `lmfit.MinimizerResult`
569 return `lmfit.MinimizerResult` object containing the fit
570 parameters
and other information.
576 fp = source.getFootprint()
578 subim = afwImage.MaskedImageF(self.
diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
580 z = diArr = subim.getArrays()[0]
581 weights = 1. / subim.getArrays()[2]
583 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
585 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
587 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
589 posSubim = subim.clone()
592 negSubim = posSubim.clone()
595 z = np.append([z], [posSubim.getArrays()[0],
596 negSubim.getArrays()[0]], axis=0)
598 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
599 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
606 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
607 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
608 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
610 """Generate dipole model with given parameters.
612 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
613 out of `kwargs['modelObj']`.
615 modelObj = kwargs.pop('modelObj')
616 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
617 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
618 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
619 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
623 modelFunctor = dipoleModelFunctor
626 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
631 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
632 cenNeg = cenPos = fpCentroid
637 cenPos = pks[0].getF()
639 cenNeg = pks[-1].getF()
643 maxSep = self.
psfSigma * maxSepInSigma
646 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
648 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
654 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
655 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
656 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
657 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
658 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
659 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
660 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
661 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
665 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
666 posFlux = negFlux = startingFlux
669 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
671 if separateNegParams:
673 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
681 bgParsPos = bgParsNeg = (0., 0., 0.)
682 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
686 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
687 order=bgGradientOrder)
690 if fitBackground == 1:
691 in_x = dipoleModel._generateXYGrid(bbox)
692 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
694 z[1, :] -= np.nanmedian(z[1, :])
695 posFlux = np.nansum(z[1, :])
696 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
698 if separateNegParams
and self.
negImage is not None:
699 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
700 order=bgGradientOrder)
701 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
703 z[2, :] -= np.nanmedian(z[2, :])
704 if separateNegParams:
705 negFlux = np.nansum(z[2, :])
706 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
709 if fitBackground == 2:
710 if bgGradientOrder >= 0:
711 gmod.set_param_hint(
'b', value=bgParsPos[0])
712 if separateNegParams:
713 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
714 if bgGradientOrder >= 1:
715 gmod.set_param_hint(
'x1', value=bgParsPos[1])
716 gmod.set_param_hint(
'y1', value=bgParsPos[2])
717 if separateNegParams:
718 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
719 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
720 if bgGradientOrder >= 2:
721 gmod.set_param_hint(
'xy', value=bgParsPos[3])
722 gmod.set_param_hint(
'x2', value=bgParsPos[4])
723 gmod.set_param_hint(
'y2', value=bgParsPos[5])
724 if separateNegParams:
725 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
726 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
727 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
729 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
730 in_x = np.array([x, y]).astype(np.float64)
731 in_x[0, :] -= in_x[0, :].mean()
732 in_x[1, :] -= in_x[1, :].mean()
736 mask = np.ones_like(z, dtype=bool)
741 weights = mask.astype(np.float64)
742 if self.
posImage is not None and rel_weight > 0.:
743 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
744 np.ones_like(diArr)*rel_weight])
753 with warnings.catch_warnings():
754 warnings.simplefilter(
"ignore")
755 result = gmod.fit(z, weights=weights, x=in_x,
757 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
760 rel_weight=rel_weight,
762 modelObj=dipoleModel)
769 print(result.fit_report(show_correl=
False))
770 if separateNegParams:
771 print(result.ci_report())
776 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
777 bgGradientOrder=1, verbose=False, display=False):
778 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
780 Actually, fits the subimage bounded by the input source's
781 footprint) and optionally constrain the fit using the
782 pre-subtraction images self.
posImage (science)
and
783 self.
negImage (template). Wraps the output into a
784 `pipeBase.Struct` named tuple after computing additional
785 statistics such
as orientation
and SNR.
790 Record containing the (merged) dipole source footprint detected on the diffim
791 tol : `float`, optional
792 Tolerance parameter
for scipy.leastsq() optimization
793 rel_weight : `float`, optional
794 Weighting of posImage/negImage relative to the diffim
in the fit
795 fitBackground : `int`, {0, 1, 2}, optional
796 How to fit linear background gradient
in posImage/negImage
798 - 0: do
not fit background at all
799 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
800 as part of the dipole fitting optimization
801 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
802 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
803 background
as part of the overall dipole fitting optimization.
804 maxSepInSigma : `float`, optional
805 Allowed window of centroid parameters relative to peak
in input source footprint
806 separateNegParams : `bool`, optional
807 Fit separate parameters to the flux
and background gradient
in
808 bgGradientOrder : `int`, {0, 1, 2}, optional
809 Desired polynomial order of background gradient
810 verbose: `bool`, optional
813 Display input data, best fit
model(s)
and residuals
in a matplotlib window.
818 `pipeBase.Struct` object containing the fit parameters
and other information.
821 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
825 Parameter `fitBackground` has three options, thus it
is an integer:
830 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
831 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
832 bgGradientOrder=bgGradientOrder, verbose=verbose)
836 fp = source.getFootprint()
839 fitParams = fitResult.best_values
840 if fitParams[
'flux'] <= 1.:
841 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
842 negCentroidX=np.nan, negCentroidY=np.nan,
843 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
844 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
845 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
846 return out, fitResult
848 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
849 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
850 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
851 angle = np.arctan2(dy, dx) / np.pi * 180.
855 def computeSumVariance(exposure, footprint):
856 box = footprint.getBBox()
857 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
858 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
860 fluxVal = fluxVar = fitParams[
'flux']
861 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
863 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
865 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
867 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
868 if separateNegParams:
869 fluxValNeg = fitParams[
'fluxNeg']
870 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
872 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
875 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
876 except ZeroDivisionError:
877 signalToNoise = np.nan
879 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
880 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
881 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
882 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
883 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
886 return out, fitResult
889 """Display data, model fits and residuals (currently uses matplotlib display functions).
893 footprint : TODO: DM-17458
894 Footprint containing the dipole that was fit
895 result : `lmfit.MinimizerResult`
896 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
900 fig : `matplotlib.pyplot.plot`
903 import matplotlib.pyplot
as plt
904 except ImportError
as err:
905 self.
log.warning(
'Unable to import matplotlib: %s', err)
908 def display2dArray(arr, title='Data', extent=None):
909 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
911 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
913 plt.colorbar(fig, cmap=
'gray')
917 fit = result.best_fit
918 bbox = footprint.getBBox()
919 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
921 fig = plt.figure(figsize=(8, 8))
923 plt.subplot(3, 3, i*3+1)
924 display2dArray(z[i, :],
'Data', extent=extent)
925 plt.subplot(3, 3, i*3+2)
926 display2dArray(fit[i, :],
'Model', extent=extent)
927 plt.subplot(3, 3, i*3+3)
928 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
931 fig = plt.figure(figsize=(8, 2.5))
933 display2dArray(z,
'Data', extent=extent)
935 display2dArray(fit,
'Model', extent=extent)
937 display2dArray(z - fit,
'Residual', extent=extent)
943@measBase.register("ip_diffim_DipoleFit")
945 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
947 This measurement plugin accepts up to three input images in
948 its `measure` method. If these are provided, it includes data
949 from the pre-subtraction posImage (science image)
and optionally
950 negImage (template image) to constrain the fit. The meat of the
951 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
955 The motivation behind this plugin
and the necessity
for including more than
956 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
958 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
959 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
960 are deemed to be replaceable by this.
963 ConfigClass = DipoleFitPluginConfig
964 DipoleFitAlgorithmClass = DipoleFitAlgorithm
968 FAILURE_NOT_DIPOLE = 4
972 """Set execution order to `FLUX_ORDER`.
974 This includes algorithms that require both `getShape()` and `getCentroid()`,
975 in addition to a Footprint
and its Peaks.
977 return cls.FLUX_ORDER
979 def __init__(self, config, name, schema, metadata, logName=None):
982 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
984 self.
log = logging.getLogger(logName)
988 def _setupSchema(self, config, name, schema, metadata):
996 for pos_neg
in [
'pos',
'neg']:
998 key = schema.addField(
999 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
1000 doc=
"Dipole {0} lobe flux".format(pos_neg))
1001 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
1003 key = schema.addField(
1004 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1005 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1006 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1008 for x_y
in [
'x',
'y']:
1009 key = schema.addField(
1010 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1011 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1012 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1014 for x_y
in [
'x',
'y']:
1015 key = schema.addField(
1016 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1017 doc=
"Dipole centroid")
1018 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1021 schema.join(name,
"instFlux"), type=float, units=
"count",
1022 doc=
"Dipole overall flux")
1025 schema.join(name,
"orientation"), type=float, units=
"deg",
1026 doc=
"Dipole orientation")
1029 schema.join(name,
"separation"), type=float, units=
"pixel",
1030 doc=
"Pixel separation between positive and negative lobes of dipole")
1033 schema.join(name,
"chi2dof"), type=float,
1034 doc=
"Chi2 per degree of freedom of dipole fit")
1037 schema.join(name,
"signalToNoise"), type=float,
1038 doc=
"Estimated signal-to-noise of dipole fit")
1041 schema.join(name,
"flag",
"classification"), type=
"Flag",
1042 doc=
"Flag indicating diaSource is classified as a dipole")
1045 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1046 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1049 schema.join(name,
"flag"), type=
"Flag",
1050 doc=
"General failure flag for dipole fit")
1053 schema.join(name,
"flag",
"edge"), type=
"Flag",
1054 doc=
"Flag set when dipole is too close to edge of image")
1056 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1057 """Perform the non-linear least squares minimization on the putative dipole source.
1062 diaSources that will be measured using dipole measurement
1064 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1065 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1066 dipole to just the `exposure`
with no constraint.
1068 "Positive" exposure, typically a science exposure,
or None if unavailable
1069 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1071 "Negative" exposure, typically a template exposure,
or None if unavailable
1072 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1076 The main functionality of this routine was placed outside of
1077 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1078 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1079 testing (
@see `tests/testDipoleFitter.py`)
1083 result : TODO: DM-17458
1088 pks = measRecord.getFootprint().getPeaks()
1093 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1094 == np.sign(pks[-1].getPeakValue())))
1099 if not self.config.fitAllDiaSources:
1104 result, _ = alg.fitDipole(
1105 measRecord, rel_weight=self.config.relWeight,
1106 tol=self.config.tolerance,
1107 maxSepInSigma=self.config.maxSeparation,
1108 fitBackground=self.config.fitBackground,
1109 separateNegParams=self.config.fitSeparateNegParams,
1110 verbose=
False, display=
False)
1112 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
1114 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1121 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(),
str(result))
1123 if result.posFlux <= 1.:
1124 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1128 measRecord[self.posFluxKey] = result.posFlux
1129 measRecord[self.posFluxErrKey] = result.signalToNoise
1130 measRecord[self.posCentroidKeyX] = result.posCentroidX
1131 measRecord[self.posCentroidKeyY] = result.posCentroidY
1133 measRecord[self.negFluxKey] = result.negFlux
1134 measRecord[self.negFluxErrKey] = result.signalToNoise
1135 measRecord[self.negCentroidKeyX] = result.negCentroidX
1136 measRecord[self.negCentroidKeyY] = result.negCentroidY
1139 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1141 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1142 + (result.posCentroidY - result.negCentroidY)**2.)
1143 measRecord[self.centroidKeyX] = result.centroidX
1144 measRecord[self.centroidKeyY] = result.centroidY
1152 """Classify a source as a dipole.
1156 measRecord : TODO: DM-17458
1158 chi2val : TODO: DM-17458
1163 Sources are classified as dipoles,
or not, according to three criteria:
1165 1. Does the total signal-to-noise surpass the ``minSn``?
1166 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1167 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1168 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1176 passesFluxPos = (abs(measRecord[self.posFluxKey])
1177 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1178 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1179 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1180 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1181 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1182 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1190 from scipy.stats
import chi2
1192 significance = chi2.cdf(chi2val, ndof)
1193 passesChi2 = significance < self.config.maxChi2DoF
1194 allPass = allPass
and passesChi2
1203 def fail(self, measRecord, error=None):
1204 """Catch failures and set the correct flags.
1207 measRecord.set(self.flagKey, True)
1208 if error
is not None:
1210 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(),
str(error))
1213 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(),
str(error))
1214 measRecord.set(self.
flagKey,
True)
1216 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1217 measRecord.getId(),
str(error))
1219 measRecord.set(self.
flagKey,
True)
1221 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.
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 _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0)
def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None, b=None, x1=None, y1=None, xy=None, x2=None, y2=None, bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None, **kwargs)
def makeBackgroundModel(self, in_x, pars=None)
def fitFootprintBackground(self, source, posImage, order=1)
def makeStarModel(self, bbox, psf, xcen, ycen, flux)
Reports attempts to exceed implementation-defined length limits for some classes.