Coverage for python/lsst/ip/diffim/subtractImages.py: 23%

335 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-09 03:52 -0700

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import warnings 

23 

24import numpy as np 

25 

26import lsst.afw.image 

27import lsst.afw.math 

28import lsst.geom 

29from lsst.utils.introspection import find_outside_stacklevel 

30from lsst.ip.diffim.utils import evaluateMeanPsfFwhm, getPsfFwhm 

31from lsst.meas.algorithms import ScaleVarianceTask 

32import lsst.pex.config 

33import lsst.pipe.base 

34import lsst.pex.exceptions 

35from lsst.pipe.base import connectionTypes 

36from . import MakeKernelTask, DecorrelateALKernelTask 

37from lsst.utils.timer import timeMethod 

38 

39__all__ = ["AlardLuptonSubtractConfig", "AlardLuptonSubtractTask", 

40 "AlardLuptonPreconvolveSubtractConfig", "AlardLuptonPreconvolveSubtractTask"] 

41 

42_dimensions = ("instrument", "visit", "detector") 

43_defaultTemplates = {"coaddName": "deep", "fakesType": ""} 

44 

45 

46class SubtractInputConnections(lsst.pipe.base.PipelineTaskConnections, 

47 dimensions=_dimensions, 

48 defaultTemplates=_defaultTemplates): 

49 template = connectionTypes.Input( 

50 doc="Input warped template to subtract.", 

51 dimensions=("instrument", "visit", "detector"), 

52 storageClass="ExposureF", 

53 name="{fakesType}{coaddName}Diff_templateExp" 

54 ) 

55 science = connectionTypes.Input( 

56 doc="Input science exposure to subtract from.", 

57 dimensions=("instrument", "visit", "detector"), 

58 storageClass="ExposureF", 

59 name="{fakesType}calexp" 

60 ) 

61 sources = connectionTypes.Input( 

62 doc="Sources measured on the science exposure; " 

63 "used to select sources for making the matching kernel.", 

64 dimensions=("instrument", "visit", "detector"), 

65 storageClass="SourceCatalog", 

66 name="{fakesType}src" 

67 ) 

68 visitSummary = connectionTypes.Input( 

69 doc=("Per-visit catalog with final calibration objects. " 

70 "These catalogs use the detector id for the catalog id, " 

71 "sorted on id for fast lookup."), 

72 dimensions=("instrument", "visit"), 

73 storageClass="ExposureCatalog", 

74 name="finalVisitSummary", 

75 ) 

76 

77 def __init__(self, *, config=None): 

78 super().__init__(config=config) 

79 if not config.doApplyExternalCalibrations: 

80 del self.visitSummary 

81 

82 

83class SubtractImageOutputConnections(lsst.pipe.base.PipelineTaskConnections, 

84 dimensions=_dimensions, 

85 defaultTemplates=_defaultTemplates): 

86 difference = connectionTypes.Output( 

87 doc="Result of subtracting convolved template from science image.", 

88 dimensions=("instrument", "visit", "detector"), 

89 storageClass="ExposureF", 

90 name="{fakesType}{coaddName}Diff_differenceTempExp", 

91 ) 

92 matchedTemplate = connectionTypes.Output( 

93 doc="Warped and PSF-matched template used to create `subtractedExposure`.", 

94 dimensions=("instrument", "visit", "detector"), 

95 storageClass="ExposureF", 

96 name="{fakesType}{coaddName}Diff_matchedExp", 

97 ) 

98 psfMatchingKernel = connectionTypes.Output( 

99 doc="Kernel used to PSF match the science and template images.", 

100 dimensions=("instrument", "visit", "detector"), 

101 storageClass="MatchingKernel", 

102 name="{fakesType}{coaddName}Diff_psfMatchKernel", 

103 ) 

104 

105 

106class SubtractScoreOutputConnections(lsst.pipe.base.PipelineTaskConnections, 

107 dimensions=_dimensions, 

108 defaultTemplates=_defaultTemplates): 

109 scoreExposure = connectionTypes.Output( 

110 doc="The maximum likelihood image, used for the detection of diaSources.", 

111 dimensions=("instrument", "visit", "detector"), 

112 storageClass="ExposureF", 

113 name="{fakesType}{coaddName}Diff_scoreExp", 

114 ) 

115 psfMatchingKernel = connectionTypes.Output( 

116 doc="Kernel used to PSF match the science and template images.", 

117 dimensions=("instrument", "visit", "detector"), 

118 storageClass="MatchingKernel", 

119 name="{fakesType}{coaddName}Diff_psfScoreMatchKernel", 

120 ) 

121 

122 

123class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

124 pass 

125 

126 

127class AlardLuptonSubtractBaseConfig(lsst.pex.config.Config): 

128 makeKernel = lsst.pex.config.ConfigurableField( 

129 target=MakeKernelTask, 

130 doc="Task to construct a matching kernel for convolution.", 

131 ) 

132 doDecorrelation = lsst.pex.config.Field( 

133 dtype=bool, 

134 default=True, 

135 doc="Perform diffim decorrelation to undo pixel correlation due to A&L " 

136 "kernel convolution? If True, also update the diffim PSF." 

137 ) 

138 decorrelate = lsst.pex.config.ConfigurableField( 

139 target=DecorrelateALKernelTask, 

140 doc="Task to decorrelate the image difference.", 

141 ) 

142 requiredTemplateFraction = lsst.pex.config.Field( 

143 dtype=float, 

144 default=0.1, 

145 doc="Raise NoWorkFound and do not attempt image subtraction if template covers less than this " 

146 " fraction of pixels. Setting to 0 will always attempt image subtraction." 

147 ) 

148 minTemplateFractionForExpectedSuccess = lsst.pex.config.Field( 

149 dtype=float, 

150 default=0.2, 

151 doc="Raise NoWorkFound if PSF-matching fails and template covers less than this fraction of pixels." 

152 " If the fraction of pixels covered by the template is less than this value (and greater than" 

153 " requiredTemplateFraction) this task is attempted but failure is anticipated and tolerated." 

154 ) 

155 doScaleVariance = lsst.pex.config.Field( 

156 dtype=bool, 

157 default=True, 

158 doc="Scale variance of the image difference?" 

159 ) 

160 scaleVariance = lsst.pex.config.ConfigurableField( 

161 target=ScaleVarianceTask, 

162 doc="Subtask to rescale the variance of the template to the statistically expected level." 

163 ) 

164 doSubtractBackground = lsst.pex.config.Field( 

165 doc="Subtract the background fit when solving the kernel?", 

166 dtype=bool, 

167 default=True, 

168 ) 

169 doApplyExternalCalibrations = lsst.pex.config.Field( 

170 doc=( 

171 "Replace science Exposure's calibration objects with those" 

172 " in visitSummary. Ignored if `doApplyFinalizedPsf is True." 

173 ), 

174 dtype=bool, 

175 default=False, 

176 ) 

177 detectionThreshold = lsst.pex.config.Field( 

178 dtype=float, 

179 default=10, 

180 doc="Minimum signal to noise ratio of detected sources " 

181 "to use for calculating the PSF matching kernel." 

182 ) 

183 detectionThresholdMax = lsst.pex.config.Field( 

184 dtype=float, 

185 default=500, 

186 doc="Maximum signal to noise ratio of detected sources " 

187 "to use for calculating the PSF matching kernel." 

188 ) 

189 maxKernelSources = lsst.pex.config.Field( 

190 dtype=int, 

191 default=1000, 

192 doc="Maximum number of sources to use for calculating the PSF matching kernel." 

193 "Set to -1 to disable." 

194 ) 

195 minKernelSources = lsst.pex.config.Field( 

196 dtype=int, 

197 default=3, 

198 doc="Minimum number of sources needed for calculating the PSF matching kernel." 

199 ) 

200 badSourceFlags = lsst.pex.config.ListField( 

201 dtype=str, 

202 doc="Flags that, if set, the associated source should not " 

203 "be used to determine the PSF matching kernel.", 

204 default=("sky_source", "slot_Centroid_flag", 

205 "slot_ApFlux_flag", "slot_PsfFlux_flag", 

206 "base_PixelFlags_flag_interpolated", 

207 "base_PixelFlags_flag_saturated", 

208 "base_PixelFlags_flag_bad", 

209 ), 

210 ) 

211 excludeMaskPlanes = lsst.pex.config.ListField( 

212 dtype=str, 

213 default=("NO_DATA", "BAD", "SAT", "EDGE", "FAKE"), 

214 doc="Mask planes to exclude when selecting sources for PSF matching." 

215 ) 

216 badMaskPlanes = lsst.pex.config.ListField( 

217 dtype=str, 

218 default=("NO_DATA", "BAD", "SAT", "EDGE"), 

219 doc="Mask planes to interpolate over." 

220 ) 

221 preserveTemplateMask = lsst.pex.config.ListField( 

222 dtype=str, 

223 default=("NO_DATA", "BAD",), 

224 doc="Mask planes from the template to propagate to the image difference." 

225 ) 

226 renameTemplateMask = lsst.pex.config.ListField( 

227 dtype=str, 

228 default=("SAT", "INJECTED", "INJECTED_CORE",), 

229 doc="Mask planes from the template to propagate to the image difference" 

230 "with '_TEMPLATE' appended to the name." 

231 ) 

232 allowKernelSourceDetection = lsst.pex.config.Field( 

233 dtype=bool, 

234 default=False, 

235 doc="Re-run source detection for kernel candidates if an error is" 

236 " encountered while calculating the matching kernel." 

237 ) 

238 

239 def setDefaults(self): 

240 self.makeKernel.kernel.name = "AL" 

241 self.makeKernel.kernel.active.fitForBackground = self.doSubtractBackground 

242 self.makeKernel.kernel.active.spatialKernelOrder = 1 

243 self.makeKernel.kernel.active.spatialBgOrder = 2 

244 

245 

246class AlardLuptonSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig, 

247 pipelineConnections=AlardLuptonSubtractConnections): 

248 mode = lsst.pex.config.ChoiceField( 

249 dtype=str, 

250 default="convolveTemplate", 

251 allowed={"auto": "Choose which image to convolve at runtime.", 

252 "convolveScience": "Only convolve the science image.", 

253 "convolveTemplate": "Only convolve the template image."}, 

254 doc="Choose which image to convolve at runtime, or require that a specific image is convolved." 

255 ) 

256 

257 

258class AlardLuptonSubtractTask(lsst.pipe.base.PipelineTask): 

259 """Compute the image difference of a science and template image using 

260 the Alard & Lupton (1998) algorithm. 

261 """ 

262 ConfigClass = AlardLuptonSubtractConfig 

263 _DefaultName = "alardLuptonSubtract" 

264 

265 def __init__(self, **kwargs): 

266 super().__init__(**kwargs) 

267 self.makeSubtask("decorrelate") 

268 self.makeSubtask("makeKernel") 

269 if self.config.doScaleVariance: 

270 self.makeSubtask("scaleVariance") 

271 

272 self.convolutionControl = lsst.afw.math.ConvolutionControl() 

273 # Normalization is an extra, unnecessary, calculation and will result 

274 # in mis-subtraction of the images if there are calibration errors. 

275 self.convolutionControl.setDoNormalize(False) 

276 self.convolutionControl.setDoCopyEdge(True) 

277 

278 def _applyExternalCalibrations(self, exposure, visitSummary): 

279 """Replace calibrations (psf, and ApCorrMap) on this exposure with 

280 external ones.". 

281 

282 Parameters 

283 ---------- 

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

285 Input exposure to adjust calibrations. 

286 visitSummary : `lsst.afw.table.ExposureCatalog` 

287 Exposure catalog with external calibrations to be applied. Catalog 

288 uses the detector id for the catalog id, sorted on id for fast 

289 lookup. 

290 

291 Returns 

292 ------- 

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

294 Exposure with adjusted calibrations. 

295 """ 

296 detectorId = exposure.info.getDetector().getId() 

297 

298 row = visitSummary.find(detectorId) 

299 if row is None: 

300 self.log.warning("Detector id %s not found in external calibrations catalog; " 

301 "Using original calibrations.", detectorId) 

302 else: 

303 psf = row.getPsf() 

304 apCorrMap = row.getApCorrMap() 

305 if psf is None: 

306 self.log.warning("Detector id %s has None for psf in " 

307 "external calibrations catalog; Using original psf and aperture correction.", 

308 detectorId) 

309 elif apCorrMap is None: 

310 self.log.warning("Detector id %s has None for apCorrMap in " 

311 "external calibrations catalog; Using original psf and aperture correction.", 

312 detectorId) 

313 else: 

314 exposure.setPsf(psf) 

315 exposure.info.setApCorrMap(apCorrMap) 

316 

317 return exposure 

318 

319 @timeMethod 

320 def run(self, template, science, sources, finalizedPsfApCorrCatalog=None, 

321 visitSummary=None): 

322 """PSF match, subtract, and decorrelate two images. 

323 

324 Parameters 

325 ---------- 

326 template : `lsst.afw.image.ExposureF` 

327 Template exposure, warped to match the science exposure. 

328 science : `lsst.afw.image.ExposureF` 

329 Science exposure to subtract from the template. 

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

331 Identified sources on the science exposure. This catalog is used to 

332 select sources in order to perform the AL PSF matching on stamp 

333 images around them. 

334 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`, optional 

335 Exposure catalog with finalized psf models and aperture correction 

336 maps to be applied. Catalog uses the detector id for the catalog 

337 id, sorted on id for fast lookup. Deprecated in favor of 

338 ``visitSummary``, and will be removed after v26. 

339 visitSummary : `lsst.afw.table.ExposureCatalog`, optional 

340 Exposure catalog with external calibrations to be applied. Catalog 

341 uses the detector id for the catalog id, sorted on id for fast 

342 lookup. Ignored (for temporary backwards compatibility) if 

343 ``finalizedPsfApCorrCatalog`` is provided. 

344 

345 Returns 

346 ------- 

347 results : `lsst.pipe.base.Struct` 

348 ``difference`` : `lsst.afw.image.ExposureF` 

349 Result of subtracting template and science. 

350 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

351 Warped and PSF-matched template exposure. 

352 ``backgroundModel`` : `lsst.afw.math.Function2D` 

353 Background model that was fit while solving for the 

354 PSF-matching kernel 

355 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

356 Kernel used to PSF-match the convolved image. 

357 

358 Raises 

359 ------ 

360 RuntimeError 

361 If an unsupported convolution mode is supplied. 

362 RuntimeError 

363 If there are too few sources to calculate the PSF matching kernel. 

364 lsst.pipe.base.NoWorkFound 

365 Raised if fraction of good pixels, defined as not having NO_DATA 

366 set, is less then the configured requiredTemplateFraction 

367 """ 

368 

369 if finalizedPsfApCorrCatalog is not None: 

370 warnings.warn( 

371 "The finalizedPsfApCorrCatalog argument is deprecated in favor of the visitSummary " 

372 "argument, and will be removed after v26.", 

373 FutureWarning, 

374 stacklevel=find_outside_stacklevel("lsst.ip.diffim"), 

375 ) 

376 visitSummary = finalizedPsfApCorrCatalog 

377 

378 self._prepareInputs(template, science, visitSummary=visitSummary) 

379 

380 # In the event that getPsfFwhm fails, evaluate the PSF on a grid. 

381 fwhmExposureBuffer = self.config.makeKernel.fwhmExposureBuffer 

382 fwhmExposureGrid = self.config.makeKernel.fwhmExposureGrid 

383 

384 # Calling getPsfFwhm on template.psf fails on some rare occasions when 

385 # the template has no input exposures at the average position of the 

386 # stars. So we try getPsfFwhm first on template, and if that fails we 

387 # evaluate the PSF on a grid specified by fwhmExposure* fields. 

388 # To keep consistent definitions for PSF size on the template and 

389 # science images, we use the same method for both. 

390 try: 

391 templatePsfSize = getPsfFwhm(template.psf) 

392 sciencePsfSize = getPsfFwhm(science.psf) 

393 except lsst.pex.exceptions.InvalidParameterError: 

394 self.log.info("Unable to evaluate PSF at the average position. " 

395 "Evaluting PSF on a grid of points." 

396 ) 

397 templatePsfSize = evaluateMeanPsfFwhm(template, 

398 fwhmExposureBuffer=fwhmExposureBuffer, 

399 fwhmExposureGrid=fwhmExposureGrid 

400 ) 

401 sciencePsfSize = evaluateMeanPsfFwhm(science, 

402 fwhmExposureBuffer=fwhmExposureBuffer, 

403 fwhmExposureGrid=fwhmExposureGrid 

404 ) 

405 self.log.info("Science PSF FWHM: %f pixels", sciencePsfSize) 

406 self.log.info("Template PSF FWHM: %f pixels", templatePsfSize) 

407 self.metadata.add("sciencePsfSize", sciencePsfSize) 

408 self.metadata.add("templatePsfSize", templatePsfSize) 

409 

410 if self.config.mode == "auto": 

411 convolveTemplate = _shapeTest(template, 

412 science, 

413 fwhmExposureBuffer=fwhmExposureBuffer, 

414 fwhmExposureGrid=fwhmExposureGrid) 

415 if convolveTemplate: 

416 if sciencePsfSize < templatePsfSize: 

417 self.log.info("Average template PSF size is greater, " 

418 "but science PSF greater in one dimension: convolving template image.") 

419 else: 

420 self.log.info("Science PSF size is greater: convolving template image.") 

421 else: 

422 self.log.info("Template PSF size is greater: convolving science image.") 

423 elif self.config.mode == "convolveTemplate": 

424 self.log.info("`convolveTemplate` is set: convolving template image.") 

425 convolveTemplate = True 

426 elif self.config.mode == "convolveScience": 

427 self.log.info("`convolveScience` is set: convolving science image.") 

428 convolveTemplate = False 

429 else: 

430 raise RuntimeError("Cannot handle AlardLuptonSubtract mode: %s", self.config.mode) 

431 

432 try: 

433 sourceMask = science.mask.clone() 

434 sourceMask.array |= template[science.getBBox()].mask.array 

435 selectSources = self._sourceSelector(sources, sourceMask) 

436 if convolveTemplate: 

437 self.metadata.add("convolvedExposure", "Template") 

438 subtractResults = self.runConvolveTemplate(template, science, selectSources) 

439 else: 

440 self.metadata.add("convolvedExposure", "Science") 

441 subtractResults = self.runConvolveScience(template, science, selectSources) 

442 

443 except (RuntimeError, lsst.pex.exceptions.Exception) as e: 

444 self.log.warning("Failed to match template. Checking coverage") 

445 # Raise NoWorkFound if template fraction is insufficient 

446 checkTemplateIsSufficient(template[science.getBBox()], self.log, 

447 self.config.minTemplateFractionForExpectedSuccess, 

448 exceptionMessage="Template coverage lower than expected to succeed." 

449 f" Failure is tolerable: {e}") 

450 # checkTemplateIsSufficient did not raise NoWorkFound, so raise original exception 

451 raise e 

452 

453 return subtractResults 

454 

455 def runConvolveTemplate(self, template, science, selectSources): 

456 """Convolve the template image with a PSF-matching kernel and subtract 

457 from the science image. 

458 

459 Parameters 

460 ---------- 

461 template : `lsst.afw.image.ExposureF` 

462 Template exposure, warped to match the science exposure. 

463 science : `lsst.afw.image.ExposureF` 

464 Science exposure to subtract from the template. 

465 selectSources : `lsst.afw.table.SourceCatalog` 

466 Identified sources on the science exposure. This catalog is used to 

467 select sources in order to perform the AL PSF matching on stamp 

468 images around them. 

469 

470 Returns 

471 ------- 

472 results : `lsst.pipe.base.Struct` 

473 

474 ``difference`` : `lsst.afw.image.ExposureF` 

475 Result of subtracting template and science. 

476 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

477 Warped and PSF-matched template exposure. 

478 ``backgroundModel`` : `lsst.afw.math.Function2D` 

479 Background model that was fit while solving for the PSF-matching kernel 

480 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

481 Kernel used to PSF-match the template to the science image. 

482 """ 

483 try: 

484 kernelSources = self.makeKernel.selectKernelSources(template, science, 

485 candidateList=selectSources, 

486 preconvolved=False) 

487 kernelResult = self.makeKernel.run(template, science, kernelSources, 

488 preconvolved=False) 

489 except Exception as e: 

490 if self.config.allowKernelSourceDetection: 

491 self.log.warning("Error encountered trying to construct the matching kernel" 

492 f" Running source detection and retrying. {e}") 

493 kernelSources = self.makeKernel.selectKernelSources(template, science, 

494 candidateList=None, 

495 preconvolved=False) 

496 kernelResult = self.makeKernel.run(template, science, kernelSources, 

497 preconvolved=False) 

498 else: 

499 raise e 

500 

501 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel, 

502 self.convolutionControl, 

503 bbox=science.getBBox(), 

504 psf=science.psf, 

505 photoCalib=science.photoCalib) 

506 

507 difference = _subtractImages(science, matchedTemplate, 

508 backgroundModel=(kernelResult.backgroundModel 

509 if self.config.doSubtractBackground else None)) 

510 correctedExposure = self.finalize(template, science, difference, 

511 kernelResult.psfMatchingKernel, 

512 templateMatched=True) 

513 

514 return lsst.pipe.base.Struct(difference=correctedExposure, 

515 matchedTemplate=matchedTemplate, 

516 matchedScience=science, 

517 backgroundModel=kernelResult.backgroundModel, 

518 psfMatchingKernel=kernelResult.psfMatchingKernel) 

519 

520 def runConvolveScience(self, template, science, selectSources): 

521 """Convolve the science image with a PSF-matching kernel and subtract 

522 the template image. 

523 

524 Parameters 

525 ---------- 

526 template : `lsst.afw.image.ExposureF` 

527 Template exposure, warped to match the science exposure. 

528 science : `lsst.afw.image.ExposureF` 

529 Science exposure to subtract from the template. 

530 selectSources : `lsst.afw.table.SourceCatalog` 

531 Identified sources on the science exposure. This catalog is used to 

532 select sources in order to perform the AL PSF matching on stamp 

533 images around them. 

534 

535 Returns 

536 ------- 

537 results : `lsst.pipe.base.Struct` 

538 

539 ``difference`` : `lsst.afw.image.ExposureF` 

540 Result of subtracting template and science. 

541 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

542 Warped template exposure. Note that in this case, the template 

543 is not PSF-matched to the science image. 

544 ``backgroundModel`` : `lsst.afw.math.Function2D` 

545 Background model that was fit while solving for the PSF-matching kernel 

546 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

547 Kernel used to PSF-match the science image to the template. 

548 """ 

549 bbox = science.getBBox() 

550 kernelSources = self.makeKernel.selectKernelSources(science, template, 

551 candidateList=selectSources, 

552 preconvolved=False) 

553 kernelResult = self.makeKernel.run(science, template, kernelSources, 

554 preconvolved=False) 

555 modelParams = kernelResult.backgroundModel.getParameters() 

556 # We must invert the background model if the matching kernel is solved for the science image. 

557 kernelResult.backgroundModel.setParameters([-p for p in modelParams]) 

558 

559 kernelImage = lsst.afw.image.ImageD(kernelResult.psfMatchingKernel.getDimensions()) 

560 norm = kernelResult.psfMatchingKernel.computeImage(kernelImage, doNormalize=False) 

561 

562 matchedScience = self._convolveExposure(science, kernelResult.psfMatchingKernel, 

563 self.convolutionControl, 

564 psf=template.psf) 

565 

566 # Place back on native photometric scale 

567 matchedScience.maskedImage /= norm 

568 matchedTemplate = template.clone()[bbox] 

569 matchedTemplate.maskedImage /= norm 

570 matchedTemplate.setPhotoCalib(science.photoCalib) 

571 

572 difference = _subtractImages(matchedScience, matchedTemplate, 

573 backgroundModel=(kernelResult.backgroundModel 

574 if self.config.doSubtractBackground else None)) 

575 

576 correctedExposure = self.finalize(template, science, difference, 

577 kernelResult.psfMatchingKernel, 

578 templateMatched=False) 

579 

580 return lsst.pipe.base.Struct(difference=correctedExposure, 

581 matchedTemplate=matchedTemplate, 

582 matchedScience=matchedScience, 

583 backgroundModel=kernelResult.backgroundModel, 

584 psfMatchingKernel=kernelResult.psfMatchingKernel,) 

585 

586 def finalize(self, template, science, difference, kernel, 

587 templateMatched=True, 

588 preConvMode=False, 

589 preConvKernel=None, 

590 spatiallyVarying=False): 

591 """Decorrelate the difference image to undo the noise correlations 

592 caused by convolution. 

593 

594 Parameters 

595 ---------- 

596 template : `lsst.afw.image.ExposureF` 

597 Template exposure, warped to match the science exposure. 

598 science : `lsst.afw.image.ExposureF` 

599 Science exposure to subtract from the template. 

600 difference : `lsst.afw.image.ExposureF` 

601 Result of subtracting template and science. 

602 kernel : `lsst.afw.math.Kernel` 

603 An (optionally spatially-varying) PSF matching kernel 

604 templateMatched : `bool`, optional 

605 Was the template PSF-matched to the science image? 

606 preConvMode : `bool`, optional 

607 Was the science image preconvolved with its own PSF 

608 before PSF matching the template? 

609 preConvKernel : `lsst.afw.detection.Psf`, optional 

610 If not `None`, then the science image was pre-convolved with 

611 (the reflection of) this kernel. Must be normalized to sum to 1. 

612 spatiallyVarying : `bool`, optional 

613 Compute the decorrelation kernel spatially varying across the image? 

614 

615 Returns 

616 ------- 

617 correctedExposure : `lsst.afw.image.ExposureF` 

618 The decorrelated image difference. 

619 """ 

620 if self.config.doDecorrelation: 

621 self.log.info("Decorrelating image difference.") 

622 # We have cleared the template mask plane, so copy the mask plane of 

623 # the image difference so that we can calculate correct statistics 

624 # during decorrelation 

625 correctedExposure = self.decorrelate.run(science, template[science.getBBox()], difference, kernel, 

626 templateMatched=templateMatched, 

627 preConvMode=preConvMode, 

628 preConvKernel=preConvKernel, 

629 spatiallyVarying=spatiallyVarying).correctedExposure 

630 else: 

631 self.log.info("NOT decorrelating image difference.") 

632 correctedExposure = difference 

633 return correctedExposure 

634 

635 @staticmethod 

636 def _validateExposures(template, science): 

637 """Check that the WCS of the two Exposures match, and the template bbox 

638 contains the science bbox. 

639 

640 Parameters 

641 ---------- 

642 template : `lsst.afw.image.ExposureF` 

643 Template exposure, warped to match the science exposure. 

644 science : `lsst.afw.image.ExposureF` 

645 Science exposure to subtract from the template. 

646 

647 Raises 

648 ------ 

649 AssertionError 

650 Raised if the WCS of the template is not equal to the science WCS, 

651 or if the science image is not fully contained in the template 

652 bounding box. 

653 """ 

654 assert template.wcs == science.wcs,\ 

655 "Template and science exposure WCS are not identical." 

656 templateBBox = template.getBBox() 

657 scienceBBox = science.getBBox() 

658 

659 assert templateBBox.contains(scienceBBox),\ 

660 "Template bbox does not contain all of the science image." 

661 

662 def _convolveExposure(self, exposure, kernel, convolutionControl, 

663 bbox=None, 

664 psf=None, 

665 photoCalib=None, 

666 interpolateBadMaskPlanes=False, 

667 ): 

668 """Convolve an exposure with the given kernel. 

669 

670 Parameters 

671 ---------- 

672 exposure : `lsst.afw.Exposure` 

673 exposure to convolve. 

674 kernel : `lsst.afw.math.LinearCombinationKernel` 

675 PSF matching kernel computed in the ``makeKernel`` subtask. 

676 convolutionControl : `lsst.afw.math.ConvolutionControl` 

677 Configuration for convolve algorithm. 

678 bbox : `lsst.geom.Box2I`, optional 

679 Bounding box to trim the convolved exposure to. 

680 psf : `lsst.afw.detection.Psf`, optional 

681 Point spread function (PSF) to set for the convolved exposure. 

682 photoCalib : `lsst.afw.image.PhotoCalib`, optional 

683 Photometric calibration of the convolved exposure. 

684 

685 Returns 

686 ------- 

687 convolvedExp : `lsst.afw.Exposure` 

688 The convolved image. 

689 """ 

690 convolvedExposure = exposure.clone() 

691 if psf is not None: 

692 convolvedExposure.setPsf(psf) 

693 if photoCalib is not None: 

694 convolvedExposure.setPhotoCalib(photoCalib) 

695 if interpolateBadMaskPlanes and self.config.badMaskPlanes is not None: 

696 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

697 self.config.badMaskPlanes) 

698 self.metadata.add("nInterpolated", nInterp) 

699 convolvedImage = lsst.afw.image.MaskedImageF(convolvedExposure.getBBox()) 

700 lsst.afw.math.convolve(convolvedImage, convolvedExposure.maskedImage, kernel, convolutionControl) 

701 convolvedExposure.setMaskedImage(convolvedImage) 

702 if bbox is None: 

703 return convolvedExposure 

704 else: 

705 return convolvedExposure[bbox] 

706 

707 def _sourceSelector(self, sources, mask): 

708 """Select sources from a catalog that meet the selection criteria. 

709 

710 Parameters 

711 ---------- 

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

713 Input source catalog to select sources from. 

714 mask : `lsst.afw.image.Mask` 

715 The image mask plane to use to reject sources 

716 based on their location on the ccd. 

717 

718 Returns 

719 ------- 

720 selectSources : `lsst.afw.table.SourceCatalog` 

721 The input source catalog, with flagged and low signal-to-noise 

722 sources removed. 

723 

724 Raises 

725 ------ 

726 RuntimeError 

727 If there are too few sources to compute the PSF matching kernel 

728 remaining after source selection. 

729 """ 

730 flags = np.ones(len(sources), dtype=bool) 

731 for flag in self.config.badSourceFlags: 

732 try: 

733 flags *= ~sources[flag] 

734 except Exception as e: 

735 self.log.warning("Could not apply source flag: %s", e) 

736 signalToNoise = sources.getPsfInstFlux()/sources.getPsfInstFluxErr() 

737 sToNFlag = signalToNoise > self.config.detectionThreshold 

738 flags *= sToNFlag 

739 sToNFlagMax = signalToNoise < self.config.detectionThresholdMax 

740 flags *= sToNFlagMax 

741 flags *= self._checkMask(mask, sources, self.config.excludeMaskPlanes) 

742 selectSources = sources[flags].copy(deep=True) 

743 if (len(selectSources) > self.config.maxKernelSources) & (self.config.maxKernelSources > 0): 

744 signalToNoise = selectSources.getPsfInstFlux()/selectSources.getPsfInstFluxErr() 

745 indices = np.argsort(signalToNoise) 

746 indices = indices[-self.config.maxKernelSources:] 

747 flags = np.zeros(len(selectSources), dtype=bool) 

748 flags[indices] = True 

749 selectSources = selectSources[flags].copy(deep=True) 

750 

751 self.log.info("%i/%i=%.1f%% of sources selected for PSF matching from the input catalog", 

752 len(selectSources), len(sources), 100*len(selectSources)/len(sources)) 

753 if len(selectSources) < self.config.minKernelSources: 

754 self.log.error("Too few sources to calculate the PSF matching kernel: " 

755 "%i selected but %i needed for the calculation.", 

756 len(selectSources), self.config.minKernelSources) 

757 raise RuntimeError("Cannot compute PSF matching kernel: too few sources selected.") 

758 self.metadata.add("nPsfSources", len(selectSources)) 

759 

760 return selectSources 

761 

762 @staticmethod 

763 def _checkMask(mask, sources, excludeMaskPlanes): 

764 """Exclude sources that are located on masked pixels. 

765 

766 Parameters 

767 ---------- 

768 mask : `lsst.afw.image.Mask` 

769 The image mask plane to use to reject sources 

770 based on the location of their centroid on the ccd. 

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

772 The source catalog to evaluate. 

773 excludeMaskPlanes : `list` of `str` 

774 List of the names of the mask planes to exclude. 

775 

776 Returns 

777 ------- 

778 flags : `numpy.ndarray` of `bool` 

779 Array indicating whether each source in the catalog should be 

780 kept (True) or rejected (False) based on the value of the 

781 mask plane at its location. 

782 """ 

783 setExcludeMaskPlanes = [ 

784 maskPlane for maskPlane in excludeMaskPlanes if maskPlane in mask.getMaskPlaneDict() 

785 ] 

786 

787 excludePixelMask = mask.getPlaneBitMask(setExcludeMaskPlanes) 

788 

789 xv = np.rint(sources.getX() - mask.getX0()) 

790 yv = np.rint(sources.getY() - mask.getY0()) 

791 

792 mv = mask.array[yv.astype(int), xv.astype(int)] 

793 flags = np.bitwise_and(mv, excludePixelMask) == 0 

794 return flags 

795 

796 def _prepareInputs(self, template, science, visitSummary=None): 

797 """Perform preparatory calculations common to all Alard&Lupton Tasks. 

798 

799 Parameters 

800 ---------- 

801 template : `lsst.afw.image.ExposureF` 

802 Template exposure, warped to match the science exposure. The 

803 variance plane of the template image is modified in place. 

804 science : `lsst.afw.image.ExposureF` 

805 Science exposure to subtract from the template. The variance plane 

806 of the science image is modified in place. 

807 visitSummary : `lsst.afw.table.ExposureCatalog`, optional 

808 Exposure catalog with external calibrations to be applied. Catalog 

809 uses the detector id for the catalog id, sorted on id for fast 

810 lookup. 

811 """ 

812 self._validateExposures(template, science) 

813 if visitSummary is not None: 

814 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

815 templateCoverageFraction = checkTemplateIsSufficient( 

816 template[science.getBBox()], self.log, 

817 requiredTemplateFraction=self.config.requiredTemplateFraction, 

818 exceptionMessage="Not attempting subtraction. To force subtraction," 

819 " set config requiredTemplateFraction=0" 

820 ) 

821 self.metadata.add("templateCoveragePercent", 100*templateCoverageFraction) 

822 

823 if self.config.doScaleVariance: 

824 # Scale the variance of the template and science images before 

825 # convolution, subtraction, or decorrelation so that they have the 

826 # correct ratio. 

827 templateVarFactor = self.scaleVariance.run(template.maskedImage) 

828 sciVarFactor = self.scaleVariance.run(science.maskedImage) 

829 self.log.info("Template variance scaling factor: %.2f", templateVarFactor) 

830 self.metadata.add("scaleTemplateVarianceFactor", templateVarFactor) 

831 self.log.info("Science variance scaling factor: %.2f", sciVarFactor) 

832 self.metadata.add("scaleScienceVarianceFactor", sciVarFactor) 

833 

834 # Erase existing detection mask planes. 

835 # We don't want the detection mask from the science image 

836 self.updateMasks(template, science) 

837 

838 def updateMasks(self, template, science): 

839 """Update the science and template mask planes before differencing. 

840 

841 Parameters 

842 ---------- 

843 template : `lsst.afw.image.Exposure` 

844 Template exposure, warped to match the science exposure. 

845 The template mask planes will be erased, except for a few specified 

846 in the task config. 

847 science : `lsst.afw.image.Exposure` 

848 Science exposure to subtract from the template. 

849 The DETECTED and DETECTED_NEGATIVE mask planes of the science image 

850 will be erased. 

851 """ 

852 self._clearMask(science.mask, clearMaskPlanes=["DETECTED", "DETECTED_NEGATIVE"]) 

853 

854 # We will clear ALL template mask planes, except for those specified 

855 # via the `preserveTemplateMask` config. Mask planes specified via 

856 # the `renameTemplateMask` config will be copied to new planes with 

857 # "_TEMPLATE" appended to their names, and the original mask plane will 

858 # be cleared. 

859 clearMaskPlanes = [mp for mp in template.mask.getMaskPlaneDict().keys() 

860 if mp not in self.config.preserveTemplateMask] 

861 renameMaskPlanes = [mp for mp in self.config.renameTemplateMask 

862 if mp in template.mask.getMaskPlaneDict().keys()] 

863 

864 # propagate the mask plane related to Fake source injection 

865 # NOTE: the fake source injection sets FAKE plane, but it should be INJECTED 

866 # NOTE: This can be removed in DM-40796 

867 if "FAKE" in science.mask.getMaskPlaneDict().keys(): 

868 self.log.info("Adding injected mask plane to science image") 

869 self._renameMaskPlanes(science.mask, "FAKE", "INJECTED") 

870 if "FAKE" in template.mask.getMaskPlaneDict().keys(): 

871 self.log.info("Adding injected mask plane to template image") 

872 self._renameMaskPlanes(template.mask, "FAKE", "INJECTED_TEMPLATE") 

873 if "INJECTED" in renameMaskPlanes: 

874 renameMaskPlanes.remove("INJECTED") 

875 if "INJECTED_TEMPLATE" in clearMaskPlanes: 

876 clearMaskPlanes.remove("INJECTED_TEMPLATE") 

877 

878 for maskPlane in renameMaskPlanes: 

879 self._renameMaskPlanes(template.mask, maskPlane, maskPlane + "_TEMPLATE") 

880 self._clearMask(template.mask, clearMaskPlanes=clearMaskPlanes) 

881 

882 @staticmethod 

883 def _renameMaskPlanes(mask, maskPlane, newMaskPlane): 

884 """Rename a mask plane by adding the new name and copying the data. 

885 

886 Parameters 

887 ---------- 

888 mask : `lsst.afw.image.Mask` 

889 The mask image to update in place. 

890 maskPlane : `str` 

891 The name of the existing mask plane to copy. 

892 newMaskPlane : `str` 

893 The new name of the mask plane that will be added. 

894 If the mask plane already exists, it will be updated in place. 

895 """ 

896 mask.addMaskPlane(newMaskPlane) 

897 originBitMask = mask.getPlaneBitMask(maskPlane) 

898 destinationBitMask = mask.getPlaneBitMask(newMaskPlane) 

899 mask.array |= ((mask.array & originBitMask) > 0)*destinationBitMask 

900 

901 def _clearMask(self, mask, clearMaskPlanes=None): 

902 """Clear the mask plane of the template. 

903 

904 Parameters 

905 ---------- 

906 mask : `lsst.afw.image.Mask` 

907 The mask plane to erase, which will be modified in place. 

908 clearMaskPlanes : `list` of `str`, optional 

909 Erase the specified mask planes. 

910 If not supplied, the entire mask will be erased. 

911 """ 

912 if clearMaskPlanes is None: 

913 clearMaskPlanes = list(mask.getMaskPlaneDict().keys()) 

914 

915 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

916 mask &= ~bitMaskToClear 

917 

918 

919class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

920 SubtractScoreOutputConnections): 

921 pass 

922 

923 

924class AlardLuptonPreconvolveSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig, 

925 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

926 pass 

927 

928 

929class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

930 """Subtract a template from a science image, convolving the science image 

931 before computing the kernel, and also convolving the template before 

932 subtraction. 

933 """ 

934 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

935 _DefaultName = "alardLuptonPreconvolveSubtract" 

936 

937 def run(self, template, science, sources, visitSummary=None): 

938 """Preconvolve the science image with its own PSF, 

939 convolve the template image with a PSF-matching kernel and subtract 

940 from the preconvolved science image. 

941 

942 Parameters 

943 ---------- 

944 template : `lsst.afw.image.ExposureF` 

945 The template image, which has previously been warped to the science 

946 image. The template bbox will be padded by a few pixels compared to 

947 the science bbox. 

948 science : `lsst.afw.image.ExposureF` 

949 The science exposure. 

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

951 Identified sources on the science exposure. This catalog is used to 

952 select sources in order to perform the AL PSF matching on stamp 

953 images around them. 

954 visitSummary : `lsst.afw.table.ExposureCatalog`, optional 

955 Exposure catalog with complete external calibrations. Catalog uses 

956 the detector id for the catalog id, sorted on id for fast lookup. 

957 

958 Returns 

959 ------- 

960 results : `lsst.pipe.base.Struct` 

961 ``scoreExposure`` : `lsst.afw.image.ExposureF` 

962 Result of subtracting the convolved template and science 

963 images. Attached PSF is that of the original science image. 

964 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

965 Warped and PSF-matched template exposure. Attached PSF is that 

966 of the original science image. 

967 ``matchedScience`` : `lsst.afw.image.ExposureF` 

968 The science exposure after convolving with its own PSF. 

969 Attached PSF is that of the original science image. 

970 ``backgroundModel`` : `lsst.afw.math.Function2D` 

971 Background model that was fit while solving for the 

972 PSF-matching kernel 

973 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

974 Final kernel used to PSF-match the template to the science 

975 image. 

976 """ 

977 self._prepareInputs(template, science, visitSummary=visitSummary) 

978 

979 # TODO: DM-37212 we need to mirror the kernel in order to get correct cross correlation 

980 scienceKernel = science.psf.getKernel() 

981 matchedScience = self._convolveExposure(science, scienceKernel, self.convolutionControl, 

982 interpolateBadMaskPlanes=True) 

983 self.metadata.add("convolvedExposure", "Preconvolution") 

984 try: 

985 selectSources = self._sourceSelector(sources, matchedScience.mask) 

986 subtractResults = self.runPreconvolve(template, science, matchedScience, 

987 selectSources, scienceKernel) 

988 

989 except (RuntimeError, lsst.pex.exceptions.Exception) as e: 

990 self.log.warning("Failed to match template. Checking coverage") 

991 # Raise NoWorkFound if template fraction is insufficient 

992 checkTemplateIsSufficient(template[science.getBBox()], self.log, 

993 self.config.minTemplateFractionForExpectedSuccess, 

994 exceptionMessage="Template coverage lower than expected to succeed." 

995 f" Failure is tolerable: {e}") 

996 # checkTemplateIsSufficient did not raise NoWorkFound, so raise original exception 

997 raise e 

998 

999 return subtractResults 

1000 

1001 def runPreconvolve(self, template, science, matchedScience, selectSources, preConvKernel): 

1002 """Convolve the science image with its own PSF, then convolve the 

1003 template with a matching kernel and subtract to form the Score 

1004 exposure. 

1005 

1006 Parameters 

1007 ---------- 

1008 template : `lsst.afw.image.ExposureF` 

1009 Template exposure, warped to match the science exposure. 

1010 science : `lsst.afw.image.ExposureF` 

1011 Science exposure to subtract from the template. 

1012 matchedScience : `lsst.afw.image.ExposureF` 

1013 The science exposure, convolved with the reflection of its own PSF. 

1014 selectSources : `lsst.afw.table.SourceCatalog` 

1015 Identified sources on the science exposure. This catalog is used to 

1016 select sources in order to perform the AL PSF matching on stamp 

1017 images around them. 

1018 preConvKernel : `lsst.afw.math.Kernel` 

1019 The reflection of the kernel that was used to preconvolve the 

1020 `science` exposure. Must be normalized to sum to 1. 

1021 

1022 Returns 

1023 ------- 

1024 results : `lsst.pipe.base.Struct` 

1025 

1026 ``scoreExposure`` : `lsst.afw.image.ExposureF` 

1027 Result of subtracting the convolved template and science 

1028 images. Attached PSF is that of the original science image. 

1029 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

1030 Warped and PSF-matched template exposure. Attached PSF is that 

1031 of the original science image. 

1032 ``matchedScience`` : `lsst.afw.image.ExposureF` 

1033 The science exposure after convolving with its own PSF. 

1034 Attached PSF is that of the original science image. 

1035 ``backgroundModel`` : `lsst.afw.math.Function2D` 

1036 Background model that was fit while solving for the 

1037 PSF-matching kernel 

1038 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

1039 Final kernel used to PSF-match the template to the science 

1040 image. 

1041 """ 

1042 bbox = science.getBBox() 

1043 innerBBox = preConvKernel.shrinkBBox(bbox) 

1044 

1045 kernelSources = self.makeKernel.selectKernelSources(template[innerBBox], matchedScience[innerBBox], 

1046 candidateList=selectSources, 

1047 preconvolved=True) 

1048 kernelResult = self.makeKernel.run(template[innerBBox], matchedScience[innerBBox], kernelSources, 

1049 preconvolved=True) 

1050 

1051 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel, 

1052 self.convolutionControl, 

1053 bbox=bbox, 

1054 psf=science.psf, 

1055 interpolateBadMaskPlanes=True, 

1056 photoCalib=science.photoCalib) 

1057 score = _subtractImages(matchedScience, matchedTemplate, 

1058 backgroundModel=(kernelResult.backgroundModel 

1059 if self.config.doSubtractBackground else None)) 

1060 correctedScore = self.finalize(template[bbox], science, score, 

1061 kernelResult.psfMatchingKernel, 

1062 templateMatched=True, preConvMode=True, 

1063 preConvKernel=preConvKernel) 

1064 

1065 return lsst.pipe.base.Struct(scoreExposure=correctedScore, 

1066 matchedTemplate=matchedTemplate, 

1067 matchedScience=matchedScience, 

1068 backgroundModel=kernelResult.backgroundModel, 

1069 psfMatchingKernel=kernelResult.psfMatchingKernel) 

1070 

1071 

1072def checkTemplateIsSufficient(templateExposure, logger, requiredTemplateFraction=0., 

1073 exceptionMessage=""): 

1074 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1075 

1076 Parameters 

1077 ---------- 

1078 templateExposure : `lsst.afw.image.ExposureF` 

1079 The template exposure to check 

1080 logger : `lsst.log.Log` 

1081 Logger for printing output. 

1082 requiredTemplateFraction : `float`, optional 

1083 Fraction of pixels of the science image required to have coverage 

1084 in the template. 

1085 exceptionMessage : `str`, optional 

1086 Message to include in the exception raised if the template coverage 

1087 is insufficient. 

1088 

1089 Returns 

1090 ------- 

1091 templateCoverageFraction: `float` 

1092 Fraction of pixels in the template with data. 

1093 

1094 Raises 

1095 ------ 

1096 lsst.pipe.base.NoWorkFound 

1097 Raised if fraction of good pixels, defined as not having NO_DATA 

1098 set, is less than the requiredTemplateFraction 

1099 """ 

1100 # Count the number of pixels with the NO_DATA mask bit set 

1101 # counting NaN pixels is insufficient because pixels without data are often intepolated over) 

1102 pixNoData = np.count_nonzero(templateExposure.mask.array 

1103 & templateExposure.mask.getPlaneBitMask('NO_DATA')) 

1104 pixGood = templateExposure.getBBox().getArea() - pixNoData 

1105 templateCoverageFraction = pixGood/templateExposure.getBBox().getArea() 

1106 logger.info("template has %d good pixels (%.1f%%)", pixGood, 100*templateCoverageFraction) 

1107 

1108 if templateCoverageFraction < requiredTemplateFraction: 

1109 message = ("Insufficient Template Coverage. (%.1f%% < %.1f%%)" % ( 

1110 100*templateCoverageFraction, 

1111 100*requiredTemplateFraction)) 

1112 raise lsst.pipe.base.NoWorkFound(message + " " + exceptionMessage) 

1113 return templateCoverageFraction 

1114 

1115 

1116def _subtractImages(science, template, backgroundModel=None): 

1117 """Subtract template from science, propagating relevant metadata. 

1118 

1119 Parameters 

1120 ---------- 

1121 science : `lsst.afw.Exposure` 

1122 The input science image. 

1123 template : `lsst.afw.Exposure` 

1124 The template to subtract from the science image. 

1125 backgroundModel : `lsst.afw.MaskedImage`, optional 

1126 Differential background model 

1127 

1128 Returns 

1129 ------- 

1130 difference : `lsst.afw.Exposure` 

1131 The subtracted image. 

1132 """ 

1133 difference = science.clone() 

1134 if backgroundModel is not None: 

1135 difference.maskedImage -= backgroundModel 

1136 difference.maskedImage -= template.maskedImage 

1137 return difference 

1138 

1139 

1140def _shapeTest(exp1, exp2, fwhmExposureBuffer, fwhmExposureGrid): 

1141 """Determine that the PSF of ``exp1`` is not wider than that of ``exp2``. 

1142 

1143 Parameters 

1144 ---------- 

1145 exp1 : `~lsst.afw.image.Exposure` 

1146 Exposure with the reference point spread function (PSF) to evaluate. 

1147 exp2 : `~lsst.afw.image.Exposure` 

1148 Exposure with a candidate point spread function (PSF) to evaluate. 

1149 fwhmExposureBuffer : `float` 

1150 Fractional buffer margin to be left out of all sides of the image 

1151 during the construction of the grid to compute mean PSF FWHM in an 

1152 exposure, if the PSF is not available at its average position. 

1153 fwhmExposureGrid : `int` 

1154 Grid size to compute the mean FWHM in an exposure, if the PSF is not 

1155 available at its average position. 

1156 Returns 

1157 ------- 

1158 result : `bool` 

1159 True if ``exp1`` has a PSF that is not wider than that of ``exp2`` in 

1160 either dimension. 

1161 """ 

1162 try: 

1163 shape1 = getPsfFwhm(exp1.psf, average=False) 

1164 shape2 = getPsfFwhm(exp2.psf, average=False) 

1165 except lsst.pex.exceptions.InvalidParameterError: 

1166 shape1 = evaluateMeanPsfFwhm(exp1, 

1167 fwhmExposureBuffer=fwhmExposureBuffer, 

1168 fwhmExposureGrid=fwhmExposureGrid 

1169 ) 

1170 shape2 = evaluateMeanPsfFwhm(exp2, 

1171 fwhmExposureBuffer=fwhmExposureBuffer, 

1172 fwhmExposureGrid=fwhmExposureGrid 

1173 ) 

1174 return shape1 <= shape2 

1175 

1176 # Results from getPsfFwhm is a tuple of two values, one for each dimension. 

1177 xTest = shape1[0] <= shape2[0] 

1178 yTest = shape1[1] <= shape2[1] 

1179 return xTest | yTest 

1180 

1181 

1182def _interpolateImage(maskedImage, badMaskPlanes, fallbackValue=None): 

1183 """Replace masked image pixels with interpolated values. 

1184 

1185 Parameters 

1186 ---------- 

1187 maskedImage : `lsst.afw.image.MaskedImage` 

1188 Image on which to perform interpolation. 

1189 badMaskPlanes : `list` of `str` 

1190 List of mask planes to interpolate over. 

1191 fallbackValue : `float`, optional 

1192 Value to set when interpolation fails. 

1193 

1194 Returns 

1195 ------- 

1196 result: `float` 

1197 The number of masked pixels that were replaced. 

1198 """ 

1199 imgBadMaskPlanes = [ 

1200 maskPlane for maskPlane in badMaskPlanes if maskPlane in maskedImage.mask.getMaskPlaneDict() 

1201 ] 

1202 

1203 image = maskedImage.image.array 

1204 badPixels = (maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(imgBadMaskPlanes)) > 0 

1205 image[badPixels] = np.nan 

1206 if fallbackValue is None: 

1207 fallbackValue = np.nanmedian(image) 

1208 # For this initial implementation, skip the interpolation and just fill with 

1209 # the median value. 

1210 image[badPixels] = fallbackValue 

1211 return np.sum(badPixels)