Note
This page is generated from the Jupyter notebook
notebooks/07_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 — ~100 sigma for a G=8 star in focus at 90 s — which is the classic “postage stamp” artifact in FITS viewers.
Since wing extension was added (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 behavior and quantifies what the wings do to the encircled energy (EE).
Note: these wings describe the diffraction halo only. Scattered-light PSFs are not in the models yet; when they land, the measured extended profile will replace the power-law fit behind the same
WingModelinterface.
[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
plt.style.use('gks')
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)')]:
ax.imshow(f.image_adu, origin='lower', cmap='gray', norm=norm)
ax.set_title(title)
ax.set_xlabel('x [px]'); ax.set_ylabel('y [px]')
ax.grid(False)
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='#d1495b', label='wings=False')
ax.semilogy(xs, np.maximum(field_on.image_adu[y0], 0.5), lw=1,
color='#00798c', 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.
[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
focus=1w stamp=257 px alpha= -3.66 c=1.257e+00
focus=2w stamp=257 px alpha= -4.15 c=1.543e+01
[6]:
fig, ax = plt.subplots(figsize=(8, 4.6))
colors = {0: '#00798c', 1: '#edae49', 2: '#d1495b'}
for focus in (0, 1, 2):
stamp, w = stamps[focus], wings[focus]
n = stamp.shape[0]; half = n // 2
yy, xx = np.mgrid[:n, :n]
r = np.hypot(yy - half, xx - half)
rb = np.arange(2, half, 2)
prof = [np.median(stamp[(r >= b - 1) & (r < b + 1)]) for b in rb]
ax.loglog(rb, prof, '.', 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: 602.7 e-
clean-image step across the stamp edge, wings on : 48.4 e-
local wing level there: 551.2 e-/pix -> relative jump 8.8%
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)
yy, xx = np.mgrid[:N, :N]
r = np.hypot(yy - c0, xx - c0).ravel()
order = np.argsort(r)
r_sorted = r[order]
ee_off = np.cumsum(img_ee_off.ravel()[order].astype(np.float64)) / flux
ee_on = np.cumsum(img_ee_on.ravel()[order].astype(np.float64)) / 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='#d1495b', label='wings=False')
ax1.semilogx(r_sorted[idx], ee_on[idx], color='#00798c', 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='#30638e')
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.153%
2w 257px -4.15 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.46e+12 9434 159.1 2677 256 px
2 5.43e+11 5084 85.8 1712 138 px
4 8.70e+10 2758 46.5 1101 75 px
6 1.38e+10 1491 25.2 706 40 px
8 2.19e+09 806 13.6 453 22 px
10 3.46e+08 436 7.4 290 12 px
12 5.49e+07 236 4.0 186 6 px
14 8.70e+06 127 2.2 128 3 px
A 0-mag star’s wing reaches ~9,400 px (~160 arcsec) — essentially the whole detector — and that is physically correct, not an artifact: 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]:
rb = np.unique(np.geomspace(6, hb - 2, 60).astype(int))
prof_big = [np.median(psf_big[(rbig > b - 2) & (rbig < b + 2)]) for b in rb]
fig, ax = plt.subplots(figsize=(8, 4.6))
ax.loglog(rb, prof_big, '.', ms=4, color='#30638e',
label='physical-optics render (1025 px)')
rr = np.geomspace(25, hb, 100)
ax.loglog(rr, wings[0].profile(rr), '-', color='#00798c', 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='#edae49', 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 — their wing amplitudes are ~10x lower to begin with).
The caveat that matters most for the brightest stars: real far halos at arcminute scales are usually scatter-dominated (mirror micro-roughness, particulates), shallower (~r^-2) and brighter than pure diffraction. The current wings are therefore a lower bound on the far halo — the diffraction floor. The planned scattered-light PSFs will replace the power-law fit behind the same WingModel interface.
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 renormalization: ≈0.7% less flux in any core aperture for the in-focus 129 px stamp (≈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 renormalization whether or not its (sub-noise) halo is drawn.
Wing extent scales as flux^(1/3): ~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.
Scattered-light PSFs are a planned follow-up and will slot in as measured
WingModelprofiles; until then the far wings of the brightest stars are a lower bound (diffraction only).