Note
This page is generated from the Jupyter notebook
notebooks/08_psf_wings.ipynb in the
repository. You can download it and run it interactively.
PSF wings: no more postage stamps
The PSF stamps are finite (129 px in focus, 257 px defocused), so without correction the wing of every bright star is cut to exactly zero at a square boundary. At a hard display stretch that square sits far above the noise, about 100 sigma for a G=8 star in focus at 90 s, which is the classic “postage stamp” artifact in FITS viewers.
With wing extension (wings=True, the default), each star’s stamp is continued beyond its edge with an analytic power-law wing fit from the stamp’s own outer annulus (wcc_sim.wings), on a circular footprint, down to 0.1 sigma of the sky+read noise floor. This notebook verifies the behaviour and quantifies what the wings do to the encircled energy (EE).
The wings fitted here describe the diffraction halo only. The measured scattered-light halo is a separate additive term (scatter=True, also the default, see wcc_sim.scatter and the user guide); field.plot_psf_profile() in notebook 10 shows both terms on one radial profile.
[1]:
import numpy as np
import matplotlib.pyplot as plt
from astropy.visualization import simple_norm
import wcc_sim
from wcc_sim import simulate_field
from wcc_sim.detectors import make_base_simulation
from wcc_sim.psf import DEFAULT_STAMP, render_oversampled_psf
from wcc_sim.render import bin_oversampled, render_scene
from wcc_sim.wings import fit_wing_model
from wcc_sim.plotting import use_style, TEAL, RED, AMBER, BLUE, plot_radial_profile, encircled_energy
use_style()
print('wcc_sim', wcc_sim.__version__)
wcc_sim 0.1.0
1. Before / after on a real field
A 1024×1024 subarray centered on a G=8.1 star (Gaia DR3 3836000669773723520, near the COSMOS field), simulated with and without wings (same seed, same noise realization). The stretch is set just above the sky to mimic cranking the contrast in a FITS viewer.
[2]:
RA, DEC = 150.63121, 1.82361 # G = 8.1 star
kw = dict(sensorfilter='zwo:r', focus=0, exptime=90.0, seed=7,
shape=(1024, 1024), cache_dir='gaia_cache')
field_off = simulate_field(RA, DEC, wings=False, **kw)
field_on = simulate_field(RA, DEC, wings=True, **kw)
star = field_on.catalog[np.argmin(field_on.catalog['phot_g_mean_mag'])]
x0, y0 = int(round(star['x'])), int(round(star['y']))
print(f"G = {star['phot_g_mean_mag']:.2f} at (x, y) = ({x0}, {y0})")
print('wing model: alpha = %.3f, c = %.4f, floor = %.3f e-/pix' % (
field_on.params['wing_alpha'], field_on.params['wing_c'],
field_on.params['wing_floor_e']))
G = 8.14 at (x, y) = (510, 511)
wing model: alpha = -2.996, c = 0.0766, floor = 0.328 e-/pix
[3]:
med = np.median(field_off.image_adu)
norm = simple_norm(field_off.image_adu, 'asinh', vmin=med - 1, vmax=med + 25)
fig, axes = plt.subplots(1, 2, figsize=(11, 5.4), constrained_layout=True)
for ax, f, title in [(axes[0], field_off, 'wings=False (129 px stamp only)'),
(axes[1], field_on, 'wings=True (default)')]:
f.plot(ax=ax, norm=norm, cmap='gray')
ax.set_title(title)
fig.suptitle('G=8.1 star, SCI [ADU], hard asinh stretch', y=1.04);
The square is the old stamp boundary; with wings the halo is circular and fades into the noise. A row cut through the star shows the same thing quantitatively: the truncated profile falls off a cliff at ±64 px, the extended one follows the r^-3 Airy envelope down to the noise.
[4]:
xs = np.arange(1024) - x0
fig, ax = plt.subplots(figsize=(8, 4.6))
ax.semilogy(xs, np.maximum(field_off.image_adu[y0], 0.5), lw=1,
color=RED, label='wings=False')
ax.semilogy(xs, np.maximum(field_on.image_adu[y0], 0.5), lw=1,
color=TEAL, label='wings=True')
for edge in (-64, 64):
ax.axvline(edge, color='0.4', ls='--', lw=1)
ax.set_xlabel('x offset from star [px]'); ax.set_ylabel('SCI [ADU]')
ax.set_title('row cut through the star (dashed: stamp edge)')
ax.legend();
2. The wing model
fit_wing_model fits profile(r) = c * r**alpha (PSF fraction per pixel) to the azimuthally smooth outer annulus of the detector-binned stamp. In focus the slope comes out at the physical Airy-envelope value of -3; the defocused Huygens PSFs fall faster. The dots are plot_radial_profile() of each stamp on log-spaced annuli.
[5]:
sim = make_base_simulation('zwo:r')
wings, stamps = {}, {}
for focus in (0, 1, 2):
psf_os = render_oversampled_psf(sim, focus, oversample=11)
stamps[focus] = bin_oversampled(psf_os, 11)
wings[focus] = fit_wing_model(stamps[focus])
n = stamps[focus].shape[0]
print(f'focus={focus}w stamp={n:3d} px '
f'alpha={wings[focus].alpha:6.2f} c={wings[focus].c:.3e}')
focus=0w stamp=129 px alpha= -3.00 c=7.663e-02
/Users/gudmundurstefansson/Dropbox/mypylib/notebooks/GIT/wcc-sim/src/wcc_sim/psf.py:35: RuntimeWarning: divide by zero encountered in matmul
cx = (psf.sum(axis=0) @ i) / total
/Users/gudmundurstefansson/Dropbox/mypylib/notebooks/GIT/wcc-sim/src/wcc_sim/psf.py:35: RuntimeWarning: overflow encountered in matmul
cx = (psf.sum(axis=0) @ i) / total
/Users/gudmundurstefansson/Dropbox/mypylib/notebooks/GIT/wcc-sim/src/wcc_sim/psf.py:35: RuntimeWarning: invalid value encountered in matmul
cx = (psf.sum(axis=0) @ i) / total
/Users/gudmundurstefansson/Dropbox/mypylib/notebooks/GIT/wcc-sim/src/wcc_sim/psf.py:36: RuntimeWarning: divide by zero encountered in matmul
cy = (psf.sum(axis=1) @ i) / total
/Users/gudmundurstefansson/Dropbox/mypylib/notebooks/GIT/wcc-sim/src/wcc_sim/psf.py:36: RuntimeWarning: overflow encountered in matmul
cy = (psf.sum(axis=1) @ i) / total
/Users/gudmundurstefansson/Dropbox/mypylib/notebooks/GIT/wcc-sim/src/wcc_sim/psf.py:36: RuntimeWarning: invalid value encountered in matmul
cy = (psf.sum(axis=1) @ i) / total
focus=1w stamp=257 px alpha= -3.66 c=1.261e+00
focus=2w stamp=257 px alpha= -4.14 c=1.518e+01
[6]:
fig, ax = plt.subplots(figsize=(8, 4.6))
colors = {0: TEAL, 1: AMBER, 2: RED}
for focus in (0, 1, 2):
stamp, w = stamps[focus], wings[focus]
half = stamp.shape[0] // 2
plot_radial_profile(stamp, half, half, ax=ax, r_max=half, n_bins=half // 2, log=True,
marker='.', ls='none', ms=3, color=colors[focus], alpha=0.6)
rr = np.linspace(0.4 * half, 3 * half, 100)
ax.loglog(rr, w.profile(rr), '-', color=colors[focus], lw=1.5,
label=f'focus={focus}w: $r^{{{w.alpha:.2f}}}$')
ax.axvline(half, color=colors[focus], ls=':', lw=1, alpha=0.5)
ax.set_xlabel('r [px]'); ax.set_ylabel('PSF fraction per pixel')
ax.set_title('azimuthal profile (dots) and fitted wing (lines); dotted: stamp edge')
ax.legend();
3. Seam smoothness
The fix has two parts: the stamp’s outer 20% is crossfaded into the smooth model (so Airy-ring texture doesn’t stop abruptly), and the halo continues outside on a circular footprint. On the noiseless image the profile should now cross the old stamp edge without any visible jump.
[7]:
half = 64 # 129 px in-focus stamp
row_off = field_off.image_clean[y0]
row_on = field_on.image_clean[y0]
jump_off = abs(row_off[x0 + half] - row_off[x0 + half + 2])
jump_on = abs(row_on[x0 + half] - row_on[x0 + half + 2])
local = row_on[x0 + half]
print(f'clean-image step across the stamp edge, wings off: {jump_off:9.1f} e-')
print(f'clean-image step across the stamp edge, wings on : {jump_on:9.1f} e-')
print(f'local wing level there: {local:.1f} e-/pix '
f'-> relative jump {jump_on / local:.1%}')
assert jump_on / local < 0.15, 'seam should be smooth'
assert jump_off / local > 0.5, 'truncated case should be a cliff'
clean-image step across the stamp edge, wings off: 525.2 e-
clean-image step across the stamp edge, wings on : 47.1 e-
local wing level there: 541.1 e-/pix -> relative jump 8.7%
4. Encircled energy: what do the wings change?
Two effects, both small and physical:
Renormalization. The truncated stamp pretended 100% of the flux landed inside the stamp. With wings, every star is scaled by
1/(1 + E_wing)whereE_wingis the wing energy beyond the stamp edge, so EE inside any small aperture drops by that fraction.The halo itself. EE keeps growing beyond the old stamp edge instead of flat-lining at 1.0.
Below: EE curves for a single in-focus star rendered noiselessly with and without wings (flux 10⁹ e⁻, halo drawn to its 0.25 e⁻/pix floor at ~680 px).
[8]:
N = 1601; c0 = N // 2; flux = 1e9
psf0 = render_oversampled_psf(sim, 0, oversample=11)
img_ee_off = render_scene((N, N), [c0], [c0], [flux], psf0, 11)
img_ee_on = render_scene((N, N), [c0], [c0], [flux], psf0, 11,
wing=wings[0], floor_e=0.25)
# normalised to the star's true flux, not the image sum, so a truncated render shows up as EE < 1
r_sorted, ee_off = encircled_energy(img_ee_off, total=flux)
_, ee_on = encircled_energy(img_ee_on, total=flux)
E_wing = wings[0].energy_beyond(64.0)
print(f'total flux ratio, wings on : {img_ee_on.sum() / flux:.4f}')
print(f'total flux ratio, wings off: {img_ee_off.sum() / flux:.4f}')
print(f'analytic wing energy beyond the 129 px stamp: {E_wing:.4f} '
f'({E_wing / (1 + E_wing):.2%} of total flux)')
assert abs(img_ee_on.sum() / flux - 1.0) < 0.01
total flux ratio, wings on : 0.9985
total flux ratio, wings off: 1.0000
analytic wing energy beyond the 129 px stamp: 0.0077 (0.76% of total flux)
[9]:
idx = np.unique(np.searchsorted(r_sorted, np.logspace(0, np.log10(790), 300)))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.6), constrained_layout=True)
ax1.semilogx(r_sorted[idx], ee_off[idx], color=RED, label='wings=False')
ax1.semilogx(r_sorted[idx], ee_on[idx], color=TEAL, label='wings=True')
ax1.axvline(64, color='0.4', ls='--', lw=1)
ax1.set_xlabel('aperture radius [px]'); ax1.set_ylabel('encircled energy')
ax1.set_xlim(1, 800); ax1.set_ylim(0, 1.05)
ax1.set_title('EE (dashed: stamp edge)')
ax1.legend(loc='lower right')
ax2.semilogx(r_sorted[idx], (ee_off - ee_on)[idx] * 100, color=BLUE)
ax2.axvline(64, color='0.4', ls='--', lw=1)
ax2.axhline(0, color='0.55', ls=':', lw=1)
ax2.set_xlabel('aperture radius [px]')
ax2.set_ylabel(r'EE$_{\rm off}$ $-$ EE$_{\rm on}$ [% of total flux]')
ax2.set_title('EE difference');
[10]:
radii = [2, 5, 10, 20, 40, 64, 100, 200, 400, 680]
print(' r [px] EE off EE on diff [%]')
for rad in radii:
i = np.searchsorted(r_sorted, rad)
print(f'{rad:6d} {ee_off[i]:.4f} {ee_on[i]:.4f} '
f'{(ee_off[i] - ee_on[i]) * 100:+7.3f}')
assert np.all(np.diff(ee_on[::997]) > -1e-9), 'EE must be monotonic'
r [px] EE off EE on diff [%]
2 0.5900 0.5855 +0.450
5 0.8986 0.8917 +0.685
10 0.9564 0.9491 +0.729
20 0.9820 0.9745 +0.749
40 0.9945 0.9869 +0.758
64 0.9992 0.9916 +0.765
100 1.0000 0.9943 +0.571
200 1.0000 0.9967 +0.327
400 1.0000 0.9980 +0.205
680 1.0000 0.9985 +0.154
Inside the core the EE simply drops by the renormalization factor, a uniform ≈0.7% for the in-focus 129 px stamp. Beyond the stamp edge the wings return that energy, closing back to 100% at the halo’s truncation radius.
The same number per focus mode, from the fitted models (this is the total flux each mode’s default stamp used to overcount):
[11]:
print('focus stamp alpha flux beyond stamp')
for focus in (0, 1, 2):
w = wings[focus]
half_f = DEFAULT_STAMP[focus] // 2
E = w.energy_beyond(half_f)
print(f'{focus:3d}w {DEFAULT_STAMP[focus]:4d}px {w.alpha:6.2f} '
f'{E / (1 + E):.3%}')
focus stamp alpha flux beyond stamp
0w 129px -3.00 0.763%
1w 257px -3.66 0.152%
2w 257px -4.14 0.135%
5. How far do the wings extend in practice?
The halo is drawn out to r_out, where flux * profile(r) drops to the 0.1 sigma floor; with an r^-3 wing that radius scales as flux^(1/3). Against the full IMX455 frame:
[12]:
from astropy.table import Table
from wcc_sim.detectors import get_geometry
from wcc_sim.starflux import rates_for_catalog
geom = get_geometry('zwo:r', sim=sim)
print(f'detector: {geom.nx} x {geom.ny} px '
f'(diagonal {np.hypot(geom.nx, geom.ny):.0f} px), '
f'{geom.plate_scale_mas:.2f} mas/px')
mags = np.arange(0.0, 15.0, 2.0)
cat = Table({'phot_g_mean_mag': mags,
'phot_bp_mean_mag': mags + 0.4,
'phot_rp_mean_mag': mags - 0.4,
'ra': np.zeros_like(mags), 'dec': np.zeros_like(mags)})
rates, _ = rates_for_catalog(cat, 'zwo:r')
exptime, floor = 90.0, field_on.params['wing_floor_e']
well = sim.sensor.meta['well_depth']
print(f'\n G flux (90 s) r_out 0w [px] [arcsec] r_out 2w sat-halo 0w')
for G, rate in zip(mags, rates):
f = rate * exptime
r0, r2 = wings[0].r_out(f, floor), wings[2].r_out(f, floor)
rsat = (wings[0].c * f / well) ** (-1 / wings[0].alpha) \
if wings[0].c * f > well else 0.0
print(f'{G:4.0f} {f:10.2e} {r0:10.0f} {r0 * geom.plate_scale_mas / 1e3:8.1f}'
f' {r2:8.0f} {rsat:8.0f} px')
detector: 9568 x 6380 px (diagonal 11500 px), 16.87 mas/px
G flux (90 s) r_out 0w [px] [arcsec] r_out 2w sat-halo 0w
0 3.43e+12 9405 158.7 2670 255 px
2 5.44e+11 5085 85.8 1712 138 px
4 8.62e+10 2750 46.4 1098 74 px
6 1.37e+10 1487 25.1 704 40 px
8 2.17e+09 804 13.6 451 22 px
10 3.43e+08 435 7.3 289 12 px
12 5.44e+07 235 4.0 185 6 px
14 8.62e+06 127 2.1 128 3 px
A 0-mag star’s wing reaches ~9,400 px (~160 arcsec), essentially the whole detector, and that is physically correct: at 3,000 px its diffraction wing is still ~10 e⁻/pix ≈ 3 sigma above sky, a real detectable gradient across the frame. The drawn halo always ends at 0.1 sigma, so the outer cutoff stays invisible. The brightest stars also saturate a large disk (~255 px radius for G=0, the “sat-halo” column). Full-frame halos are evaluated in row chunks, so even a G=0 star costs only a few seconds.
6. Is the extrapolation correct?
Two independent arguments for the in-focus PSF:
The physics fixes the law. r^-3 is the exact asymptotic envelope of the circular-aperture Airy pattern; the fit recovering alpha = -2.996 means the stamp’s outer annulus is already in that regime. Jitter and broadband smearing smooth the rings but do not change the envelope.
Direct test against the physical-optics code. Below we render the wcc_etc PSF at 1025 px (8x the production stamp) and compare its measured profile with the power law extrapolated from the 129 px fit.
[13]:
psf_big = render_oversampled_psf(sim, 0, oversample=1, stamp_npix=1025)
nb_ = psf_big.shape[0]; hb = nb_ // 2
yy, xx = np.mgrid[:nb_, :nb_]
rbig = np.hypot(yy - hb, xx - hb)
radii_chk = [80, 120, 180, 260, 360, 480]
print(' r [px] rendered PSF c*r^alpha ratio')
ratios = []
for r0 in radii_chk:
m = (rbig > r0 - 2) & (rbig < r0 + 2)
meas = np.median(psf_big[m])
model = wings[0].profile(r0)
ratios.append(meas / model)
print(f'{r0:6d} {meas:.3e} {model:.3e} {meas / model:6.2f}')
assert max(abs(np.log(ratios))) < np.log(1.2), \
'extrapolation should match physical optics to <20%'
r [px] rendered PSF c*r^alpha ratio
80 1.439e-07 1.524e-07 0.94
120 4.444e-08 4.523e-08 0.98
180 1.372e-08 1.342e-08 1.02
260 4.788e-09 4.461e-09 1.07
360 1.660e-09 1.683e-09 0.99
480 6.907e-10 7.107e-10 0.97
[14]:
fig, ax = plt.subplots(figsize=(8, 4.6))
plot_radial_profile(psf_big, hb, hb, ax=ax, r_max=hb - 2, n_bins=60, log=True,
marker='.', ls='none', ms=4, color=BLUE,
label='physical-optics render (1025 px)')
rr = np.geomspace(25, hb, 100)
ax.loglog(rr, wings[0].profile(rr), '-', color=TEAL, lw=1.5,
label=f'wing fit from 129 px stamp: $r^{{{wings[0].alpha:.2f}}}$')
ax.axvspan(0.65 * 64, 0.98 * 64, color=AMBER, alpha=0.2,
label='fit annulus (129 px stamp)')
ax.axvline(64, color='0.4', ls='--', lw=1)
ax.set_xlabel('r [px]'); ax.set_ylabel('PSF fraction per pixel')
ax.set_title('extrapolation check: agrees to $\\pm$7% out to 480 px')
ax.legend();
For the defocused PSFs the fitted slopes (-3.7, -4.2) are only backed by the Huygens data out to its ~272 px span; far enough out the wing should revert to the r^-3 diffraction envelope, so the defocused far wings are likely slight underestimates beyond a few hundred px (small in absolute terms, since their wing amplitudes are about 10x lower to begin with).
For the brightest stars the far halo at arcminute scales is dominated by scattered light (mirror micro-roughness, particulates), which is shallower (about r^-2) and brighter than pure diffraction. That term is modelled separately from the FRED stray-light ray trace and added on top of these wings (scatter=True); the diffraction wings alone are a lower bound on the far halo.
Summary
Truncated stamps put a square, ~100-sigma cliff around every bright star; with
wings=Truethe halo is circular, follows the fitted power law, and ends below 0.1 sigma of the noise floor.The seam at the old stamp edge is smooth (relative jump at the percent level, on a signal that is itself deep in the wing).
Encircled energy changes by the wing renormalisation: about 0.7% less flux in any core aperture for the in-focus 129 px stamp (about 0.1% for the defocused 257 px stamps), returned in the halo so the total closes to 100% within 1%.
Photometry stays magnitude independent: every star gets the same renormalisation whether or not its (sub-noise) halo is drawn.
Wing extent scales as flux^(1/3): about 4 arcsec at G=12, the full detector at G=0, which is physically expected for a 0-mag star.
The in-focus extrapolation matches an 8x larger physical-optics render to +/-7% (and r^-3 is the analytic Airy envelope); the defocused slopes are an assumption beyond the ~272 px Huygens data span.
The scattered-light halo is a separate, additive term of the PSF; see the scattered-light page of the user guide and
field.plot_psf_profile().