Large Crossmatches with EDP2#
In this notebook we’ll show how to:
Load the HATS-formatted version of DP2 on the Rubin Science Platform (RSP)
Load an external HATS catalog
Apply column-based filtering on catalogs
Perform large crossmatches between DP2 and other catalogs
Demonstrate how to use Catalog streaming to overcome significant data volume
Plot some resulting lightcurves
[1]:
import lsdb
from upath import UPath
from dask.distributed import Client
from lsdb.streams import CatalogStream
import pandas as pd
import matplotlib.pyplot as plt
[2]:
import logging
logging.getLogger().setLevel(logging.WARNING)
logging.getLogger("distributed").setLevel(logging.WARNING)
[3]:
plt.rcParams.update(
{
"axes.titlesize": 16,
"axes.labelsize": 15,
"xtick.labelsize": 13,
"ytick.labelsize": 13,
"legend.fontsize": 13,
"figure.titlesize": 18,
}
)
Load Data Preview 2#
We begin by loading in DP2, specifying just ra and dec columns to have the lightest weight catalog possible. In practice, trimming down columns to only the subset you’ll need for analysis can greatly help in avoiding memory-related issues during computation.
Tip: It’s fine to load them all lazily at first and then trim down later once you’ve solidified a workflow.
[4]:
dp2_path = UPath("/rubin/lsdb_data/object_collection")
dp2 = lsdb.open_catalog(dp2_path, columns=["coord_ra", "coord_dec"])
dp2
[4]:
| coord_ra | coord_dec | |
|---|---|---|
| npartitions=8840 | ||
| Order: 8, Pixel: 10240 | double[pyarrow] | double[pyarrow] |
| ... | ... | ... |
| Order: 5, Pixel: 12283 | ... | ... |
| Order: 5, Pixel: 12284 | ... | ... |
Load another Catalog - Gaia DR3#
We’ll now load Gaia remotely as a second catalog for future crossmatching. Again, we’re limiting our column loading to only columns we’ll need.
Many public catalogs are available to load directly with LSDB — you can browse them at data.lsdb.io.
[5]:
# Open from a remote s3 bucket
gaia = lsdb.open_catalog(
"s3://stpubdata/gaia/gaia_dr3/public/hats",
columns=[
"ra",
"dec",
"parallax",
"parallax_over_error",
"teff_gspphot",
"logg_gspphot",
"classprob_dsc_combmod_star",
],
)
gaia
[5]:
| ra | dec | parallax | parallax_over_error | teff_gspphot | logg_gspphot | classprob_dsc_combmod_star | |
|---|---|---|---|---|---|---|---|
| npartitions=2016 | |||||||
| Order: 2, Pixel: 0 | double[pyarrow] | double[pyarrow] | double[pyarrow] | float[pyarrow] | float[pyarrow] | float[pyarrow] | float[pyarrow] |
| ... | ... | ... | ... | ... | ... | ... | ... |
| Order: 3, Pixel: 766 | ... | ... | ... | ... | ... | ... | ... |
| Order: 3, Pixel: 767 | ... | ... | ... | ... | ... | ... | ... |
Query Gaia: Filtering for FGK Stars#
Catalog.query allows us to apply filters based on column values, in this case we’ll apply a set of cuts to select for FGK Stars.
[6]:
gaia_filtered = gaia.query("parallax > 0 and parallax_over_error > 5 and \
teff_gspphot > 5380 and teff_gspphot < 7220 and logg_gspphot > 4.5 \
and logg_gspphot < 4.72 and classprob_dsc_combmod_star > 0.5")
Perform a Crossmatch#
Now we’re ready to crossmatch DP2 and our filtered Gaia DR3.
[7]:
xmatch = dp2.crossmatch(gaia_filtered, suffix_method="overlapping_columns")
xmatch
[7]:
| coord_ra | coord_dec | ra | dec | parallax | parallax_over_error | teff_gspphot | logg_gspphot | classprob_dsc_combmod_star | _dist_arcsec | |
|---|---|---|---|---|---|---|---|---|---|---|
| npartitions=9139 | ||||||||||
| Order: 8, Pixel: 10240 | double[pyarrow] | double[pyarrow] | double[pyarrow] | double[pyarrow] | double[pyarrow] | float[pyarrow] | float[pyarrow] | float[pyarrow] | float[pyarrow] | double[pyarrow] |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| Order: 5, Pixel: 12283 | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| Order: 5, Pixel: 12284 | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
Count the Resulting Matches#
At this point, we’re just interested in the number of matches we found, so we’ll construct a small function to apply through Catalog.map_partition which counts the number of matches in each output partition.
[8]:
# Write a simple map_partitions function to count resulting matches
def count_matches(df):
return {"n_matches": len(df)}
matches_by_partition = xmatch.map_partitions(count_matches)
Performing Computation#
For computation, we establish a Dask Client and dictate a number of workers and memory limit per worker. The Dask client will work within it’s boundaries to perform the computation that we kick off with Catalog.compute() in parallel.
[9]:
# Define a client, and .compute() the match result
with Client(n_workers=4, threads_per_worker=1, memory_limit="4GB") as client:
print(f"Dask dashboard: {client.dashboard_link}")
match_df = matches_by_partition.compute()
client.close()
Dask dashboard: https://ncaplar.nb.data-int.lsst.cloud/nb/user/ncaplar/proxy/8787/status
Finally we can check the total number of Gaia-filtered FGK stars we matched in DP2:
[10]:
print(f"Number of Matches: {match_df["n_matches"].sum()}")
Number of Matches: 548168
Including Lightcurves#
Let’s start again, but this time we’ll add the “ObjectForcedSource” column, which includes our lightcurves, with the goal of inspecting a few of them.
[11]:
dp2_lc = lsdb.open_catalog(dp2_path, columns=["coord_ra", "coord_dec", "objectForcedSource"])
dp2_lc
[11]:
| coord_ra | coord_dec | objectForcedSource | |
|---|---|---|---|
| npartitions=8840 | |||
| Order: 8, Pixel: 10240 | double[pyarrow] | double[pyarrow] | nested<parentObjectId: [int64], coord_ra: [dou... |
| ... | ... | ... | ... |
| Order: 5, Pixel: 12283 | ... | ... | ... |
| Order: 5, Pixel: 12284 | ... | ... | ... |
A common operation is to filter lightcurve lengths, we lack a pre-existing column to tell us the lengths, so we’ll take advantage of our nested column itself to perform the filter:
[12]:
# query for lightcurves with at least 50 observations
def filter_lengths(nf):
# define a function that applies to each dataframe partition
return nf[nf["objectForcedSource"].len() > 50] # nested columns have a direct len()
dp2_lc_filtered = dp2_lc.map_partitions(filter_lengths)
Now we can redo our crossmatch with Gaia:
[13]:
xmatch_lc = dp2_lc_filtered.crossmatch(gaia_filtered, suffix_method="overlapping_columns")
xmatch_lc
[13]:
| coord_ra | coord_dec | objectForcedSource | ra | dec | parallax | parallax_over_error | teff_gspphot | logg_gspphot | classprob_dsc_combmod_star | _dist_arcsec | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| npartitions=9139 | |||||||||||
| Order: 8, Pixel: 10240 | double[pyarrow] | double[pyarrow] | nested<parentObjectId: [int64], coord_ra: [dou... | double[pyarrow] | double[pyarrow] | double[pyarrow] | float[pyarrow] | float[pyarrow] | float[pyarrow] | float[pyarrow] | double[pyarrow] |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| Order: 5, Pixel: 12283 | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| Order: 5, Pixel: 12284 | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
[14]:
# And re-initialize our counting result
def count_matches(df):
return {"n_matches": len(df)}
matches_by_partition_lc = xmatch_lc.map_partitions(count_matches)
Dealing with Heavy Workloads - Catalog Streaming#
However, our added lightcurves add a ton of weight to the DP2 catalog. If you check the estimated catalog size when we first loaded the catalogs, you can see that DP2 was ~20GB total when loading just the “coord_ra” and “coord_dec” columns, but with “objectForcedSource” this balloons to ~1.3TB. Due to this, it’s likely that direct computation on a RSP environment (4 cores, 32GB total) will fail. We can take advantage of an alternative computation method, Catalog Streaming, in cases like this:
[15]:
%%time
match_stream = CatalogStream(xmatch_lc, partitions_per_chunk=4, shuffle=False)
chunks = []
# 16 GB workers end up being needed to perform this workflow
with Client(n_workers=1, threads_per_worker=1, memory_limit="16GB") as client:
print(f"Dask dashboard: {client.dashboard_link}")
print(client)
match_iter = iter(match_stream)
for i in range(50):
chunks.append(next(match_iter))
client.close()
match_df = pd.concat(chunks)
match_df
Dask dashboard: https://ncaplar.nb.data-int.lsst.cloud/nb/user/ncaplar/proxy/8787/status
<Client: 'tcp://127.0.0.1:36333' processes=1 threads=1, memory=14.90 GiB>
CPU times: user 38.9 s, sys: 3.79 s, total: 42.7 s
Wall time: 14min 28s
[15]:
| coord_ra | coord_dec | objectForcedSource | ra | dec | parallax | parallax_over_error | teff_gspphot | logg_gspphot | classprob_dsc_combmod_star | _dist_arcsec | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 3452204825677797065 | 305.946264 | -10.583908 |
|
305.946255 | -10.583901 | 0.710761 | 20.467844 | 5504.502930 | 4.551600 | 0.999955 | 0.039326 | |||||||||||||||
| 3452205377521584693 | 305.728630 | -10.691067 |
|
305.728641 | -10.691061 | 0.460955 | 10.544349 | 5609.919922 | 4.500900 | 0.999961 | 0.046429 | |||||||||||||||
| 3452216821237142673 | 305.824922 | -10.395305 |
|
305.824930 | -10.395294 | 0.469625 | 6.168673 | 5459.346680 | 4.676500 | 0.999966 | 0.047867 | |||||||||||||||
| 3452218383995908147 | 305.850041 | -10.357047 |
|
305.850029 | -10.357021 | 1.189362 | 34.080879 | 5504.515137 | 4.509100 | 0.999954 | 0.102755 | |||||||||||||||
| 3452187698109812560 | 305.430909 | -10.953104 |
|
305.430911 | -10.953095 | 0.358552 | 5.336546 | 5449.150879 | 4.636100 | 0.999985 | 0.033853 | |||||||||||||||
| ... | ... | ... | ... | ... | ... | ... |
Above, we’ve streamed the result for the first 200 partitions (4 partitions per chunk, 50 chunks grabbed). We ultimately found many lightcurves that matched our criteria. Let’s end by plotting one of them:
[16]:
lc = match_df.iloc[0]["objectForcedSource"]
lc
[16]:
| parentObjectId | coord_ra | coord_dec | visit | detector | band | psfFlux | psfFluxErr | psfFlux_flag | psfDiffFlux | ... | tract | patch | psfMag | psfMagErr | midpointMjdTai | psfFluxErr_corrected | psfFluxErr_corrected_flag | psfDiffFluxErr_corrected | psfDiffFluxErr_corrected_flag | psfMagErr_corrected | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 305.946264 | -10.583908 | 2025062000415 | 11 | i | 4.204871e+05 | 551.586121 | False | -2.398203e+06 | ... | 7743 | 83 | 17.340618 | 0.001424 | 60847.236666 | 4696.559570 | False | 2157.076660 | False | 0.012127 |
| 1 | 0 | 305.946264 | -10.583908 | 2025062000458 | 181 | i | 3.382192e+06 | 1910.429321 | False | 4.734175e+05 | ... | 7743 | 83 | 15.077004 | 0.000613 | 60847.256542 | 30366.257812 | False | 4757.828613 | False | 0.009748 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 51 | 0 | 305.946264 | -10.583908 | 2025072800441 | 14 | i | 3.349842e+06 | 1852.315796 | False | 3.979522e+05 | ... | 7743 | 83 | 15.087439 | 0.000600 | 60885.357343 | 30350.798828 | False | 4599.051758 | False | 0.009837 |
| 52 | 0 | 305.946264 | -10.583908 | 2025072800489 | 14 | z | 3.589231e+06 | 2230.597900 | False | 7.802666e+05 | ... | 7743 | 83 | 15.012497 | 0.000675 | 60885.380867 | 32634.371094 | False | 6119.346680 | False | 0.009872 |
53 rows × 35 columns
[17]:
band_colors = {
"u": "#0c71ff",
"g": "#49be61",
"r": "#c61c00",
"i": "#ffc200",
"z": "#f341a2",
"y": "#5d0000",
}
mag_cut = 19.0 # faint-end cutoff (inverted magnitude axis)
fig, ax = plt.subplots(figsize=(10, 6))
# One error-bar series per band, colored by filter, with capped error bars
for band in band_colors:
grp = lc[lc["band"] == band]
if len(grp) == 0:
continue
ax.errorbar(
grp["midpointMjdTai"],
grp["psfMag"],
yerr=grp["psfMagErr"],
fmt="o",
markersize=3,
alpha=0.6,
ls="none",
capsize=2,
color=band_colors[band],
label=band,
)
# Inverted axis, cut off at the faint end (mag 19); brighter objects sit higher
top = lc.loc[lc["psfMag"] <= mag_cut, "psfMag"].min() - 0.3
ax.set_ylim(mag_cut, top)
# Points fainter than the cutoff fall off the bottom: mark each epoch with a
# down-arrow colored by its band
for _, row in lc[lc["psfMag"] > mag_cut].iterrows():
ax.annotate(
r"$\downarrow$",
xy=(row["midpointMjdTai"], mag_cut),
ha="center",
va="bottom", # bottom of the glyph aligns with the axis
color=band_colors[row["band"]],
fontsize=14,
annotation_clip=False,
)
ax.set_xlabel("MJD")
ax.set_ylabel("PSF magnitude")
ax.set_title("Crossmatched DP2 light curve")
ax.legend(title="Band")
plt.show()
[18]:
band_colors = {
"u": "#0c71ff",
"g": "#49be61",
"r": "#c61c00",
"i": "#ffc200",
"z": "#f341a2",
"y": "#5d0000",
}
mag_cut = 19.0 # faint-end cutoff (inverted magnitude axis)
fig, ax = plt.subplots(figsize=(10, 6))
# One error-bar series per band, colored by filter, with capped error bars
for band in band_colors:
grp = lc[lc["band"] == band]
if len(grp) == 0:
continue
ax.errorbar(
grp["midpointMjdTai"],
grp["psfMag"],
yerr=grp["psfMagErr"],
fmt="o",
markersize=3,
alpha=0.6,
ls="none",
capsize=2,
color=band_colors[band],
label=band,
)
# Inverted axis, cut off at the faint end (mag 19); brighter objects sit higher
top = lc.loc[lc["psfMag"] <= mag_cut, "psfMag"].min() - 0.3
ax.set_ylim(mag_cut, top)
# Points fainter than the cutoff fall off the bottom: mark each epoch with a
# down-arrow colored by its band
for _, row in lc[lc["psfMag"] > mag_cut].iterrows():
ax.annotate(
r"$\downarrow$",
xy=(row["midpointMjdTai"], mag_cut),
ha="center",
va="bottom", # bottom of the glyph aligns with the axis
color=band_colors[row["band"]],
fontsize=14,
annotation_clip=False,
)
ax.set_xlabel("MJD")
ax.set_ylabel("PSF magnitude")
ax.set_title("Crossmatched DP2 light curve")
ax.legend(title="Band")
plt.show()
Summary#
With an RSP-sized environment, it’s possible to do large crossmatches between DP2 and other large HATS catalogs, as long as you are mindful of the columns being loaded and the Dask Client setup you are specifying for computation. Note how much the computational approach needed to change just by including lightcurves from “objectForcedSource”. Being aware of the computational weight of your workflows is it’s own learning curve, even as LSDB tries to assist as much as it can with this.
If you’re looking for more tips on doing large scale workflows, consult the LSDB documentation.
About#
Author(s):Doug Branton
Last run: July 27, 2026
If you use lsdb for published research, please cite following instructions.