Signal-to-noise and exposure time

wcc-etc has two SNR paths, which agree to about 1% for an in-focus point source, and the exposure-time inverse of each. The image-based path is the default; the analytic Airy path is kept for cross-checks.

What goes into the SNR

The image-based path renders the PSF on the detector grid, scales it to the source count rate, adds the host, the sky and the dark current, and then does circular-aperture photometry on that frame. For an aperture of \(n_{pix}\) pixels and an exposure of \(t\) seconds the result is the usual CCD equation,

\[\mathrm{SNR} = \frac{S} {\sqrt{S + H + \sum_{pix} (B + D + E)\, t + n_{pix}\, n_{reads}\, \sigma_R^2}},\]

where \(S\) is the source charge inside the aperture (the total source charge times the enclosed fraction of the PSF), \(H\) the charge from an unresolved host that rides the same PSF, \(B\) the sky rate per pixel, \(D\) the dark rate per pixel, \(E\) the per-pixel rate of any resolved element such as a Sersic host, and \(\sigma_R\) the read noise per read. Only \(S\) is signal. Everything else contributes shot noise and fills the well, and that is what the host slot in a scene is for (see Scenes, sources, and backgrounds).

The balance of these terms decides how the SNR scales. When the source dominates, SNR grows as the square root of the exposure time and gains a factor of about 1.6 per magnitude. When sky or read noise dominates, SNR grows linearly with the source and a magnitude costs a factor of 2.5. The two sensors below cross from one regime to the other at different magnitudes because the qCMOS has far lower read noise:

import numpy as np
import matplotlib.pyplot as plt
import wcc_etc
from wcc_etc import get_scene, Simulation

wcc_etc.set_wcc_style()

scene = get_scene("G5V", mag=20, background="zodi", bandpass="johnson_r")

fig, (ax_mag, ax_t) = plt.subplots(1, 2, figsize=(11, 4))
for label in ("sony:r", "qcmos:r"):
    sim = Simulation.from_sensor_and_scene(label, scene)
    sim.plot_snr_vs_mag(np.arange(14, 25), time=60, ax=ax_mag, label=label)
    sim.plot_exptime_for_snr([5, 10, 20, 50, 100], ax=ax_t, label=label)
ax_mag.set_yscale("log")
ax_t.set_yscale("log")
../_images/snr-1.png

Image-based SNR (the default)

get_snr() delegates to get_image_snr() and returns a dictionary:

r = sim.get_snr(time=60)
r["snr"]                 # signal-to-noise ratio
r["signal_e"]            # source electrons inside the aperture
r["noise_e"]             # total noise (electrons)
r["enclosed_fraction"]   # PSF fraction inside the aperture
r["r_aper_mas"]          # aperture radius (mas)
r["n_pix"]               # number of pixels in the aperture
r["n_saturated"]         # pixels at/above full well in the per-frame image
r["saturated"]           # bool

Important

The return value is a dict, so index ["snr"]. Earlier versions returned a Quantity with .value.

Choosing the aperture

Aperture precedence is optimize, then r_aper_mas, then ee_frac. If none is given, the aperture follows the PSF: 70 mas for the in-focus Airy PSF, the SNR-optimized radius for a defocused filter (zwo:r+1, zwo:bb2, …), or the Simulation’s own r_aper_mas if one was set at construction.

sim.get_snr(60, r_aper_mas=300)          # fixed aperture radius (mas)
sim.get_snr(60, ee_frac=0.8)             # aperture enclosing 80% of the PSF
sim.get_snr(60, optimize=True)           # radius that maximizes SNR

plot_snr_vs_aperture() draws SNR against aperture radius and marks the default and the optimized choice, which is the quickest way to see how much a wrong aperture costs.

Sweeping time or magnitude

time may be a scalar or an array. get_image_snr also accepts mags to sweep the source brightness off a single cached render, with the host, background, dark and read noise held fixed. time and mags may not both be arrays.

import numpy as np
sim.get_snr(time=np.array([10, 30, 60, 120]))["snr"]      # array of SNR
sim.get_image_snr(time=60, mags=np.arange(16, 24))["snr"] # SNR vs. magnitude

The same sweeps as plots are plot_snr() (SNR against time, also reachable as sim.plot) and plot_snr_vs_mag().

Specifying the PSF and jitter

Pass any PSFSource, and override the jitter per call:

from wcc_etc import DefocusPSF, DEFOCUS_2WAVE_PATH
sim.get_image_snr(time=60, psf=DefocusPSF(DEFOCUS_2WAVE_PATH))
sim.get_image_snr(time=60, jitter_sigma_mas=15)

See PSFs and image simulation for the PSF models and the npix / oversample grid parameters.

Exposure time for a target SNR

get_image_exptime_for_snr() inverts the image-based SNR. get_exptime_for_snr() is the analytic Airy inverse that pairs with get_snr_airy.

sim.get_image_exptime_for_snr(snr=100)        # seconds (PSF-aware)
sim.get_exptime_for_snr(snr=100)              # seconds (analytic Airy)

plot_exptime_for_snr() draws the first of these for a list of target SNRs, as in the right-hand panel above.

Multiple reads (n_reads)

Splitting an exposure into several reads adds read noise once per read, so the read term in the SNR grows with n_reads. All SNR and exposure-time methods accept n_reads, and the default comes from the sensor or the simulation’s meta:

sim.get_snr(60, n_reads=4)
sim.get_image_exptime_for_snr(snr=100, n_reads=4)

The reason to pay that price is saturation, which is tested per frame (see Saturation). sim.plot_sweep("n_reads", [1, 2, 4, 8], time=60) shows the cost for a given exposure.

The analytic Airy SNR

get_snr_airy() is the original closed-form Airy-disk approximation. It is kept for cross-checking the image-based path and emits a DeprecationWarning. To use it quietly:

import warnings
with warnings.catch_warnings():
    warnings.simplefilter("ignore", DeprecationWarning)
    snr_airy = sim.get_snr_airy(60)

For an in-focus AiryPSF with the default aperture, the image-based and analytic results agree to about 1%. The analytic path cannot place a Sersic host and warns that it ignores one.