Coverage for tests/test_calibrateImage.py: 19%

184 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-15 10:38 +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/>. 

21 

22import unittest 

23import tempfile 

24 

25import astropy.units as u 

26from astropy.coordinates import SkyCoord 

27import numpy as np 

28 

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 

41 

42 

43class CalibrateImageTaskTests(lsst.utils.tests.TestCase): 

44 

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])) 

70 

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) 

76 

77 schema = dataset.makeMinimalSchema() 

78 afwTable.CoordKey.addErrorFields(schema) 

79 self.truth_exposure, self.truth_cat = dataset.realize(noise=noise, schema=schema) 

80 lsst.afw.table.updateSourceCoords(self.truth_exposure.wcs, self.truth_cat) 

81 # To make it look like a version=1 (nJy fluxes) refcat 

82 self.truth_cat = self.truth_exposure.photoCalib.calibrateCatalog(self.truth_cat) 

83 self.ref_loader = testUtils.MockReferenceObjectLoaderFromMemory([self.truth_cat]) 

84 metadata = lsst.daf.base.PropertyList() 

85 metadata.set("REFCAT_FORMAT_VERSION", 1) 

86 self.truth_cat.setMetadata(metadata) 

87 

88 # TODO: a cosmic ray (need to figure out how to insert a fake-CR) 

89 # self.truth_exposure.image.array[10, 10] = 100000 

90 # self.truth_exposure.variance.array[10, 10] = 100000/noise 

91 

92 # Copy the truth exposure, because CalibrateImage modifies the input. 

93 # Post-ISR ccds only contain: initial WCS, VisitInfo, filter 

94 self.exposure = afwImage.ExposureF(self.truth_exposure.maskedImage) 

95 self.exposure.setWcs(self.truth_exposure.wcs) 

96 self.exposure.info.setVisitInfo(self.truth_exposure.visitInfo) 

97 # "truth" filter, to match the "truth" refcat. 

98 self.exposure.setFilter(lsst.afw.image.FilterLabel(physical='truth', band="truth")) 

99 

100 # Test-specific configuration: 

101 self.config = CalibrateImageTask.ConfigClass() 

102 # We don't have many sources, so have to fit simpler models. 

103 self.config.psf_detection.background.approxOrderX = 1 

104 self.config.star_detection.background.approxOrderX = 1 

105 # Use PCA psf fitter, as psfex fails if there are only 4 stars. 

106 self.config.psf_measure_psf.psfDeterminer = 'pca' 

107 # We don't have many test points, so can't match on complicated shapes. 

108 self.config.astrometry.matcher.numPointsForShape = 3 

109 

110 # Something about this test dataset prefers the older fluxRatio here. 

111 self.config.star_catalog_calculation.plugins['base_ClassificationExtendedness'].fluxRatio = 0.925 

112 

113 def test_run(self): 

114 """Test that run() returns reasonable values to be butler put. 

115 """ 

116 calibrate = CalibrateImageTask(config=self.config) 

117 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

118 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

119 result = calibrate.run(exposure=self.exposure) 

120 

121 # Background should have 4 elements: 3 from compute_psf and one from 

122 # re-estimation during source detection. 

123 self.assertEqual(len(result.background), 4) 

124 

125 # Check that the summary statistics are reasonable. 

126 summary = self.exposure.info.getSummaryStats() 

127 self.assertFloatsAlmostEqual(self.exposure.info.getSummaryStats().psfSigma, 2.0, rtol=1e-2) 

128 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7) 

129 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7) 

130 

131 # Returned photoCalib should be the applied value, not the ==1 one on the exposure. 

132 self.assertFloatsAlmostEqual(result.applied_photo_calib.getCalibrationMean(), 

133 self.photo_calib, rtol=2e-3) 

134 # Should have flux/magnitudes in the catalog. 

135 self.assertIn("slot_PsfFlux_flux", result.stars.schema) 

136 self.assertIn("slot_PsfFlux_mag", result.stars.schema) 

137 

138 # Check that all necessary fields are in the output. 

139 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result) 

140 

141 def test_compute_psf(self): 

142 """Test that our brightest sources are found by _compute_psf(), 

143 that a PSF is assigned to the expopsure. 

144 """ 

145 calibrate = CalibrateImageTask(config=self.config) 

146 sources, background, candidates = calibrate._compute_psf(self.exposure) 

147 

148 # Background should have 3 elements: initial subtraction, and two from 

149 # re-estimation during the two detection passes. 

150 self.assertEqual(len(background), 3) 

151 

152 # Only the point-sources with S/N > 50 should be in this output. 

153 self.assertEqual(sources["calib_psf_used"].sum(), 3) 

154 # Sort in order of brightness, to easily compare with expected positions. 

155 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

156 for record, flux, center in zip(sources[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 

157 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01) 

158 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01) 

159 # PsfFlux should match the values inserted. 

160 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01) 

161 

162 # TODO: While debugging DM-32701, we're using PCA instead of psfex. 

163 # Check that we got a useable PSF. 

164 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf) 

165 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf) 

166 # TestDataset sources have PSF radius=2 pixels. 

167 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius() 

168 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2) 

169 

170 # To look at images for debugging (`setup display_ds9` and run ds9): 

171 # import lsst.afw.display 

172 # display = lsst.afw.display.getDisplay() 

173 # display.mtv(self.exposure) 

174 

175 def test_measure_aperture_correction(self): 

176 """Test that _measure_aperture_correction() assigns an ApCorrMap to the 

177 exposure. 

178 """ 

179 calibrate = CalibrateImageTask(config=self.config) 

180 sources, background, candidates = calibrate._compute_psf(self.exposure) 

181 

182 # First check that the exposure doesn't have an ApCorrMap. 

183 self.assertIsNone(self.exposure.apCorrMap) 

184 calibrate._measure_aperture_correction(self.exposure, sources) 

185 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap) 

186 

187 def test_find_stars(self): 

188 """Test that _find_stars() correctly identifies the S/N>10 stars 

189 in the image and returns them in the output catalog. 

190 """ 

191 calibrate = CalibrateImageTask(config=self.config) 

192 sources, background, candidates = calibrate._compute_psf(self.exposure) 

193 calibrate._measure_aperture_correction(self.exposure, sources) 

194 

195 stars = calibrate._find_stars(self.exposure, background) 

196 

197 # Background should have 4 elements: 3 from compute_psf and one from 

198 # re-estimation during source detection. 

199 self.assertEqual(len(background), 4) 

200 

201 # Only psf-like sources with S/N>10 should be in the output catalog. 

202 self.assertEqual(len(stars), 4) 

203 self.assertTrue(sources.isContiguous()) 

204 # Sort in order of brightness, to easily compare with expected positions. 

205 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

206 for record, flux, center in zip(sources[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 

207 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01) 

208 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01) 

209 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01) 

210 

211 def test_astrometry(self): 

212 """Test that the fitted WCS gives good catalog coordinates. 

213 """ 

214 calibrate = CalibrateImageTask(config=self.config) 

215 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

216 sources, background, candidates = calibrate._compute_psf(self.exposure) 

217 calibrate._measure_aperture_correction(self.exposure, sources) 

218 stars = calibrate._find_stars(self.exposure, background) 

219 

220 calibrate._fit_astrometry(self.exposure, stars) 

221 

222 # Check that we got reliable matches with the truth coordinates. 

223 fitted = SkyCoord(stars['coord_ra'], stars['coord_dec'], unit="radian") 

224 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian") 

225 idx, d2d, _ = fitted.match_to_catalog_sky(truth) 

226 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 30.0) 

227 

228 def test_photometry(self): 

229 """Test that the fitted photoCalib matches the one we generated, 

230 and that the exposure is calibrated. 

231 """ 

232 calibrate = CalibrateImageTask(config=self.config) 

233 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

234 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

235 sources, background, candidates = calibrate._compute_psf(self.exposure) 

236 calibrate._measure_aperture_correction(self.exposure, sources) 

237 stars = calibrate._find_stars(self.exposure, background) 

238 calibrate._fit_astrometry(self.exposure, stars) 

239 

240 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars) 

241 

242 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0, 

243 # so we can't check that the photoCal error has been set. 

244 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=2e-3) 

245 # The exposure should be calibrated by the applied photoCalib. 

246 self.assertFloatsAlmostEqual(self.exposure.image.array/self.truth_exposure.image.array, 

247 self.photo_calib, rtol=2e-3) 

248 # PhotoCalib on the exposure must be identically 1. 

249 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0) 

250 

251 # Check that we got reliable magnitudes and fluxes vs. truth. 

252 fitted = SkyCoord(stars['coord_ra'], stars['coord_dec'], unit="radian") 

253 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian") 

254 idx, _, _ = fitted.match_to_catalog_sky(truth) 

255 # Because the input variance image does not include contributions from 

256 # the sources, we can't use fluxErr as a bound on the measurement 

257 # quality here. 

258 self.assertFloatsAlmostEqual(stars['slot_PsfFlux_flux'], self.truth_cat['truth_flux'][idx], rtol=0.1) 

259 self.assertFloatsAlmostEqual(stars['slot_PsfFlux_mag'], self.truth_cat['truth_mag'][idx], rtol=0.01) 

260 

261 

262class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase): 

263 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler, 

264 but do not need real images. 

265 """ 

266 def setUp(self): 

267 instrument = "testCam" 

268 exposure = 101 

269 visit = 100101 

270 detector = 42 

271 

272 # Create a and populate a test butler for runQuantum tests. 

273 self.repo_path = tempfile.TemporaryDirectory() 

274 self.repo = butlerTests.makeTestRepo(self.repo_path.name) 

275 

276 # dataIds for fake data 

277 butlerTests.addDataIdValue(self.repo, "instrument", instrument) 

278 butlerTests.addDataIdValue(self.repo, "exposure", exposure) 

279 butlerTests.addDataIdValue(self.repo, "visit", visit) 

280 butlerTests.addDataIdValue(self.repo, "detector", detector) 

281 

282 # inputs 

283 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"}, 

284 "ExposureF") 

285 butlerTests.addDatasetType(self.repo, "gaia_dr3_20230707", {"htm7"}, "SimpleCatalog") 

286 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog") 

287 

288 # outputs 

289 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"}, 

290 "ExposureF") 

291 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector", 

292 {"instrument", "visit", "detector"}, 

293 "SourceCatalog") 

294 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector", 

295 {"instrument", "visit", "detector"}, 

296 "PhotoCalib") 

297 # optional outputs 

298 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"}, 

299 "Background") 

300 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints", 

301 {"instrument", "visit", "detector"}, 

302 "SourceCatalog") 

303 butlerTests.addDatasetType(self.repo, 

304 "initial_astrometry_match_detector", 

305 {"instrument", "visit", "detector"}, 

306 "Catalog") 

307 butlerTests.addDatasetType(self.repo, 

308 "initial_photometry_match_detector", 

309 {"instrument", "visit", "detector"}, 

310 "Catalog") 

311 

312 # dataIds 

313 self.exposure_id = self.repo.registry.expandDataId( 

314 {"instrument": instrument, "exposure": exposure, "detector": detector}) 

315 self.visit_id = self.repo.registry.expandDataId( 

316 {"instrument": instrument, "visit": visit, "detector": detector}) 

317 self.htm_id = self.repo.registry.expandDataId({"htm7": 42}) 

318 

319 # put empty data 

320 self.butler = butlerTests.makeTestCollection(self.repo) 

321 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure_id) 

322 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr3_20230707", self.htm_id) 

323 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id) 

324 

325 def tearDown(self): 

326 del self.repo_path # this removes the temporary directory 

327 

328 def test_runQuantum(self): 

329 task = CalibrateImageTask() 

330 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

331 

332 quantum = lsst.pipe.base.testUtils.makeQuantum( 

333 task, self.butler, self.visit_id, 

334 {"exposure": self.exposure_id, 

335 "astrometry_ref_cat": [self.htm_id], 

336 "photometry_ref_cat": [self.htm_id], 

337 # outputs 

338 "output_exposure": self.visit_id, 

339 "stars": self.visit_id, 

340 "background": self.visit_id, 

341 "psf_stars": self.visit_id, 

342 "applied_photo_calib": self.visit_id, 

343 "initial_pvi_background": self.visit_id, 

344 "astrometry_matches": self.visit_id, 

345 "photometry_matches": self.visit_id, 

346 }) 

347 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

348 

349 # Ensure the reference loaders have been configured. 

350 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707") 

351 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110") 

352 # Check that the proper kwargs are passed to run(). 

353 self.assertEqual(mock_run.call_args.kwargs.keys(), {"exposure"}) 

354 

355 def test_runQuantum_no_optional_outputs(self): 

356 config = CalibrateImageTask.ConfigClass() 

357 config.optional_outputs = None 

358 task = CalibrateImageTask(config=config) 

359 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

360 

361 quantum = lsst.pipe.base.testUtils.makeQuantum( 

362 task, self.butler, self.visit_id, 

363 {"exposure": self.exposure_id, 

364 "astrometry_ref_cat": [self.htm_id], 

365 "photometry_ref_cat": [self.htm_id], 

366 # outputs 

367 "output_exposure": self.visit_id, 

368 "stars": self.visit_id, 

369 "applied_photo_calib": self.visit_id, 

370 "background": self.visit_id, 

371 }) 

372 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

373 

374 # Ensure the reference loaders have been configured. 

375 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707") 

376 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110") 

377 # Check that the proper kwargs are passed to run(). 

378 self.assertEqual(mock_run.call_args.kwargs.keys(), {"exposure"}) 

379 

380 def test_lintConnections(self): 

381 """Check that the connections are self-consistent. 

382 """ 

383 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

384 lsst.pipe.base.testUtils.lintConnections(Connections) 

385 

386 

387def setup_module(module): 

388 lsst.utils.tests.init() 

389 

390 

391class MemoryTestCase(lsst.utils.tests.MemoryTestCase): 

392 pass 

393 

394 

395if __name__ == "__main__": 395 ↛ 396line 395 didn't jump to line 396, because the condition on line 395 was never true

396 lsst.utils.tests.init() 

397 unittest.main()