Coverage for tests/test_astrometryTask.py: 18%
178 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-10 02:49 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-10 02:49 -0700
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#
23import os.path
24import math
25import unittest
26import glob
28import astropy.units as u
29import scipy.stats
30import numpy as np
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
43class TestAstrometricSolver(lsst.utils.tests.TestCase):
45 def setUp(self):
46 refCatDir = os.path.join(os.path.dirname(__file__), "data", "sdssrefcat")
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)
60 def testTrivial(self):
61 """Test fit with no distortion
62 """
63 self.doTest(afwGeom.makeIdentityTransform())
65 def testRadial(self):
66 """Test fit with radial distortion
68 The offset comes from the fact that the CCD is not centered
69 """
70 self.doTest(afwGeom.makeRadialTransform([0, 1.01, 1e-7]))
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)
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))
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()
106 angSep = refCoord.separation(srcCoord)
107 maxAngSep = max(maxAngSep, angSep)
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)
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))
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
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)
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)
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))
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
179 def makeSourceCat(self, wcs, schema, doScatterCentroids=False):
180 """Make a source catalog by reading the position reference stars using
181 the proviced WCS.
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
190 sourceCat = afwTable.SourceCatalog(schema)
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
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
208 return sourceCat
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"])))
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"])))
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))
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
261class TestMagnitudeOutliers(lsst.utils.tests.TestCase):
262 def testMagnitudeOutlierRejection(self):
263 """Test rejection of magnitude outliers.
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)
273 nTest = 100
275 refSchema = lsst.afw.table.SimpleTable.makeMinimalSchema()
276 refSchema.addField('refFlux', 'F')
277 refCat = lsst.afw.table.SimpleCatalog(refSchema)
278 refCat.resize(nTest)
280 srcSchema = lsst.afw.table.SourceTable.makeMinimalSchema()
281 srcSchema.addField('srcFlux', 'F')
282 srcCat = lsst.afw.table.SourceCatalog(srcSchema)
283 srcCat.resize(nTest)
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)
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')
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)
297 refCat['refFlux'] = (refMag*u.ABmag).to_value(u.nJy)
298 srcCat['srcFlux'] = 10.0**(srcMag/(-2.5))
300 # Deliberately poison some reference fluxes.
301 refCat['refFlux'][-1] = np.inf
302 refCat['refFlux'][-2] = np.nan
304 matchesIn = []
305 for ref, src in zip(refCat, srcCat):
306 matchesIn.append(lsst.afw.table.ReferenceMatch(first=ref, second=src, distance=0.0))
308 matchesOut = task._removeMagnitudeOutliers('srcFlux', 'refFlux', matchesIn)
310 # We should lose the 4 outliers we created.
311 self.assertEqual(len(matchesOut), len(matchesIn) - 4)
314class MemoryTester(lsst.utils.tests.MemoryTestCase):
315 pass
318def setup_module(module):
319 lsst.utils.tests.init()
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()