Coverage for python/lsst/meas/algorithms/loadReferenceObjects.py: 39%
Shortcuts 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
Shortcuts 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", "ReferenceObjectLoaderBase"]
27import abc
28import logging
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
38from lsst import sphgeom
39from lsst.daf.base import PropertyList
40from lsst.utils.timer import timeMethod
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()): 58 ↛ 59line 58 didn't jump to line 59, because the condition on line 58 was never true
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: 78 ↛ 79line 78 didn't jump to line 79, because the condition on line 78 was never true
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` or `logging.Logger`
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 = afwTable.SchemaMapper(catalog.schema, shareAliasMap=False)
113 mapper.addMinimalSchema(afwTable.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 = afwTable.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 = afwTable.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): 181 ↛ 183line 181 didn't jump to line 183, because the condition on line 181 was never true
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 LoadReferenceObjectsConfig(pexConfig.Config):
193 pixelMargin = pexConfig.RangeField(
194 doc="Padding to add to 4 all edges of the bounding box (pixels)",
195 dtype=int,
196 default=250,
197 min=0,
198 )
199 anyFilterMapsToThis = pexConfig.Field(
200 doc=("Always use this reference catalog filter, no matter whether or what filter name is "
201 "supplied to the loader. Effectively a trivial filterMap: map all filter names to this filter."
202 " This can be set for purely-astrometric catalogs (e.g. Gaia DR2) where there is only one "
203 "reasonable choice for every camera filter->refcat mapping, but not for refcats used for "
204 "photometry, which need a filterMap and/or colorterms/transmission corrections."),
205 dtype=str,
206 default=None,
207 optional=True
208 )
209 filterMap = pexConfig.DictField(
210 doc=("Mapping of camera filter name: reference catalog filter name; "
211 "each reference filter must exist in the refcat."
212 " Note that this does not perform any bandpass corrections: it is just a lookup."),
213 keytype=str,
214 itemtype=str,
215 default={},
216 )
217 requireProperMotion = pexConfig.Field(
218 doc="Require that the fields needed to correct proper motion "
219 "(epoch, pm_ra and pm_dec) are present?",
220 dtype=bool,
221 default=False,
222 )
224 def validate(self):
225 super().validate()
226 if self.filterMap != {} and self.anyFilterMapsToThis is not None:
227 msg = "`filterMap` and `anyFilterMapsToThis` are mutually exclusive"
228 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis,
229 self, msg)
232class ReferenceObjectLoaderBase:
233 """Base class for reference object loaders, to facilitate gen2/gen3 code
234 sharing.
236 Parameters
237 ----------
238 config : `lsst.pex.config.Config`
239 Configuration for the loader.
240 """
241 ConfigClass = LoadReferenceObjectsConfig
243 def __init__(self, config=None, *args, **kwargs):
244 self.config = config
246 def applyProperMotions(self, catalog, epoch):
247 """Apply proper motion correction to a reference catalog.
249 Adjust position and position error in the ``catalog``
250 for proper motion to the specified ``epoch``,
251 modifying the catalog in place.
253 Parameters
254 ----------
255 catalog : `lsst.afw.table.SimpleCatalog`
256 Catalog of positions, containing at least these fields:
258 - Coordinates, retrieved by the table's coordinate key.
259 - ``coord_raErr`` : Error in Right Ascension (rad).
260 - ``coord_decErr`` : Error in Declination (rad).
261 - ``pm_ra`` : Proper motion in Right Ascension (rad/yr,
262 East positive)
263 - ``pm_raErr`` : Error in ``pm_ra`` (rad/yr), optional.
264 - ``pm_dec`` : Proper motion in Declination (rad/yr,
265 North positive)
266 - ``pm_decErr`` : Error in ``pm_dec`` (rad/yr), optional.
267 - ``epoch`` : Mean epoch of object (an astropy.time.Time)
268 epoch : `astropy.time.Time`
269 Epoch to which to correct proper motion.
270 If None, do not apply PM corrections or raise if
271 ``config.requireProperMotion`` is True.
273 Raises
274 ------
275 RuntimeError
276 Raised if ``config.requireProperMotion`` is set but we cannot
277 apply the proper motion correction for some reason.
278 """
279 if epoch is None: 279 ↛ 287line 279 didn't jump to line 287, because the condition on line 279 was never false
280 if self.config.requireProperMotion: 280 ↛ 281line 280 didn't jump to line 281, because the condition on line 280 was never true
281 raise RuntimeError("requireProperMotion=True but epoch not provided to loader.")
282 else:
283 self.log.debug("No epoch provided: not applying proper motion corrections to refcat.")
284 return
286 # Warn/raise for a catalog in an incorrect format, if epoch was specified.
287 if ("pm_ra" in catalog.schema
288 and not isinstance(catalog.schema["pm_ra"].asKey(), afwTable.KeyAngle)):
289 if self.config.requireProperMotion:
290 raise RuntimeError("requireProperMotion=True but refcat pm_ra field is not an Angle.")
291 else:
292 self.log.warning("Reference catalog pm_ra field is not an Angle; cannot apply proper motion.")
293 return
295 if ("epoch" not in catalog.schema or "pm_ra" not in catalog.schema):
296 if self.config.requireProperMotion:
297 raise RuntimeError("requireProperMotion=True but PM data not available from catalog.")
298 else:
299 self.log.warning("Proper motion correction not available for this reference catalog.")
300 return
302 applyProperMotionsImpl(self.log, catalog, epoch)
304 @staticmethod
305 def makeMinimalSchema(filterNameList, *, addCentroid=False,
306 addIsPhotometric=False, addIsResolved=False,
307 addIsVariable=False, coordErrDim=2,
308 addProperMotion=False, properMotionErrDim=2,
309 addParallax=False):
310 """Make a standard schema for reference object catalogs.
312 Parameters
313 ----------
314 filterNameList : `list` of `str`
315 List of filter names. Used to create <filterName>_flux fields.
316 addIsPhotometric : `bool`
317 If True then add field "photometric".
318 addIsResolved : `bool`
319 If True then add field "resolved".
320 addIsVariable : `bool`
321 If True then add field "variable".
322 coordErrDim : `int`
323 Number of coord error fields; must be one of 0, 2, 3:
325 - If 2 or 3: add fields "coord_raErr" and "coord_decErr".
326 - If 3: also add field "coord_radecErr".
327 addProperMotion : `bool`
328 If True add fields "epoch", "pm_ra", "pm_dec" and "pm_flag".
329 properMotionErrDim : `int`
330 Number of proper motion error fields; must be one of 0, 2, 3;
331 ignored if addProperMotion false:
332 - If 2 or 3: add fields "pm_raErr" and "pm_decErr".
333 - If 3: also add field "pm_radecErr".
334 addParallax : `bool`
335 If True add fields "epoch", "parallax", "parallaxErr"
336 and "parallax_flag".
338 Returns
339 -------
340 schema : `lsst.afw.table.Schema`
341 Schema for reference catalog, an
342 `lsst.afw.table.SimpleCatalog`.
344 Notes
345 -----
346 Reference catalogs support additional covariances, such as
347 covariance between RA and proper motion in declination,
348 that are not supported by this method, but can be added after
349 calling this method.
350 """
351 schema = afwTable.SimpleTable.makeMinimalSchema()
352 if addCentroid: 352 ↛ 353line 352 didn't jump to line 353, because the condition on line 352 was never true
353 afwTable.Point2DKey.addFields(
354 schema,
355 "centroid",
356 "centroid on an exposure, if relevant",
357 "pixel",
358 )
359 schema.addField(
360 field="hasCentroid",
361 type="Flag",
362 doc="is position known?",
363 )
364 for filterName in filterNameList:
365 schema.addField(
366 field="%s_flux" % (filterName,),
367 type=numpy.float64,
368 doc="flux in filter %s" % (filterName,),
369 units="nJy",
370 )
371 for filterName in filterNameList:
372 schema.addField(
373 field="%s_fluxErr" % (filterName,),
374 type=numpy.float64,
375 doc="flux uncertainty in filter %s" % (filterName,),
376 units="nJy",
377 )
378 if addIsPhotometric: 378 ↛ 379line 378 didn't jump to line 379, because the condition on line 378 was never true
379 schema.addField(
380 field="photometric",
381 type="Flag",
382 doc="set if the object can be used for photometric calibration",
383 )
384 if addIsResolved: 384 ↛ 385line 384 didn't jump to line 385, because the condition on line 384 was never true
385 schema.addField(
386 field="resolved",
387 type="Flag",
388 doc="set if the object is spatially resolved",
389 )
390 if addIsVariable: 390 ↛ 391line 390 didn't jump to line 391, because the condition on line 390 was never true
391 schema.addField(
392 field="variable",
393 type="Flag",
394 doc="set if the object has variable brightness",
395 )
396 if coordErrDim not in (0, 2, 3): 396 ↛ 397line 396 didn't jump to line 397, because the condition on line 396 was never true
397 raise ValueError("coordErrDim={}; must be (0, 2, 3)".format(coordErrDim))
398 if coordErrDim > 0:
399 afwTable.CovarianceMatrix2fKey.addFields(
400 schema=schema,
401 prefix="coord",
402 names=["ra", "dec"],
403 units=["rad", "rad"],
404 diagonalOnly=(coordErrDim == 2),
405 )
407 if addProperMotion or addParallax:
408 schema.addField(
409 field="epoch",
410 type=numpy.float64,
411 doc="date of observation (TAI, MJD)",
412 units="day",
413 )
415 if addProperMotion:
416 schema.addField(
417 field="pm_ra",
418 type="Angle",
419 doc="proper motion in the right ascension direction = dra/dt * cos(dec)",
420 units="rad/year",
421 )
422 schema.addField(
423 field="pm_dec",
424 type="Angle",
425 doc="proper motion in the declination direction",
426 units="rad/year",
427 )
428 if properMotionErrDim not in (0, 2, 3): 428 ↛ 429line 428 didn't jump to line 429, because the condition on line 428 was never true
429 raise ValueError("properMotionErrDim={}; must be (0, 2, 3)".format(properMotionErrDim))
430 if properMotionErrDim > 0: 430 ↛ 438line 430 didn't jump to line 438, because the condition on line 430 was never false
431 afwTable.CovarianceMatrix2fKey.addFields(
432 schema=schema,
433 prefix="pm",
434 names=["ra", "dec"],
435 units=["rad/year", "rad/year"],
436 diagonalOnly=(properMotionErrDim == 2),
437 )
438 schema.addField(
439 field="pm_flag",
440 type="Flag",
441 doc="Set if proper motion or proper motion error is bad",
442 )
444 if addParallax: 444 ↛ 445line 444 didn't jump to line 445, because the condition on line 444 was never true
445 schema.addField(
446 field="parallax",
447 type="Angle",
448 doc="parallax",
449 units="rad",
450 )
451 schema.addField(
452 field="parallaxErr",
453 type="Angle",
454 doc="uncertainty in parallax",
455 units="rad",
456 )
457 schema.addField(
458 field="parallax_flag",
459 type="Flag",
460 doc="Set if parallax or parallax error is bad",
461 )
462 return schema
464 @staticmethod
465 def _remapReferenceCatalogSchema(refCat, *, anyFilterMapsToThis=None,
466 filterMap=None, centroids=False):
467 """This function takes in a reference catalog and returns a new catalog
468 with additional columns defined from the remaining function arguments.
470 Parameters
471 ----------
472 refCat : `lsst.afw.table.SimpleCatalog`
473 Reference catalog to map to new catalog
474 anyFilterMapsToThis : `str`, optional
475 Always use this reference catalog filter.
476 Mutually exclusive with `filterMap`
477 filterMap : `dict` [`str`,`str`], optional
478 Mapping of camera filter name: reference catalog filter name.
479 centroids : `bool`, optional
480 Add centroid fields to the loaded Schema. ``loadPixelBox`` expects
481 these fields to exist.
483 Returns
484 -------
485 expandedCat : `lsst.afw.table.SimpleCatalog`
486 Deep copy of input reference catalog with additional columns added
487 """
488 if anyFilterMapsToThis or filterMap: 488 ↛ 489line 488 didn't jump to line 489, because the condition on line 488 was never true
489 ReferenceObjectLoaderBase._addFluxAliases(refCat.schema, anyFilterMapsToThis, filterMap)
491 mapper = afwTable.SchemaMapper(refCat.schema, True)
492 mapper.addMinimalSchema(refCat.schema, True)
493 mapper.editOutputSchema().disconnectAliases()
495 if centroids: 495 ↛ 500line 495 didn't jump to line 500, because the condition on line 495 was never true
496 # Add and initialize centroid and hasCentroid fields (these are
497 # added after loading to avoid wasting space in the saved catalogs).
498 # The new fields are automatically initialized to (nan, nan) and
499 # False so no need to set them explicitly.
500 mapper.editOutputSchema().addField("centroid_x", type=float, doReplace=True)
501 mapper.editOutputSchema().addField("centroid_y", type=float, doReplace=True)
502 mapper.editOutputSchema().addField("hasCentroid", type="Flag", doReplace=True)
503 mapper.editOutputSchema().getAliasMap().set("slot_Centroid", "centroid")
505 expandedCat = afwTable.SimpleCatalog(mapper.getOutputSchema())
506 expandedCat.setMetadata(refCat.getMetadata())
507 expandedCat.extend(refCat, mapper=mapper)
509 return expandedCat
511 @staticmethod
512 def _addFluxAliases(schema, anyFilterMapsToThis=None, filterMap=None):
513 """Add aliases for camera filter fluxes to the schema.
515 For each camFilter: refFilter in filterMap, adds these aliases:
516 <camFilter>_camFlux: <refFilter>_flux
517 <camFilter>_camFluxErr: <refFilter>_fluxErr, if the latter exists
518 or sets `anyFilterMapsToThis` in the schema.
520 Parameters
521 ----------
522 schema : `lsst.afw.table.Schema`
523 Schema for reference catalog.
524 anyFilterMapsToThis : `str`, optional
525 Always use this reference catalog filter.
526 Mutually exclusive with `filterMap`.
527 filterMap : `dict` [`str`,`str`], optional
528 Mapping of camera filter name: reference catalog filter name.
529 Mutually exclusive with `anyFilterMapsToThis`.
531 Raises
532 ------
533 RuntimeError
534 Raised if any required reference flux field is missing from the
535 schema.
536 """
537 # Fail on any truthy value for either of these.
538 if anyFilterMapsToThis and filterMap:
539 raise ValueError("anyFilterMapsToThis and filterMap are mutually exclusive!")
541 aliasMap = schema.getAliasMap()
543 if anyFilterMapsToThis is not None:
544 refFluxName = anyFilterMapsToThis + "_flux"
545 if refFluxName not in schema:
546 msg = f"Unknown reference filter for anyFilterMapsToThis='{refFluxName}'"
547 raise RuntimeError(msg)
548 aliasMap.set("anyFilterMapsToThis", refFluxName)
549 return # this is mutually exclusive with filterMap
551 def addAliasesForOneFilter(filterName, refFilterName):
552 """Add aliases for a single filter
554 Parameters
555 ----------
556 filterName : `str` (optional)
557 Camera filter name. The resulting alias name is
558 <filterName>_camFlux
559 refFilterName : `str`
560 Reference catalog filter name; the field
561 <refFilterName>_flux must exist.
562 """
563 camFluxName = filterName + "_camFlux"
564 refFluxName = refFilterName + "_flux"
565 if refFluxName not in schema:
566 raise RuntimeError("Unknown reference filter %s" % (refFluxName,))
567 aliasMap.set(camFluxName, refFluxName)
568 refFluxErrName = refFluxName + "Err"
569 if refFluxErrName in schema:
570 camFluxErrName = camFluxName + "Err"
571 aliasMap.set(camFluxErrName, refFluxErrName)
573 if filterMap is not None:
574 for filterName, refFilterName in filterMap.items():
575 addAliasesForOneFilter(filterName, refFilterName)
577 @staticmethod
578 def _makeBoxRegion(BBox, wcs, BBoxPadding):
579 outerLocalBBox = geom.Box2D(BBox)
580 innerLocalBBox = geom.Box2D(BBox)
582 # Grow the bounding box to allow for effects not fully captured by the
583 # wcs provided (which represents the current best-guess wcs solution
584 # associated with the dataset for which the calibration is to be
585 # computed using the loaded and trimmed reference catalog being defined
586 # here). These effects could include pointing errors and/or an
587 # insufficient optical distorition model for the instrument. The idea
588 # is to ensure the spherical geometric region created contains the
589 # entire region covered by the bbox.
590 # Also create an inner region that is sure to be inside the bbox.
591 outerLocalBBox.grow(BBoxPadding)
592 innerLocalBBox.grow(-1*BBoxPadding)
594 # Handle the case where the inner bounding box shrank to a zero sized
595 # region (which will be the case if the shrunken size of either
596 # dimension is less than or equal to zero). In this case, the inner
597 # bounding box is set to the original input bounding box. This is
598 # probably not the best way to handle an empty inner bounding box, but
599 # it is what the calling code currently expects.
600 if innerLocalBBox.getDimensions() == geom.Extent2D(0, 0):
601 innerLocalBBox = geom.Box2D(BBox)
603 # Convert the corners of the bounding boxes to sky coordinates.
604 innerBoxCorners = innerLocalBBox.getCorners()
605 innerSphCorners = [wcs.pixelToSky(corner).getVector() for corner in innerBoxCorners]
606 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners)
608 outerBoxCorners = outerLocalBBox.getCorners()
609 outerSphCorners = [wcs.pixelToSky(corner).getVector() for corner in outerBoxCorners]
610 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners)
612 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners
614 @staticmethod
615 def _calculateCircle(bbox, wcs, pixelMargin):
616 """Compute on-sky center and radius of search region.
618 Parameters
619 ----------
620 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
621 Pixel bounding box.
622 wcs : `lsst.afw.geom.SkyWcs`
623 WCS; used to convert pixel positions to sky coordinates.
624 pixelMargin : `int`
625 Padding to add to 4 all edges of the bounding box (pixels).
627 Returns
628 -------
629 results : `lsst.pipe.base.Struct`
630 A Struct containing:
632 - coord : `lsst.geom.SpherePoint`
633 ICRS center of the search region.
634 - radius : `lsst.geom.Angle`
635 Radius of the search region.
636 - bbox : `lsst.geom.Box2D`
637 Bounding box used to compute the circle.
638 """
639 bbox = geom.Box2D(bbox) # we modify the box, so use a copy
640 bbox.grow(pixelMargin)
641 coord = wcs.pixelToSky(bbox.getCenter())
642 radius = max(coord.separation(wcs.pixelToSky(pp)) for pp in bbox.getCorners())
643 return pipeBase.Struct(coord=coord, radius=radius, bbox=bbox)
645 @staticmethod
646 def getMetadataCircle(coord, radius, filterName, epoch=None):
647 """Return metadata about the reference catalog being loaded.
649 This metadata is used for reloading the catalog (e.g. for
650 reconstituting a normalized match list).
652 Parameters
653 ----------
654 coord : `lsst.geom.SpherePoint`
655 ICRS center of the search region.
656 radius : `lsst.geom.Angle`
657 Radius of the search region.
658 filterName : `str`
659 Name of the camera filter.
660 epoch : `astropy.time.Time` or `None`, optional
661 Epoch to which to correct proper motion and parallax, or `None` to
662 not apply such corrections.
664 Returns
665 -------
666 md : `lsst.daf.base.PropertyList`
667 Metadata about the catalog.
668 """
669 md = PropertyList()
670 md.add('RA', coord.getRa().asDegrees(), 'field center in degrees')
671 md.add('DEC', coord.getDec().asDegrees(), 'field center in degrees')
672 md.add('RADIUS', radius.asDegrees(), 'field radius in degrees, minimum')
673 md.add('SMATCHV', 1, 'SourceMatchVector version number')
674 md.add('FILTER', filterName, 'filter name for photometric data')
675 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog')
676 return md
678 def getMetadataBox(self, bbox, wcs, filterName, epoch=None,
679 bboxToSpherePadding=100):
680 """Return metadata about the load
682 This metadata is used for reloading the catalog (e.g., for
683 reconstituting a normalised match list).
685 Parameters
686 ----------
687 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
688 Bounding box for the pixels.
689 wcs : `lsst.afw.geom.SkyWcs`
690 The WCS object associated with ``bbox``.
691 filterName : `str`
692 Name of the camera filter.
693 epoch : `astropy.time.Time` or `None`, optional
694 Epoch to which to correct proper motion and parallax, or `None` to
695 not apply such corrections.
696 bboxToSpherePadding : `int`, optional
697 Padding in pixels to account for translating a set of corners into
698 a spherical (convex) boundary that is certain to encompass the
699 enitre area covered by the box.
701 Returns
702 -------
703 md : `lsst.daf.base.PropertyList`
704 The metadata detailing the search parameters used for this
705 dataset.
706 """
707 circle = self._calculateCircle(bbox, wcs, self.config.pixelMargin)
708 md = self.getMetadataCircle(circle.coord, circle.radius, filterName, epoch=epoch)
710 paddedBbox = circle.bbox
711 _, _, innerCorners, outerCorners = self._makeBoxRegion(paddedBbox, wcs, bboxToSpherePadding)
712 for box, corners in zip(("INNER", "OUTER"), (innerCorners, outerCorners)):
713 for (name, corner) in zip(("UPPER_LEFT", "UPPER_RIGHT", "LOWER_LEFT", "LOWER_RIGHT"),
714 corners):
715 md.add(f"{box}_{name}_RA", geom.SpherePoint(corner).getRa().asDegrees(), f"{box}_corner")
716 md.add(f"{box}_{name}_DEC", geom.SpherePoint(corner).getDec().asDegrees(), f"{box}_corner")
717 return md
719 def joinMatchListWithCatalog(self, matchCat, sourceCat):
720 """Relink an unpersisted match list to sources and reference objects.
722 A match list is persisted and unpersisted as a catalog of IDs
723 produced by afw.table.packMatches(), with match metadata
724 (as returned by the astrometry tasks) in the catalog's metadata
725 attribute. This method converts such a match catalog into a match
726 list, with links to source records and reference object records.
728 Parameters
729 ----------
730 matchCat : `lsst.afw.table.BaseCatalog`
731 Unpersisted packed match list.
732 ``matchCat.table.getMetadata()`` must contain match metadata,
733 as returned by the astrometry tasks.
734 sourceCat : `lsst.afw.table.SourceCatalog`
735 Source catalog. As a side effect, the catalog will be sorted
736 by ID.
738 Returns
739 -------
740 matchList : `lsst.afw.table.ReferenceMatchVector`
741 Match list.
742 """
743 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat)
746class ReferenceObjectLoader(ReferenceObjectLoaderBase):
747 """This class facilitates loading reference catalogs with gen 3 middleware.
749 The QuantumGraph generation will create a list of datasets that may
750 possibly overlap a given region. These datasets are then used to construct
751 and instance of this class. The class instance should then be passed into
752 a task which needs reference catalogs. These tasks should then determine
753 the exact region of the sky reference catalogs will be loaded for, and
754 call a corresponding method to load the reference objects.
756 Parameters
757 ----------
758 dataIds : iterable of `lsst.daf.butler.DataCoordinate`
759 An iterable object of data IDs that point to reference catalogs
760 in a gen 3 repository.
761 refCats : iterable of `lsst.daf.butler.DeferedDatasetHandle`
762 Handles to load refCats on demand
763 log : `lsst.log.Log`, `logging.Logger` or `None`, optional
764 Logger object used to write out messages. If `None` a default
765 logger will be used.
766 """
767 def __init__(self, dataIds, refCats, log=None, **kwargs):
768 super().__init__(**kwargs)
769 self.dataIds = dataIds
770 self.refCats = refCats
771 self.log = log or logging.getLogger(__name__).getChild("ReferenceObjectLoader")
773 def loadPixelBox(self, bbox, wcs, filterName, epoch=None,
774 bboxToSpherePadding=100):
775 """Load reference objects that are within a pixel-based rectangular
776 region.
778 This algorithm works by creating a spherical box whose corners
779 correspond to the WCS converted corners of the input bounding box
780 (possibly padded). It then defines a filtering function which looks at
781 the pixel position of the reference objects and accepts only those that
782 lie within the specified bounding box.
784 The spherical box region and filtering function are passed to the
785 generic loadRegion method which loads and filters the reference objects
786 from the datastore and returns a single catalog containing the filtered
787 set of reference objects.
789 Parameters
790 ----------
791 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
792 Box which bounds a region in pixel space.
793 wcs : `lsst.afw.geom.SkyWcs`
794 Wcs object defining the pixel to sky (and inverse) transform for
795 the supplied ``bbox``.
796 filterName : `str`
797 Name of camera filter.
798 epoch : `astropy.time.Time` or `None`, optional
799 Epoch to which to correct proper motion and parallax, or `None`
800 to not apply such corrections.
801 bboxToSpherePadding : `int`, optional
802 Padding to account for translating a set of corners into a
803 spherical (convex) boundary that is certain to encompase the
804 enitre area covered by the box.
806 Returns
807 -------
808 referenceCatalog : `lsst.afw.table.SimpleCatalog`
809 Catalog containing reference objects inside the specified bounding
810 box (padded by self.config.pixelMargin).
812 Raises
813 ------
814 RuntimeError
815 Raised if no reference catalogs could be found for the specified
816 region.
817 TypeError
818 Raised if the loaded reference catalogs do not have matching
819 schemas.
820 """
821 paddedBbox = geom.Box2D(bbox)
822 paddedBbox.grow(self.config.pixelMargin)
823 innerSkyRegion, outerSkyRegion, _, _ = self._makeBoxRegion(paddedBbox, wcs, bboxToSpherePadding)
825 def _filterFunction(refCat, region):
826 # Perform an initial "pre filter" step based on the refCat coords
827 # and the outerSkyRegion created from the self.config.pixelMargin-
828 # paddedBbox plus an "extra" padding of bboxToSpherePadding and the
829 # raw wcs. This should ensure a large enough projected area on the
830 # sky that accounts for any projection/distortion issues, but small
831 # enough to filter out loaded reference objects that lie well
832 # beyond the projected detector of interest. This step is required
833 # due to the very local nature of the wcs available for the
834 # sky <--> pixel conversions.
835 preFiltFunc = _FilterCatalog(outerSkyRegion)
836 refCat = preFiltFunc(refCat, region)
838 # Add columns to the pre-filtered reference catalog relating their
839 # coordinates to equivalent pixel positions for the wcs provided
840 # and use to populate those columns.
841 refCat = self._remapReferenceCatalogSchema(refCat, centroids=True)
842 afwTable.updateRefCentroids(wcs, refCat)
843 # No need to filter the catalog if it is entirely contained in the
844 # region defined by the inner sky region.
845 if innerSkyRegion.contains(region):
846 return refCat
847 # Create a new reference catalog, and populate it only with records
848 # that fall inside the padded bbox.
849 filteredRefCat = type(refCat)(refCat.table)
850 centroidKey = afwTable.Point2DKey(refCat.schema['centroid'])
851 for record in refCat:
852 pixCoords = record[centroidKey]
853 if paddedBbox.contains(geom.Point2D(pixCoords)):
854 filteredRefCat.append(record)
855 return filteredRefCat
856 return self.loadRegion(outerSkyRegion, filterName, filtFunc=_filterFunction, epoch=epoch)
858 def loadRegion(self, region, filterName, filtFunc=None, epoch=None):
859 """Load reference objects within a specified region.
861 This function loads the DataIds used to construct an instance of this
862 class which intersect or are contained within the specified region. The
863 reference catalogs which intersect but are not fully contained within
864 the input region are further filtered by the specified filter function.
865 This function returns a single source catalog containing all reference
866 objects inside the specified region.
868 Parameters
869 ----------
870 region : `lsst.sphgeom.Region`
871 This can be any type that is derived from `lsst.sphgeom.Region` and
872 should define the spatial region for which reference objects are to
873 be loaded.
874 filtFunc : callable or `None`, optional
875 This optional parameter should be a callable object that takes a
876 reference catalog and its corresponding region as parameters,
877 filters the catalog by some criteria and returns the filtered
878 reference catalog. If `None`, an internal filter function is used
879 which filters according to if a reference object falls within the
880 input region.
881 filterName : `str`
882 Name of camera filter.
883 epoch : `astropy.time.Time` or `None`, optional
884 Epoch to which to correct proper motion and parallax, or `None` to
885 not apply such corrections.
887 Returns
888 -------
889 referenceCatalog : `lsst.afw.table.SourceCatalog`
890 Catalog containing reference objects which intersect the input region,
891 filtered by the specified filter function.
893 Raises
894 ------
895 RuntimeError
896 Raised if no reference catalogs could be found for the specified
897 region.
898 TypeError
899 Raised if the loaded reference catalogs do not have matching
900 schemas.
901 """
902 regionLat = region.getBoundingBox().getLat()
903 regionLon = region.getBoundingBox().getLon()
904 self.log.info("Loading reference objects from %s in region bounded by "
905 "[%.8f, %.8f], [%.8f, %.8f] RA Dec",
906 # Name of refcat we're loading from is the datasetType.
907 self.refCats[0].ref.datasetType.name,
908 regionLon.getA().asDegrees(), regionLon.getB().asDegrees(),
909 regionLat.getA().asDegrees(), regionLat.getB().asDegrees())
910 if filtFunc is None: 910 ↛ 913line 910 didn't jump to line 913, because the condition on line 910 was never false
911 filtFunc = _FilterCatalog(region)
912 # filter out all the regions supplied by the constructor that do not overlap
913 overlapList = []
914 for dataId, refCat in zip(self.dataIds, self.refCats):
915 # SphGeom supports some objects intersecting others, but is not symmetric,
916 # try the intersect operation in both directions
917 try:
918 intersects = dataId.region.intersects(region)
919 except TypeError:
920 intersects = region.intersects(dataId.region)
922 if intersects:
923 overlapList.append((dataId, refCat))
925 if len(overlapList) == 0: 925 ↛ 926line 925 didn't jump to line 926, because the condition on line 925 was never true
926 raise RuntimeError("No reference tables could be found for input region")
928 firstCat = overlapList[0][1].get()
929 refCat = filtFunc(firstCat, overlapList[0][0].region)
930 trimmedAmount = len(firstCat) - len(refCat)
932 # Load in the remaining catalogs
933 for dataId, inputRefCat in overlapList[1:]: 933 ↛ 934line 933 didn't jump to line 934, because the loop on line 933 never started
934 tmpCat = inputRefCat.get()
936 if tmpCat.schema != firstCat.schema:
937 raise TypeError("Reference catalogs have mismatching schemas")
939 filteredCat = filtFunc(tmpCat, dataId.region)
940 refCat.extend(filteredCat)
941 trimmedAmount += len(tmpCat) - len(filteredCat)
943 self.log.debug("Trimmed %d refCat objects lying outside padded region, leaving %d",
944 trimmedAmount, len(refCat))
945 self.log.info("Loaded %d reference objects", len(refCat))
947 # Ensure that the loaded reference catalog is continuous in memory
948 if not refCat.isContiguous(): 948 ↛ 949line 948 didn't jump to line 949, because the condition on line 948 was never true
949 refCat = refCat.copy(deep=True)
951 self.applyProperMotions(refCat, epoch)
953 # Verify the schema is in the correct units and has the correct version; automatically convert
954 # it with a warning if this is not the case.
955 if not hasNanojanskyFluxUnits(refCat.schema) or not getFormatVersionFromRefCat(refCat) >= 1: 955 ↛ 956line 955 didn't jump to line 956, because the condition on line 955 was never true
956 self.log.warning("Found version 0 reference catalog with old style units in schema.")
957 self.log.warning("run `meas_algorithms/bin/convert_refcat_to_nJy.py` to convert fluxes to nJy.")
958 self.log.warning("See RFC-575 for more details.")
959 refCat = convertToNanojansky(refCat, self.log)
961 expandedCat = self._remapReferenceCatalogSchema(refCat,
962 anyFilterMapsToThis=self.config.anyFilterMapsToThis,
963 filterMap=self.config.filterMap)
965 # Ensure that the returned reference catalog is continuous in memory
966 if not expandedCat.isContiguous(): 966 ↛ 967line 966 didn't jump to line 967, because the condition on line 966 was never true
967 expandedCat = expandedCat.copy(deep=True)
969 fluxField = getRefFluxField(expandedCat.schema, filterName)
970 return pipeBase.Struct(refCat=expandedCat, fluxField=fluxField)
972 def loadSkyCircle(self, ctrCoord, radius, filterName, epoch=None):
973 """Load reference objects that lie within a circular region on the sky.
975 This method constructs a circular region from an input center and
976 angular radius, loads reference catalogs which are contained in or
977 intersect the circle, and filters reference catalogs which intersect
978 down to objects which lie within the defined circle.
980 Parameters
981 ----------
982 ctrCoord : `lsst.geom.SpherePoint`
983 Point defining the center of the circular region.
984 radius : `lsst.geom.Angle`
985 Defines the angular radius of the circular region.
986 filterName : `str`
987 Name of camera filter.
988 epoch : `astropy.time.Time` or `None`, optional
989 Epoch to which to correct proper motion and parallax, or `None` to
990 not apply such corrections.
992 Returns
993 -------
994 referenceCatalog : `lsst.afw.table.SourceCatalog`
995 Catalog containing reference objects inside the specified search
996 circle.
997 """
998 centerVector = ctrCoord.getVector()
999 sphRadius = sphgeom.Angle(radius.asRadians())
1000 circularRegion = sphgeom.Circle(centerVector, sphRadius)
1001 return self.loadRegion(circularRegion, filterName, epoch=epoch)
1004def getRefFluxField(schema, filterName):
1005 """Get the name of a flux field from a schema.
1007 return the alias of "anyFilterMapsToThis", if present
1008 else:
1009 return "*filterName*_camFlux" if present
1010 else return "*filterName*_flux" if present (camera filter name
1011 matches reference filter name)
1012 else throw RuntimeError
1014 Parameters
1015 ----------
1016 schema : `lsst.afw.table.Schema`
1017 Reference catalog schema.
1018 filterName : `str`
1019 Name of camera filter.
1021 Returns
1022 -------
1023 fluxFieldName : `str`
1024 Name of flux field.
1026 Raises
1027 ------
1028 RuntimeError
1029 If an appropriate field is not found.
1030 """
1031 if not isinstance(schema, afwTable.Schema): 1031 ↛ 1032line 1031 didn't jump to line 1032, because the condition on line 1031 was never true
1032 raise RuntimeError("schema=%s is not a schema" % (schema,))
1033 try:
1034 return schema.getAliasMap().get("anyFilterMapsToThis")
1035 except LookupError:
1036 pass # try the filterMap next
1038 fluxFieldList = [filterName + "_camFlux", filterName + "_flux"]
1039 for fluxField in fluxFieldList: 1039 ↛ 1043line 1039 didn't jump to line 1043, because the loop on line 1039 didn't complete
1040 if fluxField in schema:
1041 return fluxField
1043 raise RuntimeError("Could not find flux field(s) %s" % (", ".join(fluxFieldList)))
1046def getRefFluxKeys(schema, filterName):
1047 """Return keys for flux and flux error.
1049 Parameters
1050 ----------
1051 schema : `lsst.afw.table.Schema`
1052 Reference catalog schema.
1053 filterName : `str`
1054 Name of camera filter.
1056 Returns
1057 -------
1058 keys : `tuple` of (`lsst.afw.table.Key`, `lsst.afw.table.Key`)
1059 Two keys:
1061 - flux key
1062 - flux error key, if present, else None
1064 Raises
1065 ------
1066 RuntimeError
1067 If flux field not found.
1068 """
1069 fluxField = getRefFluxField(schema, filterName)
1070 fluxErrField = fluxField + "Err"
1071 fluxKey = schema[fluxField].asKey()
1072 try:
1073 fluxErrKey = schema[fluxErrField].asKey()
1074 except Exception:
1075 fluxErrKey = None
1076 return (fluxKey, fluxErrKey)
1079class LoadReferenceObjectsTask(pipeBase.Task, ReferenceObjectLoaderBase, metaclass=abc.ABCMeta):
1080 """Abstract gen2 base class to load objects from reference catalogs.
1081 """
1082 _DefaultName = "LoadReferenceObjects"
1084 def __init__(self, butler=None, *args, **kwargs):
1085 """Construct a LoadReferenceObjectsTask
1087 Parameters
1088 ----------
1089 butler : `lsst.daf.persistence.Butler`
1090 Data butler, for access reference catalogs.
1091 """
1092 pipeBase.Task.__init__(self, *args, **kwargs)
1093 self.butler = butler
1095 @timeMethod
1096 def loadPixelBox(self, bbox, wcs, filterName, photoCalib=None, epoch=None):
1097 """Load reference objects that overlap a rectangular pixel region.
1099 Parameters
1100 ----------
1101 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D`
1102 Bounding box for pixels.
1103 wcs : `lsst.afw.geom.SkyWcs`
1104 WCS; used to convert pixel positions to sky coordinates
1105 and vice-versa.
1106 filterName : `str`
1107 Name of filter. This can be used for flux limit comparisons.
1108 photoCalib : `None`
1109 Deprecated, only included for api compatibility.
1110 epoch : `astropy.time.Time` or `None`, optional
1111 Epoch to which to correct proper motion and parallax, or `None` to
1112 not apply such corrections.
1114 Returns
1115 -------
1116 results : `lsst.pipe.base.Struct`
1117 A Struct containing the following fields:
1118 refCat : `lsst.afw.catalog.SimpleCatalog`
1119 A catalog of reference objects with the standard
1120 schema, as documented in the main doc string for
1121 `LoadReferenceObjects`.
1122 The catalog is guaranteed to be contiguous.
1123 fluxField : `str`
1124 Name of flux field for specified `filterName`.
1126 Notes
1127 -----
1128 The search algorithm works by searching in a region in sky
1129 coordinates whose center is the center of the bbox and radius
1130 is large enough to just include all 4 corners of the bbox.
1131 Stars that lie outside the bbox are then trimmed from the list.
1132 """
1133 circle = self._calculateCircle(bbox, wcs, self.config.pixelMargin)
1135 # find objects in circle
1136 self.log.info("Loading reference objects from %s using center %s and radius %s deg",
1137 self.config.ref_dataset_name, circle.coord, circle.radius.asDegrees())
1138 loadRes = self.loadSkyCircle(circle.coord, circle.radius, filterName, epoch=epoch,
1139 centroids=True)
1140 refCat = loadRes.refCat
1141 numFound = len(refCat)
1143 # trim objects outside bbox
1144 refCat = self._trimToBBox(refCat=refCat, bbox=circle.bbox, wcs=wcs)
1145 numTrimmed = numFound - len(refCat)
1146 self.log.debug("trimmed %d out-of-bbox objects, leaving %d", numTrimmed, len(refCat))
1147 self.log.info("Loaded %d reference objects", len(refCat))
1149 # make sure catalog is contiguous
1150 if not refCat.isContiguous():
1151 loadRes.refCat = refCat.copy(deep=True)
1153 return loadRes
1155 @abc.abstractmethod
1156 def loadSkyCircle(self, ctrCoord, radius, filterName, epoch=None, centroids=False):
1157 """Load reference objects that overlap a circular sky region.
1159 Parameters
1160 ----------
1161 ctrCoord : `lsst.geom.SpherePoint`
1162 ICRS center of search region.
1163 radius : `lsst.geom.Angle`
1164 Radius of search region.
1165 filterName : `str`
1166 Name of filter. This can be used for flux limit comparisons.
1167 epoch : `astropy.time.Time` or `None`, optional
1168 Epoch to which to correct proper motion and parallax, or `None` to
1169 not apply such corrections.
1170 centroids : `bool`, optional
1171 Add centroid fields to the loaded Schema. ``loadPixelBox`` expects
1172 these fields to exist.
1174 Returns
1175 -------
1176 results : `lsst.pipe.base.Struct`
1177 A Struct containing the following fields:
1178 refCat : `lsst.afw.catalog.SimpleCatalog`
1179 A catalog of reference objects with the standard
1180 schema, as documented in the main doc string for
1181 `LoadReferenceObjects`.
1182 The catalog is guaranteed to be contiguous.
1183 fluxField : `str`
1184 Name of flux field for specified `filterName`.
1186 Notes
1187 -----
1188 Note that subclasses are responsible for performing the proper motion
1189 correction, since this is the lowest-level interface for retrieving
1190 the catalog.
1191 """
1192 return
1194 @staticmethod
1195 def _trimToBBox(refCat, bbox, wcs):
1196 """Remove objects outside a given pixel bounding box and set
1197 centroid and hasCentroid fields.
1199 Parameters
1200 ----------
1201 refCat : `lsst.afw.table.SimpleCatalog`
1202 A catalog of objects. The schema must include fields
1203 "coord", "centroid" and "hasCentroid".
1204 The "coord" field is read.
1205 The "centroid" and "hasCentroid" fields are set.
1206 bbox : `lsst.geom.Box2D`
1207 Pixel region
1208 wcs : `lsst.afw.geom.SkyWcs`
1209 WCS; used to convert sky coordinates to pixel positions.
1211 Returns
1212 -------
1213 catalog : `lsst.afw.table.SimpleCatalog`
1214 Reference objects in the bbox, with centroid and
1215 hasCentroid fields set.
1216 """
1217 afwTable.updateRefCentroids(wcs, refCat)
1218 centroidKey = afwTable.Point2DKey(refCat.schema["centroid"])
1219 retStarCat = type(refCat)(refCat.table)
1220 for star in refCat:
1221 point = star.get(centroidKey)
1222 if bbox.contains(point):
1223 retStarCat.append(star)
1224 return retStarCat
1227def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat):
1228 """Relink an unpersisted match list to sources and reference
1229 objects.
1231 A match list is persisted and unpersisted as a catalog of IDs
1232 produced by afw.table.packMatches(), with match metadata
1233 (as returned by the astrometry tasks) in the catalog's metadata
1234 attribute. This method converts such a match catalog into a match
1235 list, with links to source records and reference object records.
1237 Parameters
1238 ----------
1239 refObjLoader
1240 Reference object loader to use in getting reference objects
1241 matchCat : `lsst.afw.table.BaseCatalog`
1242 Unperisted packed match list.
1243 ``matchCat.table.getMetadata()`` must contain match metadata,
1244 as returned by the astrometry tasks.
1245 sourceCat : `lsst.afw.table.SourceCatalog`
1246 Source catalog. As a side effect, the catalog will be sorted
1247 by ID.
1249 Returns
1250 -------
1251 matchList : `lsst.afw.table.ReferenceMatchVector`
1252 Match list.
1253 """
1254 matchmeta = matchCat.table.getMetadata()
1255 version = matchmeta.getInt('SMATCHV')
1256 if version != 1:
1257 raise ValueError('SourceMatchVector version number is %i, not 1.' % version)
1258 filterName = matchmeta.getString('FILTER').strip()
1259 try:
1260 epoch = matchmeta.getDouble('EPOCH')
1261 except (LookupError, TypeError):
1262 epoch = None # Not present, or not correct type means it's not set
1263 if 'RADIUS' in matchmeta:
1264 # This is a circle style metadata, call loadSkyCircle
1265 ctrCoord = geom.SpherePoint(matchmeta.getDouble('RA'),
1266 matchmeta.getDouble('DEC'), geom.degrees)
1267 rad = matchmeta.getDouble('RADIUS')*geom.degrees
1268 refCat = refObjLoader.loadSkyCircle(ctrCoord, rad, filterName, epoch=epoch).refCat
1269 elif "INNER_UPPER_LEFT_RA" in matchmeta:
1270 # This is the sky box type (only triggers in the LoadReferenceObject class, not task)
1271 # Only the outer box is required to be loaded to get the maximum region, all filtering
1272 # will be done by the unpackMatches function, and no spatial filtering needs to be done
1273 # by the refObjLoader
1274 box = []
1275 for place in ("UPPER_LEFT", "UPPER_RIGHT", "LOWER_LEFT", "LOWER_RIGHT"):
1276 coord = geom.SpherePoint(matchmeta.getDouble(f"OUTER_{place}_RA"),
1277 matchmeta.getDouble(f"OUTER_{place}_DEC"),
1278 geom.degrees).getVector()
1279 box.append(coord)
1280 outerBox = sphgeom.ConvexPolygon(box)
1281 refCat = refObjLoader.loadRegion(outerBox, filterName, epoch=epoch).refCat
1283 refCat.sort()
1284 sourceCat.sort()
1285 return afwTable.unpackMatches(matchCat, refCat, sourceCat)
1288def applyProperMotionsImpl(log, catalog, epoch):
1289 """Apply proper motion correction to a reference catalog.
1291 Adjust position and position error in the ``catalog``
1292 for proper motion to the specified ``epoch``,
1293 modifying the catalog in place.
1295 Parameters
1296 ----------
1297 log : `lsst.log.Log` or `logging.getLogger`
1298 Log object to write to.
1299 catalog : `lsst.afw.table.SimpleCatalog`
1300 Catalog of positions, containing:
1302 - Coordinates, retrieved by the table's coordinate key.
1303 - ``coord_raErr`` : Error in Right Ascension (rad).
1304 - ``coord_decErr`` : Error in Declination (rad).
1305 - ``pm_ra`` : Proper motion in Right Ascension (rad/yr,
1306 East positive)
1307 - ``pm_raErr`` : Error in ``pm_ra`` (rad/yr), optional.
1308 - ``pm_dec`` : Proper motion in Declination (rad/yr,
1309 North positive)
1310 - ``pm_decErr`` : Error in ``pm_dec`` (rad/yr), optional.
1311 - ``epoch`` : Mean epoch of object (an astropy.time.Time)
1312 epoch : `astropy.time.Time`
1313 Epoch to which to correct proper motion.
1314 """
1315 if "epoch" not in catalog.schema or "pm_ra" not in catalog.schema or "pm_dec" not in catalog.schema:
1316 log.warning("Proper motion correction not available from catalog")
1317 return
1318 if not catalog.isContiguous():
1319 raise RuntimeError("Catalog must be contiguous")
1320 catEpoch = astropy.time.Time(catalog["epoch"], scale="tai", format="mjd")
1321 log.info("Correcting reference catalog for proper motion to %r", epoch)
1322 # Use `epoch.tai` to make sure the time difference is in TAI
1323 timeDiffsYears = (epoch.tai - catEpoch).to(astropy.units.yr).value
1324 coordKey = catalog.table.getCoordKey()
1325 # Compute the offset of each object due to proper motion
1326 # as components of the arc of a great circle along RA and Dec
1327 pmRaRad = catalog["pm_ra"]
1328 pmDecRad = catalog["pm_dec"]
1329 offsetsRaRad = pmRaRad*timeDiffsYears
1330 offsetsDecRad = pmDecRad*timeDiffsYears
1331 # Compute the corresponding bearing and arc length of each offset
1332 # due to proper motion, and apply the offset
1333 # The factor of 1e6 for computing bearing is intended as
1334 # a reasonable scale for typical values of proper motion
1335 # in order to avoid large errors for small values of proper motion;
1336 # using the offsets is another option, but it can give
1337 # needlessly large errors for short duration
1338 offsetBearingsRad = numpy.arctan2(pmDecRad*1e6, pmRaRad*1e6)
1339 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad)
1340 for record, bearingRad, amountRad in zip(catalog, offsetBearingsRad, offsetAmountsRad):
1341 record.set(coordKey,
1342 record.get(coordKey).offset(bearing=bearingRad*geom.radians,
1343 amount=amountRad*geom.radians))
1344 # Increase error in RA and Dec based on error in proper motion
1345 if "coord_raErr" in catalog.schema:
1346 catalog["coord_raErr"] = numpy.hypot(catalog["coord_raErr"],
1347 catalog["pm_raErr"]*timeDiffsYears)
1348 if "coord_decErr" in catalog.schema:
1349 catalog["coord_decErr"] = numpy.hypot(catalog["coord_decErr"],
1350 catalog["pm_decErr"]*timeDiffsYears)