Multi-Band ToO Follow-up#

The primary design scope of uvex-transients is to simulate yields from UVEX’s various surveys; however, there will certainly be cases when UVEX observes sources with corresponding OIR data from ZTF / Rubin / other ground based surveys. In some cases, this may be serendipetous, but in others, it may be because the sources was discovered and classified by a ground based observatory and a TOO was triggered on UVEX.

In this example, we’ll show off a simulation of this sort of scenario: Rubin gets on target first and starts a multi-band follow-up campaign of its own, then UVEX is triggered and joins in with its two UV bands.

import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.table import vstack
from m4opt.missions import rubin, uvex
from m4opt.synphot.background import GalacticBackground, SkyBackground
from matplotlib import pyplot as plt

from uvex_transients.dust import dust_map, log_attenuation, resolve_ebv
from uvex_transients.transients.TDEs import TidalDisruptionEvent
from uvex_transients.utils.plotting import get_band_color, plot_band_light_curve, resolve_fig_axes, set_plot_style

set_plot_style()

# Configure the transient and its parameters.
tde = TidalDisruptionEvent()

coord = SkyCoord(ra=195.3 * u.deg, dec=27.8 * u.deg)
redshift = 0.2
luminosity_distance = tde.cosmology.luminosity_distance(redshift)

# Determine the redenning from the dust map.
ebv = float(resolve_ebv(dust_map(), coord))

params = {name: value[0] for name, value in tde.sed.sample_parameters(1, rng=12345).items()}
print(f"E(B-V) at target: {ebv:.3f}")
print("SED parameters:", {name: f"{value:.3g}" for name, value in params.items()})
E(B-V) at target: 0.007
SED parameters: {'amplitude': '2.36e+43 erg / s', 'sigma_rise': '3.58 d', 'tau_decline': '26 d', 'temperature': '1.44e+04 K'}

Choosing the cadences#

Rubin’s redder bands (r/i/z/y) get visited every 5 days and its bluer bands (u/g) every 10 days – roughly the cadence split of the Rubin/LSST wide-fast-deep baseline, all at Rubin’s standard 30 s visit exposure time. UVEX joins on its own 10-day cadence for FUV/NUV. Every band that shares an (instrument, cadence) pair also shares one time grid, so simulate_photometry() only needs to be called once per group.

RUBIN_CADENCES = {
    "u": 10 * u.day,
    "g": 10 * u.day,
    "r": 5 * u.day,
    "i": 5 * u.day,
    "z": 5 * u.day,
    "y": 5 * u.day,
}
RUBIN_EXPTIME = 30 * u.s
UVEX_CADENCE = 20 * u.day
UVEX_EXPTIME = 900 * u.s

# Rubin/LSST's own systematic photometric calibration floor (Table 14 of the LSST
# Science Requirements Document, LPM-17 -- the same `sigma_sys` `rubin_sim.phot_utils`
# uses), in magnitudes. `simulate_photometry`'s own noise model is shot noise only
# (source + sky Poisson, detector read/dark noise, via the standard CCD equation); it
# has no notion of flat-fielding, PSF-fit, or zeropoint calibration systematics, which
# is why bright-end Rubin points would otherwise come out implausibly precise. UVEX's
# points are left as pure shot noise -- no comparably-established floor for it here.
RUBIN_SIGMA_SYS = {
    "u": 0.0075,
    "g": 0.005,
    "r": 0.005,
    "i": 0.005,
    "z": 0.0075,
    "y": 0.0075,
}

rubin_band_groups: dict[u.Quantity, list[str]] = {}
for band, cadence in RUBIN_CADENCES.items():
    rubin_band_groups.setdefault(cadence, []).append(band)

Simulating photometry#

As in the single-instrument case, there’s no known real trigger time, so each instrument’s background is chosen so that it doesn’t need one: SkyBackground.medium() for Rubin (a fixed dark-sky brightness condition, no zodiacal light) and GalacticBackground() for UVEX (the Milky Way’s diffuse UV glow only), leaving observer_location/obstime at their placeholder defaults. See simulate_photometry()’s own docstring for exactly when that placeholder default is (and isn’t) safe.

Each (instrument, cadence) group gets its own simulate_photometry call and the resulting tables are stacked together afterwards, tagged with an instrument column. Rubin’s call also passes sys_err=RUBIN_SIGMA_SYS, so its snr/flux_err/mag_err – and the simulated flux/ab_mag draw itself – reflect the combined shot-noise-plus-systematic uncertainty; UVEX’s call doesn’t, so its points stay pure shot noise.

tables = []

for cadence, bands in rubin_band_groups.items():
    t_rubin = np.arange(0, tde.duration_limit.to_value(u.day), cadence.to_value(u.day)) * u.day
    phot_group = tde.sed.simulate_photometry(
        t_rubin,
        RUBIN_EXPTIME,
        rubin.detector,
        coord,
        bands=bands,
        background=SkyBackground.medium(),
        redshift=redshift,
        luminosity_distance=luminosity_distance,
        ebv=ebv,
        sys_err=RUBIN_SIGMA_SYS,
        rng=0,
        **params,
    )
    phot_group["instrument"] = "Rubin"
    tables.append(phot_group)
    print(f"Rubin {'/'.join(bands)}: {len(t_rubin)} visits, one every {cadence}.")

t_uvex = np.arange(0, tde.duration_limit.to_value(u.day), UVEX_CADENCE.to_value(u.day)) * u.day
phot_uvex = tde.sed.simulate_photometry(
    t_uvex,
    UVEX_EXPTIME,
    uvex.detector,
    coord,
    background=GalacticBackground(),
    redshift=redshift,
    luminosity_distance=luminosity_distance,
    ebv=ebv,
    rng=0,
    **params,
)
phot_uvex["instrument"] = "UVEX"
print(f"UVEX FUV/NUV: {len(t_uvex)} visits, one every {UVEX_CADENCE}.")
tables.append(phot_uvex)

phot = vstack(tables)
phot.sort(["t", "band"])
print(phot["t", "instrument", "band", "snr", "ab_mag"][:6])
Rubin u/g: 20 visits, one every 10.0 d.
Rubin r/i/z/y: 40 visits, one every 5.0 d.
UVEX FUV/NUV: 10 visits, one every 20.0 d.
 t  instrument band          snr                 ab_mag
 d
--- ---------- ---- ---------------------- ------------------
0.0       UVEX  FUV 0.00013221681436506683  28.99481738028228
0.0       UVEX  NUV  0.0006738824614370494                nan
0.0      Rubin    g  0.0015027763939941836                nan
0.0      Rubin    i  0.0006517172496378308                nan
0.0      Rubin    r  0.0008886615886936758 29.203724142556172
0.0      Rubin    u  0.0006981291295000424 28.824280282178023

The light curves#

The noiseless theory curve (mag(), evaluated at each band’s pivot wavelength) alongside the simulated visits from both instruments: detections above SNR=5 as points with error bars, fainter visits as downward-pointing upper limits – the same plotting convention used at the end of TDE End-to-End Simulation.

SNR_THRESHOLD = 5.0
t_theory = np.linspace(0, tde.duration_limit.to_value(u.day), 300) * u.day

band_detectors = {band: rubin.detector for band in RUBIN_CADENCES} | {
    "FUV": uvex.detector,
    "NUV": uvex.detector,
}

fig, ax = resolve_fig_axes(fig_size=(9, 5))
for band, detector in band_detectors.items():
    nu = detector.bandpasses[band].pivot().to(u.Hz, equivalencies=u.spectral())
    theory_mag = tde.sed.mag(
        nu,
        t_theory,
        redshift=redshift,
        luminosity_distance=luminosity_distance,
        log_attenuation=log_attenuation(nu, ebv),
        **params,
    )
    plot_band_light_curve(
        ax,
        band,
        phot["t"],
        phot,
        t_theory=t_theory,
        theory_mag=theory_mag,
        snr_threshold=SNR_THRESHOLD,
        color=get_band_color(band),
        label=band,
    )

ax.invert_yaxis()
ax.set_xlabel("Days since explosion")
ax.set_ylabel("AB magnitude")
ax.set_ylim([25, None])
ax.set_title(f"Rubin + UVEX ToO follow-up (z={redshift})")
ax.legend(ncol=4, fontsize=8)
fig.tight_layout()
plt.show()
Rubin + UVEX ToO follow-up (z=0.2)

Total running time of the script: (0 minutes 0.247 seconds)

Gallery generated by Sphinx-Gallery