Coverage for python/lsst/analysis/ap/ppdb.py: 94%
253 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 09:03 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 09:03 +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 PPDB ``DiaObject`` table holds only the latest version of each object,
36so it has one row per ``diaObjectId``. ``DiaSource`` and ``DiaForcedSource``
37are append-only.
39The production PPDB will allow cone searches only on ``DiaObject``. Sources
40and forced sources for a region must therefore be loaded object-first:
41cone-search DiaObjects, then load the sources of those objects. The
42``load_sources_for_region`` / ``load_forced_sources_for_region`` methods do
43this for you and are the encouraged entry points. ``load_sources`` /
44``load_forced_sources`` require a ``diaObjectId`` (scalar or iterable) and
45take no spatial argument. Direct, object-unrestricted access to the source
46tables is available only through the explicit, prototype-only
47``load_sources_by_cone`` / ``load_forced_sources_by_cone`` (spatial) and
48``load_sources_by_time_window`` / ``load_forced_sources_by_time_window``
49(temporal) methods, which warn when used.
51All loaders return `astropy.table.Table` objects. Their columns are returned
52in SDM-schema order, since the TAP service returns ``SELECT *`` columns
53alphabetically; passing an explicit ``columns`` list overrides this with the
54order you request.
55"""
57__all__ = [
58 "PpdbTap",
59 "DiaObjectLightCurve",
60 "region_from_exposure",
61 "DEFAULT_PPDB_TAP_URL",
62 "DEFAULT_PADDING_ARCSEC",
63 "DEFAULT_ROW_LIMIT",
64]
66import dataclasses
67import functools
68import logging
69import numbers
70import os
71import time
72from importlib.resources import files
74import astropy.table
75import pyvo
76import requests
78import lsst.geom
79from lsst.pipe.tasks.schemaUtils import readSdmSchemaFile
81_log = logging.getLogger(__name__)
83# TAP endpoint of the prototype PPDB (the ``data-int`` RSP environment).
84DEFAULT_PPDB_TAP_URL = "https://data-int.lsst.cloud/api/ppdbtap"
86# Default padding (arcseconds) loaded around an exposure footprint.
87DEFAULT_PADDING_ARCSEC = 5.0
89# Default cap on rows returned by a single query, to guard against
90# accidentally pulling the whole (multi-million row) PPDB. Pass ``limit=None``
91# to disable.
92DEFAULT_ROW_LIMIT = 100000
94# Valid LSST band names; used to validate band filters before they are
95# interpolated into an ADQL string.
96_VALID_BANDS = frozenset("ugrizy")
98# The PPDB tables share the APDB SDM schema (``sdm_schemas/apdb.yaml``); its
99# column order is the canonical, scientifically-grouped order. Loaders use it
100# to reorder results, because the TAP service returns ``SELECT *`` columns
101# alphabetically (its ``tap_schema.columns`` has no ``column_index``). Both
102# helpers are memoized, so the schema file is parsed at most once.
105@functools.cache
106def _sdm_schema():
107 """Parse and return the APDB SDM schema (shared by the PPDB tables)."""
108 path = os.fspath(files("lsst.sdm.schemas").joinpath("apdb.yaml"))
109 return readSdmSchemaFile(path)
112@functools.cache
113def _sdm_column_order(table_name):
114 """Return the SDM-schema column order for a PPDB table, or ``()``.
116 Parameters
117 ----------
118 table_name : `str`
119 Unqualified table name, e.g. ``"DiaSource"``.
121 Returns
122 -------
123 columns : `tuple` [`str`]
124 Column names in schema order, or an empty tuple if the table is not
125 in the SDM schema.
126 """
127 table_def = _sdm_schema().get(table_name)
128 if table_def is None: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 return ()
130 return tuple(column.name for column in table_def.columns)
133def region_from_exposure(exposure, padding=DEFAULT_PADDING_ARCSEC):
134 """Compute a cone (ra, dec, radius) covering an exposure's sky footprint.
136 The cone is the smallest circle centered on the exposure's bounding-box
137 center that contains all four corners, expanded by ``padding``
138 arcseconds. A circumscribing circle (rather than the exact polygon) keeps
139 the resulting ADQL robust: ``CIRCLE`` is universally supported by TAP
140 services, whereas ``POLYGON`` support and winding conventions vary.
142 Parameters
143 ----------
144 exposure : `lsst.afw.image.Exposure`
145 Exposure whose WCS and bounding box define the region.
146 padding : `float`, optional
147 Margin to add around the footprint, in arcseconds.
149 Returns
150 -------
151 ra : `float`
152 Cone center right ascension, in degrees (ICRS).
153 dec : `float`
154 Cone center declination, in degrees (ICRS).
155 radius : `float`
156 Cone radius, in degrees.
158 Raises
159 ------
160 ValueError
161 If the exposure has no WCS, an empty bounding box, or negative
162 padding.
163 """
164 if padding < 0:
165 raise ValueError(f"padding must be non-negative, got {padding}")
166 wcs = exposure.getWcs()
167 if wcs is None:
168 raise ValueError("Exposure has no WCS; cannot compute a sky region.")
169 bbox = exposure.getBBox()
170 if bbox.isEmpty(): 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 raise ValueError("Exposure has an empty bounding box.")
173 center = wcs.pixelToSky(bbox.getCenter())
174 corners = [wcs.pixelToSky(lsst.geom.Point2D(corner))
175 for corner in bbox.getCorners()]
176 radius = max(center.separation(corner) for corner in corners)
177 radius = radius + padding * lsst.geom.arcseconds
178 return (center.getRa().asDegrees(),
179 center.getDec().asDegrees(),
180 radius.asDegrees())
183@dataclasses.dataclass
184class DiaObjectLightCurve:
185 """The full PPDB record for a single diaObject.
187 Attributes
188 ----------
189 diaObjectId : `int`
190 Identifier of the diaObject.
191 dia_object : `astropy.table.Row`
192 The DiaObject row.
193 dia_sources : `astropy.table.Table`
194 All DiaSources associated with the object, time-ordered.
195 dia_forced_sources : `astropy.table.Table`
196 All DiaForcedSources for the object, time-ordered.
197 """
199 diaObjectId: int
200 dia_object: "astropy.table.Row"
201 dia_sources: astropy.table.Table
202 dia_forced_sources: astropy.table.Table
204 @property
205 def n_sources(self):
206 """Number of associated DiaSources (`int`)."""
207 return len(self.dia_sources)
209 @property
210 def n_forced_sources(self):
211 """Number of associated DiaForcedSources (`int`)."""
212 return len(self.dia_forced_sources)
214 def __repr__(self):
215 return (f"DiaObjectLightCurve(diaObjectId={self.diaObjectId}, "
216 f"n_sources={self.n_sources}, "
217 f"n_forced_sources={self.n_forced_sources})")
220class PpdbTap:
221 """Load PPDB catalogs through the RSP TAP service as astropy Tables.
223 Parameters
224 ----------
225 url : `str`, optional
226 TAP endpoint to query. Defaults to the prototype PPDB at ``data-int``.
227 token : `str`, optional
228 RSP token with the ``read:tap`` scope. If not given (and ``service``
229 is also not given), the ``RSP_TOKEN`` environment variable is used.
230 service : `pyvo.dal.TAPService`, optional
231 Pre-built TAP service. When supplied, ``url`` and ``token`` are
232 ignored. Intended for use with unit tests.
233 log : `logging.Logger`, optional
234 Logger to use; defaults to the module logger.
236 Notes
237 -----
238 The token is read only from the environment or the constructor argument
239 and is never logged.
240 """
242 def __init__(self, url=DEFAULT_PPDB_TAP_URL, *, token=None,
243 service=None, log=None):
244 self.url = url
245 self.log = log if log is not None else _log
246 if service is not None:
247 self._service = service
248 return
249 if token is None:
250 token = os.environ.get("RSP_TOKEN")
251 if not token:
252 raise RuntimeError(
253 "No RSP token available for the PPDB TAP service. Generate "
254 "a token with the 'read:tap' scope on data-int "
255 "(https://data-int.lsst.cloud) and set it in the RSP_TOKEN "
256 "environment variable, or pass token=...")
257 session = requests.Session()
258 session.headers["Authorization"] = f"Bearer {token}"
259 self._service = pyvo.dal.TAPService(url, session=session)
261 # ------------------------------------------------------------------
262 # Query execution
263 # ------------------------------------------------------------------
264 def run_query(self, adql, *, maxrec=None, order_table=None):
265 """Run an ADQL query and return the result as an astropy Table.
267 Parameters
268 ----------
269 adql : `str`
270 The ADQL query to execute.
271 maxrec : `int`, optional
272 Server-side cap on the number of records returned.
273 order_table : `str`, optional
274 If given (e.g. ``"DiaSource"``), reorder the result columns into
275 that table's SDM-schema order. The loaders pass this so that
276 ``SELECT *`` results, which the TAP service returns alphabetically,
277 come back in the schema's logical column order. Columns not in the
278 schema are appended in their original order.
280 Returns
281 -------
282 table : `astropy.table.Table`
283 The query result.
284 """
285 self.log.debug("Executing ADQL query: %s", adql)
286 start = time.perf_counter()
287 results = self._service.run_async(adql, maxrec=maxrec)
288 table = results.to_table()
289 if order_table is not None:
290 table = self._order_columns(table, order_table)
291 elapsed = time.perf_counter() - start
292 self.log.info("Query returned %d rows in %.3f s", len(table), elapsed)
293 return table
295 @staticmethod
296 def _order_columns(table, table_name):
297 """Reorder a result table's columns into SDM-schema order.
299 Every result column is expected to be defined in the SDM schema for
300 ``table_name`` (the PPDB does not add columns outside it), so columns
301 are returned in schema order. Returns the table unchanged if the
302 schema is unknown or the order already matches.
304 Raises
305 ------
306 RuntimeError
307 If the result contains a column not present in the SDM schema,
308 which signals an unexpected schema mismatch.
309 """
310 schema_order = _sdm_column_order(table_name)
311 if not schema_order: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 return table
313 schema_set = set(schema_order)
314 unknown = [c for c in table.colnames if c not in schema_set]
315 if unknown:
316 raise RuntimeError(
317 f"ppdb.{table_name} returned columns not in the SDM schema: "
318 f"{unknown}")
319 new_order = [c for c in schema_order if c in table.colnames]
320 if new_order == table.colnames:
321 return table
322 return table[new_order]
324 # ------------------------------------------------------------------
325 # ADQL construction helpers
326 # ------------------------------------------------------------------
327 @staticmethod
328 def _select_clause(columns, limit):
329 """Build the ``SELECT [TOP n] cols`` prefix of a query."""
330 cols = "*" if not columns else ", ".join(columns)
331 top = f"TOP {int(limit)} " if limit is not None else ""
332 return f"SELECT {top}{cols}"
334 @staticmethod
335 def _cone_clause(ra, dec, radius):
336 """Build an ADQL cone-search predicate (all angles in degrees)."""
337 return (f"CONTAINS(POINT('ICRS', ra, dec), "
338 f"CIRCLE('ICRS', {float(ra)}, {float(dec)}, "
339 f"{float(radius)})) = 1")
341 def _resolve_cone(self, ra, dec, radius, exposure, padding):
342 """Resolve spatial arguments into a (ra, dec, radius) cone or None.
344 Either an ``exposure`` or an explicit ``ra``/``dec``/``radius`` triple
345 may be given, but not both. Returns None when no spatial constraint
346 was requested. Explicit ``radius`` is in degrees; ``padding`` (used
347 only with ``exposure``) is in arcseconds.
348 """
349 if exposure is not None:
350 if any(v is not None for v in (ra, dec, radius)):
351 raise ValueError("Specify either exposure= or ra/dec/radius, "
352 "not both.")
353 return region_from_exposure(exposure, padding=padding)
354 if ra is None and dec is None and radius is None:
355 return None
356 if None in (ra, dec, radius):
357 raise ValueError("ra, dec, and radius must all be provided for a "
358 "cone search.")
359 return float(ra), float(dec), float(radius)
361 @staticmethod
362 def _band_clause(bands):
363 """Build an ADQL ``band IN (...)`` predicate, validating each band."""
364 quoted = []
365 for band in bands:
366 if band not in _VALID_BANDS:
367 raise ValueError(f"Unknown band {band!r}; expected one of "
368 f"{sorted(_VALID_BANDS)}.")
369 quoted.append(f"'{band}'")
370 return f"band IN ({', '.join(quoted)})"
372 def _finish(self, table, limit, table_name):
373 """Warn on probable truncation and return the table unchanged."""
374 if limit is not None and len(table) == limit:
375 self.log.warning(
376 "%s query returned exactly the row limit (%d); results are "
377 "likely truncated. Narrow the query or raise limit.",
378 table_name, limit)
379 return table
381 # ------------------------------------------------------------------
382 # DiaObject loaders
383 # ------------------------------------------------------------------
384 def load_objects(self, *, ra=None, dec=None, radius=None, exposure=None,
385 padding=DEFAULT_PADDING_ARCSEC, columns=None,
386 limit=DEFAULT_ROW_LIMIT):
387 """Load DiaObjects, optionally within a spatial region.
389 Parameters
390 ----------
391 ra, dec, radius : `float`, optional
392 Cone-search center and radius, in degrees. All three must be
393 given together; mutually exclusive with ``exposure``.
394 exposure : `lsst.afw.image.Exposure`, optional
395 Load objects covering this exposure's footprint (see
396 `region_from_exposure`). Mutually exclusive with
397 ``ra``/``dec``/``radius``.
398 padding : `float`, optional
399 Margin around the exposure footprint, in arcseconds (only used
400 with ``exposure``).
401 columns : `list` [`str`], optional
402 Columns to select; defaults to all columns.
403 limit : `int`, optional
404 Maximum number of rows to return; None means no limit.
406 Returns
407 -------
408 objects : `astropy.table.Table`
409 The matching DiaObjects.
410 """
411 cone = self._resolve_cone(ra, dec, radius, exposure, padding)
412 where = f" WHERE {self._cone_clause(*cone)}" if cone is not None else ""
413 adql = (f"{self._select_clause(columns, limit)} FROM ppdb.DiaObject"
414 f"{where} ORDER BY diaObjectId")
415 order_table = "DiaObject" if columns is None else None
416 table = self._finish(self.run_query(adql, order_table=order_table),
417 limit, "DiaObject")
418 if len(table) and "diaObjectId" in table.colnames:
419 n_unique = len(set(table["diaObjectId"].tolist()))
420 if n_unique != len(table):
421 self.log.warning(
422 "DiaObject query returned %d rows but only %d unique "
423 "diaObjectIds; the PPDB may contain duplicate current "
424 "versions.",
425 len(table), n_unique)
426 return table
428 def load_object(self, diaObjectId, *, columns=None):
429 """Load a single DiaObject.
431 Parameters
432 ----------
433 diaObjectId : `int`
434 Identifier of the object to load.
435 columns : `list` [`str`], optional
436 Columns to select; defaults to all columns.
438 Returns
439 -------
440 object : `astropy.table.Row`
441 The current DiaObject row.
443 Raises
444 ------
445 RuntimeError
446 If the object is not in the PPDB.
447 """
448 adql = (f"{self._select_clause(columns, None)} FROM ppdb.DiaObject "
449 f"WHERE diaObjectId = {int(diaObjectId)}")
450 order_table = "DiaObject" if columns is None else None
451 table = self.run_query(adql, order_table=order_table)
452 if len(table) == 0:
453 raise RuntimeError(
454 f"diaObjectId={diaObjectId} not found in ppdb.DiaObject")
455 if len(table) > 1: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 self.log.warning(
457 "diaObjectId=%s has %d rows; returning the first.",
458 diaObjectId, len(table))
459 return table[0]
461 # ------------------------------------------------------------------
462 # DiaSource / DiaForcedSource loaders
463 #
464 # The production PPDB will permit cone searches only on DiaObject. The
465 # encouraged way to retrieve sources for a region is therefore object-
466 # first: cone-search DiaObjects (`load_*_for_region`), then load their
467 # sources by diaObjectId. `load_sources`/`load_forced_sources` require a
468 # diaObjectId and take no spatial argument. Direct, object-unrestricted
469 # source-table access is confined to the explicit, prototype-only
470 # `load_*_by_cone` and `load_*_by_time_window` methods.
471 # ------------------------------------------------------------------
472 def _source_filter_clauses(self, bands, mjd_begin, mjd_end):
473 """Build the time/band filter clauses shared by the source loaders."""
474 clauses = []
475 if mjd_begin is not None:
476 clauses.append(f"midpointMjdTai >= {float(mjd_begin)}")
477 if mjd_end is not None:
478 clauses.append(f"midpointMjdTai < {float(mjd_end)}")
479 if bands:
480 clauses.append(self._band_clause(bands))
481 return clauses
483 def _run_source_query(self, table_name, clauses, columns, limit):
484 """Run a single time-ordered SELECT against a source table."""
485 where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
486 adql = (f"{self._select_clause(columns, limit)} FROM ppdb.{table_name}"
487 f"{where} ORDER BY midpointMjdTai")
488 order_table = table_name if columns is None else None
489 return self._finish(self.run_query(adql, order_table=order_table),
490 limit, table_name)
492 def _load_source_like(self, table_name, diaObjectId, extra_clauses,
493 columns, limit, id_chunk_size):
494 """Dispatch a source load by one id or many ids.
496 ``diaObjectId`` is required: source tables are loaded by object. An
497 iterable (including the empty list a region search may produce) takes
498 the chunked ``IN`` path; a scalar takes a single ``=`` query.
499 """
500 if diaObjectId is None:
501 raise ValueError(
502 "diaObjectId is required; DiaSources and DiaForcedSources are "
503 "loaded by object. Use load_*_for_region for a spatial "
504 "region, or the prototype-only load_*_by_cone / "
505 "load_*_by_time_window methods for direct source-table access.")
506 if isinstance(diaObjectId, numbers.Integral):
507 clauses = [f"diaObjectId = {int(diaObjectId)}"] + list(extra_clauses)
508 return self._run_source_query(table_name, clauses, columns, limit)
509 return self._load_sources_for_ids(table_name, diaObjectId,
510 extra_clauses, columns, limit,
511 id_chunk_size)
513 def _load_sources_for_ids(self, table_name, ids, extra_clauses, columns,
514 limit, id_chunk_size):
515 """Load sources for many diaObjectIds via chunked ``IN`` queries.
517 A cone search can return more diaObjectIds than fit in a single
518 ``diaObjectId IN (...)`` clause, so the ids are split into chunks
519 of ``id_chunk_size`` and the per-chunk results are concatenated and
520 re-sorted by time.
521 ``limit`` caps the total rows returned across all chunks.
522 """
523 if id_chunk_size < 1: 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true
524 raise ValueError("id_chunk_size must be >= 1")
525 ids = list(dict.fromkeys(int(i) for i in ids))
526 if not ids or (limit is not None and limit <= 0):
527 # Empty result, but issue a query so columns/dtypes are populated.
528 return self._run_source_query(table_name, ["1 = 0"], columns, None)
530 order_table = table_name if columns is None else None
531 pages = []
532 total = 0
533 truncated = False
534 for start in range(0, len(ids), id_chunk_size):
535 if limit is not None and total >= limit: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 truncated = True
537 break
538 chunk = ids[start:start + id_chunk_size]
539 remaining = None if limit is None else limit - total
540 id_list = ", ".join(str(i) for i in chunk)
541 clauses = [f"diaObjectId IN ({id_list})"] + list(extra_clauses)
542 where = " WHERE " + " AND ".join(clauses)
543 adql = (f"{self._select_clause(columns, remaining)} "
544 f"FROM ppdb.{table_name}{where} ORDER BY midpointMjdTai")
545 page = self.run_query(adql, order_table=order_table)
546 total += len(page)
547 pages.append(page)
549 table = (pages[0] if len(pages) == 1
550 else astropy.table.vstack(pages, metadata_conflicts="silent"))
551 if len(table) and "midpointMjdTai" in table.colnames: 551 ↛ 553line 551 didn't jump to line 553 because the condition on line 551 was always true
552 table.sort("midpointMjdTai")
553 if limit is not None and (truncated or total == limit): 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true
554 self.log.warning(
555 "%s query reached the row limit (%d) while loading sources for "
556 "%d objects; results are likely truncated. Raise limit or "
557 "narrow the query.", table_name, limit, len(ids))
558 return table
560 def _direct_source_query(self, table_name, region_method, kind, clauses,
561 columns, limit):
562 """Warn, then run a prototype-only direct source-table query.
564 ``load_sources``/``load_forced_sources`` require a diaObjectId; these
565 direct (object-unrestricted) source-table searches will not exist in
566 the production PPDB, so callers reach them only through the explicit
567 ``load_*_by_cone`` / ``load_*_by_time_window`` methods, which route
568 here and emit this warning.
569 """
570 self.log.warning(
571 "Direct %s %s search is a prototype-only capability and will not "
572 "be available in the production PPDB; prefer %s().",
573 table_name, kind, region_method)
574 return self._run_source_query(table_name, clauses, columns, limit)
576 def _load_by_cone(self, table_name, region_method, *, ra, dec, radius,
577 exposure, padding, bands, mjd_begin, mjd_end, columns,
578 limit):
579 """Direct cone search on a source table (prototype-only)."""
580 cone = self._resolve_cone(ra, dec, radius, exposure, padding)
581 if cone is None:
582 raise ValueError("A cone search requires ra/dec/radius or an "
583 "exposure.")
584 clauses = [self._cone_clause(*cone)]
585 clauses += self._source_filter_clauses(bands, mjd_begin, mjd_end)
586 return self._direct_source_query(table_name, region_method, "cone",
587 clauses, columns, limit)
589 def _load_by_time_window(self, table_name, region_method, *, mjd_begin,
590 mjd_end, bands, columns, limit):
591 """Direct time-window scan of a source table (prototype-only)."""
592 if mjd_begin is None and mjd_end is None:
593 raise ValueError("A time-window search requires mjd_begin and/or "
594 "mjd_end.")
595 clauses = self._source_filter_clauses(bands, mjd_begin, mjd_end)
596 return self._direct_source_query(table_name, region_method,
597 "time-window", clauses, columns,
598 limit)
600 def load_sources(self, *, diaObjectId, mjd_begin=None, mjd_end=None,
601 bands=None, columns=None, limit=DEFAULT_ROW_LIMIT,
602 id_chunk_size=1000):
603 """Load DiaSources for a given object (or objects), by time and band.
605 DiaSources are loaded by object: ``diaObjectId`` is required. This
606 loader has no spatial argument, because the production PPDB does not
607 permit searches on the source tables. To load sources for a region,
608 use `load_sources_for_region` (object-first); for direct, object-
609 unrestricted prototype-only access, use `load_sources_by_cone` or
610 `load_sources_by_time_window`.
612 Parameters
613 ----------
614 diaObjectId : `int` or iterable of `int`
615 Restrict to sources of this object, or of any of these objects
616 (issued as chunked ``diaObjectId IN (...)`` queries). Required.
617 mjd_begin, mjd_end : `float`, optional
618 Restrict to ``mjd_begin <= midpointMjdTai < mjd_end`` (TAI).
619 bands : `list` [`str`], optional
620 Restrict to these bands (subset of ``ugrizy``).
621 columns : `list` [`str`], optional
622 Columns to select; defaults to all columns.
623 limit : `int`, optional
624 Maximum number of rows to return; None means no limit.
625 id_chunk_size : `int`, optional
626 Number of diaObjectIds per ``IN`` query when an iterable is given.
628 Returns
629 -------
630 sources : `astropy.table.Table`
631 The matching DiaSources, ordered by ``midpointMjdTai``.
633 Raises
634 ------
635 ValueError
636 If ``diaObjectId`` is None.
637 """
638 extra = self._source_filter_clauses(bands, mjd_begin, mjd_end)
639 return self._load_source_like("DiaSource", diaObjectId, extra,
640 columns, limit, id_chunk_size)
642 def load_forced_sources(self, *, diaObjectId, mjd_begin=None,
643 mjd_end=None, bands=None, columns=None,
644 limit=DEFAULT_ROW_LIMIT, id_chunk_size=1000):
645 """Load DiaForcedSources, with the same filters as `load_sources`.
647 Like `load_sources`, ``diaObjectId`` is required and there is no
648 spatial argument; use `load_forced_sources_for_region`, or the
649 prototype-only `load_forced_sources_by_cone` /
650 `load_forced_sources_by_time_window`.
652 Returns
653 -------
654 forced_sources : `astropy.table.Table`
655 The matching DiaForcedSources, ordered by ``midpointMjdTai``.
657 Raises
658 ------
659 ValueError
660 If ``diaObjectId`` is None.
661 """
662 extra = self._source_filter_clauses(bands, mjd_begin, mjd_end)
663 return self._load_source_like("DiaForcedSource", diaObjectId, extra,
664 columns, limit, id_chunk_size)
666 def load_sources_for_region(self, *, ra=None, dec=None, radius=None,
667 exposure=None, padding=DEFAULT_PADDING_ARCSEC,
668 mjd_begin=None, mjd_end=None, bands=None,
669 columns=None, limit=DEFAULT_ROW_LIMIT,
670 object_limit=DEFAULT_ROW_LIMIT,
671 id_chunk_size=1000):
672 """Load DiaSources for a region, the production-compatible way.
674 Performs the two-step workflow the production PPDB requires: cone-
675 search DiaObjects (the only table that allows spatial search), then
676 load the DiaSources of those objects.
678 Parameters
679 ----------
680 ra, dec, radius : `float`, optional
681 Cone center and radius, in degrees; mutually exclusive with
682 ``exposure``. The radius is applied to *object* positions.
683 exposure : `lsst.afw.image.Exposure`, optional
684 Load sources for objects covering this exposure's footprint.
685 padding : `float`, optional
686 Margin around the exposure footprint, in arcseconds.
687 mjd_begin, mjd_end, bands, columns : optional
688 Passed through to the source query (see `load_sources`).
689 limit : `int`, optional
690 Cap on returned sources; None means no limit.
691 object_limit : `int`, optional
692 Cap on the DiaObjects found by the cone search.
693 id_chunk_size : `int`, optional
694 diaObjectIds per ``IN`` query when fetching sources.
696 Returns
697 -------
698 sources : `astropy.table.Table`
699 DiaSources of the objects in the region, ordered by time.
701 Notes
702 -----
703 Because this is object-first, DiaSources not associated with a
704 returned DiaObject (e.g. unassociated or ssObject-only detections)
705 are not included. To find those, use `load_sources_by_cone`.
706 """
707 ids = self._region_object_ids(
708 ra, dec, radius, exposure, padding, object_limit)
709 return self.load_sources(diaObjectId=ids, mjd_begin=mjd_begin,
710 mjd_end=mjd_end, bands=bands, columns=columns,
711 limit=limit, id_chunk_size=id_chunk_size)
713 def load_forced_sources_for_region(self, *, ra=None, dec=None,
714 radius=None, exposure=None,
715 padding=DEFAULT_PADDING_ARCSEC,
716 mjd_begin=None, mjd_end=None, bands=None,
717 columns=None, limit=DEFAULT_ROW_LIMIT,
718 object_limit=DEFAULT_ROW_LIMIT,
719 id_chunk_size=1000):
720 """Load DiaForcedSources for a region (object-first).
722 Same two-step workflow and caveats as `load_sources_for_region`.
724 Returns
725 -------
726 forced_sources : `astropy.table.Table`
727 DiaForcedSources of the objects in the region, ordered by time.
728 """
729 ids = self._region_object_ids(
730 ra, dec, radius, exposure, padding, object_limit)
731 return self.load_forced_sources(
732 diaObjectId=ids, mjd_begin=mjd_begin, mjd_end=mjd_end, bands=bands,
733 columns=columns, limit=limit, id_chunk_size=id_chunk_size)
735 def _region_object_ids(self, ra, dec, radius, exposure, padding,
736 object_limit):
737 """Cone-search DiaObjects and return their diaObjectIds."""
738 objects = self.load_objects(ra=ra, dec=dec, radius=radius,
739 exposure=exposure, padding=padding,
740 columns=["diaObjectId"],
741 limit=object_limit)
742 if not len(objects):
743 return []
744 return objects["diaObjectId"].tolist()
746 def load_sources_by_cone(self, *, ra=None, dec=None, radius=None,
747 exposure=None, padding=DEFAULT_PADDING_ARCSEC,
748 mjd_begin=None, mjd_end=None, bands=None,
749 columns=None, limit=DEFAULT_ROW_LIMIT):
750 """Directly cone-search the DiaSource table (prototype-only).
752 .. warning::
754 The production PPDB will not allow spatial searches on the source
755 tables. This works only against the prototype and is intended for
756 debugging/validation (e.g. finding DiaSources with no associated
757 DiaObject). For the production-compatible region workflow use
758 `load_sources_for_region`.
760 Parameters mirror `load_sources_for_region` minus ``object_limit``.
762 Returns
763 -------
764 sources : `astropy.table.Table`
765 DiaSources within the cone, ordered by ``midpointMjdTai``.
766 """
767 return self._load_by_cone(
768 "DiaSource", "load_sources_for_region", ra=ra, dec=dec,
769 radius=radius, exposure=exposure, padding=padding, bands=bands,
770 mjd_begin=mjd_begin, mjd_end=mjd_end, columns=columns, limit=limit)
772 def load_forced_sources_by_cone(self, *, ra=None, dec=None, radius=None,
773 exposure=None,
774 padding=DEFAULT_PADDING_ARCSEC,
775 mjd_begin=None, mjd_end=None, bands=None,
776 columns=None, limit=DEFAULT_ROW_LIMIT):
777 """Directly cone-search the DiaForcedSource table (prototype-only).
779 .. warning::
781 Not supported by the production PPDB; prototype/debugging only.
782 Prefer `load_forced_sources_for_region`.
784 Returns
785 -------
786 forced_sources : `astropy.table.Table`
787 DiaForcedSources within the cone, ordered by ``midpointMjdTai``.
788 """
789 return self._load_by_cone(
790 "DiaForcedSource", "load_forced_sources_for_region", ra=ra,
791 dec=dec, radius=radius, exposure=exposure, padding=padding,
792 bands=bands, mjd_begin=mjd_begin, mjd_end=mjd_end, columns=columns,
793 limit=limit)
795 def load_sources_by_time_window(self, *, mjd_begin=None, mjd_end=None,
796 bands=None, columns=None,
797 limit=DEFAULT_ROW_LIMIT):
798 """Load DiaSources in a time window, any object (prototype-only).
800 .. warning::
802 The production PPDB will not allow object-unrestricted searches on
803 the source tables. This works only against the prototype and is
804 intended for debugging (e.g. inspecting everything processed in an
805 MJD range). For the production-compatible workflow use
806 `load_sources_for_region`.
808 Parameters
809 ----------
810 mjd_begin, mjd_end : `float`, optional
811 Time window on ``midpointMjdTai`` (TAI), as
812 ``mjd_begin <= midpointMjdTai < mjd_end``. At least one bound is
813 required (an unbounded source-table scan is not permitted).
814 bands : `list` [`str`], optional
815 Restrict to these bands (subset of ``ugrizy``).
816 columns : `list` [`str`], optional
817 Columns to select; defaults to all columns.
818 limit : `int`, optional
819 Maximum number of rows to return; None means no limit.
821 Returns
822 -------
823 sources : `astropy.table.Table`
824 DiaSources in the window, ordered by ``midpointMjdTai``.
826 Raises
827 ------
828 ValueError
829 If neither ``mjd_begin`` nor ``mjd_end`` is given.
830 """
831 return self._load_by_time_window(
832 "DiaSource", "load_sources_for_region", mjd_begin=mjd_begin,
833 mjd_end=mjd_end, bands=bands, columns=columns, limit=limit)
835 def load_forced_sources_by_time_window(self, *, mjd_begin=None,
836 mjd_end=None, bands=None,
837 columns=None,
838 limit=DEFAULT_ROW_LIMIT):
839 """Load DiaForcedSources in a time window, any object (prototype-only).
841 .. warning::
843 Not supported by the production PPDB; prototype/debugging only.
844 Prefer `load_forced_sources_for_region`. At least one of
845 ``mjd_begin`` / ``mjd_end`` is required.
847 Returns
848 -------
849 forced_sources : `astropy.table.Table`
850 DiaForcedSources in the window, ordered by ``midpointMjdTai``.
852 Raises
853 ------
854 ValueError
855 If neither ``mjd_begin`` nor ``mjd_end`` is given.
856 """
857 return self._load_by_time_window(
858 "DiaForcedSource", "load_forced_sources_for_region",
859 mjd_begin=mjd_begin, mjd_end=mjd_end, bands=bands, columns=columns,
860 limit=limit)
862 def load_sources_for_object(self, diaObjectId, *, columns=None,
863 limit=DEFAULT_ROW_LIMIT):
864 """Load all DiaSources associated with one diaObject.
866 Returns
867 -------
868 sources : `astropy.table.Table`
869 The object's DiaSources, ordered by ``midpointMjdTai``.
870 """
871 return self.load_sources(diaObjectId=diaObjectId, columns=columns,
872 limit=limit)
874 def load_forced_sources_for_object(self, diaObjectId, *, columns=None,
875 limit=DEFAULT_ROW_LIMIT):
876 """Load all DiaForcedSources associated with one diaObject.
878 Returns
879 -------
880 forced_sources : `astropy.table.Table`
881 The object's DiaForcedSources, ordered by ``midpointMjdTai``.
882 """
883 return self.load_forced_sources(diaObjectId=diaObjectId,
884 columns=columns, limit=limit)
886 def load_source(self, diaSourceId, *, columns=None):
887 """Load a single DiaSource by id.
889 Raises
890 ------
891 RuntimeError
892 If the source does not exist.
893 """
894 return self._load_one("DiaSource", "diaSourceId", diaSourceId, columns)
896 def load_forced_source(self, diaForcedSourceId, *, columns=None):
897 """Load a single DiaForcedSource by id.
899 Raises
900 ------
901 RuntimeError
902 If the forced source does not exist.
903 """
904 return self._load_one("DiaForcedSource", "diaForcedSourceId",
905 diaForcedSourceId, columns)
907 def _load_one(self, table_name, id_column, id_value, columns):
908 """Load exactly one row from a table by id, raising if absent."""
909 adql = (f"{self._select_clause(columns, None)} FROM ppdb.{table_name} "
910 f"WHERE {id_column} = {int(id_value)}")
911 order_table = table_name if columns is None else None
912 table = self.run_query(adql, order_table=order_table)
913 if len(table) == 0:
914 raise RuntimeError(
915 f"{id_column}={id_value} not found in ppdb.{table_name}")
916 return table[0]
918 # ------------------------------------------------------------------
919 # Light-curve assembly
920 # ------------------------------------------------------------------
921 def load_light_curve(self, diaObjectId):
922 """Assemble the full PPDB record for one diaObject.
924 Loads the DiaObject plus all of its DiaSources and
925 DiaForcedSources, time-ordered.
927 Parameters
928 ----------
929 diaObjectId : `int`
930 Identifier of the object.
932 Returns
933 -------
934 light_curve : `DiaObjectLightCurve`
935 The object and its associated source time series.
937 Raises
938 ------
939 RuntimeError
940 If the object is not in the PPDB.
941 """
942 dia_object = self.load_object(diaObjectId)
943 dia_sources = self.load_sources_for_object(diaObjectId)
944 dia_forced_sources = self.load_forced_sources_for_object(diaObjectId)
945 return DiaObjectLightCurve(
946 diaObjectId=int(diaObjectId),
947 dia_object=dia_object,
948 dia_sources=dia_sources,
949 dia_forced_sources=dia_forced_sources)