Coverage for python/lsst/ip/diffim/dipoleFitTask.py: 11%

441 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-16 01:41 -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# 

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" 

131 self.doReplaceWithNoise = False 

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 ---------- 

161 sources : `lsst.afw.table.SourceCatalog` 

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 

186class DipoleModel: 

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 ---------- 

269 fp : `lsst.afw.detection.HeavyFootprint` 

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.array, 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 ---------- 

296 source : `lsst.afw.table.SourceRecord` 

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.image, 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.array).ravel() 

324 

325 data = posImg.array.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.array) 

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.array[:, :] += gradient 

472 negIm.array[:, :] += gradientNeg 

473 

474 # Generate the diffIm model 

475 diffIm = afwImage.ImageF(bbox) 

476 diffIm += posIm 

477 diffIm -= negIm 

478 

479 zout = diffIm.array 

480 if rel_weight > 0.: 

481 zout = np.append([zout], [posIm.array, negIm.array], axis=0) 

482 

483 return zout 

484 

485 

486class DipoleFitAlgorithm: 

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.image.array 

582 weights = 1. / subim.variance.array # 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.image.array, 

597 negSubim.image.array], axis=0) 

598 # Weight the pos/neg images by rel_weight relative to the diffim 

599 weights = np.append([weights], [1. / posSubim.variance.array * rel_weight, 

600 1. / negSubim.variance.array * 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 ---------- 

794 source : `lsst.afw.table.SourceRecord` 

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 return np.sqrt(np.nansum(exposure[footprint.getBBox(), afwImage.PARENT].variance.array)) 

862 

863 fluxVal = fluxVar = fitParams['flux'] 

864 fluxErr = fluxErrNeg = fitResult.params['flux'].stderr 

865 if self.posImage is not None: 

866 fluxVar = computeSumVariance(self.posImage, source.getFootprint()) 

867 else: 

868 fluxVar = computeSumVariance(self.diffim, source.getFootprint()) 

869 

870 fluxValNeg, fluxVarNeg = fluxVal, fluxVar 

871 if separateNegParams: 

872 fluxValNeg = fitParams['fluxNeg'] 

873 fluxErrNeg = fitResult.params['fluxNeg'].stderr 

874 if self.negImage is not None: 

875 fluxVarNeg = computeSumVariance(self.negImage, source.getFootprint()) 

876 

877 try: 

878 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2) 

879 except ZeroDivisionError: # catch divide by zero - should never happen. 

880 signalToNoise = np.nan 

881 

882 out = Struct(posCentroidX=fitParams['xcenPos'], posCentroidY=fitParams['ycenPos'], 

883 negCentroidX=fitParams['xcenNeg'], negCentroidY=fitParams['ycenNeg'], 

884 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxErr=fluxErr, negFluxErr=fluxErrNeg, 

885 centroidX=centroid[0], centroidY=centroid[1], orientation=angle, 

886 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi) 

887 

888 # fitResult may be returned for debugging 

889 return out, fitResult 

890 

891 def displayFitResults(self, footprint, result): 

892 """Display data, model fits and residuals (currently uses matplotlib display functions). 

893 

894 Parameters 

895 ---------- 

896 footprint : TODO: DM-17458 

897 Footprint containing the dipole that was fit 

898 result : `lmfit.MinimizerResult` 

899 `lmfit.MinimizerResult` object returned by `lmfit` optimizer 

900 

901 Returns 

902 ------- 

903 fig : `matplotlib.pyplot.plot` 

904 """ 

905 try: 

906 import matplotlib.pyplot as plt 

907 except ImportError as err: 

908 self.log.warning('Unable to import matplotlib: %s', err) 

909 raise err 

910 

911 def display2dArray(arr, title='Data', extent=None): 

912 """Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range. 

913 """ 

914 fig = plt.imshow(arr, origin='lower', interpolation='none', cmap='gray', extent=extent) 

915 plt.title(title) 

916 plt.colorbar(fig, cmap='gray') 

917 return fig 

918 

919 z = result.data 

920 fit = result.best_fit 

921 bbox = footprint.getBBox() 

922 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY()) 

923 if z.shape[0] == 3: 

924 fig = plt.figure(figsize=(8, 8)) 

925 for i in range(3): 

926 plt.subplot(3, 3, i*3+1) 

927 display2dArray(z[i, :], 'Data', extent=extent) 

928 plt.subplot(3, 3, i*3+2) 

929 display2dArray(fit[i, :], 'Model', extent=extent) 

930 plt.subplot(3, 3, i*3+3) 

931 display2dArray(z[i, :] - fit[i, :], 'Residual', extent=extent) 

932 return fig 

933 else: 

934 fig = plt.figure(figsize=(8, 2.5)) 

935 plt.subplot(1, 3, 1) 

936 display2dArray(z, 'Data', extent=extent) 

937 plt.subplot(1, 3, 2) 

938 display2dArray(fit, 'Model', extent=extent) 

939 plt.subplot(1, 3, 3) 

940 display2dArray(z - fit, 'Residual', extent=extent) 

941 return fig 

942 

943 plt.show() 

944 

945 

946@measBase.register("ip_diffim_DipoleFit") 

947class DipoleFitPlugin(measBase.SingleFramePlugin): 

948 """A single frame measurement plugin that fits dipoles to all merged (two-peak) ``diaSources``. 

949 

950 This measurement plugin accepts up to three input images in 

951 its `measure` method. If these are provided, it includes data 

952 from the pre-subtraction posImage (science image) and optionally 

953 negImage (template image) to constrain the fit. The meat of the 

954 fitting routines are in the class `~lsst.module.name.DipoleFitAlgorithm`. 

955 

956 Notes 

957 ----- 

958 The motivation behind this plugin and the necessity for including more than 

959 one exposure are documented in DMTN-007 (http://dmtn-007.lsst.io). 

960 

961 This class is named `ip_diffim_DipoleFit` so that it may be used alongside 

962 the existing `ip_diffim_DipoleMeasurement` classes until such a time as those 

963 are deemed to be replaceable by this. 

964 """ 

965 

966 ConfigClass = DipoleFitPluginConfig 

967 DipoleFitAlgorithmClass = DipoleFitAlgorithm # Pointer to the class that performs the fit 

968 

969 FAILURE_EDGE = 1 # too close to the edge 

970 FAILURE_FIT = 2 # failure in the fitting 

971 FAILURE_NOT_DIPOLE = 4 # input source is not a putative dipole to begin with 

972 

973 @classmethod 

974 def getExecutionOrder(cls): 

975 """Set execution order to `FLUX_ORDER`. 

976 

977 This includes algorithms that require both `getShape()` and `getCentroid()`, 

978 in addition to a Footprint and its Peaks. 

979 """ 

980 return cls.FLUX_ORDER 

981 

982 def __init__(self, config, name, schema, metadata, logName=None): 

983 if logName is None: 

984 logName = name 

985 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName) 

986 

987 self.log = logging.getLogger(logName) 

988 

989 self._setupSchema(config, name, schema, metadata) 

990 

991 def _setupSchema(self, config, name, schema, metadata): 

992 # Get a FunctorKey that can quickly look up the "blessed" centroid value. 

993 self.centroidKey = afwTable.Point2DKey(schema["slot_Centroid"]) 

994 

995 # Add some fields for our outputs, and save their Keys. 

996 # Use setattr() to programmatically set the pos/neg named attributes to values, e.g. 

997 # self.posCentroidKeyX = 'ip_diffim_DipoleFit_pos_centroid_x' 

998 

999 for pos_neg in ['pos', 'neg']: 

1000 

1001 key = schema.addField( 

1002 schema.join(name, pos_neg, "instFlux"), type=float, units="count", 

1003 doc="Dipole {0} lobe flux".format(pos_neg)) 

1004 setattr(self, ''.join((pos_neg, 'FluxKey')), key) 

1005 

1006 key = schema.addField( 

1007 schema.join(name, pos_neg, "instFluxErr"), type=float, units="count", 

1008 doc="1-sigma uncertainty for {0} dipole flux".format(pos_neg)) 

1009 setattr(self, ''.join((pos_neg, 'FluxErrKey')), key) 

1010 

1011 for x_y in ['x', 'y']: 

1012 key = schema.addField( 

1013 schema.join(name, pos_neg, "centroid", x_y), type=float, units="pixel", 

1014 doc="Dipole {0} lobe centroid".format(pos_neg)) 

1015 setattr(self, ''.join((pos_neg, 'CentroidKey', x_y.upper())), key) 

1016 

1017 for x_y in ['x', 'y']: 

1018 key = schema.addField( 

1019 schema.join(name, "centroid", x_y), type=float, units="pixel", 

1020 doc="Dipole centroid") 

1021 setattr(self, ''.join(('centroidKey', x_y.upper())), key) 

1022 

1023 self.fluxKey = schema.addField( 

1024 schema.join(name, "instFlux"), type=float, units="count", 

1025 doc="Dipole overall flux") 

1026 

1027 self.orientationKey = schema.addField( 

1028 schema.join(name, "orientation"), type=float, units="deg", 

1029 doc="Dipole orientation") 

1030 

1031 self.separationKey = schema.addField( 

1032 schema.join(name, "separation"), type=float, units="pixel", 

1033 doc="Pixel separation between positive and negative lobes of dipole") 

1034 

1035 self.chi2dofKey = schema.addField( 

1036 schema.join(name, "chi2dof"), type=float, 

1037 doc="Chi2 per degree of freedom of dipole fit") 

1038 

1039 self.signalToNoiseKey = schema.addField( 

1040 schema.join(name, "signalToNoise"), type=float, 

1041 doc="Estimated signal-to-noise of dipole fit") 

1042 

1043 self.classificationFlagKey = schema.addField( 

1044 schema.join(name, "flag", "classification"), type="Flag", 

1045 doc="Flag indicating diaSource is classified as a dipole") 

1046 

1047 self.classificationAttemptedFlagKey = schema.addField( 

1048 schema.join(name, "flag", "classificationAttempted"), type="Flag", 

1049 doc="Flag indicating diaSource was attempted to be classified as a dipole") 

1050 

1051 self.flagKey = schema.addField( 

1052 schema.join(name, "flag"), type="Flag", 

1053 doc="General failure flag for dipole fit") 

1054 

1055 self.edgeFlagKey = schema.addField( 

1056 schema.join(name, "flag", "edge"), type="Flag", 

1057 doc="Flag set when dipole is too close to edge of image") 

1058 

1059 def measure(self, measRecord, exposure, posExp=None, negExp=None): 

1060 """Perform the non-linear least squares minimization on the putative dipole source. 

1061 

1062 Parameters 

1063 ---------- 

1064 measRecord : `lsst.afw.table.SourceRecord` 

1065 diaSources that will be measured using dipole measurement 

1066 exposure : `lsst.afw.image.Exposure` 

1067 Difference exposure on which the diaSources were detected; `exposure = posExp-negExp` 

1068 If both `posExp` and `negExp` are `None`, will attempt to fit the 

1069 dipole to just the `exposure` with no constraint. 

1070 posExp : `lsst.afw.image.Exposure`, optional 

1071 "Positive" exposure, typically a science exposure, or None if unavailable 

1072 When `posExp` is `None`, will compute `posImage = exposure + negExp`. 

1073 negExp : `lsst.afw.image.Exposure`, optional 

1074 "Negative" exposure, typically a template exposure, or None if unavailable 

1075 When `negExp` is `None`, will compute `negImage = posExp - exposure`. 

1076 

1077 Notes 

1078 ----- 

1079 The main functionality of this routine was placed outside of 

1080 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that 

1081 `DipoleFitAlgorithm.fitDipole()` can be called separately for 

1082 testing (@see `tests/testDipoleFitter.py`) 

1083 

1084 Returns 

1085 ------- 

1086 result : TODO: DM-17458 

1087 TODO: DM-17458 

1088 """ 

1089 

1090 result = None 

1091 pks = measRecord.getFootprint().getPeaks() 

1092 

1093 # Check if the footprint consists of a putative dipole - else don't fit it. 

1094 if ( 

1095 # One peak in the footprint (not a dipole) 

1096 (len(pks) <= 1) 

1097 # Peaks are the same sign (not a dipole) 

1098 or (len(pks) > 1 and (np.sign(pks[0].getPeakValue()) 

1099 == np.sign(pks[-1].getPeakValue()))) 

1100 # Footprint is too large (not a dipole) 

1101 or (measRecord.getFootprint().getArea() > self.config.maxFootprintArea) 

1102 ): 

1103 measRecord.set(self.classificationFlagKey, False) 

1104 measRecord.set(self.classificationAttemptedFlagKey, False) 

1105 self.fail(measRecord, measBase.MeasurementError('not a dipole', self.FAILURE_NOT_DIPOLE)) 

1106 if not self.config.fitAllDiaSources: 

1107 return result 

1108 

1109 try: 

1110 alg = self.DipoleFitAlgorithmClass(exposure, posImage=posExp, negImage=negExp) 

1111 result, _ = alg.fitDipole( 

1112 measRecord, rel_weight=self.config.relWeight, 

1113 tol=self.config.tolerance, 

1114 maxSepInSigma=self.config.maxSeparation, 

1115 fitBackground=self.config.fitBackground, 

1116 separateNegParams=self.config.fitSeparateNegParams, 

1117 verbose=False, display=False) 

1118 except pexExcept.LengthError: 

1119 self.fail(measRecord, measBase.MeasurementError('edge failure', self.FAILURE_EDGE)) 

1120 except Exception as e: 

1121 self.fail(measRecord, measBase.MeasurementError('Exception in dipole fit', self.FAILURE_FIT)) 

1122 self.log.error("Exception in dipole fit. %s: %s", e.__class__.__name__, e) 

1123 

1124 if result is None: 

1125 measRecord.set(self.classificationFlagKey, False) 

1126 measRecord.set(self.classificationAttemptedFlagKey, False) 

1127 return result 

1128 

1129 self.log.debug("Dipole fit result: %d %s", measRecord.getId(), str(result)) 

1130 

1131 if result.posFlux <= 1.: # usually around 0.1 -- the minimum flux allowed -- i.e. bad fit. 

1132 self.fail(measRecord, measBase.MeasurementError('dipole fit failure', self.FAILURE_FIT)) 

1133 

1134 # add chi2, coord/flux uncertainties (TBD), dipole classification 

1135 # Add the relevant values to the measRecord 

1136 measRecord[self.posFluxKey] = result.posFlux 

1137 measRecord[self.posFluxErrKey] = result.signalToNoise # to be changed to actual sigma! 

1138 measRecord[self.posCentroidKeyX] = result.posCentroidX 

1139 measRecord[self.posCentroidKeyY] = result.posCentroidY 

1140 

1141 measRecord[self.negFluxKey] = result.negFlux 

1142 measRecord[self.negFluxErrKey] = result.signalToNoise # to be changed to actual sigma! 

1143 measRecord[self.negCentroidKeyX] = result.negCentroidX 

1144 measRecord[self.negCentroidKeyY] = result.negCentroidY 

1145 

1146 # Dia source flux: average of pos+neg 

1147 measRecord[self.fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2. 

1148 measRecord[self.orientationKey] = result.orientation 

1149 measRecord[self.separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2. 

1150 + (result.posCentroidY - result.negCentroidY)**2.) 

1151 measRecord[self.centroidKeyX] = result.centroidX 

1152 measRecord[self.centroidKeyY] = result.centroidY 

1153 

1154 measRecord[self.signalToNoiseKey] = result.signalToNoise 

1155 measRecord[self.chi2dofKey] = result.redChi2 

1156 

1157 self.doClassify(measRecord, result.chi2) 

1158 

1159 def doClassify(self, measRecord, chi2val): 

1160 """Classify a source as a dipole. 

1161 

1162 Parameters 

1163 ---------- 

1164 measRecord : TODO: DM-17458 

1165 TODO: DM-17458 

1166 chi2val : TODO: DM-17458 

1167 TODO: DM-17458 

1168 

1169 Notes 

1170 ----- 

1171 Sources are classified as dipoles, or not, according to three criteria: 

1172 

1173 1. Does the total signal-to-noise surpass the ``minSn``? 

1174 2. Are the pos/neg fluxes greater than 1.0 and no more than 0.65 (``maxFluxRatio``) 

1175 of the total flux? By default this will never happen since ``posFlux == negFlux``. 

1176 3. Is it a good fit (``chi2dof`` < 1)? (Currently not used.) 

1177 """ 

1178 

1179 # First, does the total signal-to-noise surpass the minSn? 

1180 passesSn = measRecord[self.signalToNoiseKey] > self.config.minSn 

1181 

1182 # Second, are the pos/neg fluxes greater than 1.0 and no more than 0.65 (param maxFluxRatio) 

1183 # of the total flux? By default this will never happen since posFlux = negFlux. 

1184 passesFluxPos = (abs(measRecord[self.posFluxKey]) 

1185 / (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio 

1186 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0) 

1187 passesFluxNeg = (abs(measRecord[self.negFluxKey]) 

1188 / (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio 

1189 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0) 

1190 allPass = (passesSn and passesFluxPos and passesFluxNeg) # and passesChi2) 

1191 

1192 # Third, is it a good fit (chi2dof < 1)? 

1193 # Use scipy's chi2 cumulative distrib to estimate significance 

1194 # This doesn't really work since I don't trust the values in the variance plane (which 

1195 # affects the least-sq weights, which affects the resulting chi2). 

1196 # But I'm going to keep this here for future use. 

1197 if False: 

1198 from scipy.stats import chi2 

1199 ndof = chi2val / measRecord[self.chi2dofKey] 

1200 significance = chi2.cdf(chi2val, ndof) 

1201 passesChi2 = significance < self.config.maxChi2DoF 

1202 allPass = allPass and passesChi2 

1203 

1204 measRecord.set(self.classificationAttemptedFlagKey, True) 

1205 

1206 if allPass: # Note cannot pass `allPass` into the `measRecord.set()` call below...? 

1207 measRecord.set(self.classificationFlagKey, True) 

1208 else: 

1209 measRecord.set(self.classificationFlagKey, False) 

1210 

1211 def fail(self, measRecord, error=None): 

1212 """Catch failures and set the correct flags. 

1213 """ 

1214 

1215 measRecord.set(self.flagKey, True) 

1216 if error is not None: 

1217 if error.getFlagBit() == self.FAILURE_EDGE: 

1218 self.log.warning('DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error)) 

1219 measRecord.set(self.edgeFlagKey, True) 

1220 if error.getFlagBit() == self.FAILURE_FIT: 

1221 self.log.warning('DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error)) 

1222 measRecord.set(self.flagKey, True) 

1223 if error.getFlagBit() == self.FAILURE_NOT_DIPOLE: 

1224 self.log.debug('DipoleFitPlugin not run on record %d: %s', 

1225 measRecord.getId(), str(error)) 

1226 measRecord.set(self.classificationAttemptedFlagKey, False) 

1227 measRecord.set(self.flagKey, True) 

1228 else: 

1229 self.log.warning('DipoleFitPlugin failed on record %d', measRecord.getId())