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