Creating your own optical elements ================================== We will present the internal workings of optical elements, and show how you can create your own optical elements in HCIPy. Standard optical elements ------------------------- We’ll start by importing all the necessary packages. .. code:: ipython3 from hcipy import * import numpy as np import matplotlib.pyplot as plt import traceback An ``OpticalElement`` is an object that can propagate a ``Wavefront`` from one plane to another. This includes apodizers, deformable mirrors, focal-plane masks, coronagraphs or even complete optical systems. An optical element should in principle be able to handle any type of ``Wavefront`` passed to its ``forward()`` and ``backward()`` functions. This puts a large responsibility on the implementation of the optical element. Any proper implementation of an optical element should therefore implement a number of functions to help signal its intent. Here, we are going to implement a very simple ``OpticalElement``: a neutral-density filter. We’ll show the full implementation first, and then go in to the details for each implemented function. .. code:: ipython3 class NeutralDensityFilter(OpticalElement): def __init__(self, transmittance): self.transmittance = transmittance def forward(self, wavefront): wf = wavefront.copy() wf.electric_field *= np.sqrt(self.transmittance) return wf def backward(self, wavefront): wf = Wavefront.copy() wf.electric_field *= np.sqrt(self.transmittance) return wf A neutral-density filter is a filter that reduces the intensity of the transmitted light independent of wavelength or spatial position. Therefore, the above implementation can handle any incoming wavefront. The initializer of the class just stores the passed transmittance. In the initializer, usually we prefer to do as much computation as possible, to aliviate the burden on the ``forward()`` and ``backward()`` functions. The ``forward()`` function propagates the wavefront through the filter. This function first creates a copy of the wavefront to work on, then modifies its electric field and returns it. Any implementation of ``forward()`` should not attempt to operate on a wavefront in-place and should always return a new ``Wavefront`` object. The ``backward()`` function might need some extra explanation. It does not implement the propagation in the opposite direction through the optical element, or even the inverse or pseudoinverse of the forward propagation, but rather the adjoint. In absense of non-linear effects, any optical element can be thought of as a linear operator on a complex Hilbert space. This Hilbert space is the space of all possible wavefronts, and an optical element acts on a wavefront to produce another wavefront in the same space. This means that we can also construct the Hermitan adjoint of this operator. The ``backward()`` function implements this Hermitian adjoint propagation. In some cases, ie. when energy is conserved in the optical element, the ``backward()`` function is the inverse of the ``forward()`` function, but *in general this is not the case*. Agnostic optical elements ------------------------- Motivation ~~~~~~~~~~ Sometimes we want more complicated behaviour. For instance, we might want the transmission of the optic to change as function of wavelength, or spatial position in the plane. As a case study, we take here an ``ColourFilter`` class, which implements the transmission through a colour filter. A naive implementation would be something along the lines of the following. .. code:: ipython3 class NaiveColourFilter(OpticalElement): def __init__(self, filter_transmission): self.filter_transmission = filter_transmission def forward(self, wavefront): wf = wavefront.copy() transmission = self.filter_transmission(wavefront.wavelength) wf.electric_field *= np.sqrt(transmission) return wf def backward(self, wavefront): wf = wavefront.copy() transmission = self.filter_transmission(wavefront.wavelength) wf.electric_field *= np.sqrt(transmission) return wf We would initialize this class with a transmission which is a function of wavelength. .. code:: ipython3 central_wavelength = 700e-9 # m spectral_bandwidth = 100e-9 # m def block_filter(wavelength): lower = central_wavelength - spectral_bandwidth / 2 upper = central_wavelength + spectral_bandwidth / 2 return (lower < wavelength) and (wavelength < upper) colour_filter = NaiveColourFilter(block_filter) pupil_grid = make_pupil_grid(128) wf = Wavefront(pupil_grid.ones()) post_filter = colour_filter(wf) However, this implementation doesn’t support a float as the filter transmission, as a float cannot be called with a wavelength. .. code:: ipython3 try: colour_filter = NaiveColourFilter(0.7) post_ilter = colour_filter(wf) except Exception as e: traceback.print_exc() .. parsed-literal:: Traceback (most recent call last): File "", line 3, in post_ilter = colour_filter(wf) File "c:\users\emiel por\github\hcipy\hcipy\optics\optical_element.py", line 28, in __call__ return self.forward(wavefront) File "", line 8, in forward transmission = self.filter_transmission(wavefront.wavelength) TypeError: 'float' object is not callable This narrow behaviour is fine for quick development, that is, in cases where we expect ourselves to be the only ones using the optical element and/or there are only a few use cases when we would want to use it. For more general optical elements, however, we would prefer the objects to be able to handle a wide variety of input arguments. Take for example the ``LinearRetarder`` optical element. The apodization pass as the argument to its initializer/constructor can be 1. a scalar, 2. a ``Field``, 3. a ``Field`` generator, 4. a function of wavelength, returning either a ``Field`` or scalar, 5. a function of both a ``Grid`` and a wavelength, returning a ``Field``. If they apodization will be evaluated at the ``Grid`` and wavelength of the incoming ``Wavefront``. All of this is done seamlessly and is hidden from view for the user. Another example is that of the ``LinearRetarder``, which should be able to handle (at least) a phase retardance which is either constant with wavelength (ie. a scalar) or a function of wavelength. Manually distinguising between the above cases can become quite cumbersome, so HCIPy offers a simpler solution. Implementation details ~~~~~~~~~~~~~~~~~~~~~~ Let’s look at the implementation of ``Apodizer`` as an example: .. code:: ipython3 class Apodizer(AgnosticOpticalElement): def __init__(self, apodization): self.apodization = apodization AgnosticOpticalElement.__init__(self, grid_dependent=True, wavelength_dependent=True, max_in_cache=11) def make_instance(self, instance_data, input_grid, output_grid, wavelength): instance_data.apodization = self.evaluate_parameter(self.apodization, input_grid, output_grid, wavelength) @property def apodization(self): return self._apodization @apodization.setter def apodization(self, apodization): self._apodization = apodization self.clear_cache() def get_input_grid(self, output_grid, wavelength): return output_grid def get_output_grid(self, input_grid, wavelength): return input_grid @make_agnostic_forward def forward(self, instance_data, wavefront): wf = wavefront.copy() wf.electric_field *= instance_data.apodization return wf @make_agnostic_backward def backward(self, instance_data, wavefront): wf = wavefront.copy() wf.electric_field *= np.conj(instance_data.apodization) return wf The ``Apodizer`` class is derived from ``AgnosticOpticalElement`` rather than ``OpticalElement``. The ``AgnosticOpticalElement`` class provides the ability of an optical element to more easily work correctly for a wide range of inputs. It allows the optical element to be written mostly expecting a single input ``Grid`` and wavelength. The data required for each set of grid and wavelength is cached, so expensive calculations are not redone each time a ``Wavefront`` is propagated through. Setup code for an ``AgnosticOpticalElement`` is done in two different functions. The first is the initializer ``__init__()``. Anything done in this function should not depend on wavelength or input grid. The above implmenetation just stores the value of ``apodization`` whatever that may be, and calls the ``__init__()`` of the parent class. This initializer has a few parameters, indicating if the ``AgnosticOpticalELlement`` class should create new instances when a ``Wavefront`` differs in ``Grid`` or in wavelength. For example, if our optical element is achromatic, but can still depend on the input grid, we would set ``wavelength_dependent`` to ``False``. The last argument is the maximum number of instances to keep in the internal cache. If the class stores a lot of data per instance, you might run out of memory if too many instances are kept in memory. This value can be reduced if necessary (or increased if the set up time per instance is high, but memory usage is small). The second initializer is ``make_instance()``. This function should calculate any data that depends on input grid, output grid and/or wavelength. Any calculated data should be stored in ``instance_data`` which is the object that is stored in the cache. In the implmementation above, we evalute the ``apodization`` at the supplied input grid, output grid and wavelength. This is done with the ``evaluate_parameter()`` function, which does all the heavy lifting with figuring out what type of parameter it actually is (ie. is it dependent on grid, or wavelength, or both, or none), and evaluates with the appropriate signature. The ``apodization`` is implemented in the class using a ``property``. The reason for this is that we want to clear the cache when we change the ``apodization``. If we wouldn’t do this, we would still use the old apodization during propagation on a grid and wavelength that we have seen before, and were in the cache already. The next two functions let the ``AgnosticOpticaElement`` know what the corresponding input grid is, given an output grid and wavelength, and vice versa. This allows backwards propagation through an optical element, without having done a forward propagation first. (The exact reason for this is a bit harder to explain. When doing a backward propagation, it needs to look up the instance data in the cache. At that point, only the output grid is available, while the cache would have ``instance_data``\ s listed by input grid. In this case it is necessary to know the input grid corresponding to previously calculated instances.) Finally, we have the ``forward()`` and ``backward()`` functions. These are implemened with the use of the ``make_agnostic_forward()`` and ``make_agnostic_backward()`` decorators, which look up the correct ``instance_data`` corresponding to the supplied ``Wavefront`` and call the ``forward()`` or ``backward()`` functions with these as well. Then, inside the propagation functions themselves, you have access to the evaluated parameters via ``instance_data``. In the case of the ``Apodizer``, we have access to the ``instance_data.apodization``, and we propagate through the apodizer in the usual way. Adding properties based on other properties ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Suppose now that we would want to know the phase of the apodization in the apodizer. If we would have the apodization as a ``Field`` or a scalar, it would be easy: .. code:: ipython3 apodization = Field(np.random.randn(pupil_grid.size) + 1j * np.random.randn(pupil_grid.size), pupil_grid) phase = np.angle(apodization) But in case the apodization is a function, things are not that trivial. The ``AgnosticOpticalElement`` also provides a method for simplifying this operation. We will inherit from ``Apodizer`` and create a ``SpecialApodizer`` with this new method: .. code:: ipython3 class SpecialApodizer(Apodizer): def __init__(self, apodization): Apodizer.__init__(self, apodization) @property def phase(self): return self.construct_function(np.angle, self.apodization) The ``construct_function()`` method of ``AgnosticOpticalElement`` returns a function with the encompassing signature. This means that if one of the arguments of the function depends on input grid, then the returned function will also be a function of input grid. If at least one of the arguments of the function is a function of wavelength, then the returned function will also be a function of wavelength. The non of the arguments were functions, then the supplied function is evaluated with the supplied arguments. The net effect is that, for this function, the ``phase`` will behave in the same way as ``apodization`` did. Conclusion ---------- Implementing your own optical elements can be as easy as deriving from the ``OpticalElement`` class and overloading (= rewriting) the ``forward()`` and ``backward()`` functions. When arguments to your optical element depend on the grid or the wavelength of the propagated wavefront, it sometimes is easier to derive from ``AgnosticOpticalElement`` instead. This class provides several methods and a caching strategy to simplify otherwise complicated optical elements. The best way to really get an insight in the internals of HCIPy is to look in the source code. A good place to start is are the apodizers, ``Apodizer``, ``PhaseApodizer`` and ``SurfaceApodizer``. These classes provide an easy and uncomplicated starting point. If the user is familiar with polarization and Jones matrices, the ``JonesMatrixOpticalElement`` and ``PhaseRetarder`` classes are also good examples of relatively simple agnostic optical elements.