Coverage for tests/test_calibrateImage.py: 13%
529 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 08:13 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 08:13 +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.refCatSourceDensity, 39470.0430204, rtol=1e-3)
220 self.assertFloatsAlmostEqual(summary.psfSigma, 2.0, rtol=1e-2)
221 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7)
222 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7)
224 # Should have finite sky coordinates in the afw and astropy catalogs.
225 self.assertTrue(np.isfinite(result.stars_footprints["coord_ra"]).all())
226 self.assertTrue(np.isfinite(result.stars["coord_ra"]).all())
228 if expect_calibrated_pixels:
229 # Fit photoCalib should be the applied value if we calibrated
230 # pixels, not the ==1 one on the exposure.
231 photo_calib = result.applied_photo_calib
232 self.assertEqual(result.exposure.photoCalib.getCalibrationMean(), 1.0)
233 else:
234 self.assertIsNone(result.applied_photo_calib)
235 photo_calib = result.exposure.photoCalib
236 # PhotoCalib comparison is very approximate because we are basing this
237 # comparison on just 2-3 stars.
238 self.assertFloatsAlmostEqual(photo_calib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
239 # Should have calibrated flux/mags in the afw and astropy catalogs
240 self.assertIn("slot_PsfFlux_flux", result.stars_footprints.schema)
241 self.assertIn("slot_PsfFlux_mag", result.stars_footprints.schema)
242 self.assertEqual(result.stars["slot_PsfFlux_flux"].unit, u.nJy)
243 self.assertEqual(result.stars["slot_PsfFlux_mag"].unit, u.ABmag)
245 # Should have detected all S/N >= 10 sources plus 2 sky sources,
246 # whether 1 or 2 snaps.
247 self.assertEqual(len(result.stars), 6)
248 # Did the psf flags get propagated from the psf_stars catalog?
249 self.assertEqual(result.stars["calib_psf_used"].sum(), 3)
251 # Check that all necessary fields are in the output.
252 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result)
254 # Check metadata.
255 key = "LSST CALIB ILLUMCORR APPLIED"
256 self.assertIn(key, result.exposure.metadata)
257 self.assertEqual(result.exposure.metadata[key], False)
258 key = "REF_CAT_SOURCE_DENSITY"
259 self.assertIn(key, result.exposure.metadata)
261 # Check that the psf_stars cross match worked correctly.
262 matches = esutil.numpy_util.match(result.psf_stars["id"], result.stars["psf_id"])
263 self.assertFloatsAlmostEqual(result.psf_stars["slot_Centroid_x"][matches[0]],
264 result.stars["slot_Centroid_x"][matches[1]], atol=3e-4)
265 if "astrometry_matches" in self.config.optional_outputs:
266 matches = esutil.numpy_util.match(result.astrometry_matches["src_id"],
267 result.photometry_matches["src_psf_id"])
268 self.assertFloatsAlmostEqual(result.astrometry_matches["src_slot_Centroid_x"][matches[0]],
269 result.photometry_matches["src_slot_Centroid_x"][matches[1]],
270 atol=3e-4)
272 def test_run(self):
273 """Test that run() returns reasonable values to be butler put.
274 """
275 calibrate = CalibrateImageTask(config=self.config)
276 calibrate.astrometry.setRefObjLoader(self.ref_loader)
277 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
278 result = calibrate.run(exposures=self.exposure)
280 self._check_run(calibrate, result, do_shapelet_check=True)
282 def test_run_adaptive_threshold_detection(self):
283 """Test that run() runs with adaptive threshold detection turned on.
284 """
285 config = copy.copy(self.config)
286 # Set the adaptive threshold detection, config values...
287 config.do_adaptive_threshold_detection = True
288 config.psf_adaptive_threshold_detection.minFootprint = 4
289 config.psf_adaptive_threshold_detection.minIsolated = 4
290 config.psf_adaptive_threshold_detection.sufficientIsolated = 4
291 config.psf_detection.reEstimateBackground = False
292 config.star_detection.reEstimateBackground = False
294 calibrate = CalibrateImageTask(config=config)
295 calibrate.astrometry.setRefObjLoader(self.ref_loader)
296 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
297 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
298 result = calibrate.run(exposures=self.exposure)
299 subString = "Using adaptive threshold detection "
300 self.assertTrue(any(subString in s for s in cm.output))
302 # Number of backgrounds in list is only guaranteed to have at least 2
303 # entries in the adaptive threshold code path.
304 self._check_run(calibrate, result, expect_n_background=None,
305 expect_n_background_equal_or_greater_than=2)
307 def test_run_downsample(self):
308 """Test that run() runs with downsample.
309 """
310 config = copy.copy(self.config)
311 config.do_downsample_footprints = True
312 config.downsample_max_footprints = 5
314 calibrate = CalibrateImageTask(config=config)
315 calibrate.astrometry.setRefObjLoader(self.ref_loader)
316 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
317 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
318 result = calibrate.run(exposures=self.exposure)
319 self.assertIn(
320 "INFO:lsst.calibrateImage:Downsampling from 8 to 7 non-sky-source footprints.",
321 cm.output,
322 )
324 self._check_run(calibrate, result)
326 def test_run_2_snaps(self):
327 """Test that run() returns reasonable values to be butler put, when
328 passed two exposures to combine as snaps.
329 """
330 calibrate = CalibrateImageTask(config=self.config)
331 calibrate.astrometry.setRefObjLoader(self.ref_loader)
332 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
333 # Halve the flux in each exposure to get the expected visit sum.
334 self.exposure.image /= 2
335 self.exposure.variance /= 2
336 result = calibrate.run(exposures=[self.exposure, self.exposure])
338 self._check_run(calibrate, result)
340 def test_run_no_optionals(self):
341 """Test that disabling optional outputs removes them from the output
342 struct, as appropriate.
343 """
344 self.config.optional_outputs = []
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)
351 # These are the only optional outputs that require extra computation,
352 # the others are included in the output struct regardless.
353 self.assertNotIn("astrometry_matches", result.getDict())
354 self.assertNotIn("photometry_matches", result.getDict())
356 def test_run_no_calibrate_pixels(self):
357 """Test that run() returns reasonable values when
358 do_calibrate_pixels=False.
359 """
360 self.config.do_calibrate_pixels = False
361 calibrate = CalibrateImageTask(config=self.config)
362 calibrate.astrometry.setRefObjLoader(self.ref_loader)
363 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
364 result = calibrate.run(exposures=self.exposure)
366 self._check_run(calibrate, result, expect_calibrated_pixels=False)
368 def test_run_no_astrom_errors(self):
369 """Test that run() returns reasonable values when
370 do_calibrate_pixels=False.
371 """
372 self.config.do_include_astrometric_errors = False
373 calibrate = CalibrateImageTask(config=self.config)
374 calibrate.astrometry.setRefObjLoader(self.ref_loader)
375 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
376 result = calibrate.run(exposures=self.exposure)
378 self._check_run(calibrate, result)
380 def test_compute_psf(self):
381 """Test that our brightest sources are found by _compute_psf(),
382 that a PSF is assigned to the exposure.
383 """
384 calibrate = CalibrateImageTask(config=self.config)
385 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
386 psf_stars = compute_psf_struct.detections_sources
388 # Catalog ids should be very large from this id generator.
389 self.assertTrue(all(psf_stars['id'] > 1000000000))
391 # Background should have 3 elements: initial subtraction, and two from
392 # re-estimation during the two detection passes.
393 self.assertEqual(len(self.attributes.background), 3)
395 # Only the point-sources with S/N > 50 should be in this output.
396 self.assertEqual(psf_stars["calib_psf_used"].sum(), 3)
397 # Sort in brightness order, to easily compare with expected positions.
398 psf_stars.sort(psf_stars.getPsfFluxSlot().getMeasKey())
399 for record, flux, center in zip(psf_stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
400 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
401 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
402 # PsfFlux should match the values inserted.
403 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
405 # TODO: While debugging DM-32701, we're using PCA instead of psfex.
406 # Check that we got a useable PSF.
407 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf)
408 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf)
409 # TestDataset sources have PSF radius=2 pixels.
410 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius()
411 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2)
413 # To look at images for debugging (`setup display_ds9` and run ds9):
414 # import lsst.afw.display
415 # display = lsst.afw.display.getDisplay()
416 # display.mtv(self.exposure)
418 def test_compute_psf_bad_centroids(self):
419 """Test that we raise an appropriate error if all measured centroids
420 are bad in compute_psf.
421 The root cause of this is likely an interaction between the PSF fit
422 and cosmic ray repair. An ugly true PSF can result in the centers of
423 sources being masked and repaired as CRs, thus removing the center of
424 the sources and causing them all to be flagged.
425 """
426 # make the sources have a (not too deep) hole in the middle
427 for point in self.centroids:
428 for i in (-1, 0, 1):
429 for j in (-1, 0, 1):
430 self.exposure.image[point[0]+i, point[1]+j] -= \
431 (self.exposure.image[point[0]+i, point[1]+j] - self.background_level)/1.1
432 # and the extended central source
433 point = self.extended_source
434 self.exposure.image[point[0], point[1]] -= \
435 (self.exposure.image[point[0], point[1]] - self.background_level)
437 # add a diagonal gradient to each source
438 size = 5
439 for point, flux in zip(self.centroids, self.fluxes):
440 box = lsst.geom.Box2I.makeCenteredBox(lsst.geom.Point2D(point[0], point[1]),
441 lsst.geom.Extent2I(size*2, size*2))
442 X, Y = np.ogrid[point[0] - size:point[0] + size, point[1] - size:point[1] + size]
443 distance = np.sqrt((X - point[0])**2 + (Y - point[1])**2)
444 gradient = (X - point[0] + size)*10 + (Y - point[1] + size)*10
445 gradient[distance > size] = 0
446 cutout = self.exposure.image.subset(box)
447 cutout.array += gradient / flux
449 calibrate = CalibrateImageTask(config=self.config)
450 with self.assertRaisesRegex(AllCentroidsFlaggedError, r"source centroids \(out of 4\) flagged"):
451 calibrate.run(exposures=[self.exposure], id_generator=self.id_generator)
453 def test_measure_aperture_correction(self):
454 """Test that _measure_aperture_correction() assigns an ApCorrMap to the
455 exposure.
456 """
457 calibrate = CalibrateImageTask(config=self.config)
458 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
459 psf_stars = compute_psf_struct.detections_sources
461 # First check that the exposure doesn't have an ApCorrMap.
462 self.assertIsNone(self.exposure.apCorrMap)
463 calibrate._measure_aperture_correction(self.exposure, psf_stars)
464 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap)
465 # We know that there are 2 fields from the normalization, plus more
466 # from other configured plugins.
467 self.assertGreater(len(self.exposure.apCorrMap), 2)
469 def test_find_stars(self):
470 """Test that _find_stars() correctly identifies the S/N>10 stars
471 in the image and returns them in the output catalog.
472 """
473 calibrate = CalibrateImageTask(config=self.config)
474 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
475 psf_stars = compute_psf_struct.detections_sources
476 calibrate._measure_aperture_correction(self.exposure, psf_stars)
478 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
480 # Catalog ids should be very large from this id generator.
481 self.assertTrue(all(stars['id'] > 1000000000))
483 # Background should have 4 elements: 3 from compute_psf and one from
484 # re-estimation during source detection.
485 self.assertEqual(len(self.attributes.background), 4)
487 # Only 5 psf-like sources with S/N>10 should be in the output catalog,
488 # plus two sky sources.
489 self.assertEqual(len(stars), 6)
490 self.assertTrue(stars.isContiguous())
491 # Sort in brightness order, to easily compare with expected positions.
492 stars.sort(stars.getPsfFluxSlot().getMeasKey())
493 for record, flux, center in zip(stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
494 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
495 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
496 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
498 def test_astrometry(self):
499 """Test that the fitted WCS gives good catalog coordinates.
500 """
501 calibrate = CalibrateImageTask(config=self.config)
502 calibrate.astrometry.setRefObjLoader(self.ref_loader)
503 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
504 psf_stars = compute_psf_struct.detections_sources
505 calibrate._measure_aperture_correction(self.exposure, psf_stars)
506 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
508 calibrate._fit_astrometry(self.exposure, stars)
510 # Check that we got reliable matches with the truth coordinates.
511 sky = stars["sky_source"]
512 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
513 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
514 idx, d2d, _ = fitted.match_to_catalog_sky(truth)
515 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 35.0)
517 def test_photometry(self):
518 """Test that the fitted photoCalib matches the one we generated,
519 and that the exposure is calibrated.
520 """
521 calibrate = CalibrateImageTask(config=self.config)
522 calibrate.astrometry.setRefObjLoader(self.ref_loader)
523 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
524 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
525 psf_stars = compute_psf_struct.detections_sources
526 calibrate._measure_aperture_correction(self.exposure, psf_stars)
527 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
528 calibrate._fit_astrometry(self.exposure, stars)
530 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars)
531 calibrate._apply_photometry(self.exposure, self.attributes.background)
533 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0,
534 # so we can't check that the photoCal error has been set.
535 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
536 # The exposure should be calibrated by the applied photoCalib,
537 # and the background should be calibrated to match.
538 uncalibrated = self.exposure.image.clone()
539 uncalibrated += self.attributes.background.getImage()
540 uncalibrated /= self.photo_calib
541 self.assertFloatsAlmostEqual(uncalibrated.array, self.truth_exposure.image.array, rtol=1e-2)
542 # PhotoCalib on the exposure must be identically 1.
543 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0)
545 # Check that we got reliable magnitudes and fluxes vs. truth, ignoring
546 # sky sources.
547 sky = stars["sky_source"]
548 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
549 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
550 idx, _, _ = fitted.match_to_catalog_sky(truth)
551 # Because the input variance image does not include contributions from
552 # the sources, we can't use fluxErr as a bound on the measurement
553 # quality here.
554 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_flux'],
555 self.truth_cat['truth_flux'][idx],
556 rtol=0.1)
557 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_mag'],
558 self.truth_cat['truth_mag'][idx],
559 rtol=0.01)
561 def test_match_psf_stars(self):
562 """Test that _match_psf_stars() flags the correct stars as psf stars
563 and candidates.
564 """
565 calibrate = CalibrateImageTask(config=self.config)
566 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
567 psf_stars = compute_psf_struct.detections_sources
568 calibrate._measure_aperture_correction(self.exposure, psf_stars)
569 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
571 # There should be no psf-related flags set at first.
572 self.assertEqual(stars["calib_psf_candidate"].sum(), 0)
573 self.assertEqual(stars["calib_psf_used"].sum(), 0)
574 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
576 # Reorder stars to be out of order with psf_stars (putting the sky
577 # sources in front); this tests that I get the indexing right.
578 stars.sort(stars.getCentroidSlot().getMeasKey().getX())
579 stars = stars.copy(deep=True)
580 # Re-number the ids: the matcher requires sorted ids: this is always
581 # true in the code itself, but we've permuted them by sorting on
582 # flux. We don't care what the actual ids themselves are here.
583 stars["id"] = np.arange(len(stars))
585 calibrate._match_psf_stars(psf_stars, stars)
587 # Check that the three brightest stars have the psf flags transferred
588 # from the psf_stars catalog by sorting in order of brightness.
589 stars.sort(stars.getPsfFluxSlot().getMeasKey())
590 # sort() above leaves the catalog non-contiguous.
591 stars = stars.copy(deep=True)
592 np.testing.assert_array_equal(stars["calib_psf_candidate"],
593 [False, False, False, True, True, True])
594 np.testing.assert_array_equal(stars["calib_psf_used"],
595 [False, False, False, True, True, True])
596 # Too few sources to reserve any in these tests.
597 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
599 def test_match_psf_stars_no_matches(self):
600 """Check that _match_psf_stars handles the case of no cross-matches.
601 """
602 calibrate = CalibrateImageTask(config=self.config)
603 # Make two catalogs that cannot have matches.
604 stars = self.truth_cat[2:].copy(deep=True)
605 psf_stars = self.truth_cat[:2].copy(deep=True)
607 with self.assertRaisesRegex(NoPsfStarsToStarsMatchError,
608 "No psf stars out of 2 matched 5 calib stars") as cm:
609 calibrate._match_psf_stars(psf_stars, stars)
610 self.assertEqual(cm.exception.metadata["n_psf_stars"], 2)
611 self.assertEqual(cm.exception.metadata["n_stars"], 5)
613 def test_calibrate_image_illumcorr(self):
614 """Test running through with an illumination correction."""
615 config = copy.copy(self.config)
616 config.do_illumination_correction = True
617 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
618 config.psf_detection.doApplyFlatBackgroundRatio = True
619 config.star_background.doApplyFlatBackgroundRatio = True
620 config.star_detection.doApplyFlatBackgroundRatio = True
622 calibrate = CalibrateImageTask(config=config)
623 calibrate.astrometry.setRefObjLoader(self.ref_loader)
624 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
626 # Assume that the exposure has been flattened by a flat-flat.
627 background_flat = self.exposure.clone()
628 background_flat.image.array[:, :] = 1.0
629 background_flat.mask.array[:, :] = 0
630 background_flat.variance.array[:, :] = 0.0
632 # And create an illumination correction of 1.1.
633 illum_corr_value = 1.1
634 illumination_correction = self.exposure.clone()
635 illumination_correction.image.array[:, :] = illum_corr_value
636 illumination_correction.mask.array[:, :] = 0
637 illumination_correction.variance.array[:, :] = 0.0
639 result = calibrate.run(
640 exposures=self.exposure,
641 id_generator=self.id_generator,
642 background_flat=background_flat,
643 illumination_correction=illumination_correction,
644 )
646 # We divide the image by the illumination correction, but the reference
647 # sources stay the same, so the applied photocalib will increase by
648 # the illum_corr_value.
649 # Tolerance is the same as the direct test with no illumination
650 # correction.
651 self.assertFloatsAlmostEqual(
652 result.applied_photo_calib.getCalibrationMean(),
653 self.photo_calib * illum_corr_value,
654 rtol=1e-2,
655 )
657 self.assertEqual(len(result.background), 4)
658 self.assertFloatsAlmostEqual(
659 np.median(result.background.getImage().array),
660 result.applied_photo_calib.getCalibrationMean() * self.background_level,
661 rtol=1e-3,
662 )
664 # Check metadata.
665 key = "LSST CALIB ILLUMCORR APPLIED"
666 self.assertIn(key, result.exposure.metadata)
667 self.assertEqual(result.exposure.metadata[key], True)
669 def test_run_with_diffraction_spike_mask(self):
670 """Test that the diffraction spike mask subtask runs.
671 """
672 config = self.config
673 config.doMaskDiffractionSpikes = True
674 config.diffractionSpikeMask.magnitudeThreshold = 19
675 # Define a fake SATURATED mask plane for use by the diffraction spike
676 # task, so that it does not affect the rest of calibrate
677 config.diffractionSpikeMask.saturatedMaskPlane = "FAKESATURATED"
678 calibrate = CalibrateImageTask(config=config)
679 calibrate.astrometry.setRefObjLoader(self.ref_loader)
680 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
681 calibrate.diffractionSpikeMask.setRefObjLoader(self.ref_loader)
683 exposure = self.exposure.clone()
685 exposure.info.setVisitInfo(makeTestVisitInfo())
686 exposure.mask.addMaskPlane(config.diffractionSpikeMask.saturatedMaskPlane)
688 # Set the saturated mask plane in half of the image
689 saturatedMaskBit = exposure.mask.getPlaneBitMask(config.diffractionSpikeMask.saturatedMaskPlane)
690 bbox = exposure.getBBox()
691 bbox.grow(-lsst.geom.Extent2I(0, bbox.height//4))
692 exposure[bbox].mask.array |= saturatedMaskBit
694 result = calibrate.run(exposures=exposure)
696 self._check_run(calibrate, result)
698 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": ""})
699 def test_fail_on_sattle_misconfiguration(self):
700 """Test for failure if sattle is requested without appropriate
701 configurations.
702 """
703 self.config.run_sattle = True
704 with self.assertRaises(pexConfig.FieldValidationError):
705 CalibrateImageTask(config=self.config)
707 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
708 def test_continue_on_sattle_failure(self):
709 """Processing should continue when sattle returns status codes other
710 than 200.
711 """
712 response = MockResponse({}, 500, "internal sattle error")
714 self.config.run_sattle = True
715 calibrate = CalibrateImageTask(config=self.config)
716 calibrate.astrometry.setRefObjLoader(self.ref_loader)
717 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
718 with mock.patch('requests.put', return_value=response) as mock_put:
719 calibrate.run(exposures=self.exposure)
720 mock_put.assert_called_once()
722 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
723 def test_sattle(self):
724 """Test for successful completion when sattle call returns
725 successfully.
726 """
727 response = MockResponse({}, 200, "success")
729 self.config.run_sattle = True
730 calibrate = CalibrateImageTask(config=self.config)
731 calibrate.astrometry.setRefObjLoader(self.ref_loader)
732 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
733 with mock.patch('requests.put', return_value=response) as mock_put:
734 calibrate.run(exposures=self.exposure)
735 mock_put.assert_called_once()
738class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase):
739 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler,
740 but do not need real images.
741 """
742 def setUp(self):
743 instrument = "testCam"
744 exposure0 = 101
745 exposure1 = 102
746 visit = 100101
747 detector = 42
748 physical_filter = "r"
750 # Create a and populate a test butler for runQuantum tests.
751 self.repo_path = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
752 self.repo = butlerTests.makeTestRepo(self.repo_path.name)
753 self.enterContext(self.repo)
755 # A complete instrument record is necessary for the id generator.
756 instrumentRecord = self.repo.dimensions["instrument"].RecordClass(
757 name=instrument, visit_max=1e6, exposure_max=1e6, detector_max=128,
758 class_name="lsst.obs.base.instrument_tests.DummyCam",
759 )
760 self.repo.registry.syncDimensionData("instrument", instrumentRecord)
762 # dataIds for fake data
763 butlerTests.addDataIdValue(self.repo, "detector", detector)
764 butlerTests.addDataIdValue(self.repo, "exposure", exposure0)
765 butlerTests.addDataIdValue(self.repo, "exposure", exposure1)
766 butlerTests.addDataIdValue(self.repo, "visit", visit)
767 butlerTests.addDataIdValue(self.repo, "physical_filter", physical_filter)
769 # inputs
770 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"},
771 "ExposureF")
772 butlerTests.addDatasetType(self.repo, "gaia_dr3_20230707", {"htm7"}, "SimpleCatalog")
773 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog")
774 butlerTests.addDatasetType(self.repo, "flat", {"instrument", "detector", "physical_filter"},
775 "Exposure")
776 butlerTests.addDatasetType(self.repo,
777 "illuminationCorrection",
778 {"instrument", "detector", "physical_filter"},
779 "Exposure")
781 # outputs
782 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"},
783 "ExposureF")
784 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector",
785 {"instrument", "visit", "detector"},
786 "SourceCatalog")
787 butlerTests.addDatasetType(self.repo, "initial_stars_detector",
788 {"instrument", "visit", "detector"},
789 "ArrowAstropy")
790 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector",
791 {"instrument", "visit", "detector"},
792 "PhotoCalib")
793 butlerTests.addDatasetType(self.repo, "background_to_photometric_ratio",
794 {"instrument", "visit", "detector"},
795 "Image")
796 # optional outputs
797 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"},
798 "Background")
799 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints_detector",
800 {"instrument", "visit", "detector"},
801 "SourceCatalog")
802 butlerTests.addDatasetType(self.repo, "initial_psf_stars_detector",
803 {"instrument", "visit", "detector"},
804 "ArrowAstropy")
805 butlerTests.addDatasetType(self.repo,
806 "initial_astrometry_match_detector",
807 {"instrument", "visit", "detector"},
808 "Catalog")
809 butlerTests.addDatasetType(self.repo,
810 "initial_photometry_match_detector",
811 {"instrument", "visit", "detector"},
812 "Catalog")
813 butlerTests.addDatasetType(self.repo,
814 "preliminary_visit_mask",
815 {"instrument", "visit", "detector"},
816 "Mask")
818 # dataIds
819 self.exposure0_id = self.repo.registry.expandDataId(
820 {"instrument": instrument, "exposure": exposure0, "detector": detector})
821 self.exposure1_id = self.repo.registry.expandDataId(
822 {"instrument": instrument, "exposure": exposure1, "detector": detector})
823 self.visit_id = self.repo.registry.expandDataId(
824 {"instrument": instrument, "visit": visit, "detector": detector})
825 self.htm_id = self.repo.registry.expandDataId({"htm7": 42})
826 self.flat_id = self.repo.registry.expandDataId(
827 {"instrument": instrument, "detector": detector, "physical_filter": physical_filter})
829 # put empty data
830 self.butler = butlerTests.makeTestCollection(self.repo)
831 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure0_id)
832 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure1_id)
833 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr3_20230707", self.htm_id)
834 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id)
835 self.butler.put(afwImage.ExposureF(), "flat", self.flat_id)
836 self.butler.put(afwImage.ExposureF(), "illuminationCorrection", self.flat_id)
838 def tearDown(self):
839 self.repo_path.cleanup()
841 def test_runQuantum(self):
842 task = CalibrateImageTask()
843 lsst.pipe.base.testUtils.assertValidInitOutput(task)
845 quantum = lsst.pipe.base.testUtils.makeQuantum(
846 task, self.butler, self.visit_id,
847 {"exposures": [self.exposure0_id],
848 "astrometry_ref_cat": [self.htm_id],
849 "photometry_ref_cat": [self.htm_id],
850 "background_flat": self.flat_id,
851 "illumination_correction": self.flat_id,
852 # outputs
853 "exposure": self.visit_id,
854 "stars": self.visit_id,
855 "stars_footprints": self.visit_id,
856 "background": self.visit_id,
857 "psf_stars": self.visit_id,
858 "psf_stars_footprints": self.visit_id,
859 "applied_photo_calib": self.visit_id,
860 "initial_pvi_background": self.visit_id,
861 "astrometry_matches": self.visit_id,
862 "photometry_matches": self.visit_id,
863 "mask": self.visit_id,
864 })
865 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
867 # Ensure the reference loaders have been configured.
868 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
869 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
870 # Check that the proper kwargs are passed to run().
871 self.assertEqual(
872 mock_run.call_args.kwargs.keys(),
873 {"exposures",
874 "result",
875 "id_generator",
876 "background_flat",
877 "illumination_correction",
878 "camera_model",
879 "exposure_record",
880 "exposure_region",
881 },
882 )
884 def test_runQuantum_illumination_correction(self):
885 config = CalibrateImageTask.ConfigClass()
886 config.do_illumination_correction = True
887 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
888 config.psf_detection.doApplyFlatBackgroundRatio = True
889 config.star_background.doApplyFlatBackgroundRatio = True
890 config.star_detection.doApplyFlatBackgroundRatio = True
891 task = CalibrateImageTask(config=config)
892 lsst.pipe.base.testUtils.assertValidInitOutput(task)
894 quantum = lsst.pipe.base.testUtils.makeQuantum(
895 task, self.butler, self.visit_id,
896 {"exposures": [self.exposure0_id],
897 "astrometry_ref_cat": [self.htm_id],
898 "photometry_ref_cat": [self.htm_id],
899 "background_flat": self.flat_id,
900 "illumination_correction": self.flat_id,
901 # outputs
902 "exposure": self.visit_id,
903 "stars": self.visit_id,
904 "stars_footprints": self.visit_id,
905 "background": self.visit_id,
906 "background_to_photometric_ratio": self.visit_id,
907 "psf_stars": self.visit_id,
908 "psf_stars_footprints": self.visit_id,
909 "applied_photo_calib": self.visit_id,
910 "initial_pvi_background": self.visit_id,
911 "astrometry_matches": self.visit_id,
912 "photometry_matches": self.visit_id,
913 "mask": self.visit_id,
914 })
915 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
917 # Ensure the reference loaders have been configured.
918 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
919 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
920 # Check that the proper kwargs are passed to run().
921 self.assertEqual(
922 mock_run.call_args.kwargs.keys(),
923 {"exposures",
924 "result",
925 "id_generator",
926 "background_flat",
927 "illumination_correction",
928 "camera_model",
929 "exposure_record",
930 "exposure_region",
931 },
932 )
934 def test_runQuantum_2_snaps(self):
935 task = CalibrateImageTask()
936 lsst.pipe.base.testUtils.assertValidInitOutput(task)
938 quantum = lsst.pipe.base.testUtils.makeQuantum(
939 task, self.butler, self.visit_id,
940 {"exposures": [self.exposure0_id, self.exposure1_id],
941 "astrometry_ref_cat": [self.htm_id],
942 "photometry_ref_cat": [self.htm_id],
943 "background_flat": self.flat_id,
944 "illumination_correction": self.flat_id,
945 # outputs
946 "exposure": self.visit_id,
947 "stars": self.visit_id,
948 "stars_footprints": self.visit_id,
949 "background": self.visit_id,
950 "psf_stars": self.visit_id,
951 "psf_stars_footprints": self.visit_id,
952 "applied_photo_calib": self.visit_id,
953 "initial_pvi_background": self.visit_id,
954 "astrometry_matches": self.visit_id,
955 "photometry_matches": self.visit_id,
956 "mask": self.visit_id,
957 })
958 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
960 # Ensure the reference loaders have been configured.
961 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
962 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
963 # Check that the proper kwargs are passed to run().
964 self.assertEqual(
965 mock_run.call_args.kwargs.keys(),
966 {"exposures",
967 "result",
968 "id_generator",
969 "background_flat",
970 "illumination_correction",
971 "camera_model",
972 "exposure_record",
973 "exposure_region",
974 },
975 )
977 def test_runQuantum_no_optional_outputs(self):
978 # All the possible connections: we modify this to test each one by
979 # popping off the removed connection, then re-setting it.
980 connections = {"exposures": [self.exposure0_id, self.exposure1_id],
981 "astrometry_ref_cat": [self.htm_id],
982 "photometry_ref_cat": [self.htm_id],
983 "background_flat": self.flat_id,
984 "illumination_correction": self.flat_id,
985 # outputs
986 "exposure": self.visit_id,
987 "stars": self.visit_id,
988 "stars_footprints": self.visit_id,
989 "background": self.visit_id,
990 "psf_stars": self.visit_id,
991 "psf_stars_footprints": self.visit_id,
992 "applied_photo_calib": self.visit_id,
993 "initial_pvi_background": self.visit_id,
994 "astrometry_matches": self.visit_id,
995 "photometry_matches": self.visit_id,
996 "mask": self.visit_id,
997 }
999 # Check that we can turn off one output at a time.
1000 for optional in ["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches",
1001 "mask"]:
1002 config = CalibrateImageTask.ConfigClass()
1003 config.optional_outputs.remove(optional)
1004 task = CalibrateImageTask(config=config)
1005 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1006 # Save the removed one for the next test.
1007 temp = connections.pop(optional)
1008 # This will fail with "Error in connection ..." if we don't pop
1009 # the optional item from the connections list just above.
1010 quantum = lsst.pipe.base.testUtils.makeQuantum(task, self.butler, self.visit_id, connections)
1011 # This confirms that the outputs did skip the removed one.
1012 self.assertNotIn(optional, quantum.outputs)
1013 # Restore the one we removed for the next test.
1014 connections[optional] = temp
1016 def test_runQuantum_no_calibrate_pixels(self):
1017 """Test that the the task runs when calibrating pixels is disabled,
1018 and that this results in the ``applied_photo_calib`` output being
1019 removed.
1020 """
1021 config = CalibrateImageTask.ConfigClass()
1022 config.do_calibrate_pixels = False
1023 task = CalibrateImageTask(config=config)
1024 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1026 quantum = lsst.pipe.base.testUtils.makeQuantum(
1027 task, self.butler, self.visit_id,
1028 {"exposures": [self.exposure0_id],
1029 "astrometry_ref_cat": [self.htm_id],
1030 "photometry_ref_cat": [self.htm_id],
1031 "background_flat": self.flat_id,
1032 "illumination_correction": self.flat_id,
1033 # outputs
1034 "exposure": self.visit_id,
1035 "stars": self.visit_id,
1036 "stars_footprints": self.visit_id,
1037 "background": self.visit_id,
1038 "psf_stars": self.visit_id,
1039 "psf_stars_footprints": self.visit_id,
1040 "initial_pvi_background": self.visit_id,
1041 "astrometry_matches": self.visit_id,
1042 "photometry_matches": self.visit_id,
1043 "mask": self.visit_id,
1044 })
1045 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1047 # Ensure the reference loaders have been configured.
1048 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
1049 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
1050 # Check that the proper kwargs are passed to run().
1051 self.assertEqual(
1052 mock_run.call_args.kwargs.keys(),
1053 {"exposures",
1054 "result",
1055 "id_generator",
1056 "background_flat",
1057 "illumination_correction",
1058 "camera_model",
1059 "exposure_record",
1060 "exposure_region",
1061 },
1062 )
1064 def test_lintConnections(self):
1065 """Check that the connections are self-consistent.
1066 """
1067 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass
1068 lsst.pipe.base.testUtils.lintConnections(Connections)
1070 def test_runQuantum_exception(self):
1071 """Test exception handling in runQuantum.
1072 """
1073 task = CalibrateImageTask()
1074 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1076 quantum = lsst.pipe.base.testUtils.makeQuantum(
1077 task, self.butler, self.visit_id,
1078 {"exposures": [self.exposure0_id],
1079 "astrometry_ref_cat": [self.htm_id],
1080 "photometry_ref_cat": [self.htm_id],
1081 "background_flat": self.flat_id,
1082 "illuminationCorrection": self.flat_id,
1083 # outputs
1084 "exposure": self.visit_id,
1085 "stars": self.visit_id,
1086 "stars_footprints": self.visit_id,
1087 "background": self.visit_id,
1088 "psf_stars": self.visit_id,
1089 "psf_stars_footprints": self.visit_id,
1090 "applied_photo_calib": self.visit_id,
1091 "initial_pvi_background": self.visit_id,
1092 "astrometry_matches": self.visit_id,
1093 "photometry_matches": self.visit_id,
1094 "mask": self.visit_id,
1095 })
1097 # A generic exception should raise directly.
1098 msg = "mocked run exception"
1099 with (
1100 mock.patch.object(task, "run", side_effect=ValueError(msg)),
1101 self.assertRaisesRegex(ValueError, "mocked run exception")
1102 ):
1103 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False)
1105 # An AlgorithmError should write annotated partial outputs.
1106 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101)
1108 def mock_run(
1109 exposures,
1110 result=None,
1111 id_generator=None,
1112 background_flat=None,
1113 illumination_correction=None,
1114 camera_model=None,
1115 exposure_record=None,
1116 exposure_region=None,
1117 ):
1118 """Mock success through compute_psf, but failure after.
1119 """
1120 result.exposure = afwImage.ExposureF(10, 10)
1121 result.psf_stars_footprints = afwTable.SourceCatalog()
1122 result.psf_stars = afwTable.SourceCatalog().asAstropy()
1123 result.background = afwMath.BackgroundList()
1124 raise error
1126 with (
1127 mock.patch.object(task, "run", side_effect=mock_run),
1128 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError),
1129 ):
1130 with self.assertLogs("lsst.calibrateImage", level="DEBUG") as cm:
1131 lsst.pipe.base.testUtils.runTestQuantum(task,
1132 self.butler,
1133 quantum,
1134 mockRun=False)
1136 logged = "\n".join(cm.output)
1137 self.assertIn("Task failed with only partial outputs", logged)
1138 self.assertIn("MeasureApCorrError", logged)
1140 # NOTE: This is an integration test of afw Exposure & SourceCatalog
1141 # metadata with the error annotation system in pipe_base.
1142 # Check that we did get the annotated partial outputs...
1143 pvi = self.butler.get("initial_pvi", self.visit_id)
1144 self.assertIn("Unable to measure aperture correction", pvi.metadata["failure.message"])
1145 self.assertIn("MeasureApCorrError", pvi.metadata["failure.type"])
1146 self.assertEqual(pvi.metadata["failure.metadata.ndof"], 101)
1147 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id)
1148 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"])
1149 self.assertIn("MeasureApCorrError", stars.metadata["failure.type"])
1150 self.assertEqual(stars.metadata["failure.metadata.ndof"], 101)
1151 # ... but not the un-produced outputs.
1152 with self.assertRaises(FileNotFoundError):
1153 self.butler.get("initial_stars_footprints_detector", self.visit_id)
1156class MockResponse:
1157 """Provide a mock for requests.put calls"""
1158 def __init__(self, json_data, status_code, text):
1159 self.json_data = json_data
1160 self.status_code = status_code
1161 self.text = text
1163 def json(self):
1164 return self.json_data
1166 def raise_for_status(self):
1167 if self.status_code != 200:
1168 raise requests.exceptions.HTTPError
1171def setup_module(module):
1172 lsst.utils.tests.init()
1175class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
1176 pass
1179if __name__ == "__main__": 1179 ↛ 1180line 1179 didn't jump to line 1180 because the condition on line 1179 was never true
1180 lsst.utils.tests.init()
1181 unittest.main()