{ "cells": [ { "cell_type": "markdown", "id": "d1ab449f", "metadata": {}, "source": [ "# 04 — PSF simulator and PSF-aware SNR\n", "\n", "This notebook tours the detector-grid **PSF simulator** and the **PSF-aware SNR** features of `wcc_etc`:\n", "\n", "- **PSF sources** — `AiryPSF` (diffraction limited), `DefocusPSF` (+1 / +2 wave from Zemax Huygens data), and `CustomPSF` (any image).\n", "- **`ImageSimulator`** — renders a realistic detector image: source distributed by the PSF, sky + dark added per pixel, Poisson + read noise, and a saturation mask.\n", "- **`Simulation.get_image_snr`** — an aperture SNR computed from a *rendered* PSF, so it works for any PSF, not just the analytic Airy disk that `get_snr` assumes. For the in-focus case it reproduces `get_snr`; for defocus it quantifies the SNR penalty and the optimal aperture.\n", "\n", "Everything is in electrons." ] }, { "cell_type": "markdown", "id": "9a3085b9", "metadata": {}, "source": [ "## Setup\n", "\n", "Imports, a G5V `r=15` scene with zodiacal background, and a `Simulation` on the Sony sensor in the r band." ] }, { "cell_type": "code", "execution_count": null, "id": "37be6954", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:21.504133Z", "iopub.status.busy": "2026-06-06T21:06:21.504035Z", "iopub.status.idle": "2026-06-06T21:06:25.647509Z", "shell.execute_reply": "2026-06-06T21:06:25.647236Z" } }, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "from matplotlib.colors import LogNorm\n", "\n", "import wcc_etc\n", "\n", "wcc_etc.set_wcc_style()\n", "\n", "np.random.seed(0)\n", "\n", "scene = wcc_etc.get_scene(\n", " name=\"G5V\",\n", " mag=15,\n", " background=\"zodi\",\n", " bandpass=\"johnson_r\",\n", " background_prop={\"bandpass\": \"johnson_r\", \"mag\": 22.5},\n", ")\n", "\n", "sim = wcc_etc.Simulation.from_sensor_and_scene(\"sony:r\", scene)\n", "print(\"sensor :\", \"sony:r\")\n", "print(\"plate scale :\", round(sim.meta[\"sensor\"][\"pixel_size\"], 3), \"um pixels\")\n", "print(\"default aperture:\", sim.meta[\"r_aper_mas\"], \"mas\")\n", "print(\"SNR @60s, r=15:\", round(float(sim.get_snr(60)[\"snr\"]), 2))" ] }, { "cell_type": "markdown", "id": "52bdb5c6", "metadata": {}, "source": [ "## 1. PSF gallery\n", "\n", "Render each PSF source as a normalized image (sum = 1) on the Sony grid with **no jitter**, so the diffraction limit is visible. `DefocusPSF` reads the bundled Zemax Huygens files. The diffraction-limited Airy core is compact; defocus spreads the light over many more pixels and drops the peak fraction." ] }, { "cell_type": "code", "execution_count": null, "id": "b3f616ee", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:25.648993Z", "iopub.status.busy": "2026-06-06T21:06:25.648897Z", "iopub.status.idle": "2026-06-06T21:06:26.100059Z", "shell.execute_reply": "2026-06-06T21:06:26.099719Z" } }, "outputs": [], "source": [ "# Build an ImageSimulator to get a rendering context (plate scale, npix, jitter).\n", "imsim = wcc_etc.ImageSimulator.from_sensor_and_scene(\"sony:r\", scene, npix=300)\n", "\n", "\n", "def render(psf, jitter=0.0, npix=128):\n", " ctx = imsim._context(jitter_sigma_mas=jitter)\n", " ctx.npix = npix\n", " return psf.render(ctx)\n", "\n", "\n", "psfs = {\n", " \"Airy (diffraction limited)\": wcc_etc.AiryPSF(),\n", " \"+1 wave defocus\": wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_1WAVE_PATH),\n", " \"+2 wave defocus\": wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_2WAVE_PATH),\n", "}\n", "\n", "fig, axes = plt.subplots(1, 3, figsize=(13, 4.2))\n", "for ax, (name, psf) in zip(axes, psfs.items()):\n", " img = render(psf, jitter=0.0, npix=128)\n", " im = ax.imshow(\n", " img,\n", " origin=\"lower\",\n", " norm=LogNorm(vmin=img.max() * 1e-4, vmax=img.max()),\n", " cmap=\"viridis\",\n", " )\n", " ax.set_title(f\"{name}\\npeak fraction = {img.max():.3f}\")\n", " ax.grid(False) # 2D image: no gridlines\n", " ax.set_xlabel(\"pixel\")\n", " ax.set_ylabel(\"pixel\")\n", " fig.colorbar(im, ax=ax, fraction=0.046)\n", "fig.suptitle(\n", " \"Normalized PSFs on the Sony detector grid (log stretch, no jitter)\", y=1.03\n", ")\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "27b92261", "metadata": {}, "source": [ "## 2. Full image simulation\n", "\n", "`ImageSimulator.simulate(time, psf=...)` distributes the source electrons over the PSF, adds the per-pixel sky background and dark current, then Poisson + Gaussian read noise. It returns a `SimulatedImage` with `image_clean` (noise-free), `image_e` (noisy electrons), a `saturation_mask`, and converters `to_adu()` / `to_fitsimg()`. Here: a G5V `r=15` star, 30 s, in-focus Airy PSF." ] }, { "cell_type": "code", "execution_count": null, "id": "ac77d5bc", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:26.101565Z", "iopub.status.busy": "2026-06-06T21:06:26.101462Z", "iopub.status.idle": "2026-06-06T21:06:27.135137Z", "shell.execute_reply": "2026-06-06T21:06:27.134848Z" } }, "outputs": [], "source": [ "res = imsim.simulate(time=30, psf=wcc_etc.AiryPSF(), add_noise=True, seed=0)\n", "\n", "# SimulatedImage.plot_image_row gives the standard 3-panel view:\n", "# PSF + noise, PSF (no noise), and the saturation mask, on a shared color scale.\n", "fig, axes = res.plot_image_row(stretch=\"log\", units=\"mas\")\n", "fig.suptitle(\"G5V r=15, 30 s, Sony, Airy PSF\", y=1.02)\n", "fig.tight_layout()\n", "plt.show()\n", "\n", "print(f\"clean total electrons : {res.image_clean.sum():.3e}\")\n", "print(f\"peak pixel (e-) : {res.image_e.max():.1f}\")\n", "print(f\"saturated pixels : {res.saturation_mask.sum()}\")" ] }, { "cell_type": "markdown", "id": "220ddb94", "metadata": {}, "source": [ "### Radial profile and encircled energy\n", "\n", "The same `SimulatedImage` exposes convenience plots for the azimuthally-averaged radial profile and the encircled-energy (EE) curve. Both take `units='mas'` or `'pix'`; `plot_radial` marks the HWHM and `plot_encircled_energy` can mark a target EE fraction (here 80%)." ] }, { "cell_type": "code", "execution_count": null, "id": "5f8a248b", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:27.137489Z", "iopub.status.busy": "2026-06-06T21:06:27.137388Z", "iopub.status.idle": "2026-06-06T21:06:27.304747Z", "shell.execute_reply": "2026-06-06T21:06:27.304524Z" } }, "outputs": [], "source": [ "fig, (axL, axR) = plt.subplots(1, 2, figsize=(11, 4))\n", "res.plot_radial(ax=axL, units=\"mas\", title=\"Radial Profile\")\n", "res.plot_encircled_energy(ax=axR, units=\"mas\", ee_target=0.8, title=\"Encircled Energy\")\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "c77aa843", "metadata": {}, "source": [ "### Comparing PSFs across focus and filter\n", "\n", "Azimuthally-averaged **normalized** PSF (sum = 1) and encircled energy for three configurations: in-focus (Sony r), +1 wave defocus (Sony r), and +2 wave defocus (Sony bb). Defocus spreads the light into a broad doughnut, so the radial peak drops (note the log scale) and the encircled energy needs a much larger radius to reach 80%." ] }, { "cell_type": "code", "execution_count": null, "id": "19ae61a5", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:27.306111Z", "iopub.status.busy": "2026-06-06T21:06:27.306018Z", "iopub.status.idle": "2026-06-06T21:06:28.009061Z", "shell.execute_reply": "2026-06-06T21:06:28.008771Z" } }, "outputs": [], "source": [ "configs = [\n", " (\"In-focus, Sony r\", \"sony:r\", wcc_etc.AiryPSF()),\n", " (\n", " \"+1 wave defocus, Sony r\",\n", " \"sony:r\",\n", " wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_1WAVE_PATH),\n", " ),\n", " (\n", " \"+2 wave defocus, Sony bb\",\n", " \"sony:bb\",\n", " wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_2WAVE_PATH),\n", " ),\n", "]\n", "\n", "fig, (axR, axE) = plt.subplots(1, 2, figsize=(12, 4.5))\n", "for label, sensorlabel, psf in configs:\n", " isim = wcc_etc.ImageSimulator.from_sensor_and_scene(sensorlabel, scene, npix=300)\n", " ctx = isim._context()\n", " psf_img = psf.render(ctx) # normalized PSF (sum = 1)\n", " ps = ctx.plate_scale_mas\n", " wcc_etc.plot_radial_mpl(\n", " image_clean=psf_img, pixel_scale_mas=ps, ax=axR, units=\"mas\", show_hwhm=False\n", " )\n", " axR.lines[-1].set_label(label)\n", " wcc_etc.plot_encircled_energy_mpl(\n", " image_clean=psf_img, pixel_scale_mas=ps, ax=axE, units=\"mas\"\n", " )\n", " axE.lines[-1].set_label(label)\n", "\n", "axR.set_yscale(\"log\")\n", "axR.set_title(\"Azimuthally-Averaged PSF\")\n", "axE.set_title(\"Encircled Energy\")\n", "axR.legend(fontsize=9)\n", "axE.legend(fontsize=9, loc=\"lower right\")\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "96159c68", "metadata": {}, "source": [ "### Bokeh backend\n", "\n", "Every plot has a bokeh variant via `backend='bokeh'`. In a notebook, call `output_notebook()` once and `show(...)` the returned figure. For the web portal you would instead pass `return_='components'` (script + div) or `return_='html'` to embed it." ] }, { "cell_type": "code", "execution_count": null, "id": "c477602f", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:28.010269Z", "iopub.status.busy": "2026-06-06T21:06:28.010202Z", "iopub.status.idle": "2026-06-06T21:06:28.468541Z", "shell.execute_reply": "2026-06-06T21:06:28.468157Z" } }, "outputs": [], "source": [ "from bokeh.io import output_notebook, show\n", "\n", "output_notebook()\n", "show(res.plot_image(backend=\"bokeh\", units=\"mas\", title=\"Airy PSF (Bokeh)\"))" ] }, { "cell_type": "markdown", "id": "382a8595", "metadata": {}, "source": [ "The defocused PSFs render the same way. Here the **+1** and **+2 wave defocus** images are shown side by side as a bokeh row (`return_='obj'` figures combined with `bokeh.layouts.row`)." ] }, { "cell_type": "code", "execution_count": null, "id": "4cc89f6f", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:28.471832Z", "iopub.status.busy": "2026-06-06T21:06:28.471686Z", "iopub.status.idle": "2026-06-06T21:06:28.568759Z", "shell.execute_reply": "2026-06-06T21:06:28.568498Z" } }, "outputs": [], "source": [ "from bokeh.io import show\n", "from bokeh.layouts import row\n", "\n", "defocus_psfs = [\n", " (\"+1 wave defocus\", wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_1WAVE_PATH)),\n", " (\"+2 wave defocus\", wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_2WAVE_PATH)),\n", "]\n", "panels = []\n", "for name, psf in defocus_psfs:\n", " sim_img = imsim.simulate(time=30, psf=psf, add_noise=True, seed=0)\n", " panels.append(\n", " sim_img.plot_image(\n", " backend=\"bokeh\", units=\"mas\", title=f\"{name} (Bokeh)\", return_=\"obj\"\n", " )\n", " )\n", "show(row(*panels))" ] }, { "cell_type": "markdown", "id": "29ed03cd", "metadata": {}, "source": [ "## 3. Effect of pointing jitter\n", "\n", "Jitter is applied as a Gaussian blur of the PSF. More jitter -> broader PSF -> lower peak fraction and a more spread-out image." ] }, { "cell_type": "code", "execution_count": null, "id": "e7690481", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:28.575873Z", "iopub.status.busy": "2026-06-06T21:06:28.575776Z", "iopub.status.idle": "2026-06-06T21:06:28.959082Z", "shell.execute_reply": "2026-06-06T21:06:28.958848Z" } }, "outputs": [], "source": [ "airy = wcc_etc.AiryPSF()\n", "fig, axes = plt.subplots(1, 3, figsize=(13, 4.2))\n", "for ax, jit in zip(axes, [0.0, 20.0, 50.0]):\n", " img = render(airy, jitter=jit, npix=96)\n", " im = ax.imshow(img, origin=\"lower\", cmap=\"viridis\")\n", " ax.set_title(f\"jitter = {jit:.0f} mas\\npeak fraction = {img.max():.3f}\")\n", " ax.grid(False) # 2D image: no gridlines\n", " fig.colorbar(im, ax=ax, fraction=0.046)\n", "fig.suptitle(\"Airy PSF vs pointing jitter (Sony)\", y=1.03)\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "44b8da0b", "metadata": {}, "source": [ "## 4. PSF-aware SNR cross-check (rendered Airy vs analytic)\n", "\n", "`get_snr` now runs the 2-D image simulation (it delegates to `get_image_snr`). With the default aperture and the diffraction-limited Airy PSF it should reproduce the legacy analytic formula — now the deprecated `get_snr_airy` (the small residual is 2-D pixelation vs the 1-D Airy curve). We assert they agree to within ~3%." ] }, { "cell_type": "code", "execution_count": null, "id": "b86a7238", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:28.960328Z", "iopub.status.busy": "2026-06-06T21:06:28.960263Z", "iopub.status.idle": "2026-06-06T21:06:29.269216Z", "shell.execute_reply": "2026-06-06T21:06:29.268994Z" } }, "outputs": [], "source": [ "import warnings\n", "\n", "sim.update(source__mag=15)\n", "with warnings.catch_warnings():\n", " warnings.simplefilter(\"ignore\", DeprecationWarning)\n", " etc_snr = float(sim.get_snr_airy(60).value) # legacy analytic path\n", "img_snr = sim.get_snr(60)[\"snr\"] # 2-D default (delegates to get_image_snr)\n", "diff_pct = 100.0 * (img_snr - etc_snr) / etc_snr\n", "\n", "print(f\"get_snr_airy (analytic Airy) : {etc_snr:.3f}\")\n", "print(f\"get_snr (rendered Airy, 2-D) : {img_snr:.3f}\")\n", "print(f\"difference : {diff_pct:+.2f} %\")\n", "\n", "assert abs(diff_pct) < 3.0, f\"cross-check off by {diff_pct:.2f}%\"\n", "print(\"OK: agree within 3%\")" ] }, { "cell_type": "markdown", "id": "54d1d21e", "metadata": {}, "source": [ "## 5. Defocus SNR penalty (same aperture)\n", "\n", "At a fixed aperture, a defocused PSF spreads the same light over more noise pixels and loses less of it inside the aperture only at large radii — at the default aperture the enclosed fraction drops, so the SNR drops. Comparing the +2 wave defocus to the in-focus Airy at the same default aperture quantifies the penalty." ] }, { "cell_type": "code", "execution_count": null, "id": "15e40dc4", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:29.270458Z", "iopub.status.busy": "2026-06-06T21:06:29.270389Z", "iopub.status.idle": "2026-06-06T21:06:29.300306Z", "shell.execute_reply": "2026-06-06T21:06:29.300091Z" } }, "outputs": [], "source": [ "defocus2 = wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_2WAVE_PATH)\n", "\n", "airy_res = sim.get_image_snr(time=60, psf=wcc_etc.AiryPSF())\n", "def2_res = sim.get_image_snr(time=60, psf=defocus2)\n", "drop_pct = 100.0 * (def2_res[\"snr\"] - airy_res[\"snr\"]) / airy_res[\"snr\"]\n", "\n", "print(f\"aperture radius (mas) : {airy_res['r_aper_mas']:.1f} (same for both)\")\n", "print()\n", "for label, r in [(\"in-focus Airy\", airy_res), (\"+2 wave defocus\", def2_res)]:\n", " print(\n", " f\"{label:16s}: SNR = {r['snr']:6.2f} enclosed = {r['enclosed_fraction']:.3f} \"\n", " f\"n_pix = {r['n_pix']}\"\n", " )\n", "print()\n", "print(f\"SNR drop from defocus : {drop_pct:+.1f} %\")" ] }, { "cell_type": "markdown", "id": "1d60549c", "metadata": {}, "source": [ "## 6. Aperture optimization\n", "\n", "`optimize=True` finds the SNR-maximizing aperture radius. For the compact Airy the optimum is small; for the spread-out defocus PSF it moves outward (and recovers some, but not all, of the lost SNR). We compare the optimized result to the default-aperture result for each PSF." ] }, { "cell_type": "code", "execution_count": null, "id": "bfae3e06", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:29.301597Z", "iopub.status.busy": "2026-06-06T21:06:29.301521Z", "iopub.status.idle": "2026-06-06T21:06:29.306801Z", "shell.execute_reply": "2026-06-06T21:06:29.306568Z" } }, "outputs": [], "source": [ "for name, psf in [(\"in-focus Airy\", wcc_etc.AiryPSF()), (\"+2 wave defocus\", defocus2)]:\n", " default = sim.get_image_snr(time=60, psf=psf)\n", " opt = sim.get_image_snr(time=60, psf=psf, optimize=True)\n", " print(f\"{name}:\")\n", " print(\n", " f\" default aperture : r = {default['r_aper_mas']:6.1f} mas \"\n", " f\"SNR = {default['snr']:6.2f} n_pix = {default['n_pix']}\"\n", " )\n", " print(\n", " f\" optimized : r = {opt['r_aper_mas']:6.1f} mas \"\n", " f\"SNR = {opt['snr']:6.2f} n_pix = {opt['n_pix']} \"\n", " f\"enclosed = {opt['enclosed_fraction']:.3f}\"\n", " )\n", " gain = 100.0 * (opt[\"snr\"] - default[\"snr\"]) / default[\"snr\"]\n", " print(f\" SNR gain from optimizing aperture: {gain:+.1f} %\")\n", " print()" ] }, { "cell_type": "markdown", "id": "fe5b2f28", "metadata": {}, "source": [ "SNR as a function of aperture radius for each PSF (r=15, 60 s); the marker shows the `optimize=True` choice — small for the compact Airy, larger for the defocus PSF." ] }, { "cell_type": "code", "execution_count": null, "id": "f34ee9c1", "metadata": { "execution": { "iopub.execute_input": "2026-06-06T21:06:29.308143Z", "iopub.status.busy": "2026-06-06T21:06:29.308050Z", "iopub.status.idle": "2026-06-06T21:06:29.488593Z", "shell.execute_reply": "2026-06-06T21:06:29.488322Z" } }, "outputs": [], "source": [ "radii = np.linspace(20, 800, 50)\n", "psfs_opt = {\n", " \"in-focus Airy\": wcc_etc.AiryPSF(),\n", " \"+1 wave defocus\": wcc_etc.DefocusPSF(wcc_etc.DEFOCUS_1WAVE_PATH),\n", " \"+2 wave defocus\": defocus2,\n", "}\n", "\n", "fig, ax = plt.subplots(figsize=(7.6, 4.6))\n", "for name, psf in psfs_opt.items():\n", " snr = [sim.get_image_snr(time=60, psf=psf, r_aper_mas=r)[\"snr\"] for r in radii]\n", " (line,) = ax.plot(radii, snr, label=name)\n", " opt = sim.get_image_snr(time=60, psf=psf, optimize=True)\n", " ax.plot(opt[\"r_aper_mas\"], opt[\"snr\"], \"o\", color=line.get_color(), ms=8)\n", "ax.set_xlabel(\"aperture radius [mas]\")\n", "ax.set_ylabel(\"SNR (r=15, 60 s)\")\n", "ax.set_title(\"SNR vs aperture radius (markers = optimized aperture)\")\n", "ax.legend()\n", "ax.grid(lw=0.3, alpha=0.4)\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "27b0f97f", "metadata": {}, "source": [ "## Summary\n", "\n", "- `AiryPSF`, `DefocusPSF`, `CustomPSF` render normalized PSFs onto the detector grid (pixel-size aware, jitter aware).\n", "- `ImageSimulator.from_sensor_and_scene(...).simulate(time, psf=...)` produces a realistic noisy electron image driven by source magnitude, with a saturation mask; `to_adu()` / `to_fitsimg()` bridge to ADU and the photometry tools.\n", "- `Simulation.get_image_snr` computes an aperture SNR from a rendered PSF. For the in-focus Airy case it reproduces the analytic `get_snr` (cross-check within a few percent); for defocus it quantifies the SNR penalty and `optimize=True` finds the SNR-maximizing aperture.\n", "- `get_snr` itself (the analytic path) is unchanged." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.5" } }, "nbformat": 4, "nbformat_minor": 5 }