Coverage for tests/test_getTemplate.py: 95%
610 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-31 02:11 -0700
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-31 02:11 -0700
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/>.
22import collections
23import itertools
24import unittest
25import unittest.mock
27import astropy.units as u
28from astropy.stats import gaussian_sigma_to_fwhm
29import numpy as np
31import lsst.afw.cameraGeom.testUtils
32import lsst.afw.detection
33import lsst.afw.geom
34import lsst.afw.image
35import lsst.afw.math
36import lsst.afw.table
37from lsst.daf.butler import DataCoordinate, DatasetRef, DatasetType, DimensionUniverse
38import lsst.geom
39import lsst.images
40import lsst.images.psfs
41from lsst.images.tests import compare_masked_image_to_legacy
42import lsst.ip.diffim
43import lsst.meas.algorithms
44import lsst.meas.base.tests
45import lsst.pex.exceptions
46import lsst.pipe.base as pipeBase
47import lsst.pipe.base.testUtils
48import lsst.skymap
49import lsst.utils.tests
51from utils import generate_data_id, makeTestExposureRecord
53# Change this to True, `setup display_ds9`, and open ds9 (or use another afw
54# display backend) to show the tract/patch layouts on the image.
55debug = False
56if debug: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 import lsst.afw.display
58 display = lsst.afw.display.Display()
59 display.frame = 1
61# Sum of squared warping kernel values along one axis, with the kernel
62# normalized to unit sum at each sub-pixel phase, averaged over phase.
63KERNEL_SUM_SQ_1D = {
64 "bilinear": 2/3,
65 "lanczos3": 0.8933699084,
66 "lanczos4": 0.9141249535,
67 "lanczos5": 0.9342245513,
68}
71def _showTemplate(box, template):
72 """Show the corners of the template we made in this test."""
73 for point in box.getCorners():
74 display.dot("+", point.x, point.y, ctype="orange", size=40)
75 display.frame = 2
76 display.image(template, "warped template")
77 display.frame = 3
78 display.image(template.variance, "warped variance")
81class GetTemplateTaskTestCase(lsst.utils.tests.TestCase):
82 """Test that GetTemplateTask works on both one tract and multiple tract
83 input coadd exposures.
85 Makes a synthetic exposure large enough to fit four small tracts with 2x2
86 (300x300 pixel) patches each, extracts pixels for those patches by warping,
87 and tests GetTemplateTask's output against boxes that overlap various
88 combinations of one or multiple tracts.
89 """
90 scale = 0.2 # arcsec/pixel
91 # Use 5th order to minimize artifacts on the templates.
92 coaddWarpKernel = "lanczos5"
94 @classmethod
95 def setUpClass(cls):
96 # Warping the patches is the slowest part of every test, so it is done
97 # once. Tests must not modify the exposure; the patch handles return
98 # copies, and setUp gives each test its own lists of them.
99 cls.skymap = cls._makeSkymap()
100 cls.patches = collections.defaultdict(list)
101 cls.dataIds = collections.defaultdict(list)
102 cls.exposure = cls._makeExposure()
104 if debug: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 display.image(cls.exposure, "base exposure")
107 for tract_id in range(4):
108 tract = cls.skymap.generateTract(tract_id)
109 cls._makePatches(tract)
111 @classmethod
112 def tearDownClass(cls):
113 del cls.skymap, cls.patches, cls.dataIds, cls.exposure
115 def setUp(self):
116 cls = type(self)
117 self.patches = collections.defaultdict(list, {k: list(v) for k, v in cls.patches.items()})
118 self.dataIds = collections.defaultdict(list, {k: list(v) for k, v in cls.dataIds.items()})
119 self.coaddRefs = {}
120 self.varianceBox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
122 @classmethod
123 def _makeSkymap(cls):
124 """Make a Skymap with 4 tracts with 4 patches each.
125 """
126 tractScale = 0.02 # degrees
127 # On-sky coordinates of the tract centers.
128 coords = [(0, 0),
129 (0, tractScale),
130 (tractScale, 0),
131 (tractScale, tractScale),
132 ]
133 config = lsst.skymap.DiscreteSkyMap.ConfigClass()
134 config.raList = [c[0] for c in coords]
135 config.decList = [c[1] for c in coords]
136 # Half the tract center step size, to keep the tract overlap small.
137 config.radiusList = [tractScale/2 for c in coords]
138 config.projection = "TAN"
139 config.pixelScale = cls.scale
140 config.tractOverlap = 0.0005
141 config.tractBuilder = "legacy"
142 config.tractBuilder["legacy"].patchInnerDimensions = (300, 300)
143 config.tractBuilder["legacy"].patchBorder = 10
144 return lsst.skymap.DiscreteSkyMap(config=config)
146 @classmethod
147 def _makeExposure(cls):
148 """Create a large image to break up into tracts and patches.
150 The image will have a source every 100 pixels in x and y, and a WCS
151 that results in the tracts all fitting in the image, with tract=0
152 in the lower left, tract=1 to the right, tract=2 above, and tract=3
153 to the upper right.
154 """
155 box = lsst.geom.Box2I(lsst.geom.Point2I(-200, -200), lsst.geom.Point2I(800, 800))
156 # This WCS was constructed so that tract 0 mostly fills the lower left
157 # quadrant of the image, and the other tracts fill the rest; slight
158 # extra rotation as a check on the final warp layout, scaled by 5%
159 # from the patch pixel scale.
160 cd_matrix = lsst.afw.geom.makeCdMatrix(1.05*cls.scale*lsst.geom.arcseconds, 93*lsst.geom.degrees)
161 wcs = lsst.afw.geom.makeSkyWcs(lsst.geom.Point2D(120, 150),
162 lsst.geom.SpherePoint(0, 0, lsst.geom.radians),
163 cd_matrix)
164 dataset = lsst.meas.base.tests.TestDataset(box, wcs=wcs)
165 for x, y in itertools.product(np.arange(0, 500, 100), np.arange(0, 500, 100)):
166 dataset.addSource(1e5, lsst.geom.Point2D(x, y))
167 exposure, _ = dataset.realize(2, dataset.makeMinimalSchema())
168 exposure.setFilter(lsst.afw.image.FilterLabel("a", "a_test"))
169 return exposure
171 @classmethod
172 def _makePatches(cls, tract):
173 """Populate the patches and dataId dicts, keyed on tract id, with the
174 warps of the main exposure and minimal dataIds, respectively.
175 """
176 if debug: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 color = ['red', 'green', 'cyan', 'yellow'][tract.tract_id]
178 point = cls.exposure.wcs.skyToPixel(tract.ctr_coord)
179 # Show the tract center, colored by tract id.
180 display.dot("x", point.x, point.y, ctype=color, size=30)
182 config = lsst.afw.math.Warper.ConfigClass()
183 config.warpingKernelName = cls.coaddWarpKernel
184 warper = lsst.afw.math.Warper.fromConfig(config)
185 for patchId in range(tract.num_patches.x*tract.num_patches.y):
186 patch = tract.getPatchInfo(patchId)
187 box = patch.getOuterBBox()
189 if debug: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was never true
190 # Show the patch corners as patch ids, colored by tract id.
191 points = cls.exposure.wcs.skyToPixel(patch.wcs.pixelToSky([lsst.geom.Point2D(x)
192 for x in box.getCorners()]))
193 for p in points:
194 display.dot(patchId, p.x, p.y, ctype=color)
196 # This is mostly taken from drp_tasks makePsfMatchedWarp, but
197 # ip_diffim cannot depend on drp_tasks.
198 xyTransform = lsst.afw.geom.makeWcsPairTransform(cls.exposure.wcs, patch.wcs)
199 warpedPsf = lsst.meas.algorithms.WarpedPsf(cls.exposure.psf, xyTransform)
200 warped = warper.warpExposure(patch.wcs, cls.exposure, destBBox=box)
201 warped.setPsf(warpedPsf)
203 warped.getInfo().setCoaddInputs(
204 cls._makeCoaddInputs([(cls.exposure.wcs, cls.exposure.getBBox())]))
205 dataRef = pipeBase.InMemoryDatasetHandle(
206 warped,
207 storageClass="ExposureF",
208 copy=True,
209 dataId=generate_data_id(
210 tract=tract,
211 patch=patch,
212 )
213 )
214 cls.patches[tract.tract_id].append(dataRef)
215 dataCoordinate = DataCoordinate.standardize({"tract": tract.tract_id,
216 "patch": patchId,
217 "band": "a",
218 "skymap": "skymap"},
219 universe=DimensionUniverse())
220 cls.dataIds[tract.tract_id].append(dataCoordinate)
222 def _checkMetadata(self, template, config, box, wcs, nPsfs):
223 """Check that the various metadata components were set correctly.
224 """
225 expectedBox = lsst.geom.Box2I(box)
226 expectedBox.grow(config.templateBorderSize)
227 self.assertEqual(template.getBBox(), expectedBox)
228 # WCS should match our exposure, not any of the coadd tracts.
229 for tract in self.patches:
230 self.assertNotEqual(template.wcs, self.patches[tract][0].get().wcs)
231 self.assertEqual(template.wcs, self.exposure.wcs)
232 # The template pixels are calibrated to nJy.
233 self.assertEqual(template.photoCalib, lsst.afw.image.PhotoCalib(1.0))
234 self.assertEqual(template.getXY0(), expectedBox.getMin())
235 self.assertEqual(template.filter.bandLabel, "a")
236 self.assertEqual(template.filter.physicalLabel, "a_test")
237 # The template PSF is a Gaussian with the width of the CoaddPsf of
238 # its inputs.
239 self.assertIsInstance(template.psf, lsst.afw.detection.GaussianPsf)
240 coaddPsf = self._makeCoaddPsf(template, config)
241 self.assertEqual(coaddPsf.getComponentCount(), nPsfs)
242 position = coaddPsf.getAveragePosition()
243 self.assertFloatsAlmostEqual(template.psf.getSigma(),
244 coaddPsf.computeShape(position).getTraceRadius())
245 self.assertTrue(template.getInfo().hasCoaddInputs())
246 self.assertEqual(len(template.getInfo().getCoaddInputs().ccds), nPsfs)
248 def _makeConfig(self):
249 """Make a GetTemplateTask config whose coaddWarpKernel is the kernel
250 the test patches were made with.
251 """
252 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
253 config.coaddWarpKernel = self.coaddWarpKernel
254 return config
256 def _makeCoaddPsf(self, template, config):
257 """Return the CoaddPsf that ``run`` approximated with a Gaussian,
258 rebuilt from the coadd inputs recorded on the template.
259 """
260 task = lsst.ip.diffim.GetTemplateTask(config=config)
261 return task._makePsf(template, template.getInfo().getCoaddInputs().ccds, template.wcs)
263 def _checkPixels(self, template, config, box):
264 """Check that the pixel values in the template are close to the
265 original image, calibrated to nJy.
266 """
267 # All pixels should have real values!
268 expectedBox = lsst.geom.Box2I(box)
269 expectedBox.grow(config.templateBorderSize)
270 calibration = self.exposure.photoCalib.getCalibrationMean()
271 expected = self.exposure.photoCalib.calibrateImage(self.exposure[expectedBox].maskedImage)
273 if debug: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 _showTemplate(expectedBox, template)
276 # Check that we fully filled the template from the patches.
277 self.assertTrue(np.all(np.isfinite(template.image.array)))
278 # Because of the scale changes, there will be some ringing in the
279 # difference between the template and the original image; pick
280 # tolerances large enough to account for that.
281 self.assertImagesAlmostEqual(template.image, expected.image, rtol=.1, atol=4*calibration)
282 # Variance plane ==4 in the original image (realize() takes a noise
283 # sigma). Warping sets the level from the pixel areas and the two
284 # warping kernels, which `_correctVariance` corrects. A per-pixel
285 # ripple that no scalar correction can remove remains on top of that,
286 # so check the level tightly and allow for the ripple around it.
287 variance = template.variance.array[np.isfinite(template.variance.array)]
288 median = np.median(variance)
289 self.assertFloatsAlmostEqual(median, np.median(expected.variance.array),
290 rtol=0.02, msg="variance level differs")
291 self.assertLess(np.percentile(variance, 99)/median, 1.6, msg="variance ripple too large")
292 self.assertGreater(np.percentile(variance, 1)/median, 0.6, msg="variance ripple too large")
293 # Not checking the mask, as warping changes the sizes of the masks.
295 def testRunOneTractInput(self):
296 """Test a bounding box that fully fits inside one tract, with only
297 that tract passed as input. This checks that the code handles a single
298 tract input correctly.
299 """
300 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
301 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
302 # Restrict to tract 0, since the box fits in just that tract.
303 result = task.run(coaddExposureHandles={0: self.patches[0]},
304 bbox=box,
305 wcs=self.exposure.wcs,
306 dataIds={0: self.dataIds[0]},
307 physical_filter="a_test")
309 # All 4 patches from tract 0 are included in this template.
310 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 4)
311 self._checkPixels(result.template, task.config, box)
313 def testRunOneTractMultipleInputs(self):
314 """Test a bounding box that fully fits inside one tract but where
315 multiple tracts were passed in. This checks that patches that are
316 mostly NaN after warping are merged correctly in the output.
317 """
318 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
319 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
320 result = task.run(coaddExposureHandles=self.patches,
321 bbox=box,
322 wcs=self.exposure.wcs,
323 dataIds=self.dataIds,
324 physical_filter="a_test")
326 # All 4 patches from two tracts are included in this template.
327 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 6)
328 self._checkPixels(result.template, task.config, box)
330 def testRunTwoTracts(self):
331 """Test a bounding box that crosses tract boundaries.
332 """
333 box = lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600))
334 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
335 result = task.run(coaddExposureHandles=self.patches,
336 bbox=box,
337 wcs=self.exposure.wcs,
338 dataIds=self.dataIds,
339 physical_filter="a_test")
341 # All 4 patches from all 4 tracts are included in this template
342 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 9)
343 self._checkPixels(result.template, task.config, box)
345 def testRunCalibratesToNanojansky(self):
346 """Test that the template is in nJy whatever the calibration of the
347 coadds, and that coadds already in nJy are not rescaled.
348 """
349 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
350 calibration = self.exposure.photoCalib.getCalibrationMean()
351 self.assertNotEqual(calibration, 1.0)
352 # The same coadd pixels, labelled as already in nJy.
353 uncalibrated = []
354 for handle in self.patches[0]:
355 coadd = handle.get()
356 coadd.setPhotoCalib(lsst.afw.image.PhotoCalib(1.0))
357 uncalibrated.append(pipeBase.InMemoryDatasetHandle(coadd, storageClass="ExposureF", copy=True,
358 dataId=handle.dataId))
359 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
360 templates = {}
361 for key, handles in ((calibration, self.patches[0]), (1.0, uncalibrated)):
362 result = task.run(coaddExposureHandles={0: handles},
363 bbox=box,
364 wcs=self.exposure.wcs,
365 dataIds={0: self.dataIds[0]},
366 physical_filter="a_test")
367 self.assertEqual(result.template.photoCalib, lsst.afw.image.PhotoCalib(1.0))
368 templates[key] = result.template
370 self.assertFloatsAlmostEqual(templates[calibration].image.array,
371 calibration*templates[1.0].image.array, rtol=1e-6)
372 self.assertFloatsAlmostEqual(templates[calibration].variance.array,
373 calibration**2*templates[1.0].variance.array, rtol=1e-6)
375 def testGaussianPsfGridFallback(self):
376 """If the CoaddPsf cannot be evaluated at its average position, the
377 Gaussian width is averaged over a grid of positions instead.
378 """
379 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
380 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
381 result = task.run(coaddExposureHandles={0: self.patches[0]},
382 bbox=box,
383 wcs=self.exposure.wcs,
384 dataIds={0: self.dataIds[0]},
385 physical_filter="a_test")
386 template = result.template
387 template.setPsf(self._makeCoaddPsf(template, task.config))
388 expected = lsst.ip.diffim.utils.evaluateMeanPsfFwhm(
389 template,
390 fwhmExposureBuffer=task.config.fwhmExposureBuffer,
391 fwhmExposureGrid=task.config.fwhmExposureGrid,
392 )/gaussian_sigma_to_fwhm
394 error = lsst.pex.exceptions.InvalidParameterError("No inputs at the average position.")
395 with unittest.mock.patch("lsst.ip.diffim.getTemplate.getPsfFwhm", side_effect=error):
396 with self.assertLogs(task.log.name, level="INFO") as cm:
397 psf = task._makeGaussianPsf(template)
399 self.assertIn("grid of points", "\n".join(cm.output))
400 self.assertIsInstance(psf, lsst.afw.detection.GaussianPsf)
401 self.assertFloatsAlmostEqual(psf.getSigma(), expected)
402 self.assertFloatsAlmostEqual(task.metadata["templatePsfSigma"], expected)
404 def testRunNoTemplate(self):
405 """A bounding box that doesn't overlap the patches will raise.
406 """
407 box = lsst.geom.Box2I(lsst.geom.Point2I(1200, 1200), lsst.geom.Point2I(1600, 1600))
408 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
409 with self.assertRaisesRegex(lsst.pipe.base.NoWorkFound, "No patches found"):
410 task.run(coaddExposureHandles=self.patches,
411 bbox=box,
412 wcs=self.exposure.wcs,
413 dataIds=self.dataIds,
414 physical_filter="a_test")
416 def testMissingPatches(self):
417 """Test that a missing patch results in an appropriate mask.
419 This fixes the bug reported on DM-44997 (image and variance were NaN
420 but the mask was not set to NO_DATA for those pixels).
421 """
422 # tract=0, patch=1 is the lower-left corner, as displayed in DS9.
423 self.patches[0].pop(1)
424 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
425 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
426 result = task.run(coaddExposureHandles=self.patches,
427 bbox=box,
428 wcs=self.exposure.wcs,
429 dataIds=self.dataIds,
430 physical_filter="a_test")
431 no_data = (result.template.mask.array & result.template.mask.getPlaneBitMask("NO_DATA")) != 0
432 self.assertTrue(np.isfinite(result.template.image.array).all())
433 self.assertTrue(np.isfinite(result.template.variance.array).all())
434 self.assertEqual(no_data.sum(), 20990)
436 @lsst.utils.tests.methodParameters(
437 box=[
438 lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)),
439 lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600)),
440 ],
441 nInput=[8, 16],
442 )
443 def testNanInputs(self, box=None, nInput=None):
444 """Test that the template has finite values when some of the input
445 pixels have NaN as variance.
446 """
447 for tract, patchRefs in self.patches.items():
448 for patchRef in patchRefs:
449 patchCoadd = patchRef.get()
450 bbox = lsst.geom.Box2I()
451 bbox.include(lsst.geom.Point2I(patchCoadd.getBBox().getCenter()))
452 bbox.grow(3)
453 patchCoadd.variance[bbox].array *= np.nan
455 box = lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600))
456 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
457 result = task.run(coaddExposureHandles=self.patches,
458 bbox=box,
459 wcs=self.exposure.wcs,
460 dataIds=self.dataIds,
461 physical_filter="a_test")
462 if debug: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 _showTemplate(box, result.template)
464 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 9)
465 # We just check that the pixel values are all finite. We cannot check that pixel values
466 # in the template are closer to the original anymore.
467 self.assertTrue(np.isfinite(result.template.image.array).all())
469 def _runCorrection(self, doCorrectVarianceWarpCorrelation=False, doScaleVariance=False,
470 handles=None, instrument="SciCam", coaddWarpKernel=None,
471 templateWarpKernel="lanczos3"):
472 """Run the task on tract 0, with a science image from ``instrument``.
474 The plate scale correction is applied only when ``instrument`` is not
475 the instrument of the template coadds, which is ``"TestCam"`` unless
476 ``handles`` says otherwise. ``coaddWarpKernel`` defaults to the kernel
477 the patches were made with, so the reconstructed factors are exact.
478 """
479 config = self._makeConfig()
480 config.doCorrectVarianceWarpCorrelation = doCorrectVarianceWarpCorrelation
481 config.doScaleVariance = doScaleVariance
482 if coaddWarpKernel is not None:
483 config.coaddWarpKernel = coaddWarpKernel
484 config.warp.warpingKernelName = templateWarpKernel
485 task = lsst.ip.diffim.GetTemplateTask(config=config)
486 result = task.run(coaddExposureHandles={0: handles or self.patches[0]},
487 bbox=lsst.geom.Box2I(self.varianceBox),
488 wcs=self.exposure.wcs,
489 dataIds={0: self.dataIds[0]},
490 physical_filter="a_test",
491 instrument=instrument)
492 return task, result.template
494 def _makeScaledWcs(self, factor):
495 """Make a WCS like the base exposure's, but with its pixel scale
496 multiplied by ``factor``.
497 """
498 cdMatrix = lsst.afw.geom.makeCdMatrix(factor*1.05*self.scale*lsst.geom.arcseconds,
499 93*lsst.geom.degrees)
500 return lsst.afw.geom.makeSkyWcs(lsst.geom.Point2D(120, 150),
501 lsst.geom.SpherePoint(0, 0, lsst.geom.radians),
502 cdMatrix)
504 @staticmethod
505 def _makeCoaddInputs(records, instrument="TestCam"):
506 """Make a CoaddInputs holding the given input records.
508 Parameters
509 ----------
510 records : `list` [`tuple` [`lsst.afw.geom.SkyWcs` or `None`, \
511 `lsst.geom.Box2I`]]
512 The WCS and bbox to record for each input. A `None` WCS makes a
513 record that `_plateScaleFactor` has to skip. May be empty, to
514 simulate a coadd whose plate scale cannot be reconstructed.
515 instrument : `str` or `None`, optional
516 Instrument label to record for every input. `None` leaves the
517 inputs without a visit info, and an empty string gives them a
518 visit info with no label; either way the instrument is unknown.
519 """
520 ccdSchema = lsst.afw.table.ExposureTable.makeMinimalSchema()
521 weightKey = ccdSchema.addField("weight", type=float, doc="Coadd weight")
522 coaddInputs = lsst.afw.image.CoaddInputs(
523 lsst.afw.table.ExposureTable.makeMinimalSchema(), ccdSchema)
524 for wcs, bbox in records:
525 record = coaddInputs.ccds.addNew()
526 record.setWcs(wcs)
527 record.setBBox(bbox)
528 if instrument is not None:
529 record.setVisitInfo(lsst.afw.image.VisitInfo(instrumentLabel=instrument))
530 # Included because real coadds have it, though a single-record
531 # correction does not use it.
532 record.set(weightKey, 1.0)
533 return coaddInputs
535 def _patchHandles(self, tract, records, instrument="TestCam"):
536 """Return handles for a tract's patches, with their CoaddInputs
537 replaced by ``records`` from ``instrument``.
538 """
539 handles = []
540 for ref in self.patches[tract]:
541 coadd = ref.get()
542 coadd.getInfo().setCoaddInputs(self._makeCoaddInputs(records, instrument=instrument))
543 handles.append(pipeBase.InMemoryDatasetHandle(
544 coadd, storageClass="ExposureF", copy=True, dataId=ref.dataId))
545 return handles
547 def testCorrectVarianceWarpCorrelation(self):
548 """The correlation correction covers both warps: the one that built
549 the coadds and the one this task performs.
550 """
551 _, off = self._runCorrection(instrument="TestCam")
552 for coaddKernel, templateKernel in (("lanczos3", "lanczos3"),
553 ("lanczos5", "lanczos3"),
554 ("lanczos5", "lanczos5"),
555 ("bilinear", "lanczos3")):
556 with self.subTest(coaddWarpKernel=coaddKernel, templateWarpKernel=templateKernel):
557 task, on = self._runCorrection(doCorrectVarianceWarpCorrelation=True,
558 instrument="TestCam",
559 coaddWarpKernel=coaddKernel,
560 templateWarpKernel=templateKernel)
561 factor = task.metadata["varianceWarpCorrelationFactor"]
562 # A kernel spreads power, so this always makes the plane
563 # larger.
564 self.assertGreater(factor, 1.0)
565 # The phase average converges slowly for bilinear, because
566 # its sum of squares has a kink where the phase wraps.
567 rtol = 1e-4 if "bilinear" in (coaddKernel, templateKernel) else 1e-5
568 self.assertFloatsAlmostEqual(
569 factor,
570 1/(KERNEL_SUM_SQ_1D[coaddKernel]*KERNEL_SUM_SQ_1D[templateKernel])**2,
571 rtol=rtol)
572 self.assertEqual(task.metadata["variancePlateScaleFactor"], 1.0)
573 if templateKernel == "lanczos3":
574 # A pure scalar, with no per-pixel structure of its own;
575 # only comparable when `off` used the same template warp.
576 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*factor,
577 rtol=1e-5, ignoreNaNs=True)
579 def testCorrectVariancePlateScale(self):
580 """The plate scale correction is the total pixel area change from the
581 images the coadds were built from to the science image.
582 """
583 # The science image and the coadds are from the same instrument, so
584 # the factor is 1 without reading any coadd inputs.
585 task, off = self._runCorrection(instrument="TestCam")
586 self.assertEqual(task.metadata["variancePlateScaleFactor"], 1.0)
587 self.assertNotIn("templateVarianceCorrectionFactor", task.metadata)
589 # The fixture's records are the science image itself, so there is no
590 # net change in pixel area even when the instruments differ.
591 task, on = self._runCorrection()
592 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.0, rtol=1e-6)
593 self.assertNotIn("varianceWarpCorrelationFactor", task.metadata)
595 # Coarser original pixels than science pixels, the DECam-template
596 # case: the correction is the ratio of their areas.
597 for scaleFactor in (1.315, 0.5):
598 with self.subTest(scaleFactor=scaleFactor):
599 handles = self._patchHandles(
600 0, [(self._makeScaledWcs(scaleFactor), self.exposure.getBBox())])
601 task, on = self._runCorrection(handles=handles)
602 factor = task.metadata["variancePlateScaleFactor"]
603 self.assertFloatsAlmostEqual(factor, scaleFactor**2, rtol=1e-6)
604 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*factor,
605 rtol=1e-5, ignoreNaNs=True)
607 def testVarianceCorrectionsCompose(self):
608 """The two corrections are independent and multiply together.
610 The patches are warped from the exposure and the template is warped
611 back to its pixel grid, so the two warps cancel geometrically, leaving
612 only the two kernels' correlation to undo. The substituted coadd
613 inputs claim coarser original pixels than the exposure really has, so
614 with both corrections applied the variance must come back to its
615 original value times the claimed change in pixel area.
616 """
617 plateScale = 1.315
618 handles = self._patchHandles(0, [(self._makeScaledWcs(plateScale), self.exposure.getBBox())])
619 # A science image from the coadds' instrument applies no correction
620 # at all; one from another instrument applies both.
621 _, off = self._runCorrection(handles=handles, instrument="TestCam")
622 task, on = self._runCorrection(doCorrectVarianceWarpCorrelation=True, handles=handles)
624 # Both together is exactly the product of the two factors.
625 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], plateScale**2, rtol=1e-6)
626 scale = (task.metadata["varianceWarpCorrelationFactor"]
627 * task.metadata["variancePlateScaleFactor"])
628 self.assertFloatsAlmostEqual(task.metadata["templateVarianceCorrectionFactor"],
629 scale, rtol=1e-12)
630 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*scale,
631 rtol=1e-5, ignoreNaNs=True)
633 # Only the variance plane is touched. Nothing about the correction
634 # feeds back into how the patches are combined.
635 self.assertImagesEqual(on.image, off.image)
636 self.assertMasksEqual(on.mask, off.mask)
638 # realize() takes a noise sigma, so the original variance is 4, not 2,
639 # before calibrating to nJy.
640 calibration = self.exposure.photoCalib.getCalibrationMean()
641 original = calibration**2*np.median(self.exposure.variance.array)
642 self.assertFloatsAlmostEqual(np.median(on.variance.array), original*plateScale**2, rtol=0.02)
644 # Which is a real correction: uncorrected, the plane is too small by
645 # the two warping kernels' phase-averaged sums of squares.
646 self.assertFloatsAlmostEqual(
647 np.median(off.variance.array)/original,
648 (KERNEL_SUM_SQ_1D[self.coaddWarpKernel]*KERNEL_SUM_SQ_1D["lanczos3"])**2, rtol=0.02)
650 def testCorrectVariancePlateScaleUsesOneRecord(self):
651 """Only the first usable coadd input is read.
653 The spread between records is just the local pixel scale, a few
654 tenths of a percent across a real focal plane, so one stands for all
655 of them.
656 """
657 handles = self._patchHandles(
658 0, [(None, self.exposure.getBBox()),
659 (self._makeScaledWcs(2.0), self.exposure.getBBox()),
660 (self.exposure.wcs, self.exposure.getBBox())])
661 task, _ = self._runCorrection(handles=handles)
663 # The first record with a WCS: the one without is skipped, and the
664 # last (which would give 1.0) is never reached.
665 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 4.0, rtol=1e-6)
667 def testCorrectVariancePlateScaleSkipsDroppedCoadd(self):
668 """The plate scale comes from the first coadd that is used, not from
669 one dropped for its instrument.
670 """
671 bbox = self.exposure.getBBox()
672 handles = (self._patchHandles(0, [(self._makeScaledWcs(2.0), bbox)], instrument="OtherCam")[:1]
673 + self._patchHandles(0, [(self._makeScaledWcs(1.315), bbox)])[1:])
674 task, _ = self._runCorrection(handles=handles)
675 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.315**2, rtol=1e-6)
677 def testCorrectVariancePlateScaleNextTract(self):
678 """If the first coadd kept in a tract cannot give the plate scale, the
679 first coadd kept in the next tract is used.
680 """
681 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
682 handles = {0: self._patchHandles(0, []),
683 1: self._patchHandles(1, [(self._makeScaledWcs(1.315), self.exposure.getBBox())])}
684 task.run(coaddExposureHandles=handles,
685 bbox=lsst.geom.Box2I(self.varianceBox),
686 wcs=self.exposure.wcs,
687 dataIds={0: self.dataIds[0], 1: self.dataIds[1]},
688 physical_filter="a_test",
689 instrument="SciCam")
690 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.315**2, rtol=1e-6)
692 def testCorrectVariancePlateScaleNeedsCoaddInputs(self):
693 """Without usable coadd inputs the plate scale cannot be
694 reconstructed, so the template cannot be built.
695 """
696 handles = self._patchHandles(0, [])
697 with self.assertRaisesRegex(RuntimeError, "cannot be corrected for the change in pixel area"):
698 self._runCorrection(handles=handles)
700 def _runTract0(self, handles):
701 """Build a template from the given tract 0 patch handles.
702 """
703 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
704 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
705 return task.run(coaddExposureHandles={0: handles},
706 bbox=box,
707 wcs=self.exposure.wcs,
708 dataIds={0: self.dataIds[0]},
709 physical_filter="a_test")
711 def testRunRecordsInstrument(self):
712 """The instrument of each coadd is recorded with the template's coadd
713 inputs.
714 """
715 result = self._runTract0(self.patches[0])
716 ccds = result.template.getInfo().getCoaddInputs().ccds
717 self.assertEqual(len(ccds), 4)
718 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
720 def testRunUnknownInstrument(self):
721 """A coadd whose inputs record no instrument is dropped when the
722 other coadds record one, and kept, with the template's instrument
723 ``"UNKNOWN"``, when none of them records one.
724 """
725 records = [(self.exposure.wcs, self.exposure.getBBox())]
726 # Inputs with no visit info, and with a visit info but no label.
727 for unknown in (None, ""):
728 with self.subTest(label=unknown):
729 handles = (self._patchHandles(0, records, instrument=unknown)[:1]
730 + self._patchHandles(0, records)[1:])
731 ccds, dropped = self._runLogged({0: handles})
732 self.assertEqual(len(ccds), 3)
733 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
734 self.assertEqual(len(dropped), 1)
735 self.assertIn("made from UNKNOWN", dropped[0])
737 # No coadd records an instrument.
738 result = self._runTract0(self._patchHandles(0, records, instrument=unknown))
739 ccds = result.template.getInfo().getCoaddInputs().ccds
740 self.assertEqual(len(ccds), 4)
741 self.assertEqual({record["instrument"] for record in ccds}, {"UNKNOWN"})
743 def _runLogged(self, handles, dataIds=None, box=None, instrument=None):
744 """Build a template, capturing the messages about dropped coadds.
746 Parameters
747 ----------
748 handles : `dict` [`int`, `list`]
749 Patch handles, keyed by tract.
750 dataIds : `dict` [`int`, `list`], optional
751 Data ids matching ``handles``; defaults to the fixture's.
752 box : `lsst.geom.Box2I`, optional
753 Box to build the template on; defaults to one in tract 0.
754 instrument : `str`, optional
755 Instrument of the science image.
757 Returns
758 -------
759 ccds : `lsst.afw.table.ExposureCatalog`
760 The coadd inputs recorded on the template.
761 dropped : `list` [`str`]
762 The info messages about dropped coadds.
763 """
764 if dataIds is None:
765 dataIds = {tract: self.dataIds[tract] for tract in handles}
766 if box is None:
767 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
768 task = lsst.ip.diffim.GetTemplateTask(config=self._makeConfig())
769 with self.assertLogs(task.log.name, level="INFO") as cm:
770 result = task.run(coaddExposureHandles=handles,
771 bbox=box,
772 wcs=self.exposure.wcs,
773 dataIds=dataIds,
774 physical_filter="a_test",
775 instrument=instrument)
776 dropped = [record for record in cm.records
777 if record.getMessage().startswith("Dropping template coadd")]
778 self.assertEqual({record.levelname for record in dropped}, {"INFO"} if dropped else set())
779 return result.template.getInfo().getCoaddInputs().ccds, [record.getMessage() for record in dropped]
781 def testRunDropsMinorityInstrument(self):
782 """Coadds made from an instrument other than the one most coadds were
783 made from are dropped.
784 """
785 records = [(self.exposure.wcs, self.exposure.getBBox())]
786 handles = (self._patchHandles(0, records)[:3]
787 + self._patchHandles(0, records, instrument="OtherCam")[3:])
789 ccds, dropped = self._runLogged({0: handles})
790 self.assertEqual({record["patch"] for record in ccds}, {0, 1, 2})
791 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
792 self.assertEqual(len(dropped), 1)
793 self.assertIn(str(self.dataIds[0][3]), dropped[0])
794 self.assertIn("made from OtherCam", dropped[0])
796 def testRunPrefersScienceInstrument(self):
797 """Coadds made from the science image's instrument are used even when
798 they are in the minority; otherwise the majority instrument is used.
799 """
800 records = [(self.exposure.wcs, self.exposure.getBBox())]
801 handles = (self._patchHandles(0, records, instrument="OtherCam")[:3]
802 + self._patchHandles(0, records)[3:])
804 ccds, dropped = self._runLogged({0: handles}, instrument="TestCam")
805 self.assertEqual({record["patch"] for record in ccds}, {3})
806 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
807 self.assertEqual(len(dropped), 3)
808 self.assertTrue(all("made from OtherCam" in message for message in dropped))
810 # No coadd was made from the science image's instrument.
811 ccds, dropped = self._runLogged({0: handles}, instrument="SciCam")
812 self.assertEqual({record["patch"] for record in ccds}, {0, 1, 2})
813 self.assertEqual({record["instrument"] for record in ccds}, {"OtherCam"})
814 self.assertEqual(len(dropped), 1)
816 def testRunInstrumentTie(self):
817 """A tie goes to the instrument whose name sorts first.
818 """
819 records = [(self.exposure.wcs, self.exposure.getBBox())]
820 handles = (self._patchHandles(0, records)[:2]
821 + self._patchHandles(0, records, instrument="OtherCam")[2:])
823 ccds, dropped = self._runLogged({0: handles})
824 self.assertEqual({record["patch"] for record in ccds}, {2, 3})
825 self.assertEqual({record["instrument"] for record in ccds}, {"OtherCam"})
826 self.assertEqual(len(dropped), 2)
828 def testRunDropsNonOverlappingCoadd(self):
829 """A coadd from another instrument is dropped even if it does not
830 overlap the template.
831 """
832 records = [(self.exposure.wcs, self.exposure.getBBox())]
833 # Tract 3 does not overlap a box in the lower left of tract 0.
834 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(50, 50))
835 handles = {0: self.patches[0], 3: self._patchHandles(3, records, instrument="OtherCam")[:1]}
836 dataIds = {0: self.dataIds[0], 3: self.dataIds[3][:1]}
838 ccds, dropped = self._runLogged(handles, dataIds=dataIds, box=box)
839 self.assertEqual({record["tract"] for record in ccds}, {0})
840 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
841 self.assertEqual(len(dropped), 1)
842 self.assertIn(str(self.dataIds[3][0]), dropped[0])
844 def _scaleInputVariance(self, tract, factor):
845 """Return fresh handles for one tract's patches, with their variance
846 planes multiplied by ``factor``.
848 Parameters
849 ----------
850 tract : `int`
851 Id of the tract whose patches should be copied.
852 factor : `float`
853 Factor to multiply the input variance planes by.
855 Returns
856 -------
857 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`]
858 Handles to the modified patches.
859 """
860 handles = []
861 for handle in self.patches[tract]:
862 # ``copy=True`` on the original handles means this is a copy, so
863 # the patches shared with the other tests are left untouched.
864 patch = handle.get()
865 patch.variance.array *= factor
866 handles.append(pipeBase.InMemoryDatasetHandle(patch,
867 storageClass="ExposureF",
868 copy=True,
869 dataId=handle.dataId))
870 return handles
872 def testScaleVariance(self):
873 """Test that the template variance plane is rescaled to match the
874 empirical pixel noise, and that the factor used is recorded in the
875 task metadata.
876 """
877 scaleFactor = 1.345
878 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
880 def _configureAndRunTask(doScaleVariance, varianceScale=1.):
881 """Build a template from tract 0, optionally rescaling the input
882 variance planes by ``varianceScale`` first.
883 """
884 config = self._makeConfig()
885 config.doScaleVariance = doScaleVariance
886 task = lsst.ip.diffim.GetTemplateTask(config=config)
887 result = task.run(coaddExposureHandles={0: self._scaleInputVariance(0, varianceScale)},
888 bbox=box,
889 wcs=self.exposure.wcs,
890 dataIds={0: self.dataIds[0]},
891 physical_filter="a_test")
892 return task, result.template
894 # With scaling disabled the subtask is never constructed, and nothing
895 # is recorded in the metadata.
896 taskOff, templateOff = _configureAndRunTask(doScaleVariance=False)
897 self.assertFalse(hasattr(taskOff, "scaleVariance"))
898 self.assertNotIn("scaleTemplateVarianceFactor", taskOff.metadata)
900 # The image is correlated after warping, so ``doScaleVariance`` will
901 # apply a correction. The value here is empirical from a warped image
902 # with white noise and no sources.
903 taskOn, templateOn = _configureAndRunTask(doScaleVariance=True)
904 factor = taskOn.metadata["scaleTemplateVarianceFactor"]
905 self.assertFloatsAlmostEqual(factor, 1.145, atol=0.01,
906 msg="Measured template variance scaling changed; check the"
907 " warping kernels used by _makePatches and the task,"
908 " and the order of the variance corrections.")
909 # The only difference from the unscaled template is the constant
910 # factor applied to the variance plane.
911 self.assertFloatsAlmostEqual(templateOn.variance.array,
912 templateOff.variance.array*factor, rtol=1e-5)
913 # Tolerance here is float32 round-off: repeated runs of the task are
914 # not bitwise identical.
915 self.assertImagesAlmostEqual(templateOn.image, templateOff.image, rtol=1e-5, atol=1e-5)
917 # If the input variance planes under-estimate the noise by a known
918 # factor, the measured factor grows by that amount and the same
919 # output variance plane is recovered.
920 taskLow, templateLow = _configureAndRunTask(doScaleVariance=True, varianceScale=1/scaleFactor)
921 self.assertFloatsAlmostEqual(taskLow.metadata["scaleTemplateVarianceFactor"],
922 factor*scaleFactor, rtol=1e-5)
923 self.assertImagesAlmostEqual(templateLow.variance, templateOn.variance, rtol=1e-5)
925 def _runLegacyForFuture(self, raiseOnUndefinedMaskMap=True, handles=None):
926 """Build a template from tract 0 with a task configured for the
927 future output type, without converting it.
929 The task is kept as ``self.futureTask``.
931 Parameters
932 ----------
933 raiseOnUndefinedMaskMap : `bool`, optional
934 Value of the task config field of the same name.
935 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`], optional
936 Handles to the tract 0 patches to use instead of the fixture's.
938 Returns
939 -------
940 result : `lsst.pipe.base.Struct`
941 The legacy output struct.
942 box : `lsst.geom.Box2I`
943 The bounding box the template was requested on, before the task
944 grew it by the template border.
945 """
946 config = self._makeConfig()
947 config.output_image_type = "future"
948 config.raiseOnUndefinedMaskMap = raiseOnUndefinedMaskMap
949 task = lsst.ip.diffim.GetTemplateTask(config=config)
950 self.visit = 9876
951 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
952 result = task.run(coaddExposureHandles={0: self.patches[0] if handles is None else handles},
953 bbox=box,
954 wcs=self.exposure.wcs,
955 dataIds={0: self.dataIds[0]},
956 physical_filter="a_test",
957 visit=self.visit)
958 # run() always produces legacy types, whatever output_image_type is.
959 self.assertIsInstance(result.template, lsst.afw.image.ExposureF)
960 self.futureTask = task
961 return result, box
963 def _runFuture(self, handles=None):
964 """Build a template and convert it, the way ``runQuantum`` does.
966 Parameters
967 ----------
968 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`], optional
969 Handles to the tract 0 patches to use instead of the fixture's.
971 Returns
972 -------
973 result : `lsst.pipe.base.Struct`
974 The converted output struct.
975 box : `lsst.geom.Box2I`
976 The bounding box the template was requested on, before the task
977 grew it by the template border.
978 """
979 # lsst.images needs per-amplifier raw geometry and a field angle
980 # transform, which the trivial test detectors do not have.
981 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
982 result, box = self._runLegacyForFuture(handles=handles)
983 result.template = self.futureTask.convert_outputs_to_future(
984 result.template, self._coaddRefs(0), detector=detector,
985 exposureRecord=self._exposureRecord())
986 return result, box, detector
988 def _exposureRecord(self):
989 """Return the exposure record that ``runQuantum`` rebuilds from the
990 observation metadata of the science image.
991 """
992 return makeTestExposureRecord(DimensionUniverse(), visit=self.visit)
994 def _coaddRefs(self, tract):
995 """Return butler references for the coadds of one tract, like the
996 ones ``runQuantum`` passes on from its ``coaddExposures`` input.
998 Parameters
999 ----------
1000 tract : `int`
1001 Id of the tract whose coadds to make references for.
1003 Returns
1004 -------
1005 refs : `list` [`lsst.daf.butler.DatasetRef`]
1006 One reference per patch of that tract. The same references are
1007 returned on every call, because each new one would get a new
1008 dataset id.
1009 """
1010 if tract not in self.coaddRefs:
1011 datasetType = DatasetType("template_coadd", ("tract", "patch", "band", "skymap"),
1012 "ExposureF", universe=DimensionUniverse())
1013 self.coaddRefs[tract] = [DatasetRef(datasetType, dataId, run="test_run")
1014 for dataId in self.dataIds[tract]]
1015 return self.coaddRefs[tract]
1017 def testConvertOutputsToFuture(self):
1018 """Test that the output template is converted to a DifferenceImage
1019 that keeps the pixels and the detector it was built on.
1020 """
1021 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1022 result, box = self._runLegacyForFuture()
1023 legacy = result.template.clone()
1024 result.template = self.futureTask.convert_outputs_to_future(
1025 result.template, self._coaddRefs(0), detector=detector,
1026 exposureRecord=self._exposureRecord())
1028 self.assertIsInstance(result.template, lsst.images.DifferenceImage)
1029 compare_masked_image_to_legacy(result.template, legacy.maskedImage,
1030 plane_map=lsst.images.get_legacy_template_mask_planes())
1031 self.assertEqual(result.template.unit, u.nJy)
1032 self.assertIsNotNone(result.template.detector)
1033 self.assertEqual(result.template.detector.name, detector.getName())
1034 # The template is grown by the border, so it is larger than both the
1035 # requested box and the detector.
1036 expectedBox = lsst.geom.Box2I(box)
1037 expectedBox.grow(lsst.ip.diffim.GetTemplateTask.ConfigClass().templateBorderSize)
1038 self.assertEqual(result.template.bbox.to_legacy(), expectedBox)
1039 self.assertEqual(result.template.obs_info.visit_id, self.visit)
1041 def testConvertOutputsToFutureCoaddMaskPlanes(self):
1042 """Test that the coadd mask planes a warped template carries are
1043 converted, and come back when the image is converted to legacy.
1045 The template is converted with the template plane map, so the coadd
1046 planes are named there; the source injection planes are optional, and
1047 are added because this template has pixels set in them.
1048 """
1049 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1050 planes = ("CLIPPED", "REJECTED", "INEXACT_PSF", "SENSOR_EDGE", "HIGH_VARIANCE",
1051 "INJECTED", "INJECTED_CORE")
1052 result, _ = self._runLegacyForFuture()
1053 mask = result.template.mask
1054 for n, plane in enumerate(planes):
1055 mask.addMaskPlane(plane)
1056 mask.array[0, n] |= mask.getPlaneBitMask(plane)
1057 legacyTemplate = result.template.clone()
1059 result.template = self.futureTask.convert_outputs_to_future(
1060 result.template, self._coaddRefs(0), detector=detector,
1061 exposureRecord=self._exposureRecord())
1063 template = result.template
1064 self.assertIsInstance(template, lsst.images.DifferenceImage)
1065 # Name the optional planes that have pixels set in the map, so that
1066 # dropping one fails the comparison instead of skipping it. The others
1067 # are left out because they may be registered by unrelated tests.
1068 optional = lsst.images.get_legacy_optional_mask_planes()
1069 planeMap = lsst.images.get_legacy_template_mask_planes()
1070 planeMap.update({plane: optional[plane] for plane in planes if plane in optional})
1071 compare_masked_image_to_legacy(template, legacyTemplate.maskedImage, plane_map=planeMap)
1072 # The butler converts a DifferenceImage to an ExposureF with the
1073 # difference image plane map, which does not name the coadd planes;
1074 # they keep their own names instead of being dropped.
1075 legacy = template.to_legacy()
1076 for n, plane in enumerate(planes):
1077 with self.subTest(plane=plane):
1078 self.assertIn(plane, legacy.mask.getMaskPlaneDict())
1079 self.assertEqual(
1080 np.count_nonzero(legacy.mask.array & legacy.mask.getPlaneBitMask(plane)), 1)
1082 def _addUnmappedMaskPlane(self, template):
1083 """Set one pixel in the template's BRIGHT_OBJECT plane, which the
1084 template plane map does not include, and one in its CLIPPED plane,
1085 which it does.
1086 """
1087 mask = template.mask
1088 for n, plane in enumerate(("BRIGHT_OBJECT", "CLIPPED")):
1089 mask.addMaskPlane(plane)
1090 mask.array[0, n] |= mask.getPlaneBitMask(plane)
1092 def testConvertOutputsToFutureUnmappedMaskPlaneRaises(self):
1093 """With raiseOnUndefinedMaskMap=True, a template with pixels set in
1094 an unmapped mask plane cannot be converted.
1095 """
1096 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1097 result, _ = self._runLegacyForFuture()
1098 self._addUnmappedMaskPlane(result.template)
1099 with self.assertRaisesRegex(RuntimeError, "BRIGHT_OBJECT"):
1100 self.futureTask.convert_outputs_to_future(
1101 result.template, self._coaddRefs(0), detector=detector,
1102 exposureRecord=self._exposureRecord())
1104 def testConvertOutputsToFutureUnmappedMaskPlaneWarns(self):
1105 """With raiseOnUndefinedMaskMap=False, an unmapped mask plane is
1106 dropped with a warning and the mapped planes are kept.
1107 """
1108 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1109 result, _ = self._runLegacyForFuture(raiseOnUndefinedMaskMap=False)
1110 self._addUnmappedMaskPlane(result.template)
1111 with self.assertLogs(self.futureTask.log.name, level="WARNING") as cm:
1112 template = self.futureTask.convert_outputs_to_future(
1113 result.template, self._coaddRefs(0), detector=detector,
1114 exposureRecord=self._exposureRecord())
1115 self.assertIn("BRIGHT_OBJECT", "\n".join(cm.output))
1116 self.assertIsInstance(template, lsst.images.DifferenceImage)
1117 self.assertNotIn("BRIGHT_OBJECT", template.mask.schema.names)
1118 self.assertEqual(np.count_nonzero(template.mask.get("CLIPPED")), 1)
1120 def testConvertOutputsToFutureLosesProvenance(self):
1121 """The coadd inputs that `run` attaches are not carried by
1122 `lsst.images.DifferenceImage`.
1123 """
1124 legacy, _ = self._runLegacyForFuture()
1125 self.assertTrue(legacy.template.getInfo().hasCoaddInputs())
1126 result, _, _ = self._runFuture()
1127 self.assertFalse(result.template.to_legacy().getInfo().hasCoaddInputs())
1129 def testConvertOutputsToFutureTemplates(self):
1130 """The coadds that went into the template are recorded on the
1131 converted image.
1132 """
1133 result, _, _ = self._runFuture()
1134 refs = {(ref.dataId["tract"], ref.dataId["patch"]): ref for ref in self._coaddRefs(0)}
1136 templates = result.template.templates
1137 self.assertGreater(len(templates), 0)
1138 for template in templates:
1139 with self.subTest(tract=template.tract, patch=template.patch):
1140 ref = refs[template.tract, template.patch]
1141 self.assertEqual(template.skymap, "skymap")
1142 self.assertEqual(template.dataset_id, ref.id)
1143 self.assertEqual(template.dataset_run, ref.run)
1144 self.assertEqual(template.instrument, "TestCam")
1145 self.assertFalse(template.psf_shape_flag)
1146 self.assertGreater(template.psf_shape_xx, 0)
1147 self.assertGreater(template.bounds.area, 0)
1149 def testConvertOutputsToFutureInstrument(self):
1150 """The template instrument is the one recorded by the coadds that
1151 know it, or ``"UNKNOWN"`` if none of them do.
1152 """
1153 records = [(self.exposure.wcs, self.exposure.getBBox())]
1154 handles = (self._patchHandles(0, records, instrument=None)[:1]
1155 + self._patchHandles(0, records)[1:])
1156 result, _, _ = self._runFuture(handles=handles)
1157 self.assertEqual({template.instrument for template in result.template.templates}, {"TestCam"})
1159 result, _, _ = self._runFuture(handles=self._patchHandles(0, records, instrument=None))
1160 self.assertEqual({template.instrument for template in result.template.templates}, {"UNKNOWN"})
1162 def testConvertOutputsToFuturePsf(self):
1163 """The Gaussian PSF set by `run` is kept, defined over the detector.
1164 """
1165 result, _ = self._runLegacyForFuture()
1166 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1167 legacyPsf = result.template.getPsf()
1169 result.template = self.futureTask.convert_outputs_to_future(
1170 result.template, self._coaddRefs(0), detector=detector,
1171 exposureRecord=self._exposureRecord())
1173 psf = result.template.psf
1174 self.assertIsInstance(psf, lsst.images.psfs.GaussianPointSpreadFunction)
1175 self.assertFloatsAlmostEqual(psf.sigma, legacyPsf.getSigma())
1176 self.assertEqual(psf.bounds.to_legacy(), detector.getBBox())
1177 self.assertEqual(psf.kernel_bbox.to_legacy().getDimensions(), legacyPsf.getDimensions())
1179 def testConvertOutputsToFuturePsfToLegacy(self):
1180 """The Gaussian survives conversion back to an Exposure, which is how
1181 a task that has not been converted reads the template.
1182 """
1183 result, _, _ = self._runFuture()
1185 psf = result.template.to_legacy().getPsf()
1186 self.assertIsInstance(psf, lsst.afw.detection.GaussianPsf)
1187 self.assertFloatsAlmostEqual(psf.getSigma(), result.template.psf.sigma)
1190class WarpCorrelationFactorTestCase(lsst.utils.tests.TestCase):
1191 """Test the warping correlation correction on pure noise, with no other
1192 variance correction.
1193 """
1195 def setUp(self):
1196 self.size = 1024
1197 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(self.size, self.size))
1199 def _makeWcs(self, angle, offset):
1200 """Make a WCS with a fixed plate scale, rotated by ``angle`` degrees
1201 and with its reference pixel moved by ``offset`` pixels.
1202 """
1203 center = lsst.geom.Point2D(self.size/2 + offset[0], self.size/2 + offset[1])
1204 cdMatrix = lsst.afw.geom.makeCdMatrix(0.2*lsst.geom.arcseconds, angle*lsst.geom.degrees)
1205 return lsst.afw.geom.makeSkyWcs(center, lsst.geom.SpherePoint(45, 30, lsst.geom.degrees),
1206 cdMatrix)
1208 def testCorrelationFactorRestoresNoisePower(self):
1209 """Warp pure noise to a rotated, offset frame with the coadd kernel,
1210 then to a third frame with the task's own warper.
1212 Warping leaves the variance of a flux summed over a region (the noise
1213 power) unchanged, but lowers the per-pixel scatter and the variance
1214 plane. The variance plane is lowered by a larger factor because the
1215 input to the second warp is already correlated. The correction must
1216 bring the variance plane back to the noise power of the original image.
1217 """
1218 sigma = 2.0
1219 rng = np.random.default_rng(42)
1220 original = lsst.afw.image.ExposureF(self.bbox, self._makeWcs(0, (0, 0)))
1221 original.image.array[:] = rng.normal(0, sigma, original.image.array.shape)
1222 original.variance.array[:] = sigma**2
1224 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
1225 config.doScaleVariance = False
1226 config.coaddWarpKernel = "lanczos3"
1227 task = lsst.ip.diffim.GetTemplateTask(config=config)
1228 coadd = lsst.afw.math.Warper(config.coaddWarpKernel).warpExposure(
1229 self._makeWcs(37, (0.31, 0.72)), original, destBBox=self.bbox)
1230 template = task.warper.warpExposure(self._makeWcs(-21, (0.58, 0.23)), coadd, destBBox=self.bbox)
1232 # The central quarter is covered in every frame.
1233 inner = lsst.geom.Box2I(lsst.geom.Point2I(self.size//4, self.size//4),
1234 lsst.geom.Extent2I(self.size//2, self.size//2))
1235 originalStd = original.image[inner].array.std()
1236 image = template.image[inner].array.astype(float)
1237 pixelStd = image.std()
1238 # Each warp moves flux between neighboring pixels but doesn't create
1239 # or destroy any, so the noise in a flux summed over a region is the
1240 # same before and after. Summing the image over blocks and measuring
1241 # how much those sums scatter gives that quantity directly, without
1242 # depending on the variance plane. Every placement of the block is
1243 # used, from a summed-area table, to reduce the noise.
1244 blockSize = 8
1245 table = np.pad(image.cumsum(axis=0).cumsum(axis=1), ((1, 0), (1, 0)))
1246 sums = (table[blockSize:, blockSize:] - table[:-blockSize, blockSize:]
1247 - table[blockSize:, :-blockSize] + table[:-blockSize, :-blockSize])
1248 blockStd = sums.std()/blockSize
1249 planeStdBefore = np.sqrt(template.variance[inner].array.mean())
1250 task._correctVariance(template, plateScaleFactor=1.0)
1251 planeStdAfter = np.sqrt(template.variance[inner].array.mean())
1252 factor = task.metadata["varianceWarpCorrelationFactor"]
1254 # afw propagates the variance as if each warp's input pixels were
1255 # independent, so the plane drops by exactly the factor's reciprocal.
1256 self.assertFloatsAlmostEqual(planeStdBefore, sigma/np.sqrt(factor), rtol=1e-5)
1257 self.assertFloatsAlmostEqual(planeStdAfter, sigma, rtol=1e-5)
1259 # The warps conserve the noise power, which the corrected plane
1260 # matches.
1261 self.assertFloatsAlmostEqual(blockStd, originalStd, rtol=0.03)
1262 self.assertFloatsAlmostEqual(blockStd, planeStdAfter, rtol=0.03)
1264 # The per-pixel scatter drops, though by less than the uncorrected
1265 # plane, because the second warp receives correlated input. The
1266 # corrected plane deliberately exceeds it.
1267 self.assertLess(pixelStd, 0.9*originalStd)
1268 self.assertGreater(pixelStd, planeStdBefore)
1269 self.assertLess(pixelStd, planeStdAfter)
1272class GetTemplateConnectionsTestCase(lsst.utils.tests.TestCase):
1273 """Test the connections that ``output_image_type`` switches on.
1275 These only need the config classes, not a built template.
1276 """
1278 def testOutputImageType(self):
1279 """Test that the future output type changes the template storage
1280 class, and leaves the input connections alone.
1281 """
1282 Connections = lsst.ip.diffim.GetTemplateTask.ConfigClass.ConnectionsClass
1283 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
1285 connections = Connections(config=config)
1286 self.assertEqual(connections.template.storageClass, "ExposureF")
1287 self.assertNotIn("obs_info", connections.inputs)
1289 config.output_image_type = "future"
1290 connections = Connections(config=config)
1291 self.assertEqual(connections.template.storageClass, "DifferenceImage")
1292 # The dataset name is unchanged; only its storage class differs.
1293 self.assertEqual(connections.template.name, "goodSeeingDiff_templateExp")
1294 # The detector the conversion needs comes from the science image.
1295 self.assertEqual(connections.detector.name, "calexp.detector")
1296 self.assertEqual(connections.detector.storageClass, "Detector")
1297 # The observation metadata is only read in future mode, where it is
1298 # the one input the conversion adds.
1299 self.assertEqual(connections.obs_info.name, "calexp.obs_info")
1300 self.assertEqual(connections.obs_info.storageClass, "ObservationInfo")
1302 def testLintConnections(self):
1303 """Check that the connections are self-consistent in both modes.
1304 """
1305 for task in (lsst.ip.diffim.GetTemplateTask, lsst.ip.diffim.GetDcrTemplateTask):
1306 with self.subTest(task=task.__name__):
1307 lsst.pipe.base.testUtils.lintConnections(task.ConfigClass.ConnectionsClass)
1310def setup_module(module):
1311 lsst.utils.tests.init()
1314class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
1315 pass
1318if __name__ == "__main__": 1318 ↛ 1319line 1318 didn't jump to line 1319 because the condition on line 1318 was never true
1319 lsst.utils.tests.init()
1320 unittest.main()