|
| 1 | +{ |
| 2 | + "cells": [ |
| 3 | + { |
| 4 | + "cell_type": "markdown", |
| 5 | + "metadata": {}, |
| 6 | + "source": [ |
| 7 | + "# Astronomical Coordinates 4: Cross-matching Catalogs Using astropy.coordinates and astroquery\n", |
| 8 | + "\n", |
| 9 | + "## Authors\n", |
| 10 | + "Adrian Price-Whelan\n", |
| 11 | + "\n", |
| 12 | + "## Learning Goals\n", |
| 13 | + "* Demonstrate how to retrieve a catalog from Vizier using astroquery\n", |
| 14 | + "* Show how to perform positional cross-matches between catalogs of sky coordinates\n", |
| 15 | + "\n", |
| 16 | + "## Keywords\n", |
| 17 | + "coordinates, OOP, astroquery, gaia\n", |
| 18 | + "\n", |
| 19 | + "\n", |
| 20 | + "## Summary\n", |
| 21 | + "\n", |
| 22 | + "In the previous tutorials in this series, we introduced many of the key concepts underlying how to represent and transform astronomical coordinates using `astropy.coordinates`, including how to work with both position and velocity data within the coordinate objects.\n", |
| 23 | + "\n", |
| 24 | + "In this tutorial, we will explore how the `astropy.coordinates` package can be used to cross-match two catalogs that contain overlapping sources that may have been observed at different times. You may find it helpful to keep [the Astropy documentation for the coordinates package](http://docs.astropy.org/en/stable/coordinates/index.html) open alongside this tutorial for reference or additional reading. In the text below, you may also see some links that look like ([docs](http://docs.astropy.org/en/stable/coordinates/index.html)). These links will take you to parts of the documentation that are directly relevant to the cells from which they link. \n", |
| 25 | + "\n", |
| 26 | + "*Note: This is the 4th tutorial in a series of tutorials about astropy.coordinates. If you are new to astropy.coordinates, you may want to start from the beginning or an earlier tutorial.*" |
| 27 | + ] |
| 28 | + }, |
| 29 | + { |
| 30 | + "cell_type": "markdown", |
| 31 | + "metadata": {}, |
| 32 | + "source": [ |
| 33 | + "## Imports\n", |
| 34 | + "\n", |
| 35 | + "We start by importing some general packages that we will need below:" |
| 36 | + ] |
| 37 | + }, |
| 38 | + { |
| 39 | + "cell_type": "code", |
| 40 | + "execution_count": null, |
| 41 | + "metadata": {}, |
| 42 | + "outputs": [], |
| 43 | + "source": [ |
| 44 | + "import warnings\n", |
| 45 | + "\n", |
| 46 | + "import matplotlib.pyplot as plt\n", |
| 47 | + "\n", |
| 48 | + "%matplotlib inline\n", |
| 49 | + "import numpy as np\n", |
| 50 | + "\n", |
| 51 | + "from astropy import units as u\n", |
| 52 | + "from astropy.coordinates import SkyCoord, Distance\n", |
| 53 | + "from astropy.table import QTable\n", |
| 54 | + "from astropy.time import Time\n", |
| 55 | + "\n", |
| 56 | + "from astroquery.vizier import Vizier" |
| 57 | + ] |
| 58 | + }, |
| 59 | + { |
| 60 | + "cell_type": "markdown", |
| 61 | + "metadata": {}, |
| 62 | + "source": [ |
| 63 | + "## Cross-matching and comparing catalogs\n", |
| 64 | + "\n", |
| 65 | + "In this tutorial, we are going to return to a set of data that we downloaded from the *Gaia* archive back in [Tutorial 1](1-Coordinates-Intro) of this series.\n", |
| 66 | + "\n", |
| 67 | + "Let's recap what we did in that tutorial: We defined a `SkyCoord` object to represent the center of an open cluster (NGC 188), we queried the *Gaia* DR2 catalog to select stars that are close (on the sky) to the center of the cluster, and we used the parallax values from *Gaia* to select stars that are near NGC 188 in 3D position. Here, we will briefly reproduce those selections so that we can start here with a catalog of sources that are likely members of NGC 188 (see [Tutorial 1](1-Coordinates-Intro) for more information):" |
| 68 | + ] |
| 69 | + }, |
| 70 | + { |
| 71 | + "cell_type": "code", |
| 72 | + "execution_count": null, |
| 73 | + "metadata": {}, |
| 74 | + "outputs": [], |
| 75 | + "source": [ |
| 76 | + "ngc188_table = QTable.read(\"gaia_results.fits\")\n", |
| 77 | + "ngc188_table = ngc188_table[ngc188_table[\"parallax\"] > 0.25 * u.mas]\n", |
| 78 | + "\n", |
| 79 | + "ngc188_center_3d = SkyCoord(\n", |
| 80 | + " 12.11 * u.deg,\n", |
| 81 | + " 85.26 * u.deg,\n", |
| 82 | + " distance=1.96 * u.kpc,\n", |
| 83 | + " pm_ra_cosdec=-2.3087 * u.mas / u.yr,\n", |
| 84 | + " pm_dec=-0.9565 * u.mas / u.yr,\n", |
| 85 | + ")\n", |
| 86 | + "\n", |
| 87 | + "# Deal with masked quantity data in a backwards-compatible way:\n", |
| 88 | + "parallax = ngc188_table[\"parallax\"]\n", |
| 89 | + "if hasattr(parallax, \"mask\"):\n", |
| 90 | + " parallax = parallax.filled(np.nan)\n", |
| 91 | + "\n", |
| 92 | + "velocity_data = {\n", |
| 93 | + " \"pm_ra_cosdec\": ngc188_table[\"pmra\"],\n", |
| 94 | + " \"pm_dec\": ngc188_table[\"pmdec\"],\n", |
| 95 | + " \"radial_velocity\": ngc188_table[\"radial_velocity\"],\n", |
| 96 | + "}\n", |
| 97 | + "for k, v in velocity_data.items():\n", |
| 98 | + " if hasattr(v, \"mask\"):\n", |
| 99 | + " velocity_data[k] = v.filled(0.0)\n", |
| 100 | + " velocity_data[k][np.isnan(velocity_data[k])] = 0.0\n", |
| 101 | + "\n", |
| 102 | + "ngc188_coords_3d = SkyCoord(\n", |
| 103 | + " ra=ngc188_table[\"ra\"],\n", |
| 104 | + " dec=ngc188_table[\"dec\"],\n", |
| 105 | + " distance=Distance(parallax=parallax),\n", |
| 106 | + " obstime=Time(\"J2015.5\"),\n", |
| 107 | + " **velocity_data,\n", |
| 108 | + ")\n", |
| 109 | + "\n", |
| 110 | + "sep3d = ngc188_coords_3d.separation_3d(ngc188_center_3d)\n", |
| 111 | + "pm_diff = np.sqrt(\n", |
| 112 | + " (ngc188_table[\"pmra\"] - ngc188_center_3d.pm_ra_cosdec) ** 2\n", |
| 113 | + " + (ngc188_table[\"pmdec\"] - ngc188_center_3d.pm_dec) ** 2\n", |
| 114 | + ")\n", |
| 115 | + "\n", |
| 116 | + "ngc188_members_mask = (sep3d < 50 * u.pc) & (pm_diff < 1.5 * u.mas / u.yr)\n", |
| 117 | + "ngc188_members = ngc188_table[ngc188_members_mask]\n", |
| 118 | + "ngc188_members_coords = ngc188_coords_3d[ngc188_members_mask]\n", |
| 119 | + "len(ngc188_members)" |
| 120 | + ] |
| 121 | + }, |
| 122 | + { |
| 123 | + "cell_type": "markdown", |
| 124 | + "metadata": {}, |
| 125 | + "source": [ |
| 126 | + "From the selections above, the table `ngc188_members` and the `SkyCoord` instance `ngc188_members_coords` contain 216 sources that, based on their 3D positions and proper motions, are consistent with being members of the open cluster NGC 188." |
| 127 | + ] |
| 128 | + }, |
| 129 | + { |
| 130 | + "cell_type": "markdown", |
| 131 | + "metadata": {}, |
| 132 | + "source": [ |
| 133 | + "Let's assume that we now want to cross-match our catalog of candidate members of NGC 188 — here, based on *Gaia* data — to some other catalog. In this tutorial, we will demonstrate how to manually cross-match these *Gaia* sources with the 2MASS photometric catalog to retrieve infrared magnitudes for these stars, and then we will plot a color–magnitude diagram. To do this, we first need to query the 2MASS catalog to retrieve all sources in a region around the center of NGC 188, as we did for *Gaia*. Here, we will also take into account the fact that the *Gaia* data release 2 reference epoch is J2015.5, whereas the 2MASS coordinates are likely reported at their time of observation (in the late 1990's). \n", |
| 134 | + "\n", |
| 135 | + "*Note that some data archives, like the Gaia science archive, support running cross-matches at the database level and even support epoch propagation. If you need to perform a large cross-match, it will be much more efficient to use these services!*\n", |
| 136 | + "\n", |
| 137 | + "We will again use `astroquery` to execute this query. This will again require an internet connection, but we have included the results of this query in a file along with this notebook in case you are not connected to the internet. To query 2MASS, we will use the `astroquery.vizier` module ([docs](https://astroquery.readthedocs.io/en/latest/vizier/vizier.html)) to run a cone search centered on the sky position of NGC 188 with a search radius of 0.5º:" |
| 138 | + ] |
| 139 | + }, |
| 140 | + { |
| 141 | + "cell_type": "code", |
| 142 | + "execution_count": null, |
| 143 | + "metadata": {}, |
| 144 | + "outputs": [], |
| 145 | + "source": [ |
| 146 | + "# NOTE: skip this cell if you do not have an internet connection\n", |
| 147 | + "\n", |
| 148 | + "# II/246 is the catalog name for the main 2MASS photometric catalog\n", |
| 149 | + "v = Vizier(catalog=\"II/246\", columns=[\"*\", \"Date\"])\n", |
| 150 | + "v.ROW_LIMIT = -1\n", |
| 151 | + "\n", |
| 152 | + "result = v.query_region(ngc188_center_3d, radius=0.5 * u.deg)\n", |
| 153 | + "tmass_table = result[0]" |
| 154 | + ] |
| 155 | + }, |
| 156 | + { |
| 157 | + "cell_type": "markdown", |
| 158 | + "metadata": {}, |
| 159 | + "source": [ |
| 160 | + "Alternatively, we can read the 2MASS table provided along with this tutorial:" |
| 161 | + ] |
| 162 | + }, |
| 163 | + { |
| 164 | + "cell_type": "code", |
| 165 | + "execution_count": null, |
| 166 | + "metadata": {}, |
| 167 | + "outputs": [], |
| 168 | + "source": [ |
| 169 | + "# the .read() below produces some warnings that we can safely ignore\n", |
| 170 | + "with warnings.catch_warnings():\n", |
| 171 | + " warnings.simplefilter(\"ignore\", UserWarning)\n", |
| 172 | + "\n", |
| 173 | + " tmass_table = QTable.read(\"2MASS_results.ecsv\")" |
| 174 | + ] |
| 175 | + }, |
| 176 | + { |
| 177 | + "cell_type": "markdown", |
| 178 | + "metadata": {}, |
| 179 | + "source": [ |
| 180 | + "As with the *Gaia* results table, we can now create a single `SkyCoord` object to represent all of the sources returned from our query to the 2MASS catalog. Let's look at the column names in this table by displaying the first few rows:" |
| 181 | + ] |
| 182 | + }, |
| 183 | + { |
| 184 | + "cell_type": "code", |
| 185 | + "execution_count": null, |
| 186 | + "metadata": {}, |
| 187 | + "outputs": [], |
| 188 | + "source": [ |
| 189 | + "tmass_table[:3]" |
| 190 | + ] |
| 191 | + }, |
| 192 | + { |
| 193 | + "cell_type": "markdown", |
| 194 | + "metadata": {}, |
| 195 | + "source": [ |
| 196 | + "From looking at the column names, the two relevant sky coordinate columns are `RAJ2000` for `ra` and `DEJ2000` for `dec`:" |
| 197 | + ] |
| 198 | + }, |
| 199 | + { |
| 200 | + "cell_type": "code", |
| 201 | + "execution_count": null, |
| 202 | + "metadata": {}, |
| 203 | + "outputs": [], |
| 204 | + "source": [ |
| 205 | + "tmass_coords = SkyCoord(tmass_table[\"RAJ2000\"], tmass_table[\"DEJ2000\"])\n", |
| 206 | + "len(tmass_coords)" |
| 207 | + ] |
| 208 | + }, |
| 209 | + { |
| 210 | + "cell_type": "markdown", |
| 211 | + "metadata": {}, |
| 212 | + "source": [ |
| 213 | + "Note also that the table contains a \"Date\" column that specifies the epoch of the coordinates. Are all of these epochs the same?" |
| 214 | + ] |
| 215 | + }, |
| 216 | + { |
| 217 | + "cell_type": "code", |
| 218 | + "execution_count": null, |
| 219 | + "metadata": {}, |
| 220 | + "outputs": [], |
| 221 | + "source": [ |
| 222 | + "np.unique(tmass_table[\"Date\"])" |
| 223 | + ] |
| 224 | + }, |
| 225 | + { |
| 226 | + "cell_type": "markdown", |
| 227 | + "metadata": {}, |
| 228 | + "source": [ |
| 229 | + "It looks like all of the sources in our 2MASS table have the same epoch, so let's create an `astropy.time.Time` object to represent this date:" |
| 230 | + ] |
| 231 | + }, |
| 232 | + { |
| 233 | + "cell_type": "code", |
| 234 | + "execution_count": null, |
| 235 | + "metadata": {}, |
| 236 | + "outputs": [], |
| 237 | + "source": [ |
| 238 | + "tmass_epoch = Time(np.unique(tmass_table[\"Date\"]))" |
| 239 | + ] |
| 240 | + }, |
| 241 | + { |
| 242 | + "cell_type": "markdown", |
| 243 | + "metadata": {}, |
| 244 | + "source": [ |
| 245 | + "We now want to cross-match our *Gaia*-selected candidate members of NGC 188, `ngc_members_coords`, with this table of photometry from 2MASS. However, as noted previously, the *Gaia* coordinates are given at a different epoch J2015.5, which is nearly ~16 years after the 2MASS epoch of the data we downloaded (1999-10-19 or roughly J1999.88). We will therefore first use the `SkyCoord.apply_space_motion()` method ([docs](http://docs.astropy.org/en/latest/api/astropy.coordinates.SkyCoord.html#astropy.coordinates.SkyCoord.apply_space_motion)) to transform the *Gaia* positions back to the 2MASS epoch before we do the cross-match:" |
| 246 | + ] |
| 247 | + }, |
| 248 | + { |
| 249 | + "cell_type": "code", |
| 250 | + "execution_count": null, |
| 251 | + "metadata": {}, |
| 252 | + "outputs": [], |
| 253 | + "source": [ |
| 254 | + "# you can ignore the warning raised here\n", |
| 255 | + "ngc188_members_coords_1999 = ngc188_members_coords.apply_space_motion(tmass_epoch)" |
| 256 | + ] |
| 257 | + }, |
| 258 | + { |
| 259 | + "cell_type": "markdown", |
| 260 | + "metadata": {}, |
| 261 | + "source": [ |
| 262 | + "The object `ngc188_members_coords_1999` now contains the coordinate information for our *Gaia*-selected members of NGC 188, as we think they would appear if observed on 1999-10-19.\n", |
| 263 | + "\n", |
| 264 | + "We can now use the ``SkyCoord.match_to_catalog_sky`` method to match these two catalogs ([docs](http://docs.astropy.org/en/latest/coordinates/matchsep.html#astropy-coordinates-matching)), using the `ngc188_members_coords_1999` as our NGC 188 members coordinates. \n", |
| 265 | + "\n", |
| 266 | + "Note that order matters with this method: Here we will match *Gaia* to 2MASS. `SkyCoord.match_to_catalog_sky` returns three objects: (1) the indices into `tmass_coords` that get the closest matches in `ngc188_members_coords_1999`, (2) the angular separation between each `ngc188_members_coords_1999` coordinate and the closest source in `tmass_coords`, and (3) the 3D distance between each `ngc188_members_coords_1999` coordinate and the closest source in `tmass_coords`. Here, the 3D distances will not be useful because the 2MASS coordinates do not have associated distance information, so we will ignore these quantities:" |
| 267 | + ] |
| 268 | + }, |
| 269 | + { |
| 270 | + "cell_type": "code", |
| 271 | + "execution_count": null, |
| 272 | + "metadata": {}, |
| 273 | + "outputs": [], |
| 274 | + "source": [ |
| 275 | + "idx_gaia, sep2d_gaia, _ = ngc188_members_coords_1999.match_to_catalog_sky(tmass_coords)" |
| 276 | + ] |
| 277 | + }, |
| 278 | + { |
| 279 | + "cell_type": "markdown", |
| 280 | + "metadata": {}, |
| 281 | + "source": [ |
| 282 | + "Let's now look at the distribution of separations (in arcseconds) for all of the cross-matched sources:" |
| 283 | + ] |
| 284 | + }, |
| 285 | + { |
| 286 | + "cell_type": "code", |
| 287 | + "execution_count": null, |
| 288 | + "metadata": {}, |
| 289 | + "outputs": [], |
| 290 | + "source": [ |
| 291 | + "plt.hist(sep2d_gaia.arcsec, histtype=\"step\", bins=np.logspace(-2, 2.0, 64))\n", |
| 292 | + "plt.xlabel(\"separation [arcsec]\")\n", |
| 293 | + "plt.xscale(\"log\")\n", |
| 294 | + "plt.yscale(\"log\")\n", |
| 295 | + "plt.tight_layout()" |
| 296 | + ] |
| 297 | + }, |
| 298 | + { |
| 299 | + "cell_type": "markdown", |
| 300 | + "metadata": {}, |
| 301 | + "source": [ |
| 302 | + "From this, it looks like all of sources in our *Gaia* NGC 188 member list cross-match to another sources within a few arcseconds, so these all seem like they are correctly matches to a 2MASS source!" |
| 303 | + ] |
| 304 | + }, |
| 305 | + { |
| 306 | + "cell_type": "code", |
| 307 | + "execution_count": null, |
| 308 | + "metadata": {}, |
| 309 | + "outputs": [], |
| 310 | + "source": [ |
| 311 | + "(sep2d_gaia < 2 * u.arcsec).sum(), len(ngc188_members)" |
| 312 | + ] |
| 313 | + }, |
| 314 | + { |
| 315 | + "cell_type": "markdown", |
| 316 | + "metadata": {}, |
| 317 | + "source": [ |
| 318 | + "With our cross-match done, we can now make `Gaia`+2MASS color–magnitude diagrams of our candidate NGC 188 members using the information returned by the cross-match:" |
| 319 | + ] |
| 320 | + }, |
| 321 | + { |
| 322 | + "cell_type": "code", |
| 323 | + "execution_count": null, |
| 324 | + "metadata": {}, |
| 325 | + "outputs": [], |
| 326 | + "source": [ |
| 327 | + "Jmag = tmass_table[\"Jmag\"][idx_gaia] # note that we use the index array returned above\n", |
| 328 | + "Gmag = ngc188_members[\"phot_g_mean_mag\"]\n", |
| 329 | + "Bmag = ngc188_members[\"phot_bp_mean_mag\"]" |
| 330 | + ] |
| 331 | + }, |
| 332 | + { |
| 333 | + "cell_type": "code", |
| 334 | + "execution_count": null, |
| 335 | + "metadata": {}, |
| 336 | + "outputs": [], |
| 337 | + "source": [ |
| 338 | + "fig, axes = plt.subplots(1, 2, figsize=(10, 5))\n", |
| 339 | + "\n", |
| 340 | + "ax = axes[0]\n", |
| 341 | + "ax.scatter(Gmag - Jmag, Gmag, marker=\"o\", color=\"k\", linewidth=0, alpha=0.5)\n", |
| 342 | + "ax.set_xlabel(\"$G - J$\")\n", |
| 343 | + "ax.set_ylabel(\"$G$\")\n", |
| 344 | + "ax.set_xlim(0, 3)\n", |
| 345 | + "ax.set_ylim(19, 10) # backwards because magnitudes!\n", |
| 346 | + "\n", |
| 347 | + "ax = axes[1]\n", |
| 348 | + "ax.scatter(Bmag - Gmag, Jmag, marker=\"o\", color=\"k\", linewidth=0, alpha=0.5)\n", |
| 349 | + "ax.set_xlabel(\"$G_{BP} - G$\")\n", |
| 350 | + "ax.set_ylabel(\"$J$\")\n", |
| 351 | + "ax.set_xlim(0.2, 1)\n", |
| 352 | + "ax.set_ylim(17, 8) # backwards because magnitudes!\n", |
| 353 | + "\n", |
| 354 | + "fig.tight_layout()" |
| 355 | + ] |
| 356 | + }, |
| 357 | + { |
| 358 | + "cell_type": "markdown", |
| 359 | + "metadata": {}, |
| 360 | + "source": [ |
| 361 | + "Those both look like color-magnitude diagrams of a main sequence + red giant branch of an intermediate-age stellar cluster, so it looks like our selection and cross-matching has worked!\n", |
| 362 | + "\n", |
| 363 | + "For more on what matching options are available, check out the [separation and matching section of the Astropy documentation](https://astropy.readthedocs.io/en/stable/coordinates/matchsep.html). Or for more on what you can do with `SkyCoord`, see [its API documentation](http://astropy.readthedocs.org/en/stable/api/astropy.coordinates.SkyCoord.html)." |
| 364 | + ] |
| 365 | + }, |
| 366 | + { |
| 367 | + "cell_type": "code", |
| 368 | + "execution_count": null, |
| 369 | + "metadata": {}, |
| 370 | + "outputs": [], |
| 371 | + "source": [] |
| 372 | + } |
| 373 | + ], |
| 374 | + "metadata": { |
| 375 | + "language_info": { |
| 376 | + "codemirror_mode": { |
| 377 | + "name": "ipython" |
| 378 | + }, |
| 379 | + "file_extension": ".py", |
| 380 | + "mimetype": "text/x-python", |
| 381 | + "name": "python", |
| 382 | + "nbconvert_exporter": "python" |
| 383 | + } |
| 384 | + }, |
| 385 | + "nbformat": 4, |
| 386 | + "nbformat_minor": 4 |
| 387 | +} |
0 commit comments