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

494 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-07 08:35 +0000

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 

22from astropy import units as u 

23from astropy.stats import gaussian_fwhm_to_sigma 

24import numpy as np 

25 

26import lsst.afw.detection as afwDetection 

27import lsst.afw.image 

28import lsst.afw.math 

29import lsst.geom 

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

31 computeDifferenceImageMetrics, 

32 checkMask, setSourceFootprints) 

33from lsst.meas.algorithms import ScaleVarianceTask, ScienceSourceSelectorTask 

34import lsst.pex.config 

35import lsst.pipe.base 

36import lsst.pex.exceptions 

37from lsst.pipe.base import connectionTypes 

38from . import MakeKernelTask, DecorrelateALKernelTask 

39from lsst.utils.timer import timeMethod 

40 

41__all__ = ["AlardLuptonSubtractConfig", "AlardLuptonSubtractTask", 

42 "AlardLuptonPreconvolveSubtractConfig", "AlardLuptonPreconvolveSubtractTask", 

43 "SimplifiedSubtractConfig", "SimplifiedSubtractTask", 

44 "InsufficientKernelSourcesError"] 

45 

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

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

48 

49 

50class InsufficientKernelSourcesError(lsst.pipe.base.AlgorithmError): 

51 """Raised when there are too few sources to calculate the PSF matching 

52 kernel. 

53 """ 

54 def __init__(self, *, nSources, nRequired): 

55 msg = (f"Only {nSources} sources were selected for PSF matching," 

56 f" but {nRequired} are required.") 

57 super().__init__(msg) 

58 self.nSources = nSources 

59 self.nRequired = nRequired 

60 

61 @property 

62 def metadata(self): 

63 return {"nSources": self.nSources, 

64 "nRequired": self.nRequired 

65 } 

66 

67 

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

69 dimensions=_dimensions, 

70 defaultTemplates=_defaultTemplates): 

71 template = connectionTypes.Input( 

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

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

74 storageClass="ExposureF", 

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

76 ) 

77 science = connectionTypes.Input( 

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

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

80 storageClass="ExposureF", 

81 name="{fakesType}calexp" 

82 ) 

83 sources = connectionTypes.Input( 

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

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

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

87 storageClass="SourceCatalog", 

88 name="{fakesType}src" 

89 ) 

90 visitSummary = connectionTypes.Input( 

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

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

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

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

95 storageClass="ExposureCatalog", 

96 name="finalVisitSummary", 

97 ) 

98 

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

100 super().__init__(config=config) 

101 if not config.doApplyExternalCalibrations: 

102 del self.visitSummary 

103 

104 

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

106 dimensions=_dimensions, 

107 defaultTemplates=_defaultTemplates): 

108 difference = connectionTypes.Output( 

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

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

111 storageClass="ExposureF", 

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

113 ) 

114 matchedTemplate = connectionTypes.Output( 

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

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

117 storageClass="ExposureF", 

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

119 ) 

120 psfMatchingKernel = connectionTypes.Output( 

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

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

123 storageClass="MatchingKernel", 

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

125 ) 

126 kernelSources = connectionTypes.Output( 

127 doc="Final selection of sources used for psf matching.", 

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

129 storageClass="SourceCatalog", 

130 name="{fakesType}{coaddName}Diff_psfMatchSources" 

131 ) 

132 

133 

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

135 dimensions=_dimensions, 

136 defaultTemplates=_defaultTemplates): 

137 scoreExposure = connectionTypes.Output( 

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

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

140 storageClass="ExposureF", 

141 name="{fakesType}{coaddName}Diff_scoreTempExp", 

142 ) 

143 psfMatchingKernel = connectionTypes.Output( 

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

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

146 storageClass="MatchingKernel", 

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

148 ) 

149 kernelSources = connectionTypes.Output( 

150 doc="Final selection of sources used for psf matching.", 

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

152 storageClass="SourceCatalog", 

153 name="{fakesType}{coaddName}Diff_psfScoreMatchSources" 

154 ) 

155 

156 

157class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

158 pass 

159 

160 

161class SimplifiedSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

162 inputPsfMatchingKernel = connectionTypes.Input( 

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

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

165 storageClass="MatchingKernel", 

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

167 ) 

168 

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

170 super().__init__(config=config) 

171 del self.sources 

172 if config.useExistingKernel: 

173 del self.psfMatchingKernel 

174 del self.kernelSources 

175 else: 

176 del self.inputPsfMatchingKernel 

177 

178 

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

180 makeKernel = lsst.pex.config.ConfigurableField( 

181 target=MakeKernelTask, 

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

183 ) 

184 doDecorrelation = lsst.pex.config.Field( 

185 dtype=bool, 

186 default=True, 

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

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

189 ) 

190 decorrelate = lsst.pex.config.ConfigurableField( 

191 target=DecorrelateALKernelTask, 

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

193 ) 

194 requiredTemplateFraction = lsst.pex.config.Field( 

195 dtype=float, 

196 default=0.1, 

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

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

199 ) 

200 minTemplateFractionForExpectedSuccess = lsst.pex.config.Field( 

201 dtype=float, 

202 default=0.2, 

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

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

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

206 ) 

207 doScaleVariance = lsst.pex.config.Field( 

208 dtype=bool, 

209 default=True, 

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

211 ) 

212 scaleVariance = lsst.pex.config.ConfigurableField( 

213 target=ScaleVarianceTask, 

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

215 ) 

216 doSubtractBackground = lsst.pex.config.Field( 

217 doc="Subtract the background fit when solving the kernel? " 

218 "It is generally better to instead subtract the background in detectAndMeasure.", 

219 dtype=bool, 

220 default=False, 

221 ) 

222 doApplyExternalCalibrations = lsst.pex.config.Field( 

223 doc=( 

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

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

226 ), 

227 dtype=bool, 

228 default=False, 

229 ) 

230 sourceSelector = lsst.pex.config.ConfigurableField( 

231 target=ScienceSourceSelectorTask, 

232 doc="Task to select sources to be used for PSF matching.", 

233 ) 

234 fallbackSourceSelector = lsst.pex.config.ConfigurableField( 

235 target=ScienceSourceSelectorTask, 

236 doc="Task to select sources to be used for PSF matching." 

237 "Used only if the kernel calculation fails and" 

238 "`allowKernelSourceDetection` is set. The fallback source detection" 

239 " will not include all of the same plugins as the original source " 

240 " detection, so not all of the same flags can be used.", 

241 ) 

242 detectionThreshold = lsst.pex.config.Field( 

243 dtype=float, 

244 default=10, 

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

246 "to use for calculating the PSF matching kernel.", 

247 deprecated="No longer used. Will be removed after v30" 

248 ) 

249 detectionThresholdMax = lsst.pex.config.Field( 

250 dtype=float, 

251 default=500, 

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

253 "to use for calculating the PSF matching kernel.", 

254 deprecated="No longer used. Will be removed after v30" 

255 ) 

256 restrictKernelEdgeSources = lsst.pex.config.Field( 

257 dtype=bool, 

258 default=True, 

259 doc="Exclude sources close to the edge from the kernel calculation?" 

260 ) 

261 maxKernelSources = lsst.pex.config.Field( 

262 dtype=int, 

263 default=1000, 

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

265 "Set to -1 to disable." 

266 ) 

267 minKernelSources = lsst.pex.config.Field( 

268 dtype=int, 

269 default=3, 

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

271 ) 

272 excludeMaskPlanes = lsst.pex.config.ListField( 

273 dtype=str, 

274 default=("NO_DATA", "BAD", "SAT", "EDGE", "FAKE", "HIGH_VARIANCE"), 

275 doc="Template mask planes to exclude when selecting sources for PSF matching.", 

276 ) 

277 badMaskPlanes = lsst.pex.config.ListField( 

278 dtype=str, 

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

280 doc="Mask planes to interpolate over." 

281 ) 

282 preserveTemplateMask = lsst.pex.config.ListField( 

283 dtype=str, 

284 default=("NO_DATA", "BAD", "HIGH_VARIANCE"), 

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

286 ) 

287 renameTemplateMask = lsst.pex.config.ListField( 

288 dtype=str, 

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

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

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

292 ) 

293 allowKernelSourceDetection = lsst.pex.config.Field( 

294 dtype=bool, 

295 default=False, 

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

297 " encountered while calculating the matching kernel." 

298 ) 

299 

300 def setDefaults(self): 

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

302 # Always include background fitting in the kernel fit, 

303 # even if it is not subtracted 

304 self.makeKernel.kernel.active.fitForBackground = True 

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

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

307 # Shared source selector settings 

308 doSkySources = False # Do not include sky sources 

309 doSignalToNoise = True # apply signal to noise filter 

310 doUnresolved = True # apply star-galaxy separation 

311 signalToNoiseMinimum = 10 

312 signalToNoiseMaximum = 500 

313 self.sourceSelector.doIsolated = True # apply isolated star selection 

314 self.sourceSelector.doRequirePrimary = True # apply primary flag selection 

315 self.sourceSelector.doUnresolved = doUnresolved 

316 self.sourceSelector.doSkySources = doSkySources 

317 self.sourceSelector.doSignalToNoise = doSignalToNoise 

318 self.sourceSelector.signalToNoise.minimum = signalToNoiseMinimum 

319 self.sourceSelector.signalToNoise.maximum = signalToNoiseMaximum 

320 # The following two configs should not be necessary to be turned on for 

321 # PSF-matching, and the fallback kernel source selection will fail if 

322 # they are set since it does not run deblending. 

323 self.fallbackSourceSelector.doIsolated = False # Do not apply isolated star selection 

324 self.fallbackSourceSelector.doRequirePrimary = False # Do not apply primary flag selection 

325 self.fallbackSourceSelector.doUnresolved = doUnresolved 

326 self.fallbackSourceSelector.doSkySources = doSkySources 

327 self.fallbackSourceSelector.doSignalToNoise = doSignalToNoise 

328 self.fallbackSourceSelector.signalToNoise.minimum = signalToNoiseMinimum 

329 self.fallbackSourceSelector.signalToNoise.maximum = signalToNoiseMaximum 

330 

331 

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

333 pipelineConnections=AlardLuptonSubtractConnections): 

334 mode = lsst.pex.config.ChoiceField( 

335 dtype=str, 

336 default="convolveTemplate", 

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

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

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

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

341 ) 

342 

343 

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

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

346 the Alard & Lupton (1998) algorithm. 

347 """ 

348 ConfigClass = AlardLuptonSubtractConfig 

349 _DefaultName = "alardLuptonSubtract" 

350 usePreconvolution = False 

351 """Whether this task preconvolves the science image with its own PSF 

352 before kernel-matching. Subclasses that preconvolve override this to 

353 `True`.""" 

354 

355 def __init__(self, **kwargs): 

356 super().__init__(**kwargs) 

357 self.makeSubtask("decorrelate") 

358 self.makeSubtask("makeKernel") 

359 self.makeSubtask("sourceSelector") 

360 self.makeSubtask("fallbackSourceSelector") 

361 if self.config.doScaleVariance: 

362 self.makeSubtask("scaleVariance") 

363 

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

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

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

367 self.convolutionControl.setDoNormalize(False) 

368 self.convolutionControl.setDoCopyEdge(True) 

369 

370 def _applyExternalCalibrations(self, exposure, visitSummary): 

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

372 external ones.". 

373 

374 Parameters 

375 ---------- 

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

377 Input exposure to adjust calibrations. 

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

379 Exposure catalog with external calibrations to be applied. Catalog 

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

381 lookup. 

382 

383 Returns 

384 ------- 

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

386 Exposure with adjusted calibrations. 

387 """ 

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

389 

390 row = visitSummary.find(detectorId) 

391 if row is None: 

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

393 "Using original calibrations.", detectorId) 

394 else: 

395 psf = row.getPsf() 

396 apCorrMap = row.getApCorrMap() 

397 if psf is None: 

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

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

400 detectorId) 

401 elif apCorrMap is None: 

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

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

404 detectorId) 

405 else: 

406 exposure.setPsf(psf) 

407 exposure.info.setApCorrMap(apCorrMap) 

408 

409 return exposure 

410 

411 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

412 inputs = butlerQC.get(inputRefs) 

413 

414 try: 

415 results = self.run(**inputs) 

416 except lsst.pipe.base.AlgorithmError as e: 

417 error = lsst.pipe.base.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

418 # No partial outputs for butler to put 

419 raise error from e 

420 

421 butlerQC.put(results, outputRefs) 

422 

423 @timeMethod 

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

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

426 

427 Parameters 

428 ---------- 

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

430 Template exposure, warped to match the science exposure. 

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

432 Science exposure to subtract from the template. 

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

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

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

436 images around them. 

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

438 Exposure catalog with external calibrations to be applied. Catalog 

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

440 lookup. 

441 

442 Returns 

443 ------- 

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

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

446 Result of subtracting template and science. 

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

448 Warped and PSF-matched template exposure. 

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

450 Background model that was fit while solving for the 

451 PSF-matching kernel 

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

453 Kernel used to PSF-match the convolved image. 

454 ``kernelSources` : `lsst.afw.table.SourceCatalog` 

455 Sources from the input catalog that were used to construct the 

456 PSF-matching kernel. 

457 """ 

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

459 

460 convolveTemplate = self.chooseConvolutionMethod(template, science) 

461 self.matchedPsfSize = self.sciencePsfSize if convolveTemplate else self.templatePsfSize 

462 

463 kernelResult = self.runMakeKernel(template, science, sources=sources, 

464 convolveTemplate=convolveTemplate, 

465 runSourceDetection=False) 

466 

467 if self.config.doSubtractBackground: 

468 backgroundModel = kernelResult.backgroundModel 

469 else: 

470 backgroundModel = None 

471 if convolveTemplate: 

472 subtractResults = self.runConvolveTemplate(template, science, kernelResult.psfMatchingKernel, 

473 backgroundModel=backgroundModel) 

474 else: 

475 subtractResults = self.runConvolveScience(template, science, kernelResult.psfMatchingKernel, 

476 backgroundModel=backgroundModel) 

477 subtractResults.kernelSources = kernelResult.kernelSources 

478 

479 metrics = computeDifferenceImageMetrics(science, subtractResults.difference, sources) 

480 

481 self.metadata["differenceFootprintRatioMean"] = metrics.differenceFootprintRatioMean 

482 self.metadata["differenceFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev 

483 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean 

484 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev 

485 self.log.info("Mean, stdev of ratio of difference to science " 

486 "pixels in star footprints: %5.4f, %5.4f", 

487 self.metadata["differenceFootprintRatioMean"], 

488 self.metadata["differenceFootprintRatioStdev"]) 

489 

490 return subtractResults 

491 

492 def chooseConvolutionMethod(self, template, science): 

493 """Determine whether the template should be convolved with the PSF 

494 matching kernel. 

495 

496 Parameters 

497 ---------- 

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

499 Template exposure, warped to match the science exposure. 

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

501 Science exposure to subtract from the template. 

502 

503 Returns 

504 ------- 

505 convolveTemplate : `bool` 

506 Convolve the template to match the two images? 

507 

508 Raises 

509 ------ 

510 RuntimeError 

511 If an unsupported convolution mode is supplied. 

512 """ 

513 if self.usePreconvolution: 

514 raise RuntimeError("Choosing a convolution method is incompatible with preconvolution!") 

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

516 convolveTemplate = _shapeTest(template, 

517 science, 

518 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

519 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid) 

520 if convolveTemplate: 

521 if self.sciencePsfSize < self.templatePsfSize: 

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

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

524 else: 

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

526 else: 

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

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

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

530 convolveTemplate = True 

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

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

533 convolveTemplate = False 

534 else: 

535 raise RuntimeError(f"Cannot handle AlardLuptonSubtract mode: {self.config.mode}") 

536 return convolveTemplate 

537 

538 def runMakeKernel(self, template, science, sources=None, convolveTemplate=True, runSourceDetection=False): 

539 """Construct the PSF-matching kernel. Not used for preconvolution. 

540 

541 Parameters 

542 ---------- 

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

544 Template exposure, warped to match the science exposure. 

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

546 Science exposure to subtract from the template. 

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

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

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

550 images around them. 

551 Not used if ``runSourceDetection`` is set. 

552 convolveTemplate : `bool`, optional 

553 Construct the matching kernel to convolve the template? 

554 runSourceDetection : `bool`, optional 

555 Run a minimal version of source detection to determine kernel 

556 candidates? If False, a source list to select kernel candidates 

557 from must be supplied. 

558 

559 Returns 

560 ------- 

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

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

563 Background model that was fit while solving for the 

564 PSF-matching kernel 

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

566 Kernel used to PSF-match the convolved image. 

567 ``kernelSources` : `lsst.afw.table.SourceCatalog` 

568 Sources from the input catalog that were used to construct the 

569 PSF-matching kernel. 

570 """ 

571 if self.usePreconvolution: 

572 raise RuntimeError("Incorrect matching kernel calculation configured. " 

573 "`runMakeKernel` can't be called if `usePreconvolution` is set.") 

574 if convolveTemplate: 

575 reference = template 

576 target = science 

577 referenceFwhmPix = self.templatePsfSize 

578 targetFwhmPix = self.sciencePsfSize 

579 else: 

580 reference = science 

581 target = template 

582 referenceFwhmPix = self.sciencePsfSize 

583 targetFwhmPix = self.templatePsfSize 

584 try: 

585 if runSourceDetection: 

586 kernelSources = self.runKernelSourceDetection(template, science) 

587 else: 

588 kernelSources = self._sourceSelector(template, science, sources) 

589 kernelResult = self.makeKernel.run(reference, target, kernelSources, 

590 preconvolved=False, 

591 templateFwhmPix=referenceFwhmPix, 

592 scienceFwhmPix=targetFwhmPix) 

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

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

595 # Raise NoWorkFound if template fraction is insufficient 

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

597 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

601 raise e 

602 

603 return lsst.pipe.base.Struct(backgroundModel=kernelResult.backgroundModel, 

604 psfMatchingKernel=kernelResult.psfMatchingKernel, 

605 kernelSources=kernelSources) 

606 

607 def runKernelSourceDetection(self, template, science): 

608 """Run detection on the science image and use the template mask plane 

609 to reject candidate sources. 

610 

611 Parameters 

612 ---------- 

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

614 Template exposure, warped to match the science exposure. 

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

616 Science exposure to subtract from the template. 

617 

618 Returns 

619 ------- 

620 kernelSources : `lsst.afw.table.SourceCatalog` 

621 Sources from the input catalog to use to construct the 

622 PSF-matching kernel. 

623 """ 

624 kernelSize = self.makeKernel.makeKernelBasisList( 

625 self.templatePsfSize, self.matchedPsfSize)[0].getWidth() 

626 sources = self.makeKernel.makeCandidateList(template, science, kernelSize, 

627 candidateList=None, 

628 sigma=gaussian_fwhm_to_sigma*self.sciencePsfSize) 

629 return self._sourceSelector(template, science, sources, fallback=True) 

630 

631 def runConvolveTemplate(self, template, science, psfMatchingKernel, backgroundModel=None): 

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

633 from the science image. 

634 

635 Parameters 

636 ---------- 

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

638 Template exposure, warped to match the science exposure. 

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

640 Science exposure to subtract from the template. 

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

642 Kernel to be used to PSF-match the science image to the template. 

643 backgroundModel : `lsst.afw.math.Function2D`, optional 

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

645 kernel. 

646 

647 Returns 

648 ------- 

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

650 

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

652 Result of subtracting template and science. 

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

654 Warped and PSF-matched template exposure. 

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

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

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

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

659 """ 

660 self.metadata["convolvedExposure"] = "Template" 

661 

662 matchedTemplate = self._convolveExposure(template, psfMatchingKernel, 

663 self.convolutionControl, 

664 bbox=science.getBBox(), 

665 psf=science.psf, 

666 photoCalib=science.photoCalib) 

667 

668 difference = _subtractImages(science, matchedTemplate, backgroundModel=backgroundModel) 

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

670 psfMatchingKernel, 

671 templateMatched=True) 

672 

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

674 matchedTemplate=matchedTemplate, 

675 matchedScience=science, 

676 backgroundModel=backgroundModel, 

677 psfMatchingKernel=psfMatchingKernel) 

678 

679 def runConvolveScience(self, template, science, psfMatchingKernel, backgroundModel=None): 

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

681 the template image. 

682 

683 Parameters 

684 ---------- 

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

686 Template exposure, warped to match the science exposure. 

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

688 Science exposure to subtract from the template. 

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

690 Kernel to be used to PSF-match the science image to the template. 

691 backgroundModel : `lsst.afw.math.Function2D`, optional 

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

693 kernel. 

694 

695 Returns 

696 ------- 

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

698 

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

700 Result of subtracting template and science. 

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

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

703 is not PSF-matched to the science image. 

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

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

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

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

708 """ 

709 self.metadata["convolvedExposure"] = "Science" 

710 bbox = science.getBBox() 

711 

712 kernelImage = lsst.afw.image.ImageD(psfMatchingKernel.getDimensions()) 

713 xcen, ycen = bbox.getCenter() 

714 norm = psfMatchingKernel.computeImage(kernelImage, doNormalize=False, x=xcen, y=ycen) 

715 

716 matchedScience = self._convolveExposure(science, psfMatchingKernel, 

717 self.convolutionControl, 

718 psf=template.psf) 

719 

720 # Place back on native photometric scale 

721 matchedScience.maskedImage /= norm 

722 matchedTemplate = template.clone()[bbox] 

723 matchedTemplate.setPhotoCalib(science.photoCalib) 

724 

725 if backgroundModel is not None: 

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

727 invertedBackground = backgroundModel.clone() 

728 invertedBackground.setParameters([-p for p in backgroundModel.getParameters()]) 

729 backgroundModel = invertedBackground 

730 

731 difference = _subtractImages(matchedScience, matchedTemplate, backgroundModel=backgroundModel) 

732 

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

734 psfMatchingKernel, 

735 templateMatched=False) 

736 

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

738 matchedTemplate=matchedTemplate, 

739 matchedScience=matchedScience, 

740 backgroundModel=backgroundModel, 

741 psfMatchingKernel=psfMatchingKernel) 

742 

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

744 templateMatched=True, 

745 preConvMode=False, 

746 preConvKernel=None, 

747 spatiallyVarying=False): 

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

749 caused by convolution. 

750 

751 Parameters 

752 ---------- 

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

754 Template exposure, warped to match the science exposure. 

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

756 Science exposure to subtract from the template. 

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

758 Result of subtracting template and science. 

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

760 An (optionally spatially-varying) PSF matching kernel 

761 templateMatched : `bool`, optional 

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

763 preConvMode : `bool`, optional 

764 Was the science image preconvolved with its own PSF 

765 before PSF matching the template? 

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

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

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

769 spatiallyVarying : `bool`, optional 

770 Compute the decorrelation kernel spatially varying across the image? 

771 

772 Returns 

773 ------- 

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

775 The decorrelated image difference. 

776 """ 

777 if self.config.doDecorrelation: 

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

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

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

781 # during decorrelation 

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

783 templateMatched=templateMatched, 

784 preConvMode=preConvMode, 

785 preConvKernel=preConvKernel, 

786 spatiallyVarying=spatiallyVarying).correctedExposure 

787 else: 

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

789 correctedExposure = difference 

790 return correctedExposure 

791 

792 def _calculateMagLim(self, exposure, nsigma=5.0, fallbackPsfSize=None): 

793 """Calculate an exposure's limiting magnitude. 

794 

795 This method uses the photometric zeropoint together with the 

796 PSF size from the average position of the exposure. 

797 

798 Parameters 

799 ---------- 

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

801 The target exposure to calculate the limiting magnitude for. 

802 nsigma : `float`, optional 

803 The detection threshold in sigma. 

804 fallbackPsfSize : `float`, optional 

805 PSF FWHM to use in the event the exposure PSF cannot be retrieved. 

806 

807 Returns 

808 ------- 

809 maglim : `astropy.units.Quantity` 

810 The limiting magnitude of the exposure, or np.nan. 

811 """ 

812 if exposure.photoCalib is None: 

813 return np.nan 

814 try: 

815 psf = exposure.getPsf() 

816 psf_shape = psf.computeShape(psf.getAveragePosition()) 

817 except (lsst.pex.exceptions.InvalidParameterError, 

818 afwDetection.InvalidPsfError, 

819 lsst.pex.exceptions.RangeError): 

820 if fallbackPsfSize is None: 

821 self.log.info("Unable to evaluate PSF, setting maglim to nan") 

822 return np.nan 

823 self.log.info("Unable to evaluate PSF, using fallback FWHM %f", fallbackPsfSize) 

824 psf_area = np.pi*(fallbackPsfSize/2)**2 

825 else: 

826 # Get a more accurate area than `psf_shape.getArea()` via moments 

827 psf_area = np.pi*np.sqrt(psf_shape.getIxx()*psf_shape.getIyy()) 

828 

829 zeropoint = exposure.photoCalib.instFluxToMagnitude(1) 

830 return zeropoint - 2.5*np.log10(nsigma*np.sqrt(psf_area)) 

831 

832 @staticmethod 

833 def _validateExposures(template, science): 

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

835 contains the science bbox, and that the bands match. 

836 

837 Parameters 

838 ---------- 

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

840 Template exposure, warped to match the science exposure. 

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

842 Science exposure to subtract from the template. 

843 

844 Raises 

845 ------ 

846 AssertionError 

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

848 if the science image is not fully contained in the template 

849 bounding box, or if the bands do not match. 

850 """ 

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

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

853 templateBBox = template.getBBox() 

854 scienceBBox = science.getBBox() 

855 assert science.filter.bandLabel == template.filter.bandLabel, \ 

856 "Science and template exposures have different bands: %s, %s" % \ 

857 (science.filter, template.filter) 

858 

859 assert templateBBox.contains(scienceBBox), \ 

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

861 

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

863 bbox=None, 

864 psf=None, 

865 photoCalib=None, 

866 interpolateBadMaskPlanes=False, 

867 ): 

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

869 

870 Parameters 

871 ---------- 

872 exposure : `lsst.afw.Exposure` 

873 exposure to convolve. 

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

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

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

877 Configuration for convolve algorithm. 

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

879 Bounding box to trim the convolved exposure to. 

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

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

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

883 Photometric calibration of the convolved exposure. 

884 interpolateBadMaskPlanes : `bool`, optional 

885 If set, interpolate over mask planes specified in 

886 ``config.badMaskPlanes`` before convolving the image. 

887 

888 Returns 

889 ------- 

890 convolvedExp : `lsst.afw.Exposure` 

891 The convolved image. 

892 """ 

893 convolvedExposure = exposure.clone() 

894 if psf is not None: 

895 convolvedExposure.setPsf(psf) 

896 if photoCalib is not None: 

897 convolvedExposure.setPhotoCalib(photoCalib) 

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

899 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

900 self.config.badMaskPlanes) 

901 self.metadata["nInterpolated"] = nInterp 

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

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

904 convolvedExposure.setMaskedImage(convolvedImage) 

905 if bbox is None: 

906 return convolvedExposure 

907 else: 

908 return convolvedExposure[bbox] 

909 

910 def _sourceSelector(self, template, science, sources, fallback=False): 

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

912 The selection criteria include any configured parameters of the 

913 `sourceSelector` subtask, as well as checking the science and template 

914 mask planes. 

915 

916 Parameters 

917 ---------- 

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

919 Template exposure, warped to match the science exposure. 

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

921 Science exposure to subtract from the template. 

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

923 Input source catalog to select sources from. 

924 fallback : `bool`, optional 

925 Switch indicating the source selector is being called after 

926 running the fallback source detection subtask, which does not run a 

927 full set of measurement plugins and can't use the same settings for 

928 the source selector. 

929 

930 Returns 

931 ------- 

932 kernelSources : `lsst.afw.table.SourceCatalog` 

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

934 sources removed and footprints added. 

935 

936 Raises 

937 ------ 

938 InsufficientKernelSourcesError 

939 An AlgorithmError that is raised if there are not enough PSF 

940 candidates to construct the PSF matching kernel. 

941 """ 

942 if fallback: 

943 selected = self.fallbackSourceSelector.selectSources(sources).selected 

944 else: 

945 selected = self.sourceSelector.selectSources(sources).selected 

946 # It is OK to use just self.matchedPsfSize for the science PSF here, 

947 # since we are just using it to calculate the size of the matching 

948 # kernel. 

949 kSize = self.makeKernel.makeKernelBasisList(self.templatePsfSize, self.matchedPsfSize)[0].getWidth() 

950 selectSources = sources[selected].copy(deep=True) 

951 # Set the footprints, to be used in `makeKernel` and `checkMask`. 

952 kernelSources = setSourceFootprints(selectSources, kernelSize=kSize) 

953 bbox = science.getBBox() 

954 if self.usePreconvolution: 

955 # Exclude a wider buffer around the edge of the image to 

956 # account for an extra convolution. 

957 bbox.grow(-kSize) 

958 if self.config.restrictKernelEdgeSources: 

959 bbox.grow(-kSize) 

960 # Remove sources that land on masked pixels 

961 scienceSelected = checkMask(science.mask[bbox], kernelSources, self.config.excludeMaskPlanes) 

962 templateSelected = checkMask(template.mask[bbox], kernelSources, self.config.excludeMaskPlanes) 

963 maskSelected = scienceSelected & templateSelected 

964 kernelSources = kernelSources[maskSelected].copy(deep=True) 

965 # Trim kernelSources if they exceed ``maxKernelSources``. 

966 # Keep the highest signal-to-noise sources of those selected. 

967 if (len(kernelSources) > self.config.maxKernelSources) & (self.config.maxKernelSources > 0): 

968 signalToNoise = kernelSources.getPsfInstFlux()/kernelSources.getPsfInstFluxErr() 

969 indices = np.argsort(signalToNoise) 

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

971 selected = np.zeros(len(kernelSources), dtype=bool) 

972 selected[indices] = True 

973 kernelSources = kernelSources[selected].copy(deep=True) 

974 

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

976 len(kernelSources), len(sources), 100*len(kernelSources)/len(sources)) 

977 if len(kernelSources) < self.config.minKernelSources: 

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

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

980 len(kernelSources), self.config.minKernelSources) 

981 if self.config.allowKernelSourceDetection and not fallback: 

982 # The fallback source detection pipeline calls this method, so 

983 # allowing source detection in that case would create an endless 

984 # loop 

985 kernelSources = self.runKernelSourceDetection(template, science) 

986 else: 

987 raise InsufficientKernelSourcesError(nSources=len(kernelSources), 

988 nRequired=self.config.minKernelSources) 

989 

990 self.metadata["nPsfSources"] = len(kernelSources) 

991 

992 return kernelSources 

993 

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

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

996 

997 Parameters 

998 ---------- 

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

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

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

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

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

1004 of the science image is modified in place. 

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

1006 Exposure catalog with external calibrations to be applied. Catalog 

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

1008 lookup. 

1009 """ 

1010 self._validateExposures(template, science) 

1011 if visitSummary is not None: 

1012 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

1013 templateCoverageFraction = checkTemplateIsSufficient( 

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

1015 requiredTemplateFraction=self.config.requiredTemplateFraction, 

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

1017 " set config requiredTemplateFraction=0" 

1018 ) 

1019 self.metadata["templateCoveragePercent"] = 100*templateCoverageFraction 

1020 

1021 if self.config.doScaleVariance: 

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

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

1024 # correct ratio. 

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

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

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

1028 self.metadata["scaleTemplateVarianceFactor"] = templateVarFactor 

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

1030 self.metadata["scaleScienceVarianceFactor"] = sciVarFactor 

1031 

1032 # Erase existing detection mask planes. 

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

1034 self.updateMasks(template, science) 

1035 

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

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

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

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

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

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

1042 # In the try block below, we catch two exceptions: 

1043 # 1. InvalidParameterError, in case the point where we are evaluating 

1044 # the PSF lands in a gap in the template. 

1045 # 2. RangeError, in case the template coverage is so poor that we end 

1046 # up near a region with no data. 

1047 try: 

1048 self.templatePsfSize = getPsfFwhm(template.psf) 

1049 self.sciencePsfSize = getPsfFwhm(science.psf) 

1050 except lsst.pex.exceptions.Exception: 

1051 # Catch a broad range of exceptions, since some are C++ only 

1052 # Catching: 

1053 # - lsst::geom::SingularTransformException 

1054 # - lsst.pex.exceptions.InvalidParameterError 

1055 # - lsst.pex.exceptions.RangeError 

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

1057 "Evaluting PSF on a grid of points." 

1058 ) 

1059 self.templatePsfSize = evaluateMeanPsfFwhm( 

1060 template, 

1061 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

1062 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid 

1063 ) 

1064 self.sciencePsfSize = evaluateMeanPsfFwhm( 

1065 science, 

1066 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

1067 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid 

1068 ) 

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

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

1071 self.metadata["sciencePsfSize"] = self.sciencePsfSize 

1072 self.metadata["templatePsfSize"] = self.templatePsfSize 

1073 

1074 # Calculate estimated image depths, i.e., limiting magnitudes 

1075 maglim_science = self._calculateMagLim(science, fallbackPsfSize=self.sciencePsfSize) 

1076 if np.isnan(maglim_science): 

1077 self.log.warning("Limiting magnitude of the science image is NaN!") 

1078 fluxlim_science = (maglim_science*u.ABmag).to_value(u.nJy) 

1079 maglim_template = self._calculateMagLim(template, fallbackPsfSize=self.templatePsfSize) 

1080 if np.isnan(maglim_template): 

1081 self.log.info("Cannot evaluate template limiting mag; adopting science limiting mag for diffim") 

1082 maglim_diffim = maglim_science 

1083 else: 

1084 fluxlim_template = (maglim_template*u.ABmag).to_value(u.nJy) 

1085 maglim_diffim = (np.sqrt(fluxlim_science**2 + fluxlim_template**2)*u.nJy).to(u.ABmag).value 

1086 self.metadata["scienceLimitingMagnitude"] = maglim_science 

1087 self.metadata["templateLimitingMagnitude"] = maglim_template 

1088 self.metadata["diffimLimitingMagnitude"] = maglim_diffim 

1089 

1090 def updateMasks(self, template, science): 

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

1092 

1093 Parameters 

1094 ---------- 

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

1096 Template exposure, warped to match the science exposure. 

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

1098 in the task config. 

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

1100 Science exposure to subtract from the template. 

1101 The DETECTED and DETECTED_NEGATIVE mask planes of the science image 

1102 will be erased. 

1103 """ 

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

1105 

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

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

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

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

1110 # be cleared. 

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

1112 if mp not in self.config.preserveTemplateMask] 

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

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

1115 

1116 # propagate the mask plane related to Fake source injection 

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

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

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

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

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

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

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

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

1125 if "INJECTED" in renameMaskPlanes: 

1126 renameMaskPlanes.remove("INJECTED") 

1127 if "INJECTED_TEMPLATE" in clearMaskPlanes: 

1128 clearMaskPlanes.remove("INJECTED_TEMPLATE") 

1129 

1130 for maskPlane in renameMaskPlanes: 

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

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

1133 

1134 @staticmethod 

1135 def _renameMaskPlanes(mask, maskPlane, newMaskPlane): 

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

1137 

1138 Parameters 

1139 ---------- 

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

1141 The mask image to update in place. 

1142 maskPlane : `str` 

1143 The name of the existing mask plane to copy. 

1144 newMaskPlane : `str` 

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

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

1147 """ 

1148 mask.addMaskPlane(newMaskPlane) 

1149 originBitMask = mask.getPlaneBitMask(maskPlane) 

1150 destinationBitMask = mask.getPlaneBitMask(newMaskPlane) 

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

1152 

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

1154 """Clear the mask plane of an exposure. 

1155 

1156 Parameters 

1157 ---------- 

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

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

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

1161 Erase the specified mask planes. 

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

1163 """ 

1164 if clearMaskPlanes is None: 

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

1166 

1167 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

1168 mask &= ~bitMaskToClear 

1169 

1170 

1171class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

1172 SubtractScoreOutputConnections): 

1173 pass 

1174 

1175 

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

1177 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

1178 pass 

1179 

1180 

1181class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

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

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

1184 subtraction. 

1185 """ 

1186 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

1187 _DefaultName = "alardLuptonPreconvolveSubtract" 

1188 usePreconvolution = True 

1189 

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

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

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

1193 from the preconvolved science image. 

1194 

1195 Parameters 

1196 ---------- 

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

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

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

1200 the science bbox. 

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

1202 The science exposure. 

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

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

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

1206 images around them. 

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

1208 Exposure catalog with complete external calibrations. Catalog uses 

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

1210 

1211 Returns 

1212 ------- 

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

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

1215 Result of subtracting the convolved template and science 

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

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

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

1219 of the original science image. 

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

1221 The science exposure after convolving with its own PSF. 

1222 Attached PSF is that of the original science image. 

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

1224 Background model that was fit while solving for the 

1225 PSF-matching kernel 

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

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

1228 image. 

1229 """ 

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

1231 

1232 convolutionKernel = self._makePreconvolutionKernel(science.psf) 

1233 matchedScience = self._convolveExposure(science, convolutionKernel, self.convolutionControl, 

1234 interpolateBadMaskPlanes=True) 

1235 self.metadata["convolvedExposure"] = "Preconvolution" 

1236 

1237 self.matchedPsfSize = self.sciencePsfSize*np.sqrt(2) 

1238 self.log.info("Preconvolved science PSF FWHM: %f pixels", self.matchedPsfSize) 

1239 self.metadata["preconvolvedSciencePsfSize"] = self.matchedPsfSize 

1240 try: 

1241 kernelSources = self._sourceSelector(template, matchedScience, sources) 

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

1243 kernelSources, convolutionKernel) 

1244 

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

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

1247 # Raise NoWorkFound if template fraction is insufficient 

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

1249 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

1253 raise e 

1254 

1255 return subtractResults 

1256 

1257 @staticmethod 

1258 def _flagScoreEdge(mask, innerBBox): 

1259 """Set the EDGE mask bit on pixels outside a known-valid region. 

1260 

1261 Parameters 

1262 ---------- 

1263 mask : `~lsst.afw.image.Mask` 

1264 Exposure mask that will be modified in place. Must have 

1265 an ``EDGE`` mask plane. 

1266 innerBBox : `~lsst.geom.Box2I` 

1267 The valid inner region. Pixels 

1268 outside this bbox will have their ``EDGE`` bit set. 

1269 """ 

1270 bbox = mask.getBBox() 

1271 edgeBit = mask.getPlaneBitMask("EDGE") 

1272 dx0 = innerBBox.getMinX() - bbox.getMinX() 

1273 dx1 = bbox.getMaxX() - innerBBox.getMaxX() 

1274 dy0 = innerBBox.getMinY() - bbox.getMinY() 

1275 dy1 = bbox.getMaxY() - innerBBox.getMaxY() 

1276 if dy0 > 0: 

1277 mask.array[:dy0, :] |= edgeBit 

1278 if dy1 > 0: 

1279 mask.array[-dy1:, :] |= edgeBit 

1280 if dx0 > 0: 

1281 mask.array[:, :dx0] |= edgeBit 

1282 if dx1 > 0: 

1283 mask.array[:, -dx1:] |= edgeBit 

1284 

1285 @staticmethod 

1286 def _makePreconvolutionKernel(psf): 

1287 """Build a normalized, reflected matched-filter kernel from a PSF. 

1288 

1289 Convolving an image with this kernel is equivalent to correlating 

1290 the image with the PSF, so peaks in the output align with the PSF's 

1291 centroid — even for asymmetric PSFs. The kernel is evaluated at the 

1292 PSF's average position and returned as a constant 

1293 `~lsst.afw.math.Kernel`. 

1294 

1295 Parameters 

1296 ---------- 

1297 psf : `~lsst.afw.detection.Psf` 

1298 The PSF to derive the preconvolution kernel from. 

1299 

1300 Returns 

1301 ------- 

1302 kernel : `~lsst.afw.math.Kernel` 

1303 The PSF reflected about both axes, normalized to sum to one. 

1304 

1305 Raises 

1306 ------ 

1307 ValueError 

1308 Raised if the PSF kernel has an even size along either axis. 

1309 It's not possible to center an even-sized kernel. 

1310 """ 

1311 avgPos = psf.getAveragePosition() 

1312 localKernel = psf.getLocalKernel(avgPos) 

1313 dims = localKernel.getDimensions() 

1314 if dims.x % 2 == 0 or dims.y % 2 == 0: 

1315 raise ValueError( 

1316 f"Preconvolution requires an odd-sized PSF kernel, got {dims.x}x{dims.y}. " 

1317 ) 

1318 kimg = lsst.afw.image.ImageD(dims) 

1319 localKernel.computeImage(kimg, doNormalize=True) # normalize to unit sum 

1320 # Reflect about the kernel center. PSF kernels are odd-sized, 

1321 # so ``[::-1, ::-1]`` places the peak at the same pixel. 

1322 kimg.array[...] = kimg.array[::-1, ::-1] 

1323 return lsst.afw.math.FixedKernel(kimg) 

1324 

1325 def runPreconvolve(self, template, science, matchedScience, kernelSources, preConvKernel): 

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

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

1328 exposure. 

1329 

1330 Parameters 

1331 ---------- 

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

1333 Template exposure, warped to match the science exposure. 

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

1335 Science exposure to subtract from the template. 

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

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

1338 kernelSources : `lsst.afw.table.SourceCatalog` 

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

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

1341 images around them. 

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

1343 The kernel that was used to preconvolve the ``science`` 

1344 exposure. Must be normalized to sum to 1. 

1345 

1346 Returns 

1347 ------- 

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

1349 

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

1351 Result of subtracting the convolved template and science 

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

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

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

1355 of the original science image. 

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

1357 The science exposure after convolving with its own PSF. 

1358 Attached PSF is that of the original science image. 

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

1360 Background model that was fit while solving for the 

1361 PSF-matching kernel 

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

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

1364 image. 

1365 """ 

1366 bbox = science.getBBox() 

1367 innerBBox = preConvKernel.shrinkBBox(bbox) 

1368 

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

1370 preconvolved=True, 

1371 templateFwhmPix=self.templatePsfSize, 

1372 scienceFwhmPix=self.matchedPsfSize) 

1373 

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

1375 self.convolutionControl, 

1376 bbox=bbox, 

1377 psf=science.psf, 

1378 interpolateBadMaskPlanes=True, 

1379 photoCalib=science.photoCalib) 

1380 score = _subtractImages(matchedScience, matchedTemplate, 

1381 backgroundModel=(kernelResult.backgroundModel 

1382 if self.config.doSubtractBackground else None)) 

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

1384 kernelResult.psfMatchingKernel, 

1385 templateMatched=True, preConvMode=True, 

1386 preConvKernel=preConvKernel) 

1387 

1388 # Flag the outer ``preConvKernel/2``-wide border as EDGE. 

1389 self._flagScoreEdge(correctedScore.mask, innerBBox) 

1390 

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

1392 matchedTemplate=matchedTemplate, 

1393 matchedScience=matchedScience, 

1394 backgroundModel=kernelResult.backgroundModel, 

1395 psfMatchingKernel=kernelResult.psfMatchingKernel, 

1396 kernelSources=kernelSources) 

1397 

1398 

1399def checkTemplateIsSufficient(templateExposure, scienceExposure, logger, requiredTemplateFraction=0., 

1400 exceptionMessage=""): 

1401 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1402 

1403 Parameters 

1404 ---------- 

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

1406 The template exposure to check 

1407 logger : `logging.Logger` 

1408 Logger for printing output. 

1409 requiredTemplateFraction : `float`, optional 

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

1411 in the template. 

1412 exceptionMessage : `str`, optional 

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

1414 is insufficient. 

1415 

1416 Returns 

1417 ------- 

1418 templateCoverageFraction: `float` 

1419 Fraction of pixels in the template with data. 

1420 

1421 Raises 

1422 ------ 

1423 lsst.pipe.base.NoWorkFound 

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

1425 set, is less than the requiredTemplateFraction 

1426 """ 

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

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

1429 noTemplate = templateExposure.mask.array & templateExposure.mask.getPlaneBitMask('NO_DATA') 

1430 # Also need to account for missing data in the science image, 

1431 # because template coverage there doesn't help 

1432 noScience = scienceExposure.mask.array & scienceExposure.mask.getPlaneBitMask('NO_DATA') 

1433 pixNoData = np.count_nonzero(noTemplate | noScience) 

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

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

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

1437 

1438 if templateCoverageFraction < requiredTemplateFraction: 

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

1440 100*templateCoverageFraction, 

1441 100*requiredTemplateFraction)) 

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

1443 return templateCoverageFraction 

1444 

1445 

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

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

1448 

1449 Parameters 

1450 ---------- 

1451 science : `lsst.afw.Exposure` 

1452 The input science image. 

1453 template : `lsst.afw.Exposure` 

1454 The template to subtract from the science image. 

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

1456 Differential background model 

1457 

1458 Returns 

1459 ------- 

1460 difference : `lsst.afw.Exposure` 

1461 The subtracted image. 

1462 """ 

1463 difference = science.clone() 

1464 if backgroundModel is not None: 

1465 difference.maskedImage -= backgroundModel 

1466 difference.maskedImage -= template.maskedImage 

1467 return difference 

1468 

1469 

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

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

1472 

1473 Parameters 

1474 ---------- 

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

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

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

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

1479 fwhmExposureBuffer : `float` 

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

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

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

1483 fwhmExposureGrid : `int` 

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

1485 available at its average position. 

1486 Returns 

1487 ------- 

1488 result : `bool` 

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

1490 either dimension. 

1491 """ 

1492 try: 

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

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

1495 except (lsst.pex.exceptions.InvalidParameterError, lsst.pex.exceptions.RangeError): 

1496 shape1 = evaluateMeanPsfFwhm(exp1, 

1497 fwhmExposureBuffer=fwhmExposureBuffer, 

1498 fwhmExposureGrid=fwhmExposureGrid 

1499 ) 

1500 shape2 = evaluateMeanPsfFwhm(exp2, 

1501 fwhmExposureBuffer=fwhmExposureBuffer, 

1502 fwhmExposureGrid=fwhmExposureGrid 

1503 ) 

1504 return shape1 <= shape2 

1505 

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

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

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

1509 return xTest | yTest 

1510 

1511 

1512class SimplifiedSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig, 

1513 pipelineConnections=SimplifiedSubtractConnections): 

1514 mode = lsst.pex.config.ChoiceField( 

1515 dtype=str, 

1516 default="convolveTemplate", 

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

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

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

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

1521 ) 

1522 useExistingKernel = lsst.pex.config.Field( 

1523 dtype=bool, 

1524 default=True, 

1525 doc="Use a pre-existing PSF matching kernel?" 

1526 "If False, source detection and measurement will be run." 

1527 ) 

1528 

1529 

1530class SimplifiedSubtractTask(AlardLuptonSubtractTask): 

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

1532 the Alard & Lupton (1998) algorithm. 

1533 """ 

1534 ConfigClass = SimplifiedSubtractConfig 

1535 _DefaultName = "simplifiedSubtract" 

1536 

1537 @timeMethod 

1538 def run(self, template, science, visitSummary=None, inputPsfMatchingKernel=None): 

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

1540 

1541 Parameters 

1542 ---------- 

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

1544 Template exposure, warped to match the science exposure. 

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

1546 Science exposure to subtract from the template. 

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

1548 Exposure catalog with external calibrations to be applied. Catalog 

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

1550 lookup. 

1551 inputPsfMatchingKernel : `lsst.afw.math.Kernel`, optional 

1552 Pre-existing PSF matching kernel to use for convolution. 

1553 Required, and only used, if ``config.useExistingKernel`` is set. 

1554 

1555 Returns 

1556 ------- 

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

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

1559 Result of subtracting template and science. 

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

1561 Warped and PSF-matched template exposure. 

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

1563 Background model that was fit while solving for the 

1564 PSF-matching kernel 

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

1566 Kernel used to PSF-match the convolved image. 

1567 ``kernelSources` : `lsst.afw.table.SourceCatalog` 

1568 Sources detected on the science image that were used to 

1569 construct the PSF-matching kernel. 

1570 

1571 Raises 

1572 ------ 

1573 lsst.pipe.base.NoWorkFound 

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

1575 set, is less then the configured requiredTemplateFraction 

1576 """ 

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

1578 

1579 convolveTemplate = self.chooseConvolutionMethod(template, science) 

1580 self.matchedPsfSize = self.sciencePsfSize if convolveTemplate else self.templatePsfSize 

1581 

1582 if self.config.useExistingKernel: 

1583 psfMatchingKernel = inputPsfMatchingKernel 

1584 backgroundModel = None 

1585 kernelSources = None 

1586 else: 

1587 kernelResult = self.runMakeKernel(template, science, convolveTemplate=convolveTemplate, 

1588 runSourceDetection=True) 

1589 psfMatchingKernel = kernelResult.psfMatchingKernel 

1590 kernelSources = kernelResult.kernelSources 

1591 if self.config.doSubtractBackground: 

1592 backgroundModel = kernelResult.backgroundModel 

1593 else: 

1594 backgroundModel = None 

1595 if convolveTemplate: 

1596 subtractResults = self.runConvolveTemplate(template, science, psfMatchingKernel, 

1597 backgroundModel=backgroundModel) 

1598 else: 

1599 subtractResults = self.runConvolveScience(template, science, psfMatchingKernel, 

1600 backgroundModel=backgroundModel) 

1601 if kernelSources is not None: 

1602 subtractResults.kernelSources = kernelSources 

1603 return subtractResults 

1604 

1605 

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

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

1608 

1609 Parameters 

1610 ---------- 

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

1612 Image on which to perform interpolation. 

1613 badMaskPlanes : `list` of `str` 

1614 List of mask planes to interpolate over. 

1615 fallbackValue : `float`, optional 

1616 Value to set when interpolation fails. 

1617 

1618 Returns 

1619 ------- 

1620 result: `float` 

1621 The number of masked pixels that were replaced. 

1622 """ 

1623 imgBadMaskPlanes = [ 

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

1625 ] 

1626 

1627 image = maskedImage.image.array 

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

1629 image[badPixels] = np.nan 

1630 if fallbackValue is None: 

1631 fallbackValue = np.nanmedian(image) 

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

1633 # the median value. 

1634 image[badPixels] = fallbackValue 

1635 return np.sum(badPixels)