lsst.ip.diffim g85b7a35e91+e634f8099e
Loading...
Searching...
No Matches
dipoleFitTask.py
Go to the documentation of this file.
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#
22
23import logging
24import numpy as np
25import warnings
26
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
36
37__all__ = ("DipoleFitTask", "DipoleFitPlugin", "DipoleFitTaskConfig", "DipoleFitPluginConfig",
38 "DipoleFitAlgorithm")
39
40
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.
43
44
45class DipoleFitPluginConfig(measBase.SingleFramePluginConfig):
46 """Configuration for DipoleFitPlugin
47 """
48
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)""")
53
54 maxSeparation = pexConfig.Field(
55 dtype=float, default=5.,
56 doc="Assume dipole is not separated by more than maxSeparation * psfSigma")
57
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)""")
62
63 tolerance = pexConfig.Field(
64 dtype=float, default=1e-7,
65 doc="Fit tolerance")
66
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")
75
76 fitSeparateNegParams = pexConfig.Field(
77 dtype=bool, default=False,
78 doc="Include parameters to fit for negative values (flux, gradient) separately from pos.")
79
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")
84
85 maxFluxRatio = pexConfig.Field(
86 dtype=float, default=0.65,
87 doc="Maximum flux ratio in either lobe to be considered a dipole")
88
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).""")
94
95 maxFootprintArea = pexConfig.Field(
96 dtype=int, default=5_000,
97 doc=("Maximum area for footprints before they are ignored as large; "
98 "non-positive means no threshold applied"))
99
100
101class DipoleFitTaskConfig(measBase.SingleFrameMeasurementConfig):
102 """Measurement of detected diaSources as dipoles
103
104 Currently we keep the "old" DipoleMeasurement algorithms turned on.
105 """
106
107 def setDefaults(self):
108 measBase.SingleFrameMeasurementConfig.setDefaults(self)
109
110 self.plugins.names = ["base_CircularApertureFlux",
111 "base_PixelFlags",
112 "base_SkyCoord",
113 "base_PsfFlux",
114 "base_SdssCentroid",
115 "base_SdssShape",
116 "base_GaussianFlux",
117 "base_PeakLikelihoodFlux",
118 "base_PeakCentroid",
119 "base_NaiveCentroid",
120 "ip_diffim_NaiveDipoleCentroid",
121 "ip_diffim_NaiveDipoleFlux",
122 "ip_diffim_PsfDipoleFlux",
123 "ip_diffim_ClassificationDipole",
124 ]
125
126 self.slots.calibFlux = None
127 self.slots.modelFlux = None
128 self.slots.gaussianFlux = None
129 self.slots.shape = "base_SdssShape"
130 self.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
132
133
134class DipoleFitTask(measBase.SingleFrameMeasurementTask):
135 """A task that fits a dipole to a difference image, with an optional separate detection image.
136
137 Because it subclasses SingleFrameMeasurementTask, and calls
138 SingleFrameMeasurementTask.run() from its run() method, it still
139 can be used identically to a standard SingleFrameMeasurementTask.
140 """
141
142 ConfigClass = DipoleFitTaskConfig
143 _DefaultName = "ip_diffim_DipoleFit"
144
145 def __init__(self, schema, algMetadata=None, **kwargs):
146
147 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwargs)
148
149 dpFitPluginConfig = self.config.plugins['ip_diffim_DipoleFit']
150
151 self.dipoleFitter = DipoleFitPlugin(dpFitPluginConfig, name=self._DefaultName,
152 schema=schema, metadata=algMetadata,
153 logName=self.log.name)
154
155 @timeMethod
156 def run(self, sources, exposure, posExp=None, negExp=None, **kwargs):
157 """Run dipole measurement and classification
158
159 Parameters
160 ----------
162 ``diaSources`` that will be measured using dipole measurement
163 exposure : `lsst.afw.image.Exposure`
164 The difference exposure on which the ``diaSources`` of the ``sources`` parameter
165 were detected. If neither ``posExp`` nor ``negExp`` are set, then the dipole is also
166 fitted directly to this difference image.
167 posExp : `lsst.afw.image.Exposure`, optional
168 "Positive" exposure, typically a science exposure, or None if unavailable
169 When `posExp` is `None`, will compute `posImage = exposure + negExp`.
170 negExp : `lsst.afw.image.Exposure`, optional
171 "Negative" exposure, typically a template exposure, or None if unavailable
172 When `negExp` is `None`, will compute `negImage = posExp - exposure`.
173 **kwargs
174 Additional keyword arguments for `lsst.meas.base.sfm.SingleFrameMeasurementTask`.
175 """
176
177 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwargs)
178
179 if not sources:
180 return
181
182 for source in sources:
183 self.dipoleFitter.measure(source, exposure, posExp, negExp)
184
185
187 """Lightweight class containing methods for generating a dipole model for fitting
188 to sources in diffims, used by DipoleFitAlgorithm.
189
190 See also:
191 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
192 """
193
194 def __init__(self):
195 import lsstDebug
196 self.debug = lsstDebug.Info(__name__).debug
197 self.log = logging.getLogger(__name__)
198
199 def makeBackgroundModel(self, in_x, pars=None):
200 """Generate gradient model (2-d array) with up to 2nd-order polynomial
201
202 Parameters
203 ----------
204 in_x : `numpy.array`
205 (2, w, h)-dimensional `numpy.array`, containing the
206 input x,y meshgrid providing the coordinates upon which to
207 compute the gradient. This will typically be generated via
208 `_generateXYGrid()`. `w` and `h` correspond to the width and
209 height of the desired grid.
210 pars : `list` of `float`, optional
211 Up to 6 floats for up
212 to 6 2nd-order 2-d polynomial gradient parameters, in the
213 following order: (intercept, x, y, xy, x**2, y**2). If `pars`
214 is emtpy or `None`, do nothing and return `None` (for speed).
215
216 Returns
217 -------
218 result : `None` or `numpy.array`
219 return None, or 2-d numpy.array of width/height matching
220 input bbox, containing computed gradient values.
221 """
222
223 # Don't fit for other gradient parameters if the intercept is not included.
224 if (pars is None) or (len(pars) <= 0) or (pars[0] is None):
225 return
226
227 y, x = in_x[0, :], in_x[1, :]
228 gradient = np.full_like(x, pars[0], dtype='float64')
229 if len(pars) > 1 and pars[1] is not None:
230 gradient += pars[1] * x
231 if len(pars) > 2 and pars[2] is not None:
232 gradient += pars[2] * y
233 if len(pars) > 3 and pars[3] is not None:
234 gradient += pars[3] * (x * y)
235 if len(pars) > 4 and pars[4] is not None:
236 gradient += pars[4] * (x * x)
237 if len(pars) > 5 and pars[5] is not None:
238 gradient += pars[5] * (y * y)
239
240 return gradient
241
242 def _generateXYGrid(self, bbox):
243 """Generate a meshgrid covering the x,y coordinates bounded by bbox
244
245 Parameters
246 ----------
247 bbox : `lsst.geom.Box2I`
248 input Bounding Box defining the coordinate limits
249
250 Returns
251 -------
252 in_x : `numpy.array`
253 (2, w, h)-dimensional numpy array containing the grid indexing over x- and
254 y- coordinates
255 """
256
257 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
258 in_x = np.array([y, x]).astype(np.float64)
259 in_x[0, :] -= np.mean(in_x[0, :])
260 in_x[1, :] -= np.mean(in_x[1, :])
261 return in_x
262
263 def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0):
264 """Extract the image from a ``~lsst.afw.detection.HeavyFootprint``
265 as an `lsst.afw.image.ImageF`.
266
267 Parameters
268 ----------
270 HeavyFootprint to use to generate the subimage
271 badfill : `float`, optional
272 Value to fill in pixels in extracted image that are outside the footprint
273 grow : `int`
274 Optionally grow the footprint by this amount before extraction
275
276 Returns
277 -------
278 subim2 : `lsst.afw.image.ImageF`
279 An `~lsst.afw.image.ImageF` containing the subimage.
280 """
281 bbox = fp.getBBox()
282 if grow > 0:
283 bbox.grow(grow)
284
285 subim2 = afwImage.ImageF(bbox, badfill)
286 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
287 return subim2
288
289 def fitFootprintBackground(self, source, posImage, order=1):
290 """Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
291
292 Only fit the pixels OUTSIDE of the footprint, but within its bounding box.
293
294 Parameters
295 ----------
297 SourceRecord, the footprint of which is to be fit
298 posImage : `lsst.afw.image.Exposure`
299 The exposure from which to extract the footprint subimage
300 order : `int`
301 Polynomial order of background gradient to fit.
302
303 Returns
304 -------
305 pars : `tuple` of `float`
306 `tuple` of length (1 if order==0; 3 if order==1; 6 if order == 2),
307 containing the resulting fit parameters
308 """
309
310 # TODO look into whether to use afwMath background methods -- see
311 # http://lsst-web.ncsa.illinois.edu/doxygen/x_masterDoxyDoc/_background_example.html
312 fp = source.getFootprint()
313 bbox = fp.getBBox()
314 bbox.grow(3)
315 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
316
317 # This code constructs the footprint image so that we can identify the pixels that are
318 # outside the footprint (but within the bounding box). These are the pixels used for
319 # fitting the background.
320 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
321 posFpImg = self._getHeavyFootprintSubimage(posHfp, grow=3)
322
323 isBg = np.isnan(posFpImg.getArray()).ravel()
324
325 data = posImg.getArray().ravel()
326 data = data[isBg]
327 B = data
328
329 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
330 x = x.astype(np.float64).ravel()
331 x -= np.mean(x)
332 x = x[isBg]
333 y = y.astype(np.float64).ravel()
334 y -= np.mean(y)
335 y = y[isBg]
336 b = np.ones_like(x, dtype=np.float64)
337
338 M = np.vstack([b]).T # order = 0
339 if order == 1:
340 M = np.vstack([b, x, y]).T
341 elif order == 2:
342 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
343
344 pars = np.linalg.lstsq(M, B, rcond=-1)[0]
345 return pars
346
347 def makeStarModel(self, bbox, psf, xcen, ycen, flux):
348 """Generate a 2D image model of a single PDF centered at the given coordinates.
349
350 Parameters
351 ----------
352 bbox : `lsst.geom.Box`
353 Bounding box marking pixel coordinates for generated model
354 psf : TODO: DM-17458
355 Psf model used to generate the 'star'
356 xcen : `float`
357 Desired x-centroid of the 'star'
358 ycen : `float`
359 Desired y-centroid of the 'star'
360 flux : `float`
361 Desired flux of the 'star'
362
363 Returns
364 -------
365 p_Im : `lsst.afw.image.Image`
366 2-d stellar image of width/height matching input ``bbox``,
367 containing PSF with given centroid and flux
368 """
369
370 # Generate the psf image, normalize to flux
371 psf_img = psf.computeImage(geom.Point2D(xcen, ycen)).convertF()
372 psf_img_sum = np.nansum(psf_img.getArray())
373 psf_img *= (flux/psf_img_sum)
374
375 # Clip the PSF image bounding box to fall within the footprint bounding box
376 psf_box = psf_img.getBBox()
377 psf_box.clip(bbox)
378 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
379
380 # Then actually crop the psf image.
381 # Usually not necessary, but if the dipole is near the edge of the image...
382 # Would be nice if we could compare original pos_box with clipped pos_box and
383 # see if it actually was clipped.
384 p_Im = afwImage.ImageF(bbox)
385 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
386 tmpSubim += psf_img
387
388 return p_Im
389
390 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
391 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
392 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
393 **kwargs):
394 """Generate dipole model with given parameters.
395
396 This is the function whose sum-of-squared difference from data
397 is minimized by `lmfit`.
398
399 x : TODO: DM-17458
400 Input independent variable. Used here as the grid on
401 which to compute the background gradient model.
402 flux : `float`
403 Desired flux of the positive lobe of the dipole
404 xcenPos, ycenPos : `float`
405 Desired x,y-centroid of the positive lobe of the dipole
406 xcenNeg, ycenNeg : `float`
407 Desired x,y-centroid of the negative lobe of the dipole
408 fluxNeg : `float`, optional
409 Desired flux of the negative lobe of the dipole, set to 'flux' if None
410 b, x1, y1, xy, x2, y2 : `float`
411 Gradient parameters for positive lobe.
412 bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg : `float`, optional
413 Gradient parameters for negative lobe.
414 They are set to the corresponding positive values if None.
415
416 **kwargs : `dict` [`str`]
417 Keyword arguments passed through ``lmfit`` and
418 used by this function. These must include:
419
420 - ``psf`` Psf model used to generate the 'star'
421 - ``rel_weight`` Used to signify least-squares weighting of posImage/negImage
422 relative to diffim. If ``rel_weight == 0`` then posImage/negImage are ignored.
423 - ``bbox`` Bounding box containing region to be modelled
424
425 Returns
426 -------
427 zout : `numpy.array`
428 Has width and height matching the input bbox, and
429 contains the dipole model with given centroids and flux(es). If
430 ``rel_weight`` = 0, this is a 2-d array with dimensions matching
431 those of bbox; otherwise a stack of three such arrays,
432 representing the dipole (diffim), positive, and negative images
433 respectively.
434 """
435
436 psf = kwargs.get('psf')
437 rel_weight = kwargs.get('rel_weight') # if > 0, we're including pre-sub. images
438 fp = kwargs.get('footprint')
439 bbox = fp.getBBox()
440
441 if fluxNeg is None:
442 fluxNeg = flux
443
444 if self.debug:
445 self.log.debug('%.2f %.2f %.2f %.2f %.2f %.2f',
446 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
447 if x1 is not None:
448 self.log.debug(' %.2f %.2f %.2f', b, x1, y1)
449 if xy is not None:
450 self.log.debug(' %.2f %.2f %.2f', xy, x2, y2)
451
452 posIm = self.makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
453 negIm = self.makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
454
455 in_x = x
456 if in_x is None: # use the footprint to generate the input grid
457 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
458 in_x = np.array([x, y]) * 1.
459 in_x[0, :] -= in_x[0, :].mean() # center it!
460 in_x[1, :] -= in_x[1, :].mean()
461
462 if b is not None:
463 gradient = self.makeBackgroundModel(in_x, (b, x1, y1, xy, x2, y2))
464
465 # If bNeg is None, then don't fit the negative background separately
466 if bNeg is not None:
467 gradientNeg = self.makeBackgroundModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))
468 else:
469 gradientNeg = gradient
470
471 posIm.getArray()[:, :] += gradient
472 negIm.getArray()[:, :] += gradientNeg
473
474 # Generate the diffIm model
475 diffIm = afwImage.ImageF(bbox)
476 diffIm += posIm
477 diffIm -= negIm
478
479 zout = diffIm.getArray()
480 if rel_weight > 0.:
481 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
482
483 return zout
484
485
487 """Fit a dipole model using an image difference.
488
489 See also:
490 `DMTN-007: Dipole characterization for image differencing <https://dmtn-007.lsst.io>`_.
491 """
492
493 # This is just a private version number to sync with the ipython notebooks that I have been
494 # using for algorithm development.
495 _private_version_ = '0.0.5'
496
497 # Below is a (somewhat incomplete) list of improvements
498 # that would be worth investigating, given the time:
499
500 # todo 1. evaluate necessity for separate parameters for pos- and neg- images
501 # todo 2. only fit background OUTSIDE footprint (DONE) and dipole params INSIDE footprint (NOT DONE)?
502 # todo 3. correct normalization of least-squares weights based on variance planes
503 # todo 4. account for PSFs that vary across the exposures (should be happening by default?)
504 # todo 5. correctly account for NA/masks (i.e., ignore!)
505 # todo 6. better exception handling in the plugin
506 # todo 7. better classification of dipoles (e.g. by comparing chi2 fit vs. monopole?)
507 # todo 8. (DONE) Initial fast estimate of background gradient(s) params -- perhaps using numpy.lstsq
508 # todo 9. (NOT NEEDED - see (2)) Initial fast test whether a background gradient needs to be fit
509 # todo 10. (DONE) better initial estimate for flux when there's a strong gradient
510 # todo 11. (DONE) requires a new package `lmfit` -- investiate others? (astropy/scipy/iminuit?)
511
512 def __init__(self, diffim, posImage=None, negImage=None):
513 """Algorithm to run dipole measurement on a diaSource
514
515 Parameters
516 ----------
517 diffim : `lsst.afw.image.Exposure`
518 Exposure on which the diaSources were detected
519 posImage : `lsst.afw.image.Exposure`
520 "Positive" exposure from which the template was subtracted
521 negImage : `lsst.afw.image.Exposure`
522 "Negative" exposure which was subtracted from the posImage
523 """
524
525 self.diffim = diffim
526 self.posImage = posImage
527 self.negImage = negImage
528 self.psfSigma = None
529 if diffim is not None:
530 diffimPsf = diffim.getPsf()
531 diffimAvgPos = diffimPsf.getAveragePosition()
532 self.psfSigma = diffimPsf.computeShape(diffimAvgPos).getDeterminantRadius()
533
534 self.log = logging.getLogger(__name__)
535
536 import lsstDebug
537 self.debug = lsstDebug.Info(__name__).debug
538
539 def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5,
540 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
541 separateNegParams=True, verbose=False):
542 """Fit a dipole model to an input difference image.
543
544 Actually, fits the subimage bounded by the input source's
545 footprint) and optionally constrain the fit using the
546 pre-subtraction images posImage and negImage.
547
548 Parameters
549 ----------
550 source : TODO: DM-17458
551 TODO: DM-17458
552 tol : float, optional
553 TODO: DM-17458
554 rel_weight : `float`, optional
555 TODO: DM-17458
556 fitBackground : `int`, optional
557 TODO: DM-17458
558 bgGradientOrder : `int`, optional
559 TODO: DM-17458
560 maxSepInSigma : `float`, optional
561 TODO: DM-17458
562 separateNegParams : `bool`, optional
563 TODO: DM-17458
564 verbose : `bool`, optional
565 TODO: DM-17458
566
567 Returns
568 -------
569 result : `lmfit.MinimizerResult`
570 return `lmfit.MinimizerResult` object containing the fit
571 parameters and other information.
572 """
573
574 # Only import lmfit if someone wants to use the new DipoleFitAlgorithm.
575 import lmfit
576
577 fp = source.getFootprint()
578 bbox = fp.getBBox()
579 subim = afwImage.MaskedImageF(self.diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
580
581 z = diArr = subim.getArrays()[0]
582 weights = 1. / subim.getArrays()[2] # get the weights (=1/variance)
583
584 if rel_weight > 0. and ((self.posImage is not None) or (self.negImage is not None)):
585 if self.negImage is not None:
586 negSubim = afwImage.MaskedImageF(self.negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
587 if self.posImage is not None:
588 posSubim = afwImage.MaskedImageF(self.posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
589 if self.posImage is None: # no science image provided; generate it from diffim + negImage
590 posSubim = subim.clone()
591 posSubim += negSubim
592 if self.negImage is None: # no template provided; generate it from the posImage - diffim
593 negSubim = posSubim.clone()
594 negSubim -= subim
595
596 z = np.append([z], [posSubim.getArrays()[0],
597 negSubim.getArrays()[0]], axis=0)
598 # Weight the pos/neg images by rel_weight relative to the diffim
599 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
600 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
601 else:
602 rel_weight = 0. # a short-cut for "don't include the pre-subtraction data"
603
604 # It seems that `lmfit` requires a static functor as its optimized method, which eliminates
605 # the ability to pass a bound method or other class method. Here we write a wrapper which
606 # makes this possible.
607 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
608 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
609 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
610 **kwargs):
611 """Generate dipole model with given parameters.
612
613 It simply defers to `modelObj.makeModel()`, where `modelObj` comes
614 out of `kwargs['modelObj']`.
615 """
616 modelObj = kwargs.pop('modelObj')
617 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
618 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
619 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
620 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
621
622 dipoleModel = DipoleModel()
623
624 modelFunctor = dipoleModelFunctor # dipoleModel.makeModel does not work for now.
625 # Create the lmfit model (lmfit uses scipy 'leastsq' option by default - Levenberg-Marquardt)
626 # Note we can also tell it to drop missing values from the data.
627 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing='drop')
628
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
633
634 pks = fp.getPeaks()
635
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()
640
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
644
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
650
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)
662
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
667
668 # TBD: set max. flux limit?
669 gmod.set_param_hint('flux', value=posFlux, min=0.1)
670
671 if separateNegParams:
672 # TBD: set max negative lobe flux limit?
673 gmod.set_param_hint('fluxNeg', value=np.abs(negFlux), min=0.1)
674
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)
688
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)
697
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)
707
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])
728
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()
733
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
737
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])
745
746 # Set the weights to zero if mask is False
747 if np.any(~mask):
748 weights[~mask] = 0.
749
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 # Ignore lmfit unknown argument warnings:
755 # "psf, rel_weight, footprint, modelObj" all become pass-through kwargs for makeModel.
756 warnings.filterwarnings("ignore", "The keyword argument .* does not match", UserWarning)
757 result = gmod.fit(z, weights=weights, x=in_x, max_nfev=250,
758 method="leastsq", # TODO: try using `least_squares` here for speed/robustness
759 verbose=verbose,
760 # see scipy docs for the meaning of these keywords
761 fit_kws={'ftol': tol, 'xtol': tol, 'gtol': tol,
762 # Our model is float32 internally, so we need a larger epsfcn.
763 'epsfcn': 1e-8},
764 psf=self.diffim.getPsf(), # hereon: kwargs that get passed to makeModel()
765 rel_weight=rel_weight,
766 footprint=fp,
767 modelObj=dipoleModel)
768
769 if verbose: # the ci_report() seems to fail if neg params are constrained -- TBD why.
770 # Never wanted in production - this takes a long time (longer than the fit!)
771 # This is how to get confidence intervals out:
772 # https://lmfit.github.io/lmfit-py/confidence.html and
773 # http://cars9.uchicago.edu/software/python/lmfit/model.html
774 print(result.fit_report(show_correl=False))
775 if separateNegParams:
776 print(result.ci_report())
777
778 return result
779
780 def fitDipole(self, source, tol=1e-7, rel_weight=0.1,
781 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
782 bgGradientOrder=1, verbose=False, display=False):
783 """Fit a dipole model to an input ``diaSource`` (wraps `fitDipoleImpl`).
784
785 Actually, fits the subimage bounded by the input source's
786 footprint) and optionally constrain the fit using the
787 pre-subtraction images self.posImage (science) and
788 self.negImage (template). Wraps the output into a
789 `pipeBase.Struct` named tuple after computing additional
790 statistics such as orientation and SNR.
791
792 Parameters
793 ----------
795 Record containing the (merged) dipole source footprint detected on the diffim
796 tol : `float`, optional
797 Tolerance parameter for scipy.leastsq() optimization
798 rel_weight : `float`, optional
799 Weighting of posImage/negImage relative to the diffim in the fit
800 fitBackground : `int`, {0, 1, 2}, optional
801 How to fit linear background gradient in posImage/negImage
802
803 - 0: do not fit background at all
804 - 1 (default): pre-fit the background using linear least squares and then do not fit it
805 as part of the dipole fitting optimization
806 - 2: pre-fit the background using linear least squares (as in 1), and use the parameter
807 estimates from that fit as starting parameters for an integrated "re-fit" of the
808 background as part of the overall dipole fitting optimization.
809 maxSepInSigma : `float`, optional
810 Allowed window of centroid parameters relative to peak in input source footprint
811 separateNegParams : `bool`, optional
812 Fit separate parameters to the flux and background gradient in
813 bgGradientOrder : `int`, {0, 1, 2}, optional
814 Desired polynomial order of background gradient
815 verbose: `bool`, optional
816 Be verbose
817 display
818 Display input data, best fit model(s) and residuals in a matplotlib window.
819
820 Returns
821 -------
822 result : `struct`
823 `pipeBase.Struct` object containing the fit parameters and other information.
824
825 result : `callable`
826 `lmfit.MinimizerResult` object for debugging and error estimation, etc.
827
828 Notes
829 -----
830 Parameter `fitBackground` has three options, thus it is an integer:
831
832 """
833
834 fitResult = self.fitDipoleImpl(
835 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
836 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
837 bgGradientOrder=bgGradientOrder, verbose=verbose)
838
839 # Display images, model fits and residuals (currently uses matplotlib display functions)
840 if display:
841 fp = source.getFootprint()
842 self.displayFitResults(fp, fitResult)
843
844 fitParams = fitResult.best_values
845 if fitParams['flux'] <= 1.: # usually around 0.1 -- the minimum flux allowed -- i.e. bad fit.
846 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
847 negCentroidX=np.nan, negCentroidY=np.nan,
848 posFlux=np.nan, negFlux=np.nan, posFluxErr=np.nan, negFluxErr=np.nan,
849 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
850 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
851 return out, fitResult
852
853 centroid = ((fitParams['xcenPos'] + fitParams['xcenNeg']) / 2.,
854 (fitParams['ycenPos'] + fitParams['ycenNeg']) / 2.)
855 dx, dy = fitParams['xcenPos'] - fitParams['xcenNeg'], fitParams['ycenPos'] - fitParams['ycenNeg']
856 angle = np.arctan2(dy, dx) / np.pi * 180. # convert to degrees (should keep as rad?)
857
858 # Exctract flux value, compute signalToNoise from flux/variance_within_footprint
859 # Also extract the stderr of flux estimate.
860 def computeSumVariance(exposure, footprint):
861 box = footprint.getBBox()
862 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
863 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
864
865 fluxVal = fluxVar = fitParams['flux']
866 fluxErr = fluxErrNeg = fitResult.params['flux'].stderr
867 if self.posImage is not None:
868 fluxVar = computeSumVariance(self.posImage, source.getFootprint())
869 else:
870 fluxVar = computeSumVariance(self.diffim, source.getFootprint())
871
872 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
873 if separateNegParams:
874 fluxValNeg = fitParams['fluxNeg']
875 fluxErrNeg = fitResult.params['fluxNeg'].stderr
876 if self.negImage is not None:
877 fluxVarNeg = computeSumVariance(self.negImage, source.getFootprint())
878
879 try:
880 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
881 except ZeroDivisionError: # catch divide by zero - should never happen.
882 signalToNoise = np.nan
883
884 out = Struct(posCentroidX=fitParams['xcenPos'], posCentroidY=fitParams['ycenPos'],
885 negCentroidX=fitParams['xcenNeg'], negCentroidY=fitParams['ycenNeg'],
886 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg,
887 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
888 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
889
890 # fitResult may be returned for debugging
891 return out, fitResult
892
893 def displayFitResults(self, footprint, result):
894 """Display data, model fits and residuals (currently uses matplotlib display functions).
895
896 Parameters
897 ----------
898 footprint : TODO: DM-17458
899 Footprint containing the dipole that was fit
900 result : `lmfit.MinimizerResult`
901 `lmfit.MinimizerResult` object returned by `lmfit` optimizer
902
903 Returns
904 -------
905 fig : `matplotlib.pyplot.plot`
906 """
907 try:
908 import matplotlib.pyplot as plt
909 except ImportError as err:
910 self.log.warning('Unable to import matplotlib: %s', err)
911 raise err
912
913 def display2dArray(arr, title='Data', extent=None):
914 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range.
915 """
916 fig = plt.imshow(arr, origin='lower', interpolation='none', cmap='gray', extent=extent)
917 plt.title(title)
918 plt.colorbar(fig, cmap='gray')
919 return fig
920
921 z = result.data
922 fit = result.best_fit
923 bbox = footprint.getBBox()
924 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
925 if z.shape[0] == 3:
926 fig = plt.figure(figsize=(8, 8))
927 for i in range(3):
928 plt.subplot(3, 3, i*3+1)
929 display2dArray(z[i, :], 'Data', extent=extent)
930 plt.subplot(3, 3, i*3+2)
931 display2dArray(fit[i, :], 'Model', extent=extent)
932 plt.subplot(3, 3, i*3+3)
933 display2dArray(z[i, :] - fit[i, :], 'Residual', extent=extent)
934 return fig
935 else:
936 fig = plt.figure(figsize=(8, 2.5))
937 plt.subplot(1, 3, 1)
938 display2dArray(z, 'Data', extent=extent)
939 plt.subplot(1, 3, 2)
940 display2dArray(fit, 'Model', extent=extent)
941 plt.subplot(1, 3, 3)
942 display2dArray(z - fit, 'Residual', extent=extent)
943 return fig
944
945 plt.show()
946
947
948@measBase.register("ip_diffim_DipoleFit")
949class DipoleFitPlugin(measBase.SingleFramePlugin):
950 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``.
951
952 This measurement plugin accepts up to three input images in
953 its `measure` method. If these are provided, it includes data
954 from the pre-subtraction posImage (science image) and optionally
955 negImage (template image) to constrain the fit. The meat of the
956 fitting routines are in the class `~lsst.module.name.DipoleFitAlgorithm`.
957
958 Notes
959 -----
960 The motivation behind this plugin and the necessity for including more than
961 one exposure are documented in DMTN-007 (http://dmtn-007.lsst.io).
962
963 This class is named `ip_diffim_DipoleFit` so that it may be used alongside
964 the existing `ip_diffim_DipoleMeasurement` classes until such a time as those
965 are deemed to be replaceable by this.
966 """
967
968 ConfigClass = DipoleFitPluginConfig
969 DipoleFitAlgorithmClass = DipoleFitAlgorithm # Pointer to the class that performs the fit
970
971 FAILURE_EDGE = 1 # too close to the edge
972 FAILURE_FIT = 2 # failure in the fitting
973 FAILURE_NOT_DIPOLE = 4 # input source is not a putative dipole to begin with
974
975 @classmethod
977 """Set execution order to `FLUX_ORDER`.
978
979 This includes algorithms that require both `getShape()` and `getCentroid()`,
980 in addition to a Footprint and its Peaks.
981 """
982 return cls.FLUX_ORDER
983
984 def __init__(self, config, name, schema, metadata, logName=None):
985 if logName is None:
986 logName = name
987 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName)
988
989 self.log = logging.getLogger(logName)
990
991 self._setupSchema(config, name, schema, metadata)
992
993 def _setupSchema(self, config, name, schema, metadata):
994 # Get a FunctorKey that can quickly look up the "blessed" centroid value.
995 self.centroidKey = afwTable.Point2DKey(schema["slot_Centroid"])
996
997 # Add some fields for our outputs, and save their Keys.
998 # Use setattr() to programmatically set the pos/neg named attributes to values, e.g.
999 # self.posCentroidKeyX = 'ip_diffim_DipoleFit_pos_centroid_x'
1000
1001 for pos_neg in ['pos', 'neg']:
1002
1003 key = schema.addField(
1004 schema.join(name, pos_neg, "instFlux"), type=float, units="count",
1005 doc="Dipole {0} lobe flux".format(pos_neg))
1006 setattr(self, ''.join((pos_neg, 'FluxKey')), key)
1007
1008 key = schema.addField(
1009 schema.join(name, pos_neg, "instFluxErr"), type=float, units="count",
1010 doc="1-sigma uncertainty for {0} dipole flux".format(pos_neg))
1011 setattr(self, ''.join((pos_neg, 'FluxErrKey')), key)
1012
1013 for x_y in ['x', 'y']:
1014 key = schema.addField(
1015 schema.join(name, pos_neg, "centroid", x_y), type=float, units="pixel",
1016 doc="Dipole {0} lobe centroid".format(pos_neg))
1017 setattr(self, ''.join((pos_neg, 'CentroidKey', x_y.upper())), key)
1018
1019 for x_y in ['x', 'y']:
1020 key = schema.addField(
1021 schema.join(name, "centroid", x_y), type=float, units="pixel",
1022 doc="Dipole centroid")
1023 setattr(self, ''.join(('centroidKey', x_y.upper())), key)
1024
1025 self.fluxKey = schema.addField(
1026 schema.join(name, "instFlux"), type=float, units="count",
1027 doc="Dipole overall flux")
1028
1029 self.orientationKey = schema.addField(
1030 schema.join(name, "orientation"), type=float, units="deg",
1031 doc="Dipole orientation")
1032
1033 self.separationKey = schema.addField(
1034 schema.join(name, "separation"), type=float, units="pixel",
1035 doc="Pixel separation between positive and negative lobes of dipole")
1036
1037 self.chi2dofKey = schema.addField(
1038 schema.join(name, "chi2dof"), type=float,
1039 doc="Chi2 per degree of freedom of dipole fit")
1040
1041 self.signalToNoiseKey = schema.addField(
1042 schema.join(name, "signalToNoise"), type=float,
1043 doc="Estimated signal-to-noise of dipole fit")
1044
1045 self.classificationFlagKey = schema.addField(
1046 schema.join(name, "flag", "classification"), type="Flag",
1047 doc="Flag indicating diaSource is classified as a dipole")
1048
1049 self.classificationAttemptedFlagKey = schema.addField(
1050 schema.join(name, "flag", "classificationAttempted"), type="Flag",
1051 doc="Flag indicating diaSource was attempted to be classified as a dipole")
1052
1053 self.flagKey = schema.addField(
1054 schema.join(name, "flag"), type="Flag",
1055 doc="General failure flag for dipole fit")
1056
1057 self.edgeFlagKey = schema.addField(
1058 schema.join(name, "flag", "edge"), type="Flag",
1059 doc="Flag set when dipole is too close to edge of image")
1060
1061 def measure(self, measRecord, exposure, posExp=None, negExp=None):
1062 """Perform the non-linear least squares minimization on the putative dipole source.
1063
1064 Parameters
1065 ----------
1066 measRecord : `lsst.afw.table.SourceRecord`
1067 diaSources that will be measured using dipole measurement
1068 exposure : `lsst.afw.image.Exposure`
1069 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp`
1070 If both `posExp` and `negExp` are `None`, will attempt to fit the
1071 dipole to just the `exposure` with no constraint.
1072 posExp : `lsst.afw.image.Exposure`, optional
1073 "Positive" exposure, typically a science exposure, or None if unavailable
1074 When `posExp` is `None`, will compute `posImage = exposure + negExp`.
1075 negExp : `lsst.afw.image.Exposure`, optional
1076 "Negative" exposure, typically a template exposure, or None if unavailable
1077 When `negExp` is `None`, will compute `negImage = posExp - exposure`.
1078
1079 Notes
1080 -----
1081 The main functionality of this routine was placed outside of
1082 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that
1083 `DipoleFitAlgorithm.fitDipole()` can be called separately for
1084 testing (@see `tests/testDipoleFitter.py`)
1085
1086 Returns
1087 -------
1088 result : TODO: DM-17458
1089 TODO: DM-17458
1090 """
1091
1092 result = None
1093 pks = measRecord.getFootprint().getPeaks()
1094
1095 # Check if the footprint consists of a putative dipole - else don't fit it.
1096 if (
1097 # One peak in the footprint (not a dipole)
1098 (len(pks) <= 1)
1099 # Peaks are the same sign (not a dipole)
1100 or (len(pks) > 1 and (np.sign(pks[0].getPeakValue())
1101 == np.sign(pks[-1].getPeakValue())))
1102 # Footprint is too large (not a dipole)
1103 or (measRecord.getFootprint().getArea() > self.config.maxFootprintArea)
1104 ):
1105 measRecord.set(self.classificationFlagKey, False)
1106 measRecord.set(self.classificationAttemptedFlagKey, False)
1107 self.fail(measRecord, measBase.MeasurementError('not a dipole', self.FAILURE_NOT_DIPOLE))
1108 if not self.config.fitAllDiaSources:
1109 return result
1110
1111 try:
1112 alg = self.DipoleFitAlgorithmClass(exposure, posImage=posExp, negImage=negExp)
1113 result, _ = alg.fitDipole(
1114 measRecord, rel_weight=self.config.relWeight,
1115 tol=self.config.tolerance,
1116 maxSepInSigma=self.config.maxSeparation,
1117 fitBackground=self.config.fitBackground,
1118 separateNegParams=self.config.fitSeparateNegParams,
1119 verbose=False, display=False)
1120 except pexExcept.LengthError:
1121 self.fail(measRecord, measBase.MeasurementError('edge failure', self.FAILURE_EDGE))
1122 except Exception as e:
1123 self.fail(measRecord, measBase.MeasurementError('Exception in dipole fit', self.FAILURE_FIT))
1124 self.log.error("Exception in dipole fit. %s: %s", e.__class__.__name__, e)
1125
1126 if result is None:
1127 measRecord.set(self.classificationFlagKey, False)
1128 measRecord.set(self.classificationAttemptedFlagKey, False)
1129 return result
1130
1131 self.log.debug("Dipole fit result: %d %s", measRecord.getId(), str(result))
1132
1133 if result.posFlux <= 1.: # usually around 0.1 -- the minimum flux allowed -- i.e. bad fit.
1134 self.fail(measRecord, measBase.MeasurementError('dipole fit failure', self.FAILURE_FIT))
1135
1136 # add chi2, coord/flux uncertainties (TBD), dipole classification
1137 # Add the relevant values to the measRecord
1138 measRecord[self.posFluxKey] = result.posFlux
1139 measRecord[self.posFluxErrKey] = result.signalToNoise # to be changed to actual sigma!
1140 measRecord[self.posCentroidKeyX] = result.posCentroidX
1141 measRecord[self.posCentroidKeyY] = result.posCentroidY
1142
1143 measRecord[self.negFluxKey] = result.negFlux
1144 measRecord[self.negFluxErrKey] = result.signalToNoise # to be changed to actual sigma!
1145 measRecord[self.negCentroidKeyX] = result.negCentroidX
1146 measRecord[self.negCentroidKeyY] = result.negCentroidY
1147
1148 # Dia source flux: average of pos+neg
1149 measRecord[self.fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1150 measRecord[self.orientationKey] = result.orientation
1151 measRecord[self.separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2.
1152 + (result.posCentroidY - result.negCentroidY)**2.)
1153 measRecord[self.centroidKeyX] = result.centroidX
1154 measRecord[self.centroidKeyY] = result.centroidY
1155
1156 measRecord[self.signalToNoiseKey] = result.signalToNoise
1157 measRecord[self.chi2dofKey] = result.redChi2
1158
1159 self.doClassify(measRecord, result.chi2)
1160
1161 def doClassify(self, measRecord, chi2val):
1162 """Classify a source as a dipole.
1163
1164 Parameters
1165 ----------
1166 measRecord : TODO: DM-17458
1167 TODO: DM-17458
1168 chi2val : TODO: DM-17458
1169 TODO: DM-17458
1170
1171 Notes
1172 -----
1173 Sources are classified as dipoles, or not, according to three criteria:
1174
1175 1. Does the total signal-to-noise surpass the ``minSn``?
1176 2. Are the pos/neg fluxes greater than 1.0 and no more than 0.65 (``maxFluxRatio``)
1177 of the total flux? By default this will never happen since ``posFlux == negFlux``.
1178 3. Is it a good fit (``chi2dof`` < 1)? (Currently not used.)
1179 """
1180
1181 # First, does the total signal-to-noise surpass the minSn?
1182 passesSn = measRecord[self.signalToNoiseKey] > self.config.minSn
1183
1184 # Second, are the pos/neg fluxes greater than 1.0 and no more than 0.65 (param maxFluxRatio)
1185 # of the total flux? By default this will never happen since posFlux = negFlux.
1186 passesFluxPos = (abs(measRecord[self.posFluxKey])
1187 / (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio
1188 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1189 passesFluxNeg = (abs(measRecord[self.negFluxKey])
1190 / (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio
1191 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1192 allPass = (passesSn and passesFluxPos and passesFluxNeg) # and passesChi2)
1193
1194 # Third, is it a good fit (chi2dof < 1)?
1195 # Use scipy's chi2 cumulative distrib to estimate significance
1196 # This doesn't really work since I don't trust the values in the variance plane (which
1197 # affects the least-sq weights, which affects the resulting chi2).
1198 # But I'm going to keep this here for future use.
1199 if False:
1200 from scipy.stats import chi2
1201 ndof = chi2val / measRecord[self.chi2dofKey]
1202 significance = chi2.cdf(chi2val, ndof)
1203 passesChi2 = significance < self.config.maxChi2DoF
1204 allPass = allPass and passesChi2
1205
1206 measRecord.set(self.classificationAttemptedFlagKey, True)
1207
1208 if allPass: # Note cannot pass `allPass` into the `measRecord.set()` call below...?
1209 measRecord.set(self.classificationFlagKey, True)
1210 else:
1211 measRecord.set(self.classificationFlagKey, False)
1212
1213 def fail(self, measRecord, error=None):
1214 """Catch failures and set the correct flags.
1215 """
1216
1217 measRecord.set(self.flagKey, True)
1218 if error is not None:
1219 if error.getFlagBit() == self.FAILURE_EDGE:
1220 self.log.warning('DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1221 measRecord.set(self.edgeFlagKey, True)
1222 if error.getFlagBit() == self.FAILURE_FIT:
1223 self.log.warning('DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1224 measRecord.set(self.flagKey, True)
1225 if error.getFlagBit() == self.FAILURE_NOT_DIPOLE:
1226 self.log.debug('DipoleFitPlugin not run on record %d: %s',
1227 measRecord.getId(), str(error))
1228 measRecord.set(self.classificationAttemptedFlagKey, False)
1229 measRecord.set(self.flagKey, True)
1230 else:
1231 self.log.warning('DipoleFitPlugin failed on record %d', measRecord.getId())
def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
def fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
def displayFitResults(self, footprint, result)
def __init__(self, diffim, posImage=None, negImage=None)
def fail(self, measRecord, error=None)
def measure(self, measRecord, exposure, posExp=None, negExp=None)
def doClassify(self, measRecord, chi2val)
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)