Source code for optiland.fileio.codev.reader.converter

"""CODE V to Optic Converter

Converts a CodeVDataModel into an Optiland Optic object. This module also
provides the ``load_codev_file`` entry point for the reader path.

Kramer Harrison, 2026
"""

from __future__ import annotations

from typing import Any

import optiland.backend as be
from optiland.fileio.base import BaseOpticReader
from optiland.fileio.codev.model import CodeVDataModel
from optiland.fileio.codev.reader.parser import CodeVDataParser
from optiland.optic import Optic

# Map from CODE V aperture key to Optiland aperture type
_APERTURE_KEY_MAP: dict[str, str] = {
    "EPD": "EPD",
    "FNO": "imageFNO",
    "NA": "imageFNO",  # approximate — Optiland uses imageFNO
    "NAO": "objectNA",
}


[docs] class CodeVToOpticConverter(BaseOpticReader): """Converts a CodeVDataModel into an Optic object. Also implements BaseOpticReader so that the full pipeline (file load → parsing → conversion) can be triggered via ``read()``. Args: codev_data: A plain dict or CodeVDataModel containing the CODE V optical system data. Attributes: data: The CODE V data as a plain dict. optic: The Optic instance built by :py:meth:`convert`. """ def __init__(self, codev_data: dict[str, Any] | CodeVDataModel): if isinstance(codev_data, CodeVDataModel): self.data = codev_data.to_dict() else: self.data = dict(codev_data) self.optic: Optic | None = None # ------------------------------------------------------------------ # BaseOpticReader # ------------------------------------------------------------------
[docs] def read(self, source: str) -> Optic: """Read a CODE V .seq file and return a fully-configured Optic. Args: source: Local file path to a .seq file. Returns: A configured Optic instance. """ data_model = CodeVDataParser(source).parse() self.data = data_model.to_dict() return self.convert()
# ------------------------------------------------------------------ # Conversion # ------------------------------------------------------------------
[docs] def convert(self) -> Optic: """Convert the stored CODE V data dict into an Optic object. Returns: The fully-configured Optic instance. """ self.optic = Optic(self.data.get("name")) self._configure_surfaces() self._configure_aperture() self._configure_fields() self._configure_wavelengths() return self.optic
# ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ def _configure_surfaces(self) -> None: """Configure all surfaces on the optic. All surfaces from the data model — including object (SO) and image (SI) — are added sequentially. The Optiland surface factory automatically creates an ObjectSurface for index 0. CODE V XDE/YDE/ADE/BDE/CDE modifiers are mapped directly to the ``dx``/``dy``/``rx``/``ry``/``rz`` surface parameters so that thickness-based sequential propagation is preserved. """ surfaces = self.data.get("surfaces", {}) sto_index = self.data.get("sto_surface_index") # Ensure the first surface is an object-type surface. Files that # contain only bare S lines (no SO / SI) need an implicit object. sorted_keys = sorted(surfaces.keys(), key=int) first_surf = surfaces[sorted_keys[0]] if sorted_keys else {} if first_surf.get("type", "standard") not in ("object",): implicit_obj: dict[str, Any] = { "type": "object", "radius": float(be.inf), "thickness": float(be.inf), "material": None, "is_stop": False, "conic": 0.0, "coefficients": [], "xde": 0.0, "yde": 0.0, "zde": 0.0, "ade": 0.0, "bde": 0.0, "cde": 0.0, "aperture": None, } # Renumber existing surfaces to make room for index 0 new_surfaces: dict[int, dict[str, Any]] = {0: implicit_obj} for new_k, old_k in enumerate(sorted_keys, start=1): new_surfaces[new_k] = surfaces[old_k] surfaces = new_surfaces sorted_keys = sorted(surfaces.keys(), key=int) if sto_index is not None: sto_index += 1 # shift by 1 for the inserted object surface has_stop = any(sd.get("is_stop", False) for sd in surfaces.values()) for surf_idx, idx in enumerate(sorted_keys): surf = surfaces[idx] # Apply explicit global stop reference (STO Sn) if sto_index is not None and surf_idx == sto_index: surf = dict(surf) surf["is_stop"] = True has_stop = True # Default stop: if no STO in file, surface 1 (first real surface) if not has_stop and surf_idx == 1: surf = dict(surf) surf["is_stop"] = True surface_params = self._build_surface_params(surf, surf_idx) self.optic.surfaces.add(**surface_params) def _build_surface_params( self, surf: dict[str, Any], surf_idx: int ) -> dict[str, Any]: """Build the kwargs dict for ``optic.surfaces.add()``. CODE V XDE/YDE/ZDE/ADE/BDE/CDE modifiers are mapped to Optiland's ``dx``/``dy``/``rx``/``ry``/``rz`` surface parameters so that the sequential propagation thickness is preserved. Args: surf: Raw surface dict from the parser. surf_idx: Sequential surface index within the optic. Returns: Keyword-argument dict for ``optic.surfaces.add()``. """ # Object and image surfaces map to "standard" in Optiland's factory. # (The factory creates ObjectSurface for index 0 automatically.) surf_type_cv = surf.get("type", "standard") if surf_type_cv in ("object", "image"): optiland_type = "standard" else: profile = surf.get("profile", "SPH") if surf.get("coefficients"): profile = "ASP" optiland_type = "even_asphere" if profile == "ASP" else "standard" material = surf.get("material") or "air" thickness = surf.get("thickness", 0.0) # Large object distances (≥ 1e10) represent optical infinity in CODE V if surf_type_cv == "object" and abs(float(thickness)) >= 1e10: thickness = float(be.inf) params: dict[str, Any] = { "index": surf_idx, "surface_type": optiland_type, "radius": surf.get("radius", float(be.inf)), "conic": surf.get("conic", 0.0), "thickness": thickness, "is_stop": surf.get("is_stop", False), "material": material, } coefficients = surf.get("coefficients") if coefficients: params["coefficients"] = coefficients if surf.get("aperture") is not None: params["aperture"] = surf["aperture"] # Map CODE V surface decenters/tilts to Optiland surface parameters. xde = float(surf.get("xde", 0.0)) yde = float(surf.get("yde", 0.0)) ade_deg = float(surf.get("ade", 0.0)) bde_deg = float(surf.get("bde", 0.0)) cde_deg = float(surf.get("cde", 0.0)) if xde or yde or ade_deg or bde_deg or cde_deg: params["dx"] = xde params["dy"] = yde params["rx"] = float(be.deg2rad(ade_deg)) params["ry"] = float(be.deg2rad(bde_deg)) params["rz"] = float(be.deg2rad(cde_deg)) return params def _configure_aperture(self) -> None: """Configure the system aperture on the optic.""" aperture_data = self.data.get("aperture", {}) if not aperture_data: return for cv_key, optiland_key in _APERTURE_KEY_MAP.items(): if cv_key in aperture_data: self.optic.set_aperture( aperture_type=optiland_key, value=float(aperture_data[cv_key]) ) return raise ValueError("No valid aperture type found in CODE V data.") def _configure_fields(self) -> None: """Configure the field group on the optic.""" fields = self.data.get("fields", {}) field_type = fields.get("type", "angle") self.optic.fields.set_type(field_type=field_type) field_x = fields.get("x", [0.0]) field_y = fields.get("y", [0.0]) for k in range(len(field_y)): x = field_x[k] if k < len(field_x) else 0.0 y = field_y[k] self.optic.fields.add(x=float(x), y=float(y)) def _configure_wavelengths(self) -> None: """Configure the wavelength group on the optic.""" wl_data = self.data.get("wavelengths", {}) primary_idx = wl_data.get("primary_index", 0) for idx, value in enumerate(wl_data.get("data", [])): self.optic.wavelengths.add( value=float(value), is_primary=(idx == primary_idx) )