Coverage for python/lsst/validate/drp/matchreduce.py : 8%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# LSST Data Management System
2# Copyright 2016-2019 AURA/LSST.
3#
4# This product includes software developed by the
5# LSST Project (http://www.lsst.org/).
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the LSST License Statement and
18# the GNU General Public License along with this program. If not,
19# see <https://www.lsstcorp.org/LegalNotices/>.
20"""Blob classes that reduce a multi-visit dataset and encapsulate data
21for measurement classes, plotting functions, and JSON persistence.
22"""
24__all__ = ['build_matched_dataset', 'getKeysFilter', 'filterSources', 'summarizeSources']
26import numpy as np
27import astropy.units as u
28from sqlalchemy.exc import OperationalError
29import sqlite3
31import lsst.geom as geom
32import lsst.daf.persistence as dafPersist
33from lsst.afw.table import (SourceCatalog, SchemaMapper, Field,
34 MultiMatch, SimpleRecord, GroupView,
35 SOURCE_IO_NO_FOOTPRINTS)
36import lsst.afw.table as afwTable
37from lsst.afw.fits import FitsError
38import lsst.pipe.base as pipeBase
39from lsst.verify import Blob, Datum
41from .util import (getCcdKeyName, raftSensorToInt, positionRmsFromCat,
42 ellipticity_from_cat)
45def build_matched_dataset(repo, dataIds, matchRadius=None, brightSnrMin=None, brightSnrMax=None,
46 faintSnrMin=None, faintSnrMax=None,
47 doApplyExternalPhotoCalib=False, externalPhotoCalibName=None,
48 doApplyExternalSkyWcs=False, externalSkyWcsName=None,
49 skipTEx=False, skipNonSrd=False):
50 """Construct a container for matched star catalogs from multple visits, with filtering,
51 summary statistics, and modelling.
53 `lsst.verify.Blob` instances are serializable to JSON.
55 Parameters
56 ----------
57 repo : `str` or `lsst.daf.persistence.Butler`
58 A Butler instance or a repository URL that can be used to construct
59 one.
60 dataIds : `list` of `dict`
61 List of `butler` data IDs of Image catalogs to compare to reference.
62 The `calexp` cpixel image is needed for the photometric calibration.
63 matchRadius : `lsst.geom.Angle`, optional
64 Radius for matching. Default is 1 arcsecond.
65 brightSnrMin : `float`, optional
66 Minimum median SNR for a source to be considered bright; passed to `filterSources`.
67 brightSnrMax : `float`, optional
68 Maximum median SNR for a source to be considered bright; passed to `filterSources`.
69 faintSnrMin : `float`, optional
70 Minimum median SNR for a source to be considered faint; passed to `filterSources`.
71 faintSnrMax : `float`, optional
72 Maximum median SNR for a source to be considered faint; passed to `filterSources`.
73 doApplyExternalPhotoCalib : bool, optional
74 Apply external photoCalib to calibrate fluxes.
75 externalPhotoCalibName : str, optional
76 Type of external `PhotoCalib` to apply. Currently supported are jointcal,
77 fgcm, and fgcm_tract. Must be set if "doApplyExternalPhotoCalib" is True.
78 doApplyExternalSkyWcs : bool, optional
79 Apply external wcs to calibrate positions.
80 externalSkyWcsName : str, optional:
81 Type of external `wcs` to apply. Currently supported is jointcal.
82 Must be set if "doApplyExternalSkyWcs" is True.
83 skipTEx : `bool`, optional
84 Skip TEx calculations (useful for older catalogs that don't have
85 PsfShape measurements).
86 skipNonSrd : `bool`, optional
87 Skip any metrics not defined in the LSST SRD; default False.
89 Attributes of returned Blob
90 ----------
91 filterName : `str`
92 Name of filter used for all observations.
93 mag : `astropy.units.Quantity`
94 Mean PSF magnitudes of stars over multiple visits (magnitudes).
95 magerr : `astropy.units.Quantity`
96 Median 1-sigma uncertainty of PSF magnitudes over multiple visits
97 (magnitudes).
98 magrms : `astropy.units.Quantity`
99 RMS of PSF magnitudes over multiple visits (magnitudes).
100 snr : `astropy.units.Quantity`
101 Median signal-to-noise ratio of PSF magnitudes over multiple visits
102 (dimensionless).
103 dist : `astropy.units.Quantity`
104 RMS of sky coordinates of stars over multiple visits (milliarcseconds).
106 *Not serialized.*
107 matchesFaint : `afw.table.GroupView`
108 Faint matches containing only objects that have:
110 1. A PSF Flux measurement with sufficient S/N.
111 2. A finite (non-nan) PSF magnitude. This separate check is largely
112 to reject failed zeropoints.
113 3. No flags set for bad, cosmic ray, edge or saturated.
114 4. Extendedness consistent with a point source.
116 *Not serialized.*
117 matchesBright : `afw.table.GroupView`
118 Bright matches matching a higher S/N threshold than matchesFaint.
120 *Not serialized.*
121 magKey
122 Key for `"base_PsfFlux_mag"` in the `matchesFaint` and `matchesBright`
123 catalog tables.
125 *Not serialized.*
127 Raises
128 ------
129 RuntimeError:
130 Raised if "doApplyExternalPhotoCalib" is True and "externalPhotoCalibName"
131 is None, or if "doApplyExternalSkyWcs" is True and "externalSkyWcsName" is
132 None.
133 """
134 if doApplyExternalPhotoCalib and externalPhotoCalibName is None:
135 raise RuntimeError("Must set externalPhotoCalibName if doApplyExternalPhotoCalib is True.")
136 if doApplyExternalSkyWcs and externalSkyWcsName is None:
137 raise RuntimeError("Must set externalSkyWcsName if doApplyExternalSkyWcs is True.")
139 blob = Blob('MatchedMultiVisitDataset')
141 if not matchRadius:
142 matchRadius = geom.Angle(1, geom.arcseconds)
144 # Extract single filter
145 blob['filterName'] = Datum(quantity=set([dId['filter'] for dId in dataIds]).pop(),
146 description='Filter name')
148 # Record important configuration
149 blob['doApplyExternalPhotoCalib'] = Datum(quantity=doApplyExternalPhotoCalib,
150 description=('Whether external photometric '
151 'calibrations were used.'))
152 blob['externalPhotoCalibName'] = Datum(quantity=externalPhotoCalibName,
153 description='Name of external PhotoCalib dataset used.')
154 blob['doApplyExternalSkyWcs'] = Datum(quantity=doApplyExternalSkyWcs,
155 description='Whether external wcs calibrations were used.')
156 blob['externalSkyWcsName'] = Datum(quantity=externalSkyWcsName,
157 description='Name of external wcs dataset used.')
159 # Match catalogs across visits
160 blob._catalog, blob._matchedCatalog = \
161 _loadAndMatchCatalogs(repo, dataIds, matchRadius,
162 doApplyExternalPhotoCalib=doApplyExternalPhotoCalib,
163 externalPhotoCalibName=externalPhotoCalibName,
164 doApplyExternalSkyWcs=doApplyExternalSkyWcs,
165 externalSkyWcsName=externalSkyWcsName,
166 skipTEx=skipTEx, skipNonSrd=skipNonSrd)
168 blob.magKey = blob._matchedCatalog.schema.find("base_PsfFlux_mag").key
169 # Reduce catalogs into summary statistics.
170 # These are the serializable attributes of this class.
171 filterResult = filterSources(
172 blob._matchedCatalog, brightSnrMin=brightSnrMin, brightSnrMax=brightSnrMax,
173 faintSnrMin=faintSnrMin, faintSnrMax=faintSnrMax,
174 )
175 summarizeSources(blob, filterResult)
176 return blob
179def _loadAndMatchCatalogs(repo, dataIds, matchRadius,
180 doApplyExternalPhotoCalib=False, externalPhotoCalibName=None,
181 doApplyExternalSkyWcs=False, externalSkyWcsName=None,
182 skipTEx=False, skipNonSrd=False):
183 """Load data from specific visits and returned a calibrated catalog matched
184 with a reference.
186 Parameters
187 ----------
188 repo : `str` or `lsst.daf.persistence.Butler`
189 A Butler or a repository URL that can be used to construct one.
190 dataIds : list of dict
191 List of butler data IDs of Image catalogs to compare to
192 reference. The calexp cpixel image is needed for the photometric
193 calibration.
194 matchRadius : `lsst.geom.Angle`, optional
195 Radius for matching. Default is 1 arcsecond.
196 doApplyExternalPhotoCalib : bool, optional
197 Apply external photoCalib to calibrate fluxes.
198 externalPhotoCalibName : str, optional
199 Type of external `PhotoCalib` to apply. Currently supported are jointcal,
200 fgcm, and fgcm_tract. Must be set if doApplyExternalPhotoCalib is True.
201 doApplyExternalSkyWcs : bool, optional
202 Apply external wcs to calibrate positions.
203 externalSkyWcsName : str, optional
204 Type of external `wcs` to apply. Currently supported is jointcal.
205 Must be set if "doApplyExternalWcs" is True.
206 skipTEx : `bool`, optional
207 Skip TEx calculations (useful for older catalogs that don't have
208 PsfShape measurements).
209 skipNonSrd : `bool`, optional
210 Skip any metrics not defined in the LSST SRD; default False.
212 Returns
213 -------
214 catalog : `lsst.afw.table.SourceCatalog`
215 A new calibrated SourceCatalog.
216 matches : `lsst.afw.table.GroupView`
217 A GroupView of the matched sources.
219 Raises
220 ------
221 RuntimeError:
222 Raised if "doApplyExternalPhotoCalib" is True and "externalPhotoCalibName"
223 is None, or if "doApplyExternalSkyWcs" is True and "externalSkyWcsName" is
224 None.
225 """
227 if doApplyExternalPhotoCalib and externalPhotoCalibName is None:
228 raise RuntimeError("Must set externalPhotoCalibName if doApplyExternalPhotoCalib is True.")
229 if doApplyExternalSkyWcs and externalSkyWcsName is None:
230 raise RuntimeError("Must set externalSkyWcsName if doApplyExternalSkyWcs is True.")
232 # Following
233 # https://github.com/lsst/afw/blob/tickets/DM-3896/examples/repeatability.ipynb
234 if isinstance(repo, dafPersist.Butler):
235 butler = repo
236 else:
237 butler = dafPersist.Butler(repo)
238 dataset = 'src'
240 # 2016-02-08 MWV:
241 # I feel like I could be doing something more efficient with
242 # something along the lines of the following:
243 # dataRefs = [dafPersist.ButlerDataRef(butler, vId) for vId in dataIds]
245 ccdKeyName = getCcdKeyName(dataIds[0])
247 # Hack to support raft and sensor 0,1 IDs as ints for multimatch
248 if ccdKeyName == 'sensor':
249 ccdKeyName = 'raft_sensor_int'
250 for vId in dataIds:
251 vId[ccdKeyName] = raftSensorToInt(vId)
253 schema = butler.get(dataset + "_schema").schema
254 mapper = SchemaMapper(schema)
255 mapper.addMinimalSchema(schema)
256 mapper.addOutputField(Field[float]('base_PsfFlux_snr',
257 'PSF flux SNR'))
258 mapper.addOutputField(Field[float]('base_PsfFlux_mag',
259 'PSF magnitude'))
260 mapper.addOutputField(Field[float]('base_PsfFlux_magErr',
261 'PSF magnitude uncertainty'))
262 if not skipNonSrd:
263 # Needed because addOutputField(... 'slot_ModelFlux_mag') will add a field with that literal name
264 aliasMap = schema.getAliasMap()
265 # Possibly not needed since base_GaussianFlux is the default, but this ought to be safe
266 modelName = aliasMap['slot_ModelFlux'] if 'slot_ModelFlux' in aliasMap.keys() else 'base_GaussianFlux'
267 mapper.addOutputField(Field[float](f'{modelName}_mag',
268 'Model magnitude'))
269 mapper.addOutputField(Field[float](f'{modelName}_magErr',
270 'Model magnitude uncertainty'))
271 mapper.addOutputField(Field[float](f'{modelName}_snr',
272 'Model flux snr'))
273 mapper.addOutputField(Field[float]('e1',
274 'Source Ellipticity 1'))
275 mapper.addOutputField(Field[float]('e2',
276 'Source Ellipticity 1'))
277 mapper.addOutputField(Field[float]('psf_e1',
278 'PSF Ellipticity 1'))
279 mapper.addOutputField(Field[float]('psf_e2',
280 'PSF Ellipticity 1'))
281 newSchema = mapper.getOutputSchema()
282 newSchema.setAliasMap(schema.getAliasMap())
284 # Create an object that matches multiple catalogs with same schema
285 mmatch = MultiMatch(newSchema,
286 dataIdFormat={'visit': np.int32, ccdKeyName: np.int32},
287 radius=matchRadius,
288 RecordClass=SimpleRecord)
290 # create the new extented source catalog
291 srcVis = SourceCatalog(newSchema)
293 for vId in dataIds:
294 if not butler.datasetExists('src', vId):
295 print(f'Could not find source catalog for {vId}; skipping.')
296 continue
298 photoCalib = _loadPhotoCalib(butler, vId,
299 doApplyExternalPhotoCalib, externalPhotoCalibName)
300 if photoCalib is None:
301 continue
303 if doApplyExternalSkyWcs:
304 wcs = _loadExternalSkyWcs(butler, vId, externalSkyWcsName)
305 if wcs is None:
306 continue
308 # We don't want to put this above the first _loadPhotoCalib call
309 # because we need to use the first `butler.get` in there to quickly
310 # catch dataIDs with no usable outputs.
311 try:
312 # HSC supports these flags, which dramatically improve I/O
313 # performance; support for other cameras is DM-6927.
314 oldSrc = butler.get('src', vId, flags=SOURCE_IO_NO_FOOTPRINTS)
315 except (OperationalError, sqlite3.OperationalError):
316 oldSrc = butler.get('src', vId)
318 print(len(oldSrc), "sources in ccd %s visit %s" %
319 (vId[ccdKeyName], vId["visit"]))
321 # create temporary catalog
322 tmpCat = SourceCatalog(SourceCatalog(newSchema).table)
323 tmpCat.extend(oldSrc, mapper=mapper)
324 tmpCat['base_PsfFlux_snr'][:] = tmpCat['base_PsfFlux_instFlux'] \
325 / tmpCat['base_PsfFlux_instFluxErr']
327 if doApplyExternalSkyWcs:
328 afwTable.updateSourceCoords(wcs, tmpCat)
329 photoCalib.instFluxToMagnitude(tmpCat, "base_PsfFlux", "base_PsfFlux")
330 if not skipNonSrd:
331 tmpCat['slot_ModelFlux_snr'][:] = (tmpCat['slot_ModelFlux_instFlux'] /
332 tmpCat['slot_ModelFlux_instFluxErr'])
333 photoCalib.instFluxToMagnitude(tmpCat, "slot_ModelFlux", "slot_ModelFlux")
335 if not skipTEx:
336 _, psf_e1, psf_e2 = ellipticity_from_cat(oldSrc, slot_shape='slot_PsfShape')
337 _, star_e1, star_e2 = ellipticity_from_cat(oldSrc, slot_shape='slot_Shape')
338 tmpCat['e1'][:] = star_e1
339 tmpCat['e2'][:] = star_e2
340 tmpCat['psf_e1'][:] = psf_e1
341 tmpCat['psf_e2'][:] = psf_e2
343 srcVis.extend(tmpCat, False)
344 mmatch.add(catalog=tmpCat, dataId=vId)
346 # Complete the match, returning a catalog that includes
347 # all matched sources with object IDs that can be used to group them.
348 matchCat = mmatch.finish()
350 # Create a mapping object that allows the matches to be manipulated
351 # as a mapping of object ID to catalog of sources.
352 allMatches = GroupView.build(matchCat)
354 return srcVis, allMatches
357def getKeysFilter(schema, nameFluxKey=None):
358 """ Get schema keys for filtering sources.
360 schema : `lsst.afw.table.Schema`
361 A table schema to retrieve keys from.
362 nameFluxKey : `str`
363 The name of a flux field to retrieve
365 Returns
366 -------
367 keys : `lsst.pipe.base.Struct`
368 A struct storing schema keys to aggregate over.
369 """
370 if nameFluxKey is None:
371 nameFluxKey = "base_PsfFlux"
372 # Filter down to matches with at least 2 sources and good flags
374 return pipeBase.Struct(
375 flags=[schema.find("base_PixelFlags_flag_%s" % flag).key
376 for flag in ("saturated", "cr", "bad", "edge")],
377 snr=schema.find(f"{nameFluxKey}_snr").key,
378 mag=schema.find(f"{nameFluxKey}_mag").key,
379 magErr=schema.find(f"{nameFluxKey}_magErr").key,
380 extended=schema.find("base_ClassificationExtendedness_value").key,
381 )
384def filterSources(allMatches, keys=None, faintSnrMin=None, brightSnrMin=None, safeExtendedness=None,
385 extended=False, faintSnrMax=None, brightSnrMax=None):
386 """Filter matched sources on flags and SNR.
388 Parameters
389 ----------
390 allMatches : `lsst.afw.table.GroupView`
391 GroupView object with matches.
392 keys : `lsst.pipe.base.Struct`
393 A struct storing schema keys to aggregate over.
394 faintSnrMin : float, optional
395 Minimum median SNR for a faint source match; default 5.
396 brightSnrMin : float, optional
397 Minimum median SNR for a bright source match; default 50.
398 safeExtendedness: float, optional
399 Maximum (exclusive) extendedness for sources or minimum (inclusive) if extended==True; default 1.
400 extended: bool, optional
401 Whether to select extended sources, i.e. galaxies.
402 faintSnrMax : float, optional
403 Maximum median SNR for a faint source match; default `numpy.Inf`.
404 brightSnrMax : float, optional
405 Maximum median SNR for a bright source match; default `numpy.Inf`.
407 Returns
408 -------
409 filterResult : `lsst.pipe.base.Struct`
410 A struct containing good and safe matches and the necessary keys to use them.
411 """
412 if brightSnrMin is None:
413 brightSnrMin = 50
414 if brightSnrMax is None:
415 brightSnrMax = np.Inf
416 if faintSnrMin is None:
417 faintSnrMin = 5
418 if faintSnrMax is None:
419 faintSnrMax = np.Inf
420 if safeExtendedness is None:
421 safeExtendedness = 1.0
422 if keys is None:
423 keys = getKeysFilter(allMatches.schema, "slot_ModelFlux" if extended else "base_PsfFlux")
424 nMatchesRequired = 2
425 snrMin, snrMax = faintSnrMin, faintSnrMax
427 def extendedFilter(cat):
428 if len(cat) < nMatchesRequired:
429 return False
430 for flagKey in keys.flags:
431 if cat.get(flagKey).any():
432 return False
433 if not np.isfinite(cat.get(keys.mag)).all():
434 return False
435 extendedness = cat.get(keys.extended)
436 return np.min(extendedness) >= safeExtendedness if extended else \
437 np.max(extendedness) < safeExtendedness
439 def snrFilter(cat):
440 # Note that this also implicitly checks for psfSnr being non-nan.
441 snr = np.median(cat.get(keys.snr))
442 return snrMax >= snr >= snrMin
444 def fullFilter(cat):
445 return extendedFilter(cat) and snrFilter(cat)
447 # If brightSnrMin range is a subset of faintSnrMin, it's safe to only filter on snr again
448 # Otherwise, filter on flags/extendedness first, then snr
449 isSafeSubset = faintSnrMax >= brightSnrMax and faintSnrMin <= brightSnrMin
450 matchesFaint = allMatches.where(fullFilter) if isSafeSubset else allMatches.where(extendedFilter)
451 snrMin, snrMax = brightSnrMin, brightSnrMax
452 matchesBright = matchesFaint.where(snrFilter)
453 # This means that matchesFaint has had extendedFilter but not snrFilter applied
454 if not isSafeSubset:
455 snrMin, snrMax = faintSnrMin, faintSnrMax
456 matchesFaint = matchesFaint.where(snrFilter)
458 return pipeBase.Struct(
459 extended=extended, keys=keys, matchesFaint=matchesFaint, matchesBright=matchesBright,
460 )
463def summarizeSources(blob, filterResult):
464 """Calculate summary statistics for each source. These are persisted
465 as object attributes.
467 Parameters
468 ----------
469 blob : `lsst.verify.blob.Blob`
470 A verification blob to store Datums in.
471 filterResult : `lsst.pipe.base.Struct`
472 A struct containing bright and faint filter matches, as returned by `filterSources`.
473 """
474 # Pass field=psfMagKey so np.mean just gets that as its input
475 typeMag = "model" if filterResult.extended else "PSF"
476 filter_name = blob['filterName']
477 source_type = f'{"extended" if filterResult.extended else "point"} sources"'
478 matches = filterResult.matchesFaint
479 keys = filterResult.keys
480 blob['snr'] = Datum(quantity=matches.aggregate(np.median, field=keys.snr) * u.Unit(''),
481 label='SNR({band})'.format(band=filter_name),
482 description=f'Median signal-to-noise ratio of {typeMag} magnitudes for {source_type}'
483 f' over multiple visits')
484 blob['mag'] = Datum(quantity=matches.aggregate(np.mean, field=keys.mag) * u.mag,
485 label='{band}'.format(band=filter_name),
486 description=f'Mean of {typeMag} magnitudes for {source_type} over multiple visits')
487 blob['magrms'] = Datum(quantity=matches.aggregate(np.std, field=keys.mag) * u.mag,
488 label='RMS({band})'.format(band=filter_name),
489 description=f'RMS of {typeMag} magnitudes for {source_type} over multiple visits')
490 blob['magerr'] = Datum(quantity=matches.aggregate(np.median, field=keys.magErr) * u.mag,
491 label='sigma({band})'.format(band=filter_name),
492 description=f'Median 1-sigma uncertainty of {typeMag} magnitudes for {source_type}'
493 f' over multiple visits')
494 # positionRmsFromCat knows how to query a group
495 # so we give it the whole thing by going with the default `field=None`.
496 blob['dist'] = Datum(quantity=matches.aggregate(positionRmsFromCat) * u.milliarcsecond,
497 label='d',
498 description=f'RMS of sky coordinates of {source_type} over multiple visits')
500 # These attributes are not serialized
501 blob.matchesFaint = filterResult.matchesFaint
502 blob.matchesBright = filterResult.matchesBright
505def _loadPhotoCalib(butler, dataId, doApplyExternalPhotoCalib, externalPhotoCalibName):
506 """
507 Load a photoCalib object.
509 Parameters
510 ----------
511 butler: `lsst.daf.persistence.Butler`
512 dataId: Butler dataId `dict`
513 doApplyExternalPhotoCalib: `bool`
514 Apply external photoCalib to calibrate fluxes.
515 externalPhotoCalibName: `str`
516 Type of external `PhotoCalib` to apply. Currently supported are jointcal,
517 fgcm, and fgcm_tract. Must be set if "doApplyExternalPhotoCalib" is True.
519 Returns
520 -------
521 photoCalib: `lsst.afw.image.PhotoCalib` or None
522 photoCalib to apply. None if a suitable one was not found.
523 """
525 photoCalib = None
527 if doApplyExternalPhotoCalib:
528 try:
529 photoCalib = butler.get(f"{externalPhotoCalibName}_photoCalib", dataId)
530 except (FitsError, dafPersist.NoResults) as e:
531 print(e)
532 print(f'Could not open external photometric calib for {dataId}; skipping.')
533 photoCalib = None
534 else:
535 try:
536 photoCalib = butler.get('calexp_photoCalib', dataId)
537 except (FitsError, dafPersist.NoResults) as e:
538 print(e)
539 print(f'Could not open calibrated image file for {dataId}; skipping.')
540 except TypeError as te:
541 # DECam images that haven't been properly reformatted
542 # can trigger a TypeError because of a residual FITS header
543 # LTV2 which is a float instead of the expected integer.
544 # This generates an error of the form:
545 #
546 # lsst::pex::exceptions::TypeError: 'LTV2 has mismatched type'
547 #
548 # See, e.g., DM-2957 for details.
549 print(te)
550 print(f'Calibration image header information malformed for {dataId}; skipping.')
551 photoCalib = None
553 return photoCalib
556def _loadExternalSkyWcs(butler, dataId, externalSkyWcsName):
557 """
558 Load a SkyWcs object.
560 Parameters
561 ----------
562 butler: `lsst.daf.persistence.Butler`
563 dataId: Butler dataId `dict`
564 externalSkyWcsName: `str`
565 Type of external `SkyWcs` to apply. Currently supported is jointcal.
566 Must be not None if "doApplyExternalSkyWcs" is True.
568 Returns
569 -------
570 SkyWcs: `lsst.afw.geom.SkyWcs` or None
571 SkyWcs to apply. None if a suitable one was not found.
572 """
574 try:
575 wcs = butler.get(f"{externalSkyWcsName}_wcs", dataId)
576 except (FitsError, dafPersist.NoResults) as e:
577 print(e)
578 print(f'Could not open external WCS for {dataId}; skipping.')
579 wcs = None
581 return wcs