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.