Coverage for tests/test_calibrateImage.py: 19%

183 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-17 10:06 +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 def test_run(self): 

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

112 """ 

113 calibrate = CalibrateImageTask(config=self.config) 

114 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

117 

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

119 # re-estimation during source detection. 

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

121 

122 # Check that the summary statistics are reasonable. 

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

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

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

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

127 

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

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

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

131 # Should have flux/magnitudes in the catalog. 

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

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

134 

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

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

137 

138 def test_compute_psf(self): 

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

140 that a PSF is assigned to the expopsure. 

141 """ 

142 calibrate = CalibrateImageTask(config=self.config) 

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

144 

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

146 # re-estimation during the two detection passes. 

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

148 

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

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

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

152 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

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

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

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

156 # PsfFlux should match the values inserted. 

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

158 

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

160 # Check that we got a useable PSF. 

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

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

163 # TestDataset sources have PSF radius=2 pixels. 

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

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

166 

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

168 # import lsst.afw.display 

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

170 # display.mtv(self.exposure) 

171 

172 def test_measure_aperture_correction(self): 

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

174 exposure. 

175 """ 

176 calibrate = CalibrateImageTask(config=self.config) 

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

178 

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

180 self.assertIsNone(self.exposure.apCorrMap) 

181 calibrate._measure_aperture_correction(self.exposure, sources) 

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

183 

184 def test_find_stars(self): 

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

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

187 """ 

188 calibrate = CalibrateImageTask(config=self.config) 

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

190 calibrate._measure_aperture_correction(self.exposure, sources) 

191 

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

193 

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

195 # re-estimation during source detection. 

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

197 

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

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

200 self.assertTrue(sources.isContiguous()) 

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

202 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

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

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

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

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

207 

208 def test_astrometry(self): 

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

210 """ 

211 calibrate = CalibrateImageTask(config=self.config) 

212 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

214 calibrate._measure_aperture_correction(self.exposure, sources) 

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

216 

217 calibrate._fit_astrometry(self.exposure, stars) 

218 

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

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

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

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

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

224 

225 def test_photometry(self): 

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

227 and that the exposure is calibrated. 

228 """ 

229 calibrate = CalibrateImageTask(config=self.config) 

230 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

233 calibrate._measure_aperture_correction(self.exposure, sources) 

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

235 calibrate._fit_astrometry(self.exposure, stars) 

236 

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

238 

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

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

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

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

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

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

245 # PhotoCalib on the exposure must be identically 1. 

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

247 

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

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

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

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

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

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

254 # quality here. 

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

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

257 

258 

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

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

261 but do not need real images. 

262 """ 

263 def setUp(self): 

264 instrument = "testCam" 

265 exposure = 101 

266 visit = 100101 

267 detector = 42 

268 

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

270 self.repo_path = tempfile.TemporaryDirectory() 

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

272 

273 # dataIds for fake data 

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

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

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

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

278 

279 # inputs 

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

281 "ExposureF") 

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

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

284 

285 # outputs 

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

287 "ExposureF") 

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

289 {"instrument", "visit", "detector"}, 

290 "SourceCatalog") 

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

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

293 "PhotoCalib") 

294 # optional outputs 

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

296 "Background") 

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

298 {"instrument", "visit", "detector"}, 

299 "SourceCatalog") 

300 butlerTests.addDatasetType(self.repo, 

301 "initial_astrometry_match_detector", 

302 {"instrument", "visit", "detector"}, 

303 "Catalog") 

304 butlerTests.addDatasetType(self.repo, 

305 "initial_photometry_match_detector", 

306 {"instrument", "visit", "detector"}, 

307 "Catalog") 

308 

309 # dataIds 

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

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

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

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

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

315 

316 # put empty data 

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

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

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

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

321 

322 def tearDown(self): 

323 del self.repo_path # this removes the temporary directory 

324 

325 def test_runQuantum(self): 

326 task = CalibrateImageTask() 

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

328 

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

330 task, self.butler, self.visit_id, 

331 {"exposure": self.exposure_id, 

332 "astrometry_ref_cat": [self.htm_id], 

333 "photometry_ref_cat": [self.htm_id], 

334 # outputs 

335 "output_exposure": self.visit_id, 

336 "stars": self.visit_id, 

337 "background": self.visit_id, 

338 "psf_stars": self.visit_id, 

339 "applied_photo_calib": self.visit_id, 

340 "initial_pvi_background": self.visit_id, 

341 "astrometry_matches": self.visit_id, 

342 "photometry_matches": self.visit_id, 

343 }) 

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

345 

346 # Ensure the reference loaders have been configured. 

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

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

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

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

351 

352 def test_runQuantum_no_optional_outputs(self): 

353 config = CalibrateImageTask.ConfigClass() 

354 config.optional_outputs = None 

355 task = CalibrateImageTask(config=config) 

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

357 

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

359 task, self.butler, self.visit_id, 

360 {"exposure": self.exposure_id, 

361 "astrometry_ref_cat": [self.htm_id], 

362 "photometry_ref_cat": [self.htm_id], 

363 # outputs 

364 "output_exposure": self.visit_id, 

365 "stars": self.visit_id, 

366 "applied_photo_calib": self.visit_id, 

367 "background": self.visit_id, 

368 }) 

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

370 

371 # Ensure the reference loaders have been configured. 

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

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

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

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

376 

377 def test_lintConnections(self): 

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

379 """ 

380 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

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

382 

383 

384def setup_module(module): 

385 lsst.utils.tests.init() 

386 

387 

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

389 pass 

390 

391 

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

393 lsst.utils.tests.init() 

394 unittest.main()