Coverage for python/lsst/meas/algorithms/loadReferenceObjects.py : 27%

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#
2# LSST Data Management System
3#
4# Copyright 2008-2017 AURA/LSST.
5#
6# This product includes software developed by the
7# LSST Project (http://www.lsst.org/).
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 LSST License Statement and
20# the GNU General Public License along with this program. If not,
21# see <https://www.lsstcorp.org/LegalNotices/>.
22#
24__all__ = ["getRefFluxField", "getRefFluxKeys", "LoadReferenceObjectsTask", "LoadReferenceObjectsConfig",
25 "ReferenceObjectLoader"]
27import abc
28import itertools
30import astropy.time
31import astropy.units
32import numpy
34import lsst.geom
35import lsst.afw.table as afwTable
36import lsst.pex.config as pexConfig
37import lsst.pex.exceptions as pexExceptions
38import lsst.pipe.base as pipeBase
39import lsst.pex.exceptions as pexExcept
40import lsst.log
41from lsst import geom
42from lsst import sphgeom
43from lsst.daf.base import PropertyList
46def isOldFluxField(name, units):
47 """Return True if this name/units combination corresponds to an
48 "old-style" reference catalog flux field.
49 """
50 unitsCheck = units != 'nJy' # (units == 'Jy' or units == '' or units == '?')
51 isFlux = name.endswith('_flux')
52 isFluxSigma = name.endswith('_fluxSigma')
53 isFluxErr = name.endswith('_fluxErr')
54 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck
57def hasNanojanskyFluxUnits(schema):
58 """Return True if the units of all flux and fluxErr are correct (nJy).
59 """
60 for field in schema:
61 if isOldFluxField(field.field.getName(), field.field.getUnits()): 61 ↛ 62line 61 didn't jump to line 62, because the condition on line 61 was never true
62 return False
63 return True
66def getFormatVersionFromRefCat(refCat):
67 """"Return the format version stored in a reference catalog header.
69 Parameters
70 ----------
71 refCat : `lsst.afw.table.SimpleCatalog`
72 Reference catalog to inspect.
74 Returns
75 -------
76 version : `int` or `None`
77 Format version integer, or `None` if the catalog has no metadata
78 or the metadata does not include a "REFCAT_FORMAT_VERSION" key.
79 """
80 md = refCat.getMetadata()
81 if md is None: 81 ↛ 82line 81 didn't jump to line 82, because the condition on line 81 was never true
82 return None
83 try:
84 return md.getScalar("REFCAT_FORMAT_VERSION")
85 except KeyError:
86 return None
89def convertToNanojansky(catalog, log, doConvert=True):
90 """Convert fluxes in a catalog from jansky to nanojansky.
92 Parameters
93 ----------
94 catalog : `lsst.afw.table.SimpleCatalog`
95 The catalog to convert.
96 log : `lsst.log.Log`
97 Log to send messages to.
98 doConvert : `bool`, optional
99 Return a converted catalog, or just identify the fields that need to be converted?
100 This supports the "write=False" mode of `bin/convert_to_nJy.py`.
102 Returns
103 -------
104 catalog : `lsst.afw.table.SimpleCatalog` or None
105 The converted catalog, or None if ``doConvert`` is False.
107 Notes
108 -----
109 Support for old units in reference catalogs will be removed after the
110 release of late calendar year 2019.
111 Use `meas_algorithms/bin/convert_to_nJy.py` to update your reference catalog.
112 """
113 # Do not share the AliasMap: for refcats, that gets created when the
114 # catalog is read from disk and should not be propagated.
115 mapper = lsst.afw.table.SchemaMapper(catalog.schema, shareAliasMap=False)
116 mapper.addMinimalSchema(lsst.afw.table.SimpleTable.makeMinimalSchema())
117 input_fields = []
118 output_fields = []
119 for field in catalog.schema:
120 oldName = field.field.getName()
121 oldUnits = field.field.getUnits()
122 if isOldFluxField(oldName, oldUnits):
123 units = 'nJy'
124 # remap Sigma flux fields to Err, so we can drop the alias
125 if oldName.endswith('_fluxSigma'):
126 name = oldName.replace('_fluxSigma', '_fluxErr')
127 else:
128 name = oldName
129 newField = lsst.afw.table.Field[field.dtype](name, field.field.getDoc(), units)
130 mapper.addMapping(field.getKey(), newField)
131 input_fields.append(field.field)
132 output_fields.append(newField)
133 else:
134 mapper.addMapping(field.getKey())
136 fluxFieldsStr = '; '.join("(%s, '%s')" % (field.getName(), field.getUnits()) for field in input_fields)
138 if doConvert:
139 newSchema = mapper.getOutputSchema()
140 output = lsst.afw.table.SimpleCatalog(newSchema)
141 output.extend(catalog, mapper=mapper)
142 for field in output_fields:
143 output[field.getName()] *= 1e9
144 log.info(f"Converted refcat flux fields to nJy (name, units): {fluxFieldsStr}")
145 return output
146 else:
147 log.info(f"Found old-style refcat flux fields (name, units): {fluxFieldsStr}")
148 return None
151class _FilterCatalog:
152 """This is a private helper class which filters catalogs by
153 row based on the row being inside the region used to initialize
154 the class.
156 Parameters
157 ----------
158 region : `lsst.sphgeom.Region`
159 The spatial region which all objects should lie within
160 """
161 def __init__(self, region):
162 self.region = region
164 def __call__(self, refCat, catRegion):
165 """This call method on an instance of this class takes in a reference
166 catalog, and the region from which the catalog was generated.
168 If the catalog region is entirely contained within the region used to
169 initialize this class, then all the entries in the catalog must be
170 within the region and so the whole catalog is returned.
172 If the catalog region is not entirely contained, then the location for
173 each record is tested against the region used to initialize the class.
174 Records which fall inside this region are added to a new catalog, and
175 this catalog is then returned.
177 Parameters
178 ---------
179 refCat : `lsst.afw.table.SourceCatalog`
180 SourceCatalog to be filtered.
181 catRegion : `lsst.sphgeom.Region`
182 Region in which the catalog was created
183 """
184 if catRegion.isWithin(self.region):
185 # no filtering needed, region completely contains refcat
186 return refCat
188 filteredRefCat = type(refCat)(refCat.table)
189 for record in refCat:
190 if self.region.contains(record.getCoord().getVector()):
191 filteredRefCat.append(record)
192 return filteredRefCat
195class ReferenceObjectLoaderBase:
196 """Base class for reference object loaders, to facilitate gen2/gen3 code
197 sharing.
198 """
199 def applyProperMotions(self, catalog, epoch):
200 """Apply proper motion correction to a reference catalog.
202 Adjust position and position error in the ``catalog``
203 for proper motion to the specified ``epoch``,
204 modifying the catalog in place.
206 Parameters
207 ----------
208 catalog : `lsst.afw.table.SimpleCatalog`
209 Catalog of positions, containing at least these fields:
211 - Coordinates, retrieved by the table's coordinate key.
212 - ``coord_raErr`` : Error in Right Ascension (rad).
213 - ``coord_decErr`` : Error in Declination (rad).
214 - ``pm_ra`` : Proper motion in Right Ascension (rad/yr,
215 East positive)
216 - ``pm_raErr`` : Error in ``pm_ra`` (rad/yr), optional.
217 - ``pm_dec`` : Proper motion in Declination (rad/yr,
218 North positive)
219 - ``pm_decErr`` : Error in ``pm_dec`` (rad/yr), optional.
220 - ``epoch`` : Mean epoch of object (an astropy.time.Time)
221 epoch : `astropy.time.Time`
222 Epoch to which to correct proper motion.
223 If None, do not apply PM corrections or raise if
224 ``config.requireProperMotion`` is True.
226 Raises
227 ------
228 RuntimeError
229 Raised if ``config.requireProperMotion`` is set but we cannot
230 apply the proper motion correction for some reason.
231 """
232 if epoch is None: 232 ↛ 240line 232 didn't jump to line 240, because the condition on line 232 was never false
233 if self.config.requireProperMotion: 233 ↛ 234line 233 didn't jump to line 234, because the condition on line 233 was never true
234 raise RuntimeError("requireProperMotion=True but epoch not provided to loader.")
235 else:
236 self.log.debug("No epoch provided: not applying proper motion corrections to refcat.")
237 return
239 # Warn/raise for a catalog in an incorrect format, if epoch was specified.
240 if ("pm_ra" in catalog.schema
241 and not isinstance(catalog.schema["pm_ra"].asKey(), lsst.afw.table.KeyAngle)):
242 if self.config.requireProperMotion:
243 raise RuntimeError("requireProperMotion=True but refcat pm_ra field is not an Angle.")
244 else:
245 self.log.warn("Reference catalog pm_ra field is not an Angle; cannot apply proper motion.")
246 return
248 if ("epoch" not in catalog.schema or "pm_ra" not in catalog.schema):
249 if self.config.requireProperMotion:
250 raise RuntimeError("requireProperMotion=True but PM data not available from catalog.")
251 else:
252 self.log.warn("Proper motion correction not available for this reference catalog.")
253 return
255 applyProperMotionsImpl(self.log, catalog, epoch)
258class ReferenceObjectLoader(ReferenceObjectLoaderBase):
259 """ This class facilitates loading reference catalogs with gen 3 middleware
261 The middleware preflight solver will create a list of datarefs that may
262 possibly overlap a given region. These datarefs are then used to construct
263 and instance of this class. The class instance should then be passed into
264 a task which needs reference catalogs. These tasks should then determine
265 the exact region of the sky reference catalogs will be loaded for, and
266 call a corresponding method to load the reference objects.
267 """
268 def __init__(self, dataIds, refCats, config, log=None):
269 """ Constructs an instance of ReferenceObjectLoader
271 Parameters
272 ----------
273 dataIds : iterable of `lsst.daf.butler.DataIds`
274 An iterable object of DataSetRefs which point to reference catalogs
275 in a gen 3 repository
276 refCats : Iterable of `lsst.daf.butler.DeferedDatasetHandle`
277 Handles to load refCats on demand
278 log : `lsst.log.Log`
279 Logger object used to write out messages. If `None` (default) the default
280 lsst logger will be used
282 """
283 self.dataIds = dataIds
284 self.refCats = refCats
285 self.log = log or lsst.log.Log.getDefaultLogger()
286 self.config = config
288 @staticmethod
289 def _makeBoxRegion(BBox, wcs, BBoxPadding):
290 outerLocalBBox = geom.Box2D(BBox)
291 innerLocalBBox = geom.Box2D(BBox)
293 # Grow the bounding box to make sure the spherical geometry bbox will contain
294 # the same region, as non-padded boxes may contain different regions because of optical distortion.
295 # Also create an inner region that is sure to be inside the bbox
296 outerLocalBBox.grow(BBoxPadding)
297 innerLocalBBox.grow(-1*BBoxPadding)
299 # Handle the fact that the inner bounding box shrunk to a zero sized region in at least one
300 # dimension, in which case all reference catalogs must be checked fully against the input
301 # bounding box
302 if innerLocalBBox.getDimensions() == geom.Extent2D(0, 0):
303 innerSkyRegion = sphgeom.Box()
304 else:
305 innerBoxCorners = innerLocalBBox.getCorners()
306 innerSphCorners = [wcs.pixelToSky(corner).getVector() for corner in innerBoxCorners]
308 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners)
310 # Convert the corners of the box to sky coordinates
311 outerBoxCorners = outerLocalBBox.getCorners()
312 outerSphCorners = [wcs.pixelToSky(corner).getVector() for corner in outerBoxCorners]
314 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners)
316 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners
318 def loadPixelBox(self, bbox, wcs, filterName=None, epoch=None, photoCalib=None, bboxPadding=100):
319 """Load reference objects that are within a pixel-based rectangular region
321 This algorithm works by creating a spherical box whose corners correspond
322 to the WCS converted corners of the input bounding box (possibly padded).
323 It then defines a filtering function which will look at a reference
324 objects pixel position and accept objects that lie within the specified
325 bounding box.
327 The spherical box region and filtering function are passed to the generic
328 loadRegion method which will load and filter the reference objects from
329 the datastore and return a single catalog containing all reference objects
331 Parameters
332 ----------
333 bbox : `lsst.geom.box2I`
334 Box which bounds a region in pixel space
335 wcs : `lsst.afw.geom.SkyWcs`
336 Wcs object defining the pixel to sky (and inverse) transform for the space
337 of pixels of the supplied bbox
338 filterName : `str`
339 Name of camera filter, or None or blank for the default filter
340 epoch : `astropy.time.Time` (optional)
341 Epoch to which to correct proper motion and parallax,
342 or None to not apply such corrections.
343 photoCalib : None
344 Deprecated and ignored, only included for api compatibility
345 bboxPadding : `int`
346 Number describing how much to pad the input bbox by (in pixels), defaults
347 to 100. This parameter is necessary because optical distortions in telescopes
348 can cause a rectangular pixel grid to map into a non "rectangular" spherical
349 region in sky coordinates. This padding is used to create a spherical
350 "rectangle", which will for sure enclose the input box. This padding is only
351 used to determine if the reference catalog for a sky patch will be loaded from
352 the data store, this function will filter out objects which lie within the
353 padded region but fall outside the input bounding box region.
355 Returns
356 -------
357 referenceCatalog : `lsst.afw.table.SimpleCatalog`
358 Catalog containing reference objects inside the specified bounding box
360 Raises
361 ------
362 `lsst.pex.exception.RuntimeError`
363 Raised if no reference catalogs could be found for the specified region
365 `lsst.pex.exception.TypeError`
366 Raised if the loaded reference catalogs do not have matching schemas
367 """
368 innerSkyRegion, outerSkyRegion, _, _ = self._makeBoxRegion(bbox, wcs, bboxPadding)
370 def _filterFunction(refCat, region):
371 # Add columns to the reference catalog relating to center positions and use afwTable
372 # to populate those columns
373 refCat = self.remapReferenceCatalogSchema(refCat, position=True)
374 afwTable.updateRefCentroids(wcs, refCat)
375 # no need to filter the catalog if it is sure that it is entirely contained in the region
376 # defined by given bbox
377 if innerSkyRegion.contains(region):
378 return refCat
379 # Create a new reference catalog, and populate it with records which fall inside the bbox
380 filteredRefCat = type(refCat)(refCat.table)
381 centroidKey = afwTable.Point2DKey(refCat.schema['centroid'])
382 for record in refCat:
383 pixCoords = record[centroidKey]
384 if bbox.contains(geom.Point2I(pixCoords)):
385 filteredRefCat.append(record)
386 return filteredRefCat
387 return self.loadRegion(outerSkyRegion, filtFunc=_filterFunction, epoch=epoch, filterName=filterName)
389 def loadRegion(self, region, filtFunc=None, filterName=None, epoch=None):
390 """ Load reference objects within a specified region
392 This function loads the DataIds used to construct an instance of this class
393 which intersect or are contained within the specified region. The reference
394 catalogs which intersect but are not fully contained within the input region are
395 further filtered by the specified filter function. This function will return a
396 single source catalog containing all reference objects inside the specified region.
398 Parameters
399 ----------
400 region : `lsst.sphgeom.Region`
401 This can be any type that is derived from `lsst.sphgeom.region` and should
402 define the spatial region for which reference objects are to be loaded.
403 filtFunc : callable
404 This optional parameter should be a callable object that takes a reference
405 catalog and its corresponding region as parameters, filters the catalog by
406 some criteria and returns the filtered reference catalog. If the value is
407 left as the default (None) than an internal filter function is used which
408 filters according to if a reference object falls within the input region.
409 filterName : `str`
410 Name of camera filter, or None or blank for the default filter
411 epoch : `astropy.time.Time` (optional)
412 Epoch to which to correct proper motion and parallax,
413 or None to not apply such corrections.
415 Returns
416 -------
417 referenceCatalog : `lsst.afw.table.SourceCatalog`
418 Catalog containing reference objects which intersect the input region,
419 filtered by the specified filter function
421 Raises
422 ------
423 `lsst.pex.exception.RuntimeError`
424 Raised if no reference catalogs could be found for the specified region
426 `lsst.pex.exception.TypeError`
427 Raised if the loaded reference catalogs do not have matching schemas
429 """
430 regionBounding = region.getBoundingBox()
431 self.log.info("Loading reference objects from region bounded by {}, {} lat lon".format(
432 regionBounding.getLat(), regionBounding.getLon()))
433 if filtFunc is None:
434 filtFunc = _FilterCatalog(region)
435 # filter out all the regions supplied by the constructor that do not overlap
436 overlapList = []
437 for dataId, refCat in zip(self.dataIds, self.refCats):
438 # SphGeom supports some objects intersecting others, but is not symmetric,
439 # try the intersect operation in both directions
440 try:
441 intersects = dataId.region.intersects(region)
442 except TypeError:
443 intersects = region.intersects(dataId.region)
445 if intersects:
446 overlapList.append((dataId, refCat))
448 if len(overlapList) == 0:
449 raise pexExceptions.RuntimeError("No reference tables could be found for input region")
451 firstCat = overlapList[0][1].get()
452 refCat = filtFunc(firstCat, overlapList[0][0].region)
453 trimmedAmount = len(firstCat) - len(refCat)
455 # Load in the remaining catalogs
456 for dataId, inputRefCat in overlapList[1:]:
457 tmpCat = inputRefCat.get()
459 if tmpCat.schema != firstCat.schema:
460 raise pexExceptions.TypeError("Reference catalogs have mismatching schemas")
462 filteredCat = filtFunc(tmpCat, dataId.region)
463 refCat.extend(filteredCat)
464 trimmedAmount += len(tmpCat) - len(filteredCat)
466 self.log.debug(f"Trimmed {trimmedAmount} out of region objects, leaving {len(refCat)}")
467 self.log.info(f"Loaded {len(refCat)} reference objects")
469 # Ensure that the loaded reference catalog is continuous in memory
470 if not refCat.isContiguous():
471 refCat = refCat.copy(deep=True)
473 self.applyProperMotions(refCat, epoch)
475 # Verify the schema is in the correct units and has the correct version; automatically convert
476 # it with a warning if this is not the case.
477 if not hasNanojanskyFluxUnits(refCat.schema) or not getFormatVersionFromRefCat(refCat) >= 1:
478 self.log.warn("Found version 0 reference catalog with old style units in schema.")
479 self.log.warn("run `meas_algorithms/bin/convert_refcat_to_nJy.py` to convert fluxes to nJy.")
480 self.log.warn("See RFC-575 for more details.")
481 refCat = convertToNanojansky(refCat, self.log)
483 expandedCat = self.remapReferenceCatalogSchema(refCat, position=True)
485 # Add flux aliases
486 expandedCat = self.addFluxAliases(expandedCat, self.config.defaultFilter, self.config.filterMap)
488 # Ensure that the returned reference catalog is continuous in memory
489 if not expandedCat.isContiguous():
490 expandedCat = expandedCat.copy(deep=True)
492 fluxField = getRefFluxField(schema=expandedCat.schema, filterName=filterName)
493 return pipeBase.Struct(refCat=expandedCat, fluxField=fluxField)
495 def loadSkyCircle(self, ctrCoord, radius, filterName=None, epoch=None):
496 """Load reference objects that lie within a circular region on the sky
498 This method constructs a circular region from an input center and angular radius,
499 loads reference catalogs which are contained in or intersect the circle, and
500 filters reference catalogs which intersect down to objects which lie within
501 the defined circle.
503 Parameters
504 ----------
505 ctrCoord : `lsst.geom.SpherePoint`
506 Point defining the center of the circular region
507 radius : `lsst.geom.Angle`
508 Defines the angular radius of the circular region
509 filterName : `str`
510 Name of camera filter, or None or blank for the default filter
511 epoch : `astropy.time.Time` (optional)
512 Epoch to which to correct proper motion and parallax,
513 or None to not apply such corrections.
515 Returns
516 -------
517 referenceCatalog : `lsst.afw.table.SourceCatalog`
518 Catalog containing reference objects inside the specified bounding box
520 Raises
521 ------
522 `lsst.pex.exception.RuntimeError`
523 Raised if no reference catalogs could be found for the specified region
525 `lsst.pex.exception.TypeError`
526 Raised if the loaded reference catalogs do not have matching schemas
528 """
529 centerVector = ctrCoord.getVector()
530 sphRadius = sphgeom.Angle(radius.asRadians())
531 circularRegion = sphgeom.Circle(centerVector, sphRadius)
532 return self.loadRegion(circularRegion, filterName=filterName, epoch=epoch)
534 def joinMatchListWithCatalog(self, matchCat, sourceCat):
535 """Relink an unpersisted match list to sources and reference
536 objects.
538 A match list is persisted and unpersisted as a catalog of IDs
539 produced by afw.table.packMatches(), with match metadata
540 (as returned by the astrometry tasks) in the catalog's metadata
541 attribute. This method converts such a match catalog into a match
542 list, with links to source records and reference object records.
544 Parameters
545 ----------
546 matchCat : `lsst.afw.table.BaseCatalog`
547 Unpersisted packed match list.
548 ``matchCat.table.getMetadata()`` must contain match metadata,
549 as returned by the astrometry tasks.
550 sourceCat : `lsst.afw.table.SourceCatalog`
551 Source catalog. As a side effect, the catalog will be sorted
552 by ID.
554 Returns
555 -------
556 matchList : `lsst.afw.table.ReferenceMatchVector`
557 Match list.
558 """
559 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat)
561 @classmethod
562 def getMetadataBox(cls, bbox, wcs, filterName=None, photoCalib=None, epoch=None, bboxPadding=100):
563 """Return metadata about the load
565 This metadata is used for reloading the catalog (e.g., for
566 reconstituting a normalised match list.)
568 Parameters
569 ----------
570 bbox : `lsst.geom.Box2I`
571 Bounding bos for the pixels
572 wcs : `lsst.afw.geom.SkyWcs
573 WCS object
574 filterName : `str` or None
575 filterName of the camera filter, or None or blank for the default filter
576 photoCalib : None
577 Deprecated, only included for api compatibility
578 epoch : `astropy.time.Time` (optional)
579 Epoch to which to correct proper motion and parallax,
580 or None to not apply such corrections.
581 bboxPadding : `int`
582 Number describing how much to pad the input bbox by (in pixels), defaults
583 to 100. This parameter is necessary because optical distortions in telescopes
584 can cause a rectangular pixel grid to map into a non "rectangular" spherical
585 region in sky coordinates. This padding is used to create a spherical
586 "rectangle", which will for sure enclose the input box. This padding is only
587 used to determine if the reference catalog for a sky patch will be loaded from
588 the data store, this function will filter out objects which lie within the
589 padded region but fall outside the input bounding box region.
590 Returns
591 -------
592 md : `lsst.daf.base.PropertyList`
593 """
594 _, _, innerCorners, outerCorners = cls._makeBoxRegion(bbox, wcs, bboxPadding)
595 md = PropertyList()
596 for box, corners in zip(("INNER", "OUTER"), (innerCorners, outerCorners)):
597 for (name, corner) in zip(("UPPER_LEFT", "UPPER_RIGHT", "LOWER_LEFT", "LOWER_RIGHT"),
598 corners):
599 md.add(f"{box}_{name}_RA", geom.SpherePoint(corner).getRa().asDegrees(), f"{box}_corner")
600 md.add(f"{box}_{name}_DEC", geom.SpherePoint(corner).getDec().asDegrees(), f"{box}_corner")
601 md.add("SMATCHV", 1, 'SourceMatchVector version number')
602 filterName = "UNKNOWN" if filterName is None else str(filterName)
603 md.add('FILTER', filterName, 'filter name for photometric data')
604 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog')
605 return md
607 @staticmethod
608 def getMetadataCircle(coord, radius, filterName, photoCalib=None, epoch=None):
609 """Return metadata about the load
611 This metadata is used for reloading the catalog (e.g. for reconstituting
612 a normalized match list.)
614 Parameters
615 ----------
616 coord : `lsst.geom.SpherePoint`
617 ICRS center of a circle
618 radius : `lsst.geom.angle`
619 radius of a circle
620 filterName : `str` or None
621 filterName of the camera filter, or None or blank for the default filter
622 photoCalib : None
623 Deprecated, only included for api compatibility
624 epoch : `astropy.time.Time` (optional)
625 Epoch to which to correct proper motion and parallax,
626 or None to not apply such corrections.
628 Returns
629 -------
630 md : `lsst.daf.base.PropertyList`
631 """
632 md = PropertyList()
633 md.add('RA', coord.getRa().asDegrees(), 'field center in degrees')
634 md.add('DEC', coord.getDec().asDegrees(), 'field center in degrees')
635 md.add('RADIUS', radius.asDegrees(), 'field radius in degrees, minimum')
636 md.add('SMATCHV', 1, 'SourceMatchVector version number')
637 filterName = "UNKNOWN" if filterName is None else str(filterName)
638 md.add('FILTER', filterName, 'filter name for photometric data')
639 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog')
640 return md
642 @staticmethod
643 def addFluxAliases(refCat, defaultFilter, filterReferenceMap):
644 """This function creates a new catalog containing the information of the input refCat
645 as well as added flux columns and aliases between camera and reference flux.
647 Parameters
648 ----------
649 refCat : `lsst.afw.table.SimpleCatalog`
650 Catalog of reference objects
651 defaultFilter : `str`
652 Name of the default reference filter
653 filterReferenceMap : `dict` of `str`
654 Dictionary with keys corresponding to a filter name, and values which
655 correspond to the name of the reference filter.
657 Returns
658 -------
659 refCat : `lsst.afw.table.SimpleCatalog`
660 Reference catalog with columns added to track reference filters
662 Raises
663 ------
664 `RuntimeError`
665 If specified reference filter name is not a filter specifed as a key in the
666 reference filter map.
667 """
668 refCat = ReferenceObjectLoader.remapReferenceCatalogSchema(refCat,
669 filterNameList=filterReferenceMap.keys())
670 aliasMap = refCat.schema.getAliasMap()
671 if filterReferenceMap is None:
672 filterReferenceMap = {}
673 for filterName, refFilterName in itertools.chain([(None, defaultFilter)],
674 filterReferenceMap.items()):
675 if refFilterName:
676 camFluxName = filterName + "_camFlux" if filterName is not None else "camFlux"
677 refFluxName = refFilterName + "_flux"
678 if refFluxName not in refCat.schema:
679 raise RuntimeError("Unknown reference filter %s" % (refFluxName,))
680 aliasMap.set(camFluxName, refFluxName)
682 refFluxErrName = refFluxName + "Err"
683 camFluxErrName = camFluxName + "Err"
684 aliasMap.set(camFluxErrName, refFluxErrName)
686 return refCat
688 @staticmethod
689 def remapReferenceCatalogSchema(refCat, *, filterNameList=None, position=False, photometric=False):
690 """This function takes in a reference catalog and creates a new catalog with additional
691 columns defined the remaining function arguments.
693 Parameters
694 ----------
695 refCat : `lsst.afw.table.SimpleCatalog`
696 Reference catalog to map to new catalog
698 Returns
699 -------
700 expandedCat : `lsst.afw.table.SimpleCatalog`
701 Deep copy of input reference catalog with additional columns added
702 """
703 mapper = afwTable.SchemaMapper(refCat.schema, True)
704 mapper.addMinimalSchema(refCat.schema, True)
705 mapper.editOutputSchema().disconnectAliases()
706 if filterNameList:
707 for filterName in filterNameList:
708 mapper.editOutputSchema().addField(f"{filterName}_flux",
709 type=numpy.float64,
710 doc=f"flux in filter {filterName}",
711 units="Jy"
712 )
713 mapper.editOutputSchema().addField(f"{filterName}_fluxErr",
714 type=numpy.float64,
715 doc=f"flux uncertanty in filter {filterName}",
716 units="Jy"
717 )
719 if position:
720 mapper.editOutputSchema().addField("centroid_x", type=float, doReplace=True)
721 mapper.editOutputSchema().addField("centroid_y", type=float, doReplace=True)
722 mapper.editOutputSchema().addField("hasCentroid", type="Flag", doReplace=True)
723 mapper.editOutputSchema().getAliasMap().set("slot_Centroid", "centroid")
725 if photometric:
726 mapper.editOutputSchema().addField("photometric",
727 type="Flag",
728 doc="set if the object can be used for photometric"
729 "calibration",
730 )
731 mapper.editOutputSchema().addField("resolved",
732 type="Flag",
733 doc="set if the object is spatially resolved"
734 )
735 mapper.editOutputSchema().addField("variable",
736 type="Flag",
737 doc="set if the object has variable brightness"
738 )
740 expandedCat = afwTable.SimpleCatalog(mapper.getOutputSchema())
741 expandedCat.setMetadata(refCat.getMetadata())
742 expandedCat.extend(refCat, mapper=mapper)
744 return expandedCat
747def getRefFluxField(schema, filterName=None):
748 """Get the name of a flux field from a schema.
750 return the alias of "anyFilterMapsToThis", if present
751 else if filterName is specified:
752 return "*filterName*_camFlux" if present
753 else return "*filterName*_flux" if present (camera filter name
754 matches reference filter name)
755 else throw RuntimeError
756 else:
757 return "camFlux", if present,
758 else throw RuntimeError
760 Parameters
761 ----------
762 schema : `lsst.afw.table.Schema`
763 Reference catalog schema.
764 filterName : `str`, optional
765 Name of camera filter. If not specified, ``defaultFilter`` needs to be
766 set in the refcat loader config.
768 Returns
769 -------
770 fluxFieldName : `str`
771 Name of flux field.
773 Raises
774 ------
775 RuntimeError
776 If an appropriate field is not found.
777 """
778 if not isinstance(schema, afwTable.Schema): 778 ↛ 779line 778 didn't jump to line 779, because the condition on line 778 was never true
779 raise RuntimeError("schema=%s is not a schema" % (schema,))
780 try:
781 return schema.getAliasMap().get("anyFilterMapsToThis")
782 except LookupError:
783 pass # try the filterMap next
785 if filterName: 785 ↛ 788line 785 didn't jump to line 788, because the condition on line 785 was never false
786 fluxFieldList = [filterName + "_camFlux", filterName + "_flux"]
787 else:
788 fluxFieldList = ["camFlux"]
789 for fluxField in fluxFieldList: 789 ↛ 793line 789 didn't jump to line 793, because the loop on line 789 didn't complete
790 if fluxField in schema:
791 return fluxField
793 raise RuntimeError("Could not find flux field(s) %s" % (", ".join(fluxFieldList)))
796def getRefFluxKeys(schema, filterName=None):
797 """Return keys for flux and flux error.
799 Parameters
800 ----------
801 schema : `lsst.afw.table.Schema`
802 Reference catalog schema.
803 filterName : `str`
804 Name of camera filter.
806 Returns
807 -------
808 keys : `tuple` of (`lsst.afw.table.Key`, `lsst.afw.table.Key`)
809 Two keys:
811 - flux key
812 - flux error key, if present, else None
814 Raises
815 ------
816 RuntimeError
817 If flux field not found.
818 """
819 fluxField = getRefFluxField(schema, filterName)
820 fluxErrField = fluxField + "Err"
821 fluxKey = schema[fluxField].asKey()
822 try:
823 fluxErrKey = schema[fluxErrField].asKey()
824 except Exception:
825 fluxErrKey = None
826 return (fluxKey, fluxErrKey)
829class LoadReferenceObjectsConfig(pexConfig.Config):
830 pixelMargin = pexConfig.RangeField(
831 doc="Padding to add to 4 all edges of the bounding box (pixels)",
832 dtype=int,
833 default=300,
834 min=0,
835 )
836 defaultFilter = pexConfig.Field(
837 doc=("Default reference catalog filter to use if filter not specified in exposure;"
838 " if blank then filter must be specified in exposure."),
839 dtype=str,
840 default="",
841 deprecated="defaultFilter is deprecated by RFC-716. Will be removed after v22."
842 )
843 anyFilterMapsToThis = pexConfig.Field(
844 doc=("Always use this reference catalog filter, no matter whether or what filter name is "
845 "supplied to the loader. Effectively a trivial filterMap: map all filter names to this filter."
846 " This can be set for purely-astrometric catalogs (e.g. Gaia DR2) where there is only one "
847 "reasonable choice for every camera filter->refcat mapping, but not for refcats used for "
848 "photometry, which need a filterMap and/or colorterms/transmission corrections."),
849 dtype=str,
850 default=None,
851 optional=True
852 )
853 filterMap = pexConfig.DictField(
854 doc=("Mapping of camera filter name: reference catalog filter name; "
855 "each reference filter must exist in the refcat."
856 " Note that this does not perform any bandpass corrections: it is just a lookup."),
857 keytype=str,
858 itemtype=str,
859 default={},
860 )
861 requireProperMotion = pexConfig.Field(
862 doc="Require that the fields needed to correct proper motion "
863 "(epoch, pm_ra and pm_dec) are present?",
864 dtype=bool,
865 default=False,
866 )
868 def validate(self):
869 super().validate()
870 if self.filterMap != {} and self.anyFilterMapsToThis is not None:
871 msg = "`filterMap` and `anyFilterMapsToThis` are mutually exclusive"
872 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis,
873 self, msg)
876class LoadReferenceObjectsTask(pipeBase.Task, ReferenceObjectLoaderBase, metaclass=abc.ABCMeta):
877 """Abstract base class to load objects from reference catalogs.
878 """
879 ConfigClass = LoadReferenceObjectsConfig
880 _DefaultName = "LoadReferenceObjects"
882 def __init__(self, butler=None, *args, **kwargs):
883 """Construct a LoadReferenceObjectsTask
885 Parameters
886 ----------
887 butler : `lsst.daf.persistence.Butler`
888 Data butler, for access reference catalogs.
889 """
890 pipeBase.Task.__init__(self, *args, **kwargs)
891 self.butler = butler
893 @pipeBase.timeMethod
894 def loadPixelBox(self, bbox, wcs, filterName=None, photoCalib=None, epoch=None):
895 """Load reference objects that overlap a rectangular pixel region.
897 Parameters
898 ----------
899 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
900 Bounding box for pixels.
901 wcs : `lsst.afw.geom.SkyWcs`
902 WCS; used to convert pixel positions to sky coordinates
903 and vice-versa.
904 filterName : `str`
905 Name of filter, or `None` or `""` for the default filter.
906 This is used for flux values in case we have flux limits
907 (which are not yet implemented).
908 photoCalib : `lsst.afw.image.PhotoCalib` (optional)
909 Calibration, or `None` if unknown.
910 epoch : `astropy.time.Time` (optional)
911 Epoch to which to correct proper motion and parallax,
912 or None to not apply such corrections.
914 Returns
915 -------
916 results : `lsst.pipe.base.Struct`
917 A Struct containing the following fields:
918 refCat : `lsst.afw.catalog.SimpleCatalog`
919 A catalog of reference objects with the standard
920 schema, as documented in the main doc string for
921 `LoadReferenceObjects`.
922 The catalog is guaranteed to be contiguous.
923 fluxField : `str`
924 Name of flux field for specified `filterName`.
926 Notes
927 -----
928 The search algorithm works by searching in a region in sky
929 coordinates whose center is the center of the bbox and radius
930 is large enough to just include all 4 corners of the bbox.
931 Stars that lie outside the bbox are then trimmed from the list.
932 """
933 circle = self._calculateCircle(bbox, wcs)
935 # find objects in circle
936 self.log.info("Loading reference objects using center %s and radius %s deg" %
937 (circle.coord, circle.radius.asDegrees()))
938 loadRes = self.loadSkyCircle(circle.coord, circle.radius, filterName=filterName, epoch=epoch,
939 centroids=True)
940 refCat = loadRes.refCat
941 numFound = len(refCat)
943 # trim objects outside bbox
944 refCat = self._trimToBBox(refCat=refCat, bbox=circle.bbox, wcs=wcs)
945 numTrimmed = numFound - len(refCat)
946 self.log.debug("trimmed %d out-of-bbox objects, leaving %d", numTrimmed, len(refCat))
947 self.log.info("Loaded %d reference objects", len(refCat))
949 # make sure catalog is contiguous
950 if not refCat.isContiguous():
951 loadRes.refCat = refCat.copy(deep=True)
953 return loadRes
955 @abc.abstractmethod
956 def loadSkyCircle(self, ctrCoord, radius, filterName=None, epoch=None, centroids=False):
957 """Load reference objects that overlap a circular sky region.
959 Parameters
960 ----------
961 ctrCoord : `lsst.geom.SpherePoint`
962 ICRS center of search region.
963 radius : `lsst.geom.Angle`
964 Radius of search region.
965 filterName : `str` (optional)
966 Name of filter, or `None` or `""` for the default filter.
967 This is used for flux values in case we have flux limits
968 (which are not yet implemented).
969 epoch : `astropy.time.Time` (optional)
970 Epoch to which to correct proper motion and parallax,
971 or None to not apply such corrections.
972 centroids : `bool` (optional)
973 Add centroid fields to the loaded Schema. ``loadPixelBox`` expects
974 these fields to exist.
976 Returns
977 -------
978 results : `lsst.pipe.base.Struct`
979 A Struct containing the following fields:
980 refCat : `lsst.afw.catalog.SimpleCatalog`
981 A catalog of reference objects with the standard
982 schema, as documented in the main doc string for
983 `LoadReferenceObjects`.
984 The catalog is guaranteed to be contiguous.
985 fluxField : `str`
986 Name of flux field for specified `filterName`.
988 Notes
989 -----
990 Note that subclasses are responsible for performing the proper motion
991 correction, since this is the lowest-level interface for retrieving
992 the catalog.
993 """
994 return
996 @staticmethod
997 def _trimToBBox(refCat, bbox, wcs):
998 """Remove objects outside a given pixel bounding box and set
999 centroid and hasCentroid fields.
1001 Parameters
1002 ----------
1003 refCat : `lsst.afw.table.SimpleCatalog`
1004 A catalog of objects. The schema must include fields
1005 "coord", "centroid" and "hasCentroid".
1006 The "coord" field is read.
1007 The "centroid" and "hasCentroid" fields are set.
1008 bbox : `lsst.geom.Box2D`
1009 Pixel region
1010 wcs : `lsst.afw.geom.SkyWcs`
1011 WCS; used to convert sky coordinates to pixel positions.
1013 Returns
1014 -------
1015 catalog : `lsst.afw.table.SimpleCatalog`
1016 Reference objects in the bbox, with centroid and
1017 hasCentroid fields set.
1018 """
1019 afwTable.updateRefCentroids(wcs, refCat)
1020 centroidKey = afwTable.Point2DKey(refCat.schema["centroid"])
1021 retStarCat = type(refCat)(refCat.table)
1022 for star in refCat:
1023 point = star.get(centroidKey)
1024 if bbox.contains(point):
1025 retStarCat.append(star)
1026 return retStarCat
1028 def _addFluxAliases(self, schema):
1029 """Add aliases for camera filter fluxes to the schema.
1031 If self.config.defaultFilter then adds these aliases:
1032 camFlux: <defaultFilter>_flux
1033 camFluxErr: <defaultFilter>_fluxErr, if the latter exists
1035 For each camFilter: refFilter in self.config.filterMap adds these aliases:
1036 <camFilter>_camFlux: <refFilter>_flux
1037 <camFilter>_camFluxErr: <refFilter>_fluxErr, if the latter exists
1039 Parameters
1040 ----------
1041 schema : `lsst.afw.table.Schema`
1042 Schema for reference catalog.
1044 Raises
1045 ------
1046 RuntimeError
1047 If any reference flux field is missing from the schema.
1048 """
1049 aliasMap = schema.getAliasMap()
1051 if self.config.anyFilterMapsToThis is not None: 1051 ↛ 1052line 1051 didn't jump to line 1052, because the condition on line 1051 was never true
1052 refFluxName = self.config.anyFilterMapsToThis + "_flux"
1053 if refFluxName not in schema:
1054 msg = f"Unknown reference filter for anyFilterMapsToThis='{refFluxName}'"
1055 raise RuntimeError(msg)
1056 aliasMap.set("anyFilterMapsToThis", refFluxName)
1057 return # this is mutually exclusive with filterMap
1059 def addAliasesForOneFilter(filterName, refFilterName):
1060 """Add aliases for a single filter
1062 Parameters
1063 ----------
1064 filterName : `str` (optional)
1065 Camera filter name. The resulting alias name is
1066 <filterName>_camFlux, or simply "camFlux" if `filterName`
1067 is `None` or `""`.
1068 refFilterName : `str`
1069 Reference catalog filter name; the field
1070 <refFilterName>_flux must exist.
1071 """
1072 camFluxName = filterName + "_camFlux" if filterName is not None else "camFlux"
1073 refFluxName = refFilterName + "_flux"
1074 if refFluxName not in schema:
1075 raise RuntimeError("Unknown reference filter %s" % (refFluxName,))
1076 aliasMap.set(camFluxName, refFluxName)
1077 refFluxErrName = refFluxName + "Err"
1078 if refFluxErrName in schema:
1079 camFluxErrName = camFluxName + "Err"
1080 aliasMap.set(camFluxErrName, refFluxErrName)
1082 if self.config.defaultFilter: 1082 ↛ 1083line 1082 didn't jump to line 1083, because the condition on line 1082 was never true
1083 addAliasesForOneFilter(None, self.config.defaultFilter)
1085 for filterName, refFilterName in self.config.filterMap.items(): 1085 ↛ 1086line 1085 didn't jump to line 1086, because the loop on line 1085 never started
1086 addAliasesForOneFilter(filterName, refFilterName)
1088 @staticmethod
1089 def makeMinimalSchema(filterNameList, *, addCentroid=False,
1090 addIsPhotometric=False, addIsResolved=False,
1091 addIsVariable=False, coordErrDim=2,
1092 addProperMotion=False, properMotionErrDim=2,
1093 addParallax=False):
1094 """Make a standard schema for reference object catalogs.
1096 Parameters
1097 ----------
1098 filterNameList : `list` of `str`
1099 List of filter names. Used to create <filterName>_flux fields.
1100 addIsPhotometric : `bool`
1101 If True then add field "photometric".
1102 addIsResolved : `bool`
1103 If True then add field "resolved".
1104 addIsVariable : `bool`
1105 If True then add field "variable".
1106 coordErrDim : `int`
1107 Number of coord error fields; must be one of 0, 2, 3:
1109 - If 2 or 3: add fields "coord_raErr" and "coord_decErr".
1110 - If 3: also add field "coord_radecErr".
1111 addProperMotion : `bool`
1112 If True add fields "epoch", "pm_ra", "pm_dec" and "pm_flag".
1113 properMotionErrDim : `int`
1114 Number of proper motion error fields; must be one of 0, 2, 3;
1115 ignored if addProperMotion false:
1116 - If 2 or 3: add fields "pm_raErr" and "pm_decErr".
1117 - If 3: also add field "pm_radecErr".
1118 addParallax : `bool`
1119 If True add fields "epoch", "parallax", "parallaxErr"
1120 and "parallax_flag".
1122 Returns
1123 -------
1124 schema : `lsst.afw.table.Schema`
1125 Schema for reference catalog, an
1126 `lsst.afw.table.SimpleCatalog`.
1128 Notes
1129 -----
1130 Reference catalogs support additional covariances, such as
1131 covariance between RA and proper motion in declination,
1132 that are not supported by this method, but can be added after
1133 calling this method.
1134 """
1135 schema = afwTable.SimpleTable.makeMinimalSchema()
1136 if addCentroid: 1136 ↛ 1137line 1136 didn't jump to line 1137, because the condition on line 1136 was never true
1137 afwTable.Point2DKey.addFields(
1138 schema,
1139 "centroid",
1140 "centroid on an exposure, if relevant",
1141 "pixel",
1142 )
1143 schema.addField(
1144 field="hasCentroid",
1145 type="Flag",
1146 doc="is position known?",
1147 )
1148 for filterName in filterNameList:
1149 schema.addField(
1150 field="%s_flux" % (filterName,),
1151 type=numpy.float64,
1152 doc="flux in filter %s" % (filterName,),
1153 units="nJy",
1154 )
1155 for filterName in filterNameList:
1156 schema.addField(
1157 field="%s_fluxErr" % (filterName,),
1158 type=numpy.float64,
1159 doc="flux uncertainty in filter %s" % (filterName,),
1160 units="nJy",
1161 )
1162 if addIsPhotometric: 1162 ↛ 1163line 1162 didn't jump to line 1163, because the condition on line 1162 was never true
1163 schema.addField(
1164 field="photometric",
1165 type="Flag",
1166 doc="set if the object can be used for photometric calibration",
1167 )
1168 if addIsResolved: 1168 ↛ 1169line 1168 didn't jump to line 1169, because the condition on line 1168 was never true
1169 schema.addField(
1170 field="resolved",
1171 type="Flag",
1172 doc="set if the object is spatially resolved",
1173 )
1174 if addIsVariable: 1174 ↛ 1175line 1174 didn't jump to line 1175, because the condition on line 1174 was never true
1175 schema.addField(
1176 field="variable",
1177 type="Flag",
1178 doc="set if the object has variable brightness",
1179 )
1180 if coordErrDim not in (0, 2, 3): 1180 ↛ 1181line 1180 didn't jump to line 1181, because the condition on line 1180 was never true
1181 raise ValueError("coordErrDim={}; must be (0, 2, 3)".format(coordErrDim))
1182 if coordErrDim > 0:
1183 afwTable.CovarianceMatrix2fKey.addFields(
1184 schema=schema,
1185 prefix="coord",
1186 names=["ra", "dec"],
1187 units=["rad", "rad"],
1188 diagonalOnly=(coordErrDim == 2),
1189 )
1191 if addProperMotion or addParallax:
1192 schema.addField(
1193 field="epoch",
1194 type=numpy.float64,
1195 doc="date of observation (TAI, MJD)",
1196 units="day",
1197 )
1199 if addProperMotion:
1200 schema.addField(
1201 field="pm_ra",
1202 type="Angle",
1203 doc="proper motion in the right ascension direction = dra/dt * cos(dec)",
1204 units="rad/year",
1205 )
1206 schema.addField(
1207 field="pm_dec",
1208 type="Angle",
1209 doc="proper motion in the declination direction",
1210 units="rad/year",
1211 )
1212 if properMotionErrDim not in (0, 2, 3): 1212 ↛ 1213line 1212 didn't jump to line 1213, because the condition on line 1212 was never true
1213 raise ValueError("properMotionErrDim={}; must be (0, 2, 3)".format(properMotionErrDim))
1214 if properMotionErrDim > 0: 1214 ↛ 1222line 1214 didn't jump to line 1222, because the condition on line 1214 was never false
1215 afwTable.CovarianceMatrix2fKey.addFields(
1216 schema=schema,
1217 prefix="pm",
1218 names=["ra", "dec"],
1219 units=["rad/year", "rad/year"],
1220 diagonalOnly=(properMotionErrDim == 2),
1221 )
1222 schema.addField(
1223 field="pm_flag",
1224 type="Flag",
1225 doc="Set if proper motion or proper motion error is bad",
1226 )
1228 if addParallax:
1229 schema.addField(
1230 field="parallax",
1231 type="Angle",
1232 doc="parallax",
1233 units="rad",
1234 )
1235 schema.addField(
1236 field="parallaxErr",
1237 type="Angle",
1238 doc="uncertainty in parallax",
1239 units="rad",
1240 )
1241 schema.addField(
1242 field="parallax_flag",
1243 type="Flag",
1244 doc="Set if parallax or parallax error is bad",
1245 )
1246 return schema
1248 def _calculateCircle(self, bbox, wcs):
1249 """Compute on-sky center and radius of search region.
1251 Parameters
1252 ----------
1253 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
1254 Pixel bounding box.
1255 wcs : `lsst.afw.geom.SkyWcs`
1256 WCS; used to convert pixel positions to sky coordinates.
1258 Returns
1259 -------
1260 results : `lsst.pipe.base.Struct`
1261 A Struct containing:
1263 - coord : `lsst.geom.SpherePoint`
1264 ICRS center of the search region.
1265 - radius : `lsst.geom.Angle`
1266 Radius of the search region.
1267 - bbox : `lsst.geom.Box2D`
1268 Bounding box used to compute the circle.
1269 """
1270 bbox = lsst.geom.Box2D(bbox) # make sure bbox is double and that we have a copy
1271 bbox.grow(self.config.pixelMargin)
1272 coord = wcs.pixelToSky(bbox.getCenter())
1273 radius = max(coord.separation(wcs.pixelToSky(pp)) for pp in bbox.getCorners())
1274 return pipeBase.Struct(coord=coord, radius=radius, bbox=bbox)
1276 def getMetadataBox(self, bbox, wcs, filterName=None, photoCalib=None, epoch=None):
1277 """Return metadata about the load.
1279 This metadata is used for reloading the catalog (e.g., for
1280 reconstituting a normalised match list.
1282 Parameters
1283 ----------
1284 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
1285 Pixel bounding box.
1286 wcs : `lsst.afw.geom.SkyWcs`
1287 WCS; used to convert pixel positions to sky coordinates.
1288 filterName : `str`
1289 Name of camera filter, or `None` or `""` for the default
1290 filter.
1291 photoCalib : `lsst.afw.image.PhotoCalib` (optional)
1292 Calibration, or `None` if unknown.
1293 epoch : `astropy.time.Time` (optional)
1294 Epoch to which to correct proper motion and parallax,
1295 or None to not apply such corrections.
1297 Returns
1298 -------
1299 metadata : lsst.daf.base.PropertyList
1300 Metadata about the load.
1301 """
1302 circle = self._calculateCircle(bbox, wcs)
1303 return self.getMetadataCircle(circle.coord, circle.radius, filterName, photoCalib=photoCalib,
1304 epoch=epoch)
1306 def getMetadataCircle(self, coord, radius, filterName, photoCalib=None, epoch=None):
1307 """Return metadata about the load.
1309 This metadata is used for reloading the catalog (e.g., for
1310 reconstituting a normalised match list.
1312 Parameters
1313 ----------
1314 coord : `lsst.geom.SpherePoint`
1315 ICRS center of the search region.
1316 radius : `lsst.geom.Angle`
1317 Radius of the search region.
1318 filterName : `str`
1319 Name of camera filter, or `None` or `""` for the default
1320 filter.
1321 photoCalib : `lsst.afw.image.PhotoCalib` (optional)
1322 Calibration, or `None` if unknown.
1323 epoch : `astropy.time.Time` (optional)
1324 Epoch to which to correct proper motion and parallax,
1325 or None to not apply such corrections.
1327 Returns
1328 -------
1329 metadata : lsst.daf.base.PropertyList
1330 Metadata about the load
1331 """
1332 md = PropertyList()
1333 md.add('RA', coord.getRa().asDegrees(), 'field center in degrees')
1334 md.add('DEC', coord.getDec().asDegrees(), 'field center in degrees')
1335 md.add('RADIUS', radius.asDegrees(), 'field radius in degrees, minimum')
1336 md.add('SMATCHV', 1, 'SourceMatchVector version number')
1337 filterName = "UNKNOWN" if filterName is None else str(filterName)
1338 md.add('FILTER', filterName, 'filter name for photometric data')
1339 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog')
1340 return md
1342 def joinMatchListWithCatalog(self, matchCat, sourceCat):
1343 """Relink an unpersisted match list to sources and reference
1344 objects.
1346 A match list is persisted and unpersisted as a catalog of IDs
1347 produced by afw.table.packMatches(), with match metadata
1348 (as returned by the astrometry tasks) in the catalog's metadata
1349 attribute. This method converts such a match catalog into a match
1350 list, with links to source records and reference object records.
1352 Parameters
1353 ----------
1354 matchCat : `lsst.afw.table.BaseCatalog`
1355 Unperisted packed match list.
1356 ``matchCat.table.getMetadata()`` must contain match metadata,
1357 as returned by the astrometry tasks.
1358 sourceCat : `lsst.afw.table.SourceCatalog`
1359 Source catalog. As a side effect, the catalog will be sorted
1360 by ID.
1362 Returns
1363 -------
1364 matchList : `lsst.afw.table.ReferenceMatchVector`
1365 Match list.
1366 """
1367 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat)
1370def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat):
1371 """Relink an unpersisted match list to sources and reference
1372 objects.
1374 A match list is persisted and unpersisted as a catalog of IDs
1375 produced by afw.table.packMatches(), with match metadata
1376 (as returned by the astrometry tasks) in the catalog's metadata
1377 attribute. This method converts such a match catalog into a match
1378 list, with links to source records and reference object records.
1380 Parameters
1381 ----------
1382 refObjLoader
1383 Reference object loader to use in getting reference objects
1384 matchCat : `lsst.afw.table.BaseCatalog`
1385 Unperisted packed match list.
1386 ``matchCat.table.getMetadata()`` must contain match metadata,
1387 as returned by the astrometry tasks.
1388 sourceCat : `lsst.afw.table.SourceCatalog`
1389 Source catalog. As a side effect, the catalog will be sorted
1390 by ID.
1392 Returns
1393 -------
1394 matchList : `lsst.afw.table.ReferenceMatchVector`
1395 Match list.
1396 """
1397 matchmeta = matchCat.table.getMetadata()
1398 version = matchmeta.getInt('SMATCHV')
1399 if version != 1:
1400 raise ValueError('SourceMatchVector version number is %i, not 1.' % version)
1401 filterName = matchmeta.getString('FILTER').strip()
1402 try:
1403 epoch = matchmeta.getDouble('EPOCH')
1404 except (pexExcept.NotFoundError, pexExcept.TypeError):
1405 epoch = None # Not present, or not correct type means it's not set
1406 if 'RADIUS' in matchmeta:
1407 # This is a circle style metadata, call loadSkyCircle
1408 ctrCoord = lsst.geom.SpherePoint(matchmeta.getDouble('RA'),
1409 matchmeta.getDouble('DEC'), lsst.geom.degrees)
1410 rad = matchmeta.getDouble('RADIUS') * lsst.geom.degrees
1411 refCat = refObjLoader.loadSkyCircle(ctrCoord, rad, filterName, epoch=epoch).refCat
1412 elif "INNER_UPPER_LEFT_RA" in matchmeta:
1413 # This is the sky box type (only triggers in the LoadReferenceObject class, not task)
1414 # Only the outer box is required to be loaded to get the maximum region, all filtering
1415 # will be done by the unpackMatches function, and no spatial filtering needs to be done
1416 # by the refObjLoader
1417 box = []
1418 for place in ("UPPER_LEFT", "UPPER_RIGHT", "LOWER_LEFT", "LOWER_RIGHT"):
1419 coord = lsst.geom.SpherePoint(matchmeta.getDouble(f"OUTER_{place}_RA"),
1420 matchmeta.getDouble(f"OUTER_{place}_DEC"),
1421 lsst.geom.degrees).getVector()
1422 box.append(coord)
1423 outerBox = sphgeom.ConvexPolygon(box)
1424 refCat = refObjLoader.loadRegion(outerBox, filterName=filterName, epoch=epoch).refCat
1426 refCat.sort()
1427 sourceCat.sort()
1428 return afwTable.unpackMatches(matchCat, refCat, sourceCat)
1431def applyProperMotionsImpl(log, catalog, epoch):
1432 """Apply proper motion correction to a reference catalog.
1434 Adjust position and position error in the ``catalog``
1435 for proper motion to the specified ``epoch``,
1436 modifying the catalog in place.
1438 Parameters
1439 ----------
1440 log : `lsst.log.Log`
1441 Log object to write to.
1442 catalog : `lsst.afw.table.SimpleCatalog`
1443 Catalog of positions, containing:
1445 - Coordinates, retrieved by the table's coordinate key.
1446 - ``coord_raErr`` : Error in Right Ascension (rad).
1447 - ``coord_decErr`` : Error in Declination (rad).
1448 - ``pm_ra`` : Proper motion in Right Ascension (rad/yr,
1449 East positive)
1450 - ``pm_raErr`` : Error in ``pm_ra`` (rad/yr), optional.
1451 - ``pm_dec`` : Proper motion in Declination (rad/yr,
1452 North positive)
1453 - ``pm_decErr`` : Error in ``pm_dec`` (rad/yr), optional.
1454 - ``epoch`` : Mean epoch of object (an astropy.time.Time)
1455 epoch : `astropy.time.Time`
1456 Epoch to which to correct proper motion.
1457 """
1458 if "epoch" not in catalog.schema or "pm_ra" not in catalog.schema or "pm_dec" not in catalog.schema:
1459 log.warn("Proper motion correction not available from catalog")
1460 return
1461 if not catalog.isContiguous():
1462 raise RuntimeError("Catalog must be contiguous")
1463 catEpoch = astropy.time.Time(catalog["epoch"], scale="tai", format="mjd")
1464 log.info("Correcting reference catalog for proper motion to %r", epoch)
1465 # Use `epoch.tai` to make sure the time difference is in TAI
1466 timeDiffsYears = (epoch.tai - catEpoch).to(astropy.units.yr).value
1467 coordKey = catalog.table.getCoordKey()
1468 # Compute the offset of each object due to proper motion
1469 # as components of the arc of a great circle along RA and Dec
1470 pmRaRad = catalog["pm_ra"]
1471 pmDecRad = catalog["pm_dec"]
1472 offsetsRaRad = pmRaRad*timeDiffsYears
1473 offsetsDecRad = pmDecRad*timeDiffsYears
1474 # Compute the corresponding bearing and arc length of each offset
1475 # due to proper motion, and apply the offset
1476 # The factor of 1e6 for computing bearing is intended as
1477 # a reasonable scale for typical values of proper motion
1478 # in order to avoid large errors for small values of proper motion;
1479 # using the offsets is another option, but it can give
1480 # needlessly large errors for short duration
1481 offsetBearingsRad = numpy.arctan2(pmDecRad*1e6, pmRaRad*1e6)
1482 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad)
1483 for record, bearingRad, amountRad in zip(catalog, offsetBearingsRad, offsetAmountsRad):
1484 record.set(coordKey,
1485 record.get(coordKey).offset(bearing=bearingRad*lsst.geom.radians,
1486 amount=amountRad*lsst.geom.radians))
1487 # Increase error in RA and Dec based on error in proper motion
1488 if "coord_raErr" in catalog.schema:
1489 catalog["coord_raErr"] = numpy.hypot(catalog["coord_raErr"],
1490 catalog["pm_raErr"]*timeDiffsYears)
1491 if "coord_decErr" in catalog.schema:
1492 catalog["coord_decErr"] = numpy.hypot(catalog["coord_decErr"],
1493 catalog["pm_decErr"]*timeDiffsYears)