Coverage for tests/test_calibrateImage.py: 19%

182 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-04 11:12 +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 self.truth_exposure, self.truth_cat = dataset.realize(noise=noise, schema=schema) 

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

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

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

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

83 metadata.set("REFCAT_FORMAT_VERSION", 1) 

84 self.truth_cat.setMetadata(metadata) 

85 

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

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

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

89 

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

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

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

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

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

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

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

97 

98 # Test-specific configuration: 

99 self.config = CalibrateImageTask.ConfigClass() 

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

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

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

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

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

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

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

107 

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

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

110 

111 def test_run(self): 

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

113 """ 

114 calibrate = CalibrateImageTask(config=self.config) 

115 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

118 

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

120 # re-estimation during source detection. 

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

122 

123 # Check that the summary statistics are reasonable. 

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

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

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

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

128 

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

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

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

132 # Should have flux/magnitudes in the catalog. 

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

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

135 

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

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

138 

139 def test_compute_psf(self): 

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

141 that a PSF is assigned to the expopsure. 

142 """ 

143 calibrate = CalibrateImageTask(config=self.config) 

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

145 

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

147 # re-estimation during the two detection passes. 

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

149 

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

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

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

153 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

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

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

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

157 # PsfFlux should match the values inserted. 

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

159 

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

161 # Check that we got a useable PSF. 

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

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

164 # TestDataset sources have PSF radius=2 pixels. 

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

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

167 

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

169 # import lsst.afw.display 

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

171 # display.mtv(self.exposure) 

172 

173 def test_measure_aperture_correction(self): 

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

175 exposure. 

176 """ 

177 calibrate = CalibrateImageTask(config=self.config) 

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

179 

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

181 self.assertIsNone(self.exposure.apCorrMap) 

182 calibrate._measure_aperture_correction(self.exposure, sources) 

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

184 

185 def test_find_stars(self): 

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

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

188 """ 

189 calibrate = CalibrateImageTask(config=self.config) 

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

191 calibrate._measure_aperture_correction(self.exposure, sources) 

192 

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

194 

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

196 # re-estimation during source detection. 

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

198 

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

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

201 self.assertTrue(sources.isContiguous()) 

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

203 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

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

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

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

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

208 

209 def test_astrometry(self): 

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

211 """ 

212 calibrate = CalibrateImageTask(config=self.config) 

213 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

215 calibrate._measure_aperture_correction(self.exposure, sources) 

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

217 

218 calibrate._fit_astrometry(self.exposure, stars) 

219 

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

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

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

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

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

225 

226 def test_photometry(self): 

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

228 and that the exposure is calibrated. 

229 """ 

230 calibrate = CalibrateImageTask(config=self.config) 

231 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

234 calibrate._measure_aperture_correction(self.exposure, sources) 

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

236 calibrate._fit_astrometry(self.exposure, stars) 

237 

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

239 

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

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

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

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

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

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

246 # PhotoCalib on the exposure must be identically 1. 

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

248 

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

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

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

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

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

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

255 # quality here. 

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

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

258 

259 

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

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

262 but do not need real images. 

263 """ 

264 def setUp(self): 

265 instrument = "testCam" 

266 exposure = 101 

267 visit = 100101 

268 detector = 42 

269 

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

271 self.repo_path = tempfile.TemporaryDirectory() 

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

273 

274 # dataIds for fake data 

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

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

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

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

279 

280 # inputs 

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

282 "ExposureF") 

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

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

285 

286 # outputs 

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

288 "ExposureF") 

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

290 {"instrument", "visit", "detector"}, 

291 "SourceCatalog") 

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

293 {"instrument", "visit", "detector"}, 

294 "PhotoCalib") 

295 # optional outputs 

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

297 "Background") 

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

299 {"instrument", "visit", "detector"}, 

300 "SourceCatalog") 

301 butlerTests.addDatasetType(self.repo, 

302 "initial_astrometry_match_detector", 

303 {"instrument", "visit", "detector"}, 

304 "Catalog") 

305 butlerTests.addDatasetType(self.repo, 

306 "initial_photometry_match_detector", 

307 {"instrument", "visit", "detector"}, 

308 "Catalog") 

309 

310 # dataIds 

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

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

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

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

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

316 

317 # put empty data 

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

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

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

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

322 

323 def tearDown(self): 

324 del self.repo_path # this removes the temporary directory 

325 

326 def test_runQuantum(self): 

327 task = CalibrateImageTask() 

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

329 

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

331 task, self.butler, self.visit_id, 

332 {"exposure": self.exposure_id, 

333 "astrometry_ref_cat": [self.htm_id], 

334 "photometry_ref_cat": [self.htm_id], 

335 # outputs 

336 "output_exposure": self.visit_id, 

337 "stars": self.visit_id, 

338 "background": self.visit_id, 

339 "psf_stars": self.visit_id, 

340 "applied_photo_calib": self.visit_id, 

341 "initial_pvi_background": self.visit_id, 

342 "astrometry_matches": self.visit_id, 

343 "photometry_matches": self.visit_id, 

344 }) 

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

346 

347 # Ensure the reference loaders have been configured. 

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

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

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

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

352 

353 def test_runQuantum_no_optional_outputs(self): 

354 config = CalibrateImageTask.ConfigClass() 

355 config.optional_outputs = None 

356 task = CalibrateImageTask(config=config) 

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

358 

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

360 task, self.butler, self.visit_id, 

361 {"exposure": self.exposure_id, 

362 "astrometry_ref_cat": [self.htm_id], 

363 "photometry_ref_cat": [self.htm_id], 

364 # outputs 

365 "output_exposure": self.visit_id, 

366 "stars": self.visit_id, 

367 "applied_photo_calib": self.visit_id, 

368 "background": self.visit_id, 

369 }) 

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

371 

372 # Ensure the reference loaders have been configured. 

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

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

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

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

377 

378 def test_lintConnections(self): 

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

380 """ 

381 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

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

383 

384 

385def setup_module(module): 

386 lsst.utils.tests.init() 

387 

388 

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

390 pass 

391 

392 

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

394 lsst.utils.tests.init() 

395 unittest.main()