Coverage for tests/test_astrometryTask.py: 18%

178 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-18 10:36 +0000

1# 

2# LSST Data Management System 

3# Copyright 2008-2017 LSST Corporation. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23import os.path 

24import math 

25import unittest 

26import glob 

27 

28import astropy.units as u 

29import scipy.stats 

30import numpy as np 

31 

32import lsst.utils.tests 

33import lsst.geom 

34import lsst.afw.geom as afwGeom 

35import lsst.afw.table as afwTable 

36import lsst.afw.image as afwImage 

37import lsst.meas.base as measBase 

38from lsst.meas.algorithms.testUtils import MockReferenceObjectLoaderFromFiles 

39from lsst.meas.astrom import AstrometryTask, exceptions 

40import lsst.pipe.base as pipeBase 

41 

42 

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

44 

45 def setUp(self): 

46 refCatDir = os.path.join(os.path.dirname(__file__), "data", "sdssrefcat") 

47 

48 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(3001, 3001)) 

49 crpix = lsst.geom.Box2D(self.bbox).getCenter() 

50 self.tanWcs = afwGeom.makeSkyWcs(crpix=crpix, 

51 crval=lsst.geom.SpherePoint(215.5, 53.0, lsst.geom.degrees), 

52 cdMatrix=afwGeom.makeCdMatrix(scale=5.1e-5*lsst.geom.degrees)) 

53 self.exposure = afwImage.ExposureF(self.bbox) 

54 self.exposure.setWcs(self.tanWcs) 

55 self.exposure.info.setVisitInfo(afwImage.VisitInfo(date=lsst.daf.base.DateTime(60000))) 

56 self.exposure.setFilter(afwImage.FilterLabel(band="r", physical="rTest")) 

57 filenames = sorted(glob.glob(os.path.join(refCatDir, 'ref_cats', 'cal_ref_cat', '??????.fits'))) 

58 self.refObjLoader = MockReferenceObjectLoaderFromFiles(filenames, htmLevel=8) 

59 

60 def testTrivial(self): 

61 """Test fit with no distortion 

62 """ 

63 self.doTest(afwGeom.makeIdentityTransform()) 

64 

65 def testRadial(self): 

66 """Test fit with radial distortion 

67 

68 The offset comes from the fact that the CCD is not centered 

69 """ 

70 self.doTest(afwGeom.makeRadialTransform([0, 1.01, 1e-7])) 

71 

72 def doTest(self, pixelsToTanPixels): 

73 """Test using pixelsToTanPixels to distort the source positions. 

74 """ 

75 schema = self._makeSourceCatalogSchema() 

76 config = AstrometryTask.ConfigClass() 

77 config.wcsFitter.order = 3 

78 config.wcsFitter.numRejIter = 0 

79 task = AstrometryTask(config=config, refObjLoader=self.refObjLoader, schema=schema) 

80 distortedWcs = afwGeom.makeModifiedWcs(pixelTransform=pixelsToTanPixels, wcs=self.tanWcs, 

81 modifyActualPixels=False) 

82 # Make the source catalog at the distorted positions, but keep the 

83 # initial TAN WCS on the exposure, to check that the fitted WCS 

84 # is close to the distorted one and different from the input. 

85 sourceCat = self.makeSourceCat(distortedWcs, schema) 

86 # This test is from before rough magnitude rejection was implemented. 

87 config.doMagnitudeOutlierRejection = False 

88 results = task.run(sourceCat=sourceCat, exposure=self.exposure) 

89 

90 self.assertWcsAlmostEqualOverBBox(distortedWcs, self.exposure.wcs, self.bbox, 

91 maxDiffSky=0.002*lsst.geom.arcseconds, maxDiffPix=0.02) 

92 # Test that the sources used in the fit are flagged in the catalog. 

93 self.assertEqual(sum(sourceCat["calib_astrometry_used"]), len(results.matches)) 

94 

95 srcCoordKey = afwTable.CoordKey(sourceCat.schema["coord"]) 

96 refCoordKey = afwTable.CoordKey(results.refCat.schema["coord"]) 

97 refCentroidKey = afwTable.Point2DKey(results.refCat.schema["centroid"]) 

98 maxAngSep = 0*lsst.geom.radians 

99 maxPixSep = 0 

100 for refObj, src, d in results.matches: 

101 refCoord = refObj.get(refCoordKey) 

102 refPixPos = refObj.get(refCentroidKey) 

103 srcCoord = src.get(srcCoordKey) 

104 srcPixPos = src.getCentroid() 

105 

106 angSep = refCoord.separation(srcCoord) 

107 maxAngSep = max(maxAngSep, angSep) 

108 

109 pixSep = math.hypot(*(srcPixPos-refPixPos)) 

110 maxPixSep = max(maxPixSep, pixSep) 

111 print("max angular separation = %0.4f arcsec" % (maxAngSep.asArcseconds(),)) 

112 print("max pixel separation = %0.3f" % (maxPixSep,)) 

113 self.assertLess(maxAngSep.asArcseconds(), 0.0038) 

114 self.assertLess(maxPixSep, 0.021) 

115 

116 # try again, invoking the reference selector 

117 config.referenceSelector.doUnresolved = True 

118 config.referenceSelector.unresolved.name = 'resolved' 

119 solverRefSelect = AstrometryTask(config=config, refObjLoader=self.refObjLoader) 

120 self.exposure.setWcs(distortedWcs) 

121 resultsRefSelect = solverRefSelect.run( 

122 sourceCat=sourceCat, 

123 exposure=self.exposure, 

124 ) 

125 self.assertLess(len(resultsRefSelect.matches), len(results.matches)) 

126 

127 # try again, allowing magnitude outlier rejection. 

128 config.doMagnitudeOutlierRejection = True 

129 solverMagOutlierRejection = AstrometryTask(config=config, refObjLoader=self.refObjLoader) 

130 self.exposure.setWcs(distortedWcs) 

131 resultsMagOutlierRejection = solverMagOutlierRejection.run( 

132 sourceCat=sourceCat, 

133 exposure=self.exposure, 

134 ) 

135 self.assertLess(len(resultsMagOutlierRejection.matches), len(resultsRefSelect.matches)) 

136 config.doMagnitudeOutlierRejection = False 

137 

138 # try again, but without fitting the WCS, no reference selector 

139 config.referenceSelector.doUnresolved = False 

140 config.forceKnownWcs = True 

141 solverNoFit = AstrometryTask(config=config, refObjLoader=self.refObjLoader) 

142 self.exposure.setWcs(distortedWcs) 

143 resultsNoFit = solverNoFit.run( 

144 sourceCat=sourceCat, 

145 exposure=self.exposure, 

146 ) 

147 self.assertIsNone(resultsNoFit.scatterOnSky) 

148 

149 # fitting should result in matches that are at least as good 

150 # (strictly speaking fitting might result in a larger match list with 

151 # some outliers, but in practice this test passes) 

152 meanFitDist = np.mean([match.distance for match in results.matches]) 

153 meanNoFitDist = np.mean([match.distance for match in resultsNoFit.matches]) 

154 self.assertLessEqual(meanFitDist, meanNoFitDist) 

155 

156 # try once again, without fitting the WCS, with the reference selector 

157 # (this goes through a different code path) 

158 config.referenceSelector.doUnresolved = True 

159 solverNoFitRefSelect = AstrometryTask(config=config, refObjLoader=self.refObjLoader) 

160 resultsNoFitRefSelect = solverNoFitRefSelect.run( 

161 sourceCat=sourceCat, 

162 exposure=self.exposure, 

163 ) 

164 self.assertLess(len(resultsNoFitRefSelect.matches), len(resultsNoFit.matches)) 

165 

166 @staticmethod 

167 def _makeSourceCatalogSchema(): 

168 """Return a catalog schema with all necessary fields added. 

169 """ 

170 schema = afwTable.SourceTable.makeMinimalSchema() 

171 measBase.SingleFrameMeasurementTask(schema=schema) # expand the schema 

172 afwTable.CoordKey.addErrorFields(schema) 

173 schema.addField("deblend_nChild", type=np.int32, 

174 doc="Number of children this object has (defaults to 0)") 

175 schema.addField("detect_isPrimary", type=np.int32, 

176 doc="true if source has no children and is not a sky source") 

177 return schema 

178 

179 def makeSourceCat(self, wcs, schema, doScatterCentroids=False): 

180 """Make a source catalog by reading the position reference stars using 

181 the proviced WCS. 

182 

183 Optionally, via doScatterCentroids, add some scatter to the centroids 

184 assiged to the source catalog (otherwise they will be identical to 

185 those of the reference catalog). 

186 """ 

187 loadRes = self.refObjLoader.loadPixelBox(bbox=self.bbox, wcs=wcs, filterName="r") 

188 refCat = loadRes.refCat 

189 

190 sourceCat = afwTable.SourceCatalog(schema) 

191 

192 sourceCat.resize(len(refCat)) 

193 scatterFactor = 1.0 

194 if doScatterCentroids: 

195 np.random.seed(12345) 

196 scatterFactor = np.random.uniform(0.999, 1.001, len(sourceCat)) 

197 sourceCat["slot_Centroid_x"] = scatterFactor*refCat["centroid_x"] 

198 sourceCat["slot_Centroid_y"] = scatterFactor*refCat["centroid_y"] 

199 sourceCat["slot_PsfFlux_instFlux"] = refCat["r_flux"] 

200 sourceCat["slot_PsfFlux_instFluxErr"] = refCat["r_flux"]/100 

201 # All of these sources are primary. 

202 sourceCat['detect_isPrimary'] = 1 

203 

204 # Deliberately add some outliers to check that the magnitude 

205 # outlier rejection code is being run. 

206 sourceCat["slot_PsfFlux_instFlux"][0: 4] *= 1000.0 

207 

208 return sourceCat 

209 

210 def testBadAstrometry(self): 

211 """Test that an appropriately informative exception is raised for a 

212 bad quality fit. 

213 """ 

214 catalog = self.makeSourceCat(self.tanWcs, self._makeSourceCatalogSchema()) 

215 # Fake match list with 10" match distance for every source to force 

216 # a bad quality fit result. 

217 matches = [afwTable.ReferenceMatch(r, r, (10*u.arcsecond).to_value(u.radian)) for r in catalog] 

218 result = pipeBase.Struct( 

219 matches=matches, 

220 wcs=None, 

221 scatterOnSky=20.0*lsst.geom.arcseconds, 

222 matchTolerance=None 

223 ) 

224 with unittest.mock.patch("lsst.meas.astrom.AstrometryTask._matchAndFitWcs", 

225 return_value=result, autospec=True): 

226 with self.assertRaises(exceptions.BadAstrometryFit): 

227 task = AstrometryTask(refObjLoader=self.refObjLoader) 

228 task.run(catalog, self.exposure) 

229 self.assertIsNone(self.exposure.wcs) 

230 self.assertTrue(np.all(np.isnan(catalog["coord_ra"]))) 

231 self.assertTrue(np.all(np.isnan(catalog["coord_dec"]))) 

232 

233 def testMatcherFails(self): 

234 """Test that a matcher exception has additional metadata attached. 

235 """ 

236 catalog = self.makeSourceCat(self.tanWcs, self._makeSourceCatalogSchema()) 

237 with unittest.mock.patch("lsst.meas.astrom.AstrometryTask._matchAndFitWcs", autospec=True, 

238 side_effect=exceptions.MatcherFailure("some matcher problem")): 

239 with self.assertRaises(exceptions.MatcherFailure) as cm: 

240 task = AstrometryTask(refObjLoader=self.refObjLoader) 

241 task.run(catalog, self.exposure) 

242 self.assertEqual(cm.exception.metadata["iterations"], 1) 

243 self.assertIsNone(self.exposure.wcs) 

244 self.assertTrue(np.all(np.isnan(catalog["coord_ra"]))) 

245 self.assertTrue(np.all(np.isnan(catalog["coord_dec"]))) 

246 

247 def testExceptions(self): 

248 """Test that the custom astrometry exceptions are well behaved. 

249 """ 

250 error = exceptions.AstrometryError("something", blah=10) 

251 self.assertEqual(error.metadata["blah"], 10) 

252 self.assertIn("something", str(error)) 

253 self.assertIn("'blah': 10", str(error)) 

254 

255 # Metadata cannot contain an astropy unit. 

256 error = exceptions.AstrometryError("something", blah=10*u.arcsecond) 

257 with self.assertRaisesRegex(TypeError, "blah is of type <class 'astropy.units.quantity.Quantity'>"): 

258 error.metadata 

259 

260 

261class TestMagnitudeOutliers(lsst.utils.tests.TestCase): 

262 def testMagnitudeOutlierRejection(self): 

263 """Test rejection of magnitude outliers. 

264 

265 This test only tests the outlier rejection, and not any other 

266 part of the matching or astrometry fitter. 

267 """ 

268 config = AstrometryTask.ConfigClass() 

269 config.doMagnitudeOutlierRejection = True 

270 config.magnitudeOutlierRejectionNSigma = 4.0 

271 task = AstrometryTask(config=config, refObjLoader=None) 

272 

273 nTest = 100 

274 

275 refSchema = lsst.afw.table.SimpleTable.makeMinimalSchema() 

276 refSchema.addField('refFlux', 'F') 

277 refCat = lsst.afw.table.SimpleCatalog(refSchema) 

278 refCat.resize(nTest) 

279 

280 srcSchema = lsst.afw.table.SourceTable.makeMinimalSchema() 

281 srcSchema.addField('srcFlux', 'F') 

282 srcCat = lsst.afw.table.SourceCatalog(srcSchema) 

283 srcCat.resize(nTest) 

284 

285 np.random.seed(12345) 

286 refMag = np.full(nTest, 20.0) 

287 srcMag = np.random.normal(size=nTest, loc=0.0, scale=1.0) 

288 

289 # Determine the sigma of the random sample 

290 zp = np.median(refMag[: -4] - srcMag[: -4]) 

291 sigma = scipy.stats.median_abs_deviation(srcMag[: -4], scale='normal') 

292 

293 # Deliberately alter some magnitudes to be outliers. 

294 srcMag[-3] = (config.magnitudeOutlierRejectionNSigma + 0.1)*sigma + (20.0 - zp) 

295 srcMag[-4] = -(config.magnitudeOutlierRejectionNSigma + 0.1)*sigma + (20.0 - zp) 

296 

297 refCat['refFlux'] = (refMag*u.ABmag).to_value(u.nJy) 

298 srcCat['srcFlux'] = 10.0**(srcMag/(-2.5)) 

299 

300 # Deliberately poison some reference fluxes. 

301 refCat['refFlux'][-1] = np.inf 

302 refCat['refFlux'][-2] = np.nan 

303 

304 matchesIn = [] 

305 for ref, src in zip(refCat, srcCat): 

306 matchesIn.append(lsst.afw.table.ReferenceMatch(first=ref, second=src, distance=0.0)) 

307 

308 matchesOut = task._removeMagnitudeOutliers('srcFlux', 'refFlux', matchesIn) 

309 

310 # We should lose the 4 outliers we created. 

311 self.assertEqual(len(matchesOut), len(matchesIn) - 4) 

312 

313 

314class MemoryTester(lsst.utils.tests.MemoryTestCase): 

315 pass 

316 

317 

318def setup_module(module): 

319 lsst.utils.tests.init() 

320 

321 

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

323 lsst.utils.tests.init() 

324 unittest.main()