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=5_000,
97 doc=("Maximum area for footprints before they are ignored as large; "
98 "non-positive means no threshold applied"))
102 """Measurement of detected diaSources as dipoles
104 Currently we keep the "old" DipoleMeasurement algorithms turned on.
108 measBase.SingleFrameMeasurementConfig.setDefaults(self)
110 self.plugins.names = [
"base_CircularApertureFlux",
117 "base_PeakLikelihoodFlux",
119 "base_NaiveCentroid",
120 "ip_diffim_NaiveDipoleCentroid",
121 "ip_diffim_NaiveDipoleFlux",
122 "ip_diffim_PsfDipoleFlux",
123 "ip_diffim_ClassificationDipole",
126 self.slots.calibFlux =
None
127 self.slots.modelFlux =
None
128 self.slots.gaussianFlux =
None
129 self.slots.shape =
"base_SdssShape"
130 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
135 """A task that fits a dipole to a difference image, with an optional separate detection image.
137 Because it subclasses SingleFrameMeasurementTask, and calls
138 SingleFrameMeasurementTask.run()
from its
run() method, it still
139 can be used identically to a standard SingleFrameMeasurementTask.
142 ConfigClass = DipoleFitTaskConfig
143 _DefaultName = "ip_diffim_DipoleFit"
145 def __init__(self, schema, algMetadata=None, **kwargs):
147 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
149 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
152 schema=schema, metadata=algMetadata,
153 logName=self.log.name)
156 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
157 """Run dipole measurement and classification
162 ``diaSources`` that will be measured using dipole measurement
164 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
165 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
166 fitted directly to this difference image.
168 "Positive" exposure, typically a science exposure,
or None if unavailable
169 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
171 "Negative" exposure, typically a template exposure,
or None if unavailable
172 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
177 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
182 for source
in sources:
183 self.
dipoleFitter.measure(source, exposure, posExp, negExp)
187 """Lightweight class containing methods for generating a dipole model for fitting
188 to sources in diffims, used by DipoleFitAlgorithm.
191 `DMTN-007: Dipole characterization
for image differencing <https://dmtn-007.lsst.io>`_.
197 self.
log = logging.getLogger(__name__)
200 """Generate gradient model (2-d array) with up to 2nd-order polynomial
205 (2, w, h)-dimensional `numpy.array`, containing the
206 input x,y meshgrid providing the coordinates upon which to
207 compute the gradient. This will typically be generated via
208 `_generateXYGrid()`. `w` and `h` correspond to the width
and
209 height of the desired grid.
210 pars : `list` of `float`, optional
211 Up to 6 floats
for up
212 to 6 2nd-order 2-d polynomial gradient parameters,
in the
213 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
214 is emtpy
or `
None`, do nothing
and return `
None` (
for speed).
218 result : `
None`
or `numpy.array`
219 return None,
or 2-d numpy.array of width/height matching
220 input bbox, containing computed gradient values.
224 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
227 y, x = in_x[0, :], in_x[1, :]
228 gradient = np.full_like(x, pars[0], dtype=
'float64')
229 if len(pars) > 1
and pars[1]
is not None:
230 gradient += pars[1] * x
231 if len(pars) > 2
and pars[2]
is not None:
232 gradient += pars[2] * y
233 if len(pars) > 3
and pars[3]
is not None:
234 gradient += pars[3] * (x * y)
235 if len(pars) > 4
and pars[4]
is not None:
236 gradient += pars[4] * (x * x)
237 if len(pars) > 5
and pars[5]
is not None:
238 gradient += pars[5] * (y * y)
242 def _generateXYGrid(self, bbox):
243 """Generate a meshgrid covering the x,y coordinates bounded by bbox
248 input Bounding Box defining the coordinate limits
253 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
257 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
258 in_x = np.array([y, x]).astype(np.float64)
259 in_x[0, :] -= np.mean(in_x[0, :])
260 in_x[1, :] -= np.mean(in_x[1, :])
263 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
264 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
265 as an `lsst.afw.image.ImageF`.
270 HeavyFootprint to use to generate the subimage
271 badfill : `float`, optional
272 Value to fill
in pixels
in extracted image that are outside the footprint
274 Optionally grow the footprint by this amount before extraction
278 subim2 : `lsst.afw.image.ImageF`
279 An `~lsst.afw.image.ImageF` containing the subimage.
285 subim2 = afwImage.ImageF(bbox, badfill)
286 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
290 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
292 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
297 SourceRecord, the footprint of which is to be fit
299 The exposure
from which to extract the footprint subimage
301 Polynomial order of background gradient to fit.
305 pars : `tuple` of `float`
306 `tuple` of length (1
if order==0; 3
if order==1; 6
if order == 2),
307 containing the resulting fit parameters
312 fp = source.getFootprint()
315 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
320 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
323 isBg = np.isnan(posFpImg.getArray()).ravel()
325 data = posImg.getArray().ravel()
329 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
330 x = x.astype(np.float64).ravel()
333 y = y.astype(np.float64).ravel()
336 b = np.ones_like(x, dtype=np.float64)
340 M = np.vstack([b, x, y]).T
342 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
344 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
348 """Generate a 2D image model of a single PDF centered at the given coordinates.
352 bbox : `lsst.geom.Box`
353 Bounding box marking pixel coordinates for generated model
355 Psf model used to generate the
'star'
357 Desired x-centroid of the
'star'
359 Desired y-centroid of the
'star'
361 Desired flux of the
'star'
366 2-d stellar image of width/height matching input ``bbox``,
367 containing PSF
with given centroid
and flux
371 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
372 psf_img_sum = np.nansum(psf_img.getArray())
373 psf_img *= (flux/psf_img_sum)
376 psf_box = psf_img.getBBox()
378 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
384 p_Im = afwImage.ImageF(bbox)
385 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
390 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
391 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
392 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
394 """Generate dipole model with given parameters.
396 This is the function whose sum-of-squared difference
from data
397 is minimized by `lmfit`.
400 Input independent variable. Used here
as the grid on
401 which to compute the background gradient model.
403 Desired flux of the positive lobe of the dipole
404 xcenPos, ycenPos : `float`
405 Desired x,y-centroid of the positive lobe of the dipole
406 xcenNeg, ycenNeg : `float`
407 Desired x,y-centroid of the negative lobe of the dipole
408 fluxNeg : `float`, optional
409 Desired flux of the negative lobe of the dipole, set to
'flux' if None
410 b, x1, y1, xy, x2, y2 : `float`
411 Gradient parameters
for positive lobe.
412 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
413 Gradient parameters
for negative lobe.
414 They are set to the corresponding positive values
if None.
416 **kwargs : `dict` [`str`]
417 Keyword arguments passed through ``lmfit``
and
418 used by this function. These must include:
420 - ``psf`` Psf model used to generate the
'star'
421 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
422 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
423 - ``bbox`` Bounding box containing region to be modelled
428 Has width
and height matching the input bbox,
and
429 contains the dipole model
with given centroids
and flux(es). If
430 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
431 those of bbox; otherwise a stack of three such arrays,
432 representing the dipole (diffim), positive,
and negative images
436 psf = kwargs.get('psf')
437 rel_weight = kwargs.get(
'rel_weight')
438 fp = kwargs.get(
'footprint')
445 self.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
446 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
448 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
450 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
452 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
453 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
457 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
458 in_x = np.array([x, y]) * 1.
459 in_x[0, :] -= in_x[0, :].mean()
460 in_x[1, :] -= in_x[1, :].mean()
469 gradientNeg = gradient
471 posIm.getArray()[:, :] += gradient
472 negIm.getArray()[:, :] += gradientNeg
475 diffIm = afwImage.ImageF(bbox)
479 zout = diffIm.getArray()
481 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
487 """Fit a dipole model using an image difference.
490 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
495 _private_version_ =
'0.0.5'
512 def __init__(self, diffim, posImage=None, negImage=None):
513 """Algorithm to run dipole measurement on a diaSource
518 Exposure on which the diaSources were detected
520 "Positive" exposure
from which the template was subtracted
522 "Negative" exposure which was subtracted
from the posImage
529 if diffim
is not None:
530 diffimPsf = diffim.getPsf()
531 diffimAvgPos = diffimPsf.getAveragePosition()
532 self.
psfSigma = diffimPsf.computeShape(diffimAvgPos).getDeterminantRadius()
534 self.
log = logging.getLogger(__name__)
540 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
541 separateNegParams=True, verbose=False):
542 """Fit a dipole model to an input difference image.
544 Actually, fits the subimage bounded by the input source's
545 footprint) and optionally constrain the fit using the
546 pre-subtraction images posImage
and negImage.
550 source : TODO: DM-17458
552 tol : float, optional
554 rel_weight : `float`, optional
556 fitBackground : `int`, optional
558 bgGradientOrder : `int`, optional
560 maxSepInSigma : `float`, optional
562 separateNegParams : `bool`, optional
564 verbose : `bool`, optional
569 result : `lmfit.MinimizerResult`
570 return `lmfit.MinimizerResult` object containing the fit
571 parameters
and other information.
577 fp = source.getFootprint()
579 subim = afwImage.MaskedImageF(self.
diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
581 z = diArr = subim.getArrays()[0]
582 weights = 1. / subim.getArrays()[2]
584 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
586 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
588 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
590 posSubim = subim.clone()
593 negSubim = posSubim.clone()
596 z = np.append([z], [posSubim.getArrays()[0],
597 negSubim.getArrays()[0]], axis=0)
599 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
600 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
607 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
608 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
609 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
611 """Generate dipole model with given parameters.
613 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
614 out of `kwargs['modelObj']`.
616 modelObj = kwargs.pop('modelObj')
617 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
618 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
619 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
620 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
624 modelFunctor = dipoleModelFunctor
627 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():
756 warnings.filterwarnings(
"ignore",
"The keyword argument .* does not match", UserWarning)
757 result = gmod.fit(z, weights=weights, x=in_x, max_nfev=250,
761 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
765 rel_weight=rel_weight,
767 modelObj=dipoleModel)
774 print(result.fit_report(show_correl=
False))
775 if separateNegParams:
776 print(result.ci_report())
781 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
782 bgGradientOrder=1, verbose=False, display=False):
783 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
785 Actually, fits the subimage bounded by the input source's
786 footprint) and optionally constrain the fit using the
787 pre-subtraction images self.
posImage (science)
and
788 self.
negImage (template). Wraps the output into a
789 `pipeBase.Struct` named tuple after computing additional
790 statistics such
as orientation
and SNR.
795 Record containing the (merged) dipole source footprint detected on the diffim
796 tol : `float`, optional
797 Tolerance parameter
for scipy.leastsq() optimization
798 rel_weight : `float`, optional
799 Weighting of posImage/negImage relative to the diffim
in the fit
800 fitBackground : `int`, {0, 1, 2}, optional
801 How to fit linear background gradient
in posImage/negImage
803 - 0: do
not fit background at all
804 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
805 as part of the dipole fitting optimization
806 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
807 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
808 background
as part of the overall dipole fitting optimization.
809 maxSepInSigma : `float`, optional
810 Allowed window of centroid parameters relative to peak
in input source footprint
811 separateNegParams : `bool`, optional
812 Fit separate parameters to the flux
and background gradient
in
813 bgGradientOrder : `int`, {0, 1, 2}, optional
814 Desired polynomial order of background gradient
815 verbose: `bool`, optional
818 Display input data, best fit model(s)
and residuals
in a matplotlib window.
823 `pipeBase.Struct` object containing the fit parameters
and other information.
826 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
830 Parameter `fitBackground` has three options, thus it
is an integer:
835 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
836 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
837 bgGradientOrder=bgGradientOrder, verbose=verbose)
841 fp = source.getFootprint()
844 fitParams = fitResult.best_values
845 if fitParams[
'flux'] <= 1.:
846 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
847 negCentroidX=np.nan, negCentroidY=np.nan,
848 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
849 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
850 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
851 return out, fitResult
853 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
854 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
855 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
856 angle = np.arctan2(dy, dx) / np.pi * 180.
860 def computeSumVariance(exposure, footprint):
861 box = footprint.getBBox()
862 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
863 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
865 fluxVal = fluxVar = fitParams[
'flux']
866 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
868 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
870 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
872 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
873 if separateNegParams:
874 fluxValNeg = fitParams[
'fluxNeg']
875 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
877 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
880 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
881 except ZeroDivisionError:
882 signalToNoise = np.nan
884 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
885 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
886 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
887 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
888 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
891 return out, fitResult
894 """Display data, model fits and residuals (currently uses matplotlib display functions).
898 footprint : TODO: DM-17458
899 Footprint containing the dipole that was fit
900 result : `lmfit.MinimizerResult`
901 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
905 fig : `matplotlib.pyplot.plot`
908 import matplotlib.pyplot
as plt
909 except ImportError
as err:
910 self.
log.warning(
'Unable to import matplotlib: %s', err)
913 def display2dArray(arr, title='Data', extent=None):
914 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
916 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
918 plt.colorbar(fig, cmap=
'gray')
922 fit = result.best_fit
923 bbox = footprint.getBBox()
924 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
926 fig = plt.figure(figsize=(8, 8))
928 plt.subplot(3, 3, i*3+1)
929 display2dArray(z[i, :],
'Data', extent=extent)
930 plt.subplot(3, 3, i*3+2)
931 display2dArray(fit[i, :],
'Model', extent=extent)
932 plt.subplot(3, 3, i*3+3)
933 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
936 fig = plt.figure(figsize=(8, 2.5))
938 display2dArray(z,
'Data', extent=extent)
940 display2dArray(fit,
'Model', extent=extent)
942 display2dArray(z - fit,
'Residual', extent=extent)
948@measBase.register("ip_diffim_DipoleFit")
950 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
952 This measurement plugin accepts up to three input images in
953 its `measure` method. If these are provided, it includes data
954 from the pre-subtraction posImage (science image)
and optionally
955 negImage (template image) to constrain the fit. The meat of the
956 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
960 The motivation behind this plugin
and the necessity
for including more than
961 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
963 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
964 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
965 are deemed to be replaceable by this.
968 ConfigClass = DipoleFitPluginConfig
969 DipoleFitAlgorithmClass = DipoleFitAlgorithm
973 FAILURE_NOT_DIPOLE = 4
977 """Set execution order to `FLUX_ORDER`.
979 This includes algorithms that require both `getShape()` and `getCentroid()`,
980 in addition to a Footprint
and its Peaks.
982 return cls.FLUX_ORDER
984 def __init__(self, config, name, schema, metadata, logName=None):
987 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
989 self.
log = logging.getLogger(logName)
993 def _setupSchema(self, config, name, schema, metadata):
1001 for pos_neg
in [
'pos',
'neg']:
1003 key = schema.addField(
1004 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
1005 doc=
"Dipole {0} lobe flux".format(pos_neg))
1006 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
1008 key = schema.addField(
1009 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1010 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1011 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1013 for x_y
in [
'x',
'y']:
1014 key = schema.addField(
1015 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1016 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1017 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1019 for x_y
in [
'x',
'y']:
1020 key = schema.addField(
1021 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1022 doc=
"Dipole centroid")
1023 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1026 schema.join(name,
"instFlux"), type=float, units=
"count",
1027 doc=
"Dipole overall flux")
1030 schema.join(name,
"orientation"), type=float, units=
"deg",
1031 doc=
"Dipole orientation")
1034 schema.join(name,
"separation"), type=float, units=
"pixel",
1035 doc=
"Pixel separation between positive and negative lobes of dipole")
1038 schema.join(name,
"chi2dof"), type=float,
1039 doc=
"Chi2 per degree of freedom of dipole fit")
1042 schema.join(name,
"signalToNoise"), type=float,
1043 doc=
"Estimated signal-to-noise of dipole fit")
1046 schema.join(name,
"flag",
"classification"), type=
"Flag",
1047 doc=
"Flag indicating diaSource is classified as a dipole")
1050 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1051 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1054 schema.join(name,
"flag"), type=
"Flag",
1055 doc=
"General failure flag for dipole fit")
1058 schema.join(name,
"flag",
"edge"), type=
"Flag",
1059 doc=
"Flag set when dipole is too close to edge of image")
1061 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1062 """Perform the non-linear least squares minimization on the putative dipole source.
1067 diaSources that will be measured using dipole measurement
1069 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1070 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1071 dipole to just the `exposure`
with no constraint.
1073 "Positive" exposure, typically a science exposure,
or None if unavailable
1074 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1076 "Negative" exposure, typically a template exposure,
or None if unavailable
1077 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1081 The main functionality of this routine was placed outside of
1082 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1083 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1084 testing (
@see `tests/testDipoleFitter.py`)
1088 result : TODO: DM-17458
1093 pks = measRecord.getFootprint().getPeaks()
1100 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1101 == np.sign(pks[-1].getPeakValue())))
1103 or (measRecord.getFootprint().getArea() > self.config.maxFootprintArea)
1108 if not self.config.fitAllDiaSources:
1113 result, _ = alg.fitDipole(
1114 measRecord, rel_weight=self.config.relWeight,
1115 tol=self.config.tolerance,
1116 maxSepInSigma=self.config.maxSeparation,
1117 fitBackground=self.config.fitBackground,
1118 separateNegParams=self.config.fitSeparateNegParams,
1119 verbose=
False, display=
False)
1121 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
1122 except Exception
as e:
1123 self.
fail(measRecord, measBase.MeasurementError(
'Exception in dipole fit', self.
FAILURE_FIT))
1124 self.
log.error(
"Exception in dipole fit. %s: %s", e.__class__.__name__, e)
1131 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(),
str(result))
1133 if result.posFlux <= 1.:
1134 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1138 measRecord[self.posFluxKey] = result.posFlux
1139 measRecord[self.posFluxErrKey] = result.signalToNoise
1140 measRecord[self.posCentroidKeyX] = result.posCentroidX
1141 measRecord[self.posCentroidKeyY] = result.posCentroidY
1143 measRecord[self.negFluxKey] = result.negFlux
1144 measRecord[self.negFluxErrKey] = result.signalToNoise
1145 measRecord[self.negCentroidKeyX] = result.negCentroidX
1146 measRecord[self.negCentroidKeyY] = result.negCentroidY
1149 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1151 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1152 + (result.posCentroidY - result.negCentroidY)**2.)
1153 measRecord[self.centroidKeyX] = result.centroidX
1154 measRecord[self.centroidKeyY] = result.centroidY
1162 """Classify a source as a dipole.
1166 measRecord : TODO: DM-17458
1168 chi2val : TODO: DM-17458
1173 Sources are classified as dipoles,
or not, according to three criteria:
1175 1. Does the total signal-to-noise surpass the ``minSn``?
1176 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1177 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1178 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1186 passesFluxPos = (abs(measRecord[self.posFluxKey])
1187 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1188 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1189 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1190 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1191 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1192 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1200 from scipy.stats
import chi2
1202 significance = chi2.cdf(chi2val, ndof)
1203 passesChi2 = significance < self.config.maxChi2DoF
1204 allPass = allPass
and passesChi2
1213 def fail(self, measRecord, error=None):
1214 """Catch failures and set the correct flags.
1217 measRecord.set(self.flagKey, True)
1218 if error
is not None:
1220 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(),
str(error))
1223 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(),
str(error))
1224 measRecord.set(self.
flagKey,
True)
1226 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1227 measRecord.getId(),
str(error))
1229 measRecord.set(self.
flagKey,
True)
1231 self.
log.warning(
'DipoleFitPlugin failed on record %d', measRecord.getId())
def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
def fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
def displayFitResults(self, footprint, result)
def __init__(self, diffim, posImage=None, negImage=None)
def fail(self, measRecord, error=None)
def getExecutionOrder(cls)
def measure(self, measRecord, exposure, posExp=None, negExp=None)
def doClassify(self, measRecord, chi2val)
classificationAttemptedFlagKey
def _setupSchema(self, config, name, schema, metadata)
def __init__(self, config, name, schema, metadata, logName=None)
def __init__(self, schema, algMetadata=None, **kwargs)
def run(self, sources, exposure, posExp=None, negExp=None, **kwargs)
def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0)
def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None, b=None, x1=None, y1=None, xy=None, x2=None, y2=None, bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None, **kwargs)
def makeBackgroundModel(self, in_x, pars=None)
def fitFootprintBackground(self, source, posImage, order=1)
def makeStarModel(self, bbox, psf, xcen, ycen, flux)