Coverage for python/lsst/analysis/ap/ppdb.py: 95%
258 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-19 07:48 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-19 07:48 +0000
1# This file is part of analysis_ap.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Load PPDB catalogs (DiaObjects, DiaSources, DiaForcedSources) through the
23Rubin Science Platform TAP service.
25These tools deliberately use the scientist-facing TAP interface (ADQL over
26``pyvo``) rather than a direct database connection, so that exercising them
27also exercises the interface scientists will use once the PPDB goes live.
29The prototype PPDB currently lives at the ``data-int`` RSP environment
30(``https://data-int.lsst.cloud/api/ppdbtap``). Tooling running elsewhere
31(e.g. the USDF RSP) reaches it cross-environment with a ``data-int`` RSP
32token carrying the ``read:tap`` scope, supplied via the ``RSP_TOKEN``
33environment variable.
35The ``DiaObject`` table is versioned: a single object accumulates multiple
36rows over time and only the row with ``validityEndMjdTai IS NULL`` is the
37current version. Every DiaObject query here applies that filter by default
38so callers never accidentally retrieve (or double-count) stale versions.
39``DiaSource`` and ``DiaForcedSource`` are append-only and are not versioned.
41The production PPDB will allow cone searches only on ``DiaObject``. Sources
42and forced sources for a region must therefore be loaded object-first:
43cone-search DiaObjects, then load the sources of those objects. The
44``load_sources_for_region`` / ``load_forced_sources_for_region`` methods do
45this for you and are the encouraged entry points. ``load_sources`` /
46``load_forced_sources`` require a ``diaObjectId`` (scalar or iterable) and
47take no spatial argument. Direct, object-unrestricted access to the source
48tables is available only through the explicit, prototype-only
49``load_sources_by_cone`` / ``load_forced_sources_by_cone`` (spatial) and
50``load_sources_by_time_window`` / ``load_forced_sources_by_time_window``
51(temporal) methods, which warn when used.
53All loaders return `astropy.table.Table` objects. Their columns are returned
54in SDM-schema order, since the TAP service returns ``SELECT *`` columns
55alphabetically; passing an explicit ``columns`` list overrides this with the
56order you request.
57"""
59__all__ = [
60 "PpdbTap",
61 "DiaObjectLightCurve",
62 "region_from_exposure",
63 "DEFAULT_PPDB_TAP_URL",
64 "DEFAULT_PADDING_ARCSEC",
65 "DEFAULT_ROW_LIMIT",
66]
68import dataclasses
69import functools
70import logging
71import numbers
72import os
73import time
74from importlib.resources import files
76import astropy.table
77import pyvo
78import requests
80import lsst.geom
81from lsst.pipe.tasks.schemaUtils import readSdmSchemaFile
83_log = logging.getLogger(__name__)
85# TAP endpoint of the prototype PPDB (the ``data-int`` RSP environment).
86DEFAULT_PPDB_TAP_URL = "https://data-int.lsst.cloud/api/ppdbtap"
88# Default padding (arcseconds) loaded around an exposure footprint.
89DEFAULT_PADDING_ARCSEC = 5.0
91# Default cap on rows returned by a single query, to guard against
92# accidentally pulling the whole (multi-million row) PPDB. Pass ``limit=None``
93# to disable.
94DEFAULT_ROW_LIMIT = 100000
96# Valid LSST band names; used to validate band filters before they are
97# interpolated into an ADQL string.
98_VALID_BANDS = frozenset("ugrizy")
100# The PPDB tables share the APDB SDM schema (``sdm_schemas/apdb.yaml``); its
101# column order is the canonical, scientifically-grouped order. Loaders use it
102# to reorder results, because the TAP service returns ``SELECT *`` columns
103# alphabetically (its ``tap_schema.columns`` has no ``column_index``). Both
104# helpers are memoized, so the schema file is parsed at most once.
107@functools.cache
108def _sdm_schema():
109 """Parse and return the APDB SDM schema (shared by the PPDB tables)."""
110 path = os.fspath(files("lsst.sdm.schemas").joinpath("apdb.yaml"))
111 return readSdmSchemaFile(path)
114@functools.cache
115def _sdm_column_order(table_name):
116 """Return the SDM-schema column order for a PPDB table, or ``()``.
118 Parameters
119 ----------
120 table_name : `str`
121 Unqualified table name, e.g. ``"DiaSource"``.
123 Returns
124 -------
125 columns : `tuple` [`str`]
126 Column names in schema order, or an empty tuple if the table is not
127 in the SDM schema.
128 """
129 table_def = _sdm_schema().get(table_name)
130 if table_def is None: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 return ()
132 return tuple(column.name for column in table_def.columns)
135def region_from_exposure(exposure, padding=DEFAULT_PADDING_ARCSEC):
136 """Compute a cone (ra, dec, radius) covering an exposure's sky footprint.
138 The cone is the smallest circle centered on the exposure's bounding-box
139 center that contains all four corners, expanded by ``padding``
140 arcseconds. A circumscribing circle (rather than the exact polygon) keeps
141 the resulting ADQL robust: ``CIRCLE`` is universally supported by TAP
142 services, whereas ``POLYGON`` support and winding conventions vary.
144 Parameters
145 ----------
146 exposure : `lsst.afw.image.Exposure`
147 Exposure whose WCS and bounding box define the region.
148 padding : `float`, optional
149 Margin to add around the footprint, in arcseconds.
151 Returns
152 -------
153 ra : `float`
154 Cone center right ascension, in degrees (ICRS).
155 dec : `float`
156 Cone center declination, in degrees (ICRS).
157 radius : `float`
158 Cone radius, in degrees.
160 Raises
161 ------
162 ValueError
163 If the exposure has no WCS, an empty bounding box, or negative
164 padding.
165 """
166 if padding < 0:
167 raise ValueError(f"padding must be non-negative, got {padding}")
168 wcs = exposure.getWcs()
169 if wcs is None:
170 raise ValueError("Exposure has no WCS; cannot compute a sky region.")
171 bbox = exposure.getBBox()
172 if bbox.isEmpty(): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 raise ValueError("Exposure has an empty bounding box.")
175 center = wcs.pixelToSky(bbox.getCenter())
176 corners = [wcs.pixelToSky(lsst.geom.Point2D(corner))
177 for corner in bbox.getCorners()]
178 radius = max(center.separation(corner) for corner in corners)
179 radius = radius + padding * lsst.geom.arcseconds
180 return (center.getRa().asDegrees(),
181 center.getDec().asDegrees(),
182 radius.asDegrees())
185@dataclasses.dataclass
186class DiaObjectLightCurve:
187 """The full PPDB record for a single diaObject.
189 Attributes
190 ----------
191 diaObjectId : `int`
192 Identifier of the diaObject.
193 dia_object : `astropy.table.Row`
194 The current (latest-version) DiaObject row.
195 dia_sources : `astropy.table.Table`
196 All DiaSources associated with the object, time-ordered.
197 dia_forced_sources : `astropy.table.Table`
198 All DiaForcedSources for the object, time-ordered.
199 """
201 diaObjectId: int
202 dia_object: "astropy.table.Row"
203 dia_sources: astropy.table.Table
204 dia_forced_sources: astropy.table.Table
206 @property
207 def n_sources(self):
208 """Number of associated DiaSources (`int`)."""
209 return len(self.dia_sources)
211 @property
212 def n_forced_sources(self):
213 """Number of associated DiaForcedSources (`int`)."""
214 return len(self.dia_forced_sources)
216 def __repr__(self):
217 return (f"DiaObjectLightCurve(diaObjectId={self.diaObjectId}, "
218 f"n_sources={self.n_sources}, "
219 f"n_forced_sources={self.n_forced_sources})")
222class PpdbTap:
223 """Load PPDB catalogs through the RSP TAP service as astropy Tables.
225 Parameters
226 ----------
227 url : `str`, optional
228 TAP endpoint to query. Defaults to the prototype PPDB at ``data-int``.
229 token : `str`, optional
230 RSP token with the ``read:tap`` scope. If not given (and ``service``
231 is also not given), the ``RSP_TOKEN`` environment variable is used.
232 service : `pyvo.dal.TAPService`, optional
233 Pre-built TAP service. When supplied, ``url`` and ``token`` are
234 ignored. Intended for use with unit tests.
235 log : `logging.Logger`, optional
236 Logger to use; defaults to the module logger.
238 Notes
239 -----
240 The token is read only from the environment or the constructor argument
241 and is never logged.
242 """
244 def __init__(self, url=DEFAULT_PPDB_TAP_URL, *, token=None,
245 service=None, log=None):
246 self.url = url
247 self.log = log if log is not None else _log
248 if service is not None:
249 self._service = service
250 return
251 if token is None:
252 token = os.environ.get("RSP_TOKEN")
253 if not token:
254 raise RuntimeError(
255 "No RSP token available for the PPDB TAP service. Generate "
256 "a token with the 'read:tap' scope on data-int "
257 "(https://data-int.lsst.cloud) and set it in the RSP_TOKEN "
258 "environment variable, or pass token=...")
259 session = requests.Session()
260 session.headers["Authorization"] = f"Bearer {token}"
261 self._service = pyvo.dal.TAPService(url, session=session)
263 # ------------------------------------------------------------------
264 # Query execution
265 # ------------------------------------------------------------------
266 def run_query(self, adql, *, maxrec=None, order_table=None):
267 """Run an ADQL query and return the result as an astropy Table.
269 Parameters
270 ----------
271 adql : `str`
272 The ADQL query to execute.
273 maxrec : `int`, optional
274 Server-side cap on the number of records returned.
275 order_table : `str`, optional
276 If given (e.g. ``"DiaSource"``), reorder the result columns into
277 that table's SDM-schema order. The loaders pass this so that
278 ``SELECT *`` results, which the TAP service returns alphabetically,
279 come back in the schema's logical column order. Columns not in the
280 schema are appended in their original order.
282 Returns
283 -------
284 table : `astropy.table.Table`
285 The query result.
286 """
287 self.log.debug("Executing ADQL query: %s", adql)
288 start = time.perf_counter()
289 results = self._service.run_async(adql, maxrec=maxrec)
290 table = results.to_table()
291 if order_table is not None:
292 table = self._order_columns(table, order_table)
293 elapsed = time.perf_counter() - start
294 self.log.info("Query returned %d rows in %.3f s", len(table), elapsed)
295 return table
297 @staticmethod
298 def _order_columns(table, table_name):
299 """Reorder a result table's columns into SDM-schema order.
301 Every result column is expected to be defined in the SDM schema for
302 ``table_name`` (the PPDB does not add columns outside it), so columns
303 are returned in schema order. Returns the table unchanged if the
304 schema is unknown or the order already matches.
306 Raises
307 ------
308 RuntimeError
309 If the result contains a column not present in the SDM schema,
310 which signals an unexpected schema mismatch.
311 """
312 schema_order = _sdm_column_order(table_name)
313 if not schema_order: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 return table
315 schema_set = set(schema_order)
316 unknown = [c for c in table.colnames if c not in schema_set]
317 if unknown:
318 raise RuntimeError(
319 f"ppdb.{table_name} returned columns not in the SDM schema: "
320 f"{unknown}")
321 new_order = [c for c in schema_order if c in table.colnames]
322 if new_order == table.colnames:
323 return table
324 return table[new_order]
326 # ------------------------------------------------------------------
327 # ADQL construction helpers
328 # ------------------------------------------------------------------
329 @staticmethod
330 def _select_clause(columns, limit):
331 """Build the ``SELECT [TOP n] cols`` prefix of a query."""
332 cols = "*" if not columns else ", ".join(columns)
333 top = f"TOP {int(limit)} " if limit is not None else ""
334 return f"SELECT {top}{cols}"
336 @staticmethod
337 def _cone_clause(ra, dec, radius):
338 """Build an ADQL cone-search predicate (all angles in degrees)."""
339 return (f"CONTAINS(POINT('ICRS', ra, dec), "
340 f"CIRCLE('ICRS', {float(ra)}, {float(dec)}, "
341 f"{float(radius)})) = 1")
343 def _resolve_cone(self, ra, dec, radius, exposure, padding):
344 """Resolve spatial arguments into a (ra, dec, radius) cone or None.
346 Either an ``exposure`` or an explicit ``ra``/``dec``/``radius`` triple
347 may be given, but not both. Returns None when no spatial constraint
348 was requested. Explicit ``radius`` is in degrees; ``padding`` (used
349 only with ``exposure``) is in arcseconds.
350 """
351 if exposure is not None:
352 if any(v is not None for v in (ra, dec, radius)):
353 raise ValueError("Specify either exposure= or ra/dec/radius, "
354 "not both.")
355 return region_from_exposure(exposure, padding=padding)
356 if ra is None and dec is None and radius is None:
357 return None
358 if None in (ra, dec, radius):
359 raise ValueError("ra, dec, and radius must all be provided for a "
360 "cone search.")
361 return float(ra), float(dec), float(radius)
363 @staticmethod
364 def _band_clause(bands):
365 """Build an ADQL ``band IN (...)`` predicate, validating each band."""
366 quoted = []
367 for band in bands:
368 if band not in _VALID_BANDS:
369 raise ValueError(f"Unknown band {band!r}; expected one of "
370 f"{sorted(_VALID_BANDS)}.")
371 quoted.append(f"'{band}'")
372 return f"band IN ({', '.join(quoted)})"
374 def _finish(self, table, limit, table_name):
375 """Warn on probable truncation and return the table unchanged."""
376 if limit is not None and len(table) == limit:
377 self.log.warning(
378 "%s query returned exactly the row limit (%d); results are "
379 "likely truncated. Narrow the query or raise limit.",
380 table_name, limit)
381 return table
383 # ------------------------------------------------------------------
384 # DiaObject loaders
385 # ------------------------------------------------------------------
386 def load_objects(self, *, ra=None, dec=None, radius=None, exposure=None,
387 padding=DEFAULT_PADDING_ARCSEC, columns=None,
388 limit=DEFAULT_ROW_LIMIT, latest=True):
389 """Load DiaObjects, optionally within a spatial region.
391 Parameters
392 ----------
393 ra, dec, radius : `float`, optional
394 Cone-search center and radius, in degrees. All three must be
395 given together; mutually exclusive with ``exposure``.
396 exposure : `lsst.afw.image.Exposure`, optional
397 Load objects covering this exposure's footprint (see
398 `region_from_exposure`). Mutually exclusive with
399 ``ra``/``dec``/``radius``.
400 padding : `float`, optional
401 Margin around the exposure footprint, in arcseconds (only used
402 with ``exposure``).
403 columns : `list` [`str`], optional
404 Columns to select; defaults to all columns.
405 limit : `int`, optional
406 Maximum number of rows to return; None means no limit.
407 latest : `bool`, optional
408 If True (default), return only the current version of each object
409 (``validityEndMjdTai IS NULL``). Setting this False returns every
410 historical version and is rarely what you want.
412 Returns
413 -------
414 objects : `astropy.table.Table`
415 The matching DiaObjects.
416 """
417 cone = self._resolve_cone(ra, dec, radius, exposure, padding)
418 clauses = []
419 if latest:
420 clauses.append("validityEndMjdTai IS NULL")
421 if cone is not None:
422 clauses.append(self._cone_clause(*cone))
423 where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
424 adql = (f"{self._select_clause(columns, limit)} FROM ppdb.DiaObject"
425 f"{where} ORDER BY diaObjectId")
426 order_table = "DiaObject" if columns is None else None
427 table = self._finish(self.run_query(adql, order_table=order_table),
428 limit, "DiaObject")
429 if latest and len(table) and "diaObjectId" in table.colnames:
430 n_unique = len(set(table["diaObjectId"].tolist()))
431 if n_unique != len(table):
432 self.log.warning(
433 "DiaObject query returned %d rows but only %d unique "
434 "diaObjectIds despite validityEndMjdTai IS NULL; the PPDB "
435 "may contain duplicate current versions.",
436 len(table), n_unique)
437 return table
439 def load_object(self, diaObjectId, *, columns=None):
440 """Load the current version of a single DiaObject.
442 Parameters
443 ----------
444 diaObjectId : `int`
445 Identifier of the object to load.
446 columns : `list` [`str`], optional
447 Columns to select; defaults to all columns.
449 Returns
450 -------
451 object : `astropy.table.Row`
452 The current DiaObject row.
454 Raises
455 ------
456 RuntimeError
457 If no current version of the object exists.
458 """
459 adql = (f"{self._select_clause(columns, None)} FROM ppdb.DiaObject "
460 f"WHERE validityEndMjdTai IS NULL AND "
461 f"diaObjectId = {int(diaObjectId)}")
462 order_table = "DiaObject" if columns is None else None
463 table = self.run_query(adql, order_table=order_table)
464 if len(table) == 0:
465 raise RuntimeError(
466 f"diaObjectId={diaObjectId} not found in ppdb.DiaObject")
467 if len(table) > 1: 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 self.log.warning(
469 "diaObjectId=%s has %d current versions (validityEndMjdTai "
470 "IS NULL); returning the first.", diaObjectId, len(table))
471 return table[0]
473 # ------------------------------------------------------------------
474 # DiaSource / DiaForcedSource loaders
475 #
476 # The production PPDB will permit cone searches only on DiaObject. The
477 # encouraged way to retrieve sources for a region is therefore object-
478 # first: cone-search DiaObjects (`load_*_for_region`), then load their
479 # sources by diaObjectId. `load_sources`/`load_forced_sources` require a
480 # diaObjectId and take no spatial argument. Direct, object-unrestricted
481 # source-table access is confined to the explicit, prototype-only
482 # `load_*_by_cone` and `load_*_by_time_window` methods.
483 # ------------------------------------------------------------------
484 def _source_filter_clauses(self, bands, mjd_begin, mjd_end):
485 """Build the time/band filter clauses shared by the source loaders."""
486 clauses = []
487 if mjd_begin is not None:
488 clauses.append(f"midpointMjdTai >= {float(mjd_begin)}")
489 if mjd_end is not None:
490 clauses.append(f"midpointMjdTai < {float(mjd_end)}")
491 if bands:
492 clauses.append(self._band_clause(bands))
493 return clauses
495 def _run_source_query(self, table_name, clauses, columns, limit):
496 """Run a single time-ordered SELECT against a source table."""
497 where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
498 adql = (f"{self._select_clause(columns, limit)} FROM ppdb.{table_name}"
499 f"{where} ORDER BY midpointMjdTai")
500 order_table = table_name if columns is None else None
501 return self._finish(self.run_query(adql, order_table=order_table),
502 limit, table_name)
504 def _load_source_like(self, table_name, diaObjectId, extra_clauses,
505 columns, limit, id_chunk_size):
506 """Dispatch a source load by one id or many ids.
508 ``diaObjectId`` is required: source tables are loaded by object. An
509 iterable (including the empty list a region search may produce) takes
510 the chunked ``IN`` path; a scalar takes a single ``=`` query.
511 """
512 if diaObjectId is None:
513 raise ValueError(
514 "diaObjectId is required; DiaSources and DiaForcedSources are "
515 "loaded by object. Use load_*_for_region for a spatial "
516 "region, or the prototype-only load_*_by_cone / "
517 "load_*_by_time_window methods for direct source-table access.")
518 if isinstance(diaObjectId, numbers.Integral):
519 clauses = [f"diaObjectId = {int(diaObjectId)}"] + list(extra_clauses)
520 return self._run_source_query(table_name, clauses, columns, limit)
521 return self._load_sources_for_ids(table_name, diaObjectId,
522 extra_clauses, columns, limit,
523 id_chunk_size)
525 def _load_sources_for_ids(self, table_name, ids, extra_clauses, columns,
526 limit, id_chunk_size):
527 """Load sources for many diaObjectIds via chunked ``IN`` queries.
529 A cone search can return more diaObjectIds than fit in a single
530 ``diaObjectId IN (...)`` clause, so the ids are split into chunks
531 of ``id_chunk_size`` and the per-chunk results are concatenated and
532 re-sorted by time.
533 ``limit`` caps the total rows returned across all chunks.
534 """
535 if id_chunk_size < 1: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 raise ValueError("id_chunk_size must be >= 1")
537 ids = list(dict.fromkeys(int(i) for i in ids))
538 if not ids or (limit is not None and limit <= 0):
539 # Empty result, but issue a query so columns/dtypes are populated.
540 return self._run_source_query(table_name, ["1 = 0"], columns, None)
542 order_table = table_name if columns is None else None
543 pages = []
544 total = 0
545 truncated = False
546 for start in range(0, len(ids), id_chunk_size):
547 if limit is not None and total >= limit: 547 ↛ 548line 547 didn't jump to line 548 because the condition on line 547 was never true
548 truncated = True
549 break
550 chunk = ids[start:start + id_chunk_size]
551 remaining = None if limit is None else limit - total
552 id_list = ", ".join(str(i) for i in chunk)
553 clauses = [f"diaObjectId IN ({id_list})"] + list(extra_clauses)
554 where = " WHERE " + " AND ".join(clauses)
555 adql = (f"{self._select_clause(columns, remaining)} "
556 f"FROM ppdb.{table_name}{where} ORDER BY midpointMjdTai")
557 page = self.run_query(adql, order_table=order_table)
558 total += len(page)
559 pages.append(page)
561 table = (pages[0] if len(pages) == 1
562 else astropy.table.vstack(pages, metadata_conflicts="silent"))
563 if len(table) and "midpointMjdTai" in table.colnames: 563 ↛ 565line 563 didn't jump to line 565 because the condition on line 563 was always true
564 table.sort("midpointMjdTai")
565 if limit is not None and (truncated or total == limit): 565 ↛ 566line 565 didn't jump to line 566 because the condition on line 565 was never true
566 self.log.warning(
567 "%s query reached the row limit (%d) while loading sources for "
568 "%d objects; results are likely truncated. Raise limit or "
569 "narrow the query.", table_name, limit, len(ids))
570 return table
572 def _direct_source_query(self, table_name, region_method, kind, clauses,
573 columns, limit):
574 """Warn, then run a prototype-only direct source-table query.
576 ``load_sources``/``load_forced_sources`` require a diaObjectId; these
577 direct (object-unrestricted) source-table searches will not exist in
578 the production PPDB, so callers reach them only through the explicit
579 ``load_*_by_cone`` / ``load_*_by_time_window`` methods, which route
580 here and emit this warning.
581 """
582 self.log.warning(
583 "Direct %s %s search is a prototype-only capability and will not "
584 "be available in the production PPDB; prefer %s().",
585 table_name, kind, region_method)
586 return self._run_source_query(table_name, clauses, columns, limit)
588 def _load_by_cone(self, table_name, region_method, *, ra, dec, radius,
589 exposure, padding, bands, mjd_begin, mjd_end, columns,
590 limit):
591 """Direct cone search on a source table (prototype-only)."""
592 cone = self._resolve_cone(ra, dec, radius, exposure, padding)
593 if cone is None:
594 raise ValueError("A cone search requires ra/dec/radius or an "
595 "exposure.")
596 clauses = [self._cone_clause(*cone)]
597 clauses += self._source_filter_clauses(bands, mjd_begin, mjd_end)
598 return self._direct_source_query(table_name, region_method, "cone",
599 clauses, columns, limit)
601 def _load_by_time_window(self, table_name, region_method, *, mjd_begin,
602 mjd_end, bands, columns, limit):
603 """Direct time-window scan of a source table (prototype-only)."""
604 if mjd_begin is None and mjd_end is None:
605 raise ValueError("A time-window search requires mjd_begin and/or "
606 "mjd_end.")
607 clauses = self._source_filter_clauses(bands, mjd_begin, mjd_end)
608 return self._direct_source_query(table_name, region_method,
609 "time-window", clauses, columns,
610 limit)
612 def load_sources(self, *, diaObjectId, mjd_begin=None, mjd_end=None,
613 bands=None, columns=None, limit=DEFAULT_ROW_LIMIT,
614 id_chunk_size=1000):
615 """Load DiaSources for a given object (or objects), by time and band.
617 DiaSources are loaded by object: ``diaObjectId`` is required. This
618 loader has no spatial argument, because the production PPDB does not
619 permit searches on the source tables. To load sources for a region,
620 use `load_sources_for_region` (object-first); for direct, object-
621 unrestricted prototype-only access, use `load_sources_by_cone` or
622 `load_sources_by_time_window`.
624 Parameters
625 ----------
626 diaObjectId : `int` or iterable of `int`
627 Restrict to sources of this object, or of any of these objects
628 (issued as chunked ``diaObjectId IN (...)`` queries). Required.
629 mjd_begin, mjd_end : `float`, optional
630 Restrict to ``mjd_begin <= midpointMjdTai < mjd_end`` (TAI).
631 bands : `list` [`str`], optional
632 Restrict to these bands (subset of ``ugrizy``).
633 columns : `list` [`str`], optional
634 Columns to select; defaults to all columns.
635 limit : `int`, optional
636 Maximum number of rows to return; None means no limit.
637 id_chunk_size : `int`, optional
638 Number of diaObjectIds per ``IN`` query when an iterable is given.
640 Returns
641 -------
642 sources : `astropy.table.Table`
643 The matching DiaSources, ordered by ``midpointMjdTai``.
645 Raises
646 ------
647 ValueError
648 If ``diaObjectId`` is None.
649 """
650 extra = self._source_filter_clauses(bands, mjd_begin, mjd_end)
651 return self._load_source_like("DiaSource", diaObjectId, extra,
652 columns, limit, id_chunk_size)
654 def load_forced_sources(self, *, diaObjectId, mjd_begin=None,
655 mjd_end=None, bands=None, columns=None,
656 limit=DEFAULT_ROW_LIMIT, id_chunk_size=1000):
657 """Load DiaForcedSources, with the same filters as `load_sources`.
659 Like `load_sources`, ``diaObjectId`` is required and there is no
660 spatial argument; use `load_forced_sources_for_region`, or the
661 prototype-only `load_forced_sources_by_cone` /
662 `load_forced_sources_by_time_window`.
664 Returns
665 -------
666 forced_sources : `astropy.table.Table`
667 The matching DiaForcedSources, ordered by ``midpointMjdTai``.
669 Raises
670 ------
671 ValueError
672 If ``diaObjectId`` is None.
673 """
674 extra = self._source_filter_clauses(bands, mjd_begin, mjd_end)
675 return self._load_source_like("DiaForcedSource", diaObjectId, extra,
676 columns, limit, id_chunk_size)
678 def load_sources_for_region(self, *, ra=None, dec=None, radius=None,
679 exposure=None, padding=DEFAULT_PADDING_ARCSEC,
680 mjd_begin=None, mjd_end=None, bands=None,
681 columns=None, limit=DEFAULT_ROW_LIMIT,
682 object_limit=DEFAULT_ROW_LIMIT,
683 id_chunk_size=1000):
684 """Load DiaSources for a region, the production-compatible way.
686 Performs the two-step workflow the production PPDB requires: cone-
687 search DiaObjects (the only table that allows spatial search), then
688 load the DiaSources of those objects.
690 Parameters
691 ----------
692 ra, dec, radius : `float`, optional
693 Cone center and radius, in degrees; mutually exclusive with
694 ``exposure``. The radius is applied to *object* positions.
695 exposure : `lsst.afw.image.Exposure`, optional
696 Load sources for objects covering this exposure's footprint.
697 padding : `float`, optional
698 Margin around the exposure footprint, in arcseconds.
699 mjd_begin, mjd_end, bands, columns : optional
700 Passed through to the source query (see `load_sources`).
701 limit : `int`, optional
702 Cap on returned sources; None means no limit.
703 object_limit : `int`, optional
704 Cap on the DiaObjects found by the cone search.
705 id_chunk_size : `int`, optional
706 diaObjectIds per ``IN`` query when fetching sources.
708 Returns
709 -------
710 sources : `astropy.table.Table`
711 DiaSources of the objects in the region, ordered by time.
713 Notes
714 -----
715 Because this is object-first, DiaSources not associated with a
716 returned DiaObject (e.g. unassociated or ssObject-only detections)
717 are not included. To find those, use `load_sources_by_cone`.
718 """
719 ids = self._region_object_ids(
720 ra, dec, radius, exposure, padding, object_limit)
721 return self.load_sources(diaObjectId=ids, mjd_begin=mjd_begin,
722 mjd_end=mjd_end, bands=bands, columns=columns,
723 limit=limit, id_chunk_size=id_chunk_size)
725 def load_forced_sources_for_region(self, *, ra=None, dec=None,
726 radius=None, exposure=None,
727 padding=DEFAULT_PADDING_ARCSEC,
728 mjd_begin=None, mjd_end=None, bands=None,
729 columns=None, limit=DEFAULT_ROW_LIMIT,
730 object_limit=DEFAULT_ROW_LIMIT,
731 id_chunk_size=1000):
732 """Load DiaForcedSources for a region (object-first).
734 Same two-step workflow and caveats as `load_sources_for_region`.
736 Returns
737 -------
738 forced_sources : `astropy.table.Table`
739 DiaForcedSources of the objects in the region, ordered by time.
740 """
741 ids = self._region_object_ids(
742 ra, dec, radius, exposure, padding, object_limit)
743 return self.load_forced_sources(
744 diaObjectId=ids, mjd_begin=mjd_begin, mjd_end=mjd_end, bands=bands,
745 columns=columns, limit=limit, id_chunk_size=id_chunk_size)
747 def _region_object_ids(self, ra, dec, radius, exposure, padding,
748 object_limit):
749 """Cone-search DiaObjects and return their diaObjectIds."""
750 objects = self.load_objects(ra=ra, dec=dec, radius=radius,
751 exposure=exposure, padding=padding,
752 columns=["diaObjectId"],
753 limit=object_limit)
754 if not len(objects):
755 return []
756 return objects["diaObjectId"].tolist()
758 def load_sources_by_cone(self, *, ra=None, dec=None, radius=None,
759 exposure=None, padding=DEFAULT_PADDING_ARCSEC,
760 mjd_begin=None, mjd_end=None, bands=None,
761 columns=None, limit=DEFAULT_ROW_LIMIT):
762 """Directly cone-search the DiaSource table (prototype-only).
764 .. warning::
766 The production PPDB will not allow spatial searches on the source
767 tables. This works only against the prototype and is intended for
768 debugging/validation (e.g. finding DiaSources with no associated
769 DiaObject). For the production-compatible region workflow use
770 `load_sources_for_region`.
772 Parameters mirror `load_sources_for_region` minus ``object_limit``.
774 Returns
775 -------
776 sources : `astropy.table.Table`
777 DiaSources within the cone, ordered by ``midpointMjdTai``.
778 """
779 return self._load_by_cone(
780 "DiaSource", "load_sources_for_region", ra=ra, dec=dec,
781 radius=radius, exposure=exposure, padding=padding, bands=bands,
782 mjd_begin=mjd_begin, mjd_end=mjd_end, columns=columns, limit=limit)
784 def load_forced_sources_by_cone(self, *, ra=None, dec=None, radius=None,
785 exposure=None,
786 padding=DEFAULT_PADDING_ARCSEC,
787 mjd_begin=None, mjd_end=None, bands=None,
788 columns=None, limit=DEFAULT_ROW_LIMIT):
789 """Directly cone-search the DiaForcedSource table (prototype-only).
791 .. warning::
793 Not supported by the production PPDB; prototype/debugging only.
794 Prefer `load_forced_sources_for_region`.
796 Returns
797 -------
798 forced_sources : `astropy.table.Table`
799 DiaForcedSources within the cone, ordered by ``midpointMjdTai``.
800 """
801 return self._load_by_cone(
802 "DiaForcedSource", "load_forced_sources_for_region", ra=ra,
803 dec=dec, radius=radius, exposure=exposure, padding=padding,
804 bands=bands, mjd_begin=mjd_begin, mjd_end=mjd_end, columns=columns,
805 limit=limit)
807 def load_sources_by_time_window(self, *, mjd_begin=None, mjd_end=None,
808 bands=None, columns=None,
809 limit=DEFAULT_ROW_LIMIT):
810 """Load DiaSources in a time window, any object (prototype-only).
812 .. warning::
814 The production PPDB will not allow object-unrestricted searches on
815 the source tables. This works only against the prototype and is
816 intended for debugging (e.g. inspecting everything processed in an
817 MJD range). For the production-compatible workflow use
818 `load_sources_for_region`.
820 Parameters
821 ----------
822 mjd_begin, mjd_end : `float`, optional
823 Time window on ``midpointMjdTai`` (TAI), as
824 ``mjd_begin <= midpointMjdTai < mjd_end``. At least one bound is
825 required (an unbounded source-table scan is not permitted).
826 bands : `list` [`str`], optional
827 Restrict to these bands (subset of ``ugrizy``).
828 columns : `list` [`str`], optional
829 Columns to select; defaults to all columns.
830 limit : `int`, optional
831 Maximum number of rows to return; None means no limit.
833 Returns
834 -------
835 sources : `astropy.table.Table`
836 DiaSources in the window, ordered by ``midpointMjdTai``.
838 Raises
839 ------
840 ValueError
841 If neither ``mjd_begin`` nor ``mjd_end`` is given.
842 """
843 return self._load_by_time_window(
844 "DiaSource", "load_sources_for_region", mjd_begin=mjd_begin,
845 mjd_end=mjd_end, bands=bands, columns=columns, limit=limit)
847 def load_forced_sources_by_time_window(self, *, mjd_begin=None,
848 mjd_end=None, bands=None,
849 columns=None,
850 limit=DEFAULT_ROW_LIMIT):
851 """Load DiaForcedSources in a time window, any object (prototype-only).
853 .. warning::
855 Not supported by the production PPDB; prototype/debugging only.
856 Prefer `load_forced_sources_for_region`. At least one of
857 ``mjd_begin`` / ``mjd_end`` is required.
859 Returns
860 -------
861 forced_sources : `astropy.table.Table`
862 DiaForcedSources in the window, ordered by ``midpointMjdTai``.
864 Raises
865 ------
866 ValueError
867 If neither ``mjd_begin`` nor ``mjd_end`` is given.
868 """
869 return self._load_by_time_window(
870 "DiaForcedSource", "load_forced_sources_for_region",
871 mjd_begin=mjd_begin, mjd_end=mjd_end, bands=bands, columns=columns,
872 limit=limit)
874 def load_sources_for_object(self, diaObjectId, *, columns=None,
875 limit=DEFAULT_ROW_LIMIT):
876 """Load all DiaSources associated with one diaObject.
878 Returns
879 -------
880 sources : `astropy.table.Table`
881 The object's DiaSources, ordered by ``midpointMjdTai``.
882 """
883 return self.load_sources(diaObjectId=diaObjectId, columns=columns,
884 limit=limit)
886 def load_forced_sources_for_object(self, diaObjectId, *, columns=None,
887 limit=DEFAULT_ROW_LIMIT):
888 """Load all DiaForcedSources associated with one diaObject.
890 Returns
891 -------
892 forced_sources : `astropy.table.Table`
893 The object's DiaForcedSources, ordered by ``midpointMjdTai``.
894 """
895 return self.load_forced_sources(diaObjectId=diaObjectId,
896 columns=columns, limit=limit)
898 def load_source(self, diaSourceId, *, columns=None):
899 """Load a single DiaSource by id.
901 Raises
902 ------
903 RuntimeError
904 If the source does not exist.
905 """
906 return self._load_one("DiaSource", "diaSourceId", diaSourceId, columns)
908 def load_forced_source(self, diaForcedSourceId, *, columns=None):
909 """Load a single DiaForcedSource by id.
911 Raises
912 ------
913 RuntimeError
914 If the forced source does not exist.
915 """
916 return self._load_one("DiaForcedSource", "diaForcedSourceId",
917 diaForcedSourceId, columns)
919 def _load_one(self, table_name, id_column, id_value, columns):
920 """Load exactly one row from a table by id, raising if absent."""
921 adql = (f"{self._select_clause(columns, None)} FROM ppdb.{table_name} "
922 f"WHERE {id_column} = {int(id_value)}")
923 order_table = table_name if columns is None else None
924 table = self.run_query(adql, order_table=order_table)
925 if len(table) == 0:
926 raise RuntimeError(
927 f"{id_column}={id_value} not found in ppdb.{table_name}")
928 return table[0]
930 # ------------------------------------------------------------------
931 # Light-curve assembly
932 # ------------------------------------------------------------------
933 def load_light_curve(self, diaObjectId):
934 """Assemble the full PPDB record for one diaObject.
936 Loads the current DiaObject plus all of its DiaSources and
937 DiaForcedSources, time-ordered.
939 Parameters
940 ----------
941 diaObjectId : `int`
942 Identifier of the object.
944 Returns
945 -------
946 light_curve : `DiaObjectLightCurve`
947 The object and its associated source time series.
949 Raises
950 ------
951 RuntimeError
952 If no current version of the object exists.
953 """
954 dia_object = self.load_object(diaObjectId)
955 dia_sources = self.load_sources_for_object(diaObjectId)
956 dia_forced_sources = self.load_forced_sources_for_object(diaObjectId)
957 return DiaObjectLightCurve(
958 diaObjectId=int(diaObjectId),
959 dia_object=dia_object,
960 dia_sources=dia_sources,
961 dia_forced_sources=dia_forced_sources)