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

566 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 09:32 +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 

22import dataclasses 

23 

24from astropy import units as u 

25from astropy.stats import gaussian_fwhm_to_sigma 

26import numpy as np 

27 

28import lsst.afw.detection as afwDetection 

29import lsst.afw.image 

30import lsst.afw.math 

31import lsst.geom 

32from lsst.images import DifferenceImage 

33from lsst.images.convolution_kernels import ImageBasisConvolutionKernel 

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

35 computeDifferenceImageMetrics, computePSFNoiseEquivalentArea, 

36 checkMask, record_from_obs_info, setSourceFootprints, 

37 get_difference_image_provenance) 

38from lsst.meas.algorithms import ScaleVarianceTask, ScienceSourceSelectorTask 

39import lsst.pex.config 

40import lsst.pipe.base 

41import lsst.pex.exceptions 

42from lsst.pipe.base import connectionTypes 

43from . import MakeKernelTask, DecorrelateALKernelTask 

44from lsst.utils.timer import timeMethod 

45 

46__all__ = ["AlardLuptonSubtractConfig", "AlardLuptonSubtractTask", 

47 "AlardLuptonPreconvolveSubtractConfig", "AlardLuptonPreconvolveSubtractTask", 

48 "SimplifiedSubtractConfig", "SimplifiedSubtractTask", 

49 "InsufficientKernelSourcesError"] 

50 

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

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

53 

54 

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

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

57 kernel. 

58 """ 

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

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

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

62 super().__init__(msg) 

63 self.nSources = nSources 

64 self.nRequired = nRequired 

65 

66 @property 

67 def metadata(self): 

68 return {"nSources": self.nSources, 

69 "nRequired": self.nRequired 

70 } 

71 

72 

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

74 dimensions=_dimensions, 

75 defaultTemplates=_defaultTemplates): 

76 template = connectionTypes.Input( 

77 doc="Input warped template to subtract." 

78 " With image_type='future' this is read as a lsst.images.DifferenceImage, so that the" 

79 " record of the coadds that went into it can be passed on to the outputs.", 

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

81 storageClass="ExposureF", 

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

83 ) 

84 science = connectionTypes.Input( 

85 doc="Input science exposure to subtract from." 

86 " With image_type='future' this is read as a lsst.images.VisitImage, so that its" 

87 " photometric scaling can be passed on to the outputs.", 

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

89 storageClass="ExposureF", 

90 name="{fakesType}calexp" 

91 ) 

92 sources = connectionTypes.Input( 

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

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

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

96 storageClass="SourceCatalog", 

97 name="{fakesType}src" 

98 ) 

99 visitSummary = connectionTypes.Input( 

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

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

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

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

104 storageClass="ExposureCatalog", 

105 name="finalVisitSummary", 

106 ) 

107 

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

109 super().__init__(config=config) 

110 if not config.doApplyExternalCalibrations: 110 ↛ 112line 110 didn't jump to line 112 because the condition on line 110 was always true

111 del self.visitSummary 

112 if config.image_type == "future": 

113 self.template = dataclasses.replace(self.template, storageClass="DifferenceImage") 

114 self.science = dataclasses.replace(self.science, storageClass="VisitImage") 

115 

116 

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

118 dimensions=_dimensions, 

119 defaultTemplates=_defaultTemplates): 

120 difference = connectionTypes.Output( 

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

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

123 storageClass="ExposureF", 

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

125 ) 

126 matchedTemplate = connectionTypes.Output( 

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

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

129 storageClass="ExposureF", 

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

131 ) 

132 psfMatchingKernel = connectionTypes.Output( 

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

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

135 storageClass="MatchingKernel", 

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

137 ) 

138 kernelSources = connectionTypes.Output( 

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

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

141 storageClass="SourceCatalog", 

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

143 ) 

144 

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

146 super().__init__(config=config) 

147 if config.image_type == "future": 

148 self.difference = dataclasses.replace(self.difference, storageClass="DifferenceImage") 

149 self.matchedTemplate = dataclasses.replace(self.matchedTemplate, 

150 storageClass="DifferenceImage") 

151 self.psfMatchingKernel = dataclasses.replace(self.psfMatchingKernel, 

152 storageClass="ConvolutionKernel") 

153 

154 

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

156 dimensions=_dimensions, 

157 defaultTemplates=_defaultTemplates): 

158 scoreExposure = connectionTypes.Output( 

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

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

161 storageClass="ExposureF", 

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

163 ) 

164 psfMatchingKernel = connectionTypes.Output( 

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

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

167 storageClass="MatchingKernel", 

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

169 ) 

170 kernelSources = connectionTypes.Output( 

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

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

173 storageClass="SourceCatalog", 

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

175 ) 

176 

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

178 super().__init__(config=config) 

179 if config.image_type == "future": 

180 self.scoreExposure = dataclasses.replace(self.scoreExposure, storageClass="DifferenceImage") 

181 self.psfMatchingKernel = dataclasses.replace(self.psfMatchingKernel, 

182 storageClass="ConvolutionKernel") 

183 

184 

185class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

186 pass 

187 

188 

189class SimplifiedSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

190 inputPsfMatchingKernel = connectionTypes.Input( 

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

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

193 storageClass="MatchingKernel", 

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

195 ) 

196 

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

198 super().__init__(config=config) 

199 del self.sources 

200 if config.useExistingKernel: 200 ↛ 207line 200 didn't jump to line 207 because the condition on line 200 was always true

201 del self.psfMatchingKernel 

202 del self.kernelSources 

203 if config.image_type == "future": 

204 self.inputPsfMatchingKernel = dataclasses.replace(self.inputPsfMatchingKernel, 

205 storageClass="ConvolutionKernel") 

206 else: 

207 del self.inputPsfMatchingKernel 

208 

209 

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

211 makeKernel = lsst.pex.config.ConfigurableField( 

212 target=MakeKernelTask, 

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

214 ) 

215 doDecorrelation = lsst.pex.config.Field( 

216 dtype=bool, 

217 default=True, 

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

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

220 ) 

221 decorrelate = lsst.pex.config.ConfigurableField( 

222 target=DecorrelateALKernelTask, 

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

224 ) 

225 requiredTemplateFraction = lsst.pex.config.Field( 

226 dtype=float, 

227 default=0.1, 

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

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

230 ) 

231 minTemplateFractionForExpectedSuccess = lsst.pex.config.Field( 

232 dtype=float, 

233 default=0.2, 

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

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

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

237 ) 

238 doScaleVariance = lsst.pex.config.Field( 

239 dtype=bool, 

240 default=True, 

241 doc="Scale variance of the science image? Note that the template variance is NOT scaled" 

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

243 ) 

244 scaleVariance = lsst.pex.config.ConfigurableField( 

245 target=ScaleVarianceTask, 

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

247 ) 

248 doSubtractBackground = lsst.pex.config.Field( 

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

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

251 dtype=bool, 

252 default=False, 

253 deprecated="Subtract the background in detectAndMeasure instead. Will be removed after v31.", 

254 ) 

255 doApplyExternalCalibrations = lsst.pex.config.Field( 

256 doc=( 

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

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

259 ), 

260 dtype=bool, 

261 default=False, 

262 ) 

263 sourceSelector = lsst.pex.config.ConfigurableField( 

264 target=ScienceSourceSelectorTask, 

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

266 ) 

267 fallbackSourceSelector = lsst.pex.config.ConfigurableField( 

268 target=ScienceSourceSelectorTask, 

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

270 "Used only if the kernel calculation fails and" 

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

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

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

274 ) 

275 detectionThreshold = lsst.pex.config.Field( 

276 dtype=float, 

277 default=10, 

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

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

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

281 ) 

282 detectionThresholdMax = lsst.pex.config.Field( 

283 dtype=float, 

284 default=500, 

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

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

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

288 ) 

289 restrictKernelEdgeSources = lsst.pex.config.Field( 

290 dtype=bool, 

291 default=True, 

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

293 ) 

294 maxKernelSources = lsst.pex.config.Field( 

295 dtype=int, 

296 default=1000, 

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

298 "Set to -1 to disable." 

299 ) 

300 minKernelSources = lsst.pex.config.Field( 

301 dtype=int, 

302 default=3, 

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

304 ) 

305 excludeMaskPlanes = lsst.pex.config.ListField( 

306 dtype=str, 

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

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

309 ) 

310 badMaskPlanes = lsst.pex.config.ListField( 

311 dtype=str, 

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

313 doc="Mask planes to interpolate over." 

314 ) 

315 preserveTemplateMask = lsst.pex.config.ListField( 

316 dtype=str, 

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

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

319 ) 

320 renameTemplateMask = lsst.pex.config.ListField( 

321 dtype=str, 

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

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

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

325 ) 

326 preserveMaskPlanes = lsst.pex.config.ListField( 

327 dtype=str, 

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

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

330 ) 

331 allowKernelSourceDetection = lsst.pex.config.Field( 

332 dtype=bool, 

333 default=False, 

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

335 " encountered while calculating the matching kernel." 

336 ) 

337 image_type = lsst.pex.config.ChoiceField[str]( 

338 doc="Image type of the input template, the image outputs of this" 

339 " task (difference and matchedTemplate, or scoreExposure), and the PSF" 

340 " matching kernel.", 

341 allowed={ 

342 "legacy": "Read and write lsst.afw.image.ExposureF and" 

343 " lsst.afw.math.Kernel.", 

344 "future": "Read and write lsst.images.DifferenceImage and" 

345 " lsst.images.convolution_kernels.ConvolutionKernel.", 

346 }, 

347 optional=False, 

348 default="legacy", 

349 ) 

350 

351 def setDefaults(self): 

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

353 # Always include background fitting in the kernel fit, 

354 # even if it is not subtracted 

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

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

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

358 # Shared source selector settings 

359 doSkySources = False # Do not include sky sources 

360 doSignalToNoise = True # apply signal to noise filter 

361 doUnresolved = True # apply star-galaxy separation 

362 signalToNoiseMinimum = 10 

363 signalToNoiseMaximum = 500 

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

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

366 self.sourceSelector.doUnresolved = doUnresolved 

367 self.sourceSelector.doSkySources = doSkySources 

368 self.sourceSelector.doSignalToNoise = doSignalToNoise 

369 self.sourceSelector.signalToNoise.minimum = signalToNoiseMinimum 

370 self.sourceSelector.signalToNoise.maximum = signalToNoiseMaximum 

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

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

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

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

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

376 self.fallbackSourceSelector.doUnresolved = doUnresolved 

377 self.fallbackSourceSelector.doSkySources = doSkySources 

378 self.fallbackSourceSelector.doSignalToNoise = doSignalToNoise 

379 self.fallbackSourceSelector.signalToNoise.minimum = signalToNoiseMinimum 

380 self.fallbackSourceSelector.signalToNoise.maximum = signalToNoiseMaximum 

381 

382 def validate(self): 

383 super().validate() 

384 if self.doSubtractBackground and self.image_type == "future": 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true

385 raise lsst.pex.config.FieldValidationError( 

386 AlardLuptonSubtractBaseConfig.doSubtractBackground, self, 

387 "The background subtracted when solving the kernel is not recorded on" 

388 " image_type='future' outputs; subtract the background in detectAndMeasure instead." 

389 ) 

390 

391 

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

393 pipelineConnections=AlardLuptonSubtractConnections): 

394 mode = lsst.pex.config.ChoiceField( 

395 dtype=str, 

396 default="convolveTemplate", 

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

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

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

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

401 ) 

402 

403 

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

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

406 the Alard & Lupton (1998) algorithm. 

407 """ 

408 ConfigClass = AlardLuptonSubtractConfig 

409 _DefaultName = "alardLuptonSubtract" 

410 usePreconvolution = False 

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

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

413 `True`.""" 

414 

415 futureImageOutputs = ("difference", "matchedTemplate") 

416 """Names of the images in the results struct that are converted in future 

417 mode (`tuple` [`str`]). Subclasses with different image outputs override 

418 this.""" 

419 

420 def __init__(self, **kwargs): 

421 super().__init__(**kwargs) 

422 self.makeSubtask("decorrelate") 

423 self.makeSubtask("makeKernel") 

424 self.makeSubtask("sourceSelector") 

425 self.makeSubtask("fallbackSourceSelector") 

426 if self.config.doScaleVariance: 

427 self.makeSubtask("scaleVariance") 

428 

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

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

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

432 self.convolutionControl.setDoNormalize(False) 

433 self.convolutionControl.setDoCopyEdge(True) 

434 

435 def _applyExternalCalibrations(self, exposure, visitSummary): 

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

437 external ones.". 

438 

439 Parameters 

440 ---------- 

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

442 Input exposure to adjust calibrations. 

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

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 exposure : `lsst.afw.image.exposure.Exposure` 

451 Exposure with adjusted calibrations. 

452 """ 

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

454 

455 row = visitSummary.find(detectorId) 

456 if row is None: 

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

458 "Using original calibrations.", detectorId) 

459 else: 

460 psf = row.getPsf() 

461 apCorrMap = row.getApCorrMap() 

462 if psf is None: 

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

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

465 detectorId) 

466 elif apCorrMap is None: 

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

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

469 detectorId) 

470 else: 

471 exposure.setPsf(psf) 

472 exposure.info.setApCorrMap(apCorrMap) 

473 

474 return exposure 

475 

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

477 inputs = butlerQC.get(inputRefs) 

478 templateInfo = None 

479 exposureRecord = None 

480 photometricScaling = None 

481 if self.config.image_type == "future": 

482 template = inputs["template"] 

483 dataId = butlerQC.quantum.dataId 

484 # Read the `TemplateInfo` and the observation metadata from the 

485 # template while it is still in the `DifferenceImage` format. 

486 templateInfo = get_difference_image_provenance(template, "templates", "template", dataId) 

487 exposureRecord = record_from_obs_info(template.obs_info, dataId["instrument"], 

488 dataId["visit"], dataId.universe) 

489 inputs["template"] = template.to_legacy() 

490 photometricScaling = inputs["science"].photometric_scaling 

491 inputs["science"] = inputs["science"].to_legacy() 

492 if "inputPsfMatchingKernel" in inputs: 

493 inputs["inputPsfMatchingKernel"] = inputs["inputPsfMatchingKernel"].to_legacy() 

494 

495 try: 

496 results = self.run(**inputs) 

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

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

499 # No partial outputs for butler to put, so nothing to convert. 

500 raise error from e 

501 

502 if self.config.image_type == "future": 

503 self.convert_outputs_to_future(results, exposureRecord, templateInfo=templateInfo, 

504 photometricScaling=photometricScaling) 

505 

506 butlerQC.put(results, outputRefs) 

507 

508 def convert_outputs_to_future(self, results, exposureRecord, templateInfo=None, 

509 photometricScaling=None): 

510 """Convert the image outputs to `lsst.images` types. 

511 

512 Each image named in `futureImageOutputs` that is present on 

513 ``results`` is replaced by an `lsst.images.DifferenceImage`, with the 

514 PSF matching kernel attached to it. ``psfMatchingKernel`` is replaced 

515 by the same kernel as an 

516 `lsst.images.convolution_kernels.ImageBasisConvolutionKernel`. 

517 

518 Parameters 

519 ---------- 

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

521 Output struct to read and modify in place. 

522 exposureRecord : `lsst.daf.butler.DimensionRecord` 

523 The ``exposure`` record of the observation, which supplies the 

524 observation metadata recorded on each converted output. 

525 templateInfo : `list` [`lsst.images.DifferenceImageTemplateInfo`], \ 

526 optional 

527 Record of the coadds that went into the template, taken from the 

528 template this task subtracted. 

529 photometricScaling : `lsst.images.fields.BaseField`, optional 

530 Photometric scaling of the science image. 

531 

532 Raises 

533 ------ 

534 TypeError 

535 Raised if the PSF matching kernel cannot be stored on an 

536 `lsst.images.DifferenceImage`. 

537 RuntimeError 

538 Raised if an output has pixels set in a mask plane that 

539 `lsst.images.get_legacy_difference_image_mask_planes` does not 

540 map. 

541 

542 Notes 

543 ----- 

544 This uses the default legacy mask plane map of 

545 `lsst.images.get_legacy_difference_image_mask_planes` and adds any 

546 planes from `~lsst.images.get_legacy_optional_mask_planes` that are set 

547 for any pixels in the image. 

548 """ 

549 # The science image and every output built from it share the science 

550 # pixel grid, so its detector applies to all of them. 

551 science = results.matchedScience 

552 try: 

553 kernel = ImageBasisConvolutionKernel.from_legacy(results.psfMatchingKernel) 

554 except (TypeError, ValueError, AttributeError) as e: 

555 raise TypeError("The supplied PSF matching kernel cannot be attached to an" 

556 f" lsst.images.DifferenceImage: {e}") from e 

557 for name in self.futureImageOutputs: 

558 exposure = getattr(results, name, None) 

559 if exposure is None: 

560 continue 

561 if exposure.getDetector() is None: 

562 # Fall back on the science detector for images without a 

563 # detector of their own (e.g. matchedTemplate) 

564 exposure.setDetector(science.getDetector()) 

565 image = DifferenceImage.from_legacy( 

566 exposure, 

567 unit=u.nJy, 

568 exposure_record=exposureRecord, 

569 ) 

570 if kernel is not None: 570 ↛ 572line 570 didn't jump to line 572 because the condition on line 570 was always true

571 image.kernel = kernel 

572 if templateInfo: 

573 image.templates = templateInfo 

574 if photometricScaling is not None: 

575 image.photometric_scaling = photometricScaling 

576 setattr(results, name, image) 

577 results.psfMatchingKernel = kernel 

578 

579 @timeMethod 

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

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

582 

583 Parameters 

584 ---------- 

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

586 Template exposure, warped to match the science exposure. 

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

588 Science exposure to subtract from the template. 

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

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

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

592 images around them. 

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

594 Exposure catalog with external calibrations to be applied. Catalog 

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

596 lookup. 

597 

598 Returns 

599 ------- 

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

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

602 Result of subtracting template and science. 

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

604 Warped and PSF-matched template exposure. 

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

606 Background model that was fit while solving for the 

607 PSF-matching kernel 

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

609 Kernel used to PSF-match the convolved image. 

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

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

612 PSF-matching kernel. 

613 """ 

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

615 

616 convolveTemplate = self.chooseConvolutionMethod(template, science) 

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

618 

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

620 convolveTemplate=convolveTemplate, 

621 runSourceDetection=False) 

622 

623 if self.config.doSubtractBackground: 

624 backgroundModel = kernelResult.backgroundModel 

625 else: 

626 backgroundModel = None 

627 if convolveTemplate: 

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

629 backgroundModel=backgroundModel) 

630 else: 

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

632 backgroundModel=backgroundModel) 

633 subtractResults.kernelSources = kernelResult.kernelSources 

634 

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

636 

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

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

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

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

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

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

643 self.metadata["differenceFootprintRatioMean"], 

644 self.metadata["differenceFootprintRatioStdev"]) 

645 

646 return subtractResults 

647 

648 def chooseConvolutionMethod(self, template, science): 

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

650 matching kernel. 

651 

652 Parameters 

653 ---------- 

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

655 Template exposure, warped to match the science exposure. 

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

657 Science exposure to subtract from the template. 

658 

659 Returns 

660 ------- 

661 convolveTemplate : `bool` 

662 Convolve the template to match the two images? 

663 

664 Raises 

665 ------ 

666 RuntimeError 

667 If an unsupported convolution mode is supplied. 

668 """ 

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

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

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

672 convolveTemplate = _shapeTest(template, 

673 science, 

674 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

675 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid) 

676 if convolveTemplate: 

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

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

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

680 else: 

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

682 else: 

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

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

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

686 convolveTemplate = True 

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

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

689 convolveTemplate = False 

690 else: 

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

692 return convolveTemplate 

693 

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

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

696 

697 Parameters 

698 ---------- 

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

700 Template exposure, warped to match the science exposure. 

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

702 Science exposure to subtract from the template. 

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

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

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

706 images around them. 

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

708 convolveTemplate : `bool`, optional 

709 Construct the matching kernel to convolve the template? 

710 runSourceDetection : `bool`, optional 

711 Run a minimal version of source detection to determine kernel 

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

713 from must be supplied. 

714 

715 Returns 

716 ------- 

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

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

719 Background model that was fit while solving for the 

720 PSF-matching kernel 

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

722 Kernel used to PSF-match the convolved image. 

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

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

725 PSF-matching kernel. 

726 """ 

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

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

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

730 if convolveTemplate: 

731 reference = template 

732 target = science 

733 referenceFwhmPix = self.templatePsfSize 

734 targetFwhmPix = self.sciencePsfSize 

735 else: 

736 reference = science 

737 target = template 

738 referenceFwhmPix = self.sciencePsfSize 

739 targetFwhmPix = self.templatePsfSize 

740 try: 

741 if runSourceDetection: 

742 kernelSources = self.runKernelSourceDetection(template, science) 

743 else: 

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

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

746 preconvolved=False, 

747 templateFwhmPix=referenceFwhmPix, 

748 scienceFwhmPix=targetFwhmPix) 

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

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

751 # Raise NoWorkFound if template fraction is insufficient 

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

753 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

757 raise e 

758 

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

760 psfMatchingKernel=kernelResult.psfMatchingKernel, 

761 kernelSources=kernelSources) 

762 

763 def runKernelSourceDetection(self, template, science): 

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

765 to reject candidate sources. 

766 

767 Parameters 

768 ---------- 

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

770 Template exposure, warped to match the science exposure. 

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

772 Science exposure to subtract from the template. 

773 

774 Returns 

775 ------- 

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

777 Sources from the input catalog to use to construct the 

778 PSF-matching kernel. 

779 """ 

780 kernelSize = self.makeKernel.makeKernelBasisList( 

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

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

783 candidateList=None, 

784 sigma=gaussian_fwhm_to_sigma*self.sciencePsfSize) 

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

786 

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

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

789 from the science image. 

790 

791 Parameters 

792 ---------- 

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

794 Template exposure, warped to match the science exposure. 

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

796 Science exposure to subtract from the template. 

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

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

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

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

801 kernel. 

802 

803 Returns 

804 ------- 

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

806 

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

808 Result of subtracting template and science. 

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

810 Warped and PSF-matched template exposure. 

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

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

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

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

815 """ 

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

817 

818 matchedTemplate = self._convolveExposure(template, psfMatchingKernel, 

819 self.convolutionControl, 

820 bbox=science.getBBox(), 

821 psf=science.psf, 

822 photoCalib=science.photoCalib) 

823 

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

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

826 psfMatchingKernel, 

827 templateMatched=True) 

828 

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

830 matchedTemplate=matchedTemplate, 

831 matchedScience=science, 

832 backgroundModel=backgroundModel, 

833 psfMatchingKernel=psfMatchingKernel) 

834 

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

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

837 the template image. 

838 

839 Parameters 

840 ---------- 

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

842 Template exposure, warped to match the science exposure. 

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

844 Science exposure to subtract from the template. 

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

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

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

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

849 kernel. 

850 

851 Returns 

852 ------- 

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

854 

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

856 Result of subtracting template and science. 

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

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

859 is not PSF-matched to the science image. 

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

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

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

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

864 """ 

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

866 bbox = science.getBBox() 

867 

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

869 xcen, ycen = bbox.getCenter() 

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

871 

872 matchedScience = self._convolveExposure(science, psfMatchingKernel, 

873 self.convolutionControl, 

874 psf=template.psf) 

875 

876 # Place back on native photometric scale 

877 matchedScience.maskedImage /= norm 

878 matchedTemplate = template.clone()[bbox] 

879 matchedTemplate.setPhotoCalib(science.photoCalib) 

880 

881 if backgroundModel is not None: 

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

883 invertedBackground = backgroundModel.clone() 

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

885 backgroundModel = invertedBackground 

886 

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

888 

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

890 psfMatchingKernel, 

891 templateMatched=False) 

892 

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

894 matchedTemplate=matchedTemplate, 

895 matchedScience=matchedScience, 

896 backgroundModel=backgroundModel, 

897 psfMatchingKernel=psfMatchingKernel) 

898 

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

900 templateMatched=True, 

901 preConvMode=False, 

902 preConvKernel=None, 

903 spatiallyVarying=False): 

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

905 caused by convolution. 

906 

907 Parameters 

908 ---------- 

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

910 Template exposure, warped to match the science exposure. 

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

912 Science exposure to subtract from the template. 

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

914 Result of subtracting template and science. 

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

916 An (optionally spatially-varying) PSF matching kernel 

917 templateMatched : `bool`, optional 

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

919 preConvMode : `bool`, optional 

920 Was the science image preconvolved with its own PSF 

921 before PSF matching the template? 

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

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

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

925 spatiallyVarying : `bool`, optional 

926 Compute the decorrelation kernel spatially varying across the image? 

927 

928 Returns 

929 ------- 

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

931 The decorrelated image difference. 

932 """ 

933 if self.config.doDecorrelation: 

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

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

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

937 # during decorrelation 

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

939 templateMatched=templateMatched, 

940 preConvMode=preConvMode, 

941 preConvKernel=preConvKernel, 

942 spatiallyVarying=spatiallyVarying).correctedExposure 

943 else: 

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

945 correctedExposure = difference 

946 return correctedExposure 

947 

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

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

950 

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

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

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

954 

955 Parameters 

956 ---------- 

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

958 The exposure to calculate the limiting magnitude for. 

959 nsigma : `float`, optional 

960 The detection threshold in sigma. 

961 fallbackPsfSize : `float`, optional 

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

963 evaluated. 

964 

965 Returns 

966 ------- 

967 maglim : `float` 

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

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

970 """ 

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

972 return np.nan 

973 try: 

974 psfArea = computePSFNoiseEquivalentArea(exposure.getPsf()) 

975 except (lsst.pex.exceptions.InvalidParameterError, 

976 afwDetection.InvalidPsfError, 

977 lsst.pex.exceptions.RangeError): 

978 if fallbackPsfSize is None: 

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

980 return np.nan 

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

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

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

984 mask = exposure.mask 

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

986 variance = exposure.variance.array[good] 

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

988 if variance.size == 0: 

989 return np.nan 

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

991 return exposure.photoCalib.instFluxToMagnitude(fluxLim) 

992 

993 @staticmethod 

994 def _validateExposures(template, science): 

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

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

997 

998 Parameters 

999 ---------- 

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

1001 Template exposure, warped to match the science exposure. 

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

1003 Science exposure to subtract from the template. 

1004 

1005 Raises 

1006 ------ 

1007 AssertionError 

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

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

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

1011 """ 

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

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

1014 templateBBox = template.getBBox() 

1015 scienceBBox = science.getBBox() 

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

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

1018 (science.filter, template.filter) 

1019 

1020 assert templateBBox.contains(scienceBBox), \ 

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

1022 

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

1024 bbox=None, 

1025 psf=None, 

1026 photoCalib=None, 

1027 interpolateBadMaskPlanes=False, 

1028 ): 

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

1030 

1031 Parameters 

1032 ---------- 

1033 exposure : `lsst.afw.Exposure` 

1034 exposure to convolve. 

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

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

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

1038 Configuration for convolve algorithm. 

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

1040 Bounding box to trim the convolved exposure to. 

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

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

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

1044 Photometric calibration of the convolved exposure. 

1045 interpolateBadMaskPlanes : `bool`, optional 

1046 If set, interpolate over mask planes specified in 

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

1048 

1049 Returns 

1050 ------- 

1051 convolvedExp : `lsst.afw.Exposure` 

1052 The convolved image. 

1053 """ 

1054 convolvedExposure = exposure.clone() 

1055 if psf is not None: 

1056 convolvedExposure.setPsf(psf) 

1057 if photoCalib is not None: 

1058 convolvedExposure.setPhotoCalib(photoCalib) 

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

1060 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

1061 self.config.badMaskPlanes) 

1062 self.metadata["nInterpolated"] = nInterp 

1063 

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

1065 # the convolution. 

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

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

1068 maskResetDict = { 

1069 mp: (convolvedExposure.mask.array 

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

1071 for mp in preservePlanes 

1072 } 

1073 

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

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

1076 convolvedExposure.setMaskedImage(convolvedImage) 

1077 

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

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

1080 # previously set. 

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

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

1083 bit = convolvedExposure.mask.getPlaneBitMask(maskPlane) 

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

1085 

1086 if bbox is None: 

1087 return convolvedExposure 

1088 else: 

1089 return convolvedExposure[bbox] 

1090 

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

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

1093 The selection criteria include any configured parameters of the 

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

1095 mask planes. 

1096 

1097 Parameters 

1098 ---------- 

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

1100 Template exposure, warped to match the science exposure. 

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

1102 Science exposure to subtract from the template. 

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

1104 Input source catalog to select sources from. 

1105 fallback : `bool`, optional 

1106 Switch indicating the source selector is being called after 

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

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

1109 the source selector. 

1110 

1111 Returns 

1112 ------- 

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

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

1115 sources removed and footprints added. 

1116 

1117 Raises 

1118 ------ 

1119 InsufficientKernelSourcesError 

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

1121 candidates to construct the PSF matching kernel. 

1122 """ 

1123 if fallback: 

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

1125 else: 

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

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

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

1129 # kernel. 

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

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

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

1133 kernelSources = setSourceFootprints(selectSources, kernelSize=kSize) 

1134 bbox = science.getBBox() 

1135 if self.usePreconvolution: 

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

1137 # account for an extra convolution. 

1138 bbox.grow(-kSize) 

1139 if self.config.restrictKernelEdgeSources: 

1140 bbox.grow(-kSize) 

1141 # Remove sources that land on masked pixels 

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

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

1144 maskSelected = scienceSelected & templateSelected 

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

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

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

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

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

1150 indices = np.argsort(signalToNoise) 

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

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

1153 selected[indices] = True 

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

1155 

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

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

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

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

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

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

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

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

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

1165 # loop 

1166 kernelSources = self.runKernelSourceDetection(template, science) 

1167 else: 

1168 raise InsufficientKernelSourcesError(nSources=len(kernelSources), 

1169 nRequired=self.config.minKernelSources) 

1170 

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

1172 

1173 return kernelSources 

1174 

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

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

1177 

1178 Parameters 

1179 ---------- 

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

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

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

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

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

1185 of the science image is modified in place. 

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

1187 Exposure catalog with external calibrations to be applied. Catalog 

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

1189 lookup. 

1190 """ 

1191 self._validateExposures(template, science) 

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

1193 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

1194 templateCoverageFraction = checkTemplateIsSufficient( 

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

1196 requiredTemplateFraction=self.config.requiredTemplateFraction, 

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

1198 " set config requiredTemplateFraction=0" 

1199 ) 

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

1201 

1202 if self.config.doScaleVariance: 

1203 # Scale the variance of the science image before 

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

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

1206 # independently in ``GetTemplateTask``. 

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

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

1209 self.metadata["scaleScienceVarianceFactor"] = sciVarFactor 

1210 

1211 # Erase existing detection mask planes. 

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

1213 self.updateMasks(template, science) 

1214 

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

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

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

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

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

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

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

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

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

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

1225 # up near a region with no data. 

1226 try: 

1227 self.templatePsfSize = getPsfFwhm(template.psf) 

1228 self.sciencePsfSize = getPsfFwhm(science.psf) 

1229 except lsst.pex.exceptions.Exception: 

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

1231 # Catching: 

1232 # - lsst::geom::SingularTransformException 

1233 # - lsst.pex.exceptions.InvalidParameterError 

1234 # - lsst.pex.exceptions.RangeError 

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

1236 "Evaluting PSF on a grid of points." 

1237 ) 

1238 self.templatePsfSize = evaluateMeanPsfFwhm( 

1239 template, 

1240 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

1241 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid 

1242 ) 

1243 self.sciencePsfSize = evaluateMeanPsfFwhm( 

1244 science, 

1245 fwhmExposureBuffer=self.config.makeKernel.fwhmExposureBuffer, 

1246 fwhmExposureGrid=self.config.makeKernel.fwhmExposureGrid 

1247 ) 

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

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

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

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

1252 

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

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

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

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

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

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

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

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

1261 maglim_diffim = maglim_science 

1262 else: 

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

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

1265 self.metadata["scienceLimitingMagnitude"] = maglim_science 

1266 self.metadata["templateLimitingMagnitude"] = maglim_template 

1267 self.metadata["diffimLimitingMagnitude"] = maglim_diffim 

1268 

1269 def updateMasks(self, template, science): 

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

1271 

1272 Parameters 

1273 ---------- 

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

1275 Template exposure, warped to match the science exposure. 

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

1277 in the task config. 

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

1279 Science exposure to subtract from the template. 

1280 The DETECTED and DETECTED_NEGATIVE mask planes of the science image 

1281 will be erased. 

1282 """ 

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

1284 

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

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

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

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

1289 # be cleared. 

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

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

1292 # afw's mask planes are global, so if the "*_TEMPLATE" mask planes were 

1293 # defined anywhere previously they would now also be cleared unless we 

1294 # exclude them. 

1295 renamedMaskPlanes = {maskPlane + "_TEMPLATE" for maskPlane in renameMaskPlanes} 

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

1297 if mp not in self.config.preserveTemplateMask 

1298 and mp not in renamedMaskPlanes] 

1299 

1300 # propagate the mask plane related to Fake source injection 

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

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

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

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

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

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

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

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

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

1310 renameMaskPlanes.remove("INJECTED") 

1311 if "INJECTED_TEMPLATE" in clearMaskPlanes: 1311 ↛ 1312line 1311 didn't jump to line 1312 because the condition on line 1311 was never true

1312 clearMaskPlanes.remove("INJECTED_TEMPLATE") 

1313 

1314 for maskPlane in renameMaskPlanes: 

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

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

1317 

1318 @staticmethod 

1319 def _renameMaskPlanes(mask, maskPlane, newMaskPlane): 

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

1321 

1322 Parameters 

1323 ---------- 

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

1325 The mask image to update in place. 

1326 maskPlane : `str` 

1327 The name of the existing mask plane to copy. 

1328 newMaskPlane : `str` 

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

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

1331 """ 

1332 mask.addMaskPlane(newMaskPlane) 

1333 originBitMask = mask.getPlaneBitMask(maskPlane) 

1334 destinationBitMask = mask.getPlaneBitMask(newMaskPlane) 

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

1336 

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

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

1339 

1340 Parameters 

1341 ---------- 

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

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

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

1345 Erase the specified mask planes. 

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

1347 """ 

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

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

1350 

1351 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

1352 mask &= ~bitMaskToClear 

1353 

1354 

1355class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

1356 SubtractScoreOutputConnections): 

1357 pass 

1358 

1359 

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

1361 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

1362 pass 

1363 

1364 

1365class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

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

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

1368 subtraction. 

1369 """ 

1370 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

1371 _DefaultName = "alardLuptonPreconvolveSubtract" 

1372 usePreconvolution = True 

1373 # This task just writes the score image instead of the difference image and 

1374 # matchedTemplate 

1375 futureImageOutputs = ("scoreExposure",) 

1376 

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

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

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

1380 from the preconvolved science image. 

1381 

1382 Parameters 

1383 ---------- 

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

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

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

1387 the science bbox. 

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

1389 The science exposure. 

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

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

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

1393 images around them. 

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

1395 Exposure catalog with complete external calibrations. Catalog uses 

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

1397 

1398 Returns 

1399 ------- 

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

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

1402 Result of subtracting the convolved template and science 

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

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

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

1406 of the original science image. 

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

1408 The science exposure after convolving with its own PSF. 

1409 Attached PSF is that of the original science image. 

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

1411 Background model that was fit while solving for the 

1412 PSF-matching kernel 

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

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

1415 image. 

1416 """ 

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

1418 

1419 convolutionKernel = self._makePreconvolutionKernel(science.psf) 

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

1421 interpolateBadMaskPlanes=True) 

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

1423 

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

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

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

1427 try: 

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

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

1430 kernelSources, convolutionKernel) 

1431 

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

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

1434 # Raise NoWorkFound if template fraction is insufficient 

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

1436 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

1440 raise e 

1441 

1442 return subtractResults 

1443 

1444 @staticmethod 

1445 def _flagScoreEdge(mask, innerBBox): 

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

1447 

1448 Parameters 

1449 ---------- 

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

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

1452 an ``EDGE`` mask plane. 

1453 innerBBox : `~lsst.geom.Box2I` 

1454 The valid inner region. Pixels 

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

1456 """ 

1457 bbox = mask.getBBox() 

1458 edgeBit = mask.getPlaneBitMask("EDGE") 

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

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

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

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

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

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

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

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

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

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

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

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

1471 

1472 @staticmethod 

1473 def _makePreconvolutionKernel(psf): 

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

1475 

1476 Convolving an image with this kernel is equivalent to correlating 

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

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

1479 PSF's average position and returned as a constant 

1480 `~lsst.afw.math.Kernel`. 

1481 

1482 Parameters 

1483 ---------- 

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

1485 The PSF to derive the preconvolution kernel from. 

1486 

1487 Returns 

1488 ------- 

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

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

1491 

1492 Raises 

1493 ------ 

1494 ValueError 

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

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

1497 """ 

1498 avgPos = psf.getAveragePosition() 

1499 localKernel = psf.getLocalKernel(avgPos) 

1500 dims = localKernel.getDimensions() 

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

1502 raise ValueError( 

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

1504 ) 

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

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

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

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

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

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

1511 

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

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

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

1515 exposure. 

1516 

1517 Parameters 

1518 ---------- 

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

1520 Template exposure, warped to match the science exposure. 

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

1522 Science exposure to subtract from the template. 

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

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

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

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

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

1528 images around them. 

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

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

1531 exposure. Must be normalized to sum to 1. 

1532 

1533 Returns 

1534 ------- 

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

1536 

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

1538 Result of subtracting the convolved template and science 

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

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

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

1542 of the original science image. 

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

1544 The science exposure after convolving with its own PSF. 

1545 Attached PSF is that of the original science image. 

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

1547 Background model that was fit while solving for the 

1548 PSF-matching kernel 

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

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

1551 image. 

1552 """ 

1553 bbox = science.getBBox() 

1554 innerBBox = preConvKernel.shrinkBBox(bbox) 

1555 

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

1557 preconvolved=True, 

1558 templateFwhmPix=self.templatePsfSize, 

1559 scienceFwhmPix=self.matchedPsfSize) 

1560 

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

1562 self.convolutionControl, 

1563 bbox=bbox, 

1564 psf=science.psf, 

1565 interpolateBadMaskPlanes=True, 

1566 photoCalib=science.photoCalib) 

1567 score = _subtractImages(matchedScience, matchedTemplate, 

1568 backgroundModel=(kernelResult.backgroundModel 

1569 if self.config.doSubtractBackground else None)) 

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

1571 kernelResult.psfMatchingKernel, 

1572 templateMatched=True, preConvMode=True, 

1573 preConvKernel=preConvKernel) 

1574 

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

1576 self._flagScoreEdge(correctedScore.mask, innerBBox) 

1577 

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

1579 matchedTemplate=matchedTemplate, 

1580 matchedScience=matchedScience, 

1581 backgroundModel=kernelResult.backgroundModel, 

1582 psfMatchingKernel=kernelResult.psfMatchingKernel, 

1583 kernelSources=kernelSources) 

1584 

1585 

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

1587 exceptionMessage=""): 

1588 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1589 

1590 Parameters 

1591 ---------- 

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

1593 The template exposure to check 

1594 logger : `logging.Logger` 

1595 Logger for printing output. 

1596 requiredTemplateFraction : `float`, optional 

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

1598 in the template. 

1599 exceptionMessage : `str`, optional 

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

1601 is insufficient. 

1602 

1603 Returns 

1604 ------- 

1605 templateCoverageFraction: `float` 

1606 Fraction of pixels in the template with data. 

1607 

1608 Raises 

1609 ------ 

1610 lsst.pipe.base.NoWorkFound 

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

1612 set, is less than the requiredTemplateFraction 

1613 """ 

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

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

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

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

1618 # because template coverage there doesn't help 

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

1620 pixNoData = np.count_nonzero(noTemplate | noScience) 

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

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

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

1624 

1625 if templateCoverageFraction < requiredTemplateFraction: 

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

1627 100*templateCoverageFraction, 

1628 100*requiredTemplateFraction)) 

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

1630 return templateCoverageFraction 

1631 

1632 

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

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

1635 

1636 Parameters 

1637 ---------- 

1638 science : `lsst.afw.Exposure` 

1639 The input science image. 

1640 template : `lsst.afw.Exposure` 

1641 The template to subtract from the science image. 

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

1643 Differential background model 

1644 

1645 Returns 

1646 ------- 

1647 difference : `lsst.afw.Exposure` 

1648 The subtracted image. 

1649 """ 

1650 difference = science.clone() 

1651 if backgroundModel is not None: 

1652 difference.maskedImage -= backgroundModel 

1653 difference.maskedImage -= template.maskedImage 

1654 return difference 

1655 

1656 

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

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

1659 

1660 Parameters 

1661 ---------- 

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

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

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

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

1666 fwhmExposureBuffer : `float` 

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

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

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

1670 fwhmExposureGrid : `int` 

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

1672 available at its average position. 

1673 Returns 

1674 ------- 

1675 result : `bool` 

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

1677 either dimension. 

1678 """ 

1679 try: 

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

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

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

1683 shape1 = evaluateMeanPsfFwhm(exp1, 

1684 fwhmExposureBuffer=fwhmExposureBuffer, 

1685 fwhmExposureGrid=fwhmExposureGrid 

1686 ) 

1687 shape2 = evaluateMeanPsfFwhm(exp2, 

1688 fwhmExposureBuffer=fwhmExposureBuffer, 

1689 fwhmExposureGrid=fwhmExposureGrid 

1690 ) 

1691 return shape1 <= shape2 

1692 

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

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

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

1696 return xTest | yTest 

1697 

1698 

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

1700 pipelineConnections=SimplifiedSubtractConnections): 

1701 mode = lsst.pex.config.ChoiceField( 

1702 dtype=str, 

1703 default="convolveTemplate", 

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

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

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

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

1708 ) 

1709 useExistingKernel = lsst.pex.config.Field( 

1710 dtype=bool, 

1711 default=True, 

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

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

1714 ) 

1715 

1716 

1717class SimplifiedSubtractTask(AlardLuptonSubtractTask): 

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

1719 the Alard & Lupton (1998) algorithm. 

1720 """ 

1721 ConfigClass = SimplifiedSubtractConfig 

1722 _DefaultName = "simplifiedSubtract" 

1723 

1724 @timeMethod 

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

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

1727 

1728 Parameters 

1729 ---------- 

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

1731 Template exposure, warped to match the science exposure. 

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

1733 Science exposure to subtract from the template. 

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

1735 Exposure catalog with external calibrations to be applied. Catalog 

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

1737 lookup. 

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

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

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

1741 

1742 Returns 

1743 ------- 

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

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

1746 Result of subtracting template and science. 

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

1748 Warped and PSF-matched template exposure. 

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

1750 Background model that was fit while solving for the 

1751 PSF-matching kernel 

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

1753 Kernel used to PSF-match the convolved image. 

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

1755 Sources detected on the science image that were used to 

1756 construct the PSF-matching kernel. 

1757 

1758 Raises 

1759 ------ 

1760 lsst.pipe.base.NoWorkFound 

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

1762 set, is less then the configured requiredTemplateFraction 

1763 """ 

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

1765 

1766 convolveTemplate = self.chooseConvolutionMethod(template, science) 

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

1768 

1769 if self.config.useExistingKernel: 

1770 psfMatchingKernel = inputPsfMatchingKernel 

1771 backgroundModel = None 

1772 kernelSources = None 

1773 else: 

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

1775 runSourceDetection=True) 

1776 psfMatchingKernel = kernelResult.psfMatchingKernel 

1777 kernelSources = kernelResult.kernelSources 

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

1779 backgroundModel = kernelResult.backgroundModel 

1780 else: 

1781 backgroundModel = None 

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

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

1784 backgroundModel=backgroundModel) 

1785 else: 

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

1787 backgroundModel=backgroundModel) 

1788 if kernelSources is not None: 

1789 subtractResults.kernelSources = kernelSources 

1790 return subtractResults 

1791 

1792 

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

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

1795 

1796 Parameters 

1797 ---------- 

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

1799 Image on which to perform interpolation. 

1800 badMaskPlanes : `list` of `str` 

1801 List of mask planes to interpolate over. 

1802 fallbackValue : `float`, optional 

1803 Value to set when interpolation fails. 

1804 

1805 Returns 

1806 ------- 

1807 result: `float` 

1808 The number of masked pixels that were replaced. 

1809 """ 

1810 imgBadMaskPlanes = [ 

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

1812 ] 

1813 

1814 image = maskedImage.image.array 

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

1816 image[badPixels] = np.nan 

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

1818 fallbackValue = np.nanmedian(image) 

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

1820 # the median value. 

1821 image[badPixels] = fallbackValue 

1822 return np.sum(badPixels)