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.array, 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.image, bbox, afwImage.PARENT)
320 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
323 isBg = np.isnan(posFpImg.array).ravel()
325 data = posImg.array.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.array)
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.array[:, :] += gradient
472 negIm.array[:, :] += gradientNeg
475 diffIm = afwImage.ImageF(bbox)
481 zout = np.append([zout], [posIm.array, negIm.array], 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.image.array
582 weights = 1. / subim.variance.array
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.image.array,
597 negSubim.image.array], axis=0)
599 weights = np.append([weights], [1. / posSubim.variance.array * rel_weight,
600 1. / negSubim.variance.array * 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 return np.sqrt(np.nansum(exposure[footprint.getBBox(), afwImage.PARENT].variance.array))
863 fluxVal = fluxVar = fitParams[
'flux']
864 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
866 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
868 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
870 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
871 if separateNegParams:
872 fluxValNeg = fitParams[
'fluxNeg']
873 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
875 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
878 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
879 except ZeroDivisionError:
880 signalToNoise = np.nan
882 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
883 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
884 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
885 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
886 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
889 return out, fitResult
892 """Display data, model fits and residuals (currently uses matplotlib display functions).
896 footprint : TODO: DM-17458
897 Footprint containing the dipole that was fit
898 result : `lmfit.MinimizerResult`
899 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
903 fig : `matplotlib.pyplot.plot`
906 import matplotlib.pyplot
as plt
907 except ImportError
as err:
908 self.
log.warning(
'Unable to import matplotlib: %s', err)
911 def display2dArray(arr, title='Data', extent=None):
912 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
914 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
916 plt.colorbar(fig, cmap=
'gray')
920 fit = result.best_fit
921 bbox = footprint.getBBox()
922 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
924 fig = plt.figure(figsize=(8, 8))
926 plt.subplot(3, 3, i*3+1)
927 display2dArray(z[i, :],
'Data', extent=extent)
928 plt.subplot(3, 3, i*3+2)
929 display2dArray(fit[i, :],
'Model', extent=extent)
930 plt.subplot(3, 3, i*3+3)
931 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
934 fig = plt.figure(figsize=(8, 2.5))
936 display2dArray(z,
'Data', extent=extent)
938 display2dArray(fit,
'Model', extent=extent)
940 display2dArray(z - fit,
'Residual', extent=extent)
946@measBase.register("ip_diffim_DipoleFit")
948 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
950 This measurement plugin accepts up to three input images in
951 its `measure` method. If these are provided, it includes data
952 from the pre-subtraction posImage (science image)
and optionally
953 negImage (template image) to constrain the fit. The meat of the
954 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
958 The motivation behind this plugin
and the necessity
for including more than
959 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
961 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
962 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
963 are deemed to be replaceable by this.
966 ConfigClass = DipoleFitPluginConfig
967 DipoleFitAlgorithmClass = DipoleFitAlgorithm
971 FAILURE_NOT_DIPOLE = 4
975 """Set execution order to `FLUX_ORDER`.
977 This includes algorithms that require both `getShape()` and `getCentroid()`,
978 in addition to a Footprint
and its Peaks.
980 return cls.FLUX_ORDER
982 def __init__(self, config, name, schema, metadata, logName=None):
985 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
987 self.
log = logging.getLogger(logName)
991 def _setupSchema(self, config, name, schema, metadata):
999 for pos_neg
in [
'pos',
'neg']:
1001 key = schema.addField(
1002 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
1003 doc=
"Dipole {0} lobe flux".format(pos_neg))
1004 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
1006 key = schema.addField(
1007 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1008 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1009 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1011 for x_y
in [
'x',
'y']:
1012 key = schema.addField(
1013 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1014 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1015 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1017 for x_y
in [
'x',
'y']:
1018 key = schema.addField(
1019 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1020 doc=
"Dipole centroid")
1021 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1024 schema.join(name,
"instFlux"), type=float, units=
"count",
1025 doc=
"Dipole overall flux")
1028 schema.join(name,
"orientation"), type=float, units=
"deg",
1029 doc=
"Dipole orientation")
1032 schema.join(name,
"separation"), type=float, units=
"pixel",
1033 doc=
"Pixel separation between positive and negative lobes of dipole")
1036 schema.join(name,
"chi2dof"), type=float,
1037 doc=
"Chi2 per degree of freedom of dipole fit")
1040 schema.join(name,
"signalToNoise"), type=float,
1041 doc=
"Estimated signal-to-noise of dipole fit")
1044 schema.join(name,
"flag",
"classification"), type=
"Flag",
1045 doc=
"Flag indicating diaSource is classified as a dipole")
1048 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1049 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1052 schema.join(name,
"flag"), type=
"Flag",
1053 doc=
"General failure flag for dipole fit")
1056 schema.join(name,
"flag",
"edge"), type=
"Flag",
1057 doc=
"Flag set when dipole is too close to edge of image")
1059 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1060 """Perform the non-linear least squares minimization on the putative dipole source.
1065 diaSources that will be measured using dipole measurement
1067 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1068 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1069 dipole to just the `exposure`
with no constraint.
1071 "Positive" exposure, typically a science exposure,
or None if unavailable
1072 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1074 "Negative" exposure, typically a template exposure,
or None if unavailable
1075 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1079 The main functionality of this routine was placed outside of
1080 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1081 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1082 testing (
@see `tests/testDipoleFitter.py`)
1086 result : TODO: DM-17458
1091 pks = measRecord.getFootprint().getPeaks()
1098 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1099 == np.sign(pks[-1].getPeakValue())))
1101 or (measRecord.getFootprint().getArea() > self.config.maxFootprintArea)
1106 if not self.config.fitAllDiaSources:
1111 result, _ = alg.fitDipole(
1112 measRecord, rel_weight=self.config.relWeight,
1113 tol=self.config.tolerance,
1114 maxSepInSigma=self.config.maxSeparation,
1115 fitBackground=self.config.fitBackground,
1116 separateNegParams=self.config.fitSeparateNegParams,
1117 verbose=
False, display=
False)
1119 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
1120 except Exception
as e:
1121 self.
fail(measRecord, measBase.MeasurementError(
'Exception in dipole fit', self.
FAILURE_FIT))
1122 self.
log.error(
"Exception in dipole fit. %s: %s", e.__class__.__name__, e)
1129 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(),
str(result))
1131 if result.posFlux <= 1.:
1132 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1136 measRecord[self.posFluxKey] = result.posFlux
1137 measRecord[self.posFluxErrKey] = result.signalToNoise
1138 measRecord[self.posCentroidKeyX] = result.posCentroidX
1139 measRecord[self.posCentroidKeyY] = result.posCentroidY
1141 measRecord[self.negFluxKey] = result.negFlux
1142 measRecord[self.negFluxErrKey] = result.signalToNoise
1143 measRecord[self.negCentroidKeyX] = result.negCentroidX
1144 measRecord[self.negCentroidKeyY] = result.negCentroidY
1147 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1149 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1150 + (result.posCentroidY - result.negCentroidY)**2.)
1151 measRecord[self.centroidKeyX] = result.centroidX
1152 measRecord[self.centroidKeyY] = result.centroidY
1160 """Classify a source as a dipole.
1164 measRecord : TODO: DM-17458
1166 chi2val : TODO: DM-17458
1171 Sources are classified as dipoles,
or not, according to three criteria:
1173 1. Does the total signal-to-noise surpass the ``minSn``?
1174 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1175 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1176 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1184 passesFluxPos = (abs(measRecord[self.posFluxKey])
1185 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1186 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1187 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1188 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1189 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1190 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1198 from scipy.stats
import chi2
1200 significance = chi2.cdf(chi2val, ndof)
1201 passesChi2 = significance < self.config.maxChi2DoF
1202 allPass = allPass
and passesChi2
1211 def fail(self, measRecord, error=None):
1212 """Catch failures and set the correct flags.
1215 measRecord.set(self.flagKey, True)
1216 if error
is not None:
1218 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(),
str(error))
1221 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(),
str(error))
1222 measRecord.set(self.
flagKey,
True)
1224 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1225 measRecord.getId(),
str(error))
1227 measRecord.set(self.
flagKey,
True)
1229 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)