Source code for iso10110.elements

"""ISO 10110 Lens Element Inference

Infers lens elements (singlets, cemented doublets, etc.) from an optic's
surface sequence.  Optiland is purely surface-based; there is no first-class
LensElement concept.  This module derives elements by grouping consecutive
glass-bounded surface intervals.

Bernhard Lutzer, 2026
"""

from __future__ import annotations

import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np

import optiland.backend as be

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


# ---------------------------------------------------------------------------
# Air / glass discrimination
# ---------------------------------------------------------------------------


def _to_float(value) -> float:
    """Convert a scalar or size-1 array (any backend) to a Python float."""
    return float(np.asarray(be.to_numpy(be.array(value))).ravel()[0])


[docs] def is_air(material) -> bool: """Return True if *material* is air or vacuum (n ≈ 1).""" if material is None: return True try: n = _to_float(material.n(0.5876)) return n < 1.01 except Exception: return False
def _physical_aperture_radius(aperture) -> float | None: """Return the max radial extent of a physical aperture, or None. Mirrors the priority given to ``surface.aperture.extent`` in ``optiland.visualization.system.surface`` — an explicitly configured physical aperture (e.g. ``RadialAperture``) defines the actual manufactured/clipped edge of the surface, so it must take priority over any ray-traced or paraxial estimate: that is the whole point of setting one, whether it is larger *or* smaller than the illuminated ray bundle (vignetting). Returns None when the aperture has no finite extent (e.g. an unbounded boolean combination), so the caller can fall back. """ try: extent = np.abs(np.asarray(be.to_numpy(be.array(aperture.extent)), dtype=float)) except Exception: return None finite = extent[np.isfinite(extent)] if finite.size == 0: return None return float(finite.max()) # --------------------------------------------------------------------------- # LensElement # ---------------------------------------------------------------------------
[docs] @dataclass class LensElement: """A logical lens element inferred from a consecutive run of glass surfaces. A *singlet* has two bounding surfaces and one glass segment. A *cemented doublet* has three surfaces and two glass segments (the middle surface is the cement interface). In general an N-component cemented group has N+1 surfaces. Attributes: surfaces: Ordered list of Surface objects that bound this element, including the final air-exit surface. surface_indices: Corresponding global indices in the SurfaceGroup. """ surfaces: list[Surface] surface_indices: list[int] # ------------------------------------------------------------------ # Derived properties # ------------------------------------------------------------------ @property def is_cemented(self) -> bool: """True if this element consists of more than one glass component.""" return len(self.surfaces) > 2 @property def num_components(self) -> int: """Number of individual glass pieces (1 for singlet, 2 for doublet…).""" return len(self.surfaces) - 1 @property def glass_materials(self): """List of glass materials, one per component (excludes trailing air).""" return [s.material_post for s in self.surfaces[:-1]]
[docs] def semi_aperture(self, surf_local_idx: int, optic: Optic) -> float: """Return the semi-aperture of a surface within this element. An explicitly configured physical aperture (e.g. ``RadialAperture``) takes priority, since it defines the actual manufactured/clipped edge of the surface — this matters both when it is smaller than the ray bundle (vignetting: the drawing must show the true, smaller mechanical edge, not the unclipped ray height) and when it is larger (an intentionally oversized blank). Falls back to ``semi_aperture`` populated by ``optic.updater.update_paraxial()``, and finally to a direct paraxial marginal-ray estimate when neither is available. Args: surf_local_idx: 0-based index within this element's surface list. optic: The parent optic (used for the paraxial fallback). """ surf = self.surfaces[surf_local_idx] if surf.aperture is not None: r = _physical_aperture_radius(surf.aperture) if r is not None: return r if surf.semi_aperture is not None: return abs(_to_float(surf.semi_aperture)) # Paraxial fallback from optiland.paraxial import Paraxial p = Paraxial(optic) heights, _ = p.marginal_ray() global_idx = self.surface_indices[surf_local_idx] return float( abs(np.asarray(be.to_numpy(be.array(heights[global_idx]))).ravel()[0]) )
[docs] def max_semi_aperture(self, optic: Optic) -> float: """Return the maximum semi-aperture across all surfaces of this element.""" return max(self.semi_aperture(i, optic) for i in range(len(self.surfaces)))
[docs] def center_thickness(self, optic) -> float: """Total on-axis thickness of the element (front vertex to rear vertex).""" z_front = _to_float(self.surfaces[0].geometry.cs.z) z_rear = _to_float(self.surfaces[-1].geometry.cs.z) return abs(z_rear - z_front)
[docs] def component_thicknesses(self, optic) -> list[float]: """Center thickness of each individual glass component.""" thicknesses = [] for i in range(len(self.surfaces) - 1): z0 = _to_float(self.surfaces[i].geometry.cs.z) z1 = _to_float(self.surfaces[i + 1].geometry.cs.z) thicknesses.append(abs(z1 - z0)) return thicknesses
[docs] def radii(self) -> list[float]: """Radius of curvature for every surface (including the air-exit surface).""" result = [] for surf in self.surfaces: try: r = _to_float(surf.geometry.radius) except AttributeError: r = float("inf") result.append(r) return result
# --------------------------------------------------------------------------- # Element identification # ---------------------------------------------------------------------------
[docs] def identify_elements(optic: Optic) -> list[LensElement]: """Infer lens elements from *optic*'s surface sequence. Skips the object surface (index 0) and the image surface (index -1). Consecutive surfaces whose ``material_post`` is glass (n > 1.01) are grouped into a single :class:`LensElement`. The final surface of each element is the one whose ``material_post`` transitions back to air. This grouping is purely a glass/air-transition heuristic and does not understand reflective (mirror) surfaces or images immersed in a non-air medium — both configurations emit a ``UserWarning`` and may yield a missing or incomplete element list rather than raising, since mirror support is not implemented yet. Returns: List of :class:`LensElement` in optical order. """ surfaces = optic.surfaces.surfaces # tuple, index 0 = object elements: list[LensElement] = [] in_glass = False current_surfaces: list[Surface] = [] current_indices: list[int] = [] has_reflective = False # Walk real surfaces only (skip object [0] and image [-1]) for idx in range(1, len(surfaces) - 1): surf = surfaces[idx] if getattr(getattr(surf, "interaction_model", None), "is_reflective", False): has_reflective = True glass = not is_air(surf.material_post) if glass: if not in_glass: in_glass = True current_surfaces.append(surf) current_indices.append(idx) else: # material_post is air if in_glass: # This surface closes the element current_surfaces.append(surf) current_indices.append(idx) elements.append( LensElement( surfaces=list(current_surfaces), surface_indices=list(current_indices), ) ) in_glass = False current_surfaces = [] current_indices = [] # else: air gap between elements — skip if has_reflective: warnings.warn( "identify_elements() does not support reflective (mirror) surfaces: " "a mirror's material_post is aliased to material_pre, so it looks " "like an air gap to this glass/air-transition grouping. Elements " "adjacent to a mirror may be dropped or grouped incorrectly, and the " "resulting ISO 10110 drawings may be incomplete or empty.", stacklevel=2, ) if in_glass: warnings.warn( "The optic's final surface is still immersed in a non-air material " "(no closing air transition found before the image surface); the " "trailing lens element could not be closed and will be missing from " "the ISO 10110 drawings.", stacklevel=2, ) return elements