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

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