"""
A single sampled transient event, reconstructed from one `EventCatalog` row.
An :class:`Event` wraps everything needed to evaluate one sampled transient's
detectability against a real survey schedule: its position, redshift, the
luminosity distance and E(B-V) already cached in the catalog at generation time
(see :meth:`~uvex_transients.simulation.core.SurveySimulator.generate_events` --
neither is re-derived here), its SED parameter seed, and the schedule's own record
of which observations actually covered its position during its active window.
Normally reconstructed via :meth:`~uvex_transients.simulation.event_catalog.EventCatalog.get_events`,
not constructed directly.
"""
from collections.abc import Hashable
import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.table import QTable
from astropy.time import Time
from astropy.units import Quantity
from m4opt.missions import Mission
from uvex_transients.dust import log_attenuation
from uvex_transients.utils import get_rng
from ..surveys.base import SurveySchedule
from ..transients.base import TransientBase
[docs]
class Event:
"""
One sampled transient event, tied to a real survey schedule.
Building an `Event` runs exactly one query against `schedule`
(:meth:`~uvex_transients.surveys.base.SurveySchedule.get_observations_of`) to find
which scheduled observations actually covered this event's position during
``[t_explosion, t_explosion + transient.duration_limit)`` -- no photometry is done
here. :meth:`simulate_photometry` does the (comparatively expensive) per-observation,
per-band synthetic photometry, on demand.
Parameters
----------
event_id : int
See :meth:`__init__`.
schedule : ~uvex_transients.surveys.base.SurveySchedule
See :meth:`__init__`.
transient : ~uvex_transients.transients.base.TransientBase
See :meth:`__init__`.
coord : ~astropy.coordinates.SkyCoord
See :meth:`__init__`.
redshift : float
See :meth:`__init__`.
t_explosion : ~astropy.time.Time
See :meth:`__init__`.
seed : int
See :meth:`__init__`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`__init__`.
ebv : float, optional
See :meth:`__init__`.
transient_type : str, optional
See :meth:`__init__`.
"""
def __init__(
self,
event_id: int,
schedule: SurveySchedule,
transient: TransientBase,
coord: SkyCoord,
redshift: float,
t_explosion: Time,
seed: int,
*,
luminosity_distance: Quantity | None = None,
ebv: float | None = None,
transient_type: str | None = None,
):
"""
Construct an `Event` and query `schedule` for its covering observations.
Parameters
----------
event_id : int
This event's unique id, as assigned by `EventCatalog`.
schedule : ~uvex_transients.surveys.base.SurveySchedule
The survey schedule to check this event's visibility against.
transient : ~uvex_transients.transients.base.TransientBase
The transient type this event is a realization of -- supplies `sed` and
`duration_limit`.
coord : ~astropy.coordinates.SkyCoord
Scalar sky position of the event.
redshift : float
Cosmological redshift.
t_explosion : ~astropy.time.Time
Scalar time of explosion.
seed : int
This event's stored parameter seed; deterministically regenerates its
physical SED parameters (see :meth:`sample_parameters`).
luminosity_distance : ~astropy.units.Quantity, optional
Luminosity distance, if already known (e.g. cached by `EventCatalog` at
generation time). If not given, it's derived from `redshift` via
`transient.cosmology` -- a real cosmology call, so passing it in when
it's already on hand is the whole point of caching it.
ebv : float, optional
Milky Way foreground E(B-V) at `coord`, if already known (cached by
`EventCatalog`). If not given, reddening is treated as zero.
transient_type : str, optional
This event's transient-type name, for `__repr__` only.
"""
if not isinstance(schedule, SurveySchedule):
raise TypeError(f"'schedule' must be a SurveySchedule, got {type(schedule)}.")
if not isinstance(transient, TransientBase):
raise TypeError(f"'transient' must be a TransientBase, got {type(transient)}.")
self._event_id = int(event_id)
self._schedule = schedule
self._transient = transient
self._transient_type = transient_type
self._coord = coord
self._redshift = float(redshift)
self._t_explosion = t_explosion
self._seed = int(seed)
self._luminosity_distance = (
luminosity_distance
if luminosity_distance is not None
else transient.cosmology.luminosity_distance(self._redshift)
)
self._ebv = 0.0 if ebv is None else float(ebv)
# The one query this class exists to make: which scheduled observations actually
# covered this event, while it was active. `get_observations_of`'s own window can
# include a row that *started* slightly before `t_explosion` but overlaps into it
# (e.g. a long downlink); that row is dropped here since only observations that
# began at or after the explosion are ever useful for this event's lightcurve.
candidate = schedule.get_observations_of(coord, t_explosion, t_explosion + transient.duration_limit)
self._observations = candidate[candidate["start_time"] >= t_explosion]
def __repr__(self) -> str:
"""
Return a one-line summary showing the event's id, type, redshift, and observation count.
Returns
-------
str
``<Event id=... type=... z=... n_observations=...>``.
"""
return (
f"<Event id={self._event_id} type={self._transient_type!r} "
f"z={self._redshift:.4g} n_observations={len(self._observations)}>"
)
# ------------------------------ #
# Properties #
# ------------------------------ #
@property
def event_id(self) -> int:
"""int: This event's unique id."""
return self._event_id
@property
def transient(self) -> TransientBase:
"""TransientBase: The transient type this event is a realization of."""
return self._transient
@property
def transient_type(self) -> str | None:
"""Str or None: This event's transient-type name."""
return self._transient_type
@property
def coord(self) -> SkyCoord:
"""~astropy.coordinates.SkyCoord: Sky position of the event."""
return self._coord
@property
def redshift(self) -> float:
"""float: Cosmological redshift."""
return self._redshift
@property
def luminosity_distance(self) -> Quantity:
"""~astropy.units.Quantity: Luminosity distance to the event."""
return self._luminosity_distance
@property
def ebv(self) -> float:
"""float: Milky Way foreground E(B-V) at `coord` (0 if never supplied)."""
return self._ebv
@property
def t_explosion(self) -> Time:
"""~astropy.time.Time: Time of explosion."""
return self._t_explosion
@property
def seed(self) -> int:
"""int: This event's stored parameter seed."""
return self._seed
@property
def observations(self) -> QTable:
"""
QTable: The schedule's ``"observe"`` rows that covered this event while active.
One row per candidate observation, chronological, with the same columns as
`SurveySchedule.table` (``start_time``, ``duration``, ``observer_location``
-- i.e. where the spacecraft was -- ``target_coord``, ``roll``, ...). Empty if
the survey never observed this event's position during its active window.
"""
return self._observations
@property
def n_observations(self) -> int:
"""int: Number of candidate observations (``len(observations)``)."""
return len(self._observations)
# ------------------------------ #
# Parameters #
# ------------------------------ #
[docs]
def sample_parameters(self) -> dict:
"""
Return this event's physical SED parameters, deterministically regenerated from `seed`.
A pure, idempotent "peek" -- every call reseeds a fresh generator from `seed`
rather than sharing state with `simulate_photometry`, so this always returns
the same values regardless of how many times it (or `simulate_photometry`)
has already been called.
Returns
-------
dict
``{name: value}`` for each of this event's transient type's SED parameters.
"""
rng = get_rng(self._seed)
return {name: value[0] for name, value in self._transient.sed.sample_parameters(size=1, rng=rng).items()}
# ------------------------------ #
# Theoretical Photometry #
# ------------------------------ #
def _resolve_bandpass(self, mission: Mission, band: Hashable | None):
"""
Return the `~synphot.SpectralElement` named `band` in `mission.detector.bandpasses`.
`band` may be omitted only if the detector has exactly one bandpass -- mirrors
`~m4opt.synphot.Detector`'s own bandpass-resolution rule (see
`~m4opt.synphot.Detector.get_snr`).
Parameters
----------
mission : ~m4opt.missions.Mission
Supplies the `~m4opt.synphot.Detector` `band` selects a bandpass from.
band : Hashable, optional
Which of `mission.detector`'s bandpasses to return. Required unless the
detector has exactly one.
Returns
-------
~synphot.SpectralElement
The resolved bandpass.
Raises
------
ValueError
If `mission` has no detector configured, `band` is required but not
given, or `band` isn't one of the detector's bandpasses.
"""
detector = mission.detector
if detector is None:
raise ValueError(f"Mission {mission.name!r} has no detector configured.")
if band is not None:
return detector.bandpasses[band]
if len(detector.bandpasses) == 1:
(bandpass,) = detector.bandpasses.values()
return bandpass
raise ValueError(
f"Mission {mission.name!r} has more than one bandpass. Please specify "
f"one of them: {list(detector.bandpasses)}."
)
def _pivot_nu_and_log_attenuation(self, bandpass) -> tuple[Quantity, np.ndarray]:
"""
Return `bandpass`'s pivot frequency and this event's own dust attenuation there.
The same single-wavelength approximation `simulate_photometry` uses for its
noiseless flux (`SpectralElement.pivot`), not a full bandpass-throughput
integral -- see :meth:`mag`'s docstring for why that distinction matters here.
Parameters
----------
bandpass : ~synphot.SpectralElement
The bandpass to evaluate.
Returns
-------
tuple of (~astropy.units.Quantity, numpy.ndarray)
`bandpass`'s pivot frequency, and the natural log of this event's
dust attenuation there (see :func:`~uvex_transients.dust.log_attenuation`).
"""
nu = bandpass.pivot().to(u.Hz, equivalencies=u.spectral())
return nu, log_attenuation(nu, self._ebv)
[docs]
def mag(self, t: Quantity, mission: Mission, band: Hashable | None = None) -> Quantity:
"""
Theoretical (noiseless) apparent AB magnitude of this event's SED at time(s) `t`.
This is the literal noiseless curve :meth:`simulate_photometry`'s noisy points
scatter around, not merely a physically similar but numerically different
quantity: like that method, the flux is evaluated at `band`'s pivot
wavelength (`~synphot.SpectralElement.pivot`), *not* integrated across the
full bandpass throughput, and this event's own foreground dust (`ebv`) is
applied, in addition to its `redshift`/`luminosity_distance`/SED parameters
(see :meth:`sample_parameters`) -- so only a time grid and a mission/band are
needed. Useful for plotting a theory curve alongside real observations, not
for simulating a detection.
Parameters
----------
t : ~astropy.units.Quantity
Observed time(s) since explosion, any shape.
mission : ~m4opt.missions.Mission
Supplies the `~m4opt.synphot.Detector` `band` selects a bandpass from.
band : Hashable, optional
Which of `mission.detector`'s bandpasses to evaluate. Required unless the
detector has exactly one.
Returns
-------
~astropy.units.Quantity
The apparent AB magnitude, as an :attr:`~astropy.units.ABmag` Quantity,
with `t`'s shape.
"""
bandpass = self._resolve_bandpass(mission, band)
nu, attenuation = self._pivot_nu_and_log_attenuation(bandpass)
return self._transient.sed.mag(
nu,
t,
redshift=self._redshift,
luminosity_distance=self._luminosity_distance,
log_attenuation=attenuation,
**self.sample_parameters(),
)
[docs]
def flux(self, t: Quantity, mission: Mission, band: Hashable | None = None) -> Quantity:
"""
Theoretical (noiseless) observed flux density of this event's SED, at `band`'s pivot wavelength.
Same inputs, and the same pivot-wavelength-plus-dust definition matching
:meth:`simulate_photometry` exactly, as :meth:`mag` -- see its docstring.
Parameters
----------
t : ~astropy.units.Quantity
Observed time(s) since explosion, any shape.
mission : ~m4opt.missions.Mission
Supplies the `~m4opt.synphot.Detector` `band` selects a bandpass from.
band : Hashable, optional
Which of `mission.detector`'s bandpasses to evaluate. Required unless the
detector has exactly one.
Returns
-------
~astropy.units.Quantity
The observed flux density, with `t`'s shape.
"""
bandpass = self._resolve_bandpass(mission, band)
nu, attenuation = self._pivot_nu_and_log_attenuation(bandpass)
return self._transient.sed.flux(
nu,
t,
redshift=self._redshift,
luminosity_distance=self._luminosity_distance,
log_attenuation=attenuation,
**self.sample_parameters(),
)
[docs]
def luminosity(self, t: Quantity) -> Quantity:
r"""
Bolometric luminosity of this event's SED at time(s) `t` since explosion.
Intrinsic (rest-frame, distance-independent) -- unlike :meth:`mag`/:meth:`flux`,
no `mission`/`band` is involved. This event's own SED parameters (see
:meth:`sample_parameters`) are supplied automatically. Wraps
`~uvex_transients.models.core.base.SpectralModel.eval_bolometric`.
Parameters
----------
t : ~astropy.units.Quantity
Time(s) since explosion, any shape.
Returns
-------
~astropy.units.Quantity
:math:`L_\\mathrm{bol}(t)`, in erg/s, with `t`'s shape.
"""
return self._transient.sed.eval_bolometric(t, **self.sample_parameters())
# ------------------------------ #
# Photometry #
# ------------------------------ #
@staticmethod
def _empty_photometry_table() -> QTable:
"""
Return a zero-row `QTable` with `simulate_photometry`'s columns and dtypes.
Returned as-is by `simulate_photometry` when there are no observations to
simulate, so a caller can always rely on the result having the right columns.
Returns
-------
~astropy.table.QTable
An empty table with `simulate_photometry`'s schema.
"""
table = QTable()
table["event_id"] = np.array([], dtype=np.int64)
table["obs_time"] = Time([], format="jd")
table["exptime"] = u.Quantity([], u.s)
# A wide, explicit itemsize -- `dtype=str` alone infers itemsize 1 from
# an empty array (silently truncating any band name to one character
# once populated rows are ever `vstack`'d onto this one).
table["band"] = np.array([], dtype="<U32")
table["snr"] = np.array([], dtype=np.float64)
table["flux"] = u.Quantity([], u.Jy)
table["flux_err"] = u.Quantity([], u.Jy)
table["flux_upper"] = u.Quantity([], u.Jy)
table["flux_lower"] = u.Quantity([], u.Jy)
table["ab_mag"] = np.array([], dtype=np.float64)
table["mag_err"] = np.array([], dtype=np.float64)
table["mag_upper"] = np.array([], dtype=np.float64)
table["mag_lower"] = np.array([], dtype=np.float64)
return table
[docs]
def simulate_photometry(
self,
mission: Mission,
bands: list | None = None,
n_sigma: float | None = None,
) -> QTable:
"""
Evaluate this event's detectability at every observation in `observations`.
Builds one `~synphot.SourceSpectrum` for this whole event -- batched over
every candidate observation's own time since explosion, via
`~uvex_transients.models.core.base.SpectralModel.as_source_spectrum`
(dust folded in through its ``log_attenuation``, from this event's own cached
`ebv`) -- and reuses it, unmodified, for every requested band:
a `~synphot.SourceSpectrum` is purely a function of wavelength/time, so
"band" only enters once it's integrated against a bandpass, via
`~m4opt.synphot.Detector.get_snr`. That turns what used to be a Python loop
over every (observation, band) pair, each doing its own scalar
`SpectralModel.flux`/`astropy.stats.signal_to_noise_oir_ccd` call, into one
vectorized `get_snr` call per band, regardless of how many observations
there are.
The reported flux/magnitude at each (observation, band) is then a Gaussian
realization of the true (noiseless) flux -- evaluated from that same
`SourceSpectrum` at the band's pivot wavelength -- at that SNR's implied
uncertainty; a synthetic measurement, not the ground truth. Physical SED
parameters are sampled once, from `seed`; every band's noise draws are one
vectorized call over all observations at once, in `bands` order, on the
same stream that draw consumed -- so the whole event still replays
identically given the same `seed`, but *not* row-for-row identically to an
older, unbatched implementation, since the draws are now grouped per band
across every observation rather than interleaved observation-by-observation.
``mag_err`` is the usual linearized (first-order) propagation of ``flux_err``
through the magnitude log transform -- a good description of the uncertainty
only while it's small relative to ``flux``, i.e. at high SNR. It is *not* a
substitute for a real confidence interval: because magnitude is a nonlinear
(logarithmic) function of flux, a symmetric interval in flux is an asymmetric
one in magnitude, and that asymmetry grows as SNR drops -- comparing against
``ab_mag``/``mag_err`` as if they were a plain Gaussian pull systematically
reads as biased at low SNR even when the underlying flux draw has no bias at
all. ``flux_upper``/``flux_lower`` and ``mag_upper``/``mag_lower`` are the
actual ``n_sigma`` interval, built the correct way around: bound `flux`
symmetrically first (where the noise is actually Gaussian), then transform
each bound to magnitude separately, rather than propagating one linearized
width through the transform.
Parameters
----------
mission : m4opt.missions.Mission
Supplies the `~m4opt.synphot.Detector` (bandpasses, background, ...) evaluated
against.
bands : list of str, optional
Which of `mission.detector`'s bandpasses to evaluate. Defaults to every
bandpass the detector has.
n_sigma : float, optional
Width, in multiples of ``flux_err``, of the ``flux_upper``/``flux_lower``/
``mag_upper``/``mag_lower`` interval. If `None` (the default), uses
``config["simulation.detection_n_sigma"]`` (5 out of the box).
Returns
-------
astropy.table.QTable
One row per (observation, band), sorted by ``obs_time`` then ``band``,
with columns ``event_id``, ``obs_time``, ``exptime``, ``band``, ``snr``,
``flux``/``flux_err`` (Jy), ``flux_upper``/``flux_lower`` (Jy, ``flux ±
n_sigma*flux_err``), ``ab_mag``/``mag_err``, and ``mag_upper``/``mag_lower``
-- the ``n_sigma`` interval transformed to magnitude, brighter bound first:
``mag_lower`` (from ``flux_upper``) is always finite when ``flux_upper>0``;
``mag_upper`` (from ``flux_lower``) is ``nan`` whenever ``flux_lower<=0``,
i.e. whenever the source isn't securely distinguished from zero flux at
``n_sigma`` -- the correct behavior is a one-sided (no faint bound) result
there, not a spuriously finite one. ``flux``/``flux_err``/``ab_mag``/
``mag_err`` are ``nan`` wherever ``snr`` is non-positive or non-finite
(``ab_mag``/``mag_err`` are also ``nan`` wherever the noisy ``flux``
realization itself came out non-positive). Empty (but correctly typed) if
`observations` is empty.
"""
detector = mission.detector
if detector is None:
raise ValueError(f"Mission {mission.name!r} has no detector configured.")
# Validated up front (rather than left to `SpectralModel.simulate_photometry`)
# so an unknown band raises even when `n_obs == 0` below short-circuits before
# ever reaching that call.
band_names = list(detector.bandpasses) if bands is None else list(bands)
unknown = [band for band in band_names if band not in detector.bandpasses]
if unknown:
raise ValueError(f"Unknown bandpass(es) {unknown}; available: {list(detector.bandpasses)}.")
n_obs = len(self._observations)
if n_obs == 0:
return self._empty_photometry_table()
# One RNG, seeded from this event's own stored `seed`, drives both the parameter
# draw and every band's noise realization below (inside
# `SpectralModel.simulate_photometry`, in `band_names` order) -- so the whole
# event replays identically from `seed` alone. Deliberately *not*
# `self.sample_parameters()` (which reseeds fresh every call): the noise draws
# must continue on the same stream the parameter draw already consumed.
rng = get_rng(self._seed)
sed_params = {name: value[0] for name, value in self._transient.sed.sample_parameters(size=1, rng=rng).items()}
t_obs = (self._observations["start_time"] - self._t_explosion).to(u.day)
# The actual noise simulation -- batched `as_source_spectrum` plus `get_snr`,
# the Gaussian flux realization, and the `n_sigma` bound math -- lives on
# `SpectralModel.simulate_photometry` now, schedule-independent, so it's shared
# with callers that have no `SurveySchedule`/`Event` at all (e.g. a target of
# opportunity). This event's own `observer_location`/`start_time` are passed
# through as real `observer_location`/`obstime`, so `detector`'s own
# `background` (left unmodified -- `background` isn't overridden here) sees the
# same real observing geometry it always did.
phot = self._transient.sed.simulate_photometry(
t_obs,
self._observations["duration"],
detector,
self._coord,
bands=band_names,
observer_location=self._observations["observer_location"],
obstime=self._observations["start_time"],
redshift=self._redshift,
luminosity_distance=self._luminosity_distance,
ebv=self._ebv,
n_sigma=n_sigma,
rng=rng,
**sed_params,
)
phot["event_id"] = np.full(len(phot), self._event_id, dtype=np.int64)
phot["obs_time"] = self._t_explosion + phot["t"]
del phot["t"]
return phot[
[
"event_id",
"obs_time",
"exptime",
"band",
"snr",
"flux",
"flux_err",
"flux_upper",
"flux_lower",
"ab_mag",
"mag_err",
"mag_upper",
"mag_lower",
]
]