Source code for esis.optics._instruments._instruments

from __future__ import annotations
from typing import Any
import abc
import dataclasses
import functools
import numpy as np
import scipy.spatial
import matplotlib.axes
import matplotlib.pyplot as plt
import astropy.units as u
import named_arrays as na
import optika
import esis

__all__ = [
    "AbstractInstrument",
    "Instrument",
]


[docs] @dataclasses.dataclass(eq=False, repr=False) class AbstractInstrument( optika.mixins.Printable, optika.mixins.Rollable, optika.mixins.Yawable, optika.mixins.Pitchable, ): """An interface describing the entire optical system.""" @property @abc.abstractmethod def name(self) -> str: """The human-readable name of the instrument.""" @property @abc.abstractmethod def axis_channel(self): """The name of the logical axis corresponding to changing camera channel.""" @property @abc.abstractmethod def front_aperture(self) -> None | esis.optics.abc.AbstractFrontAperture: """A model of the front aperture plate.""" @property @abc.abstractmethod def central_obscuration(self) -> None | esis.optics.abc.AbstractCentralObscuration: """A model of the central obscuration.""" @property @abc.abstractmethod def primary_mirror(self) -> None | esis.optics.abc.AbstractPrimaryMirror: """A model of the primary mirror.""" @property @abc.abstractmethod def field_stop(self) -> None | esis.optics.abc.AbstractFieldStop: """A model of the field stop.""" @property @abc.abstractmethod def grating(self) -> None | esis.optics.abc.AbstractGrating: """A model of the diffraction grating array.""" @property @abc.abstractmethod def filter(self) -> None | esis.optics.abc.AbstractFilter: """A model of the thin-film filters.""" @property @abc.abstractmethod def camera(self) -> None | esis.optics.Camera: """A model of the camera and sensors.""" @property @abc.abstractmethod def wavelength(self) -> None | u.Quantity | na.AbstractScalar: """ A default grid of wavelengths to trace through the system. Can be either in normalized coordinates (in the range :math:`-1` to :math:`+1`) or in physical coordinates (with units of length). See Also -------- :attr:`wavelength_physical`: This value converted into in physical coordinates. """ @property @abc.abstractmethod def field(self) -> None | na.AbstractCartesian2dVectorArray: """A default grid of field positions to trace through the system.""" @property @abc.abstractmethod def pupil(self): """A default grid of pupil positions to trace through the system.""" @property @abc.abstractmethod def kwargs_plot(self): """Extra keyword arguments used to plot the optical system.""" @property def angle_grating_input(self) -> na.AbstractScalar: """ The angle between the grating normal and the direction of the incident light. This is the incidence angle :math:`theta_i` in the `diffraction grating equation <https://en.wikipedia.org/wiki/Diffraction_grating>`_. """ fs = self.field_stop.surface grating = self.grating.surface position = na.Cartesian3dVectorArray() * u.mm normal_surface = grating.sag.normal(position) normal_rulings = grating.rulings.spacing_(position, normal_surface).normalized transformation = grating.transformation.inverse @ fs.transformation wire = np.moveaxis( a=fs.aperture.wire(), source="wire", destination="wire_grating_input", ) wire = transformation(wire) return np.arctan2( wire @ normal_rulings, wire @ normal_surface, ) @property def angle_grating_output(self) -> na.AbstractScalar: """ The angle between the grating normal and the direction of the diffracted light. This is an analogue to the diffracted angle in the `diffraction grating equation <https://en.wikipedia.org/wiki/Diffraction_grating>`_. """ detector = self.camera.surface grating = self.grating.surface position = na.Cartesian3dVectorArray() * u.mm normal_surface = grating.sag.normal(position) normal_rulings = grating.rulings.spacing_(position, normal_surface).normalized transformation = grating.transformation.inverse @ detector.transformation wire = np.moveaxis( a=detector.aperture.wire(), source="wire", destination="wire_grating_output", ) wire = transformation(wire) return np.arctan2( wire @ normal_rulings, wire @ normal_surface, ) @property def _wavelength_test_grid(self) -> na.AbstractScalar: position = na.Cartesian3dVectorArray() * u.mm grating = self.grating.surface normal = grating.sag.normal(position) m = grating.rulings.diffraction_order d = grating.rulings.spacing_(position, normal).length a = self.angle_grating_input b = self.angle_grating_output result = np.abs((np.sin(a) + np.sin(b)) * d / m) return result.to(u.AA) @property def wavelength_min(self) -> u.Quantity | na.AbstractScalar: """The minimum wavelength permitted through the system.""" return self._wavelength_test_grid.min( axis=("wire_grating_input", "wire_grating_output"), ) @property def wavelength_max(self) -> u.Quantity | na.AbstractScalar: """The maximum wavelength permitted through the system.""" return self._wavelength_test_grid.max( axis=("wire_grating_input", "wire_grating_output"), ) @property def wavelength_physical(self) -> na.ScalarArray: """The value of :attr:`wavelength` converted to physical units if needed.""" wavelength = self.wavelength if na.unit_normalized(wavelength).is_equivalent(u.dimensionless_unscaled): wavelength_min = self.wavelength_min wavelength_max = self.wavelength_max wavelength_range = wavelength_max - wavelength_min wavelength = wavelength_range * (wavelength + 1) / 2 + wavelength_min return wavelength @functools.cached_property def system(self) -> optika.systems.SequentialSystem: """ Convert this model into an instance of :class:`optika.systems.SequentialSystem`. This is a cached property that is only computed once. """ surfaces = [] surfaces += [self.front_aperture.surface] surfaces += [self.central_obscuration.surface] surfaces += [self.primary_mirror.surface] surfaces += [self.field_stop.surface] surfaces += [self.grating.surface] surfaces += [self.filter.surface] result = optika.systems.SequentialSystem( surfaces=surfaces, sensor=self.camera.surface, grid_input=optika.vectors.ObjectVectorArray( wavelength=self.wavelength_physical, field=self.field, pupil=self.pupil, ), transformation=self.transformation, kwargs_plot=self.kwargs_plot, ) return result
[docs] def schematic_primary( self, ax: None | matplotlib.axes.Axes = None, transformation: None | na.transformations.AbstractTransformation = None, footprint: bool = True, color: str = "black", kwargs_footprint: None | dict[str, Any] = None, **kwargs, ) -> None: """ Plot a schematic of the primary mirror along with the beam footprint. Parameters ---------- ax The :mod:`matplotlib` axes on which to plot this schematic. If :obj:`None` (the default), the schematic is plotted on the current axes. transformation An additional transformation to apply to the coordinates before plotting the schematic. footprint Whether to plot the footprint of the rays on the mirror surface. color The color of the primary mirror. kwargs_footprint Additional kwargs for plotting the footprint of the beam. kwargs Additional kwargs for plotting the primary mirror. """ if ax is None: ax = plt.gca() if transformation is None: transformation = na.transformations.IdentityTransformation() if self.transformation is not None: transformation = transformation @ self.transformation if kwargs_footprint is None: kwargs_footprint = dict() kwargs_footprint = kwargs_footprint | dict( facecolor="none", edgecolor="tab:blue", ) shape = self.system.shape primary = self.primary_mirror.surface components = ("x", "y") primary.plot( ax=ax, transformation=transformation, components=components, color=color, **kwargs, ) if footprint: index_primary = self.system.surfaces_all.index(primary) index_primary = {self.system.axis_surface: index_primary} rays = self.system.raytrace().outputs where = rays.unvignetted[{self.system.axis_surface: ~0}] rays = rays[index_primary] rays = transformation(rays) rays = primary.transformation.inverse(rays) for i in na.ndindex(shape): position_i = rays.position[i] where_i = where[i] position_x = na.nominal(position_i.x[where_i]).ndarray position_y = na.nominal(position_i.y[where_i]).ndarray position = np.stack( arrays=[ position_x, position_y, ], axis=~0, ) hull = scipy.spatial.ConvexHull(position) px = position_x[hull.vertices] py = position_y[hull.vertices] ax.fill(px, py, **kwargs_footprint) sx = px.mean() sy = py.mean() ax.text( x=sx, y=sy, s=f"Ch. {self.camera.channel[i].ndarray}", ha="center", va="center", color=kwargs_footprint["edgecolor"], )
[docs] @dataclasses.dataclass(eq=False, repr=False) class Instrument( AbstractInstrument, ): """ An object which represents the entire optical system. A composition of the optical elements and a grid of input rays. Designed to resolve the optical elements into an instance of :class:`optika.systems.SequentialSystem` for performance modeling. """ name: str = "ESIS" """The human-readable name of the instrument.""" axis_channel: str = "channel" """The name of the logical axis corresponding to changing camera channel.""" front_aperture: None | esis.optics.FrontAperture = None """A model of the front aperture plate.""" central_obscuration: None | esis.optics.CentralObscuration = None """A model of the central obscuration.""" primary_mirror: None | esis.optics.PrimaryMirror = None """A model of the primary mirror.""" field_stop: None | esis.optics.FieldStop = None """A model of the field stop.""" grating: None | esis.optics.Grating = None """A model of the diffraction grating array.""" filter: None | esis.optics.Filter = None """A model of the thin-film filters.""" camera: None | esis.optics.Camera = None """A model of the camera and sensors.""" wavelength: None | u.Quantity | na.AbstractScalar = None """A default grid of wavelengths to trace through the system.""" field: None | na.AbstractCartesian2dVectorArray = None """A default grid of field positions to trace through the system.""" pupil: None | na.AbstractCartesian2dVectorArray = None """A default grid of pupil positions to trace through the system.""" pitch: u.Quantity | na.AbstractScalar = 0 * u.deg """The pitch angle of the instrument.""" yaw: u.Quantity | na.AbstractScalar = 0 * u.deg """The yaw angle of the instrument.""" roll: u.Quantity | na.AbstractScalar = 0 * u.deg """The roll angle of the instrument.""" kwargs_plot: None | dict = None """Extra keyword arguments used to plot the optical system."""