This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

ARTcore API

Core ray-tracing package. Contains definitions of mirrors, masks, optical chains, detectors, sources etc…

The API may be updated without warning

The names are self-descriptive.

1 - ModuleDefects

Provides classes for different deformations of optical surfaces.

Created on Fri Mar 10 2023

@author: Semptum + Stefan Haessler

  1"""
  2Provides classes for different deformations of optical surfaces.
  3
  4
  5Created on Fri Mar 10 2023
  6
  7@author: Semptum + Stefan Haessler 
  8"""
  9# %% Modules
 10
 11import numpy as np
 12#import cupy as cp
 13from abc import ABC, abstractmethod
 14import scipy
 15import math
 16from ARTcore.ModuleZernike import zernike_gradient
 17import logging
 18
 19logger = logging.getLogger(__name__)
 20
 21# %% Abstract class
 22
 23
 24class Defect(ABC):
 25    """
 26    Abstract class representing a defect on the optical surface.
 27    """
 28
 29    @abstractmethod
 30    def RMS(self):
 31        pass
 32
 33    @abstractmethod
 34    def PV(self):
 35        pass
 36
 37class MeasuredMap(Defect):
 38    """
 39    Class describing a defect map measured experimentally (for instance using a Hartmann wavefront sensor). Note that the provided image should cover the entire support. 
 40    """
 41    def __init__(self, Support, Map):
 42        self.deformation = Map
 43        self. Support = Support
 44        rect = Support._CircumRect() # TODO fix this so it just uses a rectangle with known size
 45        X = np.linspace(-rect[0], rect[0], num=self.deformation.shape[0])
 46        Y = np.linspace(-rect[1], rect[1], num=self.deformation.shape[1])
 47        self.DerivX, self.DerivY = np.gradient(self.deformation, rect/self.deformation.shape)
 48        DerivInterpX = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.DerivX), method="linear")
 49        DerivInterpY = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.DerivY), method="linear")
 50        SurfInterp = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.deformation), method="linear")
 51
 52        self.rms = np.std(self.deformation)
 53        self.DerivInterp = lambda x: np.array([DerivInterpX(x[:2]), DerivInterpY(x[:2])])
 54        self.SurfInterp = lambda x: SurfInterp(x[:2])
 55    def get_normal(self, Point):
 56        Grad = self.DerivInterp(Point)
 57        dX, dY =  Grad.flatten()
 58        norm = np.linalg.norm([dX, dY, 1])
 59        dX /= norm
 60        dY /= norm
 61        return np.array([dX, dY, np.sqrt(1 - dX**2 - dY**2)])
 62
 63    def get_offset(self, Point):
 64        return self.SurfInterp(Point)
 65
 66    def RMS(self):
 67        return self.rms
 68
 69    def PV(self):
 70        pass
 71
 72class Fourrier(Defect):
 73    """
 74    
 75    """
 76    def __init__(self, Support, RMS, slope=-2, smallest=0.1, biggest=None):
 77        # The sizes are the wavelength in mm
 78        rect = Support._CircumRect()  # TODO fix this so it just uses a rectangle with known size
 79        if biggest is None:
 80            biggest = np.max(rect)
 81            
 82        k_max = 2 / smallest
 83        k_min = 2 / biggest
 84        ResX = int(round(k_max * rect[0] / 2))+1
 85        ResY = int(round(k_max * rect[1]))
 86
 87        kXX, kYY = np.meshgrid(
 88            np.linspace(0, k_max, num=ResX, dtype='float32', endpoint=False),
 89            np.linspace(-k_max, k_max, num=ResY, dtype='float32', endpoint=False),
 90            sparse=True)
 91
 92        maskedFFT = np.ma.masked_outside(np.sqrt(kXX**2 + kYY**2), k_min, k_max)
 93
 94        FFT = maskedFFT**slope * np.exp(
 95            1j *np.random.uniform(0, 2 * np.pi, size=maskedFFT.shape).astype('float32')
 96            )
 97        FFT = FFT.data*(1-FFT.mask)
 98
 99        deformation = np.fft.irfft2(np.fft.ifftshift(FFT, axes=0))
100        RMS_factor = RMS/np.std(deformation)
101        deformation *= RMS_factor
102
103        DerivX = np.fft.irfft2(np.fft.ifftshift(FFT * 1j * kXX * RMS_factor, axes=0))*np.pi/2
104        kY = np.concatenate((kYY[kYY.shape[0]//2:],kYY[:kYY.shape[0]//2]))
105        DerivY = np.fft.irfft2(np.fft.ifftshift(FFT * 1j * RMS_factor, axes=0)*kY)*np.pi/2
106        #del FFT
107
108        X = np.linspace(-rect[0]/2, rect[0]/2, num=(ResX-1)*2) # Because iRfft
109        Y = np.linspace(-rect[1]/2, rect[1]/2, num=ResY)
110        
111        DerivInterpX = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(DerivX), method="linear")
112        DerivInterpY = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(DerivY), method="linear")
113        SurfInterp = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(deformation), method="linear")
114
115        self.DerivX = DerivX
116        self.DerivY = DerivY
117        self.rms = np.std(deformation)
118        self.deformation = deformation
119        self.DerivInterp = lambda x: np.array([DerivInterpX(x[:2]), DerivInterpY(x[:2])])
120        self.SurfInterp = lambda x: SurfInterp(x[:2])
121
122    def get_normal(self, Point):
123        """
124        Calculates and returns the surface normal at the given Point.
125        It uses the derivative interpolators to compute the partial derivatives of the surface
126        deformation and returns a normalized vector representing the surface normal.
127        """
128        dX, dY = self.DerivInterp(Point)
129        norm = np.linalg.norm([dX, dY, 1])
130        dX /= norm
131        dY /= norm
132        return np.array([dX, dY, np.sqrt(1 - dX**2 - dY**2)])
133
134    def get_offset(self, Point):
135        """
136        Calculates and returns the surface offset at the given Point.
137        It uses the surface deformation interpolator to determine the displacement
138        at the specified point on the surface.
139        """
140        return self.SurfInterp(Point)
141
142    def RMS(self):
143        """
144        Returns the root mean square (RMS) value of the surface deformation (self.rms).
145        """
146        return self.rms
147
148    def PV(self):
149        pass
150
151
152class Zernike(Defect):
153    def __init__(self, Support, coefficients):
154        self.coefficients = coefficients
155        self.max_order = np.max([k[0] for k in coefficients])
156        self.support = Support
157        self.R = Support._CircumCirc()
158
159    def get_normal(self, Point):
160        x, y, z = Point / self.R
161        val, derivX, derivY = zernike_gradient([x], [y], self.max_order)
162        dX = 0
163        dY = 0
164        for k in self.coefficients:
165            dX += self.coefficients[k] * derivX[k][0][1][0]
166            dY += self.coefficients[k] * derivY[k][0][1][0]
167        dX /= self.R
168        dY /= self.R
169        return np.array([-dX, -dY, 1])
170
171    def get_offset(self, Point):
172        x, y, z = Point / self.R
173        val, derivX, derivY = zernike_gradient([x], [y], self.max_order)
174        Z = 0
175        for k in self.coefficients:
176            Z += self.coefficients[k] * val[k][0][1][0]
177        return Z
178
179    def RMS(self):
180        return np.sqrt(np.sum([i**2 for i in self.coefficients.values()]))
181
182    def PV(self):
183        pass
logger = <Logger ARTcore.ModuleDefects (WARNING)>
class Defect(abc.ABC):
25class Defect(ABC):
26    """
27    Abstract class representing a defect on the optical surface.
28    """
29
30    @abstractmethod
31    def RMS(self):
32        pass
33
34    @abstractmethod
35    def PV(self):
36        pass

Abstract class representing a defect on the optical surface.

@abstractmethod
def RMS(self):
30    @abstractmethod
31    def RMS(self):
32        pass
@abstractmethod
def PV(self):
34    @abstractmethod
35    def PV(self):
36        pass
class MeasuredMap(Defect):
38class MeasuredMap(Defect):
39    """
40    Class describing a defect map measured experimentally (for instance using a Hartmann wavefront sensor). Note that the provided image should cover the entire support. 
41    """
42    def __init__(self, Support, Map):
43        self.deformation = Map
44        self. Support = Support
45        rect = Support._CircumRect() # TODO fix this so it just uses a rectangle with known size
46        X = np.linspace(-rect[0], rect[0], num=self.deformation.shape[0])
47        Y = np.linspace(-rect[1], rect[1], num=self.deformation.shape[1])
48        self.DerivX, self.DerivY = np.gradient(self.deformation, rect/self.deformation.shape)
49        DerivInterpX = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.DerivX), method="linear")
50        DerivInterpY = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.DerivY), method="linear")
51        SurfInterp = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.deformation), method="linear")
52
53        self.rms = np.std(self.deformation)
54        self.DerivInterp = lambda x: np.array([DerivInterpX(x[:2]), DerivInterpY(x[:2])])
55        self.SurfInterp = lambda x: SurfInterp(x[:2])
56    def get_normal(self, Point):
57        Grad = self.DerivInterp(Point)
58        dX, dY =  Grad.flatten()
59        norm = np.linalg.norm([dX, dY, 1])
60        dX /= norm
61        dY /= norm
62        return np.array([dX, dY, np.sqrt(1 - dX**2 - dY**2)])
63
64    def get_offset(self, Point):
65        return self.SurfInterp(Point)
66
67    def RMS(self):
68        return self.rms
69
70    def PV(self):
71        pass

Class describing a defect map measured experimentally (for instance using a Hartmann wavefront sensor). Note that the provided image should cover the entire support.

MeasuredMap(Support, Map)
42    def __init__(self, Support, Map):
43        self.deformation = Map
44        self. Support = Support
45        rect = Support._CircumRect() # TODO fix this so it just uses a rectangle with known size
46        X = np.linspace(-rect[0], rect[0], num=self.deformation.shape[0])
47        Y = np.linspace(-rect[1], rect[1], num=self.deformation.shape[1])
48        self.DerivX, self.DerivY = np.gradient(self.deformation, rect/self.deformation.shape)
49        DerivInterpX = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.DerivX), method="linear")
50        DerivInterpY = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.DerivY), method="linear")
51        SurfInterp = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(self.deformation), method="linear")
52
53        self.rms = np.std(self.deformation)
54        self.DerivInterp = lambda x: np.array([DerivInterpX(x[:2]), DerivInterpY(x[:2])])
55        self.SurfInterp = lambda x: SurfInterp(x[:2])
deformation
Support
rms
DerivInterp
SurfInterp
def get_normal(self, Point):
56    def get_normal(self, Point):
57        Grad = self.DerivInterp(Point)
58        dX, dY =  Grad.flatten()
59        norm = np.linalg.norm([dX, dY, 1])
60        dX /= norm
61        dY /= norm
62        return np.array([dX, dY, np.sqrt(1 - dX**2 - dY**2)])
def get_offset(self, Point):
64    def get_offset(self, Point):
65        return self.SurfInterp(Point)
def RMS(self):
67    def RMS(self):
68        return self.rms
def PV(self):
70    def PV(self):
71        pass
class Fourrier(Defect):
 73class Fourrier(Defect):
 74    """
 75    
 76    """
 77    def __init__(self, Support, RMS, slope=-2, smallest=0.1, biggest=None):
 78        # The sizes are the wavelength in mm
 79        rect = Support._CircumRect()  # TODO fix this so it just uses a rectangle with known size
 80        if biggest is None:
 81            biggest = np.max(rect)
 82            
 83        k_max = 2 / smallest
 84        k_min = 2 / biggest
 85        ResX = int(round(k_max * rect[0] / 2))+1
 86        ResY = int(round(k_max * rect[1]))
 87
 88        kXX, kYY = np.meshgrid(
 89            np.linspace(0, k_max, num=ResX, dtype='float32', endpoint=False),
 90            np.linspace(-k_max, k_max, num=ResY, dtype='float32', endpoint=False),
 91            sparse=True)
 92
 93        maskedFFT = np.ma.masked_outside(np.sqrt(kXX**2 + kYY**2), k_min, k_max)
 94
 95        FFT = maskedFFT**slope * np.exp(
 96            1j *np.random.uniform(0, 2 * np.pi, size=maskedFFT.shape).astype('float32')
 97            )
 98        FFT = FFT.data*(1-FFT.mask)
 99
100        deformation = np.fft.irfft2(np.fft.ifftshift(FFT, axes=0))
101        RMS_factor = RMS/np.std(deformation)
102        deformation *= RMS_factor
103
104        DerivX = np.fft.irfft2(np.fft.ifftshift(FFT * 1j * kXX * RMS_factor, axes=0))*np.pi/2
105        kY = np.concatenate((kYY[kYY.shape[0]//2:],kYY[:kYY.shape[0]//2]))
106        DerivY = np.fft.irfft2(np.fft.ifftshift(FFT * 1j * RMS_factor, axes=0)*kY)*np.pi/2
107        #del FFT
108
109        X = np.linspace(-rect[0]/2, rect[0]/2, num=(ResX-1)*2) # Because iRfft
110        Y = np.linspace(-rect[1]/2, rect[1]/2, num=ResY)
111        
112        DerivInterpX = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(DerivX), method="linear")
113        DerivInterpY = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(DerivY), method="linear")
114        SurfInterp = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(deformation), method="linear")
115
116        self.DerivX = DerivX
117        self.DerivY = DerivY
118        self.rms = np.std(deformation)
119        self.deformation = deformation
120        self.DerivInterp = lambda x: np.array([DerivInterpX(x[:2]), DerivInterpY(x[:2])])
121        self.SurfInterp = lambda x: SurfInterp(x[:2])
122
123    def get_normal(self, Point):
124        """
125        Calculates and returns the surface normal at the given Point.
126        It uses the derivative interpolators to compute the partial derivatives of the surface
127        deformation and returns a normalized vector representing the surface normal.
128        """
129        dX, dY = self.DerivInterp(Point)
130        norm = np.linalg.norm([dX, dY, 1])
131        dX /= norm
132        dY /= norm
133        return np.array([dX, dY, np.sqrt(1 - dX**2 - dY**2)])
134
135    def get_offset(self, Point):
136        """
137        Calculates and returns the surface offset at the given Point.
138        It uses the surface deformation interpolator to determine the displacement
139        at the specified point on the surface.
140        """
141        return self.SurfInterp(Point)
142
143    def RMS(self):
144        """
145        Returns the root mean square (RMS) value of the surface deformation (self.rms).
146        """
147        return self.rms
148
149    def PV(self):
150        pass
Fourrier(Support, RMS, slope=-2, smallest=0.1, biggest=None)
 77    def __init__(self, Support, RMS, slope=-2, smallest=0.1, biggest=None):
 78        # The sizes are the wavelength in mm
 79        rect = Support._CircumRect()  # TODO fix this so it just uses a rectangle with known size
 80        if biggest is None:
 81            biggest = np.max(rect)
 82            
 83        k_max = 2 / smallest
 84        k_min = 2 / biggest
 85        ResX = int(round(k_max * rect[0] / 2))+1
 86        ResY = int(round(k_max * rect[1]))
 87
 88        kXX, kYY = np.meshgrid(
 89            np.linspace(0, k_max, num=ResX, dtype='float32', endpoint=False),
 90            np.linspace(-k_max, k_max, num=ResY, dtype='float32', endpoint=False),
 91            sparse=True)
 92
 93        maskedFFT = np.ma.masked_outside(np.sqrt(kXX**2 + kYY**2), k_min, k_max)
 94
 95        FFT = maskedFFT**slope * np.exp(
 96            1j *np.random.uniform(0, 2 * np.pi, size=maskedFFT.shape).astype('float32')
 97            )
 98        FFT = FFT.data*(1-FFT.mask)
 99
100        deformation = np.fft.irfft2(np.fft.ifftshift(FFT, axes=0))
101        RMS_factor = RMS/np.std(deformation)
102        deformation *= RMS_factor
103
104        DerivX = np.fft.irfft2(np.fft.ifftshift(FFT * 1j * kXX * RMS_factor, axes=0))*np.pi/2
105        kY = np.concatenate((kYY[kYY.shape[0]//2:],kYY[:kYY.shape[0]//2]))
106        DerivY = np.fft.irfft2(np.fft.ifftshift(FFT * 1j * RMS_factor, axes=0)*kY)*np.pi/2
107        #del FFT
108
109        X = np.linspace(-rect[0]/2, rect[0]/2, num=(ResX-1)*2) # Because iRfft
110        Y = np.linspace(-rect[1]/2, rect[1]/2, num=ResY)
111        
112        DerivInterpX = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(DerivX), method="linear")
113        DerivInterpY = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(DerivY), method="linear")
114        SurfInterp = scipy.interpolate.RegularGridInterpolator((X, Y), np.transpose(deformation), method="linear")
115
116        self.DerivX = DerivX
117        self.DerivY = DerivY
118        self.rms = np.std(deformation)
119        self.deformation = deformation
120        self.DerivInterp = lambda x: np.array([DerivInterpX(x[:2]), DerivInterpY(x[:2])])
121        self.SurfInterp = lambda x: SurfInterp(x[:2])
DerivX
DerivY
rms
deformation
DerivInterp
SurfInterp
def get_normal(self, Point):
123    def get_normal(self, Point):
124        """
125        Calculates and returns the surface normal at the given Point.
126        It uses the derivative interpolators to compute the partial derivatives of the surface
127        deformation and returns a normalized vector representing the surface normal.
128        """
129        dX, dY = self.DerivInterp(Point)
130        norm = np.linalg.norm([dX, dY, 1])
131        dX /= norm
132        dY /= norm
133        return np.array([dX, dY, np.sqrt(1 - dX**2 - dY**2)])

Calculates and returns the surface normal at the given Point. It uses the derivative interpolators to compute the partial derivatives of the surface deformation and returns a normalized vector representing the surface normal.

def get_offset(self, Point):
135    def get_offset(self, Point):
136        """
137        Calculates and returns the surface offset at the given Point.
138        It uses the surface deformation interpolator to determine the displacement
139        at the specified point on the surface.
140        """
141        return self.SurfInterp(Point)

Calculates and returns the surface offset at the given Point. It uses the surface deformation interpolator to determine the displacement at the specified point on the surface.

def RMS(self):
143    def RMS(self):
144        """
145        Returns the root mean square (RMS) value of the surface deformation (self.rms).
146        """
147        return self.rms

Returns the root mean square (RMS) value of the surface deformation (self.rms).

def PV(self):
149    def PV(self):
150        pass
class Zernike(Defect):
153class Zernike(Defect):
154    def __init__(self, Support, coefficients):
155        self.coefficients = coefficients
156        self.max_order = np.max([k[0] for k in coefficients])
157        self.support = Support
158        self.R = Support._CircumCirc()
159
160    def get_normal(self, Point):
161        x, y, z = Point / self.R
162        val, derivX, derivY = zernike_gradient([x], [y], self.max_order)
163        dX = 0
164        dY = 0
165        for k in self.coefficients:
166            dX += self.coefficients[k] * derivX[k][0][1][0]
167            dY += self.coefficients[k] * derivY[k][0][1][0]
168        dX /= self.R
169        dY /= self.R
170        return np.array([-dX, -dY, 1])
171
172    def get_offset(self, Point):
173        x, y, z = Point / self.R
174        val, derivX, derivY = zernike_gradient([x], [y], self.max_order)
175        Z = 0
176        for k in self.coefficients:
177            Z += self.coefficients[k] * val[k][0][1][0]
178        return Z
179
180    def RMS(self):
181        return np.sqrt(np.sum([i**2 for i in self.coefficients.values()]))
182
183    def PV(self):
184        pass

Abstract class representing a defect on the optical surface.

Zernike(Support, coefficients)
154    def __init__(self, Support, coefficients):
155        self.coefficients = coefficients
156        self.max_order = np.max([k[0] for k in coefficients])
157        self.support = Support
158        self.R = Support._CircumCirc()
coefficients
max_order
support
R
def get_normal(self, Point):
160    def get_normal(self, Point):
161        x, y, z = Point / self.R
162        val, derivX, derivY = zernike_gradient([x], [y], self.max_order)
163        dX = 0
164        dY = 0
165        for k in self.coefficients:
166            dX += self.coefficients[k] * derivX[k][0][1][0]
167            dY += self.coefficients[k] * derivY[k][0][1][0]
168        dX /= self.R
169        dY /= self.R
170        return np.array([-dX, -dY, 1])
def get_offset(self, Point):
172    def get_offset(self, Point):
173        x, y, z = Point / self.R
174        val, derivX, derivY = zernike_gradient([x], [y], self.max_order)
175        Z = 0
176        for k in self.coefficients:
177            Z += self.coefficients[k] * val[k][0][1][0]
178        return Z
def RMS(self):
180    def RMS(self):
181        return np.sqrt(np.sum([i**2 for i in self.coefficients.values()]))
def PV(self):
183    def PV(self):
184        pass

2 - ModuleDetector

Provides a class which represents a virtual detector —a plane in 3D space— with methods to analyze the impact points of a ray bundle intercepting it.

Illustration of the detector plane.

Created in Sept 2020

@author: Stefan Haessler + André Kalouguine

  1"""
  2Provides a class which represents a virtual detector —a plane in 3D space— with methods
  3to analyze the impact points of a ray bundle intercepting it.
  4
  5![Illustration of the detector plane.](detector.svg)
  6
  7
  8
  9Created in Sept 2020
 10
 11@author: Stefan Haessler + André Kalouguine
 12"""
 13# %% Modules
 14
 15import numpy as np
 16import quaternion
 17from abc import ABC, abstractmethod
 18
 19import ARTcore.ModuleProcessing as mp
 20import ARTcore.ModuleOpticalRay as mray
 21import ARTcore.ModuleGeometry as mgeo
 22from ARTcore.ModuleGeometry import Point, Vector, Origin
 23
 24# This is to allow multiple constructors based on the type of the first argument
 25from functools import singledispatchmethod
 26import logging
 27from copy import copy
 28import time
 29
 30
 31logger = logging.getLogger(__name__)
 32
 33LightSpeed = 299792458000
 34
 35# %% Abstract class
 36class Detector(ABC):
 37    """
 38    Abstract class describing a detector and the properties/methods it has to have. 
 39    It implements the required components for the detector to behave as a 3D object.
 40    Non-abstract subclasses of `Detector` should implement the remaining
 41    functiuons such as the detection of rays
 42    """
 43    type = "Generic Detector (Abstract)"
 44    @property
 45    def r(self):
 46        """
 47        Return the offset of the optical element from its reference frame to the lab reference frame.
 48        Not that this is **NOT** the position of the optical element's center point. Rather it is the
 49        offset of the reference frame of the optical element from the lab reference frame.
 50        """
 51        return self._r
 52
 53    @r.setter
 54    def r(self, NewPosition):
 55        """
 56        Set the offset of the optical element from its reference frame to the lab reference frame.
 57        """
 58        if isinstance(NewPosition, mgeo.Point) and len(NewPosition) == 3:
 59            self._r = NewPosition
 60        else:
 61            raise TypeError("Position must be a 3D mgeo.Point.")
 62    @property
 63    def q(self):
 64        """
 65        Return the orientation of the optical element.
 66        The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.
 67        """
 68        return self._q
 69    
 70    @q.setter
 71    def q(self, NewOrientation):
 72        """
 73        Set the orientation of the optical element.
 74        This function normalizes the input quaternion before storing it.
 75        If the input is not a quaternion, raise a TypeError.
 76        """
 77        if isinstance(NewOrientation, np.quaternion):
 78            self._q = NewOrientation.normalized()
 79        else:
 80            raise TypeError("Orientation must be a quaternion.")
 81
 82    @property
 83    def position(self):
 84        """
 85        Return the position of the basepoint of the optical element. Often it is the same as the optical centre.
 86        This position is the one around which all rotations are performed.
 87        """
 88        return self.r0 + self.r
 89    
 90    @property
 91    def orientation(self):
 92        """
 93        Return the orientation of the optical element.
 94        The utility of this method is unclear.
 95        """
 96        return self.q
 97    
 98    @property
 99    def basis(self):
100        return self.r0, self.r, self.q
101    
102    def __init__(self):
103        self._r = mgeo.Vector([0.0, 0.0, 0.0])
104        self._q = np.quaternion(1, 0, 0, 0)
105        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
106
107    @abstractmethod
108    def centre(self):
109        """
110        (0,0) point of the detector.
111        """
112        pass
113
114    @abstractmethod
115    def refpoint(self):
116        """
117        Reference point from which to measure the distance of the detector. 
118        Usually the center of the previous optical element.
119        """
120        pass
121
122    @property
123    def distance(self):
124        """
125        Return distance of the Detector from its reference point Detector.refpoint.
126        """
127        return (self.refpoint - self.centre).norm
128
129    @distance.setter
130    def distance(self, NewDistance: float):
131        """
132        Shift the Detector to the distance NewDistance from its reference point Detector.refpoint.
133
134        Parameters
135        ----------
136            NewDistance : number
137                The distance (absolute) to which to shift the Detector.
138        """
139        vector = (self.centre - self.refpoint).normalized
140        self.centre = self.refpoint + vector * NewDistance
141
142    @abstractmethod
143    def get_2D_points(self, RayList):
144        pass
145
146    @abstractmethod
147    def get_3D_points(self, RayList):
148        pass
149
150    @abstractmethod
151    def __copy__(self):
152        pass
153
154
155# %% Infinite plane detector class
156class InfiniteDetector(Detector):
157    """
158    Simple infinite plane.
159    Beyond being just a solid object, the 
160
161    Attributes
162    ----------
163        centre : np.ndarray
164            3D Point in the Detector plane.
165
166        refpoint : np.ndarray
167            3D reference point from which to measure the Detector distance.
168    """
169    type = "Infinite Detector"
170    def __init__(self, index=-1):
171        super().__init__()
172        self._centre = mgeo.Origin
173        self._refpoint = mgeo.Origin
174        self.index = index
175    
176    def __copy__(self):
177        """
178        Returns a new Detector object with the same properties.
179        """
180        result = InfiniteDetector()
181        result._centre = copy(self._centre)
182        result._refpoint = copy(self._refpoint)
183        result._r = copy(self._r)
184        result._q = copy(self._q)
185        result.r0 = copy(self.r0)
186        result.index = self.index
187        return result
188    
189    @property
190    def normal(self):
191        return mgeo.Vector([0,0,1]).from_basis(*self.basis)
192
193    @property
194    def centre(self):
195        return self._centre
196
197    # a setter function
198    @centre.setter
199    def centre(self, Centre):
200        if isinstance(Centre, np.ndarray) and Centre.shape == (3,):
201            self._centre = mgeo.Point(Centre)
202            self.r = self._centre # Simply because r0 is 0,0,0 anyways
203        else:
204            raise TypeError("Detector Centre must be a 3D-vector, given as numpy.ndarray of shape (3,).")
205    
206    @property
207    def refpoint(self):
208        return self._refpoint
209
210    # a setter function
211    @refpoint.setter
212    def refpoint(self, RefPoint):
213        if isinstance(RefPoint, np.ndarray) and RefPoint.shape == (3,):
214            self._refpoint = mgeo.Point(RefPoint)
215        else:
216            raise TypeError("Detector RefPoint must a 3D-Point")
217
218    # %% Detector placement methods
219
220    @property
221    def distance(self):
222        """
223        Return distance of the Detector from its reference point Detector.refpoint.
224        """
225        return (self.refpoint - self.centre).norm
226    
227    @distance.setter
228    def distance(self, NewDistance: float):
229        """
230        Shift the Detector to the distance NewDistance from its reference point Detector.refpoint.
231
232        Parameters
233        ----------
234            NewDistance : number
235                The distance (absolute) to which to shift the Detector.
236        """
237        vector = (self.centre - self.refpoint).normalized()
238        self.centre = self.refpoint + vector * NewDistance
239    
240    def set_distance(self,x):
241        self.distance = x
242
243    def autoplace(self, RayList, DistanceDetector: float):
244        """
245        Automatically place and orient the detector such that it is normal to the central ray
246        of the supplied RayList, at the distance DistanceDetector the origin point of that central ray.
247
248        Parameters
249        ----------
250            RayList : list[Ray]
251                A list of objects of the ModuleOpticalRay.Ray-class.
252
253            DistanceDetector : float
254                The distance at which to place the Detector.
255        """
256        #CentralRay = mp.FindCentralRay(RayList)
257        CentralRay = RayList[0]
258        if CentralRay is None:
259            logger.warning(f"Could not find central ray! The list of rays has a length of {len(RayList)}")
260            CentralPoint = mgeo.Origin
261            for k in RayList:
262                CentralPoint = CentralPoint + (k.point - mgeo.Origin)
263            CentralPoint = CentralPoint / len(RayList)
264        else:
265            logger.debug(f"Found central ray, using it to position detector: \n{CentralRay}")
266            CentralPoint = CentralRay.point
267
268        self.centre = CentralPoint + CentralRay.vector * DistanceDetector
269        self.refpoint = CentralPoint
270        self.q = mgeo.QRotationVector2Vector(mgeo.Vector([0,0,1]), -CentralRay.vector)
271
272    # %% Detector optimisation methods
273    def test_callback_distances(self, RayList, distances, callback,
274                                provide_points=False,
275                                detector_reference_frame=False):
276        LocalRayList = [k.to_basis(*self.basis) for k in RayList]
277        N = len(distances)
278        # Calculate the impact points
279        Points = mgeo.IntersectionRayListZPlane(LocalRayList, distances)
280        values = []
281        for k in range(N):
282            Points[k] = Points[k]._add_dimension() + mgeo.Point([0,0,distances[k]])
283            values += [callback(RayList, Points[k], self.basis)]
284        return values
285
286    def optimise_distance(self, RayList, Range,  callback, 
287                          detector_reference_frame=False,
288                          provide_points=False,
289                          maxiter=5, 
290                          tol=1e-6,
291                          splitting=50,
292                          Nrays=1000,
293                          callback_iteration=None
294        ):
295        """
296        Optimise the position of the detector within the provided range, trying to 
297        maximise some value calculated by the callback function.
298
299        The callback function receives a list of rays that already hit the detector.
300        If requested, the rays can be provided in the reference frame of the detector.
301
302        The callback function should return a single value that is to be minimised.
303
304        The function splits the range into `splitting` points and uses the `IntersectionRayListZPlane`
305        function to calculate the impact points for all of these.
306        If `provide_points` is set to True, the function will pass on the result to the callback function.
307        Otherwise it will generate rays for the callback function, including calculating the paths.
308
309        It will then find the best value and redo the iteration around that value.
310        Repeat until either the maximum number of iterations is reached or the tolerance is met.
311
312        Parameters
313        ----------
314            RayList : list[Ray]
315                A list of objects of the ModuleOpticalRay.Ray-class.
316
317            Range : tuple
318                The range of distances to consider for the detector placement.
319
320            callback : function
321                The function to be minimised. It should take a list of rays and return a single value.
322
323            detector_reference_frame : bool
324                If True, the rays are provided in the reference frame of the detector.
325
326            provide_points : bool
327                If True, the callback function will receive the impact points directly.
328
329            maxiter : int
330                The maximum number of iterations to perform.
331
332            tol : float
333                The tolerance to reach before stopping the iteration.
334
335            splitting : int
336                The number of points to split the range into.
337        
338        Returns
339        ----------
340            The optimal distance of the detector.
341        """
342        Range = [i-self.distance for i in Range]
343        if Nrays<len(RayList):
344            RayList = np.random.choice(RayList, Nrays, replace=False)
345        previous_best = None
346        values = [0]*splitting
347        for i in range(maxiter):
348            if callback_iteration is not None:
349                callback_iteration(i, Range)
350            # Split the range into `splitting` points
351            distances = np.linspace(*Range, splitting)
352            values = self.test_callback_distances(RayList, distances, callback, 
353                                                  provide_points=provide_points, 
354                                                  detector_reference_frame=detector_reference_frame)
355            best = np.argmin(values)
356            # Update the range
357            if best == 0:
358                Range = (distances[0], distances[2])
359            elif best == splitting - 1:
360                Range = (distances[-3], distances[-1])
361            else:
362                Range = (distances[best - 1], distances[best + 1])
363            # Check if the tolerance is met
364            if i>0:
365                if np.abs(values[best] - previous_best) < tol:
366                    break
367            previous_best = values[best]
368        self.distance -= distances[best]
369
370    def _spot_size(self, RayList, Points, basis):
371        """
372        Returns focal spot size.
373
374        Parameters
375        ----------
376            Points : mgeo.PointArray
377                The points where the rays hit the detector.
378        Returns
379        ----------
380            float
381        """
382        center = mgeo.Point(np.mean(Points[:,:2], axis=0))
383        distances = (Points[:,:2] - center).norm
384        return np.std(distances)
385    
386    def _delay_std(self, RayList, Points, basis):
387        """
388        Returns the standard deviation of the delays of the rays
389
390        Parameters
391        ----------
392            RayList : list[Ray]
393                A list of objects of the ModuleOpticalRay.Ray-class.
394
395        Returns
396        ----------
397            float
398        """
399        #paths = np.array([np.sum(k.path) for k in RayList])
400        paths = np.sum(np.array([k.path for k in RayList], dtype=float), axis=1)
401        StartingPoints = mgeo.PointArray([k.point for k in RayList])
402        XYZ = Points.from_basis(*basis)
403        LastDistances = (XYZ - StartingPoints).norm
404        return float(np.std(paths+LastDistances))
405
406    def _over_intensity(self, RayList, Points, Basis):
407        """
408        Calculates spot_size * delay_std for the given RayList.
409        """
410        spot_size = self._spot_size(RayList, Points, Basis)
411        delay_std = self._delay_std(RayList, Points, Basis)
412        return spot_size**2 * delay_std
413
414    # %% Detector response methods 
415    def get_3D_points(self, RayList) -> list[np.ndarray]:
416        """
417        Returns the list of 3D-points in lab-space where the rays in the
418        list RayList hit the detector plane.
419
420        Parameters
421        ----------
422            RayList : list[Ray]
423                A list of objects of the ModuleOpticalRay.Ray-class.
424
425        Returns
426        ----------
427            ListPointDetector3D : list[np.ndarray of shape (3,)]
428        """
429        return self.get_2D_points(RayList)[0]._add_dimension().from_basis(*self.basis)
430
431    def get_2D_points(self, RayList) -> list[np.ndarray]:
432        """
433        Returns the list of 2D-points in the detector plane, with the origin at Detector.centre.
434
435        Parameters
436        ----------
437            RayList : list[Ray]
438                A list of objects of the ModuleOpticalRay.Ray-class of length N
439
440        Returns
441        ----------
442            XY : np.ndarray of shape (N,2)
443        """
444        return mgeo.IntersectionRayListZPlane([r.to_basis(*self.basis) for r in RayList])
445
446    def get_centre_2D_points(self, RayList) -> list[np.ndarray]:
447        """
448        Returns the center of the 2D array of points.
449
450        Parameters
451        ----------
452            RayList* : list[Ray]
453                A list of objects of the ModuleOpticalRay.Ray-class.
454
455        Returns
456        ----------
457            ListPointDetector2DCentre : list[np.ndarray of shape (2,)]
458        """
459        return np.mean(self.get_2D_points(RayList), axis=0)
460
461    def get_Delays(self, RayList) -> list[float]:
462        """
463        Returns the list of delays of the rays of RayList as they hit the detector plane,
464        calculated as their total path length divided by the speed of light.
465        The delays are relative to the mean “travel time”, i.e. the mean path length of RayList
466        divided by the speed of light.
467        The list index corresponds to that of the RayList.
468
469        Parameters
470        ----------
471            RayList : list[Ray]
472                A list of objects of the ModuleOpticalRay.Ray-class.
473
474        Returns
475        ----------
476            DelayList : list[float]
477        """
478        localRayList = RayList.to_basis(*self.basis)
479        paths = np.sum(RayList.path, axis=1)
480        StartingPoints = mgeo.PointArray([k.point for k in localRayList])
481        XYZ = mgeo.IntersectionRayListZPlane(localRayList)[0]._add_dimension()
482        LastDistances = (XYZ - StartingPoints).norm
483        TotalPaths =  paths+LastDistances
484        MeanPath = np.mean(TotalPaths)
485        return list((TotalPaths-MeanPath) / LightSpeed * 1e15) # in fs
logger = <Logger ARTcore.ModuleDetector (WARNING)>
LightSpeed = 299792458000
class Detector(abc.ABC):
 37class Detector(ABC):
 38    """
 39    Abstract class describing a detector and the properties/methods it has to have. 
 40    It implements the required components for the detector to behave as a 3D object.
 41    Non-abstract subclasses of `Detector` should implement the remaining
 42    functiuons such as the detection of rays
 43    """
 44    type = "Generic Detector (Abstract)"
 45    @property
 46    def r(self):
 47        """
 48        Return the offset of the optical element from its reference frame to the lab reference frame.
 49        Not that this is **NOT** the position of the optical element's center point. Rather it is the
 50        offset of the reference frame of the optical element from the lab reference frame.
 51        """
 52        return self._r
 53
 54    @r.setter
 55    def r(self, NewPosition):
 56        """
 57        Set the offset of the optical element from its reference frame to the lab reference frame.
 58        """
 59        if isinstance(NewPosition, mgeo.Point) and len(NewPosition) == 3:
 60            self._r = NewPosition
 61        else:
 62            raise TypeError("Position must be a 3D mgeo.Point.")
 63    @property
 64    def q(self):
 65        """
 66        Return the orientation of the optical element.
 67        The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.
 68        """
 69        return self._q
 70    
 71    @q.setter
 72    def q(self, NewOrientation):
 73        """
 74        Set the orientation of the optical element.
 75        This function normalizes the input quaternion before storing it.
 76        If the input is not a quaternion, raise a TypeError.
 77        """
 78        if isinstance(NewOrientation, np.quaternion):
 79            self._q = NewOrientation.normalized()
 80        else:
 81            raise TypeError("Orientation must be a quaternion.")
 82
 83    @property
 84    def position(self):
 85        """
 86        Return the position of the basepoint of the optical element. Often it is the same as the optical centre.
 87        This position is the one around which all rotations are performed.
 88        """
 89        return self.r0 + self.r
 90    
 91    @property
 92    def orientation(self):
 93        """
 94        Return the orientation of the optical element.
 95        The utility of this method is unclear.
 96        """
 97        return self.q
 98    
 99    @property
100    def basis(self):
101        return self.r0, self.r, self.q
102    
103    def __init__(self):
104        self._r = mgeo.Vector([0.0, 0.0, 0.0])
105        self._q = np.quaternion(1, 0, 0, 0)
106        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
107
108    @abstractmethod
109    def centre(self):
110        """
111        (0,0) point of the detector.
112        """
113        pass
114
115    @abstractmethod
116    def refpoint(self):
117        """
118        Reference point from which to measure the distance of the detector. 
119        Usually the center of the previous optical element.
120        """
121        pass
122
123    @property
124    def distance(self):
125        """
126        Return distance of the Detector from its reference point Detector.refpoint.
127        """
128        return (self.refpoint - self.centre).norm
129
130    @distance.setter
131    def distance(self, NewDistance: float):
132        """
133        Shift the Detector to the distance NewDistance from its reference point Detector.refpoint.
134
135        Parameters
136        ----------
137            NewDistance : number
138                The distance (absolute) to which to shift the Detector.
139        """
140        vector = (self.centre - self.refpoint).normalized
141        self.centre = self.refpoint + vector * NewDistance
142
143    @abstractmethod
144    def get_2D_points(self, RayList):
145        pass
146
147    @abstractmethod
148    def get_3D_points(self, RayList):
149        pass
150
151    @abstractmethod
152    def __copy__(self):
153        pass

Abstract class describing a detector and the properties/methods it has to have. It implements the required components for the detector to behave as a 3D object. Non-abstract subclasses of Detector should implement the remaining functiuons such as the detection of rays

type = 'Generic Detector (Abstract)'
r
45    @property
46    def r(self):
47        """
48        Return the offset of the optical element from its reference frame to the lab reference frame.
49        Not that this is **NOT** the position of the optical element's center point. Rather it is the
50        offset of the reference frame of the optical element from the lab reference frame.
51        """
52        return self._r

Return the offset of the optical element from its reference frame to the lab reference frame. Not that this is NOT the position of the optical element's center point. Rather it is the offset of the reference frame of the optical element from the lab reference frame.

q
63    @property
64    def q(self):
65        """
66        Return the orientation of the optical element.
67        The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.
68        """
69        return self._q

Return the orientation of the optical element. The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.

position
83    @property
84    def position(self):
85        """
86        Return the position of the basepoint of the optical element. Often it is the same as the optical centre.
87        This position is the one around which all rotations are performed.
88        """
89        return self.r0 + self.r

Return the position of the basepoint of the optical element. Often it is the same as the optical centre. This position is the one around which all rotations are performed.

orientation
91    @property
92    def orientation(self):
93        """
94        Return the orientation of the optical element.
95        The utility of this method is unclear.
96        """
97        return self.q

Return the orientation of the optical element. The utility of this method is unclear.

basis
 99    @property
100    def basis(self):
101        return self.r0, self.r, self.q
r0
@abstractmethod
def centre(self):
108    @abstractmethod
109    def centre(self):
110        """
111        (0,0) point of the detector.
112        """
113        pass

(0,0) point of the detector.

@abstractmethod
def refpoint(self):
115    @abstractmethod
116    def refpoint(self):
117        """
118        Reference point from which to measure the distance of the detector. 
119        Usually the center of the previous optical element.
120        """
121        pass

Reference point from which to measure the distance of the detector. Usually the center of the previous optical element.

distance
123    @property
124    def distance(self):
125        """
126        Return distance of the Detector from its reference point Detector.refpoint.
127        """
128        return (self.refpoint - self.centre).norm

Return distance of the Detector from its reference point Detector.refpoint.

@abstractmethod
def get_2D_points(self, RayList):
143    @abstractmethod
144    def get_2D_points(self, RayList):
145        pass
@abstractmethod
def get_3D_points(self, RayList):
147    @abstractmethod
148    def get_3D_points(self, RayList):
149        pass
class InfiniteDetector(Detector):
157class InfiniteDetector(Detector):
158    """
159    Simple infinite plane.
160    Beyond being just a solid object, the 
161
162    Attributes
163    ----------
164        centre : np.ndarray
165            3D Point in the Detector plane.
166
167        refpoint : np.ndarray
168            3D reference point from which to measure the Detector distance.
169    """
170    type = "Infinite Detector"
171    def __init__(self, index=-1):
172        super().__init__()
173        self._centre = mgeo.Origin
174        self._refpoint = mgeo.Origin
175        self.index = index
176    
177    def __copy__(self):
178        """
179        Returns a new Detector object with the same properties.
180        """
181        result = InfiniteDetector()
182        result._centre = copy(self._centre)
183        result._refpoint = copy(self._refpoint)
184        result._r = copy(self._r)
185        result._q = copy(self._q)
186        result.r0 = copy(self.r0)
187        result.index = self.index
188        return result
189    
190    @property
191    def normal(self):
192        return mgeo.Vector([0,0,1]).from_basis(*self.basis)
193
194    @property
195    def centre(self):
196        return self._centre
197
198    # a setter function
199    @centre.setter
200    def centre(self, Centre):
201        if isinstance(Centre, np.ndarray) and Centre.shape == (3,):
202            self._centre = mgeo.Point(Centre)
203            self.r = self._centre # Simply because r0 is 0,0,0 anyways
204        else:
205            raise TypeError("Detector Centre must be a 3D-vector, given as numpy.ndarray of shape (3,).")
206    
207    @property
208    def refpoint(self):
209        return self._refpoint
210
211    # a setter function
212    @refpoint.setter
213    def refpoint(self, RefPoint):
214        if isinstance(RefPoint, np.ndarray) and RefPoint.shape == (3,):
215            self._refpoint = mgeo.Point(RefPoint)
216        else:
217            raise TypeError("Detector RefPoint must a 3D-Point")
218
219    # %% Detector placement methods
220
221    @property
222    def distance(self):
223        """
224        Return distance of the Detector from its reference point Detector.refpoint.
225        """
226        return (self.refpoint - self.centre).norm
227    
228    @distance.setter
229    def distance(self, NewDistance: float):
230        """
231        Shift the Detector to the distance NewDistance from its reference point Detector.refpoint.
232
233        Parameters
234        ----------
235            NewDistance : number
236                The distance (absolute) to which to shift the Detector.
237        """
238        vector = (self.centre - self.refpoint).normalized()
239        self.centre = self.refpoint + vector * NewDistance
240    
241    def set_distance(self,x):
242        self.distance = x
243
244    def autoplace(self, RayList, DistanceDetector: float):
245        """
246        Automatically place and orient the detector such that it is normal to the central ray
247        of the supplied RayList, at the distance DistanceDetector the origin point of that central ray.
248
249        Parameters
250        ----------
251            RayList : list[Ray]
252                A list of objects of the ModuleOpticalRay.Ray-class.
253
254            DistanceDetector : float
255                The distance at which to place the Detector.
256        """
257        #CentralRay = mp.FindCentralRay(RayList)
258        CentralRay = RayList[0]
259        if CentralRay is None:
260            logger.warning(f"Could not find central ray! The list of rays has a length of {len(RayList)}")
261            CentralPoint = mgeo.Origin
262            for k in RayList:
263                CentralPoint = CentralPoint + (k.point - mgeo.Origin)
264            CentralPoint = CentralPoint / len(RayList)
265        else:
266            logger.debug(f"Found central ray, using it to position detector: \n{CentralRay}")
267            CentralPoint = CentralRay.point
268
269        self.centre = CentralPoint + CentralRay.vector * DistanceDetector
270        self.refpoint = CentralPoint
271        self.q = mgeo.QRotationVector2Vector(mgeo.Vector([0,0,1]), -CentralRay.vector)
272
273    # %% Detector optimisation methods
274    def test_callback_distances(self, RayList, distances, callback,
275                                provide_points=False,
276                                detector_reference_frame=False):
277        LocalRayList = [k.to_basis(*self.basis) for k in RayList]
278        N = len(distances)
279        # Calculate the impact points
280        Points = mgeo.IntersectionRayListZPlane(LocalRayList, distances)
281        values = []
282        for k in range(N):
283            Points[k] = Points[k]._add_dimension() + mgeo.Point([0,0,distances[k]])
284            values += [callback(RayList, Points[k], self.basis)]
285        return values
286
287    def optimise_distance(self, RayList, Range,  callback, 
288                          detector_reference_frame=False,
289                          provide_points=False,
290                          maxiter=5, 
291                          tol=1e-6,
292                          splitting=50,
293                          Nrays=1000,
294                          callback_iteration=None
295        ):
296        """
297        Optimise the position of the detector within the provided range, trying to 
298        maximise some value calculated by the callback function.
299
300        The callback function receives a list of rays that already hit the detector.
301        If requested, the rays can be provided in the reference frame of the detector.
302
303        The callback function should return a single value that is to be minimised.
304
305        The function splits the range into `splitting` points and uses the `IntersectionRayListZPlane`
306        function to calculate the impact points for all of these.
307        If `provide_points` is set to True, the function will pass on the result to the callback function.
308        Otherwise it will generate rays for the callback function, including calculating the paths.
309
310        It will then find the best value and redo the iteration around that value.
311        Repeat until either the maximum number of iterations is reached or the tolerance is met.
312
313        Parameters
314        ----------
315            RayList : list[Ray]
316                A list of objects of the ModuleOpticalRay.Ray-class.
317
318            Range : tuple
319                The range of distances to consider for the detector placement.
320
321            callback : function
322                The function to be minimised. It should take a list of rays and return a single value.
323
324            detector_reference_frame : bool
325                If True, the rays are provided in the reference frame of the detector.
326
327            provide_points : bool
328                If True, the callback function will receive the impact points directly.
329
330            maxiter : int
331                The maximum number of iterations to perform.
332
333            tol : float
334                The tolerance to reach before stopping the iteration.
335
336            splitting : int
337                The number of points to split the range into.
338        
339        Returns
340        ----------
341            The optimal distance of the detector.
342        """
343        Range = [i-self.distance for i in Range]
344        if Nrays<len(RayList):
345            RayList = np.random.choice(RayList, Nrays, replace=False)
346        previous_best = None
347        values = [0]*splitting
348        for i in range(maxiter):
349            if callback_iteration is not None:
350                callback_iteration(i, Range)
351            # Split the range into `splitting` points
352            distances = np.linspace(*Range, splitting)
353            values = self.test_callback_distances(RayList, distances, callback, 
354                                                  provide_points=provide_points, 
355                                                  detector_reference_frame=detector_reference_frame)
356            best = np.argmin(values)
357            # Update the range
358            if best == 0:
359                Range = (distances[0], distances[2])
360            elif best == splitting - 1:
361                Range = (distances[-3], distances[-1])
362            else:
363                Range = (distances[best - 1], distances[best + 1])
364            # Check if the tolerance is met
365            if i>0:
366                if np.abs(values[best] - previous_best) < tol:
367                    break
368            previous_best = values[best]
369        self.distance -= distances[best]
370
371    def _spot_size(self, RayList, Points, basis):
372        """
373        Returns focal spot size.
374
375        Parameters
376        ----------
377            Points : mgeo.PointArray
378                The points where the rays hit the detector.
379        Returns
380        ----------
381            float
382        """
383        center = mgeo.Point(np.mean(Points[:,:2], axis=0))
384        distances = (Points[:,:2] - center).norm
385        return np.std(distances)
386    
387    def _delay_std(self, RayList, Points, basis):
388        """
389        Returns the standard deviation of the delays of the rays
390
391        Parameters
392        ----------
393            RayList : list[Ray]
394                A list of objects of the ModuleOpticalRay.Ray-class.
395
396        Returns
397        ----------
398            float
399        """
400        #paths = np.array([np.sum(k.path) for k in RayList])
401        paths = np.sum(np.array([k.path for k in RayList], dtype=float), axis=1)
402        StartingPoints = mgeo.PointArray([k.point for k in RayList])
403        XYZ = Points.from_basis(*basis)
404        LastDistances = (XYZ - StartingPoints).norm
405        return float(np.std(paths+LastDistances))
406
407    def _over_intensity(self, RayList, Points, Basis):
408        """
409        Calculates spot_size * delay_std for the given RayList.
410        """
411        spot_size = self._spot_size(RayList, Points, Basis)
412        delay_std = self._delay_std(RayList, Points, Basis)
413        return spot_size**2 * delay_std
414
415    # %% Detector response methods 
416    def get_3D_points(self, RayList) -> list[np.ndarray]:
417        """
418        Returns the list of 3D-points in lab-space where the rays in the
419        list RayList hit the detector plane.
420
421        Parameters
422        ----------
423            RayList : list[Ray]
424                A list of objects of the ModuleOpticalRay.Ray-class.
425
426        Returns
427        ----------
428            ListPointDetector3D : list[np.ndarray of shape (3,)]
429        """
430        return self.get_2D_points(RayList)[0]._add_dimension().from_basis(*self.basis)
431
432    def get_2D_points(self, RayList) -> list[np.ndarray]:
433        """
434        Returns the list of 2D-points in the detector plane, with the origin at Detector.centre.
435
436        Parameters
437        ----------
438            RayList : list[Ray]
439                A list of objects of the ModuleOpticalRay.Ray-class of length N
440
441        Returns
442        ----------
443            XY : np.ndarray of shape (N,2)
444        """
445        return mgeo.IntersectionRayListZPlane([r.to_basis(*self.basis) for r in RayList])
446
447    def get_centre_2D_points(self, RayList) -> list[np.ndarray]:
448        """
449        Returns the center of the 2D array of points.
450
451        Parameters
452        ----------
453            RayList* : list[Ray]
454                A list of objects of the ModuleOpticalRay.Ray-class.
455
456        Returns
457        ----------
458            ListPointDetector2DCentre : list[np.ndarray of shape (2,)]
459        """
460        return np.mean(self.get_2D_points(RayList), axis=0)
461
462    def get_Delays(self, RayList) -> list[float]:
463        """
464        Returns the list of delays of the rays of RayList as they hit the detector plane,
465        calculated as their total path length divided by the speed of light.
466        The delays are relative to the mean “travel time”, i.e. the mean path length of RayList
467        divided by the speed of light.
468        The list index corresponds to that of the RayList.
469
470        Parameters
471        ----------
472            RayList : list[Ray]
473                A list of objects of the ModuleOpticalRay.Ray-class.
474
475        Returns
476        ----------
477            DelayList : list[float]
478        """
479        localRayList = RayList.to_basis(*self.basis)
480        paths = np.sum(RayList.path, axis=1)
481        StartingPoints = mgeo.PointArray([k.point for k in localRayList])
482        XYZ = mgeo.IntersectionRayListZPlane(localRayList)[0]._add_dimension()
483        LastDistances = (XYZ - StartingPoints).norm
484        TotalPaths =  paths+LastDistances
485        MeanPath = np.mean(TotalPaths)
486        return list((TotalPaths-MeanPath) / LightSpeed * 1e15) # in fs

Simple infinite plane. Beyond being just a solid object, the

Attributes

centre : np.ndarray
    3D Point in the Detector plane.

refpoint : np.ndarray
    3D reference point from which to measure the Detector distance.
InfiniteDetector(index=-1)
171    def __init__(self, index=-1):
172        super().__init__()
173        self._centre = mgeo.Origin
174        self._refpoint = mgeo.Origin
175        self.index = index
type = 'Infinite Detector'
index
normal
190    @property
191    def normal(self):
192        return mgeo.Vector([0,0,1]).from_basis(*self.basis)
centre
194    @property
195    def centre(self):
196        return self._centre

(0,0) point of the detector.

refpoint
207    @property
208    def refpoint(self):
209        return self._refpoint

Reference point from which to measure the distance of the detector. Usually the center of the previous optical element.

distance
221    @property
222    def distance(self):
223        """
224        Return distance of the Detector from its reference point Detector.refpoint.
225        """
226        return (self.refpoint - self.centre).norm

Return distance of the Detector from its reference point Detector.refpoint.

def set_distance(self, x):
241    def set_distance(self,x):
242        self.distance = x
def autoplace(self, RayList, DistanceDetector: float):
244    def autoplace(self, RayList, DistanceDetector: float):
245        """
246        Automatically place and orient the detector such that it is normal to the central ray
247        of the supplied RayList, at the distance DistanceDetector the origin point of that central ray.
248
249        Parameters
250        ----------
251            RayList : list[Ray]
252                A list of objects of the ModuleOpticalRay.Ray-class.
253
254            DistanceDetector : float
255                The distance at which to place the Detector.
256        """
257        #CentralRay = mp.FindCentralRay(RayList)
258        CentralRay = RayList[0]
259        if CentralRay is None:
260            logger.warning(f"Could not find central ray! The list of rays has a length of {len(RayList)}")
261            CentralPoint = mgeo.Origin
262            for k in RayList:
263                CentralPoint = CentralPoint + (k.point - mgeo.Origin)
264            CentralPoint = CentralPoint / len(RayList)
265        else:
266            logger.debug(f"Found central ray, using it to position detector: \n{CentralRay}")
267            CentralPoint = CentralRay.point
268
269        self.centre = CentralPoint + CentralRay.vector * DistanceDetector
270        self.refpoint = CentralPoint
271        self.q = mgeo.QRotationVector2Vector(mgeo.Vector([0,0,1]), -CentralRay.vector)

Automatically place and orient the detector such that it is normal to the central ray of the supplied RayList, at the distance DistanceDetector the origin point of that central ray.

Parameters

RayList : list[Ray]
    A list of objects of the ModuleOpticalRay.Ray-class.

DistanceDetector : float
    The distance at which to place the Detector.
def test_callback_distances( self, RayList, distances, callback, provide_points=False, detector_reference_frame=False):
274    def test_callback_distances(self, RayList, distances, callback,
275                                provide_points=False,
276                                detector_reference_frame=False):
277        LocalRayList = [k.to_basis(*self.basis) for k in RayList]
278        N = len(distances)
279        # Calculate the impact points
280        Points = mgeo.IntersectionRayListZPlane(LocalRayList, distances)
281        values = []
282        for k in range(N):
283            Points[k] = Points[k]._add_dimension() + mgeo.Point([0,0,distances[k]])
284            values += [callback(RayList, Points[k], self.basis)]
285        return values
def optimise_distance( self, RayList, Range, callback, detector_reference_frame=False, provide_points=False, maxiter=5, tol=1e-06, splitting=50, Nrays=1000, callback_iteration=None):
287    def optimise_distance(self, RayList, Range,  callback, 
288                          detector_reference_frame=False,
289                          provide_points=False,
290                          maxiter=5, 
291                          tol=1e-6,
292                          splitting=50,
293                          Nrays=1000,
294                          callback_iteration=None
295        ):
296        """
297        Optimise the position of the detector within the provided range, trying to 
298        maximise some value calculated by the callback function.
299
300        The callback function receives a list of rays that already hit the detector.
301        If requested, the rays can be provided in the reference frame of the detector.
302
303        The callback function should return a single value that is to be minimised.
304
305        The function splits the range into `splitting` points and uses the `IntersectionRayListZPlane`
306        function to calculate the impact points for all of these.
307        If `provide_points` is set to True, the function will pass on the result to the callback function.
308        Otherwise it will generate rays for the callback function, including calculating the paths.
309
310        It will then find the best value and redo the iteration around that value.
311        Repeat until either the maximum number of iterations is reached or the tolerance is met.
312
313        Parameters
314        ----------
315            RayList : list[Ray]
316                A list of objects of the ModuleOpticalRay.Ray-class.
317
318            Range : tuple
319                The range of distances to consider for the detector placement.
320
321            callback : function
322                The function to be minimised. It should take a list of rays and return a single value.
323
324            detector_reference_frame : bool
325                If True, the rays are provided in the reference frame of the detector.
326
327            provide_points : bool
328                If True, the callback function will receive the impact points directly.
329
330            maxiter : int
331                The maximum number of iterations to perform.
332
333            tol : float
334                The tolerance to reach before stopping the iteration.
335
336            splitting : int
337                The number of points to split the range into.
338        
339        Returns
340        ----------
341            The optimal distance of the detector.
342        """
343        Range = [i-self.distance for i in Range]
344        if Nrays<len(RayList):
345            RayList = np.random.choice(RayList, Nrays, replace=False)
346        previous_best = None
347        values = [0]*splitting
348        for i in range(maxiter):
349            if callback_iteration is not None:
350                callback_iteration(i, Range)
351            # Split the range into `splitting` points
352            distances = np.linspace(*Range, splitting)
353            values = self.test_callback_distances(RayList, distances, callback, 
354                                                  provide_points=provide_points, 
355                                                  detector_reference_frame=detector_reference_frame)
356            best = np.argmin(values)
357            # Update the range
358            if best == 0:
359                Range = (distances[0], distances[2])
360            elif best == splitting - 1:
361                Range = (distances[-3], distances[-1])
362            else:
363                Range = (distances[best - 1], distances[best + 1])
364            # Check if the tolerance is met
365            if i>0:
366                if np.abs(values[best] - previous_best) < tol:
367                    break
368            previous_best = values[best]
369        self.distance -= distances[best]

Optimise the position of the detector within the provided range, trying to maximise some value calculated by the callback function.

The callback function receives a list of rays that already hit the detector. If requested, the rays can be provided in the reference frame of the detector.

The callback function should return a single value that is to be minimised.

The function splits the range into splitting points and uses the IntersectionRayListZPlane function to calculate the impact points for all of these. If provide_points is set to True, the function will pass on the result to the callback function. Otherwise it will generate rays for the callback function, including calculating the paths.

It will then find the best value and redo the iteration around that value. Repeat until either the maximum number of iterations is reached or the tolerance is met.

Parameters

RayList : list[Ray]
    A list of objects of the ModuleOpticalRay.Ray-class.

Range : tuple
    The range of distances to consider for the detector placement.

callback : function
    The function to be minimised. It should take a list of rays and return a single value.

detector_reference_frame : bool
    If True, the rays are provided in the reference frame of the detector.

provide_points : bool
    If True, the callback function will receive the impact points directly.

maxiter : int
    The maximum number of iterations to perform.

tol : float
    The tolerance to reach before stopping the iteration.

splitting : int
    The number of points to split the range into.

Returns

The optimal distance of the detector.
def get_3D_points(self, RayList) -> list[numpy.ndarray]:
416    def get_3D_points(self, RayList) -> list[np.ndarray]:
417        """
418        Returns the list of 3D-points in lab-space where the rays in the
419        list RayList hit the detector plane.
420
421        Parameters
422        ----------
423            RayList : list[Ray]
424                A list of objects of the ModuleOpticalRay.Ray-class.
425
426        Returns
427        ----------
428            ListPointDetector3D : list[np.ndarray of shape (3,)]
429        """
430        return self.get_2D_points(RayList)[0]._add_dimension().from_basis(*self.basis)

Returns the list of 3D-points in lab-space where the rays in the list RayList hit the detector plane.

Parameters

RayList : list[Ray]
    A list of objects of the ModuleOpticalRay.Ray-class.

Returns

ListPointDetector3D : list[np.ndarray of shape (3,)]
def get_2D_points(self, RayList) -> list[numpy.ndarray]:
432    def get_2D_points(self, RayList) -> list[np.ndarray]:
433        """
434        Returns the list of 2D-points in the detector plane, with the origin at Detector.centre.
435
436        Parameters
437        ----------
438            RayList : list[Ray]
439                A list of objects of the ModuleOpticalRay.Ray-class of length N
440
441        Returns
442        ----------
443            XY : np.ndarray of shape (N,2)
444        """
445        return mgeo.IntersectionRayListZPlane([r.to_basis(*self.basis) for r in RayList])

Returns the list of 2D-points in the detector plane, with the origin at Detector.centre.

Parameters

RayList : list[Ray]
    A list of objects of the ModuleOpticalRay.Ray-class of length N

Returns

XY : np.ndarray of shape (N,2)
def get_centre_2D_points(self, RayList) -> list[numpy.ndarray]:
447    def get_centre_2D_points(self, RayList) -> list[np.ndarray]:
448        """
449        Returns the center of the 2D array of points.
450
451        Parameters
452        ----------
453            RayList* : list[Ray]
454                A list of objects of the ModuleOpticalRay.Ray-class.
455
456        Returns
457        ----------
458            ListPointDetector2DCentre : list[np.ndarray of shape (2,)]
459        """
460        return np.mean(self.get_2D_points(RayList), axis=0)

Returns the center of the 2D array of points.

Parameters

RayList* : list[Ray]
    A list of objects of the ModuleOpticalRay.Ray-class.

Returns

ListPointDetector2DCentre : list[np.ndarray of shape (2,)]
def get_Delays(self, RayList) -> list[float]:
462    def get_Delays(self, RayList) -> list[float]:
463        """
464        Returns the list of delays of the rays of RayList as they hit the detector plane,
465        calculated as their total path length divided by the speed of light.
466        The delays are relative to the mean “travel time”, i.e. the mean path length of RayList
467        divided by the speed of light.
468        The list index corresponds to that of the RayList.
469
470        Parameters
471        ----------
472            RayList : list[Ray]
473                A list of objects of the ModuleOpticalRay.Ray-class.
474
475        Returns
476        ----------
477            DelayList : list[float]
478        """
479        localRayList = RayList.to_basis(*self.basis)
480        paths = np.sum(RayList.path, axis=1)
481        StartingPoints = mgeo.PointArray([k.point for k in localRayList])
482        XYZ = mgeo.IntersectionRayListZPlane(localRayList)[0]._add_dimension()
483        LastDistances = (XYZ - StartingPoints).norm
484        TotalPaths =  paths+LastDistances
485        MeanPath = np.mean(TotalPaths)
486        return list((TotalPaths-MeanPath) / LightSpeed * 1e15) # in fs

Returns the list of delays of the rays of RayList as they hit the detector plane, calculated as their total path length divided by the speed of light. The delays are relative to the mean “travel time”, i.e. the mean path length of RayList divided by the speed of light. The list index corresponds to that of the RayList.

Parameters

RayList : list[Ray]
    A list of objects of the ModuleOpticalRay.Ray-class.

Returns

DelayList : list[float]

3 - ModuleGeometry

Contains a bunch of useful function for geometric transformations and measurements. Usually these don't need to be called by users of ART, but they may be useful.

Some general conventions:

  • As much as possible, avoid having lists of points or vectors transiting between functions. Instead, use the Vector and Point classes defined at the end of this file.
  • Functions operating on Rays should preferentially operate on lists of Rays, not individual Rays. The reason for that is that it's fairly rare to manipluate a single Ray, and it's easier to just put it in a single-element list and call the function that way.

Created in 2019

@author: Anthony Guillaume + Stefan Haessler + André Kalouguine

  1"""
  2Contains a bunch of useful function for geometric transformations and measurements.
  3Usually these don't need to be called by users of ART, but they may be useful.
  4
  5Some general conventions:
  6- As much as possible, avoid having lists of points or vectors transiting between functions.
  7    Instead, use the Vector and Point classes defined at the end of this file.
  8- Functions operating on Rays should preferentially operate on lists of Rays, not individual Rays.
  9    The reason for that is that it's fairly rare to manipluate a single Ray, and it's easier to
 10    just put it in a single-element list and call the function that way.
 11
 12Created in 2019
 13
 14@author: Anthony Guillaume + Stefan Haessler + André Kalouguine
 15"""
 16# %% Modules
 17import numpy as np
 18import quaternion
 19from functools import singledispatch
 20import logging
 21import math
 22
 23logger = logging.getLogger(__name__)
 24
 25# %% Points and Vectors classes
 26class Vector(np.ndarray):
 27    def __new__(cls, input_array):
 28        input_array = np.asarray(input_array)
 29        if input_array.ndim > 1:
 30            return VectorArray(input_array)
 31        obj = input_array.view(cls)  # Ensure we are viewing as `Vector`
 32        return obj
 33    @property
 34    def norm(self):
 35        return np.linalg.norm(self)
 36    def normalized(self):
 37        return self / self.norm
 38    def __add__(self, other):
 39        if isinstance(other, Point):
 40            return Point(super().__add__(other))
 41        return Vector(super().__add__(other))
 42    def __sub__(self, other):
 43        if isinstance(other, Point):
 44            return Point(super().__sub__(other))
 45        return Vector(super().__sub__(other))
 46    def translate(self, vector):
 47        return self
 48    def rotate(self,q):
 49        return Vector((q*np.quaternion(0,*self)*q.conj()).imag)
 50    def from_basis(self, r0, r, q):
 51        return self.rotate(q)
 52    def to_basis(self, r0, r, q):
 53        return self.rotate(q.conj())
 54    def _add_dimension(self, value=0):
 55        return Vector(np.concatenate((self, [value])))
 56    def __hash__(self) -> int:
 57        vector_tuple = tuple(self.reshape(1, -1)[0])
 58        return hash(vector_tuple)
 59class VectorArray(np.ndarray):
 60    def __new__(cls, input_array):
 61        input_array = np.asarray(input_array)
 62        if input_array.ndim == 1:
 63            return Vector(input_array)
 64        obj = input_array.view(cls)  # Ensure we are viewing as `Vector`
 65        return obj
 66    @property
 67    def norm(self):
 68        return np.linalg.norm(self, axis=1)
 69    def __getitem__(self, index):
 70        return Vector(super().__getitem__(index))
 71    def normalized(self):
 72        return self / self.norm[:, np.newaxis]
 73    def __add__(self, other):
 74        if isinstance(other, Point):
 75            return PointArray(super().__add__(other))
 76        return VectorArray(super().__add__(other))
 77    def __sub__(self, other):
 78        if isinstance(other, Point):
 79            return PointArray(super().__sub__(other))
 80        return VectorArray(super().__sub__(other))
 81    def translate(self, vector):
 82        return self
 83    def rotate(self,q):
 84        return VectorArray(quaternion.rotate_vectors(q, self))
 85    def from_basis(self, r0, r, q):
 86        return self.rotate(q)
 87    def to_basis(self, r0, r, q):
 88        return self.rotate(q.conj())
 89    def _add_dimension(self, values=0):
 90        if values == 0:
 91            values = np.zeros((self.shape[0], 1))
 92        if not isinstance(values, np.ndarray):
 93            values = np.array(values)
 94        return VectorArray(np.concatenate((self, values), axis=1))
 95    
 96class Point(np.ndarray):
 97    def __new__(cls, input_array):
 98        input_array = np.asarray(input_array)
 99        if input_array.ndim > 1:
100            return PointArray(input_array)
101        obj = input_array.view(cls)  # Ensure we are viewing as `Point`
102        return obj
103    def __add__(self, other):
104        return Point(super().__add__(other))
105    def __sub__(self, other):
106        return Vector(super().__sub__(other))
107    def translate(self, vector):
108        return Point(super()._add__(vector))
109    def rotate(self,q):
110        return (self-Origin).rotate(q)
111    def from_basis(self, r0, r, q):
112        return Point([0,0,0]) + r0 + Vector(self-r0).rotate(q) + r
113    def to_basis(self, r0, r, q):
114        return Point([0,0,0]) + r0 + Vector(self-r0-r).rotate(q.conj())
115    def _add_dimension(self, value=0):
116        return Point(np.concatenate((self, [value])))
117    def __hash__(self) -> int:
118        point_tuple = tuple(self.reshape(1, -1)[0])
119        return hash(point_tuple)
120
121class PointArray(np.ndarray):
122    def __new__(cls, input_array):
123        input_array = np.asarray(input_array)
124        if input_array.ndim == 1:
125            return Point(input_array)
126        obj = input_array.view(cls)  # Ensure we are viewing as `Point`
127        return obj
128    def __getitem__(self, index):
129        return Point(super().__getitem__(index))
130    def __add__(self, other):
131        return PointArray(super().__add__(other))
132    def __sub__(self, other):
133        return VectorArray(super().__sub__(other))
134    def translate(self, vector):
135        return PointArray(super().__add__(vector))
136    def rotate(self,q):
137        return PointArray(quaternion.rotate_vectors(q, self))
138    def from_basis(self, r0, r, q):
139        return Point([0,0,0]) + r0 + VectorArray(self-r0).rotate(q) + r
140        #return PointArray([Point([0,0,0]) + r0 + (p-r0).rotate(q) + r for p in self])
141    def to_basis(self, r0, r, q):
142        return Point([0,0,0]) + r0 + VectorArray(self-r0-r).rotate(q.conj())
143        #return PointArray([Point([0,0,0]) + r0 + (p-r0-r).rotate(q.conj()) for p in self])
144    def _add_dimension(self, values=0):
145        if values == 0:
146            values = np.zeros((self.shape[0], 1))
147        if not isinstance(values, np.ndarray):
148            values = np.array(values)
149        return PointArray(np.concatenate((self, values), axis=1))
150    
151Origin = Point([0,0,0])
152
153# %% More traditional vector operations that don't belong in the classes
154def Normalize(vector):
155    """
156    Normalize Vector.
157    Obsolete, use use the mgeo.Vector class instead as it has a `normalize` method.
158    """
159    return vector / np.linalg.norm(vector)
160
161def VectorPerpendicular(vector):
162    """
163    Find a perpendicular 3D vector in some arbitrary direction
164    Undefined behavior, use with caution. There is no unique perpendicular vector to a 3D vector.
165    """
166    logger.warning("VectorPerpendicular is undefined behavior. There is no unique perpendicular vector to a 3D vector.")
167    if abs(vector[0]) < 1e-15:
168        return Vector([1, 0, 0])
169    if abs(vector[1]) < 1e-15:
170        return Vector([0, 1, 0])
171    if abs(vector[2]) < 1e-15:
172        return Vector([0, 0, 1])
173
174    # set arbitrarily a = b =1
175    return Vector([1, 1, -1.0 * (vector[0] + vector[1]) / vector[2]]).normalized()
176
177def AngleBetweenTwoVectors(U, V):
178    """
179    Return the angle in radians between the vectors U and V ; formula from W.Kahan
180    Value in radians between 0 and pi.
181    """
182    u = np.linalg.norm(U)
183    v = np.linalg.norm(V)
184    return 2 * np.arctan2(np.linalg.norm(U * v - V * u), np.linalg.norm(U * v + V * u))
185
186def SymmetricalVector(V, SymmetryAxis):
187    """
188    Return the symmetrical vector to V
189    """
190    q = QRotationAroundAxis(SymmetryAxis, np.pi)
191    return V.rotate(q)
192
193def normal_add(N1, N2):
194    """
195    Simple function that takes in two normal vectors of a deformation and calculates
196    the total normal vector if the two deformations were individually applied.
197    Be very careful, this *only* works when the surface is z = f(x,y) and when 
198    the deformation is small.
199    Might be made obsolete when shifting to a modernised deformation system.
200    """
201    normal1 = N1.normalized()
202    normal2 = N2.normalized()
203    grad1 = -normal1[:2] / normal1[2]
204    grad2 = -normal2[:2] / normal2[2]
205    grad = grad1 + grad2
206    total_normal = np.append(-grad, 1)
207    return Vector(total_normal).normalized()
208
209# %% Intersection finding
210def IntersectionLinePlane(A, u, P, n):
211    """
212    Return the intersection point between a line and a plane.
213    A is a point of the line, u a vector of the line ; P is a point of the plane, n a normal vector
214    Line's equation : OM = u*t + OA , t a real
215    Plane's equation : n.OP - n.OM = 0
216    """
217    t = np.dot(n, -A + P) / np.dot(u, n)
218    I = u * t + A
219    return I
220
221def IntersectionRayListZPlane(RayList, Z=np.array([0])):
222    """
223    Return the intersection of a list of rays with a different planes with equiations z = Z[i]
224    Basically, by default it returns the intersection of the rays with the Z=0 plane but you can 
225    give it a few values of Z and it should be faster than calling it multiple times.
226    This should let us quickly find the optimal position of the detector as well as trace the caustics.
227    If a ray does not intersect the plane... it should replace that point with a NaN. 
228    """
229    Positions = np.vstack([i.point for i in RayList])
230    Vectors = np.vstack([i.vector for i in RayList])
231    non_zero = Vectors[:,2] != 0
232    Positions = Positions[non_zero]
233    Vectors = Vectors[non_zero]
234    Z = Z[:, np.newaxis]
235    A = Positions[:,2]-Z
236    B = -Vectors[:,2]
237    #times = (Positions[:,2]-Z)/Vectors[:,2]
238    #return A,B
239    with np.errstate(divide='ignore', invalid='ignore'):
240        times = np.divide(A, B, where=(B != 0), out=np.full_like(A, np.nan))
241        #times[times < 0] = np.nan  # Set negative results to NaN
242    #return times
243    #positive_times = times >= 0
244    intersect_positions = Positions[:, :2] + times[:, :, np.newaxis] * Vectors[:, :2]
245    result = []
246    for i in range(Z.shape[0]):
247        # For each plane, we find the intersection points
248        #valid_intersections = intersect_positions[i][positive_times[i]]
249        valid_intersections = intersect_positions[i]
250        result.append(PointArray(valid_intersections))
251    return result
252
253
254# %% Geometrical utilities for plotting
255def SpiralVogel(NbPoint, Radius):
256    """
257    Return a NbPoint x 2 matrix of 2D points representative of Vogel's spiral with radius Radius
258    Careful, contrary to most of the code, this is *not* in the 
259    ARTcore.Vector or ARTcore.Point format. It is a simple numpy array. 
260    The reason is that this is a utility function that can be used both to define directions
261    and to generate grids of points.
262    """
263    GoldenAngle = np.pi * (3 - np.sqrt(5))
264    r = np.sqrt(np.arange(NbPoint) / NbPoint) * Radius
265
266    theta = GoldenAngle * np.arange(NbPoint)
267
268    Matrix = np.zeros((NbPoint, 2))
269    Matrix[:, 0] = np.cos(theta)
270    Matrix[:, 1] = np.sin(theta)
271    Matrix = Matrix * r.reshape((NbPoint, 1))
272
273    return Matrix
274
275def find_hull(points):
276    """
277    Find the convex hull of a set of points using a greedy algorithm.
278    This is used to create a polygon that encloses the points.
279    """
280    # start from leftmost point
281    current_point = min(range(len(points)), key=lambda i: points[i][0])
282    # initialize hull with current point
283    hull = [current_point]
284    # initialize list of linked points
285    linked = []
286    # continue until all points have been linked
287    while len(linked) < len(points) - 1:
288        # initialize minimum distance and closest point
289        min_distance = math.inf
290        closest_point = None
291        # find closest unlinked point to current point
292        for i, point in enumerate(points):
293            if i not in linked:
294                distance = math.dist(points[current_point], point)
295                if distance < min_distance:
296                    min_distance = distance
297                    closest_point = i
298        # add closest point to hull and linked list
299        hull.append(closest_point)
300        linked.append(closest_point)
301        # update current point
302        current_point = closest_point
303    # add link between last point and first point
304    hull.append(hull[0])
305    # convert hull to a list of pairs of indices
306    indices = [[hull[i], hull[i + 1]] for i in range(len(hull) - 1)]
307    return indices
308
309
310# %% Solvers and utilities for solving equations
311def SolverQuadratic(a, b, c):
312    """
313    Solve the quadratic equation a*x^2 + b*x +c = 0 ; keep only real solutions
314    """
315    Solution = np.roots([a, b, c])
316    RealSolution = []
317
318    for k in range(len(Solution)):
319        if abs(Solution[k].imag) < 1e-15:
320            RealSolution.append(Solution[k].real)
321
322    return RealSolution
323
324
325def SolverQuartic(a, b, c, d, e):
326    """
327    Solve the quartic equation a*x^4 + b*x^3 +c*x^2 + d*x + e = 0 ; keep only real solutions
328    """
329    Solution = np.roots([a, b, c, d, e])
330    RealSolution = []
331
332    for k in range(len(Solution)):
333        if abs(Solution[k].imag) < 1e-15:
334            RealSolution.append(Solution[k].real)
335
336    return RealSolution
337
338
339def KeepPositiveSolution(SolutionList):
340    """
341    Keep only positive solution (numbers) in the list
342    """
343    PositiveSolutionList = []
344    epsilon = 1e-12
345    for k in SolutionList:
346        if k > epsilon:
347            PositiveSolutionList.append(k)
348
349    return PositiveSolutionList
350
351
352def KeepNegativeSolution(SolutionList):
353    """
354    Keep only positive solution (numbers) in the list
355    """
356    NegativeSolutionList = []
357    epsilon = -1e-12
358    for k in SolutionList:
359        if k < epsilon:
360            NegativeSolutionList.append(k)
361
362    return NegativeSolutionList
363
364
365# %% Point geometry tools
366def ClosestPoint(A: Point, Points: PointArray):
367    """
368    Given a reference point A and an array of points, return the index of the point closest to A
369    """
370    distances = (Points-A).norm
371    return np.argmin(distances)
372
373def DiameterPointArray(Points: PointArray):
374    """
375    Return the diameter of the smallest circle (for 2D points) 
376    or sphere (3D points) including all the points.
377    """
378    if len(Points) == 0:
379        return None
380    return float(np.ptp(Points, axis=0).max())
381
382def CentrePointList(Points):
383    """
384    Shift all 2D-points in PointList so as to center the point-cloud on the origin [0,0].
385    """
386    return Points - np.mean(Points, axis=0)
387
388# %% Solid object orientation
389
390def RotateSolid(Object, q):
391    """
392    Rotate object around basepoint by quaternion q
393    """
394    Object.q = q*Object.q
395
396def TranslateSolid(Object, T):
397    """
398    Translate object by vector T
399    """
400    Object.r = Object.r + T
401
402def RotateSolidAroundInternalPointByQ(Object, q, P):
403    """
404    Rotate object around P by quaternion q where P is in the object's frame
405    """
406    pass #TODO
407
408def RotateSolidAroundExternalPointByQ(Object, q, P):
409    """Rotate object around P by quaternion q, where P is in the global frame"""
410    pass #TODO
411
412
413# %% Signed distance functions 
414
415def SDF_Rectangle(Point, SizeX, SizeY):
416    """Signed distance function for a rectangle centered at the origin"""
417    d = np.abs(Point[:2]) - np.array([SizeX, SizeY]) / 2
418    return (np.linalg.norm(np.maximum(d, 0)) + np.min(np.max(d, 0)))/2
419
420def SDF_Circle(Point, Radius):
421    """Signed distance function for a circle centered at the origin"""
422    return np.linalg.norm(Point[:2]) - Radius
423
424def Union_SDF(SDF1, SDF2):
425    """Union of two signed distance functions"""
426    return np.minimum(SDF1, SDF2)
427
428def Difference_SDF(SDF1, SDF2):
429    """Difference of two signed distance functions"""
430    return np.maximum(SDF1, -SDF2)
431
432def Intersection_SDF(SDF1, SDF2):
433    """Intersection of two signed distance functions"""
434    return np.maximum(SDF1, SDF2)
435
436
437
438# %% Quaternion calculations
439def QRotationAroundAxis(Axis, Angle):
440    """
441    Return quaternion for rotation by Angle (in rad) around Axis
442    """
443    rot_axis = Normalize(np.array([0.0] + Axis))
444    axis_angle = (Angle * 0.5) * rot_axis
445    qlog = np.quaternion(*axis_angle)
446    q = np.exp(qlog)
447    return q
448
449def QRotationVector2Vector(Vector1, Vector2):
450    """
451    Return a possible quaternion (among many) that would rotate Vector1 into Vector2.
452    Undefined behavior, use with caution. There is no unique quaternion that rotates one vector into another.
453    """
454    Vector1 = Normalize(Vector1)
455    Vector2 = Normalize(Vector2)
456    a = np.cross(Vector1, Vector2)
457    return np.quaternion(1 + np.dot(Vector1, Vector2), *a).normalized()
458
459def QRotationVectorPair2VectorPair(InitialVector1, Vector1, InitialVector2, Vector2):
460    """
461    Return the only quaternion that rotates InitialVector1 to Vector1 and InitialVector2 to Vector2.
462    Please ensure orthogonality two input and two output vectors.
463    """
464    Vector1 = Normalize(Vector1)
465    Vector2 = Normalize(Vector2)
466    Vector3 = Normalize(np.cross(Vector1,Vector2))
467    InitialVector1 = Normalize(InitialVector1)
468    InitialVector2 = Normalize(InitialVector2)
469    InitialVector3 = Normalize(np.cross(InitialVector1,InitialVector2))
470    rot_2Initial = np.zeros((3,3))
471    rot_2Initial[:,0] = InitialVector1
472    rot_2Initial[:,1] = InitialVector2
473    rot_2Initial[:,2] = InitialVector3
474    rot_2Final = np.zeros((3,3))
475    rot_2Final[:,0] = Vector1
476    rot_2Final[:,1] = Vector2
477    rot_2Final[:,2] = Vector3
478    q2Init = quaternion.from_rotation_matrix(rot_2Initial)
479    q2Fin = quaternion.from_rotation_matrix(rot_2Final)
480    return (q2Fin/q2Init).normalized()
481
482
483# %% RayList stuff
484def RotationRayList(RayList, q):
485    """Like RotationPointList but with a list of Ray objects"""
486    return [i.rotate(q) for i in RayList]
487
488def TranslationRayList(RayList, T):
489    """Translate a RayList by vector T"""
490    return [i.translate(T) for i in RayList]
logger = <Logger ARTcore.ModuleGeometry (WARNING)>
class Vector(numpy.ndarray):
27class Vector(np.ndarray):
28    def __new__(cls, input_array):
29        input_array = np.asarray(input_array)
30        if input_array.ndim > 1:
31            return VectorArray(input_array)
32        obj = input_array.view(cls)  # Ensure we are viewing as `Vector`
33        return obj
34    @property
35    def norm(self):
36        return np.linalg.norm(self)
37    def normalized(self):
38        return self / self.norm
39    def __add__(self, other):
40        if isinstance(other, Point):
41            return Point(super().__add__(other))
42        return Vector(super().__add__(other))
43    def __sub__(self, other):
44        if isinstance(other, Point):
45            return Point(super().__sub__(other))
46        return Vector(super().__sub__(other))
47    def translate(self, vector):
48        return self
49    def rotate(self,q):
50        return Vector((q*np.quaternion(0,*self)*q.conj()).imag)
51    def from_basis(self, r0, r, q):
52        return self.rotate(q)
53    def to_basis(self, r0, r, q):
54        return self.rotate(q.conj())
55    def _add_dimension(self, value=0):
56        return Vector(np.concatenate((self, [value])))
57    def __hash__(self) -> int:
58        vector_tuple = tuple(self.reshape(1, -1)[0])
59        return hash(vector_tuple)

ndarray(shape, dtype=float, buffer=None, offset=0, strides=None, order=None)

An array object represents a multidimensional, homogeneous array of fixed-size items. An associated data-type object describes the format of each element in the array (its byte-order, how many bytes it occupies in memory, whether it is an integer, a floating point number, or something else, etc.)

Arrays should be constructed using array, zeros or empty (refer to the See Also section below). The parameters given here refer to a low-level method (ndarray(...)) for instantiating an array.

For more information, refer to the numpy module and examine the methods and attributes of an array.

Parameters

(for the __new__ method; see Notes below)

shape : tuple of ints Shape of created array. dtype : data-type, optional Any object that can be interpreted as a numpy data type. Default is numpy.float64. buffer : object exposing buffer interface, optional Used to fill the array with data. offset : int, optional Offset of array data in buffer. strides : tuple of ints, optional Strides of data in memory. order : {'C', 'F'}, optional Row-major (C-style) or column-major (Fortran-style) order.

Attributes

T : ndarray Transpose of the array. data : buffer The array's elements, in memory. dtype : dtype object Describes the format of the elements in the array. flags : dict Dictionary containing information related to memory use, e.g., 'C_CONTIGUOUS', 'OWNDATA', 'WRITEABLE', etc. flat : numpy.flatiter object Flattened version of the array as an iterator. The iterator allows assignments, e.g., x.flat = 3 (See ndarray.flat for assignment examples; TODO). imag : ndarray Imaginary part of the array. real : ndarray Real part of the array. size : int Number of elements in the array. itemsize : int The memory use of each array element in bytes. nbytes : int The total number of bytes required to store the array data, i.e., itemsize * size. ndim : int The array's number of dimensions. shape : tuple of ints Shape of the array. strides : tuple of ints The step-size required to move from one element to the next in memory. For example, a contiguous (3, 4) array of type int16 in C-order has strides (8, 2). This implies that to move from element to element in memory requires jumps of 2 bytes. To move from row-to-row, one needs to jump 8 bytes at a time (2 * 4). ctypes : ctypes object Class containing properties of the array needed for interaction with ctypes. base : ndarray If the array is a view into another array, that array is its base (unless that array is also a view). The base array is where the array data is actually stored.

See Also

array : Construct an array. zeros : Create an array, each element of which is zero. empty : Create an array, but leave its allocated memory unchanged (i.e., it contains "garbage"). dtype : Create a data-type. numpy.typing.NDArray : An ndarray alias :term:generic <generic type> w.r.t. its dtype.type <numpy.dtype.type>.

Notes

There are two modes of creating an array using __new__:

  1. If buffer is None, then only shape, dtype, and order are used.
  2. If buffer is an object exposing the buffer interface, then all keywords are interpreted.

No __init__ method is needed because the array is fully initialized after the __new__ method.

Examples

These examples illustrate the low-level ndarray constructor. Refer to the See Also section above for easier ways of constructing an ndarray.

First mode, buffer is None:

>>> import numpy as np
>>> np.ndarray(shape=(2,2), dtype=float, order='F')
array([[0.0e+000, 0.0e+000], # random
       [     nan, 2.5e-323]])

Second mode:

>>> np.ndarray((2,), buffer=np.array([1,2,3]),
...            offset=np.int_().itemsize,
...            dtype=int) # offset = 1*itemsize, i.e. skip first element
array([2, 3])
norm
34    @property
35    def norm(self):
36        return np.linalg.norm(self)
def normalized(self):
37    def normalized(self):
38        return self / self.norm
def translate(self, vector):
47    def translate(self, vector):
48        return self
def rotate(self, q):
49    def rotate(self,q):
50        return Vector((q*np.quaternion(0,*self)*q.conj()).imag)
def from_basis(self, r0, r, q):
51    def from_basis(self, r0, r, q):
52        return self.rotate(q)
def to_basis(self, r0, r, q):
53    def to_basis(self, r0, r, q):
54        return self.rotate(q.conj())
class VectorArray(numpy.ndarray):
60class VectorArray(np.ndarray):
61    def __new__(cls, input_array):
62        input_array = np.asarray(input_array)
63        if input_array.ndim == 1:
64            return Vector(input_array)
65        obj = input_array.view(cls)  # Ensure we are viewing as `Vector`
66        return obj
67    @property
68    def norm(self):
69        return np.linalg.norm(self, axis=1)
70    def __getitem__(self, index):
71        return Vector(super().__getitem__(index))
72    def normalized(self):
73        return self / self.norm[:, np.newaxis]
74    def __add__(self, other):
75        if isinstance(other, Point):
76            return PointArray(super().__add__(other))
77        return VectorArray(super().__add__(other))
78    def __sub__(self, other):
79        if isinstance(other, Point):
80            return PointArray(super().__sub__(other))
81        return VectorArray(super().__sub__(other))
82    def translate(self, vector):
83        return self
84    def rotate(self,q):
85        return VectorArray(quaternion.rotate_vectors(q, self))
86    def from_basis(self, r0, r, q):
87        return self.rotate(q)
88    def to_basis(self, r0, r, q):
89        return self.rotate(q.conj())
90    def _add_dimension(self, values=0):
91        if values == 0:
92            values = np.zeros((self.shape[0], 1))
93        if not isinstance(values, np.ndarray):
94            values = np.array(values)
95        return VectorArray(np.concatenate((self, values), axis=1))

ndarray(shape, dtype=float, buffer=None, offset=0, strides=None, order=None)

An array object represents a multidimensional, homogeneous array of fixed-size items. An associated data-type object describes the format of each element in the array (its byte-order, how many bytes it occupies in memory, whether it is an integer, a floating point number, or something else, etc.)

Arrays should be constructed using array, zeros or empty (refer to the See Also section below). The parameters given here refer to a low-level method (ndarray(...)) for instantiating an array.

For more information, refer to the numpy module and examine the methods and attributes of an array.

Parameters

(for the __new__ method; see Notes below)

shape : tuple of ints Shape of created array. dtype : data-type, optional Any object that can be interpreted as a numpy data type. Default is numpy.float64. buffer : object exposing buffer interface, optional Used to fill the array with data. offset : int, optional Offset of array data in buffer. strides : tuple of ints, optional Strides of data in memory. order : {'C', 'F'}, optional Row-major (C-style) or column-major (Fortran-style) order.

Attributes

T : ndarray Transpose of the array. data : buffer The array's elements, in memory. dtype : dtype object Describes the format of the elements in the array. flags : dict Dictionary containing information related to memory use, e.g., 'C_CONTIGUOUS', 'OWNDATA', 'WRITEABLE', etc. flat : numpy.flatiter object Flattened version of the array as an iterator. The iterator allows assignments, e.g., x.flat = 3 (See ndarray.flat for assignment examples; TODO). imag : ndarray Imaginary part of the array. real : ndarray Real part of the array. size : int Number of elements in the array. itemsize : int The memory use of each array element in bytes. nbytes : int The total number of bytes required to store the array data, i.e., itemsize * size. ndim : int The array's number of dimensions. shape : tuple of ints Shape of the array. strides : tuple of ints The step-size required to move from one element to the next in memory. For example, a contiguous (3, 4) array of type int16 in C-order has strides (8, 2). This implies that to move from element to element in memory requires jumps of 2 bytes. To move from row-to-row, one needs to jump 8 bytes at a time (2 * 4). ctypes : ctypes object Class containing properties of the array needed for interaction with ctypes. base : ndarray If the array is a view into another array, that array is its base (unless that array is also a view). The base array is where the array data is actually stored.

See Also

array : Construct an array. zeros : Create an array, each element of which is zero. empty : Create an array, but leave its allocated memory unchanged (i.e., it contains "garbage"). dtype : Create a data-type. numpy.typing.NDArray : An ndarray alias :term:generic <generic type> w.r.t. its dtype.type <numpy.dtype.type>.

Notes

There are two modes of creating an array using __new__:

  1. If buffer is None, then only shape, dtype, and order are used.
  2. If buffer is an object exposing the buffer interface, then all keywords are interpreted.

No __init__ method is needed because the array is fully initialized after the __new__ method.

Examples

These examples illustrate the low-level ndarray constructor. Refer to the See Also section above for easier ways of constructing an ndarray.

First mode, buffer is None:

>>> import numpy as np
>>> np.ndarray(shape=(2,2), dtype=float, order='F')
array([[0.0e+000, 0.0e+000], # random
       [     nan, 2.5e-323]])

Second mode:

>>> np.ndarray((2,), buffer=np.array([1,2,3]),
...            offset=np.int_().itemsize,
...            dtype=int) # offset = 1*itemsize, i.e. skip first element
array([2, 3])
norm
67    @property
68    def norm(self):
69        return np.linalg.norm(self, axis=1)
def normalized(self):
72    def normalized(self):
73        return self / self.norm[:, np.newaxis]
def translate(self, vector):
82    def translate(self, vector):
83        return self
def rotate(self, q):
84    def rotate(self,q):
85        return VectorArray(quaternion.rotate_vectors(q, self))
def from_basis(self, r0, r, q):
86    def from_basis(self, r0, r, q):
87        return self.rotate(q)
def to_basis(self, r0, r, q):
88    def to_basis(self, r0, r, q):
89        return self.rotate(q.conj())
class Point(numpy.ndarray):
 97class Point(np.ndarray):
 98    def __new__(cls, input_array):
 99        input_array = np.asarray(input_array)
100        if input_array.ndim > 1:
101            return PointArray(input_array)
102        obj = input_array.view(cls)  # Ensure we are viewing as `Point`
103        return obj
104    def __add__(self, other):
105        return Point(super().__add__(other))
106    def __sub__(self, other):
107        return Vector(super().__sub__(other))
108    def translate(self, vector):
109        return Point(super()._add__(vector))
110    def rotate(self,q):
111        return (self-Origin).rotate(q)
112    def from_basis(self, r0, r, q):
113        return Point([0,0,0]) + r0 + Vector(self-r0).rotate(q) + r
114    def to_basis(self, r0, r, q):
115        return Point([0,0,0]) + r0 + Vector(self-r0-r).rotate(q.conj())
116    def _add_dimension(self, value=0):
117        return Point(np.concatenate((self, [value])))
118    def __hash__(self) -> int:
119        point_tuple = tuple(self.reshape(1, -1)[0])
120        return hash(point_tuple)

ndarray(shape, dtype=float, buffer=None, offset=0, strides=None, order=None)

An array object represents a multidimensional, homogeneous array of fixed-size items. An associated data-type object describes the format of each element in the array (its byte-order, how many bytes it occupies in memory, whether it is an integer, a floating point number, or something else, etc.)

Arrays should be constructed using array, zeros or empty (refer to the See Also section below). The parameters given here refer to a low-level method (ndarray(...)) for instantiating an array.

For more information, refer to the numpy module and examine the methods and attributes of an array.

Parameters

(for the __new__ method; see Notes below)

shape : tuple of ints Shape of created array. dtype : data-type, optional Any object that can be interpreted as a numpy data type. Default is numpy.float64. buffer : object exposing buffer interface, optional Used to fill the array with data. offset : int, optional Offset of array data in buffer. strides : tuple of ints, optional Strides of data in memory. order : {'C', 'F'}, optional Row-major (C-style) or column-major (Fortran-style) order.

Attributes

T : ndarray Transpose of the array. data : buffer The array's elements, in memory. dtype : dtype object Describes the format of the elements in the array. flags : dict Dictionary containing information related to memory use, e.g., 'C_CONTIGUOUS', 'OWNDATA', 'WRITEABLE', etc. flat : numpy.flatiter object Flattened version of the array as an iterator. The iterator allows assignments, e.g., x.flat = 3 (See ndarray.flat for assignment examples; TODO). imag : ndarray Imaginary part of the array. real : ndarray Real part of the array. size : int Number of elements in the array. itemsize : int The memory use of each array element in bytes. nbytes : int The total number of bytes required to store the array data, i.e., itemsize * size. ndim : int The array's number of dimensions. shape : tuple of ints Shape of the array. strides : tuple of ints The step-size required to move from one element to the next in memory. For example, a contiguous (3, 4) array of type int16 in C-order has strides (8, 2). This implies that to move from element to element in memory requires jumps of 2 bytes. To move from row-to-row, one needs to jump 8 bytes at a time (2 * 4). ctypes : ctypes object Class containing properties of the array needed for interaction with ctypes. base : ndarray If the array is a view into another array, that array is its base (unless that array is also a view). The base array is where the array data is actually stored.

See Also

array : Construct an array. zeros : Create an array, each element of which is zero. empty : Create an array, but leave its allocated memory unchanged (i.e., it contains "garbage"). dtype : Create a data-type. numpy.typing.NDArray : An ndarray alias :term:generic <generic type> w.r.t. its dtype.type <numpy.dtype.type>.

Notes

There are two modes of creating an array using __new__:

  1. If buffer is None, then only shape, dtype, and order are used.
  2. If buffer is an object exposing the buffer interface, then all keywords are interpreted.

No __init__ method is needed because the array is fully initialized after the __new__ method.

Examples

These examples illustrate the low-level ndarray constructor. Refer to the See Also section above for easier ways of constructing an ndarray.

First mode, buffer is None:

>>> import numpy as np
>>> np.ndarray(shape=(2,2), dtype=float, order='F')
array([[0.0e+000, 0.0e+000], # random
       [     nan, 2.5e-323]])

Second mode:

>>> np.ndarray((2,), buffer=np.array([1,2,3]),
...            offset=np.int_().itemsize,
...            dtype=int) # offset = 1*itemsize, i.e. skip first element
array([2, 3])
def translate(self, vector):
108    def translate(self, vector):
109        return Point(super()._add__(vector))
def rotate(self, q):
110    def rotate(self,q):
111        return (self-Origin).rotate(q)
def from_basis(self, r0, r, q):
112    def from_basis(self, r0, r, q):
113        return Point([0,0,0]) + r0 + Vector(self-r0).rotate(q) + r
def to_basis(self, r0, r, q):
114    def to_basis(self, r0, r, q):
115        return Point([0,0,0]) + r0 + Vector(self-r0-r).rotate(q.conj())
class PointArray(numpy.ndarray):
122class PointArray(np.ndarray):
123    def __new__(cls, input_array):
124        input_array = np.asarray(input_array)
125        if input_array.ndim == 1:
126            return Point(input_array)
127        obj = input_array.view(cls)  # Ensure we are viewing as `Point`
128        return obj
129    def __getitem__(self, index):
130        return Point(super().__getitem__(index))
131    def __add__(self, other):
132        return PointArray(super().__add__(other))
133    def __sub__(self, other):
134        return VectorArray(super().__sub__(other))
135    def translate(self, vector):
136        return PointArray(super().__add__(vector))
137    def rotate(self,q):
138        return PointArray(quaternion.rotate_vectors(q, self))
139    def from_basis(self, r0, r, q):
140        return Point([0,0,0]) + r0 + VectorArray(self-r0).rotate(q) + r
141        #return PointArray([Point([0,0,0]) + r0 + (p-r0).rotate(q) + r for p in self])
142    def to_basis(self, r0, r, q):
143        return Point([0,0,0]) + r0 + VectorArray(self-r0-r).rotate(q.conj())
144        #return PointArray([Point([0,0,0]) + r0 + (p-r0-r).rotate(q.conj()) for p in self])
145    def _add_dimension(self, values=0):
146        if values == 0:
147            values = np.zeros((self.shape[0], 1))
148        if not isinstance(values, np.ndarray):
149            values = np.array(values)
150        return PointArray(np.concatenate((self, values), axis=1))

ndarray(shape, dtype=float, buffer=None, offset=0, strides=None, order=None)

An array object represents a multidimensional, homogeneous array of fixed-size items. An associated data-type object describes the format of each element in the array (its byte-order, how many bytes it occupies in memory, whether it is an integer, a floating point number, or something else, etc.)

Arrays should be constructed using array, zeros or empty (refer to the See Also section below). The parameters given here refer to a low-level method (ndarray(...)) for instantiating an array.

For more information, refer to the numpy module and examine the methods and attributes of an array.

Parameters

(for the __new__ method; see Notes below)

shape : tuple of ints Shape of created array. dtype : data-type, optional Any object that can be interpreted as a numpy data type. Default is numpy.float64. buffer : object exposing buffer interface, optional Used to fill the array with data. offset : int, optional Offset of array data in buffer. strides : tuple of ints, optional Strides of data in memory. order : {'C', 'F'}, optional Row-major (C-style) or column-major (Fortran-style) order.

Attributes

T : ndarray Transpose of the array. data : buffer The array's elements, in memory. dtype : dtype object Describes the format of the elements in the array. flags : dict Dictionary containing information related to memory use, e.g., 'C_CONTIGUOUS', 'OWNDATA', 'WRITEABLE', etc. flat : numpy.flatiter object Flattened version of the array as an iterator. The iterator allows assignments, e.g., x.flat = 3 (See ndarray.flat for assignment examples; TODO). imag : ndarray Imaginary part of the array. real : ndarray Real part of the array. size : int Number of elements in the array. itemsize : int The memory use of each array element in bytes. nbytes : int The total number of bytes required to store the array data, i.e., itemsize * size. ndim : int The array's number of dimensions. shape : tuple of ints Shape of the array. strides : tuple of ints The step-size required to move from one element to the next in memory. For example, a contiguous (3, 4) array of type int16 in C-order has strides (8, 2). This implies that to move from element to element in memory requires jumps of 2 bytes. To move from row-to-row, one needs to jump 8 bytes at a time (2 * 4). ctypes : ctypes object Class containing properties of the array needed for interaction with ctypes. base : ndarray If the array is a view into another array, that array is its base (unless that array is also a view). The base array is where the array data is actually stored.

See Also

array : Construct an array. zeros : Create an array, each element of which is zero. empty : Create an array, but leave its allocated memory unchanged (i.e., it contains "garbage"). dtype : Create a data-type. numpy.typing.NDArray : An ndarray alias :term:generic <generic type> w.r.t. its dtype.type <numpy.dtype.type>.

Notes

There are two modes of creating an array using __new__:

  1. If buffer is None, then only shape, dtype, and order are used.
  2. If buffer is an object exposing the buffer interface, then all keywords are interpreted.

No __init__ method is needed because the array is fully initialized after the __new__ method.

Examples

These examples illustrate the low-level ndarray constructor. Refer to the See Also section above for easier ways of constructing an ndarray.

First mode, buffer is None:

>>> import numpy as np
>>> np.ndarray(shape=(2,2), dtype=float, order='F')
array([[0.0e+000, 0.0e+000], # random
       [     nan, 2.5e-323]])

Second mode:

>>> np.ndarray((2,), buffer=np.array([1,2,3]),
...            offset=np.int_().itemsize,
...            dtype=int) # offset = 1*itemsize, i.e. skip first element
array([2, 3])
def translate(self, vector):
135    def translate(self, vector):
136        return PointArray(super().__add__(vector))
def rotate(self, q):
137    def rotate(self,q):
138        return PointArray(quaternion.rotate_vectors(q, self))
def from_basis(self, r0, r, q):
139    def from_basis(self, r0, r, q):
140        return Point([0,0,0]) + r0 + VectorArray(self-r0).rotate(q) + r
141        #return PointArray([Point([0,0,0]) + r0 + (p-r0).rotate(q) + r for p in self])
def to_basis(self, r0, r, q):
142    def to_basis(self, r0, r, q):
143        return Point([0,0,0]) + r0 + VectorArray(self-r0-r).rotate(q.conj())
144        #return PointArray([Point([0,0,0]) + r0 + (p-r0-r).rotate(q.conj()) for p in self])
Origin = Point([0, 0, 0])
def Normalize(vector):
155def Normalize(vector):
156    """
157    Normalize Vector.
158    Obsolete, use use the mgeo.Vector class instead as it has a `normalize` method.
159    """
160    return vector / np.linalg.norm(vector)

Normalize Vector. Obsolete, use use the mgeo.Vector class instead as it has a normalize method.

def VectorPerpendicular(vector):
162def VectorPerpendicular(vector):
163    """
164    Find a perpendicular 3D vector in some arbitrary direction
165    Undefined behavior, use with caution. There is no unique perpendicular vector to a 3D vector.
166    """
167    logger.warning("VectorPerpendicular is undefined behavior. There is no unique perpendicular vector to a 3D vector.")
168    if abs(vector[0]) < 1e-15:
169        return Vector([1, 0, 0])
170    if abs(vector[1]) < 1e-15:
171        return Vector([0, 1, 0])
172    if abs(vector[2]) < 1e-15:
173        return Vector([0, 0, 1])
174
175    # set arbitrarily a = b =1
176    return Vector([1, 1, -1.0 * (vector[0] + vector[1]) / vector[2]]).normalized()

Find a perpendicular 3D vector in some arbitrary direction Undefined behavior, use with caution. There is no unique perpendicular vector to a 3D vector.

def AngleBetweenTwoVectors(U, V):
178def AngleBetweenTwoVectors(U, V):
179    """
180    Return the angle in radians between the vectors U and V ; formula from W.Kahan
181    Value in radians between 0 and pi.
182    """
183    u = np.linalg.norm(U)
184    v = np.linalg.norm(V)
185    return 2 * np.arctan2(np.linalg.norm(U * v - V * u), np.linalg.norm(U * v + V * u))

Return the angle in radians between the vectors U and V ; formula from W.Kahan Value in radians between 0 and pi.

def SymmetricalVector(V, SymmetryAxis):
187def SymmetricalVector(V, SymmetryAxis):
188    """
189    Return the symmetrical vector to V
190    """
191    q = QRotationAroundAxis(SymmetryAxis, np.pi)
192    return V.rotate(q)

Return the symmetrical vector to V

def normal_add(N1, N2):
194def normal_add(N1, N2):
195    """
196    Simple function that takes in two normal vectors of a deformation and calculates
197    the total normal vector if the two deformations were individually applied.
198    Be very careful, this *only* works when the surface is z = f(x,y) and when 
199    the deformation is small.
200    Might be made obsolete when shifting to a modernised deformation system.
201    """
202    normal1 = N1.normalized()
203    normal2 = N2.normalized()
204    grad1 = -normal1[:2] / normal1[2]
205    grad2 = -normal2[:2] / normal2[2]
206    grad = grad1 + grad2
207    total_normal = np.append(-grad, 1)
208    return Vector(total_normal).normalized()

Simple function that takes in two normal vectors of a deformation and calculates the total normal vector if the two deformations were individually applied. Be very careful, this only works when the surface is z = f(x,y) and when the deformation is small. Might be made obsolete when shifting to a modernised deformation system.

def IntersectionLinePlane(A, u, P, n):
211def IntersectionLinePlane(A, u, P, n):
212    """
213    Return the intersection point between a line and a plane.
214    A is a point of the line, u a vector of the line ; P is a point of the plane, n a normal vector
215    Line's equation : OM = u*t + OA , t a real
216    Plane's equation : n.OP - n.OM = 0
217    """
218    t = np.dot(n, -A + P) / np.dot(u, n)
219    I = u * t + A
220    return I

Return the intersection point between a line and a plane. A is a point of the line, u a vector of the line ; P is a point of the plane, n a normal vector Line's equation : OM = u*t + OA , t a real Plane's equation : n.OP - n.OM = 0

def IntersectionRayListZPlane(RayList, Z=array([0])):
222def IntersectionRayListZPlane(RayList, Z=np.array([0])):
223    """
224    Return the intersection of a list of rays with a different planes with equiations z = Z[i]
225    Basically, by default it returns the intersection of the rays with the Z=0 plane but you can 
226    give it a few values of Z and it should be faster than calling it multiple times.
227    This should let us quickly find the optimal position of the detector as well as trace the caustics.
228    If a ray does not intersect the plane... it should replace that point with a NaN. 
229    """
230    Positions = np.vstack([i.point for i in RayList])
231    Vectors = np.vstack([i.vector for i in RayList])
232    non_zero = Vectors[:,2] != 0
233    Positions = Positions[non_zero]
234    Vectors = Vectors[non_zero]
235    Z = Z[:, np.newaxis]
236    A = Positions[:,2]-Z
237    B = -Vectors[:,2]
238    #times = (Positions[:,2]-Z)/Vectors[:,2]
239    #return A,B
240    with np.errstate(divide='ignore', invalid='ignore'):
241        times = np.divide(A, B, where=(B != 0), out=np.full_like(A, np.nan))
242        #times[times < 0] = np.nan  # Set negative results to NaN
243    #return times
244    #positive_times = times >= 0
245    intersect_positions = Positions[:, :2] + times[:, :, np.newaxis] * Vectors[:, :2]
246    result = []
247    for i in range(Z.shape[0]):
248        # For each plane, we find the intersection points
249        #valid_intersections = intersect_positions[i][positive_times[i]]
250        valid_intersections = intersect_positions[i]
251        result.append(PointArray(valid_intersections))
252    return result

Return the intersection of a list of rays with a different planes with equiations z = Z[i] Basically, by default it returns the intersection of the rays with the Z=0 plane but you can give it a few values of Z and it should be faster than calling it multiple times. This should let us quickly find the optimal position of the detector as well as trace the caustics. If a ray does not intersect the plane... it should replace that point with a NaN.

def SpiralVogel(NbPoint, Radius):
256def SpiralVogel(NbPoint, Radius):
257    """
258    Return a NbPoint x 2 matrix of 2D points representative of Vogel's spiral with radius Radius
259    Careful, contrary to most of the code, this is *not* in the 
260    ARTcore.Vector or ARTcore.Point format. It is a simple numpy array. 
261    The reason is that this is a utility function that can be used both to define directions
262    and to generate grids of points.
263    """
264    GoldenAngle = np.pi * (3 - np.sqrt(5))
265    r = np.sqrt(np.arange(NbPoint) / NbPoint) * Radius
266
267    theta = GoldenAngle * np.arange(NbPoint)
268
269    Matrix = np.zeros((NbPoint, 2))
270    Matrix[:, 0] = np.cos(theta)
271    Matrix[:, 1] = np.sin(theta)
272    Matrix = Matrix * r.reshape((NbPoint, 1))
273
274    return Matrix

Return a NbPoint x 2 matrix of 2D points representative of Vogel's spiral with radius Radius Careful, contrary to most of the code, this is not in the ARTcore.Vector or ARTcore.Point format. It is a simple numpy array. The reason is that this is a utility function that can be used both to define directions and to generate grids of points.

def find_hull(points):
276def find_hull(points):
277    """
278    Find the convex hull of a set of points using a greedy algorithm.
279    This is used to create a polygon that encloses the points.
280    """
281    # start from leftmost point
282    current_point = min(range(len(points)), key=lambda i: points[i][0])
283    # initialize hull with current point
284    hull = [current_point]
285    # initialize list of linked points
286    linked = []
287    # continue until all points have been linked
288    while len(linked) < len(points) - 1:
289        # initialize minimum distance and closest point
290        min_distance = math.inf
291        closest_point = None
292        # find closest unlinked point to current point
293        for i, point in enumerate(points):
294            if i not in linked:
295                distance = math.dist(points[current_point], point)
296                if distance < min_distance:
297                    min_distance = distance
298                    closest_point = i
299        # add closest point to hull and linked list
300        hull.append(closest_point)
301        linked.append(closest_point)
302        # update current point
303        current_point = closest_point
304    # add link between last point and first point
305    hull.append(hull[0])
306    # convert hull to a list of pairs of indices
307    indices = [[hull[i], hull[i + 1]] for i in range(len(hull) - 1)]
308    return indices

Find the convex hull of a set of points using a greedy algorithm. This is used to create a polygon that encloses the points.

def SolverQuadratic(a, b, c):
312def SolverQuadratic(a, b, c):
313    """
314    Solve the quadratic equation a*x^2 + b*x +c = 0 ; keep only real solutions
315    """
316    Solution = np.roots([a, b, c])
317    RealSolution = []
318
319    for k in range(len(Solution)):
320        if abs(Solution[k].imag) < 1e-15:
321            RealSolution.append(Solution[k].real)
322
323    return RealSolution

Solve the quadratic equation ax^2 + bx +c = 0 ; keep only real solutions

def SolverQuartic(a, b, c, d, e):
326def SolverQuartic(a, b, c, d, e):
327    """
328    Solve the quartic equation a*x^4 + b*x^3 +c*x^2 + d*x + e = 0 ; keep only real solutions
329    """
330    Solution = np.roots([a, b, c, d, e])
331    RealSolution = []
332
333    for k in range(len(Solution)):
334        if abs(Solution[k].imag) < 1e-15:
335            RealSolution.append(Solution[k].real)
336
337    return RealSolution

Solve the quartic equation ax^4 + bx^3 +cx^2 + dx + e = 0 ; keep only real solutions

def KeepPositiveSolution(SolutionList):
340def KeepPositiveSolution(SolutionList):
341    """
342    Keep only positive solution (numbers) in the list
343    """
344    PositiveSolutionList = []
345    epsilon = 1e-12
346    for k in SolutionList:
347        if k > epsilon:
348            PositiveSolutionList.append(k)
349
350    return PositiveSolutionList

Keep only positive solution (numbers) in the list

def KeepNegativeSolution(SolutionList):
353def KeepNegativeSolution(SolutionList):
354    """
355    Keep only positive solution (numbers) in the list
356    """
357    NegativeSolutionList = []
358    epsilon = -1e-12
359    for k in SolutionList:
360        if k < epsilon:
361            NegativeSolutionList.append(k)
362
363    return NegativeSolutionList

Keep only positive solution (numbers) in the list

def ClosestPoint( A: Point, Points: PointArray):
367def ClosestPoint(A: Point, Points: PointArray):
368    """
369    Given a reference point A and an array of points, return the index of the point closest to A
370    """
371    distances = (Points-A).norm
372    return np.argmin(distances)

Given a reference point A and an array of points, return the index of the point closest to A

def DiameterPointArray(Points: PointArray):
374def DiameterPointArray(Points: PointArray):
375    """
376    Return the diameter of the smallest circle (for 2D points) 
377    or sphere (3D points) including all the points.
378    """
379    if len(Points) == 0:
380        return None
381    return float(np.ptp(Points, axis=0).max())

Return the diameter of the smallest circle (for 2D points) or sphere (3D points) including all the points.

def CentrePointList(Points):
383def CentrePointList(Points):
384    """
385    Shift all 2D-points in PointList so as to center the point-cloud on the origin [0,0].
386    """
387    return Points - np.mean(Points, axis=0)

Shift all 2D-points in PointList so as to center the point-cloud on the origin [0,0].

def RotateSolid(Object, q):
391def RotateSolid(Object, q):
392    """
393    Rotate object around basepoint by quaternion q
394    """
395    Object.q = q*Object.q

Rotate object around basepoint by quaternion q

def TranslateSolid(Object, T):
397def TranslateSolid(Object, T):
398    """
399    Translate object by vector T
400    """
401    Object.r = Object.r + T

Translate object by vector T

def RotateSolidAroundInternalPointByQ(Object, q, P):
403def RotateSolidAroundInternalPointByQ(Object, q, P):
404    """
405    Rotate object around P by quaternion q where P is in the object's frame
406    """
407    pass #TODO

Rotate object around P by quaternion q where P is in the object's frame

def RotateSolidAroundExternalPointByQ(Object, q, P):
409def RotateSolidAroundExternalPointByQ(Object, q, P):
410    """Rotate object around P by quaternion q, where P is in the global frame"""
411    pass #TODO

Rotate object around P by quaternion q, where P is in the global frame

def SDF_Rectangle(Point, SizeX, SizeY):
416def SDF_Rectangle(Point, SizeX, SizeY):
417    """Signed distance function for a rectangle centered at the origin"""
418    d = np.abs(Point[:2]) - np.array([SizeX, SizeY]) / 2
419    return (np.linalg.norm(np.maximum(d, 0)) + np.min(np.max(d, 0)))/2

Signed distance function for a rectangle centered at the origin

def SDF_Circle(Point, Radius):
421def SDF_Circle(Point, Radius):
422    """Signed distance function for a circle centered at the origin"""
423    return np.linalg.norm(Point[:2]) - Radius

Signed distance function for a circle centered at the origin

def Union_SDF(SDF1, SDF2):
425def Union_SDF(SDF1, SDF2):
426    """Union of two signed distance functions"""
427    return np.minimum(SDF1, SDF2)

Union of two signed distance functions

def Difference_SDF(SDF1, SDF2):
429def Difference_SDF(SDF1, SDF2):
430    """Difference of two signed distance functions"""
431    return np.maximum(SDF1, -SDF2)

Difference of two signed distance functions

def Intersection_SDF(SDF1, SDF2):
433def Intersection_SDF(SDF1, SDF2):
434    """Intersection of two signed distance functions"""
435    return np.maximum(SDF1, SDF2)

Intersection of two signed distance functions

def QRotationAroundAxis(Axis, Angle):
440def QRotationAroundAxis(Axis, Angle):
441    """
442    Return quaternion for rotation by Angle (in rad) around Axis
443    """
444    rot_axis = Normalize(np.array([0.0] + Axis))
445    axis_angle = (Angle * 0.5) * rot_axis
446    qlog = np.quaternion(*axis_angle)
447    q = np.exp(qlog)
448    return q

Return quaternion for rotation by Angle (in rad) around Axis

def QRotationVector2Vector(Vector1, Vector2):
450def QRotationVector2Vector(Vector1, Vector2):
451    """
452    Return a possible quaternion (among many) that would rotate Vector1 into Vector2.
453    Undefined behavior, use with caution. There is no unique quaternion that rotates one vector into another.
454    """
455    Vector1 = Normalize(Vector1)
456    Vector2 = Normalize(Vector2)
457    a = np.cross(Vector1, Vector2)
458    return np.quaternion(1 + np.dot(Vector1, Vector2), *a).normalized()

Return a possible quaternion (among many) that would rotate Vector1 into Vector2. Undefined behavior, use with caution. There is no unique quaternion that rotates one vector into another.

def QRotationVectorPair2VectorPair(InitialVector1, Vector1, InitialVector2, Vector2):
460def QRotationVectorPair2VectorPair(InitialVector1, Vector1, InitialVector2, Vector2):
461    """
462    Return the only quaternion that rotates InitialVector1 to Vector1 and InitialVector2 to Vector2.
463    Please ensure orthogonality two input and two output vectors.
464    """
465    Vector1 = Normalize(Vector1)
466    Vector2 = Normalize(Vector2)
467    Vector3 = Normalize(np.cross(Vector1,Vector2))
468    InitialVector1 = Normalize(InitialVector1)
469    InitialVector2 = Normalize(InitialVector2)
470    InitialVector3 = Normalize(np.cross(InitialVector1,InitialVector2))
471    rot_2Initial = np.zeros((3,3))
472    rot_2Initial[:,0] = InitialVector1
473    rot_2Initial[:,1] = InitialVector2
474    rot_2Initial[:,2] = InitialVector3
475    rot_2Final = np.zeros((3,3))
476    rot_2Final[:,0] = Vector1
477    rot_2Final[:,1] = Vector2
478    rot_2Final[:,2] = Vector3
479    q2Init = quaternion.from_rotation_matrix(rot_2Initial)
480    q2Fin = quaternion.from_rotation_matrix(rot_2Final)
481    return (q2Fin/q2Init).normalized()

Return the only quaternion that rotates InitialVector1 to Vector1 and InitialVector2 to Vector2. Please ensure orthogonality two input and two output vectors.

def RotationRayList(RayList, q):
485def RotationRayList(RayList, q):
486    """Like RotationPointList but with a list of Ray objects"""
487    return [i.rotate(q) for i in RayList]

Like RotationPointList but with a list of Ray objects

def TranslationRayList(RayList, T):
489def TranslationRayList(RayList, T):
490    """Translate a RayList by vector T"""
491    return [i.translate(T) for i in RayList]

Translate a RayList by vector T

4 - ModuleMask

Provides a class for masks, which are a type of optical element that simply stops rays that hit it.

Also provides the function TransmitMaskRayList that returns the rays transmitted by the mask.

Illustration the Mask-class.

Created in 2021

@author: Stefan Haessler + André Kalouguine

  1"""
  2Provides a class for masks, which are a type of optical element that simply stops rays that hit it.
  3
  4Also provides the function *TransmitMaskRayList* that returns the rays transmitted by the mask.
  5
  6![Illustration the Mask-class.](Mask.svg)
  7
  8
  9
 10Created in 2021
 11
 12@author: Stefan Haessler + André Kalouguine
 13"""
 14# %% Modules
 15import ARTcore.ModuleGeometry as mgeo
 16from ARTcore.ModuleGeometry import Point, Vector, Origin
 17import ARTcore.ModuleSupport as msup
 18import ARTcore.ModuleOpticalRay as mray
 19import ARTcore.ModuleOpticalElement as moe
 20import ARTcore.ModuleMirror as mmirror
 21
 22import numpy as np
 23from copy import copy
 24import logging
 25
 26logger = logging.getLogger(__name__)
 27
 28# %%############################################################################
 29class Mask(moe.OpticalElement):
 30    """
 31    A mask: a plane surface of the shape of its [Support](ModuleSupport.html), which stops all rays that hit it,
 32    while all other rays continue their path unchanged. No diffraction effects.
 33
 34    Attributes
 35    ----------
 36        support : [Support](ModuleSupport.html)-object
 37
 38        type : str 'Mask'.
 39
 40    Methods
 41    ----------
 42        get_local_normal(Point)
 43
 44        get_centre()
 45
 46        get_grid3D(NbPoints)
 47
 48    """
 49
 50    def __init__(self, Support, **kwargs):
 51        """
 52        Parameters
 53        ----------
 54            Support : [Support](ModuleSupport.html)-object
 55        """
 56        self.type = "Mask"
 57        self.support = Support
 58        self.curvature = mmirror.Curvature.FLAT
 59
 60        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
 61        self._r = mgeo.Vector([0.0, 0.0, 0.0])
 62        self._q = np.quaternion(1)
 63
 64        self.centre_ref = mgeo.Point([0, 0, 0])
 65
 66        self.support_normal_ref = mgeo.Vector([0, 0, 1])
 67        self.normal_ref = mgeo.Vector([0, 0, 1])
 68        self.majoraxis_ref = mgeo.Vector([1, 0, 0])
 69
 70        self.add_global_vectors("support_normal", "normal", "majoraxis")
 71        self.add_global_points("centre")
 72
 73        super().__init__()
 74
 75    def _get_intersection(self, Ray):
 76        """
 77        Return the intersection point between Ray and the xy-plane.
 78        If not in alignment mode, any intersection point that is on the support is blocked.
 79        """
 80        t = -Ray.point[2] / Ray.vector[2]
 81        I = mgeo.Point(Ray.point +Ray.vector * t)
 82        return I, I in self.support
 83    
 84    def _get_intersections(self, RayList):
 85        """
 86        Return the intersection point between Ray and the xy-plane.
 87        If not in alignment mode, any intersection point that is on the support is blocked.
 88        """
 89        vector = mgeo.VectorArray([ray.vector for ray in RayList])
 90        point = mgeo.PointArray([ray.point for ray in RayList])
 91        t = -point[:, 2] / vector[:, 2]
 92        I = mgeo.PointArray(point + vector * t[:, np.newaxis])
 93        return I, [not(i in self.support) for i in I]
 94
 95    def get_local_normal(self, Point):
 96        """Return normal unit vector in point 'Point' on the mask."""
 97        return np.array([0, 0, 1])
 98    
 99    def _zfunc(self, PointArray):
100        """Return the z-values of the plane surface at the points in PointArray."""
101        return np.zeros(len(PointArray))
102
103    def propagate_raylist(self, RayList, alignment=False):
104        """
105        Propagate a list of rays through the mask, returning the transmitted rays.
106
107        Parameters
108        ----------
109        RayList : list
110            List of Ray objects to be propagated through the mask.
111        alignment : bool, optional
112            If True, the alignment of the mask is considered. Default is False.
113
114        Returns
115        -------
116        list
117            List of transmitted Ray objects.
118        """
119        transmitted_rays = []
120        local_rays = [ray.to_basis(*self.basis) for ray in RayList]
121        intersection_points, OK = self._get_intersections(local_rays)
122        N = len(RayList)
123        for i in range(N):
124            # Then we find the intersection point in the mirror reference frame
125            intersection_point = intersection_points[i]
126            local_ray = local_rays[i]
127            if OK[i]:
128                local_transmittedray = copy(local_ray)
129                local_transmittedray.point = intersection_point
130                local_transmittedray.vector = local_ray.vector
131                local_transmittedray.incidence = mgeo.AngleBetweenTwoVectors(-local_ray.vector, self.normal_ref)
132                local_transmittedray.path = local_ray.path + (np.linalg.norm(intersection_point - local_ray.point),)
133                transmitted_rays.append(local_transmittedray.from_basis(*self.basis))
134        if len(transmitted_rays) == 0:
135            logger.warning("No rays were transmitted by the mask.")
136            logger.debug(f"Mask: {self}")
137            logger.debug(f"First ray: {RayList[0]}")
138            logger.debug(f"First ray in mask reference frame: {RayList[0].to_basis(self.r0, self.r, self.q)}")
139            logger.debug(f"First ray intersection point: {self._get_intersection(RayList[0].to_basis(self.r0, self.r, self.q))}")
140        return mray.RayList.from_list(transmitted_rays)
141
142
143# %%############################################################################
144def _TransmitMaskRay(Mask, PointMask, Ray):
145    """Returns the transmitted ray"""
146    PointRay = Ray.point
147    VectorRay = Ray.vector
148    NormalMask = Mask.get_local_normal(PointMask)
149
150    AngleIncidence = mgeo.AngleBetweenTwoVectors(VectorRay, NormalMask)
151    Path = Ray.path + (np.linalg.norm(PointMask - PointRay),)
152
153    RayTransmitted = Ray.copy_ray()
154    RayTransmitted.point = PointMask
155    RayTransmitted.vector = VectorRay
156    RayTransmitted.incidence = AngleIncidence
157    RayTransmitted.path = Path
158
159    return RayTransmitted
160
161
162# %%
163def TransmitMaskRayList(Mask, RayList):
164    """
165    Returns the the transmitted rays that pass the mask for the list of
166    incident rays ListRay.
167
168    Rays that hit the support are not further propagated.
169
170    Updates the reflected rays' incidence angle and path.
171
172    Parameters
173    ----------
174        Mask : Mask-object
175
176        ListRay : list[Ray-object]
177
178    """
179    ListRayTransmitted = []
180    for Ray in RayList:
181        PointMask = Mask._get_intersection(Ray)
182
183        if PointMask is not None:
184            RayTransmitted = _TransmitMaskRay(Mask, PointMask, Ray)
185            ListRayTransmitted.append(RayTransmitted)
186
187    return ListRayTransmitted
logger = <Logger ARTcore.ModuleMask (WARNING)>
 30class Mask(moe.OpticalElement):
 31    """
 32    A mask: a plane surface of the shape of its [Support](ModuleSupport.html), which stops all rays that hit it,
 33    while all other rays continue their path unchanged. No diffraction effects.
 34
 35    Attributes
 36    ----------
 37        support : [Support](ModuleSupport.html)-object
 38
 39        type : str 'Mask'.
 40
 41    Methods
 42    ----------
 43        get_local_normal(Point)
 44
 45        get_centre()
 46
 47        get_grid3D(NbPoints)
 48
 49    """
 50
 51    def __init__(self, Support, **kwargs):
 52        """
 53        Parameters
 54        ----------
 55            Support : [Support](ModuleSupport.html)-object
 56        """
 57        self.type = "Mask"
 58        self.support = Support
 59        self.curvature = mmirror.Curvature.FLAT
 60
 61        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
 62        self._r = mgeo.Vector([0.0, 0.0, 0.0])
 63        self._q = np.quaternion(1)
 64
 65        self.centre_ref = mgeo.Point([0, 0, 0])
 66
 67        self.support_normal_ref = mgeo.Vector([0, 0, 1])
 68        self.normal_ref = mgeo.Vector([0, 0, 1])
 69        self.majoraxis_ref = mgeo.Vector([1, 0, 0])
 70
 71        self.add_global_vectors("support_normal", "normal", "majoraxis")
 72        self.add_global_points("centre")
 73
 74        super().__init__()
 75
 76    def _get_intersection(self, Ray):
 77        """
 78        Return the intersection point between Ray and the xy-plane.
 79        If not in alignment mode, any intersection point that is on the support is blocked.
 80        """
 81        t = -Ray.point[2] / Ray.vector[2]
 82        I = mgeo.Point(Ray.point +Ray.vector * t)
 83        return I, I in self.support
 84    
 85    def _get_intersections(self, RayList):
 86        """
 87        Return the intersection point between Ray and the xy-plane.
 88        If not in alignment mode, any intersection point that is on the support is blocked.
 89        """
 90        vector = mgeo.VectorArray([ray.vector for ray in RayList])
 91        point = mgeo.PointArray([ray.point for ray in RayList])
 92        t = -point[:, 2] / vector[:, 2]
 93        I = mgeo.PointArray(point + vector * t[:, np.newaxis])
 94        return I, [not(i in self.support) for i in I]
 95
 96    def get_local_normal(self, Point):
 97        """Return normal unit vector in point 'Point' on the mask."""
 98        return np.array([0, 0, 1])
 99    
100    def _zfunc(self, PointArray):
101        """Return the z-values of the plane surface at the points in PointArray."""
102        return np.zeros(len(PointArray))
103
104    def propagate_raylist(self, RayList, alignment=False):
105        """
106        Propagate a list of rays through the mask, returning the transmitted rays.
107
108        Parameters
109        ----------
110        RayList : list
111            List of Ray objects to be propagated through the mask.
112        alignment : bool, optional
113            If True, the alignment of the mask is considered. Default is False.
114
115        Returns
116        -------
117        list
118            List of transmitted Ray objects.
119        """
120        transmitted_rays = []
121        local_rays = [ray.to_basis(*self.basis) for ray in RayList]
122        intersection_points, OK = self._get_intersections(local_rays)
123        N = len(RayList)
124        for i in range(N):
125            # Then we find the intersection point in the mirror reference frame
126            intersection_point = intersection_points[i]
127            local_ray = local_rays[i]
128            if OK[i]:
129                local_transmittedray = copy(local_ray)
130                local_transmittedray.point = intersection_point
131                local_transmittedray.vector = local_ray.vector
132                local_transmittedray.incidence = mgeo.AngleBetweenTwoVectors(-local_ray.vector, self.normal_ref)
133                local_transmittedray.path = local_ray.path + (np.linalg.norm(intersection_point - local_ray.point),)
134                transmitted_rays.append(local_transmittedray.from_basis(*self.basis))
135        if len(transmitted_rays) == 0:
136            logger.warning("No rays were transmitted by the mask.")
137            logger.debug(f"Mask: {self}")
138            logger.debug(f"First ray: {RayList[0]}")
139            logger.debug(f"First ray in mask reference frame: {RayList[0].to_basis(self.r0, self.r, self.q)}")
140            logger.debug(f"First ray intersection point: {self._get_intersection(RayList[0].to_basis(self.r0, self.r, self.q))}")
141        return mray.RayList.from_list(transmitted_rays)

A mask: a plane surface of the shape of its Support, which stops all rays that hit it, while all other rays continue their path unchanged. No diffraction effects.

Attributes

support : [Support](ModuleSupport.html)-object

type : str 'Mask'.

Methods

get_local_normal(Point)

get_centre()

get_grid3D(NbPoints)
Mask(Support, **kwargs)
51    def __init__(self, Support, **kwargs):
52        """
53        Parameters
54        ----------
55            Support : [Support](ModuleSupport.html)-object
56        """
57        self.type = "Mask"
58        self.support = Support
59        self.curvature = mmirror.Curvature.FLAT
60
61        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
62        self._r = mgeo.Vector([0.0, 0.0, 0.0])
63        self._q = np.quaternion(1)
64
65        self.centre_ref = mgeo.Point([0, 0, 0])
66
67        self.support_normal_ref = mgeo.Vector([0, 0, 1])
68        self.normal_ref = mgeo.Vector([0, 0, 1])
69        self.majoraxis_ref = mgeo.Vector([1, 0, 0])
70
71        self.add_global_vectors("support_normal", "normal", "majoraxis")
72        self.add_global_points("centre")
73
74        super().__init__()

Parameters

Support : [Support](ModuleSupport.html)-object
type
support
curvature
r0
centre_ref
support_normal_ref
normal_ref
majoraxis_ref
def get_local_normal(self, Point):
96    def get_local_normal(self, Point):
97        """Return normal unit vector in point 'Point' on the mask."""
98        return np.array([0, 0, 1])

Return normal unit vector in point 'Point' on the mask.

def propagate_raylist(self, RayList, alignment=False):
104    def propagate_raylist(self, RayList, alignment=False):
105        """
106        Propagate a list of rays through the mask, returning the transmitted rays.
107
108        Parameters
109        ----------
110        RayList : list
111            List of Ray objects to be propagated through the mask.
112        alignment : bool, optional
113            If True, the alignment of the mask is considered. Default is False.
114
115        Returns
116        -------
117        list
118            List of transmitted Ray objects.
119        """
120        transmitted_rays = []
121        local_rays = [ray.to_basis(*self.basis) for ray in RayList]
122        intersection_points, OK = self._get_intersections(local_rays)
123        N = len(RayList)
124        for i in range(N):
125            # Then we find the intersection point in the mirror reference frame
126            intersection_point = intersection_points[i]
127            local_ray = local_rays[i]
128            if OK[i]:
129                local_transmittedray = copy(local_ray)
130                local_transmittedray.point = intersection_point
131                local_transmittedray.vector = local_ray.vector
132                local_transmittedray.incidence = mgeo.AngleBetweenTwoVectors(-local_ray.vector, self.normal_ref)
133                local_transmittedray.path = local_ray.path + (np.linalg.norm(intersection_point - local_ray.point),)
134                transmitted_rays.append(local_transmittedray.from_basis(*self.basis))
135        if len(transmitted_rays) == 0:
136            logger.warning("No rays were transmitted by the mask.")
137            logger.debug(f"Mask: {self}")
138            logger.debug(f"First ray: {RayList[0]}")
139            logger.debug(f"First ray in mask reference frame: {RayList[0].to_basis(self.r0, self.r, self.q)}")
140            logger.debug(f"First ray intersection point: {self._get_intersection(RayList[0].to_basis(self.r0, self.r, self.q))}")
141        return mray.RayList.from_list(transmitted_rays)

Propagate a list of rays through the mask, returning the transmitted rays.

Parameters

RayList : list List of Ray objects to be propagated through the mask. alignment : bool, optional If True, the alignment of the mask is considered. Default is False.

Returns

list List of transmitted Ray objects.

def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

def TransmitMaskRayList(Mask, RayList):
164def TransmitMaskRayList(Mask, RayList):
165    """
166    Returns the the transmitted rays that pass the mask for the list of
167    incident rays ListRay.
168
169    Rays that hit the support are not further propagated.
170
171    Updates the reflected rays' incidence angle and path.
172
173    Parameters
174    ----------
175        Mask : Mask-object
176
177        ListRay : list[Ray-object]
178
179    """
180    ListRayTransmitted = []
181    for Ray in RayList:
182        PointMask = Mask._get_intersection(Ray)
183
184        if PointMask is not None:
185            RayTransmitted = _TransmitMaskRay(Mask, PointMask, Ray)
186            ListRayTransmitted.append(RayTransmitted)
187
188    return ListRayTransmitted

Returns the the transmitted rays that pass the mask for the list of incident rays ListRay.

Rays that hit the support are not further propagated.

Updates the reflected rays' incidence angle and path.

Parameters

Mask : Mask-object

ListRay : list[Ray-object]

5 - ModuleMirror

Provides classes for different mirror surfaces, which are types of optics.

Think of these as the z-coordinates on top of the x-y-grid provided by the ART.ModuleSupport.Support.

Also provides the function ReflectionMirrorRayList that returns the rays reflected on the mirror. Rays that do not hit the support are not propagated any further.

Illustration the Mirror-class.

Created in 2019

@author: Anthony Guillaume and Stefan Haessler and Andre Kalouguine and Charles Bourassin-Bouchet

   1"""
   2Provides classes for different mirror surfaces, which are types of optics.
   3
   4Think of these as the z-coordinates on top of the x-y-grid provided by the ART.ModuleSupport.Support.
   5
   6Also provides the function *ReflectionMirrorRayList* that returns the rays reflected on
   7the mirror. Rays that do not hit the support are not propagated any further.
   8
   9![Illustration the Mirror-class.](Mirror.svg)
  10
  11
  12
  13Created in 2019
  14
  15@author: Anthony Guillaume and Stefan Haessler and Andre Kalouguine and Charles Bourassin-Bouchet
  16"""
  17# %% Modules
  18import ARTcore.ModuleGeometry as mgeo
  19from ARTcore.ModuleGeometry import Point, Vector, Origin
  20import ARTcore.ModuleSupport as msup
  21import ARTcore.ModuleOpticalRay as mray
  22import ARTcore.ModuleOpticalElement as moe
  23from ARTcore.DepGraphDefinitions import OAP_calculator, EllipsoidalMirrorCalculator
  24import ARTcore.ModuleSurface as msurf
  25
  26import numpy as np
  27from enum import Enum
  28import math
  29from abc import ABC, abstractmethod
  30from copy import copy
  31import logging
  32import time
  33
  34logger = logging.getLogger(__name__)
  35
  36# %% Generic mirror class definition
  37
  38class Curvature(Enum):
  39    CONVEX = -1
  40    FLAT = 0
  41    CONCAVE = 1
  42
  43class Mirror(moe.OpticalElement):
  44    """Abstract base class for mirrors."""
  45    vectorised = False
  46    def __init__(self):
  47        self.Surface = msurf.IdealSurface()
  48        super().__init__()
  49
  50    @abstractmethod
  51    def _get_intersection(self, Ray):
  52        """
  53        This method should return the intersection point between the ray and the mirror surface.
  54        The calculation should be done in the reference frame of the mirror.
  55        It essentially defines the mirror surface (but not its normal).
  56        """
  57        pass
  58
  59    @abstractmethod
  60    def get_local_normal(self, Point: np.ndarray):
  61        """
  62        This method should return the normal unit vector in point 'Point' on the mirror surface.
  63        The normal is in the reference frame of the mirror.
  64        """
  65        pass
  66    
  67    def propagate_raylist(self, RayList, alignment=False):
  68        """
  69        Propagate a list of rays to the mirror and return the reflected rays.
  70
  71        Parameters
  72        ----------
  73            RayList : a RayList (a class defined in ARTcore.ModuleOpticalRay)
  74                Rays to propagate through the mirror.
  75
  76            alignment : bool
  77                If True, the rays are not blocked by the support. Not implemented yet
  78
  79        Returns
  80        -------
  81            ReflectedRayList : a RayList
  82                The rays that were reflected by the mirror.
  83        """
  84        #local_rays = [ray.to_basis(*self.basis) for ray in RayList]
  85        local_rays = RayList.to_basis(*self.basis)
  86        # So local_rays is a list of rays in the mirror reference frame
  87        if self.vectorised and len(local_rays) > 10:
  88            # If the class implements the vectorised version of the intersection and normal calculation
  89            intersection_points, OK = self._get_intersections(local_rays)
  90            # NB: the _get_intersections method should return two things:
  91            # - the intersection points in the mirror reference frame with nan's for rays that do not intersect
  92            # - a boolean array indicating which rays intersections make sense
  93            local_normals = mgeo.VectorArray(np.zeros_like(intersection_points))
  94            local_normals[OK] = self.get_local_normals(intersection_points[OK])
  95            local_reflected_rays = [
  96                self.Surface.reflect_ray(local_rays[i], intersection_points[i], local_normals[i]) 
  97                for i in range(len(local_rays)) if OK[i]]
  98        else:
  99            intersection_points, OK = zip(*[self._get_intersection(r) for r in local_rays])
 100            # OK is a boolean list indicating if the ray intersects the mirror (for instance is in the support)
 101            local_reflected_rays = [
 102                self.Surface.reflect_ray(local_rays[i], intersection_points[i], self.get_local_normal(intersection_points[i])) 
 103                for i in range(len(local_rays)) if OK[i]]
 104        reflected_rays = mray.RayList.from_list(local_reflected_rays).from_basis(*self.basis)
 105        if alignment and len(reflected_rays) == 0:
 106            logger.error("Unexpected error occurred: no rays were reflected during alignment procedure.")
 107            logger.error("This should not happen.")
 108            logger.error(f"Mirror on which error ocurred: {self}")
 109            logger.error(f"Alignment ray: {RayList[0]}")
 110            logger.error(f"Alignment ray in mirror reference frame: {local_rays[0]}")
 111            logger.error(f"Intersection point: {self._get_intersection(local_rays[0])[0]}")
 112            logger.error(f"Support: {self.support}")
 113        elif len(reflected_rays) == 0:
 114            logger.warning("No rays were reflected by the mirror.")
 115            logger.debug(f"Mirror: {self}")
 116            logger.debug(f"First ray: {RayList[0]}")
 117            logger.debug(f"First ray in mirror reference frame: {RayList[0].to_basis(self.r0, self.r, self.q)}")
 118            logger.debug(f"First ray intersection point: {self._get_intersection(RayList[0].to_basis(self.r0, self.r, self.q))}")
 119        return reflected_rays
 120
 121
 122def _IntersectionRayMirror(PointMirror, ListPointIntersectionMirror):
 123    """When the ray has pierced the mirror twice, select the first point, otherwise just keep that one."""
 124    if len(ListPointIntersectionMirror) == 2:
 125        return mgeo.ClosestPoint(
 126            PointMirror,
 127            ListPointIntersectionMirror[0],
 128            ListPointIntersectionMirror[1],
 129        )
 130    elif len(ListPointIntersectionMirror) == 1:
 131        return ListPointIntersectionMirror[0]
 132    else:
 133        return None
 134
 135
 136# %% Plane mirror definitions
 137class MirrorPlane(Mirror):
 138    """
 139    A plane mirror.
 140
 141    Attributes
 142    ----------
 143        support : ART.ModuleSupport.Support
 144            Mirror support
 145        type : str = 'Plane Mirror'
 146            Human readable mirror type
 147    Methods
 148    -------
 149        MirrorPlane.get_normal(Point)
 150
 151        MirrorPlane.get_centre()
 152
 153        MirrorPlane.get_grid3D(NbPoints)
 154    """
 155    vectorised = True
 156    def __init__(self, Support, **kwargs):
 157        """
 158        Create a plane mirror on a given Support.
 159
 160        Parameters
 161        ----------
 162            Support : ART.ModuleSupport.Support
 163
 164        """
 165        super().__init__()
 166        self.support = Support
 167        self.type = "Plane Mirror"
 168        self.curvature = Curvature.FLAT
 169
 170        if "Surface" in kwargs:
 171            self.Surface = kwargs["Surface"]
 172
 173        self.r0 = Point([0.0, 0.0, 0.0])
 174
 175        self.centre_ref = Point([0.0, 0.0, 0.0])
 176        self.support_normal_ref = Vector([0, 0, 1.0])
 177        self.majoraxis_ref = Vector([1.0, 0, 0])
 178
 179        self.add_global_points("centre")
 180        self.add_global_vectors("support_normal", "majoraxis")
 181        
 182    def _get_intersection(self, Ray):
 183        """Return the intersection point between Ray and the xy-plane."""
 184        t = -Ray.point[2] / Ray.vector[2]
 185        PointIntersection = Ray.point + Ray.vector * t
 186        return PointIntersection, t > 0 and PointIntersection in self.support
 187
 188    def get_local_normal(self, Point: np.ndarray):
 189        """Return normal unit vector in point 'Point' on the plane mirror."""
 190        return Vector([0, 0, 1])
 191    
 192    def _get_intersections(self, RayList):
 193        """
 194        Vectorised version of the intersection calculation.
 195        """
 196        vectors = np.array([ray.vector for ray in RayList])
 197        points = np.array([ray.point for ray in RayList])
 198
 199        t = -points[:,2] / vectors[:,2]
 200        Points = points + vectors * t[:,np.newaxis]
 201        OK = (t > 0) & np.array([p in self.support for p in Points])
 202        return mgeo.PointArray(Points), OK
 203
 204    def get_local_normals(self, Points):
 205        return mgeo.VectorArray(np.zeros_like(Points) + [0, 0, 1])
 206
 207    def _zfunc(self, PointArray):
 208        x = PointArray[:,0]
 209        y = PointArray[:,1]
 210        return np.zeros_like(x)
 211
 212# %% Spherical mirror definitions
 213class MirrorSpherical(Mirror):
 214    """
 215    Spherical mirror surface with eqn. $x^2 + y^2 + z^2  = R^2$, where $R$ is the radius.
 216
 217    ![Illustration of a spherical mirror.](sphericalmirror.svg)
 218
 219    Attributes
 220    ----------
 221        radius : float
 222            Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.
 223
 224        support : ART.ModuleSupport.Support
 225
 226        type : str SphericalCC Mirror' or SphericalCX Mirror'
 227
 228    Methods
 229    -------
 230        MirrorSpherical.get_normal(Point)
 231
 232        MirrorSpherical.get_centre()
 233
 234        MirrorSpherical.get_grid3D(NbPoints)
 235
 236    """
 237    def __init__(self, Support, Radius, **kwargs):
 238        """
 239        Construct a spherical mirror.
 240
 241        Parameters
 242        ----------
 243            Radius : float
 244                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
 245
 246            Support : ART.ModuleSupport.Support
 247
 248        """
 249        super().__init__()
 250        if Radius < 0:
 251            self.type = "SphericalCX Mirror"
 252            self.curvature = Curvature.CONVEX
 253            self.radius = -Radius
 254        elif Radius > 0:
 255            self.type = "SphericalCC Mirror"
 256            self.curvature = Curvature.CONCAVE
 257            self.radius = Radius
 258        else:
 259            raise ValueError("Radius of curvature must be non-zero")
 260        self.support = Support
 261
 262        if "Surface" in kwargs:
 263            self.Surface = kwargs["Surface"]
 264
 265        self.r0 = Point([0.0, 0, -self.radius])
 266
 267        self.centre_ref = Point([0.0, 0, -self.radius])
 268        self.sphere_center_ref = Point([0.0, 0, 0])
 269        self.focus_ref = Point([0.0, 0, -self.radius/2])
 270
 271        self.support_normal_ref = Vector([0, 0, 1.0])
 272        self.majoraxis_ref = Vector([1.0, 0, 0])
 273        self.towards_focus_ref = (self.focus_ref - self.centre_ref).normalized()
 274
 275        self.add_global_points("sphere_center", "focus", "centre")
 276        self.add_global_vectors("towards_focus", "majoraxis", "support_normal")
 277
 278    def _get_intersection(self, Ray):
 279        """Return the intersection point between the ray and the sphere."""
 280        a = np.dot(Ray.vector, Ray.vector)
 281        b = 2 * np.dot(Ray.vector, Ray.point)
 282        c = np.dot(Ray.point, Ray.point) - self.radius**2
 283
 284        Solution = mgeo.SolverQuadratic(a, b, c)
 285        Solution = mgeo.KeepPositiveSolution(Solution)
 286
 287        for t in Solution:
 288            Intersect = Ray.vector * t + Ray.point
 289            if Intersect - self.r0 in self.support:
 290                return Intersect, True
 291
 292        return Ray.point, False
 293
 294    def get_local_normal(self, Point):
 295        """Return the normal unit vector on the spherical surface at point Point."""
 296        return -Vector(Point).normalized()
 297
 298    def _zfunc(self, PointArray):
 299        x = PointArray[:,0]
 300        y = PointArray[:,1]
 301        return -np.sqrt(self.radius**2 - x**2 - y**2)
 302
 303# %% Parabolic mirror definitions
 304class MirrorParabolic(Mirror):
 305    r"""
 306    A paraboloid with vertex at the origin $O=[0,0,0]$ and symmetry axis z:
 307    $z = \frac{1}{4f}[x^2 + y^2]$ where $f$ is the focal lenght of the *mother*
 308    parabola (i.e. measured from its center at $O$ to the focal point $F$).
 309
 310    The center of the support is shifted along the x-direction by the off-axis distance $x_c$.
 311    This leads to an *effective focal length* $f_\mathrm{eff}$, measured from the shifted center
 312    of the support  $P$ to the focal point $F$.
 313    It is related to the mother focal length by $f = f_\\mathrm{eff} \cos^2(\alpha/2) $,
 314    or equivalently $ p = 2f = f_\mathrm{eff} (1+\\cos\\alpha)$, where $\\alpha$
 315    is the off-axis angle, and $p = 2f$ is called the semi latus rectum.
 316
 317    Another useful relationship is that between the off-axis distance and the resulting
 318    off-axis angle: $x_c = 2 f \tan(\alpha/2)$.
 319
 320
 321    ![Illustration of a parabolic mirror.](parabola.svg)
 322
 323    Attributes
 324    ----------
 325        offaxisangle : float
 326            Off-axis angle of the parabola. Modifying this also updates p, keeping feff constant.
 327            Attention: The off-axis angle must be *given in degrees*, but is stored and will be *returned in radian* !
 328
 329        feff : float
 330            Effective focal length of the parabola in mm. Modifying this also updates p, keeping offaxisangle constant.
 331
 332        p : float
 333            Semi latus rectum of the parabola in mm. Modifying this also updates feff, keeping offaxisangle constant.
 334
 335        support : ART.ModuleSupport.Support
 336
 337        type : str 'Parabolic Mirror'.
 338
 339    Methods
 340    -------
 341        MirrorParabolic.get_normal(Point)
 342
 343        MirrorParabolic.get_centre()
 344
 345        MirrorParabolic.get_grid3D(NbPoints)
 346
 347    """
 348    #vectorised = True # Unitl I fix the closest solution thing
 349    def __init__(self, Support,
 350                FocalEffective: float=None,
 351                OffAxisAngle: float = None,
 352                FocalParent: float = None,
 353                RadiusParent: float = None,
 354                OffAxisDistance: float = None,
 355                MoreThan90: bool = None,
 356                **kwargs):
 357        """
 358        Initialise a Parabolic mirror.
 359
 360        Parameters
 361        ----------
 362            FocalEffective : float
 363                Effective focal length of the parabola in mm.
 364
 365            OffAxisAngle : float
 366                Off-axis angle *in degrees* of the parabola.
 367
 368            Support : ART.ModuleSupport.Support
 369
 370        """
 371        super().__init__()
 372        self.curvature = Curvature.CONCAVE
 373        self.support = Support
 374        self.type = "Parabolic Mirror"
 375
 376        if "Surface" in kwargs:
 377            self.Surface = kwargs["Surface"]
 378
 379        parameter_calculator = OAP_calculator()
 380        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
 381                                                             fs=FocalEffective,
 382                                                             theta=OffAxisAngle,
 383                                                             fp=FocalParent,
 384                                                             Rc=RadiusParent,
 385                                                             OAD=OffAxisDistance,
 386                                                             more_than_90=MoreThan90)
 387        self._offaxisangle = values["theta"]
 388        self._feff = values["fs"]
 389        self._p = values["p"]
 390        self._offaxisdistance = values["OAD"]
 391        self._fparent = values["fp"]
 392        self._rparent = values["Rc"]
 393
 394        self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
 395
 396        self.centre_ref = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
 397        self.support_normal_ref = Vector([0, 0, 1.0])
 398        self.majoraxis_ref = Vector([1.0, 0, 0])
 399        
 400        self.focus_ref = Point([0.0, 0, self._fparent])
 401        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
 402        self.towards_collimated_ref = Vector([0, 0, 1.0])
 403
 404        self.add_global_points("focus", "centre")
 405        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")
 406
 407    def _get_intersection(self, Ray):
 408        """Return the intersection point between the ray and the parabola."""
 409        ux, uy, uz = Ray.vector
 410        xA, yA, zA = Ray.point
 411
 412        da = ux**2 + uy**2
 413        db = 2 * (ux * xA + uy * yA) - 2 * self._p * uz
 414        dc = xA**2 + yA**2 - 2 * self._p * zA
 415
 416        Solution = mgeo.SolverQuadratic(da, db, dc)
 417        Solution = mgeo.KeepPositiveSolution(Solution)
 418        IntersectionPoint = [Ray.point + Ray.vector * t for t in Solution if t > 0]
 419        distances = [mgeo.Vector(i - self.r0).norm for i in IntersectionPoint]
 420        IntersectionPoint = IntersectionPoint[distances.index(min(distances))]
 421        return IntersectionPoint, IntersectionPoint-self.r0 in self.support
 422    
 423    def _get_intersections(self, RayList):
 424        """
 425        Vectorised version of the intersection calculation.
 426        """
 427        vectors = np.array([ray.vector for ray in RayList])
 428        points = np.array([ray.point for ray in RayList])
 429        Solutions = np.zeros(len(RayList), dtype=float)
 430
 431        da = np.sum(vectors[:,0:2]**2, axis=1)
 432        db = 2 * (np.sum(vectors[:,0:2] * points[:,0:2], axis=1) - self._p * vectors[:,2])
 433        dc = np.sum(points[:,0:2]**2, axis=1) - 2 * self._p * points[:,2]
 434
 435        linear = da == 0
 436        Solutions[linear] = -dc[linear] / db[linear]
 437
 438        centered = (dc==0) & (da!=0) # If equation is of form ax**2 + bx = 0
 439        Solutions[centered] = np.maximum(-db[centered] / da[centered], 0)
 440
 441        unstable = (4*da*dc) < db**2 * 1e-10 # Cases leading to catastrophic cancellation
 442
 443        Deltas = db**2 - 4 * da * dc
 444        OK = Deltas >= 0
 445        SolutionsPlus = (-db + np.sqrt(Deltas)) / 2 / da
 446        SolutionsMinus = (-db - np.sqrt(Deltas)) / 2 / da
 447        SolutionsPlus[unstable] = dc[unstable] / (da[unstable] * SolutionsMinus[unstable])
 448        SolutionsPlus = np.where(SolutionsPlus >= 0, SolutionsPlus, np.inf)
 449        SolutionsMinus = np.where(SolutionsMinus >= 0, SolutionsMinus, np.inf)
 450        Solutions = np.minimum(SolutionsPlus, SolutionsMinus)
 451        Solutions = np.maximum(Solutions, 0)
 452        Points = points + vectors * Solutions[:,np.newaxis]
 453        OK = OK & (Solutions > 0) & np.array([p-self.r0 in self.support for p in Points])
 454        return mgeo.PointArray(Points), OK
 455
 456    def get_local_normal(self, Point):
 457        """Return the normal unit vector on the paraboloid surface at point Point."""
 458        Gradient = Vector(np.zeros(3))
 459        Gradient[0] = -Point[0]
 460        Gradient[1] = -Point[1]
 461        Gradient[2] = self._p
 462        return Gradient.normalized()
 463
 464    def get_local_normals(self, Points):
 465        """
 466        Vectorised version of the normal calculation.
 467        """
 468        Gradients = mgeo.VectorArray(np.zeros_like(Points))
 469        Gradients[:,0] = -Points[:,0]
 470        Gradients[:,1] = -Points[:,1]
 471        Gradients[:,2] = self._p
 472        return Gradients.normalized()
 473    
 474    def _zfunc(self, PointArray):
 475        x = PointArray[:,0]
 476        y = PointArray[:,1]
 477        return (x**2 + y**2) / 2 / self._p
 478
 479
 480# %% Toroidal mirror definitions
 481class MirrorToroidal(Mirror):
 482    r"""
 483    Toroidal mirror surface with eqn.$(\sqrt{x^2+z^2}-R)^2 + y^2 = r^2$ where $R$ and $r$ the major and minor radii.
 484
 485    ![Illustration of a toroidal mirror.](toroidal.svg)
 486
 487    Attributes
 488    ----------
 489        majorradius : float
 490            Major radius of the toroid in mm.
 491
 492        minorradius : float
 493            Minor radius of the toroid in mm.
 494
 495        support : ART.ModuleSupport.Support
 496
 497        type : str 'Toroidal Mirror'.
 498
 499    Methods
 500    -------
 501        MirrorToroidal.get_normal(Point)
 502
 503        MirrorToroidal.get_centre()
 504
 505        MirrorToroidal.get_grid3D(NbPoints)
 506
 507    """
 508
 509    def __init__(self, Support, MajorRadius, MinorRadius, **kwargs):
 510        """
 511        Construct a toroidal mirror.
 512
 513        Parameters
 514        ----------
 515            MajorRadius : float
 516                Major radius of the toroid in mm.
 517
 518            MinorRadius : float
 519                Minor radius of the toroid in mm.
 520
 521            Support : ART.ModuleSupport.Support
 522
 523        """
 524        super().__init__()
 525        self.support = Support
 526        self.type = "Toroidal Mirror"
 527
 528        if "Surface" in kwargs:
 529            self.Surface = kwargs["Surface"]
 530
 531        self.curvature = Curvature.CONCAVE
 532
 533        self.majorradius = MajorRadius
 534        self.minorradius = MinorRadius
 535
 536        self.r0 = Point([0.0, 0.0, -MajorRadius - MinorRadius])
 537        
 538        self.centre_ref = Point([0.0, 0.0, -MajorRadius - MinorRadius])
 539        self.support_normal_ref = Vector([0, 0, 1.0])
 540        self.majoraxis_ref = Vector([1.0, 0, 0])
 541
 542        self.add_global_vectors("support_normal", "majoraxis")
 543        self.add_global_points("centre")
 544
 545    def _get_intersection(self, Ray):
 546        """Return the intersection point between Ray and the toroidal mirror surface. The intersection points are given in the reference frame of the mirror"""
 547        ux = Ray.vector[0]
 548        uz = Ray.vector[2]
 549        xA = Ray.point[0]
 550        zA = Ray.point[2]
 551
 552        G = 4.0 * self.majorradius**2 * (ux**2 + uz**2)
 553        H = 8.0 * self.majorradius**2 * (ux * xA + uz * zA)
 554        I = 4.0 * self.majorradius**2 * (xA**2 + zA**2)
 555        J = np.dot(Ray.vector, Ray.vector)
 556        K = 2.0 * np.dot(Ray.vector, Ray.point)
 557        L = (
 558            np.dot(Ray.point, Ray.point)
 559            + self.majorradius**2
 560            - self.minorradius**2
 561        )
 562
 563        a = J**2
 564        b = 2 * J * K
 565        c = 2 * J * L + K**2 - G
 566        d = 2 * K * L - H
 567        e = L**2 - I
 568
 569        Solution = mgeo.SolverQuartic(a, b, c, d, e)
 570        if len(Solution) == 0:
 571            return None, False
 572        Solution = [t for t in Solution if t > 0]
 573        IntersectionPoint = [Ray.point + Ray.vector *i for i in Solution]
 574        distances = [mgeo.Vector(i - self.r0).norm for i in IntersectionPoint]
 575        IntersectionPoint = IntersectionPoint[distances.index(min(distances))]
 576        OK = IntersectionPoint - self.r0 in self.support and np.abs(IntersectionPoint[2]-self.r0[2]) < self.majorradius
 577        return IntersectionPoint, OK
 578
 579    def get_local_normal(self, Point):
 580        """Return the normal unit vector on the toroidal surface at point Point in the reference frame of the mirror"""
 581        x = Point[0]
 582        y = Point[1]
 583        z = Point[2]
 584        A = self.majorradius**2 - self.minorradius**2
 585
 586        Gradient = Vector(np.zeros(3))
 587        Gradient[0] = (
 588            4 * (x**3 + x * y**2 + x * z**2 + x * A)
 589            - 8 * x * self.majorradius**2
 590        )
 591        Gradient[1] = 4 * (y**3 + y * x**2 + y * z**2 + y * A)
 592        Gradient[2] = (
 593            4 * (z**3 + z * x**2 + z * y**2 + z * A)
 594            - 8 * z * self.majorradius**2
 595        )
 596
 597        return -Gradient.normalized()
 598
 599    def _zfunc(self, PointArray):
 600        x = PointArray[:,0]
 601        y = PointArray[:,1]
 602        return -np.sqrt(
 603            (np.sqrt(self.minorradius**2 - y**2) + self.majorradius)**2 - x**2
 604        )
 605
 606def ReturnOptimalToroidalRadii(
 607    Focal: float, AngleIncidence: float
 608) -> (float, float):
 609    """
 610    Get optimal parameters for a toroidal mirror.
 611
 612    Useful helper function to get the optimal major and minor radii for a toroidal mirror to achieve a
 613    focal length 'Focal' with an angle of incidence 'AngleIncidence' and with vanishing astigmatism.
 614
 615    Parameters
 616    ----------
 617        Focal : float
 618            Focal length in mm.
 619
 620        AngleIncidence : int
 621            Angle of incidence in degrees.
 622
 623    Returns
 624    -------
 625        OptimalMajorRadius, OptimalMinorRadius : float, float.
 626    """
 627    AngleIncidenceRadian = AngleIncidence * np.pi / 180
 628    OptimalMajorRadius = (
 629        2
 630        * Focal
 631        * (1 / np.cos(AngleIncidenceRadian) - np.cos(AngleIncidenceRadian))
 632    )
 633    OptimalMinorRadius = 2 * Focal * np.cos(AngleIncidenceRadian)
 634    return OptimalMajorRadius, OptimalMinorRadius
 635
 636
 637# %% Ellipsoidal mirror definitions
 638class MirrorEllipsoidal(Mirror):
 639    """
 640    Ellipdoidal mirror surface with eqn. $(x/a)^2 + (y/b)^2 + (z/b)^2 = 1$, where $a$ and $b$ are semi major and semi minor axes.
 641
 642    ![Illustration of a ellipsoidal mirror.](ellipsoid.svg)
 643
 644    Attributes
 645    ----------
 646        a : float
 647            Semi major axis of the ellipsoid in mm.
 648
 649        b : float
 650            Semi minor axis of the ellipsoid in mm.
 651
 652        support : ART.ModuleSupport.Support
 653
 654        type : str 'Ellipsoidal Mirror'.
 655
 656    Methods
 657    -------
 658        MirrorEllipsoidal.get_normal(Point)
 659
 660        MirrorEllipsoidal.get_centre()
 661
 662        MirrorEllipsoidal.get_grid3D(NbPoints)
 663
 664    """
 665
 666    def __init__(
 667        self,
 668        Support,
 669        SemiMajorAxis=None,
 670        SemiMinorAxis=None,
 671        OffAxisAngle=None,
 672        f_object=None,
 673        f_image=None,
 674        IncidenceAngle=None,
 675        Magnification=None,
 676        DistanceObjectImage=None,
 677        Eccentricity=None,
 678        **kwargs,
 679    ):
 680        """
 681        Generate an ellipsoidal mirror with given parameters.
 682
 683        The angles are given in degrees but converted to radians for internal calculations.
 684        You can specify the semi-major and semi-minor axes, the off-axis angle, the object and image focal distances or some other parameters.
 685        The constructor uses a magic calculator to determine the missing parameters.
 686
 687        Parameters
 688        ----------
 689        Support : TYPE
 690            ART.ModuleSupport.Support.
 691        SemiMajorAxis : float (optional)
 692            Semi major axis of the ellipsoid in mm..
 693        SemiMinorAxis : float (optional)
 694            Semi minor axis of the ellipsoid in mm..
 695        OffAxisAngle : float (optional)
 696            Off-axis angle of the mirror in mm. Defined as the angle at the centre of the mirror between the two foci..
 697        f_object : float (optional)
 698            Object focal distance in mm.
 699        f_image : float (optional)
 700            Image focal distance in mm.
 701        IncidenceAngle : float  (optional)
 702            Angle of incidence in degrees.
 703        Magnification : float  (optional)
 704            Magnification.
 705        DistanceObjectImage : float  (optional)
 706            Distance between object and image in mm.
 707        Eccentricity : float  (optional)
 708            Eccentricity of the ellipsoid.
 709        """
 710        super().__init__()
 711        self.type = "Ellipsoidal Mirror"
 712        self.support = Support
 713
 714        if "Surface" in kwargs:
 715            self.Surface = kwargs["Surface"]
 716
 717        parameter_calculator = EllipsoidalMirrorCalculator()
 718        values, steps = parameter_calculator.calculate_values(verify_consistency=True, 
 719                                                              f1=f_object, 
 720                                                              f2=f_image, 
 721                                                              a=SemiMajorAxis, 
 722                                                              b=SemiMinorAxis, 
 723                                                              offset_angle=OffAxisAngle,
 724                                                              incidence_angle=IncidenceAngle,
 725                                                              m=Magnification,
 726                                                              l=DistanceObjectImage,
 727                                                              e=Eccentricity)
 728        self.a = values["a"]
 729        self.b = values["b"]
 730        self._offaxisangle = np.deg2rad(values["offset_angle"])
 731        self._f_object = values["f1"]
 732        self._f_image = values["f2"]
 733
 734        self.centre_ref = self._get_centre_ref()
 735
 736        self.r0 = self.centre_ref
 737
 738        self.support_normal_ref = Vector([0, 0, 1.0])
 739        self.majoraxis_ref = Vector([1.0, 0, 0])
 740
 741        self.f1_ref = Point([-self.a, 0,0])
 742        self.f2_ref = Point([ self.a, 0,0])
 743        self.towards_image_ref = (self.f2_ref - self.centre_ref).normalized()
 744        self.towards_object_ref = (self.f1_ref - self.centre_ref).normalized()
 745        self.centre_normal_ref = self.get_local_normal(self.centre_ref)
 746
 747        self.add_global_points("f1", "f2", "centre")
 748        self.add_global_vectors("towards_image", "towards_object", "centre_normal", "support_normal", "majoraxis")
 749
 750    def _get_intersection(self, Ray):
 751        """Return the intersection point between Ray and the ellipsoidal mirror surface."""
 752        ux, uy, uz = Ray.vector
 753        xA, yA, zA = Ray.point
 754
 755        da = (uy**2 + uz**2) / self.b**2 + (ux / self.a) ** 2
 756        db = 2 * ((uy * yA + uz * zA) / self.b**2 + (ux * xA) / self.a**2)
 757        dc = (yA**2 + zA**2) / self.b**2 + (xA / self.a) ** 2 - 1
 758
 759        Solution = mgeo.SolverQuadratic(da, db, dc)
 760        Solution = mgeo.KeepPositiveSolution(Solution)
 761
 762        ListPointIntersection = []
 763        C = self.get_centre_ref()
 764        for t in Solution:
 765            Intersect = Ray.vector * t + Ray.point
 766            if Intersect[2] < 0 and Intersect - C in self.support:
 767                ListPointIntersection.append(Intersect)
 768
 769        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
 770
 771    def get_local_normal(self, Point):
 772        """Return the normal unit vector on the ellipsoidal surface at point Point."""
 773        Gradient = Vector(np.zeros(3))
 774
 775        Gradient[0] = -Point[0] / self.a**2
 776        Gradient[1] = -Point[1] / self.b**2
 777        Gradient[2] = -Point[2] / self.b**2
 778
 779        return Gradient.normalized()
 780
 781    def _get_centre_ref(self):
 782        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
 783        foci = 2 * np.sqrt(self.a**2 - self.b**2) # distance between the foci
 784        h = -foci / 2 / np.tan(self._offaxisangle)
 785        R = np.sqrt(foci**2 / 4 + h**2) 
 786        sign = 1
 787        if math.isclose(self._offaxisangle, np.pi / 2):
 788            h = 0
 789        elif self._offaxisangle > np.pi / 2:
 790            h = -h
 791            sign = -1
 792        a = 1 - self.a**2 / self.b**2
 793        b = -2 * h
 794        c = self.a**2 + h**2 - R**2
 795        z = (-b + sign * np.sqrt(b**2 - 4 * a * c)) / (2 * a)
 796        if math.isclose(z**2, self.b**2):
 797            return np.array([0, 0, -self.b])
 798        x = self.a * np.sqrt(1 - z**2 / self.b**2)
 799        centre = Point([x, 0, sign * z])
 800        return centre
 801
 802    def _zfunc(self, PointArray):
 803        x = PointArray[:,0]
 804        y = PointArray[:,1]
 805        x-= self.r0[0]
 806        y-= self.r0[1]
 807        z =  -np.sqrt(1 - (x / self.a)**2 - (y / self.b)**2)
 808        z += self.r0[2]
 809        return z
 810    
 811
 812# %% Cylindrical mirror definitions
 813class MirrorCylindrical(Mirror):
 814    """
 815    Cylindrical mirror surface with eqn. $y^2 + z^2  = R^2$, where $R$ is the radius.
 816
 817    Attributes
 818    ----------
 819        radius : float
 820            Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.
 821
 822        support : ART.ModuleSupport.Support
 823
 824        type : str 'Cylindrical Mirror'.
 825
 826    Methods
 827    -------
 828        MirrorCylindrical.get_normal(Point)
 829
 830        MirrorCylindrical.get_centre()
 831
 832        MirrorCylindrical.get_grid3D(NbPoints)
 833    """
 834        
 835    def __init__(self, Support, Radius, **kwargs):
 836        """
 837        Construct a cylindrical mirror.
 838
 839        Parameters
 840        ----------
 841            Radius : float
 842                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
 843
 844            Support : ART.ModuleSupport.Support
 845
 846        """
 847        super().__init__()
 848        if Radius < 0:
 849            self.type = "CylindricalCX Mirror"
 850            self.curvature = Curvature.CONVEX
 851            self.radius = -Radius
 852        else:
 853            self.type = "CylindricalCC Mirror"
 854            self.curvature = Curvature.CONCAVE
 855            self.radius = Radius
 856
 857        self.support = Support
 858
 859        if "Surface" in kwargs:
 860            self.Surface = kwargs["Surface"]
 861
 862        self.r0 = Point([0.0, 0.0, -self.radius])
 863
 864        self.support_normal_ref = Vector([0, 0, 1.0])
 865        self.majoraxis_ref = Vector([1.0, 0, 0])
 866        self.centre_ref = Point([0.0, 0.0, -self.radius])
 867
 868        self.add_global_points("centre")
 869        self.add_global_vectors("support_normal", "majoraxis")
 870
 871    def _get_intersection(self, Ray):
 872        """Return the intersection point between the Ray and the cylinder."""
 873        uy = Ray.vector[1]
 874        uz = Ray.vector[2]
 875        yA = Ray.point[1]
 876        zA = Ray.point[2]
 877
 878        a = uy**2 + uz**2
 879        b = 2 * (uy * yA + uz * zA)
 880        c = yA**2 + zA**2 - self.radius**2
 881
 882        Solution = mgeo.SolverQuadratic(a, b, c)
 883        Solution = mgeo.KeepPositiveSolution(Solution)
 884
 885        ListPointIntersection = []
 886        for t in Solution:
 887            Intersect = Ray.vector * t + Ray.point
 888            if Intersect[2] < 0 and Intersect in self.support:
 889                ListPointIntersection.append(Intersect)
 890
 891        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
 892
 893    def get_local_normal(self, Point):
 894        """Return the normal unit vector on the cylinder surface at point P."""
 895        Gradient = Vector([0, -Point[1], -Point[2]])
 896        return Gradient.normalized()
 897
 898    def get_centre(self):
 899        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
 900        return Point([0, 0, -self.radius])
 901    
 902    def _zfunc(self, PointArray):
 903        y = PointArray[:,1]
 904        return -np.sqrt(self.radius**2 - y**2)
 905
 906
 907# %% Grazing parabolic mirror definitions
 908# A grazing parabola is no different from a parabola, it's just a question of what we consider to be the support.
 909# For an ordinary parabola, the support is the xy-plane, for a grazing parabola, the support is parallel to the surface at the optical center.
 910
 911class GrazingParabola(Mirror):
 912    r"""
 913    A parabolic mirror with a support parallel to the surface at the optical center.
 914    Needs to be completed, currently not functional.
 915    TODO
 916    """
 917
 918    def __init__(self, Support,
 919                FocalEffective: float=None,
 920                OffAxisAngle: float = None,
 921                FocalParent: float = None,
 922                RadiusParent: float = None,
 923                OffAxisDistance: float = None,
 924                MoreThan90: bool = None,
 925                **kwargs):
 926        """
 927        Initialise a Parabolic mirror.
 928
 929        Parameters
 930        ----------
 931            FocalEffective : float
 932                Effective focal length of the parabola in mm.
 933
 934            OffAxisAngle : float
 935                Off-axis angle *in degrees* of the parabola.
 936
 937            Support : ART.ModuleSupport.Support
 938
 939        """
 940        super().__init__()
 941        self.curvature = Curvature.CONCAVE
 942        self.support = Support
 943        self.type = "Grazing Parabolic Mirror"
 944
 945        if "Surface" in kwargs:
 946            self.Surface = kwargs["Surface"]
 947
 948        parameter_calculator = OAP_calculator()
 949        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
 950                                                             fs=FocalEffective,
 951                                                             theta=OffAxisAngle,
 952                                                             fp=FocalParent,
 953                                                             Rc=RadiusParent,
 954                                                             OAD=OffAxisDistance,
 955                                                             more_than_90=MoreThan90)
 956        self._offaxisangle = values["theta"]
 957        self._feff = values["fs"]
 958        self._p = values["p"]
 959        self._offaxisdistance = values["OAD"]
 960        self._fparent = values["fp"]
 961        self._rparent = values["Rc"]
 962
 963        #self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
 964        self.r0 = Point([0, 0, 0])
 965
 966        self.centre_ref = Point([0, 0, 0])
 967        self.support_normal_ref = Vector([0, 0, 1.0])
 968        self.majoraxis_ref = Vector([1.0, 0, 0])
 969        
 970        focus_ref = Point([np.sqrt(self._feff**2 - self._offaxisdistance**2), 0, self._offaxisdistance]) # frame where x is as collimated direction
 971        # We need to rotate it in the trigonometric direction by the 90°-theta/2
 972        angle = np.pi/2 - self._offaxisangle/2
 973        self.focus_ref = Point([focus_ref[0]*np.cos(angle)+focus_ref[2]*np.sin(angle),focus_ref[1], -focus_ref[0]*np.sin(angle)+focus_ref[2]*np.cos(angle), ])
 974        
 975        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
 976        towards_collimated_ref = Vector([-1.0, 0, 0]) # Again, we need to rotate it by the same angle
 977        self.collimated_ref = Point([-np.cos(angle), 0, np.sin(angle)])
 978
 979        self.add_global_points("focus", "centre")
 980        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")
 981
 982    def _get_intersection(self, Ray):
 983        """
 984        Return the intersection point between the ray and the parabola.
 985        """
 986        pass
 987
 988    def get_local_normal(self, Point):
 989        """
 990        Return the normal unit vector on the paraboloid surface at point Point.
 991        """
 992        pass
 993
 994    def _zfunc(self, PointArray):
 995        pass
 996
 997
 998# %% Reflections on mirrors, is it useful to keep this?
 999def _ReflectionMirrorRay(Mirror, PointMirror, Ray):
1000    """
1001    Return the reflected ray according to the law of reflection.
1002
1003    Parameters
1004    ----------
1005        Mirror : Mirror-objectS
1006
1007        PointMirror : np.ndarray
1008            Point of reflection on the mirror surface.
1009
1010        Ray : Ray-object
1011
1012    """
1013    PointRay = Ray.point
1014    VectorRay = Ray.vector
1015    NormalMirror = Mirror.get_local_normal(PointMirror)
1016
1017    #VectorRayReflected = mgeo.SymmetricalVector(-VectorRay, NormalMirror)
1018    VectorRayReflected = VectorRay- 2*NormalMirror*np.dot(VectorRay,NormalMirror) # Is it any better than SymmetricalVector?
1019
1020    RayReflected = Ray.copy_ray()
1021    RayReflected.point = PointMirror
1022    RayReflected.vector = VectorRayReflected
1023    RayReflected.incidence = mgeo.AngleBetweenTwoVectors(
1024    -VectorRay, NormalMirror
1025    )
1026    RayReflected.path = Ray.path + (np.linalg.norm(PointMirror - PointRay),)
1027
1028    return RayReflected
1029
1030
1031def ReflectionMirrorRayList(Mirror, ListRay, IgnoreDefects=False):
1032    """
1033    Return the the reflected rays according to the law of reflection for the list of incident rays ListRay.
1034
1035    Rays that do not hit the support are not further propagated.
1036
1037    Updates the reflected rays' incidence angle and path.
1038
1039    Parameters
1040    ----------
1041        Mirror : Mirror-object
1042
1043        ListRay : list[Ray-object]
1044
1045    """
1046    Deformed = type(Mirror) == DeformedMirror
1047    ListRayReflected = []
1048    for k in ListRay:
1049        PointMirror = Mirror._get_intersection(k)
1050
1051        if PointMirror is not None:
1052            if Deformed and IgnoreDefects:
1053                M = Mirror.Mirror
1054            else:
1055                M = Mirror
1056            RayReflected = _ReflectionMirrorRay(M, PointMirror, k)
1057            ListRayReflected.append(RayReflected)
1058    return ListRayReflected
1059
1060
1061# %% Deformed mirror definitions, need to be reworked, maybe implemented as coatings?
1062class DeformedMirror(Mirror):
1063    def __init__(self, Mirror, DeformationList):
1064        self.Mirror = Mirror
1065        self.DeformationList = DeformationList
1066        self.type = Mirror.type
1067        self.support = self.Mirror.support
1068
1069    def get_local_normal(self, PointMirror):
1070        base_normal = self.Mirror.get_normal(PointMirror)
1071        C = self.get_centre()
1072        defects_normals = [
1073            d.get_normal(PointMirror - C) for d in self.DeformationList
1074        ]
1075        for i in defects_normals:
1076            base_normal = mgeo.normal_add(base_normal, i)
1077            base_normal /= np.linalg.norm(base_normal)
1078        return base_normal
1079
1080    def get_centre(self):
1081        return self.Mirror.get_centre()
1082
1083    def get_grid3D(self, NbPoint, **kwargs):
1084        return self.Mirror.get_grid3D(NbPoint, **kwargs)
1085
1086    def _get_intersection(self, Ray):
1087        Intersect = self.Mirror._get_intersection(Ray)
1088        if Intersect is not None:
1089            h = sum(
1090                D.get_offset(Intersect - self.get_centre())
1091                for D in self.DeformationList
1092            )
1093            alpha = mgeo.AngleBetweenTwoVectors(
1094                -Ray.vector, self.Mirror.get_normal(Intersect)
1095            )
1096            Intersect -= Ray.vector * h / np.cos(alpha)
1097        return Intersect
logger = <Logger ARTcore.ModuleMirror (WARNING)>
class Curvature(enum.Enum):
39class Curvature(Enum):
40    CONVEX = -1
41    FLAT = 0
42    CONCAVE = 1
CONVEX = <Curvature.CONVEX: -1>
FLAT = <Curvature.FLAT: 0>
CONCAVE = <Curvature.CONCAVE: 1>
 44class Mirror(moe.OpticalElement):
 45    """Abstract base class for mirrors."""
 46    vectorised = False
 47    def __init__(self):
 48        self.Surface = msurf.IdealSurface()
 49        super().__init__()
 50
 51    @abstractmethod
 52    def _get_intersection(self, Ray):
 53        """
 54        This method should return the intersection point between the ray and the mirror surface.
 55        The calculation should be done in the reference frame of the mirror.
 56        It essentially defines the mirror surface (but not its normal).
 57        """
 58        pass
 59
 60    @abstractmethod
 61    def get_local_normal(self, Point: np.ndarray):
 62        """
 63        This method should return the normal unit vector in point 'Point' on the mirror surface.
 64        The normal is in the reference frame of the mirror.
 65        """
 66        pass
 67    
 68    def propagate_raylist(self, RayList, alignment=False):
 69        """
 70        Propagate a list of rays to the mirror and return the reflected rays.
 71
 72        Parameters
 73        ----------
 74            RayList : a RayList (a class defined in ARTcore.ModuleOpticalRay)
 75                Rays to propagate through the mirror.
 76
 77            alignment : bool
 78                If True, the rays are not blocked by the support. Not implemented yet
 79
 80        Returns
 81        -------
 82            ReflectedRayList : a RayList
 83                The rays that were reflected by the mirror.
 84        """
 85        #local_rays = [ray.to_basis(*self.basis) for ray in RayList]
 86        local_rays = RayList.to_basis(*self.basis)
 87        # So local_rays is a list of rays in the mirror reference frame
 88        if self.vectorised and len(local_rays) > 10:
 89            # If the class implements the vectorised version of the intersection and normal calculation
 90            intersection_points, OK = self._get_intersections(local_rays)
 91            # NB: the _get_intersections method should return two things:
 92            # - the intersection points in the mirror reference frame with nan's for rays that do not intersect
 93            # - a boolean array indicating which rays intersections make sense
 94            local_normals = mgeo.VectorArray(np.zeros_like(intersection_points))
 95            local_normals[OK] = self.get_local_normals(intersection_points[OK])
 96            local_reflected_rays = [
 97                self.Surface.reflect_ray(local_rays[i], intersection_points[i], local_normals[i]) 
 98                for i in range(len(local_rays)) if OK[i]]
 99        else:
100            intersection_points, OK = zip(*[self._get_intersection(r) for r in local_rays])
101            # OK is a boolean list indicating if the ray intersects the mirror (for instance is in the support)
102            local_reflected_rays = [
103                self.Surface.reflect_ray(local_rays[i], intersection_points[i], self.get_local_normal(intersection_points[i])) 
104                for i in range(len(local_rays)) if OK[i]]
105        reflected_rays = mray.RayList.from_list(local_reflected_rays).from_basis(*self.basis)
106        if alignment and len(reflected_rays) == 0:
107            logger.error("Unexpected error occurred: no rays were reflected during alignment procedure.")
108            logger.error("This should not happen.")
109            logger.error(f"Mirror on which error ocurred: {self}")
110            logger.error(f"Alignment ray: {RayList[0]}")
111            logger.error(f"Alignment ray in mirror reference frame: {local_rays[0]}")
112            logger.error(f"Intersection point: {self._get_intersection(local_rays[0])[0]}")
113            logger.error(f"Support: {self.support}")
114        elif len(reflected_rays) == 0:
115            logger.warning("No rays were reflected by the mirror.")
116            logger.debug(f"Mirror: {self}")
117            logger.debug(f"First ray: {RayList[0]}")
118            logger.debug(f"First ray in mirror reference frame: {RayList[0].to_basis(self.r0, self.r, self.q)}")
119            logger.debug(f"First ray intersection point: {self._get_intersection(RayList[0].to_basis(self.r0, self.r, self.q))}")
120        return reflected_rays

Abstract base class for mirrors.

vectorised = False
Surface
@abstractmethod
def get_local_normal(self, Point: numpy.ndarray):
60    @abstractmethod
61    def get_local_normal(self, Point: np.ndarray):
62        """
63        This method should return the normal unit vector in point 'Point' on the mirror surface.
64        The normal is in the reference frame of the mirror.
65        """
66        pass

This method should return the normal unit vector in point 'Point' on the mirror surface. The normal is in the reference frame of the mirror.

def propagate_raylist(self, RayList, alignment=False):
 68    def propagate_raylist(self, RayList, alignment=False):
 69        """
 70        Propagate a list of rays to the mirror and return the reflected rays.
 71
 72        Parameters
 73        ----------
 74            RayList : a RayList (a class defined in ARTcore.ModuleOpticalRay)
 75                Rays to propagate through the mirror.
 76
 77            alignment : bool
 78                If True, the rays are not blocked by the support. Not implemented yet
 79
 80        Returns
 81        -------
 82            ReflectedRayList : a RayList
 83                The rays that were reflected by the mirror.
 84        """
 85        #local_rays = [ray.to_basis(*self.basis) for ray in RayList]
 86        local_rays = RayList.to_basis(*self.basis)
 87        # So local_rays is a list of rays in the mirror reference frame
 88        if self.vectorised and len(local_rays) > 10:
 89            # If the class implements the vectorised version of the intersection and normal calculation
 90            intersection_points, OK = self._get_intersections(local_rays)
 91            # NB: the _get_intersections method should return two things:
 92            # - the intersection points in the mirror reference frame with nan's for rays that do not intersect
 93            # - a boolean array indicating which rays intersections make sense
 94            local_normals = mgeo.VectorArray(np.zeros_like(intersection_points))
 95            local_normals[OK] = self.get_local_normals(intersection_points[OK])
 96            local_reflected_rays = [
 97                self.Surface.reflect_ray(local_rays[i], intersection_points[i], local_normals[i]) 
 98                for i in range(len(local_rays)) if OK[i]]
 99        else:
100            intersection_points, OK = zip(*[self._get_intersection(r) for r in local_rays])
101            # OK is a boolean list indicating if the ray intersects the mirror (for instance is in the support)
102            local_reflected_rays = [
103                self.Surface.reflect_ray(local_rays[i], intersection_points[i], self.get_local_normal(intersection_points[i])) 
104                for i in range(len(local_rays)) if OK[i]]
105        reflected_rays = mray.RayList.from_list(local_reflected_rays).from_basis(*self.basis)
106        if alignment and len(reflected_rays) == 0:
107            logger.error("Unexpected error occurred: no rays were reflected during alignment procedure.")
108            logger.error("This should not happen.")
109            logger.error(f"Mirror on which error ocurred: {self}")
110            logger.error(f"Alignment ray: {RayList[0]}")
111            logger.error(f"Alignment ray in mirror reference frame: {local_rays[0]}")
112            logger.error(f"Intersection point: {self._get_intersection(local_rays[0])[0]}")
113            logger.error(f"Support: {self.support}")
114        elif len(reflected_rays) == 0:
115            logger.warning("No rays were reflected by the mirror.")
116            logger.debug(f"Mirror: {self}")
117            logger.debug(f"First ray: {RayList[0]}")
118            logger.debug(f"First ray in mirror reference frame: {RayList[0].to_basis(self.r0, self.r, self.q)}")
119            logger.debug(f"First ray intersection point: {self._get_intersection(RayList[0].to_basis(self.r0, self.r, self.q))}")
120        return reflected_rays

Propagate a list of rays to the mirror and return the reflected rays.

Parameters

RayList : a RayList (a class defined in ARTcore.ModuleOpticalRay)
    Rays to propagate through the mirror.

alignment : bool
    If True, the rays are not blocked by the support. Not implemented yet

Returns

ReflectedRayList : a RayList
    The rays that were reflected by the mirror.
def getClosestSphere(Mirror, Npoints=1000):
445def GetClosestSphere(Mirror, Npoints=1000):
446    """
447    This function calculates the closest sphere to the surface of a mirror.
448    It does so by sampling the surface of the mirror at Npoints points.
449    It then calculates the closest sphere to these points.
450    It returns the radius of the sphere and the center of the sphere.
451    """
452    Points = mpm.sample_support(Mirror.support, Npoints=1000)
453    Points += Mirror.r0[:2]
454    Z = Mirror._zfunc(Points)
455    Points = mgeo.PointArray([Points[:, 0], Points[:, 1], Z]).T
456    spX, spY, spZ = Points[:, 0], Points[:, 1], Points[:, 2]
457    Center, Radius = BestFitSphere(spX, spY, spZ)
458    return Center, Radius

This function calculates the closest sphere to the surface of a mirror. It does so by sampling the surface of the mirror at Npoints points. It then calculates the closest sphere to these points. It returns the radius of the sphere and the center of the sphere.

def getAsphericity(Mirror, Npoints=1000):
460def GetAsphericity(Mirror, Npoints=1000):
461    """
462    This function calculates the maximum distance of the mirror surface to the closest sphere. 
463    """
464    center, radius = GetClosestSphere(Mirror, Npoints)
465    Points = mpm.sample_support(Mirror.support, Npoints=1000)
466    Points += Mirror.r0[:2]
467    Z = Mirror._zfunc(Points)
468    Points = mgeo.PointArray([Points[:, 0], Points[:, 1], Z]).T
469    Points_centered = Points - center
470    Distance = np.linalg.norm(Points_centered, axis=1) - radius
471    Distance*=1e3 # To convert to µm
472    return np.ptp(Distance)

This function calculates the maximum distance of the mirror surface to the closest sphere.

def drawAsphericity(Mirror, Npoints=1000):
654def DrawAsphericity(Mirror, Npoints=1000):
655    """
656    This function displays a map of the asphericity of the mirror.
657    It's a scatter plot of the points of the mirror surface, with the color representing the distance to the closest sphere.
658    The closest sphere is calculated by the function get_closest_sphere, so least square method.
659
660    Parameters
661    ----------
662    Mirror : Mirror
663        The mirror to analyse.
664
665    Npoints : int, optional
666        The number of points to sample on the mirror surface. The default is 1000.
667    
668    Returns
669    -------
670    fig : Figure
671        The figure of the plot.
672    """
673    plt.ion()
674    fig = plt.figure()
675    ax = Mirror.support._ContourSupport(fig)
676    center, radius = man.GetClosestSphere(Mirror, Npoints)
677    Points = mpm.sample_support(Mirror.support, Npoints=1000)
678    Points += Mirror.r0[:2]
679    Z = Mirror._zfunc(Points)
680    Points = mgeo.PointArray([Points[:, 0], Points[:, 1], Z]).T
681    X, Y = Points[:, 0] - Mirror.r0[0], Points[:, 1] - Mirror.r0[1]
682    Points_centered = Points - center
683    Distance = np.linalg.norm(Points_centered, axis=1) - radius
684    Distance*=1e3 # To convert to µm
685    p = plt.scatter(X, Y, c=Distance, s=15)
686    divider = man.make_axes_locatable(ax)
687    cax = divider.append_axes("right", size="5%", pad=0.05)
688    cbar = fig.colorbar(p, cax=cax)
689    cbar.set_label("Distance to closest sphere (µm)")
690    ax.set_xlabel("x (mm)")
691    ax.set_ylabel("y (mm)")
692    plt.title("Asphericity map", loc="right")
693    plt.tight_layout()
694
695    bbox = ax.get_position()
696    bbox.set_points(bbox.get_points() - np.array([[0.01, 0], [0.01, 0]]))
697    ax.set_position(bbox)
698    plt.show()
699    return fig

This function displays a map of the asphericity of the mirror. It's a scatter plot of the points of the mirror surface, with the color representing the distance to the closest sphere. The closest sphere is calculated by the function get_closest_sphere, so least square method.

Parameters

Mirror : Mirror The mirror to analyse.

Npoints : int, optional The number of points to sample on the mirror surface. The default is 1000.

Returns

fig : Figure The figure of the plot.

class MirrorPlane(Mirror):
138class MirrorPlane(Mirror):
139    """
140    A plane mirror.
141
142    Attributes
143    ----------
144        support : ART.ModuleSupport.Support
145            Mirror support
146        type : str = 'Plane Mirror'
147            Human readable mirror type
148    Methods
149    -------
150        MirrorPlane.get_normal(Point)
151
152        MirrorPlane.get_centre()
153
154        MirrorPlane.get_grid3D(NbPoints)
155    """
156    vectorised = True
157    def __init__(self, Support, **kwargs):
158        """
159        Create a plane mirror on a given Support.
160
161        Parameters
162        ----------
163            Support : ART.ModuleSupport.Support
164
165        """
166        super().__init__()
167        self.support = Support
168        self.type = "Plane Mirror"
169        self.curvature = Curvature.FLAT
170
171        if "Surface" in kwargs:
172            self.Surface = kwargs["Surface"]
173
174        self.r0 = Point([0.0, 0.0, 0.0])
175
176        self.centre_ref = Point([0.0, 0.0, 0.0])
177        self.support_normal_ref = Vector([0, 0, 1.0])
178        self.majoraxis_ref = Vector([1.0, 0, 0])
179
180        self.add_global_points("centre")
181        self.add_global_vectors("support_normal", "majoraxis")
182        
183    def _get_intersection(self, Ray):
184        """Return the intersection point between Ray and the xy-plane."""
185        t = -Ray.point[2] / Ray.vector[2]
186        PointIntersection = Ray.point + Ray.vector * t
187        return PointIntersection, t > 0 and PointIntersection in self.support
188
189    def get_local_normal(self, Point: np.ndarray):
190        """Return normal unit vector in point 'Point' on the plane mirror."""
191        return Vector([0, 0, 1])
192    
193    def _get_intersections(self, RayList):
194        """
195        Vectorised version of the intersection calculation.
196        """
197        vectors = np.array([ray.vector for ray in RayList])
198        points = np.array([ray.point for ray in RayList])
199
200        t = -points[:,2] / vectors[:,2]
201        Points = points + vectors * t[:,np.newaxis]
202        OK = (t > 0) & np.array([p in self.support for p in Points])
203        return mgeo.PointArray(Points), OK
204
205    def get_local_normals(self, Points):
206        return mgeo.VectorArray(np.zeros_like(Points) + [0, 0, 1])
207
208    def _zfunc(self, PointArray):
209        x = PointArray[:,0]
210        y = PointArray[:,1]
211        return np.zeros_like(x)

A plane mirror.

Attributes

support : ART.ModuleSupport.Support
    Mirror support
type : str = 'Plane Mirror'
    Human readable mirror type

Methods

MirrorPlane.get_normal(Point)

MirrorPlane.get_centre()

MirrorPlane.get_grid3D(NbPoints)
MirrorPlane(Support, **kwargs)
157    def __init__(self, Support, **kwargs):
158        """
159        Create a plane mirror on a given Support.
160
161        Parameters
162        ----------
163            Support : ART.ModuleSupport.Support
164
165        """
166        super().__init__()
167        self.support = Support
168        self.type = "Plane Mirror"
169        self.curvature = Curvature.FLAT
170
171        if "Surface" in kwargs:
172            self.Surface = kwargs["Surface"]
173
174        self.r0 = Point([0.0, 0.0, 0.0])
175
176        self.centre_ref = Point([0.0, 0.0, 0.0])
177        self.support_normal_ref = Vector([0, 0, 1.0])
178        self.majoraxis_ref = Vector([1.0, 0, 0])
179
180        self.add_global_points("centre")
181        self.add_global_vectors("support_normal", "majoraxis")

Create a plane mirror on a given Support.

Parameters

Support : ART.ModuleSupport.Support
vectorised = True
support
type
curvature
r0
centre_ref
support_normal_ref
majoraxis_ref
def get_local_normal(self, Point: numpy.ndarray):
189    def get_local_normal(self, Point: np.ndarray):
190        """Return normal unit vector in point 'Point' on the plane mirror."""
191        return Vector([0, 0, 1])

Return normal unit vector in point 'Point' on the plane mirror.

def get_local_normals(self, Points):
205    def get_local_normals(self, Points):
206        return mgeo.VectorArray(np.zeros_like(Points) + [0, 0, 1])
def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

class MirrorSpherical(Mirror):
214class MirrorSpherical(Mirror):
215    """
216    Spherical mirror surface with eqn. $x^2 + y^2 + z^2  = R^2$, where $R$ is the radius.
217
218    ![Illustration of a spherical mirror.](sphericalmirror.svg)
219
220    Attributes
221    ----------
222        radius : float
223            Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.
224
225        support : ART.ModuleSupport.Support
226
227        type : str SphericalCC Mirror' or SphericalCX Mirror'
228
229    Methods
230    -------
231        MirrorSpherical.get_normal(Point)
232
233        MirrorSpherical.get_centre()
234
235        MirrorSpherical.get_grid3D(NbPoints)
236
237    """
238    def __init__(self, Support, Radius, **kwargs):
239        """
240        Construct a spherical mirror.
241
242        Parameters
243        ----------
244            Radius : float
245                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
246
247            Support : ART.ModuleSupport.Support
248
249        """
250        super().__init__()
251        if Radius < 0:
252            self.type = "SphericalCX Mirror"
253            self.curvature = Curvature.CONVEX
254            self.radius = -Radius
255        elif Radius > 0:
256            self.type = "SphericalCC Mirror"
257            self.curvature = Curvature.CONCAVE
258            self.radius = Radius
259        else:
260            raise ValueError("Radius of curvature must be non-zero")
261        self.support = Support
262
263        if "Surface" in kwargs:
264            self.Surface = kwargs["Surface"]
265
266        self.r0 = Point([0.0, 0, -self.radius])
267
268        self.centre_ref = Point([0.0, 0, -self.radius])
269        self.sphere_center_ref = Point([0.0, 0, 0])
270        self.focus_ref = Point([0.0, 0, -self.radius/2])
271
272        self.support_normal_ref = Vector([0, 0, 1.0])
273        self.majoraxis_ref = Vector([1.0, 0, 0])
274        self.towards_focus_ref = (self.focus_ref - self.centre_ref).normalized()
275
276        self.add_global_points("sphere_center", "focus", "centre")
277        self.add_global_vectors("towards_focus", "majoraxis", "support_normal")
278
279    def _get_intersection(self, Ray):
280        """Return the intersection point between the ray and the sphere."""
281        a = np.dot(Ray.vector, Ray.vector)
282        b = 2 * np.dot(Ray.vector, Ray.point)
283        c = np.dot(Ray.point, Ray.point) - self.radius**2
284
285        Solution = mgeo.SolverQuadratic(a, b, c)
286        Solution = mgeo.KeepPositiveSolution(Solution)
287
288        for t in Solution:
289            Intersect = Ray.vector * t + Ray.point
290            if Intersect - self.r0 in self.support:
291                return Intersect, True
292
293        return Ray.point, False
294
295    def get_local_normal(self, Point):
296        """Return the normal unit vector on the spherical surface at point Point."""
297        return -Vector(Point).normalized()
298
299    def _zfunc(self, PointArray):
300        x = PointArray[:,0]
301        y = PointArray[:,1]
302        return -np.sqrt(self.radius**2 - x**2 - y**2)

Spherical mirror surface with eqn. $x^2 + y^2 + z^2 = R^2$, where $R$ is the radius.

Illustration of a spherical mirror.

Attributes

radius : float
    Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.

support : ART.ModuleSupport.Support

type : str SphericalCC Mirror' or SphericalCX Mirror'

Methods

MirrorSpherical.get_normal(Point)

MirrorSpherical.get_centre()

MirrorSpherical.get_grid3D(NbPoints)
MirrorSpherical(Support, Radius, **kwargs)
238    def __init__(self, Support, Radius, **kwargs):
239        """
240        Construct a spherical mirror.
241
242        Parameters
243        ----------
244            Radius : float
245                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
246
247            Support : ART.ModuleSupport.Support
248
249        """
250        super().__init__()
251        if Radius < 0:
252            self.type = "SphericalCX Mirror"
253            self.curvature = Curvature.CONVEX
254            self.radius = -Radius
255        elif Radius > 0:
256            self.type = "SphericalCC Mirror"
257            self.curvature = Curvature.CONCAVE
258            self.radius = Radius
259        else:
260            raise ValueError("Radius of curvature must be non-zero")
261        self.support = Support
262
263        if "Surface" in kwargs:
264            self.Surface = kwargs["Surface"]
265
266        self.r0 = Point([0.0, 0, -self.radius])
267
268        self.centre_ref = Point([0.0, 0, -self.radius])
269        self.sphere_center_ref = Point([0.0, 0, 0])
270        self.focus_ref = Point([0.0, 0, -self.radius/2])
271
272        self.support_normal_ref = Vector([0, 0, 1.0])
273        self.majoraxis_ref = Vector([1.0, 0, 0])
274        self.towards_focus_ref = (self.focus_ref - self.centre_ref).normalized()
275
276        self.add_global_points("sphere_center", "focus", "centre")
277        self.add_global_vectors("towards_focus", "majoraxis", "support_normal")

Construct a spherical mirror.

Parameters

Radius : float
    The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.

Support : ART.ModuleSupport.Support
support
r0
centre_ref
sphere_center_ref
focus_ref
support_normal_ref
majoraxis_ref
towards_focus_ref
def get_local_normal(self, Point):
295    def get_local_normal(self, Point):
296        """Return the normal unit vector on the spherical surface at point Point."""
297        return -Vector(Point).normalized()

Return the normal unit vector on the spherical surface at point Point.

def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

class MirrorParabolic(Mirror):
305class MirrorParabolic(Mirror):
306    r"""
307    A paraboloid with vertex at the origin $O=[0,0,0]$ and symmetry axis z:
308    $z = \frac{1}{4f}[x^2 + y^2]$ where $f$ is the focal lenght of the *mother*
309    parabola (i.e. measured from its center at $O$ to the focal point $F$).
310
311    The center of the support is shifted along the x-direction by the off-axis distance $x_c$.
312    This leads to an *effective focal length* $f_\mathrm{eff}$, measured from the shifted center
313    of the support  $P$ to the focal point $F$.
314    It is related to the mother focal length by $f = f_\\mathrm{eff} \cos^2(\alpha/2) $,
315    or equivalently $ p = 2f = f_\mathrm{eff} (1+\\cos\\alpha)$, where $\\alpha$
316    is the off-axis angle, and $p = 2f$ is called the semi latus rectum.
317
318    Another useful relationship is that between the off-axis distance and the resulting
319    off-axis angle: $x_c = 2 f \tan(\alpha/2)$.
320
321
322    ![Illustration of a parabolic mirror.](parabola.svg)
323
324    Attributes
325    ----------
326        offaxisangle : float
327            Off-axis angle of the parabola. Modifying this also updates p, keeping feff constant.
328            Attention: The off-axis angle must be *given in degrees*, but is stored and will be *returned in radian* !
329
330        feff : float
331            Effective focal length of the parabola in mm. Modifying this also updates p, keeping offaxisangle constant.
332
333        p : float
334            Semi latus rectum of the parabola in mm. Modifying this also updates feff, keeping offaxisangle constant.
335
336        support : ART.ModuleSupport.Support
337
338        type : str 'Parabolic Mirror'.
339
340    Methods
341    -------
342        MirrorParabolic.get_normal(Point)
343
344        MirrorParabolic.get_centre()
345
346        MirrorParabolic.get_grid3D(NbPoints)
347
348    """
349    #vectorised = True # Unitl I fix the closest solution thing
350    def __init__(self, Support,
351                FocalEffective: float=None,
352                OffAxisAngle: float = None,
353                FocalParent: float = None,
354                RadiusParent: float = None,
355                OffAxisDistance: float = None,
356                MoreThan90: bool = None,
357                **kwargs):
358        """
359        Initialise a Parabolic mirror.
360
361        Parameters
362        ----------
363            FocalEffective : float
364                Effective focal length of the parabola in mm.
365
366            OffAxisAngle : float
367                Off-axis angle *in degrees* of the parabola.
368
369            Support : ART.ModuleSupport.Support
370
371        """
372        super().__init__()
373        self.curvature = Curvature.CONCAVE
374        self.support = Support
375        self.type = "Parabolic Mirror"
376
377        if "Surface" in kwargs:
378            self.Surface = kwargs["Surface"]
379
380        parameter_calculator = OAP_calculator()
381        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
382                                                             fs=FocalEffective,
383                                                             theta=OffAxisAngle,
384                                                             fp=FocalParent,
385                                                             Rc=RadiusParent,
386                                                             OAD=OffAxisDistance,
387                                                             more_than_90=MoreThan90)
388        self._offaxisangle = values["theta"]
389        self._feff = values["fs"]
390        self._p = values["p"]
391        self._offaxisdistance = values["OAD"]
392        self._fparent = values["fp"]
393        self._rparent = values["Rc"]
394
395        self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
396
397        self.centre_ref = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
398        self.support_normal_ref = Vector([0, 0, 1.0])
399        self.majoraxis_ref = Vector([1.0, 0, 0])
400        
401        self.focus_ref = Point([0.0, 0, self._fparent])
402        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
403        self.towards_collimated_ref = Vector([0, 0, 1.0])
404
405        self.add_global_points("focus", "centre")
406        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")
407
408    def _get_intersection(self, Ray):
409        """Return the intersection point between the ray and the parabola."""
410        ux, uy, uz = Ray.vector
411        xA, yA, zA = Ray.point
412
413        da = ux**2 + uy**2
414        db = 2 * (ux * xA + uy * yA) - 2 * self._p * uz
415        dc = xA**2 + yA**2 - 2 * self._p * zA
416
417        Solution = mgeo.SolverQuadratic(da, db, dc)
418        Solution = mgeo.KeepPositiveSolution(Solution)
419        IntersectionPoint = [Ray.point + Ray.vector * t for t in Solution if t > 0]
420        distances = [mgeo.Vector(i - self.r0).norm for i in IntersectionPoint]
421        IntersectionPoint = IntersectionPoint[distances.index(min(distances))]
422        return IntersectionPoint, IntersectionPoint-self.r0 in self.support
423    
424    def _get_intersections(self, RayList):
425        """
426        Vectorised version of the intersection calculation.
427        """
428        vectors = np.array([ray.vector for ray in RayList])
429        points = np.array([ray.point for ray in RayList])
430        Solutions = np.zeros(len(RayList), dtype=float)
431
432        da = np.sum(vectors[:,0:2]**2, axis=1)
433        db = 2 * (np.sum(vectors[:,0:2] * points[:,0:2], axis=1) - self._p * vectors[:,2])
434        dc = np.sum(points[:,0:2]**2, axis=1) - 2 * self._p * points[:,2]
435
436        linear = da == 0
437        Solutions[linear] = -dc[linear] / db[linear]
438
439        centered = (dc==0) & (da!=0) # If equation is of form ax**2 + bx = 0
440        Solutions[centered] = np.maximum(-db[centered] / da[centered], 0)
441
442        unstable = (4*da*dc) < db**2 * 1e-10 # Cases leading to catastrophic cancellation
443
444        Deltas = db**2 - 4 * da * dc
445        OK = Deltas >= 0
446        SolutionsPlus = (-db + np.sqrt(Deltas)) / 2 / da
447        SolutionsMinus = (-db - np.sqrt(Deltas)) / 2 / da
448        SolutionsPlus[unstable] = dc[unstable] / (da[unstable] * SolutionsMinus[unstable])
449        SolutionsPlus = np.where(SolutionsPlus >= 0, SolutionsPlus, np.inf)
450        SolutionsMinus = np.where(SolutionsMinus >= 0, SolutionsMinus, np.inf)
451        Solutions = np.minimum(SolutionsPlus, SolutionsMinus)
452        Solutions = np.maximum(Solutions, 0)
453        Points = points + vectors * Solutions[:,np.newaxis]
454        OK = OK & (Solutions > 0) & np.array([p-self.r0 in self.support for p in Points])
455        return mgeo.PointArray(Points), OK
456
457    def get_local_normal(self, Point):
458        """Return the normal unit vector on the paraboloid surface at point Point."""
459        Gradient = Vector(np.zeros(3))
460        Gradient[0] = -Point[0]
461        Gradient[1] = -Point[1]
462        Gradient[2] = self._p
463        return Gradient.normalized()
464
465    def get_local_normals(self, Points):
466        """
467        Vectorised version of the normal calculation.
468        """
469        Gradients = mgeo.VectorArray(np.zeros_like(Points))
470        Gradients[:,0] = -Points[:,0]
471        Gradients[:,1] = -Points[:,1]
472        Gradients[:,2] = self._p
473        return Gradients.normalized()
474    
475    def _zfunc(self, PointArray):
476        x = PointArray[:,0]
477        y = PointArray[:,1]
478        return (x**2 + y**2) / 2 / self._p

A paraboloid with vertex at the origin $O=[0,0,0]$ and symmetry axis z: $z = \frac{1}{4f}[x^2 + y^2]$ where $f$ is the focal lenght of the mother parabola (i.e. measured from its center at $O$ to the focal point $F$).

The center of the support is shifted along the x-direction by the off-axis distance $x_c$. This leads to an effective focal length $f_\mathrm{eff}$, measured from the shifted center of the support $P$ to the focal point $F$. It is related to the mother focal length by $f = f_\mathrm{eff} \cos^2(\alpha/2) $, or equivalently $ p = 2f = f_\mathrm{eff} (1+\cos\alpha)$, where $\alpha$ is the off-axis angle, and $p = 2f$ is called the semi latus rectum.

Another useful relationship is that between the off-axis distance and the resulting off-axis angle: $x_c = 2 f \tan(\alpha/2)$.

Illustration of a parabolic mirror.

Attributes

offaxisangle : float
    Off-axis angle of the parabola. Modifying this also updates p, keeping feff constant.
    Attention: The off-axis angle must be *given in degrees*, but is stored and will be *returned in radian* !

feff : float
    Effective focal length of the parabola in mm. Modifying this also updates p, keeping offaxisangle constant.

p : float
    Semi latus rectum of the parabola in mm. Modifying this also updates feff, keeping offaxisangle constant.

support : ART.ModuleSupport.Support

type : str 'Parabolic Mirror'.

Methods

MirrorParabolic.get_normal(Point)

MirrorParabolic.get_centre()

MirrorParabolic.get_grid3D(NbPoints)
MirrorParabolic( Support, FocalEffective: float = None, OffAxisAngle: float = None, FocalParent: float = None, RadiusParent: float = None, OffAxisDistance: float = None, MoreThan90: bool = None, **kwargs)
350    def __init__(self, Support,
351                FocalEffective: float=None,
352                OffAxisAngle: float = None,
353                FocalParent: float = None,
354                RadiusParent: float = None,
355                OffAxisDistance: float = None,
356                MoreThan90: bool = None,
357                **kwargs):
358        """
359        Initialise a Parabolic mirror.
360
361        Parameters
362        ----------
363            FocalEffective : float
364                Effective focal length of the parabola in mm.
365
366            OffAxisAngle : float
367                Off-axis angle *in degrees* of the parabola.
368
369            Support : ART.ModuleSupport.Support
370
371        """
372        super().__init__()
373        self.curvature = Curvature.CONCAVE
374        self.support = Support
375        self.type = "Parabolic Mirror"
376
377        if "Surface" in kwargs:
378            self.Surface = kwargs["Surface"]
379
380        parameter_calculator = OAP_calculator()
381        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
382                                                             fs=FocalEffective,
383                                                             theta=OffAxisAngle,
384                                                             fp=FocalParent,
385                                                             Rc=RadiusParent,
386                                                             OAD=OffAxisDistance,
387                                                             more_than_90=MoreThan90)
388        self._offaxisangle = values["theta"]
389        self._feff = values["fs"]
390        self._p = values["p"]
391        self._offaxisdistance = values["OAD"]
392        self._fparent = values["fp"]
393        self._rparent = values["Rc"]
394
395        self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
396
397        self.centre_ref = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
398        self.support_normal_ref = Vector([0, 0, 1.0])
399        self.majoraxis_ref = Vector([1.0, 0, 0])
400        
401        self.focus_ref = Point([0.0, 0, self._fparent])
402        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
403        self.towards_collimated_ref = Vector([0, 0, 1.0])
404
405        self.add_global_points("focus", "centre")
406        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")

Initialise a Parabolic mirror.

Parameters

FocalEffective : float
    Effective focal length of the parabola in mm.

OffAxisAngle : float
    Off-axis angle *in degrees* of the parabola.

Support : ART.ModuleSupport.Support
curvature
support
type
r0
centre_ref
support_normal_ref
majoraxis_ref
focus_ref
towards_focusing_ref
towards_collimated_ref
def get_local_normal(self, Point):
457    def get_local_normal(self, Point):
458        """Return the normal unit vector on the paraboloid surface at point Point."""
459        Gradient = Vector(np.zeros(3))
460        Gradient[0] = -Point[0]
461        Gradient[1] = -Point[1]
462        Gradient[2] = self._p
463        return Gradient.normalized()

Return the normal unit vector on the paraboloid surface at point Point.

def get_local_normals(self, Points):
465    def get_local_normals(self, Points):
466        """
467        Vectorised version of the normal calculation.
468        """
469        Gradients = mgeo.VectorArray(np.zeros_like(Points))
470        Gradients[:,0] = -Points[:,0]
471        Gradients[:,1] = -Points[:,1]
472        Gradients[:,2] = self._p
473        return Gradients.normalized()

Vectorised version of the normal calculation.

def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

vectors = {'support_normal': 'red', 'majoraxis': 'blue', 'towards_focusing_ref': 'green'}
points = {'centre_ref': 'red', 'focus_ref': 'green'}
class MirrorToroidal(Mirror):
482class MirrorToroidal(Mirror):
483    r"""
484    Toroidal mirror surface with eqn.$(\sqrt{x^2+z^2}-R)^2 + y^2 = r^2$ where $R$ and $r$ the major and minor radii.
485
486    ![Illustration of a toroidal mirror.](toroidal.svg)
487
488    Attributes
489    ----------
490        majorradius : float
491            Major radius of the toroid in mm.
492
493        minorradius : float
494            Minor radius of the toroid in mm.
495
496        support : ART.ModuleSupport.Support
497
498        type : str 'Toroidal Mirror'.
499
500    Methods
501    -------
502        MirrorToroidal.get_normal(Point)
503
504        MirrorToroidal.get_centre()
505
506        MirrorToroidal.get_grid3D(NbPoints)
507
508    """
509
510    def __init__(self, Support, MajorRadius, MinorRadius, **kwargs):
511        """
512        Construct a toroidal mirror.
513
514        Parameters
515        ----------
516            MajorRadius : float
517                Major radius of the toroid in mm.
518
519            MinorRadius : float
520                Minor radius of the toroid in mm.
521
522            Support : ART.ModuleSupport.Support
523
524        """
525        super().__init__()
526        self.support = Support
527        self.type = "Toroidal Mirror"
528
529        if "Surface" in kwargs:
530            self.Surface = kwargs["Surface"]
531
532        self.curvature = Curvature.CONCAVE
533
534        self.majorradius = MajorRadius
535        self.minorradius = MinorRadius
536
537        self.r0 = Point([0.0, 0.0, -MajorRadius - MinorRadius])
538        
539        self.centre_ref = Point([0.0, 0.0, -MajorRadius - MinorRadius])
540        self.support_normal_ref = Vector([0, 0, 1.0])
541        self.majoraxis_ref = Vector([1.0, 0, 0])
542
543        self.add_global_vectors("support_normal", "majoraxis")
544        self.add_global_points("centre")
545
546    def _get_intersection(self, Ray):
547        """Return the intersection point between Ray and the toroidal mirror surface. The intersection points are given in the reference frame of the mirror"""
548        ux = Ray.vector[0]
549        uz = Ray.vector[2]
550        xA = Ray.point[0]
551        zA = Ray.point[2]
552
553        G = 4.0 * self.majorradius**2 * (ux**2 + uz**2)
554        H = 8.0 * self.majorradius**2 * (ux * xA + uz * zA)
555        I = 4.0 * self.majorradius**2 * (xA**2 + zA**2)
556        J = np.dot(Ray.vector, Ray.vector)
557        K = 2.0 * np.dot(Ray.vector, Ray.point)
558        L = (
559            np.dot(Ray.point, Ray.point)
560            + self.majorradius**2
561            - self.minorradius**2
562        )
563
564        a = J**2
565        b = 2 * J * K
566        c = 2 * J * L + K**2 - G
567        d = 2 * K * L - H
568        e = L**2 - I
569
570        Solution = mgeo.SolverQuartic(a, b, c, d, e)
571        if len(Solution) == 0:
572            return None, False
573        Solution = [t for t in Solution if t > 0]
574        IntersectionPoint = [Ray.point + Ray.vector *i for i in Solution]
575        distances = [mgeo.Vector(i - self.r0).norm for i in IntersectionPoint]
576        IntersectionPoint = IntersectionPoint[distances.index(min(distances))]
577        OK = IntersectionPoint - self.r0 in self.support and np.abs(IntersectionPoint[2]-self.r0[2]) < self.majorradius
578        return IntersectionPoint, OK
579
580    def get_local_normal(self, Point):
581        """Return the normal unit vector on the toroidal surface at point Point in the reference frame of the mirror"""
582        x = Point[0]
583        y = Point[1]
584        z = Point[2]
585        A = self.majorradius**2 - self.minorradius**2
586
587        Gradient = Vector(np.zeros(3))
588        Gradient[0] = (
589            4 * (x**3 + x * y**2 + x * z**2 + x * A)
590            - 8 * x * self.majorradius**2
591        )
592        Gradient[1] = 4 * (y**3 + y * x**2 + y * z**2 + y * A)
593        Gradient[2] = (
594            4 * (z**3 + z * x**2 + z * y**2 + z * A)
595            - 8 * z * self.majorradius**2
596        )
597
598        return -Gradient.normalized()
599
600    def _zfunc(self, PointArray):
601        x = PointArray[:,0]
602        y = PointArray[:,1]
603        return -np.sqrt(
604            (np.sqrt(self.minorradius**2 - y**2) + self.majorradius)**2 - x**2
605        )

Toroidal mirror surface with eqn.$(\sqrt{x^2+z^2}-R)^2 + y^2 = r^2$ where $R$ and $r$ the major and minor radii.

Illustration of a toroidal mirror.

Attributes

majorradius : float
    Major radius of the toroid in mm.

minorradius : float
    Minor radius of the toroid in mm.

support : ART.ModuleSupport.Support

type : str 'Toroidal Mirror'.

Methods

MirrorToroidal.get_normal(Point)

MirrorToroidal.get_centre()

MirrorToroidal.get_grid3D(NbPoints)
MirrorToroidal(Support, MajorRadius, MinorRadius, **kwargs)
510    def __init__(self, Support, MajorRadius, MinorRadius, **kwargs):
511        """
512        Construct a toroidal mirror.
513
514        Parameters
515        ----------
516            MajorRadius : float
517                Major radius of the toroid in mm.
518
519            MinorRadius : float
520                Minor radius of the toroid in mm.
521
522            Support : ART.ModuleSupport.Support
523
524        """
525        super().__init__()
526        self.support = Support
527        self.type = "Toroidal Mirror"
528
529        if "Surface" in kwargs:
530            self.Surface = kwargs["Surface"]
531
532        self.curvature = Curvature.CONCAVE
533
534        self.majorradius = MajorRadius
535        self.minorradius = MinorRadius
536
537        self.r0 = Point([0.0, 0.0, -MajorRadius - MinorRadius])
538        
539        self.centre_ref = Point([0.0, 0.0, -MajorRadius - MinorRadius])
540        self.support_normal_ref = Vector([0, 0, 1.0])
541        self.majoraxis_ref = Vector([1.0, 0, 0])
542
543        self.add_global_vectors("support_normal", "majoraxis")
544        self.add_global_points("centre")

Construct a toroidal mirror.

Parameters

MajorRadius : float
    Major radius of the toroid in mm.

MinorRadius : float
    Minor radius of the toroid in mm.

Support : ART.ModuleSupport.Support
support
type
curvature
majorradius
minorradius
r0
centre_ref
support_normal_ref
majoraxis_ref
def get_local_normal(self, Point):
580    def get_local_normal(self, Point):
581        """Return the normal unit vector on the toroidal surface at point Point in the reference frame of the mirror"""
582        x = Point[0]
583        y = Point[1]
584        z = Point[2]
585        A = self.majorradius**2 - self.minorradius**2
586
587        Gradient = Vector(np.zeros(3))
588        Gradient[0] = (
589            4 * (x**3 + x * y**2 + x * z**2 + x * A)
590            - 8 * x * self.majorradius**2
591        )
592        Gradient[1] = 4 * (y**3 + y * x**2 + y * z**2 + y * A)
593        Gradient[2] = (
594            4 * (z**3 + z * x**2 + z * y**2 + z * A)
595            - 8 * z * self.majorradius**2
596        )
597
598        return -Gradient.normalized()

Return the normal unit vector on the toroidal surface at point Point in the reference frame of the mirror

def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

def ReturnOptimalToroidalRadii( Focal: float, AngleIncidence: float) -> (<class 'float'>, <class 'float'>):
607def ReturnOptimalToroidalRadii(
608    Focal: float, AngleIncidence: float
609) -> (float, float):
610    """
611    Get optimal parameters for a toroidal mirror.
612
613    Useful helper function to get the optimal major and minor radii for a toroidal mirror to achieve a
614    focal length 'Focal' with an angle of incidence 'AngleIncidence' and with vanishing astigmatism.
615
616    Parameters
617    ----------
618        Focal : float
619            Focal length in mm.
620
621        AngleIncidence : int
622            Angle of incidence in degrees.
623
624    Returns
625    -------
626        OptimalMajorRadius, OptimalMinorRadius : float, float.
627    """
628    AngleIncidenceRadian = AngleIncidence * np.pi / 180
629    OptimalMajorRadius = (
630        2
631        * Focal
632        * (1 / np.cos(AngleIncidenceRadian) - np.cos(AngleIncidenceRadian))
633    )
634    OptimalMinorRadius = 2 * Focal * np.cos(AngleIncidenceRadian)
635    return OptimalMajorRadius, OptimalMinorRadius

Get optimal parameters for a toroidal mirror.

Useful helper function to get the optimal major and minor radii for a toroidal mirror to achieve a focal length 'Focal' with an angle of incidence 'AngleIncidence' and with vanishing astigmatism.

Parameters

Focal : float
    Focal length in mm.

AngleIncidence : int
    Angle of incidence in degrees.

Returns

OptimalMajorRadius, OptimalMinorRadius : float, float.
class MirrorEllipsoidal(Mirror):
639class MirrorEllipsoidal(Mirror):
640    """
641    Ellipdoidal mirror surface with eqn. $(x/a)^2 + (y/b)^2 + (z/b)^2 = 1$, where $a$ and $b$ are semi major and semi minor axes.
642
643    ![Illustration of a ellipsoidal mirror.](ellipsoid.svg)
644
645    Attributes
646    ----------
647        a : float
648            Semi major axis of the ellipsoid in mm.
649
650        b : float
651            Semi minor axis of the ellipsoid in mm.
652
653        support : ART.ModuleSupport.Support
654
655        type : str 'Ellipsoidal Mirror'.
656
657    Methods
658    -------
659        MirrorEllipsoidal.get_normal(Point)
660
661        MirrorEllipsoidal.get_centre()
662
663        MirrorEllipsoidal.get_grid3D(NbPoints)
664
665    """
666
667    def __init__(
668        self,
669        Support,
670        SemiMajorAxis=None,
671        SemiMinorAxis=None,
672        OffAxisAngle=None,
673        f_object=None,
674        f_image=None,
675        IncidenceAngle=None,
676        Magnification=None,
677        DistanceObjectImage=None,
678        Eccentricity=None,
679        **kwargs,
680    ):
681        """
682        Generate an ellipsoidal mirror with given parameters.
683
684        The angles are given in degrees but converted to radians for internal calculations.
685        You can specify the semi-major and semi-minor axes, the off-axis angle, the object and image focal distances or some other parameters.
686        The constructor uses a magic calculator to determine the missing parameters.
687
688        Parameters
689        ----------
690        Support : TYPE
691            ART.ModuleSupport.Support.
692        SemiMajorAxis : float (optional)
693            Semi major axis of the ellipsoid in mm..
694        SemiMinorAxis : float (optional)
695            Semi minor axis of the ellipsoid in mm..
696        OffAxisAngle : float (optional)
697            Off-axis angle of the mirror in mm. Defined as the angle at the centre of the mirror between the two foci..
698        f_object : float (optional)
699            Object focal distance in mm.
700        f_image : float (optional)
701            Image focal distance in mm.
702        IncidenceAngle : float  (optional)
703            Angle of incidence in degrees.
704        Magnification : float  (optional)
705            Magnification.
706        DistanceObjectImage : float  (optional)
707            Distance between object and image in mm.
708        Eccentricity : float  (optional)
709            Eccentricity of the ellipsoid.
710        """
711        super().__init__()
712        self.type = "Ellipsoidal Mirror"
713        self.support = Support
714
715        if "Surface" in kwargs:
716            self.Surface = kwargs["Surface"]
717
718        parameter_calculator = EllipsoidalMirrorCalculator()
719        values, steps = parameter_calculator.calculate_values(verify_consistency=True, 
720                                                              f1=f_object, 
721                                                              f2=f_image, 
722                                                              a=SemiMajorAxis, 
723                                                              b=SemiMinorAxis, 
724                                                              offset_angle=OffAxisAngle,
725                                                              incidence_angle=IncidenceAngle,
726                                                              m=Magnification,
727                                                              l=DistanceObjectImage,
728                                                              e=Eccentricity)
729        self.a = values["a"]
730        self.b = values["b"]
731        self._offaxisangle = np.deg2rad(values["offset_angle"])
732        self._f_object = values["f1"]
733        self._f_image = values["f2"]
734
735        self.centre_ref = self._get_centre_ref()
736
737        self.r0 = self.centre_ref
738
739        self.support_normal_ref = Vector([0, 0, 1.0])
740        self.majoraxis_ref = Vector([1.0, 0, 0])
741
742        self.f1_ref = Point([-self.a, 0,0])
743        self.f2_ref = Point([ self.a, 0,0])
744        self.towards_image_ref = (self.f2_ref - self.centre_ref).normalized()
745        self.towards_object_ref = (self.f1_ref - self.centre_ref).normalized()
746        self.centre_normal_ref = self.get_local_normal(self.centre_ref)
747
748        self.add_global_points("f1", "f2", "centre")
749        self.add_global_vectors("towards_image", "towards_object", "centre_normal", "support_normal", "majoraxis")
750
751    def _get_intersection(self, Ray):
752        """Return the intersection point between Ray and the ellipsoidal mirror surface."""
753        ux, uy, uz = Ray.vector
754        xA, yA, zA = Ray.point
755
756        da = (uy**2 + uz**2) / self.b**2 + (ux / self.a) ** 2
757        db = 2 * ((uy * yA + uz * zA) / self.b**2 + (ux * xA) / self.a**2)
758        dc = (yA**2 + zA**2) / self.b**2 + (xA / self.a) ** 2 - 1
759
760        Solution = mgeo.SolverQuadratic(da, db, dc)
761        Solution = mgeo.KeepPositiveSolution(Solution)
762
763        ListPointIntersection = []
764        C = self.get_centre_ref()
765        for t in Solution:
766            Intersect = Ray.vector * t + Ray.point
767            if Intersect[2] < 0 and Intersect - C in self.support:
768                ListPointIntersection.append(Intersect)
769
770        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
771
772    def get_local_normal(self, Point):
773        """Return the normal unit vector on the ellipsoidal surface at point Point."""
774        Gradient = Vector(np.zeros(3))
775
776        Gradient[0] = -Point[0] / self.a**2
777        Gradient[1] = -Point[1] / self.b**2
778        Gradient[2] = -Point[2] / self.b**2
779
780        return Gradient.normalized()
781
782    def _get_centre_ref(self):
783        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
784        foci = 2 * np.sqrt(self.a**2 - self.b**2) # distance between the foci
785        h = -foci / 2 / np.tan(self._offaxisangle)
786        R = np.sqrt(foci**2 / 4 + h**2) 
787        sign = 1
788        if math.isclose(self._offaxisangle, np.pi / 2):
789            h = 0
790        elif self._offaxisangle > np.pi / 2:
791            h = -h
792            sign = -1
793        a = 1 - self.a**2 / self.b**2
794        b = -2 * h
795        c = self.a**2 + h**2 - R**2
796        z = (-b + sign * np.sqrt(b**2 - 4 * a * c)) / (2 * a)
797        if math.isclose(z**2, self.b**2):
798            return np.array([0, 0, -self.b])
799        x = self.a * np.sqrt(1 - z**2 / self.b**2)
800        centre = Point([x, 0, sign * z])
801        return centre
802
803    def _zfunc(self, PointArray):
804        x = PointArray[:,0]
805        y = PointArray[:,1]
806        x-= self.r0[0]
807        y-= self.r0[1]
808        z =  -np.sqrt(1 - (x / self.a)**2 - (y / self.b)**2)
809        z += self.r0[2]
810        return z

Ellipdoidal mirror surface with eqn. $(x/a)^2 + (y/b)^2 + (z/b)^2 = 1$, where $a$ and $b$ are semi major and semi minor axes.

Illustration of a ellipsoidal mirror.

Attributes

a : float
    Semi major axis of the ellipsoid in mm.

b : float
    Semi minor axis of the ellipsoid in mm.

support : ART.ModuleSupport.Support

type : str 'Ellipsoidal Mirror'.

Methods

MirrorEllipsoidal.get_normal(Point)

MirrorEllipsoidal.get_centre()

MirrorEllipsoidal.get_grid3D(NbPoints)
MirrorEllipsoidal( Support, SemiMajorAxis=None, SemiMinorAxis=None, OffAxisAngle=None, f_object=None, f_image=None, IncidenceAngle=None, Magnification=None, DistanceObjectImage=None, Eccentricity=None, **kwargs)
667    def __init__(
668        self,
669        Support,
670        SemiMajorAxis=None,
671        SemiMinorAxis=None,
672        OffAxisAngle=None,
673        f_object=None,
674        f_image=None,
675        IncidenceAngle=None,
676        Magnification=None,
677        DistanceObjectImage=None,
678        Eccentricity=None,
679        **kwargs,
680    ):
681        """
682        Generate an ellipsoidal mirror with given parameters.
683
684        The angles are given in degrees but converted to radians for internal calculations.
685        You can specify the semi-major and semi-minor axes, the off-axis angle, the object and image focal distances or some other parameters.
686        The constructor uses a magic calculator to determine the missing parameters.
687
688        Parameters
689        ----------
690        Support : TYPE
691            ART.ModuleSupport.Support.
692        SemiMajorAxis : float (optional)
693            Semi major axis of the ellipsoid in mm..
694        SemiMinorAxis : float (optional)
695            Semi minor axis of the ellipsoid in mm..
696        OffAxisAngle : float (optional)
697            Off-axis angle of the mirror in mm. Defined as the angle at the centre of the mirror between the two foci..
698        f_object : float (optional)
699            Object focal distance in mm.
700        f_image : float (optional)
701            Image focal distance in mm.
702        IncidenceAngle : float  (optional)
703            Angle of incidence in degrees.
704        Magnification : float  (optional)
705            Magnification.
706        DistanceObjectImage : float  (optional)
707            Distance between object and image in mm.
708        Eccentricity : float  (optional)
709            Eccentricity of the ellipsoid.
710        """
711        super().__init__()
712        self.type = "Ellipsoidal Mirror"
713        self.support = Support
714
715        if "Surface" in kwargs:
716            self.Surface = kwargs["Surface"]
717
718        parameter_calculator = EllipsoidalMirrorCalculator()
719        values, steps = parameter_calculator.calculate_values(verify_consistency=True, 
720                                                              f1=f_object, 
721                                                              f2=f_image, 
722                                                              a=SemiMajorAxis, 
723                                                              b=SemiMinorAxis, 
724                                                              offset_angle=OffAxisAngle,
725                                                              incidence_angle=IncidenceAngle,
726                                                              m=Magnification,
727                                                              l=DistanceObjectImage,
728                                                              e=Eccentricity)
729        self.a = values["a"]
730        self.b = values["b"]
731        self._offaxisangle = np.deg2rad(values["offset_angle"])
732        self._f_object = values["f1"]
733        self._f_image = values["f2"]
734
735        self.centre_ref = self._get_centre_ref()
736
737        self.r0 = self.centre_ref
738
739        self.support_normal_ref = Vector([0, 0, 1.0])
740        self.majoraxis_ref = Vector([1.0, 0, 0])
741
742        self.f1_ref = Point([-self.a, 0,0])
743        self.f2_ref = Point([ self.a, 0,0])
744        self.towards_image_ref = (self.f2_ref - self.centre_ref).normalized()
745        self.towards_object_ref = (self.f1_ref - self.centre_ref).normalized()
746        self.centre_normal_ref = self.get_local_normal(self.centre_ref)
747
748        self.add_global_points("f1", "f2", "centre")
749        self.add_global_vectors("towards_image", "towards_object", "centre_normal", "support_normal", "majoraxis")

Generate an ellipsoidal mirror with given parameters.

The angles are given in degrees but converted to radians for internal calculations. You can specify the semi-major and semi-minor axes, the off-axis angle, the object and image focal distances or some other parameters. The constructor uses a magic calculator to determine the missing parameters.

Parameters

Support : TYPE ART.ModuleSupport.Support. SemiMajorAxis : float (optional) Semi major axis of the ellipsoid in mm.. SemiMinorAxis : float (optional) Semi minor axis of the ellipsoid in mm.. OffAxisAngle : float (optional) Off-axis angle of the mirror in mm. Defined as the angle at the centre of the mirror between the two foci.. f_object : float (optional) Object focal distance in mm. f_image : float (optional) Image focal distance in mm. IncidenceAngle : float (optional) Angle of incidence in degrees. Magnification : float (optional) Magnification. DistanceObjectImage : float (optional) Distance between object and image in mm. Eccentricity : float (optional) Eccentricity of the ellipsoid.

type
support
a
b
centre_ref
r0
support_normal_ref
majoraxis_ref
f1_ref
f2_ref
towards_image_ref
towards_object_ref
centre_normal_ref
def get_local_normal(self, Point):
772    def get_local_normal(self, Point):
773        """Return the normal unit vector on the ellipsoidal surface at point Point."""
774        Gradient = Vector(np.zeros(3))
775
776        Gradient[0] = -Point[0] / self.a**2
777        Gradient[1] = -Point[1] / self.b**2
778        Gradient[2] = -Point[2] / self.b**2
779
780        return Gradient.normalized()

Return the normal unit vector on the ellipsoidal surface at point Point.

def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

vectors = {'support_normal_ref': 'red', 'majoraxis_ref': 'blue', 'towards_image_ref': 'green', 'towards_object_ref': 'green', 'centre_normal_ref': 'purple'}
class MirrorCylindrical(Mirror):
814class MirrorCylindrical(Mirror):
815    """
816    Cylindrical mirror surface with eqn. $y^2 + z^2  = R^2$, where $R$ is the radius.
817
818    Attributes
819    ----------
820        radius : float
821            Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.
822
823        support : ART.ModuleSupport.Support
824
825        type : str 'Cylindrical Mirror'.
826
827    Methods
828    -------
829        MirrorCylindrical.get_normal(Point)
830
831        MirrorCylindrical.get_centre()
832
833        MirrorCylindrical.get_grid3D(NbPoints)
834    """
835        
836    def __init__(self, Support, Radius, **kwargs):
837        """
838        Construct a cylindrical mirror.
839
840        Parameters
841        ----------
842            Radius : float
843                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
844
845            Support : ART.ModuleSupport.Support
846
847        """
848        super().__init__()
849        if Radius < 0:
850            self.type = "CylindricalCX Mirror"
851            self.curvature = Curvature.CONVEX
852            self.radius = -Radius
853        else:
854            self.type = "CylindricalCC Mirror"
855            self.curvature = Curvature.CONCAVE
856            self.radius = Radius
857
858        self.support = Support
859
860        if "Surface" in kwargs:
861            self.Surface = kwargs["Surface"]
862
863        self.r0 = Point([0.0, 0.0, -self.radius])
864
865        self.support_normal_ref = Vector([0, 0, 1.0])
866        self.majoraxis_ref = Vector([1.0, 0, 0])
867        self.centre_ref = Point([0.0, 0.0, -self.radius])
868
869        self.add_global_points("centre")
870        self.add_global_vectors("support_normal", "majoraxis")
871
872    def _get_intersection(self, Ray):
873        """Return the intersection point between the Ray and the cylinder."""
874        uy = Ray.vector[1]
875        uz = Ray.vector[2]
876        yA = Ray.point[1]
877        zA = Ray.point[2]
878
879        a = uy**2 + uz**2
880        b = 2 * (uy * yA + uz * zA)
881        c = yA**2 + zA**2 - self.radius**2
882
883        Solution = mgeo.SolverQuadratic(a, b, c)
884        Solution = mgeo.KeepPositiveSolution(Solution)
885
886        ListPointIntersection = []
887        for t in Solution:
888            Intersect = Ray.vector * t + Ray.point
889            if Intersect[2] < 0 and Intersect in self.support:
890                ListPointIntersection.append(Intersect)
891
892        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
893
894    def get_local_normal(self, Point):
895        """Return the normal unit vector on the cylinder surface at point P."""
896        Gradient = Vector([0, -Point[1], -Point[2]])
897        return Gradient.normalized()
898
899    def get_centre(self):
900        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
901        return Point([0, 0, -self.radius])
902    
903    def _zfunc(self, PointArray):
904        y = PointArray[:,1]
905        return -np.sqrt(self.radius**2 - y**2)

Cylindrical mirror surface with eqn. $y^2 + z^2 = R^2$, where $R$ is the radius.

Attributes

radius : float
    Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.

support : ART.ModuleSupport.Support

type : str 'Cylindrical Mirror'.

Methods

MirrorCylindrical.get_normal(Point)

MirrorCylindrical.get_centre()

MirrorCylindrical.get_grid3D(NbPoints)
MirrorCylindrical(Support, Radius, **kwargs)
836    def __init__(self, Support, Radius, **kwargs):
837        """
838        Construct a cylindrical mirror.
839
840        Parameters
841        ----------
842            Radius : float
843                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
844
845            Support : ART.ModuleSupport.Support
846
847        """
848        super().__init__()
849        if Radius < 0:
850            self.type = "CylindricalCX Mirror"
851            self.curvature = Curvature.CONVEX
852            self.radius = -Radius
853        else:
854            self.type = "CylindricalCC Mirror"
855            self.curvature = Curvature.CONCAVE
856            self.radius = Radius
857
858        self.support = Support
859
860        if "Surface" in kwargs:
861            self.Surface = kwargs["Surface"]
862
863        self.r0 = Point([0.0, 0.0, -self.radius])
864
865        self.support_normal_ref = Vector([0, 0, 1.0])
866        self.majoraxis_ref = Vector([1.0, 0, 0])
867        self.centre_ref = Point([0.0, 0.0, -self.radius])
868
869        self.add_global_points("centre")
870        self.add_global_vectors("support_normal", "majoraxis")

Construct a cylindrical mirror.

Parameters

Radius : float
    The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.

Support : ART.ModuleSupport.Support
support
r0
support_normal_ref
majoraxis_ref
centre_ref
def get_local_normal(self, Point):
894    def get_local_normal(self, Point):
895        """Return the normal unit vector on the cylinder surface at point P."""
896        Gradient = Vector([0, -Point[1], -Point[2]])
897        return Gradient.normalized()

Return the normal unit vector on the cylinder surface at point P.

def get_centre(self):
899    def get_centre(self):
900        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
901        return Point([0, 0, -self.radius])

Return 3D coordinates of the point on the mirror surface at the center of its support.

def render( Mirror, Npoints=1000, draw_support=False, draw_points=False, draw_vectors=True, recenter_support=True):
234def _RenderMirror(Mirror, Npoints=1000, draw_support = False, draw_points = False, draw_vectors = True , recenter_support = True):
235    """
236    This function renders a mirror in 3D.
237    """
238    mesh = Mirror._Render(Npoints)
239    p = pv.Plotter()
240    p.add_mesh(mesh)
241    if draw_support:
242        support = Mirror.support._Render()
243        if recenter_support:
244            support.translate(Mirror.r0,inplace=True)
245        p.add_mesh(support, color="gray", opacity=0.5)
246    
247    if draw_vectors:
248        # We draw the important vectors of the optical element
249        # For that, if we have a "vectors" attribute, we use that
250        #  (a dictionary with the vector names as keys and the colors as values)
251        # Otherwise we use the default vectors: "support_normal", "majoraxis"
252        if hasattr(Mirror, "vectors"):
253            vectors = Mirror.vectors
254        else:
255            vectors = {"support_normal_ref": "red", "majoraxis_ref": "blue"}
256        for vector, color in vectors.items():
257            if hasattr(Mirror, vector):
258                p.add_arrows(Mirror.r0, 10*getattr(Mirror, vector), color=color)
259    
260    if draw_points:
261        # We draw the important points of the optical element
262        # For that, if we have a "points" attribute, we use that
263        #  (a dictionary with the point names as keys and the colors as values)
264        # Otherwise we use the default points: "centre_ref"
265        if hasattr(Mirror, "points"):
266            points = Mirror.points
267        else:
268            points = {"centre_ref": "red"}
269        for point, color in points.items():
270            if hasattr(Mirror, point):
271                p.add_mesh(pv.Sphere(radius=1, center=getattr(Mirror, point)), color=color)
272    else:
273        p.add_mesh(pv.Sphere(radius=1, center=Mirror.r0), color="red")
274    p.show()
275    return p

This function renders a mirror in 3D.

class GrazingParabola(Mirror):
912class GrazingParabola(Mirror):
913    r"""
914    A parabolic mirror with a support parallel to the surface at the optical center.
915    Needs to be completed, currently not functional.
916    TODO
917    """
918
919    def __init__(self, Support,
920                FocalEffective: float=None,
921                OffAxisAngle: float = None,
922                FocalParent: float = None,
923                RadiusParent: float = None,
924                OffAxisDistance: float = None,
925                MoreThan90: bool = None,
926                **kwargs):
927        """
928        Initialise a Parabolic mirror.
929
930        Parameters
931        ----------
932            FocalEffective : float
933                Effective focal length of the parabola in mm.
934
935            OffAxisAngle : float
936                Off-axis angle *in degrees* of the parabola.
937
938            Support : ART.ModuleSupport.Support
939
940        """
941        super().__init__()
942        self.curvature = Curvature.CONCAVE
943        self.support = Support
944        self.type = "Grazing Parabolic Mirror"
945
946        if "Surface" in kwargs:
947            self.Surface = kwargs["Surface"]
948
949        parameter_calculator = OAP_calculator()
950        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
951                                                             fs=FocalEffective,
952                                                             theta=OffAxisAngle,
953                                                             fp=FocalParent,
954                                                             Rc=RadiusParent,
955                                                             OAD=OffAxisDistance,
956                                                             more_than_90=MoreThan90)
957        self._offaxisangle = values["theta"]
958        self._feff = values["fs"]
959        self._p = values["p"]
960        self._offaxisdistance = values["OAD"]
961        self._fparent = values["fp"]
962        self._rparent = values["Rc"]
963
964        #self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
965        self.r0 = Point([0, 0, 0])
966
967        self.centre_ref = Point([0, 0, 0])
968        self.support_normal_ref = Vector([0, 0, 1.0])
969        self.majoraxis_ref = Vector([1.0, 0, 0])
970        
971        focus_ref = Point([np.sqrt(self._feff**2 - self._offaxisdistance**2), 0, self._offaxisdistance]) # frame where x is as collimated direction
972        # We need to rotate it in the trigonometric direction by the 90°-theta/2
973        angle = np.pi/2 - self._offaxisangle/2
974        self.focus_ref = Point([focus_ref[0]*np.cos(angle)+focus_ref[2]*np.sin(angle),focus_ref[1], -focus_ref[0]*np.sin(angle)+focus_ref[2]*np.cos(angle), ])
975        
976        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
977        towards_collimated_ref = Vector([-1.0, 0, 0]) # Again, we need to rotate it by the same angle
978        self.collimated_ref = Point([-np.cos(angle), 0, np.sin(angle)])
979
980        self.add_global_points("focus", "centre")
981        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")
982
983    def _get_intersection(self, Ray):
984        """
985        Return the intersection point between the ray and the parabola.
986        """
987        pass
988
989    def get_local_normal(self, Point):
990        """
991        Return the normal unit vector on the paraboloid surface at point Point.
992        """
993        pass
994
995    def _zfunc(self, PointArray):
996        pass

A parabolic mirror with a support parallel to the surface at the optical center. Needs to be completed, currently not functional. TODO

GrazingParabola( Support, FocalEffective: float = None, OffAxisAngle: float = None, FocalParent: float = None, RadiusParent: float = None, OffAxisDistance: float = None, MoreThan90: bool = None, **kwargs)
919    def __init__(self, Support,
920                FocalEffective: float=None,
921                OffAxisAngle: float = None,
922                FocalParent: float = None,
923                RadiusParent: float = None,
924                OffAxisDistance: float = None,
925                MoreThan90: bool = None,
926                **kwargs):
927        """
928        Initialise a Parabolic mirror.
929
930        Parameters
931        ----------
932            FocalEffective : float
933                Effective focal length of the parabola in mm.
934
935            OffAxisAngle : float
936                Off-axis angle *in degrees* of the parabola.
937
938            Support : ART.ModuleSupport.Support
939
940        """
941        super().__init__()
942        self.curvature = Curvature.CONCAVE
943        self.support = Support
944        self.type = "Grazing Parabolic Mirror"
945
946        if "Surface" in kwargs:
947            self.Surface = kwargs["Surface"]
948
949        parameter_calculator = OAP_calculator()
950        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
951                                                             fs=FocalEffective,
952                                                             theta=OffAxisAngle,
953                                                             fp=FocalParent,
954                                                             Rc=RadiusParent,
955                                                             OAD=OffAxisDistance,
956                                                             more_than_90=MoreThan90)
957        self._offaxisangle = values["theta"]
958        self._feff = values["fs"]
959        self._p = values["p"]
960        self._offaxisdistance = values["OAD"]
961        self._fparent = values["fp"]
962        self._rparent = values["Rc"]
963
964        #self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
965        self.r0 = Point([0, 0, 0])
966
967        self.centre_ref = Point([0, 0, 0])
968        self.support_normal_ref = Vector([0, 0, 1.0])
969        self.majoraxis_ref = Vector([1.0, 0, 0])
970        
971        focus_ref = Point([np.sqrt(self._feff**2 - self._offaxisdistance**2), 0, self._offaxisdistance]) # frame where x is as collimated direction
972        # We need to rotate it in the trigonometric direction by the 90°-theta/2
973        angle = np.pi/2 - self._offaxisangle/2
974        self.focus_ref = Point([focus_ref[0]*np.cos(angle)+focus_ref[2]*np.sin(angle),focus_ref[1], -focus_ref[0]*np.sin(angle)+focus_ref[2]*np.cos(angle), ])
975        
976        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
977        towards_collimated_ref = Vector([-1.0, 0, 0]) # Again, we need to rotate it by the same angle
978        self.collimated_ref = Point([-np.cos(angle), 0, np.sin(angle)])
979
980        self.add_global_points("focus", "centre")
981        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")

Initialise a Parabolic mirror.

Parameters

FocalEffective : float
    Effective focal length of the parabola in mm.

OffAxisAngle : float
    Off-axis angle *in degrees* of the parabola.

Support : ART.ModuleSupport.Support
curvature
support
type
r0
centre_ref
support_normal_ref
majoraxis_ref
focus_ref
towards_focusing_ref
collimated_ref
def get_local_normal(self, Point):
989    def get_local_normal(self, Point):
990        """
991        Return the normal unit vector on the paraboloid surface at point Point.
992        """
993        pass

Return the normal unit vector on the paraboloid surface at point Point.

def ReflectionMirrorRayList(Mirror, ListRay, IgnoreDefects=False):
1032def ReflectionMirrorRayList(Mirror, ListRay, IgnoreDefects=False):
1033    """
1034    Return the the reflected rays according to the law of reflection for the list of incident rays ListRay.
1035
1036    Rays that do not hit the support are not further propagated.
1037
1038    Updates the reflected rays' incidence angle and path.
1039
1040    Parameters
1041    ----------
1042        Mirror : Mirror-object
1043
1044        ListRay : list[Ray-object]
1045
1046    """
1047    Deformed = type(Mirror) == DeformedMirror
1048    ListRayReflected = []
1049    for k in ListRay:
1050        PointMirror = Mirror._get_intersection(k)
1051
1052        if PointMirror is not None:
1053            if Deformed and IgnoreDefects:
1054                M = Mirror.Mirror
1055            else:
1056                M = Mirror
1057            RayReflected = _ReflectionMirrorRay(M, PointMirror, k)
1058            ListRayReflected.append(RayReflected)
1059    return ListRayReflected

Return the the reflected rays according to the law of reflection for the list of incident rays ListRay.

Rays that do not hit the support are not further propagated.

Updates the reflected rays' incidence angle and path.

Parameters

Mirror : Mirror-object

ListRay : list[Ray-object]
class DeformedMirror(Mirror):
1063class DeformedMirror(Mirror):
1064    def __init__(self, Mirror, DeformationList):
1065        self.Mirror = Mirror
1066        self.DeformationList = DeformationList
1067        self.type = Mirror.type
1068        self.support = self.Mirror.support
1069
1070    def get_local_normal(self, PointMirror):
1071        base_normal = self.Mirror.get_normal(PointMirror)
1072        C = self.get_centre()
1073        defects_normals = [
1074            d.get_normal(PointMirror - C) for d in self.DeformationList
1075        ]
1076        for i in defects_normals:
1077            base_normal = mgeo.normal_add(base_normal, i)
1078            base_normal /= np.linalg.norm(base_normal)
1079        return base_normal
1080
1081    def get_centre(self):
1082        return self.Mirror.get_centre()
1083
1084    def get_grid3D(self, NbPoint, **kwargs):
1085        return self.Mirror.get_grid3D(NbPoint, **kwargs)
1086
1087    def _get_intersection(self, Ray):
1088        Intersect = self.Mirror._get_intersection(Ray)
1089        if Intersect is not None:
1090            h = sum(
1091                D.get_offset(Intersect - self.get_centre())
1092                for D in self.DeformationList
1093            )
1094            alpha = mgeo.AngleBetweenTwoVectors(
1095                -Ray.vector, self.Mirror.get_normal(Intersect)
1096            )
1097            Intersect -= Ray.vector * h / np.cos(alpha)
1098        return Intersect

Abstract base class for mirrors.

DeformedMirror(Mirror, DeformationList)
1064    def __init__(self, Mirror, DeformationList):
1065        self.Mirror = Mirror
1066        self.DeformationList = DeformationList
1067        self.type = Mirror.type
1068        self.support = self.Mirror.support
Mirror
DeformationList
type
support
def get_local_normal(self, PointMirror):
1070    def get_local_normal(self, PointMirror):
1071        base_normal = self.Mirror.get_normal(PointMirror)
1072        C = self.get_centre()
1073        defects_normals = [
1074            d.get_normal(PointMirror - C) for d in self.DeformationList
1075        ]
1076        for i in defects_normals:
1077            base_normal = mgeo.normal_add(base_normal, i)
1078            base_normal /= np.linalg.norm(base_normal)
1079        return base_normal

This method should return the normal unit vector in point 'Point' on the mirror surface. The normal is in the reference frame of the mirror.

def get_centre(self):
1081    def get_centre(self):
1082        return self.Mirror.get_centre()
def get_grid3D(self, NbPoint, **kwargs):
1084    def get_grid3D(self, NbPoint, **kwargs):
1085        return self.Mirror.get_grid3D(NbPoint, **kwargs)

6 - ModuleOpticalChain

Provides the class OpticalChain, which represents the whole optical setup to be simulated, from the bundle of source-Rays through the successive OpticalElements.

Illustration the Mirror-class.

Created in Jan 2022

@author: Stefan Haessler

  1"""
  2Provides the class OpticalChain, which represents the whole optical setup to be simulated,
  3from the bundle of source-[Rays](ModuleOpticalRay.html) through the successive [OpticalElements](ModuleOpticalElement.html).
  4
  5![Illustration the Mirror-class.](OpticalChain.svg)
  6
  7
  8
  9Created in Jan 2022
 10
 11@author: Stefan Haessler
 12"""
 13# %% Modules
 14import copy
 15import numpy as np
 16import logging
 17
 18import ARTcore.ModuleProcessing as mp
 19import ARTcore.ModuleGeometry as mgeo
 20import ARTcore.ModuleOpticalRay as mray
 21import ARTcore.ModuleOpticalElement as moe
 22import ARTcore.ModuleDetector as mdet
 23import ARTcore.ModuleSource as msource
 24
 25logger = logging.getLogger(__name__)
 26
 27# %%
 28class OpticalChain:
 29    """
 30    The OpticalChain represents the whole optical setup to be simulated:
 31    Its main attributes are a list source-[Rays](ModuleOpticalRay.html) and
 32    a list of successive [OpticalElements](ModuleOpticalElement.html).
 33
 34    The method OpticalChain.get_output_rays() returns an associated list of lists of
 35    [Rays](ModuleOpticalRay.html), each calculated by ray-tracing from one
 36    [OpticalElement](ModuleOpticalElement.html) to the next.
 37    So OpticalChain.get_output_rays()[i] is the bundle of [Rays](ModuleOpticalRay.html) *after*
 38    optical_elements[i].
 39
 40    The string "description" can contain a short description of the optical setup, or similar notes.
 41
 42    The OpticalChain can be visualized quickly with the method OpticalChain.quickshow(),
 43    and more nicely with OpticalChain.render().
 44
 45    The class also provides methods for (mis-)alignment of the source-[Ray](ModuleOpticalRay.html)-bundle and the
 46    [OpticalElements](ModuleOpticalElement.html).
 47    
 48    Attributes
 49    ----------
 50        source_rays : list[mray.Ray]
 51            List of source rays, which are to be traced.
 52
 53        optical_elements : list[moe.OpticalElement]
 54            List of successive optical elements.
 55
 56        detector: mdet.Detector (optional)
 57            The detector (or list of detectors) to analyse the results.
 58        
 59        description : str
 60            A string to describe the optical setup.
 61    Methods
 62    ----------
 63        copy_chain()
 64
 65        get_output_rays()
 66
 67        render()
 68
 69        ----------
 70
 71        shift_source(axis, distance)
 72
 73        tilt_source(self, axis, angle)
 74
 75        ----------
 76
 77        rotate_OE(OEindx, axis, angle)
 78
 79        shift_OE(OEindx, axis, distance)
 80
 81    """
 82
 83    def __init__(self, source_rays, optical_elements, detectors, description=""):
 84        """
 85        Parameters
 86        ----------
 87            source_rays : list[mray.Ray]
 88                List of source rays, which are to be traced.
 89
 90            optical_elements : list[moe.OpticalElement]
 91                List of successive optical elements.
 92            
 93            detector: mdet.Detector (optional)
 94                The detector (or list of detectors) to analyse the results.
 95
 96            description : str, optional
 97                A string to describe the optical setup. Defaults to ''.
 98
 99        """
100        self.source_rays = copy.deepcopy(source_rays)
101        # deepcopy so this object doesn't get changed when the global source_rays change "outside"
102        self.optical_elements = copy.deepcopy(optical_elements)
103        # deepcopy so this object doesn't get changed when the global optical_elements changes "outside"
104        self.detectors = detectors
105        if isinstance(detectors, mdet.Detector):
106            self.detectors = {"Focus": detectors}
107        self.description = description
108        self._output_rays = None
109        self._last_source_rays_hash = None
110        self._last_optical_elements_hash = None
111
112    def __repr__(self):
113        pretty_str = "Optical setup [OpticalChain]:\n"
114        pretty_str += f"  - Description: {self.description}\n" if self.description else "Description: Not provided.\n"
115        pretty_str +=  "  - Contains the following elements:\n"
116        pretty_str +=  f"    - Source with {len(self.source_rays)} rays at coordinate origin\n"
117        prev_pos = np.zeros(3)
118        for i, element in enumerate(self.optical_elements):
119            dist = (element.position - prev_pos).norm
120            if i == 0:
121                prev = "source"
122            else:
123                prev = f"element {i-1}"
124            pretty_str += f"    - Element {i}: {element.type} at distance {round(dist)} from {prev}\n"
125            prev_pos = element.position
126        for i in self.detectors.keys():
127            detector = self.detectors[i]
128            pretty_str += f'    - Detector "{i}": {detector.type} at distance {round(detector.distance,3)} from element {detector.index % len(self.optical_elements)}\n'
129        return pretty_str
130    
131    def __getitem__(self, i):
132        return self.optical_elements[i]
133    
134    def __len__(self):
135        return len(self.optical_elements)
136
137    @property
138    def source_rays(self):
139        return self._source_rays
140
141    @source_rays.setter
142    def source_rays(self, source_rays):
143        if type(source_rays) == mray.RayList:
144            self._source_rays = source_rays
145        else:
146            raise TypeError("Source_rays must be a RayList object.")
147
148    @property
149    def optical_elements(self):
150        return self._optical_elements
151
152    @optical_elements.setter
153    def optical_elements(self, optical_elements):
154        if type(optical_elements) == list and all(isinstance(x, moe.OpticalElement) for x in optical_elements):
155            self._optical_elements = optical_elements
156        else:
157            raise TypeError("Optical_elements must be list of OpticalElement-objects.")
158
159    # %% METHODS ##################################################################
160
161    def __copy__(self):
162        """Return another optical chain with the same source, optical elements and description-string as this one."""
163        return OpticalChain(self.source_rays, self.optical_elements, self.detectors, self.description)
164
165    def __deepcopy__(self, memo):
166        """Return another optical chain with the same source, optical elements and description-string as this one."""
167        return OpticalChain(copy.deepcopy(self.source_rays), copy.deepcopy(self.optical_elements), memo+"\n"+copy.copy(self.description))
168
169    def get_input_rays(self):
170        """
171        Returns the list of source rays.
172        """
173        return self.source_rays
174
175    def get_output_rays(self, force=False, **kwargs):
176        """
177        Returns the list of (lists of) output rays, calculate them if this hasn't been done yet,
178        or if the source-ray-bundle or anything about the optical elements has changed.
179
180        This is the user-facing method to perform the ray-tracing calculation.
181        """
182        current_source_rays_hash = hash(self.source_rays)
183        current_optical_elements_hash = mp._hash_list_of_objects(self.optical_elements)
184        if (current_source_rays_hash != self._last_source_rays_hash) or (current_optical_elements_hash != self._last_optical_elements_hash) or force:
185            print("...ray-tracing...", end="", flush=True)
186            self._output_rays = mp.RayTracingCalculation(self.source_rays, self.optical_elements, **kwargs)
187            print(
188                "\r\033[K", end="", flush=True
189            )  # move to beginning of the line with \r and then delete the whole line with \033[K
190            self._last_source_rays_hash = current_source_rays_hash
191            self._last_optical_elements_hash = current_optical_elements_hash
192
193        return self._output_rays
194    
195    def get2dPoints(self, Detector = "Focus"):
196        """
197        Returns the 2D points of the detected rays on the detector.
198        """
199        if isinstance(Detector, str):
200            if Detector in self.detectors.keys():
201                Detector = self.detectors[Detector]
202            else:
203                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
204        return Detector.get_2D_points(self.get_output_rays()[Detector.index])[0]
205    
206    def get3dPoints(self, Detector = "Focus"):
207        """
208        Returns the 3D points of the detected rays on the detector.
209        """
210        if isinstance(Detector, str):
211            if Detector in self.detectors.keys():
212                Detector = self.detectors[Detector]
213            else:
214                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
215        return Detector.get_3D_points(self.get_output_rays()[Detector.index])
216    
217    def getDelays(self, Detector = "Focus"):
218        """
219        Returns the delays of the detected rays on the detector.
220        """
221        if isinstance(Detector, str):
222            if Detector in self.detectors.keys():
223                Detector = self.detectors[Detector]
224            else:
225                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
226        return Detector.get_Delays(self.get_output_rays()[Detector.index])
227    
228    # %%  methods to (mis-)align the optical chain; just uses the corresponding methods of the OpticalElement class...
229
230    def shift_source(self, axis: str|np.ndarray, distance: float):
231        """
232        Shift source ray bundle by distance (in mm) along the 'axis' specified as
233        a lab-frame vector (numpy-array of length 3) or as one of the strings
234        "vert", "horiz", or "random".
235
236        In the latter case, the reference is the incidence plane of the first
237        non-normal-incidence mirror after the source. If there is none, you will
238        be asked to rather specify the axis as a 3D-numpy-array.
239
240        axis = "vert" means the source position is shifted along the axis perpendicular
241        to that incidence plane, i.e. "vertically" away from the former incidence plane..
242
243        axis = "horiz" means the source direciton is translated along thr axis in that
244        incidence plane and perpendicular to the current source direction,
245        i.e. "horizontally" in the incidence plane, but retaining the same distance
246        of source and first optical element.
247
248        axis = "random" means the the source direction shifted in a random direction
249        within in the plane perpendicular to the current source direction,
250        e.g. simulating a fluctuation of hte transverse source position.
251
252        Parameters
253        ----------
254            axis : np.ndarray or str
255                Shift axis, specified either as a 3D lab-frame vector or as one
256                of the strings "vert", "horiz", or "random".
257
258            distance : float
259                Shift distance in mm.
260
261        Returns
262        -------
263            Nothing, just modifies the property 'source_rays'.
264        """
265        if type(distance) not in [int, float, np.float64]:
266            raise ValueError('The "distance"-argument must be an int or float number.')
267
268        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
269        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
270
271        OEnormal = None
272        for i in mirror_indcs:
273            ith_OEnormal = self.optical_elements[i].normal
274            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
275                OEnormal = ith_OEnormal
276                break
277        if OEnormal is None:
278            raise Exception(
279                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
280                            so you should rather give 'axis' as a numpy-array of length 3."
281            )
282
283        if type(axis) == np.ndarray and len(axis) == 3:
284            translation_vector = axis
285        else:
286            perp_axis = np.cross(central_ray_vector, OEnormal)
287            horiz_axis = np.cross(perp_axis, central_ray_vector)
288
289            if axis == "vert":
290                translation_vector = perp_axis
291            elif axis == "horiz":
292                translation_vector = horiz_axis
293            elif axis == "random":
294                translation_vector = (
295                    np.random.uniform(low=-1, high=1, size=1) * perp_axis
296                    + np.random.uniform(low=-1, high=1, size=1) * horiz_axis
297                )
298            else:
299                raise ValueError(
300                    'The shift direction must be specified by "axis" as one of ["vert", "horiz", "random"].'
301                )
302
303        self.source_rays = mgeo.TranslationRayList(self.source_rays, distance * mgeo.Normalize(translation_vector))
304
305    def tilt_source(self, axis: str|np.ndarray, angle: float):
306        """
307        Rotate source ray bundle by angle around an axis, specified as
308        a lab-frame vector (numpy-array of length 3) or as one of the strings
309        "in_plane", "out_plane" or "random" direction.
310
311        In the latter case, the function considers the incidence plane of the first
312        non-normal-incidence mirror after the source. If there is none, you will
313        be asked to rather specify the axis as a 3D-numpy-array.
314
315        axis = "in_plane" means the source direction is rotated about an axis
316        perpendicular to that incidence plane, which tilts the source
317        "horizontally" in the same plane.
318
319        axis = "out_plane" means the source direciton is rotated about an axis
320        in that incidence plane and perpendicular to the current source direction,
321        which tilts the source "vertically" out of the former incidence plane.
322
323        axis = "random" means the the source direction is tilted in a random direction,
324        e.g. simulating a beam pointing fluctuation.
325
326        Attention, "angle" is given in deg, so as to remain consitent with the
327        conventions of other functions, although pointing is mostly talked about
328        in mrad instead.
329
330        Parameters
331        ----------
332            axis : np.ndarray or str
333                Shift axis, specified either as a 3D lab-frame vector or as one
334                of the strings "in_plane", "out_plane", or "random".
335
336            angle : float
337                Rotation angle in degree.
338
339        Returns
340        -------
341            Nothing, just modifies the property 'source_rays'.
342        """
343        if type(angle) not in [int, float, np.float64]:
344            raise ValueError('The "angle"-argument must be an int or float number.')
345
346        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
347        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
348
349        OEnormal = None
350        for i in mirror_indcs:
351            ith_OEnormal = self.optical_elements[i].normal
352            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
353                OEnormal = ith_OEnormal
354                break
355        if OEnormal is None:
356            raise Exception(
357                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
358                            so you should rather give 'axis' as a numpy-array of length 3."
359            )
360
361        if type(axis) == np.ndarray and len(axis) == 3:
362            rot_axis = axis
363        else:
364            rot_axis_in = np.cross(central_ray_vector, OEnormal)
365            rot_axis_out = np.cross(rot_axis_in, central_ray_vector)
366            if axis == "in_plane":
367                rot_axis = rot_axis_in
368            elif axis == "out_plane":
369                rot_axis = rot_axis_out
370            elif axis == "random":
371                rot_axis = (
372                    np.random.uniform(low=-1, high=1, size=1) * rot_axis_in
373                    + np.random.uniform(low=-1, high=1, size=1) * rot_axis_out
374                )
375            else:
376                raise ValueError(
377                    'The tilt axis must be specified by as one of ["in_plane", "out_plane", "random"] or as a numpy-array of length 3.'
378                )
379
380        self.source_rays = mgeo.RotationAroundAxisRayList(self.source_rays, rot_axis, np.deg2rad(angle))
381
382    def partial_realign(self, OEstart: int, OEstop: int, DistanceList: list, IncidenceAngleList, IncidencePlaneAngleList):
383        """
384        This as-of-yet not-implemented method will realign only the parts of the optical chain that are between the elements
385        OEstart and OEstop (both included).
386        """
387        OpticsList = [i.type for i in self.optical_elements[OEstart:OEstop]]
388        outray = self.get_output_rays()[OEstart-1][0]
389        print(outray)
390        new_elements = mp.OEPlacement(OpticsList, DistanceList, IncidenceAngleList, IncidencePlaneAngleList, outray)
391        self.optical_elements[OEstart:OEstop] = new_elements
392        
393    # %%
394    def rotate_OE(self, OEindx: int, ref: str, axis: str, angle: float):
395        """
396        Rotate the optical element OpticalChain.optical_elements[OEindx] with an axis defined relative to the master ray.
397        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
398        that takes either of the two values:
399            - "in"
400            - "out"
401        In either case the "axis" can take these values:
402            - "roll": Rotation about the master ray. Looking in the same direction as light propagation, positive is counterclockwise
403            - "pitch": Rotation about the vector in the incidence plane that is normal to the master ray.
404            - "yaw": Rotation about the vector normal to the incidence plane and to the master ray.
405
406        Parameters
407        ----------
408            OEindx : int
409                Index of the optical element to modify out of OpticalChain.optical_elements.
410
411            ref : str
412                Reference ray used to define the axes of rotation. Can be either:
413                "in" or "out" or "local_normal" (in+out)
414
415            axis : str
416                Rotation axis, specified as one of the strings
417                "pitch", "roll", "yaw"
418
419            angle : float
420                Rotation angle in degree.
421
422        Returns
423        -------
424            Nothing, just modifies OpticalChain.optical_elements[OEindx].
425        """
426        if abs(OEindx) > len(self.optical_elements):
427            raise ValueError(
428                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
429            )
430        if type(angle) not in [int, float, np.float64]:
431            raise ValueError('The "angle"-argument must be an int or float number.')
432        MasterRay = [mp.FindCentralRay(self.source_rays)]
433        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
434        TracedRay = [MasterRay] + TracedRay
435        match ref:
436            case "out":
437                RefVec = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
438            case "in":
439                RefVec = mgeo.Normalize(TracedRay[OEindx][0].vector)
440            case "localnormal":
441                In = mgeo.Normalize(TracedRay[OEindx][0].vector)
442                Out = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
443                RefVec = mgeo.Normalize(Out-In)
444            case _:
445                raise ValueError('The "ref"-argument must be a string out of ["in", "out", "localnormal].')
446        if 1 - np.dot(self[OEindx].normal,RefVec) > 1e-2:
447            match axis:
448                case "pitch":
449                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
450                case "roll":
451                    self[OEindx].normal = mgeo.RotationAroundAxis(RefVec,np.deg2rad(angle),self[OEindx].normal)
452                case "yaw":
453                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
454                case _:
455                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')
456        else:
457            #If the normal vector is aligned with the ray
458            match axis:
459                case "pitch":
460                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
461                case "roll":
462                    self[OEindx].majoraxis = mgeo.RotationAroundAxis(self[OEindx].normal,np.deg2rad(angle),self[OEindx].majoraxis)
463                case "yaw":
464                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
465                case _:
466                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')
467
468    def shift_OE(self, OEindx: int, ref: str, axis: str, distance: float):
469        """
470        Shift the optical element OpticalChain.optical_elements[OEindx] along
471        axis referenced to the master ray.
472
473        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
474        that takes either of the two values:
475            - "in"
476            - "out"
477        In either case the "axis" can take these values:
478            - "along": Translation along the master ray.
479            - "in_plane": Translation along the vector in the incidence plane that is normal to the master ray.
480            - "out_plane": Translation along the vector normal to the incidence plane and to the master ray.
481
482        Parameters
483        ----------
484            OEindx : int
485                Index of the optical element to modify out of OpticalChain.optical_elements.
486
487            ref : str
488                Reference ray used to define the axes of rotation. Can be either:
489                "in" or "out".
490
491            axis : str
492                Translation axis, specified as one of the strings
493                "along", "in_plane", "out_plane".
494
495            distance : float
496                Rotation angle in degree.
497
498        Returns
499        -------
500            Nothing, just modifies OpticalChain.optical_elements[OEindx].
501        """
502        if abs(OEindx) >= len(self.optical_elements):
503            raise ValueError(
504                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
505            )
506        if type(distance) not in [int, float, np.float64]:
507            raise ValueError('The "dist"-argument must be an int or float number.')
508
509        MasterRay = [mp.FindCentralRay(self.source_rays)]
510        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
511        TracedRay = [MasterRay] + TracedRay
512        match ref:
513            case "out":
514                RefVec = TracedRay[OEindx+1][0].vector
515            case "in":
516                RefVec = TracedRay[OEindx][0].vector
517            case _:
518                raise ValueError('The "ref"-argument must be a string out of ["in", "out"].')
519        match axis:
520            case "along":
521                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(RefVec)
522            case "in_plane":
523                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, np.cross(RefVec, self[OEindx].normal)))
524            case "out_plane":
525                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, self[OEindx].normal))
526            case _:
527                raise ValueError('The "axis"-argument must be a string out of ["along", "in_plane", "out_plane"].')
528
529        # some function that randomly misalings one, or several or all ?
logger = <Logger ARTcore.ModuleOpticalChain (WARNING)>
class OpticalChain:
 29class OpticalChain:
 30    """
 31    The OpticalChain represents the whole optical setup to be simulated:
 32    Its main attributes are a list source-[Rays](ModuleOpticalRay.html) and
 33    a list of successive [OpticalElements](ModuleOpticalElement.html).
 34
 35    The method OpticalChain.get_output_rays() returns an associated list of lists of
 36    [Rays](ModuleOpticalRay.html), each calculated by ray-tracing from one
 37    [OpticalElement](ModuleOpticalElement.html) to the next.
 38    So OpticalChain.get_output_rays()[i] is the bundle of [Rays](ModuleOpticalRay.html) *after*
 39    optical_elements[i].
 40
 41    The string "description" can contain a short description of the optical setup, or similar notes.
 42
 43    The OpticalChain can be visualized quickly with the method OpticalChain.quickshow(),
 44    and more nicely with OpticalChain.render().
 45
 46    The class also provides methods for (mis-)alignment of the source-[Ray](ModuleOpticalRay.html)-bundle and the
 47    [OpticalElements](ModuleOpticalElement.html).
 48    
 49    Attributes
 50    ----------
 51        source_rays : list[mray.Ray]
 52            List of source rays, which are to be traced.
 53
 54        optical_elements : list[moe.OpticalElement]
 55            List of successive optical elements.
 56
 57        detector: mdet.Detector (optional)
 58            The detector (or list of detectors) to analyse the results.
 59        
 60        description : str
 61            A string to describe the optical setup.
 62    Methods
 63    ----------
 64        copy_chain()
 65
 66        get_output_rays()
 67
 68        render()
 69
 70        ----------
 71
 72        shift_source(axis, distance)
 73
 74        tilt_source(self, axis, angle)
 75
 76        ----------
 77
 78        rotate_OE(OEindx, axis, angle)
 79
 80        shift_OE(OEindx, axis, distance)
 81
 82    """
 83
 84    def __init__(self, source_rays, optical_elements, detectors, description=""):
 85        """
 86        Parameters
 87        ----------
 88            source_rays : list[mray.Ray]
 89                List of source rays, which are to be traced.
 90
 91            optical_elements : list[moe.OpticalElement]
 92                List of successive optical elements.
 93            
 94            detector: mdet.Detector (optional)
 95                The detector (or list of detectors) to analyse the results.
 96
 97            description : str, optional
 98                A string to describe the optical setup. Defaults to ''.
 99
100        """
101        self.source_rays = copy.deepcopy(source_rays)
102        # deepcopy so this object doesn't get changed when the global source_rays change "outside"
103        self.optical_elements = copy.deepcopy(optical_elements)
104        # deepcopy so this object doesn't get changed when the global optical_elements changes "outside"
105        self.detectors = detectors
106        if isinstance(detectors, mdet.Detector):
107            self.detectors = {"Focus": detectors}
108        self.description = description
109        self._output_rays = None
110        self._last_source_rays_hash = None
111        self._last_optical_elements_hash = None
112
113    def __repr__(self):
114        pretty_str = "Optical setup [OpticalChain]:\n"
115        pretty_str += f"  - Description: {self.description}\n" if self.description else "Description: Not provided.\n"
116        pretty_str +=  "  - Contains the following elements:\n"
117        pretty_str +=  f"    - Source with {len(self.source_rays)} rays at coordinate origin\n"
118        prev_pos = np.zeros(3)
119        for i, element in enumerate(self.optical_elements):
120            dist = (element.position - prev_pos).norm
121            if i == 0:
122                prev = "source"
123            else:
124                prev = f"element {i-1}"
125            pretty_str += f"    - Element {i}: {element.type} at distance {round(dist)} from {prev}\n"
126            prev_pos = element.position
127        for i in self.detectors.keys():
128            detector = self.detectors[i]
129            pretty_str += f'    - Detector "{i}": {detector.type} at distance {round(detector.distance,3)} from element {detector.index % len(self.optical_elements)}\n'
130        return pretty_str
131    
132    def __getitem__(self, i):
133        return self.optical_elements[i]
134    
135    def __len__(self):
136        return len(self.optical_elements)
137
138    @property
139    def source_rays(self):
140        return self._source_rays
141
142    @source_rays.setter
143    def source_rays(self, source_rays):
144        if type(source_rays) == mray.RayList:
145            self._source_rays = source_rays
146        else:
147            raise TypeError("Source_rays must be a RayList object.")
148
149    @property
150    def optical_elements(self):
151        return self._optical_elements
152
153    @optical_elements.setter
154    def optical_elements(self, optical_elements):
155        if type(optical_elements) == list and all(isinstance(x, moe.OpticalElement) for x in optical_elements):
156            self._optical_elements = optical_elements
157        else:
158            raise TypeError("Optical_elements must be list of OpticalElement-objects.")
159
160    # %% METHODS ##################################################################
161
162    def __copy__(self):
163        """Return another optical chain with the same source, optical elements and description-string as this one."""
164        return OpticalChain(self.source_rays, self.optical_elements, self.detectors, self.description)
165
166    def __deepcopy__(self, memo):
167        """Return another optical chain with the same source, optical elements and description-string as this one."""
168        return OpticalChain(copy.deepcopy(self.source_rays), copy.deepcopy(self.optical_elements), memo+"\n"+copy.copy(self.description))
169
170    def get_input_rays(self):
171        """
172        Returns the list of source rays.
173        """
174        return self.source_rays
175
176    def get_output_rays(self, force=False, **kwargs):
177        """
178        Returns the list of (lists of) output rays, calculate them if this hasn't been done yet,
179        or if the source-ray-bundle or anything about the optical elements has changed.
180
181        This is the user-facing method to perform the ray-tracing calculation.
182        """
183        current_source_rays_hash = hash(self.source_rays)
184        current_optical_elements_hash = mp._hash_list_of_objects(self.optical_elements)
185        if (current_source_rays_hash != self._last_source_rays_hash) or (current_optical_elements_hash != self._last_optical_elements_hash) or force:
186            print("...ray-tracing...", end="", flush=True)
187            self._output_rays = mp.RayTracingCalculation(self.source_rays, self.optical_elements, **kwargs)
188            print(
189                "\r\033[K", end="", flush=True
190            )  # move to beginning of the line with \r and then delete the whole line with \033[K
191            self._last_source_rays_hash = current_source_rays_hash
192            self._last_optical_elements_hash = current_optical_elements_hash
193
194        return self._output_rays
195    
196    def get2dPoints(self, Detector = "Focus"):
197        """
198        Returns the 2D points of the detected rays on the detector.
199        """
200        if isinstance(Detector, str):
201            if Detector in self.detectors.keys():
202                Detector = self.detectors[Detector]
203            else:
204                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
205        return Detector.get_2D_points(self.get_output_rays()[Detector.index])[0]
206    
207    def get3dPoints(self, Detector = "Focus"):
208        """
209        Returns the 3D points of the detected rays on the detector.
210        """
211        if isinstance(Detector, str):
212            if Detector in self.detectors.keys():
213                Detector = self.detectors[Detector]
214            else:
215                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
216        return Detector.get_3D_points(self.get_output_rays()[Detector.index])
217    
218    def getDelays(self, Detector = "Focus"):
219        """
220        Returns the delays of the detected rays on the detector.
221        """
222        if isinstance(Detector, str):
223            if Detector in self.detectors.keys():
224                Detector = self.detectors[Detector]
225            else:
226                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
227        return Detector.get_Delays(self.get_output_rays()[Detector.index])
228    
229    # %%  methods to (mis-)align the optical chain; just uses the corresponding methods of the OpticalElement class...
230
231    def shift_source(self, axis: str|np.ndarray, distance: float):
232        """
233        Shift source ray bundle by distance (in mm) along the 'axis' specified as
234        a lab-frame vector (numpy-array of length 3) or as one of the strings
235        "vert", "horiz", or "random".
236
237        In the latter case, the reference is the incidence plane of the first
238        non-normal-incidence mirror after the source. If there is none, you will
239        be asked to rather specify the axis as a 3D-numpy-array.
240
241        axis = "vert" means the source position is shifted along the axis perpendicular
242        to that incidence plane, i.e. "vertically" away from the former incidence plane..
243
244        axis = "horiz" means the source direciton is translated along thr axis in that
245        incidence plane and perpendicular to the current source direction,
246        i.e. "horizontally" in the incidence plane, but retaining the same distance
247        of source and first optical element.
248
249        axis = "random" means the the source direction shifted in a random direction
250        within in the plane perpendicular to the current source direction,
251        e.g. simulating a fluctuation of hte transverse source position.
252
253        Parameters
254        ----------
255            axis : np.ndarray or str
256                Shift axis, specified either as a 3D lab-frame vector or as one
257                of the strings "vert", "horiz", or "random".
258
259            distance : float
260                Shift distance in mm.
261
262        Returns
263        -------
264            Nothing, just modifies the property 'source_rays'.
265        """
266        if type(distance) not in [int, float, np.float64]:
267            raise ValueError('The "distance"-argument must be an int or float number.')
268
269        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
270        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
271
272        OEnormal = None
273        for i in mirror_indcs:
274            ith_OEnormal = self.optical_elements[i].normal
275            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
276                OEnormal = ith_OEnormal
277                break
278        if OEnormal is None:
279            raise Exception(
280                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
281                            so you should rather give 'axis' as a numpy-array of length 3."
282            )
283
284        if type(axis) == np.ndarray and len(axis) == 3:
285            translation_vector = axis
286        else:
287            perp_axis = np.cross(central_ray_vector, OEnormal)
288            horiz_axis = np.cross(perp_axis, central_ray_vector)
289
290            if axis == "vert":
291                translation_vector = perp_axis
292            elif axis == "horiz":
293                translation_vector = horiz_axis
294            elif axis == "random":
295                translation_vector = (
296                    np.random.uniform(low=-1, high=1, size=1) * perp_axis
297                    + np.random.uniform(low=-1, high=1, size=1) * horiz_axis
298                )
299            else:
300                raise ValueError(
301                    'The shift direction must be specified by "axis" as one of ["vert", "horiz", "random"].'
302                )
303
304        self.source_rays = mgeo.TranslationRayList(self.source_rays, distance * mgeo.Normalize(translation_vector))
305
306    def tilt_source(self, axis: str|np.ndarray, angle: float):
307        """
308        Rotate source ray bundle by angle around an axis, specified as
309        a lab-frame vector (numpy-array of length 3) or as one of the strings
310        "in_plane", "out_plane" or "random" direction.
311
312        In the latter case, the function considers the incidence plane of the first
313        non-normal-incidence mirror after the source. If there is none, you will
314        be asked to rather specify the axis as a 3D-numpy-array.
315
316        axis = "in_plane" means the source direction is rotated about an axis
317        perpendicular to that incidence plane, which tilts the source
318        "horizontally" in the same plane.
319
320        axis = "out_plane" means the source direciton is rotated about an axis
321        in that incidence plane and perpendicular to the current source direction,
322        which tilts the source "vertically" out of the former incidence plane.
323
324        axis = "random" means the the source direction is tilted in a random direction,
325        e.g. simulating a beam pointing fluctuation.
326
327        Attention, "angle" is given in deg, so as to remain consitent with the
328        conventions of other functions, although pointing is mostly talked about
329        in mrad instead.
330
331        Parameters
332        ----------
333            axis : np.ndarray or str
334                Shift axis, specified either as a 3D lab-frame vector or as one
335                of the strings "in_plane", "out_plane", or "random".
336
337            angle : float
338                Rotation angle in degree.
339
340        Returns
341        -------
342            Nothing, just modifies the property 'source_rays'.
343        """
344        if type(angle) not in [int, float, np.float64]:
345            raise ValueError('The "angle"-argument must be an int or float number.')
346
347        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
348        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
349
350        OEnormal = None
351        for i in mirror_indcs:
352            ith_OEnormal = self.optical_elements[i].normal
353            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
354                OEnormal = ith_OEnormal
355                break
356        if OEnormal is None:
357            raise Exception(
358                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
359                            so you should rather give 'axis' as a numpy-array of length 3."
360            )
361
362        if type(axis) == np.ndarray and len(axis) == 3:
363            rot_axis = axis
364        else:
365            rot_axis_in = np.cross(central_ray_vector, OEnormal)
366            rot_axis_out = np.cross(rot_axis_in, central_ray_vector)
367            if axis == "in_plane":
368                rot_axis = rot_axis_in
369            elif axis == "out_plane":
370                rot_axis = rot_axis_out
371            elif axis == "random":
372                rot_axis = (
373                    np.random.uniform(low=-1, high=1, size=1) * rot_axis_in
374                    + np.random.uniform(low=-1, high=1, size=1) * rot_axis_out
375                )
376            else:
377                raise ValueError(
378                    'The tilt axis must be specified by as one of ["in_plane", "out_plane", "random"] or as a numpy-array of length 3.'
379                )
380
381        self.source_rays = mgeo.RotationAroundAxisRayList(self.source_rays, rot_axis, np.deg2rad(angle))
382
383    def partial_realign(self, OEstart: int, OEstop: int, DistanceList: list, IncidenceAngleList, IncidencePlaneAngleList):
384        """
385        This as-of-yet not-implemented method will realign only the parts of the optical chain that are between the elements
386        OEstart and OEstop (both included).
387        """
388        OpticsList = [i.type for i in self.optical_elements[OEstart:OEstop]]
389        outray = self.get_output_rays()[OEstart-1][0]
390        print(outray)
391        new_elements = mp.OEPlacement(OpticsList, DistanceList, IncidenceAngleList, IncidencePlaneAngleList, outray)
392        self.optical_elements[OEstart:OEstop] = new_elements
393        
394    # %%
395    def rotate_OE(self, OEindx: int, ref: str, axis: str, angle: float):
396        """
397        Rotate the optical element OpticalChain.optical_elements[OEindx] with an axis defined relative to the master ray.
398        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
399        that takes either of the two values:
400            - "in"
401            - "out"
402        In either case the "axis" can take these values:
403            - "roll": Rotation about the master ray. Looking in the same direction as light propagation, positive is counterclockwise
404            - "pitch": Rotation about the vector in the incidence plane that is normal to the master ray.
405            - "yaw": Rotation about the vector normal to the incidence plane and to the master ray.
406
407        Parameters
408        ----------
409            OEindx : int
410                Index of the optical element to modify out of OpticalChain.optical_elements.
411
412            ref : str
413                Reference ray used to define the axes of rotation. Can be either:
414                "in" or "out" or "local_normal" (in+out)
415
416            axis : str
417                Rotation axis, specified as one of the strings
418                "pitch", "roll", "yaw"
419
420            angle : float
421                Rotation angle in degree.
422
423        Returns
424        -------
425            Nothing, just modifies OpticalChain.optical_elements[OEindx].
426        """
427        if abs(OEindx) > len(self.optical_elements):
428            raise ValueError(
429                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
430            )
431        if type(angle) not in [int, float, np.float64]:
432            raise ValueError('The "angle"-argument must be an int or float number.')
433        MasterRay = [mp.FindCentralRay(self.source_rays)]
434        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
435        TracedRay = [MasterRay] + TracedRay
436        match ref:
437            case "out":
438                RefVec = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
439            case "in":
440                RefVec = mgeo.Normalize(TracedRay[OEindx][0].vector)
441            case "localnormal":
442                In = mgeo.Normalize(TracedRay[OEindx][0].vector)
443                Out = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
444                RefVec = mgeo.Normalize(Out-In)
445            case _:
446                raise ValueError('The "ref"-argument must be a string out of ["in", "out", "localnormal].')
447        if 1 - np.dot(self[OEindx].normal,RefVec) > 1e-2:
448            match axis:
449                case "pitch":
450                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
451                case "roll":
452                    self[OEindx].normal = mgeo.RotationAroundAxis(RefVec,np.deg2rad(angle),self[OEindx].normal)
453                case "yaw":
454                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
455                case _:
456                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')
457        else:
458            #If the normal vector is aligned with the ray
459            match axis:
460                case "pitch":
461                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
462                case "roll":
463                    self[OEindx].majoraxis = mgeo.RotationAroundAxis(self[OEindx].normal,np.deg2rad(angle),self[OEindx].majoraxis)
464                case "yaw":
465                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
466                case _:
467                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')
468
469    def shift_OE(self, OEindx: int, ref: str, axis: str, distance: float):
470        """
471        Shift the optical element OpticalChain.optical_elements[OEindx] along
472        axis referenced to the master ray.
473
474        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
475        that takes either of the two values:
476            - "in"
477            - "out"
478        In either case the "axis" can take these values:
479            - "along": Translation along the master ray.
480            - "in_plane": Translation along the vector in the incidence plane that is normal to the master ray.
481            - "out_plane": Translation along the vector normal to the incidence plane and to the master ray.
482
483        Parameters
484        ----------
485            OEindx : int
486                Index of the optical element to modify out of OpticalChain.optical_elements.
487
488            ref : str
489                Reference ray used to define the axes of rotation. Can be either:
490                "in" or "out".
491
492            axis : str
493                Translation axis, specified as one of the strings
494                "along", "in_plane", "out_plane".
495
496            distance : float
497                Rotation angle in degree.
498
499        Returns
500        -------
501            Nothing, just modifies OpticalChain.optical_elements[OEindx].
502        """
503        if abs(OEindx) >= len(self.optical_elements):
504            raise ValueError(
505                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
506            )
507        if type(distance) not in [int, float, np.float64]:
508            raise ValueError('The "dist"-argument must be an int or float number.')
509
510        MasterRay = [mp.FindCentralRay(self.source_rays)]
511        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
512        TracedRay = [MasterRay] + TracedRay
513        match ref:
514            case "out":
515                RefVec = TracedRay[OEindx+1][0].vector
516            case "in":
517                RefVec = TracedRay[OEindx][0].vector
518            case _:
519                raise ValueError('The "ref"-argument must be a string out of ["in", "out"].')
520        match axis:
521            case "along":
522                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(RefVec)
523            case "in_plane":
524                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, np.cross(RefVec, self[OEindx].normal)))
525            case "out_plane":
526                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, self[OEindx].normal))
527            case _:
528                raise ValueError('The "axis"-argument must be a string out of ["along", "in_plane", "out_plane"].')
529
530        # some function that randomly misalings one, or several or all ?

The OpticalChain represents the whole optical setup to be simulated: Its main attributes are a list source-Rays and a list of successive OpticalElements.

The method OpticalChain.get_output_rays() returns an associated list of lists of Rays, each calculated by ray-tracing from one OpticalElement to the next. So OpticalChain.get_output_rays()[i] is the bundle of Rays after optical_elements[i].

The string "description" can contain a short description of the optical setup, or similar notes.

The OpticalChain can be visualized quickly with the method OpticalChain.quickshow(), and more nicely with OpticalChain.render().

The class also provides methods for (mis-)alignment of the source-Ray-bundle and the OpticalElements.

Attributes

source_rays : list[mray.Ray]
    List of source rays, which are to be traced.

optical_elements : list[moe.OpticalElement]
    List of successive optical elements.

detector: mdet.Detector (optional)
    The detector (or list of detectors) to analyse the results.

description : str
    A string to describe the optical setup.

Methods

copy_chain()

get_output_rays()

render()

----------

shift_source(axis, distance)

tilt_source(self, axis, angle)

----------

rotate_OE(OEindx, axis, angle)

shift_OE(OEindx, axis, distance)
OpticalChain(source_rays, optical_elements, detectors, description='')
 84    def __init__(self, source_rays, optical_elements, detectors, description=""):
 85        """
 86        Parameters
 87        ----------
 88            source_rays : list[mray.Ray]
 89                List of source rays, which are to be traced.
 90
 91            optical_elements : list[moe.OpticalElement]
 92                List of successive optical elements.
 93            
 94            detector: mdet.Detector (optional)
 95                The detector (or list of detectors) to analyse the results.
 96
 97            description : str, optional
 98                A string to describe the optical setup. Defaults to ''.
 99
100        """
101        self.source_rays = copy.deepcopy(source_rays)
102        # deepcopy so this object doesn't get changed when the global source_rays change "outside"
103        self.optical_elements = copy.deepcopy(optical_elements)
104        # deepcopy so this object doesn't get changed when the global optical_elements changes "outside"
105        self.detectors = detectors
106        if isinstance(detectors, mdet.Detector):
107            self.detectors = {"Focus": detectors}
108        self.description = description
109        self._output_rays = None
110        self._last_source_rays_hash = None
111        self._last_optical_elements_hash = None

Parameters

source_rays : list[mray.Ray]
    List of source rays, which are to be traced.

optical_elements : list[moe.OpticalElement]
    List of successive optical elements.

detector: mdet.Detector (optional)
    The detector (or list of detectors) to analyse the results.

description : str, optional
    A string to describe the optical setup. Defaults to ''.
source_rays
138    @property
139    def source_rays(self):
140        return self._source_rays
optical_elements
149    @property
150    def optical_elements(self):
151        return self._optical_elements
detectors
description
def get_input_rays(self):
170    def get_input_rays(self):
171        """
172        Returns the list of source rays.
173        """
174        return self.source_rays

Returns the list of source rays.

def get_output_rays(self, force=False, **kwargs):
176    def get_output_rays(self, force=False, **kwargs):
177        """
178        Returns the list of (lists of) output rays, calculate them if this hasn't been done yet,
179        or if the source-ray-bundle or anything about the optical elements has changed.
180
181        This is the user-facing method to perform the ray-tracing calculation.
182        """
183        current_source_rays_hash = hash(self.source_rays)
184        current_optical_elements_hash = mp._hash_list_of_objects(self.optical_elements)
185        if (current_source_rays_hash != self._last_source_rays_hash) or (current_optical_elements_hash != self._last_optical_elements_hash) or force:
186            print("...ray-tracing...", end="", flush=True)
187            self._output_rays = mp.RayTracingCalculation(self.source_rays, self.optical_elements, **kwargs)
188            print(
189                "\r\033[K", end="", flush=True
190            )  # move to beginning of the line with \r and then delete the whole line with \033[K
191            self._last_source_rays_hash = current_source_rays_hash
192            self._last_optical_elements_hash = current_optical_elements_hash
193
194        return self._output_rays

Returns the list of (lists of) output rays, calculate them if this hasn't been done yet, or if the source-ray-bundle or anything about the optical elements has changed.

This is the user-facing method to perform the ray-tracing calculation.

def get2dPoints(self, Detector='Focus'):
196    def get2dPoints(self, Detector = "Focus"):
197        """
198        Returns the 2D points of the detected rays on the detector.
199        """
200        if isinstance(Detector, str):
201            if Detector in self.detectors.keys():
202                Detector = self.detectors[Detector]
203            else:
204                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
205        return Detector.get_2D_points(self.get_output_rays()[Detector.index])[0]

Returns the 2D points of the detected rays on the detector.

def get3dPoints(self, Detector='Focus'):
207    def get3dPoints(self, Detector = "Focus"):
208        """
209        Returns the 3D points of the detected rays on the detector.
210        """
211        if isinstance(Detector, str):
212            if Detector in self.detectors.keys():
213                Detector = self.detectors[Detector]
214            else:
215                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
216        return Detector.get_3D_points(self.get_output_rays()[Detector.index])

Returns the 3D points of the detected rays on the detector.

def getDelays(self, Detector='Focus'):
218    def getDelays(self, Detector = "Focus"):
219        """
220        Returns the delays of the detected rays on the detector.
221        """
222        if isinstance(Detector, str):
223            if Detector in self.detectors.keys():
224                Detector = self.detectors[Detector]
225            else:
226                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
227        return Detector.get_Delays(self.get_output_rays()[Detector.index])

Returns the delays of the detected rays on the detector.

def shift_source(self, axis: str | numpy.ndarray, distance: float):
231    def shift_source(self, axis: str|np.ndarray, distance: float):
232        """
233        Shift source ray bundle by distance (in mm) along the 'axis' specified as
234        a lab-frame vector (numpy-array of length 3) or as one of the strings
235        "vert", "horiz", or "random".
236
237        In the latter case, the reference is the incidence plane of the first
238        non-normal-incidence mirror after the source. If there is none, you will
239        be asked to rather specify the axis as a 3D-numpy-array.
240
241        axis = "vert" means the source position is shifted along the axis perpendicular
242        to that incidence plane, i.e. "vertically" away from the former incidence plane..
243
244        axis = "horiz" means the source direciton is translated along thr axis in that
245        incidence plane and perpendicular to the current source direction,
246        i.e. "horizontally" in the incidence plane, but retaining the same distance
247        of source and first optical element.
248
249        axis = "random" means the the source direction shifted in a random direction
250        within in the plane perpendicular to the current source direction,
251        e.g. simulating a fluctuation of hte transverse source position.
252
253        Parameters
254        ----------
255            axis : np.ndarray or str
256                Shift axis, specified either as a 3D lab-frame vector or as one
257                of the strings "vert", "horiz", or "random".
258
259            distance : float
260                Shift distance in mm.
261
262        Returns
263        -------
264            Nothing, just modifies the property 'source_rays'.
265        """
266        if type(distance) not in [int, float, np.float64]:
267            raise ValueError('The "distance"-argument must be an int or float number.')
268
269        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
270        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
271
272        OEnormal = None
273        for i in mirror_indcs:
274            ith_OEnormal = self.optical_elements[i].normal
275            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
276                OEnormal = ith_OEnormal
277                break
278        if OEnormal is None:
279            raise Exception(
280                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
281                            so you should rather give 'axis' as a numpy-array of length 3."
282            )
283
284        if type(axis) == np.ndarray and len(axis) == 3:
285            translation_vector = axis
286        else:
287            perp_axis = np.cross(central_ray_vector, OEnormal)
288            horiz_axis = np.cross(perp_axis, central_ray_vector)
289
290            if axis == "vert":
291                translation_vector = perp_axis
292            elif axis == "horiz":
293                translation_vector = horiz_axis
294            elif axis == "random":
295                translation_vector = (
296                    np.random.uniform(low=-1, high=1, size=1) * perp_axis
297                    + np.random.uniform(low=-1, high=1, size=1) * horiz_axis
298                )
299            else:
300                raise ValueError(
301                    'The shift direction must be specified by "axis" as one of ["vert", "horiz", "random"].'
302                )
303
304        self.source_rays = mgeo.TranslationRayList(self.source_rays, distance * mgeo.Normalize(translation_vector))

Shift source ray bundle by distance (in mm) along the 'axis' specified as a lab-frame vector (numpy-array of length 3) or as one of the strings "vert", "horiz", or "random".

In the latter case, the reference is the incidence plane of the first non-normal-incidence mirror after the source. If there is none, you will be asked to rather specify the axis as a 3D-numpy-array.

axis = "vert" means the source position is shifted along the axis perpendicular to that incidence plane, i.e. "vertically" away from the former incidence plane..

axis = "horiz" means the source direciton is translated along thr axis in that incidence plane and perpendicular to the current source direction, i.e. "horizontally" in the incidence plane, but retaining the same distance of source and first optical element.

axis = "random" means the the source direction shifted in a random direction within in the plane perpendicular to the current source direction, e.g. simulating a fluctuation of hte transverse source position.

Parameters

axis : np.ndarray or str
    Shift axis, specified either as a 3D lab-frame vector or as one
    of the strings "vert", "horiz", or "random".

distance : float
    Shift distance in mm.

Returns

Nothing, just modifies the property 'source_rays'.
def tilt_source(self, axis: str | numpy.ndarray, angle: float):
306    def tilt_source(self, axis: str|np.ndarray, angle: float):
307        """
308        Rotate source ray bundle by angle around an axis, specified as
309        a lab-frame vector (numpy-array of length 3) or as one of the strings
310        "in_plane", "out_plane" or "random" direction.
311
312        In the latter case, the function considers the incidence plane of the first
313        non-normal-incidence mirror after the source. If there is none, you will
314        be asked to rather specify the axis as a 3D-numpy-array.
315
316        axis = "in_plane" means the source direction is rotated about an axis
317        perpendicular to that incidence plane, which tilts the source
318        "horizontally" in the same plane.
319
320        axis = "out_plane" means the source direciton is rotated about an axis
321        in that incidence plane and perpendicular to the current source direction,
322        which tilts the source "vertically" out of the former incidence plane.
323
324        axis = "random" means the the source direction is tilted in a random direction,
325        e.g. simulating a beam pointing fluctuation.
326
327        Attention, "angle" is given in deg, so as to remain consitent with the
328        conventions of other functions, although pointing is mostly talked about
329        in mrad instead.
330
331        Parameters
332        ----------
333            axis : np.ndarray or str
334                Shift axis, specified either as a 3D lab-frame vector or as one
335                of the strings "in_plane", "out_plane", or "random".
336
337            angle : float
338                Rotation angle in degree.
339
340        Returns
341        -------
342            Nothing, just modifies the property 'source_rays'.
343        """
344        if type(angle) not in [int, float, np.float64]:
345            raise ValueError('The "angle"-argument must be an int or float number.')
346
347        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
348        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
349
350        OEnormal = None
351        for i in mirror_indcs:
352            ith_OEnormal = self.optical_elements[i].normal
353            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
354                OEnormal = ith_OEnormal
355                break
356        if OEnormal is None:
357            raise Exception(
358                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
359                            so you should rather give 'axis' as a numpy-array of length 3."
360            )
361
362        if type(axis) == np.ndarray and len(axis) == 3:
363            rot_axis = axis
364        else:
365            rot_axis_in = np.cross(central_ray_vector, OEnormal)
366            rot_axis_out = np.cross(rot_axis_in, central_ray_vector)
367            if axis == "in_plane":
368                rot_axis = rot_axis_in
369            elif axis == "out_plane":
370                rot_axis = rot_axis_out
371            elif axis == "random":
372                rot_axis = (
373                    np.random.uniform(low=-1, high=1, size=1) * rot_axis_in
374                    + np.random.uniform(low=-1, high=1, size=1) * rot_axis_out
375                )
376            else:
377                raise ValueError(
378                    'The tilt axis must be specified by as one of ["in_plane", "out_plane", "random"] or as a numpy-array of length 3.'
379                )
380
381        self.source_rays = mgeo.RotationAroundAxisRayList(self.source_rays, rot_axis, np.deg2rad(angle))

Rotate source ray bundle by angle around an axis, specified as a lab-frame vector (numpy-array of length 3) or as one of the strings "in_plane", "out_plane" or "random" direction.

In the latter case, the function considers the incidence plane of the first non-normal-incidence mirror after the source. If there is none, you will be asked to rather specify the axis as a 3D-numpy-array.

axis = "in_plane" means the source direction is rotated about an axis perpendicular to that incidence plane, which tilts the source "horizontally" in the same plane.

axis = "out_plane" means the source direciton is rotated about an axis in that incidence plane and perpendicular to the current source direction, which tilts the source "vertically" out of the former incidence plane.

axis = "random" means the the source direction is tilted in a random direction, e.g. simulating a beam pointing fluctuation.

Attention, "angle" is given in deg, so as to remain consitent with the conventions of other functions, although pointing is mostly talked about in mrad instead.

Parameters

axis : np.ndarray or str
    Shift axis, specified either as a 3D lab-frame vector or as one
    of the strings "in_plane", "out_plane", or "random".

angle : float
    Rotation angle in degree.

Returns

Nothing, just modifies the property 'source_rays'.
def partial_realign( self, OEstart: int, OEstop: int, DistanceList: list, IncidenceAngleList, IncidencePlaneAngleList):
383    def partial_realign(self, OEstart: int, OEstop: int, DistanceList: list, IncidenceAngleList, IncidencePlaneAngleList):
384        """
385        This as-of-yet not-implemented method will realign only the parts of the optical chain that are between the elements
386        OEstart and OEstop (both included).
387        """
388        OpticsList = [i.type for i in self.optical_elements[OEstart:OEstop]]
389        outray = self.get_output_rays()[OEstart-1][0]
390        print(outray)
391        new_elements = mp.OEPlacement(OpticsList, DistanceList, IncidenceAngleList, IncidencePlaneAngleList, outray)
392        self.optical_elements[OEstart:OEstop] = new_elements

This as-of-yet not-implemented method will realign only the parts of the optical chain that are between the elements OEstart and OEstop (both included).

def rotate_OE(self, OEindx: int, ref: str, axis: str, angle: float):
395    def rotate_OE(self, OEindx: int, ref: str, axis: str, angle: float):
396        """
397        Rotate the optical element OpticalChain.optical_elements[OEindx] with an axis defined relative to the master ray.
398        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
399        that takes either of the two values:
400            - "in"
401            - "out"
402        In either case the "axis" can take these values:
403            - "roll": Rotation about the master ray. Looking in the same direction as light propagation, positive is counterclockwise
404            - "pitch": Rotation about the vector in the incidence plane that is normal to the master ray.
405            - "yaw": Rotation about the vector normal to the incidence plane and to the master ray.
406
407        Parameters
408        ----------
409            OEindx : int
410                Index of the optical element to modify out of OpticalChain.optical_elements.
411
412            ref : str
413                Reference ray used to define the axes of rotation. Can be either:
414                "in" or "out" or "local_normal" (in+out)
415
416            axis : str
417                Rotation axis, specified as one of the strings
418                "pitch", "roll", "yaw"
419
420            angle : float
421                Rotation angle in degree.
422
423        Returns
424        -------
425            Nothing, just modifies OpticalChain.optical_elements[OEindx].
426        """
427        if abs(OEindx) > len(self.optical_elements):
428            raise ValueError(
429                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
430            )
431        if type(angle) not in [int, float, np.float64]:
432            raise ValueError('The "angle"-argument must be an int or float number.')
433        MasterRay = [mp.FindCentralRay(self.source_rays)]
434        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
435        TracedRay = [MasterRay] + TracedRay
436        match ref:
437            case "out":
438                RefVec = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
439            case "in":
440                RefVec = mgeo.Normalize(TracedRay[OEindx][0].vector)
441            case "localnormal":
442                In = mgeo.Normalize(TracedRay[OEindx][0].vector)
443                Out = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
444                RefVec = mgeo.Normalize(Out-In)
445            case _:
446                raise ValueError('The "ref"-argument must be a string out of ["in", "out", "localnormal].')
447        if 1 - np.dot(self[OEindx].normal,RefVec) > 1e-2:
448            match axis:
449                case "pitch":
450                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
451                case "roll":
452                    self[OEindx].normal = mgeo.RotationAroundAxis(RefVec,np.deg2rad(angle),self[OEindx].normal)
453                case "yaw":
454                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
455                case _:
456                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')
457        else:
458            #If the normal vector is aligned with the ray
459            match axis:
460                case "pitch":
461                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
462                case "roll":
463                    self[OEindx].majoraxis = mgeo.RotationAroundAxis(self[OEindx].normal,np.deg2rad(angle),self[OEindx].majoraxis)
464                case "yaw":
465                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
466                case _:
467                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')

Rotate the optical element OpticalChain.optical_elements[OEindx] with an axis defined relative to the master ray. The axis can either be relative to the incoming master ray or the outgoing one. This is defined by the "ref" variable that takes either of the two values: - "in" - "out" In either case the "axis" can take these values: - "roll": Rotation about the master ray. Looking in the same direction as light propagation, positive is counterclockwise - "pitch": Rotation about the vector in the incidence plane that is normal to the master ray. - "yaw": Rotation about the vector normal to the incidence plane and to the master ray.

Parameters

OEindx : int
    Index of the optical element to modify out of OpticalChain.optical_elements.

ref : str
    Reference ray used to define the axes of rotation. Can be either:
    "in" or "out" or "local_normal" (in+out)

axis : str
    Rotation axis, specified as one of the strings
    "pitch", "roll", "yaw"

angle : float
    Rotation angle in degree.

Returns

Nothing, just modifies OpticalChain.optical_elements[OEindx].
def shift_OE(self, OEindx: int, ref: str, axis: str, distance: float):
469    def shift_OE(self, OEindx: int, ref: str, axis: str, distance: float):
470        """
471        Shift the optical element OpticalChain.optical_elements[OEindx] along
472        axis referenced to the master ray.
473
474        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
475        that takes either of the two values:
476            - "in"
477            - "out"
478        In either case the "axis" can take these values:
479            - "along": Translation along the master ray.
480            - "in_plane": Translation along the vector in the incidence plane that is normal to the master ray.
481            - "out_plane": Translation along the vector normal to the incidence plane and to the master ray.
482
483        Parameters
484        ----------
485            OEindx : int
486                Index of the optical element to modify out of OpticalChain.optical_elements.
487
488            ref : str
489                Reference ray used to define the axes of rotation. Can be either:
490                "in" or "out".
491
492            axis : str
493                Translation axis, specified as one of the strings
494                "along", "in_plane", "out_plane".
495
496            distance : float
497                Rotation angle in degree.
498
499        Returns
500        -------
501            Nothing, just modifies OpticalChain.optical_elements[OEindx].
502        """
503        if abs(OEindx) >= len(self.optical_elements):
504            raise ValueError(
505                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
506            )
507        if type(distance) not in [int, float, np.float64]:
508            raise ValueError('The "dist"-argument must be an int or float number.')
509
510        MasterRay = [mp.FindCentralRay(self.source_rays)]
511        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
512        TracedRay = [MasterRay] + TracedRay
513        match ref:
514            case "out":
515                RefVec = TracedRay[OEindx+1][0].vector
516            case "in":
517                RefVec = TracedRay[OEindx][0].vector
518            case _:
519                raise ValueError('The "ref"-argument must be a string out of ["in", "out"].')
520        match axis:
521            case "along":
522                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(RefVec)
523            case "in_plane":
524                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, np.cross(RefVec, self[OEindx].normal)))
525            case "out_plane":
526                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, self[OEindx].normal))
527            case _:
528                raise ValueError('The "axis"-argument must be a string out of ["along", "in_plane", "out_plane"].')
529
530        # some function that randomly misalings one, or several or all ?

Shift the optical element OpticalChain.optical_elements[OEindx] along axis referenced to the master ray.

The axis can either be relative to the incoming master ray or the outgoing one. This is defined by the "ref" variable that takes either of the two values: - "in" - "out" In either case the "axis" can take these values: - "along": Translation along the master ray. - "in_plane": Translation along the vector in the incidence plane that is normal to the master ray. - "out_plane": Translation along the vector normal to the incidence plane and to the master ray.

Parameters

OEindx : int
    Index of the optical element to modify out of OpticalChain.optical_elements.

ref : str
    Reference ray used to define the axes of rotation. Can be either:
    "in" or "out".

axis : str
    Translation axis, specified as one of the strings
    "along", "in_plane", "out_plane".

distance : float
    Rotation angle in degree.

Returns

Nothing, just modifies OpticalChain.optical_elements[OEindx].
def getETransmission(OpticalChain, IndexIn=0, IndexOut=-1) -> float:
67def _getETransmission(OpticalChain, IndexIn=0, IndexOut=-1) -> float:
68    """
69    Calculates the energy transmission from the input to the output of the OpticalChain in percent.
70
71    Parameters
72    ----------
73        OpticalChain : OpticalChain
74            An object of the ModuleOpticalChain.OpticalChain-class.
75
76        IndexIn : int, optional
77            Index of the input RayList in the OpticalChain, defaults to 0.
78
79        IndexOut : int, optional
80            Index of the output RayList in the OpticalChain, defaults to -1.
81
82    Returns
83    -------
84        ETransmission : float
85    """
86    Rays = OpticalChain.get_output_rays()
87    if IndexIn == 0:
88        RayListIn = OpticalChain.get_input_rays()
89    else:
90        RayListIn = Rays[IndexIn]
91    RayListOut = Rays[IndexOut]
92    ETransmission = GetETransmission(RayListIn, RayListOut)
93    return ETransmission

Calculates the energy transmission from the input to the output of the OpticalChain in percent.

Parameters

OpticalChain : OpticalChain
    An object of the ModuleOpticalChain.OpticalChain-class.

IndexIn : int, optional
    Index of the input RayList in the OpticalChain, defaults to 0.

IndexOut : int, optional
    Index of the output RayList in the OpticalChain, defaults to -1.

Returns

ETransmission : float
def getResultsSummary(OpticalChain, Detector='Focus', verbose=False):
147def _getResultsSummary(OpticalChain, Detector = "Focus", verbose=False):
148    """
149    Calculate and return FocalSpotSize-standard-deviation and Duration-standard-deviation
150    for the given Detector and RayList.
151    If verbose, then also print a summary of the results for the given Detector.
152
153    Parameters
154    ----------
155        OpticalChain : OpticalChain
156            An object of the ModuleOpticalChain.OpticalChain-class.
157
158        Detector : Detector or str, optional
159            An object of the ModuleDetector.Detector-class or "Focus" to use the focus detector, defaults to "Focus".
160
161        verbose : bool
162            Whether to print a result summary.
163
164    Returns
165    -------
166        FocalSpotSizeSD : float
167
168        DurationSD : float
169    """
170    if isinstance(Detector, str):
171        Detector = OpticalChain.detectors[Detector]
172    Index = Detector.index
173    RayListAnalysed = OpticalChain.get_output_rays()[Index]
174    FocalSpotSizeSD, DurationSD = GetResultSummary(Detector, RayListAnalysed, verbose)
175    return FocalSpotSizeSD, DurationSD

Calculate and return FocalSpotSize-standard-deviation and Duration-standard-deviation for the given Detector and RayList. If verbose, then also print a summary of the results for the given Detector.

Parameters

OpticalChain : OpticalChain
    An object of the ModuleOpticalChain.OpticalChain-class.

Detector : Detector or str, optional
    An object of the ModuleDetector.Detector-class or "Focus" to use the focus detector, defaults to "Focus".

verbose : bool
    Whether to print a result summary.

Returns

FocalSpotSizeSD : float

DurationSD : float
def drawSpotDiagram( OpticalChain, Detector='Focus', DrawAiryAndFourier=False, DrawFocalContour=False, DrawFocal=False, ColorCoded=None, Observer=None) -> matplotlib.figure.Figure:
 57def DrawSpotDiagram(OpticalChain, 
 58                Detector = "Focus",
 59                DrawAiryAndFourier=False, 
 60                DrawFocalContour=False,
 61                DrawFocal=False,
 62                ColorCoded=None,
 63                Observer = None) -> plt.Figure:
 64    """
 65    Produce an interactive figure with the spot diagram on the selected Detector.
 66    The detector distance can be shifted with the left-right cursor keys. Doing so will actually move the detector.
 67    If DrawAiryAndFourier is True, a circle with the Airy-spot-size will be shown.
 68    If DrawFocalContour is True, the focal contour calculated from some of the rays will be shown.
 69    If DrawFocal is True, a heatmap calculated from some of the rays will be shown. 
 70    The 'spots' can optionally be color-coded by specifying ColorCoded, which can be one of ["Intensity","Incidence","Delay"].
 71
 72    Parameters
 73    ----------
 74        RayListAnalysed : list(Ray)
 75            List of objects of the ModuleOpticalRay.Ray-class.
 76
 77        Detector : Detector or str, optional
 78            An object of the ModuleDetector.Detector-class or the name of the detector. The default is "Focus".
 79
 80        DrawAiryAndFourier : bool, optional
 81            Whether to draw a circle with the Airy-spot-size. The default is False.
 82        
 83        DrawFocalContour : bool, optional
 84            Whether to draw the focal contour. The default is False.
 85
 86        DrawFocal : bool, optional
 87            Whether to draw the focal heatmap. The default is False.
 88
 89        ColorCoded : str, optional
 90            Color-code the spots according to one of ["Intensity","Incidence","Delay"]. The default is None.
 91
 92        Observer : Observer, optional
 93            An observer object. If none, then we just create a copy of the detector and move it when pressing left-right. 
 94            However, if an observer is specified, then we will change the value of the observer and it will issue 
 95            the required callbacks to update several plots at the same time.
 96
 97    Returns
 98    -------
 99        fig : matlplotlib-figure-handle.
100            Shows the interactive figure.
101    """
102    if isinstance(Detector, str):
103        Detector = OpticalChain.detectors[Detector]
104    Index = Detector.index
105    movingDetector = copy(Detector) # We will move this detector when pressing left-right
106    if Observer is None:
107        detectorPosition = Observable(movingDetector.distance) # We will observe the distance of this detector
108    else:
109        detectorPosition = Observer
110        movingDetector.distance = detectorPosition.value
111
112    detectorPosition.register_calculation(lambda x: movingDetector.set_distance(x))
113
114    RayListAnalysed = OpticalChain.get_output_rays()[Index]
115
116    NumericalAperture = man.GetNumericalAperture(RayListAnalysed, 1)  # NA determined from final ray bundle
117    MaxWavelength = np.max([i.wavelength for i in RayListAnalysed])
118    if DrawAiryAndFourier:
119        AiryRadius = man.GetAiryRadius(MaxWavelength, NumericalAperture) * 1e3  # in µm
120    else:
121        AiryRadius = 0
122    
123    if DrawFocalContour or DrawFocal:
124        X,Y,Z = man.GetDiffractionFocus(OpticalChain, movingDetector, Index)
125        Z/=np.max(Z) 
126
127    DectectorPoint2D_Xcoord, DectectorPoint2D_Ycoord, FocalSpotSize, SpotSizeSD = mpu._getDetectorPoints(
128        RayListAnalysed, movingDetector
129    )
130
131    match ColorCoded:
132        case "Intensity":
133            IntensityList = [k.intensity for k in RayListAnalysed]
134            z = np.asarray(IntensityList)
135            zlabel = "Intensity (arb.u.)"
136            title = "Intensity + Spot Diagram\n press left/right to move detector position"
137            addLine = ""
138        case "Incidence":
139            IncidenceList = [np.rad2deg(k.incidence) for k in RayListAnalysed]  # degree
140            z = np.asarray(IncidenceList)
141            zlabel = "Incidence angle (deg)"
142            title = "Ray Incidence + Spot Diagram\n press left/right to move detector position"
143            addLine = ""
144        case "Delay":
145            DelayList = movingDetector.get_Delays(RayListAnalysed)
146            IntensityList = [k.intensity for k in RayListAnalysed]
147            DurationSD = mp.WeightedStandardDeviation(DelayList,IntensityList)
148            #DurationSD = mp.StandardDeviation(DelayList)
149            z = np.asarray(DelayList)
150            zlabel = "Delay (fs)"
151            title = "Delay + Spot Diagram\n press left/right to move detector position"
152            addLine = "\n" + "{:.2f}".format(DurationSD) + " fs SD"
153        case _:
154            z = "red"
155            title = "Spot Diagram\n press left/right to move detector position"
156            addLine = ""
157
158    distStep = min(50, max(0.0005, round(FocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000))  # in mm
159
160    plt.ion()
161    fig, ax = plt.subplots()
162    if DrawFocal:
163        focal = ax.pcolormesh(X*1e3,Y*1e3,Z)
164    if DrawFocalContour:
165        levels = [1/np.e**2, 0.5]
166        contour = ax.contourf(X*1e3, Y*1e3, Z, levels=levels, cmap='gray')
167
168    if DrawAiryAndFourier:
169        theta = np.linspace(0, 2 * np.pi, 100)
170        x = AiryRadius * np.cos(theta)
171        y = AiryRadius * np.sin(theta)  #
172        ax.plot(x, y, c="black")
173        
174
175    foo = ax.scatter(
176        DectectorPoint2D_Xcoord,
177        DectectorPoint2D_Ycoord,
178        c=z,
179        s=15,
180        label="{:.3f}".format(detectorPosition.value) + " mm\n" + "{:.1f}".format(SpotSizeSD * 1e3) + " \u03BCm SD" + addLine,
181    )
182
183    axisLim = 1.1 * max(AiryRadius, 0.5 * FocalSpotSize * 1000)
184    ax.set_xlim(-axisLim, axisLim)
185    ax.set_ylim(-axisLim, axisLim)
186
187    if ColorCoded == "Intensity" or ColorCoded == "Incidence" or ColorCoded == "Delay":
188        cbar = fig.colorbar(foo)
189        cbar.set_label(zlabel)
190
191    ax.legend(loc="upper right")
192    ax.set_xlabel("X (µm)")
193    ax.set_ylabel("Y (µm)")
194    ax.set_title(title)
195    # ax.margins(x=0)
196
197
198    def update_plot(new_value):
199        nonlocal movingDetector, ColorCoded, zlabel, cbar, detectorPosition, foo, distStep, focal, contour, levels, Index, RayListAnalysed
200
201        newDectectorPoint2D_Xcoord, newDectectorPoint2D_Ycoord, newFocalSpotSize, newSpotSizeSD = mpu._getDetectorPoints(
202            RayListAnalysed, movingDetector
203        )
204
205        if DrawFocal:
206            focal.set_array(Z)
207        if DrawFocalContour:
208            levels = [1/np.e**2, 0.5]
209            for coll in contour.collections:
210                coll.remove()  # Remove old contour lines
211            contour = ax.contourf(X * 1e3, Y * 1e3, Z, levels=levels, cmap='gray')
212        
213        xy = foo.get_offsets()
214        xy[:, 0] = newDectectorPoint2D_Xcoord
215        xy[:, 1] = newDectectorPoint2D_Ycoord
216        foo.set_offsets(xy)
217
218
219        if ColorCoded == "Delay":
220            newDelayList = np.asarray(movingDetector.get_Delays(RayListAnalysed))
221            newDurationSD = mp.WeightedStandardDeviation(newDelayList,IntensityList)
222            #newDurationSD = mp.StandardDeviation(newDelayList)
223            newaddLine = "\n" + "{:.2f}".format(newDurationSD) + " fs SD"
224            foo.set_array(newDelayList)
225            foo.set_clim(min(newDelayList), max(newDelayList))
226            cbar.update_normal(foo)
227        else:
228            newaddLine = ""
229
230        foo.set_label(
231            "{:.3f}".format(detectorPosition.value) + " mm\n" + "{:.1f}".format(newSpotSizeSD * 1e3) + " \u03BCm SD" + newaddLine
232        )
233        ax.legend(loc="upper right")
234
235        axisLim = 1.1 * max(AiryRadius, 0.5 * newFocalSpotSize * 1000)
236        ax.set_xlim(-axisLim, axisLim)
237        ax.set_ylim(-axisLim, axisLim)
238
239        distStep = min(
240            50, max(0.0005, round(newFocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000)
241        )  # in mm
242
243        fig.canvas.draw_idle()
244
245
246    def press(event):
247        nonlocal detectorPosition, distStep
248        if event.key == "right":
249            detectorPosition.value += distStep
250        elif event.key == "left":
251            if detectorPosition.value > 1.5 * distStep:
252                detectorPosition.value -= distStep
253            else:
254                detectorPosition.value = 0.5 * distStep
255        else:
256            return None
257
258    fig.canvas.mpl_connect("key_press_event", press)
259
260    plt.show()
261
262    detectorPosition.register(update_plot)
263
264
265    return fig, detectorPosition

Produce an interactive figure with the spot diagram on the selected Detector. The detector distance can be shifted with the left-right cursor keys. Doing so will actually move the detector. If DrawAiryAndFourier is True, a circle with the Airy-spot-size will be shown. If DrawFocalContour is True, the focal contour calculated from some of the rays will be shown. If DrawFocal is True, a heatmap calculated from some of the rays will be shown. The 'spots' can optionally be color-coded by specifying ColorCoded, which can be one of ["Intensity","Incidence","Delay"].

Parameters

RayListAnalysed : list(Ray)
    List of objects of the ModuleOpticalRay.Ray-class.

Detector : Detector or str, optional
    An object of the ModuleDetector.Detector-class or the name of the detector. The default is "Focus".

DrawAiryAndFourier : bool, optional
    Whether to draw a circle with the Airy-spot-size. The default is False.

DrawFocalContour : bool, optional
    Whether to draw the focal contour. The default is False.

DrawFocal : bool, optional
    Whether to draw the focal heatmap. The default is False.

ColorCoded : str, optional
    Color-code the spots according to one of ["Intensity","Incidence","Delay"]. The default is None.

Observer : Observer, optional
    An observer object. If none, then we just create a copy of the detector and move it when pressing left-right. 
    However, if an observer is specified, then we will change the value of the observer and it will issue 
    the required callbacks to update several plots at the same time.

Returns

fig : matlplotlib-figure-handle.
    Shows the interactive figure.
def drawDelaySpots( OpticalChain, DeltaFT: tuple[int, float], Detector='Focus', DrawAiryAndFourier=False, ColorCoded=None, Observer=None) -> matplotlib.figure.Figure:
270def DrawDelaySpots(OpticalChain, 
271                DeltaFT: tuple[int, float],
272                Detector = "Focus",
273                DrawAiryAndFourier=False, 
274                ColorCoded=None,
275                Observer = None
276                ) -> plt.Figure:
277    """
278    Produce a an interactive figure with a spot diagram resulting from the RayListAnalysed
279    hitting the Detector, with the ray-delays shown in the 3rd dimension.
280    The detector distance can be shifted with the left-right cursor keys.
281    If DrawAiryAndFourier is True, a cylinder is shown whose diameter is the Airy-spot-size and
282    whose height is the Fourier-limited pulse duration 'given by 'DeltaFT'.
283    
284    The 'spots' can optionally be color-coded by specifying ColorCoded as ["Intensity","Incidence"].
285
286    Parameters
287    ----------
288        RayListAnalysed : list(Ray)
289            List of objects of the ModuleOpticalRay.Ray-class.
290
291        Detector : Detector
292            An object of the ModuleDetector.Detector-class.
293
294        DeltaFT : (int, float)
295            The Fourier-limited pulse duration. Just used as a reference to compare the temporal spread
296            induced by the ray-delays.
297
298        DrawAiryAndFourier : bool, optional
299            Whether to draw a cylinder showing the Airy-spot-size and Fourier-limited-duration.
300            The default is False.
301
302        ColorCoded : str, optional
303            Color-code the spots according to one of ["Intensity","Incidence"].
304            The default is None.
305
306    Returns
307    -------
308        fig : matlplotlib-figure-handle.
309            Shows the interactive figure.
310    """
311    if isinstance(Detector, str):
312        Det = OpticalChain.detectors[Detector]
313    else:
314        Det = Detector
315    Index = Det.index
316    Detector = copy(Det)
317    if Observer is None:
318        detectorPosition = Observable(Detector.distance)
319    else:
320        detectorPosition = Observer
321        Detector.distance = detectorPosition.value
322    
323    RayListAnalysed = OpticalChain.get_output_rays()[Index]
324    fig, NumericalAperture, AiryRadius, FocalSpotSize = _drawDelayGraph(
325        RayListAnalysed, Detector, detectorPosition.value, DeltaFT, DrawAiryAndFourier, ColorCoded
326    )
327
328    distStep = min(50, max(0.0005, round(FocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000))  # in mm
329
330    movingDetector = copy(Detector)
331
332    def update_plot(new_value):
333        nonlocal movingDetector, ColorCoded, detectorPosition, distStep, fig
334        ax = fig.axes[0]
335        cam = [ax.azim, ax.elev, ax._dist]
336        fig, sameNumericalAperture, sameAiryRadius, newFocalSpotSize = _drawDelayGraph(
337            RayListAnalysed, movingDetector, detectorPosition.value, DeltaFT, DrawAiryAndFourier, ColorCoded, fig
338        )
339        ax = fig.axes[0]
340        ax.azim, ax.elev, ax._dist = cam
341        distStep = min(
342            50, max(0.0005, round(newFocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000)
343        )
344        return fig
345
346    def press(event):
347        nonlocal detectorPosition, distStep, movingDetector, fig
348        if event.key == "right":
349            detectorPosition.value += distStep
350        elif event.key == "left":
351            if detectorPosition.value > 1.5 * distStep:
352                detectorPosition.value -= distStep
353            else:
354                detectorPosition.value = 0.5 * distStep
355
356    fig.canvas.mpl_connect("key_press_event", press)
357    detectorPosition.register(update_plot)
358    detectorPosition.register_calculation(lambda x: movingDetector.set_distance(x))
359
360    return fig, Observable

Produce a an interactive figure with a spot diagram resulting from the RayListAnalysed hitting the Detector, with the ray-delays shown in the 3rd dimension. The detector distance can be shifted with the left-right cursor keys. If DrawAiryAndFourier is True, a cylinder is shown whose diameter is the Airy-spot-size and whose height is the Fourier-limited pulse duration 'given by 'DeltaFT'.

The 'spots' can optionally be color-coded by specifying ColorCoded as ["Intensity","Incidence"].

Parameters

RayListAnalysed : list(Ray)
    List of objects of the ModuleOpticalRay.Ray-class.

Detector : Detector
    An object of the ModuleDetector.Detector-class.

DeltaFT : (int, float)
    The Fourier-limited pulse duration. Just used as a reference to compare the temporal spread
    induced by the ray-delays.

DrawAiryAndFourier : bool, optional
    Whether to draw a cylinder showing the Airy-spot-size and Fourier-limited-duration.
    The default is False.

ColorCoded : str, optional
    Color-code the spots according to one of ["Intensity","Incidence"].
    The default is None.

Returns

fig : matlplotlib-figure-handle.
    Shows the interactive figure.
def drawMirrorProjection( OpticalChain, ReflectionNumber: int, ColorCoded=None, Detector='') -> matplotlib.figure.Figure:
441def DrawMirrorProjection(OpticalChain, ReflectionNumber: int, ColorCoded=None, Detector="") -> plt.Figure:
442    """
443    Produce a plot of the ray impact points on the optical element with index 'ReflectionNumber'.
444    The points can be color-coded according ["Incidence","Intensity","Delay"], where the ray delay is
445    measured at the Detector.
446
447    Parameters
448    ----------
449        OpticalChain : OpticalChain
450           List of objects of the ModuleOpticalOpticalChain.OpticalChain-class.
451
452        ReflectionNumber : int
453            Index specifying the optical element on which you want to see the impact points.
454
455        Detector : Detector, optional
456            Object of the ModuleDetector.Detector-class. Only necessary to project delays. The default is None.
457
458        ColorCoded : str, optional
459            Specifies which ray property to color-code: ["Incidence","Intensity","Delay"]. The default is None.
460
461    Returns
462    -------
463        fig : matlplotlib-figure-handle.
464            Shows the figure.
465    """
466    from mpl_toolkits.axes_grid1 import make_axes_locatable
467    if isinstance(Detector, str):
468        if Detector == "":
469            Detector = None
470        else:
471            Detector = OpticalChain.detectors[Detector]
472
473    Position = OpticalChain[ReflectionNumber].position
474    q = OpticalChain[ReflectionNumber].orientation
475    # n = OpticalChain.optical_elements[ReflectionNumber].normal
476    # m = OpticalChain.optical_elements[ReflectionNumber].majoraxis
477
478    RayListAnalysed = OpticalChain.get_output_rays()[ReflectionNumber]
479    # transform rays into the mirror-support reference frame
480    # (same as mirror frame but without the shift by mirror-centre)
481    r0 = OpticalChain[ReflectionNumber].r0
482    RayList = [r.to_basis(*OpticalChain[ReflectionNumber].basis) for r in RayListAnalysed]
483
484    x = np.asarray([k.point[0] for k in RayList]) - r0[0]
485    y = np.asarray([k.point[1] for k in RayList]) - r0[1]
486    if ColorCoded == "Intensity":
487        IntensityList = [k.intensity for k in RayListAnalysed]
488        z = np.asarray(IntensityList)
489        zlabel = "Intensity (arb.u.)"
490        title = "Ray intensity projected on mirror              "
491    elif ColorCoded == "Incidence":
492        IncidenceList = [np.rad2deg(k.incidence) for k in RayListAnalysed]  # in degree
493        z = np.asarray(IncidenceList)
494        zlabel = "Incidence angle (deg)"
495        title = "Ray incidence projected on mirror              "
496    elif ColorCoded == "Delay":
497        if Detector is not None:
498            z = np.asarray(Detector.get_Delays(RayListAnalysed))
499            zlabel = "Delay (fs)"
500            title = "Ray delay at detector projected on mirror              "
501        else:
502            raise ValueError("If you want to project ray delays, you must specify a detector.")
503    else:
504        z = "red"
505        title = "Ray impact points projected on mirror"
506
507    plt.ion()
508    fig = plt.figure()
509    ax = OpticalChain.optical_elements[ReflectionNumber].support._ContourSupport(fig)
510    p = plt.scatter(x, y, c=z, s=15)
511    if ColorCoded == "Delay" or ColorCoded == "Incidence" or ColorCoded == "Intensity":
512        divider = make_axes_locatable(ax)
513        cax = divider.append_axes("right", size="5%", pad=0.05)
514        cbar = fig.colorbar(p, cax=cax)
515        cbar.set_label(zlabel)
516    ax.set_xlabel("x (mm)")
517    ax.set_ylabel("y (mm)")
518    plt.title(title, loc="right")
519    plt.tight_layout()
520
521    bbox = ax.get_position()
522    bbox.set_points(bbox.get_points() - np.array([[0.01, 0], [0.01, 0]]))
523    ax.set_position(bbox)
524    plt.show()
525
526    return fig

Produce a plot of the ray impact points on the optical element with index 'ReflectionNumber'. The points can be color-coded according ["Incidence","Intensity","Delay"], where the ray delay is measured at the Detector.

Parameters

OpticalChain : OpticalChain
   List of objects of the ModuleOpticalOpticalChain.OpticalChain-class.

ReflectionNumber : int
    Index specifying the optical element on which you want to see the impact points.

Detector : Detector, optional
    Object of the ModuleDetector.Detector-class. Only necessary to project delays. The default is None.

ColorCoded : str, optional
    Specifies which ray property to color-code: ["Incidence","Intensity","Delay"]. The default is None.

Returns

fig : matlplotlib-figure-handle.
    Shows the figure.
def render( OpticalChain, EndDistance=None, maxRays=300, OEpoints=2000, draw_mesh=False, cycle_ray_colors=False, impact_points=False, DrawDetectors=True, DetectedRays=False, Observers={}):
532def DrawSetup(OpticalChain, 
533                   EndDistance=None, 
534                   maxRays=300, 
535                   OEpoints=2000, 
536                   draw_mesh=False, 
537                   cycle_ray_colors = False,
538                   impact_points = False,
539                   DrawDetectors=True,
540                   DetectedRays = False,
541                   Observers = dict()):
542    """
543    Renders an image of the Optical setup and the traced rays.
544
545    Parameters
546    ----------
547        OpticalChain : OpticalChain
548            List of objects of the ModuleOpticalOpticalChain.OpticalChain-class.
549
550        EndDistance : float, optional
551            The rays of the last ray bundle are drawn with a length given by EndDistance (in mm). If not specified,
552            this distance is set to that between the source point and the 1st optical element.
553
554        maxRays: int
555            The maximum number of rays to render. Rendering all the traced rays is a insufferable resource hog
556            and not required for a nice image. Default is 150.
557
558        OEpoints : int
559            How many little spheres to draw to represent the optical elements.  Default is 2000.
560
561    Returns
562    -------
563        fig : Pyvista-figure-handle.
564            Shows the figure.
565    """
566
567    RayListHistory = [OpticalChain.source_rays] + OpticalChain.get_output_rays()
568
569    if EndDistance is None:
570        EndDistance = np.linalg.norm(OpticalChain.source_rays[0].point - OpticalChain.optical_elements[0].position)
571
572    print("...rendering image of optical chain...", end="", flush=True)
573    fig = pvqt.BackgroundPlotter(window_size=(1500, 500), notebook=False) # Opening a window
574    fig.set_background('white')
575    
576    if cycle_ray_colors:
577        colors = mpu.generate_distinct_colors(len(OpticalChain)+1)
578    else:
579        colors = [[0.7, 0, 0]]*(len(OpticalChain)+1) # Default color: dark red
580
581    # Optics display
582    # For each optic we will send the figure to the function _RenderOpticalElement and it will add the optic to the figure
583    for i,OE in enumerate(OpticalChain.optical_elements):
584        color = pv.Color(colors[i+1])
585        rgb = color.float_rgb
586        h, l, s = rgb_to_hls(*rgb)
587        s = max(0, min(1, s * 0.3))  # Decrease saturation
588        l = max(0, min(1, l + 0.1))  # Increase lightness
589        new_rgb = hls_to_rgb(h, l, s)
590        darkened_color = pv.Color(new_rgb)
591        mpm._RenderOpticalElement(fig, OE, OEpoints, draw_mesh, darkened_color, index=i)
592    ray_meshes = mpm._RenderRays(RayListHistory, EndDistance, maxRays)
593    for i,ray in enumerate(ray_meshes):
594        color = pv.Color(colors[i])
595        fig.add_mesh(ray, color=color, name=f"RayBundle_{i}")
596    if impact_points:
597        for i,rays in enumerate(RayListHistory):
598            points = np.array([list(r.point) for r in rays], dtype=np.float32)
599            points = pv.PolyData(points)
600            color = pv.Color(colors[i-1])
601            fig.add_mesh(points, color=color, point_size=5, name=f"RayImpactPoints_{i}")
602    
603    detector_copies = {key: copy(OpticalChain.detectors[key]) for key in OpticalChain.detectors.keys()}
604    detector_meshes_list = []
605    detectedpoint_meshes = dict()
606    
607    if OpticalChain.detectors is not None and DrawDetectors:
608        # Detector display
609        for key in OpticalChain.detectors.keys():
610            det = detector_copies[key]
611            index = OpticalChain.detectors[key].index
612            if key in Observers:
613                det.distance = Observers[key].value
614                #Observers[key].register_calculation(lambda x: det.set_distance(x))
615            mpm._RenderDetector(fig, det, name = key, detector_meshes = detector_meshes_list)
616            if DetectedRays:
617                RayListAnalysed = OpticalChain.get_output_rays()[index]
618                points = det.get_3D_points(RayListAnalysed)
619                points = pv.PolyData(points)
620                detectedpoint_meshes[key] = points
621                fig.add_mesh(points, color='purple', point_size=5, name=f"DetectedRays_{key}")
622    detector_meshes = dict(zip(OpticalChain.detectors.keys(), detector_meshes_list))
623    
624    # Now we define a function that will move on the plot the detector with name "detname" when it's called
625    def move_detector(detname, new_value):
626        nonlocal fig, detector_meshes, detectedpoint_meshes, DetectedRays, detectedpoint_meshes, detector_copies, OpticalChain
627        det = detector_copies[detname]
628        index = OpticalChain.detectors[detname].index
629        det_mesh = detector_meshes[detname]
630        translation = det.normal * (det.distance - new_value)
631        det_mesh.translate(translation, inplace=True)
632        det.distance = new_value
633        if DetectedRays:
634            points_mesh = detectedpoint_meshes[detname]
635            points_mesh.points = det.get_3D_points(OpticalChain.get_output_rays()[index])
636        fig.show()
637    
638    # Now we register the function to the observers
639    for key in OpticalChain.detectors.keys():
640        if key in Observers:
641            Observers[key].register(lambda x: move_detector(key, x))
642
643    #pv.save_meshio('optics.inp', pointcloud)  
644    print(
645        "\r\033[K", end="", flush=True
646    )  # move to beginning of the line with \r and then delete the whole line with \033[K
647    fig.show()
648    return fig

Renders an image of the Optical setup and the traced rays.

Parameters

OpticalChain : OpticalChain
    List of objects of the ModuleOpticalOpticalChain.OpticalChain-class.

EndDistance : float, optional
    The rays of the last ray bundle are drawn with a length given by EndDistance (in mm). If not specified,
    this distance is set to that between the source point and the 1st optical element.

maxRays: int
    The maximum number of rays to render. Rendering all the traced rays is a insufferable resource hog
    and not required for a nice image. Default is 150.

OEpoints : int
    How many little spheres to draw to represent the optical elements.  Default is 2000.

Returns

fig : Pyvista-figure-handle.
    Shows the figure.
def drawCaustics(OpticalChain, Range=1, Detector='Focus', Npoints=1000, Nrays=1000):
704def DrawCaustics(OpticalChain, Range=1, Detector="Focus" , Npoints=1000, Nrays=1000):
705    """
706    This function displays the caustics of the rays on the detector.
707    To do so, it calculates the intersections of the rays with the detector over a 
708    range determined by the parameter Range, and then plots the standard deviation of the
709    positions in the x and y directions.
710
711    Parameters
712    ----------
713    OpticalChain : OpticalChain
714        The optical chain to analyse.
715
716    DetectorName : str
717        The name of the detector on which the caustics are calculated.
718    
719    Range : float
720        The range of the detector over which to calculate the caustics.
721
722    Npoints : int, optional
723        The number of points to sample on the detector. The default is 1000.
724    
725    Returns
726    -------
727    fig : Figure
728        The figure of the plot.
729    """
730    distances = np.linspace(-Range, Range, Npoints)
731    if isinstance(Detector, str):
732        Det = OpticalChain.detectors[Detector]
733        Index = Det.index
734    Rays = OpticalChain.get_output_rays()[Index]
735    Nrays = min(Nrays, len(Rays))
736    Rays = np.random.choice(Rays, Nrays, replace=False)
737    LocalRayList = [r.to_basis(*Det.basis) for r in Rays]
738    Points = mgeo.IntersectionRayListZPlane(LocalRayList, distances)
739    x_std = []
740    y_std = []
741    for i in range(len(distances)):
742        x_std.append(mp.StandardDeviation(Points[i][:,0]))
743        y_std.append(mp.StandardDeviation(Points[i][:,1]))
744    plt.ion()
745    fig, ax = plt.subplots()
746    ax.plot(distances, x_std, label="x std")
747    ax.plot(distances, y_std, label="y std")
748    ax.set_xlabel("Detector distance (mm)")
749    ax.set_ylabel("Standard deviation (mm)")
750    ax.legend()
751    plt.title("Caustics")
752    plt.show()
753    return fig

This function displays the caustics of the rays on the detector. To do so, it calculates the intersections of the rays with the detector over a range determined by the parameter Range, and then plots the standard deviation of the positions in the x and y directions.

Parameters

OpticalChain : OpticalChain The optical chain to analyse.

DetectorName : str The name of the detector on which the caustics are calculated.

Range : float The range of the detector over which to calculate the caustics.

Npoints : int, optional The number of points to sample on the detector. The default is 1000.

Returns

fig : Figure The figure of the plot.

7 - ModuleOpticalElement

Provides the class for a general optical element, which serves to connect the “lab-frame”, in which the light rays are traced, to the proper “optic” coordinate frame in which an optic is defined. So this class is what serves to "align" the optics in lab-space, and it provides methods to modify its alignment.

The optic can be a Mirror-object or a Mask-object.

Illustration the OpticalElement-class.

Created in Apr 2020

@author: Anthony Guillaume + Stefan Haessler

  1"""
  2Provides the class for a general optical element, which serves to connect the “lab-frame”,
  3in which the light rays are traced, to the proper “optic” coordinate frame in
  4which an optic is defined. So this class is what serves to "align" the optics in lab-space,
  5and it provides methods to modify its alignment.
  6
  7The optic can be a [Mirror-object](ModuleMirror.html) or a [Mask-object](ModuleMask.html).
  8
  9![Illustration the OpticalElement-class.](OE.svg)
 10
 11
 12
 13Created in Apr 2020
 14
 15@author: Anthony Guillaume + Stefan Haessler
 16"""
 17# %%
 18import ARTcore.ModuleGeometry as mgeo
 19from ARTcore.ModuleGeometry import Origin
 20
 21import numpy as np
 22from abc import ABC, abstractmethod
 23import logging
 24
 25logger = logging.getLogger(__name__)
 26
 27
 28# %%
 29class OpticalElement(ABC):
 30    """
 31    An optical element, can be either a Mirror or a Mask. In the future, one could add Gratings, DispersiveMedium etc...
 32
 33    Attributes: TODO update
 34    ----------
 35        position : np.ndarray
 36            Coordinate vector of the optical element’s center point in the lab frame.
 37            What this center is, depends on the 'type', but it is generally the center of symmetry
 38            of the Support of the optic. It is marked by the point 'P' in the drawings in the
 39            documentation of the Mirror-classes for example.
 40        
 41        orientation: np.quaternion
 42            Orientation of the optic in the lab reference frame. From this, we calculate
 43            the normal, the majoraxis and any other interesting vectors that are of 
 44            interest for a specific OpticalElement subclass. 
 45
 46        normal : np.ndarray
 47            Lab-frame vector pointing against the direction that would be considered
 48            as normal incidence on the optical element.
 49
 50        majoraxis : np.ndarray
 51            Lab-frame vector of another distinguished axis of non-rotationally symmetric optics,
 52            like the major axes of toroidal/elliptical mirrors or the off-axis direction of
 53            off-axis parabolas. This fixes the optical element’s rotation about 'normal'.
 54            It is required to be perpendicular to 'normal', and is usually the x-axis in the
 55            optic's proper coordinate frame.
 56
 57            What this 'majoraxis' is, e.g. for the different kinds of mirrors, is illustrated
 58            in the documentation of the Mirror-classes.
 59
 60            For pure p or s polarization, the light incidence plane should be the
 61            plane spanned by 'normal' and 'majoraxis', so that the incidence angle is varied
 62            by rotating the optical element around the cross product 'normal' x 'majoraxis'.
 63
 64
 65    Methods
 66    ----------
 67        rotate_pitch_by(angle)
 68
 69        rotate_roll_by(angle)
 70
 71        rotate_yaw_by(angle)
 72
 73        rotate_random_by(angle)
 74
 75        shift_along_normal(distance)
 76
 77        shift_along_major(distance)
 78
 79        shift_along_cross(distance)
 80
 81        shift_along_random(distance)
 82
 83    """
 84    # %% Starting 3d orientation definition
 85    _description = "Generic Optical Element"
 86
 87    def __hash__(self):
 88        return super().__hash__()+hash(self._r)+hash(self._q)
 89
 90    @property
 91    def description(self):
 92        """
 93        Return a description of the optical element.
 94        """
 95        return self._description
 96
 97    @property
 98    def r(self):
 99        """
100        Return the offset of the optical element from its reference frame to the lab reference frame.
101        Not that this is **NOT** the position of the optical element's center point. Rather it is the
102        offset of the reference frame of the optical element from the lab reference frame.
103        """
104        return self._r
105
106    @r.setter
107    def r(self, NewPosition):
108        """
109        Set the offset of the optical element from its reference frame to the lab reference frame.
110        """
111        if isinstance(NewPosition, mgeo.Point) and len(NewPosition) == 3:
112            self._r = NewPosition
113        else:
114            raise TypeError("Position must be a 3D mgeo.Point.")
115    @property
116    def q(self):
117        """
118        Return the orientation of the optical element.
119        The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.
120        """
121        return self._q
122    
123    @q.setter
124    def q(self, NewOrientation):
125        """
126        Set the orientation of the optical element.
127        This function normalizes the input quaternion before storing it.
128        If the input is not a quaternion, raise a TypeError.
129        """
130        if isinstance(NewOrientation, np.quaternion):
131            self._q = NewOrientation.normalized()
132        else:
133            raise TypeError("Orientation must be a quaternion.")
134
135    @property
136    def position(self):
137        """
138        Return the position of the basepoint of the optical element. Often it is the same as the optical centre.
139        This position is the one around which all rotations are performed.
140        """
141        return self.r0 + self.r
142    
143    @property
144    def orientation(self):
145        """
146        Return the orientation of the optical element.
147        The utility of this method is unclear.
148        """
149        return self.q
150    
151    @property
152    def basis(self):
153        return self.r0, self.r, self.q
154    # %% Start of other methods
155    def __init__(self):
156        self._r = mgeo.Vector([0.0, 0.0, 0.0])
157        self._q = np.quaternion(1, 0, 0, 0)
158        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
159
160    @abstractmethod
161    def propagate_raylist(self, RayList, alignment=False):
162        """
163        This method is used to propagate a list of rays through the optical element. Implement this method in the subclasses.
164        In the case of a mirror, the method should reflect the rays off the mirror surface.
165        In the case of a mask, the method should block the rays that hit the mask.
166        In the case of a grating, the method should diffract the rays according to the grating equation.
167        """
168        pass
169
170    def add_global_points(self, *args):
171        """
172        Automatically add global properties for point attributes.
173        As arguments it takes the names of the global point attributes and seeks for the corresponding local point attributes.
174        Then it creates a property for the global point attribute.
175
176        Example:
177        Suppose the optical element has an attribute 'center_ref' that is the center of the optical element in the optic's coordinate frame.
178        Then, calling object.add_global_points('center') will create a property 'center' that returns the global position of the center of the optical element.
179        It takes into account the position and orientation of the optical element.
180        """
181        for arg in args:
182            local_attr = f"{arg}_ref"
183            if hasattr(self, local_attr):
184                # Dynamically define a property for the global point
185                setattr(self.__class__, arg, property(self._create_global_point_property(local_attr)))
186
187    def add_global_vectors(self, *args):
188        """
189        Automatically add global properties for vector attributes.
190        As arguments it takes the names of the global vector attributes and seeks for the corresponding local vector attributes.
191        Then it creates a property for the global vector attribute.
192
193        Example:
194        Suppose the optical element has an attribute 'normal_ref' that is the normal of the optical element in the optic's coordinate frame.
195        Then, calling object.add_global_vectors('normal') will create a property 'normal' that returns the global normal of the optical element.
196        """
197        for arg in args:
198            local_attr = f"{arg}_ref"
199            if hasattr(self, local_attr):
200                # Dynamically define a property for the global vector
201                setattr(self.__class__, arg, property(self._create_global_vector_property(local_attr)))
202    
203    def _create_global_point_property(self, local_attr):
204        """
205        Return a function that computes the global point.
206        This is the actual function that is used to create the global point property. It performs the transformation of the local point to the global reference frame.
207        """
208        def global_point(self):
209            # Translate the local point to the global reference frame
210            local_point = getattr(self, local_attr)
211            return local_point.from_basis(*self.basis)
212        return global_point
213
214    def _create_global_vector_property(self, local_attr):
215        """
216        Return a function that computes the global vector.
217        This is the actual function that is used to create the global vector property. It performs the transformation of the local vector to the global reference frame.
218        """
219        def global_vector(self):
220            # Rotate the local vector to the global reference frame
221            local_vector = getattr(self, local_attr)
222            return local_vector.from_basis(*self.basis)
223        return global_vector
224    # %% Starting 3d misalignment definitions
225    # This section needs to be reworked to be more general and to allow for more flexibility in the alignment of the optical elements. TODO
226
227    def rotate_pitch_by(self, angle):
228        """
229        Pitch rotation, i.e. rotates the optical element about the axis ('normal' x 'majoraxis'), by the
230        given angle.
231        If the plane spanned by 'normal' and 'majoraxis' is the incidence plane (normally the case
232        in a "clean alignment" situation for pure p or s polarization), then this is simply a modificaiton
233        of the incidence angle by "angle". But in general, if the optical element has some odd orientation,
234        there is not a direct correspondence.
235
236        Parameters
237        ----------
238            angle : float
239                Rotation angle in *degrees*.
240        """
241        rotation_axis = np.cross(self.support_normal, self.majoraxis)
242        self.q = mgeo.QRotationAroundAxis(rotation_axis, np.deg2rad(angle))*self.q
243
244    def rotate_roll_by(self, angle):
245        """
246        Roll rotation, i.e. rotates the optical element about its 'majoraxis' by the given angle.
247
248        Parameters
249        ----------
250            angle : float
251                Rotation angle in *degrees*.
252        """
253
254        self.q = mgeo.QRotationAroundAxis(self.majoraxis, np.deg2rad(angle))*self.q
255
256    def rotate_yaw_by(self, angle):
257        """
258        Yaw rotation, i.e. rotates the optical element about its 'normal' by the given angle.
259
260        Parameters
261        ----------
262            angle : float
263                Rotation angle in *degrees*.
264        """
265        self.q = mgeo.QRotationAroundAxis(self.support_normal, np.deg2rad(angle))*self.q
266
267    def rotate_random_by(self, angle):
268        """
269        Rotates the optical element about a randomly oriented axis by the given angle.
270
271        Parameters
272        ----------
273            angle : float
274                Rotation angle in *degrees*.
275        """
276
277        self.q = mgeo.QRotationAroundAxis(np.random.random(3), np.deg2rad(angle))*self.q
278
279    def shift_along_normal(self, distance):
280        """
281        Shifts the optical element along its 'normal' by the given distance.
282
283        Parameters
284        ----------
285            distance : float
286                Shift distance in mm.
287        """
288        self.r = self.r +  distance * self.support_normal
289
290    def shift_along_major(self, distance):
291        """
292        Shifts the optical element along its 'majoraxis' by the given distance.
293
294        Parameters
295        ----------
296            distance : float
297                Shift distance in mm.
298        """
299        self.r = self.r +  distance * self.majoraxis
300
301    def shift_along_cross(self, distance):
302        """
303        Shifts the optical element along the axis 'normal'x'majoraxis'
304        (typically normal to the light incidence plane) by the given distance.
305
306        Parameters
307        ----------
308            distance : float
309                Shift distance in mm.
310        """
311        self.r = self.r +  distance * mgeo.Normalize(np.cross(self.support_normal, self.majoraxis))
312
313    def shift_along_random(self, distance):
314        """
315        Shifts the optical element along a random direction by the given distance.
316
317        Parameters
318        ----------
319            distance : float
320                Shift distance in mm.
321        """
322        self.r = self.r + distance * mgeo.Normalize(np.random.random(3))
logger = <Logger ARTcore.ModuleOpticalElement (WARNING)>
class OpticalElement(abc.ABC):
 30class OpticalElement(ABC):
 31    """
 32    An optical element, can be either a Mirror or a Mask. In the future, one could add Gratings, DispersiveMedium etc...
 33
 34    Attributes: TODO update
 35    ----------
 36        position : np.ndarray
 37            Coordinate vector of the optical element’s center point in the lab frame.
 38            What this center is, depends on the 'type', but it is generally the center of symmetry
 39            of the Support of the optic. It is marked by the point 'P' in the drawings in the
 40            documentation of the Mirror-classes for example.
 41        
 42        orientation: np.quaternion
 43            Orientation of the optic in the lab reference frame. From this, we calculate
 44            the normal, the majoraxis and any other interesting vectors that are of 
 45            interest for a specific OpticalElement subclass. 
 46
 47        normal : np.ndarray
 48            Lab-frame vector pointing against the direction that would be considered
 49            as normal incidence on the optical element.
 50
 51        majoraxis : np.ndarray
 52            Lab-frame vector of another distinguished axis of non-rotationally symmetric optics,
 53            like the major axes of toroidal/elliptical mirrors or the off-axis direction of
 54            off-axis parabolas. This fixes the optical element’s rotation about 'normal'.
 55            It is required to be perpendicular to 'normal', and is usually the x-axis in the
 56            optic's proper coordinate frame.
 57
 58            What this 'majoraxis' is, e.g. for the different kinds of mirrors, is illustrated
 59            in the documentation of the Mirror-classes.
 60
 61            For pure p or s polarization, the light incidence plane should be the
 62            plane spanned by 'normal' and 'majoraxis', so that the incidence angle is varied
 63            by rotating the optical element around the cross product 'normal' x 'majoraxis'.
 64
 65
 66    Methods
 67    ----------
 68        rotate_pitch_by(angle)
 69
 70        rotate_roll_by(angle)
 71
 72        rotate_yaw_by(angle)
 73
 74        rotate_random_by(angle)
 75
 76        shift_along_normal(distance)
 77
 78        shift_along_major(distance)
 79
 80        shift_along_cross(distance)
 81
 82        shift_along_random(distance)
 83
 84    """
 85    # %% Starting 3d orientation definition
 86    _description = "Generic Optical Element"
 87
 88    def __hash__(self):
 89        return super().__hash__()+hash(self._r)+hash(self._q)
 90
 91    @property
 92    def description(self):
 93        """
 94        Return a description of the optical element.
 95        """
 96        return self._description
 97
 98    @property
 99    def r(self):
100        """
101        Return the offset of the optical element from its reference frame to the lab reference frame.
102        Not that this is **NOT** the position of the optical element's center point. Rather it is the
103        offset of the reference frame of the optical element from the lab reference frame.
104        """
105        return self._r
106
107    @r.setter
108    def r(self, NewPosition):
109        """
110        Set the offset of the optical element from its reference frame to the lab reference frame.
111        """
112        if isinstance(NewPosition, mgeo.Point) and len(NewPosition) == 3:
113            self._r = NewPosition
114        else:
115            raise TypeError("Position must be a 3D mgeo.Point.")
116    @property
117    def q(self):
118        """
119        Return the orientation of the optical element.
120        The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.
121        """
122        return self._q
123    
124    @q.setter
125    def q(self, NewOrientation):
126        """
127        Set the orientation of the optical element.
128        This function normalizes the input quaternion before storing it.
129        If the input is not a quaternion, raise a TypeError.
130        """
131        if isinstance(NewOrientation, np.quaternion):
132            self._q = NewOrientation.normalized()
133        else:
134            raise TypeError("Orientation must be a quaternion.")
135
136    @property
137    def position(self):
138        """
139        Return the position of the basepoint of the optical element. Often it is the same as the optical centre.
140        This position is the one around which all rotations are performed.
141        """
142        return self.r0 + self.r
143    
144    @property
145    def orientation(self):
146        """
147        Return the orientation of the optical element.
148        The utility of this method is unclear.
149        """
150        return self.q
151    
152    @property
153    def basis(self):
154        return self.r0, self.r, self.q
155    # %% Start of other methods
156    def __init__(self):
157        self._r = mgeo.Vector([0.0, 0.0, 0.0])
158        self._q = np.quaternion(1, 0, 0, 0)
159        self.r0 = mgeo.Point([0.0, 0.0, 0.0])
160
161    @abstractmethod
162    def propagate_raylist(self, RayList, alignment=False):
163        """
164        This method is used to propagate a list of rays through the optical element. Implement this method in the subclasses.
165        In the case of a mirror, the method should reflect the rays off the mirror surface.
166        In the case of a mask, the method should block the rays that hit the mask.
167        In the case of a grating, the method should diffract the rays according to the grating equation.
168        """
169        pass
170
171    def add_global_points(self, *args):
172        """
173        Automatically add global properties for point attributes.
174        As arguments it takes the names of the global point attributes and seeks for the corresponding local point attributes.
175        Then it creates a property for the global point attribute.
176
177        Example:
178        Suppose the optical element has an attribute 'center_ref' that is the center of the optical element in the optic's coordinate frame.
179        Then, calling object.add_global_points('center') will create a property 'center' that returns the global position of the center of the optical element.
180        It takes into account the position and orientation of the optical element.
181        """
182        for arg in args:
183            local_attr = f"{arg}_ref"
184            if hasattr(self, local_attr):
185                # Dynamically define a property for the global point
186                setattr(self.__class__, arg, property(self._create_global_point_property(local_attr)))
187
188    def add_global_vectors(self, *args):
189        """
190        Automatically add global properties for vector attributes.
191        As arguments it takes the names of the global vector attributes and seeks for the corresponding local vector attributes.
192        Then it creates a property for the global vector attribute.
193
194        Example:
195        Suppose the optical element has an attribute 'normal_ref' that is the normal of the optical element in the optic's coordinate frame.
196        Then, calling object.add_global_vectors('normal') will create a property 'normal' that returns the global normal of the optical element.
197        """
198        for arg in args:
199            local_attr = f"{arg}_ref"
200            if hasattr(self, local_attr):
201                # Dynamically define a property for the global vector
202                setattr(self.__class__, arg, property(self._create_global_vector_property(local_attr)))
203    
204    def _create_global_point_property(self, local_attr):
205        """
206        Return a function that computes the global point.
207        This is the actual function that is used to create the global point property. It performs the transformation of the local point to the global reference frame.
208        """
209        def global_point(self):
210            # Translate the local point to the global reference frame
211            local_point = getattr(self, local_attr)
212            return local_point.from_basis(*self.basis)
213        return global_point
214
215    def _create_global_vector_property(self, local_attr):
216        """
217        Return a function that computes the global vector.
218        This is the actual function that is used to create the global vector property. It performs the transformation of the local vector to the global reference frame.
219        """
220        def global_vector(self):
221            # Rotate the local vector to the global reference frame
222            local_vector = getattr(self, local_attr)
223            return local_vector.from_basis(*self.basis)
224        return global_vector
225    # %% Starting 3d misalignment definitions
226    # This section needs to be reworked to be more general and to allow for more flexibility in the alignment of the optical elements. TODO
227
228    def rotate_pitch_by(self, angle):
229        """
230        Pitch rotation, i.e. rotates the optical element about the axis ('normal' x 'majoraxis'), by the
231        given angle.
232        If the plane spanned by 'normal' and 'majoraxis' is the incidence plane (normally the case
233        in a "clean alignment" situation for pure p or s polarization), then this is simply a modificaiton
234        of the incidence angle by "angle". But in general, if the optical element has some odd orientation,
235        there is not a direct correspondence.
236
237        Parameters
238        ----------
239            angle : float
240                Rotation angle in *degrees*.
241        """
242        rotation_axis = np.cross(self.support_normal, self.majoraxis)
243        self.q = mgeo.QRotationAroundAxis(rotation_axis, np.deg2rad(angle))*self.q
244
245    def rotate_roll_by(self, angle):
246        """
247        Roll rotation, i.e. rotates the optical element about its 'majoraxis' by the given angle.
248
249        Parameters
250        ----------
251            angle : float
252                Rotation angle in *degrees*.
253        """
254
255        self.q = mgeo.QRotationAroundAxis(self.majoraxis, np.deg2rad(angle))*self.q
256
257    def rotate_yaw_by(self, angle):
258        """
259        Yaw rotation, i.e. rotates the optical element about its 'normal' by the given angle.
260
261        Parameters
262        ----------
263            angle : float
264                Rotation angle in *degrees*.
265        """
266        self.q = mgeo.QRotationAroundAxis(self.support_normal, np.deg2rad(angle))*self.q
267
268    def rotate_random_by(self, angle):
269        """
270        Rotates the optical element about a randomly oriented axis by the given angle.
271
272        Parameters
273        ----------
274            angle : float
275                Rotation angle in *degrees*.
276        """
277
278        self.q = mgeo.QRotationAroundAxis(np.random.random(3), np.deg2rad(angle))*self.q
279
280    def shift_along_normal(self, distance):
281        """
282        Shifts the optical element along its 'normal' by the given distance.
283
284        Parameters
285        ----------
286            distance : float
287                Shift distance in mm.
288        """
289        self.r = self.r +  distance * self.support_normal
290
291    def shift_along_major(self, distance):
292        """
293        Shifts the optical element along its 'majoraxis' by the given distance.
294
295        Parameters
296        ----------
297            distance : float
298                Shift distance in mm.
299        """
300        self.r = self.r +  distance * self.majoraxis
301
302    def shift_along_cross(self, distance):
303        """
304        Shifts the optical element along the axis 'normal'x'majoraxis'
305        (typically normal to the light incidence plane) by the given distance.
306
307        Parameters
308        ----------
309            distance : float
310                Shift distance in mm.
311        """
312        self.r = self.r +  distance * mgeo.Normalize(np.cross(self.support_normal, self.majoraxis))
313
314    def shift_along_random(self, distance):
315        """
316        Shifts the optical element along a random direction by the given distance.
317
318        Parameters
319        ----------
320            distance : float
321                Shift distance in mm.
322        """
323        self.r = self.r + distance * mgeo.Normalize(np.random.random(3))

An optical element, can be either a Mirror or a Mask. In the future, one could add Gratings, DispersiveMedium etc...

Attributes: TODO update

position : np.ndarray
    Coordinate vector of the optical element’s center point in the lab frame.
    What this center is, depends on the 'type', but it is generally the center of symmetry
    of the Support of the optic. It is marked by the point 'P' in the drawings in the
    documentation of the Mirror-classes for example.

orientation: np.quaternion
    Orientation of the optic in the lab reference frame. From this, we calculate
    the normal, the majoraxis and any other interesting vectors that are of 
    interest for a specific OpticalElement subclass. 

normal : np.ndarray
    Lab-frame vector pointing against the direction that would be considered
    as normal incidence on the optical element.

majoraxis : np.ndarray
    Lab-frame vector of another distinguished axis of non-rotationally symmetric optics,
    like the major axes of toroidal/elliptical mirrors or the off-axis direction of
    off-axis parabolas. This fixes the optical element’s rotation about 'normal'.
    It is required to be perpendicular to 'normal', and is usually the x-axis in the
    optic's proper coordinate frame.

    What this 'majoraxis' is, e.g. for the different kinds of mirrors, is illustrated
    in the documentation of the Mirror-classes.

    For pure p or s polarization, the light incidence plane should be the
    plane spanned by 'normal' and 'majoraxis', so that the incidence angle is varied
    by rotating the optical element around the cross product 'normal' x 'majoraxis'.

Methods

rotate_pitch_by(angle)

rotate_roll_by(angle)

rotate_yaw_by(angle)

rotate_random_by(angle)

shift_along_normal(distance)

shift_along_major(distance)

shift_along_cross(distance)

shift_along_random(distance)
description
91    @property
92    def description(self):
93        """
94        Return a description of the optical element.
95        """
96        return self._description

Return a description of the optical element.

r
 98    @property
 99    def r(self):
100        """
101        Return the offset of the optical element from its reference frame to the lab reference frame.
102        Not that this is **NOT** the position of the optical element's center point. Rather it is the
103        offset of the reference frame of the optical element from the lab reference frame.
104        """
105        return self._r

Return the offset of the optical element from its reference frame to the lab reference frame. Not that this is NOT the position of the optical element's center point. Rather it is the offset of the reference frame of the optical element from the lab reference frame.

q
116    @property
117    def q(self):
118        """
119        Return the orientation of the optical element.
120        The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.
121        """
122        return self._q

Return the orientation of the optical element. The orientation is stored as a unit quaternion representing the rotation from the optic's coordinate frame to the lab frame.

position
136    @property
137    def position(self):
138        """
139        Return the position of the basepoint of the optical element. Often it is the same as the optical centre.
140        This position is the one around which all rotations are performed.
141        """
142        return self.r0 + self.r

Return the position of the basepoint of the optical element. Often it is the same as the optical centre. This position is the one around which all rotations are performed.

orientation
144    @property
145    def orientation(self):
146        """
147        Return the orientation of the optical element.
148        The utility of this method is unclear.
149        """
150        return self.q

Return the orientation of the optical element. The utility of this method is unclear.

basis
152    @property
153    def basis(self):
154        return self.r0, self.r, self.q
r0
@abstractmethod
def propagate_raylist(self, RayList, alignment=False):
161    @abstractmethod
162    def propagate_raylist(self, RayList, alignment=False):
163        """
164        This method is used to propagate a list of rays through the optical element. Implement this method in the subclasses.
165        In the case of a mirror, the method should reflect the rays off the mirror surface.
166        In the case of a mask, the method should block the rays that hit the mask.
167        In the case of a grating, the method should diffract the rays according to the grating equation.
168        """
169        pass

This method is used to propagate a list of rays through the optical element. Implement this method in the subclasses. In the case of a mirror, the method should reflect the rays off the mirror surface. In the case of a mask, the method should block the rays that hit the mask. In the case of a grating, the method should diffract the rays according to the grating equation.

def add_global_points(self, *args):
171    def add_global_points(self, *args):
172        """
173        Automatically add global properties for point attributes.
174        As arguments it takes the names of the global point attributes and seeks for the corresponding local point attributes.
175        Then it creates a property for the global point attribute.
176
177        Example:
178        Suppose the optical element has an attribute 'center_ref' that is the center of the optical element in the optic's coordinate frame.
179        Then, calling object.add_global_points('center') will create a property 'center' that returns the global position of the center of the optical element.
180        It takes into account the position and orientation of the optical element.
181        """
182        for arg in args:
183            local_attr = f"{arg}_ref"
184            if hasattr(self, local_attr):
185                # Dynamically define a property for the global point
186                setattr(self.__class__, arg, property(self._create_global_point_property(local_attr)))

Automatically add global properties for point attributes. As arguments it takes the names of the global point attributes and seeks for the corresponding local point attributes. Then it creates a property for the global point attribute.

Example: Suppose the optical element has an attribute 'center_ref' that is the center of the optical element in the optic's coordinate frame. Then, calling object.add_global_points('center') will create a property 'center' that returns the global position of the center of the optical element. It takes into account the position and orientation of the optical element.

def add_global_vectors(self, *args):
188    def add_global_vectors(self, *args):
189        """
190        Automatically add global properties for vector attributes.
191        As arguments it takes the names of the global vector attributes and seeks for the corresponding local vector attributes.
192        Then it creates a property for the global vector attribute.
193
194        Example:
195        Suppose the optical element has an attribute 'normal_ref' that is the normal of the optical element in the optic's coordinate frame.
196        Then, calling object.add_global_vectors('normal') will create a property 'normal' that returns the global normal of the optical element.
197        """
198        for arg in args:
199            local_attr = f"{arg}_ref"
200            if hasattr(self, local_attr):
201                # Dynamically define a property for the global vector
202                setattr(self.__class__, arg, property(self._create_global_vector_property(local_attr)))

Automatically add global properties for vector attributes. As arguments it takes the names of the global vector attributes and seeks for the corresponding local vector attributes. Then it creates a property for the global vector attribute.

Example: Suppose the optical element has an attribute 'normal_ref' that is the normal of the optical element in the optic's coordinate frame. Then, calling object.add_global_vectors('normal') will create a property 'normal' that returns the global normal of the optical element.

def rotate_pitch_by(self, angle):
228    def rotate_pitch_by(self, angle):
229        """
230        Pitch rotation, i.e. rotates the optical element about the axis ('normal' x 'majoraxis'), by the
231        given angle.
232        If the plane spanned by 'normal' and 'majoraxis' is the incidence plane (normally the case
233        in a "clean alignment" situation for pure p or s polarization), then this is simply a modificaiton
234        of the incidence angle by "angle". But in general, if the optical element has some odd orientation,
235        there is not a direct correspondence.
236
237        Parameters
238        ----------
239            angle : float
240                Rotation angle in *degrees*.
241        """
242        rotation_axis = np.cross(self.support_normal, self.majoraxis)
243        self.q = mgeo.QRotationAroundAxis(rotation_axis, np.deg2rad(angle))*self.q

Pitch rotation, i.e. rotates the optical element about the axis ('normal' x 'majoraxis'), by the given angle. If the plane spanned by 'normal' and 'majoraxis' is the incidence plane (normally the case in a "clean alignment" situation for pure p or s polarization), then this is simply a modificaiton of the incidence angle by "angle". But in general, if the optical element has some odd orientation, there is not a direct correspondence.

Parameters

angle : float
    Rotation angle in *degrees*.
def rotate_roll_by(self, angle):
245    def rotate_roll_by(self, angle):
246        """
247        Roll rotation, i.e. rotates the optical element about its 'majoraxis' by the given angle.
248
249        Parameters
250        ----------
251            angle : float
252                Rotation angle in *degrees*.
253        """
254
255        self.q = mgeo.QRotationAroundAxis(self.majoraxis, np.deg2rad(angle))*self.q

Roll rotation, i.e. rotates the optical element about its 'majoraxis' by the given angle.

Parameters

angle : float
    Rotation angle in *degrees*.
def rotate_yaw_by(self, angle):
257    def rotate_yaw_by(self, angle):
258        """
259        Yaw rotation, i.e. rotates the optical element about its 'normal' by the given angle.
260
261        Parameters
262        ----------
263            angle : float
264                Rotation angle in *degrees*.
265        """
266        self.q = mgeo.QRotationAroundAxis(self.support_normal, np.deg2rad(angle))*self.q

Yaw rotation, i.e. rotates the optical element about its 'normal' by the given angle.

Parameters

angle : float
    Rotation angle in *degrees*.
def rotate_random_by(self, angle):
268    def rotate_random_by(self, angle):
269        """
270        Rotates the optical element about a randomly oriented axis by the given angle.
271
272        Parameters
273        ----------
274            angle : float
275                Rotation angle in *degrees*.
276        """
277
278        self.q = mgeo.QRotationAroundAxis(np.random.random(3), np.deg2rad(angle))*self.q

Rotates the optical element about a randomly oriented axis by the given angle.

Parameters

angle : float
    Rotation angle in *degrees*.
def shift_along_normal(self, distance):
280    def shift_along_normal(self, distance):
281        """
282        Shifts the optical element along its 'normal' by the given distance.
283
284        Parameters
285        ----------
286            distance : float
287                Shift distance in mm.
288        """
289        self.r = self.r +  distance * self.support_normal

Shifts the optical element along its 'normal' by the given distance.

Parameters

distance : float
    Shift distance in mm.
def shift_along_major(self, distance):
291    def shift_along_major(self, distance):
292        """
293        Shifts the optical element along its 'majoraxis' by the given distance.
294
295        Parameters
296        ----------
297            distance : float
298                Shift distance in mm.
299        """
300        self.r = self.r +  distance * self.majoraxis

Shifts the optical element along its 'majoraxis' by the given distance.

Parameters

distance : float
    Shift distance in mm.
def shift_along_cross(self, distance):
302    def shift_along_cross(self, distance):
303        """
304        Shifts the optical element along the axis 'normal'x'majoraxis'
305        (typically normal to the light incidence plane) by the given distance.
306
307        Parameters
308        ----------
309            distance : float
310                Shift distance in mm.
311        """
312        self.r = self.r +  distance * mgeo.Normalize(np.cross(self.support_normal, self.majoraxis))

Shifts the optical element along the axis 'normal'x'majoraxis' (typically normal to the light incidence plane) by the given distance.

Parameters

distance : float
    Shift distance in mm.
def shift_along_random(self, distance):
314    def shift_along_random(self, distance):
315        """
316        Shifts the optical element along a random direction by the given distance.
317
318        Parameters
319        ----------
320            distance : float
321                Shift distance in mm.
322        """
323        self.r = self.r + distance * mgeo.Normalize(np.random.random(3))

Shifts the optical element along a random direction by the given distance.

Parameters

distance : float
    Shift distance in mm.

8 - ModuleOpticalRay

Created in Apr 2020

@author: Anthony Guillaume + Stefan Haessler + André Kalouguine

  1"""
  2Created in Apr 2020
  3
  4@author: Anthony Guillaume + Stefan Haessler + André Kalouguine
  5"""
  6# %% Modules
  7
  8import numpy as np
  9import logging
 10import ARTcore.ModuleGeometry as mgeo
 11logger = logging.getLogger(__name__)
 12import time
 13from dataclasses import dataclass, replace
 14
 15# %% Ray class definition
 16
 17@dataclass(slots=True)
 18class Ray:
 19    """
 20    Represents an optical ray from geometrical optics.
 21    In order to optimize lookups, Rays are represented as dataclasses.
 22    The mandatory attributes are:
 23    - point: mgeo.Point
 24    - vector: mgeo.Vector
 25    """
 26    point: mgeo.Point
 27    vector: mgeo.Vector
 28    path: tuple = (0.0,)
 29    number: int = None
 30    wavelength: float = None
 31    incidence: float = None
 32    intensity: float = None
 33
 34    def to_basis(self, r0, r, q):
 35        """
 36        Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.
 37        """
 38        return Ray(
 39            self.point.to_basis(r0, r, q),
 40            self.vector.to_basis(r0, r, q),
 41            self.path,
 42            self.number,
 43            self.wavelength,
 44            self.incidence,
 45            self.intensity,
 46        )
 47    
 48    def from_basis(self, r0, r, q):
 49        """
 50        Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.
 51        """
 52        return Ray(
 53            self.point.from_basis(r0, r, q),
 54            self.vector.from_basis(r0, r, q),
 55            self.path,
 56            self.number,
 57            self.wavelength,
 58            self.incidence,
 59            self.intensity,
 60        )
 61
 62    def rotate(self, q):
 63        """
 64        Rotates the ray by the quaternion q.
 65        """
 66        return Ray(
 67            self.point.rotate(q),
 68            self.vector.rotate(q),
 69            self.path,
 70            self.number,
 71            self.wavelength,
 72            self.incidence,
 73            self.intensity,
 74        )
 75    
 76    def translate(self, t):
 77        """
 78        Rotates the ray by the vector t
 79        """
 80        return Ray(
 81            self.point.translate(t),
 82            self.vector,
 83            self.path,
 84            self.number,
 85            self.wavelength,
 86            self.incidence,
 87            self.intensity,
 88        )
 89
 90    def __copy__(self):
 91        """
 92        Returns a new OpticalRay object with the same properties.
 93        """
 94        return replace(self)
 95
 96    def __hash__(self):
 97        point_tuple = tuple(self.point.reshape(1, -1)[0])
 98        vector_tuple = tuple(self.vector.reshape(1, -1)[0])
 99        return hash(
100            point_tuple + vector_tuple + (self.path, self.number, self.wavelength, self.incidence, self.intensity)
101        )
102
103
104@dataclass(slots=True)
105class RayList:
106    """
107    Class representing a list of rays in a form that is well suited to calculations.
108    Specifically, the points, vectors etc are all numpy arrays
109    It can then be converted to a list of Ray objects with the method 
110    """
111    point: mgeo.PointArray
112    vector: mgeo.VectorArray
113    path: np.ndarray
114    number: np.ndarray
115    wavelength: np.ndarray
116    incidence: np.ndarray
117    intensity: np.ndarray
118    N: int = None
119
120    @classmethod
121    def from_list(cls, rays):
122        N = len(rays)
123        if N == 0:
124            return cls(
125                mgeo.PointArray([[np.nan, np.nan, np.nan]]),
126                mgeo.VectorArray([[np.nan,np.nan,np.nan]]),
127                np.zeros((1,1)),
128                np.zeros(1),
129                np.zeros(1),
130                np.zeros(1),
131                np.zeros(1),
132                0,
133            )
134        N_r = len(rays[0].path)
135        points = np.zeros((N, 3))
136        vectors = np.zeros((N, 3))
137        paths = np.zeros((N, N_r))
138        numbers = np.zeros(N, dtype=int)
139        wavelengths = np.zeros(N)
140        incidences = np.zeros(N)
141        intensities = np.zeros(N)
142        for i, ray in enumerate(rays):
143            points[i:,] = ray.point
144            vectors[i:,] = ray.vector
145            paths[i:,] = ray.path
146            numbers[i] = ray.number
147            wavelengths[i] = ray.wavelength
148            incidences[i] = ray.incidence
149            intensities[i] = ray.intensity
150        return cls(
151            mgeo.PointArray(points),
152            mgeo.VectorArray(vectors),
153            paths,
154            numbers,
155            wavelengths,
156            incidences,
157            intensities,
158            len(rays),
159        )
160    
161    def __getitem__(self, key):
162        if isinstance(key, slice) or isinstance(key, list):
163            return RayList(
164                self.point[key],
165                self.vector[key],
166                self.path[key],
167                self.number[key],
168                self.wavelength[key],
169                self.incidence[key],
170                self.intensity[key],
171                len(key),
172            )
173        return Ray(
174            self.point[key],
175            self.vector[key],
176            tuple(self.path[key]),
177            self.number[key],
178            self.wavelength[key],
179            self.incidence[key],
180            self.intensity[key],
181        )
182    
183    def __len__(self):
184        return self.N
185    
186    def __iter__(self):
187        if self.N == 0:
188            return iter([])
189        return (Ray(point=self.point[i],
190                    vector=self.vector[i],
191                    path=tuple(self.path[i]),
192                    number=int(self.number[i]),
193                    wavelength=float(self.wavelength[i]),
194                    incidence=float(self.incidence[i]),
195                    intensity=float(self.intensity[i])) for i in range(self.N))
196
197    def to_basis(self, r0, r, q):
198        """
199        Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.
200        """
201        return RayList(
202            self.point.to_basis(r0, r, q),
203            self.vector.to_basis(r0, r, q),
204            self.path,
205            self.number,
206            self.wavelength,
207            self.incidence,
208            self.intensity,
209            self.N
210        )
211    
212    def from_basis(self, r0, r, q):
213        """
214        Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.
215        """
216        return RayList(
217            self.point.from_basis(r0, r, q),
218            self.vector.from_basis(r0, r, q),
219            self.path,
220            self.number,
221            self.wavelength,
222            self.incidence,
223            self.intensity,
224            self.N
225        )
226
227    def rotate(self, q):
228        """
229        Rotates the raylist by the quaternion q.
230        """
231        return RayList(
232            self.point.rotate(q),
233            self.vector.rotate(q),
234            self.path,
235            self.number,
236            self.wavelength,
237            self.incidence,
238            self.intensity,
239            self.N
240        )
241    
242    def translate(self, t):
243        """
244        Rotates the ray by the vector t
245        """
246        return RayList(
247            self.point.translate(t),
248            self.vector,
249            self.path,
250            self.number,
251            self.wavelength,
252            self.incidence,
253            self.intensity,
254            self.N
255        )
256
257    def __copy__(self):
258        """
259        Returns a new OpticalRay object with the same properties.
260        """
261        return RayList(
262            self.point.copy(),
263            self.vector.copy(),
264            self.path.copy(),
265            self.number.copy(),
266            self.wavelength.copy(),
267            self.incidence.copy(),
268            self.intensity.copy(),
269            self.N,
270        )
271
272    def __hash__(self):
273        points = self.point.reshape(1, -1)[0]
274        vectors = self.vector.reshape(1, -1)[0]
275        paths = self.path.reshape(1, -1)[0]
276        result = np.concatenate((points, vectors, paths, self.number, self.wavelength, self.incidence, self.intensity))
277        result = result[~np.isnan(result)]
278        result.flags.writeable = False
279        result_hash = hash(result.data.tobytes())
280        return result_hash
281
282    def __repr__(self) -> str:
283        intro = f"RayList with {self.N} rays\n"
284        average = mgeo.Vector(np.mean(self.vector)).normalized()
285        vectors = self.vector.normalized()
286        angles = np.arccos(np.clip(np.dot(vectors, average), -1.0, 1.0))
287        avg_string = "On average, the rays are oriented along the vector:\n" + str(average) + "\n"
288        NA = f"Numerical aperture: {np.max(np.sin(angles)):.3f}\n"
289        return intro +avg_string+ NA
logger = <Logger ARTcore.ModuleOpticalRay (WARNING)>
@dataclass(slots=True)
class Ray:
 18@dataclass(slots=True)
 19class Ray:
 20    """
 21    Represents an optical ray from geometrical optics.
 22    In order to optimize lookups, Rays are represented as dataclasses.
 23    The mandatory attributes are:
 24    - point: mgeo.Point
 25    - vector: mgeo.Vector
 26    """
 27    point: mgeo.Point
 28    vector: mgeo.Vector
 29    path: tuple = (0.0,)
 30    number: int = None
 31    wavelength: float = None
 32    incidence: float = None
 33    intensity: float = None
 34
 35    def to_basis(self, r0, r, q):
 36        """
 37        Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.
 38        """
 39        return Ray(
 40            self.point.to_basis(r0, r, q),
 41            self.vector.to_basis(r0, r, q),
 42            self.path,
 43            self.number,
 44            self.wavelength,
 45            self.incidence,
 46            self.intensity,
 47        )
 48    
 49    def from_basis(self, r0, r, q):
 50        """
 51        Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.
 52        """
 53        return Ray(
 54            self.point.from_basis(r0, r, q),
 55            self.vector.from_basis(r0, r, q),
 56            self.path,
 57            self.number,
 58            self.wavelength,
 59            self.incidence,
 60            self.intensity,
 61        )
 62
 63    def rotate(self, q):
 64        """
 65        Rotates the ray by the quaternion q.
 66        """
 67        return Ray(
 68            self.point.rotate(q),
 69            self.vector.rotate(q),
 70            self.path,
 71            self.number,
 72            self.wavelength,
 73            self.incidence,
 74            self.intensity,
 75        )
 76    
 77    def translate(self, t):
 78        """
 79        Rotates the ray by the vector t
 80        """
 81        return Ray(
 82            self.point.translate(t),
 83            self.vector,
 84            self.path,
 85            self.number,
 86            self.wavelength,
 87            self.incidence,
 88            self.intensity,
 89        )
 90
 91    def __copy__(self):
 92        """
 93        Returns a new OpticalRay object with the same properties.
 94        """
 95        return replace(self)
 96
 97    def __hash__(self):
 98        point_tuple = tuple(self.point.reshape(1, -1)[0])
 99        vector_tuple = tuple(self.vector.reshape(1, -1)[0])
100        return hash(
101            point_tuple + vector_tuple + (self.path, self.number, self.wavelength, self.incidence, self.intensity)
102        )

Represents an optical ray from geometrical optics. In order to optimize lookups, Rays are represented as dataclasses. The mandatory attributes are:

  • point: mgeo.Point
  • vector: mgeo.Vector
Ray( point: ARTcore.ModuleGeometry.Point, vector: ARTcore.ModuleGeometry.Vector, path: tuple = (0.0,), number: int = None, wavelength: float = None, incidence: float = None, intensity: float = None)
path: tuple
number: int
wavelength: float
incidence: float
intensity: float
def to_basis(self, r0, r, q):
35    def to_basis(self, r0, r, q):
36        """
37        Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.
38        """
39        return Ray(
40            self.point.to_basis(r0, r, q),
41            self.vector.to_basis(r0, r, q),
42            self.path,
43            self.number,
44            self.wavelength,
45            self.incidence,
46            self.intensity,
47        )

Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.

def from_basis(self, r0, r, q):
49    def from_basis(self, r0, r, q):
50        """
51        Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.
52        """
53        return Ray(
54            self.point.from_basis(r0, r, q),
55            self.vector.from_basis(r0, r, q),
56            self.path,
57            self.number,
58            self.wavelength,
59            self.incidence,
60            self.intensity,
61        )

Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.

def rotate(self, q):
63    def rotate(self, q):
64        """
65        Rotates the ray by the quaternion q.
66        """
67        return Ray(
68            self.point.rotate(q),
69            self.vector.rotate(q),
70            self.path,
71            self.number,
72            self.wavelength,
73            self.incidence,
74            self.intensity,
75        )

Rotates the ray by the quaternion q.

def translate(self, t):
77    def translate(self, t):
78        """
79        Rotates the ray by the vector t
80        """
81        return Ray(
82            self.point.translate(t),
83            self.vector,
84            self.path,
85            self.number,
86            self.wavelength,
87            self.incidence,
88            self.intensity,
89        )

Rotates the ray by the vector t

@dataclass(slots=True)
class RayList:
105@dataclass(slots=True)
106class RayList:
107    """
108    Class representing a list of rays in a form that is well suited to calculations.
109    Specifically, the points, vectors etc are all numpy arrays
110    It can then be converted to a list of Ray objects with the method 
111    """
112    point: mgeo.PointArray
113    vector: mgeo.VectorArray
114    path: np.ndarray
115    number: np.ndarray
116    wavelength: np.ndarray
117    incidence: np.ndarray
118    intensity: np.ndarray
119    N: int = None
120
121    @classmethod
122    def from_list(cls, rays):
123        N = len(rays)
124        if N == 0:
125            return cls(
126                mgeo.PointArray([[np.nan, np.nan, np.nan]]),
127                mgeo.VectorArray([[np.nan,np.nan,np.nan]]),
128                np.zeros((1,1)),
129                np.zeros(1),
130                np.zeros(1),
131                np.zeros(1),
132                np.zeros(1),
133                0,
134            )
135        N_r = len(rays[0].path)
136        points = np.zeros((N, 3))
137        vectors = np.zeros((N, 3))
138        paths = np.zeros((N, N_r))
139        numbers = np.zeros(N, dtype=int)
140        wavelengths = np.zeros(N)
141        incidences = np.zeros(N)
142        intensities = np.zeros(N)
143        for i, ray in enumerate(rays):
144            points[i:,] = ray.point
145            vectors[i:,] = ray.vector
146            paths[i:,] = ray.path
147            numbers[i] = ray.number
148            wavelengths[i] = ray.wavelength
149            incidences[i] = ray.incidence
150            intensities[i] = ray.intensity
151        return cls(
152            mgeo.PointArray(points),
153            mgeo.VectorArray(vectors),
154            paths,
155            numbers,
156            wavelengths,
157            incidences,
158            intensities,
159            len(rays),
160        )
161    
162    def __getitem__(self, key):
163        if isinstance(key, slice) or isinstance(key, list):
164            return RayList(
165                self.point[key],
166                self.vector[key],
167                self.path[key],
168                self.number[key],
169                self.wavelength[key],
170                self.incidence[key],
171                self.intensity[key],
172                len(key),
173            )
174        return Ray(
175            self.point[key],
176            self.vector[key],
177            tuple(self.path[key]),
178            self.number[key],
179            self.wavelength[key],
180            self.incidence[key],
181            self.intensity[key],
182        )
183    
184    def __len__(self):
185        return self.N
186    
187    def __iter__(self):
188        if self.N == 0:
189            return iter([])
190        return (Ray(point=self.point[i],
191                    vector=self.vector[i],
192                    path=tuple(self.path[i]),
193                    number=int(self.number[i]),
194                    wavelength=float(self.wavelength[i]),
195                    incidence=float(self.incidence[i]),
196                    intensity=float(self.intensity[i])) for i in range(self.N))
197
198    def to_basis(self, r0, r, q):
199        """
200        Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.
201        """
202        return RayList(
203            self.point.to_basis(r0, r, q),
204            self.vector.to_basis(r0, r, q),
205            self.path,
206            self.number,
207            self.wavelength,
208            self.incidence,
209            self.intensity,
210            self.N
211        )
212    
213    def from_basis(self, r0, r, q):
214        """
215        Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.
216        """
217        return RayList(
218            self.point.from_basis(r0, r, q),
219            self.vector.from_basis(r0, r, q),
220            self.path,
221            self.number,
222            self.wavelength,
223            self.incidence,
224            self.intensity,
225            self.N
226        )
227
228    def rotate(self, q):
229        """
230        Rotates the raylist by the quaternion q.
231        """
232        return RayList(
233            self.point.rotate(q),
234            self.vector.rotate(q),
235            self.path,
236            self.number,
237            self.wavelength,
238            self.incidence,
239            self.intensity,
240            self.N
241        )
242    
243    def translate(self, t):
244        """
245        Rotates the ray by the vector t
246        """
247        return RayList(
248            self.point.translate(t),
249            self.vector,
250            self.path,
251            self.number,
252            self.wavelength,
253            self.incidence,
254            self.intensity,
255            self.N
256        )
257
258    def __copy__(self):
259        """
260        Returns a new OpticalRay object with the same properties.
261        """
262        return RayList(
263            self.point.copy(),
264            self.vector.copy(),
265            self.path.copy(),
266            self.number.copy(),
267            self.wavelength.copy(),
268            self.incidence.copy(),
269            self.intensity.copy(),
270            self.N,
271        )
272
273    def __hash__(self):
274        points = self.point.reshape(1, -1)[0]
275        vectors = self.vector.reshape(1, -1)[0]
276        paths = self.path.reshape(1, -1)[0]
277        result = np.concatenate((points, vectors, paths, self.number, self.wavelength, self.incidence, self.intensity))
278        result = result[~np.isnan(result)]
279        result.flags.writeable = False
280        result_hash = hash(result.data.tobytes())
281        return result_hash
282
283    def __repr__(self) -> str:
284        intro = f"RayList with {self.N} rays\n"
285        average = mgeo.Vector(np.mean(self.vector)).normalized()
286        vectors = self.vector.normalized()
287        angles = np.arccos(np.clip(np.dot(vectors, average), -1.0, 1.0))
288        avg_string = "On average, the rays are oriented along the vector:\n" + str(average) + "\n"
289        NA = f"Numerical aperture: {np.max(np.sin(angles)):.3f}\n"
290        return intro +avg_string+ NA

Class representing a list of rays in a form that is well suited to calculations. Specifically, the points, vectors etc are all numpy arrays It can then be converted to a list of Ray objects with the method

RayList( point: ARTcore.ModuleGeometry.PointArray, vector: ARTcore.ModuleGeometry.VectorArray, path: numpy.ndarray, number: numpy.ndarray, wavelength: numpy.ndarray, incidence: numpy.ndarray, intensity: numpy.ndarray, N: int = None)
path: numpy.ndarray
number: numpy.ndarray
wavelength: numpy.ndarray
incidence: numpy.ndarray
intensity: numpy.ndarray
N: int
@classmethod
def from_list(cls, rays):
121    @classmethod
122    def from_list(cls, rays):
123        N = len(rays)
124        if N == 0:
125            return cls(
126                mgeo.PointArray([[np.nan, np.nan, np.nan]]),
127                mgeo.VectorArray([[np.nan,np.nan,np.nan]]),
128                np.zeros((1,1)),
129                np.zeros(1),
130                np.zeros(1),
131                np.zeros(1),
132                np.zeros(1),
133                0,
134            )
135        N_r = len(rays[0].path)
136        points = np.zeros((N, 3))
137        vectors = np.zeros((N, 3))
138        paths = np.zeros((N, N_r))
139        numbers = np.zeros(N, dtype=int)
140        wavelengths = np.zeros(N)
141        incidences = np.zeros(N)
142        intensities = np.zeros(N)
143        for i, ray in enumerate(rays):
144            points[i:,] = ray.point
145            vectors[i:,] = ray.vector
146            paths[i:,] = ray.path
147            numbers[i] = ray.number
148            wavelengths[i] = ray.wavelength
149            incidences[i] = ray.incidence
150            intensities[i] = ray.intensity
151        return cls(
152            mgeo.PointArray(points),
153            mgeo.VectorArray(vectors),
154            paths,
155            numbers,
156            wavelengths,
157            incidences,
158            intensities,
159            len(rays),
160        )
def to_basis(self, r0, r, q):
198    def to_basis(self, r0, r, q):
199        """
200        Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.
201        """
202        return RayList(
203            self.point.to_basis(r0, r, q),
204            self.vector.to_basis(r0, r, q),
205            self.path,
206            self.number,
207            self.wavelength,
208            self.incidence,
209            self.intensity,
210            self.N
211        )

Transforms the ray to the basis defined by the origin r0, the x-axis r and the y-axis q.

def from_basis(self, r0, r, q):
213    def from_basis(self, r0, r, q):
214        """
215        Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.
216        """
217        return RayList(
218            self.point.from_basis(r0, r, q),
219            self.vector.from_basis(r0, r, q),
220            self.path,
221            self.number,
222            self.wavelength,
223            self.incidence,
224            self.intensity,
225            self.N
226        )

Transforms the ray from the basis defined by the origin r0, the x-axis r and the y-axis q.

def rotate(self, q):
228    def rotate(self, q):
229        """
230        Rotates the raylist by the quaternion q.
231        """
232        return RayList(
233            self.point.rotate(q),
234            self.vector.rotate(q),
235            self.path,
236            self.number,
237            self.wavelength,
238            self.incidence,
239            self.intensity,
240            self.N
241        )

Rotates the raylist by the quaternion q.

def translate(self, t):
243    def translate(self, t):
244        """
245        Rotates the ray by the vector t
246        """
247        return RayList(
248            self.point.translate(t),
249            self.vector,
250            self.path,
251            self.number,
252            self.wavelength,
253            self.incidence,
254            self.intensity,
255            self.N
256        )

Rotates the ray by the vector t

9 - ModuleProcessing

Contains processing functions used for the ray-tracing and automated (pre-)alignment of the optical chains. Usually these don't need to be called by users of ART, but they may be useful.

Created in Apr 2020

@author: Anthony Guillaume + Stefan Haessler

  1"""
  2Contains processing functions used for the ray-tracing and automated (pre-)alignment of the optical chains.
  3Usually these don't need to be called by users of ART, but they may be useful.
  4
  5
  6
  7Created in Apr 2020
  8
  9@author: Anthony Guillaume + Stefan Haessler
 10"""
 11# %% Modules
 12import ARTcore.ModuleGeometry as mgeo
 13from ARTcore.ModuleGeometry import Point, Vector, Origin
 14import ARTcore.ModuleMirror as mmirror
 15import ARTcore.ModuleMask as mmask
 16import ARTcore.ModuleSource as msource
 17import ARTcore.ModuleOpticalElement as moe
 18import ARTcore.ModuleOpticalRay as mray
 19import ARTcore.ModuleSupport as msupp
 20import ARTcore.ModuleOpticalChain as moc
 21
 22import os
 23import pickle
 24
 25# import gzip
 26import lzma
 27from time import perf_counter
 28from datetime import datetime
 29from copy import copy
 30import numpy as np
 31import quaternion
 32import logging
 33
 34logger = logging.getLogger(__name__)
 35
 36
 37# %%
 38def singleOEPlacement(
 39    Optic: moe.OpticalElement,
 40    Distance: float,
 41    IncidenceAngle: float = 0,
 42    IncidencePlaneAngle: float = 0,
 43    InputRay: mray.Ray = None,
 44    AlignmentVector: str = "",
 45    PreviousIncidencePlane: mgeo.Vector = mgeo.Vector([0, 1, 0])
 46):
 47    """
 48    Automatic placement and alignment of a single optical element.
 49    The arguments are:
 50    - The optic to be placed.
 51    - The distance from the previous optic.
 52    - An incidence angle.
 53    - An incidence plane angle.
 54    - An input ray.
 55    - An alignment vector.
 56
 57    The alignment procedure is as follows:
 58    - The optic is initially placed with its center at the source position (origin point of previous master ray). 
 59    - It's oriented such that the alignment vector is antiparallel to the master ray. By default, the alignment vector is the support_normal.
 60    - The majoraxis of the optic is aligned with the incidence plane. 
 61    - The optic is rotated around the incidence plane by the incidence angle.
 62    - The optic is rotated around the master ray by the incidence plane angle.
 63    - The optic is translated along the master ray by the distance from the previous optic.
 64    - The master ray is propagated through the optic without any blocking or diffraction effects and the output ray is used as the master ray for the next optic.
 65    """
 66    # Convert angles to radian and wrap to 2pi
 67    IncidencePlaneAngle = np.deg2rad(IncidencePlaneAngle) % (2 * np.pi)
 68    IncidenceAngle = np.deg2rad(IncidenceAngle) % (2 * np.pi)
 69    assert InputRay is not None, "InputRay must be provided"
 70    OldOpticalElementCentre = InputRay.point
 71    MasterRayDirection = InputRay.vector.normalized()
 72    OpticalElementCentre = OldOpticalElementCentre + MasterRayDirection * Distance
 73
 74    logger.debug(f"Old Optical Element Centre: {OldOpticalElementCentre}")
 75    logger.debug(f"Master Ray: {InputRay}")
 76    logger.debug(f"Optical Element Centre: {OpticalElementCentre}")
 77    # for convex mirrors, rotated them by 180° while keeping same incidence plane so we reflect from the "back side"
 78    if Optic.curvature == mmirror.Curvature.CONVEX:
 79        IncidenceAngle = np.pi - IncidenceAngle
 80
 81    if hasattr(Optic, AlignmentVector):
 82        OpticVector = getattr(Optic, AlignmentVector)
 83    else:
 84        logger.warning(f"Optical Element {Optic} does not have an attribute {AlignmentVector}. Using support_normal instead.")
 85        OpticVector = Optic.support_normal_ref
 86    
 87    MajorAxis = Optic.majoraxis_ref
 88
 89    IncidencePlane = PreviousIncidencePlane.rotate(mgeo.QRotationAroundAxis(InputRay.vector, -IncidencePlaneAngle))
 90    # We calculate a quaternion that will rotate OpticVector against the master ray and MajorAxis into the incidence plane
 91    # The convention is that the MajorAxis vector points right if seen from the source with IncidencePlane pointing up
 92    # To do that, we first calculate a vector MinorAxis in the reference frame of the optic that is orthogonal to both OpticVector and MajorAxis
 93    # Then we calculate a rotation matrix that will:
 94    #   - rotate MinorAxis into the IncidencePlane direction
 95    #   - rotate OpticVector against the master ray direction
 96    #   - rotate MajorAxis into the direction of the cross product of the two previous vectors
 97    MinorAxis = -np.cross(OpticVector, MajorAxis)
 98    qIncidenceAngle = mgeo.QRotationAroundAxis(IncidencePlane, -IncidenceAngle)
 99    q = mgeo.QRotationVectorPair2VectorPair(MinorAxis, IncidencePlane, OpticVector, -MasterRayDirection)
100    q = qIncidenceAngle*q
101    Optic.q = q
102    logger.debug(f"Optic: {Optic}")
103    logger.debug(f"OpticVector: {OpticVector}")
104    logger.debug(f"Rotated OpticVector: {OpticVector.rotate(q)}")
105    logger.debug(f"MasterRayDirection: {MasterRayDirection}")
106
107    Optic.r = Origin + (OpticalElementCentre - Optic.centre - Optic.r)
108
109    NextRay = Optic.propagate_raylist(mray.RayList.from_list([InputRay]), alignment=True)[0]
110    return NextRay, IncidencePlane
111
112
113def OEPlacement(
114    OpticsList: list,
115    InitialRay:mray.Ray = None,
116    Source: msource.Source = None,
117    InputIncidencePlane: mgeo.Vector = None
118):
119    """
120    Automatic placement and alignment of the optical elements for one optical chain.
121    Returns a list of optical elements (copied)
122    """
123    if InitialRay is None:
124        if Source is None:
125            InputRay = InputRay = mray.Ray(
126            point=mgeo.Point([0, 0, 0]), 
127            vector=mgeo.Vector([1, 0, 0]),
128            path=(0.0,),
129            number=0,
130            wavelength=800e-9,
131            incidence=0.0,
132            intensity=1.0
133    )
134        else:
135            InputRay = Source.get_master_ray()
136    else:
137        InputRay = InitialRay.copy_ray()
138    if InputIncidencePlane is None:
139        InputIncidencePlane = mgeo.Vector([0, 1, 0])
140    logger.debug(f"Initial Ray: {InputRay}")
141    logger.debug(f"Initial Incidence Plane: {InputIncidencePlane}")
142    assert np.linalg.norm(np.dot(InputRay.vector, InputIncidencePlane))<1e-6, "InputIncidencePlane is not orthogonal to InputRay.vector"
143    PreviousIncidencePlane = InputIncidencePlane.copy()
144    OEList = []
145    for i in range(len(OpticsList)):
146        OEList.append(copy(OpticsList[i]["OpticalElement"]))
147        InputRay, PreviousIncidencePlane = singleOEPlacement(
148            OEList[-1],
149            OpticsList[i]["Distance"],
150            OpticsList[i]["IncidenceAngle"],
151            OpticsList[i]["IncidencePlaneAngle"],
152            InputRay,
153            OpticsList[i]["Alignment"] if "Alignment" in OpticsList[i] else "support_normal",
154            PreviousIncidencePlane
155        )
156    return OEList
157    
158
159
160# %%
161def RayTracingCalculation(
162    source_rays: mray.RayList, optical_elements: list[moe.OpticalElement], **kwargs
163) -> list[list[mray.Ray]]:
164    """
165    The actual ray-tracing calculation, starting from the list of 'source_rays',
166    and propagating them from one optical element to the next in the order of the
167    items of the list 'optical_elements'.
168
169
170    Parameters
171    ----------
172        source_rays : list[Ray-objects]
173            List of input rays.
174
175        optical_elements : list[OpticalElement-objects]
176
177
178    Returns
179    -------
180        output_rays : list[list[Ray-objects]]
181            List of lists of rays, each item corresponding to the ray-bundle *after*
182            the item with the same index in the 'optical_elements'-list.
183    """
184    output_rays = []
185
186    for k in range(0, len(optical_elements)):
187        if k == 0:
188            RayList = source_rays
189        else:
190            RayList = output_rays[k - 1]
191        RayList = optical_elements[k].propagate_raylist(RayList, **kwargs)
192        output_rays.append(RayList)
193
194    return output_rays
195
196
197# %%
198def _FindOptimalDistanceBIS(movingDetector, Amplitude, Step, RayList, OptFor, IntensityWeighted):
199    ListSizeSpot = []
200    ListDuration = []
201    ListFitness = []
202    if IntensityWeighted:
203        Weights = [k.intensity for k in RayList]
204
205    movingDetector.shiftByDistance(-Amplitude)
206    n = int(2 * Amplitude / Step)
207    for i in range(n):
208        ListPointDetector2DCentre = movingDetector.get_PointList2DCentre(RayList)
209        if OptFor in ["intensity", "spotsize"]:
210            if IntensityWeighted:
211                SpotSize = WeightedStandardDeviation(ListPointDetector2DCentre, Weights)
212            else:
213                SpotSize = StandardDeviation(ListPointDetector2DCentre)
214            ListSizeSpot.append(SpotSize)
215
216        if OptFor in ["intensity", "duration"]:
217            DelayList = movingDetector.get_Delays(RayList)
218            if IntensityWeighted:
219                Duration = WeightedStandardDeviation(DelayList, Weights)
220            else:
221                Duration = StandardDeviation(DelayList)
222            ListDuration.append(Duration)
223
224        if OptFor == "intensity":
225            Fitness = SpotSize**2 * Duration
226        elif OptFor == "duration":
227            Fitness = Duration
228        elif OptFor == "spotsize":
229            Fitness = SpotSize
230        ListFitness.append(Fitness)
231
232        movingDetector.shiftByDistance(Step)
233
234    FitnessMin = min(ListFitness)
235    ind = ListFitness.index(FitnessMin)
236    if OptFor in ["intensity", "spotsize"]:
237        OptSizeSpot = ListSizeSpot[ind]
238    else:
239        OptSizeSpot = np.nan
240    if OptFor in ["intensity", "duration"]:
241        OptDuration = ListDuration[ind]
242    else:
243        OptDuration = np.nan
244
245    movingDetector.shiftByDistance(-(n - ind) * Step)
246
247    return movingDetector, OptSizeSpot, OptDuration
248
249
250def FindOptimalDistance(
251    Detector,
252    RayList: list[mray.Ray],
253    OptFor="intensity",
254    Amplitude: float = None,
255    Precision: int = 3,
256    IntensityWeighted=False,
257    verbose=False,
258):
259    """
260    Automatically finds the optimal the 'Detector'-distance for either
261    maximum intensity, or minimal spotsize or duration.
262
263    Parameters
264    ----------
265        Detector : Detector-object
266
267        RayList : list[Ray-objects]
268
269        OptFor : str, optional
270            "spotsize": minimizes the standard-deviation spot size *d* on the detector.
271            "duration": minimizes the standard-deviation *tau* of the ray-delays.
272            "intensity": Maximizes 1/tau/d^2.
273            Defaults to "intensity".
274
275        Amplitude : float, optional
276            The detector-distances within which the optimization is done will be
277            the distance of 'Detector' +/- Amplitude in mm.
278
279        Precision : int, optional
280            Precision-parameter for the search algorithm. For Precision = n,
281            it will iterate with search amplitudes decreasing until 10^{-n}.
282            Defaults to 3.
283
284        IntensityWeighted : bool, optional
285            Whether to weigh the calculation of spotsize and/or duration by the ray-intensities.
286            Defaults to False.
287
288        verbose : bool, optional
289            Whether to print results to the console.
290
291    Returns
292    -------
293        OptDetector : Detector-object
294            A new detector-instance with optimized distance.
295
296        OptSpotSize : float
297            Spotsize, calculated as standard deviation in mm of the spot-diagram
298            on the optimized detector.
299
300        OptDuration : float
301            Duration, calculated as standard deviation in fs of the ray-delays
302            on the optimized detector.
303    """
304
305    if OptFor not in ["intensity", "size", "duration"]:
306        raise NameError(
307            "I don`t recognize what you want to optimize the detector distance for. OptFor must be either 'intensity', 'size' or 'duration'."
308        )
309
310    FirstDistance = Detector.get_distance()
311    ListPointDetector2DCentre = Detector.get_PointList2DCentre(RayList)
312    SizeSpot = 2 * StandardDeviation(ListPointDetector2DCentre)
313    NumericalAperture = ReturnNumericalAperture(RayList, 1)
314    if Amplitude is None:
315        Amplitude = min(4 * np.ceil(SizeSpot / np.tan(np.arcsin(NumericalAperture))), FirstDistance)
316    Step = Amplitude/10
317    #Step = Amplitude / 5  # pretty good in half the time ;-)
318
319    if verbose:
320        print(
321            f"Searching optimal detector position for *{OptFor}* within [{FirstDistance-Amplitude:.3f}, {FirstDistance+Amplitude:.3f}] mm...",
322            end="",
323            flush=True,
324        )
325    movingDetector = Detector.copy_detector()
326    for k in range(Precision + 1):
327        movingDetector, OptSpotSize, OptDuration = _FindOptimalDistanceBIS(
328            movingDetector, Amplitude * 0.1**k, Step * 0.1**k, RayList, OptFor, IntensityWeighted
329        )
330
331    if (
332        not FirstDistance - Amplitude + 10**-Precision
333        < movingDetector.get_distance()
334        < FirstDistance + Amplitude - 10**-Precision
335    ):
336        print("There`s no minimum-size/duration focus in the searched range.")
337
338    print(
339        "\r\033[K", end="", flush=True
340    )  # move to beginning of the line with \r and then delete the whole line with \033[K
341    return movingDetector, OptSpotSize, OptDuration
342
343
344# %%
345def FindCentralRay(RayList: list[mray.Ray]):
346    """
347    Out of a the list of Ray-objects, RayList, determine the average direction
348    vector and source point, and the return a Ray-object representing this 
349    central ray of the RayList.
350    
351    Parameters
352    ----------
353        RayList : list of Ray-objects
354
355    Returns
356    -------
357        CentralRay : Ray-object
358    """
359      
360    CentralVector = np.mean( [x.vector for x in RayList], axis=0)
361    CentralPoint = np.mean( [x.point for x in RayList], axis=0)
362    
363    return mray.Ray(mgeo.Point(CentralPoint), mgeo.Vector(CentralVector))
364
365
366def StandardDeviation(List: list[float, np.ndarray]) -> float:
367    """
368    Return the standard deviation of elements in the supplied list.
369    If the elements are floats, it's simply the root-mean-square over the numbers.
370    If the elements are vectors (represented as numpy-arrays), the root-mean-square
371    of the distance of the points from their mean is calculated.
372
373    Parameters
374    ----------
375        List : list of floats or of np.ndarray
376            Numbers (in ART, typically delays)
377            or 2D or 3D vectors (in ART typically space coordinates)
378
379    Returns
380    -------
381        Std : float
382    """
383    if type(List[0]) in [int, float, np.float64]:
384        return np.std(List)
385    elif len(List[0]) > 1:
386        return np.sqrt(np.var(List, axis=0).sum())
387    else:
388        raise ValueError("StandardDeviation expects a list of floats or numpy-arrays as input, but got something else.")
389
390
391def WeightedStandardDeviation(List: list[float, np.ndarray], Weights: list[float]) -> float:
392    """
393    Return the weighted standard deviation of elements in the supplied list.
394    If the elements are floats, it's simply the root-mean-square over the weighted numbers.
395    If the elements are vectors (represented as numpy-arrays), the root-mean-square
396    of the weighted distances of the points from their mean is calculated.
397
398    Parameters
399    ----------
400        List : list of floats or of np.ndarray
401            Numbers (in ART, typically delays)
402            or 2D or 3D vectors (in ART typically space coordinates)
403
404        Weights : list of floats, same length as List
405            Parameters such as Ray.intensity to be used as weights
406
407    Returns
408    -------
409        Std : float
410    """
411    average = np.average(List, axis=0, weights=Weights, returned=False)
412    variance = np.average((List - average) ** 2, axis=0, weights=Weights, returned=False)
413    return np.sqrt(variance.sum())
414
415
416# %%
417def _hash_list_of_objects(list):
418    """returns a hash value for a list of objects, by just summing up all the individual hashes."""
419    total_hash = 0
420    for x in list:
421        total_hash += hash(x)
422    return total_hash
423
424
425def _which_indeces(lst):
426    """takes an input-list, and gives you a list of the indeces where the input-list contains another list or a numpy-array"""
427    indexes = [i for i, x in enumerate(lst) if isinstance(x, (list, np.ndarray))]
428    return indexes
429
430
431# %%
432def save_compressed(obj, filename: str = None):
433    """Save (=pickle) an object 'obj' to a compressed file with name 'filename'."""
434    if not type(filename) == str:
435        filename = "kept_data_" + datetime.now().strftime("%Y-%m-%d-%Hh%M")
436
437    i = 0
438    while os.path.exists(filename + f"_{i}.xz"):
439        i += 1
440    filename = filename + f"_{i}"
441    # with gzip.open(filename + '.gz', 'wb') as f:
442    with lzma.open(filename + ".xz", "wb") as f:
443        pickle.dump(obj, f)
444    print("Saved results to " + filename + ".xz.")
445    print("->To reload from disk do: kept_data = mp.load_compressed('" + filename + "')")
446
447
448def load_compressed(filename: str):
449    """Load (=unpickle) an object 'obj' from a compressed file with name 'filename'."""
450    # with gzip.open(filename + '.gz', 'rb') as f:
451    with lzma.open(filename + ".xz", "rb") as f:
452        obj = pickle.load(f)
453    return obj
454
455
456# %%  tic-toc timer functions
457_tstart_stack = []
458
459
460def _tic():
461    _tstart_stack.append(perf_counter())
462
463
464def _toc(fmt="Elapsed: %s s"):
465    print(fmt % (perf_counter() - _tstart_stack.pop()))
logger = <Logger ARTcore.ModuleProcessing (WARNING)>
def singleOEPlacement( Optic: ARTcore.ModuleOpticalElement.OpticalElement, Distance: float, IncidenceAngle: float = 0, IncidencePlaneAngle: float = 0, InputRay: ARTcore.ModuleOpticalRay.Ray = None, AlignmentVector: str = '', PreviousIncidencePlane: ARTcore.ModuleGeometry.Vector = Vector([0, 1, 0])):
 39def singleOEPlacement(
 40    Optic: moe.OpticalElement,
 41    Distance: float,
 42    IncidenceAngle: float = 0,
 43    IncidencePlaneAngle: float = 0,
 44    InputRay: mray.Ray = None,
 45    AlignmentVector: str = "",
 46    PreviousIncidencePlane: mgeo.Vector = mgeo.Vector([0, 1, 0])
 47):
 48    """
 49    Automatic placement and alignment of a single optical element.
 50    The arguments are:
 51    - The optic to be placed.
 52    - The distance from the previous optic.
 53    - An incidence angle.
 54    - An incidence plane angle.
 55    - An input ray.
 56    - An alignment vector.
 57
 58    The alignment procedure is as follows:
 59    - The optic is initially placed with its center at the source position (origin point of previous master ray). 
 60    - It's oriented such that the alignment vector is antiparallel to the master ray. By default, the alignment vector is the support_normal.
 61    - The majoraxis of the optic is aligned with the incidence plane. 
 62    - The optic is rotated around the incidence plane by the incidence angle.
 63    - The optic is rotated around the master ray by the incidence plane angle.
 64    - The optic is translated along the master ray by the distance from the previous optic.
 65    - The master ray is propagated through the optic without any blocking or diffraction effects and the output ray is used as the master ray for the next optic.
 66    """
 67    # Convert angles to radian and wrap to 2pi
 68    IncidencePlaneAngle = np.deg2rad(IncidencePlaneAngle) % (2 * np.pi)
 69    IncidenceAngle = np.deg2rad(IncidenceAngle) % (2 * np.pi)
 70    assert InputRay is not None, "InputRay must be provided"
 71    OldOpticalElementCentre = InputRay.point
 72    MasterRayDirection = InputRay.vector.normalized()
 73    OpticalElementCentre = OldOpticalElementCentre + MasterRayDirection * Distance
 74
 75    logger.debug(f"Old Optical Element Centre: {OldOpticalElementCentre}")
 76    logger.debug(f"Master Ray: {InputRay}")
 77    logger.debug(f"Optical Element Centre: {OpticalElementCentre}")
 78    # for convex mirrors, rotated them by 180° while keeping same incidence plane so we reflect from the "back side"
 79    if Optic.curvature == mmirror.Curvature.CONVEX:
 80        IncidenceAngle = np.pi - IncidenceAngle
 81
 82    if hasattr(Optic, AlignmentVector):
 83        OpticVector = getattr(Optic, AlignmentVector)
 84    else:
 85        logger.warning(f"Optical Element {Optic} does not have an attribute {AlignmentVector}. Using support_normal instead.")
 86        OpticVector = Optic.support_normal_ref
 87    
 88    MajorAxis = Optic.majoraxis_ref
 89
 90    IncidencePlane = PreviousIncidencePlane.rotate(mgeo.QRotationAroundAxis(InputRay.vector, -IncidencePlaneAngle))
 91    # We calculate a quaternion that will rotate OpticVector against the master ray and MajorAxis into the incidence plane
 92    # The convention is that the MajorAxis vector points right if seen from the source with IncidencePlane pointing up
 93    # To do that, we first calculate a vector MinorAxis in the reference frame of the optic that is orthogonal to both OpticVector and MajorAxis
 94    # Then we calculate a rotation matrix that will:
 95    #   - rotate MinorAxis into the IncidencePlane direction
 96    #   - rotate OpticVector against the master ray direction
 97    #   - rotate MajorAxis into the direction of the cross product of the two previous vectors
 98    MinorAxis = -np.cross(OpticVector, MajorAxis)
 99    qIncidenceAngle = mgeo.QRotationAroundAxis(IncidencePlane, -IncidenceAngle)
100    q = mgeo.QRotationVectorPair2VectorPair(MinorAxis, IncidencePlane, OpticVector, -MasterRayDirection)
101    q = qIncidenceAngle*q
102    Optic.q = q
103    logger.debug(f"Optic: {Optic}")
104    logger.debug(f"OpticVector: {OpticVector}")
105    logger.debug(f"Rotated OpticVector: {OpticVector.rotate(q)}")
106    logger.debug(f"MasterRayDirection: {MasterRayDirection}")
107
108    Optic.r = Origin + (OpticalElementCentre - Optic.centre - Optic.r)
109
110    NextRay = Optic.propagate_raylist(mray.RayList.from_list([InputRay]), alignment=True)[0]
111    return NextRay, IncidencePlane

Automatic placement and alignment of a single optical element. The arguments are:

  • The optic to be placed.
  • The distance from the previous optic.
  • An incidence angle.
  • An incidence plane angle.
  • An input ray.
  • An alignment vector.

The alignment procedure is as follows:

  • The optic is initially placed with its center at the source position (origin point of previous master ray).
  • It's oriented such that the alignment vector is antiparallel to the master ray. By default, the alignment vector is the support_normal.
  • The majoraxis of the optic is aligned with the incidence plane.
  • The optic is rotated around the incidence plane by the incidence angle.
  • The optic is rotated around the master ray by the incidence plane angle.
  • The optic is translated along the master ray by the distance from the previous optic.
  • The master ray is propagated through the optic without any blocking or diffraction effects and the output ray is used as the master ray for the next optic.
def OEPlacement( OpticsList: list, InitialRay: ARTcore.ModuleOpticalRay.Ray = None, Source: ARTcore.ModuleSource.Source = None, InputIncidencePlane: ARTcore.ModuleGeometry.Vector = None):
114def OEPlacement(
115    OpticsList: list,
116    InitialRay:mray.Ray = None,
117    Source: msource.Source = None,
118    InputIncidencePlane: mgeo.Vector = None
119):
120    """
121    Automatic placement and alignment of the optical elements for one optical chain.
122    Returns a list of optical elements (copied)
123    """
124    if InitialRay is None:
125        if Source is None:
126            InputRay = InputRay = mray.Ray(
127            point=mgeo.Point([0, 0, 0]), 
128            vector=mgeo.Vector([1, 0, 0]),
129            path=(0.0,),
130            number=0,
131            wavelength=800e-9,
132            incidence=0.0,
133            intensity=1.0
134    )
135        else:
136            InputRay = Source.get_master_ray()
137    else:
138        InputRay = InitialRay.copy_ray()
139    if InputIncidencePlane is None:
140        InputIncidencePlane = mgeo.Vector([0, 1, 0])
141    logger.debug(f"Initial Ray: {InputRay}")
142    logger.debug(f"Initial Incidence Plane: {InputIncidencePlane}")
143    assert np.linalg.norm(np.dot(InputRay.vector, InputIncidencePlane))<1e-6, "InputIncidencePlane is not orthogonal to InputRay.vector"
144    PreviousIncidencePlane = InputIncidencePlane.copy()
145    OEList = []
146    for i in range(len(OpticsList)):
147        OEList.append(copy(OpticsList[i]["OpticalElement"]))
148        InputRay, PreviousIncidencePlane = singleOEPlacement(
149            OEList[-1],
150            OpticsList[i]["Distance"],
151            OpticsList[i]["IncidenceAngle"],
152            OpticsList[i]["IncidencePlaneAngle"],
153            InputRay,
154            OpticsList[i]["Alignment"] if "Alignment" in OpticsList[i] else "support_normal",
155            PreviousIncidencePlane
156        )
157    return OEList

Automatic placement and alignment of the optical elements for one optical chain. Returns a list of optical elements (copied)

def RayTracingCalculation( source_rays: ARTcore.ModuleOpticalRay.RayList, optical_elements: list[ARTcore.ModuleOpticalElement.OpticalElement], **kwargs) -> list[list[ARTcore.ModuleOpticalRay.Ray]]:
162def RayTracingCalculation(
163    source_rays: mray.RayList, optical_elements: list[moe.OpticalElement], **kwargs
164) -> list[list[mray.Ray]]:
165    """
166    The actual ray-tracing calculation, starting from the list of 'source_rays',
167    and propagating them from one optical element to the next in the order of the
168    items of the list 'optical_elements'.
169
170
171    Parameters
172    ----------
173        source_rays : list[Ray-objects]
174            List of input rays.
175
176        optical_elements : list[OpticalElement-objects]
177
178
179    Returns
180    -------
181        output_rays : list[list[Ray-objects]]
182            List of lists of rays, each item corresponding to the ray-bundle *after*
183            the item with the same index in the 'optical_elements'-list.
184    """
185    output_rays = []
186
187    for k in range(0, len(optical_elements)):
188        if k == 0:
189            RayList = source_rays
190        else:
191            RayList = output_rays[k - 1]
192        RayList = optical_elements[k].propagate_raylist(RayList, **kwargs)
193        output_rays.append(RayList)
194
195    return output_rays

The actual ray-tracing calculation, starting from the list of 'source_rays', and propagating them from one optical element to the next in the order of the items of the list 'optical_elements'.

Parameters

source_rays : list[Ray-objects]
    List of input rays.

optical_elements : list[OpticalElement-objects]

Returns

output_rays : list[list[Ray-objects]]
    List of lists of rays, each item corresponding to the ray-bundle *after*
    the item with the same index in the 'optical_elements'-list.
def FindOptimalDistance( Detector, RayList: list[ARTcore.ModuleOpticalRay.Ray], OptFor='intensity', Amplitude: float = None, Precision: int = 3, IntensityWeighted=False, verbose=False):
251def FindOptimalDistance(
252    Detector,
253    RayList: list[mray.Ray],
254    OptFor="intensity",
255    Amplitude: float = None,
256    Precision: int = 3,
257    IntensityWeighted=False,
258    verbose=False,
259):
260    """
261    Automatically finds the optimal the 'Detector'-distance for either
262    maximum intensity, or minimal spotsize or duration.
263
264    Parameters
265    ----------
266        Detector : Detector-object
267
268        RayList : list[Ray-objects]
269
270        OptFor : str, optional
271            "spotsize": minimizes the standard-deviation spot size *d* on the detector.
272            "duration": minimizes the standard-deviation *tau* of the ray-delays.
273            "intensity": Maximizes 1/tau/d^2.
274            Defaults to "intensity".
275
276        Amplitude : float, optional
277            The detector-distances within which the optimization is done will be
278            the distance of 'Detector' +/- Amplitude in mm.
279
280        Precision : int, optional
281            Precision-parameter for the search algorithm. For Precision = n,
282            it will iterate with search amplitudes decreasing until 10^{-n}.
283            Defaults to 3.
284
285        IntensityWeighted : bool, optional
286            Whether to weigh the calculation of spotsize and/or duration by the ray-intensities.
287            Defaults to False.
288
289        verbose : bool, optional
290            Whether to print results to the console.
291
292    Returns
293    -------
294        OptDetector : Detector-object
295            A new detector-instance with optimized distance.
296
297        OptSpotSize : float
298            Spotsize, calculated as standard deviation in mm of the spot-diagram
299            on the optimized detector.
300
301        OptDuration : float
302            Duration, calculated as standard deviation in fs of the ray-delays
303            on the optimized detector.
304    """
305
306    if OptFor not in ["intensity", "size", "duration"]:
307        raise NameError(
308            "I don`t recognize what you want to optimize the detector distance for. OptFor must be either 'intensity', 'size' or 'duration'."
309        )
310
311    FirstDistance = Detector.get_distance()
312    ListPointDetector2DCentre = Detector.get_PointList2DCentre(RayList)
313    SizeSpot = 2 * StandardDeviation(ListPointDetector2DCentre)
314    NumericalAperture = ReturnNumericalAperture(RayList, 1)
315    if Amplitude is None:
316        Amplitude = min(4 * np.ceil(SizeSpot / np.tan(np.arcsin(NumericalAperture))), FirstDistance)
317    Step = Amplitude/10
318    #Step = Amplitude / 5  # pretty good in half the time ;-)
319
320    if verbose:
321        print(
322            f"Searching optimal detector position for *{OptFor}* within [{FirstDistance-Amplitude:.3f}, {FirstDistance+Amplitude:.3f}] mm...",
323            end="",
324            flush=True,
325        )
326    movingDetector = Detector.copy_detector()
327    for k in range(Precision + 1):
328        movingDetector, OptSpotSize, OptDuration = _FindOptimalDistanceBIS(
329            movingDetector, Amplitude * 0.1**k, Step * 0.1**k, RayList, OptFor, IntensityWeighted
330        )
331
332    if (
333        not FirstDistance - Amplitude + 10**-Precision
334        < movingDetector.get_distance()
335        < FirstDistance + Amplitude - 10**-Precision
336    ):
337        print("There`s no minimum-size/duration focus in the searched range.")
338
339    print(
340        "\r\033[K", end="", flush=True
341    )  # move to beginning of the line with \r and then delete the whole line with \033[K
342    return movingDetector, OptSpotSize, OptDuration

Automatically finds the optimal the 'Detector'-distance for either maximum intensity, or minimal spotsize or duration.

Parameters

Detector : Detector-object

RayList : list[Ray-objects]

OptFor : str, optional
    "spotsize": minimizes the standard-deviation spot size *d* on the detector.
    "duration": minimizes the standard-deviation *tau* of the ray-delays.
    "intensity": Maximizes 1/tau/d^2.
    Defaults to "intensity".

Amplitude : float, optional
    The detector-distances within which the optimization is done will be
    the distance of 'Detector' +/- Amplitude in mm.

Precision : int, optional
    Precision-parameter for the search algorithm. For Precision = n,
    it will iterate with search amplitudes decreasing until 10^{-n}.
    Defaults to 3.

IntensityWeighted : bool, optional
    Whether to weigh the calculation of spotsize and/or duration by the ray-intensities.
    Defaults to False.

verbose : bool, optional
    Whether to print results to the console.

Returns

OptDetector : Detector-object
    A new detector-instance with optimized distance.

OptSpotSize : float
    Spotsize, calculated as standard deviation in mm of the spot-diagram
    on the optimized detector.

OptDuration : float
    Duration, calculated as standard deviation in fs of the ray-delays
    on the optimized detector.
def FindCentralRay(RayList: list[ARTcore.ModuleOpticalRay.Ray]):
346def FindCentralRay(RayList: list[mray.Ray]):
347    """
348    Out of a the list of Ray-objects, RayList, determine the average direction
349    vector and source point, and the return a Ray-object representing this 
350    central ray of the RayList.
351    
352    Parameters
353    ----------
354        RayList : list of Ray-objects
355
356    Returns
357    -------
358        CentralRay : Ray-object
359    """
360      
361    CentralVector = np.mean( [x.vector for x in RayList], axis=0)
362    CentralPoint = np.mean( [x.point for x in RayList], axis=0)
363    
364    return mray.Ray(mgeo.Point(CentralPoint), mgeo.Vector(CentralVector))

Out of a the list of Ray-objects, RayList, determine the average direction vector and source point, and the return a Ray-object representing this central ray of the RayList.

Parameters

RayList : list of Ray-objects

Returns

CentralRay : Ray-object
def StandardDeviation(List: list[float, numpy.ndarray]) -> float:
367def StandardDeviation(List: list[float, np.ndarray]) -> float:
368    """
369    Return the standard deviation of elements in the supplied list.
370    If the elements are floats, it's simply the root-mean-square over the numbers.
371    If the elements are vectors (represented as numpy-arrays), the root-mean-square
372    of the distance of the points from their mean is calculated.
373
374    Parameters
375    ----------
376        List : list of floats or of np.ndarray
377            Numbers (in ART, typically delays)
378            or 2D or 3D vectors (in ART typically space coordinates)
379
380    Returns
381    -------
382        Std : float
383    """
384    if type(List[0]) in [int, float, np.float64]:
385        return np.std(List)
386    elif len(List[0]) > 1:
387        return np.sqrt(np.var(List, axis=0).sum())
388    else:
389        raise ValueError("StandardDeviation expects a list of floats or numpy-arrays as input, but got something else.")

Return the standard deviation of elements in the supplied list. If the elements are floats, it's simply the root-mean-square over the numbers. If the elements are vectors (represented as numpy-arrays), the root-mean-square of the distance of the points from their mean is calculated.

Parameters

List : list of floats or of np.ndarray
    Numbers (in ART, typically delays)
    or 2D or 3D vectors (in ART typically space coordinates)

Returns

Std : float
def WeightedStandardDeviation(List: list[float, numpy.ndarray], Weights: list[float]) -> float:
392def WeightedStandardDeviation(List: list[float, np.ndarray], Weights: list[float]) -> float:
393    """
394    Return the weighted standard deviation of elements in the supplied list.
395    If the elements are floats, it's simply the root-mean-square over the weighted numbers.
396    If the elements are vectors (represented as numpy-arrays), the root-mean-square
397    of the weighted distances of the points from their mean is calculated.
398
399    Parameters
400    ----------
401        List : list of floats or of np.ndarray
402            Numbers (in ART, typically delays)
403            or 2D or 3D vectors (in ART typically space coordinates)
404
405        Weights : list of floats, same length as List
406            Parameters such as Ray.intensity to be used as weights
407
408    Returns
409    -------
410        Std : float
411    """
412    average = np.average(List, axis=0, weights=Weights, returned=False)
413    variance = np.average((List - average) ** 2, axis=0, weights=Weights, returned=False)
414    return np.sqrt(variance.sum())

Return the weighted standard deviation of elements in the supplied list. If the elements are floats, it's simply the root-mean-square over the weighted numbers. If the elements are vectors (represented as numpy-arrays), the root-mean-square of the weighted distances of the points from their mean is calculated.

Parameters

List : list of floats or of np.ndarray
    Numbers (in ART, typically delays)
    or 2D or 3D vectors (in ART typically space coordinates)

Weights : list of floats, same length as List
    Parameters such as Ray.intensity to be used as weights

Returns

Std : float
def save_compressed(obj, filename: str = None):
433def save_compressed(obj, filename: str = None):
434    """Save (=pickle) an object 'obj' to a compressed file with name 'filename'."""
435    if not type(filename) == str:
436        filename = "kept_data_" + datetime.now().strftime("%Y-%m-%d-%Hh%M")
437
438    i = 0
439    while os.path.exists(filename + f"_{i}.xz"):
440        i += 1
441    filename = filename + f"_{i}"
442    # with gzip.open(filename + '.gz', 'wb') as f:
443    with lzma.open(filename + ".xz", "wb") as f:
444        pickle.dump(obj, f)
445    print("Saved results to " + filename + ".xz.")
446    print("->To reload from disk do: kept_data = mp.load_compressed('" + filename + "')")

Save (=pickle) an object 'obj' to a compressed file with name 'filename'.

def load_compressed(filename: str):
449def load_compressed(filename: str):
450    """Load (=unpickle) an object 'obj' from a compressed file with name 'filename'."""
451    # with gzip.open(filename + '.gz', 'rb') as f:
452    with lzma.open(filename + ".xz", "rb") as f:
453        obj = pickle.load(f)
454    return obj

Load (=unpickle) an object 'obj' from a compressed file with name 'filename'.

10 - ModuleSource

Provides a definition of sources of light. A source can be either a composite source (a sum of sources) or a simple source. A simple source is defined by 4 parameters:

  • Wavelength (monochromatic)
  • Angular power distribution
  • Ray origins distribution
  • Ray directions distribution A reason to use composite sources is for when the power and ray distributions depend on the wavelength. Another source option is the source containing just a list of rays that can be prepared manually. Finally another source option is one used for alignment, containing a single ray.

Created in 2019

@author: Anthony Guillaume and Stefan Haessler

  1"""
  2Provides a definition of sources of light.
  3A source can be either a composite source (a sum of sources) or a simple source.
  4A simple source is defined by 4 parameters:
  5- Wavelength (monochromatic)
  6- Angular power distribution
  7- Ray origins distribution
  8- Ray directions distribution
  9A reason to use composite sources is for when the power and ray distributions depend on the wavelength.
 10Another source option is the source containing just a list of rays that can be prepared manually.
 11Finally another source option is one used for alignment, containing a single ray.
 12
 13
 14Created in 2019
 15
 16@author: Anthony Guillaume and Stefan Haessler
 17"""
 18
 19# %% Modules
 20import ARTcore.ModuleOpticalRay as mray
 21import ARTcore.ModuleGeometry as mgeo
 22import ARTcore.ModuleProcessing as mp
 23
 24from ARTcore.DepGraphDefinitions import UniformSpectraCalculator
 25
 26import numpy as np
 27from abc import ABC, abstractmethod
 28import logging
 29
 30logger = logging.getLogger(__name__)
 31
 32# %% Abstract base classes for sources
 33class Source(ABC):
 34    """
 35    Abstract base class for sources.
 36    Fundamentally a source just has to be able to return a list of rays.
 37    To do so, we make it callable.
 38    """
 39    @abstractmethod
 40    def __call__(self,N):
 41        pass
 42
 43class PowerDistribution(ABC):
 44    """
 45    Abstract base class for angular power distributions.
 46    """
 47    @abstractmethod
 48    def __call__(self, Origins, Directions):
 49        """
 50        Return the power of the rays coming 
 51        from the origin points Origins to the directions Directions.
 52        """
 53        pass
 54class RayOriginsDistribution(ABC):
 55    """
 56    Abstract base class for ray origins distributions.
 57    """
 58    @abstractmethod
 59    def __call__(self, N):
 60        """
 61        Return the origins of N rays.
 62        """
 63        pass
 64
 65class RayDirectionsDistribution(ABC):
 66    """
 67    Abstract base class for ray directions distributions.
 68    """
 69    @abstractmethod
 70    def __call__(self, N):
 71        """
 72        Return the directions of N rays.
 73        """
 74        pass
 75
 76class Spectrum(ABC):
 77    """
 78    Abstract base class for spectra.
 79    """
 80    @abstractmethod
 81    def __call__(self, N):
 82        """
 83        Return the wavelengths of N rays.
 84        """
 85        pass
 86
 87# %% Specific power distributions
 88class SpatialGaussianPowerDistribution(PowerDistribution):
 89    """
 90    Spatial Gaussian power distribution, depending only on ray origin points, and not on directions. 
 91    
 92    Attributes
 93    ----------
 94        Power : float
 95            Peak power of the distribution in arbitrary units (user can keep track of their own units.)
 96
 97        W0 : float
 98            1/e^2 beam waist in mm     
 99    """
100    def __init__(self, Power, W0):
101        self.Power = Power
102        self.W0 = W0
103
104    def __call__(self, Origins, Directions):
105        """
106        Return the power of the rays coming 
107        from the origin points Origins to the directions Directions.
108        
109        Parameters
110        ----------
111            Origins : mgeo.PointArray 
112               PointArray as defined in ModuleGeometry, with points of origin of the rays.
113
114            Directions : mgeo.VectorArray
115                VectorArray as defined in ModuleGeometry, with ray vectors.
116
117        Returns
118        -------
119            Powers: numpy.array
120                Array of powers for each ray.
121        
122        """
123        return self.Power * np.exp(-2 * np.linalg.norm(Origins, axis=1) ** 2 / self.W0 ** 2)
124    
125class AngularGaussianPowerDistribution(PowerDistribution):
126    """
127    Angular Gaussian power distribution, depending only on ray directions, but not origin points. 
128    
129    Attributes
130    ----------
131        Power : float
132            Peak power of the distribution in arbitrary units (user can keep track of their own units.)
133
134        Divergence : float
135            1/e^2 divergence half-angle in radians.
136    """
137    def __init__(self, Power, Divergence):
138        self.Power = Power
139        self.Divergence = Divergence
140
141    def __call__(self, Origins, Directions):
142        """
143        Return the power of the rays coming 
144        from the origin points Origins to the directions Directions.
145        
146        Parameters
147        ----------
148            Origins : mgeo.PointArray 
149               PointArray as defined in ModuleGeometry, with points of origin of the rays.
150
151            Directions : mgeo.VectorArray
152                VectorArray as defined in ModuleGeometry, with ray vectors.
153
154        Returns
155        -------
156            Powers: numpy.array
157                Array of powers for each ray.
158        
159        """
160        return self.Power * np.exp(-2 * np.arccos(np.dot(Directions, [0, 0, 1])) ** 2 / self.Divergence ** 2)
161
162class GaussianPowerDistribution(PowerDistribution):
163    """
164    Gaussian power distribution, depending on ray origin points and directions.
165    
166    Attributes
167    ----------
168        Power : float
169            Peak power of the distribution in arbitrary units (user can keep track of their own units.)
170
171        W0 : float
172            1/e^2 beam waist in mm            
173
174        Divergence : float
175            1/e^2 divergence half-angle in radians.
176    """
177    def __init__(self, Power, W0, Divergence):
178        self.Power = Power
179        self.W0 = W0
180        self.Divergence = Divergence
181
182    def __call__(self, Origins, Directions):
183        """
184        Return the power of the rays coming 
185        from the origin points Origins to the directions Directions.
186        
187        Parameters
188        ----------
189            Origins : mgeo.PointArray 
190               PointArray as defined in ModuleGeometry, with points of origin of the rays.
191
192            Directions : mgeo.VectorArray
193                VectorArray as defined in ModuleGeometry, with ray vectors.
194
195        Returns
196        -------
197            Powers: numpy.array
198                Array of powers for each ray.
199        
200        """
201        return self.Power * np.exp(-2 * (Origins-mgeo.Origin).norm ** 2 / self.W0 ** 2) * np.exp(-2 * np.array(np.arccos(np.dot(Directions, mgeo.Vector([1, 0, 0])))) ** 2 / self.Divergence ** 2)
202
203class UniformPowerDistribution(PowerDistribution):
204    """
205    Uniform power distribution.
206    """
207    def __init__(self, Power):
208        self.Power = Power
209
210    def __call__(self, Origins, Directions):
211        """
212        Return the power of the rays coming 
213        from the origin points Origins to the directions Directions.
214        """
215        return self.Power * np.ones(len(Origins))
216
217# %% Specific ray origins distributions
218class PointRayOriginsDistribution(RayOriginsDistribution):
219    """
220    Point ray origins distribution.
221    """
222    def __init__(self, Origin):
223        self.Origin = Origin
224
225    def __call__(self, N):
226        """
227        Return the origins of N rays.
228        """
229        return mgeo.PointArray([self.Origin for i in range(N)])
230    
231class DiskRayOriginsDistribution(RayOriginsDistribution):
232    """
233    Disk ray origins distribution. Uses the Vogel spiral to initialize the rays.
234    """
235    def __init__(self, Origin, Radius, Normal = mgeo.Vector([0, 0, 1])):
236        self.Origin = Origin
237        self.Radius = Radius
238        self.Normal = Normal
239
240    def __call__(self, N):
241        """
242        Return the origins of N rays.
243        """
244        MatrixXY = mgeo.SpiralVogel(N, self.Radius)
245        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Normal)
246        return mgeo.PointArray([self.Origin + mgeo.Vector([MatrixXY[i, 0], MatrixXY[i, 1], 0]) for i in range(N)]).rotate(q)
247    
248# %% Specific ray directions distributions
249class UniformRayDirectionsDistribution(RayDirectionsDistribution):
250    """
251    Uniform ray directions distribution.
252    """
253    def __init__(self, Direction):
254        self.Direction = Direction
255
256    def __call__(self, N):
257        """
258        Return the directions of N rays.
259        """
260        return mgeo.VectorArray([self.Direction for i in range(N)])
261
262class ConeRayDirectionsDistribution(RayDirectionsDistribution):
263    """
264    Cone ray directions distribution. Uses the Vogel spiral to initialize the rays.
265    """
266    def __init__(self, Direction, Angle):
267        self.Direction = Direction
268        self.Angle = Angle
269
270    def __call__(self, N):
271        """
272        Return the directions of N rays.
273        """
274        Height = 1
275        Radius = Height * np.tan(self.Angle)
276        MatrixXY = mgeo.SpiralVogel(N, Radius)
277        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Direction)
278        return mgeo.VectorArray([[MatrixXY[i, 0], MatrixXY[i, 1], Height] for i in range(N)]).rotate(q).normalized()
279
280
281# %% Specific spectra
282class SingleWavelengthSpectrum(Spectrum):
283    """
284    Single wavelength spectrum.
285    """
286    def __init__(self, Wavelength):
287        self.Wavelength = Wavelength
288
289    def __call__(self, N):
290        """
291        Return the wavelengths of N rays.
292        """
293        return np.ones(N) * self.Wavelength
294
295class UniformSpectrum(Spectrum):
296    """
297    Uniform spectrum.
298    Can be specified as any combination of min, max, central or width in either eV or nm.
299    """
300    def __init__(self, eVMax = None, eVMin = None, eVCentral = None, 
301                 lambdaMax = None, lambdaMin = None, lambdaCentral = None, 
302                 eVWidth = None, lambdaWidth = None):
303        # Using the DepSolver, we calculate the minimum and maximum wavelengths
304        values, steps = UniformSpectraCalculator().calculate_values(
305            lambda_min = lambdaMin, 
306            lambda_max = lambdaMax, 
307            lambda_center = lambdaCentral, 
308            lambda_width = lambdaWidth,
309            eV_min = eVMin,
310            eV_max = eVMax,
311            eV_center = eVCentral,
312            eV_width = eVWidth
313        )
314        self.lambdaMin = values['lambda_min']
315        self.lambdaMax = values['lambda_max']
316    def __call__(self, N):
317        """
318        Return the wavelengths of N rays.
319        """
320        return np.linspace(self.lambdaMin, self.lambdaMax, N)
321
322
323# %% Simple sources
324class SimpleSource(Source):
325    """
326    A simple monochromatic source defined by 4 parameters:
327    - Wavelength (in nm)
328    - Power distribution
329    - Ray origins distribution
330    - Ray directions distribution
331    """
332
333    def __init__(self, Wavelength, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution):
334        self.Wavelength = Wavelength
335        self.PowerDistribution = PowerDistribution
336        self.RayOriginsDistribution = RayOriginsDistribution
337        self.RayDirectionsDistribution = RayDirectionsDistribution
338
339    def __call__(self, N):
340        """
341        Return a list of N rays from the simple source.
342        """
343        Origins = self.RayOriginsDistribution(N)
344        rng = np.random.default_rng()
345        rng.shuffle(Origins)
346        Directions = self.RayDirectionsDistribution(N)
347        Powers = self.PowerDistribution(Origins, Directions)
348        
349        RayList = []
350        for i in range(N):
351            RayList.append(mray.Ray(Origins[i], Directions[i], wavelength=self.Wavelength, number=i, intensity=Powers[i]))
352        return mray.RayList.from_list(RayList)
353
354
355class ListSource(Source):
356    """
357    A source containing just a list of rays that can be prepared manually.
358    """
359    def __init__(self, Rays):
360        self.Rays = Rays
361
362    def __call__(self, N):
363        """
364        Return the list of rays.
365        """
366        if N < len(self.Rays):
367            return self.Rays[:N]
368        elif N>len(self.Rays):
369            logger.warning("Requested number of rays is greater than the number of rays in the source. Returning the whole source.")
370            return self.Rays    
371        return mray.RayList.from_list(self.Rays)
logger = <Logger ARTcore.ModuleSource (WARNING)>
class Source(abc.ABC):
34class Source(ABC):
35    """
36    Abstract base class for sources.
37    Fundamentally a source just has to be able to return a list of rays.
38    To do so, we make it callable.
39    """
40    @abstractmethod
41    def __call__(self,N):
42        pass

Abstract base class for sources. Fundamentally a source just has to be able to return a list of rays. To do so, we make it callable.

class PowerDistribution(abc.ABC):
44class PowerDistribution(ABC):
45    """
46    Abstract base class for angular power distributions.
47    """
48    @abstractmethod
49    def __call__(self, Origins, Directions):
50        """
51        Return the power of the rays coming 
52        from the origin points Origins to the directions Directions.
53        """
54        pass

Abstract base class for angular power distributions.

class RayOriginsDistribution(abc.ABC):
55class RayOriginsDistribution(ABC):
56    """
57    Abstract base class for ray origins distributions.
58    """
59    @abstractmethod
60    def __call__(self, N):
61        """
62        Return the origins of N rays.
63        """
64        pass

Abstract base class for ray origins distributions.

class RayDirectionsDistribution(abc.ABC):
66class RayDirectionsDistribution(ABC):
67    """
68    Abstract base class for ray directions distributions.
69    """
70    @abstractmethod
71    def __call__(self, N):
72        """
73        Return the directions of N rays.
74        """
75        pass

Abstract base class for ray directions distributions.

class Spectrum(abc.ABC):
77class Spectrum(ABC):
78    """
79    Abstract base class for spectra.
80    """
81    @abstractmethod
82    def __call__(self, N):
83        """
84        Return the wavelengths of N rays.
85        """
86        pass

Abstract base class for spectra.

class SpatialGaussianPowerDistribution(PowerDistribution):
 89class SpatialGaussianPowerDistribution(PowerDistribution):
 90    """
 91    Spatial Gaussian power distribution, depending only on ray origin points, and not on directions. 
 92    
 93    Attributes
 94    ----------
 95        Power : float
 96            Peak power of the distribution in arbitrary units (user can keep track of their own units.)
 97
 98        W0 : float
 99            1/e^2 beam waist in mm     
100    """
101    def __init__(self, Power, W0):
102        self.Power = Power
103        self.W0 = W0
104
105    def __call__(self, Origins, Directions):
106        """
107        Return the power of the rays coming 
108        from the origin points Origins to the directions Directions.
109        
110        Parameters
111        ----------
112            Origins : mgeo.PointArray 
113               PointArray as defined in ModuleGeometry, with points of origin of the rays.
114
115            Directions : mgeo.VectorArray
116                VectorArray as defined in ModuleGeometry, with ray vectors.
117
118        Returns
119        -------
120            Powers: numpy.array
121                Array of powers for each ray.
122        
123        """
124        return self.Power * np.exp(-2 * np.linalg.norm(Origins, axis=1) ** 2 / self.W0 ** 2)

Spatial Gaussian power distribution, depending only on ray origin points, and not on directions.

Attributes

Power : float
    Peak power of the distribution in arbitrary units (user can keep track of their own units.)

W0 : float
    1/e^2 beam waist in mm
SpatialGaussianPowerDistribution(Power, W0)
101    def __init__(self, Power, W0):
102        self.Power = Power
103        self.W0 = W0
Power
W0
class AngularGaussianPowerDistribution(PowerDistribution):
126class AngularGaussianPowerDistribution(PowerDistribution):
127    """
128    Angular Gaussian power distribution, depending only on ray directions, but not origin points. 
129    
130    Attributes
131    ----------
132        Power : float
133            Peak power of the distribution in arbitrary units (user can keep track of their own units.)
134
135        Divergence : float
136            1/e^2 divergence half-angle in radians.
137    """
138    def __init__(self, Power, Divergence):
139        self.Power = Power
140        self.Divergence = Divergence
141
142    def __call__(self, Origins, Directions):
143        """
144        Return the power of the rays coming 
145        from the origin points Origins to the directions Directions.
146        
147        Parameters
148        ----------
149            Origins : mgeo.PointArray 
150               PointArray as defined in ModuleGeometry, with points of origin of the rays.
151
152            Directions : mgeo.VectorArray
153                VectorArray as defined in ModuleGeometry, with ray vectors.
154
155        Returns
156        -------
157            Powers: numpy.array
158                Array of powers for each ray.
159        
160        """
161        return self.Power * np.exp(-2 * np.arccos(np.dot(Directions, [0, 0, 1])) ** 2 / self.Divergence ** 2)

Angular Gaussian power distribution, depending only on ray directions, but not origin points.

Attributes

Power : float
    Peak power of the distribution in arbitrary units (user can keep track of their own units.)

Divergence : float
    1/e^2 divergence half-angle in radians.
AngularGaussianPowerDistribution(Power, Divergence)
138    def __init__(self, Power, Divergence):
139        self.Power = Power
140        self.Divergence = Divergence
Power
Divergence
class GaussianPowerDistribution(PowerDistribution):
163class GaussianPowerDistribution(PowerDistribution):
164    """
165    Gaussian power distribution, depending on ray origin points and directions.
166    
167    Attributes
168    ----------
169        Power : float
170            Peak power of the distribution in arbitrary units (user can keep track of their own units.)
171
172        W0 : float
173            1/e^2 beam waist in mm            
174
175        Divergence : float
176            1/e^2 divergence half-angle in radians.
177    """
178    def __init__(self, Power, W0, Divergence):
179        self.Power = Power
180        self.W0 = W0
181        self.Divergence = Divergence
182
183    def __call__(self, Origins, Directions):
184        """
185        Return the power of the rays coming 
186        from the origin points Origins to the directions Directions.
187        
188        Parameters
189        ----------
190            Origins : mgeo.PointArray 
191               PointArray as defined in ModuleGeometry, with points of origin of the rays.
192
193            Directions : mgeo.VectorArray
194                VectorArray as defined in ModuleGeometry, with ray vectors.
195
196        Returns
197        -------
198            Powers: numpy.array
199                Array of powers for each ray.
200        
201        """
202        return self.Power * np.exp(-2 * (Origins-mgeo.Origin).norm ** 2 / self.W0 ** 2) * np.exp(-2 * np.array(np.arccos(np.dot(Directions, mgeo.Vector([1, 0, 0])))) ** 2 / self.Divergence ** 2)

Gaussian power distribution, depending on ray origin points and directions.

Attributes

Power : float
    Peak power of the distribution in arbitrary units (user can keep track of their own units.)

W0 : float
    1/e^2 beam waist in mm            

Divergence : float
    1/e^2 divergence half-angle in radians.
GaussianPowerDistribution(Power, W0, Divergence)
178    def __init__(self, Power, W0, Divergence):
179        self.Power = Power
180        self.W0 = W0
181        self.Divergence = Divergence
Power
W0
Divergence
class UniformPowerDistribution(PowerDistribution):
204class UniformPowerDistribution(PowerDistribution):
205    """
206    Uniform power distribution.
207    """
208    def __init__(self, Power):
209        self.Power = Power
210
211    def __call__(self, Origins, Directions):
212        """
213        Return the power of the rays coming 
214        from the origin points Origins to the directions Directions.
215        """
216        return self.Power * np.ones(len(Origins))

Uniform power distribution.

UniformPowerDistribution(Power)
208    def __init__(self, Power):
209        self.Power = Power
Power
class PointRayOriginsDistribution(RayOriginsDistribution):
219class PointRayOriginsDistribution(RayOriginsDistribution):
220    """
221    Point ray origins distribution.
222    """
223    def __init__(self, Origin):
224        self.Origin = Origin
225
226    def __call__(self, N):
227        """
228        Return the origins of N rays.
229        """
230        return mgeo.PointArray([self.Origin for i in range(N)])

Point ray origins distribution.

PointRayOriginsDistribution(Origin)
223    def __init__(self, Origin):
224        self.Origin = Origin
Origin
class DiskRayOriginsDistribution(RayOriginsDistribution):
232class DiskRayOriginsDistribution(RayOriginsDistribution):
233    """
234    Disk ray origins distribution. Uses the Vogel spiral to initialize the rays.
235    """
236    def __init__(self, Origin, Radius, Normal = mgeo.Vector([0, 0, 1])):
237        self.Origin = Origin
238        self.Radius = Radius
239        self.Normal = Normal
240
241    def __call__(self, N):
242        """
243        Return the origins of N rays.
244        """
245        MatrixXY = mgeo.SpiralVogel(N, self.Radius)
246        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Normal)
247        return mgeo.PointArray([self.Origin + mgeo.Vector([MatrixXY[i, 0], MatrixXY[i, 1], 0]) for i in range(N)]).rotate(q)

Disk ray origins distribution. Uses the Vogel spiral to initialize the rays.

DiskRayOriginsDistribution(Origin, Radius, Normal=Vector([0, 0, 1]))
236    def __init__(self, Origin, Radius, Normal = mgeo.Vector([0, 0, 1])):
237        self.Origin = Origin
238        self.Radius = Radius
239        self.Normal = Normal
Origin
Radius
Normal
class UniformRayDirectionsDistribution(RayDirectionsDistribution):
250class UniformRayDirectionsDistribution(RayDirectionsDistribution):
251    """
252    Uniform ray directions distribution.
253    """
254    def __init__(self, Direction):
255        self.Direction = Direction
256
257    def __call__(self, N):
258        """
259        Return the directions of N rays.
260        """
261        return mgeo.VectorArray([self.Direction for i in range(N)])

Uniform ray directions distribution.

UniformRayDirectionsDistribution(Direction)
254    def __init__(self, Direction):
255        self.Direction = Direction
Direction
class ConeRayDirectionsDistribution(RayDirectionsDistribution):
263class ConeRayDirectionsDistribution(RayDirectionsDistribution):
264    """
265    Cone ray directions distribution. Uses the Vogel spiral to initialize the rays.
266    """
267    def __init__(self, Direction, Angle):
268        self.Direction = Direction
269        self.Angle = Angle
270
271    def __call__(self, N):
272        """
273        Return the directions of N rays.
274        """
275        Height = 1
276        Radius = Height * np.tan(self.Angle)
277        MatrixXY = mgeo.SpiralVogel(N, Radius)
278        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Direction)
279        return mgeo.VectorArray([[MatrixXY[i, 0], MatrixXY[i, 1], Height] for i in range(N)]).rotate(q).normalized()

Cone ray directions distribution. Uses the Vogel spiral to initialize the rays.

ConeRayDirectionsDistribution(Direction, Angle)
267    def __init__(self, Direction, Angle):
268        self.Direction = Direction
269        self.Angle = Angle
Direction
Angle
class SingleWavelengthSpectrum(Spectrum):
283class SingleWavelengthSpectrum(Spectrum):
284    """
285    Single wavelength spectrum.
286    """
287    def __init__(self, Wavelength):
288        self.Wavelength = Wavelength
289
290    def __call__(self, N):
291        """
292        Return the wavelengths of N rays.
293        """
294        return np.ones(N) * self.Wavelength

Single wavelength spectrum.

SingleWavelengthSpectrum(Wavelength)
287    def __init__(self, Wavelength):
288        self.Wavelength = Wavelength
Wavelength
class UniformSpectrum(Spectrum):
296class UniformSpectrum(Spectrum):
297    """
298    Uniform spectrum.
299    Can be specified as any combination of min, max, central or width in either eV or nm.
300    """
301    def __init__(self, eVMax = None, eVMin = None, eVCentral = None, 
302                 lambdaMax = None, lambdaMin = None, lambdaCentral = None, 
303                 eVWidth = None, lambdaWidth = None):
304        # Using the DepSolver, we calculate the minimum and maximum wavelengths
305        values, steps = UniformSpectraCalculator().calculate_values(
306            lambda_min = lambdaMin, 
307            lambda_max = lambdaMax, 
308            lambda_center = lambdaCentral, 
309            lambda_width = lambdaWidth,
310            eV_min = eVMin,
311            eV_max = eVMax,
312            eV_center = eVCentral,
313            eV_width = eVWidth
314        )
315        self.lambdaMin = values['lambda_min']
316        self.lambdaMax = values['lambda_max']
317    def __call__(self, N):
318        """
319        Return the wavelengths of N rays.
320        """
321        return np.linspace(self.lambdaMin, self.lambdaMax, N)

Uniform spectrum. Can be specified as any combination of min, max, central or width in either eV or nm.

UniformSpectrum( eVMax=None, eVMin=None, eVCentral=None, lambdaMax=None, lambdaMin=None, lambdaCentral=None, eVWidth=None, lambdaWidth=None)
301    def __init__(self, eVMax = None, eVMin = None, eVCentral = None, 
302                 lambdaMax = None, lambdaMin = None, lambdaCentral = None, 
303                 eVWidth = None, lambdaWidth = None):
304        # Using the DepSolver, we calculate the minimum and maximum wavelengths
305        values, steps = UniformSpectraCalculator().calculate_values(
306            lambda_min = lambdaMin, 
307            lambda_max = lambdaMax, 
308            lambda_center = lambdaCentral, 
309            lambda_width = lambdaWidth,
310            eV_min = eVMin,
311            eV_max = eVMax,
312            eV_center = eVCentral,
313            eV_width = eVWidth
314        )
315        self.lambdaMin = values['lambda_min']
316        self.lambdaMax = values['lambda_max']
lambdaMin
lambdaMax
class SimpleSource(Source):
325class SimpleSource(Source):
326    """
327    A simple monochromatic source defined by 4 parameters:
328    - Wavelength (in nm)
329    - Power distribution
330    - Ray origins distribution
331    - Ray directions distribution
332    """
333
334    def __init__(self, Wavelength, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution):
335        self.Wavelength = Wavelength
336        self.PowerDistribution = PowerDistribution
337        self.RayOriginsDistribution = RayOriginsDistribution
338        self.RayDirectionsDistribution = RayDirectionsDistribution
339
340    def __call__(self, N):
341        """
342        Return a list of N rays from the simple source.
343        """
344        Origins = self.RayOriginsDistribution(N)
345        rng = np.random.default_rng()
346        rng.shuffle(Origins)
347        Directions = self.RayDirectionsDistribution(N)
348        Powers = self.PowerDistribution(Origins, Directions)
349        
350        RayList = []
351        for i in range(N):
352            RayList.append(mray.Ray(Origins[i], Directions[i], wavelength=self.Wavelength, number=i, intensity=Powers[i]))
353        return mray.RayList.from_list(RayList)

A simple monochromatic source defined by 4 parameters:

  • Wavelength (in nm)
  • Power distribution
  • Ray origins distribution
  • Ray directions distribution
SimpleSource( Wavelength, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution)
334    def __init__(self, Wavelength, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution):
335        self.Wavelength = Wavelength
336        self.PowerDistribution = PowerDistribution
337        self.RayOriginsDistribution = RayOriginsDistribution
338        self.RayDirectionsDistribution = RayDirectionsDistribution
Wavelength
PowerDistribution
RayOriginsDistribution
RayDirectionsDistribution
class ListSource(Source):
356class ListSource(Source):
357    """
358    A source containing just a list of rays that can be prepared manually.
359    """
360    def __init__(self, Rays):
361        self.Rays = Rays
362
363    def __call__(self, N):
364        """
365        Return the list of rays.
366        """
367        if N < len(self.Rays):
368            return self.Rays[:N]
369        elif N>len(self.Rays):
370            logger.warning("Requested number of rays is greater than the number of rays in the source. Returning the whole source.")
371            return self.Rays    
372        return mray.RayList.from_list(self.Rays)

A source containing just a list of rays that can be prepared manually.

ListSource(Rays)
360    def __init__(self, Rays):
361        self.Rays = Rays
Rays

11 - ModuleSupport

Provides classes for different shapes of a support for optics.

A support fixes the spatial extent and outer shape of optics - think of it as a substrate. Technically, the support is a section of the x-y-plane in the element coordinate frame.

The support will become an attribute of Mirror or Mask objects.

Illustration of different kinds of support.

Created in Sept 2020

@author: Anthony Guillaume and Stefan Haessler

  1"""
  2Provides classes for different shapes of a support for optics.
  3
  4A support fixes the spatial extent and  outer shape of optics - think of it as a substrate.
  5Technically, the support is a section of the x-y-plane in the element coordinate frame.
  6
  7The support will become an attribute of Mirror or Mask objects.
  8
  9![Illustration of different kinds of support.](supports.svg)
 10
 11
 12
 13Created in Sept 2020
 14
 15@author: Anthony Guillaume and Stefan Haessler
 16"""
 17
 18# %%
 19
 20import numpy as np
 21import math
 22import ARTcore.ModuleGeometry as mgeo
 23from abc import ABC, abstractmethod
 24import logging
 25
 26logger = logging.getLogger(__name__)
 27
 28
 29# %% Abstract Support Class
 30class Support(ABC):
 31    """
 32    Abstract base class for optics supports.
 33    Supports the `in` operator to test points.
 34    """
 35    @abstractmethod
 36    def _sdf(self, Point):
 37        """
 38        Signed distance function for the support.
 39        """
 40        pass
 41
 42    def __contains__(self, Point):
 43        """
 44        Interface allowing the use of the `in` operator to check if a point is within the support.
 45        """
 46        return self._sdf(Point) <= 0
 47    
 48    def _estimate_size(self, initial_distance=1000):
 49        """
 50        Estimate the size of the support using the signed distance function.
 51        """
 52        directions = np.array([[1, 0], [-1, 0], [0, 1], [0, -1]])  # Simple axis-aligned directions
 53        points = directions * initial_distance  # Generate points on a circle of the current radius
 54        distances = np.array([self._sdf(p) for p in points])
 55        avg_distance = np.mean(distances)
 56
 57        return initial_distance - avg_distance
 58    
 59
 60
 61    
 62
 63# %% Round Support
 64class SupportRound(Support):
 65    """
 66    A round support for optics.
 67
 68    Attributes
 69    ----------
 70        radius : float
 71            The radius of the support in mm.
 72    """
 73
 74    def __init__(self, Radius: float):
 75        """
 76        Create a round support.
 77
 78        Parameters
 79        ----------
 80            Radius : float
 81                The radius of the support in mm.
 82
 83        """
 84        self.radius = Radius
 85
 86    def __repr__(self):
 87        return f"{type(self).__name__}({self.radius})"
 88
 89    def _sdf(self, Point):
 90        """
 91        Return signed distance from the support.
 92        """
 93        return mgeo.SDF_Circle(Point, self.radius)
 94    
 95    def _get_grid(self, NbPoint: int, **kwargs):
 96        """
 97        Return a Point array with the coordinates of a number NbPoints of points.
 98        The points are distributed as a Vogel-spiral on the support, with the origin in the center of the support.
 99        """
100        MatrixXY = mgeo.SpiralVogel(NbPoint, self.radius)
101        return mgeo.Point(np.hstack((MatrixXY, np.zeros((MatrixXY.shape[0], 1)))))
102    
103
104# %% Round Support with Hole
105class SupportRoundHole(Support):
106    """
107    A round support for optics with a round hole.
108
109    Attributes
110    ----------
111        radius : float
112            The radius of the support in mm.
113
114        radiushole : float
115            The radius of the hole in mm.
116
117        centerholeX : float
118            The x-cordinate of the hole's centre, in mm.
119
120        centerholeY : float
121            The y-cordinate of the hole's centre, in mm.
122
123    """
124
125    def __init__(self, Radius: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float):
126        """
127        Parameters
128        ----------
129        Radius : float
130            The radius of the support in mm.
131
132        RadiusHole : float
133            The radius of the hole in mm.
134
135        CenterHoleX : float
136            The x-cordinate of the hole's centre, in mm.
137
138        CenterHoleY : float
139            The y-cordinate of the hole's centre, in mm.
140
141        """
142        self.radius = Radius
143        self.radiushole = RadiusHole
144        self.centerholeX = CenterHoleX
145        self.centerholeY = CenterHoleY
146
147    def __repr__(self):
148        return f"{type(self).__name__}(Radius={self.radius}, RadiusHole={self.radiushole}, CenterHoleX={self.centerholeX}, CenterHoleY={self.centerholeY})"
149
150    def _sdf(self, Point):
151        """Return signed distance from the support."""
152        support = mgeo.SDF_Circle(Point, self.radius)
153        hole =  mgeo.SDF_Circle(Point[:2] - np.array([self.centerholeX, self.centerholeY]), self.radiushole)
154        return mgeo.Difference_SDF(support, hole)
155    
156    def _get_grid(self, NbPoint, **kwargs):
157        """
158        Returns a list of 2D-numpy-arrays with the coordinates a number NbPoints of points,
159        distributed as a Vogel-spiral on the support, with the origin in the center of the support.
160        """
161        MatrixXY = mgeo.SpiralVogel(NbPoint, self.radius)
162        return MatrixXY
163        
164# %% Rectangular Support
165class SupportRectangle(Support):
166    """
167    A rectangular support for optics.
168
169    Attributes
170    ----------
171        dimX : float
172            The dimension in mm along x.
173
174        dimY : float
175            The dimension in mm along y.
176
177    """
178
179    def __init__(self, DimensionX: float, DimensionY: float):
180        """
181        Parameters
182        ----------
183        DimensionX : float
184            The dimension in mm along x.
185
186        DimensionY : float
187            The dimension in mm along y.
188
189        """
190        self.dimX = DimensionX
191        self.dimY = DimensionY
192
193    def __repr__(self):
194        return f"{type(self).__name__}(DimensionX={self.dimX}, DimensionY={self.dimY})"
195
196    def _sdf(self, Point):
197        """Return signed distance from the support."""
198        return mgeo.SDF_Rectangle(Point, self.dimX, self.dimY)
199
200    def _get_grid(self, NbPoints: int, **kwargs) -> list[np.ndarray]:
201        """
202        Returns a list of 2D-numpy-arrays with the coordinates a number NbPoints of points,
203        distributed as a regular grid on the support, with the origin in the center of the support.
204        """
205        nbx = int(
206            np.sqrt(self.dimX / self.dimY * NbPoints + 0.25 * (self.dimX - self.dimY) ** 2 / self.dimY**2)
207            - 0.5 * (self.dimX - self.dimY) / self.dimY
208        )
209        nby = int(NbPoints / nbx)
210        x = np.linspace(-self.dimX / 2, self.dimX / 2, nbx)
211        y = np.linspace(-self.dimY / 2, self.dimY / 2, nby)
212        ListCoordXY = []
213        for i in x:
214            for j in y:
215                ListCoordXY.append(np.array([i, j]))
216        return ListCoordXY
217
218
219# %% Rectangular Support with Hole
220class SupportRectangleHole(Support):
221    """
222    A rectangular support for optics with a round hole.
223
224    Attributes
225    ----------
226        dimX : float
227            The dimension in mm along x.
228
229        dimY : float
230            The dimension in mm along y.
231
232        radiushole : float
233            The radius of the hole in mm.
234
235        centerholeX : float
236            The x-cordinate of the hole's centre, in mm.
237
238        centerholeY : float
239            The y-cordinate of the hole's centre, in mm.
240
241    """
242
243    def __init__(self, DimensionX: float, DimensionY: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float):
244        """
245        Parameters
246        ----------
247        DimensionX : float
248            The dimension in mm along x.
249
250        DimensionY : float
251            The dimension in mm along y.
252
253        RadiusHole : float
254            The radius of the hole in mm.
255
256        CenterHoleX : float
257            The x-cordinate of the hole's centre, in mm.
258
259        CenterHoleY : float
260            The y-cordinate of the hole's centre, in mm.
261
262        """
263        self.dimX = DimensionX
264        self.dimY = DimensionY
265        self.radiushole = RadiusHole
266        self.centerholeX = CenterHoleX
267        self.centerholeY = CenterHoleY
268
269    def __repr__(self):
270        return f"{type(self).__name__}(DimensionX={self.dimX}, DimensionY={self.dimY}, RadiusHole={self.radiushole}, CenterHoleX={self.centerholeX}, CenterHoleY={self.centerholeY})"
271
272    def _sdf(self, Point):
273        """Return signed distance from the support."""
274        support = mgeo.SDF_Rectangle(Point, self.dimX, self.dimY)
275        hole = mgeo.SDF_Circle(Point[:2] - np.array([self.centerholeX, self.centerholeY]), self.radiushole)
276        return mgeo.Difference_SDF(support, hole)
277    
278
279
280# %% Rectangular Support with Rectangular Hole
281class SupportRectangleRectHole(Support):
282    """
283    A rectangular support for optics, with a rectangular hole.
284
285    Attributes
286    ----------
287        dimX : float
288            The dimension in mm along x.
289
290        dimY : float
291            The dimension in mm along y.
292
293        holeX : float
294            The dimension of the hole in mm along x.
295
296        holeY : float
297            The dimension of the hole in mm along y.
298
299        centerholeX : float
300            The x-cordinate of the hole's centre, in mm.
301
302        centerholeY : float
303            The y-cordinate of the hole's centre, in mm.
304
305    """
306
307    def __init__(
308        self, DimensionX: float, DimensionY: float, HoleX: float, HoleY: float, CenterHoleX: float, CenterHoleY: float
309    ):
310        """
311        Parameters
312        ----------
313        DimensionX : float
314            The dimension in mm along x.
315
316        DimensionY : float
317            The dimension in mm along y.
318
319        HoleX : float
320            The dimension of the hole in mm along x.
321
322        HoleY : float
323            The dimension of the hole in mm along y.
324
325        CenterHoleX : float
326            The x-cordinate of the hole's centre, in mm.
327
328        CenterHoleY : float
329            The y-cordinate of the hole's centre, in mm.
330
331        """
332        self.dimX = DimensionX
333        self.dimY = DimensionY
334        self.holeX = HoleX
335        self.holeY = HoleY
336        self.centerholeX = CenterHoleX
337        self.centerholeY = CenterHoleY
338
339    def __repr__(self):
340        return f"{type(self).__name__}(DimensionX={self.dimX}, DimensionY={self.dimY}, HoleX={self.holeX}, HoleY={self.holeY}, CenterHoleX={self.centerholeX}, CenterHoleY={self.centerholeY})"
341
342    def _sdf(self, Point):
343        """Return signed distance from the support."""
344        support = mgeo.SDF_Rectangle(Point, self.dimX, self.dimY)
345        hole = mgeo.SDF_Rectangle(Point[:2] - np.array([self.centerholeX, self.centerholeY]), self.holeX, self.holeY)
346        return mgeo.Difference_SDF(support, hole)
logger = <Logger ARTcore.ModuleSupport (WARNING)>
class Support(abc.ABC):
31class Support(ABC):
32    """
33    Abstract base class for optics supports.
34    Supports the `in` operator to test points.
35    """
36    @abstractmethod
37    def _sdf(self, Point):
38        """
39        Signed distance function for the support.
40        """
41        pass
42
43    def __contains__(self, Point):
44        """
45        Interface allowing the use of the `in` operator to check if a point is within the support.
46        """
47        return self._sdf(Point) <= 0
48    
49    def _estimate_size(self, initial_distance=1000):
50        """
51        Estimate the size of the support using the signed distance function.
52        """
53        directions = np.array([[1, 0], [-1, 0], [0, 1], [0, -1]])  # Simple axis-aligned directions
54        points = directions * initial_distance  # Generate points on a circle of the current radius
55        distances = np.array([self._sdf(p) for p in points])
56        avg_distance = np.mean(distances)
57
58        return initial_distance - avg_distance

Abstract base class for optics supports. Supports the in operator to test points.

class SupportRound(Support):
 65class SupportRound(Support):
 66    """
 67    A round support for optics.
 68
 69    Attributes
 70    ----------
 71        radius : float
 72            The radius of the support in mm.
 73    """
 74
 75    def __init__(self, Radius: float):
 76        """
 77        Create a round support.
 78
 79        Parameters
 80        ----------
 81            Radius : float
 82                The radius of the support in mm.
 83
 84        """
 85        self.radius = Radius
 86
 87    def __repr__(self):
 88        return f"{type(self).__name__}({self.radius})"
 89
 90    def _sdf(self, Point):
 91        """
 92        Return signed distance from the support.
 93        """
 94        return mgeo.SDF_Circle(Point, self.radius)
 95    
 96    def _get_grid(self, NbPoint: int, **kwargs):
 97        """
 98        Return a Point array with the coordinates of a number NbPoints of points.
 99        The points are distributed as a Vogel-spiral on the support, with the origin in the center of the support.
100        """
101        MatrixXY = mgeo.SpiralVogel(NbPoint, self.radius)
102        return mgeo.Point(np.hstack((MatrixXY, np.zeros((MatrixXY.shape[0], 1)))))

A round support for optics.

Attributes

radius : float
    The radius of the support in mm.
SupportRound(Radius: float)
75    def __init__(self, Radius: float):
76        """
77        Create a round support.
78
79        Parameters
80        ----------
81            Radius : float
82                The radius of the support in mm.
83
84        """
85        self.radius = Radius

Create a round support.

Parameters

Radius : float
    The radius of the support in mm.
radius
class SupportRoundHole(Support):
106class SupportRoundHole(Support):
107    """
108    A round support for optics with a round hole.
109
110    Attributes
111    ----------
112        radius : float
113            The radius of the support in mm.
114
115        radiushole : float
116            The radius of the hole in mm.
117
118        centerholeX : float
119            The x-cordinate of the hole's centre, in mm.
120
121        centerholeY : float
122            The y-cordinate of the hole's centre, in mm.
123
124    """
125
126    def __init__(self, Radius: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float):
127        """
128        Parameters
129        ----------
130        Radius : float
131            The radius of the support in mm.
132
133        RadiusHole : float
134            The radius of the hole in mm.
135
136        CenterHoleX : float
137            The x-cordinate of the hole's centre, in mm.
138
139        CenterHoleY : float
140            The y-cordinate of the hole's centre, in mm.
141
142        """
143        self.radius = Radius
144        self.radiushole = RadiusHole
145        self.centerholeX = CenterHoleX
146        self.centerholeY = CenterHoleY
147
148    def __repr__(self):
149        return f"{type(self).__name__}(Radius={self.radius}, RadiusHole={self.radiushole}, CenterHoleX={self.centerholeX}, CenterHoleY={self.centerholeY})"
150
151    def _sdf(self, Point):
152        """Return signed distance from the support."""
153        support = mgeo.SDF_Circle(Point, self.radius)
154        hole =  mgeo.SDF_Circle(Point[:2] - np.array([self.centerholeX, self.centerholeY]), self.radiushole)
155        return mgeo.Difference_SDF(support, hole)
156    
157    def _get_grid(self, NbPoint, **kwargs):
158        """
159        Returns a list of 2D-numpy-arrays with the coordinates a number NbPoints of points,
160        distributed as a Vogel-spiral on the support, with the origin in the center of the support.
161        """
162        MatrixXY = mgeo.SpiralVogel(NbPoint, self.radius)
163        return MatrixXY

A round support for optics with a round hole.

Attributes

radius : float
    The radius of the support in mm.

radiushole : float
    The radius of the hole in mm.

centerholeX : float
    The x-cordinate of the hole's centre, in mm.

centerholeY : float
    The y-cordinate of the hole's centre, in mm.
SupportRoundHole( Radius: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float)
126    def __init__(self, Radius: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float):
127        """
128        Parameters
129        ----------
130        Radius : float
131            The radius of the support in mm.
132
133        RadiusHole : float
134            The radius of the hole in mm.
135
136        CenterHoleX : float
137            The x-cordinate of the hole's centre, in mm.
138
139        CenterHoleY : float
140            The y-cordinate of the hole's centre, in mm.
141
142        """
143        self.radius = Radius
144        self.radiushole = RadiusHole
145        self.centerholeX = CenterHoleX
146        self.centerholeY = CenterHoleY

Parameters

Radius : float The radius of the support in mm.

RadiusHole : float The radius of the hole in mm.

CenterHoleX : float The x-cordinate of the hole's centre, in mm.

CenterHoleY : float The y-cordinate of the hole's centre, in mm.

radius
radiushole
centerholeX
centerholeY
class SupportRectangle(Support):
166class SupportRectangle(Support):
167    """
168    A rectangular support for optics.
169
170    Attributes
171    ----------
172        dimX : float
173            The dimension in mm along x.
174
175        dimY : float
176            The dimension in mm along y.
177
178    """
179
180    def __init__(self, DimensionX: float, DimensionY: float):
181        """
182        Parameters
183        ----------
184        DimensionX : float
185            The dimension in mm along x.
186
187        DimensionY : float
188            The dimension in mm along y.
189
190        """
191        self.dimX = DimensionX
192        self.dimY = DimensionY
193
194    def __repr__(self):
195        return f"{type(self).__name__}(DimensionX={self.dimX}, DimensionY={self.dimY})"
196
197    def _sdf(self, Point):
198        """Return signed distance from the support."""
199        return mgeo.SDF_Rectangle(Point, self.dimX, self.dimY)
200
201    def _get_grid(self, NbPoints: int, **kwargs) -> list[np.ndarray]:
202        """
203        Returns a list of 2D-numpy-arrays with the coordinates a number NbPoints of points,
204        distributed as a regular grid on the support, with the origin in the center of the support.
205        """
206        nbx = int(
207            np.sqrt(self.dimX / self.dimY * NbPoints + 0.25 * (self.dimX - self.dimY) ** 2 / self.dimY**2)
208            - 0.5 * (self.dimX - self.dimY) / self.dimY
209        )
210        nby = int(NbPoints / nbx)
211        x = np.linspace(-self.dimX / 2, self.dimX / 2, nbx)
212        y = np.linspace(-self.dimY / 2, self.dimY / 2, nby)
213        ListCoordXY = []
214        for i in x:
215            for j in y:
216                ListCoordXY.append(np.array([i, j]))
217        return ListCoordXY

A rectangular support for optics.

Attributes

dimX : float
    The dimension in mm along x.

dimY : float
    The dimension in mm along y.
SupportRectangle(DimensionX: float, DimensionY: float)
180    def __init__(self, DimensionX: float, DimensionY: float):
181        """
182        Parameters
183        ----------
184        DimensionX : float
185            The dimension in mm along x.
186
187        DimensionY : float
188            The dimension in mm along y.
189
190        """
191        self.dimX = DimensionX
192        self.dimY = DimensionY

Parameters

DimensionX : float The dimension in mm along x.

DimensionY : float The dimension in mm along y.

dimX
dimY
class SupportRectangleHole(Support):
221class SupportRectangleHole(Support):
222    """
223    A rectangular support for optics with a round hole.
224
225    Attributes
226    ----------
227        dimX : float
228            The dimension in mm along x.
229
230        dimY : float
231            The dimension in mm along y.
232
233        radiushole : float
234            The radius of the hole in mm.
235
236        centerholeX : float
237            The x-cordinate of the hole's centre, in mm.
238
239        centerholeY : float
240            The y-cordinate of the hole's centre, in mm.
241
242    """
243
244    def __init__(self, DimensionX: float, DimensionY: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float):
245        """
246        Parameters
247        ----------
248        DimensionX : float
249            The dimension in mm along x.
250
251        DimensionY : float
252            The dimension in mm along y.
253
254        RadiusHole : float
255            The radius of the hole in mm.
256
257        CenterHoleX : float
258            The x-cordinate of the hole's centre, in mm.
259
260        CenterHoleY : float
261            The y-cordinate of the hole's centre, in mm.
262
263        """
264        self.dimX = DimensionX
265        self.dimY = DimensionY
266        self.radiushole = RadiusHole
267        self.centerholeX = CenterHoleX
268        self.centerholeY = CenterHoleY
269
270    def __repr__(self):
271        return f"{type(self).__name__}(DimensionX={self.dimX}, DimensionY={self.dimY}, RadiusHole={self.radiushole}, CenterHoleX={self.centerholeX}, CenterHoleY={self.centerholeY})"
272
273    def _sdf(self, Point):
274        """Return signed distance from the support."""
275        support = mgeo.SDF_Rectangle(Point, self.dimX, self.dimY)
276        hole = mgeo.SDF_Circle(Point[:2] - np.array([self.centerholeX, self.centerholeY]), self.radiushole)
277        return mgeo.Difference_SDF(support, hole)

A rectangular support for optics with a round hole.

Attributes

dimX : float
    The dimension in mm along x.

dimY : float
    The dimension in mm along y.

radiushole : float
    The radius of the hole in mm.

centerholeX : float
    The x-cordinate of the hole's centre, in mm.

centerholeY : float
    The y-cordinate of the hole's centre, in mm.
SupportRectangleHole( DimensionX: float, DimensionY: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float)
244    def __init__(self, DimensionX: float, DimensionY: float, RadiusHole: float, CenterHoleX: float, CenterHoleY: float):
245        """
246        Parameters
247        ----------
248        DimensionX : float
249            The dimension in mm along x.
250
251        DimensionY : float
252            The dimension in mm along y.
253
254        RadiusHole : float
255            The radius of the hole in mm.
256
257        CenterHoleX : float
258            The x-cordinate of the hole's centre, in mm.
259
260        CenterHoleY : float
261            The y-cordinate of the hole's centre, in mm.
262
263        """
264        self.dimX = DimensionX
265        self.dimY = DimensionY
266        self.radiushole = RadiusHole
267        self.centerholeX = CenterHoleX
268        self.centerholeY = CenterHoleY

Parameters

DimensionX : float The dimension in mm along x.

DimensionY : float The dimension in mm along y.

RadiusHole : float The radius of the hole in mm.

CenterHoleX : float The x-cordinate of the hole's centre, in mm.

CenterHoleY : float The y-cordinate of the hole's centre, in mm.

dimX
dimY
radiushole
centerholeX
centerholeY
class SupportRectangleRectHole(Support):
282class SupportRectangleRectHole(Support):
283    """
284    A rectangular support for optics, with a rectangular hole.
285
286    Attributes
287    ----------
288        dimX : float
289            The dimension in mm along x.
290
291        dimY : float
292            The dimension in mm along y.
293
294        holeX : float
295            The dimension of the hole in mm along x.
296
297        holeY : float
298            The dimension of the hole in mm along y.
299
300        centerholeX : float
301            The x-cordinate of the hole's centre, in mm.
302
303        centerholeY : float
304            The y-cordinate of the hole's centre, in mm.
305
306    """
307
308    def __init__(
309        self, DimensionX: float, DimensionY: float, HoleX: float, HoleY: float, CenterHoleX: float, CenterHoleY: float
310    ):
311        """
312        Parameters
313        ----------
314        DimensionX : float
315            The dimension in mm along x.
316
317        DimensionY : float
318            The dimension in mm along y.
319
320        HoleX : float
321            The dimension of the hole in mm along x.
322
323        HoleY : float
324            The dimension of the hole in mm along y.
325
326        CenterHoleX : float
327            The x-cordinate of the hole's centre, in mm.
328
329        CenterHoleY : float
330            The y-cordinate of the hole's centre, in mm.
331
332        """
333        self.dimX = DimensionX
334        self.dimY = DimensionY
335        self.holeX = HoleX
336        self.holeY = HoleY
337        self.centerholeX = CenterHoleX
338        self.centerholeY = CenterHoleY
339
340    def __repr__(self):
341        return f"{type(self).__name__}(DimensionX={self.dimX}, DimensionY={self.dimY}, HoleX={self.holeX}, HoleY={self.holeY}, CenterHoleX={self.centerholeX}, CenterHoleY={self.centerholeY})"
342
343    def _sdf(self, Point):
344        """Return signed distance from the support."""
345        support = mgeo.SDF_Rectangle(Point, self.dimX, self.dimY)
346        hole = mgeo.SDF_Rectangle(Point[:2] - np.array([self.centerholeX, self.centerholeY]), self.holeX, self.holeY)
347        return mgeo.Difference_SDF(support, hole)

A rectangular support for optics, with a rectangular hole.

Attributes

dimX : float
    The dimension in mm along x.

dimY : float
    The dimension in mm along y.

holeX : float
    The dimension of the hole in mm along x.

holeY : float
    The dimension of the hole in mm along y.

centerholeX : float
    The x-cordinate of the hole's centre, in mm.

centerholeY : float
    The y-cordinate of the hole's centre, in mm.
SupportRectangleRectHole( DimensionX: float, DimensionY: float, HoleX: float, HoleY: float, CenterHoleX: float, CenterHoleY: float)
308    def __init__(
309        self, DimensionX: float, DimensionY: float, HoleX: float, HoleY: float, CenterHoleX: float, CenterHoleY: float
310    ):
311        """
312        Parameters
313        ----------
314        DimensionX : float
315            The dimension in mm along x.
316
317        DimensionY : float
318            The dimension in mm along y.
319
320        HoleX : float
321            The dimension of the hole in mm along x.
322
323        HoleY : float
324            The dimension of the hole in mm along y.
325
326        CenterHoleX : float
327            The x-cordinate of the hole's centre, in mm.
328
329        CenterHoleY : float
330            The y-cordinate of the hole's centre, in mm.
331
332        """
333        self.dimX = DimensionX
334        self.dimY = DimensionY
335        self.holeX = HoleX
336        self.holeY = HoleY
337        self.centerholeX = CenterHoleX
338        self.centerholeY = CenterHoleY

Parameters

DimensionX : float The dimension in mm along x.

DimensionY : float The dimension in mm along y.

HoleX : float The dimension of the hole in mm along x.

HoleY : float The dimension of the hole in mm along y.

CenterHoleX : float The x-cordinate of the hole's centre, in mm.

CenterHoleY : float The y-cordinate of the hole's centre, in mm.

dimX
dimY
holeX
holeY
centerholeX
centerholeY

12 - ModuleSurface

Provides classes for different reflective surfaces.

Created in Nov 2024

@author: Andre Kalouguine

 1"""
 2Provides classes for different reflective surfaces.
 3
 4Created in Nov 2024
 5
 6@author: Andre Kalouguine
 7"""
 8import ARTcore.ModuleGeometry as mgeo
 9
10import numpy as np
11from copy import copy
12from abc import ABC, abstractmethod
13
14
15class Surface(ABC):
16    """
17    Abstract base class for surfaces.
18    
19    This is where we should ideally define the roughness, scratches/digs, spectral response, etc.
20
21    As for the global sag, that might be better defined in the optical elements themselves.
22    """
23    pass
24
25class IdealSurface(Surface):
26    """
27    Ideal surface, i.e. no roughness, no scratches, no digs, no spectral response.
28    """
29    def __init__(self):
30        super().__init__()
31    def reflect_ray(self, ray, point, normal):
32        VectorRay = ray.vector
33        VectorRayReflected = VectorRay-2*normal*np.dot(VectorRay,normal) # Is it any better than SymmetricalVector?
34        local_reflectedray = copy(ray)
35        local_reflectedray.point = point
36        local_reflectedray.vector = VectorRayReflected
37        local_reflectedray.incidence = mgeo.AngleBetweenTwoVectors(-VectorRay, normal)
38        new_length = np.linalg.norm(point - ray.point)
39        local_reflectedray.path  = ray.path + (new_length,)
40        return local_reflectedray
41    
class Surface(abc.ABC):
16class Surface(ABC):
17    """
18    Abstract base class for surfaces.
19    
20    This is where we should ideally define the roughness, scratches/digs, spectral response, etc.
21
22    As for the global sag, that might be better defined in the optical elements themselves.
23    """
24    pass

Abstract base class for surfaces.

This is where we should ideally define the roughness, scratches/digs, spectral response, etc.

As for the global sag, that might be better defined in the optical elements themselves.

class IdealSurface(Surface):
26class IdealSurface(Surface):
27    """
28    Ideal surface, i.e. no roughness, no scratches, no digs, no spectral response.
29    """
30    def __init__(self):
31        super().__init__()
32    def reflect_ray(self, ray, point, normal):
33        VectorRay = ray.vector
34        VectorRayReflected = VectorRay-2*normal*np.dot(VectorRay,normal) # Is it any better than SymmetricalVector?
35        local_reflectedray = copy(ray)
36        local_reflectedray.point = point
37        local_reflectedray.vector = VectorRayReflected
38        local_reflectedray.incidence = mgeo.AngleBetweenTwoVectors(-VectorRay, normal)
39        new_length = np.linalg.norm(point - ray.point)
40        local_reflectedray.path  = ray.path + (new_length,)
41        return local_reflectedray

Ideal surface, i.e. no roughness, no scratches, no digs, no spectral response.

def reflect_ray(self, ray, point, normal):
32    def reflect_ray(self, ray, point, normal):
33        VectorRay = ray.vector
34        VectorRayReflected = VectorRay-2*normal*np.dot(VectorRay,normal) # Is it any better than SymmetricalVector?
35        local_reflectedray = copy(ray)
36        local_reflectedray.point = point
37        local_reflectedray.vector = VectorRayReflected
38        local_reflectedray.incidence = mgeo.AngleBetweenTwoVectors(-VectorRay, normal)
39        new_length = np.linalg.norm(point - ray.point)
40        local_reflectedray.path  = ray.path + (new_length,)
41        return local_reflectedray