Source code for stellarphot.catalogs

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Catalog-fetcher factory functions.

These build a :class:`stellarphot.CatalogData` table from an online catalog
(Vizier/astroquery). They live here, separate from :mod:`stellarphot.core`,
because they are data *retrieval* helpers rather than data-structure
definitions. ``core.py`` holds the table classes only.
"""

import logging
import time
import warnings

import numpy as np
from astropy import units as u
from astropy.table import Table, unique
from astroquery.xmatch import XMatch

from .core import CatalogData
from .settings import PassbandMap

__all__ = [
    "apass_dr9",
    "vsx_vizier",
    "refcat2",
]

logger = logging.getLogger(__name__)


def _attach_gaia_ids(catalog, xmatch_result, index_col="_sp_index"):
    """
    Attach Gaia source_ids from an XMatch result to a catalog, joining on an
    index column rather than on row order, which XMatch does not preserve.

    Parameters
    ----------
    catalog : `astropy.table.Table`
        The table the IDs are being attached to. The values of ``index_col``
        in ``xmatch_result`` are row numbers into this table.

    xmatch_result : `astropy.table.Table`
        Result of an XMatch query whose uploaded table included ``index_col``.
        Must contain ``index_col``, ``angDist`` and ``source_id`` columns.
        This table is sorted in place.

    index_col : str, optional
        Name of the column in ``xmatch_result`` holding the row number of the
        matching ``catalog`` row.

    Returns
    -------
    `astropy.table.Table`
        The matched rows of ``catalog``, in their original order, with a new
        ``id`` column holding the Gaia source_id. Rows with no match are
        dropped, with a warning saying how many.
    """
    # When one input row matches several Gaia sources, keep only the nearest.
    xmatch_result.sort([index_col, "angDist"])
    nearest = unique(xmatch_result, keys=index_col, keep="first")

    n_unmatched = len(catalog) - len(nearest)
    if n_unmatched > 0:
        warnings.warn(
            f"{n_unmatched} of {len(catalog)} catalog entries had no Gaia "
            "match and have been dropped.",
            stacklevel=2,
        )

    # nearest[index_col] holds the row numbers of the catalog rows that got a
    # match — one per matched star, sorted ascending. Indexing catalog with it
    # does two things at once: it drops the unmatched rows (their index never
    # appears in the result) and it puts the matched rows in the same order as
    # nearest, so the source_id assignment below lines up row-for-row.
    catalog = catalog[np.asarray(nearest[index_col])]
    catalog["id"] = np.asarray(nearest["source_id"])
    return catalog


def _process_refcat2(catalog):
    """
    Prepare a raw Vizier refcat2 table:

    1. Filter out galaxies from the catalog.
    2. Only keep stars that are in the Gaia DR2 catalog.
    3. Add the Gaia DR2 ID number to the catalog as the ID column.
    """
    # 1.
    # The refcat2 paper says that "Virtually all galaxies can be rejected by
    # selecting objects for which Gaia provides a nonzero proper-motion
    # uncertainty," which in the Vizier download are called e_pmRA and e_pmDE,
    # "at the cost of about 0.7% of all real stars." Seems like a reasonable
    # trade-off. Vizier omits the zero entries and astroquery returns a mask for the
    # zero entries, so galaxies are the masked ones.
    galaxies = catalog["e_pmRA"].mask & catalog["e_pmDE"].mask
    catalog = catalog[~galaxies]

    # 2.
    # Also from the paper, "A non-Gaia star may be identified in Refcat2 because it
    # will always have dGaia = 0." In the Vizier version of refcat2, this column is
    # called e_Gmag and instead of being zero, the value is masked.
    catalog = catalog[~catalog["e_Gmag"].mask]

    # 3.
    # Everything left should be a Gaia star, so match to that.
    # This adds some not-insignificant time to getting the catalog, but
    # the result is automatically cached by astroquery, which helps.
    #
    # Upload only the coordinates plus a row index; uploading the full
    # 40+ column table makes the query much slower and more likely to fail,
    # and the index is the only reliable way to join the result back to the
    # catalog because XMatch does not preserve row order.
    upload = Table(
        {
            "_sp_index": np.arange(len(catalog)),
            "RA_ICRS": catalog["RA_ICRS"],
            "DE_ICRS": catalog["DE_ICRS"],
        }
    )
    start = time.perf_counter()
    result = XMatch.query(
        cat1=upload,
        cat2="vizier:gaia_dr2_j2015p5",  # "vizier:I/345/gaia2",
        max_distance=0.01 * u.arcsec,
        colRA1="RA_ICRS",
        colDec1="DE_ICRS",
    )
    elapsed = time.perf_counter() - start
    logger.info(
        "XMatch query for Gaia DR2 IDs took %.1f sec "
        "(%d rows uploaded, %d matches returned)",
        elapsed,
        len(upload),
        len(result),
    )

    return _attach_gaia_ids(catalog, result)


[docs] def apass_dr9( field_center, radius=1 * u.degree, clip_by_frame=False, padding=100, magnitude_limit=None, magnitude_limit_passband=None, ): """ Return the items from APASS DR9 that are within the search radius and (optionally) within the field of view of a frame. Parameters ---------- field_center : `astropy.coordinates.SkyCoord`, `astropy.wcs.WCS`, or FITS header Either a `~astropy.coordinates.SkyCoord` object, a `~astropy.wcs.WCS` object or a FITS header with WCS information. The input coordinate should be the center of the frame; if a header or WCS is the input then the center of the frame will be determined from the WCS. radius : `astropy.units.Quantity`, optional Radius around which to search. clip_by_frame : bool, optional If ``True``, only return items that are within the field of view of the frame. padding : int, optional Coordinates need to be at least this many pixels in from the edge of the frame to be considered in the field of view. Default value is 100. magnitude_limit : float, optional If provided, only return items with magnitudes less than or equal to this value. magnitude_limit_passband : str, optional, default is "V" If provided, the passband to use for the magnitude limit. The name of the passband must be one of the AAVSO standard passband names. Returns ------- `stellarphot.CatalogData` Table of catalog information. Notes ----- APASS DR9 does not include an identifier column. Though Vizier does provide a ``recno`` column, it does not stay the same over time. This function generates an ID based on the coordinates of the APASS star, following the guidelines in `IAU designation specification <https://cds.unistra.fr/Dic/iau-spec.html>`_. """ apass_colnames = { # There is no APASS ID, and this isn't a real ID either...but we need something # for ID, and every APASS line is guaranteed to have a field number, so we'll # use it. We replace the id column below anyway. "Field": "id", "RAJ2000": "ra", "DEJ2000": "dec", } if magnitude_limit is None and magnitude_limit_passband is not None: raise ValueError( "If you provide a magnitude_limit_passband, you must also " "provide a magnitude_limit." ) if magnitude_limit is not None and magnitude_limit_passband is None: magnitude_limit_passband = "V" aavso_passband_to_aavso_colnames = dict( B="Bmag", V="Vmag", SG="g'mag", SR="r'mag", SI="i'mag", ) # Make sure the magnitude limit passband is one of the AAVSO standard passband names if magnitude_limit_passband: if magnitude_limit_passband not in aavso_passband_to_aavso_colnames: raise ValueError( "magnitude_limit_passband must be one of " f"{', '.join(aavso_passband_to_aavso_colnames.keys())}." ) else: # If it is valid, then use the refcat2 column name for the passband magnitude_limit_passband = aavso_passband_to_aavso_colnames[ magnitude_limit_passband ] if magnitude_limit is None: # If no magnitude limit is provided, then we will not filter the catalog # by magnitude. magnitude_limit_passband = None raw_catalog = CatalogData.from_vizier( field_center, "II/336/apass9", radius=radius, clip_by_frame=clip_by_frame, padding=padding, colname_map=apass_colnames, magnitude_limit=magnitude_limit, magnitude_limit_passband=magnitude_limit_passband, ) # IAU requires an acronym to star, so make it APASS plus SP for stellarphot designation_acronym = "APASSSP" # The formats below include 4 digits after the decimal point (accuracy of about # 0.5 arcsec), a leading sign (+ or -) and leading zeros so that the RA is always # three digits before the decimal and the DEC is always two digits before the # decimal. coord_string = [ f"J{ra.to('degree').value:0=+9.4f}{dec.to('degree').value:0=+8.4f}" for ra, dec in zip(raw_catalog["ra"], raw_catalog["dec"], strict=True) ] # IAU says there is a space between the acronym and the coordinates. raw_catalog["id"] = [f"{designation_acronym} {coord}" for coord in coord_string] # Translate the passbands to AAVSO standard names. # No need to change B and V since those are already correct. # Do this *after* initialization so that the original APASS band names # are used for the tidy-ification operation. raw_catalog.passband_map = PassbandMap( name="APASS", your_filter_names_to_aavso={ "g": "SG", "r": "SR", "i": "SI", "g'": "SG", "r'": "SR", "i'": "SI", }, ) raw_catalog._update_passbands() return raw_catalog
[docs] def vsx_vizier( field_center, radius=1 * u.degree, clip_by_frame=False, padding=100, magnitude_limit=None, magnitude_limit_passband=None, ): """ Return the items from the copy of VSX on Vizier that are within the search radius and (optionally) within the field of view of a frame. Parameters ---------- field_center : `astropy.coordinates.SkyCoord`, `astropy.wcs.WCS`, or FITS header Either a `~astropy.coordinates.SkyCoord` object, a `~astropy.wcs.WCS` object or a FITS header with WCS information. The input coordinate should be the center of the frame; if a header or WCS is the input then the center of the frame will be determined from the WCS. radius : `astropy.units.Quantity`, optional Radius around which to search. clip_by_frame : bool, optional If ``True``, only return items that are within the field of view of the frame. padding : int, optional Coordinates need to be at least this many pixels in from the edge of the frame to be considered in the field of view. Default value is 100. magnitude_limit : float, optional If provided, only return items with a brightest magnitudes less than or equal to this value. magnitude_limit_passband : str, optional, default is "max" There is no straightforward way to limit the VSX catalog by passband. The magnitude limit will be applied to the variable star's magnitude at maximum brightness, and the only valid value for this parameter is "max". Returns ------- `stellarphot.CatalogData` Table of catalog information. """ vsx_map = dict( Name="id", RAJ2000="ra", DEJ2000="dec", ) if magnitude_limit_passband is not None: raise ValueError( "There is no straightforward way to limit the VSX catalog by passband. " "The magnitude limit will be applied to the variable star's magnitude " "at maximum." ) if magnitude_limit is not None: magnitude_limit_passband = "max" # This one is easier -- it already has the passband in a column name. # We'll use the maximum magnitude as the magnitude column. def prepare_cat(cat): cat.rename_column("max", "mag") cat.rename_column("n_max", "passband") return cat return CatalogData.from_vizier( field_center, "B/vsx/vsx", radius=radius, clip_by_frame=clip_by_frame, padding=padding, colname_map=vsx_map, prepare_catalog=prepare_cat, no_catalog_error=True, tidy_catalog=False, magnitude_limit=magnitude_limit, magnitude_limit_passband=magnitude_limit_passband, )
[docs] def refcat2( field_center, radius=1 * u.degree, clip_by_frame=False, padding=100, magnitude_limit=None, magnitude_limit_passband=None, ): """ Return the items from Refcat2 that are within the search radius and (optionally) within the field of view of a frame. Parameters ---------- field_center : `astropy.coordinates.SkyCoord`, `astropy.wcs.WCS`, or FITS header Either a `~astropy.coordinates.SkyCoord` object, a `~astropy.wcs.WCS` object or a FITS header with WCS information. The input coordinate should be the center of the frame; if a header or WCS is the input then the center of the frame will be determined from the WCS. radius : `astropy.units.Quantity`, optional Radius around which to search. clip_by_frame : bool, optional If ``True``, only return items that are within the field of view of the frame. padding : int, optional Coordinates need to be at least this many pixels in from the edge of the frame to be considered in the field of view. Default value is 100. magnitude_limit : float, optional If provided, only return items with magnitudes less than or equal to this value. magnitude_limit_passband : str, optional, default is "SR" If provided, the passband to use for the magnitude limit. The name of the passband must be one of the AAVSO standard passband names. Returns ------- `stellarphot.CatalogData` Table of catalog information. Notes ----- Refcat2 includes Gaia DR2 RA/Dec and magnitudes but does **not** include the Gaia DR2 ID number. This function looks up the Gaia DR2 ID number and uses it as the ID column. The reference for the refcat2 paper is: Tonry, J. L., Denneau, L., Flewelling, H., et al. 2018, ApJ, 867, https://iopscience.iop.org/article/10.3847/1538-4357/aae386 """ refcat2_colnames = { # There is no refcat2 ID number, but below we will match the Gaia DR2 # ID number to the RA/Dec and use that as the ID. "RA_ICRS": "ra", "DE_ICRS": "dec", } if magnitude_limit is not None and magnitude_limit_passband is None: magnitude_limit_passband = "SR" aavso_passband_to_refcat_colnames = dict( SG="gmag", SR="rmag", SI="imag", SZ="zmag", ) # Make sure the magnitude limit passband is one of the AAVSO standard passband names if magnitude_limit_passband: if magnitude_limit_passband not in aavso_passband_to_refcat_colnames: raise ValueError( "magnitude_limit_passband must be one of " f"{', '.join(aavso_passband_to_refcat_colnames.keys())}." ) else: # If it is valid, then use the refcat2 column name for the passband magnitude_limit_passband = aavso_passband_to_refcat_colnames[ magnitude_limit_passband ] # if not magnitude_limit: # # If no magnitude limit is provided, then we will not filter the catalog # # by magnitude. # magnitude_limit_passband = None raw_catalog = CatalogData.from_vizier( field_center, "J/ApJ/867/105/refcat2", radius=radius, clip_by_frame=clip_by_frame, padding=padding, colname_map=refcat2_colnames, prepare_catalog=_process_refcat2, magnitude_limit=magnitude_limit, magnitude_limit_passband=magnitude_limit_passband, ) # Translate the passbands to AAVSO standard names. # No need to change B and V since those are already correct. # Do this *after* initialization so that the original passband names # are used for the tidy-ification operation. raw_catalog.passband_map = PassbandMap( name="refcat2", your_filter_names_to_aavso={ "G": "GG", "BP": "GBP", "RP": "GRP", "g": "SG", "r": "SR", "i": "SI", "z": "SZ", }, ) raw_catalog._update_passbands() return raw_catalog