Coverage for tests / test_calibrateImage.py: 14%
511 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 08:30 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 08:30 +0000
1# This file is part of pipe_tasks.
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 unittest
23from unittest import mock
24import tempfile
26import astropy.units as u
27from astropy.coordinates import SkyCoord
28import copy
29import numpy as np
30import esutil
31import os
32import requests
34import lsst.afw.image as afwImage
35import lsst.afw.math as afwMath
36import lsst.afw.table as afwTable
37import lsst.daf.base
38import lsst.daf.butler
39import lsst.daf.butler.tests as butlerTests
40import lsst.geom
41import lsst.meas.algorithms
42from lsst.meas.algorithms import testUtils
43import lsst.meas.extensions.psfex
44import lsst.meas.base
45import lsst.meas.base.tests
46import lsst.pipe.base as pipeBase
47import lsst.pipe.base.testUtils
48from lsst.pipe.tasks.calibrateImage import CalibrateImageTask, \
49 NoPsfStarsToStarsMatchError, AllCentroidsFlaggedError
50import lsst.pex.config as pexConfig
51import lsst.utils.tests
53from utils import makeTestVisitInfo
56class CalibrateImageTaskTests(lsst.utils.tests.TestCase):
58 def setUp(self):
59 # Different x/y dimensions so they're easy to distinguish in a plot,
60 # and non-zero minimum, to help catch xy0 errors.
61 bbox = lsst.geom.Box2I(lsst.geom.Point2I(5, 4), lsst.geom.Point2I(205, 184))
62 self.sky_center = lsst.geom.SpherePoint(245.0, -45.0, lsst.geom.degrees)
63 self.photo_calib = 12.3
64 dataset = lsst.meas.base.tests.TestDataset(bbox, crval=self.sky_center, calibration=self.photo_calib)
65 # sqrt of area of a normalized 2d gaussian
66 psf_scale = np.sqrt(4*np.pi*(dataset.psfShape.getDeterminantRadius())**2)
67 noise = 10.0 # stddev of noise per pixel
68 # Sources ordered from faintest to brightest.
69 self.fluxes = np.array((6*noise*psf_scale,
70 12*noise*psf_scale,
71 45*noise*psf_scale,
72 150*noise*psf_scale,
73 400*noise*psf_scale,
74 1000*noise*psf_scale))
75 self.centroids = np.array(((162, 22),
76 (40, 70),
77 (100, 160),
78 (50, 120),
79 (92, 35),
80 (175, 154)), dtype=np.float32)
81 for flux, centroid in zip(self.fluxes, self.centroids):
82 dataset.addSource(instFlux=flux, centroid=lsst.geom.Point2D(centroid[0], centroid[1]))
84 # Bright extended source in the center of the image: should not appear
85 # in any of the output catalogs.
86 self.extended_source = lsst.geom.Point2D(100, 100)
87 shape = lsst.afw.geom.Quadrupole(8, 9, 3)
88 dataset.addSource(instFlux=500*noise*psf_scale, centroid=self.extended_source, shape=shape)
90 schema = dataset.makeMinimalSchema()
91 self.truth_exposure, self.truth_cat = dataset.realize(noise=noise, schema=schema)
92 # Add in a significant background, so we can test that the output
93 # background is self-consistent with the calibrated exposure.
94 self.background_level = 500.0
95 self.truth_exposure.image += self.background_level
96 # To make it look like a version=1 (nJy fluxes) refcat
97 self.truth_cat = self.truth_exposure.photoCalib.calibrateCatalog(self.truth_cat)
98 self.ref_loader = testUtils.MockReferenceObjectLoaderFromMemory([self.truth_cat])
99 metadata = lsst.daf.base.PropertyList()
100 metadata.set("REFCAT_FORMAT_VERSION", 1)
101 self.truth_cat.setMetadata(metadata)
103 # TODO: a cosmic ray (need to figure out how to insert a fake-CR)
104 # self.truth_exposure.image.array[10, 10] = 100000
105 # self.truth_exposure.variance.array[10, 10] = 100000/noise
107 # Copy the truth exposure, because CalibrateImage modifies the input.
108 # Post-ISR images only contain: initial WCS, VisitInfo, filter
109 self.exposure = afwImage.ExposureF(self.truth_exposure, deep=True)
110 self.exposure.setWcs(self.truth_exposure.wcs)
111 self.exposure.info.setVisitInfo(self.truth_exposure.visitInfo)
112 self.exposure.info.id = 12345
113 # "truth" filter, to match the "truth" refcat.
114 self.exposure.setFilter(lsst.afw.image.FilterLabel(physical='truth', band="truth"))
115 self.exposure.metadata["LSST ISR FLAT APPLIED"] = True
117 # Set up a basic results struct to hold exposure attribute data
118 self.attributes = pipeBase.Struct()
119 self.attributes.exposure = self.exposure
120 self.attributes.background = None
121 self.attributes.background_to_photometric_ratio = None
123 # Test-specific configuration:
124 self.config = CalibrateImageTask.ConfigClass()
125 # We don't have many sources, so have to fit simpler models.
126 self.config.psf_detection.background.approxOrderX = 1
127 self.config.star_detection.background.approxOrderX = 1
128 # Only insert 2 sky sources, for simplicity.
129 self.config.star_sky_sources.nSources = 2
130 # Use PCA psf fitter, as psfex fails if there are only 4 stars.
131 self.config.psf_measure_psf.psfDeterminer = 'pca'
132 # We don't have many test points, so can't match on complicated shapes.
133 self.config.astrometry.sourceSelector["science"].flags.good = []
134 self.config.astrometry.matcher.numPointsForShape = 3
135 self.config.run_sattle = False
136 # Maintain original, no adaptive threshold detection, configs values.
137 self.config.do_adaptive_threshold_detection = False
138 self.config.psf_detection.reEstimateBackground = True
139 self.config.star_detection.reEstimateBackground = True
140 # ApFlux has more noise than PsfFlux (the latter unrealistically small
141 # in this test data), so we need to do magnitude rejection at higher
142 # sigma, otherwise we can lose otherwise good sources.
143 # TODO DM-39203: Once we are using Compensated Gaussian Fluxes, we
144 # will use those fluxes here, and hopefully can remove this.
145 self.config.astrometry.magnitudeOutlierRejectionNSigma = 9.0
147 # Make a realistic id generator so that output catalog ids are useful.
148 # NOTE: The id generator is used to seed the noise replacer during
149 # measurement, so changes to values here can have subtle effects on
150 # the centroids and fluxes measured on the image, which might cause
151 # tests to fail.
152 data_id = lsst.daf.butler.DataCoordinate.standardize(
153 instrument="I",
154 visit=self.truth_exposure.visitInfo.id,
155 detector=12,
156 universe=lsst.daf.butler.DimensionUniverse(),
157 )
158 self.config.id_generator.packer.name = "observation"
159 self.config.id_generator.packer["observation"].n_observations = 10000
160 self.config.id_generator.packer["observation"].n_detectors = 99
161 self.config.id_generator.n_releases = 8
162 self.config.id_generator.release_id = 2
163 self.id_generator = self.config.id_generator.apply(data_id)
165 # Something about this test dataset prefers a larger threshold here.
166 self.config.star_selector["science"].unresolved.maximum = 0.2
168 def _check_run(self, calibrate, result, expect_calibrated_pixels: bool = True,
169 expect_n_background: int = 4, expect_n_background_equal_or_greater_than: int = -1):
170 """Test the result of CalibrateImage.run().
172 Parameters
173 ----------
174 calibrate : `lsst.pipe.tasks.calibrateImage.CalibrateImageTask`
175 Configured task that had `run` called on it.
176 result : `lsst.pipe.base.Struct`
177 Result of calling calibrate.run().
178 expect_calibrated_pixels : `bool`, optional
179 Whether to expect image and background pixels to be calibrated.
180 """
181 # Background should have 4 elements: 3 from compute_psf and one from
182 # re-estimation during source detection.
183 if expect_n_background is not None:
184 self.assertEqual(len(result.background), expect_n_background)
185 if expect_n_background_equal_or_greater_than is not None:
186 self.assertTrue(len(result.background) >= expect_n_background_equal_or_greater_than)
188 # Both afw and astropy psf_stars catalogs should be populated.
189 self.assertEqual(result.psf_stars["calib_psf_used"].sum(), 3)
190 self.assertEqual(result.psf_stars_footprints["calib_psf_used"].sum(), 3)
192 # Check that the summary statistics are reasonable.
193 summary = result.exposure.info.getSummaryStats()
194 self.assertFloatsAlmostEqual(summary.psfSigma, 2.0, rtol=1e-2)
195 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7)
196 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7)
198 # Should have finite sky coordinates in the afw and astropy catalogs.
199 self.assertTrue(np.isfinite(result.stars_footprints["coord_ra"]).all())
200 self.assertTrue(np.isfinite(result.stars["coord_ra"]).all())
202 if expect_calibrated_pixels:
203 # Fit photoCalib should be the applied value if we calibrated
204 # pixels, not the ==1 one on the exposure.
205 photo_calib = result.applied_photo_calib
206 self.assertEqual(result.exposure.photoCalib.getCalibrationMean(), 1.0)
207 else:
208 self.assertIsNone(result.applied_photo_calib)
209 photo_calib = result.exposure.photoCalib
210 # PhotoCalib comparison is very approximate because we are basing this
211 # comparison on just 2-3 stars.
212 self.assertFloatsAlmostEqual(photo_calib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
213 # Should have calibrated flux/mags in the afw and astropy catalogs
214 self.assertIn("slot_PsfFlux_flux", result.stars_footprints.schema)
215 self.assertIn("slot_PsfFlux_mag", result.stars_footprints.schema)
216 self.assertEqual(result.stars["slot_PsfFlux_flux"].unit, u.nJy)
217 self.assertEqual(result.stars["slot_PsfFlux_mag"].unit, u.ABmag)
219 # Should have detected all S/N >= 10 sources plus 2 sky sources,
220 # whether 1 or 2 snaps.
221 self.assertEqual(len(result.stars), 6)
222 # Did the psf flags get propagated from the psf_stars catalog?
223 self.assertEqual(result.stars["calib_psf_used"].sum(), 3)
225 # Check that all necessary fields are in the output.
226 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result)
228 # Check metadata.
229 key = "LSST CALIB ILLUMCORR APPLIED"
230 self.assertIn(key, result.exposure.metadata)
231 self.assertEqual(result.exposure.metadata[key], False)
233 # Check that the psf_stars cross match worked correctly.
234 matches = esutil.numpy_util.match(result.psf_stars["id"], result.stars["psf_id"])
235 self.assertFloatsAlmostEqual(result.psf_stars["slot_Centroid_x"][matches[0]],
236 result.stars["slot_Centroid_x"][matches[1]], atol=3e-4)
237 if "astrometry_matches" in self.config.optional_outputs:
238 matches = esutil.numpy_util.match(result.astrometry_matches["src_id"],
239 result.photometry_matches["src_psf_id"])
240 self.assertFloatsAlmostEqual(result.astrometry_matches["src_slot_Centroid_x"][matches[0]],
241 result.photometry_matches["src_slot_Centroid_x"][matches[1]],
242 atol=3e-4)
244 def test_run(self):
245 """Test that run() returns reasonable values to be butler put.
246 """
247 calibrate = CalibrateImageTask(config=self.config)
248 calibrate.astrometry.setRefObjLoader(self.ref_loader)
249 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
250 result = calibrate.run(exposures=self.exposure)
252 self._check_run(calibrate, result)
254 def test_run_adaptive_threshold_detection(self):
255 """Test that run() runs with adaptive threshold detection turned on.
256 """
257 config = copy.copy(self.config)
258 # Set the adaptive threshold detection, config values...
259 config.do_adaptive_threshold_detection = True
260 config.psf_adaptive_threshold_detection.minFootprint = 4
261 config.psf_adaptive_threshold_detection.minIsolated = 4
262 config.psf_adaptive_threshold_detection.sufficientIsolated = 4
263 config.psf_detection.reEstimateBackground = False
264 config.star_detection.reEstimateBackground = False
266 calibrate = CalibrateImageTask(config=config)
267 calibrate.astrometry.setRefObjLoader(self.ref_loader)
268 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
269 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
270 result = calibrate.run(exposures=self.exposure)
271 subString = "Using adaptive threshold detection "
272 self.assertTrue(any(subString in s for s in cm.output))
274 # Number of backgrounds in list is only guaranteed to have at least 2
275 # entries in the adaptive threshold code path.
276 self._check_run(calibrate, result, expect_n_background=None,
277 expect_n_background_equal_or_greater_than=2)
279 def test_run_downsample(self):
280 """Test that run() runs with downsample.
281 """
282 config = copy.copy(self.config)
283 config.do_downsample_footprints = True
284 config.downsample_max_footprints = 5
286 calibrate = CalibrateImageTask(config=config)
287 calibrate.astrometry.setRefObjLoader(self.ref_loader)
288 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
289 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
290 result = calibrate.run(exposures=self.exposure)
291 self.assertIn(
292 "INFO:lsst.calibrateImage:Downsampling from 8 to 7 non-sky-source footprints.",
293 cm.output,
294 )
296 self._check_run(calibrate, result)
298 def test_run_2_snaps(self):
299 """Test that run() returns reasonable values to be butler put, when
300 passed two exposures to combine as snaps.
301 """
302 calibrate = CalibrateImageTask(config=self.config)
303 calibrate.astrometry.setRefObjLoader(self.ref_loader)
304 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
305 # Halve the flux in each exposure to get the expected visit sum.
306 self.exposure.image /= 2
307 self.exposure.variance /= 2
308 result = calibrate.run(exposures=[self.exposure, self.exposure])
310 self._check_run(calibrate, result)
312 def test_run_no_optionals(self):
313 """Test that disabling optional outputs removes them from the output
314 struct, as appropriate.
315 """
316 self.config.optional_outputs = []
317 calibrate = CalibrateImageTask(config=self.config)
318 calibrate.astrometry.setRefObjLoader(self.ref_loader)
319 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
320 result = calibrate.run(exposures=self.exposure)
322 self._check_run(calibrate, result)
323 # These are the only optional outputs that require extra computation,
324 # the others are included in the output struct regardless.
325 self.assertNotIn("astrometry_matches", result.getDict())
326 self.assertNotIn("photometry_matches", result.getDict())
328 def test_run_no_calibrate_pixels(self):
329 """Test that run() returns reasonable values when
330 do_calibrate_pixels=False.
331 """
332 self.config.do_calibrate_pixels = False
333 calibrate = CalibrateImageTask(config=self.config)
334 calibrate.astrometry.setRefObjLoader(self.ref_loader)
335 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
336 result = calibrate.run(exposures=self.exposure)
338 self._check_run(calibrate, result, expect_calibrated_pixels=False)
340 def test_run_no_astrom_errors(self):
341 """Test that run() returns reasonable values when
342 do_calibrate_pixels=False.
343 """
344 self.config.do_include_astrometric_errors = False
345 calibrate = CalibrateImageTask(config=self.config)
346 calibrate.astrometry.setRefObjLoader(self.ref_loader)
347 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
348 result = calibrate.run(exposures=self.exposure)
350 self._check_run(calibrate, result)
352 def test_compute_psf(self):
353 """Test that our brightest sources are found by _compute_psf(),
354 that a PSF is assigned to the exposure.
355 """
356 calibrate = CalibrateImageTask(config=self.config)
357 psf_stars, candidates, _ = calibrate._compute_psf(self.attributes, self.id_generator)
359 # Catalog ids should be very large from this id generator.
360 self.assertTrue(all(psf_stars['id'] > 1000000000))
362 # Background should have 3 elements: initial subtraction, and two from
363 # re-estimation during the two detection passes.
364 self.assertEqual(len(self.attributes.background), 3)
366 # Only the point-sources with S/N > 50 should be in this output.
367 self.assertEqual(psf_stars["calib_psf_used"].sum(), 3)
368 # Sort in brightness order, to easily compare with expected positions.
369 psf_stars.sort(psf_stars.getPsfFluxSlot().getMeasKey())
370 for record, flux, center in zip(psf_stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
371 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
372 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
373 # PsfFlux should match the values inserted.
374 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
376 # TODO: While debugging DM-32701, we're using PCA instead of psfex.
377 # Check that we got a useable PSF.
378 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf)
379 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf)
380 # TestDataset sources have PSF radius=2 pixels.
381 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius()
382 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2)
384 # To look at images for debugging (`setup display_ds9` and run ds9):
385 # import lsst.afw.display
386 # display = lsst.afw.display.getDisplay()
387 # display.mtv(self.exposure)
389 def test_compute_psf_bad_centroids(self):
390 """Test that we raise an appropriate error if all measured centroids
391 are bad in compute_psf.
392 The root cause of this is likely an interaction between the PSF fit
393 and cosmic ray repair. An ugly true PSF can result in the centers of
394 sources being masked and repaired as CRs, thus removing the center of
395 the sources and causing them all to be flagged.
396 """
397 # make the sources have a (not too deep) hole in the middle
398 for point in self.centroids:
399 for i in (-1, 0, 1):
400 for j in (-1, 0, 1):
401 self.exposure.image[point[0]+i, point[1]+j] -= \
402 (self.exposure.image[point[0]+i, point[1]+j] - self.background_level)/1.1
403 # and the extended central source
404 point = self.extended_source
405 self.exposure.image[point[0], point[1]] -= \
406 (self.exposure.image[point[0], point[1]] - self.background_level)
408 # add a diagonal gradient to each source
409 size = 5
410 for point, flux in zip(self.centroids, self.fluxes):
411 box = lsst.geom.Box2I.makeCenteredBox(lsst.geom.Point2D(point[0], point[1]),
412 lsst.geom.Extent2I(size*2, size*2))
413 X, Y = np.ogrid[point[0] - size:point[0] + size, point[1] - size:point[1] + size]
414 distance = np.sqrt((X - point[0])**2 + (Y - point[1])**2)
415 gradient = (X - point[0] + size)*10 + (Y - point[1] + size)*10
416 gradient[distance > size] = 0
417 cutout = self.exposure.image.subset(box)
418 cutout.array += gradient / flux
420 calibrate = CalibrateImageTask(config=self.config)
421 with self.assertRaisesRegex(AllCentroidsFlaggedError, r"source centroids \(out of 4\) flagged"):
422 calibrate.run(exposures=[self.exposure], id_generator=self.id_generator)
424 def test_measure_aperture_correction(self):
425 """Test that _measure_aperture_correction() assigns an ApCorrMap to the
426 exposure.
427 """
428 calibrate = CalibrateImageTask(config=self.config)
429 psf_stars, candidates, _ = calibrate._compute_psf(self.attributes, self.id_generator)
431 # First check that the exposure doesn't have an ApCorrMap.
432 self.assertIsNone(self.exposure.apCorrMap)
433 calibrate._measure_aperture_correction(self.exposure, psf_stars)
434 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap)
435 # We know that there are 2 fields from the normalization, plus more
436 # from other configured plugins.
437 self.assertGreater(len(self.exposure.apCorrMap), 2)
439 def test_find_stars(self):
440 """Test that _find_stars() correctly identifies the S/N>10 stars
441 in the image and returns them in the output catalog.
442 """
443 calibrate = CalibrateImageTask(config=self.config)
444 psf_stars, candidates, _ = calibrate._compute_psf(self.attributes, self.id_generator)
445 calibrate._measure_aperture_correction(self.exposure, psf_stars)
447 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
449 # Catalog ids should be very large from this id generator.
450 self.assertTrue(all(stars['id'] > 1000000000))
452 # Background should have 4 elements: 3 from compute_psf and one from
453 # re-estimation during source detection.
454 self.assertEqual(len(self.attributes.background), 4)
456 # Only 5 psf-like sources with S/N>10 should be in the output catalog,
457 # plus two sky sources.
458 self.assertEqual(len(stars), 6)
459 self.assertTrue(stars.isContiguous())
460 # Sort in brightness order, to easily compare with expected positions.
461 stars.sort(stars.getPsfFluxSlot().getMeasKey())
462 for record, flux, center in zip(stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
463 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
464 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
465 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
467 def test_astrometry(self):
468 """Test that the fitted WCS gives good catalog coordinates.
469 """
470 calibrate = CalibrateImageTask(config=self.config)
471 calibrate.astrometry.setRefObjLoader(self.ref_loader)
472 psf_stars, candidates, _ = calibrate._compute_psf(self.attributes, self.id_generator)
473 calibrate._measure_aperture_correction(self.exposure, psf_stars)
474 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
476 calibrate._fit_astrometry(self.exposure, stars)
478 # Check that we got reliable matches with the truth coordinates.
479 sky = stars["sky_source"]
480 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
481 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
482 idx, d2d, _ = fitted.match_to_catalog_sky(truth)
483 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 35.0)
485 def test_photometry(self):
486 """Test that the fitted photoCalib matches the one we generated,
487 and that the exposure is calibrated.
488 """
489 calibrate = CalibrateImageTask(config=self.config)
490 calibrate.astrometry.setRefObjLoader(self.ref_loader)
491 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
492 psf_stars, candidates, _ = calibrate._compute_psf(self.attributes, self.id_generator)
493 calibrate._measure_aperture_correction(self.exposure, psf_stars)
494 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
495 calibrate._fit_astrometry(self.exposure, stars)
497 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars)
498 calibrate._apply_photometry(self.exposure, self.attributes.background)
500 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0,
501 # so we can't check that the photoCal error has been set.
502 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
503 # The exposure should be calibrated by the applied photoCalib,
504 # and the background should be calibrated to match.
505 uncalibrated = self.exposure.image.clone()
506 uncalibrated += self.attributes.background.getImage()
507 uncalibrated /= self.photo_calib
508 self.assertFloatsAlmostEqual(uncalibrated.array, self.truth_exposure.image.array, rtol=1e-2)
509 # PhotoCalib on the exposure must be identically 1.
510 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0)
512 # Check that we got reliable magnitudes and fluxes vs. truth, ignoring
513 # sky sources.
514 sky = stars["sky_source"]
515 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
516 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
517 idx, _, _ = fitted.match_to_catalog_sky(truth)
518 # Because the input variance image does not include contributions from
519 # the sources, we can't use fluxErr as a bound on the measurement
520 # quality here.
521 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_flux'],
522 self.truth_cat['truth_flux'][idx],
523 rtol=0.1)
524 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_mag'],
525 self.truth_cat['truth_mag'][idx],
526 rtol=0.01)
528 def test_match_psf_stars(self):
529 """Test that _match_psf_stars() flags the correct stars as psf stars
530 and candidates.
531 """
532 calibrate = CalibrateImageTask(config=self.config)
533 psf_stars, candidates, _ = calibrate._compute_psf(self.attributes, self.id_generator)
534 calibrate._measure_aperture_correction(self.exposure, psf_stars)
535 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
537 # There should be no psf-related flags set at first.
538 self.assertEqual(stars["calib_psf_candidate"].sum(), 0)
539 self.assertEqual(stars["calib_psf_used"].sum(), 0)
540 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
542 # Reorder stars to be out of order with psf_stars (putting the sky
543 # sources in front); this tests that I get the indexing right.
544 stars.sort(stars.getCentroidSlot().getMeasKey().getX())
545 stars = stars.copy(deep=True)
546 # Re-number the ids: the matcher requires sorted ids: this is always
547 # true in the code itself, but we've permuted them by sorting on
548 # flux. We don't care what the actual ids themselves are here.
549 stars["id"] = np.arange(len(stars))
551 calibrate._match_psf_stars(psf_stars, stars)
553 # Check that the three brightest stars have the psf flags transferred
554 # from the psf_stars catalog by sorting in order of brightness.
555 stars.sort(stars.getPsfFluxSlot().getMeasKey())
556 # sort() above leaves the catalog non-contiguous.
557 stars = stars.copy(deep=True)
558 np.testing.assert_array_equal(stars["calib_psf_candidate"],
559 [False, False, False, True, True, True])
560 np.testing.assert_array_equal(stars["calib_psf_used"],
561 [False, False, False, True, True, True])
562 # Too few sources to reserve any in these tests.
563 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
565 def test_match_psf_stars_no_matches(self):
566 """Check that _match_psf_stars handles the case of no cross-matches.
567 """
568 calibrate = CalibrateImageTask(config=self.config)
569 # Make two catalogs that cannot have matches.
570 stars = self.truth_cat[2:].copy(deep=True)
571 psf_stars = self.truth_cat[:2].copy(deep=True)
573 with self.assertRaisesRegex(NoPsfStarsToStarsMatchError,
574 "No psf stars out of 2 matched 5 calib stars") as cm:
575 calibrate._match_psf_stars(psf_stars, stars)
576 self.assertEqual(cm.exception.metadata["n_psf_stars"], 2)
577 self.assertEqual(cm.exception.metadata["n_stars"], 5)
579 def test_calibrate_image_illumcorr(self):
580 """Test running through with an illumination correction."""
581 config = copy.copy(self.config)
582 config.do_illumination_correction = True
583 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
584 config.psf_detection.doApplyFlatBackgroundRatio = True
585 config.star_background.doApplyFlatBackgroundRatio = True
586 config.star_detection.doApplyFlatBackgroundRatio = True
588 calibrate = CalibrateImageTask(config=config)
589 calibrate.astrometry.setRefObjLoader(self.ref_loader)
590 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
592 # Assume that the exposure has been flattened by a flat-flat.
593 background_flat = self.exposure.clone()
594 background_flat.image.array[:, :] = 1.0
595 background_flat.mask.array[:, :] = 0
596 background_flat.variance.array[:, :] = 0.0
598 # And create an illumination correction of 1.1.
599 illum_corr_value = 1.1
600 illumination_correction = self.exposure.clone()
601 illumination_correction.image.array[:, :] = illum_corr_value
602 illumination_correction.mask.array[:, :] = 0
603 illumination_correction.variance.array[:, :] = 0.0
605 result = calibrate.run(
606 exposures=self.exposure,
607 id_generator=self.id_generator,
608 background_flat=background_flat,
609 illumination_correction=illumination_correction,
610 )
612 # We divide the image by the illumination correction, but the reference
613 # sources stay the same, so the applied photocalib will increase by
614 # the illum_corr_value.
615 # Tolerance is the same as the direct test with no illumination
616 # correction.
617 self.assertFloatsAlmostEqual(
618 result.applied_photo_calib.getCalibrationMean(),
619 self.photo_calib * illum_corr_value,
620 rtol=1e-2,
621 )
623 self.assertEqual(len(result.background), 4)
624 self.assertFloatsAlmostEqual(
625 np.median(result.background.getImage().array),
626 result.applied_photo_calib.getCalibrationMean() * self.background_level,
627 rtol=1e-3,
628 )
630 # Check metadata.
631 key = "LSST CALIB ILLUMCORR APPLIED"
632 self.assertIn(key, result.exposure.metadata)
633 self.assertEqual(result.exposure.metadata[key], True)
635 def test_run_with_diffraction_spike_mask(self):
636 """Test that the diffraction spike mask subtask runs.
637 """
638 config = self.config
639 config.doMaskDiffractionSpikes = True
640 config.diffractionSpikeMask.magnitudeThreshold = 19
641 # Define a fake SATURATED mask plane for use by the diffraction spike
642 # task, so that it does not affect the rest of calibrate
643 config.diffractionSpikeMask.saturatedMaskPlane = "FAKESATURATED"
644 calibrate = CalibrateImageTask(config=config)
645 calibrate.astrometry.setRefObjLoader(self.ref_loader)
646 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
647 calibrate.diffractionSpikeMask.setRefObjLoader(self.ref_loader)
649 exposure = self.exposure.clone()
651 exposure.info.setVisitInfo(makeTestVisitInfo())
652 exposure.mask.addMaskPlane(config.diffractionSpikeMask.saturatedMaskPlane)
654 # Set the saturated mask plane in half of the image
655 saturatedMaskBit = exposure.mask.getPlaneBitMask(config.diffractionSpikeMask.saturatedMaskPlane)
656 bbox = exposure.getBBox()
657 bbox.grow(-lsst.geom.Extent2I(0, bbox.height//4))
658 exposure[bbox].mask.array |= saturatedMaskBit
660 result = calibrate.run(exposures=exposure)
662 self._check_run(calibrate, result)
664 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": ""})
665 def test_fail_on_sattle_misconfiguration(self):
666 """Test for failure if sattle is requested without appropriate
667 configurations.
668 """
669 self.config.run_sattle = True
670 with self.assertRaises(pexConfig.FieldValidationError):
671 CalibrateImageTask(config=self.config)
673 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
674 def test_continue_on_sattle_failure(self):
675 """Processing should continue when sattle returns status codes other
676 than 200.
677 """
678 response = MockResponse({}, 500, "internal sattle error")
680 self.config.run_sattle = True
681 calibrate = CalibrateImageTask(config=self.config)
682 calibrate.astrometry.setRefObjLoader(self.ref_loader)
683 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
684 with mock.patch('requests.put', return_value=response) as mock_put:
685 calibrate.run(exposures=self.exposure)
686 mock_put.assert_called_once()
688 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
689 def test_sattle(self):
690 """Test for successful completion when sattle call returns
691 successfully.
692 """
693 response = MockResponse({}, 200, "success")
695 self.config.run_sattle = True
696 calibrate = CalibrateImageTask(config=self.config)
697 calibrate.astrometry.setRefObjLoader(self.ref_loader)
698 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
699 with mock.patch('requests.put', return_value=response) as mock_put:
700 calibrate.run(exposures=self.exposure)
701 mock_put.assert_called_once()
704class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase):
705 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler,
706 but do not need real images.
707 """
708 def setUp(self):
709 instrument = "testCam"
710 exposure0 = 101
711 exposure1 = 102
712 visit = 100101
713 detector = 42
714 physical_filter = "r"
716 # Create a and populate a test butler for runQuantum tests.
717 self.repo_path = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
718 self.repo = butlerTests.makeTestRepo(self.repo_path.name)
719 self.enterContext(self.repo)
721 # A complete instrument record is necessary for the id generator.
722 instrumentRecord = self.repo.dimensions["instrument"].RecordClass(
723 name=instrument, visit_max=1e6, exposure_max=1e6, detector_max=128,
724 class_name="lsst.obs.base.instrument_tests.DummyCam",
725 )
726 self.repo.registry.syncDimensionData("instrument", instrumentRecord)
728 # dataIds for fake data
729 butlerTests.addDataIdValue(self.repo, "detector", detector)
730 butlerTests.addDataIdValue(self.repo, "exposure", exposure0)
731 butlerTests.addDataIdValue(self.repo, "exposure", exposure1)
732 butlerTests.addDataIdValue(self.repo, "visit", visit)
733 butlerTests.addDataIdValue(self.repo, "physical_filter", physical_filter)
735 # inputs
736 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"},
737 "ExposureF")
738 butlerTests.addDatasetType(self.repo, "gaia_dr3_20230707", {"htm7"}, "SimpleCatalog")
739 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog")
740 butlerTests.addDatasetType(self.repo, "flat", {"instrument", "detector", "physical_filter"},
741 "Exposure")
742 butlerTests.addDatasetType(self.repo,
743 "illuminationCorrection",
744 {"instrument", "detector", "physical_filter"},
745 "Exposure")
747 # outputs
748 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"},
749 "ExposureF")
750 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector",
751 {"instrument", "visit", "detector"},
752 "SourceCatalog")
753 butlerTests.addDatasetType(self.repo, "initial_stars_detector",
754 {"instrument", "visit", "detector"},
755 "ArrowAstropy")
756 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector",
757 {"instrument", "visit", "detector"},
758 "PhotoCalib")
759 butlerTests.addDatasetType(self.repo, "background_to_photometric_ratio",
760 {"instrument", "visit", "detector"},
761 "Image")
762 # optional outputs
763 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"},
764 "Background")
765 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints_detector",
766 {"instrument", "visit", "detector"},
767 "SourceCatalog")
768 butlerTests.addDatasetType(self.repo, "initial_psf_stars_detector",
769 {"instrument", "visit", "detector"},
770 "ArrowAstropy")
771 butlerTests.addDatasetType(self.repo,
772 "initial_astrometry_match_detector",
773 {"instrument", "visit", "detector"},
774 "Catalog")
775 butlerTests.addDatasetType(self.repo,
776 "initial_photometry_match_detector",
777 {"instrument", "visit", "detector"},
778 "Catalog")
779 butlerTests.addDatasetType(self.repo,
780 "preliminary_visit_mask",
781 {"instrument", "visit", "detector"},
782 "Mask")
784 # dataIds
785 self.exposure0_id = self.repo.registry.expandDataId(
786 {"instrument": instrument, "exposure": exposure0, "detector": detector})
787 self.exposure1_id = self.repo.registry.expandDataId(
788 {"instrument": instrument, "exposure": exposure1, "detector": detector})
789 self.visit_id = self.repo.registry.expandDataId(
790 {"instrument": instrument, "visit": visit, "detector": detector})
791 self.htm_id = self.repo.registry.expandDataId({"htm7": 42})
792 self.flat_id = self.repo.registry.expandDataId(
793 {"instrument": instrument, "detector": detector, "physical_filter": physical_filter})
795 # put empty data
796 self.butler = butlerTests.makeTestCollection(self.repo)
797 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure0_id)
798 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure1_id)
799 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr3_20230707", self.htm_id)
800 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id)
801 self.butler.put(afwImage.ExposureF(), "flat", self.flat_id)
802 self.butler.put(afwImage.ExposureF(), "illuminationCorrection", self.flat_id)
804 def tearDown(self):
805 self.repo_path.cleanup()
807 def test_runQuantum(self):
808 task = CalibrateImageTask()
809 lsst.pipe.base.testUtils.assertValidInitOutput(task)
811 quantum = lsst.pipe.base.testUtils.makeQuantum(
812 task, self.butler, self.visit_id,
813 {"exposures": [self.exposure0_id],
814 "astrometry_ref_cat": [self.htm_id],
815 "photometry_ref_cat": [self.htm_id],
816 "background_flat": self.flat_id,
817 "illumination_correction": self.flat_id,
818 # outputs
819 "exposure": self.visit_id,
820 "stars": self.visit_id,
821 "stars_footprints": self.visit_id,
822 "background": self.visit_id,
823 "psf_stars": self.visit_id,
824 "psf_stars_footprints": self.visit_id,
825 "applied_photo_calib": self.visit_id,
826 "initial_pvi_background": self.visit_id,
827 "astrometry_matches": self.visit_id,
828 "photometry_matches": self.visit_id,
829 "mask": self.visit_id,
830 })
831 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
833 # Ensure the reference loaders have been configured.
834 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
835 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
836 # Check that the proper kwargs are passed to run().
837 self.assertEqual(
838 mock_run.call_args.kwargs.keys(),
839 {"exposures",
840 "result",
841 "id_generator",
842 "background_flat",
843 "illumination_correction",
844 "camera_model",
845 "exposure_record",
846 "exposure_region",
847 },
848 )
850 def test_runQuantum_illumination_correction(self):
851 config = CalibrateImageTask.ConfigClass()
852 config.do_illumination_correction = True
853 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
854 config.psf_detection.doApplyFlatBackgroundRatio = True
855 config.star_background.doApplyFlatBackgroundRatio = True
856 config.star_detection.doApplyFlatBackgroundRatio = True
857 task = CalibrateImageTask(config=config)
858 lsst.pipe.base.testUtils.assertValidInitOutput(task)
860 quantum = lsst.pipe.base.testUtils.makeQuantum(
861 task, self.butler, self.visit_id,
862 {"exposures": [self.exposure0_id],
863 "astrometry_ref_cat": [self.htm_id],
864 "photometry_ref_cat": [self.htm_id],
865 "background_flat": self.flat_id,
866 "illumination_correction": self.flat_id,
867 # outputs
868 "exposure": self.visit_id,
869 "stars": self.visit_id,
870 "stars_footprints": self.visit_id,
871 "background": self.visit_id,
872 "background_to_photometric_ratio": self.visit_id,
873 "psf_stars": self.visit_id,
874 "psf_stars_footprints": self.visit_id,
875 "applied_photo_calib": self.visit_id,
876 "initial_pvi_background": self.visit_id,
877 "astrometry_matches": self.visit_id,
878 "photometry_matches": self.visit_id,
879 "mask": self.visit_id,
880 })
881 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
883 # Ensure the reference loaders have been configured.
884 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
885 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
886 # Check that the proper kwargs are passed to run().
887 self.assertEqual(
888 mock_run.call_args.kwargs.keys(),
889 {"exposures",
890 "result",
891 "id_generator",
892 "background_flat",
893 "illumination_correction",
894 "camera_model",
895 "exposure_record",
896 "exposure_region",
897 },
898 )
900 def test_runQuantum_2_snaps(self):
901 task = CalibrateImageTask()
902 lsst.pipe.base.testUtils.assertValidInitOutput(task)
904 quantum = lsst.pipe.base.testUtils.makeQuantum(
905 task, self.butler, self.visit_id,
906 {"exposures": [self.exposure0_id, self.exposure1_id],
907 "astrometry_ref_cat": [self.htm_id],
908 "photometry_ref_cat": [self.htm_id],
909 "background_flat": self.flat_id,
910 "illumination_correction": self.flat_id,
911 # outputs
912 "exposure": self.visit_id,
913 "stars": self.visit_id,
914 "stars_footprints": self.visit_id,
915 "background": self.visit_id,
916 "psf_stars": self.visit_id,
917 "psf_stars_footprints": self.visit_id,
918 "applied_photo_calib": self.visit_id,
919 "initial_pvi_background": self.visit_id,
920 "astrometry_matches": self.visit_id,
921 "photometry_matches": self.visit_id,
922 "mask": self.visit_id,
923 })
924 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
926 # Ensure the reference loaders have been configured.
927 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
928 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
929 # Check that the proper kwargs are passed to run().
930 self.assertEqual(
931 mock_run.call_args.kwargs.keys(),
932 {"exposures",
933 "result",
934 "id_generator",
935 "background_flat",
936 "illumination_correction",
937 "camera_model",
938 "exposure_record",
939 "exposure_region",
940 },
941 )
943 def test_runQuantum_no_optional_outputs(self):
944 # All the possible connections: we modify this to test each one by
945 # popping off the removed connection, then re-setting it.
946 connections = {"exposures": [self.exposure0_id, self.exposure1_id],
947 "astrometry_ref_cat": [self.htm_id],
948 "photometry_ref_cat": [self.htm_id],
949 "background_flat": self.flat_id,
950 "illumination_correction": self.flat_id,
951 # outputs
952 "exposure": self.visit_id,
953 "stars": self.visit_id,
954 "stars_footprints": self.visit_id,
955 "background": self.visit_id,
956 "psf_stars": self.visit_id,
957 "psf_stars_footprints": self.visit_id,
958 "applied_photo_calib": self.visit_id,
959 "initial_pvi_background": self.visit_id,
960 "astrometry_matches": self.visit_id,
961 "photometry_matches": self.visit_id,
962 "mask": self.visit_id,
963 }
965 # Check that we can turn off one output at a time.
966 for optional in ["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches",
967 "mask"]:
968 config = CalibrateImageTask.ConfigClass()
969 config.optional_outputs.remove(optional)
970 task = CalibrateImageTask(config=config)
971 lsst.pipe.base.testUtils.assertValidInitOutput(task)
972 # Save the removed one for the next test.
973 temp = connections.pop(optional)
974 # This will fail with "Error in connection ..." if we don't pop
975 # the optional item from the connections list just above.
976 quantum = lsst.pipe.base.testUtils.makeQuantum(task, self.butler, self.visit_id, connections)
977 # This confirms that the outputs did skip the removed one.
978 self.assertNotIn(optional, quantum.outputs)
979 # Restore the one we removed for the next test.
980 connections[optional] = temp
982 def test_runQuantum_no_calibrate_pixels(self):
983 """Test that the the task runs when calibrating pixels is disabled,
984 and that this results in the ``applied_photo_calib`` output being
985 removed.
986 """
987 config = CalibrateImageTask.ConfigClass()
988 config.do_calibrate_pixels = False
989 task = CalibrateImageTask(config=config)
990 lsst.pipe.base.testUtils.assertValidInitOutput(task)
992 quantum = lsst.pipe.base.testUtils.makeQuantum(
993 task, self.butler, self.visit_id,
994 {"exposures": [self.exposure0_id],
995 "astrometry_ref_cat": [self.htm_id],
996 "photometry_ref_cat": [self.htm_id],
997 "background_flat": self.flat_id,
998 "illumination_correction": self.flat_id,
999 # outputs
1000 "exposure": self.visit_id,
1001 "stars": self.visit_id,
1002 "stars_footprints": self.visit_id,
1003 "background": self.visit_id,
1004 "psf_stars": self.visit_id,
1005 "psf_stars_footprints": self.visit_id,
1006 "initial_pvi_background": self.visit_id,
1007 "astrometry_matches": self.visit_id,
1008 "photometry_matches": self.visit_id,
1009 "mask": self.visit_id,
1010 })
1011 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1013 # Ensure the reference loaders have been configured.
1014 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
1015 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
1016 # Check that the proper kwargs are passed to run().
1017 self.assertEqual(
1018 mock_run.call_args.kwargs.keys(),
1019 {"exposures",
1020 "result",
1021 "id_generator",
1022 "background_flat",
1023 "illumination_correction",
1024 "camera_model",
1025 "exposure_record",
1026 "exposure_region",
1027 },
1028 )
1030 def test_lintConnections(self):
1031 """Check that the connections are self-consistent.
1032 """
1033 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass
1034 lsst.pipe.base.testUtils.lintConnections(Connections)
1036 def test_runQuantum_exception(self):
1037 """Test exception handling in runQuantum.
1038 """
1039 task = CalibrateImageTask()
1040 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1042 quantum = lsst.pipe.base.testUtils.makeQuantum(
1043 task, self.butler, self.visit_id,
1044 {"exposures": [self.exposure0_id],
1045 "astrometry_ref_cat": [self.htm_id],
1046 "photometry_ref_cat": [self.htm_id],
1047 "background_flat": self.flat_id,
1048 "illuminationCorrection": self.flat_id,
1049 # outputs
1050 "exposure": self.visit_id,
1051 "stars": self.visit_id,
1052 "stars_footprints": self.visit_id,
1053 "background": self.visit_id,
1054 "psf_stars": self.visit_id,
1055 "psf_stars_footprints": self.visit_id,
1056 "applied_photo_calib": self.visit_id,
1057 "initial_pvi_background": self.visit_id,
1058 "astrometry_matches": self.visit_id,
1059 "photometry_matches": self.visit_id,
1060 "mask": self.visit_id,
1061 })
1063 # A generic exception should raise directly.
1064 msg = "mocked run exception"
1065 with (
1066 mock.patch.object(task, "run", side_effect=ValueError(msg)),
1067 self.assertRaisesRegex(ValueError, "mocked run exception")
1068 ):
1069 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False)
1071 # An AlgorithmError should write annotated partial outputs.
1072 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101)
1074 def mock_run(
1075 exposures,
1076 result=None,
1077 id_generator=None,
1078 background_flat=None,
1079 illumination_correction=None,
1080 camera_model=None,
1081 exposure_record=None,
1082 exposure_region=None,
1083 ):
1084 """Mock success through compute_psf, but failure after.
1085 """
1086 result.exposure = afwImage.ExposureF(10, 10)
1087 result.psf_stars_footprints = afwTable.SourceCatalog()
1088 result.psf_stars = afwTable.SourceCatalog().asAstropy()
1089 result.background = afwMath.BackgroundList()
1090 raise error
1092 with (
1093 mock.patch.object(task, "run", side_effect=mock_run),
1094 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError),
1095 ):
1096 with self.assertLogs("lsst.calibrateImage", level="DEBUG") as cm:
1097 lsst.pipe.base.testUtils.runTestQuantum(task,
1098 self.butler,
1099 quantum,
1100 mockRun=False)
1102 logged = "\n".join(cm.output)
1103 self.assertIn("Task failed with only partial outputs", logged)
1104 self.assertIn("MeasureApCorrError", logged)
1106 # NOTE: This is an integration test of afw Exposure & SourceCatalog
1107 # metadata with the error annotation system in pipe_base.
1108 # Check that we did get the annotated partial outputs...
1109 pvi = self.butler.get("initial_pvi", self.visit_id)
1110 self.assertIn("Unable to measure aperture correction", pvi.metadata["failure.message"])
1111 self.assertIn("MeasureApCorrError", pvi.metadata["failure.type"])
1112 self.assertEqual(pvi.metadata["failure.metadata.ndof"], 101)
1113 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id)
1114 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"])
1115 self.assertIn("MeasureApCorrError", stars.metadata["failure.type"])
1116 self.assertEqual(stars.metadata["failure.metadata.ndof"], 101)
1117 # ... but not the un-produced outputs.
1118 with self.assertRaises(FileNotFoundError):
1119 self.butler.get("initial_stars_footprints_detector", self.visit_id)
1122class MockResponse:
1123 """Provide a mock for requests.put calls"""
1124 def __init__(self, json_data, status_code, text):
1125 self.json_data = json_data
1126 self.status_code = status_code
1127 self.text = text
1129 def json(self):
1130 return self.json_data
1132 def raise_for_status(self):
1133 if self.status_code != 200:
1134 raise requests.exceptions.HTTPError
1137def setup_module(module):
1138 lsst.utils.tests.init()
1141class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
1142 pass
1145if __name__ == "__main__": 1145 ↛ 1146line 1145 didn't jump to line 1146 because the condition on line 1145 was never true
1146 lsst.utils.tests.init()
1147 unittest.main()