Coverage for tests/test_calibrateImage.py: 19%

183 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-29 10:48 +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 # ApFlux has more noise than PsfFlux (the latter unrealistically small 

108 # in this test data), so we need to do magnitude rejection at higher 

109 # sigma, otherwise we can lose otherwise good sources. 

110 # TODO DM-39203: Once we are using Compensated Gaussian Fluxes, we 

111 # will use those fluxes here, and hopefully can remove this. 

112 self.config.astrometry.magnitudeOutlierRejectionNSigma = 9.0 

113 

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

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

116 

117 def test_run(self): 

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

119 """ 

120 calibrate = CalibrateImageTask(config=self.config) 

121 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

124 

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

126 # re-estimation during source detection. 

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

128 

129 # Check that the summary statistics are reasonable. 

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

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

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

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

134 

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

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

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

138 # Should have flux/magnitudes in the catalog. 

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

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

141 

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

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

144 

145 def test_compute_psf(self): 

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

147 that a PSF is assigned to the expopsure. 

148 """ 

149 calibrate = CalibrateImageTask(config=self.config) 

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

151 

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

153 # re-estimation during the two detection passes. 

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

155 

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

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

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

159 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

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

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

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

163 # PsfFlux should match the values inserted. 

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

165 

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

167 # Check that we got a useable PSF. 

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

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

170 # TestDataset sources have PSF radius=2 pixels. 

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

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

173 

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

175 # import lsst.afw.display 

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

177 # display.mtv(self.exposure) 

178 

179 def test_measure_aperture_correction(self): 

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

181 exposure. 

182 """ 

183 calibrate = CalibrateImageTask(config=self.config) 

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

185 

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

187 self.assertIsNone(self.exposure.apCorrMap) 

188 calibrate._measure_aperture_correction(self.exposure, sources) 

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

190 

191 def test_find_stars(self): 

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

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

194 """ 

195 calibrate = CalibrateImageTask(config=self.config) 

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

197 calibrate._measure_aperture_correction(self.exposure, sources) 

198 

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

200 

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

202 # re-estimation during source detection. 

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

204 

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

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

207 self.assertTrue(sources.isContiguous()) 

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

209 sources.sort(sources.getPsfFluxSlot().getMeasKey()) 

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

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

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

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

214 

215 def test_astrometry(self): 

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

217 """ 

218 calibrate = CalibrateImageTask(config=self.config) 

219 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

221 calibrate._measure_aperture_correction(self.exposure, sources) 

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

223 

224 calibrate._fit_astrometry(self.exposure, stars) 

225 

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

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

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

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

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

231 

232 def test_photometry(self): 

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

234 and that the exposure is calibrated. 

235 """ 

236 calibrate = CalibrateImageTask(config=self.config) 

237 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

240 calibrate._measure_aperture_correction(self.exposure, sources) 

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

242 calibrate._fit_astrometry(self.exposure, stars) 

243 

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

245 

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

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

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

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

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

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

252 # PhotoCalib on the exposure must be identically 1. 

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

254 

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

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

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

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

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

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

261 # quality here. 

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

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

264 

265 

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

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

268 but do not need real images. 

269 """ 

270 def setUp(self): 

271 instrument = "testCam" 

272 exposure = 101 

273 visit = 100101 

274 detector = 42 

275 

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

277 self.repo_path = tempfile.TemporaryDirectory() 

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

279 

280 # dataIds for fake data 

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

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

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

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

285 

286 # inputs 

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

288 "ExposureF") 

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

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

291 

292 # outputs 

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

294 "ExposureF") 

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

296 {"instrument", "visit", "detector"}, 

297 "SourceCatalog") 

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

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

300 "PhotoCalib") 

301 # optional outputs 

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

303 "Background") 

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

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

306 "SourceCatalog") 

307 butlerTests.addDatasetType(self.repo, 

308 "initial_astrometry_match_detector", 

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

310 "Catalog") 

311 butlerTests.addDatasetType(self.repo, 

312 "initial_photometry_match_detector", 

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

314 "Catalog") 

315 

316 # dataIds 

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

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

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

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

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

322 

323 # put empty data 

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

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

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

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

328 

329 def tearDown(self): 

330 del self.repo_path # this removes the temporary directory 

331 

332 def test_runQuantum(self): 

333 task = CalibrateImageTask() 

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

335 

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

337 task, self.butler, self.visit_id, 

338 {"exposure": self.exposure_id, 

339 "astrometry_ref_cat": [self.htm_id], 

340 "photometry_ref_cat": [self.htm_id], 

341 # outputs 

342 "output_exposure": self.visit_id, 

343 "stars": self.visit_id, 

344 "background": self.visit_id, 

345 "psf_stars": self.visit_id, 

346 "applied_photo_calib": self.visit_id, 

347 "initial_pvi_background": self.visit_id, 

348 "astrometry_matches": self.visit_id, 

349 "photometry_matches": self.visit_id, 

350 }) 

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

352 

353 # Ensure the reference loaders have been configured. 

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

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

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

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

358 

359 def test_runQuantum_no_optional_outputs(self): 

360 config = CalibrateImageTask.ConfigClass() 

361 config.optional_outputs = None 

362 task = CalibrateImageTask(config=config) 

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

364 

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

366 task, self.butler, self.visit_id, 

367 {"exposure": self.exposure_id, 

368 "astrometry_ref_cat": [self.htm_id], 

369 "photometry_ref_cat": [self.htm_id], 

370 # outputs 

371 "output_exposure": self.visit_id, 

372 "stars": self.visit_id, 

373 "applied_photo_calib": self.visit_id, 

374 "background": self.visit_id, 

375 }) 

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

377 

378 # Ensure the reference loaders have been configured. 

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

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

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

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

383 

384 def test_lintConnections(self): 

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

386 """ 

387 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

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

389 

390 

391def setup_module(module): 

392 lsst.utils.tests.init() 

393 

394 

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

396 pass 

397 

398 

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

400 lsst.utils.tests.init() 

401 unittest.main()