Coverage for tests/test_getTemplate.py: 91%

292 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 23:33 +0000

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import collections 

23import itertools 

24import unittest 

25import unittest.mock 

26 

27from astropy.stats import gaussian_sigma_to_fwhm 

28import numpy as np 

29 

30import lsst.afw.detection 

31import lsst.afw.geom 

32import lsst.afw.image 

33import lsst.afw.math 

34import lsst.afw.table 

35from lsst.daf.butler import DataCoordinate, DimensionUniverse 

36import lsst.geom 

37import lsst.ip.diffim 

38import lsst.meas.algorithms 

39import lsst.meas.base.tests 

40import lsst.pex.exceptions 

41import lsst.pipe.base as pipeBase 

42import lsst.skymap 

43import lsst.utils.tests 

44 

45from utils import generate_data_id 

46 

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

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

49debug = False 

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

51 import lsst.afw.display 

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

53 display.frame = 1 

54 

55 

56def _showTemplate(box, template): 

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

58 for point in box.getCorners(): 

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

60 display.frame = 2 

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

62 display.frame = 3 

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

64 

65 

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

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

68 input coadd exposures. 

69 

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

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

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

73 combinations of one or multiple tracts. 

74 """ 

75 def setUp(self): 

76 self.scale = 0.2 # arcsec/pixel 

77 self.skymap = self._makeSkymap() 

78 self.patches = collections.defaultdict(list) 

79 self.dataIds = collections.defaultdict(list) 

80 self.exposure = self._makeExposure() 

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

82 

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

84 display.image(self.exposure, "base exposure") 

85 

86 for tract_id in range(4): 

87 tract = self.skymap.generateTract(tract_id) 

88 self._makePatches(tract) 

89 

90 def _makeSkymap(self): 

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

92 """ 

93 tractScale = 0.02 # degrees 

94 # On-sky coordinates of the tract centers. 

95 coords = [(0, 0), 

96 (0, tractScale), 

97 (tractScale, 0), 

98 (tractScale, tractScale), 

99 ] 

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

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

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

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

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

105 config.projection = "TAN" 

106 config.pixelScale = self.scale 

107 config.tractOverlap = 0.0005 

108 config.tractBuilder = "legacy" 

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

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

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

112 

113 def _makeExposure(self): 

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

115 

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

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

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

119 to the upper right. 

120 """ 

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

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

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

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

125 # from the patch pixel scale. 

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

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

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

129 cd_matrix) 

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

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

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

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

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

135 return exposure 

136 

137 def _makePatches(self, tract): 

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

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

140 """ 

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

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

143 point = self.exposure.wcs.skyToPixel(tract.ctr_coord) 

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

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

146 

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

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

149 config.warpingKernelName = "lanczos5" 

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

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

152 patch = tract.getPatchInfo(patchId) 

153 box = patch.getOuterBBox() 

154 

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

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

157 points = self.exposure.wcs.skyToPixel(patch.wcs.pixelToSky([lsst.geom.Point2D(x) 

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

159 for p in points: 

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

161 

162 # This is mostly taken from drp_tasks makePsfMatchedWarp, but 

163 # ip_diffim cannot depend on drp_tasks. 

164 xyTransform = lsst.afw.geom.makeWcsPairTransform(self.exposure.wcs, patch.wcs) 

165 warpedPsf = lsst.meas.algorithms.WarpedPsf(self.exposure.psf, xyTransform) 

166 warped = warper.warpExposure(patch.wcs, self.exposure, destBBox=box) 

167 warped.setPsf(warpedPsf) 

168 

169 warped.getInfo().setCoaddInputs( 

170 self._makeCoaddInputs([(self.exposure.wcs, self.exposure.getBBox())])) 

171 dataRef = pipeBase.InMemoryDatasetHandle( 

172 warped, 

173 storageClass="ExposureF", 

174 copy=True, 

175 dataId=generate_data_id( 

176 tract=tract, 

177 patch=patch, 

178 ) 

179 ) 

180 self.patches[tract.tract_id].append(dataRef) 

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

182 "patch": patchId, 

183 "band": "a", 

184 "skymap": "skymap"}, 

185 universe=DimensionUniverse()) 

186 self.dataIds[tract.tract_id].append(dataCoordinate) 

187 

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

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

190 """ 

191 expectedBox = lsst.geom.Box2I(box) 

192 expectedBox.grow(config.templateBorderSize) 

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

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

195 for tract in self.patches: 

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

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

198 # The template pixels are calibrated to nJy. 

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

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

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

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

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

204 # its inputs. 

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

206 coaddPsf = self._makeCoaddPsf(template, config) 

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

208 position = coaddPsf.getAveragePosition() 

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

210 coaddPsf.computeShape(position).getTraceRadius()) 

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

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

213 

214 def _makeCoaddPsf(self, template, config): 

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

216 rebuilt from the coadd inputs recorded on the template. 

217 """ 

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

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

220 

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

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

223 original image, calibrated to nJy. 

224 """ 

225 # All pixels should have real values! 

226 expectedBox = lsst.geom.Box2I(box) 

227 expectedBox.grow(config.templateBorderSize) 

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

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

230 

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

232 _showTemplate(expectedBox, template) 

233 

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

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

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

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

238 # tolerances large enough to account for that. 

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

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

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

242 # warping kernels, which `_correctVariance` corrects up to the 

243 # difference between the configured coaddWarpKernel and the lanczos5 

244 # `_makePatches` really used. A per-pixel ripple that no scalar 

245 # correction can remove remains on top of that, so check the level 

246 # tightly and allow for the ripple around it. 

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

248 median = np.median(variance) 

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

250 rtol=0.35, msg="variance level differs") 

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

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

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

254 

255 def testRunOneTractInput(self): 

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

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

258 tract input correctly. 

259 """ 

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

261 task = lsst.ip.diffim.GetTemplateTask() 

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

263 # Task modifies the input bbox, so pass a copy. 

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

265 bbox=lsst.geom.Box2I(box), 

266 wcs=self.exposure.wcs, 

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

268 physical_filter="a_test") 

269 

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

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

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

273 

274 def testRunOneTractMultipleInputs(self): 

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

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

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

278 """ 

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

280 task = lsst.ip.diffim.GetTemplateTask() 

281 # Task modifies the input bbox, so pass a copy. 

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

283 bbox=lsst.geom.Box2I(box), 

284 wcs=self.exposure.wcs, 

285 dataIds=self.dataIds, 

286 physical_filter="a_test") 

287 

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

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

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

291 

292 def testRunTwoTracts(self): 

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

294 """ 

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

296 task = lsst.ip.diffim.GetTemplateTask() 

297 # Task modifies the input bbox, so pass a copy. 

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

299 bbox=lsst.geom.Box2I(box), 

300 wcs=self.exposure.wcs, 

301 dataIds=self.dataIds, 

302 physical_filter="a_test") 

303 

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

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

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

307 

308 def testRunCalibratesToNanojansky(self): 

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

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

311 """ 

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

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

314 self.assertNotEqual(calibration, 1.0) 

315 task = lsst.ip.diffim.GetTemplateTask() 

316 templates = {} 

317 for photoCalib in (self.exposure.photoCalib, lsst.afw.image.PhotoCalib(1.0)): 

318 self._remakePatches(photoCalib) 

319 # Task modifies the input bbox, so pass a copy. 

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

321 bbox=lsst.geom.Box2I(box), 

322 wcs=self.exposure.wcs, 

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

324 physical_filter="a_test") 

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

326 templates[photoCalib.getCalibrationMean()] = result.template 

327 

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

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

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

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

332 

333 def _remakePatches(self, photoCalib): 

334 """Rebuild the coadd patches from the main exposure with a different 

335 photometric calibration. 

336 """ 

337 self.exposure.setPhotoCalib(photoCalib) 

338 self.patches.clear() 

339 self.dataIds.clear() 

340 for tract_id in range(4): 

341 self._makePatches(self.skymap.generateTract(tract_id)) 

342 

343 def testGaussianPsfGridFallback(self): 

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

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

346 """ 

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

348 task = lsst.ip.diffim.GetTemplateTask() 

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

350 bbox=lsst.geom.Box2I(box), 

351 wcs=self.exposure.wcs, 

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

353 physical_filter="a_test") 

354 template = result.template 

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

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

357 template, 

358 fwhmExposureBuffer=task.config.fwhmExposureBuffer, 

359 fwhmExposureGrid=task.config.fwhmExposureGrid, 

360 )/gaussian_sigma_to_fwhm 

361 

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

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

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

365 psf = task._makeGaussianPsf(template) 

366 

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

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

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

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

371 

372 def testRunNoTemplate(self): 

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

374 """ 

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

376 task = lsst.ip.diffim.GetTemplateTask() 

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

378 task.run(coaddExposureHandles=self.patches, 

379 bbox=lsst.geom.Box2I(box), 

380 wcs=self.exposure.wcs, 

381 dataIds=self.dataIds, 

382 physical_filter="a_test") 

383 

384 def testMissingPatches(self): 

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

386 

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

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

389 """ 

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

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

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

393 task = lsst.ip.diffim.GetTemplateTask() 

394 # Task modifies the input bbox, so pass a copy. 

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

396 bbox=lsst.geom.Box2I(box), 

397 wcs=self.exposure.wcs, 

398 dataIds=self.dataIds, 

399 physical_filter="a_test") 

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

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

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

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

404 

405 @lsst.utils.tests.methodParameters( 

406 box=[ 

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

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

409 ], 

410 nInput=[8, 16], 

411 ) 

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

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

414 pixels have NaN as variance. 

415 """ 

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

417 for patchRef in patchRefs: 

418 patchCoadd = patchRef.get() 

419 bbox = lsst.geom.Box2I() 

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

421 bbox.grow(3) 

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

423 

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

425 task = lsst.ip.diffim.GetTemplateTask() 

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

427 bbox=lsst.geom.Box2I(box), 

428 wcs=self.exposure.wcs, 

429 dataIds=self.dataIds, 

430 physical_filter="a_test") 

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

432 _showTemplate(box, result.template) 

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

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

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

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

437 

438 def _runCorrection(self, doCorrectVariancePlateScale=False, doScaleVariance=False, 

439 handles=None): 

440 """Run the task on tract 0 with the named variance corrections. 

441 

442 Everything defaults to off, so each test turns on exactly what it is 

443 exercising. 

444 """ 

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

446 config.doCorrectVariancePlateScale = doCorrectVariancePlateScale 

447 config.doScaleVariance = doScaleVariance 

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

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

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

451 wcs=self.exposure.wcs, 

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

453 physical_filter="a_test") 

454 return task, result.template 

455 

456 def _makeScaledWcs(self, factor): 

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

458 multiplied by ``factor``. 

459 """ 

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

461 93*lsst.geom.degrees) 

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

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

464 cdMatrix) 

465 

466 @staticmethod 

467 def _makeCoaddInputs(records): 

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

469 

470 Parameters 

471 ---------- 

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

473 `lsst.geom.Box2I`]] 

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

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

476 simulate a coadd whose plate scale cannot be reconstructed. 

477 """ 

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

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

480 coaddInputs = lsst.afw.image.CoaddInputs( 

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

482 for wcs, bbox in records: 

483 record = coaddInputs.ccds.addNew() 

484 record.setWcs(wcs) 

485 record.setBBox(bbox) 

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

487 # correction does not use it. 

488 record.set(weightKey, 1.0) 

489 return coaddInputs 

490 

491 def _patchHandles(self, tract, records): 

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

493 replaced by ``records``. 

494 """ 

495 handles = [] 

496 for ref in self.patches[tract]: 

497 coadd = ref.get() 

498 coadd.getInfo().setCoaddInputs(self._makeCoaddInputs(records)) 

499 handles.append(pipeBase.InMemoryDatasetHandle( 

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

501 return handles 

502 

503 def testCorrectVariancePlateScale(self): 

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

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

506 """ 

507 _, off = self._runCorrection() 

508 

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

510 # net change in pixel area: the same-instrument case. 

511 task, on = self._runCorrection(doCorrectVariancePlateScale=True) 

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

513 

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

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

516 for scaleFactor in (1.315, 0.5): 

517 with self.subTest(scaleFactor=scaleFactor): 

518 handles = self._patchHandles( 

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

520 task, on = self._runCorrection(doCorrectVariancePlateScale=True, 

521 handles=handles) 

522 factor = task.metadata["variancePlateScaleFactor"] 

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

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

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

526 

527 def testCorrectVariancePlateScaleUsesOneRecord(self): 

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

529 

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

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

532 of them. 

533 """ 

534 handles = self._patchHandles( 

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

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

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

538 task, _ = self._runCorrection(doCorrectVariancePlateScale=True, handles=handles) 

539 

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

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

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

543 

544 def testCorrectVariancePlateScaleNeedsCoaddInputs(self): 

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

546 reconstructed, and the task must say so rather than silently applying 

547 only part of the correction. 

548 """ 

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

550 with self.assertRaisesRegex(RuntimeError, "doCorrectVariancePlateScale"): 

551 self._runCorrection(doCorrectVariancePlateScale=True, handles=handles) 

552 

553 def _scaleInputVariance(self, tract, factor): 

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

555 planes multiplied by ``factor``. 

556 

557 Parameters 

558 ---------- 

559 tract : `int` 

560 Id of the tract whose patches should be copied. 

561 factor : `float` 

562 Factor to multiply the input variance planes by. 

563 

564 Returns 

565 ------- 

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

567 Handles to the modified patches. 

568 """ 

569 handles = [] 

570 for handle in self.patches[tract]: 

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

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

573 patch = handle.get() 

574 patch.variance.array *= factor 

575 handles.append(pipeBase.InMemoryDatasetHandle(patch, 

576 storageClass="ExposureF", 

577 copy=True, 

578 dataId=handle.dataId)) 

579 return handles 

580 

581 def testScaleVariance(self): 

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

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

584 task metadata. 

585 """ 

586 scaleFactor = 1.345 

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

588 

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

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

591 variance planes by ``varianceScale`` first. 

592 """ 

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

594 config.doScaleVariance = doScaleVariance 

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

596 # Task modifies the input bbox, so pass a copy. 

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

598 bbox=lsst.geom.Box2I(box), 

599 wcs=self.exposure.wcs, 

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

601 physical_filter="a_test") 

602 return task, result.template 

603 

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

605 # is recorded in the metadata. 

606 taskOff, templateOff = _configureAndRunTask(False) 

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

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

609 

610 # Both warps -- lanczos5 in ``_makePatches`` and lanczos3 in the 

611 # task -- correlate the noise. The variance plane tracks only the 

612 # per-pixel diagonal, which the second warp leaves too low, so 

613 # ``scaleVariance`` measures a factor well above 1 even though the 

614 # input variance planes are correct. 

615 # 

616 taskOn, templateOn = _configureAndRunTask(True) 

617 factor = taskOn.metadata["scaleTemplateVarianceFactor"] 

618 # TODO DM-55879: this value is pinned on purpose. The lanczos warping 

619 # kernels introduce small correlations that artificially suppress the 

620 # image pixel stddev and inflate the variance scaling factor. This 

621 # should be changed to 1.0 after DM-55879 is merged. 

622 self.assertFloatsAlmostEqual(factor, 1.1465, atol=0.01, 

623 msg="Measured template variance scaling changed; see the" 

624 " comment above if the correlation correction landed.") 

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

626 # factor applied to the variance plane. 

627 self.assertFloatsAlmostEqual(templateOn.variance.array, 

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

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

630 # not bitwise identical. 

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

632 

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

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

635 # output variance plane is recovered. 

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

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

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

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

640 

641 

642def setup_module(module): 

643 lsst.utils.tests.init() 

644 

645 

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

647 pass 

648 

649 

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

651 lsst.utils.tests.init() 

652 unittest.main()