Source code for uvex_transients.simulation.photometry_catalog

"""
Synthetic photometry of a full event catalog, produced by `EventCatalog.compute_photometry_catalog`.

`PhotometryCatalog` is `EventCatalog`'s per-observation counterpart: rather than one row
per sampled event, it holds one row per ``(event, observation, band)`` synthetic
measurement -- exactly `Event.simulate_photometry`'s own table schema, `vstack`'d across
every event in a catalog (see `EventCatalog.simulate_photometry`). Like `EventCatalog` and
`ExposureCatalog`, it holds no live reference back to the schedule, transient instances, or
event catalog it was computed against, so it round-trips to/from disk and pickles cleanly.
"""

from dataclasses import dataclass
from pathlib import Path

import numpy as np
from astropy.table import QTable
from astropy.time import Time
from astropy.units import Quantity

from uvex_transients.utils import logger

from ..transients.base import ExtragalacticTransient
from ._stats import clopper_pearson_interval
from .exposure_catalog import ExposureCatalog


[docs] @dataclass class PhotometryCatalog: """ Synthetic photometry for a whole `~uvex_transients.simulation.event_catalog.EventCatalog`. Produced by :meth:`~uvex_transients.simulation.event_catalog.EventCatalog.compute_photometry_catalog`. """ table: QTable """QTable: One row per ``(event, observation, band)``. Columns are exactly `~uvex_transients.simulation.event.Event.simulate_photometry`'s own schema: ``event_id``, ``obs_time``, ``exptime``, ``band``, ``snr``, ``flux``/``flux_err`` (Jy), ``flux_upper``/``flux_lower`` (Jy), ``ab_mag``/``mag_err``, and ``mag_upper``/``mag_lower``. """ # ----------------------------------------- # # Dunder Methods # # ----------------------------------------- # def __len__(self) -> int: return len(self.table) # ----------------------------------------- # # Array Export # # ----------------------------------------- #
[docs] def column(self, name: str) -> np.ndarray: """ Return a table column as a plain array, rather than an `astropy.table.Column`/`Quantity` view. Parameters ---------- name : str Column name; must be one of `table.colnames`. Returns ------- numpy.ndarray or ~astropy.units.Quantity or ~astropy.time.Time `numpy.asarray` of the column, preserving units/time if the column is a `Quantity`/`Time` column. """ if name not in self.table.colnames: raise KeyError(f"No column {name!r} in this catalog; available: {self.table.colnames}.") col = self.table[name] return col if isinstance(col, (Quantity, Time)) else np.asarray(col)
@property def event_id(self) -> np.ndarray: """numpy.ndarray: Each row's event id.""" return self.column("event_id") @property def obs_time(self) -> Time: """~astropy.time.Time: Each row's observation start time.""" return self.column("obs_time") @property def exptime(self) -> Quantity: """~astropy.units.Quantity: Each row's exposure duration.""" return self.column("exptime") @property def band(self) -> np.ndarray: """numpy.ndarray of str: Each row's bandpass name.""" return np.asarray(self.table["band"]).astype(str) @property def snr(self) -> np.ndarray: """numpy.ndarray: Each row's signal-to-noise ratio.""" return self.column("snr") @property def flux(self) -> Quantity: """~astropy.units.Quantity: Each row's noisy flux realization.""" return self.column("flux") @property def flux_err(self) -> Quantity: """~astropy.units.Quantity: Each row's flux uncertainty.""" return self.column("flux_err") @property def flux_upper(self) -> Quantity: """~astropy.units.Quantity: Each row's bright-side flux bound.""" return self.column("flux_upper") @property def flux_lower(self) -> Quantity: """~astropy.units.Quantity: Each row's faint-side flux bound.""" return self.column("flux_lower") @property def ab_mag(self) -> np.ndarray: """numpy.ndarray: Each row's noisy AB magnitude realization.""" return self.column("ab_mag") @property def mag_err(self) -> np.ndarray: """numpy.ndarray: Each row's linearized magnitude uncertainty.""" return self.column("mag_err") @property def mag_upper(self) -> np.ndarray: """numpy.ndarray: Each row's faint-side magnitude bound (from `flux_lower`).""" return self.column("mag_upper") @property def mag_lower(self) -> np.ndarray: """numpy.ndarray: Each row's bright-side magnitude bound (from `flux_upper`).""" return self.column("mag_lower") # ----------------------------------------- # # Row Access # # ----------------------------------------- #
[docs] def get_event(self, event_id: int) -> QTable: """ Return this catalog's photometry rows for a single event. Just a mask on `table`'s own ``event_id`` column -- unlike `~uvex_transients.simulation.event_catalog.EventCatalog.get_events`, this doesn't reconstruct an `~uvex_transients.simulation.event.Event` (this catalog holds neither the transient instances nor the schedule that would take); it hands back the raw synthetic-photometry rows already computed for that event. Parameters ---------- event_id : int The event id to select, as it appears in `table`'s own ``event_id`` column. Returns ------- astropy.table.QTable This event's own rows, in `table`'s existing order (one row per ``(observation, band)``). Raises ------ KeyError If `event_id` isn't present in this catalog. """ mask = np.asarray(self.table["event_id"]) == event_id if not np.any(mask): raise KeyError(f"No event with id {event_id!r} in this catalog.") return self.table[mask]
# ----------------------------------------- # # Detection-Count Statistics # # ----------------------------------------- #
[docs] def compute_detection_count_table( self, event_catalog, exposure: ExposureCatalog, transients: dict[str, ExtragalacticTransient], snr_threshold: float, confidence: float = 0.9, ) -> QTable: r""" Per-transient-type estimator of how many events would show :math:`N_{\rm det}\ge k` detected epochs. A "detection" here is per-epoch, not per-band: an event's row at a given ``obs_time`` counts as one detected epoch if *any* of its bands has ``snr > snr_threshold`` there -- the same one-band-suffices convention `~uvex_transients.simulation.core.SurveySimulator.filter_by_snr` uses to collapse bands before counting visits. Each event's own count of detected epochs, :math:`N_{\rm det}`, is histogrammed within each transient type, exactly as `~uvex_transients.simulation.event_catalog.EventCatalog.compute_detection_efficiency` histograms its own single ``k`` (its detected/undetected split is this table's :math:`N_{\rm det}\ge 1` row). Every event in `event_catalog` is accounted for, including one with zero rows in this catalog at all (e.g. a survey never observed it) -- it contributes :math:`N_{\rm det}=0`, so `n_total` always equals `event_catalog`'s own per-type row count, not just however many events happen to appear in this catalog's table. This is the full :ref:`yield-statistics` treatment, one threshold :math:`k` at a time, not just a raw Monte Carlo histogram: `fraction` :math:`=n_{\ge k}/n` is `~uvex_transients.simulation.event_catalog.EventCatalog.compute_detection_efficiency`'s own :math:`\hat\epsilon`, generalized from "detected at all" to "detected in :math:`\ge k` epochs", with the same Clopper-Pearson bounds (`~uvex_transients.simulation._stats.clopper_pearson_interval`). `expected_events` :math:`=\mu_0\hat\epsilon` is `~uvex_transients.simulation.event_catalog.EventCatalog.compute_yield_summary`'s own ``expected_detections`` estimator, generalized the same way -- :math:`\mu_0` (`exposure`'s `~uvex_transients.simulation.exposure_catalog.ExposureCatalog.total_expected_events`) is the intrinsic, footprint-aware expected event count, independent of `event_catalog`'s own (possibly downsampled) size, so `expected_events` is a real expected number of UVEX events -- not an artifact of how many Monte Carlo draws happened to be sampled or how heavily `event_catalog` was downsampled. Both `expected_events` uncertainty sources are kept separate exactly as `compute_yield_summary` keeps them: ``..._binom_lower``/``_upper`` (Clopper-Pearson, propagated through :math:`\mu_0\hat\epsilon`) and ``..._rate_lower``/ ``_upper`` (`~uvex_transients.transients.base.ExtragalacticTransient.RATE_CI`, holding :math:`\hat\epsilon` fixed). Parameters ---------- event_catalog : ~uvex_transients.simulation.event_catalog.EventCatalog Supplies the full per-type event list this catalog's photometry was computed over (via its own ``event_id``/``transient_type`` columns), so that events with zero qualifying epochs are still represented at :math:`N_{\rm det}=0`, and `n_total`/`n` for the Clopper-Pearson bounds. exposure : ExposureCatalog Supplies :math:`\mu_0`, via `~uvex_transients.simulation.exposure_catalog.ExposureCatalog.total_expected_events`. transients : dict[str, ExtragalacticTransient] Transient-type instances, keyed the same way as `event_catalog.transient_type` and `exposure.transient_type`; supplies each type's `RATE_CI`. snr_threshold : float An epoch counts as detected if at least one band's ``snr`` exceeds this value. confidence : float, optional Confidence level for the Clopper-Pearson binomial bounds. The default is ``0.9``. Returns ------- astropy.table.QTable One row per ``(transient_type, n_detections)`` pair present for that type (``n_detections`` running ``0..max`` for each type), with columns ``transient_type``, ``n_detections`` (:math:`N_{\rm det}=k`), ``n_events`` (number of that type's events with exactly :math:`k` detected epochs), ``n_total`` (that type's total event count, repeated on every row), ``n_at_least`` (number of that type's events with :math:`N_{\rm det}\ge k`, i.e. the reverse cumulative sum of `n_events`), ``fraction``/``fraction_lower``/``fraction_upper`` (:math:`\hat\epsilon` and its Clopper-Pearson bounds), and ``expected_events``/``expected_events_binom_lower``/ ``expected_events_binom_upper``/``expected_events_rate_lower``/``expected_events_rate_upper`` (:math:`\mu_0\hat\epsilon` and its two uncertainty sources). ``n_at_least``/``fraction`` at ``n_detections == 1`` reproduce `~uvex_transients.simulation.event_catalog.EventCatalog.compute_detection_efficiency`'s own ``k``/``efficiency`` for the same `snr_threshold` and ``n_visits=1``. Raises ------ KeyError If `exposure` has no tabulated exposure, or `transients` no instance, for a transient type present in `event_catalog`. """ all_ids = event_catalog.event_id all_types = event_catalog.transient_type names = sorted(np.unique(all_types)) missing_exposure = [name for name in names if name not in exposure.total_expected_events] if missing_exposure: raise KeyError( f"No exposure tabulated for transient type(s) {missing_exposure}; " f"available: {sorted(exposure.total_expected_events)}." ) missing_transients = [name for name in names if name not in transients] if missing_transients: raise KeyError(f"No transient instance for type(s) {missing_transients}; available: {sorted(transients)}.") n_detections = np.zeros(len(all_ids), dtype=np.int64) id_to_index = {int(eid): i for i, eid in enumerate(all_ids)} phot = self.table if len(phot) > 0: event_ids = np.asarray(phot["event_id"], dtype=np.int64) obs_jd = np.asarray(phot["obs_time"].jd, dtype=np.float64) detected = np.asarray(phot["snr"]) > snr_threshold # Collapse bands: an (event, obs_time) "epoch" is detected if any of its bands are. visit_key = np.stack([event_ids.astype(np.float64), obs_jd], axis=1) _, visit_index, visit_inverse = np.unique(visit_key, axis=0, return_index=True, return_inverse=True) visit_detected = np.zeros(len(visit_index), dtype=bool) np.logical_or.at(visit_detected, visit_inverse, detected) detected_event_ids = event_ids[visit_index][visit_detected] uniq_eids, counts = np.unique(detected_event_ids, return_counts=True) for eid, count in zip(uniq_eids, counts): idx = id_to_index.get(int(eid)) if idx is not None: n_detections[idx] = count total_expected_events = exposure.total_expected_events columns = ( "transient_type", "n_detections", "n_events", "n_total", "n_at_least", "fraction", "fraction_lower", "fraction_upper", "expected_events", "expected_events_binom_lower", "expected_events_binom_upper", "expected_events_rate_lower", "expected_events_rate_upper", ) rows = {column: [] for column in columns} for name in names: transient = transients[name] lower_factor, upper_factor = transient.RATE_CI if transient.RATE_CI is not None else (1.0, 1.0) mu0 = total_expected_events[name] type_counts = n_detections[all_types == name] n_total = int(type_counts.size) max_k = int(type_counts.max()) if n_total > 0 else 0 hist = np.bincount(type_counts, minlength=max_k + 1) at_least = np.cumsum(hist[::-1])[::-1] for k in range(max_k + 1): n_at_least = int(at_least[k]) fraction_lower, fraction_upper = clopper_pearson_interval(n_at_least, n_total, confidence) fraction = (n_at_least / n_total) if n_total > 0 else np.nan expected = mu0 * fraction rows["transient_type"].append(name) rows["n_detections"].append(k) rows["n_events"].append(int(hist[k])) rows["n_total"].append(n_total) rows["n_at_least"].append(n_at_least) rows["fraction"].append(fraction) rows["fraction_lower"].append(fraction_lower) rows["fraction_upper"].append(fraction_upper) rows["expected_events"].append(expected) rows["expected_events_binom_lower"].append(mu0 * fraction_lower) rows["expected_events_binom_upper"].append(mu0 * fraction_upper) rows["expected_events_rate_lower"].append(expected * lower_factor) rows["expected_events_rate_upper"].append(expected * upper_factor) table = QTable() table["transient_type"] = np.asarray(rows["transient_type"]) table["n_detections"] = np.asarray(rows["n_detections"], dtype=np.int64) table["n_events"] = np.asarray(rows["n_events"], dtype=np.int64) table["n_total"] = np.asarray(rows["n_total"], dtype=np.int64) table["n_at_least"] = np.asarray(rows["n_at_least"], dtype=np.int64) for column in ( "fraction", "fraction_lower", "fraction_upper", "expected_events", "expected_events_binom_lower", "expected_events_binom_upper", "expected_events_rate_lower", "expected_events_rate_upper", ): table[column] = np.asarray(rows[column], dtype=np.float64) return table
# ----------------------------------------- # # IO Methods # # ----------------------------------------- #
[docs] def to_disk(self, path: str | Path, table_format: str | None = None, overwrite: bool = False) -> None: """ Write this catalog's photometry table to disk as ECSV. Parameters ---------- path : str or ~pathlib.Path Destination path. table_format : str, optional Passed through to :meth:`~astropy.table.QTable.write`; if `None`, inferred from ``path``'s suffix. overwrite : bool Whether to overwrite an existing file at ``path``. """ self.table.write(Path(path), format=table_format, overwrite=overwrite) logger.info("Wrote photometry catalog (%d rows) to %s.", len(self.table), path)
[docs] @classmethod def from_disk(cls, path: str | Path, table_format: str | None = None) -> "PhotometryCatalog": """ Read a photometry catalog back from disk, as written by :meth:`to_disk`. Parameters ---------- path : str or ~pathlib.Path Path to the photometry table, as written by :meth:`to_disk`. table_format : str, optional Passed through to :meth:`~astropy.table.QTable.read`; if `None`, inferred from ``path``'s suffix. Returns ------- PhotometryCatalog Photometry catalog reconstructed from the serialized table. """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"File not found: {path}") table = QTable.read(path, format=table_format) logger.info("Read photometry catalog (%d rows) from %s.", len(table), path) return cls(table=table)