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# %% More traditional vector operations that don't belong in the classes
153def Normalize(vector):
154    """
155    Normalize Vector.
156    Obsolete, use use the mgeo.Vector class instead as it has a `normalize` method.
157    """
158    return vector / np.linalg.norm(vector)
159
160def VectorPerpendicular(vector):
161    """
162    Find a perpendicular 3D vector in some arbitrary direction
163    Undefined behavior, use with caution. There is no unique perpendicular vector to a 3D vector.
164    """
165    logger.warning("VectorPerpendicular is undefined behavior. There is no unique perpendicular vector to a 3D vector.")
166    if abs(vector[0]) < 1e-15:
167        return Vector([1, 0, 0])
168    if abs(vector[1]) < 1e-15:
169        return Vector([0, 1, 0])
170    if abs(vector[2]) < 1e-15:
171        return Vector([0, 0, 1])
172
173    # set arbitrarily a = b =1
174    return Vector([1, 1, -1.0 * (vector[0] + vector[1]) / vector[2]]).normalized()
175
176def AngleBetweenTwoVectors(U, V):
177    """
178    Return the angle in radians between the vectors U and V ; formula from W.Kahan
179    Value in radians between 0 and pi.
180    """
181    u = np.linalg.norm(U)
182    v = np.linalg.norm(V)
183    return 2 * np.arctan2(np.linalg.norm(U * v - V * u), np.linalg.norm(U * v + V * u))
184
185def SymmetricalVector(V, SymmetryAxis):
186    """
187    Return the symmetrical vector to V
188    """
189    q = QRotationAroundAxis(SymmetryAxis, np.pi)
190    return V.rotate(q)
191
192def normal_add(N1, N2):
193    """
194    Simple function that takes in two normal vectors of a deformation and calculates
195    the total normal vector if the two deformations were individually applied.
196    Be very careful, this *only* works when the surface is z = f(x,y) and when 
197    the deformation is small.
198    Might be made obsolete when shifting to a modernised deformation system.
199    """
200    normal1 = N1.normalized()
201    normal2 = N2.normalized()
202    grad1 = -normal1[:2] / normal1[2]
203    grad2 = -normal2[:2] / normal2[2]
204    grad = grad1 + grad2
205    total_normal = np.append(-grad, 1)
206    return Vector(total_normal).normalized()
207
208# %% Intersection finding
209def IntersectionLinePlane(A, u, P, n):
210    """
211    Return the intersection point between a line and a plane.
212    A is a point of the line, u a vector of the line ; P is a point of the plane, n a normal vector
213    Line's equation : OM = u*t + OA , t a real
214    Plane's equation : n.OP - n.OM = 0
215    """
216    t = np.dot(n, -A + P) / np.dot(u, n)
217    I = u * t + A
218    return I
219
220def IntersectionRayListZPlane(RayList, Z=np.array([0])):
221    """
222    Return the intersection of a list of rays with a different planes with equiations z = Z[i]
223    Basically, by default it returns the intersection of the rays with the Z=0 plane but you can 
224    give it a few values of Z and it should be faster than calling it multiple times.
225    This should let us quickly find the optimal position of the detector as well as trace the caustics.
226    If a ray does not intersect the plane... it should replace that point with a NaN. 
227    """
228    Positions = np.vstack([i.point for i in RayList])
229    Vectors = np.vstack([i.vector for i in RayList])
230    non_zero = Vectors[:,2] != 0
231    Positions = Positions[non_zero]
232    Vectors = Vectors[non_zero]
233    Z = Z[:, np.newaxis]
234    A = Positions[:,2]-Z
235    B = -Vectors[:,2]
236    #times = (Positions[:,2]-Z)/Vectors[:,2]
237    #return A,B
238    with np.errstate(divide='ignore', invalid='ignore'):
239        times = np.divide(A, B, where=(B != 0), out=np.full_like(A, np.nan))
240        #times[times < 0] = np.nan  # Set negative results to NaN
241    #return times
242    #positive_times = times >= 0
243    intersect_positions = Positions[:, :2] + times[:, :, np.newaxis] * Vectors[:, :2]
244    result = []
245    for i in range(Z.shape[0]):
246        # For each plane, we find the intersection points
247        #valid_intersections = intersect_positions[i][positive_times[i]]
248        valid_intersections = intersect_positions[i]
249        result.append(PointArray(valid_intersections))
250    return result
251
252
253# %% Geometrical utilities for plotting
254def SpiralVogel(NbPoint, Radius):
255    """
256    Return a NbPoint x 2 matrix of 2D points representative of Vogel's spiral with radius Radius
257    Careful, contrary to most of the code, this is *not* in the 
258    ARTcore.Vector or ARTcore.Point format. It is a simple numpy array. 
259    The reason is that this is a utility function that can be used both to define directions
260    and to generate grids of points.
261    """
262    GoldenAngle = np.pi * (3 - np.sqrt(5))
263    r = np.sqrt(np.arange(NbPoint) / NbPoint) * Radius
264
265    theta = GoldenAngle * np.arange(NbPoint)
266
267    Matrix = np.zeros((NbPoint, 2))
268    Matrix[:, 0] = np.cos(theta)
269    Matrix[:, 1] = np.sin(theta)
270    Matrix = Matrix * r.reshape((NbPoint, 1))
271
272    return Matrix
273
274def find_hull(points):
275    """
276    Find the convex hull of a set of points using a greedy algorithm.
277    This is used to create a polygon that encloses the points.
278    """
279    # start from leftmost point
280    current_point = min(range(len(points)), key=lambda i: points[i][0])
281    # initialize hull with current point
282    hull = [current_point]
283    # initialize list of linked points
284    linked = []
285    # continue until all points have been linked
286    while len(linked) < len(points) - 1:
287        # initialize minimum distance and closest point
288        min_distance = math.inf
289        closest_point = None
290        # find closest unlinked point to current point
291        for i, point in enumerate(points):
292            if i not in linked:
293                distance = math.dist(points[current_point], point)
294                if distance < min_distance:
295                    min_distance = distance
296                    closest_point = i
297        # add closest point to hull and linked list
298        hull.append(closest_point)
299        linked.append(closest_point)
300        # update current point
301        current_point = closest_point
302    # add link between last point and first point
303    hull.append(hull[0])
304    # convert hull to a list of pairs of indices
305    indices = [[hull[i], hull[i + 1]] for i in range(len(hull) - 1)]
306    return indices
307
308
309# %% Solvers and utilities for solving equations
310def SolverQuadratic(a, b, c):
311    """
312    Solve the quadratic equation a*x^2 + b*x +c = 0 ; keep only real solutions
313    """
314    Solution = np.roots([a, b, c])
315    RealSolution = []
316
317    for k in range(len(Solution)):
318        if abs(Solution[k].imag) < 1e-15:
319            RealSolution.append(Solution[k].real)
320
321    return RealSolution
322
323
324def SolverQuartic(a, b, c, d, e):
325    """
326    Solve the quartic equation a*x^4 + b*x^3 +c*x^2 + d*x + e = 0 ; keep only real solutions
327    """
328    Solution = np.roots([a, b, c, d, e])
329    RealSolution = []
330
331    for k in range(len(Solution)):
332        if abs(Solution[k].imag) < 1e-15:
333            RealSolution.append(Solution[k].real)
334
335    return RealSolution
336
337
338def KeepPositiveSolution(SolutionList):
339    """
340    Keep only positive solution (numbers) in the list
341    """
342    PositiveSolutionList = []
343    epsilon = 1e-12
344    for k in SolutionList:
345        if k > epsilon:
346            PositiveSolutionList.append(k)
347
348    return PositiveSolutionList
349
350
351def KeepNegativeSolution(SolutionList):
352    """
353    Keep only positive solution (numbers) in the list
354    """
355    NegativeSolutionList = []
356    epsilon = -1e-12
357    for k in SolutionList:
358        if k < epsilon:
359            NegativeSolutionList.append(k)
360
361    return NegativeSolutionList
362
363
364# %% Point geometry tools
365def ClosestPoint(A: Point, Points: PointArray):
366    """
367    Given a reference point A and an array of points, return the index of the point closest to A
368    """
369    distances = (Points-A).norm
370    return np.argmin(distances)
371
372def DiameterPointArray(Points: PointArray):
373    """
374    Return the diameter of the smallest circle (for 2D points) 
375    or sphere (3D points) including all the points.
376    """
377    if len(Points) == 0:
378        return None
379    return float(np.ptp(Points, axis=0).max())
380
381def CentrePointList(Points):
382    """
383    Shift all 2D-points in PointList so as to center the point-cloud on the origin [0,0].
384    """
385    return Points - np.mean(Points, axis=0)
386
387# %% Solid object orientation
388
389def RotateSolid(Object, q):
390    """
391    Rotate object around basepoint by quaternion q
392    """
393    Object.q = q*Object.q
394
395def TranslateSolid(Object, T):
396    """
397    Translate object by vector T
398    """
399    Object.r = Object.r + T
400
401def RotateSolidAroundInternalPointByQ(Object, q, P):
402    """
403    Rotate object around P by quaternion q where P is in the object's frame
404    """
405    pass #TODO
406
407def RotateSolidAroundExternalPointByQ(Object, q, P):
408    """Rotate object around P by quaternion q, where P is in the global frame"""
409    pass #TODO
410
411
412# %% Signed distance functions 
413
414def SDF_Rectangle(Point, SizeX, SizeY):
415    """Signed distance function for a rectangle centered at the origin"""
416    d = np.abs(Point[:2]) - np.array([SizeX, SizeY]) / 2
417    return (np.linalg.norm(np.maximum(d, 0)) + np.min(np.max(d, 0)))/2
418
419def SDF_Circle(Point, Radius):
420    """Signed distance function for a circle centered at the origin"""
421    return np.linalg.norm(Point[:2]) - Radius
422
423def Union_SDF(SDF1, SDF2):
424    """Union of two signed distance functions"""
425    return np.minimum(SDF1, SDF2)
426
427def Difference_SDF(SDF1, SDF2):
428    """Difference of two signed distance functions"""
429    return np.maximum(SDF1, -SDF2)
430
431def Intersection_SDF(SDF1, SDF2):
432    """Intersection of two signed distance functions"""
433    return np.maximum(SDF1, SDF2)
434
435
436
437# %% Quaternion calculations
438def QRotationAroundAxis(Axis, Angle):
439    """
440    Return quaternion for rotation by Angle (in rad) around Axis
441    """
442    rot_axis = Normalize(np.array([0.0] + Axis))
443    axis_angle = (Angle * 0.5) * rot_axis
444    qlog = np.quaternion(*axis_angle)
445    q = np.exp(qlog)
446    return q
447
448def QRotationVector2Vector(Vector1, Vector2):
449    """
450    Return a possible quaternion (among many) that would rotate Vector1 into Vector2.
451    Undefined behavior, use with caution. There is no unique quaternion that rotates one vector into another.
452    """
453    Vector1 = Normalize(Vector1)
454    Vector2 = Normalize(Vector2)
455    a = np.cross(Vector1, Vector2)
456    return np.quaternion(1 + np.dot(Vector1, Vector2), *a).normalized()
457
458def QRotationVectorPair2VectorPair(InitialVector1, Vector1, InitialVector2, Vector2):
459    """
460    Return the only quaternion that rotates InitialVector1 to Vector1 and InitialVector2 to Vector2.
461    Please ensure orthogonality two input and two output vectors.
462    """
463    Vector1 = Normalize(Vector1)
464    Vector2 = Normalize(Vector2)
465    Vector3 = Normalize(np.cross(Vector1,Vector2))
466    InitialVector1 = Normalize(InitialVector1)
467    InitialVector2 = Normalize(InitialVector2)
468    InitialVector3 = Normalize(np.cross(InitialVector1,InitialVector2))
469    rot_2Initial = np.zeros((3,3))
470    rot_2Initial[:,0] = InitialVector1
471    rot_2Initial[:,1] = InitialVector2
472    rot_2Initial[:,2] = InitialVector3
473    rot_2Final = np.zeros((3,3))
474    rot_2Final[:,0] = Vector1
475    rot_2Final[:,1] = Vector2
476    rot_2Final[:,2] = Vector3
477    q2Init = quaternion.from_rotation_matrix(rot_2Initial)
478    q2Fin = quaternion.from_rotation_matrix(rot_2Final)
479    return (q2Fin/q2Init).normalized()
480
481
482# %% RayList stuff
483def RotationRayList(RayList, q):
484    """Like RotationPointList but with a list of Ray objects"""
485    return [i.rotate(q) for i in RayList]
486
487def TranslationRayList(RayList, T):
488    """Translate a RayList by vector T"""
489    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. 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:

>>> 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. 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:

>>> 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. 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:

>>> 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. 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:

>>> 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):
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)

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

def VectorPerpendicular(vector):
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()

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):
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))

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):
186def SymmetricalVector(V, SymmetryAxis):
187    """
188    Return the symmetrical vector to V
189    """
190    q = QRotationAroundAxis(SymmetryAxis, np.pi)
191    return V.rotate(q)

Return the symmetrical vector to V

def normal_add(N1, N2):
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()

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):
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

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])):
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

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):
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

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):
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

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):
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

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

def SolverQuartic(a, b, c, d, e):
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

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

def KeepPositiveSolution(SolutionList):
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

Keep only positive solution (numbers) in the list

def KeepNegativeSolution(SolutionList):
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

Keep only positive solution (numbers) in the list

def ClosestPoint( A: Point, Points: PointArray):
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)

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

def DiameterPointArray(Points: PointArray):
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())

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

def CentrePointList(Points):
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)

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

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

Rotate object around basepoint by quaternion q

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

Translate object by vector T

def RotateSolidAroundInternalPointByQ(Object, q, P):
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

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

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

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

def SDF_Rectangle(Point, SizeX, SizeY):
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

Signed distance function for a rectangle centered at the origin

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

Signed distance function for a circle centered at the origin

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

Union of two signed distance functions

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

Difference of two signed distance functions

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

Intersection of two signed distance functions

def QRotationAroundAxis(Axis, Angle):
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

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

def QRotationVector2Vector(Vector1, Vector2):
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()

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):
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()

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):
484def RotationRayList(RayList, q):
485    """Like RotationPointList but with a list of Ray objects"""
486    return [i.rotate(q) for i in RayList]

Like RotationPointList but with a list of Ray objects

def TranslationRayList(RayList, T):
488def TranslationRayList(RayList, T):
489    """Translate a RayList by vector T"""
490    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        ListPointIntersection = []
 288        for t in Solution:
 289            Intersect = Ray.vector * t + Ray.point
 290            if Intersect[2] < 0 and Intersect in self.support:
 291                ListPointIntersection.append(Intersect)
 292
 293        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
 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)
 303
 304# %% Parabolic mirror definitions
 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
 479
 480
 481# %% Toroidal mirror definitions
 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        )
 606
 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
 636
 637
 638# %% Ellipsoidal mirror definitions
 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
 811    
 812
 813# %% Cylindrical mirror definitions
 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)
 906
 907
 908# %% Grazing parabolic mirror definitions
 909# A grazing parabola is no different from a parabola, it's just a question of what we consider to be the support.
 910# 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.
 911
 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
 997
 998
 999# %% Reflections on mirrors, is it useful to keep this?
1000def _ReflectionMirrorRay(Mirror, PointMirror, Ray):
1001    """
1002    Return the reflected ray according to the law of reflection.
1003
1004    Parameters
1005    ----------
1006        Mirror : Mirror-objectS
1007
1008        PointMirror : np.ndarray
1009            Point of reflection on the mirror surface.
1010
1011        Ray : Ray-object
1012
1013    """
1014    PointRay = Ray.point
1015    VectorRay = Ray.vector
1016    NormalMirror = Mirror.get_local_normal(PointMirror)
1017
1018    #VectorRayReflected = mgeo.SymmetricalVector(-VectorRay, NormalMirror)
1019    VectorRayReflected = VectorRay- 2*NormalMirror*np.dot(VectorRay,NormalMirror) # Is it any better than SymmetricalVector?
1020
1021    RayReflected = Ray.copy_ray()
1022    RayReflected.point = PointMirror
1023    RayReflected.vector = VectorRayReflected
1024    RayReflected.incidence = mgeo.AngleBetweenTwoVectors(
1025    -VectorRay, NormalMirror
1026    )
1027    RayReflected.path = Ray.path + (np.linalg.norm(PointMirror - PointRay),)
1028
1029    return RayReflected
1030
1031
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
1060
1061
1062# %% Deformed mirror definitions, need to be reworked, maybe implemented as coatings?
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
logger = <Logger ARTcore.ModuleMirror (WARNING)>
class Curvature(enum.Enum):
39class Curvature(Enum):
40    CONVEX = -1
41    FLAT = 0
42    CONCAVE = 1

An enumeration.

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):
651def DrawAsphericity(Mirror, Npoints=1000):
652    """
653    This function displays a map of the asphericity of the mirror.
654    It's a scatter plot of the points of the mirror surface, with the color representing the distance to the closest sphere.
655    The closest sphere is calculated by the function get_closest_sphere, so least square method.
656
657    Parameters
658    ----------
659    Mirror : Mirror
660        The mirror to analyse.
661
662    Npoints : int, optional
663        The number of points to sample on the mirror surface. The default is 1000.
664    
665    Returns
666    -------
667    fig : Figure
668        The figure of the plot.
669    """
670    plt.ion()
671    fig = plt.figure()
672    ax = Mirror.support._ContourSupport(fig)
673    center, radius = man.GetClosestSphere(Mirror, Npoints)
674    Points = mpm.sample_support(Mirror.support, Npoints=1000)
675    Points += Mirror.r0[:2]
676    Z = Mirror._zfunc(Points)
677    Points = mgeo.PointArray([Points[:, 0], Points[:, 1], Z]).T
678    X, Y = Points[:, 0] - Mirror.r0[0], Points[:, 1] - Mirror.r0[1]
679    Points_centered = Points - center
680    Distance = np.linalg.norm(Points_centered, axis=1) - radius
681    Distance*=1e3 # To convert to µm
682    p = plt.scatter(X, Y, c=Distance, s=15)
683    divider = man.make_axes_locatable(ax)
684    cax = divider.append_axes("right", size="5%", pad=0.05)
685    cbar = fig.colorbar(p, cax=cax)
686    cbar.set_label("Distance to closest sphere (µm)")
687    ax.set_xlabel("x (mm)")
688    ax.set_ylabel("y (mm)")
689    plt.title("Asphericity map", loc="right")
690    plt.tight_layout()
691
692    bbox = ax.get_position()
693    bbox.set_points(bbox.get_points() - np.array([[0.01, 0], [0.01, 0]]))
694    ax.set_position(bbox)
695    plt.show()
696    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        ListPointIntersection = []
289        for t in Solution:
290            Intersect = Ray.vector * t + Ray.point
291            if Intersect[2] < 0 and Intersect in self.support:
292                ListPointIntersection.append(Intersect)
293
294        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
295
296    def get_local_normal(self, Point):
297        """Return the normal unit vector on the spherical surface at point Point."""
298        return -Vector(Point).normalized()
299
300    def _zfunc(self, PointArray):
301        x = PointArray[:,0]
302        y = PointArray[:,1]
303        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):
296    def get_local_normal(self, Point):
297        """Return the normal unit vector on the spherical surface at point Point."""
298        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):
306class MirrorParabolic(Mirror):
307    r"""
308    A paraboloid with vertex at the origin $O=[0,0,0]$ and symmetry axis z:
309    $z = \frac{1}{4f}[x^2 + y^2]$ where $f$ is the focal lenght of the *mother*
310    parabola (i.e. measured from its center at $O$ to the focal point $F$).
311
312    The center of the support is shifted along the x-direction by the off-axis distance $x_c$.
313    This leads to an *effective focal length* $f_\mathrm{eff}$, measured from the shifted center
314    of the support  $P$ to the focal point $F$.
315    It is related to the mother focal length by $f = f_\\mathrm{eff} \cos^2(\alpha/2) $,
316    or equivalently $ p = 2f = f_\mathrm{eff} (1+\\cos\\alpha)$, where $\\alpha$
317    is the off-axis angle, and $p = 2f$ is called the semi latus rectum.
318
319    Another useful relationship is that between the off-axis distance and the resulting
320    off-axis angle: $x_c = 2 f \tan(\alpha/2)$.
321
322
323    ![Illustration of a parabolic mirror.](parabola.svg)
324
325    Attributes
326    ----------
327        offaxisangle : float
328            Off-axis angle of the parabola. Modifying this also updates p, keeping feff constant.
329            Attention: The off-axis angle must be *given in degrees*, but is stored and will be *returned in radian* !
330
331        feff : float
332            Effective focal length of the parabola in mm. Modifying this also updates p, keeping offaxisangle constant.
333
334        p : float
335            Semi latus rectum of the parabola in mm. Modifying this also updates feff, keeping offaxisangle constant.
336
337        support : ART.ModuleSupport.Support
338
339        type : str 'Parabolic Mirror'.
340
341    Methods
342    -------
343        MirrorParabolic.get_normal(Point)
344
345        MirrorParabolic.get_centre()
346
347        MirrorParabolic.get_grid3D(NbPoints)
348
349    """
350    #vectorised = True # Unitl I fix the closest solution thing
351    def __init__(self, Support,
352                FocalEffective: float=None,
353                OffAxisAngle: float = None,
354                FocalParent: float = None,
355                RadiusParent: float = None,
356                OffAxisDistance: float = None,
357                MoreThan90: bool = None,
358                **kwargs):
359        """
360        Initialise a Parabolic mirror.
361
362        Parameters
363        ----------
364            FocalEffective : float
365                Effective focal length of the parabola in mm.
366
367            OffAxisAngle : float
368                Off-axis angle *in degrees* of the parabola.
369
370            Support : ART.ModuleSupport.Support
371
372        """
373        super().__init__()
374        self.curvature = Curvature.CONCAVE
375        self.support = Support
376        self.type = "Parabolic Mirror"
377
378        if "Surface" in kwargs:
379            self.Surface = kwargs["Surface"]
380
381        parameter_calculator = OAP_calculator()
382        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
383                                                             fs=FocalEffective,
384                                                             theta=OffAxisAngle,
385                                                             fp=FocalParent,
386                                                             Rc=RadiusParent,
387                                                             OAD=OffAxisDistance,
388                                                             more_than_90=MoreThan90)
389        self._offaxisangle = values["theta"]
390        self._feff = values["fs"]
391        self._p = values["p"]
392        self._offaxisdistance = values["OAD"]
393        self._fparent = values["fp"]
394        self._rparent = values["Rc"]
395
396        self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
397
398        self.centre_ref = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
399        self.support_normal_ref = Vector([0, 0, 1.0])
400        self.majoraxis_ref = Vector([1.0, 0, 0])
401        
402        self.focus_ref = Point([0.0, 0, self._fparent])
403        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
404        self.towards_collimated_ref = Vector([0, 0, 1.0])
405
406        self.add_global_points("focus", "centre")
407        self.add_global_vectors("towards_focusing", "towards_collimated", "support_normal", "majoraxis")
408
409    def _get_intersection(self, Ray):
410        """Return the intersection point between the ray and the parabola."""
411        ux, uy, uz = Ray.vector
412        xA, yA, zA = Ray.point
413
414        da = ux**2 + uy**2
415        db = 2 * (ux * xA + uy * yA) - 2 * self._p * uz
416        dc = xA**2 + yA**2 - 2 * self._p * zA
417
418        Solution = mgeo.SolverQuadratic(da, db, dc)
419        Solution = mgeo.KeepPositiveSolution(Solution)
420        IntersectionPoint = [Ray.point + Ray.vector * t for t in Solution if t > 0]
421        distances = [mgeo.Vector(i - self.r0).norm for i in IntersectionPoint]
422        IntersectionPoint = IntersectionPoint[distances.index(min(distances))]
423        return IntersectionPoint, IntersectionPoint-self.r0 in self.support
424    
425    def _get_intersections(self, RayList):
426        """
427        Vectorised version of the intersection calculation.
428        """
429        vectors = np.array([ray.vector for ray in RayList])
430        points = np.array([ray.point for ray in RayList])
431        Solutions = np.zeros(len(RayList), dtype=float)
432
433        da = np.sum(vectors[:,0:2]**2, axis=1)
434        db = 2 * (np.sum(vectors[:,0:2] * points[:,0:2], axis=1) - self._p * vectors[:,2])
435        dc = np.sum(points[:,0:2]**2, axis=1) - 2 * self._p * points[:,2]
436
437        linear = da == 0
438        Solutions[linear] = -dc[linear] / db[linear]
439
440        centered = (dc==0) & (da!=0) # If equation is of form ax**2 + bx = 0
441        Solutions[centered] = np.maximum(-db[centered] / da[centered], 0)
442
443        unstable = (4*da*dc) < db**2 * 1e-10 # Cases leading to catastrophic cancellation
444
445        Deltas = db**2 - 4 * da * dc
446        OK = Deltas >= 0
447        SolutionsPlus = (-db + np.sqrt(Deltas)) / 2 / da
448        SolutionsMinus = (-db - np.sqrt(Deltas)) / 2 / da
449        SolutionsPlus[unstable] = dc[unstable] / (da[unstable] * SolutionsMinus[unstable])
450        SolutionsPlus = np.where(SolutionsPlus >= 0, SolutionsPlus, np.inf)
451        SolutionsMinus = np.where(SolutionsMinus >= 0, SolutionsMinus, np.inf)
452        Solutions = np.minimum(SolutionsPlus, SolutionsMinus)
453        Solutions = np.maximum(Solutions, 0)
454        Points = points + vectors * Solutions[:,np.newaxis]
455        OK = OK & (Solutions > 0) & np.array([p-self.r0 in self.support for p in Points])
456        return mgeo.PointArray(Points), OK
457
458    def get_local_normal(self, Point):
459        """Return the normal unit vector on the paraboloid surface at point Point."""
460        Gradient = Vector(np.zeros(3))
461        Gradient[0] = -Point[0]
462        Gradient[1] = -Point[1]
463        Gradient[2] = self._p
464        return Gradient.normalized()
465
466    def get_local_normals(self, Points):
467        """
468        Vectorised version of the normal calculation.
469        """
470        Gradients = mgeo.VectorArray(np.zeros_like(Points))
471        Gradients[:,0] = -Points[:,0]
472        Gradients[:,1] = -Points[:,1]
473        Gradients[:,2] = self._p
474        return Gradients.normalized()
475    
476    def _zfunc(self, PointArray):
477        x = PointArray[:,0]
478        y = PointArray[:,1]
479        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)
351    def __init__(self, Support,
352                FocalEffective: float=None,
353                OffAxisAngle: float = None,
354                FocalParent: float = None,
355                RadiusParent: float = None,
356                OffAxisDistance: float = None,
357                MoreThan90: bool = None,
358                **kwargs):
359        """
360        Initialise a Parabolic mirror.
361
362        Parameters
363        ----------
364            FocalEffective : float
365                Effective focal length of the parabola in mm.
366
367            OffAxisAngle : float
368                Off-axis angle *in degrees* of the parabola.
369
370            Support : ART.ModuleSupport.Support
371
372        """
373        super().__init__()
374        self.curvature = Curvature.CONCAVE
375        self.support = Support
376        self.type = "Parabolic Mirror"
377
378        if "Surface" in kwargs:
379            self.Surface = kwargs["Surface"]
380
381        parameter_calculator = OAP_calculator()
382        values,steps = parameter_calculator.calculate_values(verify_consistency=True,
383                                                             fs=FocalEffective,
384                                                             theta=OffAxisAngle,
385                                                             fp=FocalParent,
386                                                             Rc=RadiusParent,
387                                                             OAD=OffAxisDistance,
388                                                             more_than_90=MoreThan90)
389        self._offaxisangle = values["theta"]
390        self._feff = values["fs"]
391        self._p = values["p"]
392        self._offaxisdistance = values["OAD"]
393        self._fparent = values["fp"]
394        self._rparent = values["Rc"]
395
396        self.r0 = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
397
398        self.centre_ref = Point([self._offaxisdistance, 0, self._offaxisdistance**2 / 2 / self._p])
399        self.support_normal_ref = Vector([0, 0, 1.0])
400        self.majoraxis_ref = Vector([1.0, 0, 0])
401        
402        self.focus_ref = Point([0.0, 0, self._fparent])
403        self.towards_focusing_ref = (self.focus_ref-self.centre_ref).normalized()
404        self.towards_collimated_ref = Vector([0, 0, 1.0])
405
406        self.add_global_points("focus", "centre")
407        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):
458    def get_local_normal(self, Point):
459        """Return the normal unit vector on the paraboloid surface at point Point."""
460        Gradient = Vector(np.zeros(3))
461        Gradient[0] = -Point[0]
462        Gradient[1] = -Point[1]
463        Gradient[2] = self._p
464        return Gradient.normalized()

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

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

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)
511    def __init__(self, Support, MajorRadius, MinorRadius, **kwargs):
512        """
513        Construct a toroidal mirror.
514
515        Parameters
516        ----------
517            MajorRadius : float
518                Major radius of the toroid in mm.
519
520            MinorRadius : float
521                Minor radius of the toroid in mm.
522
523            Support : ART.ModuleSupport.Support
524
525        """
526        super().__init__()
527        self.support = Support
528        self.type = "Toroidal Mirror"
529
530        if "Surface" in kwargs:
531            self.Surface = kwargs["Surface"]
532
533        self.curvature = Curvature.CONCAVE
534
535        self.majorradius = MajorRadius
536        self.minorradius = MinorRadius
537
538        self.r0 = Point([0.0, 0.0, -MajorRadius - MinorRadius])
539        
540        self.centre_ref = Point([0.0, 0.0, -MajorRadius - MinorRadius])
541        self.support_normal_ref = Vector([0, 0, 1.0])
542        self.majoraxis_ref = Vector([1.0, 0, 0])
543
544        self.add_global_vectors("support_normal", "majoraxis")
545        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):
581    def get_local_normal(self, Point):
582        """Return the normal unit vector on the toroidal surface at point Point in the reference frame of the mirror"""
583        x = Point[0]
584        y = Point[1]
585        z = Point[2]
586        A = self.majorradius**2 - self.minorradius**2
587
588        Gradient = Vector(np.zeros(3))
589        Gradient[0] = (
590            4 * (x**3 + x * y**2 + x * z**2 + x * A)
591            - 8 * x * self.majorradius**2
592        )
593        Gradient[1] = 4 * (y**3 + y * x**2 + y * z**2 + y * A)
594        Gradient[2] = (
595            4 * (z**3 + z * x**2 + z * y**2 + z * A)
596            - 8 * z * self.majorradius**2
597        )
598
599        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'>):
608def ReturnOptimalToroidalRadii(
609    Focal: float, AngleIncidence: float
610) -> (float, float):
611    """
612    Get optimal parameters for a toroidal mirror.
613
614    Useful helper function to get the optimal major and minor radii for a toroidal mirror to achieve a
615    focal length 'Focal' with an angle of incidence 'AngleIncidence' and with vanishing astigmatism.
616
617    Parameters
618    ----------
619        Focal : float
620            Focal length in mm.
621
622        AngleIncidence : int
623            Angle of incidence in degrees.
624
625    Returns
626    -------
627        OptimalMajorRadius, OptimalMinorRadius : float, float.
628    """
629    AngleIncidenceRadian = AngleIncidence * np.pi / 180
630    OptimalMajorRadius = (
631        2
632        * Focal
633        * (1 / np.cos(AngleIncidenceRadian) - np.cos(AngleIncidenceRadian))
634    )
635    OptimalMinorRadius = 2 * Focal * np.cos(AngleIncidenceRadian)
636    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):
640class MirrorEllipsoidal(Mirror):
641    """
642    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.
643
644    ![Illustration of a ellipsoidal mirror.](ellipsoid.svg)
645
646    Attributes
647    ----------
648        a : float
649            Semi major axis of the ellipsoid in mm.
650
651        b : float
652            Semi minor axis of the ellipsoid in mm.
653
654        support : ART.ModuleSupport.Support
655
656        type : str 'Ellipsoidal Mirror'.
657
658    Methods
659    -------
660        MirrorEllipsoidal.get_normal(Point)
661
662        MirrorEllipsoidal.get_centre()
663
664        MirrorEllipsoidal.get_grid3D(NbPoints)
665
666    """
667
668    def __init__(
669        self,
670        Support,
671        SemiMajorAxis=None,
672        SemiMinorAxis=None,
673        OffAxisAngle=None,
674        f_object=None,
675        f_image=None,
676        IncidenceAngle=None,
677        Magnification=None,
678        DistanceObjectImage=None,
679        Eccentricity=None,
680        **kwargs,
681    ):
682        """
683        Generate an ellipsoidal mirror with given parameters.
684
685        The angles are given in degrees but converted to radians for internal calculations.
686        You can specify the semi-major and semi-minor axes, the off-axis angle, the object and image focal distances or some other parameters.
687        The constructor uses a magic calculator to determine the missing parameters.
688
689        Parameters
690        ----------
691        Support : TYPE
692            ART.ModuleSupport.Support.
693        SemiMajorAxis : float (optional)
694            Semi major axis of the ellipsoid in mm..
695        SemiMinorAxis : float (optional)
696            Semi minor axis of the ellipsoid in mm..
697        OffAxisAngle : float (optional)
698            Off-axis angle of the mirror in mm. Defined as the angle at the centre of the mirror between the two foci..
699        f_object : float (optional)
700            Object focal distance in mm.
701        f_image : float (optional)
702            Image focal distance in mm.
703        IncidenceAngle : float  (optional)
704            Angle of incidence in degrees.
705        Magnification : float  (optional)
706            Magnification.
707        DistanceObjectImage : float  (optional)
708            Distance between object and image in mm.
709        Eccentricity : float  (optional)
710            Eccentricity of the ellipsoid.
711        """
712        super().__init__()
713        self.type = "Ellipsoidal Mirror"
714        self.support = Support
715
716        if "Surface" in kwargs:
717            self.Surface = kwargs["Surface"]
718
719        parameter_calculator = EllipsoidalMirrorCalculator()
720        values, steps = parameter_calculator.calculate_values(verify_consistency=True, 
721                                                              f1=f_object, 
722                                                              f2=f_image, 
723                                                              a=SemiMajorAxis, 
724                                                              b=SemiMinorAxis, 
725                                                              offset_angle=OffAxisAngle,
726                                                              incidence_angle=IncidenceAngle,
727                                                              m=Magnification,
728                                                              l=DistanceObjectImage,
729                                                              e=Eccentricity)
730        self.a = values["a"]
731        self.b = values["b"]
732        self._offaxisangle = np.deg2rad(values["offset_angle"])
733        self._f_object = values["f1"]
734        self._f_image = values["f2"]
735
736        self.centre_ref = self._get_centre_ref()
737
738        self.r0 = self.centre_ref
739
740        self.support_normal_ref = Vector([0, 0, 1.0])
741        self.majoraxis_ref = Vector([1.0, 0, 0])
742
743        self.f1_ref = Point([-self.a, 0,0])
744        self.f2_ref = Point([ self.a, 0,0])
745        self.towards_image_ref = (self.f2_ref - self.centre_ref).normalized()
746        self.towards_object_ref = (self.f1_ref - self.centre_ref).normalized()
747        self.centre_normal_ref = self.get_local_normal(self.centre_ref)
748
749        self.add_global_points("f1", "f2", "centre")
750        self.add_global_vectors("towards_image", "towards_object", "centre_normal", "support_normal", "majoraxis")
751
752    def _get_intersection(self, Ray):
753        """Return the intersection point between Ray and the ellipsoidal mirror surface."""
754        ux, uy, uz = Ray.vector
755        xA, yA, zA = Ray.point
756
757        da = (uy**2 + uz**2) / self.b**2 + (ux / self.a) ** 2
758        db = 2 * ((uy * yA + uz * zA) / self.b**2 + (ux * xA) / self.a**2)
759        dc = (yA**2 + zA**2) / self.b**2 + (xA / self.a) ** 2 - 1
760
761        Solution = mgeo.SolverQuadratic(da, db, dc)
762        Solution = mgeo.KeepPositiveSolution(Solution)
763
764        ListPointIntersection = []
765        C = self.get_centre_ref()
766        for t in Solution:
767            Intersect = Ray.vector * t + Ray.point
768            if Intersect[2] < 0 and Intersect - C in self.support:
769                ListPointIntersection.append(Intersect)
770
771        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
772
773    def get_local_normal(self, Point):
774        """Return the normal unit vector on the ellipsoidal surface at point Point."""
775        Gradient = Vector(np.zeros(3))
776
777        Gradient[0] = -Point[0] / self.a**2
778        Gradient[1] = -Point[1] / self.b**2
779        Gradient[2] = -Point[2] / self.b**2
780
781        return Gradient.normalized()
782
783    def _get_centre_ref(self):
784        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
785        foci = 2 * np.sqrt(self.a**2 - self.b**2) # distance between the foci
786        h = -foci / 2 / np.tan(self._offaxisangle)
787        R = np.sqrt(foci**2 / 4 + h**2) 
788        sign = 1
789        if math.isclose(self._offaxisangle, np.pi / 2):
790            h = 0
791        elif self._offaxisangle > np.pi / 2:
792            h = -h
793            sign = -1
794        a = 1 - self.a**2 / self.b**2
795        b = -2 * h
796        c = self.a**2 + h**2 - R**2
797        z = (-b + sign * np.sqrt(b**2 - 4 * a * c)) / (2 * a)
798        if math.isclose(z**2, self.b**2):
799            return np.array([0, 0, -self.b])
800        x = self.a * np.sqrt(1 - z**2 / self.b**2)
801        centre = Point([x, 0, sign * z])
802        return centre
803
804    def _zfunc(self, PointArray):
805        x = PointArray[:,0]
806        y = PointArray[:,1]
807        x-= self.r0[0]
808        y-= self.r0[1]
809        z =  -np.sqrt(1 - (x / self.a)**2 - (y / self.b)**2)
810        z += self.r0[2]
811        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)
668    def __init__(
669        self,
670        Support,
671        SemiMajorAxis=None,
672        SemiMinorAxis=None,
673        OffAxisAngle=None,
674        f_object=None,
675        f_image=None,
676        IncidenceAngle=None,
677        Magnification=None,
678        DistanceObjectImage=None,
679        Eccentricity=None,
680        **kwargs,
681    ):
682        """
683        Generate an ellipsoidal mirror with given parameters.
684
685        The angles are given in degrees but converted to radians for internal calculations.
686        You can specify the semi-major and semi-minor axes, the off-axis angle, the object and image focal distances or some other parameters.
687        The constructor uses a magic calculator to determine the missing parameters.
688
689        Parameters
690        ----------
691        Support : TYPE
692            ART.ModuleSupport.Support.
693        SemiMajorAxis : float (optional)
694            Semi major axis of the ellipsoid in mm..
695        SemiMinorAxis : float (optional)
696            Semi minor axis of the ellipsoid in mm..
697        OffAxisAngle : float (optional)
698            Off-axis angle of the mirror in mm. Defined as the angle at the centre of the mirror between the two foci..
699        f_object : float (optional)
700            Object focal distance in mm.
701        f_image : float (optional)
702            Image focal distance in mm.
703        IncidenceAngle : float  (optional)
704            Angle of incidence in degrees.
705        Magnification : float  (optional)
706            Magnification.
707        DistanceObjectImage : float  (optional)
708            Distance between object and image in mm.
709        Eccentricity : float  (optional)
710            Eccentricity of the ellipsoid.
711        """
712        super().__init__()
713        self.type = "Ellipsoidal Mirror"
714        self.support = Support
715
716        if "Surface" in kwargs:
717            self.Surface = kwargs["Surface"]
718
719        parameter_calculator = EllipsoidalMirrorCalculator()
720        values, steps = parameter_calculator.calculate_values(verify_consistency=True, 
721                                                              f1=f_object, 
722                                                              f2=f_image, 
723                                                              a=SemiMajorAxis, 
724                                                              b=SemiMinorAxis, 
725                                                              offset_angle=OffAxisAngle,
726                                                              incidence_angle=IncidenceAngle,
727                                                              m=Magnification,
728                                                              l=DistanceObjectImage,
729                                                              e=Eccentricity)
730        self.a = values["a"]
731        self.b = values["b"]
732        self._offaxisangle = np.deg2rad(values["offset_angle"])
733        self._f_object = values["f1"]
734        self._f_image = values["f2"]
735
736        self.centre_ref = self._get_centre_ref()
737
738        self.r0 = self.centre_ref
739
740        self.support_normal_ref = Vector([0, 0, 1.0])
741        self.majoraxis_ref = Vector([1.0, 0, 0])
742
743        self.f1_ref = Point([-self.a, 0,0])
744        self.f2_ref = Point([ self.a, 0,0])
745        self.towards_image_ref = (self.f2_ref - self.centre_ref).normalized()
746        self.towards_object_ref = (self.f1_ref - self.centre_ref).normalized()
747        self.centre_normal_ref = self.get_local_normal(self.centre_ref)
748
749        self.add_global_points("f1", "f2", "centre")
750        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):
773    def get_local_normal(self, Point):
774        """Return the normal unit vector on the ellipsoidal surface at point Point."""
775        Gradient = Vector(np.zeros(3))
776
777        Gradient[0] = -Point[0] / self.a**2
778        Gradient[1] = -Point[1] / self.b**2
779        Gradient[2] = -Point[2] / self.b**2
780
781        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):
815class MirrorCylindrical(Mirror):
816    """
817    Cylindrical mirror surface with eqn. $y^2 + z^2  = R^2$, where $R$ is the radius.
818
819    Attributes
820    ----------
821        radius : float
822            Radius of curvature. A postive value for concave mirror, a negative value for a convex mirror.
823
824        support : ART.ModuleSupport.Support
825
826        type : str 'Cylindrical Mirror'.
827
828    Methods
829    -------
830        MirrorCylindrical.get_normal(Point)
831
832        MirrorCylindrical.get_centre()
833
834        MirrorCylindrical.get_grid3D(NbPoints)
835    """
836        
837    def __init__(self, Support, Radius, **kwargs):
838        """
839        Construct a cylindrical mirror.
840
841        Parameters
842        ----------
843            Radius : float
844                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
845
846            Support : ART.ModuleSupport.Support
847
848        """
849        super().__init__()
850        if Radius < 0:
851            self.type = "CylindricalCX Mirror"
852            self.curvature = Curvature.CONVEX
853            self.radius = -Radius
854        else:
855            self.type = "CylindricalCC Mirror"
856            self.curvature = Curvature.CONCAVE
857            self.radius = Radius
858
859        self.support = Support
860
861        if "Surface" in kwargs:
862            self.Surface = kwargs["Surface"]
863
864        self.r0 = Point([0.0, 0.0, -self.radius])
865
866        self.support_normal_ref = Vector([0, 0, 1.0])
867        self.majoraxis_ref = Vector([1.0, 0, 0])
868        self.centre_ref = Point([0.0, 0.0, -self.radius])
869
870        self.add_global_points("centre")
871        self.add_global_vectors("support_normal", "majoraxis")
872
873    def _get_intersection(self, Ray):
874        """Return the intersection point between the Ray and the cylinder."""
875        uy = Ray.vector[1]
876        uz = Ray.vector[2]
877        yA = Ray.point[1]
878        zA = Ray.point[2]
879
880        a = uy**2 + uz**2
881        b = 2 * (uy * yA + uz * zA)
882        c = yA**2 + zA**2 - self.radius**2
883
884        Solution = mgeo.SolverQuadratic(a, b, c)
885        Solution = mgeo.KeepPositiveSolution(Solution)
886
887        ListPointIntersection = []
888        for t in Solution:
889            Intersect = Ray.vector * t + Ray.point
890            if Intersect[2] < 0 and Intersect in self.support:
891                ListPointIntersection.append(Intersect)
892
893        return _IntersectionRayMirror(Ray.point, ListPointIntersection)
894
895    def get_local_normal(self, Point):
896        """Return the normal unit vector on the cylinder surface at point P."""
897        Gradient = Vector([0, -Point[1], -Point[2]])
898        return Gradient.normalized()
899
900    def get_centre(self):
901        """Return 3D coordinates of the point on the mirror surface at the center of its support."""
902        return Point([0, 0, -self.radius])
903    
904    def _zfunc(self, PointArray):
905        y = PointArray[:,1]
906        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)
837    def __init__(self, Support, Radius, **kwargs):
838        """
839        Construct a cylindrical mirror.
840
841        Parameters
842        ----------
843            Radius : float
844                The radius of curvature in mm. A postive value for concave mirror, a negative value for a convex mirror.
845
846            Support : ART.ModuleSupport.Support
847
848        """
849        super().__init__()
850        if Radius < 0:
851            self.type = "CylindricalCX Mirror"
852            self.curvature = Curvature.CONVEX
853            self.radius = -Radius
854        else:
855            self.type = "CylindricalCC Mirror"
856            self.curvature = Curvature.CONCAVE
857            self.radius = Radius
858
859        self.support = Support
860
861        if "Surface" in kwargs:
862            self.Surface = kwargs["Surface"]
863
864        self.r0 = Point([0.0, 0.0, -self.radius])
865
866        self.support_normal_ref = Vector([0, 0, 1.0])
867        self.majoraxis_ref = Vector([1.0, 0, 0])
868        self.centre_ref = Point([0.0, 0.0, -self.radius])
869
870        self.add_global_points("centre")
871        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):
895    def get_local_normal(self, Point):
896        """Return the normal unit vector on the cylinder surface at point P."""
897        Gradient = Vector([0, -Point[1], -Point[2]])
898        return Gradient.normalized()

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

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

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

def ReflectionMirrorRayList(Mirror, ListRay, IgnoreDefects=False):
1033def ReflectionMirrorRayList(Mirror, ListRay, IgnoreDefects=False):
1034    """
1035    Return the the reflected rays according to the law of reflection for the list of incident rays ListRay.
1036
1037    Rays that do not hit the support are not further propagated.
1038
1039    Updates the reflected rays' incidence angle and path.
1040
1041    Parameters
1042    ----------
1043        Mirror : Mirror-object
1044
1045        ListRay : list[Ray-object]
1046
1047    """
1048    Deformed = type(Mirror) == DeformedMirror
1049    ListRayReflected = []
1050    for k in ListRay:
1051        PointMirror = Mirror._get_intersection(k)
1052
1053        if PointMirror is not None:
1054            if Deformed and IgnoreDefects:
1055                M = Mirror.Mirror
1056            else:
1057                M = Mirror
1058            RayReflected = _ReflectionMirrorRay(M, PointMirror, k)
1059            ListRayReflected.append(RayReflected)
1060    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):
1064class DeformedMirror(Mirror):
1065    def __init__(self, Mirror, DeformationList):
1066        self.Mirror = Mirror
1067        self.DeformationList = DeformationList
1068        self.type = Mirror.type
1069        self.support = self.Mirror.support
1070
1071    def get_local_normal(self, PointMirror):
1072        base_normal = self.Mirror.get_normal(PointMirror)
1073        C = self.get_centre()
1074        defects_normals = [
1075            d.get_normal(PointMirror - C) for d in self.DeformationList
1076        ]
1077        for i in defects_normals:
1078            base_normal = mgeo.normal_add(base_normal, i)
1079            base_normal /= np.linalg.norm(base_normal)
1080        return base_normal
1081
1082    def get_centre(self):
1083        return self.Mirror.get_centre()
1084
1085    def get_grid3D(self, NbPoint, **kwargs):
1086        return self.Mirror.get_grid3D(NbPoint, **kwargs)
1087
1088    def _get_intersection(self, Ray):
1089        Intersect = self.Mirror._get_intersection(Ray)
1090        if Intersect is not None:
1091            h = sum(
1092                D.get_offset(Intersect - self.get_centre())
1093                for D in self.DeformationList
1094            )
1095            alpha = mgeo.AngleBetweenTwoVectors(
1096                -Ray.vector, self.Mirror.get_normal(Intersect)
1097            )
1098            Intersect -= Ray.vector * h / np.cos(alpha)
1099        return Intersect

Abstract base class for mirrors.

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

quickshow()

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='')
 86    def __init__(self, source_rays, optical_elements, detectors, description=""):
 87        """
 88        Parameters
 89        ----------
 90            source_rays : list[mray.Ray]
 91                List of source rays, which are to be traced.
 92
 93            optical_elements : list[moe.OpticalElement]
 94                List of successive optical elements.
 95            
 96            detector: mdet.Detector (optional)
 97                The detector (or list of detectors) to analyse the results.
 98
 99            description : str, optional
100                A string to describe the optical setup. Defaults to ''.
101
102        """
103        self.source_rays = copy.deepcopy(source_rays)
104        # deepcopy so this object doesn't get changed when the global source_rays changes "outside"
105        self.optical_elements = copy.deepcopy(optical_elements)
106        # deepcopy so this object doesn't get changed when the global optical_elements changes "outside"
107        self.detectors = detectors
108        if isinstance(detectors, mdet.Detector):
109            self.detectors = {"Focus": detectors}
110        self.description = description
111        self._output_rays = None
112        self._last_source_rays_hash = None
113        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
140    @property
141    def source_rays(self):
142        return self._source_rays
optical_elements
151    @property
152    def optical_elements(self):
153        return self._optical_elements
detectors
description
def get_input_rays(self):
172    def get_input_rays(self):
173        """
174        Returns the list of source rays.
175        """
176        return self.source_rays

Returns the list of source rays.

def get_output_rays(self, force=False, **kwargs):
178    def get_output_rays(self, force=False, **kwargs):
179        """
180        Returns the list of (lists of) output rays, calculate them if this hasn't been done yet,
181        or if the source-ray-bundle or anything about the optical elements has changed.
182
183        This is the user-facing method to perform the ray-tracing calculation.
184        """
185        current_source_rays_hash = hash(self.source_rays)
186        current_optical_elements_hash = mp._hash_list_of_objects(self.optical_elements)
187        if (current_source_rays_hash != self._last_source_rays_hash) or (current_optical_elements_hash != self._last_optical_elements_hash) or force:
188            print("...ray-tracing...", end="", flush=True)
189            self._output_rays = mp.RayTracingCalculation(self.source_rays, self.optical_elements, **kwargs)
190            print(
191                "\r\033[K", end="", flush=True
192            )  # move to beginning of the line with \r and then delete the whole line with \033[K
193            self._last_source_rays_hash = current_source_rays_hash
194            self._last_optical_elements_hash = current_optical_elements_hash
195
196        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'):
198    def get2dPoints(self, Detector = "Focus"):
199        """
200        Returns the 2D points of the detected rays on the detector.
201        """
202        if isinstance(Detector, str):
203            if Detector in self.detectors.keys():
204                Detector = self.detectors[Detector]
205            else:
206                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
207        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'):
209    def get3dPoints(self, Detector = "Focus"):
210        """
211        Returns the 3D points of the detected rays on the detector.
212        """
213        if isinstance(Detector, str):
214            if Detector in self.detectors.keys():
215                Detector = self.detectors[Detector]
216            else:
217                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
218        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'):
220    def getDelays(self, Detector = "Focus"):
221        """
222        Returns the delays of the detected rays on the detector.
223        """
224        if isinstance(Detector, str):
225            if Detector in self.detectors.keys():
226                Detector = self.detectors[Detector]
227            else:
228                raise ValueError('The "Detector"-argument must be a name of an existing detector or a Detector-object.')
229        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):
233    def shift_source(self, axis: str|np.ndarray, distance: float):
234        """
235        Shift source ray bundle by distance (in mm) along the 'axis' specified as
236        a lab-frame vector (numpy-array of length 3) or as one of the strings
237        "vert", "horiz", or "random".
238
239        In the latter case, the reference is the incidence plane of the first
240        non-normal-incidence mirror after the source. If there is none, you will
241        be asked to rather specify the axis as a 3D-numpy-array.
242
243        axis = "vert" means the source position is shifted along the axis perpendicular
244        to that incidence plane, i.e. "vertically" away from the former incidence plane..
245
246        axis = "horiz" means the source direciton is translated along thr axis in that
247        incidence plane and perpendicular to the current source direction,
248        i.e. "horizontally" in the incidence plane, but retaining the same distance
249        of source and first optical element.
250
251        axis = "random" means the the source direction shifted in a random direction
252        within in the plane perpendicular to the current source direction,
253        e.g. simulating a fluctuation of hte transverse source position.
254
255        Parameters
256        ----------
257            axis : np.ndarray or str
258                Shift axis, specified either as a 3D lab-frame vector or as one
259                of the strings "vert", "horiz", or "random".
260
261            distance : float
262                Shift distance in mm.
263
264        Returns
265        -------
266            Nothing, just modifies the property 'source_rays'.
267        """
268        if type(distance) not in [int, float, np.float64]:
269            raise ValueError('The "distance"-argument must be an int or float number.')
270
271        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
272        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
273
274        OEnormal = None
275        for i in mirror_indcs:
276            ith_OEnormal = self.optical_elements[i].normal
277            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
278                OEnormal = ith_OEnormal
279                break
280        if OEnormal is None:
281            raise Exception(
282                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
283                            so you should rather give 'axis' as a numpy-array of length 3."
284            )
285
286        if type(axis) == np.ndarray and len(axis) == 3:
287            translation_vector = axis
288        else:
289            perp_axis = np.cross(central_ray_vector, OEnormal)
290            horiz_axis = np.cross(perp_axis, central_ray_vector)
291
292            if axis == "vert":
293                translation_vector = perp_axis
294            elif axis == "horiz":
295                translation_vector = horiz_axis
296            elif axis == "random":
297                translation_vector = (
298                    np.random.uniform(low=-1, high=1, size=1) * perp_axis
299                    + np.random.uniform(low=-1, high=1, size=1) * horiz_axis
300                )
301            else:
302                raise ValueError(
303                    'The shift direction must be specified by "axis" as one of ["vert", "horiz", "random"].'
304                )
305
306        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):
308    def tilt_source(self, axis: str|np.ndarray, angle: float):
309        """
310        Rotate source ray bundle by angle around an axis, specified as
311        a lab-frame vector (numpy-array of length 3) or as one of the strings
312        "in_plane", "out_plane" or "random" direction.
313
314        In the latter case, the function considers the incidence plane of the first
315        non-normal-incidence mirror after the source. If there is none, you will
316        be asked to rather specify the axis as a 3D-numpy-array.
317
318        axis = "in_plane" means the source direction is rotated about an axis
319        perpendicular to that incidence plane, which tilts the source
320        "horizontally" in the same plane.
321
322        axis = "out_plane" means the source direciton is rotated about an axis
323        in that incidence plane and perpendicular to the current source direction,
324        which tilts the source "vertically" out of the former incidence plane.
325
326        axis = "random" means the the source direction is tilted in a random direction,
327        e.g. simulating a beam pointing fluctuation.
328
329        Attention, "angle" is given in deg, so as to remain consitent with the
330        conventions of other functions, although pointing is mostly talked about
331        in mrad instead.
332
333        Parameters
334        ----------
335            axis : np.ndarray or str
336                Shift axis, specified either as a 3D lab-frame vector or as one
337                of the strings "in_plane", "out_plane", or "random".
338
339            angle : float
340                Rotation angle in degree.
341
342        Returns
343        -------
344            Nothing, just modifies the property 'source_rays'.
345        """
346        if type(angle) not in [int, float, np.float64]:
347            raise ValueError('The "angle"-argument must be an int or float number.')
348
349        central_ray_vector = mp.FindCentralRay(self.source_rays).vector
350        mirror_indcs = [i for i, OE in enumerate(self.optical_elements) if "Mirror" in OE.type.type]
351
352        OEnormal = None
353        for i in mirror_indcs:
354            ith_OEnormal = self.optical_elements[i].normal
355            if np.linalg.norm(np.cross(central_ray_vector, ith_OEnormal)) > 1e-10:
356                OEnormal = ith_OEnormal
357                break
358        if OEnormal is None:
359            raise Exception(
360                "There doesn't seem to be a non-normal-incidence mirror in this optical chain, \
361                            so you should rather give 'axis' as a numpy-array of length 3."
362            )
363
364        if type(axis) == np.ndarray and len(axis) == 3:
365            rot_axis = axis
366        else:
367            rot_axis_in = np.cross(central_ray_vector, OEnormal)
368            rot_axis_out = np.cross(rot_axis_in, central_ray_vector)
369            if axis == "in_plane":
370                rot_axis = rot_axis_in
371            elif axis == "out_plane":
372                rot_axis = rot_axis_out
373            elif axis == "random":
374                rot_axis = (
375                    np.random.uniform(low=-1, high=1, size=1) * rot_axis_in
376                    + np.random.uniform(low=-1, high=1, size=1) * rot_axis_out
377                )
378            else:
379                raise ValueError(
380                    'The tilt axis must be specified by as one of ["in_plane", "out_plane", "random"] or as a numpy-array of length 3.'
381                )
382
383        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):
385    def partial_realign(self, OEstart: int, OEstop: int, DistanceList: list, IncidenceAngleList, IncidencePlaneAngleList):
386        """
387        This as-of-yet not-implemented method will realign only the parts of the optical chain that are between the elements
388        OEstart and OEstop (both included).
389        """
390        OpticsList = [i.type for i in self.optical_elements[OEstart:OEstop]]
391        outray = self.get_output_rays()[OEstart-1][0]
392        print(outray)
393        new_elements = mp.OEPlacement(OpticsList, DistanceList, IncidenceAngleList, IncidencePlaneAngleList, outray)
394        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):
397    def rotate_OE(self, OEindx: int, ref: str, axis: str, angle: float):
398        """
399        Rotate the optical element OpticalChain.optical_elements[OEindx] with an axis defined relative to the master ray.
400        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
401        that takes either of the two values:
402            - "in"
403            - "out"
404        In either case the "axis" can take these values:
405            - "roll": Rotation about the master ray. Looking in the same direction as light propagation, positive is counterclockwise
406            - "pitch": Rotation about the vector in the incidence plane that is normal to the master ray.
407            - "yaw": Rotation about the vector normal to the incidence plane and to the master ray.
408
409        Parameters
410        ----------
411            OEindx : int
412                Index of the optical element to modify out of OpticalChain.optical_elements.
413
414            ref : str
415                Reference ray used to define the axes of rotation. Can be either:
416                "in" or "out" or "local_normal" (in+out)
417
418            axis : str
419                Rotation axis, specified as one of the strings
420                "pitch", "roll", "yaw"
421
422            angle : float
423                Rotation angle in degree.
424
425        Returns
426        -------
427            Nothing, just modifies OpticalChain.optical_elements[OEindx].
428        """
429        if abs(OEindx) > len(self.optical_elements):
430            raise ValueError(
431                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
432            )
433        if type(angle) not in [int, float, np.float64]:
434            raise ValueError('The "angle"-argument must be an int or float number.')
435        MasterRay = [mp.FindCentralRay(self.source_rays)]
436        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
437        TracedRay = [MasterRay] + TracedRay
438        match ref:
439            case "out":
440                RefVec = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
441            case "in":
442                RefVec = mgeo.Normalize(TracedRay[OEindx][0].vector)
443            case "localnormal":
444                In = mgeo.Normalize(TracedRay[OEindx][0].vector)
445                Out = mgeo.Normalize(TracedRay[OEindx+1][0].vector)
446                RefVec = mgeo.Normalize(Out-In)
447            case _:
448                raise ValueError('The "ref"-argument must be a string out of ["in", "out", "localnormal].')
449        if 1 - np.dot(self[OEindx].normal,RefVec) > 1e-2:
450            match axis:
451                case "pitch":
452                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
453                case "roll":
454                    self[OEindx].normal = mgeo.RotationAroundAxis(RefVec,np.deg2rad(angle),self[OEindx].normal)
455                case "yaw":
456                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
457                case _:
458                    raise ValueError('The "axis"-argument must be a string out of ["pitch", "roll", "yaw"].')
459        else:
460            #If the normal vector is aligned with the ray
461            match axis:
462                case "pitch":
463                    self[OEindx].normal = mgeo.RotationAroundAxis(self[OEindx].majoraxis,np.deg2rad(angle),self[OEindx].normal)
464                case "roll":
465                    self[OEindx].majoraxis = mgeo.RotationAroundAxis(self[OEindx].normal,np.deg2rad(angle),self[OEindx].majoraxis)
466                case "yaw":
467                    self[OEindx].normal = mgeo.RotationAroundAxis(mgeo.Normalize(np.cross(RefVec, self[OEindx].majoraxis)),np.deg2rad(angle),self[OEindx].normal)
468                case _:
469                    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):
471    def shift_OE(self, OEindx: int, ref: str, axis: str, distance: float):
472        """
473        Shift the optical element OpticalChain.optical_elements[OEindx] along
474        axis referenced to the master ray.
475
476        The axis can either be relative to the incoming master ray or the outgoing one. This is defined  by the "ref" variable 
477        that takes either of the two values:
478            - "in"
479            - "out"
480        In either case the "axis" can take these values:
481            - "along": Translation along the master ray.
482            - "in_plane": Translation along the vector in the incidence plane that is normal to the master ray.
483            - "out_plane": Translation along the vector normal to the incidence plane and to the master ray.
484
485        Parameters
486        ----------
487            OEindx : int
488                Index of the optical element to modify out of OpticalChain.optical_elements.
489
490            ref : str
491                Reference ray used to define the axes of rotation. Can be either:
492                "in" or "out".
493
494            axis : str
495                Translation axis, specified as one of the strings
496                "along", "in_plane", "out_plane".
497
498            distance : float
499                Rotation angle in degree.
500
501        Returns
502        -------
503            Nothing, just modifies OpticalChain.optical_elements[OEindx].
504        """
505        if abs(OEindx) >= len(self.optical_elements):
506            raise ValueError(
507                'The "OEnumber"-argument is out of range compared to the length of OpticalChain.optical_elements.'
508            )
509        if type(distance) not in [int, float, np.float64]:
510            raise ValueError('The "dist"-argument must be an int or float number.')
511
512        MasterRay = [mp.FindCentralRay(self.source_rays)]
513        TracedRay = mp.RayTracingCalculation(MasterRay, self.optical_elements)
514        TracedRay = [MasterRay] + TracedRay
515        match ref:
516            case "out":
517                RefVec = TracedRay[OEindx+1][0].vector
518            case "in":
519                RefVec = TracedRay[OEindx][0].vector
520            case _:
521                raise ValueError('The "ref"-argument must be a string out of ["in", "out"].')
522        match axis:
523            case "along":
524                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(RefVec)
525            case "in_plane":
526                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, np.cross(RefVec, self[OEindx].normal)))
527            case "out_plane":
528                self[OEindx].position = self[OEindx].position + distance * mgeo.Normalize(np.cross(RefVec, self[OEindx].normal))
529            case _:
530                raise ValueError('The "axis"-argument must be a string out of ["along", "in_plane", "out_plane"].')
531
532        # 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            DurationSD = mp.StandardDeviation(DelayList)
147            z = np.asarray(DelayList)
148            zlabel = "Delay (fs)"
149            title = "Delay + Spot Diagram\n press left/right to move detector position"
150            addLine = "\n" + "{:.2f}".format(DurationSD) + " fs SD"
151        case _:
152            z = "red"
153            title = "Spot Diagram\n press left/right to move detector position"
154            addLine = ""
155
156    distStep = min(50, max(0.0005, round(FocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000))  # in mm
157
158    plt.ion()
159    fig, ax = plt.subplots()
160    if DrawFocal:
161        focal = ax.pcolormesh(X*1e3,Y*1e3,Z)
162    if DrawFocalContour:
163        levels = [1/np.e**2, 0.5]
164        contour = ax.contourf(X*1e3, Y*1e3, Z, levels=levels, cmap='gray')
165
166    if DrawAiryAndFourier:
167        theta = np.linspace(0, 2 * np.pi, 100)
168        x = AiryRadius * np.cos(theta)
169        y = AiryRadius * np.sin(theta)  #
170        ax.plot(x, y, c="black")
171        
172
173    foo = ax.scatter(
174        DectectorPoint2D_Xcoord,
175        DectectorPoint2D_Ycoord,
176        c=z,
177        s=15,
178        label="{:.3f}".format(detectorPosition.value) + " mm\n" + "{:.1f}".format(SpotSizeSD * 1e3) + " \u03BCm SD" + addLine,
179    )
180
181    axisLim = 1.1 * max(AiryRadius, 0.5 * FocalSpotSize * 1000)
182    ax.set_xlim(-axisLim, axisLim)
183    ax.set_ylim(-axisLim, axisLim)
184
185    if ColorCoded == "Intensity" or ColorCoded == "Incidence" or ColorCoded == "Delay":
186        cbar = fig.colorbar(foo)
187        cbar.set_label(zlabel)
188
189    ax.legend(loc="upper right")
190    ax.set_xlabel("X (µm)")
191    ax.set_ylabel("Y (µm)")
192    ax.set_title(title)
193    # ax.margins(x=0)
194
195
196    def update_plot(new_value):
197        nonlocal movingDetector, ColorCoded, zlabel, cbar, detectorPosition, foo, distStep, focal, contour, levels, Index, RayListAnalysed
198
199        newDectectorPoint2D_Xcoord, newDectectorPoint2D_Ycoord, newFocalSpotSize, newSpotSizeSD = mpu._getDetectorPoints(
200            RayListAnalysed, movingDetector
201        )
202
203        if DrawFocal:
204            focal.set_array(Z)
205        if DrawFocalContour:
206            levels = [1/np.e**2, 0.5]
207            for coll in contour.collections:
208                coll.remove()  # Remove old contour lines
209            contour = ax.contourf(X * 1e3, Y * 1e3, Z, levels=levels, cmap='gray')
210        
211        xy = foo.get_offsets()
212        xy[:, 0] = newDectectorPoint2D_Xcoord
213        xy[:, 1] = newDectectorPoint2D_Ycoord
214        foo.set_offsets(xy)
215
216
217        if ColorCoded == "Delay":
218            newDelayList = np.asarray(movingDetector.get_Delays(RayListAnalysed))
219            newDurationSD = mp.StandardDeviation(newDelayList)
220            newaddLine = "\n" + "{:.2f}".format(newDurationSD) + " fs SD"
221            foo.set_array(newDelayList)
222            foo.set_clim(min(newDelayList), max(newDelayList))
223            cbar.update_normal(foo)
224        else:
225            newaddLine = ""
226
227        foo.set_label(
228            "{:.3f}".format(detectorPosition.value) + " mm\n" + "{:.1f}".format(newSpotSizeSD * 1e3) + " \u03BCm SD" + newaddLine
229        )
230        ax.legend(loc="upper right")
231
232        axisLim = 1.1 * max(AiryRadius, 0.5 * newFocalSpotSize * 1000)
233        ax.set_xlim(-axisLim, axisLim)
234        ax.set_ylim(-axisLim, axisLim)
235
236        distStep = min(
237            50, max(0.0005, round(newFocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000)
238        )  # in mm
239
240        fig.canvas.draw_idle()
241
242
243    def press(event):
244        nonlocal detectorPosition, distStep
245        if event.key == "right":
246            detectorPosition.value += distStep
247        elif event.key == "left":
248            if detectorPosition.value > 1.5 * distStep:
249                detectorPosition.value -= distStep
250            else:
251                detectorPosition.value = 0.5 * distStep
252        else:
253            return None
254
255    fig.canvas.mpl_connect("key_press_event", press)
256
257    plt.show()
258
259    detectorPosition.register(update_plot)
260
261
262    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:
267def DrawDelaySpots(OpticalChain, 
268                DeltaFT: tuple[int, float],
269                Detector = "Focus",
270                DrawAiryAndFourier=False, 
271                ColorCoded=None,
272                Observer = None
273                ) -> plt.Figure:
274    """
275    Produce a an interactive figure with a spot diagram resulting from the RayListAnalysed
276    hitting the Detector, with the ray-delays shown in the 3rd dimension.
277    The detector distance can be shifted with the left-right cursor keys.
278    If DrawAiryAndFourier is True, a cylinder is shown whose diameter is the Airy-spot-size and
279    whose height is the Fourier-limited pulse duration 'given by 'DeltaFT'.
280    
281    The 'spots' can optionally be color-coded by specifying ColorCoded as ["Intensity","Incidence"].
282
283    Parameters
284    ----------
285        RayListAnalysed : list(Ray)
286            List of objects of the ModuleOpticalRay.Ray-class.
287
288        Detector : Detector
289            An object of the ModuleDetector.Detector-class.
290
291        DeltaFT : (int, float)
292            The Fourier-limited pulse duration. Just used as a reference to compare the temporal spread
293            induced by the ray-delays.
294
295        DrawAiryAndFourier : bool, optional
296            Whether to draw a cylinder showing the Airy-spot-size and Fourier-limited-duration.
297            The default is False.
298
299        ColorCoded : str, optional
300            Color-code the spots according to one of ["Intensity","Incidence"].
301            The default is None.
302
303    Returns
304    -------
305        fig : matlplotlib-figure-handle.
306            Shows the interactive figure.
307    """
308    if isinstance(Detector, str):
309        Det = OpticalChain.detectors[Detector]
310    else:
311        Det = Detector
312    Index = Det.index
313    Detector = copy(Det)
314    if Observer is None:
315        detectorPosition = Observable(Detector.distance)
316    else:
317        detectorPosition = Observer
318        Detector.distance = detectorPosition.value
319    
320    RayListAnalysed = OpticalChain.get_output_rays()[Index]
321    fig, NumericalAperture, AiryRadius, FocalSpotSize = _drawDelayGraph(
322        RayListAnalysed, Detector, detectorPosition.value, DeltaFT, DrawAiryAndFourier, ColorCoded
323    )
324
325    distStep = min(50, max(0.0005, round(FocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000))  # in mm
326
327    movingDetector = copy(Detector)
328
329    def update_plot(new_value):
330        nonlocal movingDetector, ColorCoded, detectorPosition, distStep, fig
331        ax = fig.axes[0]
332        cam = [ax.azim, ax.elev, ax._dist]
333        fig, sameNumericalAperture, sameAiryRadius, newFocalSpotSize = _drawDelayGraph(
334            RayListAnalysed, movingDetector, detectorPosition.value, DeltaFT, DrawAiryAndFourier, ColorCoded, fig
335        )
336        ax = fig.axes[0]
337        ax.azim, ax.elev, ax._dist = cam
338        distStep = min(
339            50, max(0.0005, round(newFocalSpotSize / 8 / np.arcsin(NumericalAperture) * 10000) / 10000)
340        )
341        return fig
342
343    def press(event):
344        nonlocal detectorPosition, distStep, movingDetector, fig
345        if event.key == "right":
346            detectorPosition.value += distStep
347        elif event.key == "left":
348            if detectorPosition.value > 1.5 * distStep:
349                detectorPosition.value -= distStep
350            else:
351                detectorPosition.value = 0.5 * distStep
352
353    fig.canvas.mpl_connect("key_press_event", press)
354    detectorPosition.register(update_plot)
355    detectorPosition.register_calculation(lambda x: movingDetector.set_distance(x))
356
357    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:
438def DrawMirrorProjection(OpticalChain, ReflectionNumber: int, ColorCoded=None, Detector="") -> plt.Figure:
439    """
440    Produce a plot of the ray impact points on the optical element with index 'ReflectionNumber'.
441    The points can be color-coded according ["Incidence","Intensity","Delay"], where the ray delay is
442    measured at the Detector.
443
444    Parameters
445    ----------
446        OpticalChain : OpticalChain
447           List of objects of the ModuleOpticalOpticalChain.OpticalChain-class.
448
449        ReflectionNumber : int
450            Index specifying the optical element on which you want to see the impact points.
451
452        Detector : Detector, optional
453            Object of the ModuleDetector.Detector-class. Only necessary to project delays. The default is None.
454
455        ColorCoded : str, optional
456            Specifies which ray property to color-code: ["Incidence","Intensity","Delay"]. The default is None.
457
458    Returns
459    -------
460        fig : matlplotlib-figure-handle.
461            Shows the figure.
462    """
463    from mpl_toolkits.axes_grid1 import make_axes_locatable
464    if isinstance(Detector, str):
465        if Detector == "":
466            Detector = None
467        else:
468            Detector = OpticalChain.detectors[Detector]
469
470    Position = OpticalChain[ReflectionNumber].position
471    q = OpticalChain[ReflectionNumber].orientation
472    # n = OpticalChain.optical_elements[ReflectionNumber].normal
473    # m = OpticalChain.optical_elements[ReflectionNumber].majoraxis
474
475    RayListAnalysed = OpticalChain.get_output_rays()[ReflectionNumber]
476    # transform rays into the mirror-support reference frame
477    # (same as mirror frame but without the shift by mirror-centre)
478    r0 = OpticalChain[ReflectionNumber].r0
479    RayList = [r.to_basis(*OpticalChain[ReflectionNumber].basis) for r in RayListAnalysed]
480
481    x = np.asarray([k.point[0] for k in RayList]) - r0[0]
482    y = np.asarray([k.point[1] for k in RayList]) - r0[1]
483    if ColorCoded == "Intensity":
484        IntensityList = [k.intensity for k in RayListAnalysed]
485        z = np.asarray(IntensityList)
486        zlabel = "Intensity (arb.u.)"
487        title = "Ray intensity projected on mirror              "
488    elif ColorCoded == "Incidence":
489        IncidenceList = [np.rad2deg(k.incidence) for k in RayListAnalysed]  # in degree
490        z = np.asarray(IncidenceList)
491        zlabel = "Incidence angle (deg)"
492        title = "Ray incidence projected on mirror              "
493    elif ColorCoded == "Delay":
494        if Detector is not None:
495            z = np.asarray(Detector.get_Delays(RayListAnalysed))
496            zlabel = "Delay (fs)"
497            title = "Ray delay at detector projected on mirror              "
498        else:
499            raise ValueError("If you want to project ray delays, you must specify a detector.")
500    else:
501        z = "red"
502        title = "Ray impact points projected on mirror"
503
504    plt.ion()
505    fig = plt.figure()
506    ax = OpticalChain.optical_elements[ReflectionNumber].support._ContourSupport(fig)
507    p = plt.scatter(x, y, c=z, s=15)
508    if ColorCoded == "Delay" or ColorCoded == "Incidence" or ColorCoded == "Intensity":
509        divider = make_axes_locatable(ax)
510        cax = divider.append_axes("right", size="5%", pad=0.05)
511        cbar = fig.colorbar(p, cax=cax)
512        cbar.set_label(zlabel)
513    ax.set_xlabel("x (mm)")
514    ax.set_ylabel("y (mm)")
515    plt.title(title, loc="right")
516    plt.tight_layout()
517
518    bbox = ax.get_position()
519    bbox.set_points(bbox.get_points() - np.array([[0.01, 0], [0.01, 0]]))
520    ax.set_position(bbox)
521    plt.show()
522
523    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={}):
529def DrawSetup(OpticalChain, 
530                   EndDistance=None, 
531                   maxRays=300, 
532                   OEpoints=2000, 
533                   draw_mesh=False, 
534                   cycle_ray_colors = False,
535                   impact_points = False,
536                   DrawDetectors=True,
537                   DetectedRays = False,
538                   Observers = dict()):
539    """
540    Renders an image of the Optical setup and the traced rays.
541
542    Parameters
543    ----------
544        OpticalChain : OpticalChain
545            List of objects of the ModuleOpticalOpticalChain.OpticalChain-class.
546
547        EndDistance : float, optional
548            The rays of the last ray bundle are drawn with a length given by EndDistance (in mm). If not specified,
549            this distance is set to that between the source point and the 1st optical element.
550
551        maxRays: int
552            The maximum number of rays to render. Rendering all the traced rays is a insufferable resource hog
553            and not required for a nice image. Default is 150.
554
555        OEpoints : int
556            How many little spheres to draw to represent the optical elements.  Default is 2000.
557
558    Returns
559    -------
560        fig : Pyvista-figure-handle.
561            Shows the figure.
562    """
563
564    RayListHistory = [OpticalChain.source_rays] + OpticalChain.get_output_rays()
565
566    if EndDistance is None:
567        EndDistance = np.linalg.norm(OpticalChain.source_rays[0].point - OpticalChain.optical_elements[0].position)
568
569    print("...rendering image of optical chain...", end="", flush=True)
570    fig = pvqt.BackgroundPlotter(window_size=(1500, 500), notebook=False) # Opening a window
571    fig.set_background('white')
572    
573    if cycle_ray_colors:
574        colors = mpu.generate_distinct_colors(len(OpticalChain)+1)
575    else:
576        colors = [[0.7, 0, 0]]*(len(OpticalChain)+1) # Default color: dark red
577
578    # Optics display
579    # For each optic we will send the figure to the function _RenderOpticalElement and it will add the optic to the figure
580    for i,OE in enumerate(OpticalChain.optical_elements):
581        color = pv.Color(colors[i+1])
582        rgb = color.float_rgb
583        h, l, s = rgb_to_hls(*rgb)
584        s = max(0, min(1, s * 0.3))  # Decrease saturation
585        l = max(0, min(1, l + 0.1))  # Increase lightness
586        new_rgb = hls_to_rgb(h, l, s)
587        darkened_color = pv.Color(new_rgb)
588        mpm._RenderOpticalElement(fig, OE, OEpoints, draw_mesh, darkened_color, index=i)
589    ray_meshes = mpm._RenderRays(RayListHistory, EndDistance, maxRays)
590    for i,ray in enumerate(ray_meshes):
591        color = pv.Color(colors[i])
592        fig.add_mesh(ray, color=color, name=f"RayBundle_{i}")
593    if impact_points:
594        for i,rays in enumerate(RayListHistory):
595            points = np.array([list(r.point) for r in rays], dtype=np.float32)
596            points = pv.PolyData(points)
597            color = pv.Color(colors[i-1])
598            fig.add_mesh(points, color=color, point_size=5, name=f"RayImpactPoints_{i}")
599    
600    detector_copies = {key: copy(OpticalChain.detectors[key]) for key in OpticalChain.detectors.keys()}
601    detector_meshes_list = []
602    detectedpoint_meshes = dict()
603    
604    if OpticalChain.detectors is not None and DrawDetectors:
605        # Detector display
606        for key in OpticalChain.detectors.keys():
607            det = detector_copies[key]
608            index = OpticalChain.detectors[key].index
609            if key in Observers:
610                det.distance = Observers[key].value
611                #Observers[key].register_calculation(lambda x: det.set_distance(x))
612            mpm._RenderDetector(fig, det, name = key, detector_meshes = detector_meshes_list)
613            if DetectedRays:
614                RayListAnalysed = OpticalChain.get_output_rays()[index]
615                points = det.get_3D_points(RayListAnalysed)
616                points = pv.PolyData(points)
617                detectedpoint_meshes[key] = points
618                fig.add_mesh(points, color='purple', point_size=5, name=f"DetectedRays_{key}")
619    detector_meshes = dict(zip(OpticalChain.detectors.keys(), detector_meshes_list))
620    
621    # Now we define a function that will move on the plot the detector with name "detname" when it's called
622    def move_detector(detname, new_value):
623        nonlocal fig, detector_meshes, detectedpoint_meshes, DetectedRays, detectedpoint_meshes, detector_copies, OpticalChain
624        det = detector_copies[detname]
625        index = OpticalChain.detectors[detname].index
626        det_mesh = detector_meshes[detname]
627        translation = det.normal * (det.distance - new_value)
628        det_mesh.translate(translation, inplace=True)
629        det.distance = new_value
630        if DetectedRays:
631            points_mesh = detectedpoint_meshes[detname]
632            points_mesh.points = det.get_3D_points(OpticalChain.get_output_rays()[index])
633        fig.show()
634    
635    # Now we register the function to the observers
636    for key in OpticalChain.detectors.keys():
637        if key in Observers:
638            Observers[key].register(lambda x: move_detector(key, x))
639
640    #pv.save_meshio('optics.inp', pointcloud)  
641    print(
642        "\r\033[K", end="", flush=True
643    )  # move to beginning of the line with \r and then delete the whole line with \033[K
644    fig.show()
645    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):
701def DrawCaustics(OpticalChain, Range=1, Detector="Focus" , Npoints=1000, Nrays=1000):
702    """
703    This function displays the caustics of the rays on the detector.
704    To do so, it calculates the intersections of the rays with the detector over a 
705    range determined by the parameter Range, and then plots the standard deviation of the
706    positions in the x and y directions.
707
708    Parameters
709    ----------
710    OpticalChain : OpticalChain
711        The optical chain to analyse.
712
713    DetectorName : str
714        The name of the detector on which the caustics are calculated.
715    
716    Range : float
717        The range of the detector over which to calculate the caustics.
718
719    Npoints : int, optional
720        The number of points to sample on the detector. The default is 1000.
721    
722    Returns
723    -------
724    fig : Figure
725        The figure of the plot.
726    """
727    distances = np.linspace(-Range, Range, Npoints)
728    if isinstance(Detector, str):
729        Det = OpticalChain.detectors[Detector]
730        Index = Det.index
731    Rays = OpticalChain.get_output_rays()[Index]
732    Nrays = min(Nrays, len(Rays))
733    Rays = np.random.choice(Rays, Nrays, replace=False)
734    LocalRayList = [r.to_basis(*Det.basis) for r in Rays]
735    Points = mgeo.IntersectionRayListZPlane(LocalRayList, distances)
736    x_std = []
737    y_std = []
738    for i in range(len(distances)):
739        x_std.append(mp.StandardDeviation(Points[i][:,0]))
740        y_std.append(mp.StandardDeviation(Points[i][:,1]))
741    plt.ion()
742    fig, ax = plt.subplots()
743    ax.plot(distances, x_std, label="x std")
744    ax.plot(distances, y_std, label="y std")
745    ax.set_xlabel("Detector distance (mm)")
746    ax.set_ylabel("Standard deviation (mm)")
747    ax.legend()
748    plt.title("Caustics")
749    plt.show()
750    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:

  • Spectrum
  • 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- Spectrum
  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
 22from ARTcore.ModuleGeometry import Point, Vector, Origin
 23import ARTcore.ModuleProcessing as mp
 24
 25from ARTcore.DepGraphDefinitions import UniformSpectraCalculator
 26
 27import numpy as np
 28from abc import ABC, abstractmethod
 29import logging
 30
 31logger = logging.getLogger(__name__)
 32
 33# %% Abstract base classes for sources
 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
 43
 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
 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
 65
 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
 76
 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
 87
 88# %% Specific power distributions
 89class SpatialGaussianPowerDistribution(PowerDistribution):
 90    """
 91    Spatial Gaussian power distribution.
 92    """
 93    def __init__(self, Power, W0):
 94        self.Power = Power
 95        self.W0 = W0
 96
 97    def __call__(self, Origins, Directions):
 98        """
 99        Return the power of the rays coming 
100        from the origin points Origins to the directions Directions.
101        """
102        return self.Power * np.exp(-2 * np.linalg.norm(Origins, axis=1) ** 2 / self.W0 ** 2)
103    
104class AngularGaussianPowerDistribution(PowerDistribution):
105    """
106    Angular Gaussian power distribution.
107    """
108    def __init__(self, Power, Divergence):
109        self.Power = Power
110        self.Divergence = Divergence
111
112    def __call__(self, Origins, Directions):
113        """
114        Return the power of the rays coming 
115        from the origin points Origins to the directions Directions.
116        """
117        return self.Power * np.exp(-2 * np.arccos(np.dot(Directions, [0, 0, 1])) ** 2 / self.Divergence ** 2)
118
119class GaussianPowerDistribution(PowerDistribution):
120    """
121    Gaussian power distribution.
122    """
123    def __init__(self, Power, W0, Divergence):
124        self.Power = Power
125        self.W0 = W0
126        self.Divergence = Divergence
127
128    def __call__(self, Origins, Directions):
129        """
130        Return the power of the rays coming 
131        from the origin points Origins to the directions Directions.
132        """
133        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)
134
135class UniformPowerDistribution(PowerDistribution):
136    """
137    Uniform power distribution.
138    """
139    def __init__(self, Power):
140        self.Power = Power
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        return self.Power * np.ones(len(Origins))
148
149# %% Specific ray origins distributions
150class PointRayOriginsDistribution(RayOriginsDistribution):
151    """
152    Point ray origins distribution.
153    """
154    def __init__(self, Origin):
155        self.Origin = Origin
156
157    def __call__(self, N):
158        """
159        Return the origins of N rays.
160        """
161        return mgeo.PointArray([self.Origin for i in range(N)])
162    
163class DiskRayOriginsDistribution(RayOriginsDistribution):
164    """
165    Disk ray origins distribution. Uses the Vogel spiral to initialize the rays.
166    """
167    def __init__(self, Origin, Radius, Normal = mgeo.Vector([0, 0, 1])):
168        self.Origin = Origin
169        self.Radius = Radius
170        self.Normal = Normal
171
172    def __call__(self, N):
173        """
174        Return the origins of N rays.
175        """
176        MatrixXY = mgeo.SpiralVogel(N, self.Radius)
177        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Normal)
178        return mgeo.PointArray([self.Origin + mgeo.Vector([MatrixXY[i, 0], MatrixXY[i, 1], 0]) for i in range(N)]).rotate(q)
179    
180# %% Specific ray directions distributions
181class UniformRayDirectionsDistribution(RayDirectionsDistribution):
182    """
183    Uniform ray directions distribution.
184    """
185    def __init__(self, Direction):
186        self.Direction = Direction
187
188    def __call__(self, N):
189        """
190        Return the directions of N rays.
191        """
192        return mgeo.VectorArray([self.Direction for i in range(N)])
193
194class ConeRayDirectionsDistribution(RayDirectionsDistribution):
195    """
196    Cone ray directions distribution. Uses the Vogel spiral to initialize the rays.
197    """
198    def __init__(self, Direction, Angle):
199        self.Direction = Direction
200        self.Angle = Angle
201
202    def __call__(self, N):
203        """
204        Return the directions of N rays.
205        """
206        Height = 1
207        Radius = Height * np.tan(self.Angle)
208        MatrixXY = mgeo.SpiralVogel(N, Radius)
209        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Direction)
210        return mgeo.VectorArray([[MatrixXY[i, 0], MatrixXY[i, 1], Height] for i in range(N)]).rotate(q).normalized()
211
212
213# %% Specific spectra
214class SingleWavelengthSpectrum(Spectrum):
215    """
216    Single wavelength spectrum.
217    """
218    def __init__(self, Wavelength):
219        self.Wavelength = Wavelength
220
221    def __call__(self, N):
222        """
223        Return the wavelengths of N rays.
224        """
225        return np.ones(N) * self.Wavelength
226
227class UniformSpectrum(Spectrum):
228    """
229    Uniform spectrum.
230    Can be specified as a r
231    """
232    def __init__(self, eVMax = None, eVMin = None, eVCentral = None, 
233                 lambdaMax = None, lambdaMin = None, lambdaCentral = None, 
234                 eVWidth = None, lambdaWidth = None):
235        # Using the DepSolver, we calculate the minimum and maximum wavelengths
236        values, steps = UniformSpectraCalculator().calculate_values(
237            lambda_min = lambdaMin, 
238            lambda_max = lambdaMax, 
239            lambda_center = lambdaCentral, 
240            lambda_width = lambdaWidth,
241            eV_min = eVMin,
242            eV_max = eVMax,
243            eV_center = eVCentral,
244            eV_width = eVWidth
245        )
246        self.lambdaMin = values['lambda_min']
247        self.lambdaMax = values['lambda_max']
248    def __call__(self, N):
249        """
250        Return the wavelengths of N rays.
251        """
252        return np.linspace(self.lambdaMin, self.lambdaMax, N)
253
254
255# %% Simple sources
256class SimpleSource(Source):
257    """
258    A simple source is defined by 4 parameters:
259    - Spectrum
260    - Power distribution
261    - Ray origins distribution
262    - Ray directions distribution
263    """
264
265    def __init__(self, Spectrum, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution):
266        self.Spectrum = Spectrum
267        self.PowerDistribution = PowerDistribution
268        self.RayOriginsDistribution = RayOriginsDistribution
269        self.RayDirectionsDistribution = RayDirectionsDistribution
270
271    def __call__(self, N):
272        """
273        Return a list of N rays from the simple source.
274        """
275        Wavelengths = self.Spectrum(N)
276        Origins = self.RayOriginsDistribution(N)
277        Directions = self.RayDirectionsDistribution(N)
278        Powers = self.PowerDistribution(Origins, Directions)
279        RayList = []
280        for i in range(N):
281            RayList.append(mray.Ray(Origins[i], Directions[i], wavelength=Wavelengths[i], number=i, intensity=Powers[i]))
282        return mray.RayList.from_list(RayList)
283
284
285class ListSource(Source):
286    """
287    A source containing just a list of rays that can be prepared manually.
288    """
289    def __init__(self, Rays):
290        self.Rays = Rays
291
292    def __call__(self, N):
293        """
294        Return the list of rays.
295        """
296        if N < len(self.Rays):
297            return self.Rays[:N]
298        elif N>len(self.Rays):
299            logger.warning("Requested number of rays is greater than the number of rays in the source. Returning the whole source.")
300            return self.Rays    
301        return mray.RayList.from_list(self.Rays)
logger = <Logger ARTcore.ModuleSource (WARNING)>
class Source(abc.ABC):
35class Source(ABC):
36    """
37    Abstract base class for sources.
38    Fundamentally a source just has to be able to return a list of rays.
39    To do so, we make it callable.
40    """
41    @abstractmethod
42    def __call__(self,N):
43        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):
45class PowerDistribution(ABC):
46    """
47    Abstract base class for angular power distributions.
48    """
49    @abstractmethod
50    def __call__(self, Origins, Directions):
51        """
52        Return the power of the rays coming 
53        from the origin points Origins to the directions Directions.
54        """
55        pass

Abstract base class for angular power distributions.

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

Abstract base class for ray origins distributions.

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

Abstract base class for ray directions distributions.

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

Abstract base class for spectra.

class SpatialGaussianPowerDistribution(PowerDistribution):
 90class SpatialGaussianPowerDistribution(PowerDistribution):
 91    """
 92    Spatial Gaussian power distribution.
 93    """
 94    def __init__(self, Power, W0):
 95        self.Power = Power
 96        self.W0 = W0
 97
 98    def __call__(self, Origins, Directions):
 99        """
100        Return the power of the rays coming 
101        from the origin points Origins to the directions Directions.
102        """
103        return self.Power * np.exp(-2 * np.linalg.norm(Origins, axis=1) ** 2 / self.W0 ** 2)

Spatial Gaussian power distribution.

SpatialGaussianPowerDistribution(Power, W0)
94    def __init__(self, Power, W0):
95        self.Power = Power
96        self.W0 = W0
Power
W0
class AngularGaussianPowerDistribution(PowerDistribution):
105class AngularGaussianPowerDistribution(PowerDistribution):
106    """
107    Angular Gaussian power distribution.
108    """
109    def __init__(self, Power, Divergence):
110        self.Power = Power
111        self.Divergence = Divergence
112
113    def __call__(self, Origins, Directions):
114        """
115        Return the power of the rays coming 
116        from the origin points Origins to the directions Directions.
117        """
118        return self.Power * np.exp(-2 * np.arccos(np.dot(Directions, [0, 0, 1])) ** 2 / self.Divergence ** 2)

Angular Gaussian power distribution.

AngularGaussianPowerDistribution(Power, Divergence)
109    def __init__(self, Power, Divergence):
110        self.Power = Power
111        self.Divergence = Divergence
Power
Divergence
class GaussianPowerDistribution(PowerDistribution):
120class GaussianPowerDistribution(PowerDistribution):
121    """
122    Gaussian power distribution.
123    """
124    def __init__(self, Power, W0, Divergence):
125        self.Power = Power
126        self.W0 = W0
127        self.Divergence = Divergence
128
129    def __call__(self, Origins, Directions):
130        """
131        Return the power of the rays coming 
132        from the origin points Origins to the directions Directions.
133        """
134        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.

GaussianPowerDistribution(Power, W0, Divergence)
124    def __init__(self, Power, W0, Divergence):
125        self.Power = Power
126        self.W0 = W0
127        self.Divergence = Divergence
Power
W0
Divergence
class UniformPowerDistribution(PowerDistribution):
136class UniformPowerDistribution(PowerDistribution):
137    """
138    Uniform power distribution.
139    """
140    def __init__(self, Power):
141        self.Power = Power
142
143    def __call__(self, Origins, Directions):
144        """
145        Return the power of the rays coming 
146        from the origin points Origins to the directions Directions.
147        """
148        return self.Power * np.ones(len(Origins))

Uniform power distribution.

UniformPowerDistribution(Power)
140    def __init__(self, Power):
141        self.Power = Power
Power
class PointRayOriginsDistribution(RayOriginsDistribution):
151class PointRayOriginsDistribution(RayOriginsDistribution):
152    """
153    Point ray origins distribution.
154    """
155    def __init__(self, Origin):
156        self.Origin = Origin
157
158    def __call__(self, N):
159        """
160        Return the origins of N rays.
161        """
162        return mgeo.PointArray([self.Origin for i in range(N)])

Point ray origins distribution.

PointRayOriginsDistribution(Origin)
155    def __init__(self, Origin):
156        self.Origin = Origin
Origin
class DiskRayOriginsDistribution(RayOriginsDistribution):
164class DiskRayOriginsDistribution(RayOriginsDistribution):
165    """
166    Disk ray origins distribution. Uses the Vogel spiral to initialize the rays.
167    """
168    def __init__(self, Origin, Radius, Normal = mgeo.Vector([0, 0, 1])):
169        self.Origin = Origin
170        self.Radius = Radius
171        self.Normal = Normal
172
173    def __call__(self, N):
174        """
175        Return the origins of N rays.
176        """
177        MatrixXY = mgeo.SpiralVogel(N, self.Radius)
178        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Normal)
179        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]))
168    def __init__(self, Origin, Radius, Normal = mgeo.Vector([0, 0, 1])):
169        self.Origin = Origin
170        self.Radius = Radius
171        self.Normal = Normal
Origin
Radius
Normal
class UniformRayDirectionsDistribution(RayDirectionsDistribution):
182class UniformRayDirectionsDistribution(RayDirectionsDistribution):
183    """
184    Uniform ray directions distribution.
185    """
186    def __init__(self, Direction):
187        self.Direction = Direction
188
189    def __call__(self, N):
190        """
191        Return the directions of N rays.
192        """
193        return mgeo.VectorArray([self.Direction for i in range(N)])

Uniform ray directions distribution.

UniformRayDirectionsDistribution(Direction)
186    def __init__(self, Direction):
187        self.Direction = Direction
Direction
class ConeRayDirectionsDistribution(RayDirectionsDistribution):
195class ConeRayDirectionsDistribution(RayDirectionsDistribution):
196    """
197    Cone ray directions distribution. Uses the Vogel spiral to initialize the rays.
198    """
199    def __init__(self, Direction, Angle):
200        self.Direction = Direction
201        self.Angle = Angle
202
203    def __call__(self, N):
204        """
205        Return the directions of N rays.
206        """
207        Height = 1
208        Radius = Height * np.tan(self.Angle)
209        MatrixXY = mgeo.SpiralVogel(N, Radius)
210        q = mgeo.QRotationVector2Vector(mgeo.Vector([0, 0, 1]), self.Direction)
211        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)
199    def __init__(self, Direction, Angle):
200        self.Direction = Direction
201        self.Angle = Angle
Direction
Angle
class SingleWavelengthSpectrum(Spectrum):
215class SingleWavelengthSpectrum(Spectrum):
216    """
217    Single wavelength spectrum.
218    """
219    def __init__(self, Wavelength):
220        self.Wavelength = Wavelength
221
222    def __call__(self, N):
223        """
224        Return the wavelengths of N rays.
225        """
226        return np.ones(N) * self.Wavelength

Single wavelength spectrum.

SingleWavelengthSpectrum(Wavelength)
219    def __init__(self, Wavelength):
220        self.Wavelength = Wavelength
Wavelength
class UniformSpectrum(Spectrum):
228class UniformSpectrum(Spectrum):
229    """
230    Uniform spectrum.
231    Can be specified as a r
232    """
233    def __init__(self, eVMax = None, eVMin = None, eVCentral = None, 
234                 lambdaMax = None, lambdaMin = None, lambdaCentral = None, 
235                 eVWidth = None, lambdaWidth = None):
236        # Using the DepSolver, we calculate the minimum and maximum wavelengths
237        values, steps = UniformSpectraCalculator().calculate_values(
238            lambda_min = lambdaMin, 
239            lambda_max = lambdaMax, 
240            lambda_center = lambdaCentral, 
241            lambda_width = lambdaWidth,
242            eV_min = eVMin,
243            eV_max = eVMax,
244            eV_center = eVCentral,
245            eV_width = eVWidth
246        )
247        self.lambdaMin = values['lambda_min']
248        self.lambdaMax = values['lambda_max']
249    def __call__(self, N):
250        """
251        Return the wavelengths of N rays.
252        """
253        return np.linspace(self.lambdaMin, self.lambdaMax, N)

Uniform spectrum. Can be specified as a r

UniformSpectrum( eVMax=None, eVMin=None, eVCentral=None, lambdaMax=None, lambdaMin=None, lambdaCentral=None, eVWidth=None, lambdaWidth=None)
233    def __init__(self, eVMax = None, eVMin = None, eVCentral = None, 
234                 lambdaMax = None, lambdaMin = None, lambdaCentral = None, 
235                 eVWidth = None, lambdaWidth = None):
236        # Using the DepSolver, we calculate the minimum and maximum wavelengths
237        values, steps = UniformSpectraCalculator().calculate_values(
238            lambda_min = lambdaMin, 
239            lambda_max = lambdaMax, 
240            lambda_center = lambdaCentral, 
241            lambda_width = lambdaWidth,
242            eV_min = eVMin,
243            eV_max = eVMax,
244            eV_center = eVCentral,
245            eV_width = eVWidth
246        )
247        self.lambdaMin = values['lambda_min']
248        self.lambdaMax = values['lambda_max']
lambdaMin
lambdaMax
class SimpleSource(Source):
257class SimpleSource(Source):
258    """
259    A simple source is defined by 4 parameters:
260    - Spectrum
261    - Power distribution
262    - Ray origins distribution
263    - Ray directions distribution
264    """
265
266    def __init__(self, Spectrum, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution):
267        self.Spectrum = Spectrum
268        self.PowerDistribution = PowerDistribution
269        self.RayOriginsDistribution = RayOriginsDistribution
270        self.RayDirectionsDistribution = RayDirectionsDistribution
271
272    def __call__(self, N):
273        """
274        Return a list of N rays from the simple source.
275        """
276        Wavelengths = self.Spectrum(N)
277        Origins = self.RayOriginsDistribution(N)
278        Directions = self.RayDirectionsDistribution(N)
279        Powers = self.PowerDistribution(Origins, Directions)
280        RayList = []
281        for i in range(N):
282            RayList.append(mray.Ray(Origins[i], Directions[i], wavelength=Wavelengths[i], number=i, intensity=Powers[i]))
283        return mray.RayList.from_list(RayList)

A simple source is defined by 4 parameters:

  • Spectrum
  • Power distribution
  • Ray origins distribution
  • Ray directions distribution
SimpleSource( Spectrum, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution)
266    def __init__(self, Spectrum, PowerDistribution, RayOriginsDistribution, RayDirectionsDistribution):
267        self.Spectrum = Spectrum
268        self.PowerDistribution = PowerDistribution
269        self.RayOriginsDistribution = RayOriginsDistribution
270        self.RayDirectionsDistribution = RayDirectionsDistribution
Spectrum
PowerDistribution
RayOriginsDistribution
RayDirectionsDistribution
class ListSource(Source):
286class ListSource(Source):
287    """
288    A source containing just a list of rays that can be prepared manually.
289    """
290    def __init__(self, Rays):
291        self.Rays = Rays
292
293    def __call__(self, N):
294        """
295        Return the list of rays.
296        """
297        if N < len(self.Rays):
298            return self.Rays[:N]
299        elif N>len(self.Rays):
300            logger.warning("Requested number of rays is greater than the number of rays in the source. Returning the whole source.")
301            return self.Rays    
302        return mray.RayList.from_list(self.Rays)

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

ListSource(Rays)
290    def __init__(self, Rays):
291        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