Coverage for python/lsst/meas/astrom/astrometry.py : 18%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#
2# LSST Data Management System
3# Copyright 2008-2016 AURA/LSST.
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 <https://www.lsstcorp.org/LegalNotices/>.
21#
23__all__ = ["AstrometryConfig", "AstrometryTask"]
25import numpy as np
26from astropy import units
27import scipy.stats
29import lsst.pex.config as pexConfig
30import lsst.pipe.base as pipeBase
31from .ref_match import RefMatchTask, RefMatchConfig
32from .fitTanSipWcs import FitTanSipWcsTask
33from .display import displayAstrometry
36class AstrometryConfig(RefMatchConfig):
37 """Config for AstrometryTask.
38 """
39 wcsFitter = pexConfig.ConfigurableField(
40 target=FitTanSipWcsTask,
41 doc="WCS fitter",
42 )
43 forceKnownWcs = pexConfig.Field(
44 dtype=bool,
45 doc="If True then load reference objects and match sources but do not fit a WCS; "
46 "this simply controls whether 'run' calls 'solve' or 'loadAndMatch'",
47 default=False,
48 )
49 maxIter = pexConfig.RangeField(
50 doc="maximum number of iterations of match sources and fit WCS"
51 "ignored if not fitting a WCS",
52 dtype=int,
53 default=3,
54 min=1,
55 )
56 minMatchDistanceArcSec = pexConfig.RangeField(
57 doc="the match distance below which further iteration is pointless (arcsec); "
58 "ignored if not fitting a WCS",
59 dtype=float,
60 default=0.001,
61 min=0,
62 )
63 doMagnitudeOutlierRejection = pexConfig.Field(
64 dtype=bool,
65 doc=("If True then a rough zeropoint will be computed from matched sources "
66 "and outliers will be rejected in the iterations."),
67 default=False,
68 )
69 magnitudeOutlierRejectionNSigma = pexConfig.Field(
70 dtype=float,
71 doc=("Number of sigma (measured from the distribution) in magnitude "
72 "for a potential reference/source match to be rejected during "
73 "iteration."),
74 default=3.0,
75 )
77 def setDefaults(self):
78 # Override the default source selector for astrometry tasks
79 self.sourceFluxType = "Ap"
81 self.sourceSelector.name = "matcher"
82 self.sourceSelector["matcher"].sourceFluxType = self.sourceFluxType
84 # Note that if the matcher is MatchOptimisticBTask, then the
85 # default should be self.sourceSelector['matcher'].excludePixelFlags = False
86 # However, there is no way to do this automatically.
89class AstrometryTask(RefMatchTask):
90 """Match an input source catalog with objects from a reference catalog and
91 solve for the WCS.
93 This task is broken into two main subasks: matching and WCS fitting which
94 are very interactive. The matching here can be considered in part a first
95 pass WCS fitter due to the fitter's sensitivity to outliers.
97 Parameters
98 ----------
99 refObjLoader : `lsst.meas.algorithms.ReferenceLoader`
100 A reference object loader object
101 schema : `lsst.afw.table.Schema`
102 Used to set "calib_astrometry_used" flag in output source catalog.
103 **kwargs
104 additional keyword arguments for pipe_base
105 `lsst.pipe.base.Task.__init__`
106 """
107 ConfigClass = AstrometryConfig
108 _DefaultName = "astrometricSolver"
110 def __init__(self, refObjLoader, schema=None, **kwargs):
111 RefMatchTask.__init__(self, refObjLoader, **kwargs)
113 if schema is not None:
114 self.usedKey = schema.addField("calib_astrometry_used", type="Flag",
115 doc="set if source was used in astrometric calibration")
116 else:
117 self.usedKey = None
119 self.makeSubtask("wcsFitter")
121 @pipeBase.timeMethod
122 def run(self, sourceCat, exposure):
123 """Load reference objects, match sources and optionally fit a WCS.
125 This is a thin layer around solve or loadAndMatch, depending on
126 config.forceKnownWcs.
128 Parameters
129 ----------
130 exposure : `lsst.afw.image.Exposure`
131 exposure whose WCS is to be fit
132 The following are read only:
134 - bbox
135 - photoCalib (may be absent)
136 - filter (may be unset)
137 - detector (if wcs is pure tangent; may be absent)
139 The following are updated:
141 - wcs (the initial value is used as an initial guess, and is
142 required)
144 sourceCat : `lsst.afw.table.SourceCatalog`
145 catalog of sources detected on the exposure
147 Returns
148 -------
149 result : `lsst.pipe.base.Struct`
150 with these fields:
152 - ``refCat`` : reference object catalog of objects that overlap the
153 exposure (with some margin) (`lsst.afw.table.SimpleCatalog`).
154 - ``matches`` : astrometric matches
155 (`list` of `lsst.afw.table.ReferenceMatch`).
156 - ``scatterOnSky`` : median on-sky separation between reference
157 objects and sources in "matches"
158 (`lsst.afw.geom.Angle`) or `None` if config.forceKnownWcs True
159 - ``matchMeta`` : metadata needed to unpersist matches
160 (`lsst.daf.base.PropertyList`)
161 """
162 if self.refObjLoader is None:
163 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
164 if self.config.forceKnownWcs:
165 res = self.loadAndMatch(exposure=exposure, sourceCat=sourceCat)
166 res.scatterOnSky = None
167 else:
168 res = self.solve(exposure=exposure, sourceCat=sourceCat)
169 return res
171 @pipeBase.timeMethod
172 def solve(self, exposure, sourceCat):
173 """Load reference objects overlapping an exposure, match to sources and
174 fit a WCS
176 Returns
177 -------
178 result : `lsst.pipe.base.Struct`
179 Result struct with components:
181 - ``refCat`` : reference object catalog of objects that overlap the
182 exposure (with some margin) (`lsst::afw::table::SimpleCatalog`).
183 - ``matches`` : astrometric matches
184 (`list` of `lsst.afw.table.ReferenceMatch`).
185 - ``scatterOnSky`` : median on-sky separation between reference
186 objects and sources in "matches" (`lsst.geom.Angle`)
187 - ``matchMeta`` : metadata needed to unpersist matches
188 (`lsst.daf.base.PropertyList`)
190 Notes
191 -----
192 ignores config.forceKnownWcs
193 """
194 if self.refObjLoader is None:
195 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
196 import lsstDebug
197 debug = lsstDebug.Info(__name__)
199 expMd = self._getExposureMetadata(exposure)
201 sourceSelection = self.sourceSelector.run(sourceCat)
203 self.log.info("Purged %d sources, leaving %d good sources" %
204 (len(sourceCat) - len(sourceSelection.sourceCat),
205 len(sourceSelection.sourceCat)))
207 loadRes = self.refObjLoader.loadPixelBox(
208 bbox=expMd.bbox,
209 wcs=expMd.wcs,
210 filterName=expMd.filterName,
211 photoCalib=expMd.photoCalib,
212 epoch=expMd.epoch,
213 )
215 refSelection = self.referenceSelector.run(loadRes.refCat)
217 matchMeta = self.refObjLoader.getMetadataBox(
218 bbox=expMd.bbox,
219 wcs=expMd.wcs,
220 filterName=expMd.filterName,
221 photoCalib=expMd.photoCalib,
222 epoch=expMd.epoch,
223 )
225 if debug.display:
226 frame = int(debug.frame)
227 displayAstrometry(
228 refCat=refSelection.sourceCat,
229 sourceCat=sourceSelection.sourceCat,
230 exposure=exposure,
231 bbox=expMd.bbox,
232 frame=frame,
233 title="Reference catalog",
234 )
236 res = None
237 wcs = expMd.wcs
238 match_tolerance = None
239 for i in range(self.config.maxIter):
240 iterNum = i + 1
241 try:
242 tryRes = self._matchAndFitWcs(
243 refCat=refSelection.sourceCat,
244 sourceCat=sourceCat,
245 goodSourceCat=sourceSelection.sourceCat,
246 refFluxField=loadRes.fluxField,
247 bbox=expMd.bbox,
248 wcs=wcs,
249 exposure=exposure,
250 match_tolerance=match_tolerance,
251 )
252 except Exception as e:
253 # if we have had a succeessful iteration then use that; otherwise fail
254 if i > 0:
255 self.log.info("Fit WCS iter %d failed; using previous iteration: %s" % (iterNum, e))
256 iterNum -= 1
257 break
258 else:
259 raise
261 match_tolerance = tryRes.match_tolerance
262 tryMatchDist = self._computeMatchStatsOnSky(tryRes.matches)
263 self.log.debug(
264 "Match and fit WCS iteration %d: found %d matches with scatter = %0.3f +- %0.3f arcsec; "
265 "max match distance = %0.3f arcsec",
266 iterNum, len(tryRes.matches), tryMatchDist.distMean.asArcseconds(),
267 tryMatchDist.distStdDev.asArcseconds(), tryMatchDist.maxMatchDist.asArcseconds())
269 maxMatchDist = tryMatchDist.maxMatchDist
270 res = tryRes
271 wcs = res.wcs
272 if maxMatchDist.asArcseconds() < self.config.minMatchDistanceArcSec:
273 self.log.debug(
274 "Max match distance = %0.3f arcsec < %0.3f = config.minMatchDistanceArcSec; "
275 "that's good enough",
276 maxMatchDist.asArcseconds(), self.config.minMatchDistanceArcSec)
277 break
278 match_tolerance.maxMatchDist = maxMatchDist
280 self.log.info(
281 "Matched and fit WCS in %d iterations; "
282 "found %d matches with scatter = %0.3f +- %0.3f arcsec" %
283 (iterNum, len(tryRes.matches), tryMatchDist.distMean.asArcseconds(),
284 tryMatchDist.distStdDev.asArcseconds()))
285 for m in res.matches:
286 if self.usedKey:
287 m.second.set(self.usedKey, True)
288 exposure.setWcs(res.wcs)
290 # Record the scatter in the exposure metadata
291 md = exposure.getMetadata()
292 md['SFM_ASTROM_OFFSET_MEAN'] = tryMatchDist.distMean.asArcseconds()
293 md['SFM_ASTROM_OFFSET_STD'] = tryMatchDist.distStdDev.asArcseconds()
295 return pipeBase.Struct(
296 refCat=refSelection.sourceCat,
297 matches=res.matches,
298 scatterOnSky=res.scatterOnSky,
299 matchMeta=matchMeta,
300 )
302 @pipeBase.timeMethod
303 def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, match_tolerance,
304 exposure=None):
305 """Match sources to reference objects and fit a WCS.
307 Parameters
308 ----------
309 refCat : `lsst.afw.table.SimpleCatalog`
310 catalog of reference objects
311 sourceCat : `lsst.afw.table.SourceCatalog`
312 catalog of sources detected on the exposure
313 goodSourceCat : `lsst.afw.table.SourceCatalog`
314 catalog of down-selected good sources detected on the exposure
315 refFluxField : 'str'
316 field of refCat to use for flux
317 bbox : `lsst.geom.Box2I`
318 bounding box of exposure
319 wcs : `lsst.afw.geom.SkyWcs`
320 initial guess for WCS of exposure
321 match_tolerance : `lsst.meas.astrom.MatchTolerance`
322 a MatchTolerance object (or None) specifying
323 internal tolerances to the matcher. See the MatchTolerance
324 definition in the respective matcher for the class definition.
325 exposure : `lsst.afw.image.Exposure`
326 exposure whose WCS is to be fit, or None; used only for the debug
327 display.
329 Returns
330 -------
331 result : `lsst.pipe.base.Struct`
332 Result struct with components:
334 - ``matches``: astrometric matches
335 (`list` of `lsst.afw.table.ReferenceMatch`).
336 - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs).
337 - ``scatterOnSky`` : median on-sky separation between reference
338 objects and sources in "matches" (`lsst.afw.geom.Angle`).
339 """
340 import lsstDebug
341 debug = lsstDebug.Info(__name__)
343 sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType)
345 matchRes = self.matcher.matchObjectsToSources(
346 refCat=refCat,
347 sourceCat=goodSourceCat,
348 wcs=wcs,
349 sourceFluxField=sourceFluxField,
350 refFluxField=refFluxField,
351 match_tolerance=match_tolerance,
352 )
353 self.log.debug("Found %s matches", len(matchRes.matches))
354 if debug.display:
355 frame = int(debug.frame)
356 displayAstrometry(
357 refCat=refCat,
358 sourceCat=matchRes.usableSourceCat,
359 matches=matchRes.matches,
360 exposure=exposure,
361 bbox=bbox,
362 frame=frame + 1,
363 title="Initial WCS",
364 )
366 if self.config.doMagnitudeOutlierRejection:
367 matches = self._removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches)
368 else:
369 matches = matchRes.matches
371 self.log.debug("Fitting WCS")
372 fitRes = self.wcsFitter.fitWcs(
373 matches=matches,
374 initWcs=wcs,
375 bbox=bbox,
376 refCat=refCat,
377 sourceCat=sourceCat,
378 exposure=exposure,
379 )
380 fitWcs = fitRes.wcs
381 scatterOnSky = fitRes.scatterOnSky
382 if debug.display:
383 frame = int(debug.frame)
384 displayAstrometry(
385 refCat=refCat,
386 sourceCat=matchRes.usableSourceCat,
387 matches=matches,
388 exposure=exposure,
389 bbox=bbox,
390 frame=frame + 2,
391 title="Fit TAN-SIP WCS",
392 )
394 return pipeBase.Struct(
395 matches=matches,
396 wcs=fitWcs,
397 scatterOnSky=scatterOnSky,
398 match_tolerance=matchRes.match_tolerance,
399 )
401 def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn):
402 """Remove magnitude outliers, computing a simple zeropoint.
404 Parameters
405 ----------
406 sourceFluxField : `str`
407 Field in source catalog for instrumental fluxes.
408 refFluxField : `str`
409 Field in reference catalog for fluxes (nJy).
410 matchesIn : `list` [`lsst.afw.table.ReferenceMatch`]
411 List of source/reference matches input
413 Returns
414 -------
415 matchesOut : `list` [`lsst.afw.table.ReferenceMatch`]
416 List of source/reference matches with magnitude
417 outliers removed.
418 """
419 nMatch = len(matchesIn)
420 sourceMag = np.zeros(nMatch)
421 refMag = np.zeros(nMatch)
422 for i, match in enumerate(matchesIn):
423 sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField])
424 refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag)
426 deltaMag = refMag - sourceMag
427 # Protect against negative fluxes and nans in the reference catalog.
428 goodDelta, = np.where(np.isfinite(deltaMag))
429 zp = np.median(deltaMag[goodDelta])
430 # Use median absolute deviation (MAD) for zpSigma.
431 # Also require a minimum scatter to prevent floating-point errors from
432 # rejecting objects in zero-noise tests.
433 zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale='normal'),
434 1e-3,
435 None)
437 self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
438 zp, zpSigma)
440 goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
441 <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
443 nOutlier = nMatch - goodStars.size
444 self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.",
445 nOutlier, nMatch)
447 matchesOut = []
448 for matchInd in goodStars:
449 matchesOut.append(matchesIn[matchInd])
451 return matchesOut