"""
Monte Carlo sampling of transient populations against a survey schedule.
`SurveySimulator` does exactly one job: given a collection of transient types, a
`~uvex_transients.surveys.base.SurveySchedule`, and a root seed, sample a Monte
Carlo realization of every registered transient type into an `EventCatalog`
(:meth:`SurveySimulator.generate_events`). Real per-event synthetic photometry lives
one level down, on `~uvex_transients.simulation.event.Event`
(:meth:`~uvex_transients.simulation.event.Event.simulate_photometry`) -- it's too
expensive to run over a whole freshly-sampled population, which is typically dominated
by events far too faint to ever matter. Two progressively more expensive screening
steps narrow the catalog down first: `filter_by_limiting_magnitude` never touches the
schedule at all (only "could this event, at its brightest, ever clear a fixed magnitude
limit" over a shared per-type phase grid); `filter_by_snr` does consult the schedule --
"is this event, at any observation the schedule actually made of it, ever detected
above a given SNR" -- batched across many events at once the same way
`Event.simulate_photometry` batches across one event's own observations.
"""
from collections.abc import Mapping
from typing import Union
import astropy_healpix as ah
import numpy as np
from astropy import units as u
from astropy.table import QTable, vstack
from astropy.time import Time
from m4opt.missions import Mission
from m4opt.synphot import observing
from tqdm.auto import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm
from uvex_transients.dust import dust_map, log_attenuation
from uvex_transients.utils import config, get_seed_sequence, logger, resolve_healpix_resolution
from ..surveys.base import SurveySchedule
from ..transients.base import ExtragalacticTransient
from .event_catalog import EventCatalog
from .exposure_catalog import ExposureCatalog
_SeedType = Union[np.random.SeedSequence, int, None]
def _sample_parameters_from_seeds(sed, seeds) -> dict:
"""
Regenerate each event's own physical SED parameters from its stored `parameter_seed`.
Draws one independent `numpy.random.Generator` per row, seeded from that row's own
`parameter_seed` -- exactly matching
`~uvex_transients.simulation.event.Event.sample_parameters` row for row (same
`sed.sample_parameters(size=1, rng=np.random.default_rng(int(seed)))` call). Unlike a
single shared per-type stream, this guarantees the parameters used for screening here
are the *same* ones a kept event's own `Event.mag`/`Event.simulate_photometry` will
later reconstruct -- there is no independent, unbatched fallback for `seeds` to fall
back on.
Parameters
----------
sed : ~uvex_transients.models.core.base.SpectralModel
The transient type's SED model to sample parameters from.
seeds : array-like of int
Each event's own `parameter_seed`, in catalog row order.
Returns
-------
dict
``{name: samples}``, each with shape ``(len(seeds),)``, in the same row order as
`seeds`.
"""
if len(seeds) == 0:
return {name: np.array([]) for name in sed.sample_parameters(size=0)}
per_event = [sed.sample_parameters(size=1, rng=np.random.default_rng(int(seed))) for seed in seeds]
return {name: np.concatenate([sample[name] for sample in per_event]) for name in per_event[0]}
[docs]
def cut(name: str):
"""
Mark a `SurveySimulator` method as a named, registrable screening cut.
Applied to `SurveySimulator.filter_by_limiting_magnitude`/`filter_by_snr` below;
`SurveySimulator.run_cut`/`available_cuts` dispatch by this name rather than by the
method's own Python name, so a config-driven caller (see `uvex_transients.cli`) never
needs to hardcode either one.
Parameters
----------
name : str
The registry key this method should be reachable under.
Returns
-------
Callable
A decorator that tags the method with `name` and returns it unchanged.
"""
def decorator(method):
"""
Tag `method` with its registry name and return it unchanged.
Parameters
----------
method : Callable
The method to tag.
Returns
-------
Callable
`method`, unchanged, with `_cut_name` set.
"""
method._cut_name = name
return method
return decorator
class _CutRegistryMeta(type):
"""
Metaclass collecting every `@cut`-tagged method (across the whole MRO) into ``cls._CUT_REGISTRY``.
Subclassing `SurveySimulator` and adding more `@cut`-decorated methods extends the
registry automatically -- no separate ``Cut`` class hierarchy or manual registration
step required.
"""
def __new__(mcls, name, bases, namespace, **kwargs):
"""
Build the class, then collect every `@cut`-tagged method across its MRO into ``_CUT_REGISTRY``.
Parameters
----------
mcls : type
This metaclass.
name : str
The new class's name.
bases : tuple of type
The new class's base classes.
namespace : dict
The new class's namespace (methods, class attributes, ...).
**kwargs
Forwarded to :meth:`type.__new__` unchanged.
Returns
-------
type
The newly created class, with ``_CUT_REGISTRY`` set.
"""
cls = super().__new__(mcls, name, bases, namespace, **kwargs)
registry: dict[str, str] = {}
for base in reversed(cls.__mro__):
for attr_name, attr in vars(base).items():
cut_name = getattr(attr, "_cut_name", None)
if cut_name is not None:
registry[cut_name] = attr_name
cls._CUT_REGISTRY = registry
return cls
[docs]
class SurveySimulator(metaclass=_CutRegistryMeta):
"""
Samples transient populations against a survey schedule.
Parameters
----------
survey_schedule : ~uvex_transients.surveys.base.SurveySchedule
The schedule to sample events against. See :meth:`__init__`.
transients : dict of str to ExtragalacticTransient, optional
The transient types to register. See :meth:`__init__`.
simulation_seed : int, optional
Root seed for Monte Carlo sampling. See :meth:`__init__`.
"""
def __init__(
self,
survey_schedule: SurveySchedule,
transients: dict[str, ExtragalacticTransient] | None = None,
simulation_seed: int | None = None,
):
"""
Store the survey schedule, register any given transient types, and store the root seed.
Parameters
----------
survey_schedule : ~uvex_transients.surveys.base.SurveySchedule
The schedule to sample events against.
transients : dict of str to ExtragalacticTransient, optional
Transient types to register up front, keyed by name. More may be
added later via :attr:`transient_collection`.
simulation_seed : int, optional
Root seed for Monte Carlo sampling.
Raises
------
TypeError
If `survey_schedule` is not a `SurveySchedule`, or `transients`
contains a value that isn't an `ExtragalacticTransient`.
ValueError
If `transients` has a duplicate key.
"""
# Ensure that the survey schedule is valid.
if not isinstance(survey_schedule, SurveySchedule):
raise TypeError(
f"'survey_schedule' must be an instance of SurveySchedule, got {type(survey_schedule)} instead."
)
self._survey_schedule = survey_schedule
self._simulation_seed = simulation_seed
# If we are given any transients to start with, we'll add them to the dictionary. Otherwise
# we'll just pass through.
self._transients = {}
if transients is not None:
for _transient_type_name, _transient_type in transients.items():
if _transient_type_name in self._transients:
raise ValueError(f"Transient type '{_transient_type_name}' is already used.")
if not isinstance(_transient_type, ExtragalacticTransient):
raise TypeError(
f"Transient type '{_transient_type_name}' must be an instance of "
f"ExtragalacticTransient, got {type(_transient_type)} instead."
)
self._transients[_transient_type_name] = _transient_type
# ---------------------------------------------- #
# Properties and Accessors #
# ---------------------------------------------- #
@property
def survey_schedule(self) -> SurveySchedule:
"""~uvex_transients.surveys.base.SurveySchedule: The schedule events are sampled against."""
return self._survey_schedule
@property
def transient_collection(self) -> dict[str, ExtragalacticTransient]:
"""Dict of str to ExtragalacticTransient: The registered transient types, keyed by name."""
return self._transients
@property
def simulation_seed(self) -> _SeedType:
"""int, ~numpy.random.SeedSequence, or None: Root seed for Monte Carlo sampling."""
return self._simulation_seed
# -------------------------------------------------- #
# Event Generation #
# -------------------------------------------------- #
def _resolve_time_bins(self, time_bins: Time | int) -> Time:
"""
Resolve `generate_events`'s `time_bins` argument down to a concrete array of edges.
Parameters
----------
time_bins : ~astropy.time.Time or int
Either explicit bin edges, or a positive number of equal-width
bins spanning the whole survey.
Returns
-------
~astropy.time.Time
The concrete array of bin edges.
Raises
------
TypeError
If `time_bins` is neither a `~astropy.time.Time` array nor an int.
ValueError
If `time_bins` is a `Time` array with fewer than 2 edges, or a
non-positive int.
"""
if isinstance(time_bins, Time):
if time_bins.isscalar or time_bins.size < 2:
raise ValueError("`time_bins`, given as a Time array, must contain at least 2 edges.")
return time_bins
if isinstance(time_bins, bool) or not isinstance(time_bins, (int, np.integer)):
raise TypeError(
f"`time_bins` must be an astropy Time array of bin edges or a positive int, got {type(time_bins)!r}."
)
if time_bins < 1:
raise ValueError(f"`time_bins`, given as an int, must be a positive number of bins, got {time_bins!r}.")
schedule = self._survey_schedule
return schedule.start_time + np.linspace(0.0, 1.0, time_bins + 1) * schedule.duration
[docs]
def generate_events(
self,
time_bins: Time | int,
nside: int | None = None,
order: str | None = None,
downsample: int | Mapping[str, int] | None = None,
) -> EventCatalog:
"""
Sample a Monte Carlo realization of every registered transient type over a time grid.
For each transient type and each bin ``[t_k, t_{k+1})`` of `time_bins`, events are
sampled only within the HEALPix pixels the survey actually observes at some point
between ``t_k`` and ``t_{k+1} + transient.duration_limit`` -- i.e. only where an event
exploding in this bin could plausibly still be caught by an observation before it fades
below relevance. Explosion times themselves are drawn only within ``[t_k, t_{k+1})``, so
no event is double-counted across adjacent bins.
Two columns are computed once here, at generation time, so nothing downstream ever
re-derives them: ``luminosity_distance`` (interpolated per transient type off its own
cached
:attr:`~uvex_transients.transients.base.ExtragalacticTransient.luminosity_distance_grid`/
:attr:`~uvex_transients.transients.base.ExtragalacticTransient.redshift_grid`, rather than
a fresh `cosmology.luminosity_distance` call per event) and ``ebv`` (one vectorized
Milky Way dust-map query over every sampled position at once).
Parameters
----------
time_bins : ~astropy.time.Time or int
Either an explicit, monotonically increasing `Time` array of ``n + 1`` bin edges, or
a positive int giving the number of evenly-spaced bins to divide
`survey_schedule`'s full span into.
nside : int, optional
HEALPix resolution used both to query the observed footprint and to sample event
positions. If `None` (the default), uses ``config["healpix.default_nside"]``.
order : str, optional
HEALPix pixel ordering scheme (``"nested"`` or ``"ring"``). If `None` (the
default), uses ``config["healpix.default_order"]``.
downsample : int or Mapping of str to int, optional
Downsample the number of events generated, by drawing a random subset
(without replacement) of each per-bin, per-type table rather than a fixed
stride -- seeded reproducibly off `simulation_seed`. Either a single factor
applied to every registered transient type, or a ``{type key: factor}``
mapping giving a different factor per type (a type left out of the mapping
is not downsampled at all). The default is ``None`` (no downsampling).
Returns
-------
EventCatalog
One row per sampled event, across every registered transient type and time bin.
Raises
------
ValueError
If `downsample` is a mapping naming a key not in `self._transients`.
"""
# Validate the inputs and ensure that there are actually registered transients to model.
if not self._transients:
raise ValueError("No transient types registered in `transient_collection`; nothing to sample.")
if isinstance(downsample, Mapping):
unknown = sorted(set(downsample) - set(self._transients))
if unknown:
raise ValueError(
f"`downsample` names unknown transient key(s) {unknown}; available: {sorted(self._transients)}."
)
nside, order = resolve_healpix_resolution(nside, order)
# Set up the time bins so that we can perform the windowing analysis properly.
edges = self._resolve_time_bins(time_bins)
n_bins = len(edges) - 1
# Set up the RNG. Because the various sampled transients will need to each be assigned a seed
# from the single seed provided here, we need to create a seed sequence.
root_seed = get_seed_sequence(self._simulation_seed)
sorted_names = sorted(self._transients)
type_seeds = root_seed.spawn(len(sorted_names))
# --- Begin Iteration Section --- #
# In this code-section, we iterate through each window (t_i, t_i+1 + duration) and through each
# of the transient types to construct the sample of events. This is ALL events within the redshift limit
# which occur within the footprint of the survey. At this stage, the only event reduction is based in the
# redshift limit and the survey footprint.
if isinstance(downsample, Mapping):
if downsample:
logger.info(f"Downsampling the number of events per-type by {dict(downsample)}.")
elif downsample is not None:
logger.info(f"Downsampling the number of events by a factor of {downsample}.")
tables = []
with (
tqdm(total=len(sorted_names) * n_bins, desc="Generating events", unit="bin") as pbar,
logging_redirect_tqdm(loggers=[logger]),
):
for name, type_seed in zip(sorted_names, type_seeds):
transient = self._transients[name]
bin_seeds = type_seed.spawn(n_bins)
type_downsample = downsample.get(name) if isinstance(downsample, Mapping) else downsample
for k in range(n_bins):
pbar.set_postfix(type=name, bin=f"{k + 1}/{n_bins}")
t_start, t_end = edges[k], edges[k + 1]
# Determine which set of the healpix IDs intersect at all with the
# FOV of the survey.
pixel_ids = self._survey_schedule.get_observed_healpix_ids(
t_start,
t_end + transient.duration_limit,
nside=nside,
order=order,
)
# Determine the tiled area for this timestep and provide the debug info to console.
N_PIXELS_VISITED = len(pixel_ids)
SOLID_ANGLE_VISITED = ah.nside_to_pixel_area(nside) * N_PIXELS_VISITED
logger.debug(
"Transient Type: %s, Bin: %d/%d, Time Window: %s to %s, "
"N Pixels Visited: %d, Solid Angle Visited: %.3f deg^2",
name,
k + 1,
n_bins,
t_start.iso,
t_end.iso,
N_PIXELS_VISITED,
SOLID_ANGLE_VISITED.to_value(u.deg**2),
)
# Extract a random sample of transients which occur within the
# relevant footprint.
table = transient.sample_events_on_healpix_grid(
nside,
t_start=t_start,
t_end=t_end,
pixel_ids=pixel_ids,
order=order,
seed=bin_seeds[k],
)
# Interpolate this transient type's own cached D_L(z) grid at each sampled
# event's redshift, right here, rather than a fresh `cosmology.luminosity_distance`
# call per event later -- see `ExtragalacticTransient.luminosity_distance_grid`.
if len(table) > 0:
table["luminosity_distance"] = (
np.interp(
table["redshift"],
transient.redshift_grid,
transient.luminosity_distance_grid.to_value(u.Mpc),
)
* u.Mpc
)
else:
table["luminosity_distance"] = u.Quantity([], u.Mpc)
table["transient_type"] = name
table["time_bin"] = k
if type_downsample is not None:
# Spawned *after* `sample_events_on_healpix_grid` has already
# drawn its own 6 children from `bin_seeds[k]` above, so this
# doesn't perturb that draw -- see its own docstring on why
# spawn order off a shared `SeedSequence` matters.
downsample_rng = np.random.default_rng(bin_seeds[k].spawn(1)[0])
n_keep = -(-len(table) // type_downsample) # ceiling division
keep = np.sort(downsample_rng.choice(len(table), size=n_keep, replace=False))
table = table[keep]
tables.append(table)
pbar.update(1)
combined = vstack(tables, metadata_conflicts="silent")
combined.sort("t_explosion")
combined["event_id"] = np.arange(len(combined), dtype=np.int64)
# One vectorized Milky Way dust-map query over every sampled position at once, rather
# than per-event later (see `EventCatalog.ebv`).
combined["ebv"] = (
np.asarray(dust_map().query(combined["coord"]), dtype=np.float64)
if len(combined) > 0
else np.array([], dtype=np.float64)
)
return EventCatalog(
table=combined,
nside=nside,
order=order,
time_bins=edges,
seed=self._simulation_seed,
downsample=dict(downsample) if isinstance(downsample, Mapping) else downsample,
)
[docs]
def compute_effective_exposure(
self,
time_bins: Time | int,
nside: int | None = None,
order: str | None = None,
) -> ExposureCatalog:
r"""
Tabulate each registered transient type's effective exposure per time bin.
For each transient type and each bin ``[t_k, t_{k+1})`` of `time_bins`, this
reruns exactly the footprint query `generate_events` uses to decide where it's
even worth sampling events -- the HEALPix pixels the survey observes at some
point between ``t_k`` and ``t_k+1 + transient.duration_limit`` -- but instead of
drawing a population from it, reduces it straight to a solid angle
:math:`F(t_k, t_{k+1}+\tau)`. The **effective exposure** for that bin is then
.. math::
\mathcal E_k = F(t_k, t_{k+1}+\tau)\,(t_{k+1}-t_k),
i.e. the *visited* solid angle (not the full :math:`4\pi` sky) times the *bin*
width (not the padded window used only to decide which pixels are visitable).
This matches the ``solid_angle * duration`` product
`~uvex_transients.transients.base.ExtragalacticTransient.sample_event_count`
itself feeds to `numpy.random.Generator.poisson` inside
`~uvex_transients.transients.base.ExtragalacticTransient.sample_events_on_healpix_grid`,
so ``effective_exposure * transient.integrated_rate`` reproduces the same
per-bin expected event count `generate_events` actually samples from -- summing
`expected_events` over every bin for one transient type gives the same
expectation as ``transient.compute_all_sky_yield`` only when the survey footprint
never misses any of the sky at any point (:math:`F\equiv4\pi\,\mathrm{sr}`);
otherwise this is the tighter, footprint-aware quantity that estimator ignores.
This is deliberately *not* extracted from a `generate_events` call after the
fact -- `generate_events` never persists per-bin pixel IDs or solid angles once
it's done sampling from them, and re-deriving those from a resulting
`~uvex_transients.simulation.event_catalog.EventCatalog` would have to guess at
the very footprint query that produced it. Calling this separately recomputes
that query, but the expensive part -- `SurveySchedule.get_healpix_coverage_index`'s
whole-schedule rasterization -- is cached per ``(nside, order)`` and so is paid
for at most once, however many times either method (or both) queries it.
Parameters
----------
time_bins : ~astropy.time.Time or int
Same semantics as `generate_events`.
nside : int, optional
Same semantics as `generate_events`.
order : str, optional
Same semantics as `generate_events`.
Returns
-------
ExposureCatalog
One row per ``(transient type, time bin)``, sorted by transient type then
bin index, with columns ``transient_type``, ``time_bin``, ``t_start``,
``t_end``, ``n_pixels_visited``, ``solid_angle``, ``duration``,
``effective_exposure``, and ``expected_events``. See
`ExposureCatalog.total_effective_exposure`/`ExposureCatalog.total_expected_events`
for the per-type sums over every bin.
Raises
------
ValueError
If no transient types are registered in `transient_collection`.
"""
if not self._transients:
raise ValueError("No transient types registered in `transient_collection`; nothing to tabulate.")
nside, order = resolve_healpix_resolution(nside, order)
edges = self._resolve_time_bins(time_bins)
n_bins = len(edges) - 1
sorted_names = sorted(self._transients)
transient_type = []
time_bin = []
t_start_col = []
t_end_col = []
n_pixels_visited = []
solid_angle = []
duration = []
with (
tqdm(total=len(sorted_names) * n_bins, desc="Tabulating effective exposure", unit="bin") as pbar,
logging_redirect_tqdm(loggers=[logger]),
):
for name in sorted_names:
transient = self._transients[name]
for k in range(n_bins):
pbar.set_postfix(type=name, bin=f"{k + 1}/{n_bins}")
t_start, t_end = edges[k], edges[k + 1]
pixel_ids = self._survey_schedule.get_observed_healpix_ids(
t_start,
t_end + transient.duration_limit,
nside=nside,
order=order,
)
transient_type.append(name)
time_bin.append(k)
t_start_col.append(t_start)
t_end_col.append(t_end)
n_pixels_visited.append(len(pixel_ids))
solid_angle.append(ah.nside_to_pixel_area(nside) * len(pixel_ids))
duration.append((t_end - t_start).to_value(u.day) * u.day)
pbar.update(1)
table = QTable()
table["transient_type"] = np.asarray(transient_type)
table["time_bin"] = np.asarray(time_bin, dtype=np.int64)
table["t_start"] = Time(t_start_col)
table["t_end"] = Time(t_end_col)
table["n_pixels_visited"] = np.asarray(n_pixels_visited, dtype=np.int64)
table["solid_angle"] = u.Quantity(solid_angle)
table["duration"] = u.Quantity(duration)
table["effective_exposure"] = table["solid_angle"] * table["duration"]
table["expected_events"] = [
(self._transients[name].integrated_rate * exposure).to_value(u.dimensionless_unscaled)
for name, exposure in zip(table["transient_type"], table["effective_exposure"])
]
return ExposureCatalog(table=table, nside=nside, order=order, time_bins=edges)
# -------------------------------------------------- #
# Filtering #
# -------------------------------------------------- #
[docs]
@classmethod
def available_cuts(cls) -> tuple[str, ...]:
"""Tuple of str: Every cut name registered on this class via `@cut`, sorted."""
return tuple(sorted(cls._CUT_REGISTRY))
[docs]
def run_cut(self, name: str, catalog: EventCatalog, mission: Mission, **params) -> EventCatalog:
"""
Run one `@cut`-registered screening method by name.
A thin dispatch layer over `filter_by_limiting_magnitude`/`filter_by_snr` (and any
further ``@cut``-decorated methods a subclass adds) so a config-driven caller (see
`uvex_transients.cli`) can select a cut by name rather than hardcoding which Python
method to call.
Parameters
----------
name : str
One of `available_cuts`.
catalog : EventCatalog
The catalog to filter.
mission : m4opt.missions.Mission
The mission whose detector(s)/bandpasses the cut evaluates against.
**params
Forwarded to the underlying cut method (e.g. `mag_limit` for the
``"limiting_magnitude"`` cut, `snr_threshold` for ``"snr"``).
Returns
-------
EventCatalog
The filtered catalog.
"""
try:
method_name = self._CUT_REGISTRY[name]
except KeyError:
raise ValueError(f"Unknown cut {name!r}; available: {self.available_cuts()}.") from None
return getattr(self, method_name)(catalog, mission, **params)
[docs]
@cut("limiting_magnitude")
def filter_by_limiting_magnitude(
self,
catalog: EventCatalog,
mission: Mission,
mag_limit: float,
bands: list[str] | None = None,
n_phase: int | None = None,
chunk_size: int | None = None,
n_visits: int = 1,
) -> EventCatalog:
"""
Cheaply cut an `EventCatalog` down to events that could ever plausibly be seen.
Deliberately not synthetic photometry: no schedule, no background, no SNR formula,
no per-event noise realization. For each transient type present in `catalog`, this
regenerates each event's own physical SED parameters from its stored
`parameter_seed` (see `_sample_parameters_from_seeds`), so a kept event's later
`simulate_photometry` realization is guaranteed to match what was screened here --
not an independent draw from the same population -- then evaluates
:meth:`~uvex_transients.models.core.base.SpectralModel.flux_band` over a shared
``linspace(0, duration_limit, n_phase)`` phase grid -- the same grid for every event
of a type, regardless of whether the schedule ever actually pointed there at that
phase -- broadcasting every event and every requested band at once. At each phase
sample, an event's brightest band is compared against `mag_limit`; an event survives
only if at least `n_visits` phase samples clear the limit.
A freshly sampled catalog is typically dominated by faint, easily-rejected events
and can easily run into the hundreds of thousands of rows (e.g. a multi-year,
all-sky schedule) -- `flux_band` broadcasts every event and phase sample into one
dense ``(n_phase, n_events, n_wavelength)`` array, so evaluating that in a single
shot over the *whole* catalog can allocate tens of gigabytes and exhaust memory.
`chunk_size` bounds this: events of each type are evaluated `chunk_size` at a time,
keeping peak memory roughly constant regardless of catalog size, at the cost of
some vectorization efficiency (still fully vectorized within a chunk).
Parameters
----------
catalog : EventCatalog
Typically produced by `generate_events`; must already carry the
``luminosity_distance``/``ebv``/``parameter_seed`` columns that method fills
in.
mission : m4opt.missions.Mission
Supplies the `~m4opt.synphot.Detector` whose named bandpasses `bands` selects
from.
mag_limit : float
AB magnitude limit; a phase sample clears the limit if the event's brightest
evaluated band is at or below this value at that phase.
bands : list of str, optional
Which of `mission.detector`'s bandpasses to evaluate. Defaults to every bandpass
the detector has.
n_phase : int, optional
Number of phase-grid samples per transient type, spanning
``[0, transient.duration_limit]``. If `None` (the default), uses
``config["simulation.filter_by_limiting_magnitude.n_phase"]`` (50 out of the
box).
chunk_size : int, optional
Number of events (per transient type) evaluated per `flux_band` call. If
`None` (the default), uses
``config["simulation.filter_by_limiting_magnitude.chunk_size"]`` (5000 out of
the box -- at the default `n_phase` and a ~100-point bandpass wavelength grid,
roughly a few hundred MB of peak array memory per band); lower it further for a
very fine `n_phase` or a very densely sampled bandpass.
n_visits : int, optional
Minimum number of phase-grid samples that must clear `mag_limit` for an event to
survive. The default is 1, i.e. an event survives if it is ever bright enough at
even a single sampled phase. Raising this discards events that are only
momentarily bright enough to clear the limit, at (roughly) one sampled phase or
fewer -- a coarse stand-in for "detected on at least `n_visits` visits", since this
method never consults the actual schedule.
Returns
-------
EventCatalog
A new catalog over the surviving rows only (same `nside`/`order`/`time_bins`/
`seed` as `catalog`; original `event_id` values are preserved, not renumbered).
"""
if not isinstance(catalog, EventCatalog):
raise TypeError(f"'catalog' must be an EventCatalog, got {type(catalog)} instead.")
if not isinstance(mission, Mission):
raise TypeError(f"'mission' must be an m4opt.missions.Mission, got {type(mission)} instead.")
if isinstance(n_visits, bool) or not isinstance(n_visits, (int, np.integer)) or n_visits < 1:
raise ValueError(f"'n_visits' must be a positive int, got {n_visits!r}.")
if n_phase is None:
n_phase = config["simulation.filter_by_limiting_magnitude.n_phase"]
if chunk_size is None:
chunk_size = config["simulation.filter_by_limiting_magnitude.chunk_size"]
detector = mission.detector
if detector is None:
raise ValueError(f"Mission {mission.name!r} has no detector configured.")
band_names = list(detector.bandpasses) if bands is None else list(bands)
unknown_bands = [band for band in band_names if band not in detector.bandpasses]
if unknown_bands:
raise ValueError(f"Unknown bandpass(es) {unknown_bands}; available: {list(detector.bandpasses)}.")
table = catalog.table
missing = [col for col in ("luminosity_distance", "ebv", "parameter_seed") if col not in table.colnames]
if missing:
raise ValueError(f"'catalog' is missing column(s) {missing}; regenerate it via `generate_events`.")
if len(table) == 0:
return EventCatalog(
table=table,
nside=catalog.nside,
order=catalog.order,
time_bins=catalog.time_bins,
seed=catalog.seed,
downsample=catalog.downsample,
)
# Bandpass wavelength/throughput/frequency grids are the same for every transient
# type and event -- sampled once here, not per type.
band_grids = {}
for band in band_names:
bp = detector.bandpasses[band]
wave = bp.waveset
band_grids[band] = (
wave,
bp(wave),
wave.to(u.Hz, equivalencies=u.spectral()),
)
transient_type = np.asarray(table["transient_type"]).astype(str)
type_names = sorted(set(transient_type) & set(self._transients))
unknown_types = sorted(set(transient_type) - set(self._transients))
if unknown_types:
raise ValueError(f"'catalog' contains transient type(s) {unknown_types} not in `transient_collection`.")
keep = np.zeros(len(table), dtype=bool)
# Precompute chunk counts per type up front so the progress bar can report a single
# total spanning every chunk of every type, not just one tick per type.
type_idx = {name: np.flatnonzero(transient_type == name) for name in type_names}
n_chunks_by_type = {name: -(-idx.size // chunk_size) for name, idx in type_idx.items()}
total_chunks = sum(n_chunks_by_type.values())
with (
tqdm(total=total_chunks, desc="Filtering by mag limit", unit="chunk") as pbar,
logging_redirect_tqdm(loggers=[logger]),
):
for name in type_names:
transient = self._transients[name]
idx = type_idx[name]
n = idx.size
n_chunks = n_chunks_by_type[name]
redshift_all = np.asarray(table["redshift"])[idx]
luminosity_distance_all = table["luminosity_distance"][idx]
ebv_all = np.asarray(table["ebv"])[idx]
seeds_all = np.asarray(table["parameter_seed"])[idx]
# Sampling physical parameters for the whole type at once is cheap (a
# handful of floats per event); it's only the `flux_band` evaluation below
# -- an (n_phase, n_chunk, n_wavelength) array per band -- that can blow up
# memory for a large catalog, so only *that* is chunked (see `chunk_size`).
# Each event's own `parameter_seed` (not a shared per-type stream) keeps
# this screening pass consistent with `Event.mag`/`Event.simulate_photometry`
# -- see `_sample_parameters_from_seeds`.
sed_params_all = _sample_parameters_from_seeds(transient.sed, seeds_all)
t_grid = np.linspace(0.0, 1.0, n_phase) * transient.duration_limit
for chunk_num, start in enumerate(range(0, n, chunk_size), start=1):
stop = min(start + chunk_size, n)
chunk_idx = idx[start:stop]
pbar.set_postfix(type=name, chunk=f"{chunk_num}/{n_chunks}")
redshift = redshift_all[start:stop]
luminosity_distance = luminosity_distance_all[start:stop]
ebv = ebv_all[start:stop]
sed_params = {param_name: value[start:stop] for param_name, value in sed_params_all.items()}
# Brightest flux across bands *at each sampled phase* (not collapsed
# across phase yet), so visits can be counted per phase sample below.
best_flux_by_phase = None
for band in band_names:
wave, throughput, nu = band_grids[band]
flux = transient.sed.flux_band(
nu,
throughput,
t_grid[:, None],
redshift=redshift,
luminosity_distance=luminosity_distance,
log_attenuation=log_attenuation(nu, ebv),
**sed_params,
) # shape (n_phase, chunk_size)
best_flux_by_phase = (
flux if best_flux_by_phase is None else np.maximum(best_flux_by_phase, flux)
)
with np.errstate(invalid="ignore", divide="ignore"):
mag_by_phase = best_flux_by_phase.to_value(u.ABmag) # shape (n_phase, chunk_size)
n_visits_cleared = np.count_nonzero(mag_by_phase <= mag_limit, axis=0) # shape (chunk_size,)
keep[chunk_idx[n_visits_cleared >= n_visits]] = True
pbar.update(1)
return EventCatalog(
table=table[keep],
nside=catalog.nside,
order=catalog.order,
time_bins=catalog.time_bins,
seed=catalog.seed,
downsample=catalog.downsample,
)
[docs]
@cut("snr")
def filter_by_snr(
self,
catalog: EventCatalog,
mission: Mission,
snr_threshold: float,
bands: list[str] | None = None,
chunk_size: int | None = None,
n_visits: int = 1,
) -> EventCatalog:
"""
Cut an `EventCatalog` down to events the schedule actually detects.
Unlike `filter_by_limiting_magnitude` (which never consults the schedule, only
a shared phase grid), this asks the real question: over every observation the
schedule actually made of an event's position while it was active, is it ever
detected above `snr_threshold`? For each transient type present in `catalog`,
events are processed `chunk_size` at a time:
1. One `~uvex_transients.surveys.base.SurveySchedule.get_observation_indices_of`
call for the *whole chunk at once* finds which observations covered each
event while active, each against its own `(t_explosion, t_explosion +
transient.duration_limit)` window. The broad-phase HEALPix pixel lookup it's
built on is queried at `catalog`'s own `nside`/`order`, but unlike the
coarser screening this replaced, every candidate it returns is confirmed
with an exact polygon-containment test before being handed back -- so this
step never mistakenly keeps an event the schedule didn't truly observe; it
can only ever miss one whose position happened to fall in a HEALPix pixel
the covering footprint didn't register (see
`~uvex_transients.surveys.base.SurveySchedule.get_healpix_coverage_index`'s
docstring for that one-directional tradeoff, tunable via `catalog.nside`).
The first call for a given `(nside, order)` pays a one-time cost to index
the *whole* schedule; every call after that, for any event (even from a
different catalog, as long as the resolution matches), is a cheap lookup.
An event the schedule never observed at all is discarded here, before any
photometry.
2. Every (event, observation) pair surviving that -- however many observations
each individual event happens to have -- is flattened into one combined
table, tagged by which chunk-local event it came from.
3. Physical SED parameters are regenerated from each event's own stored
`parameter_seed` (see `_sample_parameters_from_seeds` -- the same helper
`filter_by_limiting_magnitude` uses, so both screening passes and a
surviving event's later `Event.mag`/`Event.simulate_photometry` all agree on
its physical parameters), repeated out to one row per (event, observation)
pair alongside `redshift`/`luminosity_distance`/`ebv`/the event's own sky
position, then the *whole* flattened chunk is evaluated in a single
vectorized `get_snr` call per band -- exactly the batching
`Event.simulate_photometry` does for one event's own observations, just
extended across many events at once.
4. Each row's best-band SNR is compared against `snr_threshold`; an event
survives if at least `n_visits` of its own rows clear it (mirroring
`filter_by_limiting_magnitude`'s own `n_visits`: bands are collapsed to the
best one *before* counting, so a single observation bright enough in two
bands at once still counts as one visit, not two).
Parameters
----------
catalog : EventCatalog
Typically the (already magnitude-filtered) output of
`filter_by_limiting_magnitude` -- running this directly on a freshly
sampled catalog works too, just more slowly, since every event still costs
one `get_observations_of` call regardless of how faint it is.
mission : m4opt.missions.Mission
Supplies the `~m4opt.synphot.Detector` `bands` selects from.
snr_threshold : float
An event survives if at least `n_visits` observations clear this SNR, in
their best band.
bands : list of str, optional
Which of `mission.detector`'s bandpasses to evaluate. Defaults to every
bandpass the detector has.
chunk_size : int, optional
Number of events (per transient type) whose observations are gathered and
evaluated together, bounding the size of each flattened `get_snr` batch. If
`None` (the default), uses ``config["simulation.filter_by_snr.chunk_size"]``
(2000 out of the box); see `filter_by_limiting_magnitude`'s docstring for
the same memory-vs-vectorization tradeoff on the evaluation side (the
schedule lookup itself is cheap now, so unlike before this no longer needs
to be small purely to bound how many schedule queries happen before the
next batch).
n_visits : int, optional
Minimum number of observations that must clear `snr_threshold` (in their
best band) for an event to survive. The default is 1: an event survives if
it is ever detected at all.
Returns
-------
EventCatalog
A new catalog over the surviving rows only (same `nside`/`order`/`time_bins`/
`seed` as `catalog`; original `event_id` values are preserved, not renumbered).
"""
# Validate the event catalog and the mission along with the necessary number of visits
# to be considered worthwhile.
if not isinstance(catalog, EventCatalog):
raise TypeError(f"'catalog' must be an EventCatalog, got {type(catalog)} instead.")
if not isinstance(mission, Mission):
raise TypeError(f"'mission' must be an m4opt.missions.Mission, got {type(mission)} instead.")
if isinstance(n_visits, bool) or not isinstance(n_visits, (int, np.integer)) or n_visits < 1:
raise ValueError(f"'n_visits' must be a positive int, got {n_visits!r}.")
if chunk_size is None:
chunk_size = config["simulation.filter_by_snr.chunk_size"]
detector = mission.detector
if detector is None:
raise ValueError(f"Mission {mission.name!r} has no detector configured.")
band_names = list(detector.bandpasses) if bands is None else list(bands)
unknown_bands = [band for band in band_names if band not in detector.bandpasses]
if unknown_bands:
raise ValueError(f"Unknown bandpass(es) {unknown_bands}; available: {list(detector.bandpasses)}.")
# Load the event catalog table and
table = catalog.table
missing = [
col for col in ("luminosity_distance", "ebv", "healpix_id", "parameter_seed") if col not in table.colnames
]
if missing:
raise ValueError(f"'catalog' is missing column(s) {missing}; regenerate it via `generate_events`.")
if len(table) == 0:
return EventCatalog(
table=table,
nside=catalog.nside,
order=catalog.order,
time_bins=catalog.time_bins,
seed=catalog.seed,
downsample=catalog.downsample,
)
schedule = self._survey_schedule
transient_type = np.asarray(table["transient_type"]).astype(str)
type_names = sorted(set(transient_type) & set(self._transients))
unknown_types = sorted(set(transient_type) - set(self._transients))
if unknown_types:
raise ValueError(f"'catalog' contains transient type(s) {unknown_types} not in `transient_collection`.")
keep = np.zeros(len(table), dtype=bool)
coord_all = table["coord"]
t_explosion_all = table["t_explosion"]
healpix_nside, healpix_order = catalog.nside, catalog.order
# Precompute chunk counts per type up front so the progress bar can report a
# single total spanning every chunk of every type, not just one tick per type.
type_idx = {name: np.flatnonzero(transient_type == name) for name in type_names}
n_chunks_by_type = {name: -(-idx.size // chunk_size) for name, idx in type_idx.items()}
total_chunks = sum(n_chunks_by_type.values())
with (
tqdm(total=total_chunks, desc="Filtering by SNR", unit="chunk") as pbar,
logging_redirect_tqdm(loggers=[logger]),
):
for name in type_names:
transient = self._transients[name]
idx = type_idx[name]
n = idx.size
n_chunks = n_chunks_by_type[name]
redshift_all = np.asarray(table["redshift"])[idx]
luminosity_distance_all = table["luminosity_distance"][idx]
ebv_all = np.asarray(table["ebv"])[idx]
seeds_all = np.asarray(table["parameter_seed"])[idx]
coord_type = coord_all[idx]
t_explosion_type = t_explosion_all[idx]
# As in `filter_by_limiting_magnitude`: each event's own `parameter_seed`,
# not a shared per-type stream -- see `_sample_parameters_from_seeds`.
sed_params_all = _sample_parameters_from_seeds(transient.sed, seeds_all)
for chunk_num, start in enumerate(range(0, n, chunk_size), start=1):
stop = min(start + chunk_size, n)
chunk_idx = idx[start:stop]
chunk_size_actual = stop - start
pbar.set_postfix(type=name, chunk=f"{chunk_num}/{n_chunks}")
# --- Step 1 & 2: gather every observation of every event in this
# chunk in one shot (one `get_observation_indices_of` call for the
# whole chunk, backed by the cached coverage index and doing
# exactly one QTable construction -- indexing `observe_rows` once
# by the whole chunk's row indices -- for the whole batch, *not*
# one query and one small QTable per event: at chunk_size ~ a few
# thousand, that per-event QTable-construction overhead was itself
# the dominant cost of this whole method, dwarfing the actual SNR
# computation below).
coord_chunk = coord_type[start:stop]
t_explosion_chunk = t_explosion_type[start:stop]
event_index, row_index = schedule.get_observation_indices_of(
coord_chunk,
nside=healpix_nside,
order=healpix_order,
start_time=t_explosion_chunk,
end_time=t_explosion_chunk + transient.duration_limit,
)
if len(row_index) == 0:
pbar.update(1)
continue
flat = schedule.observe_rows[row_index]
flat["event_index"] = event_index
flat["t_since_explosion"] = (flat["start_time"] - t_explosion_chunk[event_index]).to(u.day)
# --- Step 3: repeat each event's own quantities out to one row per
# (event, observation) pair, then evaluate the whole flattened
# chunk in one batch. Every batched quantity here (`t`, `redshift`,
# `luminosity_distance`, every SED param) shares the exact same flat
# row axis, so *those* need this reserved trailing axis -- otherwise
# it collides with the wavelength axis introduced later inside
# `get_snr` (see `Event.simulate_photometry`'s own comment on this;
# the same broadcasting rule applies here to every one of these
# arrays, not just `t`). `ebv` is the one exception: unlike
# `_eval`/`_eval_flux`'s own plain-broadcasting parameters,
# `dust.log_attenuation` reserves *its own* trailing axis internally
# (`Av[..., np.newaxis]`) -- giving it one here too would reserve it
# twice, silently producing an extra broadcast axis (verified: this
# produced a wrong, doubled-up `(chunk, chunk)`-shaped SNR before the
# fix). `ebv_flat` must stay flat, matching the same
# `test_synthetic_photometry_with_batched_dust_extinction` distinction.
redshift_chunk = redshift_all[start:stop]
luminosity_distance_chunk = luminosity_distance_all[start:stop]
ebv_chunk = ebv_all[start:stop]
sed_params_chunk = {param_name: value[start:stop] for param_name, value in sed_params_all.items()}
t_obs_flat = flat["t_since_explosion"][:, np.newaxis]
redshift_flat = redshift_chunk[event_index][:, np.newaxis]
luminosity_distance_flat = luminosity_distance_chunk[event_index][:, np.newaxis]
ebv_flat = ebv_chunk[event_index]
sed_params_flat = {
param_name: value[event_index][:, np.newaxis] for param_name, value in sed_params_chunk.items()
}
coord_flat = coord_chunk[event_index]
spectra = transient.sed.as_source_spectrum(
t_obs_flat,
redshift=redshift_flat,
luminosity_distance=luminosity_distance_flat,
ebv=ebv_flat,
**sed_params_flat,
)
best_snr = None
with observing(flat["observer_location"], coord_flat, flat["start_time"]):
for band in band_names:
snr = detector.get_snr(flat["duration"], spectra, band)
best_snr = snr if best_snr is None else np.maximum(best_snr, snr)
# --- Step 4: count, per chunk-local event, how many of its own
# rows clear `snr_threshold` in their best band.
above = best_snr > snr_threshold
visit_count = np.zeros(chunk_size_actual, dtype=np.int64)
np.add.at(visit_count, event_index[above], 1)
keep[chunk_idx[visit_count >= n_visits]] = True
pbar.update(1)
return EventCatalog(
table=table[keep],
nside=catalog.nside,
order=catalog.order,
time_bins=catalog.time_bins,
seed=catalog.seed,
downsample=catalog.downsample,
)