Target-of-Opportunity Follow-up of a TDE#

TDE End-to-End Simulation starts from a whole population, Monte Carlo sampled and screened against a real SurveySchedule. A target-of-opportunity (ToO) follow-up is the opposite problem: one specific TDE, at a position and redshift already known (from an alert, say), that gets pointed at directly on whatever cadence is chosen – no schedule, no windowed sampling, no Event.

simulate_photometry() is built for exactly this: given a sky position and whatever time grid and exposure time the caller wants evaluated, it runs the same noise model a real survey simulation uses (a batched SourceSpectrum plus get_snr()), with no schedule in the loop. This example follows one TDE weekly across its whole ~200-day duration and builds its UV light curves.

Choosing the target#

A target of opportunity is a specific object, not a population draw. TidalDisruptionEvent still supplies the right SED model (VanVelzenTDESED) and a conservative duration window; only its parameters are fixed here, to one concrete realization (via a seed, for reproducibility), rather than left to be sampled for a whole population. Milky Way foreground dust is resolved the same way a real survey simulation resolves it – resolve_ebv() against the real sky map – since it depends only on sky position, not on when the TDE happens to be observed.

import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.time import Time
from m4opt.missions import uvex
from m4opt.synphot.background import GalacticBackground

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()

tde = TidalDisruptionEvent()

coord = SkyCoord(ra=195.3 * u.deg, dec=27.8 * u.deg)
redshift = 0.03
luminosity_distance = tde.cosmology.luminosity_distance(redshift)
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'}

A weekly cadence#

No schedule to query – just pick the times to observe. tde.duration_limit is a conservative upper bound on how long this class of event stays relevant; weekly visits across that whole window is a plausible real follow-up cadence for something this slow.

CADENCE = 7 * u.day
EXPTIME = 900 * u.s

t = np.arange(0, tde.duration_limit.to_value(u.day), CADENCE.to_value(u.day)) * u.day
print(f"{len(t)} visits, one every {CADENCE}.")
29 visits, one every 7.0 d.

Simulating photometry, with no zodiacal light#

A real ToO trigger time isn’t known in advance, so there’s no meaningful obstime to feed a season-dependent background term like zodiacal light. Passing background=GalacticBackground() explicitly simulates against dust (already folded into the source flux itself, via ebv) plus the Milky Way’s diffuse UV glow only, and leaves observer_location/obstime at their placeholder defaults – both irrelevant to GalacticBackground, so there is nothing else to supply. See simulate_photometry()’s own docstring for exactly when that placeholder default is (and isn’t) safe.

phot = tde.sed.simulate_photometry(
    t,
    EXPTIME,
    uvex.detector,
    coord,
    background=GalacticBackground(),
    redshift=redshift,
    luminosity_distance=luminosity_distance,
    ebv=ebv,
    rng=0,
    **params,
)
print(phot["t", "band", "snr", "ab_mag"][:6])
 t   band         snr                ab_mag
 d
---- ---- -------------------- ------------------
 0.0  FUV 0.011435447604935759  28.88966586939173
 0.0  NUV 0.042660466209702985 28.658262785939527
 7.0  FUV    12.75841223741749 23.251605560381677
 7.0  NUV   28.266290110411916 22.325140164532897
14.0  FUV   114.19899302776052 18.797226845120594
14.0  NUV    228.6763329164951 17.849637520582068

The light curves#

The noiseless theory curve (mag(), evaluated at each band’s pivot wavelength) alongside the simulated weekly visits: 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

fig, ax = resolve_fig_axes(fig_size=(7, 4))
for band in ("FUV", "NUV"):
    nu = uvex.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),
        err_scale=5.0,
        label=band,
    )

ax.invert_yaxis()
ax.set_xlabel("Days since explosion")
ax.set_ylabel("AB magnitude")
ax.set_title(f"Weekly ToO follow-up (z={redshift}, {len(t)} visits)")
ax.legend()
fig.tight_layout()
Weekly ToO follow-up (z=0.03, 29 visits)

Bonus: what zodiacal light would have cost#

Suppose the trigger date had been known – say, this TDE actually went off on a specific night. Comparing that against the Galactic-only run above shows what assuming background=GalacticBackground() glossed over: passing background=None (the default) uses uvex’s own detector background, Galactic and zodiacal light together, and now real observer_location/obstime values (from observer_location()) are needed too, since zodiacal light actually depends on both.

hypothetical_trigger = Time("2031-03-01T00:00:00", scale="utc")
obstime = hypothetical_trigger + t
observer_location = uvex.observer_location(obstime)

phot_with_zodi = tde.sed.simulate_photometry(
    t,
    EXPTIME,
    uvex.detector,
    coord,
    observer_location=observer_location,
    obstime=obstime,
    redshift=redshift,
    luminosity_distance=luminosity_distance,
    ebv=ebv,
    rng=0,
    **params,
)

for band in uvex.detector.bandpasses:
    galactic_only = np.nanmedian(phot["snr"][phot["band"] == band])
    with_zodi = np.nanmedian(phot_with_zodi["snr"][phot_with_zodi["band"] == band])
    print(f"{band}: median SNR {galactic_only:.1f} (Galactic only) vs {with_zodi:.1f} (+ zodiacal)")
FUV: median SNR 27.5 (Galactic only) vs 27.5 (+ zodiacal)
NUV: median SNR 56.7 (Galactic only) vs 56.2 (+ zodiacal)

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

Gallery generated by Sphinx-Gallery