Writing a Custom Transient#

The previous page covered everything you do with a transient class: instantiate it, read off its rate and duration, sample a population from it. This page covers how to build one. If none of the populations shipped with the package (see Transients) fit what you need, this is how you add a new one.

A transient class only ever needs two things: a SpectralModel and a strict upper bound on how long it stays relevant. If you don’t already have the SED half of that, see Writing a Custom Model first – this page picks up exactly where that one leaves off, and reuses the toy SED built there.

Choosing a Base Class#

Base class

Provides

Use it when

TransientBase

sed, duration_limit

You just need the SED/duration bookkeeping – e.g. a single fixed source you’ll drive by hand, or a population with a sampling scheme of your own that has nothing to do with a cosmological volumetric rate. It has no abstract methods, so it’s directly instantiable once DEFAULT_MODEL/DEFAULT_DURATION are set (see A Transient Without a Volumetric Rate below) – but it has no sampling machinery either.

ExtragalacticTransient

everything above, plus rate/rate_shape-driven redshift/count/sky sampling

Your source is an extragalactic population with its own comoving volumetric rate as a function of redshift, and you want the same sample_events_on_healpix_grid() machinery – and, downstream, SurveySimulator support – that every built-in population uses. This is almost certainly the one you want.

The rest of this page builds an ExtragalacticTransient; the bare TransientBase case is covered briefly at the end.

Building an Extragalactic Transient#

We’ll pair a toy SED with a made-up rate to build ToyNova, an ExtragalacticTransient subclass that behaves exactly like the five built-in populations from a caller’s point of view. As in Writing a Custom Model, none of this is meant to describe a real astrophysical source – it exists so every step can be checked as we go.

Rather than deriving a new _eval from scratch again, this SED is composed from two SEDs already in the package – GaussianRiseBrokenPowerLawLightcurve (the same rise/decline shape KilonovaCoolingBlackbodySED uses) and a fixed-temperature BlackbodySpectrum – exactly the composition pattern from the previous page:

from astropy import units as u

from uvex_transients.models.core import ComposedSpectralModel
from uvex_transients.models.lightcurves.generic import GaussianRiseBrokenPowerLawLightcurve
from uvex_transients.models.spectra.thermal import BlackbodySpectrum


class ToyNovaSED(ComposedSpectralModel):
    _LIGHTCURVE_CLASS = GaussianRiseBrokenPowerLawLightcurve
    _SPECTRUM_CLASS = BlackbodySpectrum

That alone is a complete, samplable SpectralModel – see Models for everything it can already do. Pairing it with a transient class is just as short:

import numpy as np
from astropy import units as u

from uvex_transients.transients.base import ExtragalacticTransient

# A made-up, redshift-independent volumetric rate normalization, expressed in the
# units it's most naturally quoted in -- events / Gpc^3 / yr, the same convention
# every built-in population's rate constant uses (see e.g. uvex_transients.transients.kilonovae).
_TOY_NOVA_RATE = 1e4 / (u.Gpc**3 * u.yr)


class ToyNova(ExtragalacticTransient):
    """A made-up nova-like population, for demonstration only."""

    DEFAULT_MODEL = ToyNovaSED
    DEFAULT_DURATION = 20 * u.day
    DEFAULT_Z_LIM = 0.05

    @property
    def rate(self):
        return _TOY_NOVA_RATE

    def rate_shape(self, z):
        z = np.asarray(z)
        shape = np.ones_like(z, dtype=np.float64)
        return shape if z.ndim > 0 else shape.item()

DEFAULT_MODEL/DEFAULT_DURATION are exactly the two class variables described in Transients – checked at class-definition time, so a subclass that forgets one fails immediately at import rather than partway through a Monte Carlo run. DEFAULT_Z_LIM is new here: it overrides ExtragalacticTransient’s own default of 10 (very generous for most populations) with this class’s own redshift horizon – set it, like the built-in populations do, to whatever bound is generous relative to the source’s actual observable luminosity and your survey’s limiting magnitude.

Declaring a Volumetric Rate#

Rather than implementing event_rate() (the comoving event rate density \(R(z)=A f(z)\), in events / Mpc3 / yr) directly, a subclass implements the two pieces it’s built from – the normalization \(A\) and the shape \(f(z)\) – so that a rate uncertainty (see Rate Uncertainty and All-Sky Yield on the previous page) attaches to the single normalization rather than needing to be threaded through a combined function:

Abstract member

Meaning

rate

A property returning the fiducial normalization \(A\) (_TOY_NOVA_RATE above) as a single Quantity. It’s a property, not a plain class attribute, specifically so it can depend on self.cosmology if needed (a core-collapse SNe subtype’s rate, for instance, scales with \(h^2\)).

rate_shape()

The dimensionless redshift dependence \(f(z)\), evaluated at self.rate’s own normalization (i.e. by convention, though not enforced, \(f(0)=1\)). ToyNova above is redshift-independent, so rate_shape is just 1 everywhere.

Two things about rate/rate_shape are easy to get wrong and worth calling out explicitly:

Warning

Always convert to Mpc:sup:`-3` yr:sup:`-1` yourself, with .to_value(...). Internally, event_rate (built from rate/rate_shape – see event_rate()) does self.rate.to_value(u.Mpc**-3 * u.yr**-1) itself, so returning rate in the wrong units (say, per Gpc3 instead of per Mpc3) is caught by that conversion, not silently accepted – but rate_shape gets no such protection: it must already return a plain, dimensionless array or scalar, not a ~astropy.units.Quantity. Every built-in population’s rate property ends in .to()/an explicit unit-carrying Quantity for exactly this reason.

Important

``rate_shape`` must be NumPy-vectorized. ExtragalacticTransient calls it once, over the whole redshift_grid, not in a per-point loop – np.ones_like(z, dtype=np.float64) above is what makes that work whether z is a scalar or an array. Return a plain array or scalar to match, as shape if z.ndim > 0 else shape.item() does – the same convention every built-in rate_shape follows, so a caller can write nova.event_rate(0.01) and get a plain float back, not a length-1 array.

Everything downstream of event_rate – the rate-weighted redshift distribution, the caching and invalidation behavior, integrated_rate – is exactly what From Rate to a Redshift Distribution already describes; nothing about it changes because rate/rate_shape are user-defined. A rate uncertainty is entirely optional: set RATE_CI on the class (a pair of multiplicative (lower, upper) factors on rate, at whatever confidence level your source reports) if one is available; leave it at its default of None otherwise, exactly like most built-in populations do today.

Sampling and Simulating Like a Built-In#

From here, ToyNova behaves identically to any of the built-in populations – because, to every method in the package, it is one:

import numpy as np
import matplotlib.pyplot as plt
from astropy import units as u
from astropy.time import Time

from uvex_transients.models.core import ComposedSpectralModel
from uvex_transients.models.lightcurves.generic import GaussianRiseBrokenPowerLawLightcurve
from uvex_transients.models.spectra.thermal import BlackbodySpectrum
from uvex_transients.transients.base import ExtragalacticTransient


class ToyNovaSED(ComposedSpectralModel):
    _LIGHTCURVE_CLASS = GaussianRiseBrokenPowerLawLightcurve
    _SPECTRUM_CLASS = BlackbodySpectrum


_TOY_NOVA_RATE = 1e4 / (u.Gpc**3 * u.yr)


class ToyNova(ExtragalacticTransient):
    DEFAULT_MODEL = ToyNovaSED
    DEFAULT_DURATION = 20 * u.day
    DEFAULT_Z_LIM = 0.05

    @property
    def rate(self):
        return _TOY_NOVA_RATE

    def rate_shape(self, z):
        z = np.asarray(z)
        shape = np.ones_like(z, dtype=np.float64)
        return shape if z.ndim > 0 else shape.item()

nova = ToyNova()

events = nova.sample_events_on_healpix_grid(
    nside=32, t_start=Time("2025-01-01"), duration=365 * u.day, seed=0,
)

fig = plt.figure(figsize=(7, 4))
ax = fig.add_subplot(111, projection="aitoff")
ax.grid(True)
ra = events["coord"].ra.wrap_at(180 * u.deg).radian
ax.scatter(ra, events["coord"].dec.radian, s=4, c=events["redshift"], cmap="plasma")
ax.set_title(f"{len(events)} sampled ToyNova events, whole sky, one year")

(Source code, png, hires.png, pdf)

../../_images/custom_transients-1.png

Registering it with a SurveySimulator needs nothing beyond the usual {name: instance} dict – see Simulation for building schedule:

from uvex_transients.simulation.core import SurveySimulator

simulator = SurveySimulator(schedule, transients={"toy_nova": ToyNova()}, simulation_seed=0)
catalog = simulator.generate_events(time_bins=6, nside=32)

ToyNova events flow through generate_events, both filtering methods, and EventCatalog.get_events/Event.simulate_photometry exactly like TDE or kilonova events do, because none of that code ever branches on which transient type it’s looking at – it only ever calls the shared TransientBase/ExtragalacticTransient interface this page just implemented.

A Transient Without a Volumetric Rate#

If your source doesn’t fit the “comoving rate density as a function of redshift” model at all – a single fixed source at a known distance, say, or a population you intend to sample with entirely custom code – subclass TransientBase directly instead. It has no abstract methods, so the two class variables are all it takes:

from astropy import units as u
from uvex_transients.transients.base import TransientBase


class FixedSource(TransientBase):
    DEFAULT_MODEL = ToyNovaSED
    DEFAULT_DURATION = 20 * u.day

source = FixedSource()
source.sed              # ready to sample/evaluate, exactly as on the models overview page
source.duration_limit   # 20.0 d

There is no sample_events_on_healpix_grid, event_rate, or redshift sampling here – you supply the sky position(s), redshift(s)/distance, and explosion time(s) yourself, however your use case calls for, and use sed directly (or build an Event by hand) from there.

Testing a New Transient#

Tests live under tests/, mirroring the package layout, so a new transient module uvex_transients/transients/my_source.py gets a matching tests/transients/test_my_source.py. Unlike models (tests.models._contracts), there’s no shared inheritable contract for transient classes to subclass – a straightforward test instantiating the class, sampling a population with a fixed seed, and checking the result (right columns, plausible redshifts, a reproducible count for a given seed) is the right level of coverage. See uvex_transients.transients in the API reference for every method’s exact contract in the meantime.

Next Steps#

A transient class on its own is a population you can sample – it isn’t yet a survey yield. Pair it with a real SurveySchedule and a SurveySimulator, covered in Simulation, to find out how many of your new population’s events an actual survey would detect. If you plan to propose it as a real, physically-motivated population for the package (rather than a one-off), see the Transients gallery for the level of astrophysical detail – adopted rate with citations, calibrated priors, an observability summary – expected of one.