pyEDITH Tutorial: Imaging Mode#

This tutorial will guide you through using pyEDITH in imaging mode. We’ll explore how to set up parameters, run the Exposure Time Calculator (ETC), and analyze the results.

Before we start#

Make sure you follow the instructions on the Installation page.

1. Basic Usage#

This section will explain how to use the premade ``umbrella’’ functions in the code.

1.1 Setup and Imports#

First, let’s import the necessary modules and set up our environment:

[1]:
import os
import numpy as np
import matplotlib.pyplot as plt
from astropy import units as u
from pyEDITH import parse_input, calculate_texp, calculate_snr, AstrophysicalScene, Observation, Observatory, set_verbosity,calculate_exposure_time_or_snr
from pyEDITH.units import *

# Set verbosity to INFO, showing info, warnings and errors. Other options are "warning" (warnings and errors),
# "quiet" (only errors), and "debug" (all logs)
set_verbosity(level='info')

# Set the necessary environment variables --> REPLACE WITH YOUR PATHS.
# You can also open your .bashrc (or .zshrc) and type:
# export SCI_ENG_DIR="/path/to/Sci-Eng-Interface/hwo_sci_eng"
# export YIP_CORO_DIR="/path/to/yips"

# Loading HWO style package to make pretty plots
import hwostyle
hwostyle.use("light")
colors = hwostyle.palette


/Users/ealei/Coding/pyEDITH/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
[pyEDITH] INFO [2026-07-13 13:35:16,777] Logging level set to: INFO

1.2 Defining Input Parameters#

Let’s set up the parameters for an Earth-like planet around a Sun-like star.

Note

Parameters that vary with lambda (wavelength, resolution, snr, fluxes, contrasts…) can be both floats and lists. If len(wavelength)>1 and any of the other values is a float, it becomes a list of length len(wavelength) internally.

[2]:
imaging_params = {
    'wavelength': 0.5,              # Wavelength in microns
    'snr': 7,                       # Desired signal-to-noise ratio
    'bandwidth': 0.2,               # Bandwidth of observation
    'CRb_multiplier': 2.0,          # Count rate ratio multiplier (assuming differential imaging for PSF subtraction)
    'psf_trunc_ratio': 0.3,         # PSF Truncation Ratio to calculate photometric aperture of solid angle Omega.
    'distance': 10,                 # Distance to star in parsecs
    'FstarV_10pc': 122.9279,        # Stellar flux at 10 pc in the V band [ph/cm2/s/nm]
    'Fstar_10pc': 115.59984,        # Stellar flux at 10 pc in the observed band [ph/cm2/s/nm]
    'Fp/Fs': 1e-10,                # Planet-to-star contrast
    'stellar_radius': 1,            # Stellar radius in solar radii
    'nzodis': 3.0,                  # Number of zodiacal light disks
    'ra': 236.00757736823,          # Right ascension of star [deg]
    'dec': 2.51516683165,           # Declination of star [deg]
    'separation': 0.1,              # Separation between star and planet in arcseconds
    'observatory_preset': 'EAC1',   # Preset observatory configuration
    'observing_mode': 'IMAGER',     # Observing mode
}

1.3 Running the ETC#

We can now calculate the exposure time.

[3]:
# Calculate Exposure time
texp, validation_output = calculate_texp(imaging_params)
print(f"Calculated exposure time: {texp.to(u.hr)}")
[pyEDITH] INFO [2026-07-13 13:35:16,798] Flux zero point calculated at 5.5e-05 cm in units of ph / (s cm3)
[pyEDITH] INFO [2026-07-13 13:35:16,800] Flux zero point calculated at [5.e-05] cm in units of ph / (s cm3)
[pyEDITH] WARNING [2026-07-13 13:35:16,801] ez_PPF not set. Assuming EZ subtraction to Poisson limit (ez_PPF = inf)
[pyEDITH] INFO [2026-07-13 13:35:16,803] Observatory Configuration:
[pyEDITH] INFO [2026-07-13 13:35:16,804]   Using preset: EAC1
[pyEDITH] INFO [2026-07-13 13:35:16,804]
[pyEDITH] WARNING [2026-07-13 13:35:16,804] Coronagraph 'eac1_optimal_order_6_1d' not found locally. Attempting to fetch from remote database...
[yippy] INFO [2026-07-13 13:35:16,804] Fetching YIP 'eac1_optimal_order_6_1d' (cache: /Users/ealei/Library/Caches/yippy)
[yippy] INFO [2026-07-13 13:35:16,851] YIP 'eac1_optimal_order_6_1d' available at /Users/ealei/Library/Caches/yippy/eac1_optimal_order_6_1d.zip.unzip/eac1_optimal_order_6_1d
[pyEDITH] INFO [2026-07-13 13:35:16,851] Successfully downloaded coronagraph to: /Users/ealei/Library/Caches/yippy/eac1_optimal_order_6_1d.zip.unzip/eac1_optimal_order_6_1d
[yippy] INFO [2026-07-13 13:35:16,867] Creating eac1_optimal_order_6_1d coronagraph
[yippy] WARNING [2026-07-13 13:35:16,868] Unhandled header fields: {'TMULCHAR', 'TMULDET'}
[yippy] WARNING [2026-07-13 13:35:16,868] Using default unit for D: m. Could not extract unit from comment: "circumscribed diameter of the telescope in mete"
[yippy] WARNING [2026-07-13 13:35:16,868] Using default unit for D_INSC: m. Could not extract unit from comment: "inscribed diameter of the telescope in meters"
[yippy] INFO [2026-07-13 13:35:16,913] eac1_optimal_order_6_1d is radially symmetric
[yippy] INFO [2026-07-13 13:35:17,077] Loading performance metrics from /Users/ealei/Library/Caches/yippy/eac1_optimal_order_6_1d.zip.unzip/eac1_optimal_order_6_1d/yippy_cache/performance/trunc_0.30_v2.7.3.fits
[yippy] INFO [2026-07-13 13:35:17,078] Loading throughput and contrast from trunc_0.30_v2.7.3.fits
[yippy] INFO [2026-07-13 13:35:17,084] Successfully loaded performance data from trunc_0.30_v2.7.3.fits
[yippy] INFO [2026-07-13 13:35:17,084] Computing core area curve (PSF trunc ratio = 0.3)...
[yippy] INFO [2026-07-13 13:35:17,405] Computing occulter transmission curve...
[yippy] INFO [2026-07-13 13:35:17,495] Computing core mean intensity curve...
[yippy] INFO [2026-07-13 13:35:17,591] OWA set to max_offset_in_image: 32.00 lam/D
[yippy] INFO [2026-07-13 13:35:17,592] Created eac1_optimal_order_6_1d
[pyEDITH] INFO [2026-07-13 13:35:17,593] Using psf_trunc_ratio to calculate Omega...
[pyEDITH] WARNING [2026-07-13 13:35:17,605] noisefloor_PPF value not provided. Using the default value: 30.0
[pyEDITH] INFO [2026-07-13 13:35:17,609] Calculating optics throughput from preset...
[pyEDITH] INFO [2026-07-13 13:35:17,609] Calculating epswarmTrcold as 1 - optics throughput...
Calculated exposure time: [3.53041142] h

We can also calculate the SNR for a given exposure time (e.g. 3 hours):

[4]:

# Calculate SNR given a specific exposure time texp = 3*u.hr snr, validation_output = calculate_snr(imaging_params, texp) print(f"Calculated snr: {snr}")
[pyEDITH] INFO [2026-07-13 13:35:17,624] Flux zero point calculated at 5.5e-05 cm in units of ph / (s cm3)
[pyEDITH] INFO [2026-07-13 13:35:17,626] Flux zero point calculated at [5.e-05] cm in units of ph / (s cm3)
[pyEDITH] WARNING [2026-07-13 13:35:17,626] ez_PPF not set. Assuming EZ subtraction to Poisson limit (ez_PPF = inf)
[pyEDITH] INFO [2026-07-13 13:35:17,629] Observatory Configuration:
[pyEDITH] INFO [2026-07-13 13:35:17,629]   Using preset: EAC1
[pyEDITH] INFO [2026-07-13 13:35:17,629]
[pyEDITH] WARNING [2026-07-13 13:35:17,630] Coronagraph 'eac1_optimal_order_6_1d' not found locally. Attempting to fetch from remote database...
[yippy] INFO [2026-07-13 13:35:17,630] Fetching YIP 'eac1_optimal_order_6_1d' (cache: /Users/ealei/Library/Caches/yippy)
[yippy] INFO [2026-07-13 13:35:17,673] YIP 'eac1_optimal_order_6_1d' available at /Users/ealei/Library/Caches/yippy/eac1_optimal_order_6_1d.zip.unzip/eac1_optimal_order_6_1d
[pyEDITH] INFO [2026-07-13 13:35:17,673] Successfully downloaded coronagraph to: /Users/ealei/Library/Caches/yippy/eac1_optimal_order_6_1d.zip.unzip/eac1_optimal_order_6_1d
[yippy] INFO [2026-07-13 13:35:17,675] Creating eac1_optimal_order_6_1d coronagraph
[yippy] WARNING [2026-07-13 13:35:17,676] Unhandled header fields: {'TMULCHAR', 'TMULDET'}
[yippy] WARNING [2026-07-13 13:35:17,676] Using default unit for D: m. Could not extract unit from comment: "circumscribed diameter of the telescope in mete"
[yippy] WARNING [2026-07-13 13:35:17,677] Using default unit for D_INSC: m. Could not extract unit from comment: "inscribed diameter of the telescope in meters"
[yippy] INFO [2026-07-13 13:35:17,710] eac1_optimal_order_6_1d is radially symmetric
[yippy] INFO [2026-07-13 13:35:17,722] Loading performance metrics from /Users/ealei/Library/Caches/yippy/eac1_optimal_order_6_1d.zip.unzip/eac1_optimal_order_6_1d/yippy_cache/performance/trunc_0.30_v2.7.3.fits
[yippy] INFO [2026-07-13 13:35:17,723] Loading throughput and contrast from trunc_0.30_v2.7.3.fits
[yippy] INFO [2026-07-13 13:35:17,728] Successfully loaded performance data from trunc_0.30_v2.7.3.fits
[yippy] INFO [2026-07-13 13:35:17,729] Computing core area curve (PSF trunc ratio = 0.3)...
[yippy] INFO [2026-07-13 13:35:17,820] Computing occulter transmission curve...
[yippy] INFO [2026-07-13 13:35:17,822] Computing core mean intensity curve...
[yippy] INFO [2026-07-13 13:35:17,845] OWA set to max_offset_in_image: 32.00 lam/D
[yippy] INFO [2026-07-13 13:35:17,846] Created eac1_optimal_order_6_1d
[pyEDITH] INFO [2026-07-13 13:35:17,847] Using psf_trunc_ratio to calculate Omega...
[pyEDITH] WARNING [2026-07-13 13:35:17,853] noisefloor_PPF value not provided. Using the default value: 30.0
[pyEDITH] INFO [2026-07-13 13:35:17,855] Calculating optics throughput from preset...
[pyEDITH] INFO [2026-07-13 13:35:17,856] Calculating epswarmTrcold as 1 - optics throughput...
Calculated snr: [5.29329344]

validation_output contains some interesting quantities that you can use to double check or validate, or to plot additional quantities.

[5]:
validation_output
[5]:
{'F0': <Quantity [12638.83670769] ph / (nm s cm2)>,
 'magstar': <Quantity [5.09687467] mag>,
 'dist': <Quantity 10. pc>,
 'nzodis': <Quantity 3. zodi>,
 'Fs_over_F0': <Quantity [115.59984] ph / (nm s cm2)>,
 'Fp': <Quantity [1.1559984e-08] ph / (nm s cm2)>,
 'Fzodi': <Quantity [7.72303075e-06] ph / (nm s arcsec2 cm2)>,
 'Fexozodi': <Quantity [4.69979038e-05] arcsec^-2 ph / (nm s arcsec2 cm2 pc2)>,
 'D': <Quantity 7.225765 m>,
 'A_cm': <Quantity 410069.58591604 cm2>,
 'toverhead_fixed': <Quantity 8250. s>,
 'toverhead_multi': <Quantity 1.1>,
 'T_optical': <Quantity [0.36198356]>,
 'wavelength': <Quantity [500.] nm>,
 'deltalambda_nm': <Quantity [100.] nm>,
 'snr': <Quantity [5.29329344]>,
 'lod_rad': <Quantity [6.91968255e-08] rad>,
 'lod_arcsec': <Quantity [0.01427287] arcsec>,
 'detpixscale_lod': <Quantity [0.5] λ/D>,
 'oneopixscale_arcsec': <Quantity [280.25197828] pix / arcsec>,
 'ix': array([156.02519783]),
 'iy': array([128.]),
 'det_DC': <Quantity [3.e-05] electron / (pix s)>,
 'det_RN': <Quantity [0.1] electron / (pix read)>,
 'det_CIC': <Quantity [0.] electron / (frame pix)>,
 'det_tread': <Quantity [1000.] s / read>,
 'det_pixscale_mas': <Quantity 7.13643491 mas>,
 'det_omega_lod': <Quantity [1.72919211]>,
 'det_npix': <Quantity [6.91676843] pix>,
 't_photon_count': <Quantity [0.42118145] s / frame>,
 'dQE': <Quantity [0.75]>,
 'QE': <Quantity [0.88749741] electron / ph>,
 'omega_lod': <Quantity [1.64005177]>,
 'T_core or photometric_aperture_throughput': <Quantity [0.62494959]>,
 'Istar': <Quantity [2.05052378e-15]>,
 'Istar*oneopixscale2 in (l/D)^-2': <Quantity [3.28083805e-14]>,
 'skytrans': <Quantity [0.96738956]>,
 'skytrans*oneopixscale2  in (l/D)^-2': <Quantity [15.47823301]>,
 'CRp': <Quantity [0.07138007] electron / s>,
 'CRbs': <Quantity [6.1457424e-05] electron / s>,
 'CRbz': <Quantity [0.0246629]>,
 'CRbez': <Quantity [0.15008415]>,
 'CRbbin': <Quantity [0.] electron / s>,
 'CRbth': <Quantity [1.05832188e-29] electron / s>,
 'CRb': <Quantity [0.17508517] electron / s>,
 'CRbd': <Quantity [0.00027667] electron / s>,
 'CRnf': <Quantity [2.0485808e-06] electron / s>,
 'sciencetime': <Quantity [0.] s>,
 'exptime': None,
 'fullsnr': <Quantity [5.29329344]>}

The code can also perform simultaneous observations. This is the key feature used in Alei et al. 2025. For example, we assume the primary (detection) bandpass to be 0.5 micron with 20% bandwidth. What is the SNR of a secondary bandpass at 1 micron with 20% bandpass?

[6]:
# Set verbosity to "warning" for fewer log messages.
set_verbosity(level='warning')
# Let's note down the values that change compared to imaging_params
secondary_imaging_params = {
    'wavelength': 1,  # Wavelength in microns
    'Fstar_10pc': 100,  # Stellar flux in secondary observation band
    }

# Fill the missing values
for key in imaging_params:
        if key not in secondary_imaging_params:
            secondary_imaging_params[key] = imaging_params[key]

# Calculating texp from primary lambda
texp, _ = calculate_texp(imaging_params)
print("Reference exposure time: ", texp.to(u.hr))

# Calculating SNR at secondary lambda
if np.isfinite(texp).all():


    snr, _ = calculate_snr(secondary_imaging_params, texp)
    print("SNR at the secondary lambda: ", snr)
else:
    raise ValueError("Returned exposure time is infinity.")
[pyEDITH] WARNING [2026-07-13 13:35:17,879] ez_PPF not set. Assuming EZ subtraction to Poisson limit (ez_PPF = inf)
[pyEDITH] WARNING [2026-07-13 13:35:17,882] Coronagraph 'eac1_optimal_order_6_1d' not found locally. Attempting to fetch from remote database...
[yippy] WARNING [2026-07-13 13:35:17,926] Unhandled header fields: {'TMULCHAR', 'TMULDET'}
[yippy] WARNING [2026-07-13 13:35:17,927] Using default unit for D: m. Could not extract unit from comment: "circumscribed diameter of the telescope in mete"
[yippy] WARNING [2026-07-13 13:35:17,927] Using default unit for D_INSC: m. Could not extract unit from comment: "inscribed diameter of the telescope in meters"
[pyEDITH] WARNING [2026-07-13 13:35:18,087] noisefloor_PPF value not provided. Using the default value: 30.0
[pyEDITH] WARNING [2026-07-13 13:35:18,102] ez_PPF not set. Assuming EZ subtraction to Poisson limit (ez_PPF = inf)
[pyEDITH] WARNING [2026-07-13 13:35:18,106] Coronagraph 'eac1_optimal_order_6_1d' not found locally. Attempting to fetch from remote database...
[yippy] WARNING [2026-07-13 13:35:18,150] Unhandled header fields: {'TMULCHAR', 'TMULDET'}
[yippy] WARNING [2026-07-13 13:35:18,151] Using default unit for D: m. Could not extract unit from comment: "circumscribed diameter of the telescope in mete"
[yippy] WARNING [2026-07-13 13:35:18,151] Using default unit for D_INSC: m. Could not extract unit from comment: "inscribed diameter of the telescope in meters"
Reference exposure time:  [3.53041142] h
[pyEDITH] WARNING [2026-07-13 13:35:18,318] noisefloor_PPF value not provided. Using the default value: 30.0
SNR at the secondary lambda:  [5.19067342]

Danger

When the observatory architecture or the astrophysical scene change (e.g., varying the telescope diameter, the separation of the planet..) you should reload the observatory initialization, since coronagraph response and noise terms will change.

2. Parameter Space Exploration#

Now, let’s explore how various parameters affect the exposure time and SNR.

[7]:
#Set verbosity to "quiet" to only show errors. Looping will cause a lot of output to be produced.
set_verbosity(level='quiet')

2.1 Planet-to-star contrast#

Let’s explore how the exposure time changes with planet-to-star contrast:

[8]:
contrasts = np.logspace(-6, -11, 10)
exposure_times = []


for contrast in contrasts:
    params = imaging_params.copy()
    params['Fp/Fs'] = contrast
    # make the parameters be the shape that the code desires
    texp, validation_output = calculate_texp(params)
    exposure_times.append(texp.to(u.hr).value)


# Make plot
fig, ax1 = plt.subplots()
ax1.semilogx(contrasts, exposure_times, marker='o', markersize=8, linewidth=2,
             color=colors.purple, markerfacecolor=colors.purple, markeredgewidth=1.5,
             markeredgecolor='black')
ax1.set_xlim([1e-6,1e-11])
ax1.set_xlabel('Planet-to-star contrast', fontsize=14, fontweight='bold')
ax1.set_ylabel('Exposure time (hours)', fontsize=14, fontweight='bold')
ax1.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
ax1.tick_params(axis='both', which='minor', width=1, length=4)
ax1.grid(True, which='both', alpha=0.3, linestyle='--', linewidth=0.7)

for spine in ax1.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.tight_layout()
plt.show()


_images/imaging_tutorial_16_0.png

2.2 Distance and contrast#

Let’s change distance from the star and planet contrast.

[9]:
fig, ax = plt.subplots()
contrasts=np.logspace(-9, -11, 10)

for idx, dist in enumerate([5,10,15,20]):
    exposure_times=[]
    for contrast in np.logspace(-9, -11, 10):
        params=imaging_params.copy()
        params['Fp/Fs']=contrast
        params['distance']=dist
        # make the parameters be the shape that the code desires
        texp, validation_output = calculate_texp(params)
        exposure_times.append(texp.to(u.hr).value)



    # Primary x-axis (contrast)
    ax.loglog(contrasts, exposure_times, marker='o', linewidth=2.5, markersize=8,
              label=f'{dist} pc', color=colors[idx], markeredgewidth=1,
              markeredgecolor='black', alpha=0.9)
ax.set_xlim([1e-9,1e-11])
ax.set_xlabel('Planet-to-star contrast', fontsize=14, fontweight='medium')
ax.set_ylabel('Exposure time (hours)', fontsize=14, fontweight='medium')
ax.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
ax.tick_params(axis='both', which='minor', width=1, length=4)
ax.legend(frameon=True, shadow=True, fancybox=True, fontsize=12,
          title='Distance', title_fontsize=12)
ax.grid(True, which='both', alpha=0.3, linestyle='--', linewidth=0.7)

for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.tight_layout()

_images/imaging_tutorial_18_0.png

2.3 Separation#

Now let’s change the separation of the planet and see how the exposure time changes. Note: there will be some errors!

[10]:
separations = np.linspace(0.005, 0.55, 20)  # arcsec

exposure_times = []

for sep in separations:
    params = imaging_params.copy()
    params['separation'] = sep

    texp, _ = calculate_texp(params)
    exposure_times.append(texp.to(u.hr)[0].value)

fig, ax1 = plt.subplots()

# Primary x-axis (separation arcsec)
ax1.plot(separations, exposure_times, marker='o', label='Valid exposures',
         markersize=6, linewidth=2, color=colors.cyan, markerfacecolor=colors.cyan,
         markeredgecolor='black', markeredgewidth=1)
ax1.plot(separations[np.isinf(exposure_times)],
         np.ones_like(separations)[np.isinf(exposure_times)]*50,
         marker='x', linestyle='', color=colors.red, label='Infinite exposures',
         markersize=10, markeredgewidth=2)

ax1.set_xlabel('Separation (arcsec)', fontsize=14, fontweight='bold')
ax1.set_ylabel('Exposure time (hours)', fontsize=14, fontweight='bold')
ax1.tick_params(axis='both', which='major', labelsize=12, direction='in',
                width=1.5, length=6, top=False)
ax1.tick_params(axis='both', which='minor', direction='in', width=1,
                length=4, top=False)
ax1.set_xlim(0, 0.6)
ax1.set_yscale('log')

for spine in ['bottom','left','right']:
    ax1.spines[spine].set_visible(True)
    ax1.spines[spine].set_linewidth(1.5)


legend = ax1.legend(loc='best', frameon=True, fancybox=False, shadow=False,
                   fontsize=11, edgecolor='#333333', framealpha=0.95)
legend.get_frame().set_linewidth(1.2)

plt.tight_layout()

[pyEDITH] ERROR [2026-07-13 13:35:31,076] Count rate of the planet smaller than the noise floor for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:34,664] Planet outside coronagraph YIP image for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:34,889] Planet outside coronagraph YIP image for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:35,109] Planet outside coronagraph YIP image for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:35,341] Planet outside coronagraph YIP image for one or more wavelength channels. Hardcoded infinity results.
_images/imaging_tutorial_20_1.png

What if you have a planet and you want to vary the physical distance instead of calculating the corresponding separation? You can also use the keyword ``semimajor_axis’’ and pyEDITH will calculate the separation internally.

[11]:
semimajor_axes = np.linspace(0.05, 5, 10)  # AU

exposure_times = []

for a in semimajor_axes:
    params = imaging_params.copy()
    del params['separation']
    params['semimajor_axis'] = a

    texp, _ = calculate_texp(params)
    exposure_times.append(texp.to(u.hr)[0].value)



fig = plt.figure(dpi=100)
ax = plt.gca()
plt.plot(semimajor_axes, exposure_times, marker='o', label='Valid exposures',
         markersize=8, linewidth=2, color=colors.cyan, markerfacecolor=colors.cyan,
         markeredgecolor='black', markeredgewidth=1.5)
plt.plot(semimajor_axes[np.isinf(exposure_times)],
         np.ones_like(semimajor_axes)[np.isinf(exposure_times)]*50,
         marker='x', linestyle='', color=colors.red, label='Infinite exposures',
         markersize=12, markeredgewidth=2.5)

plt.xlabel('Semi-major axis (AU)', fontsize=14, fontweight='bold')
plt.ylabel('Exposure time (hours)', fontsize=14, fontweight='bold')
plt.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
plt.tick_params(axis='both', which='minor', width=1, length=3)
plt.yscale('log')

for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.8)

plt.legend(loc='best', fontsize=11, frameon=True, shadow=True, fancybox=True,
           framealpha=0.95, edgecolor='gray')

plt.tight_layout()

[pyEDITH] ERROR [2026-07-13 13:35:35,637] Count rate of the planet smaller than the noise floor for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:37,658] Planet outside coronagraph YIP image for one or more wavelength channels. Hardcoded infinity results.
_images/imaging_tutorial_22_1.png

2.4 PSF Truncation Ratio#

Also, we can test the effect of varying the PSF trunction ratio. There should exist a PSF truncation ratio that minimizes exposure time and maximizes SNR (and it should be around 0.3). Let’s test this:

[12]:
psfratios = np.linspace(0.1, 1, 20)

exposure_times = []

for ratio in psfratios:
    params = imaging_params.copy()
    params['psf_trunc_ratio'] = ratio
    texp, _ = calculate_texp(params)
    exposure_times.append(texp.to(u.hr)[0].value)

fig = plt.figure(dpi=100)
ax = plt.gca()
plt.semilogy(psfratios, exposure_times, marker='o', label='Valid exposures',
         markersize=8, linewidth=2, color=colors.cyan, markerfacecolor=colors.cyan,
         markeredgecolor='black', markeredgewidth=1.5)
plt.semilogy(psfratios[np.isinf(exposure_times)],
         np.ones_like(psfratios)[np.isinf(exposure_times)]*10,
         marker='x', linestyle='', color=colors.red, label='Infinite exposures',
         markersize=12, markeredgewidth=2.5)

plt.xlabel('PSF Truncation Ratio', fontsize=14, fontweight='bold')
plt.ylabel('Exposure time (hours)', fontsize=14, fontweight='bold')

plt.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
plt.tick_params(axis='both', which='minor', width=1, length=3)

for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.8)

plt.legend(loc='best', fontsize=11, frameon=True, shadow=True, fancybox=True,
           framealpha=0.95, edgecolor='gray')
plt.tight_layout()
plt.show()

[pyEDITH] ERROR [2026-07-13 13:35:41,700] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:41,924] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:42,142] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:42,367] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
_images/imaging_tutorial_24_1.png

Let’s try with the SNR calculation instead.

[13]:
# SNR Case

psfratios = np.linspace(0.1, 1, 20)
exptime=15*u.hr
snrs = []

for ratio in psfratios:
    params = imaging_params.copy()
    params['psf_trunc_ratio'] = ratio
    snr,_ = calculate_snr(params,exptime)
    snrs.append(snr[0].value)

# MAKE PLOT
fig = plt.figure(dpi=100)
ax = plt.gca()

plt.plot(psfratios, snrs, marker='o', label='Valid exposures',
         markersize=8, linewidth=2, color=colors.cyan, markerfacecolor=colors.cyan,
         markeredgecolor='black', markeredgewidth=1.5)

plt.xlabel('PSF Truncation Ratio', fontsize=14, fontweight='bold')
plt.ylabel('SNR', fontsize=14, fontweight='bold')

plt.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
plt.tick_params(axis='both', which='minor', width=1, length=3)

for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.8)

plt.legend(loc='best', fontsize=11, frameon=True, shadow=True, fancybox=True,
           framealpha=0.95, edgecolor='gray')
plt.tight_layout()
plt.show()

# SNR maximized at 0.3, just like it should!
[pyEDITH] ERROR [2026-07-13 13:35:46,202] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:46,443] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:46,665] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
[pyEDITH] ERROR [2026-07-13 13:35:46,893] Photometric aperture is not large enough for one or more wavelength channels. Hardcoded infinity results.
_images/imaging_tutorial_26_1.png

3. Advanced Usage#

3.1 Using pyEDITH iteratively#

In the preceding examples, we utilized the premade calculate_texp and calculate_snr functions. While this approach is straightforward, it can lead to significant performance overhead in scenarios involving repeated calculations with large parameter spaces or numerous iterations. This is because each function call reinitializes the entire calculation process.

For improved computational efficiency, particularly when dealing with extensive parameter sweeps, it is advisable to implement the loop logic within the calculate_texp function itself. This approach allows for targeted iteration over specific parameters while maintaining the state of other computationally intensive components.

To illustrate this optimization technique, we can examine the internal structure of the calculate_texp function. Refer to the pyEDITH workflow picture for details.

[14]:
params = imaging_params.copy()

# Define Observation and load relevant parameters
observation = Observation()
observation.load_configuration(params)
observation.set_output_arrays()
observation.validate_configuration()

# Define Astrophysical Scene and load relevant parameters,
# then calculate zodi/exozodi
scene = AstrophysicalScene()
scene.load_configuration(params)
scene.calculate_zodi_exozodi(params)
scene.validate_configuration()

# Create and configure Observatory
observatory_config = parse_input.get_observatory_config(params)
observatory = Observatory()
observatory.create_observatory(observatory_config)
observatory.load_configuration(params, observation, scene
)
observatory.validate_configuration()

# EXPOSURE TIME CALCULATION
calculate_exposure_time_or_snr(
    observation,
    scene,
    observatory,
    mode="exposure_time",
)

3.2. Vary observatory parameters#

By default, using a preset telescope or detector (e.g. EACTelescope, EACDetector) loads all parameters directly from the HWO specification files. Some parameters are locked by default, meaning they are owned by the specification and cannot be changed accidentally — this ensures that a standard EAC run is always internally self-consistent.

An advanced user may want to unlock specific parameters to explore the trade space (e.g. “what if the telescope were larger?” or “what if the detector had lower dark current?”). This is done by explicitly listing the parameter names in parameters['overrides']:

params['overrides'] = ['diameter']   # only this key is unlocked
params['diameter'] = 8.0             # values without units are assumed to be
                                     # in the default unit for that parameter (meters here)

A few rules worth knowing before you start:

  • Only locked parameters need to appear in ``overrides``. Parameters that are already user-editable (e.g. toverhead_fixed) can be set directly without listing them. See the Glossary for which parameters are locked for each component.

  • If you set a locked parameter without listing it in ``overrides``, the value you supplied is silently ignored and the specification value is kept. A warning will appear in the logs — if your change seems to have no effect, increase the verbosity level and check there first. To see the full provenance of every parameter (i.e. whether each value came from the specification default or from your own input), increase the logging verbosity to DEBUG. At this level, every parameter is logged individually as it is assigned.

  • If you mistype a name in ``overrides`` (e.g. 'diamter'), an error is raised immediately. This is intentional — it prevents typos from silently doing nothing.

  • This mechanism applies to Telescope and Detector parameters only. For coronagraph parameters that are locked by the YIP, the only way to change them is to provide a custom YIP file. See the Coronagraph (YIP) Guide for how to do that.

Danger

Overriding locked parameters breaks the internal self-consistency of the observatory configuration. Changing one parameter (e.g. diameter) can have physical implications for other parameters that are not automatically recalculated. Make sure you understand these dependencies before relying on the results. If you want to double check exactly which values were used in a given run, increase the log verbosity to DEBUG (see above) to see every parameter’s assignment logged

individually.

Example: impact of telescope diameter on exposure time#

Here we loop over four telescope diameters and compute exposure time as a function of planet-to-star contrast. Note that overrides is set once outside both loops, since it does not change across iterations.

[15]:
set_verbosity(level='quiet')


fig = plt.figure()

contrasts=np.logspace(-9, -11, 10)
params = imaging_params.copy()
params['overrides'] = ['diameter']# Defining here that this parameter will change.

for idx, diam in enumerate([6,8,10,12]):
    exposure_times=[]
    params['diameter'] = diam

    for contrast in np.logspace(-9, -11, 10):
        params['Fp/Fs']= contrast
        parsed_parameters= parse_input.parse_parameters(params)
        texp, validation_output = calculate_texp(parsed_parameters)

        exposure_times.append(texp.to(u.hr).value)

    plt.loglog(contrasts, exposure_times, marker='o', label=f'{diam} m',
               markersize=7, linewidth=2.5, color=colors[idx],
               markeredgewidth=1, markeredgecolor='black', alpha=0.9)

plt.xlabel('Planet-to-star contrast', fontsize=14, fontweight='bold')
plt.ylabel('Exposure time (hours)', fontsize=14, fontweight='bold')
plt.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
plt.tick_params(axis='both', which='minor', width=1, length=3)
plt.xlim(1e-9,1e-11)
plt.grid(True, which='both', alpha=0.3, linestyle='--', linewidth=0.8)
plt.legend(title='Diameter', fontsize=11, title_fontsize=12, frameon=True,
           fancybox=True, shadow=True, loc='best')

ax = plt.gca()
for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.tight_layout()
plt.show()
_images/imaging_tutorial_30_0.png

Example: impact of detector dark current on exposure time#

To show that the same mechanism works for detector parameters, here is an analogous sweep over dark current values. Note that DC appears in overrides this time, and diameter does not — the telescope stays at its EAC-default value throughout.

Note

overrides is a flat list shared across all components. A key name like diameter unambiguously refers to the Telescope (no Detector or Coronagraph key shares that name). If you are unsure which component owns a given parameter, consult the Glossary.

[16]:
set_verbosity(level='quiet')

fig = plt.figure()
params = imaging_params.copy()
params['overrides'] = ['DC']         # unlocking a detector locked key this time

dark_currents = [1e-6,1e-4,1e-2,0.1]   # counts/pix/s
contrasts=np.logspace(-9, -11, 10)

for idx, dc in enumerate(dark_currents):
    exposure_times = []
    params['DC'] = dc                # interpreted as default DC unit (counts/pix/s)

    for contrast in contrasts:
        params['Fp/Fs'] = contrast
        parsed_parameters = parse_input.parse_parameters(params)
        texp, _ = calculate_texp(parsed_parameters)
        exposure_times.append(texp.to(u.hr).value)

    plt.loglog(contrasts, exposure_times, marker='o', label=f'DC={dc}',
               markersize=7, linewidth=2.5, color=colors[idx],
               markeredgewidth=1, markeredgecolor='black', alpha=0.9)

plt.xlabel('Planet-to-star contrast', fontsize=14, fontweight='bold')
plt.ylabel('Exposure time (hours)', fontsize=14, fontweight='bold')
plt.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=6)
plt.tick_params(axis='both', which='minor', width=1, length=3)
plt.xlim(1e-9,1e-11)
plt.grid(True, which='both', alpha=0.3, linestyle='--', linewidth=0.8)
plt.legend(title='DC', fontsize=11, title_fontsize=12, frameon=True,
           fancybox=True, shadow=True, loc='best')

ax = plt.gca()
for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(1.5)

plt.tight_layout()
plt.show()
_images/imaging_tutorial_32_0.png