Querying the PPDB#

lsst.analysis.ap.ppdb loads PPDB catalogs — DiaObjects, DiaSources, and DiaForcedSources — through the Rubin Science Platform TAP service, and lsst.analysis.ap.ppdbPlotUtils maps what it finds onto the sky.

The choice of TAP (ADQL over pyvo) rather than a direct database connection is deliberate: it is the interface scientists will have once the PPDB goes live, so exercising these tools exercises that interface. All loaders return astropy.table.Table objects, with columns in SDM-schema order — the TAP service returns SELECT * columns alphabetically, so the loaders reorder them into the schema’s scientifically-grouped order unless you pass an explicit columns list.

Connecting#

The prototype PPDB lives at the data-int RSP environment, https://data-int.lsst.cloud/api/ppdbtap (DEFAULT_PPDB_TAP_URL). You need a data-int RSP token with the read:tap scope; generate one at https://data-int.lsst.cloud and put it in the RSP_TOKEN environment variable. The same token works cross-environment, so tooling running on the USDF RSP reaches the prototype with it.

from lsst.analysis.ap.ppdb import PpdbTap

ppdb = PpdbTap()                    # reads RSP_TOKEN from the environment
ppdb = PpdbTap(token=my_token)      # or pass it explicitly

The constructor raises RuntimeError with the token instructions if no token is available. Tokens are read only from the environment or the constructor and are never logged. PpdbTap.run_query is available for arbitrary ADQL when the loaders do not cover what you need.

Two things to know about the schema#

DiaObject is versioned. A single object accumulates multiple rows over time, and only the row with validityEndMjdTai IS NULL is the current version. Every DiaObject query here applies that filter by default (latest=True) so you cannot accidentally retrieve or double-count stale versions; latest=False returns the full history and is rarely what you want. DiaSource and DiaForcedSource are append-only and unversioned.

The source tables are not spatially searchable in production. The production PPDB will permit cone searches only on DiaObject. Sources for a region must therefore be loaded object-first: cone-search the DiaObjects, then load the sources of those objects. PpdbTap.load_sources_for_region and PpdbTap.load_forced_sources_for_region do exactly that and are the encouraged entry points. PpdbTap.load_sources and PpdbTap.load_forced_sources require a diaObjectId and take no spatial argument at all.

Direct, object-unrestricted access to the source tables exists only through the explicitly named prototype-only methods — PpdbTap.load_sources_by_cone, PpdbTap.load_forced_sources_by_cone, PpdbTap.load_sources_by_time_window, and PpdbTap.load_forced_sources_by_time_window — which warn when used. They will not work against the production PPDB. Their legitimate use is debugging that the object-first path cannot express: finding DiaSources with no associated DiaObject, or inspecting everything processed in an MJD range.

Every loader takes limit, defaulting to DEFAULT_ROW_LIMIT (100 000 rows), as a guard against accidentally pulling a multi-million-row table. Pass limit=None to lift it.

Loading catalogs#

Cone searches take ra, dec, radius in degrees, or an exposure whose footprint defines the region:

# Objects in a cone.
objects = ppdb.load_objects(ra=53.1, dec=-28.1, radius=0.1)

# Objects covering an exposure, with a 5 arcsecond margin.
exposure = butler.get("preliminary_visit_image", visit=..., detector=...)
objects = ppdb.load_objects(exposure=exposure, padding=5.0)

# Sources in a region, the production-compatible way.
sources = ppdb.load_sources_for_region(ra=53.1, dec=-28.1, radius=0.1,
                                       mjd_begin=60600.0, bands=["g", "r"])

region_from_exposure computes that region on its own if you want the numbers: it returns the (ra, dec, radius) of the smallest circle centered on the exposure’s bounding-box center that contains all four corners, plus padding arcseconds. A circumscribing circle rather than the exact polygon keeps the ADQL portable — CIRCLE is universally supported by TAP services, while POLYGON support and winding conventions vary.

The source loaders share mjd_begin/mjd_end (a half-open window on midpointMjdTai, TAI), bands (a subset of ugrizy), columns, and limit, and always return rows ordered by midpointMjdTai. diaObjectId accepts a scalar or an iterable; iterables are issued as chunked diaObjectId IN (...) queries, with id_chunk_size (default 1000) ids per query.

Note

Because load_*_for_region is object-first, DiaSources not associated with a returned DiaObject — unassociated detections, or ssObject-only ones — are not included. Finding those is what PpdbTap.load_sources_by_cone is for.

Single rows come back as astropy.table.Row, and raise RuntimeError rather than returning empty if the id does not exist: PpdbTap.load_object (current version only), PpdbTap.load_source, PpdbTap.load_forced_source.

Light curves#

PpdbTap.load_light_curve assembles one object’s complete PPDB record in a single call and returns a DiaObjectLightCurve:

lc = ppdb.load_light_curve(diaObjectId)
lc.dia_object          # astropy Row, the current DiaObject version
lc.dia_sources         # Table, time-ordered
lc.dia_forced_sources  # Table, time-ordered
lc.n_sources, lc.n_forced_sources

PpdbTap.load_sources_for_object and PpdbTap.load_forced_sources_for_object are the positional-argument shorthands for one object’s sources when you do not need the whole record.

Mapping the PPDB on the sky#

ppdbPlotUtils.plot_ppdb_sky_density answers “what is actually in the PPDB right now, and where?”. It bins a table into HEALPix pixels and draws the source density in # / deg² on a skyproj equal-area projection, with the galactic plane, an optional ecliptic, and the LSST Deep Drilling Fields overlaid. Because the projection is equal-area, the cos(dec) area factor is handled by the map rather than by hand.

This module is not re-exported at package level; import it directly:

from lsst.analysis.ap.ppdbPlotUtils import plot_ppdb_sky_density

sp, catalog = plot_ppdb_sky_density("DiaObject", ppdb=ppdb, nside=256)

Pass an existing PpdbTap as ppdb= to avoid re-authenticating. The function returns the skyproj.Skyproj it drew into (sp.ax is the underlying axes) and the full unfiltered catalog it fetched — feed that back as catalog= to re-plot for free, which is the intended workflow: fetch the whole table once, then zoom and retune thresholds without re-querying.

# Zoom in and tighten the cut, with no second query.
sp, _ = plot_ppdb_sky_density("DiaObject", catalog=catalog,
                              min_n_dia_sources=5,
                              ra_lim=(-60, 60), dec_lim=(-40, 10))

The per-table filter is the one extra column worth fetching and cutting on: nDiaSources for DiaObject (min_n_dia_sources) and reliability for DiaSource (min_reliability). Both cuts are applied client-side, which is why they are free to change when re-plotting. Asking for the wrong table’s filter raises rather than being silently ignored. NaN reliabilities are dropped.

Other options: band_lines sets the galactic latitudes drawn as dashed lines (default (10, -10), bracketing the |b| < 10 plane band; pass () for none); draw_ecliptic and ecliptic_band_lines do the same for the ecliptic; label_fields marks and names the LSST fields inside the window; projection takes any equal-area skyproj.Skyproj subclass (the default is skyproj.McBrydeSkyproj); and maxrec is the server-side row cap, which should sit above the table size to avoid silent truncation.