Coverage for python/lsst/ip/diffim/getTemplate.py: 77%

501 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 17:53 +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/>. 

21import collections 

22import dataclasses 

23import functools 

24 

25import astropy.units 

26from astropy.stats import gaussian_sigma_to_fwhm 

27import numpy as np 

28 

29import lsst.afw.detection as afwDetection 

30import lsst.afw.image as afwImage 

31import lsst.geom as geom 

32import lsst.afw.geom as afwGeom 

33from lsst.afw.image import VisitInfo 

34import lsst.afw.table as afwTable 

35from lsst.afw.math._warper import computeWarpedBBox 

36import lsst.afw.math as afwMath 

37from lsst.daf.base import DateTime 

38import lsst.pex.config as pexConfig 

39import lsst.pex.exceptions 

40import lsst.pipe.base as pipeBase 

41 

42from lsst.images import ( 

43 Box, 

44 DifferenceImage, 

45 DifferenceImageTemplateInfo, 

46 DifferenceImageTemplateInput, 

47 GeneralFrame, 

48 NoOverlapError, 

49 Polygon, 

50 Transform, 

51 get_legacy_optional_mask_planes, 

52 get_legacy_template_mask_planes, 

53) 

54from lsst.skymap import BaseSkyMap 

55from lsst.ip.diffim.dcrModel import DcrModel 

56from lsst.ip.diffim.utils import evaluateMeanPsfFwhm, getPsfFwhm, record_from_obs_info 

57from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig, SubtractBackgroundTask, ScaleVarianceTask 

58from lsst.utils.timer import timeMethod 

59 

60__all__ = [ 

61 "GetTemplateTask", 

62 "GetTemplateConfig", 

63 "GetDcrTemplateTask", 

64 "GetDcrTemplateConfig", 

65] 

66 

67_PSF_STAMP_SIGMAS = 5 

68"""Width, in N sigma, of the Gaussian approximation to the CoaddPsf.""" 

69 

70_UNKNOWN_INSTRUMENT = "UNKNOWN" 

71"""Instrument recorded for a coadd or template made from observations of an 

72unknown instrument. 

73""" 

74 

75_FOOTPRINT_MARGIN = 32.0 

76"""Distance, in coadd pixels, by which the science image's footprint in a 

77coadd is grown before a coadd input is clipped to it. It must exceed twice 

78the distance by which a distorted science image edge departs from a straight 

79line between its corners, and limits how far outside the science image its 

80WCS is evaluated. 

81""" 

82 

83 

84def _pixelTransform(fromWcs, toWcs): 

85 """Return a chainable transformation between two pixel frames. 

86 

87 Parameters 

88 ---------- 

89 fromWcs, toWcs : `lsst.afw.geom.SkyWcs` 

90 WCSs of the input and output pixels. 

91 

92 Returns 

93 ------- 

94 transform : `lsst.images.Transform` 

95 Transform from ``fromWcs`` pixels to ``toWcs`` pixels. 

96 """ 

97 return Transform.from_legacy(afwGeom.makeWcsPairTransform(fromWcs, toWcs), 

98 GeneralFrame(unit=astropy.units.pix), 

99 GeneralFrame(unit=astropy.units.pix) 

100 ) 

101 

102 

103def _getImageOverlap(record, coaddWcs, scienceFootprint, coaddToScienceTransform, scienceBox, border): 

104 """Return the region enclosing the overlap of the science image and one 

105 input image of the coadd. 

106 

107 Note that there are three different WCS's involved here: the WCS of the 

108 original image that was warped to contribute to the coadd, the coadd WCS, 

109 and the current science image WCS that we are building a template for. 

110 

111 Parameters 

112 ---------- 

113 record : `lsst.afw.table.ExposureRecord` 

114 Input record of a coadd, with the input's WCS, bounding box and 

115 optional valid polygon. 

116 coaddWcs : `lsst.afw.geom.SkyWcs` 

117 WCS of the coadd. 

118 scienceFootprint : `lsst.images.Region` 

119 ``scienceBox`` in coadd pixels, grown by ``_FOOTPRINT_MARGIN``. 

120 coaddToScienceTransform : `lsst.images.Transform` 

121 Transform from coadd pixels to science image pixels. 

122 scienceBox : `lsst.images.Box` 

123 Bounding box of the science image. 

124 border : `float` 

125 Width, in input pixels, of the border of the input that warping left 

126 out of the coadd. 

127 

128 Returns 

129 ------- 

130 footprint : `lsst.images.Polygon` or `None` 

131 The part of ``scienceBox`` the input's warp covers, in science image 

132 pixels, or `None` if it covers none of it. 

133 """ 

134 inputBox = geom.Box2D(record.getBBox()) 

135 inputBox.grow(-border) 

136 inputBounds = Polygon.from_legacy(afwGeom.Polygon(inputBox)) 

137 polygon = record.getValidPolygon() 

138 inputToCoadd = _pixelTransform(record.getWcs(), coaddWcs) 

139 try: 

140 if polygon is not None: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 inputBounds = inputBounds.intersection(Polygon.from_legacy(polygon)) 

142 # Only vertices are transformed, so the edges of the input stay 

143 # straight. 

144 footprint = ( 

145 inputBounds.transform(inputToCoadd) 

146 .intersection(scienceFootprint) 

147 .transform(coaddToScienceTransform) 

148 ) 

149 # The convex hull keeps the footprint in one piece when it is clipped. 

150 footprint = footprint.convex_hull().intersection(scienceBox) 

151 except NoOverlapError: 

152 return None 

153 return Polygon.from_box(footprint) if isinstance(footprint, Box) else footprint 

154 

155 

156def _warpBorder(kernelName): 

157 """Return the width of the border of an image that warping leaves out. 

158 

159 A warped pixel is computed only where the whole kernel lies on the image. 

160 

161 Parameters 

162 ---------- 

163 kernelName : `str` 

164 Name of the warping kernel, as `lsst.afw.math.Warper` takes it. 

165 

166 Returns 

167 ------- 

168 border : `float` 

169 Width of the border on each side, in pixels from the edge of the 

170 image's bounding box; 2.5 for ``lanczos3``. 

171 """ 

172 kernel = afwMath.WarpingControl(kernelName).getWarpingKernel() 

173 return kernel.getCtr().getX() + 0.5 

174 

175 

176@functools.cache 

177def _warpKernelSumSq(kernelName, nPhase=100): 

178 """Compute the phase-averaged sum of squared values of a warping kernel. 

179 

180 Warping with a kernel normalized to unit sum multiplies the variance by 

181 this sum, which is less than one. The power the warp took off the 

182 diagonal became covariance between neighboring pixels, which a variance 

183 plane cannot represent, so dividing by it restores the noise power: the 

184 variance of a flux measured over a region and not a single pixel. 

185 

186 Parameters 

187 ---------- 

188 kernelName : `str` 

189 Name of the warping kernel, as `lsst.afw.math.Warper` takes it. 

190 nPhase : `int`, optional 

191 Number of sub-pixel phases to average over. 

192 

193 Returns 

194 ------- 

195 sumSq : `float` 

196 Sum of the squared 2-D kernel values, averaged over sub-pixel 

197 phase. Converges with large ``nPhase`` to 0.798 for ``lanczos3``, 

198 4/9 for ``bilinear``, and 1 for no interpolation. 

199 

200 Notes 

201 ----- 

202 The kernels are separable and the phases of the two axes independent, 

203 so this is the square of the 1-D average. The warper normalizes the 

204 kernel to unit sum at each phase, which the analytic Lanczos integral 

205 does not include, so the average is computed from the kernel itself. 

206 """ 

207 # Disable the cache since we are evaluating fewer phase points than the 

208 # default cache size. 

209 kernel = afwMath.Warper(kernelName, cacheSize=0).getWarpingKernel() 

210 image = afwImage.ImageD(kernel.getDimensions()) 

211 total = 0.0 

212 for phase in (np.arange(nPhase) + 0.5)/nPhase: 

213 kernel.setKernelParameters((float(phase), 0.0)) 

214 kernel.computeImage(image, doNormalize=True) 

215 total += float((image.array.sum(axis=0)**2).sum()) 

216 return (total/nPhase)**2 

217 

218 

219class GetTemplateConnections( 

220 pipeBase.PipelineTaskConnections, 

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

222 defaultTemplates={"coaddName": "goodSeeing", "warpTypeSuffix": "", "fakesType": ""}, 

223): 

224 bbox = pipeBase.connectionTypes.Input( 

225 doc="Bounding box of exposure to determine the geometry of the output template.", 

226 name="{fakesType}calexp.bbox", 

227 storageClass="Box2I", 

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

229 deprecated="Replaced by the `detector` connection. Will be removed after v31.", 

230 ) 

231 detector = pipeBase.connectionTypes.Input( 

232 doc="Detector of the exposure that we will construct the template for." 

233 " Its bounding box sets the geometry of the output template.", 

234 name="{fakesType}calexp.detector", 

235 storageClass="Detector", 

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

237 ) 

238 obs_info = pipeBase.connectionTypes.Input( 

239 doc="Observation metadata of the exposure the template is built for." 

240 " Only read with output_image_type='future', which needs this to set" 

241 " the obs_info component of a lsst.images.VisitImage.", 

242 name="{fakesType}calexp.obs_info", 

243 storageClass="ObservationInfo", 

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

245 ) 

246 wcs = pipeBase.connectionTypes.Input( 

247 doc="WCS of the exposure that we will construct the template for.", 

248 name="{fakesType}calexp.wcs", 

249 storageClass="Wcs", 

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

251 ) 

252 skyMap = pipeBase.connectionTypes.Input( 

253 doc="Geometry of the tracts and patches that the coadds are defined on.", 

254 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

255 dimensions=("skymap",), 

256 storageClass="SkyMap", 

257 ) 

258 coaddExposures = pipeBase.connectionTypes.Input( 

259 doc="Coadds that may overlap the desired region, as possible inputs to the template." 

260 " Will be restricted to those that directly overlap the projected bounding box.", 

261 dimensions=("tract", "patch", "skymap", "band"), 

262 storageClass="ExposureF", 

263 name="{fakesType}{coaddName}Coadd{warpTypeSuffix}", 

264 multiple=True, 

265 deferLoad=True, 

266 deferGraphConstraint=True, 

267 ) 

268 

269 template = pipeBase.connectionTypes.Output( 

270 doc="Warped template, pixel matched to the bounding box and WCS.", 

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

272 storageClass="ExposureF", 

273 name="{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}", 

274 ) 

275 

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

277 super().__init__(config=config) 

278 # Kept only so that older config files still load. 

279 del self.bbox 

280 if config.requireCoaddAtGraphBuild: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true

281 self.coaddExposures = dataclasses.replace( 

282 self.coaddExposures, 

283 deferGraphConstraint=False, 

284 ) 

285 if config.output_image_type == "future": 

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

287 else: 

288 del self.obs_info 

289 

290 

291class GetTemplateConfig( 

292 pipeBase.PipelineTaskConfig, pipelineConnections=GetTemplateConnections 

293): 

294 templateBorderSize = pexConfig.Field( 

295 dtype=int, 

296 default=20, 

297 doc="Number of pixels to grow the requested template image to account for warping", 

298 ) 

299 warp = pexConfig.ConfigField( 

300 dtype=afwMath.Warper.ConfigClass, 

301 doc="warper configuration", 

302 ) 

303 doCorrectVarianceWarpCorrelation = pexConfig.Field( 

304 dtype=bool, 

305 default=True, 

306 doc="Correct the template variance for the correlation that warping" 

307 " introduced between neighboring pixels? This covers both warps: the" 

308 " one that built the coadds, named by coaddWarpKernel, and the one" 

309 " this task performs, named by warp.warpingKernelName. It is a single" 

310 " scalar factor, fixed entirely by those two kernels and independent" 

311 " of the pixel scales involved.", 

312 ) 

313 coaddWarpKernel = pexConfig.ChoiceField( 

314 dtype=str, 

315 default="lanczos3", 

316 allowed={ 

317 "bilinear": "bilinear interpolation", 

318 "lanczos3": "Lanczos kernel of order 3", 

319 "lanczos4": "Lanczos kernel of order 4", 

320 "lanczos5": "Lanczos kernel of order 5", 

321 }, 

322 doc="Warping kernel that was used to build the input coadds. This" 

323 " cannot be recovered from the coadds themselves. It is used to" 

324 " correct the variance if doCorrectVarianceWarpCorrelation is True," 

325 " and to trim the coadd inputs bboxes to account for the size of the" 

326 " warping kernel.", 

327 ) 

328 coaddPsf = pexConfig.ConfigField( 

329 doc="Configuration for CoaddPsf", 

330 dtype=CoaddPsfConfig, 

331 ) 

332 fwhmExposureGrid = pexConfig.Field( 

333 doc="Grid size to compute the average PSF FWHM in an exposure (pixels)", 

334 dtype=int, 

335 default=10, 

336 ) 

337 fwhmExposureBuffer = pexConfig.Field( 

338 doc="Fractional buffer margin to be left out of all sides of the image during construction" 

339 " of grid to compute average PSF FWHM in an exposure", 

340 dtype=float, 

341 default=0.05, 

342 ) 

343 varianceBackground = pexConfig.ConfigurableField( 

344 target=SubtractBackgroundTask, 

345 doc="Task to estimate the background variance.", 

346 ) 

347 highVarianceThreshold = pexConfig.RangeField( 

348 dtype=float, 

349 default=4, 

350 min=1, 

351 doc="Set the HIGH_VARIANCE mask plane for regions with variance" 

352 " greater than the median by this factor.", 

353 ) 

354 highVarianceMaskFraction = pexConfig.Field( 

355 dtype=float, 

356 default=0.1, 

357 doc="Minimum fraction of unmasked pixels needed to set the" 

358 " HIGH_VARIANCE mask plane.", 

359 ) 

360 doScaleVariance = pexConfig.Field( 

361 dtype=bool, 

362 default=False, 

363 doc="Scale the variance of the template image to match its per-pixel" 

364 " scatter? Warping correlates the noise, so when combined with" 

365 " doCorrectVarianceWarpCorrelation this overcounts the correction.", 

366 ) 

367 scaleVariance = pexConfig.ConfigurableField( 

368 target=ScaleVarianceTask, 

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

370 ) 

371 requireCoaddAtGraphBuild = pexConfig.Field( 

372 dtype=bool, 

373 default=False, 

374 doc="If True, include the coadd dataset existence in the" 

375 " initial butler query during QuantumGraph generation.", 

376 ) 

377 output_image_type = pexConfig.ChoiceField[str]( 

378 "Which image type to use for the output template.", 

379 allowed={ 

380 "legacy": "Write as a lsst.afw.image.ExposureF.", 

381 "future": "Write as a lsst.images.DifferenceImage.", 

382 }, 

383 optional=False, 

384 default="legacy", 

385 ) 

386 raiseOnUndefinedMaskMap = pexConfig.Field( 

387 dtype=bool, 

388 default=False, 

389 doc="Raise if the template has pixels set in a mask plane that" 

390 " `lsst.images` does not map? If False, log a warning and drop that" 

391 " plane from the output instead." 

392 " Only used with output_image_type='future'.", 

393 ) 

394 

395 def setDefaults(self): 

396 # Use a smaller cache: per SeparableKernel.computeCache, this should 

397 # give a warping error of a fraction of a count (these must match). 

398 self.warp.cacheSize = 100000 

399 self.coaddPsf.cacheSize = self.warp.cacheSize 

400 # The WCS for LSST should be smoothly varying, so we can use a longer 

401 # interpolation length for WCS evaluations. 

402 self.warp.interpLength = 100 

403 self.warp.warpingKernelName = "lanczos3" 

404 self.coaddPsf.warpingKernelName = self.warp.warpingKernelName 

405 

406 # Background subtraction of the variance plane 

407 self.varianceBackground.algorithm = "LINEAR" 

408 self.varianceBackground.binSize = 32 

409 self.varianceBackground.useApprox = False 

410 self.varianceBackground.statisticsProperty = "MEDIAN" 

411 self.varianceBackground.doFilterSuperPixels = True 

412 self.varianceBackground.ignoredPixelMask = ["BAD", 

413 "EDGE", 

414 "DETECTED", 

415 "DETECTED_NEGATIVE", 

416 "NO_DATA", 

417 ] 

418 

419 

420class GetTemplateTask(pipeBase.PipelineTask): 

421 ConfigClass = GetTemplateConfig 

422 _DefaultName = "getTemplate" 

423 

424 def __init__(self, *args, **kwargs): 

425 super().__init__(*args, **kwargs) 

426 if self.config.doScaleVariance: 

427 self.makeSubtask("scaleVariance") 

428 self.warper = afwMath.Warper.fromConfig(self.config.warp) 

429 self.schema = afwTable.ExposureTable.makeMinimalSchema() 

430 self.schema.addField( 

431 "tract", type=np.int32, doc="Which tract this exposure came from." 

432 ) 

433 self.schema.addField( 

434 "patch", 

435 type=np.int32, 

436 doc="Which patch in the tract this exposure came from.", 

437 ) 

438 self.schema.addField( 

439 "weight", 

440 type=float, 

441 doc="Weight for each exposure, used to make the CoaddPsf; should always be 1.", 

442 ) 

443 self.schema.addField( 

444 "instrument", 

445 type=str, 

446 size=64, 

447 doc="Instrument whose observations were coadded to make the template, or UNKNOWN.", 

448 ) 

449 self.makeSubtask("varianceBackground") 

450 

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

452 inputs = butlerQC.get(inputRefs) 

453 detector = inputs.pop("detector") 

454 bbox = detector.getBBox() 

455 wcs = inputs.pop("wcs") 

456 obsInfo = inputs.pop("obs_info", None) 

457 coaddExposures = inputs.pop("coaddExposures") 

458 skymap = inputs.pop("skyMap") 

459 

460 # This should not happen with a properly configured execution context. 

461 assert not inputs, "runQuantum got more inputs than expected" 

462 

463 results = self.getExposures(coaddExposures, bbox, skymap, wcs) 

464 physical_filter = butlerQC.quantum.dataId["physical_filter"] 

465 outputs = self.run( 

466 coaddExposureHandles=results.coaddExposures, 

467 bbox=bbox, 

468 wcs=wcs, 

469 dataIds=results.dataIds, 

470 physical_filter=physical_filter, 

471 visit=outputRefs.template.dataId["visit"], 

472 instrument=butlerQC.quantum.dataId["instrument"], 

473 ) 

474 if self.config.output_image_type == "future": 

475 dataId = butlerQC.quantum.dataId 

476 exposureRecord = record_from_obs_info(obsInfo, dataId["instrument"], dataId["visit"], 

477 dataId.universe) 

478 outputs.template = self.convert_outputs_to_future(outputs.template, 

479 inputRefs.coaddExposures, 

480 detector=detector, 

481 exposureRecord=exposureRecord, 

482 templateInputs=outputs.templateInputs) 

483 butlerQC.put(outputs, outputRefs) 

484 

485 def convert_outputs_to_future(self, template, coadd_refs, detector, exposureRecord, 

486 templateInputs=None): 

487 """Convert a template image to DifferenceImage format. 

488 

489 This replaces ``template`` with an `lsst.images.DifferenceImage` 

490 instance and records the coadds contributed to it, along with the 

491 instrument they were made from and their input image identifiers. 

492 The Gaussian PSF set by `run` is converted to an 

493 `lsst.images.psfs.GaussianPointSpreadFunction`. 

494 

495 Parameters 

496 ---------- 

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

498 Template to convert. Its detector is replaced with ``detector`` 

499 and its mask is modified in place. 

500 coadd_refs : `list` [`lsst.daf.butler.DatasetRef`] 

501 References to the coadds that may have gone into the template. 

502 May be a superset of the coadds that were used; supplies the 

503 dataset id and RUN collection recorded for each coadd that was. 

504 detector : `lsst.afw.cameraGeom.Detector` 

505 Detector of the science image that the template was built for. 

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

507 The ``exposure`` record of the observation the template was built 

508 for, which supplies the observation metadata recorded on the 

509 output. 

510 templateInputs : `dict` [`tuple` [`int`, `int`], \ 

511 `list` [`lsst.images.DifferenceImageTemplateInput`]], optional 

512 Input image records of each coadd, keyed by tract and patch, as 

513 returned by `run`. Coadds not listed record no inputs. 

514 

515 Returns 

516 ------- 

517 convertedTemplate : `lsst.images.DifferenceImage` 

518 The converted image. 

519 

520 Raises 

521 ------ 

522 RuntimeError 

523 Raised if the template has pixels set in a mask plane that 

524 `lsst.images.get_legacy_template_mask_planes` does not map and 

525 ``config.raiseOnUndefinedMaskMap`` is True, or if ``coadd_refs`` 

526 spans more than one skymap. 

527 """ 

528 # The template PSF is a Gaussian, so rebuild the CoaddPsf from the 

529 # template inputs to record which coadds contributed. 

530 catalog = template.getInfo().getCoaddInputs().ccds 

531 coaddPsf = self._makePsf(template, catalog, template.getWcs()) 

532 # `run` records the template's instrument on every input. 

533 instrument = catalog[0]["instrument"] 

534 template.setDetector(detector) 

535 skymaps = {ref.dataId["skymap"] for ref in coadd_refs} 

536 if len(skymaps) != 1: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true

537 raise RuntimeError("Expected the template inputs to come from one skymap;" 

538 f" got {sorted(skymaps)}.") 

539 butler_info = {(ref.dataId["tract"], ref.dataId["patch"]): (ref.id, ref.run) 

540 for ref in coadd_refs} 

541 planeMap = get_legacy_template_mask_planes() 

542 if not self.config.raiseOnUndefinedMaskMap: 

543 self._clearUnmappedMaskPlanes(template.mask, planeMap) 

544 convertedTemplate = DifferenceImage.from_legacy( 

545 template, 

546 exposure_record=exposureRecord, 

547 unit=astropy.units.nJy, 

548 plane_map=planeMap, 

549 ) 

550 del template 

551 convertedTemplate.templates = DifferenceImageTemplateInfo.from_legacy_psf( 

552 convertedTemplate.sky_projection.pixel_frame, 

553 coaddPsf, 

554 skymaps.pop(), 

555 butler_info, 

556 log=self.log, 

557 instrument=instrument, 

558 ) 

559 if templateInputs: 

560 for info in convertedTemplate.templates: 

561 info.inputs = templateInputs.get((info.tract, info.patch), []) 

562 return convertedTemplate 

563 

564 def _clearUnmappedMaskPlanes(self, mask, planeMap): 

565 """Clear the pixels of every mask plane that conversion would reject. 

566 

567 Parameters 

568 ---------- 

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

570 Template mask to modify in place. 

571 planeMap : `dict` [`str`, `lsst.images.MaskPlane`] 

572 Plane map the template will be converted with. The optional 

573 planes of `lsst.images.get_legacy_optional_mask_planes` are kept 

574 as well, since conversion adds them when they have pixels set. 

575 """ 

576 keep = planeMap.keys() | get_legacy_optional_mask_planes().keys() 

577 for name in mask.getMaskPlaneDict(): 

578 if name in keep: 

579 continue 

580 bitmask = mask.getPlaneBitMask(name) 

581 nSet = np.count_nonzero(mask.array & bitmask) 

582 if nSet: 

583 self.log.warning("Dropping mask plane %s from the template: %d pixels are set, but" 

584 " the template plane map does not include it.", name, nSet) 

585 mask.array &= ~bitmask 

586 

587 def getExposures(self, coaddExposureHandles, bbox, skymap, wcs): 

588 """Return a data structure containing the coadds that overlap the 

589 specified bbox projected onto the sky, and a corresponding data 

590 structure of their dataIds. 

591 These are the appropriate inputs to this task's `run` method. 

592 

593 The spatial index in the butler registry has generous padding and often 

594 supplies patches near, but not directly overlapping the desired region. 

595 This method filters the inputs so that `run` does not have to read in 

596 all possibly-matching coadd exposures. 

597 

598 Parameters 

599 ---------- 

600 coaddExposureHandles : `iterable` \ 

601 [`lsst.daf.butler.DeferredDatasetHandle` of \ 

602 `lsst.afw.image.Exposure`] 

603 Dataset handles to exposures that might overlap the desired 

604 region. 

605 bbox : `lsst.geom.Box2I` 

606 Template bounding box of the pixel geometry onto which the 

607 coaddExposures will be resampled. 

608 skymap : `lsst.skymap.SkyMap` 

609 Geometry of the tracts and patches the coadds are defined on. 

610 wcs : `lsst.afw.geom.SkyWcs` 

611 Template WCS onto which the coadds will be resampled. 

612 

613 Returns 

614 ------- 

615 result : `lsst.pipe.base.Struct` 

616 A struct with attributes: 

617 

618 ``coaddExposures`` 

619 Dict of coadd exposures that overlap the projected bbox, 

620 indexed on tract id 

621 (`dict` [`int`, `list` [`lsst.daf.butler.DeferredDatasetHandle` of 

622 `lsst.afw.image.Exposure`] ]). 

623 ``dataIds`` 

624 Dict of data IDs of the coadd exposures that overlap the 

625 projected bbox, indexed on tract id 

626 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]). 

627 

628 Raises 

629 ------ 

630 NoWorkFound 

631 Raised if no patches overlap the input detector bbox, or the input 

632 WCS is None. 

633 """ 

634 if wcs is None: 

635 raise pipeBase.NoWorkFound( 

636 "WCS is None; cannot find overlapping exposures." 

637 ) 

638 

639 # Exposure's validPolygon would be more accurate 

640 detectorPolygon = geom.Box2D(bbox) 

641 detectorCorners = wcs.pixelToSky(detectorPolygon.getCorners()) 

642 overlappingArea = 0 

643 coaddExposures = collections.defaultdict(list) 

644 dataIds = collections.defaultdict(list) 

645 

646 for coaddRef in coaddExposureHandles: 

647 dataId = coaddRef.dataId 

648 patchWcs = skymap[dataId["tract"]].getWcs() 

649 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox() 

650 patchPolygon = afwGeom.Polygon(geom.Box2D(patchBBox)) 

651 # Calculate detector/patch overlap in patch coordinates rather than 

652 # detector coordinates because the skymap's inverse mapping 

653 # (patchWcs.skyToPixel()) is more stable than the detector's for 

654 # arbitrary sky coordinates. 

655 detectorInPatchCoordinates = afwGeom.Polygon(patchWcs.skyToPixel(detectorCorners)) 

656 if patchPolygon.intersection(detectorInPatchCoordinates): 

657 overlappingArea += patchPolygon.intersectionSingle( 

658 detectorInPatchCoordinates 

659 ).calculateArea() 

660 self.log.info( 

661 "Using template input tract=%s, patch=%s", 

662 dataId["tract"], 

663 dataId["patch"], 

664 ) 

665 coaddExposures[dataId["tract"]].append(coaddRef) 

666 dataIds[dataId["tract"]].append(dataId) 

667 

668 if not overlappingArea: 

669 raise pipeBase.NoWorkFound("No patches overlap detector") 

670 

671 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds) 

672 

673 @timeMethod 

674 def run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None, instrument=None): 

675 """Warp coadds from multiple tracts and patches to form a template to 

676 subtract from a science image. 

677 

678 Tract and patch overlap regions are combined by a variance-weighted 

679 average, and the variance planes are combined with the same weights, 

680 not added in quadrature; the overlap regions are not statistically 

681 independent, because they're derived from the same original data. 

682 The PSF of the template is a Gaussian with the same width as the 

683 CoaddPsf evaluated at the center of the detector. 

684 

685 The template is built from coadds of one instrument: the science 

686 image's ``instrument`` if any coadd was made from it, and otherwise the 

687 one that most of the coadds were made from. Coadds made from another 

688 instrument are dropped, and so are coadds that record no instrument 

689 unless none of the coadds records one. 

690 

691 Parameters 

692 ---------- 

693 coaddExposureHandles : `dict` [`int`, `list` of \ 

694 [`lsst.daf.butler.DeferredDatasetHandle` of \ 

695 `lsst.afw.image.Exposure`]] 

696 Coadds to be mosaicked, indexed on tract id. 

697 bbox : `lsst.geom.Box2I` 

698 Input Bounding box of the detector geometry onto which to 

699 resample the ``coaddExposureHandles``. The output template bbox 

700 will be grown by ``templateBorderSize``. 

701 wcs : `lsst.afw.geom.SkyWcs` 

702 Template WCS onto which to resample the ``coaddExposureHandles``. 

703 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]] 

704 Record of the tract and patch of each coaddExposure, indexed on 

705 tract id. 

706 physical_filter : `str` 

707 Physical filter of the science image. 

708 visit : `int`, optional 

709 If supplied, over-write the visit ID in the template's visitInfo 

710 so that downstream source injection tasks can link the template and 

711 science image for the visit. 

712 instrument : `str`, optional 

713 Instrument of the science image. Coadds made from it are used in 

714 preference to coadds from any other instrument. 

715 

716 Returns 

717 ------- 

718 result : `lsst.pipe.base.Struct` 

719 A struct with attributes: 

720 

721 ``template`` 

722 A template coadd exposure assembled out of patches, with 

723 pixels in nJy. Its PSF is a Gaussian approximation to the 

724 CoaddPsf of its inputs (`lsst.afw.image.ExposureF`). 

725 ``templateInputs`` 

726 Input image records of each coadd that cover part of the 

727 science image, keyed by tract and patch (`dict` 

728 [`tuple` [`int`, `int`], 

729 `list` [`lsst.images.DifferenceImageTemplateInput`]]). Empty 

730 unless ``config.output_image_type`` is ``"future"``. 

731 

732 Raises 

733 ------ 

734 NoWorkFound 

735 If no coadds are found with sufficient un-masked pixels. 

736 RuntimeError 

737 If the template is not known to be from the science image's 

738 instrument and no coadd records the plate scale of its input 

739 images, so the template variance cannot be corrected. 

740 """ 

741 band, photoCalib, templateInstrument = self._checkInputs(dataIds, coaddExposureHandles, instrument) 

742 

743 scienceBBox = bbox 

744 bbox = geom.Box2I(bbox) 

745 bbox.grow(self.config.templateBorderSize) 

746 

747 warped = {} 

748 catalogs = [] 

749 inputs = [] 

750 plateScaleFactor = 1.0 if instrument == templateInstrument else None 

751 

752 for tract in coaddExposureHandles: 

753 maskedImages, catalog, totalBox, coaddInputs = self._makeExposureCatalog( 

754 coaddExposureHandles[tract], dataIds[tract], templateInstrument 

755 ) 

756 if len(catalog) == 0: 

757 # Every coadd in this tract was dropped for its instrument. 

758 continue 

759 # Determine the ratio of the original pixel area to the pixel area 

760 # of the science image. This will only be different from 1 if the 

761 # coadd was from a different instrument. A single correction factor 

762 # is calculated from the first usable coadd and applied to all. 

763 if plateScaleFactor is None: 

764 plateScaleFactor = self._plateScaleFactor(coaddInputs[0], wcs, bbox) 

765 warpedBox = computeWarpedBBox(catalog[0].wcs, bbox, wcs) 

766 warpedBox.grow(5) # to ensure we catch all relevant input pixels 

767 # Combine images from individual patches together. 

768 unwarped, count, included = self._merge( 

769 maskedImages, warpedBox, catalog[0].wcs 

770 ) 

771 # Delete `maskedImages` after combining into one large image to reduce peak memory use 

772 del maskedImages 

773 if count == 0: 

774 self.log.info( 

775 "No valid pixels from coadd patches in tract %s; not including in output.", 

776 tract, 

777 ) 

778 continue 

779 warpedBox.clip(totalBox) 

780 potentialInput = self.warper.warpExposure( 

781 wcs, unwarped.subset(warpedBox), destBBox=bbox 

782 ) 

783 # Delete the single large `unwarped` image after warping to reduce peak memory use 

784 del unwarped 

785 if np.all( 

786 potentialInput.mask.array 

787 & potentialInput.mask.getPlaneBitMask("NO_DATA") 

788 ): 

789 self.log.info( 

790 "No overlap from coadd patches in tract %s; not including in output.", 

791 tract, 

792 ) 

793 continue 

794 

795 # Trim the exposure catalog to just the patches that were used. 

796 tempCatalog = afwTable.ExposureCatalog(self.schema) 

797 tempCatalog.reserve(len(included)) 

798 for i in included: 

799 tempCatalog.append(catalog[i]) 

800 inputs.append(coaddInputs[i]) 

801 catalogs.append(tempCatalog) 

802 warped[tract] = potentialInput.maskedImage 

803 

804 if len(warped) == 0: 

805 raise pipeBase.NoWorkFound("No patches found to overlap science exposure.") 

806 

807 # At this point, all entries will be valid, so we can ignore included. 

808 template, count, _ = self._merge(warped, bbox, wcs) 

809 if count == 0: 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true

810 raise pipeBase.NoWorkFound("No valid pixels in warped template.") 

811 

812 if self.config.doScaleVariance: 

813 # Scale the variance of the template image before subtraction, if 

814 # needed. Note that the science variance is scaled 

815 # independently in ``AlardLuptonSubtractTask``. 

816 varianceFactor = self.scaleVariance.run(template.maskedImage) 

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

818 self.metadata["scaleTemplateVarianceFactor"] = varianceFactor 

819 

820 self._correctVariance(template, plateScaleFactor) 

821 

822 # Make a single catalog containing all the inputs that were accepted. 

823 catalog = afwTable.ExposureCatalog(self.schema) 

824 catalog.reserve(sum([len(c) for c in catalogs])) 

825 for c in catalogs: 

826 catalog.extend(c) 

827 # Only the future output type records the inputs. 

828 if self.config.output_image_type == "future": 

829 templateInputs = self._collectTemplateInputs(catalog, inputs, wcs, scienceBBox) 

830 else: 

831 templateInputs = {} 

832 

833 # Set a mask plane for any regions with exceptionally high variance. 

834 self.checkHighVariance(template) 

835 if visit is not None: 

836 template.getInfo().setVisitInfo(VisitInfo(id=visit)) 

837 template.setFilter(afwImage.FilterLabel(band, physical_filter)) 

838 identity = afwImage.PhotoCalib(1.0) 

839 if photoCalib is not None and photoCalib != identity: 

840 template.maskedImage = photoCalib.calibrateImage(template.maskedImage) 

841 photoCalib = identity 

842 template.setPhotoCalib(photoCalib) 

843 template.setPsf(self._makePsf(template, catalog, wcs)) 

844 template.setPsf(self._makeGaussianPsf(template)) 

845 

846 # Record the input coadd patches as the template's coadd inputs. 

847 coaddInputs = afwImage.CoaddInputs(afwTable.ExposureTable.makeMinimalSchema(), self.schema) 

848 coaddInputs.ccds.extend(catalog, deep=True) 

849 template.getInfo().setCoaddInputs(coaddInputs) 

850 return pipeBase.Struct(template=template, templateInputs=templateInputs) 

851 

852 def _correctVariance(self, template, plateScaleFactor): 

853 """Correct the template variance plane for the effects of warping. 

854 

855 Warping introduces a covariance between neighboring pixels, while the 

856 input variance plane captures only the diagonal term. Compute a 

857 multiplicative correction factor to apply to the final variance so that 

858 the detection significance on the convolved difference image is correct 

859 on average. 

860 

861 Separately, also apply a scalar multiplicative correction factor to the 

862 template variance if the plate scale of the coadd's constituent images 

863 is different than the science image the template is being constructed 

864 for. This should only be necessary if the coadd images were from a 

865 different instrument than the science image, since the scale change 

866 otherwise cancels between the two warps. If the plate scale of the 

867 science instrument is smaller than the plate scale of the coadd 

868 instrument, then the template pixels will be correlated and the true 

869 variance will be higher than the image pixel noise level would suggest. 

870 

871 Parameters 

872 ---------- 

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

874 Assembled template; its variance plane is modified in place. 

875 plateScaleFactor : `float` or `None` 

876 Correction for the change in pixel area, from `_plateScaleFactor`. 

877 

878 Raises 

879 ------ 

880 RuntimeError 

881 Raised if ``plateScaleFactor`` is `None`. 

882 """ 

883 if plateScaleFactor is None: 

884 raise RuntimeError( 

885 "Could not determine the plate scale of the images the template coadds were made from," 

886 " so the template variance cannot be corrected for the change in pixel area." 

887 ) 

888 self.metadata["variancePlateScaleFactor"] = plateScaleFactor 

889 correlationFactor = 1.0 

890 if self.config.doCorrectVarianceWarpCorrelation: 

891 # Both warps correlate the noise: the one that built the coadds, 

892 # and the one this task just performed. 

893 correlationFactor = 1/(_warpKernelSumSq(self.config.coaddWarpKernel) 

894 * _warpKernelSumSq(self.config.warp.warpingKernelName)) 

895 self.metadata["varianceWarpCorrelationFactor"] = correlationFactor 

896 scale = correlationFactor*plateScaleFactor 

897 if scale == 1.0: 

898 return 

899 template.variance.array *= scale 

900 self.metadata["templateVarianceCorrectionFactor"] = scale 

901 self.log.info( 

902 "Variance correction factor due to plate scale changes: %.4f", 

903 plateScaleFactor, 

904 ) 

905 self.log.info( 

906 "Variance correction factor due to warping correlations: %.4f", 

907 correlationFactor, 

908 ) 

909 self.log.info( 

910 "Overall template variance correction factor: %.4f", 

911 scale, 

912 ) 

913 

914 def _plateScaleFactor(self, coaddInputs, wcs, bbox): 

915 """Compute the variance correction for the total change in pixel area 

916 between the coadd's constituent images and the science image. 

917 

918 Parameters 

919 ---------- 

920 coaddInputs : `lsst.afw.image.CoaddInputs` or `None` 

921 Input records of one of the coadd patches. 

922 wcs : `lsst.afw.geom.SkyWcs` 

923 WCS of the science image the template is being built for. 

924 bbox : `lsst.geom.Box2I` 

925 Bounding box of the template, used only to choose where to 

926 evaluate the science image pixel scale. 

927 

928 Returns 

929 ------- 

930 factor : `float` or `None` 

931 The correction factor equivalent to the ratio of the area of a pixel 

932 from the coadd's instrument to the science instrument, or `None` if 

933 this patch carries no input record from which it could be computed. 

934 """ 

935 if coaddInputs is None: 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true

936 return None 

937 scienceScale = wcs.getPixelScale(geom.Box2D(bbox).getCenter()).asArcseconds() 

938 

939 for record in coaddInputs.ccds: 

940 # Iterate through the records, and use the first one with a WCS 

941 # that gives a usable pixel area. 

942 originalWcs = record.getWcs() 

943 if originalWcs is None: 

944 continue 

945 center = geom.Box2D(record.getBBox()).getCenter() 

946 pviScale = originalWcs.getPixelScale(center).asArcseconds() 

947 # The area of one original pixel, expressed in science pixels. 

948 factor = (pviScale/scienceScale)**2 

949 if np.isfinite(factor) and factor > 0: 949 ↛ 939line 949 didn't jump to line 939 because the condition on line 949 was always true

950 return factor 

951 return None 

952 

953 def _collectTemplateInputs(self, catalog, coaddInputs, wcs, bbox): 

954 """Find the input images and epochs of each coadd in the template. 

955 

956 Parameters 

957 ---------- 

958 catalog : `lsst.afw.table.ExposureCatalog` 

959 Coadds the template was built from, with their tract, patch and 

960 WCS. 

961 coaddInputs : `list` [`lsst.afw.image.CoaddInputs` or `None`] 

962 Input records of each coadd in ``catalog``, in the same order. 

963 wcs : `lsst.afw.geom.SkyWcs` 

964 WCS of the science image. 

965 bbox : `lsst.geom.Box2I` 

966 Bounding box of the science image. 

967 

968 Returns 

969 ------- 

970 templateInputs : `dict` [`tuple` [`int`, `int`], \ 

971 `list` [`lsst.images.DifferenceImageTemplateInput`]] 

972 Inputs of each coadd that cover part of the science image, keyed 

973 by tract and patch, with the part their warps cover in science 

974 image pixels. 

975 """ 

976 scienceBox = Box.from_legacy(bbox) 

977 border = _warpBorder(self.config.coaddWarpKernel) 

978 regions = {} 

979 templateInputs = {} 

980 for coadd, coaddInput in zip(catalog, coaddInputs, strict=True): 

981 coaddList = templateInputs.setdefault((coadd["tract"], coadd["patch"]), []) 

982 if coaddInput is None: 982 ↛ 983line 982 didn't jump to line 983 because the condition on line 982 was never true

983 continue 

984 scienceToCoadd = _pixelTransform(wcs, coadd.getWcs()) 

985 scienceFootprint = scienceBox.transform(scienceToCoadd).dilated_by(_FOOTPRINT_MARGIN) 

986 coaddToScience = scienceToCoadd.inverted() 

987 for record in coaddInput.ccds: 

988 visitInfo = record.getVisitInfo() 

989 weight = record["weight"] 

990 if visitInfo is None or not visitInfo.getDate().isValid() or not weight > 0: 

991 continue 

992 key = (record["visit"], record["ccd"]) 

993 # Record a single region for a ccd in overlapping patches. 

994 if key not in regions: 

995 regions[key] = _getImageOverlap(record, coadd.getWcs(), scienceFootprint, coaddToScience, 

996 scienceBox, border) 

997 region = regions[key] 

998 if region is None: 

999 continue 

1000 mjd = visitInfo.getDate().get(DateTime.MJD, DateTime.TAI) 

1001 coaddList.append(DifferenceImageTemplateInput(visit=key[0], detector=key[1], 

1002 mid_mjd_tai=mjd, weight=weight, 

1003 bounds=region)) 

1004 return templateInputs 

1005 

1006 def checkHighVariance(self, template): 

1007 """Set a mask plane for regions with unusually high variance. 

1008 

1009 Parameters 

1010 ---------- 

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

1012 The warped template exposure, which will be modified in place. 

1013 """ 

1014 highVarianceMaskPlaneBit = template.mask.addMaskPlane("HIGH_VARIANCE") 

1015 ignoredPixelBits = template.mask.getPlaneBitMask(self.varianceBackground.config.ignoredPixelMask) 

1016 goodMask = (template.mask.array & ignoredPixelBits) == 0 

1017 goodFraction = np.count_nonzero(goodMask)/template.mask.array.size 

1018 if goodFraction < self.config.highVarianceMaskFraction: 

1019 self.log.info("Not setting HIGH_VARIANCE mask plane, only %2.1f%% of" 

1020 " pixels were unmasked for background estimation, but" 

1021 " %2.1f%% are required", 100*goodFraction, 100*self.config.highVarianceMaskFraction) 

1022 else: 

1023 varianceExposure = template.clone() 

1024 varianceExposure.image.array = varianceExposure.variance.array 

1025 varianceBackground = self.varianceBackground.run(varianceExposure).background.getImage().array 

1026 threshold = self.config.highVarianceThreshold*np.nanmedian(varianceBackground) 

1027 highVariancePix = varianceBackground > threshold 

1028 template.mask.array[highVariancePix] |= 2**highVarianceMaskPlaneBit 

1029 

1030 @staticmethod 

1031 def _checkInputs(dataIds, coaddExposures, scienceInstrument=None): 

1032 """Check that the all the dataIds are from the same band and that 

1033 the exposures all have the same photometric calibration, and choose 

1034 the instrument of the template. 

1035 

1036 Parameters 

1037 ---------- 

1038 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]] 

1039 Record of the tract and patch of each coaddExposure. 

1040 coaddExposures : `dict` [`int`, `list` of \ 

1041 [`lsst.daf.butler.DeferredDatasetHandle` of \ 

1042 `lsst.afw.image.Exposure` or 

1043 `lsst.afw.image.Exposure`]] 

1044 Coadds to be mosaicked. 

1045 scienceInstrument : `str`, optional 

1046 Instrument of the science image. 

1047 

1048 Returns 

1049 ------- 

1050 band : `str` 

1051 Filter band of all the input exposures. 

1052 photoCalib : `lsst.afw.image.PhotoCalib` 

1053 Photometric calibration of all of the input exposures. 

1054 instrument : `str` 

1055 ``scienceInstrument`` if any coadd was made from it, otherwise the 

1056 instrument that the most coadds were made from, or ``"UNKNOWN"`` if 

1057 no coadd records one. A tie goes to the instrument whose name sorts 

1058 first. 

1059 

1060 Raises 

1061 ------ 

1062 RuntimeError 

1063 Raised if the bands or calibrations of the input exposures are not 

1064 all the same. 

1065 """ 

1066 bands = set(dataId["band"] for tract in dataIds for dataId in dataIds[tract]) 

1067 if len(bands) > 1: 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true

1068 raise RuntimeError(f"GetTemplateTask called with multiple bands: {bands}") 

1069 band = bands.pop() 

1070 photoCalibs = [] 

1071 instrumentCounts = collections.Counter() 

1072 for exposures in coaddExposures.values(): 

1073 for exposure in exposures: 

1074 # Read both components of a coadd in turn, so a butler cache 

1075 # can serve the second read from the first download. 

1076 photoCalibs.append(exposure.get(component="photoCalib")) 

1077 instrumentCounts[GetTemplateTask._getInstrument(exposure.get(component="coaddInputs"))] += 1 

1078 if not all([photoCalibs[0] == x for x in photoCalibs]): 1078 ↛ 1079line 1078 didn't jump to line 1079 because the condition on line 1078 was never true

1079 msg = f"GetTemplateTask called with exposures with different photoCalibs: {photoCalibs}" 

1080 raise RuntimeError(msg) 

1081 photoCalib = photoCalibs[0] 

1082 # Coadds that record no instrument do not count toward the choice. 

1083 del instrumentCounts[_UNKNOWN_INSTRUMENT] 

1084 if scienceInstrument in instrumentCounts: 

1085 return band, photoCalib, scienceInstrument 

1086 if not instrumentCounts: 

1087 return band, photoCalib, _UNKNOWN_INSTRUMENT 

1088 instrument = max(sorted(instrumentCounts), key=instrumentCounts.get) 

1089 return band, photoCalib, instrument 

1090 

1091 @staticmethod 

1092 def _getInstrument(coaddInputs): 

1093 """Return the instrument whose observations were coadded to make a 

1094 coadd. 

1095 

1096 Parameters 

1097 ---------- 

1098 coaddInputs : `lsst.afw.image.CoaddInputs` or `None` 

1099 Input records of the coadd. 

1100 

1101 Returns 

1102 ------- 

1103 instrument : `str` 

1104 Instrument label of the coadd, or ``"UNKNOWN"`` if it records none. 

1105 

1106 Notes 

1107 ----- 

1108 Every input of a coadd comes from the same instrument, so the first 

1109 input record stands for all of them. 

1110 """ 

1111 if coaddInputs is None or len(coaddInputs.ccds) == 0: 

1112 return _UNKNOWN_INSTRUMENT 

1113 visitInfo = coaddInputs.ccds[0].getVisitInfo() 

1114 label = visitInfo.getInstrumentLabel() if visitInfo is not None else "" 

1115 return label or _UNKNOWN_INSTRUMENT 

1116 

1117 def _makeExposureCatalog(self, exposureRefs, dataIds, instrument=_UNKNOWN_INSTRUMENT): 

1118 """Make an exposure catalog for one tract. 

1119 

1120 Parameters 

1121 ---------- 

1122 exposureRefs : `list` of [`lsst.daf.butler.DeferredDatasetHandle` of \ 

1123 `lsst.afw.image.Exposure`] 

1124 Exposures to include in the catalog. 

1125 dataIds : `list` [`lsst.daf.butler.DataCoordinate`] 

1126 Data ids of each of the included exposures; must have "tract" and 

1127 "patch" entries. 

1128 instrument : `str`, optional 

1129 Instrument of the template, recorded in the catalog. Exposures 

1130 made from another instrument are dropped, as are exposures that 

1131 record no instrument unless ``instrument`` is ``"UNKNOWN"``. 

1132 

1133 Returns 

1134 ------- 

1135 images : `dict` [`lsst.afw.image.MaskedImage`] 

1136 MaskedImages of each of the input exposures, for warping. 

1137 catalog : `lsst.afw.table.ExposureCatalog` 

1138 Catalog of metadata for each exposure 

1139 totalBox : `lsst.geom.Box2I` 

1140 The union of the bounding boxes of all the input exposures. 

1141 coaddInputs : `list` [`lsst.afw.image.CoaddInputs` or `None`] 

1142 Input records of each exposure in ``catalog``, in the same order. 

1143 """ 

1144 catalog = afwTable.ExposureCatalog(self.schema) 

1145 catalog.reserve(len(exposureRefs)) 

1146 exposures = (exposureRef.get() for exposureRef in exposureRefs) 

1147 images = {} 

1148 totalBox = geom.Box2I() 

1149 keptCoaddInputs = [] 

1150 

1151 for coadd, dataId in zip(exposures, dataIds): 

1152 coaddInputs = coadd.getInfo().getCoaddInputs() 

1153 coaddInstrument = self._getInstrument(coaddInputs) 

1154 if coaddInstrument != instrument: 

1155 self.log.info( 

1156 "Dropping template coadd %s: it was made from %s, but the template is made from %s.", 

1157 dataId, 

1158 coaddInstrument, 

1159 instrument, 

1160 ) 

1161 continue 

1162 keptCoaddInputs.append(coaddInputs) 

1163 images[dataId] = coadd.maskedImage 

1164 bbox = coadd.getBBox() 

1165 totalBox = totalBox.expandedTo(bbox) 

1166 record = catalog.addNew() 

1167 record.setPsf(coadd.psf) 

1168 record.setWcs(coadd.wcs) 

1169 record.setPhotoCalib(coadd.photoCalib) 

1170 record.setBBox(bbox) 

1171 record.setValidPolygon(afwGeom.Polygon(geom.Box2D(bbox).getCorners())) 

1172 record.set("tract", dataId["tract"]) 

1173 record.set("patch", dataId["patch"]) 

1174 record.set("instrument", instrument) 

1175 # Weight is used by CoaddPsf, but the PSFs from overlapping patches 

1176 # should be very similar, so this value mostly shouldn't matter. 

1177 record.set("weight", 1) 

1178 

1179 return images, catalog, totalBox, keptCoaddInputs 

1180 

1181 def _merge(self, maskedImages, bbox, wcs): 

1182 """Merge the images that came from one tract into one larger image, 

1183 ignoring NaN pixels and non-finite variance pixels from individual 

1184 exposures. 

1185 

1186 Parameters 

1187 ---------- 

1188 maskedImages : `dict` [`lsst.afw.image.MaskedImage` or 

1189 `lsst.afw.image.Exposure`] 

1190 Images to be merged into one larger bounding box. 

1191 bbox : `lsst.geom.Box2I` 

1192 Bounding box defining the image to merge into. 

1193 wcs : `lsst.afw.geom.SkyWcs` 

1194 WCS of all of the input images to set on the output image. 

1195 

1196 Returns 

1197 ------- 

1198 merged : `lsst.afw.image.MaskedImage` 

1199 Merged image with all of the inputs at their respective bbox 

1200 positions. 

1201 count : `int` 

1202 Count of the number of good pixels (those with positive weights) 

1203 in the merged image. 

1204 included : `list` [`int`] 

1205 List of indexes of patches that were included in the merged 

1206 result, to be used to trim the exposure catalog. 

1207 """ 

1208 merged = afwImage.ExposureF(bbox, wcs) 

1209 weights = afwImage.ImageF(bbox) 

1210 included = [] # which patches were included in the result 

1211 for i, (dataId, maskedImage) in enumerate(maskedImages.items()): 

1212 # Only merge into the trimmed box, to save memory 

1213 clippedBox = geom.Box2I(maskedImage.getBBox()) 

1214 clippedBox.clip(bbox) 

1215 if clippedBox.area == 0: 

1216 self.log.debug("%s does not overlap template region.", dataId) 

1217 continue # nothing in this image overlaps the output 

1218 maskedImage = maskedImage.subset(clippedBox) 

1219 # Catch both zero-value and NaN variance plane pixels 

1220 good = (maskedImage.variance.array > 0) & ( 

1221 np.isfinite(maskedImage.variance.array) 

1222 ) 

1223 weight = maskedImage.variance.array[good] ** (-0.5) 

1224 bad = np.isnan(maskedImage.image.array) | ~good 

1225 # Note that modifying the patch MaskedImage in place is fine; 

1226 # we're throwing it away at the end anyway. 

1227 maskedImage.image.array[bad] = 0.0 

1228 maskedImage.variance.array[bad] = 0.0 

1229 # Reset mask, too, since these pixels don't contribute to sum. 

1230 maskedImage.mask.array[bad] = 0 

1231 # Cannot use `merged.maskedImage *= weight` because that operator 

1232 # multiplies the variance by the weight twice; in this case 

1233 # `weight` are the exact values we want to scale by. 

1234 maskedImage.image.array[good] *= weight 

1235 maskedImage.variance.array[good] *= weight 

1236 weights[clippedBox].array[good] += weight 

1237 # Free memory before creating new large arrays 

1238 del weight 

1239 merged.maskedImage[clippedBox] += maskedImage 

1240 included.append(i) 

1241 

1242 good = weights.array > 0 

1243 

1244 # Cannot use `merged.maskedImage /= weights` because that 

1245 # operator divides the variance by the weight twice; in this case 

1246 # `weights` are the exact values we want to scale by. 

1247 weights = weights.array[good] 

1248 merged.image.array[good] /= weights 

1249 merged.variance.array[good] /= weights 

1250 

1251 merged.mask.array[~good] |= merged.mask.getPlaneBitMask("NO_DATA") 

1252 

1253 return merged, good.sum(), included 

1254 

1255 def _makePsf(self, template, catalog, wcs): 

1256 """Return a PSF containing the PSF at each of the input regions. 

1257 

1258 Note that although this includes all the exposures from the catalog, 

1259 the PSF knows which part of the template the inputs came from, so when 

1260 evaluated at a given position it will not include inputs that never 

1261 went in to those pixels. 

1262 

1263 Parameters 

1264 ---------- 

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

1266 Generated template the PSF is for. 

1267 catalog : `lsst.afw.table.ExposureCatalog` 

1268 Catalog of exposures that went into the template that contains all 

1269 of the input PSFs. 

1270 wcs : `lsst.afw.geom.SkyWcs` 

1271 WCS of the template, to warp the PSFs to. 

1272 

1273 Returns 

1274 ------- 

1275 coaddPsf : `lsst.meas.algorithms.CoaddPsf` 

1276 The meta-psf constructed from all of the input catalogs. 

1277 """ 

1278 # CoaddPsf centroid not only must overlap image, but must overlap the 

1279 # part of image with data. Use centroid of region with data. 

1280 boolmask = template.mask.array & template.mask.getPlaneBitMask("NO_DATA") == 0 

1281 maskx = afwImage.makeMaskFromArray(boolmask.astype(afwImage.MaskPixel)) 

1282 centerCoord = afwGeom.SpanSet.fromMask(maskx, 1).computeCentroid() 

1283 

1284 ctrl = self.config.coaddPsf.makeControl() 

1285 coaddPsf = CoaddPsf( 

1286 catalog, wcs, centerCoord, ctrl.warpingKernelName, ctrl.cacheSize 

1287 ) 

1288 return coaddPsf 

1289 

1290 def _makeGaussianPsf(self, template): 

1291 """Return a Gaussian PSF with the width of the template CoaddPsf. 

1292 

1293 Parameters 

1294 ---------- 

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

1296 Template with the CoaddPsf to approximate. The width is measured 

1297 at the average position of the PSF, or averaged over a grid of 

1298 positions if the PSF cannot be evaluated there. 

1299 

1300 Returns 

1301 ------- 

1302 psf : `lsst.afw.detection.GaussianPsf` 

1303 Gaussian with the width of the template PSF. 

1304 

1305 Raises 

1306 ------ 

1307 ValueError 

1308 Raised if the PSF cannot be evaluated at any grid position. 

1309 """ 

1310 # The CoaddPsf is not defined where the template has no inputs, which 

1311 # can include its average position. 

1312 try: 

1313 fwhm = getPsfFwhm(template.psf) 

1314 except lsst.pex.exceptions.Exception: 

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

1316 # Catching: 

1317 # - lsst::geom::SingularTransformException 

1318 # - lsst.pex.exceptions.InvalidParameterError 

1319 # - lsst.pex.exceptions.RangeError 

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

1321 "Evaluating PSF on a grid of points.") 

1322 fwhm = evaluateMeanPsfFwhm(template, 

1323 fwhmExposureBuffer=self.config.fwhmExposureBuffer, 

1324 fwhmExposureGrid=self.config.fwhmExposureGrid) 

1325 sigma = fwhm/gaussian_sigma_to_fwhm 

1326 size = 2*int(np.ceil(_PSF_STAMP_SIGMAS*sigma)) + 1 

1327 self.metadata["templatePsfSigma"] = float(sigma) 

1328 self.log.info("Approximated the template PSF with a Gaussian of sigma %.3f pixels.", sigma) 

1329 return afwDetection.GaussianPsf(size, size, sigma) 

1330 

1331 

1332class GetDcrTemplateConnections( 

1333 GetTemplateConnections, 

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

1335 defaultTemplates={"coaddName": "dcr", "warpTypeSuffix": "", "fakesType": ""}, 

1336): 

1337 visitInfo = pipeBase.connectionTypes.Input( 

1338 doc="VisitInfo of calexp used to determine observing conditions.", 

1339 name="{fakesType}calexp.visitInfo", 

1340 storageClass="VisitInfo", 

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

1342 ) 

1343 dcrCoadds = pipeBase.connectionTypes.Input( 

1344 doc="Input DCR template to match and subtract from the exposure", 

1345 name="{fakesType}dcrCoadd{warpTypeSuffix}", 

1346 storageClass="ExposureF", 

1347 dimensions=("tract", "patch", "skymap", "band", "subfilter"), 

1348 multiple=True, 

1349 deferLoad=True, 

1350 ) 

1351 

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

1353 super().__init__(config=config) 

1354 self.inputs.remove("coaddExposures") 

1355 

1356 

1357class GetDcrTemplateConfig( 

1358 GetTemplateConfig, pipelineConnections=GetDcrTemplateConnections 

1359): 

1360 """Config for GetDcrTemplateTask. 

1361 

1362 Only ``output_image_type="legacy"`` is supported: the task always writes 

1363 an `lsst.afw.image.ExposureF`. 

1364 """ 

1365 

1366 numSubfilters = pexConfig.Field( 

1367 doc="Number of subfilters in the DcrCoadd.", 

1368 dtype=int, 

1369 default=3, 

1370 ) 

1371 effectiveWavelength = pexConfig.Field( 

1372 doc="Effective wavelength of the filter in nm.", 

1373 optional=False, 

1374 dtype=float, 

1375 ) 

1376 bandwidth = pexConfig.Field( 

1377 doc="Bandwidth of the physical filter.", 

1378 optional=False, 

1379 dtype=float, 

1380 ) 

1381 

1382 def validate(self): 

1383 if self.effectiveWavelength is None or self.bandwidth is None: 

1384 raise ValueError( 

1385 "The effective wavelength and bandwidth of the physical filter " 

1386 "must be set in the getTemplate config for DCR coadds. " 

1387 "Required until transmission curves are used in DM-13668." 

1388 ) 

1389 

1390 

1391class GetDcrTemplateTask(GetTemplateTask): 

1392 ConfigClass = GetDcrTemplateConfig 

1393 _DefaultName = "getDcrTemplate" 

1394 

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

1396 inputs = butlerQC.get(inputRefs) 

1397 detector = inputs.pop("detector") 

1398 bbox = detector.getBBox() 

1399 wcs = inputs.pop("wcs") 

1400 dcrCoaddExposureHandles = inputs.pop("dcrCoadds") 

1401 skymap = inputs.pop("skyMap") 

1402 visitInfo = inputs.pop("visitInfo") 

1403 # This task never converts its output, so the observation metadata 

1404 # that the conversion needs is dropped here. 

1405 inputs.pop("obs_info", None) 

1406 

1407 # This should not happen with a properly configured execution context. 

1408 assert not inputs, "runQuantum got more inputs than expected" 

1409 

1410 results = self.getExposures( 

1411 dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo 

1412 ) 

1413 physical_filter = butlerQC.quantum.dataId["physical_filter"] 

1414 outputs = self.run( 

1415 coaddExposureHandles=results.coaddExposures, 

1416 bbox=bbox, 

1417 wcs=wcs, 

1418 dataIds=results.dataIds, 

1419 physical_filter=physical_filter, 

1420 visit=outputRefs.template.dataId["visit"], 

1421 instrument=butlerQC.quantum.dataId["instrument"], 

1422 ) 

1423 butlerQC.put(outputs, outputRefs) 

1424 

1425 def getExposures(self, dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo): 

1426 """Return lists of coadds and their corresponding dataIds that overlap 

1427 the detector. 

1428 

1429 The spatial index in the registry has generous padding and often 

1430 supplies patches near, but not directly overlapping the detector. 

1431 Filters inputs so that we don't have to read in all input coadds. 

1432 

1433 Parameters 

1434 ---------- 

1435 dcrCoaddExposureHandles : `list` \ 

1436 [`lsst.daf.butler.DeferredDatasetHandle` of \ 

1437 `lsst.afw.image.Exposure`] 

1438 Data references to exposures that might overlap the detector. 

1439 bbox : `lsst.geom.Box2I` 

1440 Template Bounding box of the detector geometry onto which to 

1441 resample the coaddExposures. 

1442 skymap : `lsst.skymap.SkyMap` 

1443 Input definition of geometry/bbox and projection/wcs for 

1444 template exposures. 

1445 wcs : `lsst.afw.geom.SkyWcs` 

1446 Template WCS onto which to resample the coaddExposures. 

1447 visitInfo : `lsst.afw.image.VisitInfo` 

1448 Metadata for the science image. 

1449 

1450 Returns 

1451 ------- 

1452 result : `lsst.pipe.base.Struct` 

1453 A struct with attibutes: 

1454 

1455 ``coaddExposures`` 

1456 Dict of coadd exposures that overlap the projected bbox, 

1457 indexed on tract id 

1458 (`dict` [`int`, `list` [`lsst.afw.image.Exposure`] ]). 

1459 ``dataIds`` 

1460 Dict of data IDs of the coadd exposures that overlap the 

1461 projected bbox, indexed on tract id 

1462 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]). 

1463 

1464 Raises 

1465 ------ 

1466 pipeBase.NoWorkFound 

1467 Raised if no patches overlatp the input detector bbox. 

1468 """ 

1469 # Check that the patches actually overlap the detector 

1470 # Exposure's validPolygon would be more accurate 

1471 if wcs is None: 

1472 raise pipeBase.NoWorkFound("Exposure has no WCS; cannot create a template.") 

1473 

1474 detectorPolygon = geom.Box2D(bbox) 

1475 overlappingArea = 0 

1476 dataIds = collections.defaultdict(list) 

1477 patchList = dict() 

1478 for coaddRef in dcrCoaddExposureHandles: 

1479 dataId = coaddRef.dataId 

1480 subfilter = dataId["subfilter"] 

1481 patchWcs = skymap[dataId["tract"]].getWcs() 

1482 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox() 

1483 patchCorners = patchWcs.pixelToSky(geom.Box2D(patchBBox).getCorners()) 

1484 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners)) 

1485 if patchPolygon.intersection(detectorPolygon): 

1486 overlappingArea += patchPolygon.intersectionSingle( 

1487 detectorPolygon 

1488 ).calculateArea() 

1489 self.log.info( 

1490 "Using template input tract=%s, patch=%s, subfilter=%s" 

1491 % (dataId["tract"], dataId["patch"], dataId["subfilter"]) 

1492 ) 

1493 if dataId["tract"] in patchList: 

1494 patchList[dataId["tract"]].append(dataId["patch"]) 

1495 else: 

1496 patchList[dataId["tract"]] = [ 

1497 dataId["patch"], 

1498 ] 

1499 if subfilter == 0: 

1500 dataIds[dataId["tract"]].append(dataId) 

1501 

1502 if not overlappingArea: 

1503 raise pipeBase.NoWorkFound("No patches overlap detector") 

1504 

1505 self.checkPatchList(patchList) 

1506 

1507 coaddExposures = self.getDcrModel(patchList, dcrCoaddExposureHandles, visitInfo) 

1508 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds) 

1509 

1510 def checkPatchList(self, patchList): 

1511 """Check that all of the DcrModel subfilters are present for each 

1512 patch. 

1513 

1514 Parameters 

1515 ---------- 

1516 patchList : `dict` 

1517 Dict of the patches containing valid data for each tract. 

1518 

1519 Raises 

1520 ------ 

1521 RuntimeError 

1522 If the number of exposures found for a patch does not match the 

1523 number of subfilters. 

1524 """ 

1525 for tract in patchList: 

1526 for patch in set(patchList[tract]): 

1527 if patchList[tract].count(patch) != self.config.numSubfilters: 

1528 raise RuntimeError( 

1529 "Invalid number of DcrModel subfilters found: %d vs %d expected", 

1530 patchList[tract].count(patch), 

1531 self.config.numSubfilters, 

1532 ) 

1533 

1534 def getDcrModel(self, patchList, coaddRefs, visitInfo): 

1535 """Build DCR-matched coadds from a list of exposure references. 

1536 

1537 Parameters 

1538 ---------- 

1539 patchList : `dict` 

1540 Dict of the patches containing valid data for each tract. 

1541 coaddRefs : `list` [`lsst.daf.butler.DeferredDatasetHandle`] 

1542 Data references to `~lsst.afw.image.Exposure` representing 

1543 DcrModels that overlap the detector. 

1544 visitInfo : `lsst.afw.image.VisitInfo` 

1545 Metadata for the science image. 

1546 

1547 Returns 

1548 ------- 

1549 coaddExposures : `list` [`lsst.afw.image.Exposure`] 

1550 Coadd exposures that overlap the detector. 

1551 """ 

1552 coaddExposures = collections.defaultdict(list) 

1553 for tract in patchList: 

1554 for patch in set(patchList[tract]): 

1555 coaddRefList = [ 

1556 coaddRef 

1557 for coaddRef in coaddRefs 

1558 if _selectDataRef(coaddRef, tract, patch) 

1559 ] 

1560 

1561 dcrModel = DcrModel.fromQuantum( 

1562 coaddRefList, 

1563 self.config.effectiveWavelength, 

1564 self.config.bandwidth, 

1565 self.config.numSubfilters, 

1566 ) 

1567 coaddExposures[tract].append(dcrModel.buildMatchedExposureHandle(visitInfo=visitInfo)) 

1568 return coaddExposures 

1569 

1570 

1571def _selectDataRef(coaddRef, tract, patch): 

1572 condition = (coaddRef.dataId["tract"] == tract) & ( 

1573 coaddRef.dataId["patch"] == patch 

1574 ) 

1575 return condition