Coverage for python/lsst/meas/astrom/astrometry.py: 18%
128 statements
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-12 10:15 +0000
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-12 10:15 +0000
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 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 )
87 def setDefaults(self):
88 # Override the default source selector for astrometry tasks
89 self.sourceFluxType = "Ap"
91 self.sourceSelector.name = "matcher"
92 self.sourceSelector["matcher"].sourceFluxType = self.sourceFluxType
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.
99class AstrometryTask(RefMatchTask):
100 """Match an input source catalog with objects from a reference catalog and
101 solve for the WCS.
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.
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"
120 def __init__(self, refObjLoader, schema=None, **kwargs):
121 RefMatchTask.__init__(self, refObjLoader, **kwargs)
123 if schema is not None:
124 self.usedKey = schema.addField("calib_astrometry_used", type="Flag",
125 doc="set if source was used in astrometric calibration")
126 else:
127 self.usedKey = None
129 self.makeSubtask("wcsFitter")
131 @pipeBase.timeMethod
132 def run(self, sourceCat, exposure):
133 """Load reference objects, match sources and optionally fit a WCS.
135 This is a thin layer around solve or loadAndMatch, depending on
136 config.forceKnownWcs.
138 Parameters
139 ----------
140 exposure : `lsst.afw.image.Exposure`
141 exposure whose WCS is to be fit
142 The following are read only:
144 - bbox
145 - photoCalib (may be absent)
146 - filter (may be unset)
147 - detector (if wcs is pure tangent; may be absent)
149 The following are updated:
151 - wcs (the initial value is used as an initial guess, and is
152 required)
154 sourceCat : `lsst.afw.table.SourceCatalog`
155 catalog of sources detected on the exposure
157 Returns
158 -------
159 result : `lsst.pipe.base.Struct`
160 with these fields:
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.refObjLoader is None:
173 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
174 if self.config.forceKnownWcs:
175 res = self.loadAndMatch(exposure=exposure, sourceCat=sourceCat)
176 res.scatterOnSky = None
177 else:
178 res = self.solve(exposure=exposure, sourceCat=sourceCat)
179 return res
181 @pipeBase.timeMethod
182 def solve(self, exposure, sourceCat):
183 """Load reference objects overlapping an exposure, match to sources and
184 fit a WCS
186 Returns
187 -------
188 result : `lsst.pipe.base.Struct`
189 Result struct with components:
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`)
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``.
207 Notes
208 -----
209 ignores config.forceKnownWcs
210 """
211 if self.refObjLoader is None:
212 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
213 import lsstDebug
214 debug = lsstDebug.Info(__name__)
216 expMd = self._getExposureMetadata(exposure)
218 sourceSelection = self.sourceSelector.run(sourceCat)
220 self.log.info("Purged %d sources, leaving %d good sources" %
221 (len(sourceCat) - len(sourceSelection.sourceCat),
222 len(sourceSelection.sourceCat)))
224 loadRes = self.refObjLoader.loadPixelBox(
225 bbox=expMd.bbox,
226 wcs=expMd.wcs,
227 filterName=expMd.filterName,
228 photoCalib=expMd.photoCalib,
229 epoch=expMd.epoch,
230 )
232 refSelection = self.referenceSelector.run(loadRes.refCat)
234 matchMeta = self.refObjLoader.getMetadataBox(
235 bbox=expMd.bbox,
236 wcs=expMd.wcs,
237 filterName=expMd.filterName,
238 photoCalib=expMd.photoCalib,
239 epoch=expMd.epoch,
240 )
242 if debug.display:
243 frame = int(debug.frame)
244 displayAstrometry(
245 refCat=refSelection.sourceCat,
246 sourceCat=sourceSelection.sourceCat,
247 exposure=exposure,
248 bbox=expMd.bbox,
249 frame=frame,
250 title="Reference catalog",
251 )
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(
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
278 match_tolerance = tryRes.match_tolerance
279 tryMatchDist = self._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())
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
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.usedKey:
309 m.second.set(self.usedKey, True)
310 exposure.setWcs(res.wcs)
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()
317 return pipeBase.Struct(
318 refCat=refSelection.sourceCat,
319 matches=res.matches,
320 scatterOnSky=res.scatterOnSky,
321 matchMeta=matchMeta,
322 )
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.
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.
351 Returns
352 -------
353 result : `lsst.pipe.base.Struct`
354 Result struct with components:
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__)
365 sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType)
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)
378 displayAstrometry(
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 )
388 if self.config.doMagnitudeOutlierRejection:
389 matches = self._removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches)
390 else:
391 matches = matchRes.matches
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)
406 displayAstrometry(
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 )
416 return pipeBase.Struct(
417 matches=matches,
418 wcs=fitWcs,
419 scatterOnSky=scatterOnSky,
420 match_tolerance=matchRes.match_tolerance,
421 )
423 def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn):
424 """Remove magnitude outliers, computing a simple zeropoint.
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
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)
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)
459 self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
460 zp, zpSigma)
462 goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
463 <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
465 nOutlier = nMatch - goodStars.size
466 self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.",
467 nOutlier, nMatch)
469 matchesOut = []
470 for matchInd in goodStars:
471 matchesOut.append(matchesIn[matchInd])
473 return matchesOut