Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

IRSA Tutorials

Following a simulated supernova through the Roman Time Domain Survey

Learning Goals:

By the end of this tutorial, you will:

  1. learn more about the “observations” that make up the simulated Roman Time Domain Survey (TDS).

  2. learn how to find the locations of simulated supernovae in the transient catalog.

  3. learn how to ask IRSA which simulated Roman images cover a given position and time.

  4. learn how to create aligned cutouts of simulated Roman images.

  5. learn how to make an animated gif from these cutouts.

Introduction

The Roman Time Domain Survey revisits the same patch of sky over and over, which is what makes it possible to watch a transient appear and fade. This notebook picks one simulated Type Ia supernova out of the OpenUniverse2024 transient catalog, collects every Roman image that covers it while it is bright, and stacks those images into a short movie.

The survey stores its images by pointing and detector rather than by sky position, so which files contain a particular supernova is not something you can work out from the file paths. IRSA’s Simple Image Access (SIA) service answers exactly that question, and we use it here to assemble the list of images to stack.

If you are new to OpenUniverse2024, the Quickstart tutorial introduces the directory layout, the parquet catalogs, and the image search used below.

Install and Import required modules

# Uncomment the next line to install dependencies if needed.
# !pip install astropy matplotlib numpy pandas pyarrow s3fs scipy astroquery hpgeom ipython
# Import modules
import warnings
import json

import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import hpgeom
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.nddata import Cutout2D
from astropy.nddata.utils import NoOverlapError
from astropy.wcs import WCS, FITSFixedWarning
from astroquery.ipac.irsa import Irsa
from matplotlib import animation
from scipy.ndimage import rotate
from IPython.display import HTML

# Needed to access data in the cloud
import s3fs
s3 = s3fs.S3FileSystem(anon=True)  # create an S3 client

# Filter out the FITSFixedWarning, which is consequenceless and gets thrown every time you deal with a WCS
# in a Roman openuniverse simulated image using astropy.
warnings.simplefilter('ignore', category=FITSFixedWarning)
# Point the astroquery IRSA client at the simulated-data services, which are
# separate from the ones serving IRSA's observed data.
Irsa.sia_url = "https://irsa.ipac.caltech.edu/simulated/SIA"
Irsa.tap_url = "https://irsa.ipac.caltech.edu/simulated/TAP"

OU_ROMAN_SIA_COLLECTION = 'simulated_roman_openuniverse2024'

Read in the Observation Sequence File to learn more about the “observations” that make up the simulated Roman Time Domain Survey.

# Read in the (simulated) Observation Sequence File.

BUCKET_NAME = 'nasa-irsa-simulations'
ROMAN_PREFIX = 'openuniverse2024/roman/full'

ROMAN_TDS_PATH = f'{ROMAN_PREFIX}/RomanTDS'
FILENAME = 'Roman_TDS_obseq_11_6_23.fits'
OBSEQ_PATH = f's3://{BUCKET_NAME}/{ROMAN_TDS_PATH}/{FILENAME}'

obseq_hdu = fits.open(OBSEQ_PATH, fsspec_kwargs={"anon": True})
obseq = pd.DataFrame(obseq_hdu[1].data)

print(obseq)
             ra      dec filter  exptime         date        pa
0       7.60523 -45.6541   R062  161.025  62000.02139   0.00000
1       7.60523 -44.8337   R062  161.025  62000.02407   0.00000
2       7.60523 -44.0134   R062  161.025  62000.02674   0.00000
3       7.60523 -43.1930   R062  161.025  62000.02941   0.00000
4       7.60523 -42.3727   R062  161.025  62000.03209   0.00000
...         ...      ...    ...      ...          ...       ...
57360  11.81670 -44.9265   K213  901.175  63563.01290  19.72602
57361  11.53980 -44.1543   K213  901.175  63563.02420  19.72602
57362  11.26290 -43.3821   K213  901.175  63563.03540  19.72602
57363  10.98600 -42.6099   K213  901.175  63563.04660  19.72602
57364  10.70920 -41.8377   K213  901.175  63563.05790  19.72602

[57365 rows x 6 columns]

What is the spatial and temporal coverage of the openuniverse2024 Roman TDS?

# Find the ranges of RA, Dec, and date listed in the observation sequence file.

ra_min, dec_min = obseq[['ra','dec']].min()
ra_max, dec_max = obseq[['ra','dec']].max()
mjd_min = obseq['date'].min()
mjd_max = obseq['date'].max()

print("ra_min, ra_max:", ra_min, ra_max)
print("mjd_min, mjd_max:", mjd_min, mjd_max)
ra_min, ra_max: 6.97879 12.0204
mjd_min, mjd_max: 62000.02139 63563.0579

Read in the Supernova Analysis (SNANA) file.

The transient catalogs are split by HEALPix sky region (nside=32, RING ordering), with the region index in the filename. We convert the center of the Roman TDS into that index rather than guessing at the file name.

# The Roman Time-Domain Survey is centered near the LSST ELAIS-S1 Deep Drilling Field.
region = hpgeom.angle_to_pixel(32, 9.45, -44.02, lonlat=True, nest=False)

parquet_file = f's3://{BUCKET_NAME}/{ROMAN_PREFIX}/roman_rubin_cats_v1.1.2_faint/snana_{region}.parquet'
transients = pd.read_parquet(parquet_file, filesystem=s3)

Let’s find a relatively nearby SN Ia that the survey actually watched go off.

#List the unique models in the SNANA file.
unique_models = pd.Series(transients['model_name']).drop_duplicates().tolist()
unique_models
['FIXMAG', 'NON1ASED.KN-K17', 'NON1ASED.PISN-STELLA-HECORE', 'NON1ASED.PISN-STELLA-HYDROGENIC', 'NON1ASED.SLSN-I-BBFIT', 'NON1ASED.V19_CC+HostXT_WAVEEXT', 'SALT3.NIR_WAVEEXT', 'NON1ASED.SNIax', 'NON1ASED.TDE-BBFIT']
# Most of the models are non SNIa (NON1ASED).
# Choose only the SNIa
sn1a = transients[transients['model_name'] == 'SALT3.NIR_WAVEEXT'] # SNe Ia only.
print('Number of SN1a in SNANA file: ', len(sn1a))
Number of SN1a in SNANA file:  10369
# Choose the SNIa that overlap with the spatial and temporal extent of the survey.
ra_mask = np.logical_and(sn1a['ra'] > ra_min, sn1a['ra'] < ra_max)
dec_mask = np.logical_and(sn1a['dec'] > dec_min, sn1a['dec'] < dec_max)
mjd_mask = np.logical_and(sn1a['start_mjd'] > mjd_min, sn1a['end_mjd'] < mjd_max)
all_mask = np.logical_and.reduce((ra_mask,dec_mask,mjd_mask))
covered_sn1a = sn1a[all_mask]
print('Number of SNIa within the survey:', len(covered_sn1a))
Number of SNIa within the survey: 6718
# Choose the SNIa that are nearby, at redshifts less than 0.5, so they are bright enough
# to stand out clearly against their host galaxy.
nearby_sn1a = covered_sn1a[covered_sn1a['z_CMB'] < 0.5]
print('Number of nearby SNIa:', len(nearby_sn1a))
Number of nearby SNIa: 199

A supernova is only worth animating if Roman happened to be looking at that patch of sky while it was bright. The catalog records the date each one peaks in peak_mjd, and the survey visits any given field in bursts rather than continuously, so we pick an object whose peak falls inside a well-visited stretch of the survey.

The cuts above cannot check this for us. They confirm that a supernova went off somewhere inside the survey’s footprint and date range, not that the telescope was pointed at it while it was bright. That combination is common, and when it happens the epoch window below comes back empty and there is nothing to animate. So if you change oid to explore a different object, check that the dates it was bright (start_mjd to end_mjd) overlap the dates Roman visited its position (the t_min column of the image search below).

# Let's choose SN 20131477, which peaks while its field is being visited regularly.
oid = 20131477
chosen_object = nearby_sn1a[nearby_sn1a['id'] == oid].iloc[0]

ra, dec = chosen_object['ra'], chosen_object['dec']
peak_mjd = chosen_object['peak_mjd']
coord = SkyCoord(ra*u.deg, dec*u.deg)

print(f"SN {oid}: RA={ra:.6f}, Dec={dec:.6f}, z={chosen_object['z_CMB']:.3f}, peaks at MJD {peak_mjd:.1f}")
SN 20131477: RA=9.988442, Dec=-44.061671, z=0.259, peaks at MJD 62258.3

Ask IRSA which simulated Roman images cover the chosen SNIa.

We hand the position to IRSA’s image search, which returns one row per image along with the time it was taken and where the file lives in the cloud.

sia_results = Irsa.query_sia(pos=(coord, 1 * u.arcsec),
                             collection=OU_ROMAN_SIA_COLLECTION)

print(f"Images covering this position: {len(sia_results)}")
Images covering this position: 1720

That covers every band and both Roman surveys, so we narrow it down to the Time Domain Survey images in a single band.

band = 'R062'

is_tds = np.array(['TDS_simple_model' in str(obs_id) for obs_id in sia_results['obs_id']])
is_band = np.char.strip(np.array(sia_results['energy_bandpassname'], dtype=str)) == band

instances = sia_results[is_tds & is_band]
instances.sort('t_min')

print(f"{band} images covering SN {oid}: {len(instances)}")
R062 images covering SN 20131477: 221

Finally we keep only the epochs around the peak. Frames from years before the explosion would add nothing to the movie beyond download time, so we take a window that starts shortly before the supernova appears and runs until it has faded.

epoch_mjd = np.asarray(instances['t_min'], dtype=float)
in_window = (epoch_mjd >= peak_mjd - 40) & (epoch_mjd <= peak_mjd + 80)

instances = instances[in_window]
epoch_mjd = epoch_mjd[in_window]

print(f"Epochs to animate: {len(instances)}, "
      f"spanning MJD {epoch_mjd.min():.1f} to {epoch_mjd.max():.1f}")
Epochs to animate: 31, spanning MJD 62235.1 to 62335.1

The cloud location of each image arrives as a JSON string, which we unpack into an S3 path.

Notebook Cell
def get_s3_fpath(cloud_access):
    """Extract the S3 URI from the cloud_access JSON string in an image search result."""
    cloud_info = json.loads(cloud_access)['aws']
    return f"s3://{cloud_info['bucket_name']}/{cloud_info['key']}"
image_paths = [get_s3_fpath(row['cloud_access']) for row in instances]
image_paths[:3]
['s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18117/Roman_TDS_simple_model_R062_18117_18.fits.gz', 's3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18121/Roman_TDS_simple_model_R062_18121_7.fits.gz', 's3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18502/Roman_TDS_simple_model_R062_18502_18.fits.gz']

Create cutouts of the chosen SNIa.

Roman’s focal plane sits at a different angle on the sky at each visit, so the same patch of sky arrives rotated differently in every image. Before the frames can be stacked into a movie we rotate each one so that north points the same way throughout. The rotation angle is recorded in the header, and detectors in every third slot are mounted flipped, which we correct for as well.

#Make the cutouts; this will take a few minutes.
stamps = []
mjd = []
for imgpath, epoch in zip(image_paths, epoch_mjd):
    print(imgpath)
    with fits.open(imgpath, fsspec_kwargs={"anon": True}) as hdu:
        img = hdu[1].data
        header = hdu[0].header
        wcs = WCS(header)
        x, y = wcs.world_to_pixel(coord)

        # Manually rotate the images so they are all aligned.
        CDmat = np.array([header['CD1_1'], header['CD1_2'],
                          header['CD2_1'], header['CD2_2']]).reshape(2,2)

        orientation = header['ORIENTAT']

        # These chips are "flipped".
        if header['SCA_NUM'] % 3 == 0:
            orientation += 180

        # Build rotation matrix.
        CD1_1_rot = np.cos(-orientation*np.pi/180)
        CD1_2_rot = -np.sin(-orientation*np.pi/180)
        CD2_1_rot = np.sin(-orientation*np.pi/180)
        CD2_2_rot = np.cos(-orientation*np.pi/180)

        RotMat = np.array([CD1_1_rot, CD1_2_rot,
                          CD2_1_rot, CD2_2_rot]).reshape(2,2)

        RotMat_inv = np.array([CD1_1_rot, -CD1_2_rot,
                              -CD2_1_rot, CD2_2_rot]).reshape(2,2)

        # Apply rotation to the CDi_j header keywords.
        CDmat_rot = np.dot(CDmat,RotMat_inv)

        # Update header.
        header['CD1_1'], header['CD1_2'] = CDmat_rot[0]
        header['CD2_1'], header['CD2_2'] = CDmat_rot[1]
        header['ORIENTAT'] -= orientation

        # Rotate the image.
        rot_img = rotate(img,angle=orientation,reshape=False,cval=np.nan)

        rot_wcs = WCS(header)

        try:
            # Make cutout around SN Ia location.
            cutout = Cutout2D(rot_img,coord,100,wcs=rot_wcs,mode='partial')
            stamps.append(cutout.data)
            mjd.append(epoch)
        except NoOverlapError:
            pass

print(f"Collected {len(stamps)} cutouts")
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18117/Roman_TDS_simple_model_R062_18117_18.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18121/Roman_TDS_simple_model_R062_18121_7.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18502/Roman_TDS_simple_model_R062_18502_18.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18886/Roman_TDS_simple_model_R062_18886_7.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/18887/Roman_TDS_simple_model_R062_18887_17.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/19272/Roman_TDS_simple_model_R062_19272_13.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/19657/Roman_TDS_simple_model_R062_19657_13.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/19662/Roman_TDS_simple_model_R062_19662_15.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/20427/Roman_TDS_simple_model_R062_20427_10.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/20432/Roman_TDS_simple_model_R062_20432_18.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/20807/Roman_TDS_simple_model_R062_20807_4.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/20817/Roman_TDS_simple_model_R062_20817_18.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/21192/Roman_TDS_simple_model_R062_21192_5.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/21197/Roman_TDS_simple_model_R062_21197_11.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/21202/Roman_TDS_simple_model_R062_21202_17.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/21582/Roman_TDS_simple_model_R062_21582_11.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/21587/Roman_TDS_simple_model_R062_21587_17.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/21967/Roman_TDS_simple_model_R062_21967_15.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/22352/Roman_TDS_simple_model_R062_22352_15.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/22357/Roman_TDS_simple_model_R062_22357_16.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/22737/Roman_TDS_simple_model_R062_22737_18.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/22742/Roman_TDS_simple_model_R062_22742_16.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/23508/Roman_TDS_simple_model_R062_23508_3.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/23513/Roman_TDS_simple_model_R062_23513_8.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/23898/Roman_TDS_simple_model_R062_23898_5.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/24283/Roman_TDS_simple_model_R062_24283_3.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/24668/Roman_TDS_simple_model_R062_24668_12.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/25058/Roman_TDS_simple_model_R062_25058_3.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/25442/Roman_TDS_simple_model_R062_25442_7.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/25443/Roman_TDS_simple_model_R062_25443_12.fits.gz
s3://nasa-irsa-simulations/openuniverse2024/roman/full/RomanTDS/images/simple_model/R062/25828/Roman_TDS_simple_model_R062_25828_18.fits.gz
Collected 31 cutouts

Define a module to create an animated gif from a collection of cutouts.

Notebook Cell
def animate_stamps(stamps, savepath, no_whitespace=True,
                   labels=[],labelxy=(0.05,0.95)):
    """Make an animation of a sequence of image stamps.

    Parameters
    ----------
    stamps : list of `~numpy.ndarray`
        Image stamps, in chronological order.
    savepath : str
        Path to save the gif to.
    no_whitespace : bool, optional
        Drop any stamp that is entirely NaN, along with its label.
    labels : list of str, optional
        Per-frame text, drawn in the corner of each frame.
    labelxy : tuple of float, optional
        Position of the label, in axes fractions.

    Returns
    -------
    `~matplotlib.animation.FuncAnimation`
        The animation, for displaying with playback controls.
    """

    if no_whitespace:
        with_whitespace = np.invert(np.any((np.isnan(np.array(stamps))), axis=(1,2))) # NOTE: Your first axis (first indexing value) should return one stamp. e.g. stamps[0] is the first stamp.
        idx_whitespace = np.where(with_whitespace)[0]
        stamps = np.array(stamps)[idx_whitespace]
        if len(labels) != 0:
            labels = np.array(labels)[idx_whitespace]

    fig, ax = plt.subplots(figsize=(5,5))
    fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=None, hspace=None)
    plt.xticks([])
    plt.yticks([])

    im = ax.imshow(stamps[0], animated=True)

    if len(labels) != 0:
        txt = ax.text(labelxy[0],labelxy[1],labels[0],animated=True,color='white',transform=ax.transAxes,va='top',ha='left')

    def animate(i):
        im.set_array(stamps[i])
        if len(labels) != 0:
            txt.set_text(labels[i])

            return [im] + [txt]
        else:
            return [im]

    writer = animation.PillowWriter()
    anim = animation.FuncAnimation(fig, animate, interval=600, frames=len(stamps))
    anim.save(savepath, writer=writer)

    # Close the figure so the notebook does not also print a static copy of the first frame,
    # and hand the animation back so it can be displayed with playback controls.
    plt.close(fig)
    return anim

Make an animated gif out of the cutouts.

Watch the supernova brighten near the middle of the sequence and fade away again.

savepath = f'SN{oid}.gif'
savepath
anim = animate_stamps(stamps, savepath, labels=[f'MJD {m:.1f}' for m in mjd])

The saved gif loops without stopping, which makes a brief brightening hard to follow. Displaying the animation instead gives playback controls: pause it, step one epoch at a time with the arrows, drag the slider to any frame, and read the date in the corner as you go.

HTML(anim.to_jshtml(default_mode='once'))
Loading...

About this notebook

Updated: 2026-08-05

Contact: the IRSA Helpdesk with questions or reporting problems.