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
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)
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, :])
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
399 xcenPos, ycenPos : `float`
400 Desired x,y-centroid of the positive lobe of the dipole
401 xcenNeg, ycenNeg : `float`
402 Desired x,y-centroid of the negative lobe of the dipole
403 fluxNeg : `float`, optional
404 Desired flux of the negative lobe of the dipole, set to
'flux' if None
405 b, x1, y1, xy, x2, y2 : `float`
406 Gradient parameters
for positive lobe.
407 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
408 Gradient parameters
for negative lobe.
409 They are set to the corresponding positive values
if None.
411 **kwargs : `dict` [`str`]
412 Keyword arguments passed through ``lmfit``
and
413 used by this function. These must include:
415 - ``psf`` Psf model used to generate the
'star'
416 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
417 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
418 - ``bbox`` Bounding box containing region to be modelled
423 Has width
and height matching the input bbox,
and
424 contains the dipole model
with given centroids
and flux(es). If
425 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
426 those of bbox; otherwise a stack of three such arrays,
427 representing the dipole (diffim), positive,
and negative images
431 psf = kwargs.get('psf')
432 rel_weight = kwargs.get(
'rel_weight')
433 fp = kwargs.get(
'footprint')
440 self.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
441 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
443 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
445 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
447 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
448 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
452 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
453 in_x = np.array([x, y]) * 1.
454 in_x[0, :] -= in_x[0, :].mean()
455 in_x[1, :] -= in_x[1, :].mean()
464 gradientNeg = gradient
466 posIm.getArray()[:, :] += gradient
467 negIm.getArray()[:, :] += gradientNeg
470 diffIm = afwImage.ImageF(bbox)
474 zout = diffIm.getArray()
476 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
482 """Fit a dipole model using an image difference.
485 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
490 _private_version_ =
'0.0.5'
507 def __init__(self, diffim, posImage=None, negImage=None):
508 """Algorithm to run dipole measurement on a diaSource
513 Exposure on which the diaSources were detected
515 "Positive" exposure
from which the template was subtracted
517 "Negative" exposure which was subtracted
from the posImage
524 if diffim
is not None:
525 diffimPsf = diffim.getPsf()
526 diffimAvgPos = diffimPsf.getAveragePosition()
527 self.
psfSigma = diffimPsf.computeShape(diffimAvgPos).getDeterminantRadius()
529 self.
log = logging.getLogger(__name__)
535 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
536 separateNegParams=True, verbose=False):
537 """Fit a dipole model to an input difference image.
539 Actually, fits the subimage bounded by the input source's
540 footprint) and optionally constrain the fit using the
541 pre-subtraction images posImage
and negImage.
545 source : TODO: DM-17458
547 tol : float, optional
549 rel_weight : `float`, optional
551 fitBackground : `int`, optional
553 bgGradientOrder : `int`, optional
555 maxSepInSigma : `float`, optional
557 separateNegParams : `bool`, optional
559 verbose : `bool`, optional
564 result : `lmfit.MinimizerResult`
565 return `lmfit.MinimizerResult` object containing the fit
566 parameters
and other information.
572 fp = source.getFootprint()
574 subim = afwImage.MaskedImageF(self.
diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
576 z = diArr = subim.getArrays()[0]
577 weights = 1. / subim.getArrays()[2]
579 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
581 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
583 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
585 posSubim = subim.clone()
588 negSubim = posSubim.clone()
591 z = np.append([z], [posSubim.getArrays()[0],
592 negSubim.getArrays()[0]], axis=0)
594 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
595 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
602 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
603 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
604 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
606 """Generate dipole model with given parameters.
608 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
609 out of `kwargs['modelObj']`.
611 modelObj = kwargs.pop('modelObj')
612 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
613 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
614 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
615 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
619 modelFunctor = dipoleModelFunctor
622 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
626 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
627 cenNeg = cenPos = fpCentroid
632 cenPos = pks[0].getF()
634 cenNeg = pks[-1].getF()
638 maxSep = self.
psfSigma * maxSepInSigma
641 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
643 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
649 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
650 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
651 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
652 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
653 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
654 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
655 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
656 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
660 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
661 posFlux = negFlux = startingFlux
664 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
666 if separateNegParams:
668 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
676 bgParsPos = bgParsNeg = (0., 0., 0.)
677 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
681 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
682 order=bgGradientOrder)
685 if fitBackground == 1:
686 in_x = dipoleModel._generateXYGrid(bbox)
687 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
689 z[1, :] -= np.nanmedian(z[1, :])
690 posFlux = np.nansum(z[1, :])
691 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
693 if separateNegParams
and self.
negImage is not None:
694 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
695 order=bgGradientOrder)
696 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
698 z[2, :] -= np.nanmedian(z[2, :])
699 if separateNegParams:
700 negFlux = np.nansum(z[2, :])
701 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
704 if fitBackground == 2:
705 if bgGradientOrder >= 0:
706 gmod.set_param_hint(
'b', value=bgParsPos[0])
707 if separateNegParams:
708 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
709 if bgGradientOrder >= 1:
710 gmod.set_param_hint(
'x1', value=bgParsPos[1])
711 gmod.set_param_hint(
'y1', value=bgParsPos[2])
712 if separateNegParams:
713 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
714 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
715 if bgGradientOrder >= 2:
716 gmod.set_param_hint(
'xy', value=bgParsPos[3])
717 gmod.set_param_hint(
'x2', value=bgParsPos[4])
718 gmod.set_param_hint(
'y2', value=bgParsPos[5])
719 if separateNegParams:
720 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
721 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
722 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
724 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
725 in_x = np.array([x, y]).astype(np.float64)
726 in_x[0, :] -= in_x[0, :].mean()
727 in_x[1, :] -= in_x[1, :].mean()
731 mask = np.ones_like(z, dtype=bool)
736 weights = mask.astype(np.float64)
737 if self.
posImage is not None and rel_weight > 0.:
738 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
739 np.ones_like(diArr)*rel_weight])
748 with warnings.catch_warnings():
751 warnings.filterwarnings(
"ignore",
"The keyword argument .* does not match", UserWarning)
752 result = gmod.fit(z, weights=weights, x=in_x, max_nfev=250,
756 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)
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))
1113 except Exception
as e:
1114 self.
fail(measRecord, measBase.MeasurementError(
'Exception in dipole fit', self.
FAILURE_FIT))
1115 self.
log.error(
"Exception in dipole fit. %s: %s", e.__class__.__name__, e)
1122 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1124 if result.posFlux <= 1.:
1125 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1129 measRecord[self.posFluxKey] = result.posFlux
1130 measRecord[self.posFluxErrKey] = result.signalToNoise
1131 measRecord[self.posCentroidKeyX] = result.posCentroidX
1132 measRecord[self.posCentroidKeyY] = result.posCentroidY
1134 measRecord[self.negFluxKey] = result.negFlux
1135 measRecord[self.negFluxErrKey] = result.signalToNoise
1136 measRecord[self.negCentroidKeyX] = result.negCentroidX
1137 measRecord[self.negCentroidKeyY] = result.negCentroidY
1140 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1142 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1143 + (result.posCentroidY - result.negCentroidY)**2.)
1144 measRecord[self.centroidKeyX] = result.centroidX
1145 measRecord[self.centroidKeyY] = result.centroidY
1153 """Classify a source as a dipole.
1157 measRecord : TODO: DM-17458
1159 chi2val : TODO: DM-17458
1164 Sources are classified as dipoles,
or not, according to three criteria:
1166 1. Does the total signal-to-noise surpass the ``minSn``?
1167 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1168 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1169 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1177 passesFluxPos = (abs(measRecord[self.posFluxKey])
1178 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1179 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1180 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1181 / (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1182 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1183 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1191 from scipy.stats
import chi2
1193 significance = chi2.cdf(chi2val, ndof)
1194 passesChi2 = significance < self.config.maxChi2DoF
1195 allPass = allPass
and passesChi2
1204 def fail(self, measRecord, error=None):
1205 """Catch failures and set the correct flags.
1208 measRecord.set(self.flagKey, True)
1209 if error
is not None:
1211 self.
log.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1214 self.
log.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1215 measRecord.set(self.
flagKey,
True)
1217 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1218 measRecord.getId(), str(error))
1220 measRecord.set(self.
flagKey,
True)
1222 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)
DipoleFitAlgorithm DipoleFitAlgorithmClass
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 _generateXYGrid(self, bbox)
def makeStarModel(self, bbox, psf, xcen, ycen, flux)