Coverage for tests/test_calibrateImage.py: 19%

184 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-06 03:31 +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 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") 

80 

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) 

89 

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 

93 

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

101 

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 

111 

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) 

119 

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) 

123 

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) 

129 

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) 

136 

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

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

139 

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) 

146 

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) 

150 

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) 

160 

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) 

168 

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) 

173 

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) 

180 

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) 

185 

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) 

193 

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

195 

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) 

199 

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) 

209 

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) 

218 

219 calibrate._fit_astrometry(self.exposure, stars) 

220 

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) 

226 

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) 

238 

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

240 

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) 

249 

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) 

259 

260 

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 

270 

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) 

274 

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) 

280 

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

286 

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

310 

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

317 

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) 

323 

324 def tearDown(self): 

325 del self.repo_path # this removes the temporary directory 

326 

327 def test_runQuantum(self): 

328 task = CalibrateImageTask() 

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

330 

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) 

347 

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"}) 

353 

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) 

359 

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) 

372 

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"}) 

378 

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) 

384 

385 

386def setup_module(module): 

387 lsst.utils.tests.init() 

388 

389 

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

391 pass 

392 

393 

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