34from lsst.pipe.base
import Struct
35from lsst.utils.timer
import timeMethod
37__all__ = (
"DipoleFitTask",
"DipoleFitPlugin",
"DipoleFitTaskConfig",
"DipoleFitPluginConfig",
46 """Configuration for DipoleFitPlugin
49 fitAllDiaSources = pexConfig.Field(
50 dtype=float, default=False,
51 doc=
"""Attempte dipole fit of all diaSources (otherwise just the ones consisting of overlapping
52 positive and negative footprints)
""")
54 maxSeparation = pexConfig.Field(
55 dtype=float, default=5.,
56 doc="Assume dipole is not separated by more than maxSeparation * psfSigma")
58 relWeight = pexConfig.Field(
59 dtype=float, default=0.5,
60 doc=
"""Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub.
63 tolerance = pexConfig.Field(
64 dtype=float, default=1e-7,
67 fitBackground = pexConfig.Field(
69 doc=
"Set whether and how to fit for linear gradient in pre-sub. images. Possible values:"
70 "0: do not fit background at all"
71 "1 (default): pre-fit the background using linear least squares and then do not fit it as part"
72 "of the dipole fitting optimization"
73 "2: pre-fit the background using linear least squares (as in 1), and use the parameter"
74 "estimates from that fit as starting parameters for an integrated re-fit of the background")
76 fitSeparateNegParams = pexConfig.Field(
77 dtype=bool, default=
False,
78 doc=
"Include parameters to fit for negative values (flux, gradient) separately from pos.")
81 minSn = pexConfig.Field(
82 dtype=float, default=np.sqrt(2) * 5.0,
83 doc=
"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole")
85 maxFluxRatio = pexConfig.Field(
86 dtype=float, default=0.65,
87 doc=
"Maximum flux ratio in either lobe to be considered a dipole")
89 maxChi2DoF = pexConfig.Field(
90 dtype=float, default=0.05,
91 doc=
"""Maximum Chi2/DoF significance of fit to be considered a dipole.
92 Default value means \"Choose a chi2DoF corresponding to a significance level of at most 0.05\"
93 (note this is actually a significance,
not a chi2 value).
""")
96class DipoleFitTaskConfig(measBase.SingleFrameMeasurementConfig):
97 """Measurement of detected diaSources as dipoles
99 Currently we keep the "old" DipoleMeasurement algorithms turned on.
103 measBase.SingleFrameMeasurementConfig.setDefaults(self)
105 self.plugins.names = [
"base_CircularApertureFlux",
112 "base_PeakLikelihoodFlux",
114 "base_NaiveCentroid",
115 "ip_diffim_NaiveDipoleCentroid",
116 "ip_diffim_NaiveDipoleFlux",
117 "ip_diffim_PsfDipoleFlux",
118 "ip_diffim_ClassificationDipole",
121 self.slots.calibFlux =
None
122 self.slots.modelFlux =
None
123 self.slots.gaussianFlux =
None
124 self.slots.shape =
"base_SdssShape"
125 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid"
130 """A task that fits a dipole to a difference image, with an optional separate detection image.
132 Because it subclasses SingleFrameMeasurementTask, and calls
133 SingleFrameMeasurementTask.run()
from its
run() method, it still
134 can be used identically to a standard SingleFrameMeasurementTask.
137 ConfigClass = DipoleFitTaskConfig
138 _DefaultName = "ip_diffim_DipoleFit"
140 def __init__(self, schema, algMetadata=None, **kwargs):
142 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
144 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
147 schema=schema, metadata=algMetadata,
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.
dipoleFitterdipoleFitter.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.
loglog = logging.getLogger(__name__)
195 """Generate gradient model (2-d array) with up to 2nd-order polynomial
200 (2, w, h)-dimensional `numpy.array`, containing the
201 input x,y meshgrid providing the coordinates upon which to
202 compute the gradient. This will typically be generated via
203 `_generateXYGrid()`. `w` and `h` correspond to the width
and
204 height of the desired grid.
205 pars : `list` of `float`, optional
206 Up to 6 floats
for up
207 to 6 2nd-order 2-d polynomial gradient parameters,
in the
208 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
209 is emtpy
or `
None`, do nothing
and return `
None` (
for speed).
213 result : `
None`
or `numpy.array`
214 return None,
or 2-d numpy.array of width/height matching
215 input bbox, containing computed gradient values.
219 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
222 y, x = in_x[0, :], in_x[1, :]
223 gradient = np.full_like(x, pars[0], dtype=
'float64')
224 if len(pars) > 1
and pars[1]
is not None:
225 gradient += pars[1] * x
226 if len(pars) > 2
and pars[2]
is not None:
227 gradient += pars[2] * y
228 if len(pars) > 3
and pars[3]
is not None:
229 gradient += pars[3] * (x * y)
230 if len(pars) > 4
and pars[4]
is not None:
231 gradient += pars[4] * (x * x)
232 if len(pars) > 5
and pars[5]
is not None:
233 gradient += pars[5] * (y * y)
237 def _generateXYGrid(self, bbox):
238 """Generate a meshgrid covering the x,y coordinates bounded by bbox
243 input Bounding Box defining the coordinate limits
248 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
252 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
253 in_x = np.array([y, x]).astype(np.float64)
254 in_x[0, :] -= np.mean(in_x[0, :])
255 in_x[1, :] -= np.mean(in_x[1, :])
258 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
259 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
260 as an `lsst.afw.image.ImageF`.
265 HeavyFootprint to use to generate the subimage
266 badfill : `float`, optional
267 Value to fill
in pixels
in extracted image that are outside the footprint
269 Optionally grow the footprint by this amount before extraction
273 subim2 : `lsst.afw.image.ImageF`
274 An `~lsst.afw.image.ImageF` containing the subimage.
280 subim2 = afwImage.ImageF(bbox, badfill)
281 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
285 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
287 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
292 SourceRecord, the footprint of which is to be fit
294 The exposure
from which to extract the footprint subimage
296 Polynomial order of background gradient to fit.
300 pars : `tuple` of `float`
301 `tuple` of length (1
if order==0; 3
if order==1; 6
if order == 2),
302 containing the resulting fit parameters
307 fp = source.getFootprint()
310 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
315 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
318 isBg = np.isnan(posFpImg.getArray()).ravel()
320 data = posImg.getArray().ravel()
324 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
325 x = x.astype(np.float64).ravel()
328 y = y.astype(np.float64).ravel()
331 b = np.ones_like(x, dtype=np.float64)
335 M = np.vstack([b, x, y]).T
337 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
339 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
343 """Generate a 2D image model of a single PDF centered at the given coordinates.
347 bbox : `lsst.geom.Box`
348 Bounding box marking pixel coordinates for generated model
350 Psf model used to generate the
'star'
352 Desired x-centroid of the
'star'
354 Desired y-centroid of the
'star'
356 Desired flux of the
'star'
361 2-d stellar image of width/height matching input ``bbox``,
362 containing PSF
with given centroid
and flux
366 psf_img = psf.computeImage(
geom.Point2D(xcen, ycen)).convertF()
367 psf_img_sum = np.nansum(psf_img.getArray())
368 psf_img *= (flux/psf_img_sum)
371 psf_box = psf_img.getBBox()
373 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
379 p_Im = afwImage.ImageF(bbox)
380 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
385 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
386 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
387 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
389 """Generate dipole model with given parameters.
391 This is the function whose sum-of-squared difference
from data
392 is minimized by `lmfit`.
395 Input independent variable. Used here
as the grid on
396 which to compute the background gradient model.
398 Desired flux of the positive lobe of the dipole
400 Desired x-centroid of the positive lobe of the dipole
402 Desired y-centroid of the positive lobe of the dipole
404 Desired x-centroid of the negative lobe of the dipole
406 Desired y-centroid of the negative lobe of the dipole
407 fluxNeg : `float`, optional
408 Desired flux of the negative lobe of the dipole, set to
'flux' if None
409 b, x1, y1, xy, x2, y2 : `float`
410 Gradient parameters
for positive lobe.
411 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
412 Gradient parameters
for negative lobe.
413 They are set to the corresponding positive values
if None.
416 Keyword arguments passed through ``lmfit``
and
417 used by this function. These must include:
419 - ``psf`` Psf model used to generate the
'star'
420 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
421 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
422 - ``bbox`` Bounding box containing region to be modelled
427 Has width
and height matching the input bbox,
and
428 contains the dipole model
with given centroids
and flux(es). If
429 ``rel_weight`` = 0, this
is a 2-d array
with dimensions matching
430 those of bbox; otherwise a stack of three such arrays,
431 representing the dipole (diffim), positive
and negative images
435 psf = kwargs.get('psf')
436 rel_weight = kwargs.get(
'rel_weight')
437 fp = kwargs.get(
'footprint')
444 self.
loglog.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
445 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
447 self.
loglog.
debug(
' %.2f %.2f %.2f', b, x1, y1)
449 self.
loglog.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
451 posIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenPos, ycenPos, flux)
452 negIm = self.
makeStarModelmakeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
456 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
457 in_x = np.array([x, y]) * 1.
458 in_x[0, :] -= in_x[0, :].mean()
459 in_x[1, :] -= in_x[1, :].mean()
466 gradientNeg = self.
makeBackgroundModelmakeBackgroundModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))
468 gradientNeg = gradient
470 posIm.getArray()[:, :] += gradient
471 negIm.getArray()[:, :] += gradientNeg
474 diffIm = afwImage.ImageF(bbox)
478 zout = diffIm.getArray()
480 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
486 """Fit a dipole model using an image difference.
489 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
494 _private_version_ =
'0.0.5'
511 def __init__(self, diffim, posImage=None, negImage=None):
512 """Algorithm to run dipole measurement on a diaSource
517 Exposure on which the diaSources were detected
519 "Positive" exposure
from which the template was subtracted
521 "Negative" exposure which was subtracted
from the posImage
528 if diffim
is not None:
529 self.
psfSigmapsfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
531 self.
loglog = logging.getLogger(__name__)
537 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
538 separateNegParams=True, verbose=False):
539 """Fit a dipole model to an input difference image.
541 Actually, fits the subimage bounded by the input source's
542 footprint) and optionally constrain the fit using the
543 pre-subtraction images posImage
and negImage.
547 source : TODO: DM-17458
549 tol : float, optional
551 rel_weight : `float`, optional
553 fitBackground : `int`, optional
555 bgGradientOrder : `int`, optional
557 maxSepInSigma : `float`, optional
559 separateNegParams : `bool`, optional
561 verbose : `bool`, optional
566 result : `lmfit.MinimizerResult`
567 return `lmfit.MinimizerResult` object containing the fit
568 parameters
and other information.
574 fp = source.getFootprint()
576 subim = afwImage.MaskedImageF(self.
diffimdiffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
578 z = diArr = subim.getArrays()[0]
579 weights = 1. / subim.getArrays()[2]
581 if rel_weight > 0.
and ((self.
posImageposImage
is not None)
or (self.
negImagenegImage
is not None)):
582 if self.
negImagenegImage
is not None:
583 negSubim = afwImage.MaskedImageF(self.
negImagenegImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
584 if self.
posImageposImage
is not None:
585 posSubim = afwImage.MaskedImageF(self.
posImageposImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
587 posSubim = subim.clone()
590 negSubim = posSubim.clone()
593 z = np.append([z], [posSubim.getArrays()[0],
594 negSubim.getArrays()[0]], axis=0)
596 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
597 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
604 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
605 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
606 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
608 """Generate dipole model with given parameters.
610 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
611 out of `kwargs['modelObj']`.
613 modelObj = kwargs.pop('modelObj')
614 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
615 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
616 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
617 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
621 modelFunctor = dipoleModelFunctor
624 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
629 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
630 cenNeg = cenPos = fpCentroid
635 cenPos = pks[0].getF()
637 cenNeg = pks[-1].getF()
641 maxSep = self.
psfSigmapsfSigma * maxSepInSigma
644 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
646 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
652 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
653 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
654 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
655 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
656 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
657 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
658 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
659 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
663 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
664 posFlux = negFlux = startingFlux
667 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
669 if separateNegParams:
671 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
679 bgParsPos = bgParsNeg = (0., 0., 0.)
680 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
684 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
685 order=bgGradientOrder)
688 if fitBackground == 1:
689 in_x = dipoleModel._generateXYGrid(bbox)
690 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
692 z[1, :] -= np.nanmedian(z[1, :])
693 posFlux = np.nansum(z[1, :])
694 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
696 if separateNegParams
and self.
negImagenegImage
is not None:
697 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImagenegImage,
698 order=bgGradientOrder)
699 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
701 z[2, :] -= np.nanmedian(z[2, :])
702 if separateNegParams:
703 negFlux = np.nansum(z[2, :])
704 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
707 if fitBackground == 2:
708 if bgGradientOrder >= 0:
709 gmod.set_param_hint(
'b', value=bgParsPos[0])
710 if separateNegParams:
711 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
712 if bgGradientOrder >= 1:
713 gmod.set_param_hint(
'x1', value=bgParsPos[1])
714 gmod.set_param_hint(
'y1', value=bgParsPos[2])
715 if separateNegParams:
716 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
717 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
718 if bgGradientOrder >= 2:
719 gmod.set_param_hint(
'xy', value=bgParsPos[3])
720 gmod.set_param_hint(
'x2', value=bgParsPos[4])
721 gmod.set_param_hint(
'y2', value=bgParsPos[5])
722 if separateNegParams:
723 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
724 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
725 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
727 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
728 in_x = np.array([x, y]).astype(np.float)
729 in_x[0, :] -= in_x[0, :].mean()
730 in_x[1, :] -= in_x[1, :].mean()
734 mask = np.ones_like(z, dtype=bool)
739 weights = mask.astype(np.float64)
740 if self.
posImageposImage
is not None and rel_weight > 0.:
741 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
742 np.ones_like(diArr)*rel_weight])
751 with warnings.catch_warnings():
752 warnings.simplefilter(
"ignore")
753 result = gmod.fit(z, weights=weights, x=in_x,
755 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
757 psf=self.
diffimdiffim.getPsf(),
758 rel_weight=rel_weight,
760 modelObj=dipoleModel)
767 print(result.fit_report(show_correl=
False))
768 if separateNegParams:
769 print(result.ci_report())
774 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
775 bgGradientOrder=1, verbose=False, display=False):
776 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
778 Actually, fits the subimage bounded by the input source's
779 footprint) and optionally constrain the fit using the
780 pre-subtraction images self.
posImageposImage (science)
and
781 self.
negImagenegImage (template). Wraps the output into a
782 `pipeBase.Struct` named tuple after computing additional
783 statistics such
as orientation
and SNR.
788 Record containing the (merged) dipole source footprint detected on the diffim
789 tol : `float`, optional
790 Tolerance parameter
for scipy.leastsq() optimization
791 rel_weight : `float`, optional
792 Weighting of posImage/negImage relative to the diffim
in the fit
793 fitBackground : `int`, {0, 1, 2}, optional
794 How to fit linear background gradient
in posImage/negImage
796 - 0: do
not fit background at all
797 - 1 (default): pre-fit the background using linear least squares
and then do
not fit it
798 as part of the dipole fitting optimization
799 - 2: pre-fit the background using linear least squares (
as in 1),
and use the parameter
800 estimates
from that fit
as starting parameters
for an integrated
"re-fit" of the
801 background
as part of the overall dipole fitting optimization.
802 maxSepInSigma : `float`, optional
803 Allowed window of centroid parameters relative to peak
in input source footprint
804 separateNegParams : `bool`, optional
805 Fit separate parameters to the flux
and background gradient
in
806 bgGradientOrder : `int`, {0, 1, 2}, optional
807 Desired polynomial order of background gradient
808 verbose: `bool`, optional
811 Display input data, best fit model(s)
and residuals
in a matplotlib window.
816 `pipeBase.Struct` object containing the fit parameters
and other information.
819 `lmfit.MinimizerResult` object
for debugging
and error estimation, etc.
823 Parameter `fitBackground` has three options, thus it
is an integer:
828 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
829 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
830 bgGradientOrder=bgGradientOrder, verbose=verbose)
834 fp = source.getFootprint()
837 fitParams = fitResult.best_values
838 if fitParams[
'flux'] <= 1.:
839 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
840 negCentroidX=np.nan, negCentroidY=np.nan,
841 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
842 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
843 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
844 return out, fitResult
846 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
847 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
848 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
849 angle = np.arctan2(dy, dx) / np.pi * 180.
853 def computeSumVariance(exposure, footprint):
854 box = footprint.getBBox()
855 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
856 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
858 fluxVal = fluxVar = fitParams[
'flux']
859 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
860 if self.
posImageposImage
is not None:
861 fluxVar = computeSumVariance(self.
posImageposImage, source.getFootprint())
863 fluxVar = computeSumVariance(self.
diffimdiffim, source.getFootprint())
865 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
866 if separateNegParams:
867 fluxValNeg = fitParams[
'fluxNeg']
868 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
869 if self.
negImagenegImage
is not None:
870 fluxVarNeg = computeSumVariance(self.
negImagenegImage, source.getFootprint())
873 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
874 except ZeroDivisionError:
875 signalToNoise = np.nan
877 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
878 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
879 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
880 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
881 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
884 return out, fitResult
887 """Display data, model fits and residuals (currently uses matplotlib display functions).
891 footprint : TODO: DM-17458
892 Footprint containing the dipole that was fit
893 result : `lmfit.MinimizerResult`
894 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
898 fig : `matplotlib.pyplot.plot`
901 import matplotlib.pyplot
as plt
902 except ImportError
as err:
903 self.
loglog.warning(
'Unable to import matplotlib: %s', err)
906 def display2dArray(arr, title='Data', extent=None):
907 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
909 fig = plt.imshow(arr, origin='lower', interpolation=
'none', cmap=
'gray', extent=extent)
911 plt.colorbar(fig, cmap=
'gray')
915 fit = result.best_fit
916 bbox = footprint.getBBox()
917 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
919 fig = plt.figure(figsize=(8, 8))
921 plt.subplot(3, 3, i*3+1)
922 display2dArray(z[i, :],
'Data', extent=extent)
923 plt.subplot(3, 3, i*3+2)
924 display2dArray(fit[i, :],
'Model', extent=extent)
925 plt.subplot(3, 3, i*3+3)
926 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
929 fig = plt.figure(figsize=(8, 2.5))
931 display2dArray(z,
'Data', extent=extent)
933 display2dArray(fit,
'Model', extent=extent)
935 display2dArray(z - fit,
'Residual', extent=extent)
941@measBase.register("ip_diffim_DipoleFit")
943 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
945 This measurement plugin accepts up to three input images in
946 its `measure` method. If these are provided, it includes data
947 from the pre-subtraction posImage (science image)
and optionally
948 negImage (template image) to constrain the fit. The meat of the
949 fitting routines are
in the
class `~lsst.module.name.DipoleFitAlgorithm`.
953 The motivation behind this plugin
and the necessity
for including more than
954 one exposure are documented
in DMTN-007 (http://dmtn-007.lsst.io).
956 This
class is named `ip_diffim_DipoleFit` so that it may be used alongside
957 the existing `ip_diffim_DipoleMeasurement` classes until such a time
as those
958 are deemed to be replaceable by this.
961 ConfigClass = DipoleFitPluginConfig
962 DipoleFitAlgorithmClass = DipoleFitAlgorithm
966 FAILURE_NOT_DIPOLE = 4
970 """Set execution order to `FLUX_ORDER`.
972 This includes algorithms that require both `getShape()` and `getCentroid()`,
973 in addition to a Footprint
and its Peaks.
975 return cls.FLUX_ORDER
977 def __init__(self, config, name, schema, metadata, logName=None):
980 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
982 self.
loglog = logging.getLogger(logName)
984 self.
_setupSchema_setupSchema(config, name, schema, metadata)
986 def _setupSchema(self, config, name, schema, metadata):
988 self.
centroidKeycentroidKey = afwTable.Point2DKey(schema[
"slot_Centroid"])
994 for pos_neg
in [
'pos',
'neg']:
996 key = schema.addField(
997 schema.join(name, pos_neg,
"instFlux"), type=float, units=
"count",
998 doc=
"Dipole {0} lobe flux".format(pos_neg))
999 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
1001 key = schema.addField(
1002 schema.join(name, pos_neg,
"instFluxErr"), type=float, units=
"count",
1003 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1004 setattr(self,
''.join((pos_neg,
'FluxErrKey')), key)
1006 for x_y
in [
'x',
'y']:
1007 key = schema.addField(
1008 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
1009 doc=
"Dipole {0} lobe centroid".format(pos_neg))
1010 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
1012 for x_y
in [
'x',
'y']:
1013 key = schema.addField(
1014 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
1015 doc=
"Dipole centroid")
1016 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
1019 schema.join(name,
"instFlux"), type=float, units=
"count",
1020 doc=
"Dipole overall flux")
1023 schema.join(name,
"orientation"), type=float, units=
"deg",
1024 doc=
"Dipole orientation")
1027 schema.join(name,
"separation"), type=float, units=
"pixel",
1028 doc=
"Pixel separation between positive and negative lobes of dipole")
1031 schema.join(name,
"chi2dof"), type=float,
1032 doc=
"Chi2 per degree of freedom of dipole fit")
1035 schema.join(name,
"signalToNoise"), type=float,
1036 doc=
"Estimated signal-to-noise of dipole fit")
1039 schema.join(name,
"flag",
"classification"), type=
"Flag",
1040 doc=
"Flag indicating diaSource is classified as a dipole")
1043 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
1044 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
1047 schema.join(name,
"flag"), type=
"Flag",
1048 doc=
"General failure flag for dipole fit")
1051 schema.join(name,
"flag",
"edge"), type=
"Flag",
1052 doc=
"Flag set when dipole is too close to edge of image")
1054 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1055 """Perform the non-linear least squares minimization on the putative dipole source.
1060 diaSources that will be measured using dipole measurement
1062 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1063 If both `posExp` and `negExp` are `
None`, will attempt to fit the
1064 dipole to just the `exposure`
with no constraint.
1066 "Positive" exposure, typically a science exposure,
or None if unavailable
1067 When `posExp`
is `
None`, will compute `posImage = exposure + negExp`.
1069 "Negative" exposure, typically a template exposure,
or None if unavailable
1070 When `negExp`
is `
None`, will compute `negImage = posExp - exposure`.
1074 The main functionality of this routine was placed outside of
1075 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1076 `DipoleFitAlgorithm.fitDipole()` can be called separately
for
1077 testing (
@see `tests/testDipoleFitter.py`)
1081 result : TODO: DM-17458
1086 pks = measRecord.getFootprint().getPeaks()
1091 or (len(pks) > 1
and (np.sign(pks[0].getPeakValue())
1092 == np.sign(pks[-1].getPeakValue())))
1096 self.
failfail(measRecord, measBase.MeasurementError(
'not a dipole', self.
FAILURE_NOT_DIPOLEFAILURE_NOT_DIPOLE))
1097 if not self.config.fitAllDiaSources:
1102 result, _ = alg.fitDipole(
1103 measRecord, rel_weight=self.config.relWeight,
1104 tol=self.config.tolerance,
1105 maxSepInSigma=self.config.maxSeparation,
1106 fitBackground=self.config.fitBackground,
1107 separateNegParams=self.config.fitSeparateNegParams,
1108 verbose=
False, display=
False)
1110 self.
failfail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGEFAILURE_EDGE))
1112 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1119 self.
loglog.debug(
"Dipole fit result: %d %s", measRecord.getId(),
str(result))
1121 if result.posFlux <= 1.:
1122 self.
failfail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FITFAILURE_FIT))
1126 measRecord[self.posFluxKey] = result.posFlux
1127 measRecord[self.posFluxErrKey] = result.signalToNoise
1128 measRecord[self.posCentroidKeyX] = result.posCentroidX
1129 measRecord[self.posCentroidKeyY] = result.posCentroidY
1131 measRecord[self.negFluxKey] = result.negFlux
1132 measRecord[self.negFluxErrKey] = result.signalToNoise
1133 measRecord[self.negCentroidKeyX] = result.negCentroidX
1134 measRecord[self.negCentroidKeyY] = result.negCentroidY
1137 measRecord[self.
fluxKeyfluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1138 measRecord[self.
orientationKeyorientationKey] = result.orientation
1139 measRecord[self.
separationKeyseparationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1140 + (result.posCentroidY - result.negCentroidY)**2.)
1141 measRecord[self.centroidKeyX] = result.centroidX
1142 measRecord[self.centroidKeyY] = result.centroidY
1145 measRecord[self.
chi2dofKeychi2dofKey] = result.redChi2
1147 self.
doClassifydoClassify(measRecord, result.chi2)
1150 """Classify a source as a dipole.
1154 measRecord : TODO: DM-17458
1156 chi2val : TODO: DM-17458
1161 Sources are classified as dipoles,
or not, according to three criteria:
1163 1. Does the total signal-to-noise surpass the ``minSn``?
1164 2. Are the pos/neg fluxes greater than 1.0
and no more than 0.65 (``maxFluxRatio``)
1165 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1166 3. Is it a good fit (``chi2dof`` < 1)? (Currently
not used.)
1170 passesSn = measRecord[self.
signalToNoiseKeysignalToNoiseKey] > self.config.minSn
1174 passesFluxPos = (abs(measRecord[self.posFluxKey])
1175 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1176 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1177 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1178 / (measRecord[self.
fluxKeyfluxKey]*2.)) < self.config.maxFluxRatio
1179 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1180 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1188 from scipy.stats
import chi2
1189 ndof = chi2val / measRecord[self.
chi2dofKeychi2dofKey]
1190 significance = chi2.cdf(chi2val, ndof)
1191 passesChi2 = significance < self.config.maxChi2DoF
1192 allPass = allPass
and passesChi2
1201 def fail(self, measRecord, error=None):
1202 """Catch failures and set the correct flags.
1205 measRecord.set(self.flagKeyflagKey, True)
1206 if error
is not None:
1207 if error.getFlagBit() == self.
FAILURE_EDGEFAILURE_EDGE:
1208 self.
loglog.warning(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(),
str(error))
1210 if error.getFlagBit() == self.
FAILURE_FITFAILURE_FIT:
1211 self.
loglog.warning(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(),
str(error))
1212 measRecord.set(self.
flagKeyflagKey,
True)
1214 self.
loglog.debug(
'DipoleFitPlugin not run on record %d: %s',
1215 measRecord.getId(),
str(error))
1217 measRecord.set(self.
flagKeyflagKey,
True)
1219 self.
loglog.warning(
'DipoleFitPlugin failed on record %d', measRecord.getId())
def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
def fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
def displayFitResults(self, footprint, result)
def __init__(self, diffim, posImage=None, negImage=None)
def fail(self, measRecord, error=None)
def getExecutionOrder(cls)
def measure(self, measRecord, exposure, posExp=None, negExp=None)
def doClassify(self, measRecord, chi2val)
classificationAttemptedFlagKey
def _setupSchema(self, config, name, schema, metadata)
def __init__(self, config, name, schema, metadata, 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)