DA white dwarf variability#

In this tutorial, we will:

  • collect known DA white dwarf positions from recent literature, via SIMBAD bibcode queries and a VizieR table

  • load Rubin DP2 forced-source photometry with LSDB, including new columns with corrected flux-error estimates

  • cross-match the two catalogs and plot the resulting light curves, comparing original and corrected flux errors

Introduction#

Prerequisites#

In order to access Rubin data, you must be a Rubin data rights holder, and run this notebook from a platform with access to the DP2 HATS catalogs, such as the Rubin Science Platform (RSP).

[1]:
import os

import astropy.table
import lsdb
import matplotlib.pyplot as plt
import numpy as np
from astroquery.simbad import Simbad
from astroquery.vizier import Vizier
from dask.distributed import Client

plt.rcParams.update(
    {
        "axes.titlesize": 16,
        "axes.labelsize": 15,
        "xtick.labelsize": 13,
        "ytick.labelsize": 13,
        "legend.fontsize": 13,
        "figure.titlesize": 18,
    }
)
[2]:
import logging

logging.getLogger().setLevel(logging.WARNING)
logging.getLogger("distributed").setLevel(logging.WARNING)
[3]:
lsdb.show_versions()

--------      SYSTEM INFO      --------
python        : 3.13.9
python-bits   : 64
OS            : Linux
OS-release    : 6.12.85+
Version       : #1 SMP Mon May 11 07:53:24 UTC 2026
machine       : x86_64
processor     :
byteorder     : little
LC_ALL        :
LANG          :
--------   INSTALLED VERSIONS   --------
lsdb          : 0.10.4
hats          : 0.10.4
nested-pandas : 0.6.10
pandas        : 2.3.3
numpy         : 2.3.5
dask          : 2026.7.1
pyarrow       : 21.0.0
fsspec        : 2026.6.0

1. Collect DA white dwarf positions from multiple papers#

We query SIMBAD for the objects cited in three recent DA white dwarf papers, identified by their bibcodes, and add one more table of positions from VizieR.

[4]:
simbad_bibcodes = [
    "2025A&A...701A.269G",
    "2025MNRAS.540..385B",
    "2025AJ....169...40B",
]

vizier_catalogs = [
    "J/A+A/699/A212/tableb2",  # https://ui.adsabs.harvard.edu/abs/2025A%26A...699A.212J
]

simbad = Simbad()
vizier = Vizier(columns=["_RAJ2000", "_DEJ2000"])

Query each paper’s objects from SIMBAD and the VizieR table, keeping only right ascension and declination, and stack the results into a single astropy table.

[5]:
tables = []

for bibcode in simbad_bibcodes:
    table = simbad.query_bibobj(bibcode)[["ra", "dec"]]
    tables.append(table)

for viz_cat in vizier_catalogs:
    table = vizier.get_catalogs(viz_cat)[0][["_RAJ2000", "_DEJ2000"]]
    table.rename_columns(["_RAJ2000", "_DEJ2000"], ["ra", "dec"])
    tables.append(table)

table = astropy.table.vstack(tables, metadata_conflicts="silent")
table
[5]:
Table length=135
radec
degdeg
float64float64
24.0567308228-11.3423980207
349.9933320184-5.1656030952
102.2336908394-25.3963773716
90.0870523566-10.2345829685
67.3593234723-4.8126710103
77.5118826901-0.6321245159
165.3010036032-13.2450631543
154.9681774742-14.1261809958
158.4281790018-11.6939835104
......
215.883156970051.1985618600
220.159986740015.6816023800
224.229889640052.9683501500
236.795078940044.4800542400
239.719989120042.1809409700
244.405114360053.8164746700
247.108049680033.5806735700
261.117659750058.5941397000
269.803800620061.2159802400

1.1 Convert to an LSDB catalog#

We wrap the combined table with lsdb.from_astropy so it can be used as one side of the cross-match below.

[6]:
da_cat = lsdb.from_astropy(table)
da_cat
[6]:
lsdb Catalog from_lsdb_dataframe:
ra dec
npartitions=12
Order: 0, Pixel: 0 double[pyarrow] double[pyarrow]
... ... ...
Order: 0, Pixel: 10 ... ...
Order: 0, Pixel: 11 ... ...
2 out of 2 available columns in the catalog have been loaded lazily, meaning no data has been read, only the catalog schema
This catalog has an estimated size of 2.0 KB

2. Load the Rubin DP2 forced-source catalog#

We open the DP2 object collection catalog, requesting all default columns plus two new nested forced-source subcolumns: psfFluxErr_corrected and psfDiffFluxErr_corrected.

Four new subcolumns are added to the nested forced-source tables (objectForcedSource and diaObjectForcedSource): psfFluxErr_corrected, psfDiffFluxErr_corrected, psfFluxErr_corrected_flag, and psfDiffFluxErr_corrected_flag. They come from a model trained to correct flux errors so that, for non-variable objects, the light curve’s reduced χ² is close to unity — these corrected values may be useful for light-curve fitting. The flag columns are True when the ratio between the original and corrected errors falls outside the [0.1, 50] interval. See Malanchev et al., in prep., for details; source code is available on GitHub.

We also filter to objects with PSF magnitude brighter than 22 in every band, to keep the cross-match in this tutorial small and fast.

[7]:
dp2_obj = lsdb.open_catalog(
    "/rubin/lsdb_data/object_collection",
    columns=[
        ...,  # all default columns
        "objectForcedSource.psfFluxErr_corrected",
        "objectForcedSource.psfDiffFluxErr_corrected",
    ],
    # Filter data on the read time: keep objects which are brighter 22 mag
    # in any band, since we know that these DA dwarfs are pretty bright.
    filters=[[(f"{band}_psfMag", "<", 22) for band in "ugrizy"]],
)
dp2_obj
[7]:
lsdb Catalog object_lc:
coord_dec coord_decErr coord_ra coord_raErr g_psfFlux g_psfFluxErr g_psfMag g_psfMagErr i_psfFlux i_psfFluxErr i_psfMag i_psfMagErr objectId patch r_psfFlux r_psfFluxErr r_psfMag r_psfMagErr refBand shape_flag shape_xx shape_xy shape_yy tract u_psfFlux u_psfFluxErr u_psfMag u_psfMagErr y_psfFlux y_psfFluxErr y_psfMag y_psfMagErr z_psfFlux z_psfFluxErr z_psfMag z_psfMagErr objectForcedSource
npartitions=8840
Order: 8, Pixel: 10240 double[pyarrow] float[pyarrow] double[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] int64[pyarrow] int64[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] string[pyarrow] bool[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] int64[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] nested<band: [string], coord_dec: [double], co...
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
Order: 5, Pixel: 12283 ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
Order: 5, Pixel: 12284 ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
37 out of 1261 available columns in the catalog have been loaded lazily, meaning no data has been read, only the catalog schema
This catalog has an estimated size of 1.5 TB

3. Cross-match with Rubin DP2#

We cross-match the literature white dwarf positions against the DP2 objects within 5 arcsec. As with all LSDB operations, this only plans the cross-match — no data has been loaded yet (see Lazy evaluation).

[8]:
xmatch = dp2_obj.crossmatch(da_cat, radius_arcsec=5, suffix_method="overlapping_columns")
xmatch
[8]:
lsdb Catalog object_lc_x_from_lsdb_dataframe:
coord_dec coord_decErr coord_ra coord_raErr g_psfFlux g_psfFluxErr g_psfMag g_psfMagErr i_psfFlux i_psfFluxErr i_psfMag i_psfMagErr objectId patch r_psfFlux r_psfFluxErr r_psfMag r_psfMagErr refBand shape_flag shape_xx shape_xy shape_yy tract u_psfFlux u_psfFluxErr u_psfMag u_psfMagErr y_psfFlux y_psfFluxErr y_psfMag y_psfMagErr z_psfFlux z_psfFluxErr z_psfMag z_psfMagErr objectForcedSource ra dec _dist_arcsec
npartitions=16
Order: 5, Pixel: 4505 double[pyarrow] float[pyarrow] double[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] int64[pyarrow] int64[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] string[pyarrow] bool[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] int64[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] float[pyarrow] nested<band: [string], coord_dec: [double], co... double[pyarrow] double[pyarrow] double[pyarrow]
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
Order: 7, Pixel: 195352 ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
Order: 6, Pixel: 49101 ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
40 out of 40 available columns in the catalog have been loaded lazily, meaning no data has been read, only the catalog schema

4. Run the pipeline#

Now we create a local Dask cluster and run the cross-match. See the Dask Client tutorial for tips on configuring Dask for LSDB.

[9]:
with Client(
    n_workers=2,
    threads_per_worker=1,
    memory_limit="8GB",
    local_directory=f"/deleted-sundays/{os.environ.get('USER', 'dask_scratch')}",
) as client:
    print(f"Dask dashboard: {client.dashboard_link}")
    display(client)
    df = xmatch.compute()

df
/opt/lsst/software/stack/conda/envs/lsst-scipipe-12.3.0-exact/lib/python3.13/site-packages/distributed/node.py:188: UserWarning: Port 8787 is already in use.
Perhaps you already have a cluster running?
Hosting the HTTP server on port 43221 instead
  warnings.warn(
Dask dashboard: https://ncaplar.nb.data-int.lsst.cloud/nb/user/ncaplar/proxy/43221/status

Client

Client-b2bbf605-89e7-11f1-88b3-49a1f880708d

Connection method: Cluster object Cluster type: distributed.LocalCluster
Dashboard: https://ncaplar.nb.data-int.lsst.cloud/nb/user/ncaplar/proxy/43221/status

Cluster Info

[9]:
  coord_dec coord_decErr coord_ra coord_raErr g_psfFlux g_psfFluxErr g_psfMag g_psfMagErr i_psfFlux i_psfFluxErr i_psfMag i_psfMagErr objectId patch r_psfFlux r_psfFluxErr r_psfMag r_psfMagErr refBand shape_flag shape_xx shape_xy shape_yy tract u_psfFlux u_psfFluxErr u_psfMag u_psfMagErr y_psfFlux y_psfFluxErr y_psfMag y_psfMagErr z_psfFlux z_psfFluxErr z_psfMag z_psfMagErr objectForcedSource ra dec _dist_arcsec
1918058228703156167 2.339937 0.000000 151.161863 0.000000 61795.765625 27.427217 19.422604 0.000482 79364.007812 36.498375 19.150942 0.000499 788021220577669107 58 75718.742188 30.212614 19.201992 0.000433 i False 4.252028 0.059288 3.969067 9814 39297.957031 54.880722 19.914076 0.001516 74448.414062 290.044373 19.220362 0.004230 77672.953125 71.986633 19.174326 0.001006
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
i 2.339937 ... 1516.606079 958.309509
+662 rows ... ... ... ...
151.161887 2.339897 0.167428
1918186924080762372 2.642915 0.000000 150.653521 0.000000 188631.109375 43.119041 18.210966 0.000248 135714.796875 41.864441 18.568432 0.000335 788015310702668377 72 163191.390625 37.791920 18.368258 0.000251 i False 4.255050 -0.003524 3.928190 9813 140010.812500 85.813362 18.534595 0.000665 98306.085938 323.113373 18.918549 0.003569 113173.359375 78.492111 18.765640 0.000753
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
i 2.642915 ... 1654.761108 822.603455
+639 rows ... ... ... ...
150.653515 2.642941 0.095025
2164146647781502823 -15.598661 0.000001 244.479173 0.000001 111321.867188 329.714508 18.783548 0.003216 235259.250000 602.124512 17.971132 0.002779 768628378084333113 55 101022.460938 360.336945 18.888956 0.003873 y False 8.765546 1.257625 8.170347 6992 420306.500000 1410.898804 17.341085 0.003645 112510.476562 578.089600 18.772018 0.005579 91457.890625 358.204437 18.996946 0.004252
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
z -15.598661 ... 879.961182 855.054321
+46 rows ... ... ... ...
244.480171 -15.597821 4.596668
3377248907570746225 -27.208014 0.000004 297.333079 0.000004 213423.062500 413.866516 18.076897 0.002105 137101.593750 468.876129 18.557394 0.003713 756383117085792759 63 270967.531250 442.270630 17.817707 0.001772 y False 9.512555 -0.473075 7.532055 5210 71865.968750 1241.068848 19.258692 0.018752 54183.125000 813.423279 19.565340 0.016301 15299.652344 547.012817 20.938295 0.038835
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
z -27.208014 ... 4171.870117 994.612854
+35 rows ... ... ... ...
297.332407 -27.207150 3.778421
3377248907581042017 -27.208113 0.000005 297.332446 0.000007 175127.578125 539.417358 18.291615 0.003344 173459.453125 425.247406 18.302006 0.002662 756383117085792757 63 289461.656250 466.498749 17.746023 0.001750 z True 18.028206 -10.098429 18.860741 5210 58675.542969 1298.712891 19.478857 0.024035 95707.734375 850.153503 18.947632 0.009645 77914.796875 515.328979 19.170950 0.007181
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
z -27.208113 ... 4016.803955 975.503845
+34 rows ... ... ... ...
297.332407 -27.207150 3.466011
3377248907851940554 -27.206832 0.000001 297.333607 0.000001 33922.066406 181.681366 20.073793 0.005815 135133.453125 190.545258 18.573093 0.001531 756383048366316850 62 88333.976562 200.714844 19.034681 0.002467 z False 7.478521 -0.459441 7.301531 5210 7160.368164 168.719543 21.762661 0.025588 194107.500000 755.787781 18.179893 0.004227 163486.062500 374.220642 18.366299 0.002485
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
z -27.206832 ... 2659.506348 1628.995728
+35 rows ... ... ... ...
297.332407 -27.207150 4.009363
3436679681683128908 -17.212113 0.000024 304.375029 0.000043 103467.875000 361.999237 18.862986 0.003799 42099.976562 176.454575 19.839294 0.004551 767287661093209606 45 55260.675781 278.631104 19.543959 0.005474 y False 55.105633 -11.382222 36.802410 6797 79772.148438 1019.541016 19.145372 0.013877 8311.605469 517.565247 21.600788 0.067697 28981.425781 426.063629 20.244701 0.015963
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
i -17.212113 ... 654.425293 352.275055
+56 rows ... ... ... ...
304.375005 -17.211357 2.722975
3436679681692705429 -17.212204 0.000000 304.374529 0.000000 182583.562500 412.527496 18.246346 0.002453 3123214.000000 723.848938 15.163496 0.000252 767287661093209601 45 282743.250000 375.685516 17.771519 0.001443 y False 5.720750 0.027560 5.558167 6797 2379499.000000 2168.796143 15.458786 0.000990 2417262.250000 1087.127441 15.441690 0.000488 2765065.250000 1147.374634 15.295736 0.000450
band coord_dec ... psfFluxErr_corrected psfDiffFluxErr_corrected
i -17.212204 ... 927.061218 5033.448242
+56 rows ... ... ... ...
304.375005 -17.211357 3.457759
8 rows x 40 columns

5. Plot the light curves#

For each matched white dwarf, we plot the per-band objectForcedSource light curves in both science flux and difference flux, comparing error bars from the original *Err columns against the corrected *Err_corrected columns, and report the reduced χ² for each.

[10]:
band_colors = {
    "u": "#0c71ff",
    "g": "#49be61",
    "r": "#c61c00",
    "i": "#ffc200",
    "z": "#f341a2",
    "y": "#5d0000",
}

bands = "ugrizy"


def reduced_chi2(y, yerr):
    n = len(y)
    if n <= 1:
        return None

    wmean = np.average(y, weights=1 / yerr**2)
    chi2 = np.sum(((y - wmean) / yerr) ** 2)
    return chi2 / (n - 1)


def chi2_text(value):
    return "N/A" if value is None else f"{value:.2f}"


def plot_for_flux(
    forced_sources,
    ra,
    dec,
    plot_title,
    flux_col,
    err_col,
    err_corr_col,
    flag_col,
    invalid_flag_col=None,
):
    fig, axes = plt.subplots(2, 3, figsize=(15, 8), sharex=True)
    axes = axes.ravel()

    fig.suptitle(rf"$\alpha={ra:.5f}$, $\delta={dec:.5f}$" f"\n{plot_title}")

    for ax, band in zip(axes, bands):
        color = band_colors[band]

        mask = (
            (forced_sources["band"] == band)
            & (~forced_sources[flag_col])
            & (~forced_sources["pixelFlags_bad"])
            & (~forced_sources["pixelFlags_saturated"])
            & (~forced_sources["pixelFlags_nodata"])
        )
        if invalid_flag_col is not None and invalid_flag_col in forced_sources.columns:
            mask &= ~forced_sources[invalid_flag_col]

        lc = forced_sources[mask].copy()

        chi2_orig = reduced_chi2(lc[flux_col], lc[err_col])
        chi2_corr = reduced_chi2(lc[flux_col], lc[err_corr_col])

        ax.errorbar(
            lc["midpointMjdTai"],
            lc[flux_col],
            yerr=lc[err_corr_col],
            color=color,
            linestyle="none",
            marker="o",
            markersize=6,
            markerfacecolor="none",
            markeredgewidth=1.0,
            alpha=0.35,
            elinewidth=2.5,
            capsize=3,
            zorder=1,
            label="corrected err",
        )

        ax.errorbar(
            lc["midpointMjdTai"],
            lc[flux_col],
            yerr=lc[err_col],
            color=color,
            linestyle="none",
            marker="x",
            markersize=7,
            alpha=0.95,
            elinewidth=2.0,
            capsize=2,
            zorder=2,
            label="orig err",
        )

        ax.set_title(
            f"{band}-band {plot_title}   "
            f"$\\chi^2_\\nu$={chi2_text(chi2_orig)}   "
            f"$\\chi^2_\\nu$$_{{corr}}$={chi2_text(chi2_corr)}",
            fontsize=12,
        )
        ax.grid(alpha=0.3)
        ax.legend(loc="lower right", fontsize=11)

    axes[0].set_ylabel(flux_col)
    axes[3].set_ylabel(flux_col)
    for ax in axes[3:]:
        ax.set_xlabel("MJD")

    fig.tight_layout(rect=[0, 0, 1, 0.95])
    plt.show()


for row_index in range(len(df)):
    row = df.iloc[row_index]
    forced_sources = row["objectForcedSource"]
    ra = row["coord_ra"]
    dec = row["coord_dec"]

    plot_configs = [
        (
            "science Flux",
            "psfFlux",
            "psfFluxErr",
            "psfFluxErr_corrected",
            "psfFlux_flag",
            "invalidPsfFlag",
        ),
        (
            "differential Flux",
            "psfDiffFlux",
            "psfDiffFluxErr",
            "psfDiffFluxErr_corrected",
            "psfDiffFlux_flag",
            None,
        ),
    ]

    for plot_title, flux_col, err_col, err_corr_col, flag_col, invalid_flag_col in plot_configs:
        plot_for_flux(
            forced_sources=forced_sources,
            ra=ra,
            dec=dec,
            plot_title=plot_title,
            flux_col=flux_col,
            err_col=err_col,
            err_corr_col=err_corr_col,
            flag_col=flag_col,
            invalid_flag_col=invalid_flag_col,
        )
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_0.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_1.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_2.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_3.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_4.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_5.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_6.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_7.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_8.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_9.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_10.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_11.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_12.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_13.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_14.png
../../_images/tutorials_pre_executed_rubin_dp2-da-white-dwarfs_17_15.png

Bright objects see their errors slightly overestimated, by about 30%, in exchange for reliably catching large error underestimation present in the original data.

About#

Authors: Konstantin Malanchev

Last run: July 27, 2026

If you use lsdb for published research, please cite following instructions.