lsst.meas.astrom  v23.0.x-g8ce4d5a149+4a35e5d482
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  maxMeanDistanceArcsec = pexConfig.RangeField(
64  doc="Maximum mean on-sky distance (in arcsec) between matched source and rerference "
65  "objects post-fit. A mean distance greater than this threshold raises a TaskError "
66  "and the WCS fit is considered a failure. The default is set to the maximum tolerated "
67  "by the external global calibration (e.g. jointcal) step for conceivable recovery. "
68  "Appropriate value will be dataset and workflow dependent.",
69  dtype=float,
70  default=0.5,
71  min=0,
72  )
73  doMagnitudeOutlierRejection = pexConfig.Field(
74  dtype=bool,
75  doc=("If True then a rough zeropoint will be computed from matched sources "
76  "and outliers will be rejected in the iterations."),
77  default=False,
78  )
79  magnitudeOutlierRejectionNSigma = pexConfig.Field(
80  dtype=float,
81  doc=("Number of sigma (measured from the distribution) in magnitude "
82  "for a potential reference/source match to be rejected during "
83  "iteration."),
84  default=3.0,
85  )
86 
87  def setDefaults(self):
88  # Override the default source selector for astrometry tasks
89  self.sourceFluxTypesourceFluxTypesourceFluxType = "Ap"
90 
91  self.sourceSelectorsourceSelector.name = "matcher"
92  self.sourceSelectorsourceSelector["matcher"].sourceFluxType = self.sourceFluxTypesourceFluxTypesourceFluxType
93 
94  # Note that if the matcher is MatchOptimisticBTask, then the
95  # default should be self.sourceSelector['matcher'].excludePixelFlags = False
96  # However, there is no way to do this automatically.
97 
98 
100  """Match an input source catalog with objects from a reference catalog and
101  solve for the WCS.
102 
103  This task is broken into two main subasks: matching and WCS fitting which
104  are very interactive. The matching here can be considered in part a first
105  pass WCS fitter due to the fitter's sensitivity to outliers.
106 
107  Parameters
108  ----------
109  refObjLoader : `lsst.meas.algorithms.ReferenceLoader`
110  A reference object loader object
111  schema : `lsst.afw.table.Schema`
112  Used to set "calib_astrometry_used" flag in output source catalog.
113  **kwargs
114  additional keyword arguments for pipe_base
115  `lsst.pipe.base.Task.__init__`
116  """
117  ConfigClass = AstrometryConfig
118  _DefaultName = "astrometricSolver"
119 
120  def __init__(self, refObjLoader, schema=None, **kwargs):
121  RefMatchTask.__init__(self, refObjLoader, **kwargs)
122 
123  if schema is not None:
124  self.usedKeyusedKey = schema.addField("calib_astrometry_used", type="Flag",
125  doc="set if source was used in astrometric calibration")
126  else:
127  self.usedKeyusedKey = None
128 
129  self.makeSubtask("wcsFitter")
130 
131  @pipeBase.timeMethod
132  def run(self, sourceCat, exposure):
133  """Load reference objects, match sources and optionally fit a WCS.
134 
135  This is a thin layer around solve or loadAndMatch, depending on
136  config.forceKnownWcs.
137 
138  Parameters
139  ----------
140  exposure : `lsst.afw.image.Exposure`
141  exposure whose WCS is to be fit
142  The following are read only:
143 
144  - bbox
145  - photoCalib (may be absent)
146  - filter (may be unset)
147  - detector (if wcs is pure tangent; may be absent)
148 
149  The following are updated:
150 
151  - wcs (the initial value is used as an initial guess, and is
152  required)
153 
154  sourceCat : `lsst.afw.table.SourceCatalog`
155  catalog of sources detected on the exposure
156 
157  Returns
158  -------
159  result : `lsst.pipe.base.Struct`
160  with these fields:
161 
162  - ``refCat`` : reference object catalog of objects that overlap the
163  exposure (with some margin) (`lsst.afw.table.SimpleCatalog`).
164  - ``matches`` : astrometric matches
165  (`list` of `lsst.afw.table.ReferenceMatch`).
166  - ``scatterOnSky`` : median on-sky separation between reference
167  objects and sources in "matches"
168  (`lsst.afw.geom.Angle`) or `None` if config.forceKnownWcs True
169  - ``matchMeta`` : metadata needed to unpersist matches
170  (`lsst.daf.base.PropertyList`)
171  """
172  if self.refObjLoaderrefObjLoader is None:
173  raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
174  if self.config.forceKnownWcs:
175  res = self.loadAndMatchloadAndMatch(exposure=exposure, sourceCat=sourceCat)
176  res.scatterOnSky = None
177  else:
178  res = self.solvesolve(exposure=exposure, sourceCat=sourceCat)
179  return res
180 
181  @pipeBase.timeMethod
182  def solve(self, exposure, sourceCat):
183  """Load reference objects overlapping an exposure, match to sources and
184  fit a WCS
185 
186  Returns
187  -------
188  result : `lsst.pipe.base.Struct`
189  Result struct with components:
190 
191  - ``refCat`` : reference object catalog of objects that overlap the
192  exposure (with some margin) (`lsst::afw::table::SimpleCatalog`).
193  - ``matches`` : astrometric matches
194  (`list` of `lsst.afw.table.ReferenceMatch`).
195  - ``scatterOnSky`` : median on-sky separation between reference
196  objects and sources in "matches" (`lsst.geom.Angle`)
197  - ``matchMeta`` : metadata needed to unpersist matches
198  (`lsst.daf.base.PropertyList`)
199 
200  Raises
201  ------
202  TaskError
203  If the measured mean on-sky distance between the matched source and
204  reference objects is greater than
205  ``self.config.maxMeanDistanceArcsec``.
206 
207  Notes
208  -----
209  ignores config.forceKnownWcs
210  """
211  if self.refObjLoaderrefObjLoader is None:
212  raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
213  import lsstDebug
214  debug = lsstDebug.Info(__name__)
215 
216  expMd = self._getExposureMetadata_getExposureMetadata(exposure)
217 
218  sourceSelection = self.sourceSelector.run(sourceCat)
219 
220  self.log.info("Purged %d sources, leaving %d good sources" %
221  (len(sourceCat) - len(sourceSelection.sourceCat),
222  len(sourceSelection.sourceCat)))
223 
224  loadRes = self.refObjLoaderrefObjLoader.loadPixelBox(
225  bbox=expMd.bbox,
226  wcs=expMd.wcs,
227  filterName=expMd.filterName,
228  photoCalib=expMd.photoCalib,
229  epoch=expMd.epoch,
230  )
231 
232  refSelection = self.referenceSelector.run(loadRes.refCat)
233 
234  matchMeta = self.refObjLoaderrefObjLoader.getMetadataBox(
235  bbox=expMd.bbox,
236  wcs=expMd.wcs,
237  filterName=expMd.filterName,
238  photoCalib=expMd.photoCalib,
239  epoch=expMd.epoch,
240  )
241 
242  if debug.display:
243  frame = int(debug.frame)
245  refCat=refSelection.sourceCat,
246  sourceCat=sourceSelection.sourceCat,
247  exposure=exposure,
248  bbox=expMd.bbox,
249  frame=frame,
250  title="Reference catalog",
251  )
252 
253  res = None
254  wcs = expMd.wcs
255  match_tolerance = None
256  for i in range(self.config.maxIter):
257  iterNum = i + 1
258  try:
259  tryRes = self._matchAndFitWcs_matchAndFitWcs(
260  refCat=refSelection.sourceCat,
261  sourceCat=sourceCat,
262  goodSourceCat=sourceSelection.sourceCat,
263  refFluxField=loadRes.fluxField,
264  bbox=expMd.bbox,
265  wcs=wcs,
266  exposure=exposure,
267  match_tolerance=match_tolerance,
268  )
269  except Exception as e:
270  # if we have had a succeessful iteration then use that; otherwise fail
271  if i > 0:
272  self.log.info("Fit WCS iter %d failed; using previous iteration: %s" % (iterNum, e))
273  iterNum -= 1
274  break
275  else:
276  raise
277 
278  match_tolerance = tryRes.match_tolerance
279  tryMatchDist = self._computeMatchStatsOnSky_computeMatchStatsOnSky(tryRes.matches)
280  self.log.debug(
281  "Match and fit WCS iteration %d: found %d matches with on-sky distance mean "
282  "= %0.3f +- %0.3f arcsec; max match distance = %0.3f arcsec",
283  iterNum, len(tryRes.matches), tryMatchDist.distMean.asArcseconds(),
284  tryMatchDist.distStdDev.asArcseconds(), tryMatchDist.maxMatchDist.asArcseconds())
285 
286  maxMatchDist = tryMatchDist.maxMatchDist
287  res = tryRes
288  wcs = res.wcs
289  if maxMatchDist.asArcseconds() < self.config.minMatchDistanceArcSec:
290  self.log.debug(
291  "Max match distance = %0.3f arcsec < %0.3f = config.minMatchDistanceArcSec; "
292  "that's good enough",
293  maxMatchDist.asArcseconds(), self.config.minMatchDistanceArcSec)
294  break
295  match_tolerance.maxMatchDist = maxMatchDist
296 
297  self.log.info(
298  "Matched and fit WCS in %d iterations; "
299  "found %d matches with on-sky distance mean and scatter = %0.3f +- %0.3f arcsec",
300  iterNum, len(tryRes.matches), tryMatchDist.distMean.asArcseconds(),
301  tryMatchDist.distStdDev.asArcseconds())
302  if tryMatchDist.distMean.asArcseconds() > self.config.maxMeanDistanceArcsec:
303  raise pipeBase.TaskError(
304  "Fatal astrometry failure detected: mean on-sky distance = %0.3f arcsec > %0.3f "
305  "(maxMeanDistanceArcsec)" %
306  (tryMatchDist.distMean.asArcseconds(), self.config.maxMeanDistanceArcsec))
307  for m in res.matches:
308  if self.usedKeyusedKey:
309  m.second.set(self.usedKeyusedKey, True)
310  exposure.setWcs(res.wcs)
311 
312  # Record the scatter in the exposure metadata
313  md = exposure.getMetadata()
314  md['SFM_ASTROM_OFFSET_MEAN'] = tryMatchDist.distMean.asArcseconds()
315  md['SFM_ASTROM_OFFSET_STD'] = tryMatchDist.distStdDev.asArcseconds()
316 
317  return pipeBase.Struct(
318  refCat=refSelection.sourceCat,
319  matches=res.matches,
320  scatterOnSky=res.scatterOnSky,
321  matchMeta=matchMeta,
322  )
323 
324  @pipeBase.timeMethod
325  def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, match_tolerance,
326  exposure=None):
327  """Match sources to reference objects and fit a WCS.
328 
329  Parameters
330  ----------
331  refCat : `lsst.afw.table.SimpleCatalog`
332  catalog of reference objects
333  sourceCat : `lsst.afw.table.SourceCatalog`
334  catalog of sources detected on the exposure
335  goodSourceCat : `lsst.afw.table.SourceCatalog`
336  catalog of down-selected good sources detected on the exposure
337  refFluxField : 'str'
338  field of refCat to use for flux
339  bbox : `lsst.geom.Box2I`
340  bounding box of exposure
341  wcs : `lsst.afw.geom.SkyWcs`
342  initial guess for WCS of exposure
343  match_tolerance : `lsst.meas.astrom.MatchTolerance`
344  a MatchTolerance object (or None) specifying
345  internal tolerances to the matcher. See the MatchTolerance
346  definition in the respective matcher for the class definition.
347  exposure : `lsst.afw.image.Exposure`
348  exposure whose WCS is to be fit, or None; used only for the debug
349  display.
350 
351  Returns
352  -------
353  result : `lsst.pipe.base.Struct`
354  Result struct with components:
355 
356  - ``matches``: astrometric matches
357  (`list` of `lsst.afw.table.ReferenceMatch`).
358  - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs).
359  - ``scatterOnSky`` : median on-sky separation between reference
360  objects and sources in "matches" (`lsst.afw.geom.Angle`).
361  """
362  import lsstDebug
363  debug = lsstDebug.Info(__name__)
364 
365  sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType)
366 
367  matchRes = self.matcher.matchObjectsToSources(
368  refCat=refCat,
369  sourceCat=goodSourceCat,
370  wcs=wcs,
371  sourceFluxField=sourceFluxField,
372  refFluxField=refFluxField,
373  match_tolerance=match_tolerance,
374  )
375  self.log.debug("Found %s matches", len(matchRes.matches))
376  if debug.display:
377  frame = int(debug.frame)
379  refCat=refCat,
380  sourceCat=matchRes.usableSourceCat,
381  matches=matchRes.matches,
382  exposure=exposure,
383  bbox=bbox,
384  frame=frame + 1,
385  title="Initial WCS",
386  )
387 
388  if self.config.doMagnitudeOutlierRejection:
389  matches = self._removeMagnitudeOutliers_removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches)
390  else:
391  matches = matchRes.matches
392 
393  self.log.debug("Fitting WCS")
394  fitRes = self.wcsFitter.fitWcs(
395  matches=matches,
396  initWcs=wcs,
397  bbox=bbox,
398  refCat=refCat,
399  sourceCat=sourceCat,
400  exposure=exposure,
401  )
402  fitWcs = fitRes.wcs
403  scatterOnSky = fitRes.scatterOnSky
404  if debug.display:
405  frame = int(debug.frame)
407  refCat=refCat,
408  sourceCat=matchRes.usableSourceCat,
409  matches=matches,
410  exposure=exposure,
411  bbox=bbox,
412  frame=frame + 2,
413  title="Fit TAN-SIP WCS",
414  )
415 
416  return pipeBase.Struct(
417  matches=matches,
418  wcs=fitWcs,
419  scatterOnSky=scatterOnSky,
420  match_tolerance=matchRes.match_tolerance,
421  )
422 
423  def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn):
424  """Remove magnitude outliers, computing a simple zeropoint.
425 
426  Parameters
427  ----------
428  sourceFluxField : `str`
429  Field in source catalog for instrumental fluxes.
430  refFluxField : `str`
431  Field in reference catalog for fluxes (nJy).
432  matchesIn : `list` [`lsst.afw.table.ReferenceMatch`]
433  List of source/reference matches input
434 
435  Returns
436  -------
437  matchesOut : `list` [`lsst.afw.table.ReferenceMatch`]
438  List of source/reference matches with magnitude
439  outliers removed.
440  """
441  nMatch = len(matchesIn)
442  sourceMag = np.zeros(nMatch)
443  refMag = np.zeros(nMatch)
444  for i, match in enumerate(matchesIn):
445  sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField])
446  refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag)
447 
448  deltaMag = refMag - sourceMag
449  # Protect against negative fluxes and nans in the reference catalog.
450  goodDelta, = np.where(np.isfinite(deltaMag))
451  zp = np.median(deltaMag[goodDelta])
452  # Use median absolute deviation (MAD) for zpSigma.
453  # Also require a minimum scatter to prevent floating-point errors from
454  # rejecting objects in zero-noise tests.
455  zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale='normal'),
456  1e-3,
457  None)
458 
459  self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
460  zp, zpSigma)
461 
462  goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
463  <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
464 
465  nOutlier = nMatch - goodStars.size
466  self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.",
467  nOutlier, nMatch)
468 
469  matchesOut = []
470  for matchInd in goodStars:
471  matchesOut.append(matchesIn[matchInd])
472 
473  return matchesOut
def solve(self, exposure, sourceCat)
Definition: astrometry.py:182
def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn)
Definition: astrometry.py:423
def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, match_tolerance, exposure=None)
Definition: astrometry.py:326
def run(self, sourceCat, exposure)
Definition: astrometry.py:132
def __init__(self, refObjLoader, schema=None, **kwargs)
Definition: astrometry.py:120
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