LSST Applications g063fba187b+cac8b7c890,g0f08755f38+6aee506743,g1653933729+a8ce1bb630,g168dd56ebc+a8ce1bb630,g1a2382251a+b4475c5878,g1dcb35cd9c+8f9bc1652e,g20f6ffc8e0+6aee506743,g217e2c1bcf+73dee94bd0,g28da252d5a+1f19c529b9,g2bbee38e9b+3f2625acfc,g2bc492864f+3f2625acfc,g3156d2b45e+6e55a43351,g32e5bea42b+1bb94961c2,g347aa1857d+3f2625acfc,g35bb328faa+a8ce1bb630,g3a166c0a6a+3f2625acfc,g3e281a1b8c+c5dd892a6c,g3e8969e208+a8ce1bb630,g414038480c+5927e1bc1e,g41af890bb2+8a9e676b2a,g7af13505b9+809c143d88,g80478fca09+6ef8b1810f,g82479be7b0+f568feb641,g858d7b2824+6aee506743,g89c8672015+f4add4ffd5,g9125e01d80+a8ce1bb630,ga5288a1d22+2903d499ea,gb58c049af0+d64f4d3760,gc28159a63d+3f2625acfc,gcab2d0539d+b12535109e,gcf0d15dbbd+46a3f46ba9,gda6a2b7d83+46a3f46ba9,gdaeeff99f8+1711a396fd,ge79ae78c31+3f2625acfc,gef2f8181fd+0a71e47438,gf0baf85859+c1f95f4921,gfa517265be+6aee506743,gfa999e8aa5+17cd334064,w.2024.51
LSST Data Management Base Package
Loading...
Searching...
No Matches
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
88 def setDefaults(self):
89 super().setDefaults()
90 # Astrometry needs sources to be isolated, too.
91 self.sourceSelector["science"].doRequirePrimary = True
92 self.sourceSelector["science"].doIsolated = True
93
94
96 """Match an input source catalog with objects from a reference catalog and
97 solve for the WCS.
98
99 This task is broken into two main subasks: matching and WCS fitting which
100 are very interactive. The matching here can be considered in part a first
101 pass WCS fitter due to the fitter's sensitivity to outliers.
102
103 Parameters
104 ----------
105 refObjLoader : `lsst.meas.algorithms.ReferenceLoader`
106 A reference object loader object; gen3 pipeline tasks will pass `None`
107 and call `setRefObjLoader` in `runQuantum`.
108 schema : `lsst.afw.table.Schema`
109 Used to set "calib_astrometry_used" flag in output source catalog.
110 **kwargs
111 Additional keyword arguments for pipe_base
112 `lsst.pipe.base.Task.__init__`.
113 """
114 ConfigClass = AstrometryConfig
115 _DefaultName = "astrometricSolver"
116
117 def __init__(self, refObjLoader=None, schema=None, **kwargs):
118 RefMatchTask.__init__(self, refObjLoader=refObjLoader, **kwargs)
119
120 if schema is not None:
121 self.usedKey = schema.addField("calib_astrometry_used", type="Flag",
122 doc="set if source was used in astrometric calibration")
123 else:
124 self.usedKey = None
125
126 self.makeSubtask("wcsFitter")
127
128 @timeMethod
129 def run(self, sourceCat, exposure):
130 """Load reference objects, match sources and optionally fit a WCS.
131
132 This is a thin layer around solve or loadAndMatch, depending on
133 config.forceKnownWcs.
134
135 Parameters
136 ----------
137 exposure : `lsst.afw.image.Exposure`
138 exposure whose WCS is to be fit
139 The following are read only:
140
141 - bbox
142 - filter (may be unset)
143 - detector (if wcs is pure tangent; may be absent)
144
145 The following are updated:
146
147 - wcs (the initial value is used as an initial guess, and is
148 required)
149
150 sourceCat : `lsst.afw.table.SourceCatalog`
151 catalog of sources detected on the exposure
152
153 Returns
154 -------
155 result : `lsst.pipe.base.Struct`
156 with these fields:
157
158 - ``refCat`` : reference object catalog of objects that overlap the
159 exposure (with some margin) (`lsst.afw.table.SimpleCatalog`).
160 - ``matches`` : astrometric matches
161 (`list` of `lsst.afw.table.ReferenceMatch`).
162 - ``scatterOnSky`` : median on-sky separation between reference
163 objects and sources in "matches"
164 (`lsst.afw.geom.Angle`) or `None` if config.forceKnownWcs True
165 - ``matchMeta`` : metadata needed to unpersist matches
166 (`lsst.daf.base.PropertyList`)
167 """
168 if self.refObjLoader is None:
169 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
170 if self.config.forceKnownWcs:
171 res = self.loadAndMatch(exposure=exposure, sourceCat=sourceCat)
172 res.scatterOnSky = None
173 else:
174 res = self.solve(exposure=exposure, sourceCat=sourceCat)
175 return res
176
177 @timeMethod
178 def solve(self, exposure, sourceCat):
179 """Load reference objects overlapping an exposure, match to sources and
180 fit a WCS
181
182 Returns
183 -------
184 result : `lsst.pipe.base.Struct`
185 Result struct with components:
186
187 - ``refCat`` : reference object catalog of objects that overlap the
188 exposure (with some margin) (`lsst::afw::table::SimpleCatalog`).
189 - ``matches`` : astrometric matches
190 (`list` of `lsst.afw.table.ReferenceMatch`).
191 - ``scatterOnSky`` : median on-sky separation between reference
192 objects and sources in "matches" (`lsst.geom.Angle`)
193 - ``matchMeta`` : metadata needed to unpersist matches
194 (`lsst.daf.base.PropertyList`)
195
196 Raises
197 ------
198 BadAstrometryFit
199 If the measured mean on-sky distance between the matched source and
200 reference objects is greater than
201 ``self.config.maxMeanDistanceArcsec``.
202
203 Notes
204 -----
205 ignores config.forceKnownWcs
206 """
207 if self.refObjLoader is None:
208 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
209 import lsstDebug
210 debug = lsstDebug.Info(__name__)
211
212 epoch = exposure.visitInfo.date.toAstropy()
213
214 sourceSelection = self.sourceSelector.run(sourceCat)
215
216 self.log.info("Purged %d sources, leaving %d good sources",
217 len(sourceCat) - len(sourceSelection.sourceCat),
218 len(sourceSelection.sourceCat))
219
220 loadResult = self.refObjLoader.loadPixelBox(
221 bbox=exposure.getBBox(),
222 wcs=exposure.wcs,
223 filterName=exposure.filter.bandLabel,
224 epoch=epoch,
225 )
226
227 refSelection = self.referenceSelector.run(loadResult.refCat)
228 # Some operations below require catalog contiguity, which is not
229 # guaranteed from the source selector.
230 if not refSelection.sourceCat.isContiguous():
231 refCat = refSelection.sourceCat.copy(deep=True)
232 else:
233 refCat = refSelection.sourceCat
234
235 if debug.display:
236 frame = int(debug.frame)
237 displayAstrometry(
238 refCat=refCat,
239 sourceCat=sourceSelection.sourceCat,
240 exposure=exposure,
241 bbox=exposure.getBBox(),
242 frame=frame,
243 title="Reference catalog",
244 )
245
246 result = pipeBase.Struct(matchTolerance=None)
247 maxMatchDistance = np.inf
248 i = 0
249 while (maxMatchDistance > self.config.minMatchDistanceArcSec and i < self.config.maxIter):
250 i += 1
251 try:
252 result = self._matchAndFitWcs(
253 refCat=refCat,
254 sourceCat=sourceCat,
255 goodSourceCat=sourceSelection.sourceCat,
256 refFluxField=loadResult.fluxField,
257 bbox=exposure.getBBox(),
258 wcs=exposure.wcs,
259 exposure=exposure,
260 matchTolerance=result.matchTolerance,
261 )
262 exposure.setWcs(result.wcs)
263 except exceptions.AstrometryError as e:
264 e._metadata['iterations'] = i
265 sourceCat["coord_ra"] = np.nan
266 sourceCat["coord_dec"] = np.nan
267 exposure.setWcs(None)
268 self.log.error("Failure fitting astrometry. %s: %s", type(e).__name__, e)
269 raise
270
271 result.stats = self._computeMatchStatsOnSky(result.matches)
272 maxMatchDistance = result.stats.maxMatchDist.asArcseconds()
273 distMean = result.stats.distMean.asArcseconds()
274 distStdDev = result.stats.distStdDev.asArcseconds()
275 self.log.info("Astrometric fit iteration %d: found %d matches with mean separation "
276 "= %0.3f +- %0.3f arcsec; max match distance = %0.3f arcsec.",
277 i, len(result.matches), distMean, distStdDev, maxMatchDistance)
278
279 # If fitter converged, record the scatter in the exposure metadata
280 # even if the fit was deemed a failure according to the value of
281 # the maxMeanDistanceArcsec config.
282 md = exposure.getMetadata()
283 md['SFM_ASTROM_OFFSET_MEAN'] = distMean
284 md['SFM_ASTROM_OFFSET_STD'] = distStdDev
285
286 # Poor quality fits are a failure.
287 if distMean > self.config.maxMeanDistanceArcsec:
288 exception = exceptions.BadAstrometryFit(nMatches=len(result.matches), iterations=i,
289 distMean=distMean,
290 maxMeanDist=self.config.maxMeanDistanceArcsec,
291 distMedian=result.scatterOnSky.asArcseconds())
292 exposure.setWcs(None)
293 sourceCat["coord_ra"] = np.nan
294 sourceCat["coord_dec"] = np.nan
295 self.log.error(exception)
296 raise exception
297
298 if self.usedKey:
299 for m in result.matches:
300 m.second.set(self.usedKey, True)
301
302 matchMeta = self.refObjLoader.getMetadataBox(
303 bbox=exposure.getBBox(),
304 wcs=exposure.wcs,
305 filterName=exposure.filter.bandLabel,
306 epoch=epoch,
307 )
308
309 return pipeBase.Struct(
310 refCat=refCat,
311 matches=result.matches,
312 scatterOnSky=result.scatterOnSky,
313 matchMeta=matchMeta,
314 )
315
316 @timeMethod
317 def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, matchTolerance,
318 exposure=None):
319 """Match sources to reference objects and fit a WCS.
320
321 Parameters
322 ----------
323 refCat : `lsst.afw.table.SimpleCatalog`
324 catalog of reference objects
325 sourceCat : `lsst.afw.table.SourceCatalog`
326 catalog of sources detected on the exposure
327 goodSourceCat : `lsst.afw.table.SourceCatalog`
328 catalog of down-selected good sources detected on the exposure
329 refFluxField : 'str'
330 field of refCat to use for flux
331 bbox : `lsst.geom.Box2I`
332 bounding box of exposure
333 wcs : `lsst.afw.geom.SkyWcs`
334 initial guess for WCS of exposure
335 matchTolerance : `lsst.meas.astrom.MatchTolerance`
336 a MatchTolerance object (or None) specifying
337 internal tolerances to the matcher. See the MatchTolerance
338 definition in the respective matcher for the class definition.
339 exposure : `lsst.afw.image.Exposure`, optional
340 exposure whose WCS is to be fit, or None; used only for the debug
341 display.
342
343 Returns
344 -------
345 result : `lsst.pipe.base.Struct`
346 Result struct with components:
347
348 - ``matches``: astrometric matches
349 (`list` of `lsst.afw.table.ReferenceMatch`).
350 - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs).
351 - ``scatterOnSky`` : median on-sky separation between reference
352 objects and sources in "matches" (`lsst.afw.geom.Angle`).
353 """
354 import lsstDebug
355 debug = lsstDebug.Info(__name__)
356
357 sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType)
358
359 matchRes = self.matcher.matchObjectsToSources(
360 refCat=refCat,
361 sourceCat=goodSourceCat,
362 wcs=wcs,
363 sourceFluxField=sourceFluxField,
364 refFluxField=refFluxField,
365 matchTolerance=matchTolerance,
366 bbox=bbox,
367 )
368 self.log.debug("Found %s matches", len(matchRes.matches))
369 if debug.display:
370 frame = int(debug.frame)
371 displayAstrometry(
372 refCat=refCat,
373 sourceCat=matchRes.usableSourceCat,
374 matches=matchRes.matches,
375 exposure=exposure,
376 bbox=bbox,
377 frame=frame + 1,
378 title="Initial WCS",
379 )
380
381 if self.config.doMagnitudeOutlierRejection:
382 matches = self._removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches)
383 else:
384 matches = matchRes.matches
385
386 self.log.debug("Fitting WCS")
387 fitRes = self.wcsFitter.fitWcs(
388 matches=matches,
389 initWcs=wcs,
390 bbox=bbox,
391 refCat=refCat,
392 sourceCat=sourceCat,
393 exposure=exposure,
394 )
395 fitWcs = fitRes.wcs
396 scatterOnSky = fitRes.scatterOnSky
397 if debug.display:
398 frame = int(debug.frame)
399 displayAstrometry(
400 refCat=refCat,
401 sourceCat=matchRes.usableSourceCat,
402 matches=matches,
403 exposure=exposure,
404 bbox=bbox,
405 frame=frame + 2,
406 title=f"Fitter: {self.wcsFitter._DefaultName}",
407 )
408
409 return pipeBase.Struct(
410 matches=matches,
411 wcs=fitWcs,
412 scatterOnSky=scatterOnSky,
413 matchTolerance=matchRes.matchTolerance,
414 )
415
416 def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn):
417 """Remove magnitude outliers, computing a simple zeropoint.
418
419 Parameters
420 ----------
421 sourceFluxField : `str`
422 Field in source catalog for instrumental fluxes.
423 refFluxField : `str`
424 Field in reference catalog for fluxes (nJy).
425 matchesIn : `list` [`lsst.afw.table.ReferenceMatch`]
426 List of source/reference matches input
427
428 Returns
429 -------
430 matchesOut : `list` [`lsst.afw.table.ReferenceMatch`]
431 List of source/reference matches with magnitude
432 outliers removed.
433 """
434 nMatch = len(matchesIn)
435 sourceMag = np.zeros(nMatch)
436 refMag = np.zeros(nMatch)
437 for i, match in enumerate(matchesIn):
438 sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField])
439 refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag)
440
441 deltaMag = refMag - sourceMag
442 # Protect against negative fluxes and nans in the reference catalog.
443 goodDelta, = np.where(np.isfinite(deltaMag))
444 zp = np.median(deltaMag[goodDelta])
445 # Use median absolute deviation (MAD) for zpSigma.
446 # Also require a minimum scatter to prevent floating-point errors from
447 # rejecting objects in zero-noise tests.
448 zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale='normal'),
449 1e-3,
450 None)
451
452 self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
453 zp, zpSigma)
454
455 goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
456 <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
457
458 nOutlier = nMatch - goodStars.size
459 self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.",
460 nOutlier, nMatch)
461
462 matchesOut = [matchesIn[idx] for idx in goodStars]
463
464 return matchesOut
_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:119