Coverage for tests/test_getTemplate.py: 96%

684 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 17:53 +0000

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

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 astshim 

30import numpy as np 

31 

32import lsst.afw.cameraGeom.testUtils 

33import lsst.afw.detection 

34import lsst.afw.geom 

35import lsst.afw.image 

36import lsst.afw.math 

37import lsst.afw.table 

38from lsst.daf.base import DateTime 

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

40import lsst.geom 

41import lsst.images 

42import lsst.images.psfs 

43from lsst.images.tests import compare_masked_image_to_legacy 

44import lsst.ip.diffim 

45import lsst.meas.algorithms 

46import lsst.meas.base.tests 

47import lsst.pex.exceptions 

48import lsst.pipe.base as pipeBase 

49import lsst.pipe.base.testUtils 

50import lsst.skymap 

51import lsst.utils.tests 

52 

53from utils import generate_data_id, makeTestExposureRecord 

54 

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

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

57debug = False 

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

59 import lsst.afw.display 

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

61 display.frame = 1 

62 

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

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

65KERNEL_SUM_SQ_1D = { 

66 "bilinear": 2/3, 

67 "lanczos3": 0.8933699084, 

68 "lanczos4": 0.9141249535, 

69 "lanczos5": 0.9342245513, 

70} 

71 

72 

73def _showTemplate(box, template): 

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

75 for point in box.getCorners(): 

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

77 display.frame = 2 

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

79 display.frame = 3 

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

81 

82 

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

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

85 input coadd exposures. 

86 

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

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

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

90 combinations of one or multiple tracts. 

91 """ 

92 scale = 0.2 # arcsec/pixel 

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

94 coaddWarpKernel = "lanczos5" 

95 

96 @classmethod 

97 def setUpClass(cls): 

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

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

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

101 cls.skymap = cls._makeSkymap() 

102 cls.patches = collections.defaultdict(list) 

103 cls.dataIds = collections.defaultdict(list) 

104 cls.exposure = cls._makeExposure() 

105 

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

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

108 

109 for tract_id in range(4): 

110 tract = cls.skymap.generateTract(tract_id) 

111 cls._makePatches(tract) 

112 

113 @classmethod 

114 def tearDownClass(cls): 

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

116 

117 def setUp(self): 

118 cls = type(self) 

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

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

121 self.coaddRefs = {} 

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

123 

124 @classmethod 

125 def _makeSkymap(cls): 

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

127 """ 

128 tractScale = 0.02 # degrees 

129 # On-sky coordinates of the tract centers. 

130 coords = [(0, 0), 

131 (0, tractScale), 

132 (tractScale, 0), 

133 (tractScale, tractScale), 

134 ] 

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

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

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

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

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

140 config.projection = "TAN" 

141 config.pixelScale = cls.scale 

142 config.tractOverlap = 0.0005 

143 config.tractBuilder = "legacy" 

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

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

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

147 

148 @classmethod 

149 def _makeExposure(cls): 

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

151 

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

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

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

155 to the upper right. 

156 """ 

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

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

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

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

161 # from the patch pixel scale. 

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

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

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

165 cd_matrix) 

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

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

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

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

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

171 return exposure 

172 

173 @classmethod 

174 def _makePatches(cls, tract): 

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

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

177 """ 

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

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

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

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

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

183 

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

185 config.warpingKernelName = cls.coaddWarpKernel 

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

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

188 patch = tract.getPatchInfo(patchId) 

189 box = patch.getOuterBBox() 

190 

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

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

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

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

195 for p in points: 

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

197 

198 # This is mostly taken from drp_tasks makePsfMatchedWarp, but 

199 # ip_diffim cannot depend on drp_tasks. 

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

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

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

203 warped.setPsf(warpedPsf) 

204 

205 warped.getInfo().setCoaddInputs( 

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

207 dataRef = pipeBase.InMemoryDatasetHandle( 

208 warped, 

209 storageClass="ExposureF", 

210 copy=True, 

211 dataId=generate_data_id( 

212 tract=tract, 

213 patch=patch, 

214 ) 

215 ) 

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

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

218 "patch": patchId, 

219 "band": "a", 

220 "skymap": "skymap"}, 

221 universe=DimensionUniverse()) 

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

223 

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

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

226 """ 

227 expectedBox = lsst.geom.Box2I(box) 

228 expectedBox.grow(config.templateBorderSize) 

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

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

231 for tract in self.patches: 

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

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

234 # The template pixels are calibrated to nJy. 

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

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

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

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

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

240 # its inputs. 

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

242 coaddPsf = self._makeCoaddPsf(template, config) 

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

244 position = coaddPsf.getAveragePosition() 

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

246 coaddPsf.computeShape(position).getTraceRadius()) 

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

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

249 

250 def _makeConfig(self): 

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

252 the test patches were made with. 

253 """ 

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

255 config.coaddWarpKernel = self.coaddWarpKernel 

256 return config 

257 

258 def _makeCoaddPsf(self, template, config): 

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

260 rebuilt from the coadd inputs recorded on the template. 

261 """ 

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

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

264 

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

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

267 original image, calibrated to nJy. 

268 """ 

269 # All pixels should have real values! 

270 expectedBox = lsst.geom.Box2I(box) 

271 expectedBox.grow(config.templateBorderSize) 

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

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

274 

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

276 _showTemplate(expectedBox, template) 

277 

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

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

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

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

282 # tolerances large enough to account for that. 

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

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

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

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

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

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

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

290 median = np.median(variance) 

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

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

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

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

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

296 

297 def testRunOneTractInput(self): 

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

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

300 tract input correctly. 

301 """ 

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

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

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

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

306 bbox=box, 

307 wcs=self.exposure.wcs, 

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

309 physical_filter="a_test") 

310 

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

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

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

314 

315 def testRunOneTractMultipleInputs(self): 

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

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

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

319 """ 

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

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

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

323 bbox=box, 

324 wcs=self.exposure.wcs, 

325 dataIds=self.dataIds, 

326 physical_filter="a_test") 

327 

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

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

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

331 

332 def testRunTwoTracts(self): 

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

334 """ 

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

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

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

338 bbox=box, 

339 wcs=self.exposure.wcs, 

340 dataIds=self.dataIds, 

341 physical_filter="a_test") 

342 

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

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

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

346 

347 def testRunCalibratesToNanojansky(self): 

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

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

350 """ 

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

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

353 self.assertNotEqual(calibration, 1.0) 

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

355 uncalibrated = [] 

356 for handle in self.patches[0]: 

357 coadd = handle.get() 

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

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

360 dataId=handle.dataId)) 

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

362 templates = {} 

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

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

365 bbox=box, 

366 wcs=self.exposure.wcs, 

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

368 physical_filter="a_test") 

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

370 templates[key] = result.template 

371 

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

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

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

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

376 

377 def testGaussianPsfGridFallback(self): 

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

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

380 """ 

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

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

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

384 bbox=box, 

385 wcs=self.exposure.wcs, 

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

387 physical_filter="a_test") 

388 template = result.template 

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

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

391 template, 

392 fwhmExposureBuffer=task.config.fwhmExposureBuffer, 

393 fwhmExposureGrid=task.config.fwhmExposureGrid, 

394 )/gaussian_sigma_to_fwhm 

395 

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

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

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

399 psf = task._makeGaussianPsf(template) 

400 

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

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

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

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

405 

406 def testRunNoTemplate(self): 

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

408 """ 

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

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

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

412 task.run(coaddExposureHandles=self.patches, 

413 bbox=box, 

414 wcs=self.exposure.wcs, 

415 dataIds=self.dataIds, 

416 physical_filter="a_test") 

417 

418 def testMissingPatches(self): 

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

420 

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

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

423 """ 

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

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

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

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

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

429 bbox=box, 

430 wcs=self.exposure.wcs, 

431 dataIds=self.dataIds, 

432 physical_filter="a_test") 

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

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

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

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

437 

438 @lsst.utils.tests.methodParameters( 

439 box=[ 

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

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

442 ], 

443 nInput=[8, 16], 

444 ) 

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

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

447 pixels have NaN as variance. 

448 """ 

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

450 for patchRef in patchRefs: 

451 patchCoadd = patchRef.get() 

452 bbox = lsst.geom.Box2I() 

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

454 bbox.grow(3) 

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

456 

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

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

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

460 bbox=box, 

461 wcs=self.exposure.wcs, 

462 dataIds=self.dataIds, 

463 physical_filter="a_test") 

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

465 _showTemplate(box, result.template) 

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

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

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

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

470 

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

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

473 templateWarpKernel="lanczos3"): 

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

475 

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

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

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

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

480 """ 

481 config = self._makeConfig() 

482 config.doCorrectVarianceWarpCorrelation = doCorrectVarianceWarpCorrelation 

483 config.doScaleVariance = doScaleVariance 

484 if coaddWarpKernel is not None: 

485 config.coaddWarpKernel = coaddWarpKernel 

486 config.warp.warpingKernelName = templateWarpKernel 

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

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

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

490 wcs=self.exposure.wcs, 

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

492 physical_filter="a_test", 

493 instrument=instrument) 

494 return task, result.template 

495 

496 def _makeScaledWcs(self, factor): 

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

498 multiplied by ``factor``. 

499 """ 

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

501 93*lsst.geom.degrees) 

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

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

504 cdMatrix) 

505 

506 @staticmethod 

507 def _makeCoaddInputs(records, instrument="TestCam", epochs=None): 

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

509 

510 Parameters 

511 ---------- 

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

513 `lsst.geom.Box2I`]] 

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

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

516 simulate a coadd whose plate scale cannot be reconstructed. 

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

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

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

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

521 epochs : `list` [`tuple` [`int`, `float`, `float`]], optional 

522 Visit, mid-exposure MJD (TAI) and coadd weight of each input. By 

523 default every input is visit 0 with weight 1 and no observation 

524 time. The time is not recorded if ``instrument`` is `None`. 

525 """ 

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

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

528 visitKey = ccdSchema.addField("visit", type=np.int64, doc="Visit of the input") 

529 ccdSchema.addField("ccd", type=np.int32, doc="Detector of the input") 

530 coaddInputs = lsst.afw.image.CoaddInputs( 

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

532 if epochs is None: 

533 epochs = [(0, None, 1.0)]*len(records) 

534 for (wcs, bbox), (visit, mjd, weight) in zip(records, epochs, strict=True): 

535 record = coaddInputs.ccds.addNew() 

536 record.setWcs(wcs) 

537 record.setBBox(bbox) 

538 record.set(visitKey, visit) 

539 record.set(weightKey, weight) 

540 if instrument is not None: 

541 date = DateTime(mjd, DateTime.MJD, DateTime.TAI) if mjd is not None else DateTime() 

542 record.setVisitInfo(lsst.afw.image.VisitInfo(instrumentLabel=instrument, date=date)) 

543 return coaddInputs 

544 

545 def _patchHandles(self, tract, records, instrument="TestCam", epochs=None): 

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

547 replaced by ``records`` from ``instrument``, observed at ``epochs``. 

548 """ 

549 handles = [] 

550 for ref in self.patches[tract]: 

551 coadd = ref.get() 

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

553 epochs=epochs)) 

554 handles.append(pipeBase.InMemoryDatasetHandle( 

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

556 return handles 

557 

558 def testCorrectVarianceWarpCorrelation(self): 

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

560 the coadds and the one this task performs. 

561 """ 

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

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

564 ("lanczos5", "lanczos3"), 

565 ("lanczos5", "lanczos5"), 

566 ("bilinear", "lanczos3")): 

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

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

569 instrument="TestCam", 

570 coaddWarpKernel=coaddKernel, 

571 templateWarpKernel=templateKernel) 

572 factor = task.metadata["varianceWarpCorrelationFactor"] 

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

574 # larger. 

575 self.assertGreater(factor, 1.0) 

576 # The phase average converges slowly for bilinear, because 

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

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

579 self.assertFloatsAlmostEqual( 

580 factor, 

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

582 rtol=rtol) 

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

584 if templateKernel == "lanczos3": 

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

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

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

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

589 

590 def testCorrectVariancePlateScale(self): 

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

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

593 """ 

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

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

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

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

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

599 

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

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

602 task, on = self._runCorrection() 

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

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

605 

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

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

608 for scaleFactor in (1.315, 0.5): 

609 with self.subTest(scaleFactor=scaleFactor): 

610 handles = self._patchHandles( 

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

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

613 factor = task.metadata["variancePlateScaleFactor"] 

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

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

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

617 

618 def testVarianceCorrectionsCompose(self): 

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

620 

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

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

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

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

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

626 original value times the claimed change in pixel area. 

627 """ 

628 plateScale = 1.315 

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

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

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

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

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

634 

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

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

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

638 * task.metadata["variancePlateScaleFactor"]) 

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

640 scale, rtol=1e-12) 

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

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

643 

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

645 # feeds back into how the patches are combined. 

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

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

648 

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

650 # before calibrating to nJy. 

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

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

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

654 

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

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

657 self.assertFloatsAlmostEqual( 

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

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

660 

661 def testCorrectVariancePlateScaleUsesOneRecord(self): 

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

663 

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

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

666 of them. 

667 """ 

668 handles = self._patchHandles( 

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

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

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

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

673 

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

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

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

677 

678 def testCorrectVariancePlateScaleSkipsDroppedCoadd(self): 

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

680 one dropped for its instrument. 

681 """ 

682 bbox = self.exposure.getBBox() 

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

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

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

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

687 

688 def testCorrectVariancePlateScaleNextTract(self): 

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

690 first coadd kept in the next tract is used. 

691 """ 

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

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

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

695 task.run(coaddExposureHandles=handles, 

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

697 wcs=self.exposure.wcs, 

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

699 physical_filter="a_test", 

700 instrument="SciCam") 

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

702 

703 def testCorrectVariancePlateScaleNeedsCoaddInputs(self): 

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

705 reconstructed, so the template cannot be built. 

706 """ 

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

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

709 self._runCorrection(handles=handles) 

710 

711 def testRunReturnsTemplateInputs(self): 

712 """`run` returns the inputs of each coadd that overlap the science 

713 image, with the part of the science image each one covers. Inputs 

714 with no weight or no observation time are left out, as are inputs 

715 that cover only the template border. 

716 """ 

717 wcs = self.exposure.wcs 

718 whole = (wcs, self.exposure.getBBox()) 

719 right = (wcs, lsst.geom.Box2I(lsst.geom.Point2I(90, -200), lsst.geom.Point2I(800, 800))) 

720 outside = (wcs, lsst.geom.Box2I(lsst.geom.Point2I(600, 600), lsst.geom.Point2I(800, 800))) 

721 border = (wcs, lsst.geom.Box2I(lsst.geom.Point2I(185, 0), lsst.geom.Point2I(195, 180))) 

722 handles = (self._patchHandles(0, [whole, right, outside, whole, whole, border], 

723 epochs=[(1, 60000.25, 1.0), (2, 60010.5, 3.0), (3, 60100.0, 1.0), 

724 (4, 60200.0, 0.0), (5, None, 1.0), (6, 60300.0, 1.0)])[:1] 

725 + self._patchHandles(0, [whole], epochs=[(1, 60000.25, 2.0)])[1:]) 

726 result = self._runTract0(handles, outputImageType="future") 

727 

728 templateInputs = result.templateInputs 

729 self.assertEqual(set(templateInputs), {(0, patch) for patch in range(4)}) 

730 first = templateInputs[0, 0] 

731 self.assertEqual([(i.visit, i.detector, i.weight) for i in first], [(1, 0, 1.0), (2, 0, 3.0)]) 

732 self.assertFloatsAlmostEqual(np.array([i.mid_mjd_tai for i in first]), np.array([60000.25, 60010.5]), 

733 rtol=0, atol=1e-6) 

734 # The box that _runTract0 requests. 

735 scienceBox = lsst.geom.Box2D(lsst.geom.Point2D(-0.5, -0.5), lsst.geom.Point2D(180.5, 180.5)) 

736 self.assertFloatsAlmostEqual(first[0].bounds.area, scienceBox.getArea(), rtol=1e-6) 

737 # Warping with lanczos5 left the 4.5 pixels along the input's edges 

738 # out of the coadd. 

739 rightArea = (scienceBox.getMaxX() - 94.0)*scienceBox.getHeight() 

740 self.assertFloatsAlmostEqual(first[1].bounds.area, rightArea, rtol=1e-6) 

741 self.assertFloatsAlmostEqual(first[1].bounds.x_vertices.min(), 94.0, rtol=0, atol=1e-6) 

742 # Each coadd lists its own record of an input shared with another. 

743 for patch in range(1, 4): 

744 self.assertEqual([(i.visit, i.weight) for i in templateInputs[0, patch]], [(1, 2.0)]) 

745 

746 def testRunTemplateInputsOnlyForFuture(self): 

747 """`run` records no template inputs for the legacy output type. 

748 """ 

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

750 result = self._runTract0(self._patchHandles(0, records, epochs=[(1, 60000.25, 1.0)])) 

751 self.assertEqual(result.templateInputs, {}) 

752 

753 def testRunTemplateInputsSkipDroppedCoadd(self): 

754 """A coadd dropped for its instrument has no template inputs. 

755 """ 

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

757 handles = (self._patchHandles(0, records, epochs=[(1, 60000.25, 1.0)])[:3] 

758 + self._patchHandles(0, records, instrument="OtherCam", 

759 epochs=[(2, 60100.0, 1.0)])[3:]) 

760 result = self._runTract0(handles, outputImageType="future") 

761 self.assertEqual(set(result.templateInputs), {(0, 0), (0, 1), (0, 2)}) 

762 

763 def testRunTemplateInputsLeaveOutWarpBorder(self): 

764 """An input's footprint leaves out the border along its edges that 

765 the coadd's warping kernel left out. 

766 """ 

767 right = (self.exposure.wcs, lsst.geom.Box2I(lsst.geom.Point2I(90, -200), lsst.geom.Point2I(800, 800))) 

768 for kernel, border in (("bilinear", 0.5), ("lanczos3", 2.5)): 

769 with self.subTest(coaddWarpKernel=kernel): 

770 result = self._runTract0(self._patchHandles(0, [right], epochs=[(1, 60000.25, 1.0)]), 

771 outputImageType="future", coaddWarpKernel=kernel) 

772 bounds = result.templateInputs[0, 0][0].bounds 

773 self.assertFloatsAlmostEqual(bounds.x_vertices.min(), 89.5 + border, rtol=0, atol=1e-6) 

774 

775 def testRunTemplateInputsCoverScienceEdges(self): 

776 """An input's footprint reaches the edges of a distorted science image 

777 wherever the input covers them. 

778 """ 

779 # Science pixel (x, y) lands on input pixel (x, y + a*(x - xc)**2), 

780 # which distorts the top and bottom edges of the science image by about 

781 # 2 pixels between its corners. 

782 xc = 90.0 

783 left = (self.exposure.wcs, lsst.geom.Box2I(lsst.geom.Point2I(-200, -200), lsst.geom.Point2I(89, 800))) 

784 # The centers of the edge pixels that the input's warp covers; warping 

785 # with lanczos5 left the 4.5 pixels along the input's edges out. 

786 x = np.arange(0.0, 85.0) 

787 y = np.arange(0.0, 181.0) 

788 edgeX = np.concatenate([x, x, np.zeros_like(y)]) 

789 edgeY = np.concatenate([np.zeros_like(x), np.full_like(x, 180.0), y]) 

790 for a in (-2.5e-4, 2.5e-4): 

791 with self.subTest(a=a): 

792 forward = np.array([[1.0, 1, 1, 0], [1.0, 2, 0, 1], [a, 2, 2, 0], [-2*a*xc, 2, 1, 0], 

793 [a*xc**2, 2, 0, 0]]) 

794 inverse = np.array([[1.0, 1, 1, 0], [1.0, 2, 0, 1], [-a, 2, 2, 0], [2*a*xc, 2, 1, 0], 

795 [-a*xc**2, 2, 0, 0]]) 

796 distortion = lsst.afw.geom.TransformPoint2ToPoint2(astshim.PolyMap(forward, inverse)) 

797 wcs = lsst.afw.geom.makeModifiedWcs(distortion, self.exposure.wcs, False) 

798 result = self._runTract0(self._patchHandles(0, [left], epochs=[(1, 60000.25, 1.0)]), 

799 outputImageType="future", wcs=wcs) 

800 bounds = result.templateInputs[0, 0][0].bounds 

801 self.assertTrue(np.all(bounds.contains(x=edgeX, y=edgeY))) 

802 

803 def _runTract0(self, handles, outputImageType="legacy", wcs=None, coaddWarpKernel=None): 

804 """Build a template from the given tract 0 patch handles, with a task 

805 configured for ``outputImageType`` and ``coaddWarpKernel``, for a 

806 science image with ``wcs``, by default that of the test exposure. 

807 """ 

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

809 config = self._makeConfig() 

810 config.output_image_type = outputImageType 

811 if coaddWarpKernel is not None: 

812 config.coaddWarpKernel = coaddWarpKernel 

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

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

815 bbox=box, 

816 wcs=self.exposure.wcs if wcs is None else wcs, 

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

818 physical_filter="a_test") 

819 

820 def testRunRecordsInstrument(self): 

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

822 inputs. 

823 """ 

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

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

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

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

828 

829 def testRunUnknownInstrument(self): 

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

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

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

833 """ 

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

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

836 for unknown in (None, ""): 

837 with self.subTest(label=unknown): 

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

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

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

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

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

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

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

845 

846 # No coadd records an instrument. 

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

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

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

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

851 

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

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

854 

855 Parameters 

856 ---------- 

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

858 Patch handles, keyed by tract. 

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

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

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

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

863 instrument : `str`, optional 

864 Instrument of the science image. 

865 

866 Returns 

867 ------- 

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

869 The coadd inputs recorded on the template. 

870 dropped : `list` [`str`] 

871 The info messages about dropped coadds. 

872 """ 

873 if dataIds is None: 

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

875 if box is None: 

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

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

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

879 result = task.run(coaddExposureHandles=handles, 

880 bbox=box, 

881 wcs=self.exposure.wcs, 

882 dataIds=dataIds, 

883 physical_filter="a_test", 

884 instrument=instrument) 

885 dropped = [record for record in cm.records 

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

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

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

889 

890 def testRunDropsMinorityInstrument(self): 

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

892 made from are dropped. 

893 """ 

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

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

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

897 

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

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

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

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

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

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

904 

905 def testRunPrefersScienceInstrument(self): 

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

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

908 """ 

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

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

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

912 

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

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

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

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

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

918 

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

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

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

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

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

924 

925 def testRunInstrumentTie(self): 

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

927 """ 

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

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

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

931 

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

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

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

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

936 

937 def testRunDropsNonOverlappingCoadd(self): 

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

939 overlap the template. 

940 """ 

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

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

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

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

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

946 

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

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

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

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

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

952 

953 def _scaleInputVariance(self, tract, factor): 

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

955 planes multiplied by ``factor``. 

956 

957 Parameters 

958 ---------- 

959 tract : `int` 

960 Id of the tract whose patches should be copied. 

961 factor : `float` 

962 Factor to multiply the input variance planes by. 

963 

964 Returns 

965 ------- 

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

967 Handles to the modified patches. 

968 """ 

969 handles = [] 

970 for handle in self.patches[tract]: 

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

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

973 patch = handle.get() 

974 patch.variance.array *= factor 

975 handles.append(pipeBase.InMemoryDatasetHandle(patch, 

976 storageClass="ExposureF", 

977 copy=True, 

978 dataId=handle.dataId)) 

979 return handles 

980 

981 def testScaleVariance(self): 

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

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

984 task metadata. 

985 """ 

986 scaleFactor = 1.345 

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

988 

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

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

991 variance planes by ``varianceScale`` first. 

992 """ 

993 config = self._makeConfig() 

994 config.doScaleVariance = doScaleVariance 

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

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

997 bbox=box, 

998 wcs=self.exposure.wcs, 

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

1000 physical_filter="a_test") 

1001 return task, result.template 

1002 

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

1004 # is recorded in the metadata. 

1005 taskOff, templateOff = _configureAndRunTask(doScaleVariance=False) 

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

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

1008 

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

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

1011 # with white noise and no sources. 

1012 taskOn, templateOn = _configureAndRunTask(doScaleVariance=True) 

1013 factor = taskOn.metadata["scaleTemplateVarianceFactor"] 

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

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

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

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

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

1019 # factor applied to the variance plane. 

1020 self.assertFloatsAlmostEqual(templateOn.variance.array, 

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

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

1023 # not bitwise identical. 

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

1025 

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

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

1028 # output variance plane is recovered. 

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

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

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

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

1033 

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

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

1036 future output type, without converting it. 

1037 

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

1039 

1040 Parameters 

1041 ---------- 

1042 raiseOnUndefinedMaskMap : `bool`, optional 

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

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

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

1046 

1047 Returns 

1048 ------- 

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

1050 The legacy output struct. 

1051 box : `lsst.geom.Box2I` 

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

1053 grew it by the template border. 

1054 """ 

1055 config = self._makeConfig() 

1056 config.output_image_type = "future" 

1057 config.raiseOnUndefinedMaskMap = raiseOnUndefinedMaskMap 

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

1059 self.visit = 9876 

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

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

1062 bbox=box, 

1063 wcs=self.exposure.wcs, 

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

1065 physical_filter="a_test", 

1066 visit=self.visit) 

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

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

1069 self.futureTask = task 

1070 return result, box 

1071 

1072 def _runFuture(self, handles=None): 

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

1074 

1075 Parameters 

1076 ---------- 

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

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

1079 

1080 Returns 

1081 ------- 

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

1083 The converted output struct. 

1084 box : `lsst.geom.Box2I` 

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

1086 grew it by the template border. 

1087 """ 

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

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

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

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

1092 result.template = self.futureTask.convert_outputs_to_future( 

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

1094 exposureRecord=self._exposureRecord(), templateInputs=result.templateInputs) 

1095 return result, box, detector 

1096 

1097 def _exposureRecord(self): 

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

1099 observation metadata of the science image. 

1100 """ 

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

1102 

1103 def _coaddRefs(self, tract): 

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

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

1106 

1107 Parameters 

1108 ---------- 

1109 tract : `int` 

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

1111 

1112 Returns 

1113 ------- 

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

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

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

1117 dataset id. 

1118 """ 

1119 if tract not in self.coaddRefs: 

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

1121 "ExposureF", universe=DimensionUniverse()) 

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

1123 for dataId in self.dataIds[tract]] 

1124 return self.coaddRefs[tract] 

1125 

1126 def testConvertOutputsToFuture(self): 

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

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

1129 """ 

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

1131 result, box = self._runLegacyForFuture() 

1132 legacy = result.template.clone() 

1133 result.template = self.futureTask.convert_outputs_to_future( 

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

1135 exposureRecord=self._exposureRecord()) 

1136 

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

1138 compare_masked_image_to_legacy(result.template, legacy.maskedImage, 

1139 plane_map=lsst.images.get_legacy_template_mask_planes()) 

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

1141 self.assertIsNotNone(result.template.detector) 

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

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

1144 # requested box and the detector. 

1145 expectedBox = lsst.geom.Box2I(box) 

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

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

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

1149 

1150 def testConvertOutputsToFutureCoaddMaskPlanes(self): 

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

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

1153 

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

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

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

1157 """ 

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

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

1160 "INJECTED", "INJECTED_CORE") 

1161 result, _ = self._runLegacyForFuture() 

1162 mask = result.template.mask 

1163 for n, plane in enumerate(planes): 

1164 mask.addMaskPlane(plane) 

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

1166 legacyTemplate = result.template.clone() 

1167 

1168 result.template = self.futureTask.convert_outputs_to_future( 

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

1170 exposureRecord=self._exposureRecord()) 

1171 

1172 template = result.template 

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

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

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

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

1177 optional = lsst.images.get_legacy_optional_mask_planes() 

1178 planeMap = lsst.images.get_legacy_template_mask_planes() 

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

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

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

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

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

1184 legacy = template.to_legacy() 

1185 for n, plane in enumerate(planes): 

1186 with self.subTest(plane=plane): 

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

1188 self.assertEqual( 

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

1190 

1191 def _addUnmappedMaskPlane(self, template): 

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

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

1194 which it does. 

1195 """ 

1196 mask = template.mask 

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

1198 mask.addMaskPlane(plane) 

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

1200 

1201 def testConvertOutputsToFutureUnmappedMaskPlaneRaises(self): 

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

1203 an unmapped mask plane cannot be converted. 

1204 """ 

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

1206 result, _ = self._runLegacyForFuture() 

1207 self._addUnmappedMaskPlane(result.template) 

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

1209 self.futureTask.convert_outputs_to_future( 

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

1211 exposureRecord=self._exposureRecord()) 

1212 

1213 def testConvertOutputsToFutureUnmappedMaskPlaneWarns(self): 

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

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

1216 """ 

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

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

1219 self._addUnmappedMaskPlane(result.template) 

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

1221 template = self.futureTask.convert_outputs_to_future( 

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

1223 exposureRecord=self._exposureRecord()) 

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

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

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

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

1228 

1229 def testConvertOutputsToFutureLosesProvenance(self): 

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

1231 `lsst.images.DifferenceImage`. 

1232 """ 

1233 legacy, _ = self._runLegacyForFuture() 

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

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

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

1237 

1238 def testConvertOutputsToFutureTemplates(self): 

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

1240 converted image. 

1241 """ 

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

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

1244 

1245 templates = result.template.templates 

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

1247 for template in templates: 

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

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

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

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

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

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

1254 self.assertFalse(template.psf_shape_flag) 

1255 self.assertGreater(template.psf_shape_xx, 0) 

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

1257 

1258 def testConvertOutputsToFutureInstrument(self): 

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

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

1261 """ 

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

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

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

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

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

1267 

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

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

1270 

1271 def testConvertOutputsToFutureTemplateInputs(self): 

1272 """Each coadd recorded on the converted template lists its inputs. 

1273 """ 

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

1275 handles = self._patchHandles(0, records, epochs=[(1, 60000.25, 1.0)]) 

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

1277 templates = result.template.templates 

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

1279 for template in templates: 

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

1281 self.assertEqual([(i.visit, i.detector, i.weight) for i in template.inputs], [(1, 0, 1.0)]) 

1282 self.assertFloatsAlmostEqual(template.inputs[0].mid_mjd_tai, 60000.25, rtol=0, atol=1e-6) 

1283 

1284 def testConvertOutputsToFuturePsf(self): 

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

1286 """ 

1287 result, _ = self._runLegacyForFuture() 

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

1289 legacyPsf = result.template.getPsf() 

1290 

1291 result.template = self.futureTask.convert_outputs_to_future( 

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

1293 exposureRecord=self._exposureRecord()) 

1294 

1295 psf = result.template.psf 

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

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

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

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

1300 

1301 def testConvertOutputsToFuturePsfToLegacy(self): 

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

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

1304 """ 

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

1306 

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

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

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

1310 

1311 

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

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

1314 variance correction. 

1315 """ 

1316 

1317 def setUp(self): 

1318 self.size = 1024 

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

1320 

1321 def _makeWcs(self, angle, offset): 

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

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

1324 """ 

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

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

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

1328 cdMatrix) 

1329 

1330 def testCorrelationFactorRestoresNoisePower(self): 

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

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

1333 

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

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

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

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

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

1339 """ 

1340 sigma = 2.0 

1341 rng = np.random.default_rng(42) 

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

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

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

1345 

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

1347 config.doScaleVariance = False 

1348 config.coaddWarpKernel = "lanczos3" 

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

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

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

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

1353 

1354 # The central quarter is covered in every frame. 

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

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

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

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

1359 pixelStd = image.std() 

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

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

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

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

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

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

1366 blockSize = 8 

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

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

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

1370 blockStd = sums.std()/blockSize 

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

1372 task._correctVariance(template, plateScaleFactor=1.0) 

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

1374 factor = task.metadata["varianceWarpCorrelationFactor"] 

1375 

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

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

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

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

1380 

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

1382 # matches. 

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

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

1385 

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

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

1388 # corrected plane deliberately exceeds it. 

1389 self.assertLess(pixelStd, 0.9*originalStd) 

1390 self.assertGreater(pixelStd, planeStdBefore) 

1391 self.assertLess(pixelStd, planeStdAfter) 

1392 

1393 

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

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

1396 

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

1398 """ 

1399 

1400 def testOutputImageType(self): 

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

1402 class, and leaves the input connections alone. 

1403 """ 

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

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

1406 

1407 connections = Connections(config=config) 

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

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

1410 

1411 config.output_image_type = "future" 

1412 connections = Connections(config=config) 

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

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

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

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

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

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

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

1420 # the one input the conversion adds. 

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

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

1423 

1424 def testLintConnections(self): 

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

1426 """ 

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

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

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

1430 

1431 

1432def setup_module(module): 

1433 lsst.utils.tests.init() 

1434 

1435 

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

1437 pass 

1438 

1439 

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

1441 lsst.utils.tests.init() 

1442 unittest.main()