Coverage for tests/test_getTemplate.py: 95%
524 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 09:32 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 09:32 +0000
1# This file is part of ip_diffim.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
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
62def _showTemplate(box, template):
63 """Show the corners of the template we made in this test."""
64 for point in box.getCorners():
65 display.dot("+", point.x, point.y, ctype="orange", size=40)
66 display.frame = 2
67 display.image(template, "warped template")
68 display.frame = 3
69 display.image(template.variance, "warped variance")
72class GetTemplateTaskTestCase(lsst.utils.tests.TestCase):
73 """Test that GetTemplateTask works on both one tract and multiple tract
74 input coadd exposures.
76 Makes a synthetic exposure large enough to fit four small tracts with 2x2
77 (300x300 pixel) patches each, extracts pixels for those patches by warping,
78 and tests GetTemplateTask's output against boxes that overlap various
79 combinations of one or multiple tracts.
80 """
81 def setUp(self):
82 self.scale = 0.2 # arcsec/pixel
83 self.skymap = self._makeSkymap()
84 self.patches = collections.defaultdict(list)
85 self.dataIds = collections.defaultdict(list)
86 self.coaddRefs = {}
87 self.exposure = self._makeExposure()
88 self.varianceBox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
90 if debug: 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true
91 display.image(self.exposure, "base exposure")
93 for tract_id in range(4):
94 tract = self.skymap.generateTract(tract_id)
95 self._makePatches(tract)
97 def _makeSkymap(self):
98 """Make a Skymap with 4 tracts with 4 patches each.
99 """
100 tractScale = 0.02 # degrees
101 # On-sky coordinates of the tract centers.
102 coords = [(0, 0),
103 (0, tractScale),
104 (tractScale, 0),
105 (tractScale, tractScale),
106 ]
107 config = lsst.skymap.DiscreteSkyMap.ConfigClass()
108 config.raList = [c[0] for c in coords]
109 config.decList = [c[1] for c in coords]
110 # Half the tract center step size, to keep the tract overlap small.
111 config.radiusList = [tractScale/2 for c in coords]
112 config.projection = "TAN"
113 config.pixelScale = self.scale
114 config.tractOverlap = 0.0005
115 config.tractBuilder = "legacy"
116 config.tractBuilder["legacy"].patchInnerDimensions = (300, 300)
117 config.tractBuilder["legacy"].patchBorder = 10
118 return lsst.skymap.DiscreteSkyMap(config=config)
120 def _makeExposure(self):
121 """Create a large image to break up into tracts and patches.
123 The image will have a source every 100 pixels in x and y, and a WCS
124 that results in the tracts all fitting in the image, with tract=0
125 in the lower left, tract=1 to the right, tract=2 above, and tract=3
126 to the upper right.
127 """
128 box = lsst.geom.Box2I(lsst.geom.Point2I(-200, -200), lsst.geom.Point2I(800, 800))
129 # This WCS was constructed so that tract 0 mostly fills the lower left
130 # quadrant of the image, and the other tracts fill the rest; slight
131 # extra rotation as a check on the final warp layout, scaled by 5%
132 # from the patch pixel scale.
133 cd_matrix = lsst.afw.geom.makeCdMatrix(1.05*self.scale*lsst.geom.arcseconds, 93*lsst.geom.degrees)
134 wcs = lsst.afw.geom.makeSkyWcs(lsst.geom.Point2D(120, 150),
135 lsst.geom.SpherePoint(0, 0, lsst.geom.radians),
136 cd_matrix)
137 dataset = lsst.meas.base.tests.TestDataset(box, wcs=wcs)
138 for x, y in itertools.product(np.arange(0, 500, 100), np.arange(0, 500, 100)):
139 dataset.addSource(1e5, lsst.geom.Point2D(x, y))
140 exposure, _ = dataset.realize(2, dataset.makeMinimalSchema())
141 exposure.setFilter(lsst.afw.image.FilterLabel("a", "a_test"))
142 return exposure
144 def _makePatches(self, tract):
145 """Populate the patches and dataId dicts, keyed on tract id, with the
146 warps of the main exposure and minimal dataIds, respectively.
147 """
148 if debug: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 color = ['red', 'green', 'cyan', 'yellow'][tract.tract_id]
150 point = self.exposure.wcs.skyToPixel(tract.ctr_coord)
151 # Show the tract center, colored by tract id.
152 display.dot("x", point.x, point.y, ctype=color, size=30)
154 # Use 5th order to minimize artifacts on the templates.
155 config = lsst.afw.math.Warper.ConfigClass()
156 config.warpingKernelName = "lanczos5"
157 warper = lsst.afw.math.Warper.fromConfig(config)
158 for patchId in range(tract.num_patches.x*tract.num_patches.y):
159 patch = tract.getPatchInfo(patchId)
160 box = patch.getOuterBBox()
162 if debug: 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was never true
163 # Show the patch corners as patch ids, colored by tract id.
164 points = self.exposure.wcs.skyToPixel(patch.wcs.pixelToSky([lsst.geom.Point2D(x)
165 for x in box.getCorners()]))
166 for p in points:
167 display.dot(patchId, p.x, p.y, ctype=color)
169 # This is mostly taken from drp_tasks makePsfMatchedWarp, but
170 # ip_diffim cannot depend on drp_tasks.
171 xyTransform = lsst.afw.geom.makeWcsPairTransform(self.exposure.wcs, patch.wcs)
172 warpedPsf = lsst.meas.algorithms.WarpedPsf(self.exposure.psf, xyTransform)
173 warped = warper.warpExposure(patch.wcs, self.exposure, destBBox=box)
174 warped.setPsf(warpedPsf)
176 warped.getInfo().setCoaddInputs(
177 self._makeCoaddInputs([(self.exposure.wcs, self.exposure.getBBox())]))
178 dataRef = pipeBase.InMemoryDatasetHandle(
179 warped,
180 storageClass="ExposureF",
181 copy=True,
182 dataId=generate_data_id(
183 tract=tract,
184 patch=patch,
185 )
186 )
187 self.patches[tract.tract_id].append(dataRef)
188 dataCoordinate = DataCoordinate.standardize({"tract": tract.tract_id,
189 "patch": patchId,
190 "band": "a",
191 "skymap": "skymap"},
192 universe=DimensionUniverse())
193 self.dataIds[tract.tract_id].append(dataCoordinate)
195 def _checkMetadata(self, template, config, box, wcs, nPsfs):
196 """Check that the various metadata components were set correctly.
197 """
198 expectedBox = lsst.geom.Box2I(box)
199 expectedBox.grow(config.templateBorderSize)
200 self.assertEqual(template.getBBox(), expectedBox)
201 # WCS should match our exposure, not any of the coadd tracts.
202 for tract in self.patches:
203 self.assertNotEqual(template.wcs, self.patches[tract][0].get().wcs)
204 self.assertEqual(template.wcs, self.exposure.wcs)
205 # The template pixels are calibrated to nJy.
206 self.assertEqual(template.photoCalib, lsst.afw.image.PhotoCalib(1.0))
207 self.assertEqual(template.getXY0(), expectedBox.getMin())
208 self.assertEqual(template.filter.bandLabel, "a")
209 self.assertEqual(template.filter.physicalLabel, "a_test")
210 # The template PSF is a Gaussian with the width of the CoaddPsf of
211 # its inputs.
212 self.assertIsInstance(template.psf, lsst.afw.detection.GaussianPsf)
213 coaddPsf = self._makeCoaddPsf(template, config)
214 self.assertEqual(coaddPsf.getComponentCount(), nPsfs)
215 position = coaddPsf.getAveragePosition()
216 self.assertFloatsAlmostEqual(template.psf.getSigma(),
217 coaddPsf.computeShape(position).getTraceRadius())
218 self.assertTrue(template.getInfo().hasCoaddInputs())
219 self.assertEqual(len(template.getInfo().getCoaddInputs().ccds), nPsfs)
221 def _makeCoaddPsf(self, template, config):
222 """Return the CoaddPsf that ``run`` approximated with a Gaussian,
223 rebuilt from the coadd inputs recorded on the template.
224 """
225 task = lsst.ip.diffim.GetTemplateTask(config=config)
226 return task._makePsf(template, template.getInfo().getCoaddInputs().ccds, template.wcs)
228 def _checkPixels(self, template, config, box):
229 """Check that the pixel values in the template are close to the
230 original image, calibrated to nJy.
231 """
232 # All pixels should have real values!
233 expectedBox = lsst.geom.Box2I(box)
234 expectedBox.grow(config.templateBorderSize)
235 calibration = self.exposure.photoCalib.getCalibrationMean()
236 expected = self.exposure.photoCalib.calibrateImage(self.exposure[expectedBox].maskedImage)
238 if debug: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 _showTemplate(expectedBox, template)
241 # Check that we fully filled the template from the patches.
242 self.assertTrue(np.all(np.isfinite(template.image.array)))
243 # Because of the scale changes, there will be some ringing in the
244 # difference between the template and the original image; pick
245 # tolerances large enough to account for that.
246 self.assertImagesAlmostEqual(template.image, expected.image, rtol=.1, atol=4*calibration)
247 # Variance plane ==4 in the original image (realize() takes a noise
248 # sigma). Warping sets the level from the pixel areas and the two
249 # warping kernels, which `_correctVariance` corrects up to the
250 # difference between the configured coaddWarpKernel and the lanczos5
251 # `_makePatches` really used. A per-pixel ripple that no scalar
252 # correction can remove remains on top of that, so check the level
253 # tightly and allow for the ripple around it.
254 variance = template.variance.array[np.isfinite(template.variance.array)]
255 median = np.median(variance)
256 self.assertFloatsAlmostEqual(median, np.median(expected.variance.array),
257 rtol=0.35, msg="variance level differs")
258 self.assertLess(np.percentile(variance, 99)/median, 1.6, msg="variance ripple too large")
259 self.assertGreater(np.percentile(variance, 1)/median, 0.6, msg="variance ripple too large")
260 # Not checking the mask, as warping changes the sizes of the masks.
262 def testRunOneTractInput(self):
263 """Test a bounding box that fully fits inside one tract, with only
264 that tract passed as input. This checks that the code handles a single
265 tract input correctly.
266 """
267 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
268 task = lsst.ip.diffim.GetTemplateTask()
269 # Restrict to tract 0, since the box fits in just that tract.
270 result = task.run(coaddExposureHandles={0: self.patches[0]},
271 bbox=box,
272 wcs=self.exposure.wcs,
273 dataIds={0: self.dataIds[0]},
274 physical_filter="a_test")
276 # All 4 patches from tract 0 are included in this template.
277 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 4)
278 self._checkPixels(result.template, task.config, box)
280 def testRunOneTractMultipleInputs(self):
281 """Test a bounding box that fully fits inside one tract but where
282 multiple tracts were passed in. This checks that patches that are
283 mostly NaN after warping are merged correctly in the output.
284 """
285 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
286 task = lsst.ip.diffim.GetTemplateTask()
287 result = task.run(coaddExposureHandles=self.patches,
288 bbox=box,
289 wcs=self.exposure.wcs,
290 dataIds=self.dataIds,
291 physical_filter="a_test")
293 # All 4 patches from two tracts are included in this template.
294 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 6)
295 self._checkPixels(result.template, task.config, box)
297 def testRunTwoTracts(self):
298 """Test a bounding box that crosses tract boundaries.
299 """
300 box = lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600))
301 task = lsst.ip.diffim.GetTemplateTask()
302 result = task.run(coaddExposureHandles=self.patches,
303 bbox=box,
304 wcs=self.exposure.wcs,
305 dataIds=self.dataIds,
306 physical_filter="a_test")
308 # All 4 patches from all 4 tracts are included in this template
309 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 9)
310 self._checkPixels(result.template, task.config, box)
312 def testRunCalibratesToNanojansky(self):
313 """Test that the template is in nJy whatever the calibration of the
314 coadds, and that coadds already in nJy are not rescaled.
315 """
316 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
317 calibration = self.exposure.photoCalib.getCalibrationMean()
318 self.assertNotEqual(calibration, 1.0)
319 task = lsst.ip.diffim.GetTemplateTask()
320 templates = {}
321 for photoCalib in (self.exposure.photoCalib, lsst.afw.image.PhotoCalib(1.0)):
322 self._remakePatches(photoCalib)
323 result = task.run(coaddExposureHandles={0: self.patches[0]},
324 bbox=box,
325 wcs=self.exposure.wcs,
326 dataIds={0: self.dataIds[0]},
327 physical_filter="a_test")
328 self.assertEqual(result.template.photoCalib, lsst.afw.image.PhotoCalib(1.0))
329 templates[photoCalib.getCalibrationMean()] = result.template
331 self.assertFloatsAlmostEqual(templates[calibration].image.array,
332 calibration*templates[1.0].image.array, rtol=1e-6)
333 self.assertFloatsAlmostEqual(templates[calibration].variance.array,
334 calibration**2*templates[1.0].variance.array, rtol=1e-6)
336 def _remakePatches(self, photoCalib):
337 """Rebuild the coadd patches from the main exposure with a different
338 photometric calibration.
339 """
340 self.exposure.setPhotoCalib(photoCalib)
341 self.patches.clear()
342 self.dataIds.clear()
343 for tract_id in range(4):
344 self._makePatches(self.skymap.generateTract(tract_id))
346 def testGaussianPsfGridFallback(self):
347 """If the CoaddPsf cannot be evaluated at its average position, the
348 Gaussian width is averaged over a grid of positions instead.
349 """
350 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
351 task = lsst.ip.diffim.GetTemplateTask()
352 result = task.run(coaddExposureHandles={0: self.patches[0]},
353 bbox=box,
354 wcs=self.exposure.wcs,
355 dataIds={0: self.dataIds[0]},
356 physical_filter="a_test")
357 template = result.template
358 template.setPsf(self._makeCoaddPsf(template, task.config))
359 expected = lsst.ip.diffim.utils.evaluateMeanPsfFwhm(
360 template,
361 fwhmExposureBuffer=task.config.fwhmExposureBuffer,
362 fwhmExposureGrid=task.config.fwhmExposureGrid,
363 )/gaussian_sigma_to_fwhm
365 error = lsst.pex.exceptions.InvalidParameterError("No inputs at the average position.")
366 with unittest.mock.patch("lsst.ip.diffim.getTemplate.getPsfFwhm", side_effect=error):
367 with self.assertLogs(task.log.name, level="INFO") as cm:
368 psf = task._makeGaussianPsf(template)
370 self.assertIn("grid of points", "\n".join(cm.output))
371 self.assertIsInstance(psf, lsst.afw.detection.GaussianPsf)
372 self.assertFloatsAlmostEqual(psf.getSigma(), expected)
373 self.assertFloatsAlmostEqual(task.metadata["templatePsfSigma"], expected)
375 def testRunNoTemplate(self):
376 """A bounding box that doesn't overlap the patches will raise.
377 """
378 box = lsst.geom.Box2I(lsst.geom.Point2I(1200, 1200), lsst.geom.Point2I(1600, 1600))
379 task = lsst.ip.diffim.GetTemplateTask()
380 with self.assertRaisesRegex(lsst.pipe.base.NoWorkFound, "No patches found"):
381 task.run(coaddExposureHandles=self.patches,
382 bbox=box,
383 wcs=self.exposure.wcs,
384 dataIds=self.dataIds,
385 physical_filter="a_test")
387 def testMissingPatches(self):
388 """Test that a missing patch results in an appropriate mask.
390 This fixes the bug reported on DM-44997 (image and variance were NaN
391 but the mask was not set to NO_DATA for those pixels).
392 """
393 # tract=0, patch=1 is the lower-left corner, as displayed in DS9.
394 self.patches[0].pop(1)
395 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
396 task = lsst.ip.diffim.GetTemplateTask()
397 result = task.run(coaddExposureHandles=self.patches,
398 bbox=box,
399 wcs=self.exposure.wcs,
400 dataIds=self.dataIds,
401 physical_filter="a_test")
402 no_data = (result.template.mask.array & result.template.mask.getPlaneBitMask("NO_DATA")) != 0
403 self.assertTrue(np.isfinite(result.template.image.array).all())
404 self.assertTrue(np.isfinite(result.template.variance.array).all())
405 self.assertEqual(no_data.sum(), 20990)
407 @lsst.utils.tests.methodParameters(
408 box=[
409 lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180)),
410 lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600)),
411 ],
412 nInput=[8, 16],
413 )
414 def testNanInputs(self, box=None, nInput=None):
415 """Test that the template has finite values when some of the input
416 pixels have NaN as variance.
417 """
418 for tract, patchRefs in self.patches.items():
419 for patchRef in patchRefs:
420 patchCoadd = patchRef.get()
421 bbox = lsst.geom.Box2I()
422 bbox.include(lsst.geom.Point2I(patchCoadd.getBBox().getCenter()))
423 bbox.grow(3)
424 patchCoadd.variance[bbox].array *= np.nan
426 box = lsst.geom.Box2I(lsst.geom.Point2I(200, 200), lsst.geom.Point2I(600, 600))
427 task = lsst.ip.diffim.GetTemplateTask()
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 if debug: 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 _showTemplate(box, result.template)
435 self._checkMetadata(result.template, task.config, box, self.exposure.wcs, 9)
436 # We just check that the pixel values are all finite. We cannot check that pixel values
437 # in the template are closer to the original anymore.
438 self.assertTrue(np.isfinite(result.template.image.array).all())
440 def _runCorrection(self, handles=None, instrument="SciCam"):
441 """Run the task on tract 0, with a science image from ``instrument``
442 and variance scaling turned off.
444 The plate scale correction is applied only when ``instrument`` is not
445 the instrument of the template coadds, which is ``"TestCam"`` unless
446 ``handles`` says otherwise.
447 """
448 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
449 config.doScaleVariance = False
450 task = lsst.ip.diffim.GetTemplateTask(config=config)
451 result = task.run(coaddExposureHandles={0: handles or self.patches[0]},
452 bbox=lsst.geom.Box2I(self.varianceBox),
453 wcs=self.exposure.wcs,
454 dataIds={0: self.dataIds[0]},
455 physical_filter="a_test",
456 instrument=instrument)
457 return task, result.template
459 def _makeScaledWcs(self, factor):
460 """Make a WCS like the base exposure's, but with its pixel scale
461 multiplied by ``factor``.
462 """
463 cdMatrix = lsst.afw.geom.makeCdMatrix(factor*1.05*self.scale*lsst.geom.arcseconds,
464 93*lsst.geom.degrees)
465 return lsst.afw.geom.makeSkyWcs(lsst.geom.Point2D(120, 150),
466 lsst.geom.SpherePoint(0, 0, lsst.geom.radians),
467 cdMatrix)
469 @staticmethod
470 def _makeCoaddInputs(records, instrument="TestCam"):
471 """Make a CoaddInputs holding the given input records.
473 Parameters
474 ----------
475 records : `list` [`tuple` [`lsst.afw.geom.SkyWcs` or `None`, \
476 `lsst.geom.Box2I`]]
477 The WCS and bbox to record for each input. A `None` WCS makes a
478 record that `_plateScaleFactor` has to skip. May be empty, to
479 simulate a coadd whose plate scale cannot be reconstructed.
480 instrument : `str` or `None`, optional
481 Instrument label to record for every input. `None` leaves the
482 inputs without a visit info, and an empty string gives them a
483 visit info with no label; either way the instrument is unknown.
484 """
485 ccdSchema = lsst.afw.table.ExposureTable.makeMinimalSchema()
486 weightKey = ccdSchema.addField("weight", type=float, doc="Coadd weight")
487 coaddInputs = lsst.afw.image.CoaddInputs(
488 lsst.afw.table.ExposureTable.makeMinimalSchema(), ccdSchema)
489 for wcs, bbox in records:
490 record = coaddInputs.ccds.addNew()
491 record.setWcs(wcs)
492 record.setBBox(bbox)
493 if instrument is not None:
494 record.setVisitInfo(lsst.afw.image.VisitInfo(instrumentLabel=instrument))
495 # Included because real coadds have it, though a single-record
496 # correction does not use it.
497 record.set(weightKey, 1.0)
498 return coaddInputs
500 def _patchHandles(self, tract, records, instrument="TestCam"):
501 """Return handles for a tract's patches, with their CoaddInputs
502 replaced by ``records`` from ``instrument``.
503 """
504 handles = []
505 for ref in self.patches[tract]:
506 coadd = ref.get()
507 coadd.getInfo().setCoaddInputs(self._makeCoaddInputs(records, instrument=instrument))
508 handles.append(pipeBase.InMemoryDatasetHandle(
509 coadd, storageClass="ExposureF", copy=True, dataId=ref.dataId))
510 return handles
512 def testCorrectVariancePlateScale(self):
513 """The plate scale correction is the total pixel area change from the
514 images the coadds were built from to the science image.
515 """
516 # The science image and the coadds are from the same instrument, so
517 # the factor is 1 without reading any coadd inputs.
518 task, off = self._runCorrection(instrument="TestCam")
519 self.assertEqual(task.metadata["variancePlateScaleFactor"], 1.0)
520 self.assertNotIn("templateVarianceCorrectionFactor", task.metadata)
522 # The fixture's records are the science image itself, so there is no
523 # net change in pixel area even when the instruments differ.
524 task, on = self._runCorrection()
525 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.0, rtol=1e-6)
527 # Coarser original pixels than science pixels, the DECam-template
528 # case: the correction is the ratio of their areas.
529 for scaleFactor in (1.315, 0.5):
530 with self.subTest(scaleFactor=scaleFactor):
531 handles = self._patchHandles(
532 0, [(self._makeScaledWcs(scaleFactor), self.exposure.getBBox())])
533 task, on = self._runCorrection(handles=handles)
534 factor = task.metadata["variancePlateScaleFactor"]
535 self.assertFloatsAlmostEqual(factor, scaleFactor**2, rtol=1e-6)
536 self.assertFloatsAlmostEqual(on.variance.array, off.variance.array*factor,
537 rtol=1e-5, ignoreNaNs=True)
539 def testCorrectVariancePlateScaleUsesOneRecord(self):
540 """Only the first usable coadd input is read.
542 The spread between records is just the local pixel scale, a few
543 tenths of a percent across a real focal plane, so one stands for all
544 of them.
545 """
546 handles = self._patchHandles(
547 0, [(None, self.exposure.getBBox()),
548 (self._makeScaledWcs(2.0), self.exposure.getBBox()),
549 (self.exposure.wcs, self.exposure.getBBox())])
550 task, _ = self._runCorrection(handles=handles)
552 # The first record with a WCS: the one without is skipped, and the
553 # last (which would give 1.0) is never reached.
554 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 4.0, rtol=1e-6)
556 def testCorrectVariancePlateScaleSkipsDroppedCoadd(self):
557 """The plate scale comes from the first coadd that is used, not from
558 one dropped for its instrument.
559 """
560 bbox = self.exposure.getBBox()
561 handles = (self._patchHandles(0, [(self._makeScaledWcs(2.0), bbox)], instrument="OtherCam")[:1]
562 + self._patchHandles(0, [(self._makeScaledWcs(1.315), bbox)])[1:])
563 task, _ = self._runCorrection(handles=handles)
564 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.315**2, rtol=1e-6)
566 def testCorrectVariancePlateScaleNextTract(self):
567 """If the first coadd kept in a tract cannot give the plate scale, the
568 first coadd kept in the next tract is used.
569 """
570 task = lsst.ip.diffim.GetTemplateTask()
571 handles = {0: self._patchHandles(0, []),
572 1: self._patchHandles(1, [(self._makeScaledWcs(1.315), self.exposure.getBBox())])}
573 task.run(coaddExposureHandles=handles,
574 bbox=lsst.geom.Box2I(self.varianceBox),
575 wcs=self.exposure.wcs,
576 dataIds={0: self.dataIds[0], 1: self.dataIds[1]},
577 physical_filter="a_test",
578 instrument="SciCam")
579 self.assertFloatsAlmostEqual(task.metadata["variancePlateScaleFactor"], 1.315**2, rtol=1e-6)
581 def testCorrectVariancePlateScaleNeedsCoaddInputs(self):
582 """Without usable coadd inputs the plate scale cannot be
583 reconstructed, so the template cannot be built.
584 """
585 handles = self._patchHandles(0, [])
586 with self.assertRaisesRegex(RuntimeError, "cannot be corrected for the change in pixel area"):
587 self._runCorrection(handles=handles)
589 def _runTract0(self, handles):
590 """Build a template from the given tract 0 patch handles.
591 """
592 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
593 task = lsst.ip.diffim.GetTemplateTask()
594 return task.run(coaddExposureHandles={0: handles},
595 bbox=box,
596 wcs=self.exposure.wcs,
597 dataIds={0: self.dataIds[0]},
598 physical_filter="a_test")
600 def testRunRecordsInstrument(self):
601 """The instrument of each coadd is recorded with the template's coadd
602 inputs.
603 """
604 result = self._runTract0(self.patches[0])
605 ccds = result.template.getInfo().getCoaddInputs().ccds
606 self.assertEqual(len(ccds), 4)
607 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
609 def testRunUnknownInstrument(self):
610 """A coadd whose inputs record no instrument is dropped when the
611 other coadds record one, and kept, with the template's instrument
612 ``"UNKNOWN"``, when none of them records one.
613 """
614 records = [(self.exposure.wcs, self.exposure.getBBox())]
615 # Inputs with no visit info, and with a visit info but no label.
616 for unknown in (None, ""):
617 with self.subTest(label=unknown):
618 handles = (self._patchHandles(0, records, instrument=unknown)[:1]
619 + self._patchHandles(0, records)[1:])
620 ccds, dropped = self._runLogged({0: handles})
621 self.assertEqual(len(ccds), 3)
622 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
623 self.assertEqual(len(dropped), 1)
624 self.assertIn("made from UNKNOWN", dropped[0])
626 # No coadd records an instrument.
627 result = self._runTract0(self._patchHandles(0, records, instrument=unknown))
628 ccds = result.template.getInfo().getCoaddInputs().ccds
629 self.assertEqual(len(ccds), 4)
630 self.assertEqual({record["instrument"] for record in ccds}, {"UNKNOWN"})
632 def _runLogged(self, handles, dataIds=None, box=None, instrument=None):
633 """Build a template, capturing the messages about dropped coadds.
635 Parameters
636 ----------
637 handles : `dict` [`int`, `list`]
638 Patch handles, keyed by tract.
639 dataIds : `dict` [`int`, `list`], optional
640 Data ids matching ``handles``; defaults to the fixture's.
641 box : `lsst.geom.Box2I`, optional
642 Box to build the template on; defaults to one in tract 0.
643 instrument : `str`, optional
644 Instrument of the science image.
646 Returns
647 -------
648 ccds : `lsst.afw.table.ExposureCatalog`
649 The coadd inputs recorded on the template.
650 dropped : `list` [`str`]
651 The info messages about dropped coadds.
652 """
653 if dataIds is None:
654 dataIds = {tract: self.dataIds[tract] for tract in handles}
655 if box is None:
656 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
657 task = lsst.ip.diffim.GetTemplateTask()
658 with self.assertLogs(task.log.name, level="INFO") as cm:
659 result = task.run(coaddExposureHandles=handles,
660 bbox=box,
661 wcs=self.exposure.wcs,
662 dataIds=dataIds,
663 physical_filter="a_test",
664 instrument=instrument)
665 dropped = [record for record in cm.records
666 if record.getMessage().startswith("Dropping template coadd")]
667 self.assertEqual({record.levelname for record in dropped}, {"INFO"} if dropped else set())
668 return result.template.getInfo().getCoaddInputs().ccds, [record.getMessage() for record in dropped]
670 def testRunDropsMinorityInstrument(self):
671 """Coadds made from an instrument other than the one most coadds were
672 made from are dropped.
673 """
674 records = [(self.exposure.wcs, self.exposure.getBBox())]
675 handles = (self._patchHandles(0, records)[:3]
676 + self._patchHandles(0, records, instrument="OtherCam")[3:])
678 ccds, dropped = self._runLogged({0: handles})
679 self.assertEqual({record["patch"] for record in ccds}, {0, 1, 2})
680 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
681 self.assertEqual(len(dropped), 1)
682 self.assertIn(str(self.dataIds[0][3]), dropped[0])
683 self.assertIn("made from OtherCam", dropped[0])
685 def testRunPrefersScienceInstrument(self):
686 """Coadds made from the science image's instrument are used even when
687 they are in the minority; otherwise the majority instrument is used.
688 """
689 records = [(self.exposure.wcs, self.exposure.getBBox())]
690 handles = (self._patchHandles(0, records, instrument="OtherCam")[:3]
691 + self._patchHandles(0, records)[3:])
693 ccds, dropped = self._runLogged({0: handles}, instrument="TestCam")
694 self.assertEqual({record["patch"] for record in ccds}, {3})
695 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
696 self.assertEqual(len(dropped), 3)
697 self.assertTrue(all("made from OtherCam" in message for message in dropped))
699 # No coadd was made from the science image's instrument.
700 ccds, dropped = self._runLogged({0: handles}, instrument="SciCam")
701 self.assertEqual({record["patch"] for record in ccds}, {0, 1, 2})
702 self.assertEqual({record["instrument"] for record in ccds}, {"OtherCam"})
703 self.assertEqual(len(dropped), 1)
705 def testRunInstrumentTie(self):
706 """A tie goes to the instrument whose name sorts first.
707 """
708 records = [(self.exposure.wcs, self.exposure.getBBox())]
709 handles = (self._patchHandles(0, records)[:2]
710 + self._patchHandles(0, records, instrument="OtherCam")[2:])
712 ccds, dropped = self._runLogged({0: handles})
713 self.assertEqual({record["patch"] for record in ccds}, {2, 3})
714 self.assertEqual({record["instrument"] for record in ccds}, {"OtherCam"})
715 self.assertEqual(len(dropped), 2)
717 def testRunDropsNonOverlappingCoadd(self):
718 """A coadd from another instrument is dropped even if it does not
719 overlap the template.
720 """
721 records = [(self.exposure.wcs, self.exposure.getBBox())]
722 # Tract 3 does not overlap a box in the lower left of tract 0.
723 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(50, 50))
724 handles = {0: self.patches[0], 3: self._patchHandles(3, records, instrument="OtherCam")[:1]}
725 dataIds = {0: self.dataIds[0], 3: self.dataIds[3][:1]}
727 ccds, dropped = self._runLogged(handles, dataIds=dataIds, box=box)
728 self.assertEqual({record["tract"] for record in ccds}, {0})
729 self.assertEqual({record["instrument"] for record in ccds}, {"TestCam"})
730 self.assertEqual(len(dropped), 1)
731 self.assertIn(str(self.dataIds[3][0]), dropped[0])
733 def _scaleInputVariance(self, tract, factor):
734 """Return fresh handles for one tract's patches, with their variance
735 planes multiplied by ``factor``.
737 Parameters
738 ----------
739 tract : `int`
740 Id of the tract whose patches should be copied.
741 factor : `float`
742 Factor to multiply the input variance planes by.
744 Returns
745 -------
746 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`]
747 Handles to the modified patches.
748 """
749 handles = []
750 for handle in self.patches[tract]:
751 # ``copy=True`` on the original handles means this is a copy, so
752 # the patches shared with the other tests are left untouched.
753 patch = handle.get()
754 patch.variance.array *= factor
755 handles.append(pipeBase.InMemoryDatasetHandle(patch,
756 storageClass="ExposureF",
757 copy=True,
758 dataId=handle.dataId))
759 return handles
761 def testScaleVariance(self):
762 """Test that the template variance plane is rescaled to match the
763 empirical pixel noise, and that the factor used is recorded in the
764 task metadata.
765 """
766 scaleFactor = 1.345
767 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
769 def _configureAndRunTask(doScaleVariance, varianceScale=1.):
770 """Build a template from tract 0, optionally rescaling the input
771 variance planes by ``varianceScale`` first.
772 """
773 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
774 config.doScaleVariance = doScaleVariance
775 task = lsst.ip.diffim.GetTemplateTask(config=config)
776 result = task.run(coaddExposureHandles={0: self._scaleInputVariance(0, varianceScale)},
777 bbox=box,
778 wcs=self.exposure.wcs,
779 dataIds={0: self.dataIds[0]},
780 physical_filter="a_test")
781 return task, result.template
783 # With scaling disabled the subtask is never constructed, and nothing
784 # is recorded in the metadata.
785 taskOff, templateOff = _configureAndRunTask(False)
786 self.assertFalse(hasattr(taskOff, "scaleVariance"))
787 self.assertNotIn("scaleTemplateVarianceFactor", taskOff.metadata)
789 # Both warps -- lanczos5 in ``_makePatches`` and lanczos3 in the
790 # task -- correlate the noise. The variance plane tracks only the
791 # per-pixel diagonal, which the second warp leaves too low, so
792 # ``scaleVariance`` measures a factor well above 1 even though the
793 # input variance planes are correct.
794 #
795 taskOn, templateOn = _configureAndRunTask(True)
796 factor = taskOn.metadata["scaleTemplateVarianceFactor"]
797 # TODO DM-55879: this value is pinned on purpose. The lanczos warping
798 # kernels introduce small correlations that artificially suppress the
799 # image pixel stddev and inflate the variance scaling factor. This
800 # should be changed to 1.0 after DM-55879 is merged.
801 self.assertFloatsAlmostEqual(factor, 1.1465, atol=0.01,
802 msg="Measured template variance scaling changed; see the"
803 " comment above if the correlation correction landed.")
804 # The only difference from the unscaled template is the constant
805 # factor applied to the variance plane.
806 self.assertFloatsAlmostEqual(templateOn.variance.array,
807 templateOff.variance.array*factor, rtol=1e-5)
808 # Tolerance here is float32 round-off: repeated runs of the task are
809 # not bitwise identical.
810 self.assertImagesAlmostEqual(templateOn.image, templateOff.image, rtol=1e-5, atol=1e-5)
812 # If the input variance planes under-estimate the noise by a known
813 # factor, the measured factor grows by that amount and the same
814 # output variance plane is recovered.
815 taskLow, templateLow = _configureAndRunTask(True, varianceScale=1/scaleFactor)
816 self.assertFloatsAlmostEqual(taskLow.metadata["scaleTemplateVarianceFactor"],
817 factor*scaleFactor, rtol=1e-5)
818 self.assertImagesAlmostEqual(templateLow.variance, templateOn.variance, rtol=1e-5)
820 def _runLegacyForFuture(self, raiseOnUndefinedMaskMap=True, handles=None):
821 """Build a template from tract 0 with a task configured for the
822 future output type, without converting it.
824 The task is kept as ``self.futureTask``.
826 Parameters
827 ----------
828 raiseOnUndefinedMaskMap : `bool`, optional
829 Value of the task config field of the same name.
830 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`], optional
831 Handles to the tract 0 patches to use instead of the fixture's.
833 Returns
834 -------
835 result : `lsst.pipe.base.Struct`
836 The legacy output struct.
837 box : `lsst.geom.Box2I`
838 The bounding box the template was requested on, before the task
839 grew it by the template border.
840 """
841 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
842 config.output_image_type = "future"
843 config.raiseOnUndefinedMaskMap = raiseOnUndefinedMaskMap
844 task = lsst.ip.diffim.GetTemplateTask(config=config)
845 self.visit = 9876
846 box = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(180, 180))
847 result = task.run(coaddExposureHandles={0: self.patches[0] if handles is None else handles},
848 bbox=box,
849 wcs=self.exposure.wcs,
850 dataIds={0: self.dataIds[0]},
851 physical_filter="a_test",
852 visit=self.visit)
853 # run() always produces legacy types, whatever output_image_type is.
854 self.assertIsInstance(result.template, lsst.afw.image.ExposureF)
855 self.futureTask = task
856 return result, box
858 def _runFuture(self, handles=None):
859 """Build a template and convert it, the way ``runQuantum`` does.
861 Parameters
862 ----------
863 handles : `list` [`lsst.pipe.base.InMemoryDatasetHandle`], optional
864 Handles to the tract 0 patches to use instead of the fixture's.
866 Returns
867 -------
868 result : `lsst.pipe.base.Struct`
869 The converted output struct.
870 box : `lsst.geom.Box2I`
871 The bounding box the template was requested on, before the task
872 grew it by the template border.
873 """
874 # lsst.images needs per-amplifier raw geometry and a field angle
875 # transform, which the trivial test detectors do not have.
876 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
877 result, box = self._runLegacyForFuture(handles=handles)
878 result.template = self.futureTask.convert_outputs_to_future(
879 result.template, self._coaddRefs(0), detector=detector,
880 exposureRecord=self._exposureRecord())
881 return result, box, detector
883 def _exposureRecord(self):
884 """Return the exposure record that ``runQuantum`` rebuilds from the
885 observation metadata of the science image.
886 """
887 return makeTestExposureRecord(DimensionUniverse(), visit=self.visit)
889 def _coaddRefs(self, tract):
890 """Return butler references for the coadds of one tract, like the
891 ones ``runQuantum`` passes on from its ``coaddExposures`` input.
893 Parameters
894 ----------
895 tract : `int`
896 Id of the tract whose coadds to make references for.
898 Returns
899 -------
900 refs : `list` [`lsst.daf.butler.DatasetRef`]
901 One reference per patch of that tract. The same references are
902 returned on every call, because each new one would get a new
903 dataset id.
904 """
905 if tract not in self.coaddRefs:
906 datasetType = DatasetType("template_coadd", ("tract", "patch", "band", "skymap"),
907 "ExposureF", universe=DimensionUniverse())
908 self.coaddRefs[tract] = [DatasetRef(datasetType, dataId, run="test_run")
909 for dataId in self.dataIds[tract]]
910 return self.coaddRefs[tract]
912 def testConvertOutputsToFuture(self):
913 """Test that the output template is converted to a DifferenceImage
914 that keeps the pixels and the detector it was built on.
915 """
916 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
917 result, box = self._runLegacyForFuture()
918 legacy = result.template.clone()
919 result.template = self.futureTask.convert_outputs_to_future(
920 result.template, self._coaddRefs(0), detector=detector,
921 exposureRecord=self._exposureRecord())
923 self.assertIsInstance(result.template, lsst.images.DifferenceImage)
924 compare_masked_image_to_legacy(result.template, legacy.maskedImage,
925 plane_map=lsst.images.get_legacy_template_mask_planes())
926 self.assertEqual(result.template.unit, u.nJy)
927 self.assertIsNotNone(result.template.detector)
928 self.assertEqual(result.template.detector.name, detector.getName())
929 # The template is grown by the border, so it is larger than both the
930 # requested box and the detector.
931 expectedBox = lsst.geom.Box2I(box)
932 expectedBox.grow(lsst.ip.diffim.GetTemplateTask.ConfigClass().templateBorderSize)
933 self.assertEqual(result.template.bbox.to_legacy(), expectedBox)
934 self.assertEqual(result.template.obs_info.visit_id, self.visit)
936 def testConvertOutputsToFutureCoaddMaskPlanes(self):
937 """Test that the coadd mask planes a warped template carries are
938 converted, and come back when the image is converted to legacy.
940 The template is converted with the template plane map, so the coadd
941 planes are named there; the source injection planes are optional, and
942 are added because this template has pixels set in them.
943 """
944 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
945 planes = ("CLIPPED", "REJECTED", "INEXACT_PSF", "SENSOR_EDGE", "HIGH_VARIANCE",
946 "INJECTED", "INJECTED_CORE")
947 result, _ = self._runLegacyForFuture()
948 mask = result.template.mask
949 for n, plane in enumerate(planes):
950 mask.addMaskPlane(plane)
951 mask.array[0, n] |= mask.getPlaneBitMask(plane)
952 legacyTemplate = result.template.clone()
954 result.template = self.futureTask.convert_outputs_to_future(
955 result.template, self._coaddRefs(0), detector=detector,
956 exposureRecord=self._exposureRecord())
958 template = result.template
959 self.assertIsInstance(template, lsst.images.DifferenceImage)
960 # Name the optional planes that have pixels set in the map, so that
961 # dropping one fails the comparison instead of skipping it. The others
962 # are left out because they may be registered by unrelated tests.
963 optional = lsst.images.get_legacy_optional_mask_planes()
964 planeMap = lsst.images.get_legacy_template_mask_planes()
965 planeMap.update({plane: optional[plane] for plane in planes if plane in optional})
966 compare_masked_image_to_legacy(template, legacyTemplate.maskedImage, plane_map=planeMap)
967 # The butler converts a DifferenceImage to an ExposureF with the
968 # difference image plane map, which does not name the coadd planes;
969 # they keep their own names instead of being dropped.
970 legacy = template.to_legacy()
971 for n, plane in enumerate(planes):
972 with self.subTest(plane=plane):
973 self.assertIn(plane, legacy.mask.getMaskPlaneDict())
974 self.assertEqual(
975 np.count_nonzero(legacy.mask.array & legacy.mask.getPlaneBitMask(plane)), 1)
977 def _addUnmappedMaskPlane(self, template):
978 """Set one pixel in the template's BRIGHT_OBJECT plane, which the
979 template plane map does not include, and one in its CLIPPED plane,
980 which it does.
981 """
982 mask = template.mask
983 for n, plane in enumerate(("BRIGHT_OBJECT", "CLIPPED")):
984 mask.addMaskPlane(plane)
985 mask.array[0, n] |= mask.getPlaneBitMask(plane)
987 def testConvertOutputsToFutureUnmappedMaskPlaneRaises(self):
988 """With raiseOnUndefinedMaskMap=True, a template with pixels set in
989 an unmapped mask plane cannot be converted.
990 """
991 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
992 result, _ = self._runLegacyForFuture()
993 self._addUnmappedMaskPlane(result.template)
994 with self.assertRaisesRegex(RuntimeError, "BRIGHT_OBJECT"):
995 self.futureTask.convert_outputs_to_future(
996 result.template, self._coaddRefs(0), detector=detector,
997 exposureRecord=self._exposureRecord())
999 def testConvertOutputsToFutureUnmappedMaskPlaneWarns(self):
1000 """With raiseOnUndefinedMaskMap=False, an unmapped mask plane is
1001 dropped with a warning and the mapped planes are kept.
1002 """
1003 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1004 result, _ = self._runLegacyForFuture(raiseOnUndefinedMaskMap=False)
1005 self._addUnmappedMaskPlane(result.template)
1006 with self.assertLogs(self.futureTask.log.name, level="WARNING") as cm:
1007 template = self.futureTask.convert_outputs_to_future(
1008 result.template, self._coaddRefs(0), detector=detector,
1009 exposureRecord=self._exposureRecord())
1010 self.assertIn("BRIGHT_OBJECT", "\n".join(cm.output))
1011 self.assertIsInstance(template, lsst.images.DifferenceImage)
1012 self.assertNotIn("BRIGHT_OBJECT", template.mask.schema.names)
1013 self.assertEqual(np.count_nonzero(template.mask.get("CLIPPED")), 1)
1015 def testConvertOutputsToFutureLosesProvenance(self):
1016 """The coadd inputs that `run` attaches are not carried by
1017 `lsst.images.DifferenceImage`.
1018 """
1019 legacy, _ = self._runLegacyForFuture()
1020 self.assertTrue(legacy.template.getInfo().hasCoaddInputs())
1021 result, _, _ = self._runFuture()
1022 self.assertFalse(result.template.to_legacy().getInfo().hasCoaddInputs())
1024 def testConvertOutputsToFutureTemplates(self):
1025 """The coadds that went into the template are recorded on the
1026 converted image.
1027 """
1028 result, _, _ = self._runFuture()
1029 refs = {(ref.dataId["tract"], ref.dataId["patch"]): ref for ref in self._coaddRefs(0)}
1031 templates = result.template.templates
1032 self.assertGreater(len(templates), 0)
1033 for template in templates:
1034 with self.subTest(tract=template.tract, patch=template.patch):
1035 ref = refs[template.tract, template.patch]
1036 self.assertEqual(template.skymap, "skymap")
1037 self.assertEqual(template.dataset_id, ref.id)
1038 self.assertEqual(template.dataset_run, ref.run)
1039 self.assertEqual(template.instrument, "TestCam")
1040 self.assertFalse(template.psf_shape_flag)
1041 self.assertGreater(template.psf_shape_xx, 0)
1042 self.assertGreater(template.bounds.area, 0)
1044 def testConvertOutputsToFutureInstrument(self):
1045 """The template instrument is the one recorded by the coadds that
1046 know it, or ``"UNKNOWN"`` if none of them do.
1047 """
1048 records = [(self.exposure.wcs, self.exposure.getBBox())]
1049 handles = (self._patchHandles(0, records, instrument=None)[:1]
1050 + self._patchHandles(0, records)[1:])
1051 result, _, _ = self._runFuture(handles=handles)
1052 self.assertEqual({template.instrument for template in result.template.templates}, {"TestCam"})
1054 result, _, _ = self._runFuture(handles=self._patchHandles(0, records, instrument=None))
1055 self.assertEqual({template.instrument for template in result.template.templates}, {"UNKNOWN"})
1057 def testConvertOutputsToFuturePsf(self):
1058 """The Gaussian PSF set by `run` is kept, defined over the detector.
1059 """
1060 result, _ = self._runLegacyForFuture()
1061 detector = list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]
1062 legacyPsf = result.template.getPsf()
1064 result.template = self.futureTask.convert_outputs_to_future(
1065 result.template, self._coaddRefs(0), detector=detector,
1066 exposureRecord=self._exposureRecord())
1068 psf = result.template.psf
1069 self.assertIsInstance(psf, lsst.images.psfs.GaussianPointSpreadFunction)
1070 self.assertFloatsAlmostEqual(psf.sigma, legacyPsf.getSigma())
1071 self.assertEqual(psf.bounds.to_legacy(), detector.getBBox())
1072 self.assertEqual(psf.kernel_bbox.to_legacy().getDimensions(), legacyPsf.getDimensions())
1074 def testConvertOutputsToFuturePsfToLegacy(self):
1075 """The Gaussian survives conversion back to an Exposure, which is how
1076 a task that has not been converted reads the template.
1077 """
1078 result, _, _ = self._runFuture()
1080 psf = result.template.to_legacy().getPsf()
1081 self.assertIsInstance(psf, lsst.afw.detection.GaussianPsf)
1082 self.assertFloatsAlmostEqual(psf.getSigma(), result.template.psf.sigma)
1085class GetTemplateConnectionsTestCase(lsst.utils.tests.TestCase):
1086 """Test the connections that ``output_image_type`` switches on.
1088 These only need the config classes, not a built template.
1089 """
1091 def testOutputImageType(self):
1092 """Test that the future output type changes the template storage
1093 class, and leaves the input connections alone.
1094 """
1095 Connections = lsst.ip.diffim.GetTemplateTask.ConfigClass.ConnectionsClass
1096 config = lsst.ip.diffim.GetTemplateTask.ConfigClass()
1098 connections = Connections(config=config)
1099 self.assertEqual(connections.template.storageClass, "ExposureF")
1100 self.assertNotIn("obs_info", connections.inputs)
1102 config.output_image_type = "future"
1103 connections = Connections(config=config)
1104 self.assertEqual(connections.template.storageClass, "DifferenceImage")
1105 # The dataset name is unchanged; only its storage class differs.
1106 self.assertEqual(connections.template.name, "goodSeeingDiff_templateExp")
1107 # The detector the conversion needs comes from the science image.
1108 self.assertEqual(connections.detector.name, "calexp.detector")
1109 self.assertEqual(connections.detector.storageClass, "Detector")
1110 # The observation metadata is only read in future mode, where it is
1111 # the one input the conversion adds.
1112 self.assertEqual(connections.obs_info.name, "calexp.obs_info")
1113 self.assertEqual(connections.obs_info.storageClass, "ObservationInfo")
1115 def testLintConnections(self):
1116 """Check that the connections are self-consistent in both modes.
1117 """
1118 for task in (lsst.ip.diffim.GetTemplateTask, lsst.ip.diffim.GetDcrTemplateTask):
1119 with self.subTest(task=task.__name__):
1120 lsst.pipe.base.testUtils.lintConnections(task.ConfigClass.ConnectionsClass)
1123def setup_module(module):
1124 lsst.utils.tests.init()
1127class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
1128 pass
1131if __name__ == "__main__": 1131 ↛ 1132line 1131 didn't jump to line 1132 because the condition on line 1131 was never true
1132 lsst.utils.tests.init()
1133 unittest.main()