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