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