Coverage for tests/test_getTemplate.py: 95%

610 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 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 collections 

23import itertools 

24import unittest 

25import unittest.mock 

26 

27import astropy.units as u 

28from astropy.stats import gaussian_sigma_to_fwhm 

29import numpy as np 

30 

31import lsst.afw.cameraGeom.testUtils 

32import lsst.afw.detection 

33import lsst.afw.geom 

34import lsst.afw.image 

35import lsst.afw.math 

36import lsst.afw.table 

37from lsst.daf.butler import DataCoordinate, DatasetRef, DatasetType, DimensionUniverse 

38import lsst.geom 

39import lsst.images 

40import lsst.images.psfs 

41from lsst.images.tests import compare_masked_image_to_legacy 

42import lsst.ip.diffim 

43import lsst.meas.algorithms 

44import lsst.meas.base.tests 

45import lsst.pex.exceptions 

46import lsst.pipe.base as pipeBase 

47import lsst.pipe.base.testUtils 

48import lsst.skymap 

49import lsst.utils.tests 

50 

51from utils import generate_data_id, makeTestExposureRecord 

52 

53# Change this to True, `setup display_ds9`, and open ds9 (or use another afw 

54# display backend) to show the tract/patch layouts on the image. 

55debug = False 

56if debug: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 import lsst.afw.display 

58 display = lsst.afw.display.Display() 

59 display.frame = 1 

60 

61# Sum of squared warping kernel values along one axis, with the kernel 

62# normalized to unit sum at each sub-pixel phase, averaged over phase. 

63KERNEL_SUM_SQ_1D = { 

64 "bilinear": 2/3, 

65 "lanczos3": 0.8933699084, 

66 "lanczos4": 0.9141249535, 

67 "lanczos5": 0.9342245513, 

68} 

69 

70 

71def _showTemplate(box, template): 

72 """Show the corners of the template we made in this test.""" 

73 for point in box.getCorners(): 

74 display.dot("+", point.x, point.y, ctype="orange", size=40) 

75 display.frame = 2 

76 display.image(template, "warped template") 

77 display.frame = 3 

78 display.image(template.variance, "warped variance") 

79 

80 

81class GetTemplateTaskTestCase(lsst.utils.tests.TestCase): 

82 """Test that GetTemplateTask works on both one tract and multiple tract 

83 input coadd exposures. 

84 

85 Makes a synthetic exposure large enough to fit four small tracts with 2x2 

86 (300x300 pixel) patches each, extracts pixels for those patches by warping, 

87 and tests GetTemplateTask's output against boxes that overlap various 

88 combinations of one or multiple tracts. 

89 """ 

90 scale = 0.2 # arcsec/pixel 

91 # Use 5th order to minimize artifacts on the templates. 

92 coaddWarpKernel = "lanczos5" 

93 

94 @classmethod 

95 def setUpClass(cls): 

96 # Warping the patches is the slowest part of every test, so it is done 

97 # once. Tests must not modify the exposure; the patch handles return 

98 # copies, and setUp gives each test its own lists of them. 

99 cls.skymap = cls._makeSkymap() 

100 cls.patches = collections.defaultdict(list) 

101 cls.dataIds = collections.defaultdict(list) 

102 cls.exposure = cls._makeExposure() 

103 

104 if debug: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true

105 display.image(cls.exposure, "base exposure") 

106 

107 for tract_id in range(4): 

108 tract = cls.skymap.generateTract(tract_id) 

109 cls._makePatches(tract) 

110 

111 @classmethod 

112 def tearDownClass(cls): 

113 del cls.skymap, cls.patches, cls.dataIds, cls.exposure 

114 

115 def setUp(self): 

116 cls = type(self) 

117 self.patches = collections.defaultdict(list, {k: list(v) for k, v in cls.patches.items()}) 

118 self.dataIds = collections.defaultdict(list, {k: list(v) for k, v in cls.dataIds.items()}) 

119 self.coaddRefs = {} 

120 self.varianceBox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

121 

122 @classmethod 

123 def _makeSkymap(cls): 

124 """Make a Skymap with 4 tracts with 4 patches each. 

125 """ 

126 tractScale = 0.02 # degrees 

127 # On-sky coordinates of the tract centers. 

128 coords = [(0, 0), 

129 (0, tractScale), 

130 (tractScale, 0), 

131 (tractScale, tractScale), 

132 ] 

133 config = lsst.skymap.DiscreteSkyMap.ConfigClass() 

134 config.raList = [c[0] for c in coords] 

135 config.decList = [c[1] for c in coords] 

136 # Half the tract center step size, to keep the tract overlap small. 

137 config.radiusList = [tractScale/2 for c in coords] 

138 config.projection = "TAN" 

139 config.pixelScale = cls.scale 

140 config.tractOverlap = 0.0005 

141 config.tractBuilder = "legacy" 

142 config.tractBuilder["legacy"].patchInnerDimensions = (300, 300) 

143 config.tractBuilder["legacy"].patchBorder = 10 

144 return lsst.skymap.DiscreteSkyMap(config=config) 

145 

146 @classmethod 

147 def _makeExposure(cls): 

148 """Create a large image to break up into tracts and patches. 

149 

150 The image will have a source every 100 pixels in x and y, and a WCS 

151 that results in the tracts all fitting in the image, with tract=0 

152 in the lower left, tract=1 to the right, tract=2 above, and tract=3 

153 to the upper right. 

154 """ 

155 box = lsst.geom.Box2I(lsst.geom.Point2I(-200, -200), lsst.geom.Point2I(800, 800)) 

156 # This WCS was constructed so that tract 0 mostly fills the lower left 

157 # quadrant of the image, and the other tracts fill the rest; slight 

158 # extra rotation as a check on the final warp layout, scaled by 5% 

159 # from the patch pixel scale. 

160 cd_matrix = lsst.afw.geom.makeCdMatrix(1.05*cls.scale*lsst.geom.arcseconds, 93*lsst.geom.degrees) 

161 wcs = lsst.afw.geom.makeSkyWcs(lsst.geom.Point2D(120, 150), 

162 lsst.geom.SpherePoint(0, 0, lsst.geom.radians), 

163 cd_matrix) 

164 dataset = lsst.meas.base.tests.TestDataset(box, wcs=wcs) 

165 for x, y in itertools.product(np.arange(0, 500, 100), np.arange(0, 500, 100)): 

166 dataset.addSource(1e5, lsst.geom.Point2D(x, y)) 

167 exposure, _ = dataset.realize(2, dataset.makeMinimalSchema()) 

168 exposure.setFilter(lsst.afw.image.FilterLabel("a", "a_test")) 

169 return exposure 

170 

171 @classmethod 

172 def _makePatches(cls, tract): 

173 """Populate the patches and dataId dicts, keyed on tract id, with the 

174 warps of the main exposure and minimal dataIds, respectively. 

175 """ 

176 if debug: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 color = ['red', 'green', 'cyan', 'yellow'][tract.tract_id] 

178 point = cls.exposure.wcs.skyToPixel(tract.ctr_coord) 

179 # Show the tract center, colored by tract id. 

180 display.dot("x", point.x, point.y, ctype=color, size=30) 

181 

182 config = lsst.afw.math.Warper.ConfigClass() 

183 config.warpingKernelName = cls.coaddWarpKernel 

184 warper = lsst.afw.math.Warper.fromConfig(config) 

185 for patchId in range(tract.num_patches.x*tract.num_patches.y): 

186 patch = tract.getPatchInfo(patchId) 

187 box = patch.getOuterBBox() 

188 

189 if debug: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was never true

190 # Show the patch corners as patch ids, colored by tract id. 

191 points = cls.exposure.wcs.skyToPixel(patch.wcs.pixelToSky([lsst.geom.Point2D(x) 

192 for x in box.getCorners()])) 

193 for p in points: 

194 display.dot(patchId, p.x, p.y, ctype=color) 

195 

196 # This is mostly taken from drp_tasks makePsfMatchedWarp, but 

197 # ip_diffim cannot depend on drp_tasks. 

198 xyTransform = lsst.afw.geom.makeWcsPairTransform(cls.exposure.wcs, patch.wcs) 

199 warpedPsf = lsst.meas.algorithms.WarpedPsf(cls.exposure.psf, xyTransform) 

200 warped = warper.warpExposure(patch.wcs, cls.exposure, destBBox=box) 

201 warped.setPsf(warpedPsf) 

202 

203 warped.getInfo().setCoaddInputs( 

204 cls._makeCoaddInputs([(cls.exposure.wcs, cls.exposure.getBBox())])) 

205 dataRef = pipeBase.InMemoryDatasetHandle( 

206 warped, 

207 storageClass="ExposureF", 

208 copy=True, 

209 dataId=generate_data_id( 

210 tract=tract, 

211 patch=patch, 

212 ) 

213 ) 

214 cls.patches[tract.tract_id].append(dataRef) 

215 dataCoordinate = DataCoordinate.standardize({"tract": tract.tract_id, 

216 "patch": patchId, 

217 "band": "a", 

218 "skymap": "skymap"}, 

219 universe=DimensionUniverse()) 

220 cls.dataIds[tract.tract_id].append(dataCoordinate) 

221 

222 def _checkMetadata(self, template, config, box, wcs, nPsfs): 

223 """Check that the various metadata components were set correctly. 

224 """ 

225 expectedBox = lsst.geom.Box2I(box) 

226 expectedBox.grow(config.templateBorderSize) 

227 self.assertEqual(template.getBBox(), expectedBox) 

228 # WCS should match our exposure, not any of the coadd tracts. 

229 for tract in self.patches: 

230 self.assertNotEqual(template.wcs, self.patches[tract][0].get().wcs) 

231 self.assertEqual(template.wcs, self.exposure.wcs) 

232 # The template pixels are calibrated to nJy. 

233 self.assertEqual(template.photoCalib, lsst.afw.image.PhotoCalib(1.0)) 

234 self.assertEqual(template.getXY0(), expectedBox.getMin()) 

235 self.assertEqual(template.filter.bandLabel, "a") 

236 self.assertEqual(template.filter.physicalLabel, "a_test") 

237 # The template PSF is a Gaussian with the width of the CoaddPsf of 

238 # its inputs. 

239 self.assertIsInstance(template.psf, lsst.afw.detection.GaussianPsf) 

240 coaddPsf = self._makeCoaddPsf(template, config) 

241 self.assertEqual(coaddPsf.getComponentCount(), nPsfs) 

242 position = coaddPsf.getAveragePosition() 

243 self.assertFloatsAlmostEqual(template.psf.getSigma(), 

244 coaddPsf.computeShape(position).getTraceRadius()) 

245 self.assertTrue(template.getInfo().hasCoaddInputs()) 

246 self.assertEqual(len(template.getInfo().getCoaddInputs().ccds), nPsfs) 

247 

248 def _makeConfig(self): 

249 """Make a GetTemplateTask config whose coaddWarpKernel is the kernel 

250 the test patches were made with. 

251 """ 

252 config = lsst.ip.diffim.GetTemplateTask.ConfigClass() 

253 config.coaddWarpKernel = self.coaddWarpKernel 

254 return config 

255 

256 def _makeCoaddPsf(self, template, config): 

257 """Return the CoaddPsf that ``run`` approximated with a Gaussian, 

258 rebuilt from the coadd inputs recorded on the template. 

259 """ 

260 task = lsst.ip.diffim.GetTemplateTask(config=config) 

261 return task._makePsf(template, template.getInfo().getCoaddInputs().ccds, template.wcs) 

262 

263 def _checkPixels(self, template, config, box): 

264 """Check that the pixel values in the template are close to the 

265 original image, calibrated to nJy. 

266 """ 

267 # All pixels should have real values! 

268 expectedBox = lsst.geom.Box2I(box) 

269 expectedBox.grow(config.templateBorderSize) 

270 calibration = self.exposure.photoCalib.getCalibrationMean() 

271 expected = self.exposure.photoCalib.calibrateImage(self.exposure[expectedBox].maskedImage) 

272 

273 if debug: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 _showTemplate(expectedBox, template) 

275 

276 # Check that we fully filled the template from the patches. 

277 self.assertTrue(np.all(np.isfinite(template.image.array))) 

278 # Because of the scale changes, there will be some ringing in the 

279 # difference between the template and the original image; pick 

280 # tolerances large enough to account for that. 

281 self.assertImagesAlmostEqual(template.image, expected.image, rtol=.1, atol=4*calibration) 

282 # Variance plane ==4 in the original image (realize() takes a noise 

283 # sigma). Warping sets the level from the pixel areas and the two 

284 # warping kernels, which `_correctVariance` corrects. A per-pixel 

285 # ripple that no scalar correction can remove remains on top of that, 

286 # so check the level tightly and allow for the ripple around it. 

287 variance = template.variance.array[np.isfinite(template.variance.array)] 

288 median = np.median(variance) 

289 self.assertFloatsAlmostEqual(median, np.median(expected.variance.array), 

290 rtol=0.02, msg="variance level differs") 

291 self.assertLess(np.percentile(variance, 99)/median, 1.6, msg="variance ripple too large") 

292 self.assertGreater(np.percentile(variance, 1)/median, 0.6, msg="variance ripple too large") 

293 # Not checking the mask, as warping changes the sizes of the masks. 

294 

295 def testRunOneTractInput(self): 

296 """Test a bounding box that fully fits inside one tract, with only 

297 that tract passed as input. This checks that the code handles a single 

298 tract input correctly. 

299 """ 

300 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

301 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

302 # Restrict to tract 0, since the box fits in just that tract. 

303 result = task.run(coaddExposureHandles={0: self.patches[0]}, 

304 bbox=box, 

305 wcs=self.exposure.wcs, 

306 dataIds={0: self.dataIds[0]}, 

307 physical_filter="a_test") 

308 

309 # All 4 patches from tract 0 are included in this template. 

310 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 4) 

311 self._checkPixels(result.template, task.config, box) 

312 

313 def testRunOneTractMultipleInputs(self): 

314 """Test a bounding box that fully fits inside one tract but where 

315 multiple tracts were passed in. This checks that patches that are 

316 mostly NaN after warping are merged correctly in the output. 

317 """ 

318 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

319 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

320 result = task.run(coaddExposureHandles=self.patches, 

321 bbox=box, 

322 wcs=self.exposure.wcs, 

323 dataIds=self.dataIds, 

324 physical_filter="a_test") 

325 

326 # All 4 patches from two tracts are included in this template. 

327 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 6) 

328 self._checkPixels(result.template, task.config, box) 

329 

330 def testRunTwoTracts(self): 

331 """Test a bounding box that crosses tract boundaries. 

332 """ 

333 box = lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600)) 

334 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

335 result = task.run(coaddExposureHandles=self.patches, 

336 bbox=box, 

337 wcs=self.exposure.wcs, 

338 dataIds=self.dataIds, 

339 physical_filter="a_test") 

340 

341 # All 4 patches from all 4 tracts are included in this template 

342 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 9) 

343 self._checkPixels(result.template, task.config, box) 

344 

345 def testRunCalibratesToNanojansky(self): 

346 """Test that the template is in nJy whatever the calibration of the 

347 coadds, and that coadds already in nJy are not rescaled. 

348 """ 

349 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

350 calibration = self.exposure.photoCalib.getCalibrationMean() 

351 self.assertNotEqual(calibration, 1.0) 

352 # The same coadd pixels, labelled as already in nJy. 

353 uncalibrated = [] 

354 for handle in self.patches[0]: 

355 coadd = handle.get() 

356 coadd.setPhotoCalib(lsst.afw.image.PhotoCalib(1.0)) 

357 uncalibrated.append(pipeBase.InMemoryDatasetHandle(coadd, storageClass="ExposureF", copy=True, 

358 dataId=handle.dataId)) 

359 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

360 templates = {} 

361 for key, handles in ((calibration, self.patches[0]), (1.0, uncalibrated)): 

362 result = task.run(coaddExposureHandles={0: handles}, 

363 bbox=box, 

364 wcs=self.exposure.wcs, 

365 dataIds={0: self.dataIds[0]}, 

366 physical_filter="a_test") 

367 self.assertEqual(result.template.photoCalib, lsst.afw.image.PhotoCalib(1.0)) 

368 templates[key] = result.template 

369 

370 self.assertFloatsAlmostEqual(templates[calibration].image.array, 

371 calibration*templates[1.0].image.array, rtol=1e-6) 

372 self.assertFloatsAlmostEqual(templates[calibration].variance.array, 

373 calibration**2*templates[1.0].variance.array, rtol=1e-6) 

374 

375 def testGaussianPsfGridFallback(self): 

376 """If the CoaddPsf cannot be evaluated at its average position, the 

377 Gaussian width is averaged over a grid of positions instead. 

378 """ 

379 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

380 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

381 result = task.run(coaddExposureHandles={0: self.patches[0]}, 

382 bbox=box, 

383 wcs=self.exposure.wcs, 

384 dataIds={0: self.dataIds[0]}, 

385 physical_filter="a_test") 

386 template = result.template 

387 template.setPsf(self._makeCoaddPsf(template, task.config)) 

388 expected = lsst.ip.diffim.utils.evaluateMeanPsfFwhm( 

389 template, 

390 fwhmExposureBuffer=task.config.fwhmExposureBuffer, 

391 fwhmExposureGrid=task.config.fwhmExposureGrid, 

392 )/gaussian_sigma_to_fwhm 

393 

394 error = lsst.pex.exceptions.InvalidParameterError("No inputs at the average position.") 

395 with unittest.mock.patch("lsst.ip.diffim.getTemplate.getPsfFwhm", side_effect=error): 

396 with self.assertLogs(task.log.name, level="INFO") as cm: 

397 psf = task._makeGaussianPsf(template) 

398 

399 self.assertIn("grid of points", "\n".join(cm.output)) 

400 self.assertIsInstance(psf, lsst.afw.detection.GaussianPsf) 

401 self.assertFloatsAlmostEqual(psf.getSigma(), expected) 

402 self.assertFloatsAlmostEqual(task.metadata["templatePsfSigma"], expected) 

403 

404 def testRunNoTemplate(self): 

405 """A bounding box that doesn't overlap the patches will raise. 

406 """ 

407 box = lsst.geom.Box2I(lsst.geom.Point2I(1200, 1200), lsst.geom.Point2I(1600, 1600)) 

408 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

409 with self.assertRaisesRegex(lsst.pipe.base.NoWorkFound, "No patches found"): 

410 task.run(coaddExposureHandles=self.patches, 

411 bbox=box, 

412 wcs=self.exposure.wcs, 

413 dataIds=self.dataIds, 

414 physical_filter="a_test") 

415 

416 def testMissingPatches(self): 

417 """Test that a missing patch results in an appropriate mask. 

418 

419 This fixes the bug reported on DM-44997 (image and variance were NaN 

420 but the mask was not set to NO_DATA for those pixels). 

421 """ 

422 # tract=0, patch=1 is the lower-left corner, as displayed in DS9. 

423 self.patches[0].pop(1) 

424 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

425 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

426 result = task.run(coaddExposureHandles=self.patches, 

427 bbox=box, 

428 wcs=self.exposure.wcs, 

429 dataIds=self.dataIds, 

430 physical_filter="a_test") 

431 no_data = (result.template.mask.array & result.template.mask.getPlaneBitMask("NO_DATA")) != 0 

432 self.assertTrue(np.isfinite(result.template.image.array).all()) 

433 self.assertTrue(np.isfinite(result.template.variance.array).all()) 

434 self.assertEqual(no_data.sum(), 20990) 

435 

436 @lsst.utils.tests.methodParameters( 

437 box=[ 

438 lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)), 

439 lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600)), 

440 ], 

441 nInput=[8, 16], 

442 ) 

443 def testNanInputs(self, box=None, nInput=None): 

444 """Test that the template has finite values when some of the input 

445 pixels have NaN as variance. 

446 """ 

447 for tract, patchRefs in self.patches.items(): 

448 for patchRef in patchRefs: 

449 patchCoadd = patchRef.get() 

450 bbox = lsst.geom.Box2I() 

451 bbox.include(lsst.geom.Point2I(patchCoadd.getBBox().getCenter())) 

452 bbox.grow(3) 

453 patchCoadd.variance[bbox].array *= np.nan 

454 

455 box = lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600)) 

456 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

457 result = task.run(coaddExposureHandles=self.patches, 

458 bbox=box, 

459 wcs=self.exposure.wcs, 

460 dataIds=self.dataIds, 

461 physical_filter="a_test") 

462 if debug: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 _showTemplate(box, result.template) 

464 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 9) 

465 # We just check that the pixel values are all finite. We cannot check that pixel values 

466 # in the template are closer to the original anymore. 

467 self.assertTrue(np.isfinite(result.template.image.array).all()) 

468 

469 def _runCorrection(self, doCorrectVarianceWarpCorrelation=False, doScaleVariance=False, 

470 handles=None, instrument="SciCam", coaddWarpKernel=None, 

471 templateWarpKernel="lanczos3"): 

472 """Run the task on tract 0, with a science image from ``instrument``. 

473 

474 The plate scale correction is applied only when ``instrument`` is not 

475 the instrument of the template coadds, which is ``"TestCam"`` unless 

476 ``handles`` says otherwise. ``coaddWarpKernel`` defaults to the kernel 

477 the patches were made with, so the reconstructed factors are exact. 

478 """ 

479 config = self._makeConfig() 

480 config.doCorrectVarianceWarpCorrelation = doCorrectVarianceWarpCorrelation 

481 config.doScaleVariance = doScaleVariance 

482 if coaddWarpKernel is not None: 

483 config.coaddWarpKernel = coaddWarpKernel 

484 config.warp.warpingKernelName = templateWarpKernel 

485 task = lsst.ip.diffim.GetTemplateTask(config=config) 

486 result = task.run(coaddExposureHandles={0: handles or self.patches[0]}, 

487 bbox=lsst.geom.Box2I(self.varianceBox), 

488 wcs=self.exposure.wcs, 

489 dataIds={0: self.dataIds[0]}, 

490 physical_filter="a_test", 

491 instrument=instrument) 

492 return task, result.template 

493 

494 def _makeScaledWcs(self, factor): 

495 """Make a WCS like the base exposure's, but with its pixel scale 

496 multiplied by ``factor``. 

497 """ 

498 cdMatrix = lsst.afw.geom.makeCdMatrix(factor*1.05*self.scale*lsst.geom.arcseconds, 

499 93*lsst.geom.degrees) 

500 return lsst.afw.geom.makeSkyWcs(lsst.geom.Point2D(120, 150), 

501 lsst.geom.SpherePoint(0, 0, lsst.geom.radians), 

502 cdMatrix) 

503 

504 @staticmethod 

505 def _makeCoaddInputs(records, instrument="TestCam"): 

506 """Make a CoaddInputs holding the given input records. 

507 

508 Parameters 

509 ---------- 

510 records : `list` [`tuple` [`lsst.afw.geom.SkyWcs` or `None`, \ 

511 `lsst.geom.Box2I`]] 

512 The WCS and bbox to record for each input. A `None` WCS makes a 

513 record that `_plateScaleFactor` has to skip. May be empty, to 

514 simulate a coadd whose plate scale cannot be reconstructed. 

515 instrument : `str` or `None`, optional 

516 Instrument label to record for every input. `None` leaves the 

517 inputs without a visit info, and an empty string gives them a 

518 visit info with no label; either way the instrument is unknown. 

519 """ 

520 ccdSchema = lsst.afw.table.ExposureTable.makeMinimalSchema() 

521 weightKey = ccdSchema.addField("weight", type=float, doc="Coadd weight") 

522 coaddInputs = lsst.afw.image.CoaddInputs( 

523 lsst.afw.table.ExposureTable.makeMinimalSchema(), ccdSchema) 

524 for wcs, bbox in records: 

525 record = coaddInputs.ccds.addNew() 

526 record.setWcs(wcs) 

527 record.setBBox(bbox) 

528 if instrument is not None: 

529 record.setVisitInfo(lsst.afw.image.VisitInfo(instrumentLabel=instrument)) 

530 # Included because real coadds have it, though a single-record 

531 # correction does not use it. 

532 record.set(weightKey, 1.0) 

533 return coaddInputs 

534 

535 def _patchHandles(self, tract, records, instrument="TestCam"): 

536 """Return handles for a tract's patches, with their CoaddInputs 

537 replaced by ``records`` from ``instrument``. 

538 """ 

539 handles = [] 

540 for ref in self.patches[tract]: 

541 coadd = ref.get() 

542 coadd.getInfo().setCoaddInputs(self._makeCoaddInputs(records, instrument=instrument)) 

543 handles.append(pipeBase.InMemoryDatasetHandle( 

544 coadd, storageClass="ExposureF", copy=True, dataId=ref.dataId)) 

545 return handles 

546 

547 def testCorrectVarianceWarpCorrelation(self): 

548 """The correlation correction covers both warps: the one that built 

549 the coadds and the one this task performs. 

550 """ 

551 _, off = self._runCorrection(instrument="TestCam") 

552 for coaddKernel, templateKernel in (("lanczos3", "lanczos3"), 

553 ("lanczos5", "lanczos3"), 

554 ("lanczos5", "lanczos5"), 

555 ("bilinear", "lanczos3")): 

556 with self.subTest(coaddWarpKernel=coaddKernel, templateWarpKernel=templateKernel): 

557 task, on = self._runCorrection(doCorrectVarianceWarpCorrelation=True, 

558 instrument="TestCam", 

559 coaddWarpKernel=coaddKernel, 

560 templateWarpKernel=templateKernel) 

561 factor = task.metadata["varianceWarpCorrelationFactor"] 

562 # A kernel spreads power, so this always makes the plane 

563 # larger. 

564 self.assertGreater(factor, 1.0) 

565 # The phase average converges slowly for bilinear, because 

566 # its sum of squares has a kink where the phase wraps. 

567 rtol = 1e-4 if "bilinear" in (coaddKernel, templateKernel) else 1e-5 

568 self.assertFloatsAlmostEqual( 

569 factor, 

570 1/(KERNEL_SUM_SQ_1D[coaddKernel]*KERNEL_SUM_SQ_1D[templateKernel])**2, 

571 rtol=rtol) 

572 self.assertEqual(task.metadata["variancePlateScaleFactor"], 1.0) 

573 if templateKernel == "lanczos3": 

574 # A pure scalar, with no per-pixel structure of its own; 

575 # only comparable when `off` used the same template warp. 

576 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*factor, 

577 rtol=1e-5, ignoreNaNs=True) 

578 

579 def testCorrectVariancePlateScale(self): 

580 """The plate scale correction is the total pixel area change from the 

581 images the coadds were built from to the science image. 

582 """ 

583 # The science image and the coadds are from the same instrument, so 

584 # the factor is 1 without reading any coadd inputs. 

585 task, off = self._runCorrection(instrument="TestCam") 

586 self.assertEqual(task.metadata["variancePlateScaleFactor"], 1.0) 

587 self.assertNotIn("templateVarianceCorrectionFactor", task.metadata) 

588 

589 # The fixture's records are the science image itself, so there is no 

590 # net change in pixel area even when the instruments differ. 

591 task, on = self._runCorrection() 

592 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.0, rtol=1e-6) 

593 self.assertNotIn("varianceWarpCorrelationFactor", task.metadata) 

594 

595 # Coarser original pixels than science pixels, the DECam-template 

596 # case: the correction is the ratio of their areas. 

597 for scaleFactor in (1.315, 0.5): 

598 with self.subTest(scaleFactor=scaleFactor): 

599 handles = self._patchHandles( 

600 0, [(self._makeScaledWcs(scaleFactor), self.exposure.getBBox())]) 

601 task, on = self._runCorrection(handles=handles) 

602 factor = task.metadata["variancePlateScaleFactor"] 

603 self.assertFloatsAlmostEqual(factor, scaleFactor**2, rtol=1e-6) 

604 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*factor, 

605 rtol=1e-5, ignoreNaNs=True) 

606 

607 def testVarianceCorrectionsCompose(self): 

608 """The two corrections are independent and multiply together. 

609 

610 The patches are warped from the exposure and the template is warped 

611 back to its pixel grid, so the two warps cancel geometrically, leaving 

612 only the two kernels' correlation to undo. The substituted coadd 

613 inputs claim coarser original pixels than the exposure really has, so 

614 with both corrections applied the variance must come back to its 

615 original value times the claimed change in pixel area. 

616 """ 

617 plateScale = 1.315 

618 handles = self._patchHandles(0, [(self._makeScaledWcs(plateScale), self.exposure.getBBox())]) 

619 # A science image from the coadds' instrument applies no correction 

620 # at all; one from another instrument applies both. 

621 _, off = self._runCorrection(handles=handles, instrument="TestCam") 

622 task, on = self._runCorrection(doCorrectVarianceWarpCorrelation=True, handles=handles) 

623 

624 # Both together is exactly the product of the two factors. 

625 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], plateScale**2, rtol=1e-6) 

626 scale = (task.metadata["varianceWarpCorrelationFactor"] 

627 * task.metadata["variancePlateScaleFactor"]) 

628 self.assertFloatsAlmostEqual(task.metadata["templateVarianceCorrectionFactor"], 

629 scale, rtol=1e-12) 

630 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*scale, 

631 rtol=1e-5, ignoreNaNs=True) 

632 

633 # Only the variance plane is touched. Nothing about the correction 

634 # feeds back into how the patches are combined. 

635 self.assertImagesEqual(on.image, off.image) 

636 self.assertMasksEqual(on.mask, off.mask) 

637 

638 # realize() takes a noise sigma, so the original variance is 4, not 2, 

639 # before calibrating to nJy. 

640 calibration = self.exposure.photoCalib.getCalibrationMean() 

641 original = calibration**2*np.median(self.exposure.variance.array) 

642 self.assertFloatsAlmostEqual(np.median(on.variance.array), original*plateScale**2, rtol=0.02) 

643 

644 # Which is a real correction: uncorrected, the plane is too small by 

645 # the two warping kernels' phase-averaged sums of squares. 

646 self.assertFloatsAlmostEqual( 

647 np.median(off.variance.array)/original, 

648 (KERNEL_SUM_SQ_1D[self.coaddWarpKernel]*KERNEL_SUM_SQ_1D["lanczos3"])**2, rtol=0.02) 

649 

650 def testCorrectVariancePlateScaleUsesOneRecord(self): 

651 """Only the first usable coadd input is read. 

652 

653 The spread between records is just the local pixel scale, a few 

654 tenths of a percent across a real focal plane, so one stands for all 

655 of them. 

656 """ 

657 handles = self._patchHandles( 

658 0, [(None, self.exposure.getBBox()), 

659 (self._makeScaledWcs(2.0), self.exposure.getBBox()), 

660 (self.exposure.wcs, self.exposure.getBBox())]) 

661 task, _ = self._runCorrection(handles=handles) 

662 

663 # The first record with a WCS: the one without is skipped, and the 

664 # last (which would give 1.0) is never reached. 

665 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 4.0, rtol=1e-6) 

666 

667 def testCorrectVariancePlateScaleSkipsDroppedCoadd(self): 

668 """The plate scale comes from the first coadd that is used, not from 

669 one dropped for its instrument. 

670 """ 

671 bbox = self.exposure.getBBox() 

672 handles = (self._patchHandles(0, [(self._makeScaledWcs(2.0), bbox)], instrument="OtherCam")[:1] 

673 + self._patchHandles(0, [(self._makeScaledWcs(1.315), bbox)])[1:]) 

674 task, _ = self._runCorrection(handles=handles) 

675 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.315**2, rtol=1e-6) 

676 

677 def testCorrectVariancePlateScaleNextTract(self): 

678 """If the first coadd kept in a tract cannot give the plate scale, the 

679 first coadd kept in the next tract is used. 

680 """ 

681 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

682 handles = {0: self._patchHandles(0, []), 

683 1: self._patchHandles(1, [(self._makeScaledWcs(1.315), self.exposure.getBBox())])} 

684 task.run(coaddExposureHandles=handles, 

685 bbox=lsst.geom.Box2I(self.varianceBox), 

686 wcs=self.exposure.wcs, 

687 dataIds={0: self.dataIds[0], 1: self.dataIds[1]}, 

688 physical_filter="a_test", 

689 instrument="SciCam") 

690 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.315**2, rtol=1e-6) 

691 

692 def testCorrectVariancePlateScaleNeedsCoaddInputs(self): 

693 """Without usable coadd inputs the plate scale cannot be 

694 reconstructed, so the template cannot be built. 

695 """ 

696 handles = self._patchHandles(0, []) 

697 with self.assertRaisesRegex(RuntimeError, "cannot be corrected for the change in pixel area"): 

698 self._runCorrection(handles=handles) 

699 

700 def _runTract0(self, handles): 

701 """Build a template from the given tract 0 patch handles. 

702 """ 

703 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

704 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

705 return task.run(coaddExposureHandles={0: handles}, 

706 bbox=box, 

707 wcs=self.exposure.wcs, 

708 dataIds={0: self.dataIds[0]}, 

709 physical_filter="a_test") 

710 

711 def testRunRecordsInstrument(self): 

712 """The instrument of each coadd is recorded with the template's coadd 

713 inputs. 

714 """ 

715 result = self._runTract0(self.patches[0]) 

716 ccds = result.template.getInfo().getCoaddInputs().ccds 

717 self.assertEqual(len(ccds), 4) 

718 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"}) 

719 

720 def testRunUnknownInstrument(self): 

721 """A coadd whose inputs record no instrument is dropped when the 

722 other coadds record one, and kept, with the template's instrument 

723 ``"UNKNOWN"``, when none of them records one. 

724 """ 

725 records = [(self.exposure.wcs, self.exposure.getBBox())] 

726 # Inputs with no visit info, and with a visit info but no label. 

727 for unknown in (None, ""): 

728 with self.subTest(label=unknown): 

729 handles = (self._patchHandles(0, records, instrument=unknown)[:1] 

730 + self._patchHandles(0, records)[1:]) 

731 ccds, dropped = self._runLogged({0: handles}) 

732 self.assertEqual(len(ccds), 3) 

733 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"}) 

734 self.assertEqual(len(dropped), 1) 

735 self.assertIn("made from UNKNOWN", dropped[0]) 

736 

737 # No coadd records an instrument. 

738 result = self._runTract0(self._patchHandles(0, records, instrument=unknown)) 

739 ccds = result.template.getInfo().getCoaddInputs().ccds 

740 self.assertEqual(len(ccds), 4) 

741 self.assertEqual({record["instrument"] for record in ccds}, {"UNKNOWN"}) 

742 

743 def _runLogged(self, handles, dataIds=None, box=None, instrument=None): 

744 """Build a template, capturing the messages about dropped coadds. 

745 

746 Parameters 

747 ---------- 

748 handles : `dict` [`int`, `list`] 

749 Patch handles, keyed by tract. 

750 dataIds : `dict` [`int`, `list`], optional 

751 Data ids matching ``handles``; defaults to the fixture's. 

752 box : `lsst.geom.Box2I`, optional 

753 Box to build the template on; defaults to one in tract 0. 

754 instrument : `str`, optional 

755 Instrument of the science image. 

756 

757 Returns 

758 ------- 

759 ccds : `lsst.afw.table.ExposureCatalog` 

760 The coadd inputs recorded on the template. 

761 dropped : `list` [`str`] 

762 The info messages about dropped coadds. 

763 """ 

764 if dataIds is None: 

765 dataIds = {tract: self.dataIds[tract] for tract in handles} 

766 if box is None: 

767 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

768 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig()) 

769 with self.assertLogs(task.log.name, level="INFO") as cm: 

770 result = task.run(coaddExposureHandles=handles, 

771 bbox=box, 

772 wcs=self.exposure.wcs, 

773 dataIds=dataIds, 

774 physical_filter="a_test", 

775 instrument=instrument) 

776 dropped = [record for record in cm.records 

777 if record.getMessage().startswith("Dropping template coadd")] 

778 self.assertEqual({record.levelname for record in dropped}, {"INFO"} if dropped else set()) 

779 return result.template.getInfo().getCoaddInputs().ccds, [record.getMessage() for record in dropped] 

780 

781 def testRunDropsMinorityInstrument(self): 

782 """Coadds made from an instrument other than the one most coadds were 

783 made from are dropped. 

784 """ 

785 records = [(self.exposure.wcs, self.exposure.getBBox())] 

786 handles = (self._patchHandles(0, records)[:3] 

787 + self._patchHandles(0, records, instrument="OtherCam")[3:]) 

788 

789 ccds, dropped = self._runLogged({0: handles}) 

790 self.assertEqual({record["patch"] for record in ccds}, {0, 1, 2}) 

791 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"}) 

792 self.assertEqual(len(dropped), 1) 

793 self.assertIn(str(self.dataIds[0][3]), dropped[0]) 

794 self.assertIn("made from OtherCam", dropped[0]) 

795 

796 def testRunPrefersScienceInstrument(self): 

797 """Coadds made from the science image's instrument are used even when 

798 they are in the minority; otherwise the majority instrument is used. 

799 """ 

800 records = [(self.exposure.wcs, self.exposure.getBBox())] 

801 handles = (self._patchHandles(0, records, instrument="OtherCam")[:3] 

802 + self._patchHandles(0, records)[3:]) 

803 

804 ccds, dropped = self._runLogged({0: handles}, instrument="TestCam") 

805 self.assertEqual({record["patch"] for record in ccds}, {3}) 

806 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"}) 

807 self.assertEqual(len(dropped), 3) 

808 self.assertTrue(all("made from OtherCam" in message for message in dropped)) 

809 

810 # No coadd was made from the science image's instrument. 

811 ccds, dropped = self._runLogged({0: handles}, instrument="SciCam") 

812 self.assertEqual({record["patch"] for record in ccds}, {0, 1, 2}) 

813 self.assertEqual({record["instrument"] for record in ccds}, {"OtherCam"}) 

814 self.assertEqual(len(dropped), 1) 

815 

816 def testRunInstrumentTie(self): 

817 """A tie goes to the instrument whose name sorts first. 

818 """ 

819 records = [(self.exposure.wcs, self.exposure.getBBox())] 

820 handles = (self._patchHandles(0, records)[:2] 

821 + self._patchHandles(0, records, instrument="OtherCam")[2:]) 

822 

823 ccds, dropped = self._runLogged({0: handles}) 

824 self.assertEqual({record["patch"] for record in ccds}, {2, 3}) 

825 self.assertEqual({record["instrument"] for record in ccds}, {"OtherCam"}) 

826 self.assertEqual(len(dropped), 2) 

827 

828 def testRunDropsNonOverlappingCoadd(self): 

829 """A coadd from another instrument is dropped even if it does not 

830 overlap the template. 

831 """ 

832 records = [(self.exposure.wcs, self.exposure.getBBox())] 

833 # Tract 3 does not overlap a box in the lower left of tract 0. 

834 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(50, 50)) 

835 handles = {0: self.patches[0], 3: self._patchHandles(3, records, instrument="OtherCam")[:1]} 

836 dataIds = {0: self.dataIds[0], 3: self.dataIds[3][:1]} 

837 

838 ccds, dropped = self._runLogged(handles, dataIds=dataIds, box=box) 

839 self.assertEqual({record["tract"] for record in ccds}, {0}) 

840 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"}) 

841 self.assertEqual(len(dropped), 1) 

842 self.assertIn(str(self.dataIds[3][0]), dropped[0]) 

843 

844 def _scaleInputVariance(self, tract, factor): 

845 """Return fresh handles for one tract's patches, with their variance 

846 planes multiplied by ``factor``. 

847 

848 Parameters 

849 ---------- 

850 tract : `int` 

851 Id of the tract whose patches should be copied. 

852 factor : `float` 

853 Factor to multiply the input variance planes by. 

854 

855 Returns 

856 ------- 

857 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`] 

858 Handles to the modified patches. 

859 """ 

860 handles = [] 

861 for handle in self.patches[tract]: 

862 # ``copy=True`` on the original handles means this is a copy, so 

863 # the patches shared with the other tests are left untouched. 

864 patch = handle.get() 

865 patch.variance.array *= factor 

866 handles.append(pipeBase.InMemoryDatasetHandle(patch, 

867 storageClass="ExposureF", 

868 copy=True, 

869 dataId=handle.dataId)) 

870 return handles 

871 

872 def testScaleVariance(self): 

873 """Test that the template variance plane is rescaled to match the 

874 empirical pixel noise, and that the factor used is recorded in the 

875 task metadata. 

876 """ 

877 scaleFactor = 1.345 

878 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

879 

880 def _configureAndRunTask(doScaleVariance, varianceScale=1.): 

881 """Build a template from tract 0, optionally rescaling the input 

882 variance planes by ``varianceScale`` first. 

883 """ 

884 config = self._makeConfig() 

885 config.doScaleVariance = doScaleVariance 

886 task = lsst.ip.diffim.GetTemplateTask(config=config) 

887 result = task.run(coaddExposureHandles={0: self._scaleInputVariance(0, varianceScale)}, 

888 bbox=box, 

889 wcs=self.exposure.wcs, 

890 dataIds={0: self.dataIds[0]}, 

891 physical_filter="a_test") 

892 return task, result.template 

893 

894 # With scaling disabled the subtask is never constructed, and nothing 

895 # is recorded in the metadata. 

896 taskOff, templateOff = _configureAndRunTask(doScaleVariance=False) 

897 self.assertFalse(hasattr(taskOff, "scaleVariance")) 

898 self.assertNotIn("scaleTemplateVarianceFactor", taskOff.metadata) 

899 

900 # The image is correlated after warping, so ``doScaleVariance`` will 

901 # apply a correction. The value here is empirical from a warped image 

902 # with white noise and no sources. 

903 taskOn, templateOn = _configureAndRunTask(doScaleVariance=True) 

904 factor = taskOn.metadata["scaleTemplateVarianceFactor"] 

905 self.assertFloatsAlmostEqual(factor, 1.145, atol=0.01, 

906 msg="Measured template variance scaling changed; check the" 

907 " warping kernels used by _makePatches and the task," 

908 " and the order of the variance corrections.") 

909 # The only difference from the unscaled template is the constant 

910 # factor applied to the variance plane. 

911 self.assertFloatsAlmostEqual(templateOn.variance.array, 

912 templateOff.variance.array*factor, rtol=1e-5) 

913 # Tolerance here is float32 round-off: repeated runs of the task are 

914 # not bitwise identical. 

915 self.assertImagesAlmostEqual(templateOn.image, templateOff.image, rtol=1e-5, atol=1e-5) 

916 

917 # If the input variance planes under-estimate the noise by a known 

918 # factor, the measured factor grows by that amount and the same 

919 # output variance plane is recovered. 

920 taskLow, templateLow = _configureAndRunTask(doScaleVariance=True, varianceScale=1/scaleFactor) 

921 self.assertFloatsAlmostEqual(taskLow.metadata["scaleTemplateVarianceFactor"], 

922 factor*scaleFactor, rtol=1e-5) 

923 self.assertImagesAlmostEqual(templateLow.variance, templateOn.variance, rtol=1e-5) 

924 

925 def _runLegacyForFuture(self, raiseOnUndefinedMaskMap=True, handles=None): 

926 """Build a template from tract 0 with a task configured for the 

927 future output type, without converting it. 

928 

929 The task is kept as ``self.futureTask``. 

930 

931 Parameters 

932 ---------- 

933 raiseOnUndefinedMaskMap : `bool`, optional 

934 Value of the task config field of the same name. 

935 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`], optional 

936 Handles to the tract 0 patches to use instead of the fixture's. 

937 

938 Returns 

939 ------- 

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

941 The legacy output struct. 

942 box : `lsst.geom.Box2I` 

943 The bounding box the template was requested on, before the task 

944 grew it by the template border. 

945 """ 

946 config = self._makeConfig() 

947 config.output_image_type = "future" 

948 config.raiseOnUndefinedMaskMap = raiseOnUndefinedMaskMap 

949 task = lsst.ip.diffim.GetTemplateTask(config=config) 

950 self.visit = 9876 

951 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)) 

952 result = task.run(coaddExposureHandles={0: self.patches[0] if handles is None else handles}, 

953 bbox=box, 

954 wcs=self.exposure.wcs, 

955 dataIds={0: self.dataIds[0]}, 

956 physical_filter="a_test", 

957 visit=self.visit) 

958 # run() always produces legacy types, whatever output_image_type is. 

959 self.assertIsInstance(result.template, lsst.afw.image.ExposureF) 

960 self.futureTask = task 

961 return result, box 

962 

963 def _runFuture(self, handles=None): 

964 """Build a template and convert it, the way ``runQuantum`` does. 

965 

966 Parameters 

967 ---------- 

968 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`], optional 

969 Handles to the tract 0 patches to use instead of the fixture's. 

970 

971 Returns 

972 ------- 

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

974 The converted output struct. 

975 box : `lsst.geom.Box2I` 

976 The bounding box the template was requested on, before the task 

977 grew it by the template border. 

978 """ 

979 # lsst.images needs per-amplifier raw geometry and a field angle 

980 # transform, which the trivial test detectors do not have. 

981 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0] 

982 result, box = self._runLegacyForFuture(handles=handles) 

983 result.template = self.futureTask.convert_outputs_to_future( 

984 result.template, self._coaddRefs(0), detector=detector, 

985 exposureRecord=self._exposureRecord()) 

986 return result, box, detector 

987 

988 def _exposureRecord(self): 

989 """Return the exposure record that ``runQuantum`` rebuilds from the 

990 observation metadata of the science image. 

991 """ 

992 return makeTestExposureRecord(DimensionUniverse(), visit=self.visit) 

993 

994 def _coaddRefs(self, tract): 

995 """Return butler references for the coadds of one tract, like the 

996 ones ``runQuantum`` passes on from its ``coaddExposures`` input. 

997 

998 Parameters 

999 ---------- 

1000 tract : `int` 

1001 Id of the tract whose coadds to make references for. 

1002 

1003 Returns 

1004 ------- 

1005 refs : `list` [`lsst.daf.butler.DatasetRef`] 

1006 One reference per patch of that tract. The same references are 

1007 returned on every call, because each new one would get a new 

1008 dataset id. 

1009 """ 

1010 if tract not in self.coaddRefs: 

1011 datasetType = DatasetType("template_coadd", ("tract", "patch", "band", "skymap"), 

1012 "ExposureF", universe=DimensionUniverse()) 

1013 self.coaddRefs[tract] = [DatasetRef(datasetType, dataId, run="test_run") 

1014 for dataId in self.dataIds[tract]] 

1015 return self.coaddRefs[tract] 

1016 

1017 def testConvertOutputsToFuture(self): 

1018 """Test that the output template is converted to a DifferenceImage 

1019 that keeps the pixels and the detector it was built on. 

1020 """ 

1021 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0] 

1022 result, box = self._runLegacyForFuture() 

1023 legacy = result.template.clone() 

1024 result.template = self.futureTask.convert_outputs_to_future( 

1025 result.template, self._coaddRefs(0), detector=detector, 

1026 exposureRecord=self._exposureRecord()) 

1027 

1028 self.assertIsInstance(result.template, lsst.images.DifferenceImage) 

1029 compare_masked_image_to_legacy(result.template, legacy.maskedImage, 

1030 plane_map=lsst.images.get_legacy_template_mask_planes()) 

1031 self.assertEqual(result.template.unit, u.nJy) 

1032 self.assertIsNotNone(result.template.detector) 

1033 self.assertEqual(result.template.detector.name, detector.getName()) 

1034 # The template is grown by the border, so it is larger than both the 

1035 # requested box and the detector. 

1036 expectedBox = lsst.geom.Box2I(box) 

1037 expectedBox.grow(lsst.ip.diffim.GetTemplateTask.ConfigClass().templateBorderSize) 

1038 self.assertEqual(result.template.bbox.to_legacy(), expectedBox) 

1039 self.assertEqual(result.template.obs_info.visit_id, self.visit) 

1040 

1041 def testConvertOutputsToFutureCoaddMaskPlanes(self): 

1042 """Test that the coadd mask planes a warped template carries are 

1043 converted, and come back when the image is converted to legacy. 

1044 

1045 The template is converted with the template plane map, so the coadd 

1046 planes are named there; the source injection planes are optional, and 

1047 are added because this template has pixels set in them. 

1048 """ 

1049 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0] 

1050 planes = ("CLIPPED", "REJECTED", "INEXACT_PSF", "SENSOR_EDGE", "HIGH_VARIANCE", 

1051 "INJECTED", "INJECTED_CORE") 

1052 result, _ = self._runLegacyForFuture() 

1053 mask = result.template.mask 

1054 for n, plane in enumerate(planes): 

1055 mask.addMaskPlane(plane) 

1056 mask.array[0, n] |= mask.getPlaneBitMask(plane) 

1057 legacyTemplate = result.template.clone() 

1058 

1059 result.template = self.futureTask.convert_outputs_to_future( 

1060 result.template, self._coaddRefs(0), detector=detector, 

1061 exposureRecord=self._exposureRecord()) 

1062 

1063 template = result.template 

1064 self.assertIsInstance(template, lsst.images.DifferenceImage) 

1065 # Name the optional planes that have pixels set in the map, so that 

1066 # dropping one fails the comparison instead of skipping it. The others 

1067 # are left out because they may be registered by unrelated tests. 

1068 optional = lsst.images.get_legacy_optional_mask_planes() 

1069 planeMap = lsst.images.get_legacy_template_mask_planes() 

1070 planeMap.update({plane: optional[plane] for plane in planes if plane in optional}) 

1071 compare_masked_image_to_legacy(template, legacyTemplate.maskedImage, plane_map=planeMap) 

1072 # The butler converts a DifferenceImage to an ExposureF with the 

1073 # difference image plane map, which does not name the coadd planes; 

1074 # they keep their own names instead of being dropped. 

1075 legacy = template.to_legacy() 

1076 for n, plane in enumerate(planes): 

1077 with self.subTest(plane=plane): 

1078 self.assertIn(plane, legacy.mask.getMaskPlaneDict()) 

1079 self.assertEqual( 

1080 np.count_nonzero(legacy.mask.array & legacy.mask.getPlaneBitMask(plane)), 1) 

1081 

1082 def _addUnmappedMaskPlane(self, template): 

1083 """Set one pixel in the template's BRIGHT_OBJECT plane, which the 

1084 template plane map does not include, and one in its CLIPPED plane, 

1085 which it does. 

1086 """ 

1087 mask = template.mask 

1088 for n, plane in enumerate(("BRIGHT_OBJECT", "CLIPPED")): 

1089 mask.addMaskPlane(plane) 

1090 mask.array[0, n] |= mask.getPlaneBitMask(plane) 

1091 

1092 def testConvertOutputsToFutureUnmappedMaskPlaneRaises(self): 

1093 """With raiseOnUndefinedMaskMap=True, a template with pixels set in 

1094 an unmapped mask plane cannot be converted. 

1095 """ 

1096 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0] 

1097 result, _ = self._runLegacyForFuture() 

1098 self._addUnmappedMaskPlane(result.template) 

1099 with self.assertRaisesRegex(RuntimeError, "BRIGHT_OBJECT"): 

1100 self.futureTask.convert_outputs_to_future( 

1101 result.template, self._coaddRefs(0), detector=detector, 

1102 exposureRecord=self._exposureRecord()) 

1103 

1104 def testConvertOutputsToFutureUnmappedMaskPlaneWarns(self): 

1105 """With raiseOnUndefinedMaskMap=False, an unmapped mask plane is 

1106 dropped with a warning and the mapped planes are kept. 

1107 """ 

1108 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0] 

1109 result, _ = self._runLegacyForFuture(raiseOnUndefinedMaskMap=False) 

1110 self._addUnmappedMaskPlane(result.template) 

1111 with self.assertLogs(self.futureTask.log.name, level="WARNING") as cm: 

1112 template = self.futureTask.convert_outputs_to_future( 

1113 result.template, self._coaddRefs(0), detector=detector, 

1114 exposureRecord=self._exposureRecord()) 

1115 self.assertIn("BRIGHT_OBJECT", "\n".join(cm.output)) 

1116 self.assertIsInstance(template, lsst.images.DifferenceImage) 

1117 self.assertNotIn("BRIGHT_OBJECT", template.mask.schema.names) 

1118 self.assertEqual(np.count_nonzero(template.mask.get("CLIPPED")), 1) 

1119 

1120 def testConvertOutputsToFutureLosesProvenance(self): 

1121 """The coadd inputs that `run` attaches are not carried by 

1122 `lsst.images.DifferenceImage`. 

1123 """ 

1124 legacy, _ = self._runLegacyForFuture() 

1125 self.assertTrue(legacy.template.getInfo().hasCoaddInputs()) 

1126 result, _, _ = self._runFuture() 

1127 self.assertFalse(result.template.to_legacy().getInfo().hasCoaddInputs()) 

1128 

1129 def testConvertOutputsToFutureTemplates(self): 

1130 """The coadds that went into the template are recorded on the 

1131 converted image. 

1132 """ 

1133 result, _, _ = self._runFuture() 

1134 refs = {(ref.dataId["tract"], ref.dataId["patch"]): ref for ref in self._coaddRefs(0)} 

1135 

1136 templates = result.template.templates 

1137 self.assertGreater(len(templates), 0) 

1138 for template in templates: 

1139 with self.subTest(tract=template.tract, patch=template.patch): 

1140 ref = refs[template.tract, template.patch] 

1141 self.assertEqual(template.skymap, "skymap") 

1142 self.assertEqual(template.dataset_id, ref.id) 

1143 self.assertEqual(template.dataset_run, ref.run) 

1144 self.assertEqual(template.instrument, "TestCam") 

1145 self.assertFalse(template.psf_shape_flag) 

1146 self.assertGreater(template.psf_shape_xx, 0) 

1147 self.assertGreater(template.bounds.area, 0) 

1148 

1149 def testConvertOutputsToFutureInstrument(self): 

1150 """The template instrument is the one recorded by the coadds that 

1151 know it, or ``"UNKNOWN"`` if none of them do. 

1152 """ 

1153 records = [(self.exposure.wcs, self.exposure.getBBox())] 

1154 handles = (self._patchHandles(0, records, instrument=None)[:1] 

1155 + self._patchHandles(0, records)[1:]) 

1156 result, _, _ = self._runFuture(handles=handles) 

1157 self.assertEqual({template.instrument for template in result.template.templates}, {"TestCam"}) 

1158 

1159 result, _, _ = self._runFuture(handles=self._patchHandles(0, records, instrument=None)) 

1160 self.assertEqual({template.instrument for template in result.template.templates}, {"UNKNOWN"}) 

1161 

1162 def testConvertOutputsToFuturePsf(self): 

1163 """The Gaussian PSF set by `run` is kept, defined over the detector. 

1164 """ 

1165 result, _ = self._runLegacyForFuture() 

1166 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0] 

1167 legacyPsf = result.template.getPsf() 

1168 

1169 result.template = self.futureTask.convert_outputs_to_future( 

1170 result.template, self._coaddRefs(0), detector=detector, 

1171 exposureRecord=self._exposureRecord()) 

1172 

1173 psf = result.template.psf 

1174 self.assertIsInstance(psf, lsst.images.psfs.GaussianPointSpreadFunction) 

1175 self.assertFloatsAlmostEqual(psf.sigma, legacyPsf.getSigma()) 

1176 self.assertEqual(psf.bounds.to_legacy(), detector.getBBox()) 

1177 self.assertEqual(psf.kernel_bbox.to_legacy().getDimensions(), legacyPsf.getDimensions()) 

1178 

1179 def testConvertOutputsToFuturePsfToLegacy(self): 

1180 """The Gaussian survives conversion back to an Exposure, which is how 

1181 a task that has not been converted reads the template. 

1182 """ 

1183 result, _, _ = self._runFuture() 

1184 

1185 psf = result.template.to_legacy().getPsf() 

1186 self.assertIsInstance(psf, lsst.afw.detection.GaussianPsf) 

1187 self.assertFloatsAlmostEqual(psf.getSigma(), result.template.psf.sigma) 

1188 

1189 

1190class WarpCorrelationFactorTestCase(lsst.utils.tests.TestCase): 

1191 """Test the warping correlation correction on pure noise, with no other 

1192 variance correction. 

1193 """ 

1194 

1195 def setUp(self): 

1196 self.size = 1024 

1197 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(self.size, self.size)) 

1198 

1199 def _makeWcs(self, angle, offset): 

1200 """Make a WCS with a fixed plate scale, rotated by ``angle`` degrees 

1201 and with its reference pixel moved by ``offset`` pixels. 

1202 """ 

1203 center = lsst.geom.Point2D(self.size/2 + offset[0], self.size/2 + offset[1]) 

1204 cdMatrix = lsst.afw.geom.makeCdMatrix(0.2*lsst.geom.arcseconds, angle*lsst.geom.degrees) 

1205 return lsst.afw.geom.makeSkyWcs(center, lsst.geom.SpherePoint(45, 30, lsst.geom.degrees), 

1206 cdMatrix) 

1207 

1208 def testCorrelationFactorRestoresNoisePower(self): 

1209 """Warp pure noise to a rotated, offset frame with the coadd kernel, 

1210 then to a third frame with the task's own warper. 

1211 

1212 Warping leaves the variance of a flux summed over a region (the noise 

1213 power) unchanged, but lowers the per-pixel scatter and the variance 

1214 plane. The variance plane is lowered by a larger factor because the 

1215 input to the second warp is already correlated. The correction must 

1216 bring the variance plane back to the noise power of the original image. 

1217 """ 

1218 sigma = 2.0 

1219 rng = np.random.default_rng(42) 

1220 original = lsst.afw.image.ExposureF(self.bbox, self._makeWcs(0, (0, 0))) 

1221 original.image.array[:] = rng.normal(0, sigma, original.image.array.shape) 

1222 original.variance.array[:] = sigma**2 

1223 

1224 config = lsst.ip.diffim.GetTemplateTask.ConfigClass() 

1225 config.doScaleVariance = False 

1226 config.coaddWarpKernel = "lanczos3" 

1227 task = lsst.ip.diffim.GetTemplateTask(config=config) 

1228 coadd = lsst.afw.math.Warper(config.coaddWarpKernel).warpExposure( 

1229 self._makeWcs(37, (0.31, 0.72)), original, destBBox=self.bbox) 

1230 template = task.warper.warpExposure(self._makeWcs(-21, (0.58, 0.23)), coadd, destBBox=self.bbox) 

1231 

1232 # The central quarter is covered in every frame. 

1233 inner = lsst.geom.Box2I(lsst.geom.Point2I(self.size//4, self.size//4), 

1234 lsst.geom.Extent2I(self.size//2, self.size//2)) 

1235 originalStd = original.image[inner].array.std() 

1236 image = template.image[inner].array.astype(float) 

1237 pixelStd = image.std() 

1238 # Each warp moves flux between neighboring pixels but doesn't create 

1239 # or destroy any, so the noise in a flux summed over a region is the 

1240 # same before and after. Summing the image over blocks and measuring 

1241 # how much those sums scatter gives that quantity directly, without 

1242 # depending on the variance plane. Every placement of the block is 

1243 # used, from a summed-area table, to reduce the noise. 

1244 blockSize = 8 

1245 table = np.pad(image.cumsum(axis=0).cumsum(axis=1), ((1, 0), (1, 0))) 

1246 sums = (table[blockSize:, blockSize:] - table[:-blockSize, blockSize:] 

1247 - table[blockSize:, :-blockSize] + table[:-blockSize, :-blockSize]) 

1248 blockStd = sums.std()/blockSize 

1249 planeStdBefore = np.sqrt(template.variance[inner].array.mean()) 

1250 task._correctVariance(template, plateScaleFactor=1.0) 

1251 planeStdAfter = np.sqrt(template.variance[inner].array.mean()) 

1252 factor = task.metadata["varianceWarpCorrelationFactor"] 

1253 

1254 # afw propagates the variance as if each warp's input pixels were 

1255 # independent, so the plane drops by exactly the factor's reciprocal. 

1256 self.assertFloatsAlmostEqual(planeStdBefore, sigma/np.sqrt(factor), rtol=1e-5) 

1257 self.assertFloatsAlmostEqual(planeStdAfter, sigma, rtol=1e-5) 

1258 

1259 # The warps conserve the noise power, which the corrected plane 

1260 # matches. 

1261 self.assertFloatsAlmostEqual(blockStd, originalStd, rtol=0.03) 

1262 self.assertFloatsAlmostEqual(blockStd, planeStdAfter, rtol=0.03) 

1263 

1264 # The per-pixel scatter drops, though by less than the uncorrected 

1265 # plane, because the second warp receives correlated input. The 

1266 # corrected plane deliberately exceeds it. 

1267 self.assertLess(pixelStd, 0.9*originalStd) 

1268 self.assertGreater(pixelStd, planeStdBefore) 

1269 self.assertLess(pixelStd, planeStdAfter) 

1270 

1271 

1272class GetTemplateConnectionsTestCase(lsst.utils.tests.TestCase): 

1273 """Test the connections that ``output_image_type`` switches on. 

1274 

1275 These only need the config classes, not a built template. 

1276 """ 

1277 

1278 def testOutputImageType(self): 

1279 """Test that the future output type changes the template storage 

1280 class, and leaves the input connections alone. 

1281 """ 

1282 Connections = lsst.ip.diffim.GetTemplateTask.ConfigClass.ConnectionsClass 

1283 config = lsst.ip.diffim.GetTemplateTask.ConfigClass() 

1284 

1285 connections = Connections(config=config) 

1286 self.assertEqual(connections.template.storageClass, "ExposureF") 

1287 self.assertNotIn("obs_info", connections.inputs) 

1288 

1289 config.output_image_type = "future" 

1290 connections = Connections(config=config) 

1291 self.assertEqual(connections.template.storageClass, "DifferenceImage") 

1292 # The dataset name is unchanged; only its storage class differs. 

1293 self.assertEqual(connections.template.name, "goodSeeingDiff_templateExp") 

1294 # The detector the conversion needs comes from the science image. 

1295 self.assertEqual(connections.detector.name, "calexp.detector") 

1296 self.assertEqual(connections.detector.storageClass, "Detector") 

1297 # The observation metadata is only read in future mode, where it is 

1298 # the one input the conversion adds. 

1299 self.assertEqual(connections.obs_info.name, "calexp.obs_info") 

1300 self.assertEqual(connections.obs_info.storageClass, "ObservationInfo") 

1301 

1302 def testLintConnections(self): 

1303 """Check that the connections are self-consistent in both modes. 

1304 """ 

1305 for task in (lsst.ip.diffim.GetTemplateTask, lsst.ip.diffim.GetDcrTemplateTask): 

1306 with self.subTest(task=task.__name__): 

1307 lsst.pipe.base.testUtils.lintConnections(task.ConfigClass.ConnectionsClass) 

1308 

1309 

1310def setup_module(module): 

1311 lsst.utils.tests.init() 

1312 

1313 

1314class MemoryTestCase(lsst.utils.tests.MemoryTestCase): 

1315 pass 

1316 

1317 

1318if __name__ == "__main__": 1318 ↛ 1319line 1318 didn't jump to line 1319 because the condition on line 1318 was never true

1319 lsst.utils.tests.init() 

1320 unittest.main()