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