diff --git a/docs/conf.py b/docs/conf.py index d7f5d50..79c0534 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,7 @@ 'sphinx.ext.viewcode', 'sphinxcontrib.bibtex', 'jupyter_sphinx', + 'nbsphinx', 'sphinx_codeautolink', ] autosummary_generate = True # Turn on sphinx.ext.autosummary @@ -100,6 +101,8 @@ bibtex_default_style = 'plain' bibtex_reference_style = 'author_year' +nbsphinx_execute = 'always' + codeautolink_custom_blocks = {"jupyter-execute": None} codeautolink_warn_on_missing_inventory = True codeautolink_warn_on_failed_resolve = True diff --git a/docs/index.rst b/docs/index.rst index 7833efd..7b2b5f6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,93 @@ Introduction ============ +:mod:`optika` is a Python package for designing optical systems inspired by +`Zemax `_. +It allows the user to compute the spectral response and resolution of an +arbitrary optical system and optimize it using :mod:`scipy.optimize`. +The main design goals of :mod:`optika` are to + +* Use :mod:`astropy.units` to specify the parameters of an optical system. +* Automatically compute the field of view and entrance pupil of a given optical + system. +* Allow for :math:`n`-dimensional configurations of an optical system by allowing + its parameters to be array-like. +* Compute uncertainties in the performance of an optical system using + the Monte-Carlo method. + +To satisfy the last two design goals, :mod:`optika` uses the +purpose-built :mod:`named_arrays` package as a backend. +:mod:`named_arrays` is an implementation of a +`named tensor `_, +which allows the user to name the axes in an :math:`n`-dimensional array. +This makes specifying :math:`n`-dimensional configurations in :mod:`optika` +easier since the user doesn't have to manually insert singleton dimensions +to broadcast orthogonal configuration changes against each other. +Furthermore, :mod:`named_arrays` provides an implementation of a 3D vector, +:class:`named_arrays.Cartesian3dVectorArray`, which is convenient to use since +many of the inputs and outputs of :mod:`optika` can be represented as 3D vectors. + +Features +-------- + +* Sequential raytrace modeling of an optical system +* Stratified random sampling of input rays for faster convergence +* Image simulation of a given scene using an optical system +* Spherical, conical, and toroidal surface sag profiles +* Circular, rectangular, and polygonal apertures +* Support for mirrors and arbitrary multilayer coatings +* Diffraction grating support + + * Constant, polynomial and holographic ruling spacing + * Sinusoidal, square, rectangular, sawtooth, and triangular ruling profiles + +* CCD/CMOS sensor simulation + + * Quantum efficiency + * Charge diffusion + +Missing Features +---------------- + +* **Polarization**. Different polarization states are not propagated through the + system. +* **Physical Optics**. Only geometric optics is supported right now, but adding + a Fourier optics propagator is a longstanding goal of the project. +* **Glass Optical Constants**. :mod:`optika` has a wide array of optical + constants from sources such as :cite:t:`Palik1997,Henke1993`, + but it does not yet have a database for different types of glass like Zemax + does. + +Differences from Zemax +---------------------- + +* The position and orientation of surfaces in :mod:`optika` are specified in + global coordinates instead of coordinates relative to the last surface. + +* The field of view is automatically calculated, there is no need to set the + extent of the field. + +* Diffraction grating rulings are now a parameter of an optical surface. + There is no need to change the type of surface to allow for different ruling + designs. + + +Tutorials +========= + +Jupyter notebook examples on how to use :mod:`optika`. + +.. toctree:: + :maxdepth: 1 + + tutorials/prime_focus + + +API Reference +============= + +An in-depth description of the interfaces in this package. + .. autosummary:: :toctree: _autosummary :template: module_custom.rst diff --git a/docs/tutorials/prime_focus.ipynb b/docs/tutorials/prime_focus.ipynb new file mode 100644 index 0000000..d7ddf3e --- /dev/null +++ b/docs/tutorials/prime_focus.ipynb @@ -0,0 +1,654 @@ +{ + "cells": [ + { + "cell_type": "raw", + "id": "61a8a8fd", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Prime Focus Telescope Tutorial\n", + "==============================\n", + "\n", + "The `prime focus telescope `_\n", + "is one of the simplest telescope designs since it has only one reflection and no\n", + "obscuration surfaces (ignoring the small obscuration from the sensor).\n", + "It works by placing a sensor at the focus of a parabolic primary mirror.\n", + "This tutorial will demonstrate how to model a prime focus telescope using\n", + ":mod:`optika` and investigate its performance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf080c2d", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import astropy.units as u\n", + "import astropy.visualization\n", + "import named_arrays as na\n", + "import optika" + ] + }, + { + "cell_type": "raw", + "id": "2eb334e0", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Primary mirror\n", + "--------------\n", + "\n", + "We'll start by defining the primary mirror aperture radius" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c197b7fb02addaa", + "metadata": { + "ExecuteTime": { + "end_time": "2024-10-09T23:05:37.392486Z", + "start_time": "2024-10-09T23:05:36.986489Z" + } + }, + "outputs": [], + "source": [ + "radius_aperture = 100 * u.mm" + ] + }, + { + "cell_type": "raw", + "id": "6ba582b8", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "and the :math:`f`-number of the primary mirror" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0aea0035", + "metadata": {}, + "outputs": [], + "source": [ + "f_number = 5" + ] + }, + { + "cell_type": "markdown", + "id": "b43d57178da3a0a", + "metadata": {}, + "source": [ + "So the focal length of the primary is then" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad022a0b65576089", + "metadata": { + "ExecuteTime": { + "end_time": "2024-10-09T23:05:42.415570Z", + "start_time": "2024-10-09T23:05:42.409067Z" + } + }, + "outputs": [], + "source": [ + "focal_length = f_number * radius_aperture\n", + "focal_length" + ] + }, + { + "cell_type": "raw", + "id": "dd2be92c6360815d", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "We can specify a parabolic sag profile for our primary mirror using the :class:`optika.sags.ParabolicSag` class" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "initial_id", + "metadata": {}, + "outputs": [], + "source": [ + "sag_primary = optika.sags.ParabolicSag(-focal_length)" + ] + }, + { + "cell_type": "raw", + "id": "5e1e69b4", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "For simplicity, we will consider the primary mirror to be a perfectly-reflective mirror,\n", + "which we can reprsent with :class:`optika.materials.Mirror`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7b7de780", + "metadata": {}, + "outputs": [], + "source": [ + "material_primary = optika.materials.Mirror()" + ] + }, + { + "cell_type": "raw", + "id": "89a173b1", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "To specify a circular apeture for our primary mirror, we can use :class:`optika.apertures.CircularAperture`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0ba7c6f", + "metadata": {}, + "outputs": [], + "source": [ + "aperture_primary = optika.apertures.CircularAperture(radius_aperture)" + ] + }, + { + "cell_type": "raw", + "id": "bf796a46", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Finally, we can specify the position and orientation of the primary mirror using the\n", + ":mod:`named_arrays.transformations` module.\n", + "To translate the primary mirror one focal length away from the origin, we use :class:`named_arrays.transformations.Cartesian3dTranslation`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "abcac277", + "metadata": {}, + "outputs": [], + "source": [ + "translation_primary = na.transformations.Cartesian3dTranslation(z=focal_length)" + ] + }, + { + "cell_type": "raw", + "id": "af968c6a", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "We can combine the sag profile, aperture shape, material type, and coordinate transformation into one object to represent the primary mirror,\n", + "an instance of :class:`optika.surfaces.Surface`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35571848", + "metadata": {}, + "outputs": [], + "source": [ + "primary = optika.surfaces.Surface(\n", + " name=\"primary\",\n", + " sag=sag_primary,\n", + " material=material_primary,\n", + " aperture=aperture_primary,\n", + " transformation=translation_primary,\n", + " is_pupil_stop=True,\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "0f592ceb", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "The ``is_pupil_stop=True`` statement in the previous cell sets the primary mirror as the pupil stop,\n", + "the surface that controls the size of the entrance pupil." + ] + }, + { + "cell_type": "raw", + "id": "63416de6", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Sensor\n", + "------\n", + "\n", + "If the size of each pixel in the sensor is" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f1037f0", + "metadata": {}, + "outputs": [], + "source": [ + "width_pixel = 15 * u.um" + ] + }, + { + "cell_type": "raw", + "id": "83997b47", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "The number of pixels along each axis is" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb45e493", + "metadata": {}, + "outputs": [], + "source": [ + "num_pixel = na.Cartesian2dVectorArray(256, 256)" + ] + }, + { + "cell_type": "raw", + "id": "d2454e8a", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "The name of each axis of the pixel array is" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c88e3a2d", + "metadata": {}, + "outputs": [], + "source": [ + "axis_pixel = na.Cartesian2dVectorArray(\"detector_x\", \"detector_y\")" + ] + }, + { + "cell_type": "raw", + "id": "8e1d61c7", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "In :mod:`optika`, the surface normal should be antiparallel to the incident light.\n", + "To accomplish this the sensor needs to be rotated 180 degrees." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26a421bf", + "metadata": {}, + "outputs": [], + "source": [ + "rotation_sensor = na.transformations.Cartesian3dRotationY(180 * u.deg)" + ] + }, + { + "cell_type": "raw", + "id": "ffc2686b", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Putting it all together, we can represent the sensor using :class:`optika.sensors.ImagingSensor`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7dc66809", + "metadata": {}, + "outputs": [], + "source": [ + "sensor = optika.sensors.ImagingSensor(\n", + " name=\"sensor\",\n", + " width_pixel=width_pixel,\n", + " axis_pixel=axis_pixel,\n", + " num_pixel=num_pixel,\n", + " transformation=rotation_sensor,\n", + " is_field_stop=True,\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "7f3fa93e", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "The ``is_field_stop=True`` statement in the previous cell sets the sensor as the field stop,\n", + "the surface that controls the size of the field of view\n", + "\n", + "Input rays\n", + "----------\n", + "\n", + "The position and direction of the input rays is specified in terms\n", + "of normalized coordinates.\n", + "We can specify a uniform normlized pupil grid withSS" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b028be7", + "metadata": {}, + "outputs": [], + "source": [ + "pupil = na.Cartesian2dVectorLinearSpace(\n", + " start=-1,\n", + " stop=1,\n", + " axis=na.Cartesian2dVectorArray(\"px\", \"py\"),\n", + " num=5,\n", + " centers=True,\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "04d4b71c", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "a normalized field grid with" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4900bd5f", + "metadata": {}, + "outputs": [], + "source": [ + "field = na.Cartesian2dVectorLinearSpace(\n", + " start=-1,\n", + " stop=1,\n", + " axis=na.Cartesian2dVectorArray(\"fx\", \"fy\"),\n", + " num=5,\n", + " centers=True,\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "3664820e", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "and assume a constant wavelength" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "553741ef", + "metadata": {}, + "outputs": [], + "source": [ + "wavelength = 500 * u.nm" + ] + }, + { + "cell_type": "raw", + "id": "d41cf5e7", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + ":mod:`optika` provides a vector type for representing a point in wavelength/field/pupil space,\n", + ":class:`optika.vectors.ObjectVectorArray`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2823c560", + "metadata": {}, + "outputs": [], + "source": [ + "grid_input = optika.vectors.ObjectVectorArray(\n", + " wavelength=wavelength,\n", + " field=field,\n", + " pupil=pupil,\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "c98aedeb", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Building the system\n", + "-------------------\n", + "\n", + ":class:`optika.systems.SequentialSystem`\n", + "is a composition of the optical surfaces and the input ray grid." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e9d5a7d", + "metadata": {}, + "outputs": [], + "source": [ + "system = optika.systems.SequentialSystem(\n", + " surfaces=[\n", + " primary,\n", + " ],\n", + " sensor=sensor,\n", + " grid_input=grid_input,\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "4c18b2b8", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "We can plot the system using the :meth:`optika.systems.SequentialSystem.plot` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee191ea4", + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "# plot the system\n", + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(constrained_layout=True)\n", + " ax.set_aspect(\"equal\")\n", + " system.plot(\n", + " ax=ax,\n", + " components=(\"z\", \"y\"),\n", + " kwargs_rays=dict(\n", + " color=\"tab:blue\",\n", + " ),\n", + " color=\"black\",\n", + " zorder=10,\n", + " )" + ] + }, + { + "cell_type": "raw", + "id": "d8f00304", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "Spot Diagrams\n", + "-------------\n", + "\n", + "To plot a diagram of the different spots,\n", + "we need to load the final position of the rays from the simulation\n", + "and plot them using :mod:`matplotlib`.\n", + "\n", + "We can access the final position of the rays on the sensor using the\n", + ":attr:`optika.systems.SequentialSystem.rayfunction_default` attribute.\n", + "`rayfunction_default` is an instance of a :class:`named_arrays.FunctionArray`,\n", + "an array that is a composition two other arrays: `input` and `output`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92a20217", + "metadata": {}, + "outputs": [], + "source": [ + "position = system.rayfunction_default.outputs.position.to(u.um)" + ] + }, + { + "cell_type": "raw", + "id": "5f7ad4fc", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "It can be helpful to subtract off the centroid of each PSF to compare them" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9b173d56", + "metadata": {}, + "outputs": [], + "source": [ + "position_relative = position - position.mean(pupil.axes)" + ] + }, + { + "cell_type": "raw", + "id": "b50316e2", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "To easily plot an array of PSFs for each field position,\n", + "we can use :func:`named_arrays.plt.scatter`,\n", + "which allows broadcasting over arrays of \n", + ":class:`matplotlib.axes.Axes` instances." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1f6b2547", + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = na.plt.subplots(\n", + " axis_rows=field.axis.y,\n", + " axis_cols=field.axis.x,\n", + " nrows=field.num,\n", + " ncols=field.num,\n", + " sharex=True,\n", + " sharey=True,\n", + " figsize=(6, 6),\n", + " constrained_layout=True,\n", + " )\n", + " na.plt.scatter(\n", + " position_relative.x,\n", + " position_relative.y,\n", + " ax=ax,\n", + " s=5,\n", + " )\n", + " \n", + " ax_lower = ax[{field.axis.y: +0}]\n", + " ax_upper = ax[{field.axis.y: ~0}]\n", + " ax_left = ax[{field.axis.x: +0}]\n", + " ax_right = ax[{field.axis.x: ~0}]\n", + " \n", + " na.plt.set_aspect(\"equal\", ax=ax)\n", + " na.plt.set_xlabel(f\"$x$ ({position.x.unit:latex_inline})\", ax=ax_lower)\n", + " na.plt.set_ylabel(f\"$y$ ({position.y.unit:latex_inline})\", ax=ax_left)\n", + " \n", + " angle = system.rayfunction_default.inputs.field.to(u.arcmin)\n", + " angle_x = angle.x.mean(set(angle.axes) - {field.axis.x,})\n", + " angle_y = angle.y.mean(set(angle.axes) - {field.axis.y,})\n", + " na.plt.text(\n", + " x=0.5,\n", + " y=1,\n", + " s=angle_x.to_string_array(),\n", + " ax=ax_upper,\n", + " transform=na.plt.transAxes(ax_upper),\n", + " ha=\"center\",\n", + " va=\"bottom\",\n", + " )\n", + " na.plt.text(\n", + " x=1.05,\n", + " y=0.5,\n", + " s=angle_y.to_string_array(),\n", + " ax=ax_right,\n", + " transform=na.plt.transAxes(ax_right),\n", + " ha=\"left\",\n", + " va=\"center\",\n", + " )" + ] + } + ], + "metadata": { + "celltoolbar": "Raw Cell Format", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index b7ca2b0..aa23f95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ ] dependencies = [ "astropy", - "named-arrays==0.12.0", + "named-arrays==0.13.1", "svglib", "rlPyCairo", ] @@ -34,6 +34,7 @@ doc = [ "pydata-sphinx-theme", "ipykernel", "jupyter-sphinx", + "nbsphinx", "sphinx-codeautolink", "sphinx-favicon", ]