Quick start

Every snippet on this page uses the public top-level API of esc_etc. Times given as plain numbers are seconds; astropy quantities in any time unit work too.

The mental model

A calculation is built from four pieces, combined into a Simulation:

Telescope   (aperture, surface, filter)   ─┐
Sensor      (detector + optical throughput) ├──►  Simulation  ──►  SNR / int. time / saturation
Scene       (star, planet, zodi, exozodi)   │
Coronagraph (contrast, ppgain, separation)  ─┘
  • The Telescope holds the collecting area, the focal geometry, the filter’s central wavelength and the resolution element (resel). The resel is the area of one diffraction-limited PSF core, a disc of diameter \(\lambda/D\) on the sky, and is the default photometric aperture.

  • The Sensor couples the detector (read noise, dark current, gain, well depth) with the optical bandpass and the photometric aperture every noise term uses.

  • The Scene holds the host star, the planet (given as a magnitude difference from the host) and the zodiacal and exozodiacal backgrounds, each a SceneElement.

  • The Coronagraph holds the raw dark-hole contrast, the post-processing gain, the planet separation and the focal-plane mask throughput curve. Coronagraph and contrast defines these terms.

1. Build a simulation from the defaults

Simulation.from_default assembles all four components from the bundled configuration of the Lazuli 3 m telescope and the ESC:

from esc_etc import Simulation

sim = Simulation.from_default(
    hoststarmag=-0.353,    # host-star Vega magnitude in the ESC band
    planetdeltamag=20.0,   # planet 20 mag fainter than the host: a flux ratio of 1e-8
    contrast=1e-8,         # raw dark-hole contrast (coronagraph.raw_dh_contrast)
    ppgain=10,             # assumed post-processing gain on the residual starlight
    separation=0.4,        # planet separation in arcsec, about 9 lambda/D at 630 nm
)

With these numbers the planet sits exactly at the raw contrast level, so the residual starlight in the aperture is as bright as the planet. contrast= here sets the same quantity that the rest of the documentation calls raw_dh_contrast. Concepts and architecture shows how to build each component yourself and assemble them with Simulation.from_components.

2. Compute signal-to-noise

get_snr() returns a dictionary. The signal-to-noise itself is under the "snr" key:

import astropy.units as u

result = sim.get_snr(total_time=3600 * u.s, frame_time=100 * u.s)
result = sim.get_snr(3600, 100)               # plain numbers are seconds
result["snr"]        # signal-to-noise ratio, about 9.87 here
result["signal_e"]   # planet electrons accumulated in total_time
result["noise_e"]    # total noise (electrons)
result["M"]          # speckle rate divided by ppgain: the post-processing noise floor

total_time is the on-target integration and frame_time is how often the detector is read out. The number of reads is ceil(total_time / frame_time), never fewer than one, and sets how much read noise accumulates. Saturation bounds the frame time from above, read noise from below; see Saturation. The full budget is in Signal-to-noise and integration time.

3. Invert: integration time for a target SNR

inv = sim.get_int_time_for_snr(snr=5, frame_time=100 * u.s)
inv["int_time"]             # whole-frame integration time that reaches SNR = 5
inv["int_time_continuous"]  # unrounded closed-form value
inv["noise_terms"]          # [dark, read, zodi, exozodi, speckle, reference] rates

The speckle noise floor grows with time exactly as the signal does, so each configuration has a maximum reachable signal-to-noise: ppgain times the planet-to-speckle rate ratio, which is 10 for the defaults above. A target above that limit comes back as nan.

4. Check saturation

sim.saturation_time()        # frame time (s) at which the brightest pixel fills the well
sim.is_saturated(100)        # True / False for a 100 s frame

The pixel in question is the brightest one in the planet’s aperture, where the starlight has been suppressed to the raw contrast. The stellar core behind the focal-plane mask (the occulting spot that blocks the star) is not modelled, so for a bright host the detector-safe frame time can be much shorter; see Saturation.

5. Change parameters and recompute

Every component’s tunable parameters can be changed in place with update(), using child__param double-underscore syntax, and recomputed. reset() restores the original values.

sim.update(coronagraph__separation=0.6)   # wider separation
sim.update(coronagraph__ppgain=20)        # twice the assumed post-processing gain
sim.update(sensor__read_noise=5)          # quieter detector; plain numbers are in the sensor's own units
sim.update(sensor__aperture="optimal")    # a different photometric aperture
sim.get_snr(3600, 100)["snr"]

sim.reset()                               # back to the initial configuration

The bandpass is the end-of-life throughput curve of the F630N filter unless you say otherwise. Pick another filter with filter= (a key of esc_etc.FILTERS), and lifetime="bol" for the beginning-of-life curve:

sim = Simulation.from_default(filter="F620N", lifetime="bol")

6. Plot the results

Every standard plot is a method; each accepts ax= and returns (fig, ax):

fig, axes = sim.plot()                 # 2 x 3 overview
fig, ax = sim.plot_count_rates()       # bar chart of count-rate contributors
fig, ax = sim.plot_snr_vs_time()       # SNR vs integration time
fig, ax = sim.plot_snr_vs_time(sweep={"coronagraph__ppgain": [1, 10, 100]})

The plots apply the packaged style through a context manager, so your own matplotlib settings are untouched. use_lazuli_style() applies the same style to your own figures. See Plotting.

Where to go next