Coverage for tests/test_calibrateImage.py: 98%
618 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:36 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:36 +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
37from lsst.afw.cameraGeom.testUtils import CameraWrapper
38import lsst.daf.base
39import lsst.daf.butler
40import lsst.daf.butler.tests as butlerTests
41import lsst.geom
42import lsst.images
43import lsst.meas.algorithms
44from lsst.meas.algorithms import testUtils
45import lsst.meas.extensions.psfex
46import lsst.meas.base
47import lsst.meas.base.tests
48import lsst.pipe.base as pipeBase
49import lsst.pipe.base.testUtils
50from lsst.pipe.tasks.calibrateImage import CalibrateImageTask, \
51 NoPsfStarsToStarsMatchError, AllCentroidsFlaggedError
52import lsst.pex.config as pexConfig
53import lsst.utils.tests
55from utils import makeTestVisitInfo, make_exposure_record
58class CalibrateImageTaskTests(lsst.utils.tests.TestCase):
60 def setUp(self):
61 # Different x/y dimensions so they're easy to distinguish in a plot,
62 # and non-zero minimum, to help catch xy0 errors.
63 bbox = lsst.geom.Box2I(lsst.geom.Point2I(5, 4), lsst.geom.Point2I(205, 184))
64 self.sky_center = lsst.geom.SpherePoint(245.0, -45.0, lsst.geom.degrees)
65 self.photo_calib = 12.3
66 dataset = lsst.meas.base.tests.TestDataset(bbox, crval=self.sky_center, calibration=self.photo_calib)
67 # sqrt of area of a normalized 2d gaussian
68 psf_scale = np.sqrt(4*np.pi*(dataset.psfShape.getDeterminantRadius())**2)
69 noise = 10.0 # stddev of noise per pixel
70 # Sources ordered from faintest to brightest.
71 self.fluxes = np.array((6*noise*psf_scale,
72 12*noise*psf_scale,
73 45*noise*psf_scale,
74 150*noise*psf_scale,
75 400*noise*psf_scale,
76 1000*noise*psf_scale))
77 self.centroids = np.array(((162, 22),
78 (40, 70),
79 (100, 160),
80 (50, 120),
81 (92, 35),
82 (175, 154)), dtype=np.float32)
83 for flux, centroid in zip(self.fluxes, self.centroids):
84 dataset.addSource(instFlux=flux, centroid=lsst.geom.Point2D(centroid[0], centroid[1]))
86 # Bright extended source in the center of the image: should not appear
87 # in any of the output catalogs.
88 self.extended_source = lsst.geom.Point2D(100, 100)
89 shape = lsst.afw.geom.Quadrupole(8, 9, 3)
90 dataset.addSource(instFlux=500*noise*psf_scale, centroid=self.extended_source, shape=shape)
92 schema = dataset.makeMinimalSchema()
93 self.truth_exposure, self.truth_cat = dataset.realize(noise=noise, schema=schema)
94 # Add in a significant background, so we can test that the output
95 # background is self-consistent with the calibrated exposure.
96 self.background_level = 500.0
97 self.truth_exposure.image += self.background_level
98 # To make it look like a version=1 (nJy fluxes) refcat
99 self.truth_cat = self.truth_exposure.photoCalib.calibrateCatalog(self.truth_cat)
100 self.ref_loader = testUtils.MockReferenceObjectLoaderFromMemory([self.truth_cat])
101 metadata = lsst.daf.base.PropertyList()
102 metadata.set("REFCAT_FORMAT_VERSION", 1)
103 self.truth_cat.setMetadata(metadata)
105 # TODO: a cosmic ray (need to figure out how to insert a fake-CR)
106 # self.truth_exposure.image.array[10, 10] = 100000
107 # self.truth_exposure.variance.array[10, 10] = 100000/noise
109 # Copy the truth exposure, because CalibrateImage modifies the input.
110 # Post-ISR images only contain: initial WCS, VisitInfo, filter
111 self.exposure = afwImage.ExposureF(self.truth_exposure, deep=True)
112 self.exposure.setWcs(self.truth_exposure.wcs)
113 self.exposure.info.setVisitInfo(self.truth_exposure.visitInfo)
114 self.exposure.info.id = 12345
115 # "truth" filter, to match the "truth" refcat.
116 self.exposure.setFilter(lsst.afw.image.FilterLabel(physical='truth', band="truth"))
117 self.exposure.metadata["LSST ISR FLAT APPLIED"] = True
119 # Set up a basic results struct to hold exposure attribute data
120 self.attributes = pipeBase.Struct()
121 self.attributes.exposure = self.exposure
122 self.attributes.background = None
123 self.attributes.background_to_photometric_ratio = None
125 # Test-specific configuration:
126 self.config = CalibrateImageTask.ConfigClass()
127 # We don't have many sources, so have to fit simpler models.
128 self.config.compute_shapelet_decomposition.min_n_stars = 4
129 self.config.psf_detection.background.approxOrderX = 1
130 self.config.star_detection.background.approxOrderX = 1
131 # Only insert 2 sky sources, for simplicity.
132 self.config.star_sky_sources.nSources = 2
133 # Use PCA psf fitter, as psfex fails if there are only 4 stars.
134 self.config.psf_measure_psf.psfDeterminer = 'pca'
135 # We don't have many test points, so can't match on complicated shapes.
136 self.config.astrometry.sourceSelector["science"].flags.good = []
137 self.config.astrometry.matcher.numPointsForShape = 3
138 self.config.run_sattle = False
139 # Maintain original, no adaptive threshold detection, configs values.
140 self.config.do_adaptive_threshold_detection = False
141 self.config.psf_detection.reEstimateBackground = True
142 self.config.star_detection.reEstimateBackground = True
143 # ApFlux has more noise than PsfFlux (the latter unrealistically small
144 # in this test data), so we need to do magnitude rejection at higher
145 # sigma, otherwise we can lose otherwise good sources.
146 # TODO DM-39203: Once we are using Compensated Gaussian Fluxes, we
147 # will use those fluxes here, and hopefully can remove this.
148 self.config.astrometry.magnitudeOutlierRejectionNSigma = 9.0
150 # Make a realistic id generator so that output catalog ids are useful.
151 # NOTE: The id generator is used to seed the noise replacer during
152 # measurement, so changes to values here can have subtle effects on
153 # the centroids and fluxes measured on the image, which might cause
154 # tests to fail.
155 data_id = lsst.daf.butler.DataCoordinate.standardize(
156 instrument="I",
157 visit=self.truth_exposure.visitInfo.id,
158 detector=12,
159 universe=lsst.daf.butler.DimensionUniverse(),
160 )
161 self.config.id_generator.packer.name = "observation"
162 self.config.id_generator.packer["observation"].n_observations = 10000
163 self.config.id_generator.packer["observation"].n_detectors = 99
164 self.config.id_generator.n_releases = 8
165 self.config.id_generator.release_id = 2
166 self.id_generator = self.config.id_generator.apply(data_id)
168 # Something about this test dataset prefers a larger threshold here.
169 self.config.star_selector["science"].unresolved.maximum = 0.2
171 def _check_run(self, calibrate, result, expect_calibrated_pixels: bool = True,
172 expect_n_background: int = 4, expect_n_background_equal_or_greater_than: int = -1,
173 do_shapelet_check: bool = False):
174 """Test the result of CalibrateImage.run().
176 Parameters
177 ----------
178 calibrate : `lsst.pipe.tasks.calibrateImage.CalibrateImageTask`
179 Configured task that had `run` called on it.
180 result : `lsst.pipe.base.Struct`
181 Result of calling calibrate.run().
182 expect_calibrated_pixels : `bool`, optional
183 Whether to expect image and background pixels to be calibrated.
184 """
185 # Background should have 4 elements: 3 from compute_psf and one from
186 # re-estimation during source detection.
187 if expect_n_background is not None:
188 self.assertEqual(len(result.background), expect_n_background)
189 if expect_n_background_equal_or_greater_than is not None: 189 ↛ 193line 189 didn't jump to line 193 because the condition on line 189 was always true
190 self.assertTrue(len(result.background) >= expect_n_background_equal_or_greater_than)
192 # Both afw and astropy psf_stars catalogs should be populated.
193 self.assertEqual(result.psf_stars["calib_psf_used"].sum(), 3)
194 self.assertEqual(result.psf_stars_footprints["calib_psf_used"].sum(), 3)
196 # Check that the summary statistics are reasonable.
197 summary = result.exposure.info.getSummaryStats()
198 # Values below are only correct for the base run test.
199 if do_shapelet_check:
200 self.assertFloatsAlmostEqual(summary.shapeletsOnlyIqScore, 8.104235164021247e-06, rtol=1e-4)
201 self.assertFloatsAlmostEqual(summary.shapeletsIqScore, 6.651368381893731e-05, rtol=1e-4)
202 shapeletsCoeffs = [
203 0.28358882665634155, -0.00037077977926209773, -0.002802249127080195,
204 3.439385909587145e-05, -0.0012995795522691303, -0.0018000953572589512,
205 4.4449956901327674e-05, -2.5397295072102522e-05, 3.0203139976223994e-05,
206 0.000617273102631627, -4.456396106385861e-05, 8.730505214771256e-05,
207 0.00011167120828758925, 0.00017521437985124066, -1.5413010141236832e-05,
208 -0.00015419833136474156, -8.099463936604213e-05, -0.00017129145577976686,
209 -9.799635877346543e-05, 2.7090113467370116e-05, 0.0004025635973896668,
210 -6.346980774284659e-05, 0.00013586174545049867, -0.00011295261583971951,
211 0.0003724458365468307, 1.5024524355547979e-05, 0.00012497524707246052,
212 0.00041057434072064747
213 ]
214 for iShapelet in range(len(shapeletsCoeffs)):
215 self.assertFloatsAlmostEqual(
216 summary.shapeletsCoeffs[iShapelet], shapeletsCoeffs[iShapelet], rtol=2e-3)
217 self.assertEqual(summary.nShapeletsStar, 4)
218 self.assertFloatsAlmostEqual(
219 summary.centroidDiffShapeletsVsSlotMedian, 0.10838090573, rtol=1e-3)
221 self.assertFloatsAlmostEqual(summary.refCatSourceDensity, 39470.0430204, rtol=1e-3)
222 self.assertFloatsAlmostEqual(summary.psfSigma, 2.0, rtol=1e-2)
223 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7)
224 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7)
226 # Should have finite sky coordinates in the afw and astropy catalogs.
227 self.assertTrue(np.isfinite(result.stars_footprints["coord_ra"]).all())
228 self.assertTrue(np.isfinite(result.stars["coord_ra"]).all())
230 if expect_calibrated_pixels:
231 # Fit photoCalib should be the applied value if we calibrated
232 # pixels, not the ==1 one on the exposure.
233 photo_calib = result.applied_photo_calib
234 self.assertEqual(result.exposure.photoCalib.getCalibrationMean(), 1.0)
235 else:
236 self.assertIsNone(result.applied_photo_calib)
237 photo_calib = result.exposure.photoCalib
238 # PhotoCalib comparison is very approximate because we are basing this
239 # comparison on just 2-3 stars.
240 self.assertFloatsAlmostEqual(photo_calib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
241 # Should have calibrated flux/mags in the afw and astropy catalogs
242 self.assertIn("slot_PsfFlux_flux", result.stars_footprints.schema)
243 self.assertIn("slot_PsfFlux_mag", result.stars_footprints.schema)
244 self.assertEqual(result.stars["slot_PsfFlux_flux"].unit, u.nJy)
245 self.assertEqual(result.stars["slot_PsfFlux_mag"].unit, u.ABmag)
247 # Should have detected all S/N >= 10 sources plus 2 sky sources,
248 # whether 1 or 2 snaps.
249 self.assertEqual(len(result.stars), 6)
250 # Did the psf flags get propagated from the psf_stars catalog?
251 self.assertEqual(result.stars["calib_psf_used"].sum(), 3)
253 # Check that all necessary fields are in the output.
254 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result)
256 # Check metadata.
257 key = "LSST CALIB ILLUMCORR APPLIED"
258 self.assertIn(key, result.exposure.metadata)
259 self.assertEqual(result.exposure.metadata[key], False)
260 key = "REF_CAT_SOURCE_DENSITY"
261 self.assertIn(key, result.exposure.metadata)
263 # Check that the psf_stars cross match worked correctly.
264 matches = esutil.numpy_util.match(result.psf_stars["id"], result.stars["psf_id"])
265 self.assertFloatsAlmostEqual(result.psf_stars["slot_Centroid_x"][matches[0]],
266 result.stars["slot_Centroid_x"][matches[1]], atol=3e-4)
267 if "astrometry_matches" in self.config.optional_outputs:
268 matches = esutil.numpy_util.match(result.astrometry_matches["src_id"],
269 result.photometry_matches["src_psf_id"])
270 self.assertFloatsAlmostEqual(result.astrometry_matches["src_slot_Centroid_x"][matches[0]],
271 result.photometry_matches["src_slot_Centroid_x"][matches[1]],
272 atol=3e-4)
274 def _run_legacy_for_future(self, config):
275 """Run the task configured for the `lsst.images` output type, and
276 return what it produced, without converting it.
278 Parameters
279 ----------
280 config : `lsst.pipe.tasks.calibrateImage.CalibrateImageConfig`
281 Configuration to run with; ``output_image_type`` is set to
282 "future" here.
284 Returns
285 -------
286 calibrate : `lsst.pipe.tasks.calibrateImage.CalibrateImageTask`
287 The task that was run, to convert the result with.
288 result : `lsst.pipe.base.Struct`
289 The legacy output struct.
290 """
291 # lsst.images needs raw amplifier geometry and a FIELD_ANGLE
292 # transform, which the trivial test detectors do not have.
293 self.exposure.setDetector(list(CameraWrapper().camera)[0])
294 config.output_image_type = "future"
295 calibrate = CalibrateImageTask(config=config)
296 calibrate.astrometry.setRefObjLoader(self.ref_loader)
297 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
298 result = calibrate.run(exposures=self.exposure)
299 # run() always produces legacy types, whatever output_image_type is.
300 self.assertIsInstance(result.exposure, afwImage.Exposure)
301 return calibrate, result
303 def _run_future(self, config):
304 """Run the task with an `lsst.images` output type, and convert the
305 result.
307 Parameters
308 ----------
309 config : `lsst.pipe.tasks.calibrateImage.CalibrateImageConfig`
310 Configuration to run with; ``output_image_type`` is set to
311 "future" here.
313 Returns
314 -------
315 result : `lsst.pipe.base.Struct`
316 The converted output struct.
318 Notes
319 -----
320 The conversion removes ``applied_photo_calib`` from the struct, so
321 the value `run` produced is saved as ``self.applied_photo_calib``
322 for tests that need to compare against it.
323 """
324 calibrate, result = self._run_legacy_for_future(config)
325 self.applied_photo_calib = result.applied_photo_calib
326 calibrate.convert_outputs_to_future(result, make_exposure_record(self.exposure))
327 return result
329 def test_convert_outputs_to_future(self):
330 """Test that the output exposure is converted to a VisitImage with
331 the photometric calibration and background attached.
332 """
333 config = copy.copy(self.config)
334 result = self._run_future(config)
336 self.assertIsInstance(result.exposure, lsst.images.VisitImage)
337 # Pixels were calibrated, so both image and background are in nJy.
338 self.assertEqual(result.exposure.unit, u.nJy)
339 self.assertEqual(result.exposure.backgrounds["subtracted"].field.unit, u.nJy)
340 self.assertEqual(result.exposure.backgrounds.subtracted.name, "subtracted")
342 # photometric_scaling is the calibration that was applied, not the
343 # identity that is left attached to the calibrated exposure.
344 self.assertIsNotNone(result.exposure.photometric_scaling)
345 self.assertEqual(result.exposure.photometric_scaling.unit, u.nJy/u.electron)
346 round_tripped = result.exposure.photometric_scaling.to_legacy_photo_calib(u.electron)
347 self.assertFloatsAlmostEqual(round_tripped.getCalibrationMean(),
348 self.applied_photo_calib.getCalibrationMean(),
349 rtol=1e-14)
351 # The background and the applied calibration were moved onto the
352 # image, so the separate outputs are gone; both connections are
353 # deleted in this mode.
354 self.assertFalse(hasattr(result, "background"))
355 self.assertFalse(hasattr(result, "applied_photo_calib"))
357 def test_convert_outputs_to_future_no_calibrate_pixels(self):
358 """Test the conversion when the pixels are left in instrumental
359 units, so that the photometric calibration has not been applied.
360 """
361 config = copy.copy(self.config)
362 config.do_calibrate_pixels = False
363 result = self._run_future(config)
365 self.assertIsNone(self.applied_photo_calib)
366 instrumental_unit = u.Unit(config.instrumental_unit)
367 self.assertEqual(result.exposure.unit, instrumental_unit)
368 self.assertEqual(result.exposure.backgrounds["subtracted"].field.unit, instrumental_unit)
369 self.assertEqual(result.exposure.photometric_scaling.unit, u.nJy/instrumental_unit)
370 self.assertFalse(hasattr(result, "background"))
371 self.assertFalse(hasattr(result, "applied_photo_calib"))
373 def test_convert_outputs_to_future_partial_outputs_struct(self):
374 """Test conversion of a struct with only the fields ``runQuantum``
375 seeds before calling ``run``: no ``background``, and
376 ``applied_photo_calib`` set to `None`.
377 """
378 config = copy.copy(self.config)
379 # Leave the pixels uncalibrated, to match applied_photo_calib=None.
380 config.do_calibrate_pixels = False
381 calibrate, run_result = self._run_legacy_for_future(config)
383 # Same fields, in the same state, as the struct runQuantum seeds.
384 partial = pipeBase.Struct(
385 exposure=run_result.exposure,
386 stars_footprints=None,
387 psf_stars_footprints=None,
388 background_to_photometric_ratio=None,
389 applied_photo_calib=None,
390 )
391 calibrate.convert_outputs_to_future(partial, make_exposure_record(self.exposure))
392 self.assertIsInstance(partial.exposure, lsst.images.VisitImage)
394 def test_run(self):
395 """Test that run() returns reasonable values to be butler put.
396 """
397 calibrate = CalibrateImageTask(config=self.config)
398 calibrate.astrometry.setRefObjLoader(self.ref_loader)
399 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
400 result = calibrate.run(exposures=self.exposure)
402 self._check_run(calibrate, result, do_shapelet_check=True)
404 def test_run_adaptive_threshold_detection(self):
405 """Test that run() runs with adaptive threshold detection turned on.
406 """
407 config = copy.copy(self.config)
408 # Set the adaptive threshold detection, config values...
409 config.do_adaptive_threshold_detection = True
410 config.psf_adaptive_threshold_detection.minFootprint = 4
411 config.psf_adaptive_threshold_detection.minIsolated = 4
412 config.psf_adaptive_threshold_detection.sufficientIsolated = 4
413 config.psf_detection.reEstimateBackground = False
414 config.star_detection.reEstimateBackground = False
416 calibrate = CalibrateImageTask(config=config)
417 calibrate.astrometry.setRefObjLoader(self.ref_loader)
418 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
419 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
420 result = calibrate.run(exposures=self.exposure)
421 subString = "Using adaptive threshold detection "
422 self.assertTrue(any(subString in s for s in cm.output))
424 # Number of backgrounds in list is only guaranteed to have at least 2
425 # entries in the adaptive threshold code path.
426 self._check_run(calibrate, result, expect_n_background=None,
427 expect_n_background_equal_or_greater_than=2)
429 def test_run_downsample(self):
430 """Test that run() runs with downsample.
431 """
432 config = copy.copy(self.config)
433 config.do_downsample_footprints = True
434 config.downsample_max_footprints = 5
436 calibrate = CalibrateImageTask(config=config)
437 calibrate.astrometry.setRefObjLoader(self.ref_loader)
438 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
439 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm:
440 result = calibrate.run(exposures=self.exposure)
441 self.assertIn(
442 "INFO:lsst.calibrateImage:Downsampling from 8 to 7 non-sky-source footprints.",
443 cm.output,
444 )
446 self._check_run(calibrate, result)
448 def test_run_2_snaps(self):
449 """Test that run() returns reasonable values to be butler put, when
450 passed two exposures to combine as snaps.
451 """
452 calibrate = CalibrateImageTask(config=self.config)
453 calibrate.astrometry.setRefObjLoader(self.ref_loader)
454 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
455 # Halve the flux in each exposure to get the expected visit sum.
456 self.exposure.image /= 2
457 self.exposure.variance /= 2
458 result = calibrate.run(exposures=[self.exposure, self.exposure])
460 self._check_run(calibrate, result)
462 def test_run_no_optionals(self):
463 """Test that disabling optional outputs removes them from the output
464 struct, as appropriate.
465 """
466 self.config.optional_outputs = []
467 calibrate = CalibrateImageTask(config=self.config)
468 calibrate.astrometry.setRefObjLoader(self.ref_loader)
469 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
470 result = calibrate.run(exposures=self.exposure)
472 self._check_run(calibrate, result)
473 # These are the only optional outputs that require extra computation,
474 # the others are included in the output struct regardless.
475 self.assertNotIn("astrometry_matches", result.getDict())
476 self.assertNotIn("photometry_matches", result.getDict())
478 def test_run_no_calibrate_pixels(self):
479 """Test that run() returns reasonable values when
480 do_calibrate_pixels=False.
481 """
482 self.config.do_calibrate_pixels = False
483 calibrate = CalibrateImageTask(config=self.config)
484 calibrate.astrometry.setRefObjLoader(self.ref_loader)
485 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
486 result = calibrate.run(exposures=self.exposure)
488 self._check_run(calibrate, result, expect_calibrated_pixels=False)
490 def test_run_no_astrom_errors(self):
491 """Test that run() returns reasonable values when
492 do_calibrate_pixels=False.
493 """
494 self.config.do_include_astrometric_errors = False
495 calibrate = CalibrateImageTask(config=self.config)
496 calibrate.astrometry.setRefObjLoader(self.ref_loader)
497 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
498 result = calibrate.run(exposures=self.exposure)
500 self._check_run(calibrate, result)
502 def test_compute_psf(self):
503 """Test that our brightest sources are found by _compute_psf(),
504 that a PSF is assigned to the exposure.
505 """
506 calibrate = CalibrateImageTask(config=self.config)
507 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
508 psf_stars = compute_psf_struct.detections_sources
510 # Catalog ids should be very large from this id generator.
511 self.assertTrue(all(psf_stars['id'] > 1000000000))
513 # Background should have 3 elements: initial subtraction, and two from
514 # re-estimation during the two detection passes.
515 self.assertEqual(len(self.attributes.background), 3)
517 # Only the point-sources with S/N > 50 should be in this output.
518 self.assertEqual(psf_stars["calib_psf_used"].sum(), 3)
519 # Sort in brightness order, to easily compare with expected positions.
520 psf_stars.sort(psf_stars.getPsfFluxSlot().getMeasKey())
521 for record, flux, center in zip(psf_stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 521 ↛ 522line 521 didn't jump to line 522 because the loop on line 521 never started
522 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
523 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
524 # PsfFlux should match the values inserted.
525 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
527 # TODO: While debugging DM-32701, we're using PCA instead of psfex.
528 # Check that we got a useable PSF.
529 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf)
530 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf)
531 # TestDataset sources have PSF radius=2 pixels.
532 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius()
533 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2)
535 # To look at images for debugging (`setup display_ds9` and run ds9):
536 # import lsst.afw.display
537 # display = lsst.afw.display.getDisplay()
538 # display.mtv(self.exposure)
540 def test_compute_psf_bad_centroids(self):
541 """Test that we raise an appropriate error if all measured centroids
542 are bad in compute_psf.
543 The root cause of this is likely an interaction between the PSF fit
544 and cosmic ray repair. An ugly true PSF can result in the centers of
545 sources being masked and repaired as CRs, thus removing the center of
546 the sources and causing them all to be flagged.
547 """
548 # make the sources have a (not too deep) hole in the middle
549 for point in self.centroids:
550 for i in (-1, 0, 1):
551 for j in (-1, 0, 1):
552 self.exposure.image[point[0]+i, point[1]+j] -= \
553 (self.exposure.image[point[0]+i, point[1]+j] - self.background_level)/1.1
554 # and the extended central source
555 point = self.extended_source
556 self.exposure.image[point[0], point[1]] -= \
557 (self.exposure.image[point[0], point[1]] - self.background_level)
559 # add a diagonal gradient to each source
560 size = 5
561 for point, flux in zip(self.centroids, self.fluxes):
562 box = lsst.geom.Box2I.makeCenteredBox(lsst.geom.Point2D(point[0], point[1]),
563 lsst.geom.Extent2I(size*2, size*2))
564 X, Y = np.ogrid[point[0] - size:point[0] + size, point[1] - size:point[1] + size]
565 distance = np.sqrt((X - point[0])**2 + (Y - point[1])**2)
566 gradient = (X - point[0] + size)*10 + (Y - point[1] + size)*10
567 gradient[distance > size] = 0
568 cutout = self.exposure.image.subset(box)
569 cutout.array += gradient / flux
571 calibrate = CalibrateImageTask(config=self.config)
572 with self.assertRaisesRegex(AllCentroidsFlaggedError, r"source centroids \(out of 4\) flagged"):
573 calibrate.run(exposures=[self.exposure], id_generator=self.id_generator)
575 def test_measure_aperture_correction(self):
576 """Test that _measure_aperture_correction() assigns an ApCorrMap to the
577 exposure.
578 """
579 calibrate = CalibrateImageTask(config=self.config)
580 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
581 psf_stars = compute_psf_struct.detections_sources
583 # First check that the exposure doesn't have an ApCorrMap.
584 self.assertIsNone(self.exposure.apCorrMap)
585 calibrate._measure_aperture_correction(self.exposure, psf_stars)
586 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap)
587 # We know that there are 2 fields from the normalization, plus more
588 # from other configured plugins.
589 self.assertGreater(len(self.exposure.apCorrMap), 2)
591 def test_find_stars(self):
592 """Test that _find_stars() correctly identifies the S/N>10 stars
593 in the image and returns them in the output catalog.
594 """
595 calibrate = CalibrateImageTask(config=self.config)
596 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
597 psf_stars = compute_psf_struct.detections_sources
598 calibrate._measure_aperture_correction(self.exposure, psf_stars)
600 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
602 # Catalog ids should be very large from this id generator.
603 self.assertTrue(all(stars['id'] > 1000000000))
605 # Background should have 4 elements: 3 from compute_psf and one from
606 # re-estimation during source detection.
607 self.assertEqual(len(self.attributes.background), 4)
609 # Only 5 psf-like sources with S/N>10 should be in the output catalog,
610 # plus two sky sources.
611 self.assertEqual(len(stars), 6)
612 self.assertTrue(stars.isContiguous())
613 # Sort in brightness order, to easily compare with expected positions.
614 stars.sort(stars.getPsfFluxSlot().getMeasKey())
615 for record, flux, center in zip(stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 615 ↛ 616line 615 didn't jump to line 616 because the loop on line 615 never started
616 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
617 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
618 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
620 def test_astrometry(self):
621 """Test that the fitted WCS gives good catalog coordinates.
622 """
623 calibrate = CalibrateImageTask(config=self.config)
624 calibrate.astrometry.setRefObjLoader(self.ref_loader)
625 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
626 psf_stars = compute_psf_struct.detections_sources
627 calibrate._measure_aperture_correction(self.exposure, psf_stars)
628 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
630 calibrate._fit_astrometry(self.exposure, stars)
632 # Check that we got reliable matches with the truth coordinates.
633 sky = stars["sky_source"]
634 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
635 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
636 idx, d2d, _ = fitted.match_to_catalog_sky(truth)
637 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 35.0)
639 def test_photometry(self):
640 """Test that the fitted photoCalib matches the one we generated,
641 and that the exposure is calibrated.
642 """
643 calibrate = CalibrateImageTask(config=self.config)
644 calibrate.astrometry.setRefObjLoader(self.ref_loader)
645 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
646 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
647 psf_stars = compute_psf_struct.detections_sources
648 calibrate._measure_aperture_correction(self.exposure, psf_stars)
649 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
650 calibrate._fit_astrometry(self.exposure, stars)
652 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars)
653 calibrate._apply_photometry(self.exposure, self.attributes.background)
655 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0,
656 # so we can't check that the photoCal error has been set.
657 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=1e-2)
658 # The exposure should be calibrated by the applied photoCalib,
659 # and the background should be calibrated to match.
660 uncalibrated = self.exposure.image.clone()
661 uncalibrated += self.attributes.background.getImage()
662 uncalibrated /= self.photo_calib
663 self.assertFloatsAlmostEqual(uncalibrated.array, self.truth_exposure.image.array, rtol=1e-2)
664 # PhotoCalib on the exposure must be identically 1.
665 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0)
667 # Check that we got reliable magnitudes and fluxes vs. truth, ignoring
668 # sky sources.
669 sky = stars["sky_source"]
670 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian")
671 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
672 idx, _, _ = fitted.match_to_catalog_sky(truth)
673 # Because the input variance image does not include contributions from
674 # the sources, we can't use fluxErr as a bound on the measurement
675 # quality here.
676 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_flux'],
677 self.truth_cat['truth_flux'][idx],
678 rtol=0.1)
679 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_mag'],
680 self.truth_cat['truth_mag'][idx],
681 rtol=0.01)
683 def test_match_psf_stars(self):
684 """Test that _match_psf_stars() flags the correct stars as psf stars
685 and candidates.
686 """
687 calibrate = CalibrateImageTask(config=self.config)
688 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator)
689 psf_stars = compute_psf_struct.detections_sources
690 calibrate._measure_aperture_correction(self.exposure, psf_stars)
691 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator)
693 # There should be no psf-related flags set at first.
694 self.assertEqual(stars["calib_psf_candidate"].sum(), 0)
695 self.assertEqual(stars["calib_psf_used"].sum(), 0)
696 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
698 # Reorder stars to be out of order with psf_stars (putting the sky
699 # sources in front); this tests that I get the indexing right.
700 stars.sort(stars.getCentroidSlot().getMeasKey().getX())
701 stars = stars.copy(deep=True)
702 # Re-number the ids: the matcher requires sorted ids: this is always
703 # true in the code itself, but we've permuted them by sorting on
704 # flux. We don't care what the actual ids themselves are here.
705 stars["id"] = np.arange(len(stars))
707 calibrate._match_psf_stars(psf_stars, stars)
709 # Check that the three brightest stars have the psf flags transferred
710 # from the psf_stars catalog by sorting in order of brightness.
711 stars.sort(stars.getPsfFluxSlot().getMeasKey())
712 # sort() above leaves the catalog non-contiguous.
713 stars = stars.copy(deep=True)
714 np.testing.assert_array_equal(stars["calib_psf_candidate"],
715 [False, False, False, True, True, True])
716 np.testing.assert_array_equal(stars["calib_psf_used"],
717 [False, False, False, True, True, True])
718 # Too few sources to reserve any in these tests.
719 self.assertEqual(stars["calib_psf_reserved"].sum(), 0)
721 def test_match_psf_stars_no_matches(self):
722 """Check that _match_psf_stars handles the case of no cross-matches.
723 """
724 calibrate = CalibrateImageTask(config=self.config)
725 # Make two catalogs that cannot have matches.
726 stars = self.truth_cat[2:].copy(deep=True)
727 psf_stars = self.truth_cat[:2].copy(deep=True)
729 with self.assertRaisesRegex(NoPsfStarsToStarsMatchError,
730 "No psf stars out of 2 matched 5 calib stars") as cm:
731 calibrate._match_psf_stars(psf_stars, stars)
732 self.assertEqual(cm.exception.metadata["n_psf_stars"], 2)
733 self.assertEqual(cm.exception.metadata["n_stars"], 5)
735 def test_calibrate_image_illumcorr(self):
736 """Test running through with an illumination correction."""
737 config = copy.copy(self.config)
738 config.do_illumination_correction = True
739 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
740 config.psf_detection.doApplyFlatBackgroundRatio = True
741 config.star_background.doApplyFlatBackgroundRatio = True
742 config.star_detection.doApplyFlatBackgroundRatio = True
744 calibrate = CalibrateImageTask(config=config)
745 calibrate.astrometry.setRefObjLoader(self.ref_loader)
746 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
748 # Assume that the exposure has been flattened by a flat-flat.
749 background_flat = self.exposure.clone()
750 background_flat.image.array[:, :] = 1.0
751 background_flat.mask.array[:, :] = 0
752 background_flat.variance.array[:, :] = 0.0
754 # And create an illumination correction of 1.1.
755 illum_corr_value = 1.1
756 illumination_correction = self.exposure.clone()
757 illumination_correction.image.array[:, :] = illum_corr_value
758 illumination_correction.mask.array[:, :] = 0
759 illumination_correction.variance.array[:, :] = 0.0
761 result = calibrate.run(
762 exposures=self.exposure,
763 id_generator=self.id_generator,
764 background_flat=background_flat,
765 illumination_correction=illumination_correction,
766 )
768 # We divide the image by the illumination correction, but the reference
769 # sources stay the same, so the applied photocalib will increase by
770 # the illum_corr_value.
771 # Tolerance is the same as the direct test with no illumination
772 # correction.
773 self.assertFloatsAlmostEqual(
774 result.applied_photo_calib.getCalibrationMean(),
775 self.photo_calib * illum_corr_value,
776 rtol=1e-2,
777 )
779 self.assertEqual(len(result.background), 4)
780 self.assertFloatsAlmostEqual(
781 np.median(result.background.getImage().array),
782 result.applied_photo_calib.getCalibrationMean() * self.background_level,
783 rtol=1e-3,
784 )
786 # Check metadata.
787 key = "LSST CALIB ILLUMCORR APPLIED"
788 self.assertIn(key, result.exposure.metadata)
789 self.assertEqual(result.exposure.metadata[key], True)
791 def test_run_with_diffraction_spike_mask(self):
792 """Test that the diffraction spike mask subtask runs.
793 """
794 config = self.config
795 config.doMaskDiffractionSpikes = True
796 config.diffractionSpikeMask.magnitudeThreshold = 19
797 # Define a fake SATURATED mask plane for use by the diffraction spike
798 # task, so that it does not affect the rest of calibrate
799 config.diffractionSpikeMask.saturatedMaskPlane = "FAKESATURATED"
800 calibrate = CalibrateImageTask(config=config)
801 calibrate.astrometry.setRefObjLoader(self.ref_loader)
802 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
803 calibrate.diffractionSpikeMask.setRefObjLoader(self.ref_loader)
805 exposure = self.exposure.clone()
807 exposure.info.setVisitInfo(makeTestVisitInfo())
808 exposure.mask.addMaskPlane(config.diffractionSpikeMask.saturatedMaskPlane)
810 # Set the saturated mask plane in half of the image
811 saturatedMaskBit = exposure.mask.getPlaneBitMask(config.diffractionSpikeMask.saturatedMaskPlane)
812 bbox = exposure.getBBox()
813 bbox.grow(-lsst.geom.Extent2I(0, bbox.height//4))
814 exposure[bbox].mask.array |= saturatedMaskBit
816 result = calibrate.run(exposures=exposure)
818 self._check_run(calibrate, result)
820 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": ""})
821 def test_fail_on_sattle_misconfiguration(self):
822 """Test for failure if sattle is requested without appropriate
823 configurations.
824 """
825 self.config.run_sattle = True
826 with self.assertRaises(pexConfig.FieldValidationError):
827 CalibrateImageTask(config=self.config)
829 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
830 def test_continue_on_sattle_failure(self):
831 """Processing should continue when sattle returns status codes other
832 than 200.
833 """
834 response = MockResponse({}, 500, "internal sattle error")
836 self.config.run_sattle = True
837 calibrate = CalibrateImageTask(config=self.config)
838 calibrate.astrometry.setRefObjLoader(self.ref_loader)
839 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
840 with mock.patch('requests.put', return_value=response) as mock_put:
841 calibrate.run(exposures=self.exposure)
842 mock_put.assert_called_once()
844 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"})
845 def test_sattle(self):
846 """Test for successful completion when sattle call returns
847 successfully.
848 """
849 response = MockResponse({}, 200, "success")
851 self.config.run_sattle = True
852 calibrate = CalibrateImageTask(config=self.config)
853 calibrate.astrometry.setRefObjLoader(self.ref_loader)
854 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
855 with mock.patch('requests.put', return_value=response) as mock_put:
856 calibrate.run(exposures=self.exposure)
857 mock_put.assert_called_once()
860class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase):
861 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler,
862 but do not need real images.
863 """
864 def setUp(self):
865 instrument = "testCam"
866 exposure0 = 101
867 exposure1 = 102
868 visit = 100101
869 detector = 42
870 physical_filter = "r"
872 # Create a and populate a test butler for runQuantum tests.
873 self.repo_path = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
874 self.repo = butlerTests.makeTestRepo(self.repo_path.name)
875 self.enterContext(self.repo)
877 # A complete instrument record is necessary for the id generator.
878 instrumentRecord = self.repo.dimensions["instrument"].RecordClass(
879 name=instrument, visit_max=1e6, exposure_max=1e6, detector_max=128,
880 class_name="lsst.obs.base.instrument_tests.DummyCam",
881 )
882 self.repo.registry.syncDimensionData("instrument", instrumentRecord)
884 # dataIds for fake data
885 butlerTests.addDataIdValue(self.repo, "detector", detector)
886 butlerTests.addDataIdValue(self.repo, "exposure", exposure0)
887 butlerTests.addDataIdValue(self.repo, "exposure", exposure1)
888 butlerTests.addDataIdValue(self.repo, "visit", visit)
889 butlerTests.addDataIdValue(self.repo, "physical_filter", physical_filter)
891 # inputs
892 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"},
893 "ExposureF")
894 butlerTests.addDatasetType(self.repo, "gaia_dr3_20230707", {"htm7"}, "SimpleCatalog")
895 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog")
896 butlerTests.addDatasetType(self.repo, "flat", {"instrument", "detector", "physical_filter"},
897 "Exposure")
898 butlerTests.addDatasetType(self.repo,
899 "illuminationCorrection",
900 {"instrument", "detector", "physical_filter"},
901 "Exposure")
903 # outputs
904 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"},
905 "ExposureF")
906 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector",
907 {"instrument", "visit", "detector"},
908 "SourceCatalog")
909 butlerTests.addDatasetType(self.repo, "initial_stars_detector",
910 {"instrument", "visit", "detector"},
911 "ArrowAstropy")
912 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector",
913 {"instrument", "visit", "detector"},
914 "PhotoCalib")
915 butlerTests.addDatasetType(self.repo, "background_to_photometric_ratio",
916 {"instrument", "visit", "detector"},
917 "Image")
918 # optional outputs
919 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"},
920 "Background")
921 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints_detector",
922 {"instrument", "visit", "detector"},
923 "SourceCatalog")
924 butlerTests.addDatasetType(self.repo, "initial_psf_stars_detector",
925 {"instrument", "visit", "detector"},
926 "ArrowAstropy")
927 butlerTests.addDatasetType(self.repo,
928 "initial_astrometry_match_detector",
929 {"instrument", "visit", "detector"},
930 "Catalog")
931 butlerTests.addDatasetType(self.repo,
932 "initial_photometry_match_detector",
933 {"instrument", "visit", "detector"},
934 "Catalog")
935 butlerTests.addDatasetType(self.repo,
936 "preliminary_visit_mask",
937 {"instrument", "visit", "detector"},
938 "Mask")
940 # dataIds
941 self.exposure0_id = self.repo.registry.expandDataId(
942 {"instrument": instrument, "exposure": exposure0, "detector": detector})
943 self.exposure1_id = self.repo.registry.expandDataId(
944 {"instrument": instrument, "exposure": exposure1, "detector": detector})
945 self.visit_id = self.repo.registry.expandDataId(
946 {"instrument": instrument, "visit": visit, "detector": detector})
947 self.htm_id = self.repo.registry.expandDataId({"htm7": 42})
948 self.flat_id = self.repo.registry.expandDataId(
949 {"instrument": instrument, "detector": detector, "physical_filter": physical_filter})
951 # put empty data
952 self.butler = butlerTests.makeTestCollection(self.repo)
953 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure0_id)
954 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure1_id)
955 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr3_20230707", self.htm_id)
956 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id)
957 self.butler.put(afwImage.ExposureF(), "flat", self.flat_id)
958 self.butler.put(afwImage.ExposureF(), "illuminationCorrection", self.flat_id)
960 def tearDown(self):
961 self.repo_path.cleanup()
963 def test_runQuantum(self):
964 task = CalibrateImageTask()
965 lsst.pipe.base.testUtils.assertValidInitOutput(task)
967 quantum = lsst.pipe.base.testUtils.makeQuantum(
968 task, self.butler, self.visit_id,
969 {"exposures": [self.exposure0_id],
970 "astrometry_ref_cat": [self.htm_id],
971 "photometry_ref_cat": [self.htm_id],
972 "background_flat": self.flat_id,
973 "illumination_correction": self.flat_id,
974 # outputs
975 "exposure": self.visit_id,
976 "stars": self.visit_id,
977 "stars_footprints": self.visit_id,
978 "background": self.visit_id,
979 "psf_stars": self.visit_id,
980 "psf_stars_footprints": self.visit_id,
981 "applied_photo_calib": self.visit_id,
982 "initial_pvi_background": self.visit_id,
983 "astrometry_matches": self.visit_id,
984 "photometry_matches": self.visit_id,
985 "mask": self.visit_id,
986 })
987 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
989 # Ensure the reference loaders have been configured.
990 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
991 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
992 # Check that the proper kwargs are passed to run().
993 self.assertEqual(
994 mock_run.call_args.kwargs.keys(),
995 {"exposures",
996 "result",
997 "id_generator",
998 "background_flat",
999 "illumination_correction",
1000 "camera_model",
1001 "exposure_record",
1002 "exposure_region",
1003 },
1004 )
1006 def test_runQuantum_illumination_correction(self):
1007 config = CalibrateImageTask.ConfigClass()
1008 config.do_illumination_correction = True
1009 config.psf_subtract_background.doApplyFlatBackgroundRatio = True
1010 config.psf_detection.doApplyFlatBackgroundRatio = True
1011 config.star_background.doApplyFlatBackgroundRatio = True
1012 config.star_detection.doApplyFlatBackgroundRatio = True
1013 task = CalibrateImageTask(config=config)
1014 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1016 quantum = lsst.pipe.base.testUtils.makeQuantum(
1017 task, self.butler, self.visit_id,
1018 {"exposures": [self.exposure0_id],
1019 "astrometry_ref_cat": [self.htm_id],
1020 "photometry_ref_cat": [self.htm_id],
1021 "background_flat": self.flat_id,
1022 "illumination_correction": self.flat_id,
1023 # outputs
1024 "exposure": self.visit_id,
1025 "stars": self.visit_id,
1026 "stars_footprints": self.visit_id,
1027 "background": self.visit_id,
1028 "background_to_photometric_ratio": self.visit_id,
1029 "psf_stars": self.visit_id,
1030 "psf_stars_footprints": self.visit_id,
1031 "applied_photo_calib": self.visit_id,
1032 "initial_pvi_background": self.visit_id,
1033 "astrometry_matches": self.visit_id,
1034 "photometry_matches": self.visit_id,
1035 "mask": self.visit_id,
1036 })
1037 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1039 # Ensure the reference loaders have been configured.
1040 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
1041 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
1042 # Check that the proper kwargs are passed to run().
1043 self.assertEqual(
1044 mock_run.call_args.kwargs.keys(),
1045 {"exposures",
1046 "result",
1047 "id_generator",
1048 "background_flat",
1049 "illumination_correction",
1050 "camera_model",
1051 "exposure_record",
1052 "exposure_region",
1053 },
1054 )
1056 def test_runQuantum_2_snaps(self):
1057 task = CalibrateImageTask()
1058 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1060 quantum = lsst.pipe.base.testUtils.makeQuantum(
1061 task, self.butler, self.visit_id,
1062 {"exposures": [self.exposure0_id, self.exposure1_id],
1063 "astrometry_ref_cat": [self.htm_id],
1064 "photometry_ref_cat": [self.htm_id],
1065 "background_flat": self.flat_id,
1066 "illumination_correction": self.flat_id,
1067 # outputs
1068 "exposure": self.visit_id,
1069 "stars": self.visit_id,
1070 "stars_footprints": self.visit_id,
1071 "background": self.visit_id,
1072 "psf_stars": self.visit_id,
1073 "psf_stars_footprints": self.visit_id,
1074 "applied_photo_calib": self.visit_id,
1075 "initial_pvi_background": self.visit_id,
1076 "astrometry_matches": self.visit_id,
1077 "photometry_matches": self.visit_id,
1078 "mask": self.visit_id,
1079 })
1080 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1082 # Ensure the reference loaders have been configured.
1083 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
1084 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
1085 # Check that the proper kwargs are passed to run().
1086 self.assertEqual(
1087 mock_run.call_args.kwargs.keys(),
1088 {"exposures",
1089 "result",
1090 "id_generator",
1091 "background_flat",
1092 "illumination_correction",
1093 "camera_model",
1094 "exposure_record",
1095 "exposure_region",
1096 },
1097 )
1099 def test_runQuantum_no_optional_outputs(self):
1100 # All the possible connections: we modify this to test each one by
1101 # popping off the removed connection, then re-setting it.
1102 connections = {"exposures": [self.exposure0_id, self.exposure1_id],
1103 "astrometry_ref_cat": [self.htm_id],
1104 "photometry_ref_cat": [self.htm_id],
1105 "background_flat": self.flat_id,
1106 "illumination_correction": self.flat_id,
1107 # outputs
1108 "exposure": self.visit_id,
1109 "stars": self.visit_id,
1110 "stars_footprints": self.visit_id,
1111 "background": self.visit_id,
1112 "psf_stars": self.visit_id,
1113 "psf_stars_footprints": self.visit_id,
1114 "applied_photo_calib": self.visit_id,
1115 "initial_pvi_background": self.visit_id,
1116 "astrometry_matches": self.visit_id,
1117 "photometry_matches": self.visit_id,
1118 "mask": self.visit_id,
1119 }
1121 # Check that we can turn off one output at a time.
1122 for optional in ["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches",
1123 "mask"]:
1124 config = CalibrateImageTask.ConfigClass()
1125 config.optional_outputs.remove(optional)
1126 task = CalibrateImageTask(config=config)
1127 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1128 # Save the removed one for the next test.
1129 temp = connections.pop(optional)
1130 # This will fail with "Error in connection ..." if we don't pop
1131 # the optional item from the connections list just above.
1132 quantum = lsst.pipe.base.testUtils.makeQuantum(task, self.butler, self.visit_id, connections)
1133 # This confirms that the outputs did skip the removed one.
1134 self.assertNotIn(optional, quantum.outputs)
1135 # Restore the one we removed for the next test.
1136 connections[optional] = temp
1138 def test_runQuantum_no_calibrate_pixels(self):
1139 """Test that the the task runs when calibrating pixels is disabled,
1140 and that this results in the ``applied_photo_calib`` output being
1141 removed.
1142 """
1143 config = CalibrateImageTask.ConfigClass()
1144 config.do_calibrate_pixels = False
1145 task = CalibrateImageTask(config=config)
1146 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1148 quantum = lsst.pipe.base.testUtils.makeQuantum(
1149 task, self.butler, self.visit_id,
1150 {"exposures": [self.exposure0_id],
1151 "astrometry_ref_cat": [self.htm_id],
1152 "photometry_ref_cat": [self.htm_id],
1153 "background_flat": self.flat_id,
1154 "illumination_correction": self.flat_id,
1155 # outputs
1156 "exposure": self.visit_id,
1157 "stars": self.visit_id,
1158 "stars_footprints": self.visit_id,
1159 "background": self.visit_id,
1160 "psf_stars": self.visit_id,
1161 "psf_stars_footprints": self.visit_id,
1162 "initial_pvi_background": self.visit_id,
1163 "astrometry_matches": self.visit_id,
1164 "photometry_matches": self.visit_id,
1165 "mask": self.visit_id,
1166 })
1167 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1169 # Ensure the reference loaders have been configured.
1170 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707")
1171 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
1172 # Check that the proper kwargs are passed to run().
1173 self.assertEqual(
1174 mock_run.call_args.kwargs.keys(),
1175 {"exposures",
1176 "result",
1177 "id_generator",
1178 "background_flat",
1179 "illumination_correction",
1180 "camera_model",
1181 "exposure_record",
1182 "exposure_region",
1183 },
1184 )
1186 def test_lintConnections(self):
1187 """Check that the connections are self-consistent.
1188 """
1189 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass
1190 lsst.pipe.base.testUtils.lintConnections(Connections)
1192 def test_connections_future_image_type(self):
1193 """Test that the future image type changes the exposure storage class
1194 and removes the separate background output, which is attached to the
1195 image instead.
1196 """
1197 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass
1198 config = CalibrateImageTask.ConfigClass()
1200 connections = Connections(config=config)
1201 self.assertEqual(connections.exposure.storageClass, "ExposureF")
1202 self.assertIn("background", connections.outputs)
1203 self.assertIn("applied_photo_calib", connections.outputs)
1205 config.output_image_type = "future"
1206 connections = Connections(config=config)
1207 self.assertEqual(connections.exposure.storageClass, "VisitImage")
1208 # The dataset name is unchanged; only its storage class differs.
1209 self.assertEqual(connections.exposure.name, "initial_pvi")
1210 # Both of these are carried on the image itself in this mode.
1211 self.assertNotIn("background", connections.outputs)
1212 self.assertNotIn("applied_photo_calib", connections.outputs)
1214 def test_runQuantum_partial_outputs_struct(self):
1215 """Test that the result struct runQuantum passes to run() has
1216 ``applied_photo_calib`` seeded as `None`.
1217 """
1218 task = CalibrateImageTask()
1220 quantum = lsst.pipe.base.testUtils.makeQuantum(
1221 task, self.butler, self.visit_id,
1222 {"exposures": [self.exposure0_id],
1223 "astrometry_ref_cat": [self.htm_id],
1224 "photometry_ref_cat": [self.htm_id],
1225 "background_flat": self.flat_id,
1226 "illumination_correction": self.flat_id,
1227 # outputs
1228 "exposure": self.visit_id,
1229 "stars": self.visit_id,
1230 "stars_footprints": self.visit_id,
1231 "background": self.visit_id,
1232 "psf_stars": self.visit_id,
1233 "psf_stars_footprints": self.visit_id,
1234 "applied_photo_calib": self.visit_id,
1235 "initial_pvi_background": self.visit_id,
1236 "astrometry_matches": self.visit_id,
1237 "photometry_matches": self.visit_id,
1238 "mask": self.visit_id,
1239 })
1240 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
1242 result = mock_run.call_args.kwargs["result"]
1243 self.assertIsNone(result.applied_photo_calib)
1245 def test_runQuantum_exception(self):
1246 """Test exception handling in runQuantum.
1247 """
1248 task = CalibrateImageTask()
1249 lsst.pipe.base.testUtils.assertValidInitOutput(task)
1251 quantum = lsst.pipe.base.testUtils.makeQuantum(
1252 task, self.butler, self.visit_id,
1253 {"exposures": [self.exposure0_id],
1254 "astrometry_ref_cat": [self.htm_id],
1255 "photometry_ref_cat": [self.htm_id],
1256 "background_flat": self.flat_id,
1257 "illuminationCorrection": self.flat_id,
1258 # outputs
1259 "exposure": self.visit_id,
1260 "stars": self.visit_id,
1261 "stars_footprints": self.visit_id,
1262 "background": self.visit_id,
1263 "psf_stars": self.visit_id,
1264 "psf_stars_footprints": self.visit_id,
1265 "applied_photo_calib": self.visit_id,
1266 "initial_pvi_background": self.visit_id,
1267 "astrometry_matches": self.visit_id,
1268 "photometry_matches": self.visit_id,
1269 "mask": self.visit_id,
1270 })
1272 # A generic exception should raise directly.
1273 msg = "mocked run exception"
1274 with (
1275 mock.patch.object(task, "run", side_effect=ValueError(msg)),
1276 self.assertRaisesRegex(ValueError, "mocked run exception")
1277 ):
1278 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False)
1280 # An AlgorithmError should write annotated partial outputs.
1281 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101)
1283 def mock_run(
1284 exposures,
1285 result=None,
1286 id_generator=None,
1287 background_flat=None,
1288 illumination_correction=None,
1289 camera_model=None,
1290 exposure_record=None,
1291 exposure_region=None,
1292 ):
1293 """Mock success through compute_psf, but failure after.
1294 """
1295 result.exposure = afwImage.ExposureF(10, 10)
1296 result.psf_stars_footprints = afwTable.SourceCatalog()
1297 result.psf_stars = afwTable.SourceCatalog().asAstropy()
1298 result.background = afwMath.BackgroundList()
1299 raise error
1301 with (
1302 mock.patch.object(task, "run", side_effect=mock_run),
1303 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError),
1304 ):
1305 with self.assertLogs("lsst.calibrateImage", level="DEBUG") as cm:
1306 lsst.pipe.base.testUtils.runTestQuantum(task,
1307 self.butler,
1308 quantum,
1309 mockRun=False)
1311 logged = "\n".join(cm.output)
1312 self.assertIn("Task failed with only partial outputs", logged)
1313 self.assertIn("MeasureApCorrError", logged)
1315 # NOTE: This is an integration test of afw Exposure & SourceCatalog
1316 # metadata with the error annotation system in pipe_base.
1317 # Check that we did get the annotated partial outputs...
1318 pvi = self.butler.get("initial_pvi", self.visit_id)
1319 self.assertIn("Unable to measure aperture correction", pvi.metadata["failure.message"])
1320 self.assertIn("MeasureApCorrError", pvi.metadata["failure.type"])
1321 self.assertEqual(pvi.metadata["failure.metadata.ndof"], 101)
1322 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id)
1323 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"])
1324 self.assertIn("MeasureApCorrError", stars.metadata["failure.type"])
1325 self.assertEqual(stars.metadata["failure.metadata.ndof"], 101)
1326 # ... but not the un-produced outputs.
1327 with self.assertRaises(FileNotFoundError):
1328 self.butler.get("initial_stars_footprints_detector", self.visit_id)
1330 def test_runQuantum_partial_outputs_future(self):
1331 """Test that a failed quantum in 'future' mode skips the image and
1332 still writes the rest of the annotated partial outputs.
1333 """
1334 config = CalibrateImageTask.ConfigClass()
1335 config.output_image_type = "future"
1336 task = CalibrateImageTask(config=config)
1338 quantum = lsst.pipe.base.testUtils.makeQuantum(
1339 task, self.butler, self.visit_id,
1340 {"exposures": [self.exposure0_id],
1341 "astrometry_ref_cat": [self.htm_id],
1342 "photometry_ref_cat": [self.htm_id],
1343 "background_flat": self.flat_id,
1344 "illumination_correction": self.flat_id,
1345 # outputs
1346 "exposure": self.visit_id,
1347 "stars": self.visit_id,
1348 "stars_footprints": self.visit_id,
1349 "psf_stars": self.visit_id,
1350 "psf_stars_footprints": self.visit_id,
1351 "astrometry_matches": self.visit_id,
1352 "photometry_matches": self.visit_id,
1353 "mask": self.visit_id,
1354 })
1356 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101)
1358 def mock_run(
1359 exposures,
1360 result=None,
1361 id_generator=None,
1362 background_flat=None,
1363 illumination_correction=None,
1364 camera_model=None,
1365 exposure_record=None,
1366 exposure_region=None,
1367 ):
1368 """Mock a failure of the aperture correction fit, after `run`
1369 has set the exposure and the PSF star catalog.
1370 """
1371 exposure = afwImage.ExposureF(10, 10)
1372 # `run` sets these from the post-ISR image before any fit can
1373 # fail.
1374 exposure.setDetector(list(CameraWrapper().camera)[0])
1375 exposure.setFilter(afwImage.FilterLabel(physical="test-r", band="r"))
1376 exposure.info.setVisitInfo(makeTestVisitInfo(id=self.visit_id["visit"]))
1377 result.exposure = exposure
1378 result.psf_stars_footprints = afwTable.SourceCatalog()
1379 result.psf_stars = afwTable.SourceCatalog().asAstropy()
1380 result.background = afwMath.BackgroundList()
1381 raise error
1383 with (
1384 mock.patch.object(task, "run", side_effect=mock_run),
1385 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError),
1386 self.assertLogs("lsst.calibrateImage", level="WARNING") as cm,
1387 ):
1388 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False)
1390 self.assertIn("Cannot write a partial-outputs image in 'future' mode", "\n".join(cm.output))
1391 # The image was skipped ...
1392 with self.assertRaises(FileNotFoundError):
1393 self.butler.get("initial_pvi", self.visit_id)
1394 # ... but the catalog that `run` did produce was written, with the
1395 # failure annotation on it.
1396 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id)
1397 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"])
1400class MockResponse:
1401 """Provide a mock for requests.put calls"""
1402 def __init__(self, json_data, status_code, text):
1403 self.json_data = json_data
1404 self.status_code = status_code
1405 self.text = text
1407 def json(self):
1408 return self.json_data
1410 def raise_for_status(self):
1411 if self.status_code != 200:
1412 raise requests.exceptions.HTTPError
1415def setup_module(module):
1416 lsst.utils.tests.init()
1419class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
1420 pass
1423if __name__ == "__main__": 1423 ↛ 1424line 1423 didn't jump to line 1424 because the condition on line 1423 was never true
1424 lsst.utils.tests.init()
1425 unittest.main()