Note

This page is generated from the Jupyter notebook notebooks/07_sersic_host.ipynb in the repository. You can download it and run it interactively.

Sersic-profile hosts: a transient on a galaxy

A scene host is normally either unresolved (an integrated mag; it rides the source PSF) or a uniform surface brightness (surface_brightness=True; the same value in every pixel). A real transient host is neither: a TDE sits on the cusp of a bulge, a supernova somewhere out on a disk, and how much host light lands under the aperture depends on the galaxy’s shape and on where the source sits in it.

host_prop={"profile": "sersic", ...} renders the host as an elliptical Sersic profile,

\[I(r) = I_e \exp\left\{-b_n\left[(r/r_e)^{1/n} - 1\right]\right\},\]

convolves it with the PSF, and shares that one image with every Simulation method: simulate, get_snr, get_peak_pixel, is_saturated and get_image_exptime_for_snr.

key

unit

default

meaning

profile

str

required

"sersic" (the only profile so far)

r_eff

arcsec

required

half-light radius along the major axis

n

1

Sersic index: 1 is an exponential disk, 4 a de Vaucouleurs bulge

ellip

0

ellipticity, 1 - b/a

pa

deg

0

major-axis position angle, counter-clockwise from +x

dx, dy

arcsec

0

offset of the host centre from the source (+x right, +y up)

mag keeps its usual meaning: with the default surface_brightness=False it is the total integrated host magnitude; with surface_brightness=True it is mu_e, the surface brightness at r_eff in mag/arcsec^2. The parameters are angular only, so convert physical sizes and absolute magnitudes yourself. Typical r-band values are n = 1 to 4, r_e of 0.3 to 1 arcsec for hosts at z of 0.5 to 1 and 1 to 10 arcsec for nearby galaxies, and mu_e of 20 to 24 mag/arcsec^2.

[1]:
import matplotlib.pyplot as plt
import numpy as np

import wcc_etc
from wcc_etc import Simulation, get_scene
from wcc_etc.psfsim import ImageSimulator

wcc_etc.set_wcc_style()

SENSOR = "zwo:r"  # in-focus r band on the IMX455
TIME = 60.0  # seconds
NPIX = 256  # 256 px = 4.3 arcsec on a side


def host_scene(source_mag=21, **host):
    """A mag-21 G5V transient on a G5V Sersic host; kwargs override host defaults."""
    prop = {"mag": 16, "profile": "sersic", "r_eff": 0.5, "n": 4} | host
    return get_scene(
        "G5V", mag=source_mag, host="G5V", host_prop=prop, background="zodi"
    )

1. What the host looks like on the detector

Four hosts of total magnitude 16 with the mag-21 transient (white cross) at the grid centre. Noiseless 60 s images on a log stretch, through the simulated image’s own plot, with the axes in arcsec to match the host parameters.

[2]:
cases = [
    (dict(n=1, r_eff=0.5), 'n=1 disk, $r_e$ = 0.5"'),
    (dict(n=4, r_eff=0.5), 'n=4 bulge, $r_e$ = 0.5"'),
    (dict(n=1, r_eff=1.0, ellip=0.5, pa=45), 'n=1, $r_e$ = 1.0", ellip 0.5, PA 45'),
    (
        dict(n=4, r_eff=0.3, ellip=0.3, pa=120, dx=0.6),
        'n=4, $r_e$ = 0.3", source 0.6" off',
    ),
]

fig, axes = plt.subplots(1, 4, figsize=(16, 4.2))
for ax, (kw, title) in zip(axes, cases):
    imsim = ImageSimulator.from_sensorfilter(SENSOR, host_scene(**kw), npix=NPIX)
    img = imsim.simulate(TIME, add_noise=False)
    img.plot(
        ax=ax, noise=False, units="arcsec", cmap="magma", colorbar=False, title=title
    )
    ax.plot(0, 0, "+", color="w", ms=10, mew=1)
fig.suptitle("Noiseless 60 s images; host total mag 16, transient mag 21 (+)", y=1.02)
fig.tight_layout()
plt.show()
[wcc_etc] note: even n_pixels is rounded up by 1 to center the PSF peak (shown once).
../_images/notebooks_07_sersic_host_3_1.png

One noisy realization of the last case. The transient is the faint point 0.6 arcsec to the left of the nucleus.

[3]:
imsim = ImageSimulator.from_sensorfilter(
    SENSOR, host_scene(n=4, r_eff=0.3, ellip=0.3, pa=120, dx=0.6), npix=NPIX
)
imsim.simulate(TIME, seed=1).plot(
    units="arcsec", title='n=4 host, source 0.6" off the nucleus, 60 s'
)
plt.show()
../_images/notebooks_07_sersic_host_5_0.png

2. The two normalizations

With the default total-magnitude mode, a compact host (r_eff = 0.1 arcsec, well inside the grid) must carry the same count rate as a point source of the same magnitude. With surface_brightness=True the pixel at r_eff must read the same per-pixel rate as a uniform host of that surface brightness. The two checks below read the rendered host image through _image_render_bundle and _count_rate_components. Those are internal methods, shown here only to verify the normalization; they are not part of the public API and may change.

[4]:
# total-magnitude mode vs a point source of the same magnitude
sim = Simulation.from_sensorfilter(SENSOR, host_scene(r_eff=0.1, n=1))
b = sim._image_render_bundle(sim.default_psf, None, 128, 11)
point = Simulation.from_sensorfilter(SENSOR, get_scene("G5V", mag=16))
point_rate = point._count_rate_components()["source_rate_total"]
host_rate = b["extended_rate_image"].sum()
print(f"point source, mag 16 : {point_rate:9.1f} e-/s")
print(
    f"Sersic host,  mag 16 : {host_rate:9.1f} e-/s   ratio {host_rate / point_rate:.4f}"
)

# mu_e mode vs the uniform surface-brightness host
sim_sb = Simulation.from_sensorfilter(
    SENSOR, host_scene(mag=21, surface_brightness=True, r_eff=0.5, n=1)
)
b_sb = sim_sb._image_render_bundle(sim_sb.default_psf, None, 128, 11)
uniform = Simulation.from_sensorfilter(
    SENSOR,
    get_scene(
        "G5V", mag=21, host="G5V", host_prop={"mag": 21, "surface_brightness": True}
    ),
)._count_rate_components()["diffuse_rate_per_pix"]
c = 63
r_pix = int(round(0.5 / (b_sb["plate_scale_mas"] / 1000)))
at_re = b_sb["extended_rate_image"][c, c + r_pix]
print(f"uniform SB, mu = 21  : {uniform:9.4f} e-/s/pix")
print(f"Sersic at r_eff      : {at_re:9.4f} e-/s/pix   ratio {at_re / uniform:.3f}")
point source, mag 16 :   17921.5 e-/s
Sersic host,  mag 16 :   17911.6 e-/s   ratio 0.9994
uniform SB, mu = 21  :    0.0510 e-/s/pix
Sersic at r_eff      :    0.0500 e-/s/pix   ratio 0.981

3. Nuclear vs offset transient

SNR of the mag-21 transient in 60 s against the host’s total magnitude, with the source at the nucleus (TDE-like) and offset by 0.5 and 1.5 arcsec (SN-like), on an n=4, r_eff = 0.5 arcsec bulge. plot_sweep("host__mag", ...) runs each curve and restores the host magnitude afterwards; host__dx is set by hand before each sweep. The dashed line is the SNR with no host.

[5]:
host_mags = np.arange(13, 20.5, 0.5)
no_host = Simulation.from_sensorfilter(SENSOR, get_scene("G5V", mag=21))
hostless = no_host.get_snr(TIME)["snr"]

sim = Simulation.from_sensorfilter(SENSOR, host_scene())
fig, ax = plt.subplots(figsize=(7, 4.5))
for dx in (0.0, 0.5, 1.5):
    sim.update(host__dx=dx)
    sim.plot_sweep(
        "host__mag",
        host_mags,
        time=TIME,
        ax=ax,
        label=f'source {dx:.1f}" from the nucleus',
    )
ax.axhline(hostless, color="0.4", ls="--", label="no host")
ax.set_xlabel("host total magnitude (r)")
ax.set_ylabel("SNR of a mag-21 transient in 60 s")
ax.set_title('n=4 bulge, $r_e$ = 0.5"')
ax.legend()
plt.show()

for dx in (0.0, 1.5):
    sim.update(host__mag=13.0, host__dx=dx)
    ratio = sim.get_snr(TIME)["snr"] / hostless
    print(f'mag-13 host, source {dx:.1f}" out: SNR is {ratio:.2f} of the no-host value')
sim.update(host__mag=16.0, host__dx=0.0)
../_images/notebooks_07_sersic_host_9_0.png
/home/docs/checkouts/readthedocs.org/user_builds/schmidt-observatories-wcc-etc/envs/latest/lib/python3.11/site-packages/wcc_etc/simulation.py:1210: UserWarning: detector saturates — up to 61 pixel(s) at or above full well / ADC clip (per-frame, n_reads=1); SNR is unreliable. See is_saturated/get_peak_pixel.
  return _maybe_warn(_snr_at(time.to(u.second).value, source_scale))
mag-13 host, source 0.0" out: SNR is 0.08 of the no-host value
mag-13 host, source 1.5" out: SNR is 0.84 of the no-host value

4. How compact is the host?

At fixed total magnitude, a more compact host puts more light under the nuclear aperture, so the SNR of a nuclear transient falls as r_eff shrinks.

[6]:
r_effs = [0.1, 0.2, 0.3, 0.5, 0.75, 1.0, 1.5, 2.0]

fig, ax = plt.subplots(figsize=(7, 4.5))
for n in (1, 4):
    sim = Simulation.from_sensorfilter(SENSOR, host_scene(n=n))
    sim.plot_sweep(
        "host__r_eff", r_effs, time=TIME, ax=ax, marker="o", label=f"n = {n}"
    )
ax.axhline(hostless, color="0.4", ls="--", label="no host")
ax.set_xlabel("host $r_e$ [arcsec]")
ax.set_ylabel("SNR of a nuclear mag-21 transient in 60 s")
ax.set_title("host total mag 16, source at the nucleus")
ax.legend()
plt.show()
../_images/notebooks_07_sersic_host_11_0.png

5. One charge budget

The same host image feeds the saturation and exposure-time APIs. A bright compact bulge saturates the nucleus long before the transient would, and get_image_exptime_for_snr inverts get_snr with the host included.

[7]:
bright = Simulation.from_sensorfilter(SENSOR, host_scene(mag=12, r_eff=0.3, n=4))
print(
    "peak pixel in 60 s with a mag-12 n=4 host:",
    bright.get_peak_pixel(TIME, units="e-"),
)
print("saturated?", bool(bright.is_saturated(TIME)))

sim = Simulation.from_sensorfilter(SENSOR, host_scene())
target = sim.get_snr(TIME)["snr"]
t_back = sim.get_image_exptime_for_snr(target)["time_s"]
print(f"get_snr(60 s) = {target:.3f}  ->  get_image_exptime_for_snr = {t_back:.2f} s")
peak pixel in 60 s with a mag-12 n=4 host: 245804.7465461498 electron
saturated? True
get_snr(60 s) = 28.813  ->  get_image_exptime_for_snr = 60.00 s

Summary

  • host_prop={"profile": "sersic", "r_eff": ..., "n": ..., "ellip": ..., "pa": ...,   "dx": ..., "dy": ...} with angular parameters; mag is the total host magnitude, or mu_e with surface_brightness=True.

  • One PSF-convolved host image is shared by simulate, get_snr, get_image_exptime_for_snr, get_peak_pixel and is_saturated.

  • The profile parameters are mutable, so plot_sweep("host__mag", ...) and plot_sweep("host__r_eff", ...) give the SNR curves directly.

  • Host shot noise is what costs SNR: a mag-13 bulge cuts the mag-21 transient’s SNR to under a tenth of the no-host value at the nucleus, and by about 16 percent 1.5 arcsec out. A bright compact bulge can also saturate the nucleus on its own.