Source code for optiland.nonsequential.convert

"""Sequential -> Non-Sequential Converter.

Provides :func:`sequential_to_nonsequential` to convert an
:class:`~optiland.optic.Optic` instance into a fully-populated
:class:`~optiland.nonsequential.scene.NSQScene`.

Differentiable-ready output
---------------------------
The returned scene stores all geometric parameters as plain Python
:class:`float` values extracted from the sequential surfaces.  This is
intentional: the converter does *not* know which parameters the user wants to
differentiate.  To optimize a parameter with PyTorch, re-assign it as a
tensor after conversion::

    scene = sequential_to_nonsequential(optic)
    # Access the config on the compound component:
    cfg = scene.component_registry.get("L1")._config
    cfg.r1 = torch.tensor(cfg.r1, requires_grad=True)

Kramer Harrison, 2026
"""

from __future__ import annotations

import math
import warnings
from dataclasses import dataclass
from dataclasses import field as _dc_field
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from optiland.nonsequential.scene import NSQScene
    from optiland.optic.optic import Optic


[docs] class ConversionError(Exception): """Raised when a sequential surface or element cannot be converted to NSQ."""
[docs] @dataclass class ConversionReport: """Structured record of what a conversion dropped or approximated. Attached to the returned scene as ``scene.conversion_report`` rather than requiring the caller to parse warning text -- the whole point of this class is that "what did the converter have to guess or drop" is inspectable data, not something a user has to have watched the log for. Attributes: coated_surfaces: Names of refractive surfaces whose sequential unpolarized coating was carried over to ``SurfaceConfig.coating`` (so NSQ and the sequential engine agree on R). uncoated_surfaces: Names of refractive surfaces with no usable coating in the sequential system; these get bare Fresnel reflection/refraction in NSQ, which the sequential engine never applies (transmission is always 100% there). mirror_reflectance_defaulted: Names of mirrors with no usable scalar reflectance in the sequential system, defaulted to a perfect reflector (R=1.0) -- NSQ has no implicit-mirror default, so the converter must supply *something*, and a perfect reflector is the most visible (least silently-wrong) choice. estimated_apertures: Names of surfaces whose aperture radius was not explicitly set in the sequential system and had to be estimated from paraxial ray heights (or, failing that, a fixed 10 mm fallback) rather than read directly. polarization_dropped: True if any sequential surface had a polarization-sensitive (Jones-matrix) coating -- NSQ rays carry no polarization state, so these are dropped entirely, not approximated. """ coated_surfaces: list[str] = _dc_field(default_factory=list) uncoated_surfaces: list[str] = _dc_field(default_factory=list) mirror_reflectance_defaulted: list[str] = _dc_field(default_factory=list) estimated_apertures: list[str] = _dc_field(default_factory=list) polarization_dropped: bool = False
[docs] def summary(self) -> str: """Human-readable multi-line summary of everything dropped/approximated. Returns: A multi-line string, or a single "nothing dropped" line if the conversion was fully faithful. """ lines: list[str] = [] if self.coated_surfaces: lines.append( f"Coatings carried over ({len(self.coated_surfaces)}): " f"{', '.join(self.coated_surfaces)}" ) if self.uncoated_surfaces: lines.append( f"Bare Fresnel, no sequential coating found " f"({len(self.uncoated_surfaces)}): " f"{', '.join(self.uncoated_surfaces)}" ) if self.mirror_reflectance_defaulted: lines.append( f"Mirror reflectance defaulted to 1.0, no usable coating " f"found ({len(self.mirror_reflectance_defaulted)}): " f"{', '.join(self.mirror_reflectance_defaulted)}" ) if self.estimated_apertures: lines.append( f"Apertures estimated (not explicitly set) " f"({len(self.estimated_apertures)}): " f"{', '.join(self.estimated_apertures)}" ) if self.polarization_dropped: lines.append( "Polarization-sensitive coatings were dropped entirely " "(NSQ rays carry no polarization state)." ) if not lines: return "Conversion was fully faithful: nothing was dropped or approximated." return "\n".join(lines)
def _has_polarization_surfaces(optic) -> bool: """Return True if any surface has polarization-sensitive coatings. Uses the canonical ``surface.interaction_model.coating`` accessor (not the deprecated ``surface.coating`` property) and checks against the :class:`~optiland.coatings.BaseCoatingPolarized` ABC, which is the authoritative discriminator for polarization-sensitive coatings. Args: optic: Sequential Optic. Returns: True if any surface has a polarization-sensitive coating. """ try: from optiland.coatings import BaseCoatingPolarized # noqa: PLC0415 for surf in optic.surfaces.surfaces: im = getattr(surf, "interaction_model", None) if im is None: continue coating = getattr(im, "coating", None) if coating is not None and isinstance(coating, BaseCoatingPolarized): return True return False except Exception: return False
[docs] def sequential_to_nonsequential( optic: Optic, *, num_rays: int = 1_000, detector_width: float | None = None, detector_height: float | None = None, detector_pixels: tuple[int, int] = (512, 512), beam_diameter: float | None = None, half_angle_deg: float | None = None, ) -> NSQScene: """Convert a sequential Optic to a non-sequential NSQScene. The image surface is converted to an IrradianceDetector. Each sequential field is mapped to one NSQ source (CollimatedSource for angle fields, PointSource for object-height fields). Consecutive refractive surfaces are grouped into Lens or Doublet compound components. Args: optic: Sequential optical system to convert. num_rays: Default ray count for visualization sources. Does not affect scene.trace() -- pass num_rays there directly. detector_width: Semi-width of the image detector [mm]. Defaults to 2x the paraxial image height. detector_height: Semi-height of the image detector [mm]. Defaults to detector_width. detector_pixels: (num_pixels_x, num_pixels_y) for the irradiance detector. beam_diameter: Override entrance pupil diameter for collimated sources [mm]. Defaults to paraxial EPD. half_angle_deg: Override cone half-angle for point sources [degrees]. Defaults to paraxial marginal-ray angle at the object plane. Returns: NSQScene populated with lens/mirror components, sources, and a detector. ``scene.conversion_report`` is a :class:`ConversionReport` listing everything the converter dropped or had to approximate -- coatings, apertures, mirror reflectance, polarization -- as structured data rather than only warning text. Raises: ConversionError: If any surface has an unsupported geometry type (coordinate breaks, diffraction gratings, NURBS, Zernike freeforms, or lens elements with more than 3 surfaces). Warns: UserWarning: NSQ surfaces with no carried-over coating apply bare Fresnel reflection/refraction; sequential surfaces always transmit. See ``scene.conversion_report.uncoated_surfaces`` for exactly which surfaces this applies to. """ from optiland.nonsequential.scene import NSQScene # noqa: PLC0415 scene = NSQScene() report = ConversionReport() surfs = optic.surfaces.surfaces # list of all surfaces including obj and img n = len(surfs) i = 1 elem_idx = 0 while i < n - 1: surf = surfs[i] _check_geometry(surf, i) if _is_reflective(surf): _add_mirror(scene, optic, surf, i, report) elem_idx += 1 i += 1 continue if _is_glass(surf): # Gather all surfaces that start or continue a glass element. # element_surfaces collects the glass-entry surfaces; # the loop advances j until we find the glass->air boundary. element_surfaces = [surf] element_indices = [i] j = i + 1 while j < n - 1 and _is_glass(surfs[j]): _check_geometry(surfs[j], j) element_surfaces.append(surfs[j]) element_indices.append(j) j += 1 # surfs[j] is the last surface of the element (exits to air / image) if j < n - 1: _check_geometry(surfs[j], j) element_surfaces.append(surfs[j]) element_indices.append(j) if len(element_surfaces) == 2: _add_lens(scene, optic, element_surfaces, element_indices, report) elif len(element_surfaces) == 3: _add_doublet(scene, optic, element_surfaces, element_indices, report) else: raise ConversionError( f"Lens element starting at surface index {i} has " f"{len(element_surfaces)} surfaces -- only singlets (2) " f"and cemented doublets (3) are supported." ) elem_idx += 1 i = j + 1 continue # Standalone air-air surface (e.g. pure aperture stop in air). raise ConversionError( f"Surface at index {i} is an air-to-air surface " "(not reflective, not entering glass). " "Standalone aperture stops in air are not supported by the converter. " "Move the stop to a glass surface or remove it before converting." ) _add_sources(scene, optic, beam_diameter, half_angle_deg) _add_detector(scene, optic, detector_width, detector_height, detector_pixels) if _has_polarization_surfaces(optic): report.polarization_dropped = True warnings.warn( "Polarization coatings and Jones matrices on sequential surfaces are not " "carried over to the NSQ scene. Polarization tracking in NSQ is deferred.", UserWarning, stacklevel=2, ) if report.uncoated_surfaces: coated_note = ( f" ({len(report.coated_surfaces)} surface(s) had a coating carried " "over and agree with the sequential engine on R.)" if report.coated_surfaces else "" ) warnings.warn( f"{len(report.uncoated_surfaces)} refractive surface(s) had no " "usable coating in the sequential system and use bare Fresnel " "reflection/refraction in NSQ; the sequential engine always " "transmits 100% at an uncoated surface, so ghost reflections may " "appear in NSQ ray traces that don't exist in the sequential " "trace. See scene.conversion_report.uncoated_surfaces for which " "ones, or attach SurfaceConfig.coating after conversion to " f"suppress this.{coated_note}", UserWarning, stacklevel=2, ) scene.conversion_report = report return scene
def _check_geometry(surf, index: int) -> None: """Raise ConversionError if the surface geometry is unsupported. Args: surf: Sequential Surface object. index: Surface index (for error messages). Raises: ConversionError: If the geometry type is not supported. """ geo = surf.geometry geo_type = type(geo).__name__ _unsupported = { "CoordinateBreak", "DiffractionGrating", "PlaneGrating", "StandardGrating", "GridSag", "NURBS", "ZernikeStandardSag", "ZernikeStandardPhase", "ZernikeGeometry", "ChebyshevGeometry", "PolynomialGeometry", "BionicGeometry", "ToroidalGeometry", } if geo_type in _unsupported: raise ConversionError( f"Surface {index} has geometry type '{geo_type}' which is not " "supported by the sequential-to-NSQ converter." ) def _is_reflective(surf) -> bool: """Return True if the surface is a mirror (reflective interaction model). Args: surf: Sequential Surface object. Returns: True if the surface interaction model is reflective. """ model = getattr(surf, "interaction_model", None) if model is None: return False return bool(getattr(model, "is_reflective", False)) def _is_glass(surf) -> bool: """Return True if the material after the surface is glass (not air/vacuum). Args: surf: Sequential Surface object. Returns: True if ``material_post`` is a named catalog glass. """ from optiland.materials.ideal import IdealMaterial # noqa: PLC0415 mat = surf.material_post if mat is None: return False return not isinstance(mat, IdealMaterial) def _material_name(surf) -> str: """Extract the glass name from the material after the surface. Args: surf: Sequential Surface object. Returns: Material name string (e.g. ``'N-BK7'``). Raises: ConversionError: If the material type is not recognisable. """ mat = surf.material_post if hasattr(mat, "name"): return mat.name raise ConversionError( f"Cannot extract material name from {type(mat).__name__}. " "Only catalog materials (Material class) are supported by the converter." ) def _surface_z(surf) -> float: """Return the global z-position of a surface vertex. Args: surf: Sequential Surface object. Returns: Z-coordinate in the global frame [mm]. """ x_gcs, y_gcs, z_gcs = surf.geometry.cs.position_in_gcs try: return float(z_gcs.item()) except AttributeError: return float(z_gcs) def _surface_radius(surf) -> float: """Return the radius of curvature of a surface. Args: surf: Sequential Surface object. Returns: Radius of curvature [mm]. inf for a plane. """ r = surf.geometry.radius try: return float(r.item()) except AttributeError: return float(r) def _surface_conic(surf) -> float: """Return the conic constant of a surface (0 if not applicable). Args: surf: Sequential Surface object. Returns: Conic constant. """ try: k = surf.geometry.k try: return float(k.item()) except AttributeError: return float(k) except AttributeError: return 0.0 def _surface_semi_diameter( surf, optic=None, idx: int | None = None ) -> tuple[float, bool]: """Return the semi-diameter (aperture radius) of a surface. Args: surf: Sequential Surface object. optic: Sequential Optic (for the paraxial-ray fallback). idx: Surface index within ``optic`` (for the paraxial-ray fallback). Returns: ``(semi_diameter [mm], estimated)`` -- ``estimated`` is True when the value could not be read directly from an explicit aperture/ semi_aperture on the surface and had to be inferred (paraxial ray heights, or -- worst case -- a fixed 10 mm fallback), for :class:`ConversionReport`. """ ap = surf.aperture if ap is not None and hasattr(ap, "radius"): try: return float(ap.radius.item()), False except AttributeError: return float(ap.radius), False # Fall back to semi_aperture if set if surf.semi_aperture is not None: try: return float(surf.semi_aperture.item()), False except AttributeError: return float(surf.semi_aperture), False # Use paraxial rays from optic if available if optic is not None and idx is not None: try: yb, _ = optic.paraxial.marginal_ray() yc, _ = optic.paraxial.chief_ray() import optiland.backend as be ybi = be.to_numpy(yb[idx]).item() yci = be.to_numpy(yc[idx]).item() r_ext = abs(ybi) + abs(yci) if r_ext > 0.0: return float(r_ext), True except Exception: pass return 10.0, True # Default if not set def _surface_coating(surf) -> object | None: """Extract a usable unpolarized coating from a sequential surface. Reads ``surf.interaction_model.coating`` (the non-deprecated accessor). A polarized coating is not returned here -- ``_has_polarization_surfaces`` already surfaces that case globally: polarized coatings must never be silently degraded to a scalar average. Args: surf: Sequential Surface object. Returns: The coating object (an ``optiland.coatings.BaseCoating``, e.g. ``SimpleCoating``) if present and unpolarized, else ``None``. """ from optiland.coatings import BaseCoating, BaseCoatingPolarized # noqa: PLC0415 model = getattr(surf, "interaction_model", None) coating = getattr(model, "coating", None) if model is not None else None if coating is None or isinstance(coating, BaseCoatingPolarized): return None if isinstance(coating, BaseCoating): return coating return None def _add_mirror(scene, optic, surf, elem_idx: int, report: ConversionReport) -> None: """Add a Mirror component to the scene. Args: scene: NSQScene to add to. surf: Sequential Surface object for the mirror. elem_idx: Element index (used for naming). report: ConversionReport to record fidelity notes into. """ from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415 from optiland.nonsequential.components.configs import MirrorConfig # noqa: PLC0415 name = f"M{elem_idx}" z = _surface_z(surf) radius = _surface_radius(surf) conic = _surface_conic(surf) ap_r, ap_estimated = _surface_semi_diameter(surf, optic, elem_idx) if ap_estimated: report.estimated_apertures.append(name) reflectance = _mirror_reflectance(surf, elem_idx, report) cs = CoordinateSystem(z=z) config = MirrorConfig( radius=radius, reflectance=reflectance, conic=conic, aperture_radius=ap_r ) scene.add_mirror(name, cs, config) def _mirror_reflectance(surf, elem_idx: int, report: ConversionReport) -> float: """Extract a scalar reflectance for a mirror surface being converted. Reads ``surf.interaction_model.coating`` (the non-deprecated accessor). An unpolarized ``SimpleCoating``-like coating's ``.reflectance`` attribute is used directly; anything else (no coating attached, or a coating without a plain ``.reflectance`` attribute) falls back to a perfect mirror, since NSQ requires an explicit reflectance and the sequential engine has no equivalent implicit default to defer to. Args: surf: Sequential Surface object for the mirror. elem_idx: Element index (for the warning message). report: ConversionReport to record the default into. Returns: Scalar reflectance in [0, 1]. Warns: UserWarning: If no usable reflectance could be read from the sequential surface, so the mirror is defaulting to R=1.0. """ model = getattr(surf, "interaction_model", None) coating = getattr(model, "coating", None) if model is not None else None reflectance = getattr(coating, "reflectance", None) if reflectance is not None: return float(reflectance) report.mirror_reflectance_defaulted.append(f"M{elem_idx}") warnings.warn( f"Mirror M{elem_idx} has no coating with a scalar .reflectance " "attached in the sequential system; defaulting to a perfect " "reflector (reflectance=1.0). Set MirrorConfig.reflectance " "explicitly after conversion if this is not correct.", UserWarning, stacklevel=2, ) return 1.0 def _add_lens( scene, optic, element_surfaces: list, elem_indices: list, report: ConversionReport ) -> None: """Add a singlet Lens component to the scene. Args: scene: NSQScene to add to. element_surfaces: [front_surf, back_surf] -- front is glass entry, back exits to air. elem_indices: Sequential surface indices matching ``element_surfaces``. report: ConversionReport to record fidelity notes into. """ from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415 from optiland.nonsequential.components.configs import LensConfig # noqa: PLC0415 s_front, s_back = element_surfaces idx_front, idx_back = elem_indices z_front = _surface_z(s_front) z_back = _surface_z(s_back) thickness = z_back - z_front front_ap, front_estimated = _surface_semi_diameter(s_front, optic, idx_front) back_ap, back_estimated = _surface_semi_diameter(s_back, optic, idx_back) front_name = f"L{idx_front}.front" back_name = f"L{idx_front}.back" if front_estimated: report.estimated_apertures.append(front_name) if back_estimated: report.estimated_apertures.append(back_name) cs = CoordinateSystem(z=z_front) config = LensConfig( r1=_surface_radius(s_front), r2=_surface_radius(s_back), thickness=thickness, material=_material_name(s_front), front_aperture_radius=front_ap, back_aperture_radius=back_ap, conic1=_surface_conic(s_front), conic2=_surface_conic(s_back), front=_surface_config_with_coating(s_front, front_name, report), back=_surface_config_with_coating(s_back, back_name, report), ) scene.add_lens(f"L{idx_front}", cs, config) def _surface_config_with_coating( surf, name: str, report: ConversionReport ) -> object | None: """Build a ``SurfaceConfig`` carrying a surface's coating, if any. Args: surf: Sequential Surface object. name: This surface's name, for the report. report: ConversionReport to record coated/uncoated into. Returns: A ``SurfaceConfig(coating=...)`` if the sequential surface had a usable unpolarized coating, else ``None`` (bare Fresnel -- NSQ's default, and the sequential engine's transmission behaviour, will disagree on reflectance in that case; see ``ConversionReport.uncoated_surfaces``). """ from optiland.nonsequential.components.configs import SurfaceConfig # noqa: PLC0415 coating = _surface_coating(surf) if coating is not None: report.coated_surfaces.append(name) return SurfaceConfig(coating=coating) report.uncoated_surfaces.append(name) return None def _add_doublet( scene, optic, element_surfaces: list, elem_indices: list, report: ConversionReport ) -> None: """Add a cemented Doublet component to the scene. Args: scene: NSQScene to add to. element_surfaces: [front, cement, back] surfaces. elem_indices: Sequential surface indices matching ``element_surfaces``. report: ConversionReport to record fidelity notes into. """ from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415 from optiland.nonsequential.components.configs import DoubletConfig # noqa: PLC0415 s_front, s_cement, s_back = element_surfaces idx_front, idx_cement, idx_back = elem_indices z_front = _surface_z(s_front) z_cement = _surface_z(s_cement) z_back = _surface_z(s_back) thickness1 = z_cement - z_front thickness2 = z_back - z_cement front_ap, front_est = _surface_semi_diameter(s_front, optic, idx_front) cement_ap, cement_est = _surface_semi_diameter(s_cement, optic, idx_cement) back_ap, back_est = _surface_semi_diameter(s_back, optic, idx_back) base_name = f"D{elem_indices[0]}" front_name = f"{base_name}.front" cement_name = f"{base_name}.cemented" back_name = f"{base_name}.back" for estimated, name in ( (front_est, front_name), (cement_est, cement_name), (back_est, back_name), ): if estimated: report.estimated_apertures.append(name) ap_r = max(front_ap, cement_ap, back_ap) cs = CoordinateSystem(z=z_front) config = DoubletConfig( r1=_surface_radius(s_front), r2=_surface_radius(s_cement), r3=_surface_radius(s_back), thickness1=thickness1, thickness2=thickness2, material1=_material_name(s_front), material2=_material_name(s_cement), aperture_radius=ap_r, conic1=_surface_conic(s_front), conic2=_surface_conic(s_cement), conic3=_surface_conic(s_back), front=_surface_config_with_coating(s_front, front_name, report), cemented=_surface_config_with_coating(s_cement, cement_name, report), back=_surface_config_with_coating(s_back, back_name, report), ) scene.add_doublet(base_name, cs, config) def _build_spectrum(optic) -> object: """Build an NSQ Spectrum from the optic's wavelength group. Args: optic: Sequential Optic. Returns: Spectrum instance. """ import numpy as np # noqa: PLC0415 from optiland.nonsequential.sources.base import Spectrum # noqa: PLC0415 wls_um = [w.value for w in optic.wavelengths.wavelengths] weights = list(optic.wavelengths.weights) if not weights or all(w == 0 for w in weights): weights = [1.0] * len(wls_um) return Spectrum( wavelengths=np.array(wls_um, dtype=np.float64), weights=np.array(weights, dtype=np.float64), ) def _collimated_source_cs(y_angle_deg: float, x_angle_deg: float, epd: float): """Build a CoordinateSystem for a CollimatedSource at the given field angles. The source is placed upstream of the system so that rays propagate at the specified field angles. The CS is rotated by the field angles so that the beam propagates in the correct direction. Args: y_angle_deg: Field angle in the Y direction [degrees]. x_angle_deg: Field angle in the X direction [degrees]. epd: Entrance pupil diameter [mm] -- used to set the upstream offset. Returns: CoordinateSystem for the source. """ from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415 upstream_z = -max(epd, 10.0) # rx rotates around x-axis (tilts in Y), ry around y-axis (tilts in X) rx_rad = math.radians(-y_angle_deg) ry_rad = math.radians(x_angle_deg) return CoordinateSystem(z=upstream_z, rx=rx_rad, ry=ry_rad) def _point_source_cs(y_height: float, obj_dist: float): """Build a CoordinateSystem for a PointSource at the object plane. Args: y_height: Object height [mm] (y-field coordinate). obj_dist: Object distance from the first surface [mm]. Returns: CoordinateSystem for the source. """ from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415 return CoordinateSystem(y=y_height, z=obj_dist) def _marginal_ray_angle_at_object(optic) -> float: """Compute the paraxial marginal-ray half-angle at the object plane. Args: optic: Sequential Optic. Returns: Half-angle of the marginal ray in degrees. """ ya, ua = optic.paraxial.marginal_ray() try: u0 = float(ua[0].item()) except AttributeError: u0 = float(ua[0]) return abs(math.degrees(math.atan(u0))) if u0 != 0 else 5.0 def _add_sources( scene, optic, beam_diameter: float | None, half_angle_deg_override: float | None, ) -> None: """Add one NSQ source per sequential field to the scene. Args: scene: NSQScene to add to. optic: Sequential Optic. beam_diameter: Override EPD [mm] or None. half_angle_deg_override: Override half-angle [deg] or None. """ from optiland.fields.field_types import ( # noqa: PLC0415 AngleField, ObjectHeightField, ParaxialImageHeightField, RealImageHeightField, ) from optiland.nonsequential.sources.configs import ( # noqa: PLC0415 CollimatedSourceConfig, PointSourceConfig, ) field_def = optic.fields.field_definition if isinstance(field_def, ParaxialImageHeightField | RealImageHeightField): raise ConversionError( "Fields of type 'paraxial_image_height' or 'real_image_height' " "are not supported by the converter. Switch to 'angle' or " "'object_height' fields before converting." ) spectrum = _build_spectrum(optic) epd = beam_diameter if beam_diameter is not None else float(optic.paraxial.EPD()) for i, field in enumerate(optic.fields.fields): if isinstance(field_def, AngleField): cs = _collimated_source_cs(field.y, field.x, epd) config = CollimatedSourceConfig( spectrum=spectrum, total_flux=1.0, aperture_radius=epd / 2.0, ) scene.add_source(f"S{i}", cs, config) elif isinstance(field_def, ObjectHeightField): obj_surf = optic.object_surface obj_z = _surface_z(obj_surf) if obj_surf is not None else -1e9 ha_deg = ( half_angle_deg_override if half_angle_deg_override is not None else _marginal_ray_angle_at_object(optic) ) cs = _point_source_cs(field.y, obj_z) config = PointSourceConfig( spectrum=spectrum, total_flux=1.0, half_angle_deg=ha_deg, ) scene.add_source(f"S{i}", cs, config) else: raise ConversionError( f"Field definition type '{type(field_def).__name__}' is not " "supported by the converter." ) def _add_detector( scene, optic, detector_width: float | None, detector_height: float | None, detector_pixels: tuple[int, int], ) -> None: """Add an IrradianceDetector at the image surface. Args: scene: NSQScene to add to. optic: Sequential Optic. detector_width: Semi-width override [mm] or None. detector_height: Semi-height override [mm] or None. detector_pixels: (num_pixels_x, num_pixels_y). """ from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415 from optiland.nonsequential.detectors.configs import ( # noqa: PLC0415 IrradianceDetectorConfig, ) img_surf = optic.image_surface z_img = _surface_z(img_surf) if detector_width is not None: width = detector_width else: # Use paraxial chief ray height at image plane x 2 as the full width try: yb, _ub = optic.paraxial.chief_ray() try: img_ht = abs(float(yb[-1].item())) except AttributeError: img_ht = abs(float(yb[-1])) width = max(img_ht * 2.0, 1.0) except Exception: width = 10.0 # sensible fallback height = detector_height if detector_height is not None else width nx, ny = detector_pixels cs = CoordinateSystem(z=z_img) config = IrradianceDetectorConfig( width=width, height=height, num_pixels_x=nx, num_pixels_y=ny, ) scene.add_detector("D1", cs, config)