Source code for surfaces.converters

"""Converters

This module contains classes that convert between different surface types.

Kramer Harrison, 2024
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import optiland.backend as be
from optiland.interactions.thin_lens_interaction_model import ThinLensInteractionModel
from optiland.materials.base import BaseMaterial

if TYPE_CHECKING:
    from optiland.optic.optic import Optic
    from optiland.surfaces.standard_surface import Surface


[docs] class ParaxialToThickLensConverter: """ Converts a ParaxialSurface into an equivalent thick lens composed of two real surfaces. Args: paraxial_surface: The ParaxialSurface to convert. optic: The parent Optic instance containing the paraxial surface. material: The lens material. Can be: - A string (e.g., "N-BK7", resolved via Material lookup). - A float (refractive index, creates an IdealMaterial). - A BaseMaterial instance. center_thickness: The desired center thickness of the thick lens. """ def __init__( self, paraxial_surface: Surface, optic: Optic, material: str | float | BaseMaterial = "N-BK7", center_thickness: float = 3.0, # Default center thickness in mm ): if not isinstance(paraxial_surface.interaction_model, ThinLensInteractionModel): raise TypeError("paraxial_surface must have a ThinLensInteractionModel.") self.paraxial_surface = paraxial_surface self.optic = optic self.original_focal_length = paraxial_surface.interaction_model.f self.center_thickness = center_thickness self._material_instance = self._resolve_material(material) def _resolve_material( self, material_input: str | float | BaseMaterial ) -> BaseMaterial: """Resolves the material input to a BaseMaterial instance.""" from optiland.materials.ideal import IdealMaterial from optiland.materials.material import Material if isinstance(material_input, BaseMaterial): return material_input elif isinstance(material_input, str): try: return Material(material_input) except Exception as e: raise ValueError( f"Could not resolve material string '{material_input}': {e}" ) from e elif isinstance(material_input, int | float): return IdealMaterial(n=float(material_input)) else: raise TypeError( "Invalid material type. Must be BaseMaterial, str, or float." )
[docs] def convert(self): """ Performs the conversion from paraxial to thick lens. This method will: 1. Calculate the front and back radii of the thick lens. 2. Remove the original paraxial surface from the optic. 3. Create and add two new surfaces to the optic. """ r1, r2 = self._calculate_radii() # Store original index before removal original_index = self._get_paraxial_surface_index() if original_index is None: raise RuntimeError("Original paraxial surface not found in optic.") self._remove_paraxial_surface(original_index) self._add_surfaces(r1, r2, original_index)
def _get_paraxial_surface_index(self): """Finds the index of the self.paraxial_surface in the optic's surface list.""" for i, s in enumerate(self.optic.surfaces): if s is self.paraxial_surface: return i return None def _calculate_radii(self): """ Calculates the front (R1) and back (R2) radii of curvature for the thick lens using the Lensmaker's equation. P = (n_lens - n_medium) * (1/R1 - 1/R2 + (n_lens - n_medium)*d / (n_lens*R1*R2)) where P = 1/f (power), n_lens is lens refractive index, n_medium is surrounding medium refractive index (assumed air, n_medium=1), and d is center thickness. For a target focal length f_target (self.original_focal_length), and assuming n_medium = 1 (air): 1/f_target = (n - 1) * (1/R1 - 1/R2 + (n - 1)*d / (n*R1*R2)) This method uses a biconvex lens for positive focal lengths and a biconcave lens for negative focal lengths. - biconvex: R1 > 0, R2 < 0. Assume R1 = -R2 for simplicity. - biconcave: R1 < 0, R2 > 0. Assume R1 = -R2. Returns: tuple[float, float]: (R1, R2) """ n = self._material_instance.n(self.optic.primary_wavelength) if hasattr(n, "item"): # If n is a 0-dim array/tensor n = n.item() f_target = self.original_focal_length d = self.center_thickness if abs(f_target) < 1e-9: return be.inf, be.inf P = 1.0 / f_target # Power r1, r2 = 0.0, 0.0 if f_target > 0: # Biconvex: P*n*R1^2 - 2*n*(n-1)*R1 + (n-1)^2*d = 0. For R1 = -R2. a_quad = P * n b_quad = -2 * n * (n - 1) c_quad = (n - 1) ** 2 * d if abs(a_quad) < 1e-9: if abs(b_quad) < 1e-9: raise ValueError("Cannot solve for R1 in biconvex (P=0, n=1).") r1 = -c_quad / b_quad # Linear case else: discriminant = b_quad**2 - 4 * a_quad * c_quad if discriminant < 0: raise ValueError("Biconvex: discriminant < 0, cannot find real R1.") sol1 = (-b_quad + be.sqrt(discriminant)) / (2 * a_quad) sol2 = (-b_quad - be.sqrt(discriminant)) / (2 * a_quad) r1 = sol1 if sol1 > 0 else sol2 if r1 <= 0: r1 = sol2 if sol2 > 0 else sol1 if r1 <= 0: raise ValueError("Biconvex: No positive R1 solution found.") r2 = -r1 else: # Biconcave: P*n*R1^2 - 2*n*(n-1)*R1 + (n-1)^2*d = 0. For R1 = -R2. a_quad = P * n b_quad = -2 * n * (n - 1) c_quad = (n - 1) ** 2 * d if abs(a_quad) < 1e-9: if abs(b_quad) < 1e-9: raise ValueError("Cannot solve for R1 in biconcave (P=0, n=1).") r1 = -c_quad / b_quad else: discriminant = b_quad**2 - 4 * a_quad * c_quad if discriminant < 0: raise ValueError( "Biconcave: discriminant < 0, cannot find real R1." ) # Choose solution for R1 < 0 if P < 0 (diverging) sol1 = (-b_quad + be.sqrt(discriminant)) / (2 * a_quad) sol2 = (-b_quad - be.sqrt(discriminant)) / (2 * a_quad) r1 = sol1 if sol1 < 0 else sol2 if r1 >= 0: r1 = sol2 if sol2 < 0 else sol1 if r1 >= 0: raise ValueError("Biconcave: No negative R1 solution found.") r2 = -r1 return float(r1), float(r2) def _add_surfaces(self, r1: float, r2: float, original_index: int): """ Creates the two new standard Surface instances. Materials pre/post are set based on original paraxial surface context and the new lens material. """ original_material_post = self.paraxial_surface.material_post # Surface 1: front surface of the thick lens self.optic.surfaces.add( index=original_index, radius=r1, material=self._material_instance, is_stop=self.paraxial_surface.is_stop, thickness=self.center_thickness, comment="Thick Lens - Surface 1", ) # Surface 2: back surface of the thick lens self.optic.surfaces.add( index=original_index + 1, radius=r2, material=original_material_post, is_stop=False, # Stop, if any, is on the first surface thickness=self.paraxial_surface.thickness, comment="Thick Lens - Surface 2", ) def _remove_paraxial_surface(self, original_index: int): """ Removes the original ParaxialSurface from the parent optic's surface_group using its index. """ if not (0 < original_index < len(self.optic.surfaces)): raise IndexError( f"Invalid index {original_index} for removing paraxial surface." ) self.optic.surfaces.remove(original_index)
[docs] def convert_to_thick_lens(lens: Optic): """ Converts all paraxial surfaces in a lens into thick lenses Args: lens (Optic): the lens to be converted Returns: Optic: the converted lens """ for surf in lens.surfaces: if isinstance(surf.interaction_model, ThinLensInteractionModel): converter = ParaxialToThickLensConverter(surf, lens) converter.convert() return lens