Simulation#
uvex_transients.simulation is where everything else in the package comes together:
a transients population, sampled against a real
SurveySchedule, becomes a Monte Carlo catalog of events –
narrowed down to the ones that actually matter – with real synthetic photometry available for
any one of them on demand. SurveySimulator does the
sampling and screening; EventCatalog holds the
result as a plain, portable data table; and Event
reconstructs one row of that table into something you can query for a light curve or a full
synthetic detection.
This page walks through that pipeline end to end. See Models and Transients for the layers underneath it, and Surveys for the schedule this all gets sampled against.
Quick Look#
The fastest way to get a feel for the pipeline is to run it. As in Surveys, we build a small synthetic schedule here (400 randomly-pointed 3x3 deg fields over 180 days) rather than downloading a real one, so the example below runs offline:
import numpy as np
import matplotlib.pyplot as plt
from astropy import units as u
from astropy.coordinates import EarthLocation, SkyCoord
from astropy.table import QTable
from astropy.time import Time
from regions import RectangleSkyRegion
from uvex_transients.surveys.base import SurveySchedule
from uvex_transients.simulation.core import SurveySimulator
from uvex_transients.transients.TDEs import TidalDisruptionEvent
n = 400
rng = np.random.default_rng(0)
table = QTable()
table["start_time"] = Time("2025-01-01T00:00:00") + np.sort(rng.uniform(0, 180, n)) * u.day
table["duration"] = np.full(n, 900.0) * u.s
table["observer_location"] = EarthLocation.from_geodetic(0 * u.deg, 0 * u.deg, 600 * u.km)
table["action"] = np.full(n, "observe")
table["target_coord"] = SkyCoord(
rng.uniform(0, 360, n) * u.deg,
np.degrees(np.arcsin(rng.uniform(-1, 1, n))) * u.deg,
)
table["roll"] = np.zeros(n) * u.deg
table["field_id"] = np.arrange(n)
table["block_id"] = np.zeros(n, dtype=int)
fov = RectangleSkyRegion(center=SkyCoord(0 * u.deg, 0 * u.deg), width=3 * u.deg, height=3 * u.deg)
schedule = SurveySchedule(table, fov)
tde = TidalDisruptionEvent()
simulator = SurveySimulator(schedule, transients={"tde": tde}, simulation_seed=0)
catalog = simulator.generate_events(time_bins=6, nside=32)
fig = plt.figure(figsize=(7, 4))
ax = fig.add_subplot(111, projection="aitoff")
ax.grid(True)
ra = catalog.coord.ra.wrap_at(180 * u.deg).radian
ax.scatter(ra, catalog.coord.dec.radian, s=4, color="C0")
ax.set_title(f"{len(catalog)} sampled TDEs across the example schedule")
That’s the whole shape of it: pair a transient population with a schedule, hand both to a
generate_events() call, and get back
every TDE that could plausibly have exploded somewhere the schedule looked. The rest of this page
covers the simulator itself, the two screening steps that narrow that population down to
detections, and how to turn any one surviving row back into a real light curve.
Hint
time_bins above is a plain integer – the number of evenly-spaced bins to divide the
schedule’s own span into. It also accepts an explicit Time array of bin
edges, for windows that don’t line up evenly with the schedule (e.g. one bin per lunation).
The Simulator Object#
SurveySimulator is a thin coordinator: a
SurveySchedule, a {name: transient} dict of
ExtragalacticTransient instances to sample, and a root
seed for reproducibility, all supplied at construction:
from uvex_transients.simulation.core import SurveySimulator
from uvex_transients.transients.TDEs import TidalDisruptionEvent
from uvex_transients.transients.kilonovae import Kilonova
simulator = SurveySimulator(
schedule,
transients={"tde": TidalDisruptionEvent(), "kne": Kilonova()},
simulation_seed=42,
)
simulator.survey_schedule # the SurveySchedule passed in
simulator.transient_collection # the {name: transient} dict
simulator.simulation_seed # 42
Important
Every transient in transients needs its own distinct name – this is the key that ties
each sampled event back to the transient instance that produced it (the catalog’s
transient_type column) and, later, that reconstructs an
Event from a catalog row via
get_events(). See
Transients for configuring a transient’s priors,
duration_limit, or redshift limit before
handing it to the simulator.
Simulating several transient types together, as above, is no different from simulating one –
every step below (generate_events, both filtering methods, and EventCatalog.get_events)
loops over every transient_type present in the catalog, keyed against this same
transient_collection dict.
Generating the Event Catalog#
generate_events() is the main event: a
Monte Carlo realization of every registered transient type, sampled only where and when the
schedule could plausibly have caught it.
Windowed Sampling#
Sampling a transient’s full redshift- and sky-dependent volumetric rate over the entire sky and
survey duration, then throwing away everything the schedule never observed, would waste almost all
of that effort – a real survey only ever covers a small fraction of the sky at any one time. To
avoid that, generate_events() divides the
schedule into time_bins windows and, for each window and each transient type, first asks
get_observed_healpix_ids() which HEALPix pixels
the schedule touches at all between the start of the window and its end plus that transient’s own
duration_limit – i.e. late enough that a transient exploding right at the end of the window
could still be caught while it’s active. Events are then sampled only within those pixels, via
sample_events_on_healpix_grid(), with
explosion times drawn uniformly within the window itself (never the padded tail), so no event is
ever double-counted across adjacent bins. See Transients for how that per-pixel
sampling itself works.
catalog = simulator.generate_events(time_bins=6, nside=32, order="nested")
nside/order set the HEALPix resolution both the coverage lookup and the sampling grid use
– the same tradeoff as everywhere else HEALPix shows up in this package: finer pixels track the
schedule’s real footprint more closely, at the cost of more pixels to sample per bin.
Tip
For a transient type with a very high intrinsic rate, downsample=k draws a random
1/k subset of each per-bin, per-type table (without replacement, seeded off
simulation_seed) instead of sampling the full population. Multiply any downstream count
by k to get back an estimate of the true yield; see the
Simulating Transients example for this in practice. downsample can instead be a
{transient key: k} mapping to downsample types individually – a type left out of the
mapping isn’t downsampled at all. Either form is stashed on the returned
EventCatalog as
downsample, purely as
provenance (nothing rescales counts back up automatically), and every cut carries it through
to its own output catalog unchanged.
Two columns are computed once here, rather than being left for every later step to re-derive: each
event’s luminosity_distance (interpolated off that transient type’s own cached
luminosity_distance_grid, not a
fresh cosmology call per event) and its ebv (one vectorized Milky Way foreground dust-map
query, via uvex_transients.dust, over every sampled position at once).
The Event Catalog#
generate_events() returns an
EventCatalog: a plain data table with no live
reference back to the schedule or the transient instances it was generated from (both are supplied
again, explicitly, wherever they’re needed – see Reconstructing and Simulating Events below). That
makes it trivially picklable and safe to round-trip to disk:
len(catalog) # number of sampled events
catalog.table # the underlying astropy.table.QTable
catalog.to_disk("tde_catalog.ecsv", overwrite=True)
reloaded = EventCatalog.from_disk("tde_catalog.ecsv")
to_disk() writes the table as ECSV
with nside/order/time_bins/seed/downsample stashed in the file’s header, so
from_disk() can reconstruct a
complete EventCatalog from the one file alone. (A file written by an older version of the
package, with no downsample in its header, still reads back fine – it just defaults to
None.)
Every column of catalog.table is also available as a convenience property, returning a plain
array (or Quantity/Time/
SkyCoord, as appropriate) rather than a raw table column:
Column / property |
Type |
Meaning |
|---|---|---|
|
int |
Unique id, assigned once across the whole catalog (not renumbered by filtering). |
|
str |
Which entry of |
|
int |
Index into |
|
int, float, float |
Sampling pixel (at this catalog’s own |
|
Sky position. |
|
|
float |
Cosmological redshift. |
|
Cached at generation time; see Simulation above. |
|
|
float |
Milky Way foreground E(B-V) at |
|
Time of explosion. |
|
|
int |
Regenerates this event’s physical SED parameters on demand – see Reconstructing and Simulating Events below. |
Note
An event’s physical SED parameters (amplitude, rise time, temperature, …) are deliberately
not stored as columns – only the parameter_seed that regenerates them. Storing one
column per parameter would mean a different schema per transient type; regenerating them
lazily from a stored seed keeps EventCatalog – and every filtering method below –
agnostic to which SED any given row actually uses.
Filtering the Event Catalog#
A freshly-sampled catalog is dominated by events far too faint to ever matter – most of a
transient’s redshift-limited volume is, by construction, near the limit where it’s essentially
undetectable. Two progressively more expensive passes narrow it down to the events actually worth
keeping, both taking an EventCatalog and an Mission (for its
Detector’s bandpasses) and returning a new EventCatalog over the
surviving rows – nside/order/time_bins/seed/downsample unchanged, and
original event_id values preserved rather than renumbered:
Method: filter_by_limiting_magnitude()
Description: Schedule-independent – it never checks whether the schedule actually
pointed anywhere near an event. For each transient type, every event’s own physical
parameters are regenerated from its stored parameter_seed and evaluated over a shared
linspace(0, duration_limit, n_phase) phase grid, the same grid for every event of that
type; an event survives if its brightest requested band clears mag_limit at n_visits
or more of those phase samples.
mag_filtered = simulator.filter_by_limiting_magnitude(
catalog, mission, mag_limit=25.0,
)
Uses: A cheap first pass: “could this event, at its absolute brightest, ever be seen at
all?” chunk_size bounds peak memory (evaluation broadcasts every event and phase sample
into one dense array at once), and raising n_visits above its default of 1 discards
events that only momentarily clear the limit.
Method: filter_by_snr()
Description: The real question, schedule and all: over every observation the schedule
actually made of an event’s position while it was active, is it ever detected above
snr_threshold? Every surviving event’s observations are gathered with one batched
get_observation_indices_of() call per
chunk (see Surveys), then evaluated together with
get_snr().
detected = simulator.filter_by_snr(
mag_filtered, mission, snr_threshold=5.0,
)
Uses: The actual detected population. Run it against the output of
filter_by_limiting_magnitude, not the raw catalog – it’s the more expensive of the two
(every remaining event costs at least one schedule query), so there’s no reason to pay that
cost on events the cheap pass would have rejected anyway.
from m4opt.missions import uvex
mission = uvex
mag_filtered = simulator.filter_by_limiting_magnitude(catalog, mission, mag_limit=25.0)
detected = simulator.filter_by_snr(mag_filtered, mission, snr_threshold=5.0)
stages = ["Sampled", "Mag < 25", "SNR > 5"]
counts = [len(catalog), len(mag_filtered), len(detected)]
fig, ax = plt.subplots()
ax.bar(stages, counts, color=["#888888", "#4C72B0", "#55A868"])
for i, count in enumerate(counts):
ax.text(i, count, f"{count:,}", ha="center", va="bottom")
ax.set_ylabel("Number of TDEs")
ax.set_title("TDE detection funnel")
Exposure and Yield#
A raw or filtered EventCatalog count is a realization, not an estimate – to turn one into a
formal expected-detection number with confidence bounds (Yield Statistics derives every
estimator below), you also need to know how much of the sky the survey actually covered.
compute_effective_exposure() answers that,
independent of any Monte Carlo draw: it reruns exactly the same per-bin, per-type footprint query
generate_events restricts its own sampling to, but reduces it to a solid angle instead of a
drawn population:
exposure = simulator.compute_effective_exposure(time_bins=6, nside=32)
exposure.total_effective_exposure # {transient type: total solid-angle*time exposure}
exposure.total_expected_events # {transient type: mu_0, the footprint-aware expected count}
exposure.coverage_fraction # {transient type: fraction of the full 4*pi sky-time swept}
The returned ExposureCatalog has one row per
(transient type, time bin) – the same time_bins/nside/order as generate_events
should always be passed here too, so both describe the same footprint query. Each row’s
expected_events is effective_exposure * transient.integrated_rate – Yield Statistics’s
\(\mu_0\) for that bin – so summing it over every bin (total_expected_events) gives the
intrinsic expected count actually reachable by this schedule’s footprint, not
compute_all_sky_yield()’s idealized
full-sky number. get_exposure_between()/
get_expected_events_between() and
rebin() let you query or
re-tile that same tabulated exposure over an arbitrary sub-window or a different time binning
without re-querying the schedule.
Combine a raw (feasible) catalog, a detected (post-cut) catalog, and its exposure into one
per-transient-type summary with
compute_yield_summary():
yields = catalog.compute_yield_summary(detected, exposure, {"tde": tde}, confidence=0.9)
yields.table["transient_type", "uvex_intrinsic_events", "detection_probability", "expected_detections"]
The returned YieldTable carries, per transient
type, the rate (integrated_rate/all_sky_rate), the footprint-aware intrinsic rate/count
(uvex_intrinsic_rate/uvex_intrinsic_events, i.e. \(\mu_0\)), the Monte Carlo detection
efficiency (detection_probability, \(\hat\epsilon=k/n\)), and the final yield estimate
(expected_detections, \(\hat\lambda=\mu_0\hat\epsilon\)) – each rate-derived column with
its own RATE_CI-propagated bounds, and detection_probability/expected_detections with
two separate uncertainty sources (Clopper-Pearson binomial and rate-normalization), kept apart
as ..._binom_lower/_upper and ..._rate_lower/_upper columns rather than combined
into one. to_ascii() writes a
human-readable summary table; to_latex()
renders both uncertainty sources as stacked LaTeX superscripts for a paper table.
Hint
For a finer-grained question than “was this event detected at all” – “how many separate
epochs was it detected in” – run synthetic photometry over a whole catalog with
compute_photometry_catalog()
(wrapping simulate_photometry(),
below, as a PhotometryCatalog), then
call its own
compute_detection_count_table().
It generalizes compute_yield_summary’s “detected at all” (\(N_{\rm det}\geq 1\)) to
“detected in at least \(k\) epochs” for every \(k\) at once, with the same two-source
uncertainty treatment – exactly what the detection-counts CLI command
(Command-Line Interface) automates.
Reconstructing and Simulating Events#
Every screening step above works with cheap, batched approximations – a shared phase grid, a
single flux point at each band’s pivot wavelength. Getting a real, per-observation synthetic
light curve for one particular event means reconstructing it as a full
Event, via
get_events(). Because an
EventCatalog holds no live reference to the schedule or the transients it came from (see
above), both are supplied again here – typically the same ones the catalog was generated with:
event = detected.get_events(19, {"tde": tde}, schedule)
print(event)
... <Event id=19 type='tde' z=0.7097 n_observations=2>
Building an Event runs exactly one query against the schedule
(get_observations_of()) to find which scheduled
observations actually covered it while active – available as
observations
(n_observations) – alongside its coord,
redshift, luminosity_distance, ebv, and t_explosion. No photometry is done yet at
this point; sample_parameters() regenerates its
physical SED parameters (deterministically, from the same stored parameter_seed) as a pure,
idempotent lookup any of the methods below can call as many times as needed.
Theoretical Curves#
mag(),
flux(), and
luminosity() give the noiseless truth this event’s
real photometry (below) scatters around – the first two folding in this event’s own redshift,
distance, and foreground dust; the last a rest-frame, distance-independent bolometric quantity:
t = np.linspace(0, tde.duration_limit.to_value(u.day), 300) * u.day
event.mag(t, mission, band="NUV") # apparent AB magnitude
event.flux(t, mission, band="NUV") # observed flux density, at the band's pivot wavelength
event.luminosity(t) # rest-frame bolometric L_bol(t)
Synthetic Photometry#
simulate_photometry() is the expensive step every
earlier screening pass was designed to defer: real, per-observation, per-band synthetic photometry
against every observation in observations, batched into one
get_snr() call per band regardless of how many observations there
are:
event = detected.get_events(19, {"tde": tde}, schedule)
phot = event.simulate_photometry(mission)
t_since_explosion = (phot["obs_time"] - event.t_explosion).to(u.day)
t_theory = np.linspace(0, tde.duration_limit.to_value(u.day), 300) * u.day
fig, ax = plt.subplots(figsize=(7, 4))
for band, color in {"FUV": "#4C72B0", "NUV": "#DD8452"}.items():
ax.plot(t_theory.value, event.mag(t_theory, mission, band=band).value, color=color, lw=1.5, alpha=0.6)
in_band = np.isfinite(phot["ab_mag"]) & (phot["band"] == band)
if np.any(in_band):
ax.errorbar(
t_since_explosion[in_band].value,
phot["ab_mag"][in_band],
yerr=phot["mag_err"][in_band],
marker="s", mfc=color, mec="k", ecolor=color, linestyle="none", label=band,
)
ax.invert_yaxis()
ax.set_xlabel("Days since explosion")
ax.set_ylabel("AB magnitude")
ax.set_title(f"Event {event.event_id} (z={event.redshift:.2f}, {event.n_observations} observations)")
ax.legend()
This example schedule only ever visits a given field once, so most of its events – like this one – end up with just one or two associated observations; see the Simulating Transients for the same workflow against a real, densely-cadenced UVEX schedule.
Each returned row is a Gaussian realization of the true flux at that observation’s implied SNR,
not the ground truth itself – flux/ab_mag and their symmetric-in-flux n_sigma bounds
(flux_upper/flux_lower, transformed separately to mag_upper/mag_lower rather than
through a single linearized mag_err) are nan wherever the noisy draw itself isn’t securely
above zero flux – the correct behavior at low SNR, not a bug. The whole event replays identically
given the same parameter_seed, since every parameter draw and every band’s noise realization
comes from one RNG seeded from it.
See the Simulating Transients for a full worked example of this entire pipeline against a real
UVEX schedule, and uvex_transients.simulation in the API reference for exhaustive
method-by-method detail.