codexcoronagraph-circular-pupil-fourier-toy.py
coronagraph-circular-pupil-fourier-toy.py
"""Original scalar Fourier-optics toy; output opaque PNG to CWD.
Run from _media with MPLCONFIGDIR=/tmp/codex-wiki-matplotlib.
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Ideal coherent monochromatic on-axis point source; circular unobstructed
pupil, no aberrations, scatter, atmosphere, bandwidth, or detector noise.
Coordinates x are in pupil diameters; u = theta*D/lambda. Centered unitary
DFTs propagate field amplitudes, never intensities. The finite periodic
grid has 1024 samples, 128 across D, and 0.125 lambda/D image sampling.
An unlimited relay (L=1) is contrasted with L=P and a 0.8D Lyot stop.
All image curves share the unmasked full-pupil peak normalization.
"""
from pathlib import Path
import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
from matplotlib.patches import Circle
import numpy as np
def forward(field):
return np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(field), norm="ortho"))
def inverse(field):
return np.fft.fftshift(np.fft.ifft2(np.fft.ifftshift(field), norm="ortho"))
def calculate(n=1024, diameter_samples=128):
x = (np.arange(n) - n // 2) / diameter_samples
xx, yy = np.meshgrid(x, x)
pupil_radius = np.hypot(xx, yy)
pupil = (pupil_radius <= .5).astype(float)
u = np.fft.fftshift(np.fft.fftfreq(n, 1 / diameter_samples))
ux, uy = np.meshgrid(u, u)
image_radius = np.hypot(ux, uy)
mask = (image_radius > 3).astype(float)
stop = (pupil_radius <= .4).astype(float)
direct = forward(pupil)
masked = direct * mask
lyot_field = inverse(masked)
fields = [direct, masked, forward(lyot_field * pupil), forward(lyot_field * stop)]
intensities = [np.abs(field) ** 2 for field in fields]
peak = intensities[0][n // 2, n // 2]
annulus = (image_radius >= 4) & (image_radius <= 10)
metrics = {
"n": n, "diameter_samples": diameter_samples,
"mask_radius_lambda_over_D": 3, "stop_diameter_over_D": .8,
"image_sampling_lambda_over_D": diameter_samples / n,
"power_fraction": [float(v.sum() / pupil.sum()) for v in intensities],
"annular_mean_4_to_10_over_direct_peak": [float(v[annulus].mean() / peak) for v in intensities],
"rim_fraction_of_full_pupil_residual_power": float((np.abs(lyot_field) ** 2)[(pupil > 0) & (stop == 0)].sum()
/ (np.abs(lyot_field) ** 2)[pupil > 0].sum()),
"far_off_axis_geometric_throughput": float(stop.sum() / pupil.sum()),
}
return x, image_radius, lyot_field, intensities, peak, metrics
def main():
x, radius, field, intensities, peak, metrics = calculate()
print(json.dumps(metrics, indent=2))
plt.rcParams.update({"font.size": 10, "font.family": "DejaVu Sans"})
fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10.5, 4.65), gridspec_kw={"width_ratios": [1, 1.35]}, facecolor="white")
selected = np.flatnonzero(np.abs(x) <= .65)
cropped = (np.abs(field) ** 2)[np.ix_(selected, selected)]
step = x[1] - x[0]
bounds = [x[selected[0]] - step / 2, x[selected[-1]] + step / 2] * 2
picture = ax0.imshow(cropped, origin="lower", extent=bounds, cmap="YlOrBr", norm=LogNorm(vmin=1e-5, vmax=.5))
ax0.add_patch(Circle((0, 0), .5, fill=False, edgecolor="#252525", lw=1.2, ls="--"))
ax0.add_patch(Circle((0, 0), .4, fill=False, edgecolor="#1466a1", lw=2))
ax0.set(title="Pupil field after focal mask, before stop", xlabel="x / D", ylabel="y / D")
ax0.text(.03, .03, "Dashed: original pupil\nBlue: Lyot opening", transform=ax0.transAxes,
fontsize=9, bbox={"facecolor": "white", "edgecolor": "none", "alpha": .92})
cb = fig.colorbar(picture, ax=ax0, fraction=.045, pad=.035)
cb.set_label(r"$|E|^2$ / incident pupil intensity", fontsize=9)
edges = np.arange(0, 12.2501, .25)
bins = np.digitize(radius.ravel(), edges) - 1
valid = (bins >= 0) & (bins < len(edges) - 1)
counts = np.bincount(bins[valid], minlength=len(edges) - 1)
labels = ["Direct image", "Focal mask only (unlimited relay)", "Mask + full pupil (D)", "Mask + Lyot stop (0.8D)"]
colors = ["#333333", "#d18419", "#9370ad", "#1466a1"]
for intensity, label, color, style in zip(intensities, labels, colors, ["-", "--", "-", "-"]):
sums = np.bincount(bins[valid], weights=intensity.ravel()[valid] / peak, minlength=len(counts))
means = sums / counts
ax1.semilogy((edges[:-1] + edges[1:]) / 2, np.maximum(means, 1e-12), label=label, color=color, ls=style, lw=1.7)
ax1.axvspan(0, 3, color="#eeeeee", zorder=-2)
ax1.axvline(3, color="#777777", lw=.8)
ax1.text(1.5, 2e-8, "Mask radius", ha="center", color="#555555", fontsize=9)
ax1.set(xlim=(0, 12), ylim=(1e-8, 1.2), xlabel=r"Angular radius $\theta D / \lambda$", ylabel="Annular mean / direct-image peak",
title="Same input star, same intensity normalization")
ax1.grid(alpha=.18, which="major")
ax1.legend(loc="upper right", fontsize=8.5, framealpha=1)
fig.suptitle("Computed toy: blocking the core leaves rings; clipping the pupil rim helps", fontsize=13, y=.97)
fig.text(.5, .035, "Ideal circular pupil • monochromatic point source • mask radius 3λ/D • not measured data", ha="center", fontsize=10)
fig.subplots_adjust(left=.07, right=.98, bottom=.20, top=.80, wspace=.36)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=115, facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()