Time Domain Analysis: Photometry of Simulated Transients#


Kernel Information and Read-Only Status#

To run this notebook, please select “Roman Research Nexus {VERSION}” kernel at the top right of your window. For example “Roman Research Nexus 2026.2”.

This notebook is read-only. You can run cells and make edits, but you must save changes to a different location. We recommend saving the notebook within your home directory, or to a new folder within your home (e.g. file > save notebook as > my-nbs/nb.ipynb). Note that a directory must exist before you attempt to add a notebook to it.

Introduction#

This notebook continues the time-domain simulation notebook (from hereon the simulation notebook), which injects a Type Ia supernova into Roman WFI images and writes a time series of ASDF cutouts named roman_sn1a_*_<filter>.asdf.

Here we measure the same injected transient in those cutouts. The simulation notebook places the supernova at detector position $(X, Y) = (2000, 2000)$ and creates 400 pixel cutouts centered near $(X, Y) = (2050, 2050)$. In the cutouts analyzed here, the transient is therefore expected near $(x, y) = (150, 150)$.

We will:

  • load the simulated cutout time series,

  • perform background-subtracted aperture photometry,

  • perform PSF-fitting photometry using an STPSF WFI PSF grid,

  • compare the aperture and PSF measurements,

  • estimate signal-to-noise ratios,

  • compare recovered photometry against the injected sncosmo truth model,

  • make a simple difference-image light curve,

  • diagnose residual host-galaxy and background contamination,

  • fit a normalized light-curve amplitude and peak time,

  • repeat the measurement automatically for any additional simulated filters, and

  • make a fun gif of the light curve corresponding to the cutouts.

This notebook measures instrumental image fluxes. If calibrated photometric metadata are available in later simulation products, the same workflow can be extended to convert the measured fluxes into physical units or AB magnitudes.

Reference Data#

This notebook uses stpsf to generate the WFI point-spread-function model used for PSF photometry and aperture-correction estimates.

Local Run Settings#

If you want to run the notebook on your local machine, refer to the local installation instructions before proceeding. Depending on which reference data are missing, the following cell may take several minutes to execute.

On the Roman Research Nexus#

If you are working on the Nexus, then the ancillary reference data are pre-installed and this cell will execute instantly.

import os
import sys
import importlib.util

import notebook_data_dependencies as ndd

# Download reference data (if necessary)
result = ndd.install_files(packages=['stpsf'])
ndd.setup_env(result)
Did not find stpsf data in environment, setting it up...
	Downloading and uncompressing file...
	Found 1 data URL(s) to download and install...
	Working on file 1 out of 1
	Update environment variable with the following:
		export STPSF_PATH='/home/runner/refdata/stpsf-data'
Reference data paths set to:
	STPSF_PATH = /home/runner/refdata/stpsf-data
	CRDS_SERVER_URL = https://roman-crds.stsci.edu (pre-set, not overwritten)
	CRDS_CONTEXT = roman-edit (pre-set, not overwritten)
	CRDS_OBSERVATORY = roman
	CRDS_PATH = /home/runner/crds_cache (pre-set, not overwritten)

Imports#

We use the standard NumPy and Astropy stack, plus packages used elsewhere in the Roman notebook collection:

  • asdf for reading Roman ASDF cutouts

  • astropy.table for storing photometry results

  • astropy.stats for sigma clipping background pixels

  • astropy.visualization for image normalization

  • IPython.display for rendering generated GIF animations in the notebook

  • matplotlib for plotting images, light curves, and animations

  • numpy for array operations

  • photutils for aperture definitions and aperture statistics

  • Pillow for reading GIF frames from the simulation notebook animation

  • sncosmo for recreating and fitting the simulated supernova model

  • stpsf for Roman WFI PSF models

from pathlib import Path
import re
import warnings

import asdf
import astropy.units as u
from astropy.stats import SigmaClip
from astropy.table import Table, vstack
from astropy.time import Time
from astropy.visualization import simple_norm
from IPython.display import Image as NotebookImage, display
from matplotlib import animation
from matplotlib.colors import TwoSlopeNorm
import matplotlib.pyplot as plt
import numpy as np
from photutils.aperture import CircularAnnulus, CircularAperture, ApertureStats
from PIL import Image as PILImage, ImageSequence
import sncosmo
import stpsf

warnings.filterwarnings('ignore', module='astropy.wcs')

Tutorial Data#

This analysis starts from the cutout ASDF files created near the end of the simulation notebook. By default we look for all simulated filters:

roman_sn1a_*_*.asdf

The detailed walkthrough uses F129 as the primary filter when available, matching the default simulation notebook path. If you later uncomment the F184 simulation cells, or add additional filters, this notebook will discover those files and process them in the multi-filter section below.

Each filename contains the integer Modified Julian Date (MJD) for that simulated epoch. The helper functions below sort the files by filter and MJD and load the roman/data array from each ASDF file.

# Helper functions
def extract_mjd(path):
    """Extract the integer MJD encoded in a simulation cutout filename."""
    match = re.search(r'roman_sn1a_(\d+)_([a-z0-9]+)\.asdf$', path.name.lower())
    if match is None:
        raise ValueError(f'Could not parse MJD and filter from {path.name}')
    return float(match.group(1))


def extract_filter(path):
    """Extract the WFI filter name encoded in a simulation cutout filename."""
    match = re.search(r'roman_sn1a_(\d+)_([a-z0-9]+)\.asdf$', path.name.lower())
    if match is None:
        raise ValueError(f'Could not parse MJD and filter from {path.name}')
    return match.group(2).upper()


def discover_cutout_files(directory):
    """Find all cutout files in a directory and sort them by filter and MJD."""
    return sorted(
        directory.glob(cutout_pattern),
        key=lambda path: (extract_filter(path), extract_mjd(path)),
    )


def demo_light_curve_maggies(mjds, filter_name):
    """Return an injected demo light curve in maggies."""
    try:
        # Create a model usng the buildt-n "source" named `salt3-nir`
        demo_model = sncosmo.Model(source='salt3-nir')
        demo_t0 = Time('2027-06-08T13:25:42').mjd
        demo_model.set(z=1.2, t0=demo_t0, x1=1, c=1)
        demo_model.set_source_peakmag(20, 'f158', 'ab')
        demo_abmag = demo_model.bandmag(filter_name.lower(), 'ab', mjds)
        return 10 ** (-0.4 * demo_abmag)
    except Exception:
        demo_t0 = Time('2027-06-08T13:25:42').mjd
        phase = np.asarray(mjds, dtype=float) - demo_t0
        return 10 ** (-0.4 * 20.0) * np.exp(-0.5 * (phase / 22.0) ** 2)


def create_demo_cutouts(output_dir, filter_name='F129'):
    """Create lightweight ASDF cutouts when Roman I-Sim products are unavailable."""
    output_dir.mkdir(exist_ok=True)
    demo_files = discover_cutout_files(output_dir)
    if len(demo_files) > 0:
        return demo_files

    rng = np.random.default_rng(202602)
    demo_t0 = Time('2027-06-08T13:25:42').mjd
    demo_mjds = np.arange(-40, 115, 5, dtype=float) + demo_t0
    demo_mjds = np.round(demo_mjds).astype(int)
    demo_shape = (400, 400)
    y_grid, x_grid = np.indices(demo_shape, dtype=float)
    source_x, source_y = 150.0, 150.0

    psf_sigma = 1.25
    psf_image = np.exp(-0.5 * (((x_grid - source_x) / psf_sigma) ** 2 + ((y_grid - source_y) / psf_sigma) ** 2))
    psf_image /= np.sum(psf_image)

    host_x, host_y = 154.0, 147.0
    host_radius = np.sqrt(((x_grid - host_x) / 18.0) ** 2 + ((y_grid - host_y) / 11.0) ** 2)
    host_image = 0.18 + 2.4 * np.exp(-host_radius)
    host_image += 0.03 * (x_grid - np.mean(x_grid)) / np.max(x_grid)

    demo_maggies = demo_light_curve_maggies(demo_mjds, filter_name)
    finite = np.isfinite(demo_maggies)
    if not np.any(finite) or np.nanmax(demo_maggies) <= 0:
        demo_maggies = np.exp(-0.5 * ((demo_mjds - demo_t0) / 22.0) ** 2)
    normalized_flux = demo_maggies / np.nanmax(demo_maggies)

    for mjd, relative_flux in zip(demo_mjds, normalized_flux):
        transient_flux = 460.0 * relative_flux
        noise = rng.normal(0.0, 0.035, demo_shape)
        image = host_image + transient_flux * psf_image + noise
        output_path = output_dir / f'roman_sn1a_{mjd}_{filter_name.lower()}.asdf'
        asdf.AsdfFile({'roman': {'data': image.astype(np.float32)}}).write_to(output_path)

    return discover_cutout_files(output_dir)

Running the time-domain simulation notebook first is the recommended path because it creates Roman I-Sim products with the intended host galaxy and instrument modeling. If those files are not present, this notebook can create a small synthetic demo data set with the same filename and ASDF layout so the analysis workflow remains runnable end-to-end.

data_dir = Path('.')
cutout_pattern = 'roman_sn1a_*_*.asdf'
preferred_filter = 'F129'
allow_demo_data = True
demo_data_dir = data_dir / 'demo_cutouts'

all_cutout_files = discover_cutout_files(data_dir)
cutout_data_origin = 'Roman I-Sim simulation outputs'

if len(all_cutout_files) == 0:
    if allow_demo_data:
        print(
            f'No files matching {cutout_pattern!r} were found in {data_dir.resolve()}. '
            'Creating lightweight demo cutouts instead.'
        )
        data_dir = demo_data_dir
        all_cutout_files = create_demo_cutouts(data_dir, preferred_filter)
        cutout_data_origin = 'lightweight demo cutouts'
    else:
        raise FileNotFoundError(
            f'No files matching {cutout_pattern!r} were found in {data_dir.resolve()}. '
            'Run the time-domain simulation notebook first, set allow_demo_data=True, or update cutout_pattern.'
        )

available_filters = sorted({extract_filter(path) for path in all_cutout_files})
primary_filter = preferred_filter if preferred_filter in available_filters else available_filters[0]
cutout_files = [
    path for path in all_cutout_files
    if extract_filter(path) == primary_filter
]

print(f'Data origin: {cutout_data_origin}')
print(f'Found {len(all_cutout_files)} total cutout files across filters: {available_filters}')
print(f'Using {primary_filter} for the detailed single-filter walkthrough.')
cutout_files[:5]
No files matching 'roman_sn1a_*_*.asdf' were found in /home/runner/work/roman_notebooks/roman_notebooks/notebooks/time_domain_analysis. Creating lightweight demo cutouts instead.
Downloading https://sncosmo.github.io/data/models/salt3-nir/salt3nir-p22.tar.gz
 [Done]
Downloading https://sncosmo.github.io/data/bandpasses/roman-wfi/roman_f158.dat
 [Done]
Downloading https://sncosmo.github.io/data/bandpasses/roman-wfi/roman_f129.dat
 [Done]
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: divide by zero encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
Data origin: lightweight demo cutouts
Found 31 total cutout files across filters: ['F129']
Using F129 for the detailed single-filter walkthrough.
[PosixPath('demo_cutouts/roman_sn1a_61525_f129.asdf'),
 PosixPath('demo_cutouts/roman_sn1a_61530_f129.asdf'),
 PosixPath('demo_cutouts/roman_sn1a_61535_f129.asdf'),
 PosixPath('demo_cutouts/roman_sn1a_61540_f129.asdf'),
 PosixPath('demo_cutouts/roman_sn1a_61545_f129.asdf')]

We define a new helper function to read the Roman data array from one ASDF cutout file and to estimate the source centroid.

def read_cutout(path):
    """Read the Roman data array from one ASDF cutout file."""
    with asdf.open(path) as af:
        image = np.array(af['roman']['data'], dtype=float)
    return image


def estimate_source_position(image, initial_position=(150.0, 150.0), half_size=10):
    """Estimate the source centroid in a small box around an initial position."""
    x0, y0 = initial_position
    x_min = max(int(np.floor(x0)) - half_size, 0)
    x_max = min(int(np.floor(x0)) + half_size + 1, image.shape[1])
    y_min = max(int(np.floor(y0)) - half_size, 0)
    y_max = min(int(np.floor(y0)) + half_size + 1, image.shape[0])

    stamp = image[y_min:y_max, x_min:x_max]
    yy, xx = np.mgrid[y_min:y_max, x_min:x_max]
    weights = stamp - np.nanmedian(stamp)
    weights = np.clip(weights, 0, None)

    if not np.any(np.isfinite(weights)) or np.nansum(weights) <= 0:
        return initial_position

    x_centroid = np.nansum(xx * weights) / np.nansum(weights)
    y_centroid = np.nansum(yy * weights) / np.nansum(weights)
    return (float(x_centroid), float(y_centroid))

Now, lets use them to get the cutouts, get the geometry inherited from the simulation notebook and peak index

images = np.array([read_cutout(path) for path in cutout_files])
mjds = np.array([extract_mjd(path) for path in cutout_files])
filters = np.array([extract_filter(path) for path in cutout_files])

peak_index = np.nanargmax([
    np.nanmax(image[140:161, 140:161])
    for image in images
])

# Geometry inherited from the simulation notebook, refined from the peak epoch.
nominal_cutout_target_position = (150.0, 150.0)
nominal_full_detector_target_position = (2000.0, 2000.0)
cutout_target_position = estimate_source_position(images[peak_index], nominal_cutout_target_position)
detector_to_cutout_offset = (
    nominal_full_detector_target_position[0] - nominal_cutout_target_position[0],
    nominal_full_detector_target_position[1] - nominal_cutout_target_position[1],
)
full_detector_target_position = (
    cutout_target_position[0] + detector_to_cutout_offset[0],
    cutout_target_position[1] + detector_to_cutout_offset[1],
)
filter_name = primary_filter
detector_name = 'SCA01'

print(f'Nominal cutout source position: {nominal_cutout_target_position}')
print(f'Refined cutout source position: ({cutout_target_position[0]:.2f}, {cutout_target_position[1]:.2f})')

Table({
    'file': [path.name for path in cutout_files[:5]],
    'mjd': mjds[:5],
    'filter': filters[:5],
})
Nominal cutout source position: (150.0, 150.0)
Refined cutout source position: (150.47, 149.57)
Table length=5
filemjdfilter
str26float64str4
roman_sn1a_61525_f129.asdf61525.0F129
roman_sn1a_61530_f129.asdf61530.0F129
roman_sn1a_61535_f129.asdf61535.0F129
roman_sn1a_61540_f129.asdf61540.0F129
roman_sn1a_61545_f129.asdf61545.0F129

Display the Simulated Transient#

Before measuring the light curve, we inspect one epoch near peak brightness. The red circle marks the fixed forced-photometry position inherited from the simulation notebook.

peak_index = np.nanargmax([
    np.nanmax(image[140:161, 140:161])
    for image in images
])

fig, ax = plt.subplots(figsize=(6, 6))
norm = simple_norm(images[peak_index], 'asinh', percent=99.5)
ax.imshow(images[peak_index], origin='lower', cmap='afmhot', norm=norm)

aperture = CircularAperture(cutout_target_position, r=3)
aperture.plot(ax=ax, color='red', lw=2)

ax.set_title(f'{filters[peak_index]} simulated epoch: MJD {mjds[peak_index]:.0f}')
ax.set_xlabel('Cutout X (pixels)')
ax.set_ylabel('Cutout Y (pixels)')
Text(0, 0.5, 'Cutout Y (pixels)')
../../_images/99dd2f46a313b2dd6ba447ff9345b4ed8615602fd251deb410dbfd5834f3fe8d.png

Aperture Photometry#

Forced aperture photometry uses a predefined source position and aperture size for every epoch. This is a natural first pass for a simulated time series because the source position is known from the injection step. For more on aperture photometry see the Aperture Photometry tutorial.

We estimate a local background from a circular annulus around the source and subtract that background from the circular source aperture. We also compute an optional aperture correction from the STPSF encircled-energy profile for the WFI detector, filter, and source position.

We define helper functions to estimate the aperture correction from an STPSF encircled-energy curve and to measure the local-background-subtracted aperture flux for an image.

def aperture_correction_from_stpsf(filter_name, detector_name, detector_position, radius_pix):
    """Estimate the aperture correction from an STPSF encircled-energy curve."""
    wfi = stpsf.roman.WFI()
    wfi.filter = filter_name.upper()
    wfi.detector = detector_name
    wfi.detector_position = detector_position

    psf = wfi.calc_psf(display=False, nlambda=1)
    ee_func = stpsf.measure_ee(psf)
    radius_arcsec = (radius_pix * u.pixel * pixel_scale).to_value(u.arcsec)
    return 1.0 / ee_func(radius_arcsec)

def measure_aperture_flux(image, position, radius, inner_radius, outer_radius, correction=1.0):
    """Measure local-background-subtracted aperture flux for one image."""
    source_aperture = CircularAperture(position, r=radius)
    sky_annulus = CircularAnnulus(position, r_in=inner_radius, r_out=outer_radius)

    source_stats = ApertureStats(image, source_aperture)
    sky_stats = ApertureStats(
        image,
        sky_annulus,
        sigma_clip=SigmaClip(sigma=3.0, maxiters=5),
    )

    aperture_area = source_aperture.area
    background_per_pixel = sky_stats.median
    background_total = background_per_pixel * aperture_area
    raw_flux = source_stats.sum
    net_flux = raw_flux - background_total

    # A compact uncertainty estimate from the scatter in the local sky annulus.
    flux_err = np.sqrt(aperture_area) * sky_stats.std

    return {
        'raw_flux': raw_flux,
        'background_per_pixel': background_per_pixel,
        'background_total': background_total,
        'aperture_flux': net_flux * correction,
        'aperture_flux_err': flux_err * correction,
    }

We derive the aperture correction for one image and with specific parameters.

# Selection parameters
aperture_radius = 3.0  # pixels
annulus_inner_radius = 8.0  # pixels
annulus_outer_radius = 15.0  # pixels
pixel_scale = 0.11 * u.arcsec / u.pixel

aperture_correction_cache = {}
aperture_correction = aperture_correction_from_stpsf(
    filter_name=filter_name,
    detector_name=detector_name,
    detector_position=full_detector_target_position,
    radius_pix=aperture_radius,
)
aperture_correction_cache[filter_name] = aperture_correction

print(f'{filter_name} aperture correction for r = {aperture_radius:.1f} pixels: {aperture_correction:.3f}')
F129 aperture correction for r = 3.0 pixels: 1.234

Now we measure the local-background-subtracted aperture flux all the images

aperture_rows = []
for path, mjd, filt, image in zip(cutout_files, mjds, filters, images):
    result = measure_aperture_flux(
        image,
        cutout_target_position,
        aperture_radius,
        annulus_inner_radius,
        annulus_outer_radius,
        correction=aperture_correction,
    )
    aperture_rows.append({
        'file': path.name,
        'mjd': mjd,
        'filter': filt,
        **result,
    })

aperture_table = Table(rows=aperture_rows)
aperture_table[:5]
Table length=5
filemjdfilterraw_fluxbackground_per_pixelbackground_totalaperture_fluxaperture_flux_err
str26float64str4float64float64float64float64float64
roman_sn1a_61525_f129.asdf61525.0F12965.40967691164081.144598960876464832.36277318116411540.776865265071961.895413267683614
roman_sn1a_61530_f129.asdf61530.0F129100.63278295644181.139488995075225832.2182923019727184.417241940371351.9067792928501341
roman_sn1a_61535_f129.asdf61535.0F129155.067913005092461.127617955207824731.88264655723162151.999384074035761.8980633913810305
roman_sn1a_61540_f129.asdf61540.0F129222.109385676764051.137504935264587432.16219433244425234.377510586055961.9221055330802863
roman_sn1a_61545_f129.asdf61545.0F129296.906214534112341.138013601303100632.176576525851765326.65222947282761.9240374957162327

PSF Photometry#

PSF photometry estimates the source flux by fitting a point-spread-function model directly to the pixels near the source. This is often useful when sources are faint, crowded, or when an aperture includes significant host-galaxy light.

The STPSF tutorial shows how to generate a grid of Roman WFI PSFs and evaluate it at arbitrary detector positions. We use that same pattern here. The PSF grid is evaluated at the refined source position, and each epoch is fit with a point source plus a local background plane:

$$ I(x, y) = F_{\rm PSF} P(x, y) + B_0 + B_x (x - x_0) + B_y (y - y_0) $$

where $P(x, y)$ is a unit-normalized PSF image, $F_{\rm PSF}$ is the fitted source flux, and the background-plane terms capture smooth host-galaxy light across the small fitting stamp.

We start by defining helper functions to define the PSF grid and to measure the PSF flux + local background.

def make_psf_grid(filter_name, detector_name, detector_position):
    """Create an STPSF grid for one WFI filter and detector position."""
    wfi = stpsf.roman.WFI()
    wfi.filter = filter_name.upper()
    wfi.detector = detector_name
    wfi.detector_position = detector_position
    return wfi.psf_grid(num_psfs=9, all_detectors=False)
# Define a funtion ot measure  the PSF flux + local background
def measure_psf_flux(
    image,
    cutout_position,
    full_detector_position,
    psf_grid,
    half_size=15,
    background_model='plane',
):
    """Fit a unit-normalized STPSF model plus a local background model."""
    x0, y0 = cutout_position
    x_min = int(np.round(x0)) - half_size
    x_max = int(np.round(x0)) + half_size + 1
    y_min = int(np.round(y0)) - half_size
    y_max = int(np.round(y0)) + half_size + 1

    stamp = image[y_min:y_max, x_min:x_max]
    yy_cutout, xx_cutout = np.mgrid[y_min:y_max, x_min:x_max]

    x_offset = full_detector_position[0] - cutout_position[0]
    y_offset = full_detector_position[1] - cutout_position[1]
    xx_detector = xx_cutout + x_offset
    yy_detector = yy_cutout + y_offset

    psf_unit = psf_grid.evaluate(
        x=xx_detector,
        y=yy_detector,
        flux=1.0,
        x_0=full_detector_position[0],
        y_0=full_detector_position[1],
    )
    psf_unit = np.array(psf_unit, dtype=float)
    psf_unit /= np.nansum(psf_unit)

    mask = np.isfinite(stamp) & np.isfinite(psf_unit)
    design_columns = [
        psf_unit[mask].ravel(),
        np.ones(np.count_nonzero(mask)),
    ]

    if background_model == 'plane':
        design_columns.extend([
            (xx_cutout[mask] - x0).ravel(),
            (yy_cutout[mask] - y0).ravel(),
        ])
    elif background_model != 'constant':
        raise ValueError("background_model must be 'constant' or 'plane'")

    design_matrix = np.column_stack(design_columns)
    data_vector = stamp[mask].ravel()

    fit_values, _, _, _ = np.linalg.lstsq(design_matrix, data_vector, rcond=None)
    flux = fit_values[0]
    background = fit_values[1]
    background_x_slope = fit_values[2] if background_model == 'plane' else 0.0
    background_y_slope = fit_values[3] if background_model == 'plane' else 0.0

    background_image = background + background_x_slope * (xx_cutout - x0) + background_y_slope * (yy_cutout - y0)
    model = flux * psf_unit + background_image
    residual = stamp - model

    dof = max(np.count_nonzero(mask) - design_matrix.shape[1], 1)
    residual_variance = np.nansum(residual[mask] ** 2) / dof
    covariance = residual_variance * np.linalg.pinv(design_matrix.T @ design_matrix)
    flux_err = np.sqrt(covariance[0, 0])

    return {
        'psf_flux': flux,
        'psf_flux_err': flux_err,
        'psf_background': background,
        'psf_background_x_slope': background_x_slope,
        'psf_background_y_slope': background_y_slope,
        'psf_x': x0,
        'psf_y': y0,
        'psf_residual_rms': np.sqrt(np.nanmean(residual[mask] ** 2)),
        'psf_max_abs_residual_fraction': np.nanmax(np.abs(residual)) / np.nanmax(np.abs(stamp)),
        'stamp': stamp,
        'psf_model': model,
        'psf_background_image': background_image,
        'psf_residual': residual,
    }

Now create a PSF grid.

psf_fit_half_size = 15  # pixels

# A small PSF grid is enough for this single-detector, single-position example.
psf_grid_cache = {}
psf_grid = make_psf_grid(filter_name, detector_name, full_detector_target_position)
psf_grid_cache[filter_name] = psf_grid
Running instrument: WFI, filter: F129
  Running detector: WFI01
    Position 1/9: (0, 0) pixels
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
Attempted to get aberrations at field point (0, 0) which is outside the range of the reference data; approximating to nearest interpolated point (31.993143027288347, 0.015652222616090228)
    Position 1/9 centroid: (np.float64(201.18614847849747), np.float64(201.53324686097412))
    Position 2/9: (0, 2048) pixels
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
Attempted to get aberrations at field point (0, 2048) which is outside the range of the reference data; approximating to nearest interpolated point (30.9911863199469, 2048.0151620285324)
    Position 2/9 centroid: (np.float64(201.1317100494458), np.float64(201.52558235141015))
    Position 3/9: (0, 4095) pixels
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
Attempted to get aberrations at field point (0, 4095) which is outside the range of the reference data; approximating to nearest input grid point
    Position 3/9 centroid: (np.float64(201.0791296251613), np.float64(201.5170489211573))
    Position 4/9: (2048, 0) pixels
    Position 4/9 centroid: (np.float64(201.19222268096635), np.float64(201.62229933057256))
    Position 5/9: (2048, 2048) pixels
    Position 5/9 centroid: (np.float64(201.13401916890646), np.float64(201.63039878629866))
    Position 6/9: (2048, 4095) pixels
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
Attempted to get aberrations at field point (2048, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (2048.0050174304056, 4074.4937619323296)
    Position 6/9 centroid: (np.float64(201.10523923076386), np.float64(201.66477462004127))
    Position 7/9: (4095, 0) pixels
    Position 7/9 centroid: (np.float64(201.19867950677187), np.float64(201.71232738681354))
    Position 8/9: (4095, 2048) pixels
    Position 8/9 centroid: (np.float64(201.16478982775357), np.float64(201.76433060832562))
    Position 9/9: (4095, 4095) pixels
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
Attempted to get aberrations at field point (4095, 4095) which is outside the range of the reference data; approximating to nearest interpolated point (4095.004894881753, 4074.994618276213)
    Position 9/9 centroid: (np.float64(201.13199432475577), np.float64(201.81459752157272))

We then get the PSF flux and background for a series of preselected cutouts and save them in a table (psf_table).

psf_rows = []
psf_diagnostics = {}
for path, mjd, filt, image in zip(cutout_files, mjds, filters, images):
    result = measure_psf_flux(
        image,
        cutout_target_position,
        full_detector_target_position,
        psf_grid,
        half_size=psf_fit_half_size,
    )
    psf_rows.append({
        'file': path.name,
        'mjd': mjd,
        'filter': filt,
        'psf_flux': result['psf_flux'],
        'psf_flux_err': result['psf_flux_err'],
        'psf_background': result['psf_background'],
        'psf_background_x_slope': result['psf_background_x_slope'],
        'psf_background_y_slope': result['psf_background_y_slope'],
        'psf_x': result['psf_x'],
        'psf_y': result['psf_y'],
        'psf_residual_rms': result['psf_residual_rms'],
        'psf_max_abs_residual_fraction': result['psf_max_abs_residual_fraction'],
    })
    psf_diagnostics[path.name] = result

psf_table = Table(rows=psf_rows)
psf_table[:5]
Table length=5
filemjdfilterpsf_fluxpsf_flux_errpsf_backgroundpsf_background_x_slopepsf_background_y_slopepsf_xpsf_ypsf_residual_rmspsf_max_abs_residual_fraction
str26float64str4float64float64float64float64float64float64float64float64float64
roman_sn1a_61525_f129.asdf61525.0F12913.9353464915314621.17405997891103731.21601767094177870.013627428745918442-0.018498628377671522150.4696553521533149.57486279331320.363271729714298660.418799945169603
roman_sn1a_61530_f129.asdf61530.0F12935.7221379645075741.45160109105425181.23328182148773150.013254067845078488-0.01836104708681796150.4696553521533149.57486279331320.44914710378897820.5520871949147577
roman_sn1a_61535_f129.asdf61535.0F12969.30864388751772.1025396572923621.25810176194485850.013393677789446001-0.017866464495455273150.4696553521533149.57486279331320.65055723882687640.5976213250463683
roman_sn1a_61540_f129.asdf61540.0F129111.13586204528423.04016630803655771.28983211112105560.01289817775061651-0.017993814996708504150.4696553521533149.57486279331320.94067295809191240.6135691072171596
roman_sn1a_61545_f129.asdf61545.0F129157.353606260753484.1528413271075261.32650624434653720.012566029254290637-0.017379125079890062150.4696553521533149.57486279331321.2849512624786330.6267013098193152

Inspect the PSF Fit#

The panels below show the data, fitted PSF model, and residual image for the epoch near peak brightness. The data and model panels use the same intensity stretch; the residual panel uses a zero-centered diverging stretch so positive and negative structure are easier to interpret. These simulated cutouts do not carry a reliable physical data unit in the ASDF metadata, so the color bars are labeled in simulated L2 image units.

peak_file = cutout_files[peak_index].name
diagnostic = psf_diagnostics[peak_file]

fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(12, 4), constrained_layout=True)

titles = ['Data', 'PSF model + background', 'Residual']
arrays = [
    diagnostic['stamp'],
    diagnostic['psf_model'],
    diagnostic['psf_residual'],
]

data_model_values = np.concatenate([
    diagnostic['stamp'].ravel(),
    diagnostic['psf_model'].ravel(),
])
data_model_vmin, data_model_vmax = np.nanpercentile(data_model_values, [1, 99.5])
residual_limit = np.nanpercentile(np.abs(diagnostic['psf_residual']), 99)
image_unit_label = 'Simulated L2 image units'

data_model_images = []
for ax, title, array in zip(axes[:2], titles[:2], arrays[:2]):
    image = ax.imshow(
        array,
        origin='lower',
        cmap='afmhot',
        vmin=data_model_vmin,
        vmax=data_model_vmax,
    )
    data_model_images.append(image)
    ax.set_title(title)
    ax.set_xticks([])
    ax.set_yticks([])

residual_image = axes[2].imshow(
    diagnostic['psf_residual'],
    origin='lower',
    cmap='RdBu_r',
    norm=TwoSlopeNorm(vcenter=0, vmin=-residual_limit, vmax=residual_limit),
)
axes[2].set_title(titles[2])
axes[2].set_xticks([])
axes[2].set_yticks([])

data_model_colorbar = fig.colorbar(
    data_model_images[0],
    ax=[axes[0], axes[1]],
    shrink=0.82,
    pad=0.02,
)
data_model_colorbar.set_label(image_unit_label)

residual_colorbar = fig.colorbar(
    residual_image,
    ax=axes[2],
    shrink=0.82,
    pad=0.02,
)
residual_colorbar.set_label(f'Data - model ({image_unit_label})')

fig.suptitle(f'PSF fit diagnostics: {peak_file}')

print(f"Fitted source position: x={diagnostic['psf_x']:.2f}, y={diagnostic['psf_y']:.2f}")
print(f"PSF flux: {diagnostic['psf_flux']:.2f} +/- {diagnostic['psf_flux_err']:.2f}")
print(f"Residual RMS: {diagnostic['psf_residual_rms']:.3f}")
print(
    'Max absolute residual / max data value: '
    f"{diagnostic['psf_max_abs_residual_fraction']:.3f}"
)
Fitted source position: x=150.47, y=149.57
PSF flux: 268.27 +/- 6.88
Residual RMS: 2.130
Max absolute residual / max data value: 0.637
../../_images/08404569506c7e0ce0600645d46e24b35b1bec3729c9110933f3745a4b6c0deb.png

Compare Aperture and PSF Photometry#

Now we combine the aperture and PSF measurements into one table. The two methods will not be identical because they treat the host-galaxy light differently: aperture photometry subtracts a local annulus background, while PSF photometry fits a compact point-source component plus a constant local background across the fitting stamp.

# Replace division by zero with 0
def safe_ratio(numerator, denominator):
    """Return numerator / denominator while avoiding divide-by-zero warnings."""
    numerator = np.asarray(numerator, dtype=float)
    denominator = np.asarray(denominator, dtype=float)
    return np.divide(
        numerator,
        denominator,
        out=np.full_like(numerator, np.nan, dtype=float),
        where=np.isfinite(denominator) & (denominator != 0),
    )

# Extract the information from the psf_table
photometry = aperture_table.copy()
photometry['psf_flux'] = psf_table['psf_flux']
photometry['psf_flux_err'] = psf_table['psf_flux_err']
photometry['psf_background'] = psf_table['psf_background']
photometry['psf_background_x_slope'] = psf_table['psf_background_x_slope']
photometry['psf_background_y_slope'] = psf_table['psf_background_y_slope']
photometry['psf_x'] = psf_table['psf_x']
photometry['psf_y'] = psf_table['psf_y']
photometry['psf_residual_rms'] = psf_table['psf_residual_rms']
photometry['psf_max_abs_residual_fraction'] = psf_table['psf_max_abs_residual_fraction']

# Calculates the comparison values
photometry['flux_difference'] = photometry['aperture_flux'] - photometry['psf_flux']
photometry['flux_ratio'] = safe_ratio(photometry['aperture_flux'], photometry['psf_flux'])
photometry['aperture_snr'] = safe_ratio(photometry['aperture_flux'], photometry['aperture_flux_err'])
photometry['psf_snr'] = safe_ratio(photometry['psf_flux'], photometry['psf_flux_err'])
photometry['aperture_background_fraction'] = safe_ratio(
    photometry['background_total'],
    photometry['raw_flux'],
)

# Normalized fluxes
for column in ('aperture_flux', 'psf_flux'):
    peak_flux = np.nanmax(photometry[column])
    photometry[f'{column}_normalized'] = photometry[column] / peak_flux

photometry[:5]
Table length=5
filemjdfilterraw_fluxbackground_per_pixelbackground_totalaperture_fluxaperture_flux_errpsf_fluxpsf_flux_errpsf_backgroundpsf_background_x_slopepsf_background_y_slopepsf_xpsf_ypsf_residual_rmspsf_max_abs_residual_fractionflux_differenceflux_ratioaperture_snrpsf_snraperture_background_fractionaperture_flux_normalizedpsf_flux_normalized
str26float64str4float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64
roman_sn1a_61525_f129.asdf61525.0F12965.40967691164081.144598960876464832.36277318116411540.776865265071961.89541326768361413.9353464915314621.17405997891103731.21601767094177870.013627428745918442-0.018498628377671522150.4696553521533149.57486279331320.363271729714298660.41879994516960326.84151877354052.92614649300985421.51344298381186311.8693650595745180.494770417913570070.074505851566219080.051945454907511485
roman_sn1a_61530_f129.asdf61530.0F129100.63278295644181.139488995075225832.2182923019727184.417241940371351.906779292850134135.7221379645075741.45160109105425181.23328182148773150.013254067845078488-0.01836104708681796150.4696553521533149.57486279331320.44914710378897820.552087194914757748.695103975863782.363163202164600344.2721620991539924.6087841795184370.32015702393839360.154243796249496020.13315798842625734
roman_sn1a_61535_f129.asdf61535.0F129155.067913005092461.127617955207824731.88264655723162151.999384074035761.898063391381030569.30864388751772.1025396572923621.25810176194485850.013393677789446001-0.017866464495455273150.4696553521533149.57486279331320.65055723882687640.597621325046368382.690740186518062.193079759585520380.0813001105516532.964250470677430.20560440867082920.277727173836417930.2583551860693032
roman_sn1a_61540_f129.asdf61540.0F129222.109385676764051.137504935264587432.16219433244425234.377510586055961.9221055330802863111.13586204528423.04016630803655771.28983211112105560.01289817775061651-0.017993814996708504150.4696553521533149.57486279331320.94067295809191240.6135691072171596123.241648540771762.108927813873031121.9378991175642336.555849511094510.144803400515680660.428245180218460530.4142704965383525
roman_sn1a_61545_f129.asdf61545.0F129296.906214534112341.138013601303100632.176576525851765326.65222947282761.9240374957162327157.353606260753484.1528413271075261.32650624434653720.012566029254290637-0.017379125079890062150.4696553521533149.57486279331321.2849512624786330.6267013098193152169.298623212074152.0759119364034553169.774357412525237.890589566625940.108372863048155950.59684584297174210.5865519500013526

Signal-to-Noise Ratio#

The aperture and PSF flux uncertainties are estimated differently, so their signal-to-noise ratios (S/N) should be interpreted as compact diagnostics rather than final uncertainties. They are still useful for checking whether the two extraction methods identify the same high-quality epochs.

First we compare the photometric S/N

fig, ax = plt.subplots(figsize=(8, 4))

ax.plot(photometry['mjd'], photometry['aperture_snr'], marker='o', label='Aperture S/N')
ax.plot(photometry['mjd'], photometry['psf_snr'], marker='s', label='PSF S/N')

ax.axhline(5, color='0.4', linestyle='--', linewidth=1, label='S/N = 5')
ax.set_xlabel('MJD')
ax.set_ylabel('Signal-to-noise ratio')
ax.set_title(f'{filter_name} photometric signal-to-noise')
ax.grid(alpha=0.3)
ax.legend()
<matplotlib.legend.Legend at 0x7f6ab2538590>
../../_images/58ad834f24496c245ce0096947c3660cf51abf4565c649e7c55b41721563a868.png

and use it to derive the light curve

fig, (ax_lc, ax_ratio) = plt.subplots(
    nrows=2,
    ncols=1,
    figsize=(8, 7),
    sharex=True,
    gridspec_kw={'height_ratios': [3, 1]},
    constrained_layout=True,
)

ax_lc.errorbar(
    photometry['mjd'],
    photometry['aperture_flux'],
    yerr=photometry['aperture_flux_err'],
    fmt='o-',
    label='Aperture photometry',
)
ax_lc.errorbar(
    photometry['mjd'],
    photometry['psf_flux'],
    yerr=photometry['psf_flux_err'],
    fmt='s-',
    label='PSF photometry',
)
ax_lc.set_ylabel('Flux (image units)')
ax_lc.set_title(f'{filter_name} light curve of the simulated supernova')
ax_lc.legend()

ax_ratio.scatter(photometry['mjd'], photometry['flux_ratio'])
ax_ratio.axhline(1.0, color='0.4', linestyle='--', linewidth=1)
ax_ratio.set_xlabel('MJD')
ax_ratio.set_ylabel('Aper. / PSF')

for ax in (ax_lc, ax_ratio):
    ax.grid(alpha=0.3)
../../_images/0b9b7455ca84e4163453ef7ebd60af54a74057accf9d33edb6b2230457f349a2.png

It is also useful to compare the two light-curve shapes after normalizing each method by its own peak flux. This emphasizes differences in the recovered time evolution rather than the absolute flux scale.

fig, ax = plt.subplots(figsize=(8, 5))

ax.plot(
    photometry['mjd'],
    photometry['aperture_flux_normalized'],
    marker='o',
    label='Aperture photometry',
)
ax.plot(
    photometry['mjd'],
    photometry['psf_flux_normalized'],
    marker='s',
    label='PSF photometry',
)

ax.set_xlabel('MJD')
ax.set_ylabel('Flux / peak flux')
ax.set_title(f'Normalized {filter_name} light-curve comparison')
ax.grid(alpha=0.3)
ax.legend()
<matplotlib.legend.Legend at 0x7f6aac1eda90>
../../_images/51b61ec3d9c534a5a8d0f0243e1372dad57bf6070c960e6637e3a4459d95cd1e.png

Light-Curve Analysis with sncosmo#

The simulation notebook generated the injected transient with sncosmo using the SALT3-NIR Type Ia supernova model. We can recreate that same model here and compare its expected light-curve shape with the recovered aperture and PSF photometry.

The measured image fluxes are not in sncosmo flux units, so we fit a simple linear transformation between the model light curve and each measured light curve:

$$ f_{\rm measured}(t) = A f_{\rm sncosmo}(t) + B $$

where $A$ is the flux scale and $B$ is a constant residual background term. This is enough to compare the recovered time evolution and estimate whether aperture and PSF photometry peak at the expected epoch.

First let’s define a function to evaluate the sncosmo model in one WFI filter and return maggies

# Create a sncosmo model for a Type Ia supernova using the built-in "salt3-nir" source.
sncosmo_model = sncosmo.Model(source='salt3-nir')

def sncosmo_maggies_for_filter(filter_name, mjd, model=sncosmo_model):
    """Evaluate the sncosmo model in one WFI filter and return maggies."""
    abmag = model.bandmag(filter_name.lower(), 'ab', mjd)
    return 10 ** (-0.4 * abmag)

We also define a function to fit the scaled + offset version of the supernova model to observed flux data.

def fit_scaled_sncosmo_model(measured_flux, measured_err, model_flux):
    """Fit measured_flux = scale * model_flux + offset."""
    measured_flux = np.asarray(measured_flux, dtype=float)
    measured_err = np.asarray(measured_err, dtype=float)
    model_flux = np.asarray(model_flux, dtype=float)

    mask = np.isfinite(measured_flux) & np.isfinite(model_flux)
    if np.any(np.isfinite(measured_err) & (measured_err > 0)):
        mask &= np.isfinite(measured_err) & (measured_err > 0)
        weights = 1.0 / measured_err[mask] ** 2
    else:
        weights = np.ones(np.count_nonzero(mask))

    design_matrix = np.column_stack([model_flux[mask], np.ones(np.count_nonzero(mask))])
    sqrt_weights = np.sqrt(weights)
    weighted_design = design_matrix * sqrt_weights[:, np.newaxis]
    weighted_flux = measured_flux[mask] * sqrt_weights

    scale, offset = np.linalg.lstsq(weighted_design, weighted_flux, rcond=None)[0]
    fitted_flux = scale * model_flux + offset
    residual = measured_flux - fitted_flux

    chi2 = np.sum(((measured_flux[mask] - fitted_flux[mask]) ** 2) * weights)
    dof = max(np.count_nonzero(mask) - 2, 1)

    return {
        'scale': scale,
        'offset': offset,
        'fitted_flux': fitted_flux,
        'residual': residual,
        'reduced_chi2': chi2 / dof,
    }

Now let’s set our model with specific parameters and find the peak MJD

simulation_t0 = Time('2027-06-08T13:25:42').mjd
sncosmo_model.set(
    z=1.2,
    t0=simulation_t0,
    x1=1,
    c=1,
)
sncosmo_model.set_source_peakmag(20, 'f158', 'ab')

sncosmo_band = filter_name.lower()
model_abmag = sncosmo_model.bandmag(sncosmo_band, 'ab', photometry['mjd'])
model_maggies = 10 ** (-0.4 * model_abmag)
model_maggies_normalized = model_maggies / np.nanmax(model_maggies)

photometry['sncosmo_model_abmag'] = model_abmag
photometry['sncosmo_model_maggies'] = model_maggies
photometry['sncosmo_model_normalized'] = model_maggies_normalized

dense_mjd = np.linspace(np.nanmin(mjds), np.nanmax(mjds), 400)
dense_abmag = sncosmo_model.bandmag(sncosmo_band, 'ab', dense_mjd)
dense_maggies = 10 ** (-0.4 * dense_abmag)
dense_maggies_normalized = dense_maggies / np.nanmax(dense_maggies)
model_peak_mjd = dense_mjd[np.nanargmax(dense_maggies)]

print(f'{filter_name} sncosmo model peak MJD: {model_peak_mjd:.2f}')
F129 sncosmo model peak MJD: 61567.48
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: divide by zero encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)

We then compare the output using the aperture and the PSF methods

aperture_sncosmo_fit = fit_scaled_sncosmo_model(
    photometry['aperture_flux'],
    photometry['aperture_flux_err'],
    photometry['sncosmo_model_maggies'],
)
psf_sncosmo_fit = fit_scaled_sncosmo_model(
    photometry['psf_flux'],
    photometry['psf_flux_err'],
    photometry['sncosmo_model_maggies'],
)

photometry['aperture_sncosmo_fit_flux'] = aperture_sncosmo_fit['fitted_flux']
photometry['aperture_sncosmo_residual'] = aperture_sncosmo_fit['residual']
photometry['psf_sncosmo_fit_flux'] = psf_sncosmo_fit['fitted_flux']
photometry['psf_sncosmo_residual'] = psf_sncosmo_fit['residual']

aperture_peak_mjd = photometry['mjd'][np.nanargmax(photometry['aperture_flux'])]
psf_peak_mjd = photometry['mjd'][np.nanargmax(photometry['psf_flux'])]

sncosmo_summary = Table(rows=[
    {
        'method': 'Aperture',
        'scale': aperture_sncosmo_fit['scale'],
        'offset': aperture_sncosmo_fit['offset'],
        'reduced_chi2': aperture_sncosmo_fit['reduced_chi2'],
        'measured_peak_mjd': aperture_peak_mjd,
        'model_peak_mjd': model_peak_mjd,
        'peak_offset_days': aperture_peak_mjd - model_peak_mjd,
    },
    {
        'method': 'PSF',
        'scale': psf_sncosmo_fit['scale'],
        'offset': psf_sncosmo_fit['offset'],
        'reduced_chi2': psf_sncosmo_fit['reduced_chi2'],
        'measured_peak_mjd': psf_peak_mjd,
        'model_peak_mjd': model_peak_mjd,
        'peak_offset_days': psf_peak_mjd - model_peak_mjd,
    },
])

sncosmo_summary
Table length=2
methodscaleoffsetreduced_chi2measured_peak_mjdmodel_peak_mjdpeak_offset_days
str8float64float64float64float64float64float64
Aperture18486465835.90926427.0526725057254930.01826602702517847861565.061567.48120300752-2.4812030075190705
PSF9286538442.6550986.8868230329883850.001609833818903632861565.061567.48120300752-2.4812030075190705

Model-Calibrated Magnitudes#

The aperture and PSF fluxes above are measured in image units. To express them as magnitudes, we use the fitted sncosmo scale and offset to convert each measured flux back into model maggies, then compute AB magnitudes.

# Define helper functions
def maggies_to_abmag(maggies, maggies_err=None):
    """Convert linear AB maggies to AB magnitudes and optional magnitude errors."""
    maggies = np.asarray(maggies, dtype=float)
    abmag = np.full(maggies.shape, np.nan, dtype=float)
    positive = np.isfinite(maggies) & (maggies > 0)
    abmag[positive] = -2.5 * np.log10(maggies[positive])

    if maggies_err is None:
        return abmag, None

    maggies_err = np.asarray(maggies_err, dtype=float)
    abmag_err = np.full(maggies.shape, np.nan, dtype=float)
    err_mask = positive & np.isfinite(maggies_err) & (maggies_err >= 0)
    abmag_err[err_mask] = (2.5 / np.log(10)) * maggies_err[err_mask] / maggies[err_mask]
    return abmag, abmag_err


vega_offset_cache = {}


def get_ab_minus_vega_offset(filter_name, mjd, model=sncosmo_model):
    """Return AB - Vega for one filter, or NaN if Vega data are unavailable."""
    cache_key = filter_name.upper()
    if cache_key in vega_offset_cache:
        return vega_offset_cache[cache_key]

    try:
        abmag = model.bandmag(filter_name.lower(), 'ab', mjd)
        vegamag = model.bandmag(filter_name.lower(), 'vega', mjd)
        offsets = np.asarray(abmag, dtype=float) - np.asarray(vegamag, dtype=float)
        offset = np.nanmedian(offsets[np.isfinite(offsets)])
    except Exception as exc:
        print(
            f'Vega magnitudes are unavailable for {filter_name.upper()} '
            f'({exc.__class__.__name__}); Vega columns will be NaN.'
        )
        offset = np.nan

    vega_offset_cache[cache_key] = offset
    return offset


def ensure_float_column(table, column_name):
    """Create a float column filled with NaN if it is not already present."""
    if column_name not in table.colnames:
        table[column_name] = np.full(len(table), np.nan, dtype=float)


def add_model_calibrated_magnitude_columns(
    table,
    flux_column,
    flux_err_column,
    fit_result,
    prefix,
    ab_minus_vega=np.nan,
    mask=None,
):
    """Add model-calibrated maggies, AB magnitudes, and Vega magnitudes."""
    if mask is None:
        mask = np.ones(len(table), dtype=bool)

    columns = [
        f'{prefix}_maggies_calibrated',
        f'{prefix}_maggies_calibrated_err',
        f'{prefix}_abmag',
        f'{prefix}_abmag_err',
        f'{prefix}_vega_mag',
        f'{prefix}_vega_mag_err',
    ]
    for column in columns:
        ensure_float_column(table, column)

    scale = float(fit_result['scale'])
    offset = float(fit_result['offset'])
    if np.isfinite(scale) and scale != 0:
        measured_flux = np.asarray(table[flux_column][mask], dtype=float)
        measured_err = np.asarray(table[flux_err_column][mask], dtype=float)
        maggies = (measured_flux - offset) / scale
        maggies_err = measured_err / np.abs(scale)
    else:
        maggies = np.full(np.count_nonzero(mask), np.nan, dtype=float)
        maggies_err = np.full(np.count_nonzero(mask), np.nan, dtype=float)

    abmag, abmag_err = maggies_to_abmag(maggies, maggies_err)
    table[f'{prefix}_maggies_calibrated'][mask] = maggies
    table[f'{prefix}_maggies_calibrated_err'][mask] = maggies_err
    table[f'{prefix}_abmag'][mask] = abmag
    table[f'{prefix}_abmag_err'][mask] = abmag_err

    if np.isfinite(ab_minus_vega):
        table[f'{prefix}_vega_mag'][mask] = abmag - ab_minus_vega
    table[f'{prefix}_vega_mag_err'][mask] = abmag_err

Vega magnitudes are also added when the local sncosmo Vega magnitude system is available.

ab_minus_vega = get_ab_minus_vega_offset(filter_name, photometry['mjd'])
if np.isfinite(ab_minus_vega):
    photometry['sncosmo_model_vega_mag'] = photometry['sncosmo_model_abmag'] - ab_minus_vega
else:
    photometry['sncosmo_model_vega_mag'] = np.full(len(photometry), np.nan, dtype=float)

add_model_calibrated_magnitude_columns(
    photometry,
    'aperture_flux',
    'aperture_flux_err',
    aperture_sncosmo_fit,
    'aperture',
    ab_minus_vega=ab_minus_vega,
)
add_model_calibrated_magnitude_columns(
    photometry,
    'psf_flux',
    'psf_flux_err',
    psf_sncosmo_fit,
    'psf',
    ab_minus_vega=ab_minus_vega,
)

photometry[
    [
        'mjd',
        'filter',
        'aperture_abmag',
        'aperture_abmag_err',
        'aperture_vega_mag',
        'psf_abmag',
        'psf_abmag_err',
        'psf_vega_mag',
        'sncosmo_model_abmag',
        'sncosmo_model_vega_mag',
    ]
][:8]
Downloading https://sncosmo.github.io/data/spectra/alpha_lyr_stis_007.fits
 [Done]
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: divide by zero encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
/tmp/ipykernel_3192/1817952548.py:31: RuntimeWarning: invalid value encountered in subtract
  offsets = np.asarray(abmag, dtype=float) - np.asarray(vegamag, dtype=float)
Table length=8
mjdfilteraperture_abmagaperture_abmag_erraperture_vega_magpsf_abmagpsf_abmag_errpsf_vega_magsncosmo_model_abmagsncosmo_model_vega_mag
float64str4float64float64float64float64float64float64float64float64
61525.0F12922.823417708321520.1499482588002507321.8661139599326522.7993892785106130.1808491428252637621.84208553012174322.81523270225152421.857928953862654
61530.0F12921.2705253857513770.03608951192580802520.31322163736250621.2698229046565820.05465714049480535620.3125191562677121.26668824576846420.309384497379593
61535.0F12920.42532265490030.01649340040311277419.4680189065114320.4312935698216120.0365705997776325219.47398982143274220.43001337434568419.472709625956814
61540.0F12919.875506394114320.0100658444301912218.9182026457254519.8744545015707850.0316628206877938418.91715075318191519.8749001926831518.91759644429428
61545.0F12919.475781814258810.00697263103309816518.5184780658699419.4760330610705740.02996601698217490518.51872931268170319.47518208440302318.517878336014153
61550.0F12919.1788893168949460.00524448681130787818.22158556850607519.1788741551505520.0291291218688828618.22157040676168219.17922786538443718.221924116995567
61555.0F12918.9980412438958550.00444796652793187818.04073749550698518.9977808928917750.0287953195868532218.04047714450290518.99745642579387518.040152677405004
61560.0F12918.9215791372762570.00412066754481738817.96427538888738718.922025932232010.0286593536994624617.9647221838431418.92208838181541717.964784633426547

Let’s plot the model-calibrated magnitude light-curve

fig, ax = plt.subplots(figsize=(8, 5))

for prefix, marker, label in [
    ('aperture', 'o', 'Aperture photometry'),
    ('psf', 's', 'PSF photometry'),
]:
    mag_column = f'{prefix}_abmag'
    err_column = f'{prefix}_abmag_err'
    valid = np.isfinite(photometry[mag_column])
    ax.errorbar(
        photometry['mjd'][valid],
        photometry[mag_column][valid],
        yerr=photometry[err_column][valid],
        fmt=marker,
        label=label,
    )

ax.plot(dense_mjd, dense_abmag, color='0.2', label='Injected sncosmo model')
ax.axvline(model_peak_mjd, color='0.4', linestyle='--', linewidth=1, label='sncosmo peak')
ax.set_xlabel('MJD')
ax.set_ylabel('AB magnitude')
ax.set_title(f'{filter_name} model-calibrated magnitude light curve')
ax.invert_yaxis()
ax.grid(alpha=0.3)
ax.legend(fontsize=9)

if np.isfinite(ab_minus_vega):
    secax = ax.secondary_yaxis(
        'right',
        functions=(
            lambda abmag: abmag - ab_minus_vega,
            lambda vegamag: vegamag + ab_minus_vega,
        ),
    )
    secax.set_ylabel('Vega magnitude')
../../_images/0351cbee796d8225703290eab9afae2aa99240ac8b2b41675a473e166bb3be4a.png

We can also plot the aperture and PSF photometry compared with the sncosmo model and residuals

fig, (ax_lc, ax_resid) = plt.subplots(
    nrows=2,
    ncols=1,
    figsize=(8, 7),
    sharex=True,
    gridspec_kw={'height_ratios': [3, 1]},
    constrained_layout=True,
)

aperture_dense_fit = aperture_sncosmo_fit['scale'] * dense_maggies + aperture_sncosmo_fit['offset']
psf_dense_fit = psf_sncosmo_fit['scale'] * dense_maggies + psf_sncosmo_fit['offset']

ax_lc.errorbar(
    photometry['mjd'],
    photometry['aperture_flux'],
    yerr=photometry['aperture_flux_err'],
    fmt='o',
    label='Aperture photometry',
)
ax_lc.plot(dense_mjd, aperture_dense_fit, color='C0', alpha=0.7, label='Scaled sncosmo model (aperture)')

ax_lc.errorbar(
    photometry['mjd'],
    photometry['psf_flux'],
    yerr=photometry['psf_flux_err'],
    fmt='s',
    label='PSF photometry',
)
ax_lc.plot(dense_mjd, psf_dense_fit, color='C1', alpha=0.7, label='Scaled sncosmo model (PSF)')

ax_lc.axvline(model_peak_mjd, color='0.3', linestyle='--', linewidth=1, label='sncosmo peak')
ax_lc.set_ylabel('Flux (image units)')
ax_lc.set_title(f'{filter_name} photometry compared with the input sncosmo model')
ax_lc.legend(fontsize=9)

ax_resid.axhline(0, color='0.4', linestyle='--', linewidth=1)
ax_resid.scatter(photometry['mjd'], photometry['aperture_sncosmo_residual'], label='Aperture residual')
ax_resid.scatter(photometry['mjd'], photometry['psf_sncosmo_residual'], label='PSF residual')
ax_resid.set_xlabel('MJD')
ax_resid.set_ylabel('Data - model')
ax_resid.legend(fontsize=9)

for ax in (ax_lc, ax_resid):
    ax.grid(alpha=0.3)
../../_images/3f6af90b20d14fce2b50957d54edaa8493358f112f3eb9e170e4e9c59ce9983b.png

The fitted scale factors map the dimensionless sncosmo model fluxes into the image units measured from the simulated cutouts. The constant offsets are useful diagnostics: a large offset can indicate that residual host-galaxy light or sky background remains in the measurement. The residual panel is a quick check for time-dependent structure that is not captured by the input light-curve model.

Injected-Truth Comparison#

Because this is a simulation, we know the input sncosmo light-curve shape. The absolute image fluxes and sncosmo maggies live in different units, but after normalizing each curve by its own peak we can compare how well aperture and PSF photometry recover the injected time evolution.

photometry['aperture_truth_residual_normalized'] = (
    photometry['aperture_flux_normalized'] - photometry['sncosmo_model_normalized']
)
photometry['psf_truth_residual_normalized'] = (
    photometry['psf_flux_normalized'] - photometry['sncosmo_model_normalized']
)

truth_summary = Table(rows=[
    {
        'method': 'Aperture',
        'rms_normalized_residual': np.sqrt(np.nanmean(photometry['aperture_truth_residual_normalized'] ** 2)),
        'median_normalized_residual': np.nanmedian(photometry['aperture_truth_residual_normalized']),
    },
    {
        'method': 'PSF',
        'rms_normalized_residual': np.sqrt(np.nanmean(photometry['psf_truth_residual_normalized'] ** 2)),
        'median_normalized_residual': np.nanmedian(photometry['psf_truth_residual_normalized']),
    },
])

truth_summary
Table length=2
methodrms_normalized_residualmedian_normalized_residual
str8float64float64
Aperture0.028946985333705370.025851502088473866
PSF0.0150087752921377670.013964189968344143

We can visualize the results

fig, (ax_lc, ax_resid) = plt.subplots(
    nrows=2,
    ncols=1,
    figsize=(8, 7),
    sharex=True,
    gridspec_kw={'height_ratios': [3, 1]},
    constrained_layout=True,
)

ax_lc.plot(dense_mjd, dense_maggies_normalized, color='0.2', label='Injected sncosmo truth')
ax_lc.scatter(photometry['mjd'], photometry['aperture_flux_normalized'], label='Aperture')
ax_lc.scatter(photometry['mjd'], photometry['psf_flux_normalized'], label='PSF')
ax_lc.set_ylabel('Flux / peak flux')
ax_lc.set_title(f'Normalized {filter_name} recovery compared with injected truth')
ax_lc.legend()

ax_resid.axhline(0, color='0.4', linestyle='--', linewidth=1)
ax_resid.scatter(
    photometry['mjd'],
    photometry['aperture_truth_residual_normalized'],
    label='Aperture - truth',
)
ax_resid.scatter(
    photometry['mjd'],
    photometry['psf_truth_residual_normalized'],
    label='PSF - truth',
)
ax_resid.set_xlabel('MJD')
ax_resid.set_ylabel('Residual')
ax_resid.legend()

for ax in (ax_lc, ax_resid):
    ax.grid(alpha=0.3)
../../_images/85dd6bced9130874ed97868825815055905eb1a7c642758f43342ca1894fb437.png

Difference-Image Photometry#

A common time-domain strategy is to subtract a reference image before measuring the transient. For this simulated sequence, we choose the epoch where the input sncosmo model is faintest as a simple reference template. This is not a full image-subtraction pipeline, but it demonstrates how template subtraction changes the aperture and PSF light curves.

template_index = int(np.nanargmin(photometry['sncosmo_model_maggies']))
template_image = images[template_index]
template_mjd = photometry['mjd'][template_index]
template_model_flux = photometry['sncosmo_model_maggies'][template_index]

difference_rows = []
for path, mjd, filt, image in zip(cutout_files, mjds, filters, images):
    difference_image = image - template_image
    aperture_result = measure_aperture_flux(
        difference_image,
        cutout_target_position,
        aperture_radius,
        annulus_inner_radius,
        annulus_outer_radius,
        correction=aperture_correction,
    )
    psf_result = measure_psf_flux(
        difference_image,
        cutout_target_position,
        full_detector_target_position,
        psf_grid,
        half_size=psf_fit_half_size,
    )
    difference_rows.append({
        'file': path.name,
        'mjd': mjd,
        'filter': filt,
        'difference_aperture_flux': aperture_result['aperture_flux'],
        'difference_aperture_flux_err': aperture_result['aperture_flux_err'],
        'difference_psf_flux': psf_result['psf_flux'],
        'difference_psf_flux_err': psf_result['psf_flux_err'],
    })

difference_table = Table(rows=difference_rows)
photometry['difference_aperture_flux'] = difference_table['difference_aperture_flux']
photometry['difference_aperture_flux_err'] = difference_table['difference_aperture_flux_err']
photometry['difference_psf_flux'] = difference_table['difference_psf_flux']
photometry['difference_psf_flux_err'] = difference_table['difference_psf_flux_err']
photometry['difference_model_maggies'] = photometry['sncosmo_model_maggies'] - template_model_flux
photometry['difference_aperture_snr'] = safe_ratio(
    photometry['difference_aperture_flux'],
    photometry['difference_aperture_flux_err'],
)
photometry['difference_psf_snr'] = safe_ratio(
    photometry['difference_psf_flux'],
    photometry['difference_psf_flux_err'],
)

print(f'Using MJD {template_mjd:.0f} as the difference-image reference epoch.')
difference_table[:5]
Using MJD 61675 as the difference-image reference epoch.
/tmp/ipykernel_3192/719397172.py:76: RuntimeWarning: invalid value encountered in scalar divide
  'psf_max_abs_residual_fraction': np.nanmax(np.abs(residual)) / np.nanmax(np.abs(stamp)),
Table length=5
filemjdfilterdifference_aperture_fluxdifference_aperture_flux_errdifference_psf_fluxdifference_psf_flux_err
str26float64str4float64float64float64float64
roman_sn1a_61525_f129.asdf61525.0F12914.0263970494831810.308514817834804677.045512679766620.2299574384525217
roman_sn1a_61530_f129.asdf61530.0F12957.464256953806870.2939857607463598528.832304152742740.7442808893897371
roman_sn1a_61535_f129.asdf61535.0F129124.666368219317720.3185763697031807662.418810075752851.5888463672890314
roman_sn1a_61540_f129.asdf61540.0F129207.518360227715820.3146753672564537104.246028233519452.629915005690121
roman_sn1a_61545_f129.asdf61545.0F129299.84937905962860.318880768641555150.463772448988663.8011261615833916
difference_aperture_fit = fit_scaled_sncosmo_model(
    photometry['difference_aperture_flux'],
    photometry['difference_aperture_flux_err'],
    photometry['difference_model_maggies'],
)
difference_psf_fit = fit_scaled_sncosmo_model(
    photometry['difference_psf_flux'],
    photometry['difference_psf_flux_err'],
    photometry['difference_model_maggies'],
)

photometry['difference_aperture_sncosmo_fit_flux'] = difference_aperture_fit['fitted_flux']
photometry['difference_aperture_sncosmo_residual'] = difference_aperture_fit['residual']
photometry['difference_psf_sncosmo_fit_flux'] = difference_psf_fit['fitted_flux']
photometry['difference_psf_sncosmo_residual'] = difference_psf_fit['residual']

add_model_calibrated_magnitude_columns(
    photometry,
    'difference_aperture_flux',
    'difference_aperture_flux_err',
    difference_aperture_fit,
    'difference_aperture',
    ab_minus_vega=ab_minus_vega,
)
add_model_calibrated_magnitude_columns(
    photometry,
    'difference_psf_flux',
    'difference_psf_flux_err',
    difference_psf_fit,
    'difference_psf',
    ab_minus_vega=ab_minus_vega,
)

difference_sncosmo_summary = Table(rows=[
    {
        'method': 'Difference aperture',
        'template_mjd': template_mjd,
        'scale': difference_aperture_fit['scale'],
        'offset': difference_aperture_fit['offset'],
        'reduced_chi2': difference_aperture_fit['reduced_chi2'],
    },
    {
        'method': 'Difference PSF',
        'template_mjd': template_mjd,
        'scale': difference_psf_fit['scale'],
        'offset': difference_psf_fit['offset'],
        'reduced_chi2': difference_psf_fit['reduced_chi2'],
    },
])

difference_sncosmo_summary
Table length=2
methodtemplate_mjdscaleoffsetreduced_chi2
str19float64float64float64float64
Difference aperture61675.018487495639.7505950.080440714135637660.24445635148539252
Difference PSF61675.09277524662.9561860.084784227620107580.004768983452158085

Ploting the results

fig, ax = plt.subplots(figsize=(8, 5))

ax.errorbar(
    photometry['mjd'],
    photometry['difference_aperture_flux'],
    yerr=photometry['difference_aperture_flux_err'],
    fmt='o-',
    label='Difference aperture',
)
ax.errorbar(
    photometry['mjd'],
    photometry['difference_psf_flux'],
    yerr=photometry['difference_psf_flux_err'],
    fmt='s-',
    label='Difference PSF',
)

ax.plot(
    photometry['mjd'],
    photometry['difference_aperture_sncosmo_fit_flux'],
    color='C0',
    alpha=0.7,
    label='Scaled model (difference aperture)',
)
ax.plot(
    photometry['mjd'],
    photometry['difference_psf_sncosmo_fit_flux'],
    color='C1',
    alpha=0.7,
    label='Scaled model (difference PSF)',
)

ax.axhline(0, color='0.4', linestyle='--', linewidth=1)
ax.axvline(template_mjd, color='0.5', linestyle=':', linewidth=1, label='Reference epoch')
ax.set_xlabel('MJD')
ax.set_ylabel('Difference flux (image units)')
ax.set_title(f'{filter_name} simple template-subtracted light curve')
ax.grid(alpha=0.3)
ax.legend(fontsize=9)
<matplotlib.legend.Legend at 0x7f6aabd9d6d0>
../../_images/2513622550500277a7d9f0348e27ad1b6359a2f8f84d0510f4fabee934430719.png

Host-Galaxy and Background Diagnostics#

The supernova was injected near a host galaxy, so residual host light can bias the measured transient flux. The diagnostics below look for correlations between photometric residuals and local background estimates. Strong trends can indicate that a method is still sensitive to host-galaxy structure or imperfect background removal.

# Define a helper function to compute correlation coefficients while ignoring NaN values
def finite_correlation(x, y):
    """Compute a Pearson correlation coefficient for finite values only."""
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    mask = np.isfinite(x) & np.isfinite(y)
    if np.count_nonzero(mask) < 3:
        return np.nan
    return np.corrcoef(x[mask], y[mask])[0, 1]

# Compute correlations between photometry and background values
photometry['local_background_difference'] = (
    photometry['background_per_pixel'] - photometry['psf_background']
)
photometry['absolute_truth_residual_difference'] = (
    np.abs(photometry['aperture_truth_residual_normalized'])
    - np.abs(photometry['psf_truth_residual_normalized'])
)

host_diagnostics = Table(rows=[
    {
        'diagnostic': 'Aperture residual vs aperture background',
        'correlation': finite_correlation(
            photometry['aperture_truth_residual_normalized'],
            photometry['background_per_pixel'],
        ),
    },
    {
        'diagnostic': 'PSF residual vs fitted PSF background',
        'correlation': finite_correlation(
            photometry['psf_truth_residual_normalized'],
            photometry['psf_background'],
        ),
    },
    {
        'diagnostic': 'Aperture / PSF flux ratio vs aperture background',
        'correlation': finite_correlation(
            photometry['flux_ratio'],
            photometry['background_per_pixel'],
        ),
    },
    {
        'diagnostic': 'Aperture background fraction vs truth residual difference',
        'correlation': finite_correlation(
            photometry['aperture_background_fraction'],
            photometry['absolute_truth_residual_difference'],
        ),
    },
])

host_diagnostics
Table length=4
diagnosticcorrelation
str57float64
Aperture residual vs aperture background0.10772820220999123
PSF residual vs fitted PSF background-0.9986078131191554
Aperture / PSF flux ratio vs aperture background0.20485846698432084
Aperture background fraction vs truth residual difference0.7883006930576941

Visualizing the residuals vs background and how the trransient flux compares with time using both methods.

fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(11, 4), constrained_layout=True)

axes[0].scatter(
    photometry['background_per_pixel'],
    photometry['aperture_truth_residual_normalized'],
    label='Aperture',
)
axes[0].scatter(
    photometry['psf_background'],
    photometry['psf_truth_residual_normalized'],
    label='PSF',
)
axes[0].axhline(0, color='0.4', linestyle='--', linewidth=1)
axes[0].set_xlabel('Local background estimate')
axes[0].set_ylabel('Normalized residual from truth')
axes[0].set_title('Residuals vs. background')
axes[0].legend()

axes[1].scatter(photometry['mjd'], photometry['flux_ratio'])
axes[1].axhline(1, color='0.4', linestyle='--', linewidth=1)
axes[1].set_xlabel('MJD')
axes[1].set_ylabel('Aperture / PSF flux')
axes[1].set_title('Method ratio over time')

for ax in axes:
    ax.grid(alpha=0.3)
../../_images/30f62a8e84684f52acd9230a3c5d3ecc00f0d77b9bc1a7532feac983476a8dd7.png

Normalized sncosmo Fit for Peak Time#

The previous sncosmo comparison held the simulation parameters fixed and only fit a flux scale plus constant offset. We can also ask whether the recovered normalized light curve prefers the same peak time, $t_0$, as the injected model.

# Define a function to fit normalized measured fluxes with a grid of sncosmo t0 values
def fit_normalized_t0_grid(measured_normalized, measured_err, mjd, filter_name, t0_grid):
    """Fit normalized measured fluxes with a grid of sncosmo t0 values."""
    measured_normalized = np.asarray(measured_normalized, dtype=float)
    measured_err = np.asarray(measured_err, dtype=float)
    mjd = np.asarray(mjd, dtype=float)

    fit_model = sncosmo.Model(source='salt3-nir')
    fit_model.set(z=1.2, x1=1, c=1)
    fit_model.set_source_peakmag(20, 'f158', 'ab')

    rows = []
    for trial_t0 in t0_grid:
        fit_model.set(t0=trial_t0)
        trial_maggies = sncosmo_maggies_for_filter(filter_name, mjd, model=fit_model)
        trial_model = trial_maggies / np.nanmax(trial_maggies)
        fit = fit_scaled_sncosmo_model(measured_normalized, measured_err, trial_model)
        rows.append({
            't0': trial_t0,
            'scale': fit['scale'],
            'offset': fit['offset'],
            'reduced_chi2': fit['reduced_chi2'],
        })

    results = Table(rows=rows)
    best_index = int(np.nanargmin(results['reduced_chi2']))
    return results, results[best_index]

Here we scan a small grid of possible t0 values around the injected value. For each trial t0, we evaluate the SALT3-NIR model, normalize it, and fit a linear amplitude and offset to the normalized measured light curve.

t0_grid = simulation_t0 + np.linspace(-10, 10, 201)
aperture_norm_err = safe_ratio(photometry['aperture_flux_err'], np.nanmax(photometry['aperture_flux']))
psf_norm_err = safe_ratio(photometry['psf_flux_err'], np.nanmax(photometry['psf_flux']))

aperture_t0_grid, aperture_best_t0 = fit_normalized_t0_grid(
    photometry['aperture_flux_normalized'],
    aperture_norm_err,
    photometry['mjd'],
    filter_name,
    t0_grid,
)
psf_t0_grid, psf_best_t0 = fit_normalized_t0_grid(
    photometry['psf_flux_normalized'],
    psf_norm_err,
    photometry['mjd'],
    filter_name,
    t0_grid,
)

t0_fit_summary = Table(rows=[
    {
        'method': 'Aperture',
        'best_t0': aperture_best_t0['t0'],
        't0_offset_days': aperture_best_t0['t0'] - simulation_t0,
        'scale': aperture_best_t0['scale'],
        'offset': aperture_best_t0['offset'],
        'reduced_chi2': aperture_best_t0['reduced_chi2'],
    },
    {
        'method': 'PSF',
        'best_t0': psf_best_t0['t0'],
        't0_offset_days': psf_best_t0['t0'] - simulation_t0,
        'scale': psf_best_t0['scale'],
        'offset': psf_best_t0['offset'],
        'reduced_chi2': psf_best_t0['reduced_chi2'],
    },
])

t0_fit_summary
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: divide by zero encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: invalid value encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: divide by zero encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: invalid value encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
Table length=2
methodbest_t0t0_offset_daysscaleoffsetreduced_chi2
str8float64float64float64float64float64
Aperture61564.559513888890.00.95074213437862550.049429557399244220.018266027025178103
PSF61564.559513888890.00.97435214406620940.0256713498680141570.0016098338189037193

We now plt the results

fig, ax = plt.subplots(figsize=(8, 4))

ax.plot(aperture_t0_grid['t0'] - simulation_t0, aperture_t0_grid['reduced_chi2'], label='Aperture')
ax.plot(psf_t0_grid['t0'] - simulation_t0, psf_t0_grid['reduced_chi2'], label='PSF')
ax.axvline(0, color='0.4', linestyle='--', linewidth=1, label='Injected t0')

ax.set_xlabel('Trial t0 - injected t0 (days)')
ax.set_ylabel('Reduced chi-square')
ax.set_title(f'{filter_name} normalized t0 scan')
ax.grid(alpha=0.3)
ax.legend()
<matplotlib.legend.Legend at 0x7f6aabd46e90>
../../_images/f4a91ceec2b653b2535b8ab29cf487a62d965d7ce36e843e9bd945d30444a26f.png

Multi-Filter Photometry#

The default simulation notebook writes F129 cutouts, and includes commented lines for F184. The functions above are filter-aware, so if additional files matching roman_sn1a_*_*.asdf are present, the cell below measures each filter and builds a combined photometry table.

For filters beyond the primary walkthrough filter, the notebook computes a matching aperture correction and STPSF grid before measuring aperture and PSF fluxes.

def get_aperture_correction(filter_name):
    """Return a cached aperture correction for one filter."""
    if filter_name not in aperture_correction_cache:
        aperture_correction_cache[filter_name] = aperture_correction_from_stpsf(
            filter_name=filter_name,
            detector_name=detector_name,
            detector_position=full_detector_target_position,
            radius_pix=aperture_radius,
        )
    return aperture_correction_cache[filter_name]


def get_psf_grid(filter_name):
    """Return a cached STPSF grid for one filter."""
    if filter_name not in psf_grid_cache:
        psf_grid_cache[filter_name] = make_psf_grid(
            filter_name,
            detector_name,
            full_detector_target_position,
        )
    return psf_grid_cache[filter_name]


def measure_cutout_file(path):
    """Measure aperture and PSF photometry for one ASDF cutout file."""
    filt = extract_filter(path)
    image = read_cutout(path)
    correction = get_aperture_correction(filt)
    grid = get_psf_grid(filt)

    aperture_result = measure_aperture_flux(
        image,
        cutout_target_position,
        aperture_radius,
        annulus_inner_radius,
        annulus_outer_radius,
        correction=correction,
    )
    psf_result = measure_psf_flux(
        image,
        cutout_target_position,
        full_detector_target_position,
        grid,
        half_size=psf_fit_half_size,
    )

    mjd = extract_mjd(path)
    model_abmag = sncosmo_model.bandmag(filt.lower(), 'ab', mjd)
    model_maggies = 10 ** (-0.4 * model_abmag)

    return {
        'file': path.name,
        'mjd': mjd,
        'filter': filt,
        'aperture_flux': aperture_result['aperture_flux'],
        'aperture_flux_err': aperture_result['aperture_flux_err'],
        'psf_flux': psf_result['psf_flux'],
        'psf_flux_err': psf_result['psf_flux_err'],
        'psf_background': psf_result['psf_background'],
        'psf_background_x_slope': psf_result['psf_background_x_slope'],
        'psf_background_y_slope': psf_result['psf_background_y_slope'],
        'psf_x': psf_result['psf_x'],
        'psf_y': psf_result['psf_y'],
        'psf_residual_rms': psf_result['psf_residual_rms'],
        'psf_max_abs_residual_fraction': psf_result['psf_max_abs_residual_fraction'],
        'aperture_snr': safe_ratio(
            aperture_result['aperture_flux'],
            aperture_result['aperture_flux_err'],
        ),
        'psf_snr': safe_ratio(
            psf_result['psf_flux'],
            psf_result['psf_flux_err'],
        ),
        'sncosmo_model_abmag': model_abmag,
        'sncosmo_model_maggies': model_maggies,
    }

And the resulting values are

multi_filter_photometry = Table(rows=[
    measure_cutout_file(path)
    for path in all_cutout_files
])

for column in ('aperture_flux', 'psf_flux', 'sncosmo_model_maggies'):
    multi_filter_photometry[f'{column}_normalized'] = np.nan

for filt in sorted(set(multi_filter_photometry['filter'])):
    filt_mask = multi_filter_photometry['filter'] == filt
    for column in ('aperture_flux', 'psf_flux', 'sncosmo_model_maggies'):
        peak_value = np.nanmax(multi_filter_photometry[column][filt_mask])
        multi_filter_photometry[f'{column}_normalized'][filt_mask] = (
            multi_filter_photometry[column][filt_mask] / peak_value
        )

    filt_ab_minus_vega = get_ab_minus_vega_offset(filt, multi_filter_photometry['mjd'][filt_mask])
    ensure_float_column(multi_filter_photometry, 'sncosmo_model_vega_mag')
    if np.isfinite(filt_ab_minus_vega):
        multi_filter_photometry['sncosmo_model_vega_mag'][filt_mask] = (
            multi_filter_photometry['sncosmo_model_abmag'][filt_mask] - filt_ab_minus_vega
        )

    filt_aperture_fit = fit_scaled_sncosmo_model(
        multi_filter_photometry['aperture_flux'][filt_mask],
        multi_filter_photometry['aperture_flux_err'][filt_mask],
        multi_filter_photometry['sncosmo_model_maggies'][filt_mask],
    )
    filt_psf_fit = fit_scaled_sncosmo_model(
        multi_filter_photometry['psf_flux'][filt_mask],
        multi_filter_photometry['psf_flux_err'][filt_mask],
        multi_filter_photometry['sncosmo_model_maggies'][filt_mask],
    )
    add_model_calibrated_magnitude_columns(
        multi_filter_photometry,
        'aperture_flux',
        'aperture_flux_err',
        filt_aperture_fit,
        'aperture',
        ab_minus_vega=filt_ab_minus_vega,
        mask=filt_mask,
    )
    add_model_calibrated_magnitude_columns(
        multi_filter_photometry,
        'psf_flux',
        'psf_flux_err',
        filt_psf_fit,
        'psf',
        ab_minus_vega=filt_ab_minus_vega,
        mask=filt_mask,
    )

multi_filter_photometry[:8]
/home/runner/micromamba/envs/ci-env/lib/python3.13/site-packages/sncosmo/models.py:189: RuntimeWarning: divide by zero encountered in log10
  result[i] = -2.5 * np.log10(f / zpf)
Table length=8
filemjdfilteraperture_fluxaperture_flux_errpsf_fluxpsf_flux_errpsf_backgroundpsf_background_x_slopepsf_background_y_slopepsf_xpsf_ypsf_residual_rmspsf_max_abs_residual_fractionaperture_snrpsf_snrsncosmo_model_abmagsncosmo_model_maggiesaperture_flux_normalizedpsf_flux_normalizedsncosmo_model_maggies_normalizedsncosmo_model_vega_magaperture_maggies_calibratedaperture_maggies_calibrated_erraperture_abmagaperture_abmag_erraperture_vega_magaperture_vega_mag_errpsf_maggies_calibratedpsf_maggies_calibrated_errpsf_abmagpsf_abmag_errpsf_vega_magpsf_vega_mag_err
str26float64str4float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64float64
roman_sn1a_61525_f129.asdf61525.0F12940.776865265071961.89541326768361413.9353464915314621.17405997891103731.21601767094177870.013627428745918442-0.018498628377671522150.4696553521533149.57486279331320.363271729714298660.41879994516960321.51344298381186311.86936505957451822.8152327022515247.480091649981226e-100.074505851566219080.0519454549075114850.02657508223898436321.8579289538626547.423913732979584e-101.0252977959701985e-1022.823417708321520.1499482588002507321.866113959932650.149948258800250737.590043913637045e-101.2642600751194045e-1022.7993892785106130.1808491428252637621.8420855301217430.18084914282526376
roman_sn1a_61530_f129.asdf61530.0F12984.417241940371351.906779292850134135.7221379645075741.45160109105425181.23328182148773150.013254067845078488-0.01836104708681796150.4696553521533149.57486279331320.44914710378897820.552087194914757744.2721620991539924.60878417951843721.2666882457684643.114043691941633e-090.154243796249496020.133157988426257340.1106349642244652120.3093844973795933.1030576608762797e-091.0314460913055037e-1021.2705253857513770.03608951192580802520.3132216373625060.0360895119258080253.1050660167487484e-091.5631239778072e-1021.2698229046565820.05465714049480535620.312519156267710.054657140494805356
roman_sn1a_61535_f129.asdf61535.0F129151.999384074035761.898063391381030569.30864388751772.1025396572923621.25810176194485850.013393677789446001-0.017866464495455273150.4696553521533149.57486279331320.65055723882687640.597621325046368380.0813001105516532.9642504706774320.4300133743456846.72968366455753e-090.277727173836417930.25835518606930320.2390905154596800819.4727096259568146.758820895100782e-091.0267313440160714e-1020.42532265490030.01649340040311277419.468018906511430.0164934004031127746.7217533465227764e-092.2640725285052808e-1020.4312935698216120.0365705997776325219.4739898214327420.03657059977763252
roman_sn1a_61540_f129.asdf61540.0F129234.377510586055961.9221055330802863111.13586204528423.04016630803655771.28983211112105560.01289817775061651-0.017993814996708504150.4696553521533149.57486279331320.94067295809191240.6135691072171596121.9378991175642336.5558495110945119.874900192683151.122121601639446e-080.428245180218460530.41427049653835250.3986645517342554718.917596444294281.1214952599409768e-081.0397366106325573e-1019.875506394114320.0100658444301912218.918202645725450.010065844430191221.1225823233925057e-083.2737346932980005e-1019.8744545015707850.0316628206877938418.9171507531819150.03166282068779384
roman_sn1a_61545_f129.asdf61545.0F129326.65222947282761.9240374957162327157.353606260753484.1528413271075261.32650624434653720.012566029254290637-0.017379125079890062150.4696553521533149.57486279331321.2849512624786330.6267013098193152169.774357412525237.8905895666259419.4751820844030231.6215381329893388e-080.59684584297174210.58655195000135260.576096005962025918.5178783360141531.620642688691428e-081.040781679307714e-1019.475781814258810.00697263103309816518.518478065869940.0069726310330981651.6202677042356097e-084.4718937554090337e-1019.4760330610705740.02996601698217490518.5187293126817030.029966016982174905
roman_sn1a_61550_f129.asdf61550.0F129420.873033338612631.9022905189688266204.722311140359525.30771104234597951.36292248524472860.01224908363988584-0.017199379814734475150.4696553521533149.57486279331321.64228523739059250.6315973467702639221.245403444871838.5707340710607719.1792278653844372.1296530364436267e-080.76900231408937620.76312372917068290.756617796041766318.2219241169955672.130317197070226e-081.0290179506748656e-1019.1788893168949460.00524448681130787818.2215855685060750.0052444868113078782.1303469460554812e-085.715489226821594e-1019.1788741551505520.0291291218688828618.2215704067616820.02912912186888286
roman_sn1a_61555_f129.asdf61555.0F129492.250196952353241.9057879884150366240.631378466665646.1992490863828951.39252170502421510.012122063869255545-0.01679069672781297150.4696553521533149.57486279331321.9181404519289410.6346004114915773258.292212955826738.8162138855220518.9974564257938752.517777972537858e-080.89941980260530490.89697851625494970.894509851090725918.0401526774050042.5164221683898015e-081.0309098587752318e-1018.9980412438958550.00444796652793187818.0407374955069850.0044479665279318782.517025658990841e-086.675521912350451e-1018.9977808928917750.0287953195868532218.0404771445029050.02879531958685322
roman_sn1a_61560_f129.asdf61560.0F129526.19251496573451.8943729979128923257.52284446672746.6158486348861881.40519194843101560.01190058421265533-0.017043316196811417150.4696553521533149.57486279331322.0470425875109380.6350187572198474277.7660553362311538.9251415319234518.9220883818154172.69876237809323e-080.96143784374895690.95994321440301460.958809537333471217.9647846334265472.700028479702425e-081.0247350763135829e-1018.9215791372762570.00412066754481738817.9642753888873870.0041206675448173882.6989176104899655e-087.124127763794258e-1018.922025932232010.0286593536994624617.964722183843140.02865935369946246

Save the Photometry Tables#

The combined single-filter table and the optional multi-filter table can be written to ECSV files for follow-up modeling or comparison with the input sncosmo model from the simulation notebook. These tables include image-flux measurements, model-calibrated AB magnitudes, and Vega magnitudes when the local Vega magnitude system is available.

output_table = Path('time_domain_analysis_photometry.ecsv')
photometry.write(output_table, format='ascii.ecsv', overwrite=True)
print(f'Wrote {output_table}')

multi_filter_output_table = Path('time_domain_analysis_multifilter_photometry.ecsv')
multi_filter_photometry.write(multi_filter_output_table, format='ascii.ecsv', overwrite=True)
print(f'Wrote {multi_filter_output_table}')
Wrote time_domain_analysis_photometry.ecsv
Wrote time_domain_analysis_multifilter_photometry.ecsv

Animate the Supernova and Light Curve#

As a final visual check, we can combine the image sequence with the measured light curve. The left panel shows the evolving cutout, while the right panel advances through the PSF photometry points from the same epochs. This links each image frame directly to its position on the recovered light curve.

Let’s firs define some helpful functions

def resolve_cutout_path(file_name):
    """Find a cutout file in the current directory or the demo-data directory."""
    path = Path(str(file_name))
    if path.exists():
        return path

    demo_path = Path('demo_cutouts') / path.name
    if demo_path.exists():
        return demo_path

    raise FileNotFoundError(f'Could not find cutout file: {file_name}')


def find_dark_image_crop(frame):
    """Crop the black image panel out of a Matplotlib-rendered GIF frame."""
    dark_pixels = np.mean(frame, axis=2) < 35
    row_fraction = dark_pixels.mean(axis=1)
    column_fraction = dark_pixels.mean(axis=0)
    rows = np.where(row_fraction > 0.5)[0]
    columns = np.where(column_fraction > 0.5)[0]

    if len(rows) == 0 or len(columns) == 0:
        return (0, frame.shape[0], 0, frame.shape[1])

    return (rows[0], rows[-1] + 1, columns[0], columns[-1] + 1)


def load_simulation_gif_frames(number_of_frames):
    """Load host-galaxy frames from the simulation GIF when it is available."""
    candidate_gifs = [
        Path('simulation_sn1a.gif'),
        Path('../time_domain_simulations/sn1a.gif'),
    ]
    existing_gifs = [path for path in candidate_gifs if path.exists()]
    if len(existing_gifs) == 0:
        return None, None

    source_gif = existing_gifs[0]
    with PILImage.open(source_gif) as gif:
        frames = [
            np.asarray(frame.convert('RGB'), dtype=np.uint8)
            for frame in ImageSequence.Iterator(gif)
        ]

    if len(frames) != number_of_frames:
        frame_indices = np.linspace(0, len(frames) - 1, number_of_frames)
        frame_indices = np.round(frame_indices).astype(int)
        frames = [frames[index] for index in frame_indices]

    row_start, row_stop, column_start, column_stop = find_dark_image_crop(frames[0])
    frames = [
        frame[row_start:row_stop, column_start:column_stop]
        for frame in frames
    ]

    return frames, source_gif


def update_animation(frame_index):
    image_artist.set_data(image_frames[frame_index])
    image_ax.set_title(
        f'{animation_filter} cutout: MJD {mjd_values[frame_index]:.0f}'
    )

    progress_line.set_data(
        mjd_values[: frame_index + 1],
        psf_curve[: frame_index + 1],
    )
    progress_points.set_offsets(
        np.column_stack([
            mjd_values[: frame_index + 1],
            psf_curve[: frame_index + 1],
        ])
    )
    current_point.set_offsets([[mjd_values[frame_index], psf_curve[frame_index]]])
    current_epoch.set_xdata([mjd_values[frame_index], mjd_values[frame_index]])
    epoch_label.set_text(
        f'MJD {mjd_values[frame_index]:.0f}\n'
        f'PSF flux / peak = {psf_curve[frame_index]:.2f}'
    )

    return (
        image_artist,
        progress_line,
        progress_points,
        current_point,
        current_epoch,
        epoch_label,
    )

When the host-containing simulation GIF is available, this cell uses those frames for the image panel so the full galaxy scene from the simulation notebook is preserved. The light curve still comes from the photometry measured in this analysis notebook.

animation_table = photometry.copy() if 'photometry' in globals() else Table.read(
    'time_domain_analysis_photometry.ecsv'
)
animation_table.sort('mjd')

animation_filter = filter_name if 'filter_name' in globals() else str(animation_table['filter'][0])
if 'filter' in animation_table.colnames:
    animation_table = animation_table[animation_table['filter'] == animation_filter]

cutout_paths = [resolve_cutout_path(file_name) for file_name in animation_table['file']]
mjd_values = np.asarray(animation_table['mjd'], dtype=float)
psf_curve = np.asarray(animation_table['psf_flux_normalized'], dtype=float)

image_frames, image_source = load_simulation_gif_frames(len(animation_table))
using_gif_frames = image_frames is not None

if not using_gif_frames:
    image_frames = []
    for path in cutout_paths:
        with asdf.open(path) as af:
            image_frames.append(np.asarray(af['roman']['data'], dtype=float))
    image_source = 'ASDF cutouts'

figure, (image_ax, light_curve_ax) = plt.subplots(
    nrows=1,
    ncols=2,
    figsize=(11, 4.5),
    gridspec_kw={'width_ratios': [1, 1.25]},
    constrained_layout=True,
)

if using_gif_frames:
    image_artist = image_ax.imshow(image_frames[0], animated=True)
    image_ax.set_xticks([])
    image_ax.set_yticks([])
else:
    image_norm = simple_norm(image_frames[0], 'asinh', vmin=0.5, vmax=3)
    image_artist = image_ax.imshow(
        image_frames[0],
        origin='lower',
        cmap='afmhot',
        norm=image_norm,
        animated=True,
    )

image_ax.set_title(f'{animation_filter} cutout: MJD {mjd_values[0]:.0f}')

optional_y_values = [psf_curve]
if 'sncosmo_model_normalized' in animation_table.colnames:
    model_curve = np.asarray(animation_table['sncosmo_model_normalized'], dtype=float)
    optional_y_values.append(model_curve)
    light_curve_ax.plot(
        mjd_values,
        model_curve,
        color='0.35',
        linewidth=1.6,
        label='sncosmo truth',
    )

if 'aperture_flux_normalized' in animation_table.colnames:
    aperture_curve = np.asarray(animation_table['aperture_flux_normalized'], dtype=float)
    optional_y_values.append(aperture_curve)
    light_curve_ax.scatter(
        mjd_values,
        aperture_curve,
        facecolors='none',
        edgecolors='tab:blue',
        s=34,
        alpha=0.45,
        label='Aperture',
    )

light_curve_ax.scatter(
    mjd_values,
    psf_curve,
    color='tab:orange',
    s=32,
    alpha=0.35,
    label='PSF',
)
progress_line, = light_curve_ax.plot([], [], color='tab:orange', linewidth=2.0)
progress_points = light_curve_ax.scatter([], [], color='tab:orange', s=36, zorder=4)
current_point = light_curve_ax.scatter(
    [mjd_values[0]],
    [psf_curve[0]],
    color='crimson',
    edgecolor='white',
    linewidth=0.8,
    s=95,
    zorder=5,
)
current_epoch = light_curve_ax.axvline(
    mjd_values[0],
    color='crimson',
    alpha=0.35,
    linewidth=1.2,
)
epoch_label = light_curve_ax.text(
    0.03,
    0.95,
    '',
    transform=light_curve_ax.transAxes,
    ha='left',
    va='top',
)

finite_y = np.concatenate([
    values[np.isfinite(values)]
    for values in optional_y_values
])
y_min = min(0.0, float(np.nanmin(finite_y)) - 0.05)
y_max = max(1.05, float(np.nanmax(finite_y)) + 0.08)

light_curve_ax.set_xlim(np.nanmin(mjd_values) - 2, np.nanmax(mjd_values) + 2)
light_curve_ax.set_ylim(y_min, y_max)
light_curve_ax.set_xlabel('MJD')
light_curve_ax.set_ylabel('Flux / peak flux')
light_curve_ax.set_title('Recovered light curve')
light_curve_ax.grid(alpha=0.3)
light_curve_ax.legend(loc='upper right', frameon=False)
<matplotlib.legend.Legend at 0x7f6aaba0ce10>
../../_images/f2ecead115dea3914a1953905b07ba569d08f6ccab14be4b62319499d31e5dfb.png

And now the animated plot

light_curve_animation = animation.FuncAnimation(
    figure,
    update_animation,
    frames=len(image_frames),
    interval=350,
    blit=False,
    repeat_delay=1000,
)
output_animation = Path('sn1a_lightcurve.gif')
light_curve_animation.save(
    output_animation,
    writer=animation.PillowWriter(fps=4),
)
plt.close(figure)

print(f'Image frames: {image_source}')
print(f'Wrote {output_animation}')
display(NotebookImage(filename=str(output_animation)))
Image frames: ../time_domain_simulations/sn1a.gif
Wrote sn1a_lightcurve.gif
../../_images/2c2534d2443d679c05bbc41ffa9f58d2c0b4ad85c63a58e75748cba00fb18208.gif

Additional Resources#


About This Notebook#

Author: Melissa Shahbandeh
Updated On: 2026-07-15

Top of page roman_logo