Coverage for tests/test_calibrateImage.py: 19%
184 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-07 10:59 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-07 10:59 +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
23import tempfile
25import astropy.units as u
26from astropy.coordinates import SkyCoord
27import numpy as np
29import lsst.afw.image as afwImage
30import lsst.afw.table as afwTable
31import lsst.daf.base
32import lsst.daf.butler.tests as butlerTests
33import lsst.geom
34import lsst.meas.algorithms
35from lsst.meas.algorithms import testUtils
36import lsst.meas.extensions.psfex
37import lsst.meas.base.tests
38import lsst.pipe.base.testUtils
39from lsst.pipe.tasks.calibrateImage import CalibrateImageTask
40import lsst.utils.tests
43class CalibrateImageTaskTests(lsst.utils.tests.TestCase):
45 def setUp(self):
46 # Different x/y dimensions so they're easy to distinguish in a plot,
47 # and non-zero minimum, to help catch xy0 errors.
48 bbox = lsst.geom.Box2I(lsst.geom.Point2I(5, 4), lsst.geom.Point2I(205, 184))
49 self.sky_center = lsst.geom.SpherePoint(245.0, -45.0, lsst.geom.degrees)
50 self.photo_calib = 12.3
51 dataset = lsst.meas.base.tests.TestDataset(bbox, crval=self.sky_center, calibration=self.photo_calib)
52 # sqrt of area of a normalized 2d gaussian
53 psf_scale = np.sqrt(4*np.pi*(dataset.psfShape.getDeterminantRadius())**2)
54 noise = 10.0 # stddev of noise per pixel
55 # Sources ordered from faintest to brightest.
56 self.fluxes = np.array((6*noise*psf_scale,
57 12*noise*psf_scale,
58 45*noise*psf_scale,
59 150*noise*psf_scale,
60 400*noise*psf_scale,
61 1000*noise*psf_scale))
62 self.centroids = np.array(((162, 22),
63 (25, 70),
64 (100, 160),
65 (50, 120),
66 (92, 35),
67 (175, 154)), dtype=np.float32)
68 for flux, centroid in zip(self.fluxes, self.centroids):
69 dataset.addSource(instFlux=flux, centroid=lsst.geom.Point2D(centroid[0], centroid[1]))
71 # Bright extended source in the center of the image: should not appear
72 # in any of the output catalogs.
73 center = lsst.geom.Point2D(100, 100)
74 shape = lsst.afw.geom.Quadrupole(8, 9, 3)
75 dataset.addSource(instFlux=500*noise*psf_scale, centroid=center, shape=shape)
77 schema = dataset.makeMinimalSchema()
78 schema.addField("truth_flux", type=np.float64, doc="true flux", units="nJy")
79 schema.addField("truth_fluxErr", type=np.float64, doc="true fluxErr", units="nJy")
81 self.truth_exposure, self.truth_cat = dataset.realize(noise=noise, schema=dataset.makeMinimalSchema())
82 lsst.afw.table.updateSourceCoords(self.truth_exposure.wcs, self.truth_cat)
83 # To make it look like a version=1 (nJy fluxes) refcat
84 self.truth_cat = self.truth_exposure.photoCalib.calibrateCatalog(self.truth_cat)
85 self.ref_loader = testUtils.MockReferenceObjectLoaderFromMemory([self.truth_cat])
86 metadata = lsst.daf.base.PropertyList()
87 metadata.set("REFCAT_FORMAT_VERSION", 1)
88 self.truth_cat.setMetadata(metadata)
90 # TODO: a cosmic ray (need to figure out how to insert a fake-CR)
91 # self.truth_exposure.image.array[10, 10] = 100000
92 # self.truth_exposure.variance.array[10, 10] = 100000/noise
94 # Copy the truth exposure, because CalibrateImage modifies the input.
95 # Post-ISR ccds only contain: initial WCS, VisitInfo, filter
96 self.exposure = afwImage.ExposureF(self.truth_exposure.maskedImage)
97 self.exposure.setWcs(self.truth_exposure.wcs)
98 self.exposure.info.setVisitInfo(self.truth_exposure.visitInfo)
99 # "truth" filter, to match the "truth" refcat.
100 self.exposure.setFilter(lsst.afw.image.FilterLabel(physical='truth', band="truth"))
102 # Test-specific configuration:
103 self.config = CalibrateImageTask.ConfigClass()
104 # We don't have many sources, so have to fit simpler models.
105 self.config.psf_detection.background.approxOrderX = 1
106 self.config.star_detection.background.approxOrderX = 1
107 # Use PCA psf fitter, as psfex fails if there are only 4 stars.
108 self.config.psf_measure_psf.psfDeterminer = 'pca'
109 # We don't have many test points, so can't match on complicated shapes.
110 self.config.astrometry.matcher.numPointsForShape = 3
112 def test_run(self):
113 """Test that run() returns reasonable values to be butler put.
114 """
115 calibrate = CalibrateImageTask(config=self.config)
116 calibrate.astrometry.setRefObjLoader(self.ref_loader)
117 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
118 result = calibrate.run(exposure=self.exposure)
120 # Background should have 4 elements: 3 from compute_psf and one from
121 # re-estimation during source detection.
122 self.assertEqual(len(result.background), 4)
124 # Check that the summary statistics are reasonable.
125 summary = self.exposure.info.getSummaryStats()
126 self.assertFloatsAlmostEqual(self.exposure.info.getSummaryStats().psfSigma, 2.0, rtol=1e-2)
127 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7)
128 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7)
130 # Returned photoCalib should be the applied value, not the ==1 one on the exposure.
131 self.assertFloatsAlmostEqual(result.applied_photo_calib.getCalibrationMean(),
132 self.photo_calib, rtol=2e-3)
133 # Should have flux/magnitudes in the catalog.
134 self.assertIn("slot_PsfFlux_flux", result.stars.schema)
135 self.assertIn("slot_PsfFlux_mag", result.stars.schema)
137 # Check that all necessary fields are in the output.
138 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result)
140 def test_compute_psf(self):
141 """Test that our brightest sources are found by _compute_psf(),
142 that a PSF is assigned to the expopsure.
143 """
144 calibrate = CalibrateImageTask(config=self.config)
145 sources, background, candidates = calibrate._compute_psf(self.exposure)
147 # Background should have 3 elements: initial subtraction, and two from
148 # re-estimation during the two detection passes.
149 self.assertEqual(len(background), 3)
151 # Only the point-sources with S/N > 50 should be in this output.
152 self.assertEqual(sources["calib_psf_used"].sum(), 3)
153 # Sort in order of brightness, to easily compare with expected positions.
154 sources.sort(sources.getPsfFluxSlot().getMeasKey())
155 for record, flux, center in zip(sources[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
156 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
157 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
158 # PsfFlux should match the values inserted.
159 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
161 # TODO: While debugging DM-32701, we're using PCA instead of psfex.
162 # Check that we got a useable PSF.
163 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf)
164 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf)
165 # TestDataset sources have PSF radius=2 pixels.
166 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius()
167 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2)
169 # To look at images for debugging (`setup display_ds9` and run ds9):
170 # import lsst.afw.display
171 # display = lsst.afw.display.getDisplay()
172 # display.mtv(self.exposure)
174 def test_measure_aperture_correction(self):
175 """Test that _measure_aperture_correction() assigns an ApCorrMap to the
176 exposure.
177 """
178 calibrate = CalibrateImageTask(config=self.config)
179 sources, background, candidates = calibrate._compute_psf(self.exposure)
181 # First check that the exposure doesn't have an ApCorrMap.
182 self.assertIsNone(self.exposure.apCorrMap)
183 calibrate._measure_aperture_correction(self.exposure, sources)
184 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap)
186 def test_find_stars(self):
187 """Test that _find_stars() correctly identifies the S/N>10 stars
188 in the image and returns them in the output catalog.
189 """
190 calibrate = CalibrateImageTask(config=self.config)
191 sources, background, candidates = calibrate._compute_psf(self.exposure)
192 calibrate._measure_aperture_correction(self.exposure, sources)
194 stars = calibrate._find_stars(self.exposure, background)
196 # Background should have 4 elements: 3 from compute_psf and one from
197 # re-estimation during source detection.
198 self.assertEqual(len(background), 4)
200 # Only psf-like sources with S/N>10 should be in the output catalog.
201 self.assertEqual(len(stars), 4)
202 self.assertTrue(sources.isContiguous())
203 # Sort in order of brightness, to easily compare with expected positions.
204 sources.sort(sources.getPsfFluxSlot().getMeasKey())
205 for record, flux, center in zip(sources[::-1], self.fluxes, self.centroids[self.fluxes > 50]):
206 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01)
207 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01)
208 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01)
210 def test_astrometry(self):
211 """Test that the fitted WCS gives good catalog coordinates.
212 """
213 calibrate = CalibrateImageTask(config=self.config)
214 calibrate.astrometry.setRefObjLoader(self.ref_loader)
215 sources, background, candidates = calibrate._compute_psf(self.exposure)
216 calibrate._measure_aperture_correction(self.exposure, sources)
217 stars = calibrate._find_stars(self.exposure, background)
219 calibrate._fit_astrometry(self.exposure, stars)
221 # Check that we got reliable matches with the truth coordinates.
222 fitted = SkyCoord(stars['coord_ra'], stars['coord_dec'], unit="radian")
223 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
224 idx, d2d, _ = fitted.match_to_catalog_sky(truth)
225 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 30.0)
227 def test_photometry(self):
228 """Test that the fitted photoCalib matches the one we generated,
229 and that the exposure is calibrated.
230 """
231 calibrate = CalibrateImageTask(config=self.config)
232 calibrate.astrometry.setRefObjLoader(self.ref_loader)
233 calibrate.photometry.match.setRefObjLoader(self.ref_loader)
234 sources, background, candidates = calibrate._compute_psf(self.exposure)
235 calibrate._measure_aperture_correction(self.exposure, sources)
236 stars = calibrate._find_stars(self.exposure, background)
237 calibrate._fit_astrometry(self.exposure, stars)
239 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars)
241 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0,
242 # so we can't check that the photoCal error has been set.
243 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=2e-3)
244 # The exposure should be calibrated by the applied photoCalib.
245 self.assertFloatsAlmostEqual(self.exposure.image.array/self.truth_exposure.image.array,
246 self.photo_calib, rtol=2e-3)
247 # PhotoCalib on the exposure must be identically 1.
248 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0)
250 # Check that we got reliable magnitudes and fluxes vs. truth.
251 fitted = SkyCoord(stars['coord_ra'], stars['coord_dec'], unit="radian")
252 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian")
253 idx, _, _ = fitted.match_to_catalog_sky(truth)
254 # Because the input variance image does not include contributions from
255 # the sources, we can't use fluxErr as a bound on the measurement
256 # quality here.
257 self.assertFloatsAlmostEqual(stars['slot_PsfFlux_flux'], self.truth_cat['truth_flux'][idx], rtol=0.1)
258 self.assertFloatsAlmostEqual(stars['slot_PsfFlux_mag'], self.truth_cat['truth_mag'][idx], rtol=0.01)
261class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase):
262 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler,
263 but do not need real images.
264 """
265 def setUp(self):
266 instrument = "testCam"
267 exposure = 101
268 visit = 100101
269 detector = 42
271 # Create a and populate a test butler for runQuantum tests.
272 self.repo_path = tempfile.TemporaryDirectory()
273 self.repo = butlerTests.makeTestRepo(self.repo_path.name)
275 # dataIds for fake data
276 butlerTests.addDataIdValue(self.repo, "instrument", instrument)
277 butlerTests.addDataIdValue(self.repo, "exposure", exposure)
278 butlerTests.addDataIdValue(self.repo, "visit", visit)
279 butlerTests.addDataIdValue(self.repo, "detector", detector)
281 # inputs
282 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"},
283 "ExposureF")
284 butlerTests.addDatasetType(self.repo, "gaia_dr2_20200414", {"htm7"}, "SimpleCatalog")
285 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog")
287 # outputs
288 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"},
289 "ExposureF")
290 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector",
291 {"instrument", "visit", "detector"},
292 "SourceCatalog")
293 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector",
294 {"instrument", "visit", "detector"},
295 "PhotoCalib")
296 # optional outputs
297 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"},
298 "Background")
299 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints",
300 {"instrument", "visit", "detector"},
301 "SourceCatalog")
302 butlerTests.addDatasetType(self.repo,
303 "initial_astrometry_match_detector",
304 {"instrument", "visit", "detector"},
305 "Catalog")
306 butlerTests.addDatasetType(self.repo,
307 "initial_photometry_match_detector",
308 {"instrument", "visit", "detector"},
309 "Catalog")
311 # dataIds
312 self.exposure_id = self.repo.registry.expandDataId(
313 {"instrument": instrument, "exposure": exposure, "detector": detector})
314 self.visit_id = self.repo.registry.expandDataId(
315 {"instrument": instrument, "visit": visit, "detector": detector})
316 self.htm_id = self.repo.registry.expandDataId({"htm7": 42})
318 # put empty data
319 self.butler = butlerTests.makeTestCollection(self.repo)
320 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure_id)
321 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr2_20200414", self.htm_id)
322 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id)
324 def tearDown(self):
325 del self.repo_path # this removes the temporary directory
327 def test_runQuantum(self):
328 task = CalibrateImageTask()
329 lsst.pipe.base.testUtils.assertValidInitOutput(task)
331 quantum = lsst.pipe.base.testUtils.makeQuantum(
332 task, self.butler, self.visit_id,
333 {"exposure": self.exposure_id,
334 "astrometry_ref_cat": [self.htm_id],
335 "photometry_ref_cat": [self.htm_id],
336 # outputs
337 "output_exposure": self.visit_id,
338 "stars": self.visit_id,
339 "background": self.visit_id,
340 "psf_stars": self.visit_id,
341 "applied_photo_calib": self.visit_id,
342 "initial_pvi_background": self.visit_id,
343 "astrometry_matches": self.visit_id,
344 "photometry_matches": self.visit_id,
345 })
346 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
348 # Ensure the reference loaders have been configured.
349 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr2_20200414")
350 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
351 # Check that the proper kwargs are passed to run().
352 self.assertEqual(mock_run.call_args.kwargs.keys(), {"exposure"})
354 def test_runQuantum_no_optional_outputs(self):
355 config = CalibrateImageTask.ConfigClass()
356 config.optional_outputs = None
357 task = CalibrateImageTask(config=config)
358 lsst.pipe.base.testUtils.assertValidInitOutput(task)
360 quantum = lsst.pipe.base.testUtils.makeQuantum(
361 task, self.butler, self.visit_id,
362 {"exposure": self.exposure_id,
363 "astrometry_ref_cat": [self.htm_id],
364 "photometry_ref_cat": [self.htm_id],
365 # outputs
366 "output_exposure": self.visit_id,
367 "stars": self.visit_id,
368 "applied_photo_calib": self.visit_id,
369 "background": self.visit_id,
370 })
371 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum)
373 # Ensure the reference loaders have been configured.
374 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr2_20200414")
375 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110")
376 # Check that the proper kwargs are passed to run().
377 self.assertEqual(mock_run.call_args.kwargs.keys(), {"exposure"})
379 def test_lintConnections(self):
380 """Check that the connections are self-consistent.
381 """
382 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass
383 lsst.pipe.base.testUtils.lintConnections(Connections)
386def setup_module(module):
387 lsst.utils.tests.init()
390class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
391 pass
394if __name__ == "__main__": 394 ↛ 395line 394 didn't jump to line 395, because the condition on line 394 was never true
395 lsst.utils.tests.init()
396 unittest.main()