Source code for uvex_transients.simulation.yield_table

"""
Per-transient-type yield summary, produced by `EventCatalog.compute_yield_summary`.

`YieldTable` is `EventCatalog`/`ExposureCatalog`'s summary-statistics counterpart: one
row per transient type, combining a raw (feasible) `EventCatalog`, a detected
`EventCatalog`, and an `~uvex_transients.simulation.exposure_catalog.ExposureCatalog`
into the rate/efficiency/yield estimators derived in :ref:`yield-statistics`. Like the
other two, it holds no live reference back to whatever produced it, so it round-trips
to/from disk and pickles cleanly.
"""

import math
from dataclasses import dataclass
from pathlib import Path

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

from uvex_transients.utils import logger


def _fmt(value: float, precision: int) -> str:
    """Format a plain float to `precision` significant figures, or ``"--"`` for `numpy.nan`."""
    return "--" if math.isnan(value) else f"{value:.{precision}g}"


def _value(x: Quantity | float) -> float:
    """Strip a `~astropy.units.Quantity`'s unit, or pass a plain float through unchanged."""
    return x.value if isinstance(x, Quantity) else float(x)


def _single_uncertainty_cell(
    value: Quantity | float, lower: Quantity | float, upper: Quantity | float, precision: int
) -> str:
    """Format ``value`` with one asymmetric uncertainty as ``$v^{+u}_{-l}$``."""
    v, lo, up = _value(value), _value(lower), _value(upper)
    if math.isnan(v):
        return "--"
    return f"${_fmt(v, precision)}^{{+{_fmt(up - v, precision)}}}_{{-{_fmt(v - lo, precision)}}}$"


def _double_uncertainty_tex(
    value: Quantity | float,
    binom_lower: Quantity | float,
    binom_upper: Quantity | float,
    rate_lower: Quantity | float,
    rate_upper: Quantity | float,
    precision: int,
) -> str:
    """Format ``value`` with two stacked asymmetric uncertainties as ``v^{+u_b}_{-l_b}{}^{+u_r}_{-l_r}`` (no ``$``s)."""
    v = _value(value)
    if math.isnan(v):
        return "--"
    bl, bu, rl, ru = _value(binom_lower), _value(binom_upper), _value(rate_lower), _value(rate_upper)
    return (
        f"{_fmt(v, precision)}"
        f"^{{+{_fmt(bu - v, precision)}}}_{{-{_fmt(v - bl, precision)}}}"
        f"\\,{{}}^{{+{_fmt(ru - v, precision)}}}_{{-{_fmt(v - rl, precision)}}}"
    )


def _double_uncertainty_cell(
    value: Quantity | float,
    binom_lower: Quantity | float,
    binom_upper: Quantity | float,
    rate_lower: Quantity | float,
    rate_upper: Quantity | float,
    precision: int,
) -> str:
    """Format ``value`` with two stacked asymmetric uncertainties as ``$v^{+u_b}_{-l_b}{}^{+u_r}_{-l_r}$``."""
    body = _double_uncertainty_tex(value, binom_lower, binom_upper, rate_lower, rate_upper, precision)
    return body if body == "--" else f"${body}$"


[docs] @dataclass class YieldTable: """ A per-transient-type yield summary, tagged by transient type. Produced by :meth:`~uvex_transients.simulation.event_catalog.EventCatalog.compute_yield_summary`; see that method's own docstring for the full column list and the estimators each one implements. """ table: QTable """QTable: One row per transient type; see `EventCatalog.compute_yield_summary` for the columns.""" confidence: float = 0.9 """float: Confidence level the Clopper-Pearson binomial columns were computed at.""" # ----------------------------------------- # # 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 `numpy.asarray` of the column, preserving units if the column is a `Quantity` 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) else np.asarray(col)
@property def transient_type(self) -> np.ndarray: """numpy.ndarray of str: Each row's transient-type name.""" return np.asarray(self.table["transient_type"]).astype(str) # ----------------------------------------- # # IO Methods # # ----------------------------------------- #
[docs] def to_disk(self, path: str | Path, table_format: str | None = None, overwrite: bool = False) -> None: """ Write this table to disk as ECSV, with its provenance in the header. 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``. """ table = self.table.copy() table.meta.update({"confidence": float(self.confidence)}) table.write(Path(path), format=table_format, overwrite=overwrite) logger.info("Wrote yield table (%d transient type(s)) to %s.", len(table), path)
[docs] @classmethod def from_disk(cls, path: str | Path, table_format: str | None = None) -> "YieldTable": """ Read a yield table back from disk, as written by :meth:`to_disk`. Parameters ---------- path : str or ~pathlib.Path Path to the 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 ------- YieldTable Yield table reconstructed from the serialized table and metadata. """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"File not found: {path}") table = QTable.read(path, format=table_format) meta = dict(table.meta) table.meta.clear() logger.info("Read yield table (%d transient type(s)) from %s.", len(table), path) return cls(table=table, confidence=float(meta.pop("confidence", 0.9)))
[docs] def to_ascii(self, path: str | Path, overwrite: bool = False) -> None: """ Write this table to a human-readable, fixed-width ASCII file. Uses `astropy.io.ascii`'s ``fixed_width_two_line`` format -- plain, aligned columns with a header separator line, readable directly in a text editor or terminal -- unlike :meth:`to_disk`'s ECSV (YAML-in-comments, optimized for a clean round trip back into a `QTable`, not for reading by eye). Parameters ---------- path : str or ~pathlib.Path Destination path. overwrite : bool, optional Whether to overwrite an existing file at `path`. """ path = Path(path) self.table.write(path, format="ascii.fixed_width_two_line", overwrite=overwrite) logger.info("Wrote yield table (%d transient type(s)) to %s.", len(self.table), path)
# ----------------------------------------- # # LaTeX Export # # ----------------------------------------- # _LATEX_COLUMNS = ( ("Transient Type", "transient_type"), ("Total Exposure", "total_exposure"), ("Exposure Fraction", "total_exposure_fraction"), ("Integrated Rate", "integrated_rate"), ("All-Sky Rate", "all_sky_rate"), ("UVEX Intrinsic Rate", "uvex_intrinsic_rate"), ("UVEX Intrinsic Events", "uvex_intrinsic_events"), ("Detected Events", "detected_events"), ("Detection Probability", "detection_probability"), ("Expected Detections", "expected_detections"), ) """tuple[tuple[str, str]]: ``(header label, base column name)`` pairs, in output order.""" _SINGLE_UNCERTAINTY = frozenset({"integrated_rate", "all_sky_rate", "uvex_intrinsic_rate", "uvex_intrinsic_events"}) _DOUBLE_UNCERTAINTY = frozenset({"detection_probability", "expected_detections"})
[docs] def to_latex( self, path: str | Path | None = None, precision: int = 3, caption: str | None = None, label: str | None = None, overwrite: bool = False, ) -> str: r""" Render this table as a LaTeX ``tabular``, with rate/binomial uncertainties as stacked superscripts. A column tabulated with a single ``..._lower``/``..._upper`` pair (`integrated_rate`, `all_sky_rate`, `uvex_intrinsic_rate`, `uvex_intrinsic_events`) is rendered as ``$v^{+u}_{-l}$``. A column tabulated with *both* Clopper-Pearson binomial bounds and `~uvex_transients.transients.base.ExtragalacticTransient.RATE_CI`-derived rate bounds (`detection_probability`, `expected_detections`; see `~uvex_transients.simulation.event_catalog.EventCatalog.compute_yield_summary`) is rendered with both, stacked, as ``$v^{+u_b}_{-l_b}{}^{+u_r}_{-l_r}$`` -- binomial uncertainty first, rate uncertainty second. `numpy.nan` (an unidentified `detection_probability`/ `expected_detections`, when a transient type had zero feasible Monte Carlo draws) renders as ``--``. Parameters ---------- path : str or ~pathlib.Path, optional If given, write the rendered LaTeX here as well as returning it. precision : int, optional Significant figures for every formatted number. The default is ``3``. caption, label : str, optional Forwarded into a wrapping ``table`` environment's ``\caption``/``\label``, if given. overwrite : bool, optional Whether to overwrite an existing file at `path`. Returns ------- str The rendered LaTeX source. """ lines = [] if caption is not None or label is not None: lines.append("\\begin{table}") if caption is not None: lines.append(f"\\caption{{{caption}}}") if label is not None: lines.append(f"\\label{{{label}}}") colspec = "l" + "c" * (len(self._LATEX_COLUMNS) - 1) lines.append(f"\\begin{{tabular}}{{{colspec}}}") lines.append("\\hline") lines.append(" & ".join(header for header, _ in self._LATEX_COLUMNS) + " \\\\") lines.append("\\hline") for row in self.table: cells = [] for _, column in self._LATEX_COLUMNS: if column == "transient_type": cells.append(str(row[column])) elif column == "detected_events": cells.append(str(int(row[column]))) elif column == "total_exposure": quantity = row[column] unit = quantity.unit.to_string("latex_inline").strip("$") cells.append(f"${_fmt(quantity.value, precision)}\\,{unit}$") elif column == "total_exposure_fraction": cells.append(_fmt(float(row[column]), precision)) elif column in self._SINGLE_UNCERTAINTY: cells.append( _single_uncertainty_cell(row[column], row[f"{column}_lower"], row[f"{column}_upper"], precision) ) elif column in self._DOUBLE_UNCERTAINTY: cells.append( _double_uncertainty_cell( row[column], row[f"{column}_binom_lower"], row[f"{column}_binom_upper"], row[f"{column}_rate_lower"], row[f"{column}_rate_upper"], precision, ) ) else: # pragma: no cover - guards against a future column added to _LATEX_COLUMNS raise ValueError(f"No LaTeX formatting rule registered for column {column!r}.") lines.append(" & ".join(cells) + " \\\\") lines.append("\\hline") lines.append("\\end{tabular}") if caption is not None or label is not None: lines.append("\\end{table}") latex = "\n".join(lines) if path is not None: path = Path(path) if path.exists() and not overwrite: raise FileExistsError(f"{path} already exists; pass overwrite=True to replace it.") path.write_text(latex) logger.info("Wrote LaTeX yield table (%d transient type(s)) to %s.", len(self.table), path) return latex
[docs] def expected_detections_latex(self, precision: int = 3) -> str: r""" Render each transient type's expected detections, with both uncertainty sources, as LaTeX. Uses the same double-stacked-uncertainty notation as `to_latex`'s ``expected_detections`` column (``v^{+u_b}_{-l_b}{}^{+u_r}_{-l_r}``, binomial uncertainty first, rate uncertainty second), but on its own -- a LaTeX ``align*`` block, one line per transient type -- rather than a full table. Parameters ---------- precision : int, optional Significant figures for every formatted number. The default is ``3``. Returns ------- str The rendered LaTeX source, e.g. for `IPython.display.Latex`; see `display_expected_detections`. """ lines = ["\\begin{align*}"] for row in self.table: name = str(row["transient_type"]).replace("_", "\\_") body = _double_uncertainty_tex( row["expected_detections"], row["expected_detections_binom_lower"], row["expected_detections_binom_upper"], row["expected_detections_rate_lower"], row["expected_detections_rate_upper"], precision, ) lines.append(f"\\text{{{name}}} &: {body} \\\\") lines.append("\\end{align*}") return "\n".join(lines)
[docs] def display_expected_detections(self, precision: int = 3) -> None: """ Display each transient type's expected detections, rendered if possible. In a Jupyter/IPython context, renders `expected_detections_latex` via `IPython.display.Latex`. Outside one (`IPython` not installed, or no active display), falls back to printing the raw LaTeX source. Parameters ---------- precision : int, optional Forwarded to `expected_detections_latex`. """ latex = self.expected_detections_latex(precision=precision) try: from IPython.display import Latex, display except ImportError: print(latex) return display(Latex(latex))