lsst.meas.astrom  21.0.0-6-gada85d7+beb3e52ac8
astrometry.py
Go to the documentation of this file.
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 #
22 
23 __all__ = ["AstrometryConfig", "AstrometryTask"]
24 
25 import numpy as np
26 from astropy import units
27 import scipy.stats
28 
29 import lsst.pex.config as pexConfig
30 import lsst.pipe.base as pipeBase
31 from .ref_match import RefMatchTask, RefMatchConfig
32 from .fitTanSipWcs import FitTanSipWcsTask
33 from .display import displayAstrometry
34 
35 
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  )
76 
77  def setDefaults(self):
78  # Override the default source selector for astrometry tasks
79  self.sourceFluxTypesourceFluxTypesourceFluxType = "Ap"
80 
81  self.sourceSelectorsourceSelector.name = "matcher"
82  self.sourceSelectorsourceSelector["matcher"].sourceFluxType = self.sourceFluxTypesourceFluxTypesourceFluxType
83 
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.
87 
88 
90  """Match an input source catalog with objects from a reference catalog and
91  solve for the WCS.
92 
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.
96 
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"
109 
110  def __init__(self, refObjLoader, schema=None, **kwargs):
111  RefMatchTask.__init__(self, refObjLoader, **kwargs)
112 
113  if schema is not None:
114  self.usedKeyusedKey = schema.addField("calib_astrometry_used", type="Flag",
115  doc="set if source was used in astrometric calibration")
116  else:
117  self.usedKeyusedKey = None
118 
119  self.makeSubtask("wcsFitter")
120 
121  @pipeBase.timeMethod
122  def run(self, sourceCat, exposure):
123  """Load reference objects, match sources and optionally fit a WCS.
124 
125  This is a thin layer around solve or loadAndMatch, depending on
126  config.forceKnownWcs.
127 
128  Parameters
129  ----------
130  exposure : `lsst.afw.image.Exposure`
131  exposure whose WCS is to be fit
132  The following are read only:
133 
134  - bbox
135  - photoCalib (may be absent)
136  - filter (may be unset)
137  - detector (if wcs is pure tangent; may be absent)
138 
139  The following are updated:
140 
141  - wcs (the initial value is used as an initial guess, and is
142  required)
143 
144  sourceCat : `lsst.afw.table.SourceCatalog`
145  catalog of sources detected on the exposure
146 
147  Returns
148  -------
149  result : `lsst.pipe.base.Struct`
150  with these fields:
151 
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.refObjLoaderrefObjLoader is None:
163  raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
164  if self.config.forceKnownWcs:
165  res = self.loadAndMatchloadAndMatch(exposure=exposure, sourceCat=sourceCat)
166  res.scatterOnSky = None
167  else:
168  res = self.solvesolve(exposure=exposure, sourceCat=sourceCat)
169  return res
170 
171  @pipeBase.timeMethod
172  def solve(self, exposure, sourceCat):
173  """Load reference objects overlapping an exposure, match to sources and
174  fit a WCS
175 
176  Returns
177  -------
178  result : `lsst.pipe.base.Struct`
179  Result struct with components:
180 
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`)
189 
190  Notes
191  -----
192  ignores config.forceKnownWcs
193  """
194  if self.refObjLoaderrefObjLoader is None:
195  raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
196  import lsstDebug
197  debug = lsstDebug.Info(__name__)
198 
199  expMd = self._getExposureMetadata_getExposureMetadata(exposure)
200 
201  sourceSelection = self.sourceSelector.run(sourceCat)
202 
203  self.log.info("Purged %d sources, leaving %d good sources" %
204  (len(sourceCat) - len(sourceSelection.sourceCat),
205  len(sourceSelection.sourceCat)))
206 
207  loadRes = self.refObjLoaderrefObjLoader.loadPixelBox(
208  bbox=expMd.bbox,
209  wcs=expMd.wcs,
210  filterName=expMd.filterName,
211  photoCalib=expMd.photoCalib,
212  epoch=expMd.epoch,
213  )
214 
215  refSelection = self.referenceSelector.run(loadRes.refCat)
216 
217  matchMeta = self.refObjLoaderrefObjLoader.getMetadataBox(
218  bbox=expMd.bbox,
219  wcs=expMd.wcs,
220  filterName=expMd.filterName,
221  photoCalib=expMd.photoCalib,
222  epoch=expMd.epoch,
223  )
224 
225  if debug.display:
226  frame = int(debug.frame)
228  refCat=refSelection.sourceCat,
229  sourceCat=sourceSelection.sourceCat,
230  exposure=exposure,
231  bbox=expMd.bbox,
232  frame=frame,
233  title="Reference catalog",
234  )
235 
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_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
260 
261  match_tolerance = tryRes.match_tolerance
262  tryMatchDist = self._computeMatchStatsOnSky_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())
268 
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
279 
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.usedKeyusedKey:
287  m.second.set(self.usedKeyusedKey, True)
288  exposure.setWcs(res.wcs)
289 
290  return pipeBase.Struct(
291  refCat=refSelection.sourceCat,
292  matches=res.matches,
293  scatterOnSky=res.scatterOnSky,
294  matchMeta=matchMeta,
295  )
296 
297  @pipeBase.timeMethod
298  def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, match_tolerance,
299  exposure=None):
300  """Match sources to reference objects and fit a WCS.
301 
302  Parameters
303  ----------
304  refCat : `lsst.afw.table.SimpleCatalog`
305  catalog of reference objects
306  sourceCat : `lsst.afw.table.SourceCatalog`
307  catalog of sources detected on the exposure
308  goodSourceCat : `lsst.afw.table.SourceCatalog`
309  catalog of down-selected good sources detected on the exposure
310  refFluxField : 'str'
311  field of refCat to use for flux
312  bbox : `lsst.geom.Box2I`
313  bounding box of exposure
314  wcs : `lsst.afw.geom.SkyWcs`
315  initial guess for WCS of exposure
316  match_tolerance : `lsst.meas.astrom.MatchTolerance`
317  a MatchTolerance object (or None) specifying
318  internal tolerances to the matcher. See the MatchTolerance
319  definition in the respective matcher for the class definition.
320  exposure : `lsst.afw.image.Exposure`
321  exposure whose WCS is to be fit, or None; used only for the debug
322  display.
323 
324  Returns
325  -------
326  result : `lsst.pipe.base.Struct`
327  Result struct with components:
328 
329  - ``matches``: astrometric matches
330  (`list` of `lsst.afw.table.ReferenceMatch`).
331  - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs).
332  - ``scatterOnSky`` : median on-sky separation between reference
333  objects and sources in "matches" (`lsst.afw.geom.Angle`).
334  """
335  import lsstDebug
336  debug = lsstDebug.Info(__name__)
337 
338  sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType)
339 
340  matchRes = self.matcher.matchObjectsToSources(
341  refCat=refCat,
342  sourceCat=goodSourceCat,
343  wcs=wcs,
344  sourceFluxField=sourceFluxField,
345  refFluxField=refFluxField,
346  match_tolerance=match_tolerance,
347  )
348  self.log.debug("Found %s matches", len(matchRes.matches))
349  if debug.display:
350  frame = int(debug.frame)
352  refCat=refCat,
353  sourceCat=matchRes.usableSourceCat,
354  matches=matchRes.matches,
355  exposure=exposure,
356  bbox=bbox,
357  frame=frame + 1,
358  title="Initial WCS",
359  )
360 
361  if self.config.doMagnitudeOutlierRejection:
362  matches = self._removeMagnitudeOutliers_removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches)
363  else:
364  matches = matchRes.matches
365 
366  self.log.debug("Fitting WCS")
367  fitRes = self.wcsFitter.fitWcs(
368  matches=matches,
369  initWcs=wcs,
370  bbox=bbox,
371  refCat=refCat,
372  sourceCat=sourceCat,
373  exposure=exposure,
374  )
375  fitWcs = fitRes.wcs
376  scatterOnSky = fitRes.scatterOnSky
377  if debug.display:
378  frame = int(debug.frame)
380  refCat=refCat,
381  sourceCat=matchRes.usableSourceCat,
382  matches=matches,
383  exposure=exposure,
384  bbox=bbox,
385  frame=frame + 2,
386  title="Fit TAN-SIP WCS",
387  )
388 
389  return pipeBase.Struct(
390  matches=matches,
391  wcs=fitWcs,
392  scatterOnSky=scatterOnSky,
393  match_tolerance=matchRes.match_tolerance,
394  )
395 
396  def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn):
397  """Remove magnitude outliers, computing a simple zeropoint.
398 
399  Parameters
400  ----------
401  sourceFluxField : `str`
402  Field in source catalog for instrumental fluxes.
403  refFluxField : `str`
404  Field in reference catalog for fluxes (nJy).
405  matchesIn : `list` [`lsst.afw.table.ReferenceMatch`]
406  List of source/reference matches input
407 
408  Returns
409  -------
410  matchesOut : `list` [`lsst.afw.table.ReferenceMatch`]
411  List of source/reference matches with magnitude
412  outliers removed.
413  """
414  nMatch = len(matchesIn)
415  sourceMag = np.zeros(nMatch)
416  refMag = np.zeros(nMatch)
417  for i, match in enumerate(matchesIn):
418  sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField])
419  refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag)
420 
421  deltaMag = refMag - sourceMag
422  # Protect against negative fluxes and nans in the reference catalog.
423  goodDelta, = np.where(np.isfinite(deltaMag))
424  zp = np.median(deltaMag[goodDelta])
425  # Use median absolute deviation (MAD) for zpSigma.
426  # Also require a minimum scatter to prevent floating-point errors from
427  # rejecting objects in zero-noise tests.
428  zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale='normal'),
429  1e-3,
430  None)
431 
432  self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
433  zp, zpSigma)
434 
435  goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
436  <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
437 
438  nOutlier = nMatch - goodStars.size
439  self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.",
440  nOutlier, nMatch)
441 
442  matchesOut = []
443  for matchInd in goodStars:
444  matchesOut.append(matchesIn[matchInd])
445 
446  return matchesOut
def solve(self, exposure, sourceCat)
Definition: astrometry.py:172
def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn)
Definition: astrometry.py:396
def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, match_tolerance, exposure=None)
Definition: astrometry.py:299
def run(self, sourceCat, exposure)
Definition: astrometry.py:122
def __init__(self, refObjLoader, schema=None, **kwargs)
Definition: astrometry.py:110
def _computeMatchStatsOnSky(self, matchList)
Definition: ref_match.py:207
def loadAndMatch(self, exposure, sourceCat)
Definition: ref_match.py:116
def _getExposureMetadata(self, exposure)
Definition: ref_match.py:236
def displayAstrometry(refCat=None, sourceCat=None, distortedCentroidKey=None, bbox=None, exposure=None, matches=None, frame=1, title="", pause=True)
Definition: display.py:34