Coverage for tests / test_calibrateImage.py: 13%
526 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 08:48 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 08:48 +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.compute_shapelet_decomposition.min_n_stars = 4
127 self.config.psf_detection.background.approxOrderX = 1
128 self.config.star_detection.background.approxOrderX = 1
129 # Only insert 2 sky sources, for simplicity.
130 self.config.star_sky_sources.nSources = 2
131 # Use PCA psf fitter, as psfex fails if there are only 4 stars.
132 self.config.psf_measure_psf.psfDeterminer = 'pca'
133 # We don't have many test points, so can't match on complicated shapes.
134 self.config.astrometry.sourceSelector["science"].flags.good = []
135 self.config.astrometry.matcher.numPointsForShape = 3
136 self.config.run_sattle = False
137 # Maintain original, no adaptive threshold detection, configs values.
138 self.config.do_adaptive_threshold_detection = False
139 self.config.psf_detection.reEstimateBackground = True
140 self.config.star_detection.reEstimateBackground = True
141 # ApFlux has more noise than PsfFlux (the latter unrealistically small
142 # in this test data), so we need to do magnitude rejection at higher
143 # sigma, otherwise we can lose otherwise good sources.
144 # TODO DM-39203: Once we are using Compensated Gaussian Fluxes, we
145 # will use those fluxes here, and hopefully can remove this.
146 self.config.astrometry.magnitudeOutlierRejectionNSigma = 9.0
148 # Make a realistic id generator so that output catalog ids are useful.
149 # NOTE: The id generator is used to seed the noise replacer during
150 # measurement, so changes to values here can have subtle effects on
151 # the centroids and fluxes measured on the image, which might cause
152 # tests to fail.
153 data_id = lsst.daf.butler.DataCoordinate.standardize(
154 instrument="I",
155 visit=self.truth_exposure.visitInfo.id,
156 detector=12,
157 universe=lsst.daf.butler.DimensionUniverse(),
158 )
159 self.config.id_generator.packer.name = "observation"
160 self.config.id_generator.packer["observation"].n_observations = 10000
161 self.config.id_generator.packer["observation"].n_detectors = 99
162 self.config.id_generator.n_releases = 8
163 self.config.id_generator.release_id = 2
164 self.id_generator = self.config.id_generator.apply(data_id)
166 # Something about this test dataset prefers a larger threshold here.
167 self.config.star_selector["science"].unresolved.maximum = 0.2
169 def _check_run(self, calibrate, result, expect_calibrated_pixels: bool = True,
170 expect_n_background: int = 4, expect_n_background_equal_or_greater_than: int = -1,
171 do_shapelet_check: bool = False):
172 """Test the result of CalibrateImage.run().
174 Parameters
175 ----------
176 calibrate : `lsst.pipe.tasks.calibrateImage.CalibrateImageTask`
177 Configured task that had `run` called on it.
178 result : `lsst.pipe.base.Struct`
179 Result of calling calibrate.run().
180 expect_calibrated_pixels : `bool`, optional
181 Whether to expect image and background pixels to be calibrated.
182 """
183 # Background should have 4 elements: 3 from compute_psf and one from
184 # re-estimation during source detection.
185 if expect_n_background is not None:
186 self.assertEqual(len(result.background), expect_n_background)
187 if expect_n_background_equal_or_greater_than is not None:
188 self.assertTrue(len(result.background) >= expect_n_background_equal_or_greater_than)
190 # Both afw and astropy psf_stars catalogs should be populated.
191 self.assertEqual(result.psf_stars["calib_psf_used"].sum(), 3)
192 self.assertEqual(result.psf_stars_footprints["calib_psf_used"].sum(), 3)
194 # Check that the summary statistics are reasonable.
195 summary = result.exposure.info.getSummaryStats()
196 # Values below are only correct for the base run test.
197 if do_shapelet_check:
198 self.assertFloatsAlmostEqual(summary.shapeletsOnlyIqScore, 8.104235164021247e-06, rtol=1e-4)
199 self.assertFloatsAlmostEqual(summary.shapeletsIqScore, 6.651368381893731e-05, rtol=1e-4)
200 shapeletsCoeffs = [
201 0.28358882665634155, -0.00037077977926209773, -0.002802249127080195,
202 3.439385909587145e-05, -0.0012995795522691303, -0.0018000953572589512,
203 4.4449956901327674e-05, -2.5397295072102522e-05, 3.0203139976223994e-05,
204 0.000617273102631627, -4.456396106385861e-05, 8.730505214771256e-05,
205 0.00011167120828758925, 0.00017521437985124066, -1.5413010141236832e-05,
206 -0.00015419833136474156, -8.099463936604213e-05, -0.00017129145577976686,
207 -9.799635877346543e-05, 2.7090113467370116e-05, 0.0004025635973896668,
208 -6.346980774284659e-05, 0.00013586174545049867, -0.00011295261583971951,
209 0.0003724458365468307, 1.5024524355547979e-05, 0.00012497524707246052,
210 0.00041057434072064747
211 ]
212 for iShapelet in range(len(shapeletsCoeffs)):
213 self.assertFloatsAlmostEqual(
214 summary.shapeletsCoeffs[iShapelet], shapeletsCoeffs[iShapelet], rtol=2e-3)
215 self.assertEqual(summary.nShapeletsStar, 4)
216 self.assertFloatsAlmostEqual(
217 summary.centroidDiffShapeletsVsSlotMedian, 0.10838090573, rtol=1e-3)
219 self.assertFloatsAlmostEqual(summary.psfSigma, 2.0, rtol=1e-2)
220 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7)
221 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7)
223 # Should have finite sky coordinates in the afw and astropy catalogs.
224 self.assertTrue(np.isfinite(result.stars_footprints["coord_ra"]).all())
225 self.assertTrue(np.isfinite(result.stars["coord_ra"]).all())
227 if expect_calibrated_pixels:
228 # Fit photoCalib should be the applied value if we calibrated
229 # pixels, not the ==1 one on the exposure.
230 photo_calib = result.applied_photo_calib
231 self.assertEqual(result.exposure.photoCalib.getCalibrationMean(), 1.0)
232 else:
233 self.assertIsNone(result.applied_photo_calib)
234 photo_calib = result.exposure.photoCalib
235 # PhotoCalib comparison is very approximate because we are basing this
236 # comparison on just 2-3 stars.
237 self.assertFloatsAlmostEqual(photo_calib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
238 # Should have calibrated flux/mags in the afw and astropy catalogs
239 self.assertIn("slot_PsfFlux_flux", result.stars_footprints.schema)
240 self.assertIn("slot_PsfFlux_mag", result.stars_footprints.schema)
241 self.assertEqual(result.stars["slot_PsfFlux_flux"].unit, u.nJy)
242 self.assertEqual(result.stars["slot_PsfFlux_mag"].unit, u.ABmag)
244 # Should have detected all S/N >= 10 sources plus 2 sky sources,
245 # whether 1 or 2 snaps.
246 self.assertEqual(len(result.stars), 6)
247 # Did the psf flags get propagated from the psf_stars catalog?
248 self.assertEqual(result.stars["calib_psf_used"].sum(), 3)
250 # Check that all necessary fields are in the output.
251 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result)
253 # Check metadata.
254 key = "LSST CALIB ILLUMCORR APPLIED"
255 self.assertIn(key, result.exposure.metadata)
256 self.assertEqual(result.exposure.metadata[key], False)
258 # Check that the psf_stars cross match worked correctly.
259 matches = esutil.numpy_util.match(result.psf_stars["id"], result.stars["psf_id"])
260 self.assertFloatsAlmostEqual(result.psf_stars["slot_Centroid_x"][matches[0]],
261 result.stars["slot_Centroid_x"][matches[1]], atol=3e-4)
262 if "astrometry_matches" in self.config.optional_outputs:
263 matches = esutil.numpy_util.match(result.astrometry_matches["src_id"],
264 result.photometry_matches["src_psf_id"])
265 self.assertFloatsAlmostEqual(result.astrometry_matches["src_slot_Centroid_x"][matches[0]],
266 result.photometry_matches["src_slot_Centroid_x"][matches[1]],
267 atol=3e-4)
269 def test_run(self):
270 """Test that run() returns reasonable values to be butler put.
271 """
272 calibrate = CalibrateImageTask(config=self.config)
273 calibrate.astrometry.setRefObjLoader(self.ref_loader)
274 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
275 result = calibrate.run(exposures=self.exposure)
277 self._check_run(calibrate, result, do_shapelet_check=True)
279 def test_run_adaptive_threshold_detection(self):
280 """Test that run() runs with adaptive threshold detection turned on.
281 """
282 config = copy.copy(self.config)
283 # Set the adaptive threshold detection, config values...
284 config.do_adaptive_threshold_detection = True
285 config.psf_adaptive_threshold_detection.minFootprint = 4
286 config.psf_adaptive_threshold_detection.minIsolated = 4
287 config.psf_adaptive_threshold_detection.sufficientIsolated = 4
288 config.psf_detection.reEstimateBackground = False
289 config.star_detection.reEstimateBackground = False
291 calibrate = CalibrateImageTask(config=config)
292 calibrate.astrometry.setRefObjLoader(self.ref_loader)
293 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
294 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
295 result = calibrate.run(exposures=self.exposure)
296 subString = "Using adaptive threshold detection "
297 self.assertTrue(any(subString in s for s in cm.output))
299 # Number of backgrounds in list is only guaranteed to have at least 2
300 # entries in the adaptive threshold code path.
301 self._check_run(calibrate, result, expect_n_background=None,
302 expect_n_background_equal_or_greater_than=2)
304 def test_run_downsample(self):
305 """Test that run() runs with downsample.
306 """
307 config = copy.copy(self.config)
308 config.do_downsample_footprints = True
309 config.downsample_max_footprints = 5
311 calibrate = CalibrateImageTask(config=config)
312 calibrate.astrometry.setRefObjLoader(self.ref_loader)
313 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
314 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
315 result = calibrate.run(exposures=self.exposure)
316 self.assertIn(
317 "INFO:lsst.calibrateImage:Downsampling from 8 to 7 non-sky-source footprints.",
318 cm.output,
319 )
321 self._check_run(calibrate, result)
323 def test_run_2_snaps(self):
324 """Test that run() returns reasonable values to be butler put, when
325 passed two exposures to combine as snaps.
326 """
327 calibrate = CalibrateImageTask(config=self.config)
328 calibrate.astrometry.setRefObjLoader(self.ref_loader)
329 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
330 # Halve the flux in each exposure to get the expected visit sum.
331 self.exposure.image /= 2
332 self.exposure.variance /= 2
333 result = calibrate.run(exposures=[self.exposure, self.exposure])
335 self._check_run(calibrate, result)
337 def test_run_no_optionals(self):
338 """Test that disabling optional outputs removes them from the output
339 struct, as appropriate.
340 """
341 self.config.optional_outputs = []
342 calibrate = CalibrateImageTask(config=self.config)
343 calibrate.astrometry.setRefObjLoader(self.ref_loader)
344 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
345 result = calibrate.run(exposures=self.exposure)
347 self._check_run(calibrate, result)
348 # These are the only optional outputs that require extra computation,
349 # the others are included in the output struct regardless.
350 self.assertNotIn("astrometry_matches", result.getDict())
351 self.assertNotIn("photometry_matches", result.getDict())
353 def test_run_no_calibrate_pixels(self):
354 """Test that run() returns reasonable values when
355 do_calibrate_pixels=False.
356 """
357 self.config.do_calibrate_pixels = False
358 calibrate = CalibrateImageTask(config=self.config)
359 calibrate.astrometry.setRefObjLoader(self.ref_loader)
360 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
361 result = calibrate.run(exposures=self.exposure)
363 self._check_run(calibrate, result, expect_calibrated_pixels=False)
365 def test_run_no_astrom_errors(self):
366 """Test that run() returns reasonable values when
367 do_calibrate_pixels=False.
368 """
369 self.config.do_include_astrometric_errors = False
370 calibrate = CalibrateImageTask(config=self.config)
371 calibrate.astrometry.setRefObjLoader(self.ref_loader)
372 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
373 result = calibrate.run(exposures=self.exposure)
375 self._check_run(calibrate, result)
377 def test_compute_psf(self):
378 """Test that our brightest sources are found by _compute_psf(),
379 that a PSF is assigned to the exposure.
380 """
381 calibrate = CalibrateImageTask(config=self.config)
382 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
383 psf_stars = compute_psf_struct.detections_sources
385 # Catalog ids should be very large from this id generator.
386 self.assertTrue(all(psf_stars['id'] > 1000000000))
388 # Background should have 3 elements: initial subtraction, and two from
389 # re-estimation during the two detection passes.
390 self.assertEqual(len(self.attributes.background), 3)
392 # Only the point-sources with S/N > 50 should be in this output.
393 self.assertEqual(psf_stars["calib_psf_used"].sum(), 3)
394 # Sort in brightness order, to easily compare with expected positions.
395 psf_stars.sort(psf_stars.getPsfFluxSlot().getMeasKey())
396 for record, flux, center in zip(psf_stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
397 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
398 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
399 # PsfFlux should match the values inserted.
400 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
402 # TODO: While debugging DM-32701, we're using PCA instead of psfex.
403 # Check that we got a useable PSF.
404 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf)
405 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf)
406 # TestDataset sources have PSF radius=2 pixels.
407 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius()
408 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2)
410 # To look at images for debugging (`setup display_ds9` and run ds9):
411 # import lsst.afw.display
412 # display = lsst.afw.display.getDisplay()
413 # display.mtv(self.exposure)
415 def test_compute_psf_bad_centroids(self):
416 """Test that we raise an appropriate error if all measured centroids
417 are bad in compute_psf.
418 The root cause of this is likely an interaction between the PSF fit
419 and cosmic ray repair. An ugly true PSF can result in the centers of
420 sources being masked and repaired as CRs, thus removing the center of
421 the sources and causing them all to be flagged.
422 """
423 # make the sources have a (not too deep) hole in the middle
424 for point in self.centroids:
425 for i in (-1, 0, 1):
426 for j in (-1, 0, 1):
427 self.exposure.image[point[0]+i, point[1]+j] -= \
428 (self.exposure.image[point[0]+i, point[1]+j] - self.background_level)/1.1
429 # and the extended central source
430 point = self.extended_source
431 self.exposure.image[point[0], point[1]] -= \
432 (self.exposure.image[point[0], point[1]] - self.background_level)
434 # add a diagonal gradient to each source
435 size = 5
436 for point, flux in zip(self.centroids, self.fluxes):
437 box = lsst.geom.Box2I.makeCenteredBox(lsst.geom.Point2D(point[0], point[1]),
438 lsst.geom.Extent2I(size*2, size*2))
439 X, Y = np.ogrid[point[0] - size:point[0] + size, point[1] - size:point[1] + size]
440 distance = np.sqrt((X - point[0])**2 + (Y - point[1])**2)
441 gradient = (X - point[0] + size)*10 + (Y - point[1] + size)*10
442 gradient[distance > size] = 0
443 cutout = self.exposure.image.subset(box)
444 cutout.array += gradient / flux
446 calibrate = CalibrateImageTask(config=self.config)
447 with self.assertRaisesRegex(AllCentroidsFlaggedError, r"source centroids \(out of 4\) flagged"):
448 calibrate.run(exposures=[self.exposure], id_generator=self.id_generator)
450 def test_measure_aperture_correction(self):
451 """Test that _measure_aperture_correction() assigns an ApCorrMap to the
452 exposure.
453 """
454 calibrate = CalibrateImageTask(config=self.config)
455 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
456 psf_stars = compute_psf_struct.detections_sources
458 # First check that the exposure doesn't have an ApCorrMap.
459 self.assertIsNone(self.exposure.apCorrMap)
460 calibrate._measure_aperture_correction(self.exposure, psf_stars)
461 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap)
462 # We know that there are 2 fields from the normalization, plus more
463 # from other configured plugins.
464 self.assertGreater(len(self.exposure.apCorrMap), 2)
466 def test_find_stars(self):
467 """Test that _find_stars() correctly identifies the S/N>10 stars
468 in the image and returns them in the output catalog.
469 """
470 calibrate = CalibrateImageTask(config=self.config)
471 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
472 psf_stars = compute_psf_struct.detections_sources
473 calibrate._measure_aperture_correction(self.exposure, psf_stars)
475 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
477 # Catalog ids should be very large from this id generator.
478 self.assertTrue(all(stars['id'] > 1000000000))
480 # Background should have 4 elements: 3 from compute_psf and one from
481 # re-estimation during source detection.
482 self.assertEqual(len(self.attributes.background), 4)
484 # Only 5 psf-like sources with S/N>10 should be in the output catalog,
485 # plus two sky sources.
486 self.assertEqual(len(stars), 6)
487 self.assertTrue(stars.isContiguous())
488 # Sort in brightness order, to easily compare with expected positions.
489 stars.sort(stars.getPsfFluxSlot().getMeasKey())
490 for record, flux, center in zip(stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
491 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
492 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
493 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
495 def test_astrometry(self):
496 """Test that the fitted WCS gives good catalog coordinates.
497 """
498 calibrate = CalibrateImageTask(config=self.config)
499 calibrate.astrometry.setRefObjLoader(self.ref_loader)
500 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
501 psf_stars = compute_psf_struct.detections_sources
502 calibrate._measure_aperture_correction(self.exposure, psf_stars)
503 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
505 calibrate._fit_astrometry(self.exposure, stars)
507 # Check that we got reliable matches with the truth coordinates.
508 sky = stars["sky_source"]
509 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
510 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
511 idx, d2d, _ = fitted.match_to_catalog_sky(truth)
512 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 35.0)
514 def test_photometry(self):
515 """Test that the fitted photoCalib matches the one we generated,
516 and that the exposure is calibrated.
517 """
518 calibrate = CalibrateImageTask(config=self.config)
519 calibrate.astrometry.setRefObjLoader(self.ref_loader)
520 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
521 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
522 psf_stars = compute_psf_struct.detections_sources
523 calibrate._measure_aperture_correction(self.exposure, psf_stars)
524 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
525 calibrate._fit_astrometry(self.exposure, stars)
527 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars)
528 calibrate._apply_photometry(self.exposure, self.attributes.background)
530 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0,
531 # so we can't check that the photoCal error has been set.
532 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
533 # The exposure should be calibrated by the applied photoCalib,
534 # and the background should be calibrated to match.
535 uncalibrated = self.exposure.image.clone()
536 uncalibrated += self.attributes.background.getImage()
537 uncalibrated /= self.photo_calib
538 self.assertFloatsAlmostEqual(uncalibrated.array, self.truth_exposure.image.array, rtol=1e-2)
539 # PhotoCalib on the exposure must be identically 1.
540 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0)
542 # Check that we got reliable magnitudes and fluxes vs. truth, ignoring
543 # sky sources.
544 sky = stars["sky_source"]
545 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
546 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
547 idx, _, _ = fitted.match_to_catalog_sky(truth)
548 # Because the input variance image does not include contributions from
549 # the sources, we can't use fluxErr as a bound on the measurement
550 # quality here.
551 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_flux'],
552 self.truth_cat['truth_flux'][idx],
553 rtol=0.1)
554 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_mag'],
555 self.truth_cat['truth_mag'][idx],
556 rtol=0.01)
558 def test_match_psf_stars(self):
559 """Test that _match_psf_stars() flags the correct stars as psf stars
560 and candidates.
561 """
562 calibrate = CalibrateImageTask(config=self.config)
563 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
564 psf_stars = compute_psf_struct.detections_sources
565 calibrate._measure_aperture_correction(self.exposure, psf_stars)
566 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
568 # There should be no psf-related flags set at first.
569 self.assertEqual(stars["calib_psf_candidate"].sum(), 0)
570 self.assertEqual(stars["calib_psf_used"].sum(), 0)
571 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
573 # Reorder stars to be out of order with psf_stars (putting the sky
574 # sources in front); this tests that I get the indexing right.
575 stars.sort(stars.getCentroidSlot().getMeasKey().getX())
576 stars = stars.copy(deep=True)
577 # Re-number the ids: the matcher requires sorted ids: this is always
578 # true in the code itself, but we've permuted them by sorting on
579 # flux. We don't care what the actual ids themselves are here.
580 stars["id"] = np.arange(len(stars))
582 calibrate._match_psf_stars(psf_stars, stars)
584 # Check that the three brightest stars have the psf flags transferred
585 # from the psf_stars catalog by sorting in order of brightness.
586 stars.sort(stars.getPsfFluxSlot().getMeasKey())
587 # sort() above leaves the catalog non-contiguous.
588 stars = stars.copy(deep=True)
589 np.testing.assert_array_equal(stars["calib_psf_candidate"],
590 [False, False, False, True, True, True])
591 np.testing.assert_array_equal(stars["calib_psf_used"],
592 [False, False, False, True, True, True])
593 # Too few sources to reserve any in these tests.
594 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
596 def test_match_psf_stars_no_matches(self):
597 """Check that _match_psf_stars handles the case of no cross-matches.
598 """
599 calibrate = CalibrateImageTask(config=self.config)
600 # Make two catalogs that cannot have matches.
601 stars = self.truth_cat[2:].copy(deep=True)
602 psf_stars = self.truth_cat[:2].copy(deep=True)
604 with self.assertRaisesRegex(NoPsfStarsToStarsMatchError,
605 "No psf stars out of 2 matched 5 calib stars") as cm:
606 calibrate._match_psf_stars(psf_stars, stars)
607 self.assertEqual(cm.exception.metadata["n_psf_stars"], 2)
608 self.assertEqual(cm.exception.metadata["n_stars"], 5)
610 def test_calibrate_image_illumcorr(self):
611 """Test running through with an illumination correction."""
612 config = copy.copy(self.config)
613 config.do_illumination_correction = True
614 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
615 config.psf_detection.doApplyFlatBackgroundRatio = True
616 config.star_background.doApplyFlatBackgroundRatio = True
617 config.star_detection.doApplyFlatBackgroundRatio = True
619 calibrate = CalibrateImageTask(config=config)
620 calibrate.astrometry.setRefObjLoader(self.ref_loader)
621 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
623 # Assume that the exposure has been flattened by a flat-flat.
624 background_flat = self.exposure.clone()
625 background_flat.image.array[:, :] = 1.0
626 background_flat.mask.array[:, :] = 0
627 background_flat.variance.array[:, :] = 0.0
629 # And create an illumination correction of 1.1.
630 illum_corr_value = 1.1
631 illumination_correction = self.exposure.clone()
632 illumination_correction.image.array[:, :] = illum_corr_value
633 illumination_correction.mask.array[:, :] = 0
634 illumination_correction.variance.array[:, :] = 0.0
636 result = calibrate.run(
637 exposures=self.exposure,
638 id_generator=self.id_generator,
639 background_flat=background_flat,
640 illumination_correction=illumination_correction,
641 )
643 # We divide the image by the illumination correction, but the reference
644 # sources stay the same, so the applied photocalib will increase by
645 # the illum_corr_value.
646 # Tolerance is the same as the direct test with no illumination
647 # correction.
648 self.assertFloatsAlmostEqual(
649 result.applied_photo_calib.getCalibrationMean(),
650 self.photo_calib * illum_corr_value,
651 rtol=1e-2,
652 )
654 self.assertEqual(len(result.background), 4)
655 self.assertFloatsAlmostEqual(
656 np.median(result.background.getImage().array),
657 result.applied_photo_calib.getCalibrationMean() * self.background_level,
658 rtol=1e-3,
659 )
661 # Check metadata.
662 key = "LSST CALIB ILLUMCORR APPLIED"
663 self.assertIn(key, result.exposure.metadata)
664 self.assertEqual(result.exposure.metadata[key], True)
666 def test_run_with_diffraction_spike_mask(self):
667 """Test that the diffraction spike mask subtask runs.
668 """
669 config = self.config
670 config.doMaskDiffractionSpikes = True
671 config.diffractionSpikeMask.magnitudeThreshold = 19
672 # Define a fake SATURATED mask plane for use by the diffraction spike
673 # task, so that it does not affect the rest of calibrate
674 config.diffractionSpikeMask.saturatedMaskPlane = "FAKESATURATED"
675 calibrate = CalibrateImageTask(config=config)
676 calibrate.astrometry.setRefObjLoader(self.ref_loader)
677 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
678 calibrate.diffractionSpikeMask.setRefObjLoader(self.ref_loader)
680 exposure = self.exposure.clone()
682 exposure.info.setVisitInfo(makeTestVisitInfo())
683 exposure.mask.addMaskPlane(config.diffractionSpikeMask.saturatedMaskPlane)
685 # Set the saturated mask plane in half of the image
686 saturatedMaskBit = exposure.mask.getPlaneBitMask(config.diffractionSpikeMask.saturatedMaskPlane)
687 bbox = exposure.getBBox()
688 bbox.grow(-lsst.geom.Extent2I(0, bbox.height//4))
689 exposure[bbox].mask.array |= saturatedMaskBit
691 result = calibrate.run(exposures=exposure)
693 self._check_run(calibrate, result)
695 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": ""})
696 def test_fail_on_sattle_misconfiguration(self):
697 """Test for failure if sattle is requested without appropriate
698 configurations.
699 """
700 self.config.run_sattle = True
701 with self.assertRaises(pexConfig.FieldValidationError):
702 CalibrateImageTask(config=self.config)
704 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
705 def test_continue_on_sattle_failure(self):
706 """Processing should continue when sattle returns status codes other
707 than 200.
708 """
709 response = MockResponse({}, 500, "internal sattle error")
711 self.config.run_sattle = True
712 calibrate = CalibrateImageTask(config=self.config)
713 calibrate.astrometry.setRefObjLoader(self.ref_loader)
714 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
715 with mock.patch('requests.put', return_value=response) as mock_put:
716 calibrate.run(exposures=self.exposure)
717 mock_put.assert_called_once()
719 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
720 def test_sattle(self):
721 """Test for successful completion when sattle call returns
722 successfully.
723 """
724 response = MockResponse({}, 200, "success")
726 self.config.run_sattle = True
727 calibrate = CalibrateImageTask(config=self.config)
728 calibrate.astrometry.setRefObjLoader(self.ref_loader)
729 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
730 with mock.patch('requests.put', return_value=response) as mock_put:
731 calibrate.run(exposures=self.exposure)
732 mock_put.assert_called_once()
735class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase):
736 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler,
737 but do not need real images.
738 """
739 def setUp(self):
740 instrument = "testCam"
741 exposure0 = 101
742 exposure1 = 102
743 visit = 100101
744 detector = 42
745 physical_filter = "r"
747 # Create a and populate a test butler for runQuantum tests.
748 self.repo_path = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
749 self.repo = butlerTests.makeTestRepo(self.repo_path.name)
750 self.enterContext(self.repo)
752 # A complete instrument record is necessary for the id generator.
753 instrumentRecord = self.repo.dimensions["instrument"].RecordClass(
754 name=instrument, visit_max=1e6, exposure_max=1e6, detector_max=128,
755 class_name="lsst.obs.base.instrument_tests.DummyCam",
756 )
757 self.repo.registry.syncDimensionData("instrument", instrumentRecord)
759 # dataIds for fake data
760 butlerTests.addDataIdValue(self.repo, "detector", detector)
761 butlerTests.addDataIdValue(self.repo, "exposure", exposure0)
762 butlerTests.addDataIdValue(self.repo, "exposure", exposure1)
763 butlerTests.addDataIdValue(self.repo, "visit", visit)
764 butlerTests.addDataIdValue(self.repo, "physical_filter", physical_filter)
766 # inputs
767 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"},
768 "ExposureF")
769 butlerTests.addDatasetType(self.repo, "gaia_dr3_20230707", {"htm7"}, "SimpleCatalog")
770 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog")
771 butlerTests.addDatasetType(self.repo, "flat", {"instrument", "detector", "physical_filter"},
772 "Exposure")
773 butlerTests.addDatasetType(self.repo,
774 "illuminationCorrection",
775 {"instrument", "detector", "physical_filter"},
776 "Exposure")
778 # outputs
779 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"},
780 "ExposureF")
781 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector",
782 {"instrument", "visit", "detector"},
783 "SourceCatalog")
784 butlerTests.addDatasetType(self.repo, "initial_stars_detector",
785 {"instrument", "visit", "detector"},
786 "ArrowAstropy")
787 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector",
788 {"instrument", "visit", "detector"},
789 "PhotoCalib")
790 butlerTests.addDatasetType(self.repo, "background_to_photometric_ratio",
791 {"instrument", "visit", "detector"},
792 "Image")
793 # optional outputs
794 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"},
795 "Background")
796 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints_detector",
797 {"instrument", "visit", "detector"},
798 "SourceCatalog")
799 butlerTests.addDatasetType(self.repo, "initial_psf_stars_detector",
800 {"instrument", "visit", "detector"},
801 "ArrowAstropy")
802 butlerTests.addDatasetType(self.repo,
803 "initial_astrometry_match_detector",
804 {"instrument", "visit", "detector"},
805 "Catalog")
806 butlerTests.addDatasetType(self.repo,
807 "initial_photometry_match_detector",
808 {"instrument", "visit", "detector"},
809 "Catalog")
810 butlerTests.addDatasetType(self.repo,
811 "preliminary_visit_mask",
812 {"instrument", "visit", "detector"},
813 "Mask")
815 # dataIds
816 self.exposure0_id = self.repo.registry.expandDataId(
817 {"instrument": instrument, "exposure": exposure0, "detector": detector})
818 self.exposure1_id = self.repo.registry.expandDataId(
819 {"instrument": instrument, "exposure": exposure1, "detector": detector})
820 self.visit_id = self.repo.registry.expandDataId(
821 {"instrument": instrument, "visit": visit, "detector": detector})
822 self.htm_id = self.repo.registry.expandDataId({"htm7": 42})
823 self.flat_id = self.repo.registry.expandDataId(
824 {"instrument": instrument, "detector": detector, "physical_filter": physical_filter})
826 # put empty data
827 self.butler = butlerTests.makeTestCollection(self.repo)
828 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure0_id)
829 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure1_id)
830 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr3_20230707", self.htm_id)
831 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id)
832 self.butler.put(afwImage.ExposureF(), "flat", self.flat_id)
833 self.butler.put(afwImage.ExposureF(), "illuminationCorrection", self.flat_id)
835 def tearDown(self):
836 self.repo_path.cleanup()
838 def test_runQuantum(self):
839 task = CalibrateImageTask()
840 lsst.pipe.base.testUtils.assertValidInitOutput(task)
842 quantum = lsst.pipe.base.testUtils.makeQuantum(
843 task, self.butler, self.visit_id,
844 {"exposures": [self.exposure0_id],
845 "astrometry_ref_cat": [self.htm_id],
846 "photometry_ref_cat": [self.htm_id],
847 "background_flat": self.flat_id,
848 "illumination_correction": self.flat_id,
849 # outputs
850 "exposure": self.visit_id,
851 "stars": self.visit_id,
852 "stars_footprints": self.visit_id,
853 "background": self.visit_id,
854 "psf_stars": self.visit_id,
855 "psf_stars_footprints": self.visit_id,
856 "applied_photo_calib": self.visit_id,
857 "initial_pvi_background": self.visit_id,
858 "astrometry_matches": self.visit_id,
859 "photometry_matches": self.visit_id,
860 "mask": self.visit_id,
861 })
862 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
864 # Ensure the reference loaders have been configured.
865 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
866 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
867 # Check that the proper kwargs are passed to run().
868 self.assertEqual(
869 mock_run.call_args.kwargs.keys(),
870 {"exposures",
871 "result",
872 "id_generator",
873 "background_flat",
874 "illumination_correction",
875 "camera_model",
876 "exposure_record",
877 "exposure_region",
878 },
879 )
881 def test_runQuantum_illumination_correction(self):
882 config = CalibrateImageTask.ConfigClass()
883 config.do_illumination_correction = True
884 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
885 config.psf_detection.doApplyFlatBackgroundRatio = True
886 config.star_background.doApplyFlatBackgroundRatio = True
887 config.star_detection.doApplyFlatBackgroundRatio = True
888 task = CalibrateImageTask(config=config)
889 lsst.pipe.base.testUtils.assertValidInitOutput(task)
891 quantum = lsst.pipe.base.testUtils.makeQuantum(
892 task, self.butler, self.visit_id,
893 {"exposures": [self.exposure0_id],
894 "astrometry_ref_cat": [self.htm_id],
895 "photometry_ref_cat": [self.htm_id],
896 "background_flat": self.flat_id,
897 "illumination_correction": self.flat_id,
898 # outputs
899 "exposure": self.visit_id,
900 "stars": self.visit_id,
901 "stars_footprints": self.visit_id,
902 "background": self.visit_id,
903 "background_to_photometric_ratio": self.visit_id,
904 "psf_stars": self.visit_id,
905 "psf_stars_footprints": self.visit_id,
906 "applied_photo_calib": self.visit_id,
907 "initial_pvi_background": self.visit_id,
908 "astrometry_matches": self.visit_id,
909 "photometry_matches": self.visit_id,
910 "mask": self.visit_id,
911 })
912 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
914 # Ensure the reference loaders have been configured.
915 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
916 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
917 # Check that the proper kwargs are passed to run().
918 self.assertEqual(
919 mock_run.call_args.kwargs.keys(),
920 {"exposures",
921 "result",
922 "id_generator",
923 "background_flat",
924 "illumination_correction",
925 "camera_model",
926 "exposure_record",
927 "exposure_region",
928 },
929 )
931 def test_runQuantum_2_snaps(self):
932 task = CalibrateImageTask()
933 lsst.pipe.base.testUtils.assertValidInitOutput(task)
935 quantum = lsst.pipe.base.testUtils.makeQuantum(
936 task, self.butler, self.visit_id,
937 {"exposures": [self.exposure0_id, self.exposure1_id],
938 "astrometry_ref_cat": [self.htm_id],
939 "photometry_ref_cat": [self.htm_id],
940 "background_flat": self.flat_id,
941 "illumination_correction": self.flat_id,
942 # outputs
943 "exposure": self.visit_id,
944 "stars": self.visit_id,
945 "stars_footprints": self.visit_id,
946 "background": self.visit_id,
947 "psf_stars": self.visit_id,
948 "psf_stars_footprints": self.visit_id,
949 "applied_photo_calib": self.visit_id,
950 "initial_pvi_background": self.visit_id,
951 "astrometry_matches": self.visit_id,
952 "photometry_matches": self.visit_id,
953 "mask": self.visit_id,
954 })
955 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
957 # Ensure the reference loaders have been configured.
958 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
959 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
960 # Check that the proper kwargs are passed to run().
961 self.assertEqual(
962 mock_run.call_args.kwargs.keys(),
963 {"exposures",
964 "result",
965 "id_generator",
966 "background_flat",
967 "illumination_correction",
968 "camera_model",
969 "exposure_record",
970 "exposure_region",
971 },
972 )
974 def test_runQuantum_no_optional_outputs(self):
975 # All the possible connections: we modify this to test each one by
976 # popping off the removed connection, then re-setting it.
977 connections = {"exposures": [self.exposure0_id, self.exposure1_id],
978 "astrometry_ref_cat": [self.htm_id],
979 "photometry_ref_cat": [self.htm_id],
980 "background_flat": self.flat_id,
981 "illumination_correction": self.flat_id,
982 # outputs
983 "exposure": self.visit_id,
984 "stars": self.visit_id,
985 "stars_footprints": self.visit_id,
986 "background": self.visit_id,
987 "psf_stars": self.visit_id,
988 "psf_stars_footprints": self.visit_id,
989 "applied_photo_calib": self.visit_id,
990 "initial_pvi_background": self.visit_id,
991 "astrometry_matches": self.visit_id,
992 "photometry_matches": self.visit_id,
993 "mask": self.visit_id,
994 }
996 # Check that we can turn off one output at a time.
997 for optional in ["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches",
998 "mask"]:
999 config = CalibrateImageTask.ConfigClass()
1000 config.optional_outputs.remove(optional)
1001 task = CalibrateImageTask(config=config)
1002 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1003 # Save the removed one for the next test.
1004 temp = connections.pop(optional)
1005 # This will fail with "Error in connection ..." if we don't pop
1006 # the optional item from the connections list just above.
1007 quantum = lsst.pipe.base.testUtils.makeQuantum(task, self.butler, self.visit_id, connections)
1008 # This confirms that the outputs did skip the removed one.
1009 self.assertNotIn(optional, quantum.outputs)
1010 # Restore the one we removed for the next test.
1011 connections[optional] = temp
1013 def test_runQuantum_no_calibrate_pixels(self):
1014 """Test that the the task runs when calibrating pixels is disabled,
1015 and that this results in the ``applied_photo_calib`` output being
1016 removed.
1017 """
1018 config = CalibrateImageTask.ConfigClass()
1019 config.do_calibrate_pixels = False
1020 task = CalibrateImageTask(config=config)
1021 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1023 quantum = lsst.pipe.base.testUtils.makeQuantum(
1024 task, self.butler, self.visit_id,
1025 {"exposures": [self.exposure0_id],
1026 "astrometry_ref_cat": [self.htm_id],
1027 "photometry_ref_cat": [self.htm_id],
1028 "background_flat": self.flat_id,
1029 "illumination_correction": self.flat_id,
1030 # outputs
1031 "exposure": self.visit_id,
1032 "stars": self.visit_id,
1033 "stars_footprints": self.visit_id,
1034 "background": self.visit_id,
1035 "psf_stars": self.visit_id,
1036 "psf_stars_footprints": self.visit_id,
1037 "initial_pvi_background": self.visit_id,
1038 "astrometry_matches": self.visit_id,
1039 "photometry_matches": self.visit_id,
1040 "mask": self.visit_id,
1041 })
1042 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1044 # Ensure the reference loaders have been configured.
1045 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
1046 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
1047 # Check that the proper kwargs are passed to run().
1048 self.assertEqual(
1049 mock_run.call_args.kwargs.keys(),
1050 {"exposures",
1051 "result",
1052 "id_generator",
1053 "background_flat",
1054 "illumination_correction",
1055 "camera_model",
1056 "exposure_record",
1057 "exposure_region",
1058 },
1059 )
1061 def test_lintConnections(self):
1062 """Check that the connections are self-consistent.
1063 """
1064 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass
1065 lsst.pipe.base.testUtils.lintConnections(Connections)
1067 def test_runQuantum_exception(self):
1068 """Test exception handling in runQuantum.
1069 """
1070 task = CalibrateImageTask()
1071 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1073 quantum = lsst.pipe.base.testUtils.makeQuantum(
1074 task, self.butler, self.visit_id,
1075 {"exposures": [self.exposure0_id],
1076 "astrometry_ref_cat": [self.htm_id],
1077 "photometry_ref_cat": [self.htm_id],
1078 "background_flat": self.flat_id,
1079 "illuminationCorrection": self.flat_id,
1080 # outputs
1081 "exposure": self.visit_id,
1082 "stars": self.visit_id,
1083 "stars_footprints": self.visit_id,
1084 "background": self.visit_id,
1085 "psf_stars": self.visit_id,
1086 "psf_stars_footprints": self.visit_id,
1087 "applied_photo_calib": self.visit_id,
1088 "initial_pvi_background": self.visit_id,
1089 "astrometry_matches": self.visit_id,
1090 "photometry_matches": self.visit_id,
1091 "mask": self.visit_id,
1092 })
1094 # A generic exception should raise directly.
1095 msg = "mocked run exception"
1096 with (
1097 mock.patch.object(task, "run", side_effect=ValueError(msg)),
1098 self.assertRaisesRegex(ValueError, "mocked run exception")
1099 ):
1100 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False)
1102 # An AlgorithmError should write annotated partial outputs.
1103 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101)
1105 def mock_run(
1106 exposures,
1107 result=None,
1108 id_generator=None,
1109 background_flat=None,
1110 illumination_correction=None,
1111 camera_model=None,
1112 exposure_record=None,
1113 exposure_region=None,
1114 ):
1115 """Mock success through compute_psf, but failure after.
1116 """
1117 result.exposure = afwImage.ExposureF(10, 10)
1118 result.psf_stars_footprints = afwTable.SourceCatalog()
1119 result.psf_stars = afwTable.SourceCatalog().asAstropy()
1120 result.background = afwMath.BackgroundList()
1121 raise error
1123 with (
1124 mock.patch.object(task, "run", side_effect=mock_run),
1125 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError),
1126 ):
1127 with self.assertLogs("lsst.calibrateImage", level="DEBUG") as cm:
1128 lsst.pipe.base.testUtils.runTestQuantum(task,
1129 self.butler,
1130 quantum,
1131 mockRun=False)
1133 logged = "\n".join(cm.output)
1134 self.assertIn("Task failed with only partial outputs", logged)
1135 self.assertIn("MeasureApCorrError", logged)
1137 # NOTE: This is an integration test of afw Exposure & SourceCatalog
1138 # metadata with the error annotation system in pipe_base.
1139 # Check that we did get the annotated partial outputs...
1140 pvi = self.butler.get("initial_pvi", self.visit_id)
1141 self.assertIn("Unable to measure aperture correction", pvi.metadata["failure.message"])
1142 self.assertIn("MeasureApCorrError", pvi.metadata["failure.type"])
1143 self.assertEqual(pvi.metadata["failure.metadata.ndof"], 101)
1144 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id)
1145 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"])
1146 self.assertIn("MeasureApCorrError", stars.metadata["failure.type"])
1147 self.assertEqual(stars.metadata["failure.metadata.ndof"], 101)
1148 # ... but not the un-produced outputs.
1149 with self.assertRaises(FileNotFoundError):
1150 self.butler.get("initial_stars_footprints_detector", self.visit_id)
1153class MockResponse:
1154 """Provide a mock for requests.put calls"""
1155 def __init__(self, json_data, status_code, text):
1156 self.json_data = json_data
1157 self.status_code = status_code
1158 self.text = text
1160 def json(self):
1161 return self.json_data
1163 def raise_for_status(self):
1164 if self.status_code != 200:
1165 raise requests.exceptions.HTTPError
1168def setup_module(module):
1169 lsst.utils.tests.init()
1172class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
1173 pass
1176if __name__ == "__main__": 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true
1177 lsst.utils.tests.init()
1178 unittest.main()