Aperture Photometry: Fixed Circle vs PSF Truncation#

This notebook compares two approaches to defining the photometric aperture for coronagraphic performance metrics:

EXOSIMS (Fixed Aperture)

AYO / pyEDITH (Truncation Mask)

Shape

Circular, radius \(r_{ap}\)

Adaptive, PSF > ratio \(\times\) peak

Throughput

Flux in circle / total flux

Flux above threshold / total flux

Stellar Noise

\(C(r) \cdot \Upsilon_c(r)\) (contrast \(\times\) core area)

\(\bar{I}_\star \cdot \Omega\) (intensity \(\times\) core area)

Core Area

\(\pi r_{ap}^2\) (fixed)

\(\sum_{\text{mask}} \Delta\theta^2\) (varies with separation)

Free Parameter

Aperture radius \(r_{ap}\)

Truncation ratio

The key question: how does the choice of photometric region affect the throughput-to-noise tradeoff?

Hide code cell source

import matplotlib.pyplot as plt
import numpy as np
from matplotlib import animation
from IPython.display import HTML
from lod_unit import lod
from yippy import fetch_yip
from yippy import Coronagraph
from yippy.performance import (
    compute_throughput_curve,
    compute_raw_contrast_curve,
    compute_core_area_curve,
    compute_truncation_core_area_curve,
    _iter_xaxis_positions,
    _oversample_psf,
    _threshold_mask,
)
from yippy.util import (
    extract_and_oversample_subarray,
    measure_flux_in_oversampled_aperture,
    crop_around_peak,
)
import logging; logging.getLogger('yippy').setLevel(logging.ERROR)

yip_path = fetch_yip("eac1_aavc_2d")
coro = Coronagraph(yip_path)
print(f'Coronagraph: {coro.name}')
print(f'IWA: {coro.IWA:.2f}, OWA: {coro.OWA:.2f}')
print(f'Pixel scale: {coro.pixel_scale_arcsec:.4f}')
/home/docs/checkouts/readthedocs.org/user_builds/yippy/envs/stable/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
Coronagraph: eac1_aavc_2d
IWA: 4.26 λ/D, OWA: 32.00 λ/D
Pixel scale: 0.2500 λ/D / pix

API: The functions used in this notebook are from yippy.performance:

from yippy.performance import (
    compute_throughput_curve,          # fixed aperture throughput
    compute_raw_contrast_curve,        # fixed aperture raw contrast
    compute_truncation_throughput_curve,  # truncation throughput
    compute_truncation_core_area_curve,   # truncation core area
    compute_core_mean_intensity_curve,    # stellar intensity profile
)

Fixed Circular Aperture (EXOSIMS)#

EXOSIMS uses a circular aperture of fixed radius \(r_{ap}\) (in \(\lambda/D\)) centered on the expected planet position. The same circle is used for both the planet PSF (throughput) and the stellar PSF (raw contrast).

The core area is simply \(\Omega = \pi r_{ap}^2\), independent of separation.

Hide code cell source

radii = [0.5, 0.7, 1.0, 1.5, 2.5]
colors_r = ['#E91E63', '#4CAF50', '#2196F3', '#FF9800', '#9C27B0']

fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
ax_tp, ax_con, ax_sn = axes

for r, c in zip(radii, colors_r, strict=True):
    s_t, tp = compute_throughput_curve(coro, aperture_radius_lod=r)
    s_c, con = compute_raw_contrast_curve(coro, aperture_radius_lod=r)
    omega = np.pi * r**2
    # Stellar noise proxy: contrast * core area / throughput
    noise_per_signal = con * omega / tp

    ax_tp.plot(s_t, tp, 'o-', ms=3, color=c, label=f'$r_{{ap}}$ = {r}')
    ax_con.semilogy(s_c, con, 'o-', ms=3, color=c, label=f'$r_{{ap}}$ = {r}')
    ax_sn.semilogy(s_c, noise_per_signal, 'o-', ms=3, color=c,
                   label=f'$r_{{ap}}$ = {r}')

for ax in axes:
    ax.axvline(coro.IWA.value, ls='--', color='gray', alpha=0.5)
    ax.legend(fontsize=8)
    ax.grid(True, alpha=0.3)
    ax.set_xlabel('Separation [$\\lambda/D$]')

ax_tp.set_ylabel('Throughput')
ax_tp.set_title('Throughput (higher = better)')
ax_con.set_ylabel('Raw Contrast')
ax_con.set_title('Raw Contrast (lower = better)')
ax_con.set_ylim(1e-12, 1e-8)
ax_sn.set_ylabel('$C \\cdot \\Omega \\,/\\, \\eta_p$')
ax_sn.set_title('Stellar Noise per Signal')
fig.suptitle('Fixed Circular Aperture (EXOSIMS)',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
/tmp/ipykernel_869/85225529.py:12: RuntimeWarning: divide by zero encountered in divide
  noise_per_signal = con * omega / tp
../_images/21b14c22cadbc725af8409d977342576ec357d77f6dd4ec1e7195888b65a56e4.png

PSF Truncation Mask (AYO)#

AYO defines the photometric region as all pixels where the off-axis PSF exceeds a fraction (the truncation ratio) of its peak value. This produces an adaptive aperture that follows the PSF shape:

\[\begin{split}\text{mask}(x,y) = \begin{cases} 1 & \text{if } \text{PSF}(x,y) > \rho \cdot \text{PSF}_{\max} \\ 0 & \text{otherwise} \end{cases}\end{split}\]

The core area \(\Omega = \sum_{\text{mask}} (\Delta\theta)^2\) varies with separation because the PSF shape changes across the focal plane.

Hide code cell source

ratios = [0.1, 0.3, 0.5, 0.7, 0.9]
colors_t = ['#E91E63', '#4CAF50', '#2196F3', '#FF9800', '#9C27B0']

pix_lod = coro.pixel_scale_arcsec.value
os_factor = int(np.ceil(pix_lod / 0.05))
os_pix_lod = pix_lod / os_factor
pix_solid_angle = os_pix_lod**2

positions = list(_iter_xaxis_positions(coro))

fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
ax_tp, ax_area, ax_sn = axes

for ratio, c in zip(ratios, colors_t, strict=True):
    seps, tps, areas = [], [], []
    for pos in positions:
        psf_os = _oversample_psf(pos.psf, pix_lod, os_factor)
        mask = _threshold_mask(psf_os, ratio)
        tp = psf_os[mask].sum()
        area = mask.sum() * pix_solid_angle
        seps.append(pos.separation)
        tps.append(tp)
        areas.append(area)
    seps = np.array(seps)
    tps = np.array(tps)
    areas = np.array(areas)

    ax_tp.plot(seps, tps, 'o-', ms=3, color=c, label=f'$\\rho$ = {ratio}')
    ax_area.plot(seps, areas, 'o-', ms=3, color=c, label=f'$\\rho$ = {ratio}')
    # AYO noise proxy: core_area / throughput (I_star cancels since it's same for all)
    noise_per_signal = areas / tps
    ax_sn.plot(seps, noise_per_signal, 'o-', ms=3, color=c,
              label=f'$\\rho$ = {ratio}')

for ax in axes:
    ax.axvline(coro.IWA.value, ls='--', color='gray', alpha=0.5)
    ax.legend(fontsize=8)
    ax.grid(True, alpha=0.3)
    ax.set_xlabel('Separation [$\\lambda/D$]')

ax_tp.set_ylabel('Throughput')
ax_tp.set_title('Truncation Throughput (higher = better)')
ax_area.set_ylabel('Core Area [$(\\lambda/D)^2$]')
ax_area.set_title('Core Area $\\Omega$ (lower = less noise)')
ax_sn.set_ylabel('$\\Omega \\,/\\, \\eta_p$')
ax_sn.set_title('Core Area per Unit Throughput')

# Zoom to working region
for ax in axes:
    ax.set_xlim(coro.IWA.value - 1, coro.OWA.value)
ax_sn.set_ylim(0, 50)
fig.suptitle('PSF Truncation Mask (AYO)',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
../_images/359c980aa674232eaf876db33809d332e6053fa87b4eb1666d268fa1cf7d9761.png

Side-by-Side: Aperture Shape Comparison#

Both methods define an aperture and measure throughput within it. The key difference: the truncation mask adapts to the PSF shape, while the fixed circle does not. Let’s visualize both at several separations.

Hide code cell source

compare_radii = [0.7, 1.0, 1.5]
circ_colors = ['#4CAF50', '#2196F3', '#FF9800']
compare_ratios = [0.3, 0.5, 0.7]
trunc_colors = ['#E91E63', '#9C27B0', '#795548']

# Select 3 separations: near IWA, mid, and near OWA
sep_targets = [2, 5, 25]
compare_positions = []
for target in sep_targets:
    best = min(positions, key=lambda p: abs(p.separation - target))
    compare_positions.append(best)

# Use the same crop radius (in oversampled pixels) for both rows
crop_radius = int(5 / (pix_lod / os_factor))

fig, axes = plt.subplots(2, len(sep_targets), figsize=(10, 6))

for j, pos in enumerate(compare_positions):
    # Common: oversample the planet PSF once
    psf_os = _oversample_psf(pos.psf, pix_lod, os_factor)
    peak_y, peak_x = np.unravel_index(psf_os.argmax(), psf_os.shape)
    ny, nx = psf_os.shape

    # Pad the PSF array so the crop is always crop_radius around the peak.
    # Without this, PSFs with smaller arrays get a tighter crop, making
    # the fixed aperture circles appear larger relative to the panel.
    pad_y_lo = max(0, crop_radius - peak_y)
    pad_y_hi = max(0, crop_radius - (ny - peak_y))
    pad_x_lo = max(0, crop_radius - peak_x)
    pad_x_hi = max(0, crop_radius - (nx - peak_x))
    if pad_y_lo + pad_y_hi + pad_x_lo + pad_x_hi > 0:
        psf_os = np.pad(
            psf_os,
            ((pad_y_lo, pad_y_hi), (pad_x_lo, pad_x_hi)),
            mode='constant',
            constant_values=0,
        )
        peak_y += pad_y_lo
        peak_x += pad_x_lo

    r = crop_radius  # guaranteed to fit after padding
    psf_crop = psf_os[peak_y - r:peak_y + r, peak_x - r:peak_x + r]
    log_crop = np.log10(np.maximum(psf_crop, 1e-20))
    peak_val = log_crop.max()

    # Physical extent in lambda/D for consistent axes
    half_extent_lod = r * (pix_lod / os_factor)
    extent = [-half_extent_lod, half_extent_lod,
              -half_extent_lod, half_extent_lod]

    # --- Top row: fixed apertures ---
    ax_top = axes[0, j]
    ax_top.imshow(log_crop, origin='lower', cmap='magma',
                  vmin=peak_val - 4, vmax=peak_val, extent=extent)
    for rad, color in zip(compare_radii, circ_colors, strict=True):
        circ = plt.Circle((0, 0), rad, fill=False,
                          ec=color, lw=1.5, ls='--')
        ax_top.add_patch(circ)
    ax_top.set_aspect('equal')
    ax_top.set_title(f'{pos.separation:.1f} $\\lambda/D$', fontsize=9)
    if j == 0:
        ax_top.set_ylabel('Fixed Apertures', fontsize=10)
    ax_top.set_xticks([])
    ax_top.set_yticks([])

    # --- Bottom row: truncation masks ---
    ax_bot = axes[1, j]
    ax_bot.imshow(log_crop, origin='lower', cmap='magma',
                  vmin=peak_val - 4, vmax=peak_val, extent=extent)
    for ratio, color in zip(compare_ratios, trunc_colors, strict=True):
        # Recompute mask on the padded PSF, then crop
        mask_full = _threshold_mask(psf_os, ratio)
        mask_crop = mask_full[peak_y - r:peak_y + r, peak_x - r:peak_x + r]
        ax_bot.contour(mask_crop.astype(float), levels=[0.5],
                       colors=[color], linewidths=1.5,
                       extent=extent)
    ax_bot.set_aspect('equal')
    if j == 0:
        ax_bot.set_ylabel('Truncation Masks', fontsize=10)
    ax_bot.set_xticks([])
    ax_bot.set_yticks([])

# Legends below the figure
from matplotlib.lines import Line2D
circ_handles = [Line2D([0], [0], color=c, ls='--', lw=2,
                       label=f'$r_{{ap}}$ = {rad}')
                for rad, c in zip(compare_radii, circ_colors, strict=True)]
trunc_handles = [Line2D([0], [0], color=c, ls='-', lw=2,
                        label=f'$\\rho$ = {ratio}')
                 for ratio, c in zip(compare_ratios, trunc_colors, strict=True)]
fig.legend(handles=circ_handles + trunc_handles, loc='lower center',
           ncol=6, fontsize=9, frameon=True,
           bbox_to_anchor=(0.5, -0.02))

fig.suptitle('Aperture Shape: Fixed Circles vs Truncation Masks',
             fontsize=13, fontweight='bold')
plt.tight_layout(rect=[0, 0.04, 1, 0.97])
plt.show()
../_images/dfb5d5a3d3acbc79e7823d2f5fb837f3eb5496d32b8914d6900d23889a7be18d.png

Consistent Noise Metric#

To compare both methods on equal footing, we need the same noise proxy. The ETC integration time for stellar-noise-limited observations scales as:

\[t_{\text{int}} \propto \frac{\bar{I}_\star(r) \cdot \Omega}{\eta_p^2}\]

where \(\bar{I}_\star\) is the core mean intensity (same for both methods since it depends only on the stellar PSF, not the aperture choice), \(\Omega\) is the core area, and \(\eta_p\) is the throughput.

This metric is consistent because it uses:

  • For fixed apertures: \(\Omega = \pi r_{ap}^2\) and \(\eta_p\) from circular photometry

  • For truncation masks: \(\Omega = \sum_{\text{mask}} \Delta\theta^2\) and \(\eta_p\) from threshold photometry

The stellar intensity \(\bar{I}_\star\) is the same for both, so it cancels in relative comparisons.

Hide code cell source

from yippy.performance import compute_core_mean_intensity_curve

# Get core mean intensity profile (same for both methods)
sep_ci, intensities = compute_core_mean_intensity_curve(coro)
diams = list(intensities.keys())
cmi_profile = intensities[diams[0]]  # point source

fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
ax_tp_both, ax_area_both, ax_ns = axes

# Fixed aperture curves
for r, c in [(0.7, '#4CAF50'), (1.0, '#2196F3'), (1.5, '#FF9800')]:
    s, tp = compute_throughput_curve(coro, aperture_radius_lod=r)
    omega = np.pi * r**2
    # Noise proxy: I_star * Omega / eta_p^2 using interpolated I_star
    i_star_at_s = np.interp(s, sep_ci, cmi_profile)
    noise = i_star_at_s * omega / tp**2

    ax_tp_both.plot(s, tp, '-', ms=3, color=c, marker='o',
                   label=f'Circle $r_{{ap}}$ = {r}')
    ax_area_both.axhline(omega, ls='-', color=c, alpha=0.7,
                         label=f'Circle $r_{{ap}}$ = {r}')
    ax_ns.semilogy(s, noise, '-', ms=3, color=c, marker='o',
                  label=f'Circle $r_{{ap}}$ = {r}')

# Truncation mask curves
for ratio, c in [(0.3, '#E91E63'), (0.5, '#9C27B0'), (0.7, '#795548')]:
    seps_t, tps_t, omegas_t = [], [], []
    for pos in positions:
        psf_os = _oversample_psf(pos.psf, pix_lod, os_factor)
        m = _threshold_mask(psf_os, ratio)
        seps_t.append(pos.separation)
        tps_t.append(psf_os[m].sum())
        omegas_t.append(m.sum() * pix_solid_angle)
    seps_t = np.array(seps_t)
    tps_t = np.array(tps_t)
    omegas_t = np.array(omegas_t)
    i_star_t = np.interp(seps_t, sep_ci, cmi_profile)
    noise_t = i_star_t * omegas_t / tps_t**2

    ax_tp_both.plot(seps_t, tps_t, '--', ms=3, color=c, marker='s',
                   label=f'Trunc $\\rho$ = {ratio}')
    ax_area_both.plot(seps_t, omegas_t, '--', ms=3, color=c, marker='s',
                     label=f'Trunc $\\rho$ = {ratio}')
    ax_ns.semilogy(seps_t, noise_t, '--', ms=3, color=c, marker='s',
                  label=f'Trunc $\\rho$ = {ratio}')

for ax in axes:
    ax.axvline(coro.IWA.value, ls='--', color='gray', alpha=0.3)
    ax.legend(fontsize=7, ncol=2)
    ax.grid(True, alpha=0.3)
    ax.set_xlabel('Separation [$\\lambda/D$]')

ax_tp_both.set_ylabel('Throughput')
ax_tp_both.set_title('Throughput')
ax_area_both.set_ylabel('Core Area [$(\\lambda/D)^2$]')
ax_area_both.set_title('Core Area $\\Omega$')
ax_ns.set_ylabel('$\\bar{I}_\\star \\Omega \\,/\\, \\eta_p^2$')
ax_ns.set_title('Integration Time Proxy (lower = better)')

# Zoom to working region
for ax in axes:
    ax.set_xlim(coro.IWA.value - 1, coro.OWA.value)
ax_ns.set_ylim(1e-13, 1e-10)
fig.suptitle('Fixed Aperture vs Truncation Mask -- Consistent Metric',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
/tmp/ipykernel_869/2734778706.py:17: RuntimeWarning: divide by zero encountered in divide
  noise = i_star_at_s * omega / tp**2
../_images/852ed61b61277b8c3e2ad607461231c9b08ce396935a63658ac27a105b71dc32.png

AYO’s Per-Separation Optimization#

The comparison above uses fixed truncation ratios, but AYO optimizes the truncation ratio at each planet position to minimize integration time. In calc_exp_time, AYO loops over all available truncation ratios for each planet and keeps the one that yields the shortest exposure time:

for (iratio = 0; iratio < npsfratios; iratio++) {
    CRp = tempCRpfactor * photap_frac[index2];      // signal
    CRb = tempCRbfactor * omega_lod[index2];         // all background noise
    CRbd = CRbdfactor * det_npix;                    // detector noise
    cp = (CRp + 2*CRb) / (CRp^2 - CRnf^2);         // exposure factor
    if (temptp < besttp_v_ratio)
        besttp_v_ratio = temptp;                     // keep the best
}

All noise terms – stellar leakage, zodiacal, exozodiacal, binary contamination, thermal, and detector noise – scale with omega_lod (core area). Signal scales with photap_frac (throughput).

Our simplified proxy \(\bar{I}_\star \Omega / \eta_p^2\) captures the dominant tradeoff. The figure below shows the optimization results:

Hide code cell source

# Sweep truncation ratios at each separation to find the optimum
sweep_ratios = np.arange(0.05, 0.96, 0.05)

opt_ratios = []
opt_noise = []
opt_seps = []
noise_landscape = []  # shape: (n_seps, n_ratios)

for pos in positions:
    i_star = np.interp(pos.separation, sep_ci, cmi_profile)
    psf_os = _oversample_psf(pos.psf, pix_lod, os_factor)

    row = []
    best_noise = np.inf
    best_ratio = 0.5
    for ratio in sweep_ratios:
        m = _threshold_mask(psf_os, ratio)
        eta = psf_os[m].sum()
        omega = m.sum() * pix_solid_angle
        if eta > 0:
            n = i_star * omega / eta**2
        else:
            n = np.inf
        row.append(n)
        if n < best_noise:
            best_noise = n
            best_ratio = ratio

    opt_seps.append(pos.separation)
    opt_ratios.append(best_ratio)
    opt_noise.append(best_noise)
    noise_landscape.append(row)

opt_seps = np.array(opt_seps)
opt_ratios = np.array(opt_ratios)
opt_noise = np.array(opt_noise)
noise_landscape = np.array(noise_landscape)

fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
ax_opt_ratio, ax_landscape, ax_compare = axes

# Panel 1: Optimal truncation ratio vs separation
ax_opt_ratio.plot(opt_seps, opt_ratios, 'o-', ms=5, color='#E91E63')
ax_opt_ratio.axvline(coro.IWA.value, ls='--', color='gray', alpha=0.5)
ax_opt_ratio.set_xlabel('Separation [$\\lambda/D$]')
ax_opt_ratio.set_ylabel('Optimal $\\rho$')
ax_opt_ratio.set_title('Optimal Truncation Ratio')
ax_opt_ratio.set_ylim(0, 1)
ax_opt_ratio.grid(True, alpha=0.3)

# Panel 2: Noise landscape (heatmap)
log_landscape = np.log10(noise_landscape + 1e-30)
im = ax_landscape.pcolormesh(opt_seps, sweep_ratios, log_landscape.T,
                             cmap='viridis_r', shading='nearest')
ax_landscape.plot(opt_seps, opt_ratios, 'o-', ms=3, color='white',
                  lw=2, label='Optimal $\\rho$')
ax_landscape.set_xlabel('Separation [$\\lambda/D$]')
ax_landscape.set_ylabel('Truncation Ratio $\\rho$')
ax_landscape.set_title('Noise Landscape (log$_{10}$)')
ax_landscape.legend(fontsize=8)
plt.colorbar(im, ax=ax_landscape, shrink=0.8)

# Panel 3: Optimized AYO vs fixed apertures
ax_compare.semilogy(opt_seps, opt_noise, 'o-', ms=5, color='#E91E63',
                    lw=2.5, label='AYO (optimized $\\rho$)', zorder=5)

for r, c, ls in [(0.7, '#4CAF50', '-'), (1.0, '#2196F3', '-'), (1.5, '#FF9800', '-')]:
    s, tp = compute_throughput_curve(coro, aperture_radius_lod=r)
    omega = np.pi * r**2
    i_star_s = np.interp(s, sep_ci, cmi_profile)
    noise_fix = i_star_s * omega / tp**2
    ax_compare.semilogy(s, noise_fix, ls, ms=3, color=c, marker='o',
                       alpha=0.6, label=f'Circle $r_{{ap}}$ = {r}')

ax_compare.axvline(coro.IWA.value, ls='--', color='gray', alpha=0.3)
ax_compare.set_xlabel('Separation [$\\lambda/D$]')
ax_compare.set_ylabel('$\\bar{I}_\\star \\Omega \\,/\\, \\eta_p^2$')
ax_compare.set_title('Optimized AYO vs Fixed Aperture')
ax_compare.legend(fontsize=8)
ax_compare.grid(True, alpha=0.3)

# Zoom to working region
for ax in axes:
    ax.set_xlim(coro.IWA.value - 1, coro.OWA.value)
ax_compare.set_ylim(1e-13, 1e-10)

fig.suptitle('AYO Per-Separation Optimization',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
/tmp/ipykernel_869/2722695700.py:71: RuntimeWarning: divide by zero encountered in divide
  noise_fix = i_star_s * omega / tp**2
../_images/ca64d1e17d2040ac709739e7f3fe056861518003bb7a8d4fc62eeaed7291eb0f.png

Discussion#

Key Finding: The Optimal Ratio is Separation-Independent#

The optimization sweep reveals that the optimal truncation ratio is constant across all separations for this coronagraph. This follows from the structure of AYO’s ETC:

  • All noise terms scale as \(\Omega(\rho)\)

  • Planet signal scales as \(\eta_p(\rho)\)

  • Position-dependent factors (\(\bar{I}_\star\), sky transmission, etc.) are independent of \(\rho\)

So the optimal \(\rho\) at each position depends only on \(\Omega(\rho) / \eta_p(\rho)^2\), which is a property of the PSF core shape. For this coronagraph, the PSF shape is stable across the working region, producing a uniform optimal \(\rho \approx 0.30\).

This means:

  • AYO’s per-position optimization is mathematically equivalent to a single optimized ratio for this coronagraph design

  • The advantage of truncation over fixed apertures comes from the shape adaptation (non-circular aperture), not from per-position tuning

  • Coronagraph designs with more PSF distortion near the IWA would likely show position-dependent optimal ratios

Method Comparison#

  1. With a consistent noise metric, both methods produce comparable integration time proxies in the working region.

  2. The truncation mask adapts its shape to the PSF, which is advantageous when the PSF is non-circular (near the IWA).

  3. Fixed apertures are simpler and well-suited for circular PSFs far from the IWA.

Caveats#

  • The simplified proxy only captures the dominant stellar noise term. AYO’s full ETC includes zodi, exozodi, binary, thermal, and detector noise, all of which scale with \(\Omega\) but with different per-pixel weights.

  • This analysis uses a single coronagraph design. Other designs may show different optimal ratio behavior.