Loading [MathJax]/extensions/tex2jax.js
LSST Applications g04a91732dc+9666464c73,g0fba68d861+079660c10e,g1fd858c14a+94f68680cf,g208c678f98+627fe8cd4e,g271391ec13+ac98094cfc,g2c84ff76c0+12036dbf49,g2c9e612ef2+a92a2e6025,g35bb328faa+fcb1d3bbc8,g4d2262a081+bcdfaf528c,g4e0f332c67+c58e4b632d,g53246c7159+fcb1d3bbc8,g60b5630c4e+a92a2e6025,g67b6fd64d1+9d1b2ab50a,g78460c75b0+2f9a1b4bcd,g786e29fd12+cf7ec2a62a,g7b71ed6315+fcb1d3bbc8,g8852436030+506db7da85,g89139ef638+9d1b2ab50a,g8d6b6b353c+a92a2e6025,g9125e01d80+fcb1d3bbc8,g989de1cb63+9d1b2ab50a,g9f33ca652e+d1749da127,ga2b97cdc51+a92a2e6025,gabe3b4be73+1e0a283bba,gb1101e3267+6ecbd0580e,gb58c049af0+f03b321e39,gb89ab40317+9d1b2ab50a,gb90eeb9370+384e1fc23b,gcf25f946ba+506db7da85,gd315a588df+382ef11c06,gd6cbbdb0b4+75aa4b1db4,gd9a9a58781+fcb1d3bbc8,gde0f65d7ad+a095917f21,ge278dab8ac+c61fbefdff,ge410e46f29+9d1b2ab50a,ge82c20c137+e12a08b75a,gf67bdafdda+9d1b2ab50a,gfd5510ef7b+df344d16e5,v29.0.0.rc2
LSST Data Management Base Package
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
astrometry.py
Go to the documentation of this file.
1# This file is part of meas_astrom.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21
22__all__ = ["AstrometryConfig", "AstrometryTask"]
23
24import numpy as np
25from astropy import units
26import scipy.stats
27
28import lsst.pex.config as pexConfig
29import lsst.pipe.base as pipeBase
30from lsst.utils.timer import timeMethod
31from . import exceptions
32from .ref_match import RefMatchTask, RefMatchConfig
33from .fitTanSipWcs import FitTanSipWcsTask
34from .display import displayAstrometry
35
36
38 """Config for AstrometryTask.
39 """
40 wcsFitter = pexConfig.ConfigurableField(
41 target=FitTanSipWcsTask,
42 doc="WCS fitter",
43 )
44 forceKnownWcs = pexConfig.Field(
45 dtype=bool,
46 doc="If True then load reference objects and match sources but do not fit a WCS; "
47 "this simply controls whether 'run' calls 'solve' or 'loadAndMatch'",
48 default=False,
49 )
50 maxIter = pexConfig.RangeField(
51 doc="maximum number of iterations of match sources and fit WCS"
52 "ignored if not fitting a WCS",
53 dtype=int,
54 default=3,
55 min=1,
56 )
57 minMatchDistanceArcSec = pexConfig.RangeField(
58 doc="the match distance below which further iteration is pointless (arcsec); "
59 "ignored if not fitting a WCS",
60 dtype=float,
61 default=0.001,
62 min=0,
63 )
64 maxMeanDistanceArcsec = pexConfig.RangeField(
65 doc="Maximum mean on-sky distance (in arcsec) between matched source and reference "
66 "objects post-fit. A mean distance greater than this threshold raises BadAstrometryFit "
67 "and the WCS fit is considered a failure. The default is set to the maximum tolerated "
68 "by the external global calibration (e.g. jointcal) step for conceivable recovery; "
69 "the appropriate value will be dataset and workflow dependent.",
70 dtype=float,
71 default=0.5,
72 min=0,
73 )
74 doMagnitudeOutlierRejection = pexConfig.Field(
75 dtype=bool,
76 doc=("If True then a rough zeropoint will be computed from matched sources "
77 "and outliers will be rejected in the iterations."),
78 default=True,
79 )
80 magnitudeOutlierRejectionNSigma = pexConfig.Field(
81 dtype=float,
82 doc=("Number of sigma (measured from the distribution) in magnitude "
83 "for a potential reference/source match to be rejected during "
84 "iteration."),
85 default=3.0,
86 )
87 fiducialZeroPoint = pexConfig.DictField(
88 keytype=str,
89 itemtype=float,
90 doc="Fiducial zeropoint values, keyed by band.",
91 default=None,
92 optional=True,
93 )
94 doFiducialZeroPointCull = pexConfig.Field(
95 dtype=bool,
96 doc="If True, use the obs_package-defined fiducial zeropoint values to compute the "
97 "expected zeropoint for the current exposure. This is for use in culling reference "
98 "objects down to the approximate magnitude range of the detection source catalog "
99 "used for astrometric calibration.",
100 default=False,
101 )
102 cullMagBuffer = pexConfig.Field(
103 dtype=float,
104 doc="Generous buffer on the fiducial zero point culling to account for observing "
105 "condition variations and uncertainty of the fiducial values.",
106 default=0.3,
107 optional=True,
108 )
109
110 def setDefaults(self):
111 super().setDefaults()
112 # Astrometry needs sources to be isolated, too.
113 self.sourceSelector["science"].doRequirePrimary = True
114 self.sourceSelector["science"].doIsolated = True
115 self.referenceSelector.doCullFromMaskedRegion = True
116
117 def validate(self):
118 super().validate()
119 if self.doFiducialZeroPointCull and self.fiducialZeroPoint is None:
120 msg = "doFiducialZeroPointCull=True requires `fiducialZeroPoint`, a dict of the "
121 "fiducial zeropoints measured for the camera/filter, be set."
122 raise pexConfig.FieldValidationError(AstrometryConfig.fiducialZeroPoint, self, msg)
123
124
126 """Match an input source catalog with objects from a reference catalog and
127 solve for the WCS.
128
129 This task is broken into two main subasks: matching and WCS fitting which
130 are very interactive. The matching here can be considered in part a first
131 pass WCS fitter due to the fitter's sensitivity to outliers.
132
133 Parameters
134 ----------
135 refObjLoader : `lsst.meas.algorithms.ReferenceLoader`
136 A reference object loader object; gen3 pipeline tasks will pass `None`
137 and call `setRefObjLoader` in `runQuantum`.
138 schema : `lsst.afw.table.Schema`
139 Used to set "calib_astrometry_used" flag in output source catalog.
140 **kwargs
141 Additional keyword arguments for pipe_base
142 `lsst.pipe.base.Task.__init__`.
143 """
144 ConfigClass = AstrometryConfig
145 _DefaultName = "astrometricSolver"
146
147 def __init__(self, refObjLoader=None, schema=None, **kwargs):
148 RefMatchTask.__init__(self, refObjLoader=refObjLoader, **kwargs)
149
150 if schema is not None:
151 self.usedKey = schema.addField("calib_astrometry_used", type="Flag",
152 doc="set if source was used in astrometric calibration")
153 else:
154 self.usedKey = None
155
156 self.makeSubtask("wcsFitter")
157
158 @timeMethod
159 def run(self, sourceCat, exposure):
160 """Load reference objects, match sources and optionally fit a WCS.
161
162 This is a thin layer around solve or loadAndMatch, depending on
163 config.forceKnownWcs.
164
165 Parameters
166 ----------
167 exposure : `lsst.afw.image.Exposure`
168 exposure whose WCS is to be fit
169 The following are read only:
170
171 - bbox
172 - filter (may be unset)
173 - detector (if wcs is pure tangent; may be absent)
174
175 The following are updated:
176
177 - wcs (the initial value is used as an initial guess, and is
178 required)
179
180 sourceCat : `lsst.afw.table.SourceCatalog`
181 catalog of sources detected on the exposure
182
183 Returns
184 -------
185 result : `lsst.pipe.base.Struct`
186 with these fields:
187
188 - ``refCat`` : reference object catalog of objects that overlap the
189 exposure (with some margin) (`lsst.afw.table.SimpleCatalog`).
190 - ``matches`` : astrometric matches
191 (`list` of `lsst.afw.table.ReferenceMatch`).
192 - ``scatterOnSky`` : median on-sky separation between reference
193 objects and sources in "matches"
194 (`lsst.afw.geom.Angle`) or `None` if config.forceKnownWcs True
195 - ``matchMeta`` : metadata needed to unpersist matches
196 (`lsst.daf.base.PropertyList`)
197 """
198 if self.refObjLoader is None:
199 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
200 if self.config.forceKnownWcs:
201 res = self.loadAndMatch(exposure=exposure, sourceCat=sourceCat)
202 res.scatterOnSky = None
203 else:
204 res = self.solve(exposure=exposure, sourceCat=sourceCat)
205 return res
206
207 @timeMethod
208 def solve(self, exposure, sourceCat):
209 """Load reference objects overlapping an exposure, match to sources and
210 fit a WCS
211
212 Returns
213 -------
214 result : `lsst.pipe.base.Struct`
215 Result struct with components:
216
217 - ``refCat`` : reference object catalog of objects that overlap the
218 exposure (with some margin) (`lsst::afw::table::SimpleCatalog`).
219 - ``matches`` : astrometric matches
220 (`list` of `lsst.afw.table.ReferenceMatch`).
221 - ``scatterOnSky`` : median on-sky separation between reference
222 objects and sources in "matches" (`lsst.geom.Angle`)
223 - ``matchMeta`` : metadata needed to unpersist matches
224 (`lsst.daf.base.PropertyList`)
225
226 Raises
227 ------
228 BadAstrometryFit
229 If the measured mean on-sky distance between the matched source and
230 reference objects is greater than
231 ``self.config.maxMeanDistanceArcsec``.
232
233 Notes
234 -----
235 ignores config.forceKnownWcs
236 """
237 if self.refObjLoader is None:
238 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
239 import lsstDebug
240 debug = lsstDebug.Info(__name__)
241
242 epoch = exposure.visitInfo.date.toAstropy()
243
244 sourceSelection = self.sourceSelector.run(sourceCat)
245
246 self.log.info("Purged %d sources, leaving %d good sources",
247 len(sourceCat) - len(sourceSelection.sourceCat),
248 len(sourceSelection.sourceCat))
249 if len(sourceSelection.sourceCat) == 0:
251 "No good sources selected for astrometry.",
252 lenSourceSelectionCat=len(sourceSelection.sourceCat)
253 )
254
255 loadResult = self.refObjLoader.loadPixelBox(
256 bbox=exposure.getBBox(),
257 wcs=exposure.wcs,
258 filterName=exposure.filter.bandLabel,
259 epoch=epoch,
260 )
261 refSelection = self.referenceSelector.run(loadResult.refCat, exposure=exposure)
262 refCat = refSelection.sourceCat
263
264 if self.config.doFiducialZeroPointCull:
265 nRefCatPreCull = len(refCat)
266 # Compute rough limiting magnitudes of selected sources
267 psfFlux = sourceSelection.sourceCat["base_PsfFlux_instFlux"]
268 expTime = exposure.visitInfo.getExposureTime()
269 fiducialZeroPoint = self.config.fiducialZeroPoint[exposure.filter.bandLabel]
270 psfMag = -2.5*np.log10(psfFlux) + fiducialZeroPoint + 2.5*np.log10(expTime)
271 sourceMagMin = min(psfMag) - self.config.cullMagBuffer
272 sourceMagMax = max(psfMag) + self.config.cullMagBuffer
273 self.log.info("sourceMagMin = %0.3f sourceMagMax = %0.3f", sourceMagMin, sourceMagMax)
274
275 refCat = refCat[np.isfinite(refCat[loadResult.fluxField])]
276 refMag = (refCat[loadResult.fluxField]*units.nJy).to_value(units.ABmag)
277 refCat = refCat[(refMag > sourceMagMin) & (refMag < sourceMagMax)]
278 self.log.info("Selected %d/%d reference sources based on fiducial zeropoint culling.",
279 len(refCat), nRefCatPreCull)
280
281 # Some operations below require catalog contiguity, which is not
282 # guaranteed from the source selector.
283 if not refCat.isContiguous():
284 refCat = refCat.copy(deep=True)
285
286 if debug.display:
287 frame = int(debug.frame)
289 refCat=refCat,
290 sourceCat=sourceSelection.sourceCat,
291 exposure=exposure,
292 bbox=exposure.getBBox(),
293 frame=frame,
294 title="Reference catalog",
295 )
296
297 result = pipeBase.Struct(matchTolerance=None)
298 maxMatchDistance = np.inf
299 i = 0
300 while (maxMatchDistance > self.config.minMatchDistanceArcSec and i < self.config.maxIter):
301 i += 1
302 try:
303 result = self._matchAndFitWcs(
304 refCat=refCat,
305 sourceCat=sourceCat,
306 goodSourceCat=sourceSelection.sourceCat,
307 refFluxField=loadResult.fluxField,
308 bbox=exposure.getBBox(),
309 wcs=exposure.wcs,
310 exposure=exposure,
311 matchTolerance=result.matchTolerance,
312 )
313 exposure.setWcs(result.wcs)
314 except exceptions.AstrometryError as e:
315 e._metadata['iterations'] = i
316 sourceCat["coord_ra"] = np.nan
317 sourceCat["coord_dec"] = np.nan
318 exposure.setWcs(None)
319 self.log.error("Failure fitting astrometry. %s: %s", type(e).__name__, e)
320 raise
321
322 result.stats = self._computeMatchStatsOnSky(result.matches)
323 maxMatchDistance = result.stats.maxMatchDist.asArcseconds()
324 distMean = result.stats.distMean.asArcseconds()
325 distStdDev = result.stats.distStdDev.asArcseconds()
326 self.log.info("Astrometric fit iteration %d: found %d matches with mean separation "
327 "= %0.3f +- %0.3f arcsec; max match distance = %0.3f arcsec.",
328 i, len(result.matches), distMean, distStdDev, maxMatchDistance)
329
330 # If fitter converged, record the scatter in the exposure metadata
331 # even if the fit was deemed a failure according to the value of
332 # the maxMeanDistanceArcsec config.
333 md = exposure.getMetadata()
334 md['SFM_ASTROM_OFFSET_MEAN'] = distMean
335 md['SFM_ASTROM_OFFSET_STD'] = distStdDev
336 md['SFM_ASTROM_OFFSET_MEDIAN'] = result.scatterOnSky.asArcseconds()
337
338 if self.usedKey:
339 for m in result.matches:
340 m.second.set(self.usedKey, True)
341
342 matchMeta = self.refObjLoader.getMetadataBox(
343 bbox=exposure.getBBox(),
344 wcs=exposure.wcs,
345 filterName=exposure.filter.bandLabel,
346 epoch=epoch,
347 )
348
349 return pipeBase.Struct(
350 refCat=refCat,
351 matches=result.matches,
352 scatterOnSky=result.scatterOnSky,
353 matchMeta=matchMeta,
354 )
355
356 def check(self, exposure, sourceCat, nMatches):
357 """Validate the astrometric fit against the maxMeanDistance threshold.
358
359 If the distMean metric does not satisfy the requirement of being less
360 than the value set in config.maxMeanDistanceArcsec, the WCS on the
361 exposure will be set to None and the coordinate values in the
362 source catalog will be set to NaN to reflect a failed astrometric fit.
363
364 Parameters
365 ----------
366 exposure : `lsst.afw.image.Exposure`
367 The exposure whose astrometric fit is being evaluated.
368 sourceCat : `lsst.afw.table.SourceCatalog`
369 The catalog of sources associated with the exposure.
370 nMatches : `int`
371 The number of matches that were found and used during
372 the astrometric fit (for logging purposes only).
373
374 Raises
375 ------
376 BadAstrometryFit
377 If the measured mean on-sky distance between the matched source and
378 reference objects is greater than
379 ``self.config.maxMeanDistanceArcsec``.
380 """
381 # Poor quality fits are a failure.
382 md = exposure.getMetadata()
383 distMean = md['SFM_ASTROM_OFFSET_MEAN']
384 distMedian = md['SFM_ASTROM_OFFSET_MEDIAN']
385 if distMean > self.config.maxMeanDistanceArcsec:
386 exception = exceptions.BadAstrometryFit(nMatches=nMatches, distMean=distMean,
387 maxMeanDist=self.config.maxMeanDistanceArcsec,
388 distMedian=distMedian)
389 exposure.setWcs(None)
390 sourceCat["coord_ra"] = np.nan
391 sourceCat["coord_dec"] = np.nan
392 self.log.error(exception)
393 raise exception
394 return
395
396 @timeMethod
397 def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, matchTolerance,
398 exposure=None):
399 """Match sources to reference objects and fit a WCS.
400
401 Parameters
402 ----------
403 refCat : `lsst.afw.table.SimpleCatalog`
404 catalog of reference objects
405 sourceCat : `lsst.afw.table.SourceCatalog`
406 catalog of sources detected on the exposure
407 goodSourceCat : `lsst.afw.table.SourceCatalog`
408 catalog of down-selected good sources detected on the exposure
409 refFluxField : 'str'
410 field of refCat to use for flux
411 bbox : `lsst.geom.Box2I`
412 bounding box of exposure
413 wcs : `lsst.afw.geom.SkyWcs`
414 initial guess for WCS of exposure
415 matchTolerance : `lsst.meas.astrom.MatchTolerance`
416 a MatchTolerance object (or None) specifying
417 internal tolerances to the matcher. See the MatchTolerance
418 definition in the respective matcher for the class definition.
419 exposure : `lsst.afw.image.Exposure`, optional
420 exposure whose WCS is to be fit, or None; used only for the debug
421 display.
422
423 Returns
424 -------
425 result : `lsst.pipe.base.Struct`
426 Result struct with components:
427
428 - ``matches``: astrometric matches
429 (`list` of `lsst.afw.table.ReferenceMatch`).
430 - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs).
431 - ``scatterOnSky`` : median on-sky separation between reference
432 objects and sources in "matches" (`lsst.afw.geom.Angle`).
433 """
434 import lsstDebug
435 debug = lsstDebug.Info(__name__)
436
437 sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType)
438
439 matchRes = self.matcher.matchObjectsToSources(
440 refCat=refCat,
441 sourceCat=goodSourceCat,
442 wcs=wcs,
443 sourceFluxField=sourceFluxField,
444 refFluxField=refFluxField,
445 matchTolerance=matchTolerance,
446 bbox=bbox,
447 )
448 self.log.debug("Found %s matches", len(matchRes.matches))
449 if debug.display:
450 frame = int(debug.frame)
452 refCat=refCat,
453 sourceCat=matchRes.usableSourceCat,
454 matches=matchRes.matches,
455 exposure=exposure,
456 bbox=bbox,
457 frame=frame + 1,
458 title="Initial WCS",
459 )
460
461 if self.config.doMagnitudeOutlierRejection:
462 matches = self._removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches)
463 else:
464 matches = matchRes.matches
465
466 self.log.debug("Fitting WCS")
467 fitRes = self.wcsFitter.fitWcs(
468 matches=matches,
469 initWcs=wcs,
470 bbox=bbox,
471 refCat=refCat,
472 sourceCat=sourceCat,
473 exposure=exposure,
474 )
475 fitWcs = fitRes.wcs
476 scatterOnSky = fitRes.scatterOnSky
477 if debug.display:
478 frame = int(debug.frame)
480 refCat=refCat,
481 sourceCat=matchRes.usableSourceCat,
482 matches=matches,
483 exposure=exposure,
484 bbox=bbox,
485 frame=frame + 2,
486 title=f"Fitter: {self.wcsFitter._DefaultName}",
487 )
488
489 return pipeBase.Struct(
490 matches=matches,
491 wcs=fitWcs,
492 scatterOnSky=scatterOnSky,
493 matchTolerance=matchRes.matchTolerance,
494 )
495
496 def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn):
497 """Remove magnitude outliers, computing a simple zeropoint.
498
499 Parameters
500 ----------
501 sourceFluxField : `str`
502 Field in source catalog for instrumental fluxes.
503 refFluxField : `str`
504 Field in reference catalog for fluxes (nJy).
505 matchesIn : `list` [`lsst.afw.table.ReferenceMatch`]
506 List of source/reference matches input
507
508 Returns
509 -------
510 matchesOut : `list` [`lsst.afw.table.ReferenceMatch`]
511 List of source/reference matches with magnitude
512 outliers removed.
513 """
514 nMatch = len(matchesIn)
515 sourceMag = np.zeros(nMatch)
516 refMag = np.zeros(nMatch)
517 for i, match in enumerate(matchesIn):
518 sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField])
519 refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag)
520
521 deltaMag = refMag - sourceMag
522 # Protect against negative fluxes and nans in the reference catalog.
523 goodDelta, = np.where(np.isfinite(deltaMag))
524 zp = np.median(deltaMag[goodDelta])
525 # Use median absolute deviation (MAD) for zpSigma.
526 # Also require a minimum scatter to prevent floating-point errors from
527 # rejecting objects in zero-noise tests.
528 zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale='normal'),
529 1e-3,
530 None)
531
532 self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
533 zp, zpSigma)
534
535 goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
536 <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
537
538 nOutlier = nMatch - goodStars.size
539 self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.",
540 nOutlier, nMatch)
541
542 matchesOut = [matchesIn[idx] for idx in goodStars]
543
544 return matchesOut
check(self, exposure, sourceCat, nMatches)
_matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, matchTolerance, exposure=None)
__init__(self, refObjLoader=None, schema=None, **kwargs)
_removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn)
loadAndMatch(self, exposure, sourceCat)
Definition ref_match.py:120
displayAstrometry(refCat=None, sourceCat=None, distortedCentroidKey=None, bbox=None, exposure=None, matches=None, frame=1, title="", pause=True)
Definition display.py:34