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

502 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 23:33 +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, computePSFNoiseEquivalentArea, 

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 science image? Note that the template variance is NOT scaled" 

211 " here. The template variance may be scaled independently in ``GetTemplateTask``." 

212 ) 

213 scaleVariance = lsst.pex.config.ConfigurableField( 

214 target=ScaleVarianceTask, 

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

216 ) 

217 doSubtractBackground = lsst.pex.config.Field( 

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

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

220 dtype=bool, 

221 default=False, 

222 ) 

223 doApplyExternalCalibrations = lsst.pex.config.Field( 

224 doc=( 

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

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

227 ), 

228 dtype=bool, 

229 default=False, 

230 ) 

231 sourceSelector = lsst.pex.config.ConfigurableField( 

232 target=ScienceSourceSelectorTask, 

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

234 ) 

235 fallbackSourceSelector = lsst.pex.config.ConfigurableField( 

236 target=ScienceSourceSelectorTask, 

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

238 "Used only if the kernel calculation fails and" 

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

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

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

242 ) 

243 detectionThreshold = lsst.pex.config.Field( 

244 dtype=float, 

245 default=10, 

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

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

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

249 ) 

250 detectionThresholdMax = lsst.pex.config.Field( 

251 dtype=float, 

252 default=500, 

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

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

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

256 ) 

257 restrictKernelEdgeSources = lsst.pex.config.Field( 

258 dtype=bool, 

259 default=True, 

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

261 ) 

262 maxKernelSources = lsst.pex.config.Field( 

263 dtype=int, 

264 default=1000, 

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

266 "Set to -1 to disable." 

267 ) 

268 minKernelSources = lsst.pex.config.Field( 

269 dtype=int, 

270 default=3, 

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

272 ) 

273 excludeMaskPlanes = lsst.pex.config.ListField( 

274 dtype=str, 

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

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

277 ) 

278 badMaskPlanes = lsst.pex.config.ListField( 

279 dtype=str, 

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

281 doc="Mask planes to interpolate over." 

282 ) 

283 preserveTemplateMask = lsst.pex.config.ListField( 

284 dtype=str, 

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

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

287 ) 

288 renameTemplateMask = lsst.pex.config.ListField( 

289 dtype=str, 

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

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

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

293 ) 

294 preserveMaskPlanes = lsst.pex.config.ListField( 

295 dtype=str, 

296 default=("INJECTED", "INJECTED_CORE", "INJECTED_TEMPLATE", "INJECTED_CORE_TEMPLATE"), 

297 doc="Mask planes to preserve without dilation when convolving the image.", 

298 ) 

299 allowKernelSourceDetection = lsst.pex.config.Field( 

300 dtype=bool, 

301 default=False, 

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

303 " encountered while calculating the matching kernel." 

304 ) 

305 

306 def setDefaults(self): 

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

308 # Always include background fitting in the kernel fit, 

309 # even if it is not subtracted 

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

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

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

313 # Shared source selector settings 

314 doSkySources = False # Do not include sky sources 

315 doSignalToNoise = True # apply signal to noise filter 

316 doUnresolved = True # apply star-galaxy separation 

317 signalToNoiseMinimum = 10 

318 signalToNoiseMaximum = 500 

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

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

321 self.sourceSelector.doUnresolved = doUnresolved 

322 self.sourceSelector.doSkySources = doSkySources 

323 self.sourceSelector.doSignalToNoise = doSignalToNoise 

324 self.sourceSelector.signalToNoise.minimum = signalToNoiseMinimum 

325 self.sourceSelector.signalToNoise.maximum = signalToNoiseMaximum 

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

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

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

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

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

331 self.fallbackSourceSelector.doUnresolved = doUnresolved 

332 self.fallbackSourceSelector.doSkySources = doSkySources 

333 self.fallbackSourceSelector.doSignalToNoise = doSignalToNoise 

334 self.fallbackSourceSelector.signalToNoise.minimum = signalToNoiseMinimum 

335 self.fallbackSourceSelector.signalToNoise.maximum = signalToNoiseMaximum 

336 

337 

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

339 pipelineConnections=AlardLuptonSubtractConnections): 

340 mode = lsst.pex.config.ChoiceField( 

341 dtype=str, 

342 default="convolveTemplate", 

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

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

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

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

347 ) 

348 

349 

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

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

352 the Alard & Lupton (1998) algorithm. 

353 """ 

354 ConfigClass = AlardLuptonSubtractConfig 

355 _DefaultName = "alardLuptonSubtract" 

356 usePreconvolution = False 

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

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

359 `True`.""" 

360 

361 def __init__(self, **kwargs): 

362 super().__init__(**kwargs) 

363 self.makeSubtask("decorrelate") 

364 self.makeSubtask("makeKernel") 

365 self.makeSubtask("sourceSelector") 

366 self.makeSubtask("fallbackSourceSelector") 

367 if self.config.doScaleVariance: 

368 self.makeSubtask("scaleVariance") 

369 

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

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

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

373 self.convolutionControl.setDoNormalize(False) 

374 self.convolutionControl.setDoCopyEdge(True) 

375 

376 def _applyExternalCalibrations(self, exposure, visitSummary): 

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

378 external ones.". 

379 

380 Parameters 

381 ---------- 

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

383 Input exposure to adjust calibrations. 

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

385 Exposure catalog with external calibrations to be applied. Catalog 

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

387 lookup. 

388 

389 Returns 

390 ------- 

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

392 Exposure with adjusted calibrations. 

393 """ 

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

395 

396 row = visitSummary.find(detectorId) 

397 if row is None: 

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

399 "Using original calibrations.", detectorId) 

400 else: 

401 psf = row.getPsf() 

402 apCorrMap = row.getApCorrMap() 

403 if psf is None: 

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

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

406 detectorId) 

407 elif apCorrMap is None: 

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

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

410 detectorId) 

411 else: 

412 exposure.setPsf(psf) 

413 exposure.info.setApCorrMap(apCorrMap) 

414 

415 return exposure 

416 

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

418 inputs = butlerQC.get(inputRefs) 

419 

420 try: 

421 results = self.run(**inputs) 

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

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

424 # No partial outputs for butler to put 

425 raise error from e 

426 

427 butlerQC.put(results, outputRefs) 

428 

429 @timeMethod 

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

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

432 

433 Parameters 

434 ---------- 

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

436 Template exposure, warped to match the science exposure. 

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

438 Science exposure to subtract from the template. 

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

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

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

442 images around them. 

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

444 Exposure catalog with external calibrations to be applied. Catalog 

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

446 lookup. 

447 

448 Returns 

449 ------- 

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

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

452 Result of subtracting template and science. 

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

454 Warped and PSF-matched template exposure. 

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

456 Background model that was fit while solving for the 

457 PSF-matching kernel 

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

459 Kernel used to PSF-match the convolved image. 

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

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

462 PSF-matching kernel. 

463 """ 

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

465 

466 convolveTemplate = self.chooseConvolutionMethod(template, science) 

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

468 

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

470 convolveTemplate=convolveTemplate, 

471 runSourceDetection=False) 

472 

473 if self.config.doSubtractBackground: 

474 backgroundModel = kernelResult.backgroundModel 

475 else: 

476 backgroundModel = None 

477 if convolveTemplate: 

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

479 backgroundModel=backgroundModel) 

480 else: 

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

482 backgroundModel=backgroundModel) 

483 subtractResults.kernelSources = kernelResult.kernelSources 

484 

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

486 

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

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

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

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

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

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

493 self.metadata["differenceFootprintRatioMean"], 

494 self.metadata["differenceFootprintRatioStdev"]) 

495 

496 return subtractResults 

497 

498 def chooseConvolutionMethod(self, template, science): 

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

500 matching kernel. 

501 

502 Parameters 

503 ---------- 

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

505 Template exposure, warped to match the science exposure. 

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

507 Science exposure to subtract from the template. 

508 

509 Returns 

510 ------- 

511 convolveTemplate : `bool` 

512 Convolve the template to match the two images? 

513 

514 Raises 

515 ------ 

516 RuntimeError 

517 If an unsupported convolution mode is supplied. 

518 """ 

519 if self.usePreconvolution: 519 ↛ 520line 519 didn't jump to line 520 because the condition on line 519 was never true

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

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

522 convolveTemplate = _shapeTest(template, 

523 science, 

524 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

525 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid) 

526 if convolveTemplate: 

527 if self.sciencePsfSize < self.templatePsfSize: 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true

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

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

530 else: 

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

532 else: 

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

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

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

536 convolveTemplate = True 

537 elif self.config.mode == "convolveScience": 537 ↛ 541line 537 didn't jump to line 541 because the condition on line 537 was always true

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

539 convolveTemplate = False 

540 else: 

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

542 return convolveTemplate 

543 

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

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

546 

547 Parameters 

548 ---------- 

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

550 Template exposure, warped to match the science exposure. 

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

552 Science exposure to subtract from the template. 

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

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

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

556 images around them. 

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

558 convolveTemplate : `bool`, optional 

559 Construct the matching kernel to convolve the template? 

560 runSourceDetection : `bool`, optional 

561 Run a minimal version of source detection to determine kernel 

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

563 from must be supplied. 

564 

565 Returns 

566 ------- 

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

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

569 Background model that was fit while solving for the 

570 PSF-matching kernel 

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

572 Kernel used to PSF-match the convolved image. 

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

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

575 PSF-matching kernel. 

576 """ 

577 if self.usePreconvolution: 577 ↛ 578line 577 didn't jump to line 578 because the condition on line 577 was never true

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

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

580 if convolveTemplate: 

581 reference = template 

582 target = science 

583 referenceFwhmPix = self.templatePsfSize 

584 targetFwhmPix = self.sciencePsfSize 

585 else: 

586 reference = science 

587 target = template 

588 referenceFwhmPix = self.sciencePsfSize 

589 targetFwhmPix = self.templatePsfSize 

590 try: 

591 if runSourceDetection: 

592 kernelSources = self.runKernelSourceDetection(template, science) 

593 else: 

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

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

596 preconvolved=False, 

597 templateFwhmPix=referenceFwhmPix, 

598 scienceFwhmPix=targetFwhmPix) 

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

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

601 # Raise NoWorkFound if template fraction is insufficient 

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

603 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

607 raise e 

608 

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

610 psfMatchingKernel=kernelResult.psfMatchingKernel, 

611 kernelSources=kernelSources) 

612 

613 def runKernelSourceDetection(self, template, science): 

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

615 to reject candidate sources. 

616 

617 Parameters 

618 ---------- 

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

620 Template exposure, warped to match the science exposure. 

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

622 Science exposure to subtract from the template. 

623 

624 Returns 

625 ------- 

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

627 Sources from the input catalog to use to construct the 

628 PSF-matching kernel. 

629 """ 

630 kernelSize = self.makeKernel.makeKernelBasisList( 

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

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

633 candidateList=None, 

634 sigma=gaussian_fwhm_to_sigma*self.sciencePsfSize) 

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

636 

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

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

639 from the science image. 

640 

641 Parameters 

642 ---------- 

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

644 Template exposure, warped to match the science exposure. 

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

646 Science exposure to subtract from the template. 

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

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

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

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

651 kernel. 

652 

653 Returns 

654 ------- 

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

656 

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

658 Result of subtracting template and science. 

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

660 Warped and PSF-matched template exposure. 

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

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

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

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

665 """ 

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

667 

668 matchedTemplate = self._convolveExposure(template, psfMatchingKernel, 

669 self.convolutionControl, 

670 bbox=science.getBBox(), 

671 psf=science.psf, 

672 photoCalib=science.photoCalib) 

673 

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

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

676 psfMatchingKernel, 

677 templateMatched=True) 

678 

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

680 matchedTemplate=matchedTemplate, 

681 matchedScience=science, 

682 backgroundModel=backgroundModel, 

683 psfMatchingKernel=psfMatchingKernel) 

684 

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

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

687 the template image. 

688 

689 Parameters 

690 ---------- 

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

692 Template exposure, warped to match the science exposure. 

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

694 Science exposure to subtract from the template. 

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

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

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

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

699 kernel. 

700 

701 Returns 

702 ------- 

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

704 

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

706 Result of subtracting template and science. 

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

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

709 is not PSF-matched to the science image. 

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

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

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

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

714 """ 

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

716 bbox = science.getBBox() 

717 

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

719 xcen, ycen = bbox.getCenter() 

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

721 

722 matchedScience = self._convolveExposure(science, psfMatchingKernel, 

723 self.convolutionControl, 

724 psf=template.psf) 

725 

726 # Place back on native photometric scale 

727 matchedScience.maskedImage /= norm 

728 matchedTemplate = template.clone()[bbox] 

729 matchedTemplate.setPhotoCalib(science.photoCalib) 

730 

731 if backgroundModel is not None: 

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

733 invertedBackground = backgroundModel.clone() 

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

735 backgroundModel = invertedBackground 

736 

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

738 

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

740 psfMatchingKernel, 

741 templateMatched=False) 

742 

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

744 matchedTemplate=matchedTemplate, 

745 matchedScience=matchedScience, 

746 backgroundModel=backgroundModel, 

747 psfMatchingKernel=psfMatchingKernel) 

748 

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

750 templateMatched=True, 

751 preConvMode=False, 

752 preConvKernel=None, 

753 spatiallyVarying=False): 

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

755 caused by convolution. 

756 

757 Parameters 

758 ---------- 

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

760 Template exposure, warped to match the science exposure. 

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

762 Science exposure to subtract from the template. 

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

764 Result of subtracting template and science. 

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

766 An (optionally spatially-varying) PSF matching kernel 

767 templateMatched : `bool`, optional 

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

769 preConvMode : `bool`, optional 

770 Was the science image preconvolved with its own PSF 

771 before PSF matching the template? 

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

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

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

775 spatiallyVarying : `bool`, optional 

776 Compute the decorrelation kernel spatially varying across the image? 

777 

778 Returns 

779 ------- 

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

781 The decorrelated image difference. 

782 """ 

783 if self.config.doDecorrelation: 

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

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

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

787 # during decorrelation 

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

789 templateMatched=templateMatched, 

790 preConvMode=preConvMode, 

791 preConvKernel=preConvKernel, 

792 spatiallyVarying=spatiallyVarying).correctedExposure 

793 else: 

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

795 correctedExposure = difference 

796 return correctedExposure 

797 

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

799 """Calculate the point-source limiting magnitude of an exposure. 

800 

801 The limit is the flux of a point source detected at ``nsigma`` in 

802 the median pixel noise, integrated over the effective area of the 

803 PSF, so it does not depend on the units of the pixels. 

804 

805 Parameters 

806 ---------- 

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

808 The exposure to calculate the limiting magnitude for. 

809 nsigma : `float`, optional 

810 The detection threshold in sigma. 

811 fallbackPsfSize : `float`, optional 

812 PSF FWHM in pixels to use if the exposure PSF cannot be 

813 evaluated. 

814 

815 Returns 

816 ------- 

817 maglim : `float` 

818 The limiting AB magnitude, or `numpy.nan` if the exposure has no 

819 photometric calibration, no usable pixels, or no PSF. 

820 """ 

821 if exposure.photoCalib is None: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true

822 return np.nan 

823 try: 

824 psfArea = computePSFNoiseEquivalentArea(exposure.getPsf()) 

825 except (lsst.pex.exceptions.InvalidParameterError, 

826 afwDetection.InvalidPsfError, 

827 lsst.pex.exceptions.RangeError): 

828 if fallbackPsfSize is None: 

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

830 return np.nan 

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

832 # Effective area of a Gaussian PSF with this FWHM. 

833 psfArea = 4*np.pi*(fallbackPsfSize/np.sqrt(8*np.log(2)))**2 

834 mask = exposure.mask 

835 good = (mask.array & mask.getPlaneBitMask(self.config.badMaskPlanes)) == 0 

836 variance = exposure.variance.array[good] 

837 variance = variance[np.isfinite(variance) & (variance > 0)] 

838 if variance.size == 0: 

839 return np.nan 

840 fluxLim = nsigma*np.sqrt(np.median(variance)*psfArea) 

841 return exposure.photoCalib.instFluxToMagnitude(fluxLim) 

842 

843 @staticmethod 

844 def _validateExposures(template, science): 

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

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

847 

848 Parameters 

849 ---------- 

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

851 Template exposure, warped to match the science exposure. 

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

853 Science exposure to subtract from the template. 

854 

855 Raises 

856 ------ 

857 AssertionError 

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

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

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

861 """ 

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

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

864 templateBBox = template.getBBox() 

865 scienceBBox = science.getBBox() 

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

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

868 (science.filter, template.filter) 

869 

870 assert templateBBox.contains(scienceBBox), \ 

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

872 

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

874 bbox=None, 

875 psf=None, 

876 photoCalib=None, 

877 interpolateBadMaskPlanes=False, 

878 ): 

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

880 

881 Parameters 

882 ---------- 

883 exposure : `lsst.afw.Exposure` 

884 exposure to convolve. 

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

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

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

888 Configuration for convolve algorithm. 

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

890 Bounding box to trim the convolved exposure to. 

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

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

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

894 Photometric calibration of the convolved exposure. 

895 interpolateBadMaskPlanes : `bool`, optional 

896 If set, interpolate over mask planes specified in 

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

898 

899 Returns 

900 ------- 

901 convolvedExp : `lsst.afw.Exposure` 

902 The convolved image. 

903 """ 

904 convolvedExposure = exposure.clone() 

905 if psf is not None: 

906 convolvedExposure.setPsf(psf) 

907 if photoCalib is not None: 

908 convolvedExposure.setPhotoCalib(photoCalib) 

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

910 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

911 self.config.badMaskPlanes) 

912 self.metadata["nInterpolated"] = nInterp 

913 

914 # Snapshot the footprints of mask planes that must not be dilated by 

915 # the convolution. 

916 preservePlanes = [mp for mp in self.config.preserveMaskPlanes 

917 if mp in convolvedExposure.mask.getMaskPlaneDict()] 

918 maskResetDict = { 

919 mp: (convolvedExposure.mask.array 

920 & convolvedExposure.mask.getPlaneBitMask(mp)) > 0 

921 for mp in preservePlanes 

922 } 

923 

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

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

926 convolvedExposure.setMaskedImage(convolvedImage) 

927 

928 # Undo the convolution's dilation of the preserved planes: clear the 

929 # dilated bits, then restore each mask plane for the pixels that were 

930 # previously set. 

931 self._clearMask(convolvedExposure.mask, clearMaskPlanes=preservePlanes) 

932 for maskPlane, maskSetPixels in maskResetDict.items(): 

933 bit = convolvedExposure.mask.getPlaneBitMask(maskPlane) 

934 convolvedExposure.mask.array[maskSetPixels] |= bit 

935 

936 if bbox is None: 

937 return convolvedExposure 

938 else: 

939 return convolvedExposure[bbox] 

940 

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

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

943 The selection criteria include any configured parameters of the 

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

945 mask planes. 

946 

947 Parameters 

948 ---------- 

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

950 Template exposure, warped to match the science exposure. 

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

952 Science exposure to subtract from the template. 

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

954 Input source catalog to select sources from. 

955 fallback : `bool`, optional 

956 Switch indicating the source selector is being called after 

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

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

959 the source selector. 

960 

961 Returns 

962 ------- 

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

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

965 sources removed and footprints added. 

966 

967 Raises 

968 ------ 

969 InsufficientKernelSourcesError 

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

971 candidates to construct the PSF matching kernel. 

972 """ 

973 if fallback: 

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

975 else: 

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

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

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

979 # kernel. 

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

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

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

983 kernelSources = setSourceFootprints(selectSources, kernelSize=kSize) 

984 bbox = science.getBBox() 

985 if self.usePreconvolution: 

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

987 # account for an extra convolution. 

988 bbox.grow(-kSize) 

989 if self.config.restrictKernelEdgeSources: 

990 bbox.grow(-kSize) 

991 # Remove sources that land on masked pixels 

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

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

994 maskSelected = scienceSelected & templateSelected 

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

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

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

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

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

1000 indices = np.argsort(signalToNoise) 

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

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

1003 selected[indices] = True 

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

1005 

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

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

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

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

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

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

1012 if self.config.allowKernelSourceDetection and not fallback: 1012 ↛ 1016line 1012 didn't jump to line 1016 because the condition on line 1012 was never true

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

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

1015 # loop 

1016 kernelSources = self.runKernelSourceDetection(template, science) 

1017 else: 

1018 raise InsufficientKernelSourcesError(nSources=len(kernelSources), 

1019 nRequired=self.config.minKernelSources) 

1020 

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

1022 

1023 return kernelSources 

1024 

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

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

1027 

1028 Parameters 

1029 ---------- 

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

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

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

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

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

1035 of the science image is modified in place. 

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

1037 Exposure catalog with external calibrations to be applied. Catalog 

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

1039 lookup. 

1040 """ 

1041 self._validateExposures(template, science) 

1042 if visitSummary is not None: 1042 ↛ 1043line 1042 didn't jump to line 1043 because the condition on line 1042 was never true

1043 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

1044 templateCoverageFraction = checkTemplateIsSufficient( 

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

1046 requiredTemplateFraction=self.config.requiredTemplateFraction, 

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

1048 " set config requiredTemplateFraction=0" 

1049 ) 

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

1051 

1052 if self.config.doScaleVariance: 

1053 # Scale the variance of the science image before 

1054 # convolution, subtraction, or decorrelation so that it has the 

1055 # correct ratio. Note that the template variance is scaled 

1056 # independently in ``GetTemplateTask``. 

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

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

1059 self.metadata["scaleScienceVarianceFactor"] = sciVarFactor 

1060 

1061 # Erase existing detection mask planes. 

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

1063 self.updateMasks(template, science) 

1064 

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

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

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

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

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

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

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

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

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

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

1075 # up near a region with no data. 

1076 try: 

1077 self.templatePsfSize = getPsfFwhm(template.psf) 

1078 self.sciencePsfSize = getPsfFwhm(science.psf) 

1079 except lsst.pex.exceptions.Exception: 

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

1081 # Catching: 

1082 # - lsst::geom::SingularTransformException 

1083 # - lsst.pex.exceptions.InvalidParameterError 

1084 # - lsst.pex.exceptions.RangeError 

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

1086 "Evaluting PSF on a grid of points." 

1087 ) 

1088 self.templatePsfSize = evaluateMeanPsfFwhm( 

1089 template, 

1090 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

1091 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid 

1092 ) 

1093 self.sciencePsfSize = evaluateMeanPsfFwhm( 

1094 science, 

1095 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

1096 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid 

1097 ) 

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

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

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

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

1102 

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

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

1105 if np.isnan(maglim_science): 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true

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

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

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

1109 if np.isnan(maglim_template): 1109 ↛ 1110line 1109 didn't jump to line 1110 because the condition on line 1109 was never true

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

1111 maglim_diffim = maglim_science 

1112 else: 

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

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

1115 self.metadata["scienceLimitingMagnitude"] = maglim_science 

1116 self.metadata["templateLimitingMagnitude"] = maglim_template 

1117 self.metadata["diffimLimitingMagnitude"] = maglim_diffim 

1118 

1119 def updateMasks(self, template, science): 

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

1121 

1122 Parameters 

1123 ---------- 

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

1125 Template exposure, warped to match the science exposure. 

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

1127 in the task config. 

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

1129 Science exposure to subtract from the template. 

1130 The DETECTED and DETECTED_NEGATIVE mask planes of the science image 

1131 will be erased. 

1132 """ 

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

1134 

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

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

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

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

1139 # be cleared. 

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

1141 if mp not in self.config.preserveTemplateMask] 

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

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

1144 

1145 # propagate the mask plane related to Fake source injection 

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

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

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

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

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

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

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

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

1154 if "INJECTED" in renameMaskPlanes: 1154 ↛ 1156line 1154 didn't jump to line 1156 because the condition on line 1154 was always true

1155 renameMaskPlanes.remove("INJECTED") 

1156 if "INJECTED_TEMPLATE" in clearMaskPlanes: 1156 ↛ 1159line 1156 didn't jump to line 1159 because the condition on line 1156 was always true

1157 clearMaskPlanes.remove("INJECTED_TEMPLATE") 

1158 

1159 for maskPlane in renameMaskPlanes: 

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

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

1162 

1163 @staticmethod 

1164 def _renameMaskPlanes(mask, maskPlane, newMaskPlane): 

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

1166 

1167 Parameters 

1168 ---------- 

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

1170 The mask image to update in place. 

1171 maskPlane : `str` 

1172 The name of the existing mask plane to copy. 

1173 newMaskPlane : `str` 

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

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

1176 """ 

1177 mask.addMaskPlane(newMaskPlane) 

1178 originBitMask = mask.getPlaneBitMask(maskPlane) 

1179 destinationBitMask = mask.getPlaneBitMask(newMaskPlane) 

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

1181 

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

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

1184 

1185 Parameters 

1186 ---------- 

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

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

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

1190 Erase the specified mask planes. 

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

1192 """ 

1193 if clearMaskPlanes is None: 1193 ↛ 1194line 1193 didn't jump to line 1194 because the condition on line 1193 was never true

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

1195 

1196 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

1197 mask &= ~bitMaskToClear 

1198 

1199 

1200class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

1201 SubtractScoreOutputConnections): 

1202 pass 

1203 

1204 

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

1206 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

1207 pass 

1208 

1209 

1210class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

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

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

1213 subtraction. 

1214 """ 

1215 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

1216 _DefaultName = "alardLuptonPreconvolveSubtract" 

1217 usePreconvolution = True 

1218 

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

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

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

1222 from the preconvolved science image. 

1223 

1224 Parameters 

1225 ---------- 

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

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

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

1229 the science bbox. 

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

1231 The science exposure. 

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

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

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

1235 images around them. 

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

1237 Exposure catalog with complete external calibrations. Catalog uses 

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

1239 

1240 Returns 

1241 ------- 

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

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

1244 Result of subtracting the convolved template and science 

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

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

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

1248 of the original science image. 

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

1250 The science exposure after convolving with its own PSF. 

1251 Attached PSF is that of the original science image. 

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

1253 Background model that was fit while solving for the 

1254 PSF-matching kernel 

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

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

1257 image. 

1258 """ 

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

1260 

1261 convolutionKernel = self._makePreconvolutionKernel(science.psf) 

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

1263 interpolateBadMaskPlanes=True) 

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

1265 

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

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

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

1269 try: 

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

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

1272 kernelSources, convolutionKernel) 

1273 

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

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

1276 # Raise NoWorkFound if template fraction is insufficient 

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

1278 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

1282 raise e 

1283 

1284 return subtractResults 

1285 

1286 @staticmethod 

1287 def _flagScoreEdge(mask, innerBBox): 

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

1289 

1290 Parameters 

1291 ---------- 

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

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

1294 an ``EDGE`` mask plane. 

1295 innerBBox : `~lsst.geom.Box2I` 

1296 The valid inner region. Pixels 

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

1298 """ 

1299 bbox = mask.getBBox() 

1300 edgeBit = mask.getPlaneBitMask("EDGE") 

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

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

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

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

1305 if dy0 > 0: 1305 ↛ 1307line 1305 didn't jump to line 1307 because the condition on line 1305 was always true

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

1307 if dy1 > 0: 1307 ↛ 1309line 1307 didn't jump to line 1309 because the condition on line 1307 was always true

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

1309 if dx0 > 0: 1309 ↛ 1311line 1309 didn't jump to line 1311 because the condition on line 1309 was always true

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

1311 if dx1 > 0: 1311 ↛ exitline 1311 didn't return from function '_flagScoreEdge' because the condition on line 1311 was always true

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

1313 

1314 @staticmethod 

1315 def _makePreconvolutionKernel(psf): 

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

1317 

1318 Convolving an image with this kernel is equivalent to correlating 

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

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

1321 PSF's average position and returned as a constant 

1322 `~lsst.afw.math.Kernel`. 

1323 

1324 Parameters 

1325 ---------- 

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

1327 The PSF to derive the preconvolution kernel from. 

1328 

1329 Returns 

1330 ------- 

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

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

1333 

1334 Raises 

1335 ------ 

1336 ValueError 

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

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

1339 """ 

1340 avgPos = psf.getAveragePosition() 

1341 localKernel = psf.getLocalKernel(avgPos) 

1342 dims = localKernel.getDimensions() 

1343 if dims.x % 2 == 0 or dims.y % 2 == 0: 1343 ↛ 1344line 1343 didn't jump to line 1344 because the condition on line 1343 was never true

1344 raise ValueError( 

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

1346 ) 

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

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

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

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

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

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

1353 

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

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

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

1357 exposure. 

1358 

1359 Parameters 

1360 ---------- 

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

1362 Template exposure, warped to match the science exposure. 

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

1364 Science exposure to subtract from the template. 

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

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

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

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

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

1370 images around them. 

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

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

1373 exposure. Must be normalized to sum to 1. 

1374 

1375 Returns 

1376 ------- 

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

1378 

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

1380 Result of subtracting the convolved template and science 

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

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

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

1384 of the original science image. 

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

1386 The science exposure after convolving with its own PSF. 

1387 Attached PSF is that of the original science image. 

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

1389 Background model that was fit while solving for the 

1390 PSF-matching kernel 

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

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

1393 image. 

1394 """ 

1395 bbox = science.getBBox() 

1396 innerBBox = preConvKernel.shrinkBBox(bbox) 

1397 

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

1399 preconvolved=True, 

1400 templateFwhmPix=self.templatePsfSize, 

1401 scienceFwhmPix=self.matchedPsfSize) 

1402 

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

1404 self.convolutionControl, 

1405 bbox=bbox, 

1406 psf=science.psf, 

1407 interpolateBadMaskPlanes=True, 

1408 photoCalib=science.photoCalib) 

1409 score = _subtractImages(matchedScience, matchedTemplate, 

1410 backgroundModel=(kernelResult.backgroundModel 

1411 if self.config.doSubtractBackground else None)) 

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

1413 kernelResult.psfMatchingKernel, 

1414 templateMatched=True, preConvMode=True, 

1415 preConvKernel=preConvKernel) 

1416 

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

1418 self._flagScoreEdge(correctedScore.mask, innerBBox) 

1419 

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

1421 matchedTemplate=matchedTemplate, 

1422 matchedScience=matchedScience, 

1423 backgroundModel=kernelResult.backgroundModel, 

1424 psfMatchingKernel=kernelResult.psfMatchingKernel, 

1425 kernelSources=kernelSources) 

1426 

1427 

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

1429 exceptionMessage=""): 

1430 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1431 

1432 Parameters 

1433 ---------- 

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

1435 The template exposure to check 

1436 logger : `logging.Logger` 

1437 Logger for printing output. 

1438 requiredTemplateFraction : `float`, optional 

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

1440 in the template. 

1441 exceptionMessage : `str`, optional 

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

1443 is insufficient. 

1444 

1445 Returns 

1446 ------- 

1447 templateCoverageFraction: `float` 

1448 Fraction of pixels in the template with data. 

1449 

1450 Raises 

1451 ------ 

1452 lsst.pipe.base.NoWorkFound 

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

1454 set, is less than the requiredTemplateFraction 

1455 """ 

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

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

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

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

1460 # because template coverage there doesn't help 

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

1462 pixNoData = np.count_nonzero(noTemplate | noScience) 

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

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

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

1466 

1467 if templateCoverageFraction < requiredTemplateFraction: 

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

1469 100*templateCoverageFraction, 

1470 100*requiredTemplateFraction)) 

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

1472 return templateCoverageFraction 

1473 

1474 

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

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

1477 

1478 Parameters 

1479 ---------- 

1480 science : `lsst.afw.Exposure` 

1481 The input science image. 

1482 template : `lsst.afw.Exposure` 

1483 The template to subtract from the science image. 

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

1485 Differential background model 

1486 

1487 Returns 

1488 ------- 

1489 difference : `lsst.afw.Exposure` 

1490 The subtracted image. 

1491 """ 

1492 difference = science.clone() 

1493 if backgroundModel is not None: 

1494 difference.maskedImage -= backgroundModel 

1495 difference.maskedImage -= template.maskedImage 

1496 return difference 

1497 

1498 

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

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

1501 

1502 Parameters 

1503 ---------- 

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

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

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

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

1508 fwhmExposureBuffer : `float` 

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

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

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

1512 fwhmExposureGrid : `int` 

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

1514 available at its average position. 

1515 Returns 

1516 ------- 

1517 result : `bool` 

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

1519 either dimension. 

1520 """ 

1521 try: 

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

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

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

1525 shape1 = evaluateMeanPsfFwhm(exp1, 

1526 fwhmExposureBuffer=fwhmExposureBuffer, 

1527 fwhmExposureGrid=fwhmExposureGrid 

1528 ) 

1529 shape2 = evaluateMeanPsfFwhm(exp2, 

1530 fwhmExposureBuffer=fwhmExposureBuffer, 

1531 fwhmExposureGrid=fwhmExposureGrid 

1532 ) 

1533 return shape1 <= shape2 

1534 

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

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

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

1538 return xTest | yTest 

1539 

1540 

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

1542 pipelineConnections=SimplifiedSubtractConnections): 

1543 mode = lsst.pex.config.ChoiceField( 

1544 dtype=str, 

1545 default="convolveTemplate", 

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

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

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

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

1550 ) 

1551 useExistingKernel = lsst.pex.config.Field( 

1552 dtype=bool, 

1553 default=True, 

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

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

1556 ) 

1557 

1558 

1559class SimplifiedSubtractTask(AlardLuptonSubtractTask): 

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

1561 the Alard & Lupton (1998) algorithm. 

1562 """ 

1563 ConfigClass = SimplifiedSubtractConfig 

1564 _DefaultName = "simplifiedSubtract" 

1565 

1566 @timeMethod 

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

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

1569 

1570 Parameters 

1571 ---------- 

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

1573 Template exposure, warped to match the science exposure. 

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

1575 Science exposure to subtract from the template. 

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

1577 Exposure catalog with external calibrations to be applied. Catalog 

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

1579 lookup. 

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

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

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

1583 

1584 Returns 

1585 ------- 

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

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

1588 Result of subtracting template and science. 

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

1590 Warped and PSF-matched template exposure. 

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

1592 Background model that was fit while solving for the 

1593 PSF-matching kernel 

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

1595 Kernel used to PSF-match the convolved image. 

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

1597 Sources detected on the science image that were used to 

1598 construct the PSF-matching kernel. 

1599 

1600 Raises 

1601 ------ 

1602 lsst.pipe.base.NoWorkFound 

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

1604 set, is less then the configured requiredTemplateFraction 

1605 """ 

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

1607 

1608 convolveTemplate = self.chooseConvolutionMethod(template, science) 

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

1610 

1611 if self.config.useExistingKernel: 

1612 psfMatchingKernel = inputPsfMatchingKernel 

1613 backgroundModel = None 

1614 kernelSources = None 

1615 else: 

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

1617 runSourceDetection=True) 

1618 psfMatchingKernel = kernelResult.psfMatchingKernel 

1619 kernelSources = kernelResult.kernelSources 

1620 if self.config.doSubtractBackground: 1620 ↛ 1621line 1620 didn't jump to line 1621 because the condition on line 1620 was never true

1621 backgroundModel = kernelResult.backgroundModel 

1622 else: 

1623 backgroundModel = None 

1624 if convolveTemplate: 1624 ↛ 1628line 1624 didn't jump to line 1628 because the condition on line 1624 was always true

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

1626 backgroundModel=backgroundModel) 

1627 else: 

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

1629 backgroundModel=backgroundModel) 

1630 if kernelSources is not None: 

1631 subtractResults.kernelSources = kernelSources 

1632 return subtractResults 

1633 

1634 

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

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

1637 

1638 Parameters 

1639 ---------- 

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

1641 Image on which to perform interpolation. 

1642 badMaskPlanes : `list` of `str` 

1643 List of mask planes to interpolate over. 

1644 fallbackValue : `float`, optional 

1645 Value to set when interpolation fails. 

1646 

1647 Returns 

1648 ------- 

1649 result: `float` 

1650 The number of masked pixels that were replaced. 

1651 """ 

1652 imgBadMaskPlanes = [ 

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

1654 ] 

1655 

1656 image = maskedImage.image.array 

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

1658 image[badPixels] = np.nan 

1659 if fallbackValue is None: 1659 ↛ 1663line 1659 didn't jump to line 1663 because the condition on line 1659 was always true

1660 fallbackValue = np.nanmedian(image) 

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

1662 # the median value. 

1663 image[badPixels] = fallbackValue 

1664 return np.sum(badPixels)