193 def run(self, sourceCat, exposure, exposure_region=None):
194 """Load reference objects, match sources and optionally fit a WCS.
196 This is a thin layer around solve or loadAndMatch, depending on
197 config.forceKnownWcs.
201 exposure : `lsst.afw.image.Exposure`
202 The exposure whose WCS is to be fit.
203 The following are read only:
205 - filter (may be unset)
206 - detector (if wcs is pure tangent; may be absent)
208 The following are updated:
209 - wcs (the initial value is used as an initial guess, and is
211 sourceCat : `lsst.afw.table.SourceCatalog`
212 The catalog of sources detected on the exposure.
213 exposure_region : `lsst.sphgeom.Region`, optional
214 The exposure region to use for the for the reference catalog
215 filtering. If `None`, this region will be set as a padded bbox +
216 current WCS of the exposure.
221 result : `lsst.pipe.base.Struct`
224 - ``refCat`` : reference object catalog of objects that overlap the
225 exposure (with some margin) (`lsst.afw.table.SimpleCatalog`).
226 - ``matches`` : astrometric matches
227 (`list` of `lsst.afw.table.ReferenceMatch`).
228 - ``scatterOnSky`` : median on-sky separation between reference
229 objects and sources in "matches"
230 (`lsst.afw.geom.Angle`) or `None` if config.forceKnownWcs True
231 - ``matchMeta`` : metadata needed to unpersist matches
232 (`lsst.daf.base.PropertyList`)
233 - ``sip`` : a nested struct returned by
234 `FitSipApproximationTask.run`.
237 raise RuntimeError(
"Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
238 if self.config.forceKnownWcs:
239 res = self.
loadAndMatch(exposure=exposure, sourceCat=sourceCat)
240 res.scatterOnSky =
None
242 res = self.
solve(exposure=exposure, sourceCat=sourceCat, exposure_region=exposure_region)
243 if self.config.doFitSipApproximation:
244 res.sip = self.fitSipApproximation.
run(wcs=exposure.getWcs(), bbox=exposure.getBBox())
245 exposure.setWcs(res.sip.wcs)
249 def solve(self, exposure, sourceCat, exposure_region=None):
250 """Load reference objects overlapping an exposure, match to sources and
255 exposure : `lsst.afw.image.Exposure`
256 The exposure whose WCS is to be fit
257 The following are read only:
259 - filter (may be unset)
260 - detector (if wcs is pure tangent; may be absent)
262 The following are updated:
263 - wcs (the initial value is used as an initial guess, and is
265 sourceCat : `lsst.afw.table.SourceCatalog`
266 The catalog of sources detected on the exposure.
267 exposure_region : `lsst.sphgeom.Region`, optional
268 The exposure region to use for the for the reference catalog
269 filtering. If `None`, this region will be set as a padded bbox +
270 current WCS of the exposure.
274 result : `lsst.pipe.base.Struct`
275 Result struct with components:
277 - ``refCat`` : reference object catalog of objects that overlap the
278 exposure (with some margin) (`lsst::afw::table::SimpleCatalog`).
279 - ``matches`` : astrometric matches
280 (`list` of `lsst.afw.table.ReferenceMatch`).
281 - ``scatterOnSky`` : median on-sky separation between reference
282 objects and sources in "matches" (`lsst.geom.Angle`)
283 - ``matchMeta`` : metadata needed to unpersist matches
284 (`lsst.daf.base.PropertyList`)
289 If the measured mean on-sky distance between the matched source and
290 reference objects is greater than
291 ``self.config.maxMeanDistanceArcsec``.
295 ignores config.forceKnownWcs
298 raise RuntimeError(
"Running matcher task with no refObjLoader set in __init__ or setRefObjLoader")
302 epoch = exposure.visitInfo.date.toAstropy()
303 band = exposure.filter.bandLabel
305 sourceSelection = self.sourceSelector.
run(sourceCat)
307 self.log.info(
"Purged %d sources, leaving %d good sources",
308 len(sourceCat) - len(sourceSelection.sourceCat),
309 len(sourceSelection.sourceCat))
310 if len(sourceSelection.sourceCat) == 0:
312 "No good sources selected for astrometry.",
313 lenSourceSelectionCat=len(sourceSelection.sourceCat)
316 if exposure_region
is not None:
318 region=exposure_region,
321 wcsForCentroids=exposure.wcs,
324 self.log.warning(
"Note that the astrometry_ref_loader.pixelMargin (currently "
325 "set to %d) is ignored when loading the reference catalog "
326 "with an exposure_region (i.e. the region is used as is, with "
327 "no additional padding).", self.
refObjLoader.config.pixelMargin)
330 bbox=exposure.getBBox(),
336 refSelection = self.referenceSelector.
run(loadResult.refCat, exposure=exposure)
337 refCat = refSelection.sourceCat
339 if self.config.doFiducialZeroPointCull:
342 loadResult.fluxField,
343 refCat, sourceSelection.sourceCat,
344 exposure.visitInfo.getExposureTime()
349 if not refCat.isContiguous():
350 refCat = refCat.copy(deep=
True)
353 frame = int(debug.frame)
356 sourceCat=sourceSelection.sourceCat,
358 bbox=exposure.getBBox(),
360 title=
"Reference catalog",
363 result = pipeBase.Struct(matchTolerance=
None)
364 maxMatchDistance = np.inf
366 while (maxMatchDistance > self.config.minMatchDistanceArcSec
and i < self.config.maxIter):
372 goodSourceCat=sourceSelection.sourceCat,
373 refFluxField=loadResult.fluxField,
374 bbox=exposure.getBBox(),
377 matchTolerance=result.matchTolerance,
379 exposure.setWcs(result.wcs)
381 e._metadata[
'iterations'] = i
382 sourceCat[
"coord_ra"] = np.nan
383 sourceCat[
"coord_dec"] = np.nan
384 exposure.setWcs(
None)
385 self.log.error(
"Failure fitting astrometry. %s: %s", type(e).__name__, e)
389 maxMatchDistance = result.stats.maxMatchDist.asArcseconds()
390 distMean = result.stats.distMean.asArcseconds()
391 distStdDev = result.stats.distStdDev.asArcseconds()
392 self.log.info(
"Astrometric fit iteration %d: found %d matches with mean separation "
393 "= %0.3f +- %0.3f arcsec; max match distance = %0.3f arcsec.",
394 i, len(result.matches), distMean, distStdDev, maxMatchDistance)
399 md = exposure.getMetadata()
400 md[
'SFM_ASTROM_OFFSET_MEAN'] = distMean
401 md[
'SFM_ASTROM_OFFSET_STD'] = distStdDev
402 md[
'SFM_ASTROM_OFFSET_MEDIAN'] = result.scatterOnSky.asArcseconds()
405 for m
in result.matches:
406 m.second.set(self.
usedKey,
True)
409 bbox=exposure.getBBox(),
415 return pipeBase.Struct(
417 matches=result.matches,
418 scatterOnSky=result.scatterOnSky,
422 def check(self, exposure, sourceCat, nMatches):
423 """Validate the astrometric fit against the maxMeanDistance threshold.
425 If the distMean metric does not satisfy the requirement of being less
426 than the value set in config.maxMeanDistanceArcsec, the WCS on the
427 exposure will be set to None and the coordinate values in the
428 source catalog will be set to NaN to reflect a failed astrometric fit.
432 exposure : `lsst.afw.image.Exposure`
433 The exposure whose astrometric fit is being evaluated.
434 sourceCat : `lsst.afw.table.SourceCatalog`
435 The catalog of sources associated with the exposure.
437 The number of matches that were found and used during
438 the astrometric fit (for logging purposes only).
443 If the measured mean on-sky distance between the matched source and
444 reference objects is greater than
445 ``self.config.maxMeanDistanceArcsec``.
448 md = exposure.getMetadata()
449 distMean = md[
'SFM_ASTROM_OFFSET_MEAN']
450 distMedian = md[
'SFM_ASTROM_OFFSET_MEDIAN']
451 if distMean > self.config.maxMeanDistanceArcsec:
453 maxMeanDist=self.config.maxMeanDistanceArcsec,
454 distMedian=distMedian)
455 exposure.setWcs(
None)
456 sourceCat[
"coord_ra"] = np.nan
457 sourceCat[
"coord_dec"] = np.nan
458 self.log.error(exception)
463 def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, matchTolerance,
465 """Match sources to reference objects and fit a WCS.
469 refCat : `lsst.afw.table.SimpleCatalog`
470 catalog of reference objects
471 sourceCat : `lsst.afw.table.SourceCatalog`
472 catalog of sources detected on the exposure
473 goodSourceCat : `lsst.afw.table.SourceCatalog`
474 catalog of down-selected good sources detected on the exposure
476 field of refCat to use for flux
477 bbox : `lsst.geom.Box2I`
478 bounding box of exposure
479 wcs : `lsst.afw.geom.SkyWcs`
480 initial guess for WCS of exposure
481 matchTolerance : `lsst.meas.astrom.MatchTolerance`
482 a MatchTolerance object (or None) specifying
483 internal tolerances to the matcher. See the MatchTolerance
484 definition in the respective matcher for the class definition.
485 exposure : `lsst.afw.image.Exposure`, optional
486 exposure whose WCS is to be fit, or None; used only for the debug
491 result : `lsst.pipe.base.Struct`
492 Result struct with components:
494 - ``matches``: astrometric matches
495 (`list` of `lsst.afw.table.ReferenceMatch`).
496 - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs).
497 - ``scatterOnSky`` : median on-sky separation between reference
498 objects and sources in "matches" (`lsst.afw.geom.Angle`).
503 sourceFluxField =
"slot_%sFlux_instFlux" % (self.config.sourceFluxType)
505 matchRes = self.matcher.matchObjectsToSources(
507 sourceCat=goodSourceCat,
509 sourceFluxField=sourceFluxField,
510 refFluxField=refFluxField,
511 matchTolerance=matchTolerance,
514 self.log.debug(
"Found %s matches", len(matchRes.matches))
516 frame = int(debug.frame)
519 sourceCat=matchRes.usableSourceCat,
520 matches=matchRes.matches,
527 if self.config.doMagnitudeOutlierRejection:
530 matches = matchRes.matches
532 self.log.debug(
"Fitting WCS")
533 fitRes = self.wcsFitter.fitWcs(
542 scatterOnSky = fitRes.scatterOnSky
544 frame = int(debug.frame)
547 sourceCat=matchRes.usableSourceCat,
552 title=f
"Fitter: {self.wcsFitter._DefaultName}",
555 return pipeBase.Struct(
558 scatterOnSky=scatterOnSky,
559 matchTolerance=matchRes.matchTolerance,
563 """Remove magnitude outliers, computing a simple zeropoint.
567 sourceFluxField : `str`
568 Field in source catalog for instrumental fluxes.
570 Field in reference catalog for fluxes (nJy).
571 matchesIn : `list` [`lsst.afw.table.ReferenceMatch`]
572 List of source/reference matches input
576 matchesOut : `list` [`lsst.afw.table.ReferenceMatch`]
577 List of source/reference matches with magnitude
580 nMatch = len(matchesIn)
581 sourceMag = np.zeros(nMatch)
582 refMag = np.zeros(nMatch)
583 for i, match
in enumerate(matchesIn):
584 sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField])
585 refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag)
587 deltaMag = refMag - sourceMag
589 goodDelta, = np.where(np.isfinite(deltaMag))
590 zp = np.median(deltaMag[goodDelta])
594 zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale=
'normal'),
598 self.log.info(
"Rough zeropoint from astrometry matches is %.4f +/- %.4f.",
601 goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp)
602 <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)]
604 nOutlier = nMatch - goodStars.size
605 self.log.info(
"Removed %d magnitude outliers out of %d total astrometry matches.",
608 matchesOut = [matchesIn[idx]
for idx
in goodStars]
613 """Compute the median ref flux - source filter color difference.
615 The median difference in color between the flux field used for
616 selection and that of the observations being calibrated is computed
617 from the values for each in the reference catalog.
622 Bandpass of observed data.
624 Name of the flux field used in the reference catalog.
625 refCat : `lsst.afw.table.SimpleCatalog`
626 Catalog of reference objects from which to compute the color
631 refColorDelta : `float`
632 The median color difference.
634 if band
in self.config.filterMap:
635 refFilterNameForBand = self.config.filterMap.get(band) +
"_flux"
636 refCatTemp = refCat[(np.isfinite(refCat[refFluxField])
637 & np.isfinite(refCat[refFilterNameForBand]))].copy(deep=
True)
638 if len(refCatTemp) < 3:
639 refColorDeltaDefaults = self.config.refColorDeltaDefaults
640 if band
in refColorDeltaDefaults:
641 refColorDelta = refColorDeltaDefaults[band]
643 self.log.warning(
"Band %s was not found in refColorDeltaDefaults dict "
644 "(currently set as: %s. Will use a default of 0.0 (i.e. "
645 "no color shift).", band, self.config.refColorDeltaDefaults)
647 self.log.warning(
"Not enough reference sources with finite fluxes in %s and %s, "
648 "so can't compute color shift; a default vaulue of %.2f will "
649 "be applied.", refFluxField, refFilterNameForBand, refColorDelta)
651 refMag = (refCatTemp[refFluxField]*units.nJy).to_value(units.ABmag)
652 refMagSrcBand = (refCatTemp[refFilterNameForBand]*units.nJy).to_value(units.ABmag)
653 refColorDelta = np.nanmedian(refMag - refMagSrcBand)
654 self.log.info(
"Adjusting refCat cutoffs for color shift: %s - %s = %.2f.",
655 refFluxField, refFilterNameForBand, refColorDelta)
658 self.log.warning(
"Band %s not found in filterMap: %s. No adjustment for filter "
659 "differences between reference and source catalogs attempted.",
660 band, self.config.filterMap)
664 """Perform a culling of the catalogs to attempt to match their
665 effective magnitude ranges.
667 This uses a fiducial zeropoint along with the exposure time for
668 the observations to compute our best-guess magnitudes. Also,
669 accommodation is made for the median difference in color between
670 the flux field used for selection and that of the observations being
671 calibrated, which is computed from the values for each in the reference
677 Bandpass of observed data.
679 Name of the flux field used in the reference catalog.
680 refCat : `lsst.afw.table.SimpleCatalog`
681 Catalog of reference objects to be passed to the matcher. Modified
683 sourceCat : `lsst.afw.table.SourceCatalog`
684 Catalog of observed sources to be passed to the matcher. Modified
687 Exposure time of the observation being calibrated.
691 refColorDelta : `float`
692 The median color difference.
694 nRefCatPreCull = len(refCat)
695 nSelectedSourceCat = len(sourceCat)
697 psfFlux = sourceCat[
"base_PsfFlux_instFlux"]
698 fiducialZeroPoint = self.config.fiducialZeroPoint[band]
699 psfMag = -2.5*np.log10(psfFlux) + fiducialZeroPoint + 2.5*np.log10(expTime)
700 sourceMagMin = min(psfMag) - self.config.cullMagBuffer
701 sourceMagMax = max(psfMag) + self.config.cullMagBuffer
706 if refColorDelta > self.config.cullMagBuffer:
708 sourceMagMin += 0.5*refColorDelta
709 sourceMagMax += 0.5*refColorDelta
710 if refColorDelta > 3.0*self.config.cullMagBuffer:
713 sourceMagMin = np.nanmin((refCat[refFluxField]*units.nJy).to_value(units.ABmag))
714 sourceMagMax += 0.5*refColorDelta
715 if refColorDelta < -1.0*self.config.cullMagBuffer:
719 sourceMagMin = np.nanmin((refCat[refFluxField]*units.nJy).to_value(units.ABmag))
720 sourceMagMax += refColorDelta
721 self.log.debug(
"Number of sources = %d; Number of refs = %d; refs/source = %.2f.",
722 nSelectedSourceCat, nRefCatPreCull, nRefCatPreCull/nSelectedSourceCat)
728 if nRefCatPreCull/nSelectedSourceCat > self.config.maxRefToSourceRatio:
729 sourceMagMin = np.nanmin((refCat[refFluxField]*units.nJy).to_value(units.ABmag))
731 self.log.info(
"Source selection: sourceMag (min, max) = (%.3f, %.3f)", sourceMagMin, sourceMagMax)
732 refCat = refCat[np.isfinite(refCat[refFluxField])]
733 refMag = (refCat[refFluxField]*units.nJy).to_value(units.ABmag)
734 refCat = refCat[(refMag < sourceMagMax) & (refMag > sourceMagMin)]
735 refMagMin = np.nanmin(refMag)
736 refMagMax = np.nanmax(refMag)
738 goodSources = ((psfMag < refMagMax - refColorDelta) & (psfMag > refMagMin - refColorDelta))
739 if len(goodSources) < np.sum(goodSources):
740 sourceCat = sourceCat[goodSources].copy(deep=
True)
741 nSelectedSourceCat = len(sourceCat)
742 self.log.debug(
"Number of sources = %d; Number of refs = %d; refs/source = %.2f.",
743 nSelectedSourceCat, nRefCatPreCull, nRefCatPreCull/nSelectedSourceCat)
745 self.log.info(
"Final: Selected %d/%d reference sources based on fiducial zeropoint culling. "
746 "Mag range in %s = (%.2f, %.2f)", len(refCat), nRefCatPreCull,
747 refFluxField, refMagMin, refMagMax)
748 return refCat, sourceCat