Coverage for python/lsst/ip/diffim/dipoleFitTask.py: 12%
441 statements
« prev ^ index » next coverage.py v6.4.4, created at 2022-09-01 03:03 -0700
« prev ^ index » next coverage.py v6.4.4, created at 2022-09-01 03:03 -0700
1#
2# LSST Data Management System
3# Copyright 2008-2016 AURA/LSST.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <https://www.lsstcorp.org/LegalNotices/>.
21#
23import logging
24import numpy as np
25import warnings
27import lsst.afw.image as afwImage
28import lsst.meas.base as measBase
29import lsst.afw.table as afwTable
30import lsst.afw.detection as afwDet
31import lsst.geom as geom
32import lsst.pex.exceptions as pexExcept
33import lsst.pex.config as pexConfig
34from lsst.pipe.base import Struct
35from lsst.utils.timer import timeMethod
37__all__ = ("DipoleFitTask", "DipoleFitPlugin", "DipoleFitTaskConfig", "DipoleFitPluginConfig",
38 "DipoleFitAlgorithm")
41# Create a new measurement task (`DipoleFitTask`) that can handle all other SFM tasks but can
42# pass a separate pos- and neg- exposure/image to the `DipoleFitPlugin`s `run()` method.
45class DipoleFitPluginConfig(measBase.SingleFramePluginConfig):
46 """Configuration for DipoleFitPlugin
47 """
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.
61 images on fit)""")
63 tolerance = pexConfig.Field(
64 dtype=float, default=1e-7,
65 doc="Fit tolerance")
67 fitBackground = pexConfig.Field(
68 dtype=int, default=1,
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.")
80 # Config params for classification of detected diaSources as dipole or not
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.
100 """
102 def setDefaults(self):
103 measBase.SingleFrameMeasurementConfig.setDefaults(self)
105 self.plugins.names = ["base_CircularApertureFlux",
106 "base_PixelFlags",
107 "base_SkyCoord",
108 "base_PsfFlux",
109 "base_SdssCentroid",
110 "base_SdssShape",
111 "base_GaussianFlux",
112 "base_PeakLikelihoodFlux",
113 "base_PeakCentroid",
114 "base_NaiveCentroid",
115 "ip_diffim_NaiveDipoleCentroid",
116 "ip_diffim_NaiveDipoleFlux",
117 "ip_diffim_PsfDipoleFlux",
118 "ip_diffim_ClassificationDipole",
119 ]
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"
126 self.doReplaceWithNoise = False
129class DipoleFitTask(measBase.SingleFrameMeasurementTask):
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.
135 """
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']
146 self.dipoleFitter = DipoleFitPlugin(dpFitPluginConfig, name=self._DefaultName,
147 schema=schema, metadata=algMetadata,
148 logName=self.log.name)
150 @timeMethod
151 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
152 """Run dipole measurement and classification
154 Parameters
155 ----------
156 sources : `lsst.afw.table.SourceCatalog`
157 ``diaSources`` that will be measured using dipole measurement
158 exposure : `lsst.afw.image.Exposure`
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.
162 posExp : `lsst.afw.image.Exposure`, optional
163 "Positive" exposure, typically a science exposure, or None if unavailable
164 When `posExp` is `None`, will compute `posImage = exposure + negExp`.
165 negExp : `lsst.afw.image.Exposure`, optional
166 "Negative" exposure, typically a template exposure, or None if unavailable
167 When `negExp` is `None`, will compute `negImage = posExp - exposure`.
168 **kwargs
169 Additional keyword arguments for `lsst.meas.base.sfm.SingleFrameMeasurementTask`.
170 """
172 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
174 if not sources:
175 return
177 for source in sources:
178 self.dipoleFitter.measure(source, exposure, posExp, negExp)
181class DipoleModel(object):
182 """Lightweight class containing methods for generating a dipole model for fitting
183 to sources in diffims, used by DipoleFitAlgorithm.
185 See also:
186 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
187 """
189 def __init__(self):
190 import lsstDebug
191 self.debug = lsstDebug.Info(__name__).debug
192 self.log = logging.getLogger(__name__)
194 def makeBackgroundModel(self, in_x, pars=None):
195 """Generate gradient model (2-d array) with up to 2nd-order polynomial
197 Parameters
198 ----------
199 in_x : `numpy.array`
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).
211 Returns
212 -------
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.
216 """
218 # Don't fit for other gradient parameters if the intercept is not included.
219 if (pars is None) or (len(pars) <= 0) or (pars[0] is None):
220 return
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)
235 return gradient
237 def _generateXYGrid(self, bbox):
238 """Generate a meshgrid covering the x,y coordinates bounded by bbox
240 Parameters
241 ----------
242 bbox : `lsst.geom.Box2I`
243 input Bounding Box defining the coordinate limits
245 Returns
246 -------
247 in_x : `numpy.array`
248 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
249 y- coordinates
250 """
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, :])
256 return in_x
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`.
262 Parameters
263 ----------
264 fp : `lsst.afw.detection.HeavyFootprint`
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
268 grow : `int`
269 Optionally grow the footprint by this amount before extraction
271 Returns
272 -------
273 subim2 : `lsst.afw.image.ImageF`
274 An `~lsst.afw.image.ImageF` containing the subimage.
275 """
276 bbox = fp.getBBox()
277 if grow > 0:
278 bbox.grow(grow)
280 subim2 = afwImage.ImageF(bbox, badfill)
281 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
282 return subim2
284 def fitFootprintBackground(self, source, posImage, order=1):
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.
289 Parameters
290 ----------
291 source : `lsst.afw.table.SourceRecord`
292 SourceRecord, the footprint of which is to be fit
293 posImage : `lsst.afw.image.Exposure`
294 The exposure from which to extract the footprint subimage
295 order : `int`
296 Polynomial order of background gradient to fit.
298 Returns
299 -------
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
303 """
305 # TODO look into whether to use afwMath background methods -- see
306 # http://lsst-web.ncsa.illinois.edu/doxygen/x_masterDoxyDoc/_background_example.html
307 fp = source.getFootprint()
308 bbox = fp.getBBox()
309 bbox.grow(3)
310 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
312 # This code constructs the footprint image so that we can identify the pixels that are
313 # outside the footprint (but within the bounding box). These are the pixels used for
314 # fitting the background.
315 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
316 posFpImg = self._getHeavyFootprintSubimage(posHfp, grow=3)
318 isBg = np.isnan(posFpImg.getArray()).ravel()
320 data = posImg.getArray().ravel()
321 data = data[isBg]
322 B = data
324 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
325 x = x.astype(np.float64).ravel()
326 x -= np.mean(x)
327 x = x[isBg]
328 y = y.astype(np.float64).ravel()
329 y -= np.mean(y)
330 y = y[isBg]
331 b = np.ones_like(x, dtype=np.float64)
333 M = np.vstack([b]).T # order = 0
334 if order == 1:
335 M = np.vstack([b, x, y]).T
336 elif order == 2:
337 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
339 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
340 return pars
342 def makeStarModel(self, bbox, psf, xcen, ycen, flux):
343 """Generate a 2D image model of a single PDF centered at the given coordinates.
345 Parameters
346 ----------
347 bbox : `lsst.geom.Box`
348 Bounding box marking pixel coordinates for generated model
349 psf : TODO: DM-17458
350 Psf model used to generate the 'star'
351 xcen : `float`
352 Desired x-centroid of the 'star'
353 ycen : `float`
354 Desired y-centroid of the 'star'
355 flux : `float`
356 Desired flux of the 'star'
358 Returns
359 -------
360 p_Im : `lsst.afw.image.Image`
361 2-d stellar image of width/height matching input ``bbox``,
362 containing PSF with given centroid and flux
363 """
365 # Generate the psf image, normalize to 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)
370 # Clip the PSF image bounding box to fall within the footprint bounding box
371 psf_box = psf_img.getBBox()
372 psf_box.clip(bbox)
373 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
375 # Then actually crop the psf image.
376 # Usually not necessary, but if the dipole is near the edge of the image...
377 # Would be nice if we could compare original pos_box with clipped pos_box and
378 # see if it actually was clipped.
379 p_Im = afwImage.ImageF(bbox)
380 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
381 tmpSubim += psf_img
383 return p_Im
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,
388 **kwargs):
389 """Generate dipole model with given parameters.
391 This is the function whose sum-of-squared difference from data
392 is minimized by `lmfit`.
394 x : TODO: DM-17458
395 Input independent variable. Used here as the grid on
396 which to compute the background gradient model.
397 flux : `float`
398 Desired flux of the positive lobe of the dipole
399 xcenPos : `float`
400 Desired x-centroid of the positive lobe of the dipole
401 ycenPos : `float`
402 Desired y-centroid of the positive lobe of the dipole
403 xcenNeg : `float`
404 Desired x-centroid of the negative lobe of the dipole
405 ycenNeg : `float`
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.
415 **kwargs
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
424 Returns
425 -------
426 zout : `numpy.array`
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
432 respectively.
433 """
435 psf = kwargs.get('psf')
436 rel_weight = kwargs.get('rel_weight') # if > 0, we're including pre-sub. images
437 fp = kwargs.get('footprint')
438 bbox = fp.getBBox()
440 if fluxNeg is None:
441 fluxNeg = flux
443 if self.debug:
444 self.log.debug('%.2f %.2f %.2f %.2f %.2f %.2f',
445 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
446 if x1 is not None:
447 self.log.debug(' %.2f %.2f %.2f', b, x1, y1)
448 if xy is not None:
449 self.log.debug(' %.2f %.2f %.2f', xy, x2, y2)
451 posIm = self.makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
452 negIm = self.makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
454 in_x = x
455 if in_x is None: # use the footprint to generate the input grid
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() # center it!
459 in_x[1, :] -= in_x[1, :].mean()
461 if b is not None:
462 gradient = self.makeBackgroundModel(in_x, (b, x1, y1, xy, x2, y2))
464 # If bNeg is None, then don't fit the negative background separately
465 if bNeg is not None:
466 gradientNeg = self.makeBackgroundModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))
467 else:
468 gradientNeg = gradient
470 posIm.getArray()[:, :] += gradient
471 negIm.getArray()[:, :] += gradientNeg
473 # Generate the diffIm model
474 diffIm = afwImage.ImageF(bbox)
475 diffIm += posIm
476 diffIm -= negIm
478 zout = diffIm.getArray()
479 if rel_weight > 0.:
480 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
482 return zout
485class DipoleFitAlgorithm(object):
486 """Fit a dipole model using an image difference.
488 See also:
489 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
490 """
492 # This is just a private version number to sync with the ipython notebooks that I have been
493 # using for algorithm development.
494 _private_version_ = '0.0.5'
496 # Below is a (somewhat incomplete) list of improvements
497 # that would be worth investigating, given the time:
499 # todo 1. evaluate necessity for separate parameters for pos- and neg- images
500 # todo 2. only fit background OUTSIDE footprint (DONE) and dipole params INSIDE footprint (NOT DONE)?
501 # todo 3. correct normalization of least-squares weights based on variance planes
502 # todo 4. account for PSFs that vary across the exposures (should be happening by default?)
503 # todo 5. correctly account for NA/masks (i.e., ignore!)
504 # todo 6. better exception handling in the plugin
505 # todo 7. better classification of dipoles (e.g. by comparing chi2 fit vs. monopole?)
506 # todo 8. (DONE) Initial fast estimate of background gradient(s) params -- perhaps using numpy.lstsq
507 # todo 9. (NOT NEEDED - see (2)) Initial fast test whether a background gradient needs to be fit
508 # todo 10. (DONE) better initial estimate for flux when there's a strong gradient
509 # todo 11. (DONE) requires a new package `lmfit` -- investiate others? (astropy/scipy/iminuit?)
511 def __init__(self, diffim, posImage=None, negImage=None):
512 """Algorithm to run dipole measurement on a diaSource
514 Parameters
515 ----------
516 diffim : `lsst.afw.image.Exposure`
517 Exposure on which the diaSources were detected
518 posImage : `lsst.afw.image.Exposure`
519 "Positive" exposure from which the template was subtracted
520 negImage : `lsst.afw.image.Exposure`
521 "Negative" exposure which was subtracted from the posImage
522 """
524 self.diffim = diffim
525 self.posImage = posImage
526 self.negImage = negImage
527 self.psfSigma = None
528 if diffim is not None:
529 diffimPsf = diffim.getPsf()
530 diffimAvgPos = diffimPsf.getAveragePosition()
531 self.psfSigma = diffimPsf.computeShape(diffimAvgPos).getDeterminantRadius()
533 self.log = logging.getLogger(__name__)
535 import lsstDebug
536 self.debug = lsstDebug.Info(__name__).debug
538 def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5,
539 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
540 separateNegParams=True, verbose=False):
541 """Fit a dipole model to an input difference image.
543 Actually, fits the subimage bounded by the input source's
544 footprint) and optionally constrain the fit using the
545 pre-subtraction images posImage and negImage.
547 Parameters
548 ----------
549 source : TODO: DM-17458
550 TODO: DM-17458
551 tol : float, optional
552 TODO: DM-17458
553 rel_weight : `float`, optional
554 TODO: DM-17458
555 fitBackground : `int`, optional
556 TODO: DM-17458
557 bgGradientOrder : `int`, optional
558 TODO: DM-17458
559 maxSepInSigma : `float`, optional
560 TODO: DM-17458
561 separateNegParams : `bool`, optional
562 TODO: DM-17458
563 verbose : `bool`, optional
564 TODO: DM-17458
566 Returns
567 -------
568 result : `lmfit.MinimizerResult`
569 return `lmfit.MinimizerResult` object containing the fit
570 parameters and other information.
571 """
573 # Only import lmfit if someone wants to use the new DipoleFitAlgorithm.
574 import lmfit
576 fp = source.getFootprint()
577 bbox = fp.getBBox()
578 subim = afwImage.MaskedImageF(self.diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
580 z = diArr = subim.getArrays()[0]
581 weights = 1. / subim.getArrays()[2] # get the weights (=1/variance)
583 if rel_weight > 0. and ((self.posImage is not None) or (self.negImage is not None)):
584 if self.negImage is not None:
585 negSubim = afwImage.MaskedImageF(self.negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
586 if self.posImage is not None:
587 posSubim = afwImage.MaskedImageF(self.posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
588 if self.posImage is None: # no science image provided; generate it from diffim + negImage
589 posSubim = subim.clone()
590 posSubim += negSubim
591 if self.negImage is None: # no template provided; generate it from the posImage - diffim
592 negSubim = posSubim.clone()
593 negSubim -= subim
595 z = np.append([z], [posSubim.getArrays()[0],
596 negSubim.getArrays()[0]], axis=0)
597 # Weight the pos/neg images by rel_weight relative to the diffim
598 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
599 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
600 else:
601 rel_weight = 0. # a short-cut for "don't include the pre-subtraction data"
603 # It seems that `lmfit` requires a static functor as its optimized method, which eliminates
604 # the ability to pass a bound method or other class method. Here we write a wrapper which
605 # makes this possible.
606 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
607 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
608 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
609 **kwargs):
610 """Generate dipole model with given parameters.
612 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
613 out of `kwargs['modelObj']`.
614 """
615 modelObj = kwargs.pop('modelObj')
616 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
617 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
618 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
619 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
621 dipoleModel = DipoleModel()
623 modelFunctor = dipoleModelFunctor # dipoleModel.makeModel does not work for now.
624 # Create the lmfit model (lmfit uses scipy 'leastsq' option by default - Levenberg-Marquardt)
625 # Note we can also tell it to drop missing values from the data.
626 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing='drop')
627 # independent_vars=independent_vars) #, param_names=param_names)
629 # Add the constraints for centroids, fluxes.
630 # starting constraint - near centroid of footprint
631 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
632 cenNeg = cenPos = fpCentroid
634 pks = fp.getPeaks()
636 if len(pks) >= 1:
637 cenPos = pks[0].getF() # if individual (merged) peaks were detected, use those
638 if len(pks) >= 2: # peaks are already sorted by centroid flux so take the most negative one
639 cenNeg = pks[-1].getF()
641 # For close/faint dipoles the starting locs (min/max) might be way off, let's help them a bit.
642 # First assume dipole is not separated by more than 5*psfSigma.
643 maxSep = self.psfSigma * maxSepInSigma
645 # As an initial guess -- assume the dipole is close to the center of the footprint.
646 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
647 cenPos = fpCentroid
648 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
649 cenPos = fpCentroid
651 # parameter hints/constraints: https://lmfit.github.io/lmfit-py/model.html#model-param-hints-section
652 # might make sense to not use bounds -- see http://lmfit.github.io/lmfit-py/bounds.html
653 # also see this discussion -- https://github.com/scipy/scipy/issues/3129
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)
663 # Use the (flux under the dipole)*5 for an estimate.
664 # Lots of testing showed that having startingFlux be too high was better than too low.
665 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
666 posFlux = negFlux = startingFlux
668 # TBD: set max. flux limit?
669 gmod.set_param_hint('flux', value=posFlux, min=0.1)
671 if separateNegParams:
672 # TBD: set max negative lobe flux limit?
673 gmod.set_param_hint('fluxNeg', value=np.abs(negFlux), min=0.1)
675 # Fixed parameters (don't fit for them if there are no pre-sub images or no gradient fit requested):
676 # Right now (fitBackground == 1), we fit a linear model to the background and then subtract
677 # it from the data and then don't fit the background again (this is faster).
678 # A slower alternative (fitBackground == 2) is to use the estimated background parameters as
679 # starting points in the integrated model fit. That is currently not performed by default,
680 # but might be desirable in some cases.
681 bgParsPos = bgParsNeg = (0., 0., 0.)
682 if ((rel_weight > 0.) and (fitBackground != 0) and (bgGradientOrder >= 0)):
683 pbg = 0.
684 bgFitImage = self.posImage if self.posImage is not None else self.negImage
685 # Fit the gradient to the background (linear model)
686 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
687 order=bgGradientOrder)
689 # Generate the gradient and subtract it from the pre-subtraction image data
690 if fitBackground == 1:
691 in_x = dipoleModel._generateXYGrid(bbox)
692 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
693 z[1, :] -= pbg
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))
702 z[2, :] -= pbg
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)
708 # Do not subtract the background from the images but include the background parameters in the fit
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() # center it!
732 in_x[1, :] -= in_x[1, :].mean()
734 # Instead of explicitly using a mask to ignore flagged pixels, just set the ignored pixels'
735 # weights to 0 in the fit. TBD: need to inspect mask planes to set this mask.
736 mask = np.ones_like(z, dtype=bool) # TBD: set mask values to False if the pixels are to be ignored
738 # I'm not sure about the variance planes in the diffim (or convolved pre-sub. images
739 # for that matter) so for now, let's just do an un-weighted least-squares fit
740 # (override weights computed above).
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])
746 # Set the weights to zero if mask is False
747 if np.any(~mask):
748 weights[~mask] = 0.
750 # Note that although we can, we're not required to set initial values for params here,
751 # since we set their param_hint's above.
752 # Can add "method" param to not use 'leastsq' (==levenberg-marquardt), e.g. "method='nelder'"
753 with warnings.catch_warnings():
754 warnings.simplefilter("ignore") # temporarily turn off silly lmfit warnings
755 result = gmod.fit(z, weights=weights, x=in_x,
756 verbose=verbose,
757 fit_kws={'ftol': tol, 'xtol': tol, 'gtol': tol,
758 'maxfev': 250}, # see scipy docs
759 psf=self.diffim.getPsf(), # hereon: kwargs that get passed to genDipoleModel()
760 rel_weight=rel_weight,
761 footprint=fp,
762 modelObj=dipoleModel)
764 if verbose: # the ci_report() seems to fail if neg params are constrained -- TBD why.
765 # Never wanted in production - this takes a long time (longer than the fit!)
766 # This is how to get confidence intervals out:
767 # https://lmfit.github.io/lmfit-py/confidence.html and
768 # http://cars9.uchicago.edu/software/python/lmfit/model.html
769 print(result.fit_report(show_correl=False))
770 if separateNegParams:
771 print(result.ci_report())
773 return result
775 def fitDipole(self, source, tol=1e-7, rel_weight=0.1,
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.
787 Parameters
788 ----------
789 source : `lsst.afw.table.SourceRecord`
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
811 Be verbose
812 display
813 Display input data, best fit model(s) and residuals in a matplotlib window.
815 Returns
816 -------
817 result : `struct`
818 `pipeBase.Struct` object containing the fit parameters and other information.
820 result : `callable`
821 `lmfit.MinimizerResult` object for debugging and error estimation, etc.
823 Notes
824 -----
825 Parameter `fitBackground` has three options, thus it is an integer:
827 """
829 fitResult = self.fitDipoleImpl(
830 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
831 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
832 bgGradientOrder=bgGradientOrder, verbose=verbose)
834 # Display images, model fits and residuals (currently uses matplotlib display functions)
835 if display:
836 fp = source.getFootprint()
837 self.displayFitResults(fp, fitResult)
839 fitParams = fitResult.best_values
840 if fitParams['flux'] <= 1.: # usually around 0.1 -- the minimum flux allowed -- i.e. bad fit.
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. # convert to degrees (should keep as rad?)
853 # Exctract flux value, compute signalToNoise from flux/variance_within_footprint
854 # Also extract the stderr of flux estimate.
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
862 if self.posImage is not None:
863 fluxVar = computeSumVariance(self.posImage, source.getFootprint())
864 else:
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
871 if self.negImage is not None:
872 fluxVarNeg = computeSumVariance(self.negImage, source.getFootprint())
874 try:
875 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
876 except ZeroDivisionError: # catch divide by zero - should never happen.
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)
885 # fitResult may be returned for debugging
886 return out, fitResult
888 def displayFitResults(self, footprint, result):
889 """Display data, model fits and residuals (currently uses matplotlib display functions).
891 Parameters
892 ----------
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
898 Returns
899 -------
900 fig : `matplotlib.pyplot.plot`
901 """
902 try:
903 import matplotlib.pyplot as plt
904 except ImportError as err:
905 self.log.warning('Unable to import matplotlib: %s', err)
906 raise 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.
910 """
911 fig = plt.imshow(arr, origin='lower', interpolation='none', cmap='gray', extent=extent)
912 plt.title(title)
913 plt.colorbar(fig, cmap='gray')
914 return fig
916 z = result.data
917 fit = result.best_fit
918 bbox = footprint.getBBox()
919 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
920 if z.shape[0] == 3:
921 fig = plt.figure(figsize=(8, 8))
922 for i in range(3):
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)
929 return fig
930 else:
931 fig = plt.figure(figsize=(8, 2.5))
932 plt.subplot(1, 3, 1)
933 display2dArray(z, 'Data', extent=extent)
934 plt.subplot(1, 3, 2)
935 display2dArray(fit, 'Model', extent=extent)
936 plt.subplot(1, 3, 3)
937 display2dArray(z - fit, 'Residual', extent=extent)
938 return fig
940 plt.show()
943@measBase.register("ip_diffim_DipoleFit")
944class DipoleFitPlugin(measBase.SingleFramePlugin):
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`.
953 Notes
954 -----
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.
961 """
963 ConfigClass = DipoleFitPluginConfig
964 DipoleFitAlgorithmClass = DipoleFitAlgorithm # Pointer to the class that performs the fit
966 FAILURE_EDGE = 1 # too close to the edge
967 FAILURE_FIT = 2 # failure in the fitting
968 FAILURE_NOT_DIPOLE = 4 # input source is not a putative dipole to begin with
970 @classmethod
971 def getExecutionOrder(cls):
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.
976 """
977 return cls.FLUX_ORDER
979 def __init__(self, config, name, schema, metadata, logName=None):
980 if logName is None:
981 logName = name
982 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
984 self.log = logging.getLogger(logName)
986 self._setupSchema(config, name, schema, metadata)
988 def _setupSchema(self, config, name, schema, metadata):
989 # Get a FunctorKey that can quickly look up the "blessed" centroid value.
990 self.centroidKey = afwTable.Point2DKey(schema["slot_Centroid"])
992 # Add some fields for our outputs, and save their Keys.
993 # Use setattr() to programmatically set the pos/neg named attributes to values, e.g.
994 # self.posCentroidKeyX = 'ip_diffim_DipoleFit_pos_centroid_x'
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)
1020 self.fluxKey = schema.addField(
1021 schema.join(name, "instFlux"), type=float, units="count",
1022 doc="Dipole overall flux")
1024 self.orientationKey = schema.addField(
1025 schema.join(name, "orientation"), type=float, units="deg",
1026 doc="Dipole orientation")
1028 self.separationKey = schema.addField(
1029 schema.join(name, "separation"), type=float, units="pixel",
1030 doc="Pixel separation between positive and negative lobes of dipole")
1032 self.chi2dofKey = schema.addField(
1033 schema.join(name, "chi2dof"), type=float,
1034 doc="Chi2 per degree of freedom of dipole fit")
1036 self.signalToNoiseKey = schema.addField(
1037 schema.join(name, "signalToNoise"), type=float,
1038 doc="Estimated signal-to-noise of dipole fit")
1040 self.classificationFlagKey = schema.addField(
1041 schema.join(name, "flag", "classification"), type="Flag",
1042 doc="Flag indicating diaSource is classified as a dipole")
1044 self.classificationAttemptedFlagKey = schema.addField(
1045 schema.join(name, "flag", "classificationAttempted"), type="Flag",
1046 doc="Flag indicating diaSource was attempted to be classified as a dipole")
1048 self.flagKey = schema.addField(
1049 schema.join(name, "flag"), type="Flag",
1050 doc="General failure flag for dipole fit")
1052 self.edgeFlagKey = schema.addField(
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.
1059 Parameters
1060 ----------
1061 measRecord : `lsst.afw.table.SourceRecord`
1062 diaSources that will be measured using dipole measurement
1063 exposure : `lsst.afw.image.Exposure`
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.
1067 posExp : `lsst.afw.image.Exposure`, optional
1068 "Positive" exposure, typically a science exposure, or None if unavailable
1069 When `posExp` is `None`, will compute `posImage = exposure + negExp`.
1070 negExp : `lsst.afw.image.Exposure`, optional
1071 "Negative" exposure, typically a template exposure, or None if unavailable
1072 When `negExp` is `None`, will compute `negImage = posExp - exposure`.
1074 Notes
1075 -----
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`)
1081 Returns
1082 -------
1083 result : TODO: DM-17458
1084 TODO: DM-17458
1085 """
1087 result = None
1088 pks = measRecord.getFootprint().getPeaks()
1090 # Check if the footprint consists of a putative dipole - else don't fit it.
1091 if (
1092 (len(pks) <= 1) # one peak in the footprint - not a dipole
1093 or (len(pks) > 1 and (np.sign(pks[0].getPeakValue())
1094 == np.sign(pks[-1].getPeakValue()))) # peaks are same sign - not a dipole
1095 ):
1096 measRecord.set(self.classificationFlagKey, False)
1097 measRecord.set(self.classificationAttemptedFlagKey, False)
1098 self.fail(measRecord, measBase.MeasurementError('not a dipole', self.FAILURE_NOT_DIPOLE))
1099 if not self.config.fitAllDiaSources:
1100 return result
1102 try:
1103 alg = self.DipoleFitAlgorithmClass(exposure, posImage=posExp, negImage=negExp)
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)
1111 except pexExcept.LengthError:
1112 self.fail(measRecord, measBase.MeasurementError('edge failure', self.FAILURE_EDGE))
1113 except Exception:
1114 self.fail(measRecord, measBase.MeasurementError('dipole fit failure', self.FAILURE_FIT))
1116 if result is None:
1117 measRecord.set(self.classificationFlagKey, False)
1118 measRecord.set(self.classificationAttemptedFlagKey, False)
1119 return result
1121 self.log.debug("Dipole fit result: %d %s", measRecord.getId(), str(result))
1123 if result.posFlux <= 1.: # usually around 0.1 -- the minimum flux allowed -- i.e. bad fit.
1124 self.fail(measRecord, measBase.MeasurementError('dipole fit failure', self.FAILURE_FIT))
1126 # add chi2, coord/flux uncertainties (TBD), dipole classification
1127 # Add the relevant values to the measRecord
1128 measRecord[self.posFluxKey] = result.posFlux
1129 measRecord[self.posFluxErrKey] = result.signalToNoise # to be changed to actual sigma!
1130 measRecord[self.posCentroidKeyX] = result.posCentroidX
1131 measRecord[self.posCentroidKeyY] = result.posCentroidY
1133 measRecord[self.negFluxKey] = result.negFlux
1134 measRecord[self.negFluxErrKey] = result.signalToNoise # to be changed to actual sigma!
1135 measRecord[self.negCentroidKeyX] = result.negCentroidX
1136 measRecord[self.negCentroidKeyY] = result.negCentroidY
1138 # Dia source flux: average of pos+neg
1139 measRecord[self.fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1140 measRecord[self.orientationKey] = result.orientation
1141 measRecord[self.separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1142 + (result.posCentroidY - result.negCentroidY)**2.)
1143 measRecord[self.centroidKeyX] = result.centroidX
1144 measRecord[self.centroidKeyY] = result.centroidY
1146 measRecord[self.signalToNoiseKey] = result.signalToNoise
1147 measRecord[self.chi2dofKey] = result.redChi2
1149 self.doClassify(measRecord, result.chi2)
1151 def doClassify(self, measRecord, chi2val):
1152 """Classify a source as a dipole.
1154 Parameters
1155 ----------
1156 measRecord : TODO: DM-17458
1157 TODO: DM-17458
1158 chi2val : TODO: DM-17458
1159 TODO: DM-17458
1161 Notes
1162 -----
1163 Sources are classified as dipoles, or not, according to three criteria:
1165 1. Does the total signal-to-noise surpass the ``minSn``?
1166 2. Are the pos/neg fluxes greater than 1.0 and no more than 0.65 (``maxFluxRatio``)
1167 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1168 3. Is it a good fit (``chi2dof`` < 1)? (Currently not used.)
1169 """
1171 # First, does the total signal-to-noise surpass the minSn?
1172 passesSn = measRecord[self.signalToNoiseKey] > self.config.minSn
1174 # Second, are the pos/neg fluxes greater than 1.0 and no more than 0.65 (param maxFluxRatio)
1175 # of the total flux? By default this will never happen since posFlux = negFlux.
1176 passesFluxPos = (abs(measRecord[self.posFluxKey])
1177 / (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio
1178 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1179 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1180 / (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio
1181 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1182 allPass = (passesSn and passesFluxPos and passesFluxNeg) # and passesChi2)
1184 # Third, is it a good fit (chi2dof < 1)?
1185 # Use scipy's chi2 cumulative distrib to estimate significance
1186 # This doesn't really work since I don't trust the values in the variance plane (which
1187 # affects the least-sq weights, which affects the resulting chi2).
1188 # But I'm going to keep this here for future use.
1189 if False:
1190 from scipy.stats import chi2
1191 ndof = chi2val / measRecord[self.chi2dofKey]
1192 significance = chi2.cdf(chi2val, ndof)
1193 passesChi2 = significance < self.config.maxChi2DoF
1194 allPass = allPass and passesChi2
1196 measRecord.set(self.classificationAttemptedFlagKey, True)
1198 if allPass: # Note cannot pass `allPass` into the `measRecord.set()` call below...?
1199 measRecord.set(self.classificationFlagKey, True)
1200 else:
1201 measRecord.set(self.classificationFlagKey, False)
1203 def fail(self, measRecord, error=None):
1204 """Catch failures and set the correct flags.
1205 """
1207 measRecord.set(self.flagKey, True)
1208 if error is not None:
1209 if error.getFlagBit() == self.FAILURE_EDGE:
1210 self.log.warning('DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1211 measRecord.set(self.edgeFlagKey, True)
1212 if error.getFlagBit() == self.FAILURE_FIT:
1213 self.log.warning('DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1214 measRecord.set(self.flagKey, True)
1215 if error.getFlagBit() == self.FAILURE_NOT_DIPOLE:
1216 self.log.debug('DipoleFitPlugin not run on record %d: %s',
1217 measRecord.getId(), str(error))
1218 measRecord.set(self.classificationAttemptedFlagKey, False)
1219 measRecord.set(self.flagKey, True)
1220 else:
1221 self.log.warning('DipoleFitPlugin failed on record %d', measRecord.getId())