Source code for optable.ray

from .base import *
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
from .material import *

_RAY_NONE_LENGTH = 100


[docs] class GaussianBeam: """Calculation for Gaussian beam q-parameter"""
[docs] @staticmethod def q_at_waist(w0: Union[float, np.ndarray], wl: float, n: float = 1): """Return ``q`` at beam waist.""" return (1j * n * np.pi * w0**2) / wl
[docs] @staticmethod def q_at_z(qo, z: Union[float, np.ndarray]): """Propagate ``q`` by free-space distance ``z``.""" return qo + z
[docs] @staticmethod def distance_to_waist(q: Union[complex, np.ndarray]): """Return distance from current point to waist plane.""" z = np.real(q) return z
[docs] @staticmethod def waist(q: Union[complex, np.ndarray], wl: float, n: float = 1): """Return waist radius implied by complex ``q``.""" w0 = np.sqrt((wl * np.imag(q)) / (n * np.pi)) return w0
[docs] @staticmethod def rayleigh_range(q: Union[complex, np.ndarray]): """Return Rayleigh range from complex ``q``.""" zr = np.imag(q) return zr
[docs] @staticmethod def radius_of_curvature(q: Union[complex, np.ndarray]): """Return wavefront radius of curvature from complex ``q``.""" R = 1 / np.real(1 / q) return R
[docs] @staticmethod def spot_size( qo: Union[complex, np.ndarray], z: Union[float, np.ndarray], wl: float, n: float = 1, ): """Return beam spot size after propagation distance ``z`` from the waist.""" q = qo + z w = np.sqrt(-wl / (n * np.pi * np.imag(1 / q))) return w
[docs] class Ray(Vector): """Geometric ray object, with Gaussian beam parameters.""" _n = RefractiveIndex("_n") # refractive index
[docs] def __init__( self, origin, direction, intensity: float = 1.0, wavelength=None, length=None, alive=True, qo=None, # q at origin w0=None, # if specified, qo will be calculated from w0 **kwargs, ): """Initialize a ray. Args: origin: Ray origin in lab coordinates. direction: Propagation direction (normalized internally). intensity: Relative ray intensity. wavelength: Wavelength in model units. length: Optional finite rendering/segment length. alive: If ``False``, ray is ignored in interaction steps. qo: Optional Gaussian beam complex ``q`` at origin. w0: Optional beam waist radius used to derive ``qo``. """ super().__init__(origin, **kwargs) self.length = float(length) if length else None # Length of the ray self.direction = direction # Direction vector self.intensity = float(intensity) # Intensity of the ray self.wavelength = ( float(wavelength) if wavelength else 0.0 ) # Wavelength of the ray self.alive = alive # Ray is alive means it has not been absorbed or terminated self._n = 1.0 # default vacuum self._pathlength = 0.0 # total path length traveled by the ray from its start, at t=0, count in vacuum # # >>> Gaussian Beam Parameters if qo is not None: self.qo = qo # q at origin elif w0 is not None: self.qo = self.q_at_waist(w0) else: self.qo = None
# def __repr__(self): return f"Ray(origin={self.origin}, direction={self.direction}, intensity={self.intensity}, length={self.length}, alive={self.alive}, qo={self.qo})" @property def direction(self) -> np.ndarray: """Unit propagation direction vector.""" return self._direction @direction.setter def direction(self, direction) -> np.ndarray: """Set ray direction from an arbitrary vector and normalize.""" self._direction = self._normalize_vector(direction) return self._direction @property def n(self) -> float: """Return refractive index function.""" return self._n() @property def transform_matrix(self) -> np.ndarray: """Return rotation matrix mapping local ray frame to lab frame.""" return self._vector_to_R(self.direction) @property def tangent_1(self) -> np.ndarray: """Return unit tangent vector 1 orthogonal to propagation.""" n = self.direction if n[0] == 0 and n[1] == 0: return np.array([1, 0, 0]) else: return self._normalize_vector(np.cross(n, np.array([0, 0, 1]))) @property def tangent_2(self) -> np.ndarray: """Return unit tangent vector 2 orthogonal to propagation.""" return self._normalize_vector(np.cross(self.direction, self.tangent_1))
[docs] def pathlength(self, t: float = 0) -> float: """Return accumulated optical path length at parameter t.""" return float(self._pathlength + t * self.n)
[docs] def phase(self, t: float = 0) -> float: """Return optical phase modulo ``2*pi`` at parameter t.""" return np.mod((2 * np.pi / self.wavelength) * self.pathlength(t), 2 * np.pi)
def _RotAroundLocal(self, axis, localpoint, theta) -> "Ray": """Rotate ray around an axis by angle theta in radians. Args: axis: [ux,uy,uz] vector defining the rotation axis in **global** coordinates. localpoint: Point in local coordinates around which to rotate. theta: Rotation angle in radians. Returns: self: The ray object itself, modified in place. """ R = self.R(axis, theta) localpoint = np.array(localpoint) self.direction = np.dot(R, self.direction) self.origin = self.origin + np.dot(R, -localpoint) + localpoint return self # >>> Gaussian Beam Functions
[docs] def q_at_waist(self, w0: Union[float, np.ndarray]): """Return Gaussian ``q`` at waist.""" return GaussianBeam.q_at_waist(w0, self.wavelength, self.n)
[docs] def q_at_z(self, z: Union[float, np.ndarray]): """Return Gaussian ``q`` after propagation distance ``z``.""" return GaussianBeam.q_at_z(self.qo, z)
[docs] def distance_to_waist(self, q: Union[complex, np.ndarray]): """Return distance from given ``q`` plane to waist plane.""" return GaussianBeam.distance_to_waist(q)
[docs] def waist(self, q: Union[complex, np.ndarray]): """Return waist radius implied by ``q`` for this wavelength/index.""" return GaussianBeam.waist(q, self.wavelength, self.n)
[docs] def rayleigh_range(self, q: Union[complex, np.ndarray]): """Return Rayleigh range from ``q``.""" return GaussianBeam.rayleigh_range(q)
[docs] def radius_of_curvature(self, q: Union[complex, np.ndarray]): """Return wavefront curvature radius from ``q``.""" return GaussianBeam.radius_of_curvature(q)
[docs] def spot_size(self, z: Union[float, np.ndarray]): """Return spot size at propagation distance ``z``.""" return GaussianBeam.spot_size(self.qo, z, self.wavelength, self.n)
[docs] def Propagate(self, z) -> "Ray": """Return a copied ray which is the current ray propagated by distance ``z``.""" return self.copy(qo=self.q_at_z(z))
def _sample_gaussian_beam(self, length, num_points=10) -> np.ndarray: """Generate sampling points along the Gaussian beam path, dense near the waist.""" z_to_waist = -self.distance_to_waist(self.qo) # waist is not in the ray path, linear sampling if z_to_waist < 0 or (z_to_waist > length): return np.linspace(0, length, num_points) # waist is in the ray path, non-linear sampling else: # sample at [-2,-1,0,1,2] of zR, adding start and end points # if some sampling points is not in [start, end], remove it t = z_to_waist + self.rayleigh_range(self.qo) * np.array([-2, -1, 0, 1, 2]) t = list(t[(t >= 0) & (t <= length)]) t.append(0) t.append(length) return np.sort(np.array(t)) # <<< Gaussian Beam Functions
[docs] def render(self, ax, type: str, **kwargs): """Render the ray. Args: ax: Matplotlib axis to render on. type: "Z" for 2D rendering xOy plane, "3D" for 3D rendering. **kwargs: Additional rendering options. - color: (default "black") Color of the ray. - physical_color: (default False) If True, color is based on wavelength. - linewidth: (default 0.5) Width of the ray line. - linestyle: (default "-") Style of the ray line. - ray_arrow: (default False) If True, draw an arrow indicating direction. - gaussian_beam: (default False) If True, render Gaussian beam envelope. - detailed_render: (default False) If True, render detailed Gaussian beam contours. - render_line: (default True) If True, render the ray line. - arrow: (default False) If True, draw an arrow indicating direction in 3D. - spot_size_scale: (default 1.0) Scale factor for the Gaussian beam spot size. - annote_waist: (default False) If True, annotate the waist position. Returns: None: The ray is rendered on the provided axis. """ # read configuration physical_color = kwargs.get("physical_color", False) if physical_color and self.wavelength is not None: color = wavelength_to_rgb(self.wavelength * self.unit) else: color = kwargs.get("color", "black") linewidth = kwargs.get("linewidth", 0.5) linestyle = kwargs.get("linestyle", "-") alpha = max(0.1, min(1.0, self.intensity)) length = self.length if self.length else _RAY_NONE_LENGTH # if type in ["X", "Y", "Z"]: dimension_dict = {"Z": [0, 1], "X": [1, 2], "Y": [2, 0]} switch_axis = kwargs.get("switch_axis", False) if switch_axis: dimension_dict[type] = dimension_dict[type][::-1] # Determine the start and end points of the ray start = self.origin[dimension_dict[type]] end = start + self.direction[dimension_dict[type]] * length render_line = kwargs.get("render_line", True) # Plot the line representing the ray if render_line: ax.plot( [start[0], end[0]], [start[1], end[1]], color=color, alpha=alpha, linewidth=linewidth, linestyle=linestyle, ) arrow = kwargs.get("ray_arrow", False) if arrow: # Add an arrow in the middle to indicate direction midpoint = (start + end) / 2 arrow_length = 0.2 * np.linalg.norm(end - start) arrow_direction = self._normalize_vector( self.direction[dimension_dict[type]] ) ax.arrow( midpoint[0], midpoint[1], arrow_direction[0], arrow_direction[1], head_width=0.05 * np.linalg.norm(end - start), head_length=0.1 * np.linalg.norm(end - start), fc=color, ec=color, alpha=alpha, linestyle=linestyle, ) # gaussian_beam = kwargs.get("gaussian_beam", False) if gaussian_beam: t = self._sample_gaussian_beam(length, num_points=20) vx, vy = self.direction[dimension_dict[type]] x0, y0 = self.origin[dimension_dict[type]] x = x0 + t * vx y = y0 + t * vy spot_size_scale = kwargs.get("spot_size_scale", 1.0) spots_size = self.spot_size(t) * spot_size_scale # create paths of polygon and fill it pts_x = np.concatenate( [x + spots_size * (-vy), x[::-1] + spots_size[::-1] * (vy)] ) pts_y = np.concatenate( [y + spots_size * vx, y[::-1] + spots_size[::-1] * (-vx)] ) fill_color = "red" if not physical_color else color ax.fill(pts_x, pts_y, color=fill_color, alpha=alpha / 2, ec=None) # z_to_waist = -self.distance_to_waist(self.qo) annote_waist = kwargs.get("annote_waist", True) if annote_waist: if 0 < z_to_waist < length: ax.scatter( x0 + z_to_waist * vx, y0 + z_to_waist * vy, marker=".", color="black", alpha=alpha, ) elif type == "3D": # Determine the start and end points of the ray start = self.origin end = start + self.direction * length detailed_render = kwargs.get("detailed_render") ax.plot( [start[0], end[0]], [start[1], end[1]], [start[2], end[2]], color=color, alpha=alpha, linewidth=linewidth, ) # arrow = kwargs.get("arrow", False) if arrow: # Add an arrow in the middle to indicate direction midpoint = (start + end) / 2 arrow_length = 0.2 * np.linalg.norm( end - start ) # Scale arrow relative to ray arrow_direction = arrow_length * self.direction ax.quiver( midpoint[0], midpoint[1], midpoint[2], arrow_direction[0], arrow_direction[1], arrow_direction[2], pivot="middle", color=color, alpha=alpha, linestyle=linestyle, ) gaussian_beam = kwargs.get("gaussian_beam", False) if gaussian_beam: vx, vy, vz = self.direction[0], self.direction[1], self.direction[2] z_to_waist = -self.distance_to_waist(self.qo) # Discrete z positions for the contour circles t = self._sample_gaussian_beam(length, num_points=10) n_vertices = 6 # Number of vertices per circle (polygon approximation) # Compute the contour circles and store them in a list. if detailed_render: circles = [] spot_size_scale = kwargs.get("spot_size_scale", 1.0) spots_size = self.spot_size(t) * spot_size_scale for t, w in zip(t, spots_size): theta = np.linspace(0, 2 * np.pi, n_vertices, endpoint=False) center_pt = self.origin + t * self.direction surface_pt = ( center_pt[ :, np.newaxis ] # Reshape center_pt to (3,1) for broadcasting + w * self.tangent_1[:, np.newaxis] * np.cos(theta) # Shape (3, n_vertices) + w * self.tangent_2[:, np.newaxis] * np.sin(theta) # Shape (3, n_vertices) ) circle = surface_pt.T # Transpose to get (n_vertices, 3) shape # print(circle.shape) # circle = np.column_stack((x, y, z)) circles.append(circle) # Create side surfaces connecting adjacent circles. faces = [] for i in range(len(circles) - 1): c1 = circles[i] c2 = circles[i + 1] for j in range(n_vertices): # Wrap around to the first vertex when j is the last index. j_next = (j + 1) % n_vertices # Define the quadrilateral face with 4 vertices: face = [c1[j], c1[j_next], c2[j_next], c2[j]] faces.append(face) # Create a Poly3DCollection for the side surfaces. side_collection = Poly3DCollection( faces, facecolors="red", edgecolors=None, alpha=alpha / 2 ) ax.add_collection3d(side_collection) # draw a point at the waist position.` # print(self.origin, self.direction, z_to_waist) if 0 < z_to_waist < length: ax.scatter( self.origin[0] + z_to_waist * vx, self.origin[1] + z_to_waist * vy, self.origin[2] + z_to_waist * vz, color="black", alpha=alpha, )
[docs] def multiplex_rays_in_wavelength( rays: List[Ray], wavelength_list: List[float] ) -> List[Ray]: """Create multiple rays with different wavelengths from a single ray. Args: rays (List[Ray]): List of Ray objects to be multiplexed. wavelength_list (List[float]): List of wavelengths to assign to the rays. Returns: List[Ray]: New list of Ray objects with assigned wavelengths. """ multiplexed_rays = [] for wl in wavelength_list: for ray in rays: new_ray = ray.copy(wavelength=wl) multiplexed_rays.append(new_ray) return multiplexed_rays