diff --git a/.github/workflows/notebooks.yml b/.github/workflows/notebooks.yml new file mode 100644 index 00000000..29f1fec2 --- /dev/null +++ b/.github/workflows/notebooks.yml @@ -0,0 +1,15 @@ +name: Notebooks + +on: + push: + branches: + - main + pull_request: + +jobs: + nbstripout: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pip install nbstripout + - run: find . -name "*.ipynb" -exec nbstripout --verify {} + diff --git a/docs/_templates/function_custom.rst b/docs/_templates/function_custom.rst index d9010b98..888395c1 100644 --- a/docs/_templates/function_custom.rst +++ b/docs/_templates/function_custom.rst @@ -2,4 +2,6 @@ .. currentmodule:: {{ module }} -.. autofunction:: {{ fullname }} \ No newline at end of file +.. autofunction:: {{ fullname }} + + .. autolink-examples:: {{ fullname }} \ No newline at end of file diff --git a/docs/tutorials/prime_focus.ipynb b/docs/tutorials/prime_focus.ipynb index d7ddf3e5..f2f97e21 100644 --- a/docs/tutorials/prime_focus.ipynb +++ b/docs/tutorials/prime_focus.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "raw", - "id": "61a8a8fd", + "id": "0", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -21,7 +21,7 @@ { "cell_type": "code", "execution_count": null, - "id": "cf080c2d", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -34,7 +34,7 @@ }, { "cell_type": "raw", - "id": "2eb334e0", + "id": "2", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -48,13 +48,8 @@ { "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" - } - }, + "id": "3", + "metadata": {}, "outputs": [], "source": [ "radius_aperture = 100 * u.mm" @@ -62,7 +57,7 @@ }, { "cell_type": "raw", - "id": "6ba582b8", + "id": "4", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -73,7 +68,7 @@ { "cell_type": "code", "execution_count": null, - "id": "0aea0035", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -82,7 +77,7 @@ }, { "cell_type": "markdown", - "id": "b43d57178da3a0a", + "id": "6", "metadata": {}, "source": [ "So the focal length of the primary is then" @@ -91,13 +86,8 @@ { "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" - } - }, + "id": "7", + "metadata": {}, "outputs": [], "source": [ "focal_length = f_number * radius_aperture\n", @@ -106,7 +96,7 @@ }, { "cell_type": "raw", - "id": "dd2be92c6360815d", + "id": "8", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -117,7 +107,7 @@ { "cell_type": "code", "execution_count": null, - "id": "initial_id", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -126,7 +116,7 @@ }, { "cell_type": "raw", - "id": "5e1e69b4", + "id": "10", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -138,7 +128,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7b7de780", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -147,7 +137,7 @@ }, { "cell_type": "raw", - "id": "89a173b1", + "id": "12", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -158,7 +148,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e0ba7c6f", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -167,7 +157,7 @@ }, { "cell_type": "raw", - "id": "bf796a46", + "id": "14", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -180,7 +170,7 @@ { "cell_type": "code", "execution_count": null, - "id": "abcac277", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -189,7 +179,7 @@ }, { "cell_type": "raw", - "id": "af968c6a", + "id": "16", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -201,7 +191,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35571848", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -217,7 +207,7 @@ }, { "cell_type": "raw", - "id": "0f592ceb", + "id": "18", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -228,7 +218,7 @@ }, { "cell_type": "raw", - "id": "63416de6", + "id": "19", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -242,7 +232,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9f1037f0", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -251,7 +241,7 @@ }, { "cell_type": "raw", - "id": "83997b47", + "id": "21", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -262,7 +252,7 @@ { "cell_type": "code", "execution_count": null, - "id": "cb45e493", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -271,7 +261,7 @@ }, { "cell_type": "raw", - "id": "d2454e8a", + "id": "23", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -282,7 +272,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c88e3a2d", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -291,7 +281,7 @@ }, { "cell_type": "raw", - "id": "8e1d61c7", + "id": "25", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -303,7 +293,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26a421bf", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -312,7 +302,7 @@ }, { "cell_type": "raw", - "id": "ffc2686b", + "id": "27", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -323,7 +313,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7dc66809", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -339,7 +329,7 @@ }, { "cell_type": "raw", - "id": "7f3fa93e", + "id": "29", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -358,7 +348,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8b028be7", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -373,7 +363,7 @@ }, { "cell_type": "raw", - "id": "04d4b71c", + "id": "31", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -384,7 +374,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4900bd5f", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -399,7 +389,7 @@ }, { "cell_type": "raw", - "id": "3664820e", + "id": "33", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -410,7 +400,7 @@ { "cell_type": "code", "execution_count": null, - "id": "553741ef", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -419,7 +409,7 @@ }, { "cell_type": "raw", - "id": "d41cf5e7", + "id": "35", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -431,7 +421,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2823c560", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -444,7 +434,7 @@ }, { "cell_type": "raw", - "id": "c98aedeb", + "id": "37", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -459,7 +449,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2e9d5a7d", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -474,7 +464,7 @@ }, { "cell_type": "raw", - "id": "4c18b2b8", + "id": "39", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -485,10 +475,8 @@ { "cell_type": "code", "execution_count": null, - "id": "ee191ea4", - "metadata": { - "scrolled": false - }, + "id": "40", + "metadata": {}, "outputs": [], "source": [ "# plot the system\n", @@ -508,7 +496,7 @@ }, { "cell_type": "raw", - "id": "d8f00304", + "id": "41", "metadata": { "raw_mimetype": "text/restructuredtext" }, @@ -516,116 +504,19 @@ "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" + "We can plot a spot diagram for each field angle using the\n", + ":meth:`~optika.systems.SequentialSystem.spot_diagram` method of\n", + ":class:`~optika.systems.SequentialSystem`." ] }, { "cell_type": "code", "execution_count": null, - "id": "9b173d56", + "id": "42", "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", - " )" + "fig, ax = system.spot_diagram()" ] } ], @@ -646,7 +537,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/optika/_tests/test_mixins.py b/optika/_tests/test_mixins.py index 1c0dfd12..b7fcf344 100644 --- a/optika/_tests/test_mixins.py +++ b/optika/_tests/test_mixins.py @@ -8,7 +8,6 @@ import named_arrays as na import optika - transformation_parameterization = [ None, na.transformations.Cartesian3dTranslation(x=5 * u.mm), diff --git a/optika/_tests/test_surfaces.py b/optika/_tests/test_surfaces.py index 076c353f..6945e1ea 100644 --- a/optika/_tests/test_surfaces.py +++ b/optika/_tests/test_surfaces.py @@ -6,7 +6,6 @@ from . import test_mixins from . import test_propagators - surfaces = [ optika.surfaces.Surface(), optika.surfaces.Surface( diff --git a/optika/_tests/test_systems.py b/optika/_tests/test_systems.py index 14093dc5..1b75052c 100644 --- a/optika/_tests/test_systems.py +++ b/optika/_tests/test_systems.py @@ -1,3 +1,4 @@ +import matplotlib.pyplot as plt import pytest import numpy as np import astropy.units as u @@ -228,6 +229,14 @@ def test_rayfunction_default(self, a: optika.systems.AbstractSequentialSystem): assert isinstance(rayfunction.outputs, optika.rays.RayVectorArray) assert a.axis_surface not in rayfunction.shape + def test_spot_diagram(self, a: optika.systems.AbstractSequentialSystem): + fig, axs = a.spot_diagram() + assert isinstance(fig, plt.Figure) + + for ax in axs.ndarray.flat: + assert isinstance(ax, plt.Axes) + assert ax.has_data() + _objects = [ None, diff --git a/optika/apertures/_apertures_test.py b/optika/apertures/_apertures_test.py index 45b329f2..d410993b 100644 --- a/optika/apertures/_apertures_test.py +++ b/optika/apertures/_apertures_test.py @@ -7,7 +7,6 @@ import optika.rays._tests.test_ray_vectors from .._tests import test_mixins - active_parameterization = [ True, False, diff --git a/optika/chemicals/_tests/test_chemicals.py b/optika/chemicals/_tests/test_chemicals.py index 80cf50e3..f20c2d6a 100644 --- a/optika/chemicals/_tests/test_chemicals.py +++ b/optika/chemicals/_tests/test_chemicals.py @@ -7,7 +7,6 @@ import optika from optika._tests import test_mixins - _wavelength = [ 500 * u.nm, na.geomspace(10, 10000, axis="wavelength", num=101) * u.AA, diff --git a/optika/materials/matrices.py b/optika/materials/matrices.py index 090e96c7..c7b65b4d 100644 --- a/optika/materials/matrices.py +++ b/optika/materials/matrices.py @@ -10,7 +10,6 @@ import optika from . import snells_law - __all__ = [ "refraction", "propagation", diff --git a/optika/rulings/_rulings.py b/optika/rulings/_rulings.py index 52bbed0c..bfc1df99 100644 --- a/optika/rulings/_rulings.py +++ b/optika/rulings/_rulings.py @@ -602,13 +602,13 @@ def efficiency( result = np.where( i % 2 == 0, - x=0, - y=np.square(2 * np.sin(np.pi * gamma / 2 * u.rad) / (i * np.pi)), + 0, + np.square(2 * np.sin(np.pi * gamma / 2 * u.rad) / (i * np.pi)), ) result = np.where( i == 0, - x=np.square(np.cos(np.pi * gamma / 2 * u.rad)), - y=result, + np.square(np.cos(np.pi * gamma / 2 * u.rad)), + result, ) return result @@ -904,8 +904,8 @@ def efficiency( result = np.where( i % 2 == 0, - x=np.square(a * np.sin(np.square(np.pi) * gamma / 4 * u.rad)), - y=np.square(a * np.cos(np.square(np.pi) * gamma / 4 * u.rad)), + np.square(a * np.sin(np.square(np.pi) * gamma / 4 * u.rad)), + np.square(a * np.cos(np.square(np.pi) * gamma / 4 * u.rad)), ) return result @@ -1066,8 +1066,8 @@ def efficiency( result = np.where( i == 0, - x=1 - ((2 * a / np.pi) - np.square(a / np.pi)) * b, - y=(2 / np.square(i * np.pi)) * (1 - np.cos(i * a * u.rad)) * b, + 1 - ((2 * a / np.pi) - np.square(a / np.pi)) * b, + (2 / np.square(i * np.pi)) * (1 - np.cos(i * a * u.rad)) * b, ) return result diff --git a/optika/sensors/materials/_materials.py b/optika/sensors/materials/_materials.py index d5ca52bb..3e1d13ae 100644 --- a/optika/sensors/materials/_materials.py +++ b/optika/sensors/materials/_materials.py @@ -601,9 +601,9 @@ def _discrete_gamma( ) x = np.where( - condition=vmr != 0, - x=x, - y=mean, + vmr != 0, + x, + mean, ) unit_x = x.unit diff --git a/optika/systems.py b/optika/systems.py index a5511152..06bf79af 100644 --- a/optika/systems.py +++ b/optika/systems.py @@ -8,9 +8,12 @@ import dataclasses import functools import astropy.units as u +import astropy.visualization import numpy as np import numpy.typing as npt import matplotlib.axes +import matplotlib.cm +import matplotlib.pyplot as plt import named_arrays as na import optika @@ -100,7 +103,7 @@ def object_is_at_infinity(self) -> bool: @abc.abstractmethod def surfaces(self) -> Sequence[optika.surfaces.AbstractSurface]: """ - a sequence of surfaces representing this optical system. + A sequence of surfaces representing this optical system. At least one of these surfaces needs to be marked as the pupil surface, and if the object surface is not marked as the field stop, one of these @@ -511,42 +514,22 @@ def _denormalize_grid( object_is_at_infinity = self.object_is_at_infinity if object_is_at_infinity: - field = rayfunction_stops.outputs.direction.xy + field = optika.angles(rayfunction_stops.outputs.direction) pupil = rayfunction_stops.outputs.position.xy else: field = rayfunction_stops.outputs.position.xy - pupil = rayfunction_stops.outputs.direction.xy + pupil = optika.angles(rayfunction_stops.outputs.direction) if normalized_field: min_field = field.min(axis=(axis_field, axis_pupil)) ptp_field = field.ptp(axis=(axis_field, axis_pupil)) result.field = ptp_field * (result.field + 1) / 2 + min_field - if object_is_at_infinity: - direction = na.Cartesian3dVectorArray( - x=result.field.x, - y=result.field.y, - z=np.sqrt( - 1 - np.square(result.field.x) - np.square(result.field.y) - ), - ) - result.field = optika.angles(direction) - if normalized_pupil: min_pupil = pupil.min(axis=(axis_field, axis_pupil)) ptp_pupil = pupil.ptp(axis=(axis_field, axis_pupil)) result.pupil = ptp_pupil * (result.pupil + 1) / 2 + min_pupil - if not object_is_at_infinity: - direction = na.Cartesian3dVectorArray( - x=result.pupil.x, - y=result.pupil.y, - z=np.sqrt( - 1 - np.square(result.pupil.x) - np.square(result.pupil.y) - ), - ) - result.pupil = optika.angles(direction) - return result def _calc_rayfunction_input( @@ -1034,6 +1017,139 @@ def plot( return result + def spot_diagram( + self, + figsize: tuple[float, float] = (8, 6), + s: float = 5, + cmap: None | str | plt.Colormap = None, + ) -> tuple[ + plt.Figure, + plt.Axes, + ]: + """ + Create a spot diagram of the rays at each field point to inspect + the performance of this optical system. + + Parameters + ---------- + figsize + The size of the returned figure in inches. + s + The marker size in points squared. + cmap + The colormap used to map scalar data to colors. + """ + shape = self.shape + + grid = self.grid_input + + wavelength = na.as_named_array(grid.wavelength) + field = grid.field + pupil = grid.pupil + + axis_wavelength = set(wavelength.shape) - set(shape) + axis_wavelength = tuple(axis_wavelength) + if len(axis_wavelength) > 1: # pragma: nocover + raise ValueError( + f"Expected one or zero wavelength axes, got {len(axis_wavelength)}." + ) + + axis_field_x = tuple(field.x.shape) + if len(axis_field_x) != 1: # pragma: nocover + raise ValueError( + "The horizontal field array must be one-dimensional, " + f"got {self.grid_input.field.x.shape=}." + ) + axis_field_x = axis_field_x[0] + + axis_field_y = tuple(field.y.shape) + if len(axis_field_y) != 1: # pragma: nocover + raise ValueError( + "The vertical field array must be one-dimensional, " + f"got {self.grid_input.field.y.shape=}." + ) + axis_field_y = axis_field_y[0] + + axis_field = (axis_field_x, axis_field_y) + + axis_pupil = set(pupil.shape) - set(shape) + axis_pupil = axis_pupil - set(axis_wavelength) - set(axis_field) + + rays = self.rayfunction_default.outputs + position = rays.position.to(u.um) + position_relative = position - position.mean(axis_pupil) + + with astropy.visualization.quantity_support(): + fig, ax = na.plt.subplots( + axis_rows=axis_field_y, + axis_cols=axis_field_x, + nrows=field.y.shape[axis_field_y], + ncols=field.x.shape[axis_field_x], + sharex=True, + sharey=True, + figsize=figsize, + constrained_layout=True, + origin="lower", + ) + + colorizer = plt.Colorizer( + cmap=cmap, + norm=plt.Normalize( + vmin=wavelength.ndarray.value.min(), + vmax=wavelength.ndarray.value.max(), + ), + ) + + na.plt.scatter( + position_relative.x, + position_relative.y, + ax=ax, + s=s, + where=rays.unvignetted, + c=self.grid_input.wavelength.value, + colorizer=colorizer, + ) + + ax_lower = ax[{axis_field_y: +0}] + ax_upper = ax[{axis_field_y: ~0}] + ax_left = ax[{axis_field_x: +0}] + ax_right = ax[{axis_field_x: ~0}] + + na.plt.set_xlabel(f"$x$ ({position.x.unit:latex_inline})", ax=ax_lower) + na.plt.set_ylabel(f"$y$ ({position.y.unit:latex_inline})", ax=ax_left) + + angle = self.rayfunction_default.inputs.field + + angle_x = angle.x + angle_y = angle.y + + na.plt.text( + x=0.5, + y=1, + s=angle_x.to_string_array(), + ax=ax_upper, + transform=na.plt.transAxes(ax_upper), + ha="center", + va="bottom", + ) + na.plt.text( + x=1.05, + y=0.5, + s=angle_y.to_string_array(), + ax=ax_right, + transform=na.plt.transAxes(ax_right), + ha="left", + va="center", + ) + + plt.colorbar( + mappable=matplotlib.cm.ScalarMappable(colorizer=colorizer), + ax=ax.ndarray, + label=f"wavelength ({wavelength.unit:latex_inline})", + ) + + return fig, ax + @dataclasses.dataclass(eq=False, repr=False) class SequentialSystem(