Source code for optiland.fileio.zemax.reader.parser

"""Zemax Data Parser

Parses a Zemax OpticStudio .zmx file into a ZemaxDataModel. The parser uses
a dispatch table of per-operand handler methods to process each line.

Kramer Harrison, 2024
"""

from __future__ import annotations

import re
import warnings
from typing import Any

import optiland.backend as be
from optiland.fileio.zemax.model import ZemaxDataModel
from optiland.materials import (
    AbbeMaterial,
    BaseMaterial,
    IdealMaterial,
    Material,
    OptilandMaterialWarning,
)
from optiland.materials.material_spec import MatchPolicy
from optiland.materials.registry import MaterialRegistry
from optiland.physical_apertures import OffsetRadialAperture, RadialAperture

# Fraunhofer d-line (um), used to evaluate a candidate glass's index for
# comparison against the Nd recorded on a GLAS line.
_WL_D = 0.5875618

# A six-digit MIL glass number, e.g. 517642 or 517.642: nd 1.517, vd 64.2.
_MIL_NUMBER = re.compile(r"(\d{3})\.?(\d{3})")


[docs] class ZemaxDataParser: """Parses a Zemax .zmx file into a ZemaxDataModel. Args: filename: Path to the .zmx file to parse. Attributes: filename: The file path being parsed. data_model: The ZemaxDataModel being populated during parsing. """ def __init__(self, filename: str) -> None: self.filename = filename self.data_model = ZemaxDataModel() self._current_surf = -1 self._current_surf_data: dict[str, Any] = {} self._current_aperture_offset = (0.0, 0.0) self._declared_field_count: int | None = None self._declared_wavelength_count: int | None = None self._raw_fields: dict[str, list[float]] = {} self._wavelength_slots: dict[int, tuple[float, float]] = {} self._primary_wavelength_slot: int | None = None self._read_config_data(["FTYP"]) # Operand dispatch table — maps operand string to handler method self._operand_table = { "NAME": self._read_name, "FNUM": self._read_fno, "ENPD": self._read_epd, "OBNA": self._read_object_na, "FLOA": self._read_floating_stop, "FTYP": self._read_config_data, "XFLD": self._read_x_fields, "YFLD": self._read_y_fields, "XFLN": self._read_x_fields, "YFLN": self._read_y_fields, "WAVL": self._read_wavelength, "WAVM": self._read_wavelength, "PWAV": self._read_primary_wave, "SURF": self._read_surface, "TYPE": self._read_surf_type, "PARM": self._read_surface_parameter, "CURV": self._read_radius, "DISZ": self._read_thickness, "CONI": self._read_conic, "GLAS": self._read_glass, "STOP": self._read_stop, "DIAM": self._read_diameter, "MODE": self._read_mode, "GCAT": self._read_glass_catalog, "FWGN": self._read_field_weights, "VDXN": self._read_vignette_decenter_x, "VDYN": self._read_vignette_decenter_y, "VCXN": self._read_vignette_compress_x, "VCYN": self._read_vignette_compress_y, "VANN": self._read_vignette_tangent_angle, "CLAP": self._read_circular_aperture, "FLAP": self._read_floating_aperture, "OBSC": self._read_unsupported_aperture, "SQAP": self._read_unsupported_aperture, "ELAP": self._read_unsupported_aperture, "SPID": self._read_unsupported_aperture, "USAP": self._read_unsupported_aperture, "OBDC": self._read_aperture_decenter, }
[docs] def parse(self) -> ZemaxDataModel: """Read the Zemax file and extract optical data into a ZemaxDataModel. Tries UTF-16 LE, UTF-8, and ISO-8859-1 encodings in that order. Returns: A populated ZemaxDataModel. Raises: ValueError: If the file cannot be read or contains no aperture data. """ encodings = ["utf-16", "utf-8", "iso-8859-1"] success = False for encoding in encodings: try: with open(self.filename, encoding=encoding) as fh: for line in fh: tokens = line.split() if not tokens: continue operand = tokens[0] if operand in self._operand_table: self._operand_table[operand](tokens) except (UnicodeError, UnicodeDecodeError): continue if self.data_model.aperture: success = True break if not success: raise ValueError("Failed to read Zemax file.") self._finalize_fields() self._finalize_surface() self._resolve_glasses() return self.data_model
# ------------------------------------------------------------------ # Per-operand handlers # ------------------------------------------------------------------ def _read_name(self, data: list[str]) -> None: self.data_model.name = " ".join(data[1:]) def _read_fno(self, data: list[str]) -> None: if int(data[2]) == 0: self.data_model.aperture["imageFNO"] = float(data[1]) elif int(data[2]) == 1: self.data_model.aperture["paraxialImageFNO"] = float(data[1]) def _read_epd(self, data: list[str]) -> None: self.data_model.aperture["EPD"] = float(data[1]) def _read_object_na(self, data: list[str]) -> None: if int(data[2]) == 0: self.data_model.aperture["objectNA"] = float(data[1]) elif int(data[2]) == 1: self.data_model.aperture["object_cone_angle"] = float(data[1]) def _read_floating_stop(self, data: list[str]) -> None: self.data_model.aperture["floating_stop"] = True def _read_config_data(self, data: list[str]) -> None: # Legacy ZEMAX files (e.g. VERS 6133) emit FTYP with only 1-2 tokens # where the current format has 8. Read defensively so a short FTYP # falls back to defaults instead of raising IndexError. def _safe_int(idx: int, default: int = 0) -> int: if idx >= len(data): return default tok = data[idx] if not tok: return default try: return int(tok) except ValueError: return default # Defaults describe legacy files; only declared non-negative counts # constrain incoming records. In particular, zero is authoritative. field_count = _safe_int(3, -1) wavelength_count = _safe_int(4, -1) self._declared_field_count = field_count if field_count >= 0 else None self._declared_wavelength_count = ( wavelength_count if wavelength_count >= 0 else None ) fields = self.data_model.fields fields["type"] = { 0: "angle", 1: "object_height", 2: "paraxial_image_height", 3: "real_image_height", 4: "theodolite_angle", }.get(_safe_int(1, 0), "unsupported") fields["object_space_telecentric"] = _safe_int(2, 0) == 1 fields["afocal_image_space"] = _safe_int(7, 0) == 1 self._sync_fields() self._sync_wavelengths() def _read_field_column(self, key: str, data: list[str]) -> None: """Retain the latest full column so later declarations can expand it.""" self._raw_fields[key] = [float(v) for v in data[1:]] self._sync_fields() def _sync_fields(self) -> None: """Publish bounded columns and align defaults only for omitted axes.""" fields = self.data_model.fields for key, values in self._raw_fields.items(): fields[key] = values[: self._declared_field_count] num_fields = max( (len(fields[axis]) for axis in ("x", "y") if axis in self._raw_fields), default=1, ) if self._declared_field_count is not None: num_fields = min(num_fields, self._declared_field_count) for axis in ("x", "y"): if axis not in self._raw_fields: fields[axis] = [0.0] * num_fields fields["num_fields"] = ( num_fields if self._declared_field_count is None else self._declared_field_count ) def _read_x_fields(self, data: list[str]) -> None: self._read_field_column("x", data) def _read_y_fields(self, data: list[str]) -> None: self._read_field_column("y", data) def _read_wavelength(self, data: list[str]) -> None: """Read wavelength/weight pairs in microns, with indexed WAVM slots.""" if data[0] == "WAVL": # Preserve historical token consumption, not the actual WAVL vector # layout. Parsing that layout and WWGT remains deferred. slot = max(self._wavelength_slots, default=0) + 1 else: slot = int(data[1]) if slot < 1: return val = float(data[2]) weight = float(data[3]) if len(data) > 3 else 1.0 self._wavelength_slots[slot] = (val, weight) self._sync_wavelengths() def _read_primary_wave(self, data: list[str]) -> None: self._primary_wavelength_slot = int(data[1]) self._sync_wavelengths() def _sync_wavelengths(self) -> None: """Publish active slots in order without losing PWAV slot identity.""" slots = [ slot for slot in sorted(self._wavelength_slots) if self._declared_wavelength_count is None or slot <= self._declared_wavelength_count ] wavelengths = self.data_model.wavelengths wavelengths["data"] = [self._wavelength_slots[slot][0] for slot in slots] wavelengths["weights"] = [self._wavelength_slots[slot][1] for slot in slots] wavelengths["num_wavelengths"] = ( len(slots) if self._declared_wavelength_count is None else self._declared_wavelength_count ) if self._primary_wavelength_slot is not None: # A missing slot must not alias another record when holes compact. wavelengths["primary_index"] = ( slots.index(self._primary_wavelength_slot) if self._primary_wavelength_slot in slots else None ) def _read_surface(self, data: list[str]) -> None: self._store_current_surface() self._current_surf += 1 self._current_aperture_offset = (0.0, 0.0) self._current_surf_data = { "type": "standard", "is_stop": False, "conic": 0.0, "material": "air", "aperture": None, } def _read_radius(self, data: list[str]) -> None: try: self._current_surf_data["radius"] = 1.0 / float(data[1]) except ZeroDivisionError: self._current_surf_data["radius"] = be.inf def _read_thickness(self, data: list[str]) -> None: if data[1] == "INFINITY": self._current_surf_data["thickness"] = be.inf else: self._current_surf_data["thickness"] = float(data[1]) def _read_conic(self, data: list[str]) -> None: self._current_surf_data["conic"] = float(data[1]) def _read_glass(self, data: list[str]) -> None: # GLAS <name> <solve> <?> <nd> <vd> ...: solve 1 marks a model glass. # The name is resolved once the whole file is read, so the result does # not depend on where GCAT appears. material_name = data[1] if material_name.upper() == "MIRROR": self._current_surf_data["material"] = "mirror" return self._current_surf_data["material"] = material_name self._current_surf_data["model_glass"] = ( len(data) > 2 and data[2] == "1" ) or material_name == "___BLANK" try: self._current_surf_data["index"] = float(data[4].replace(",", ".")) self._current_surf_data["abbe"] = float(data[5].replace(",", ".")) except (IndexError, ValueError): self._current_surf_data["index"] = None self._current_surf_data["abbe"] = None def _resolve_glasses(self) -> None: """Replace each surface's glass name with a material. Glasses resolve the way OpticStudio resolves them: the GCAT catalogs are searched by exact name only. A name found in several of them is settled by the nd/vd on the GLAS line, then by GCAT order (see ``_closest_to_line``). A glass found in none of them, a MIL-number name (``517642`` means nd = 1.517, vd = 64.2) and an explicit model glass become a model glass built from nd/vd; vd = 0 gives an undispersed ``IdealMaterial``. A catalog glass that had to be modelled emits a warning. There is no fuzzy search across all catalogs, so ``Material(name)`` lookups are not used here. """ known = {c.lower() for c in MaterialRegistry.instance().list_catalogs()} catalogs = [] for catalog in self.data_model.glass_catalogs or []: if catalog.lower() in known: catalogs.append(catalog.lower()) else: warnings.warn( f"Glass catalog {catalog} is not in the material database; " "its glasses are modelled from the nd/vd in the file.", OptilandMaterialWarning, stacklevel=4, ) for index, surf in self.data_model.surfaces.items(): # Only surfaces with a GLAS line carry "model_glass". if "model_glass" in surf: surf["material"] = self._resolve_glass( index, surf, surf["material"], catalogs ) @classmethod def _resolve_glass( cls, surf_index: int, surf: dict[str, Any], name: str, catalogs: list[str] ) -> BaseMaterial: if not surf.get("model_glass"): candidates = [] for catalog in catalogs: try: # reference names the catalog so the writer exports it. candidates.append( Material( name, reference=catalog, catalog=catalog, match_policy=MatchPolicy.STRICT, ) ) except ValueError: continue if candidates: return cls._closest_to_line(candidates, surf) index, abbe = surf.get("index"), surf.get("abbe") model = surf.get("model_glass") mil = _MIL_NUMBER.fullmatch(name) if mil and not model: index = 1.0 + int(mil[1]) / 1000.0 abbe = int(mil[2]) / 10.0 model = True if index is None or abbe is None or index <= 1.0: raise ValueError( f"Surface {surf_index}: glass {name} is not in the GCAT catalogs " f"{catalogs} and the file gives no nd/vd to model it from." ) if not model: warnings.warn( f"Surface {surf_index}: glass {name} is not in the GCAT catalogs " f"{catalogs}; modelled from nd = {index}, vd = {abbe}.", OptilandMaterialWarning, stacklevel=5, ) if abbe <= 0.0: return IdealMaterial(n=index) return AbbeMaterial(index, abbe, model="buchdahl") @staticmethod def _closest_to_line( candidates: list[BaseMaterial], surf: dict[str, Any] ) -> BaseMaterial: """Pick the candidate whose nd/vd best match the GLAS line. A name can exist in several declared catalogs (e.g. "F2" in Schott and Hikari), and a .zmx file lists catalogs only once for the whole file. The nd/vd that OpticStudio and the writer store on the GLAS line identify which one a surface used. Without nd/vd, or on a tie, the first catalog in GCAT order wins. """ index, abbe = surf.get("index"), surf.get("abbe") if len(candidates) == 1 or index is None or index <= 1.0: return candidates[0] def _scalar(value) -> float: return float(be.atleast_1d(be.array(value)).ravel()[0]) def _distance(candidate: BaseMaterial) -> float: try: d_n = _scalar(candidate.n(_WL_D)) - index d_v = 0.0 if abbe is not None: d_v = (_scalar(candidate.abbe()) - abbe) / 100.0 return d_n**2 + d_v**2 except Exception: return float("inf") return min(candidates, key=_distance) def _read_stop(self, data: list[str]) -> None: self._current_surf_data["is_stop"] = True def _read_diameter(self, data: list[str]) -> None: # DIAM <semi-diameter> <solve> ...: solve 1 is a user-fixed ("U") # semi-diameter, 0 an automatic one recomputed by OpticStudio. self._current_surf_data["semi_diameter"] = float(data[1]) self._current_surf_data["semi_diameter_fixed"] = ( len(data) > 2 and data[2] == "1" ) def _read_mode(self, data: list[str]) -> None: if data[1] != "SEQ": raise ValueError("Only sequential mode is supported.") def _read_glass_catalog(self, data: list[str]) -> None: self.data_model.glass_catalogs = data[1:] def _read_surf_type(self, data: list[str]) -> None: self._current_surf_data["type"] = { "STANDARD": "standard", "EVENASPH": "even_asphere", "ODDASPHE": "odd_asphere", "COORDBRK": "coordinate_break", "TOROIDAL": "toroidal", }.get(data[1], data[1].lower()) def _read_surface_parameter(self, data: list[str]) -> None: key = f"param_{int(data[1]) - 1}" self._current_surf_data[key] = float(data[2]) def _read_field_weights(self, data: list[str]) -> None: self._read_field_column("weights", data) def _read_vignette_decenter_x(self, data: list[str]) -> None: self._read_field_column("vignette_decenter_x", data) def _read_vignette_decenter_y(self, data: list[str]) -> None: self._read_field_column("vignette_decenter_y", data) def _read_vignette_compress_x(self, data: list[str]) -> None: self._read_field_column("vignette_compress_x", data) def _read_vignette_compress_y(self, data: list[str]) -> None: self._read_field_column("vignette_compress_y", data) def _read_vignette_tangent_angle(self, data: list[str]) -> None: self._read_field_column("vignette_tangent_angle", data) def _read_circular_aperture(self, data: list[str]) -> None: r_min = float(data[1]) r_max = float(data[2]) self._current_surf_data["aperture"] = self._make_circular_aperture(r_min, r_max) def _read_floating_aperture(self, data: list[str]) -> None: # FLAP stores the clear semi-diameter in its second field; a floating # aperture clips rays outside it just like a circular aperture. r_max = float(data[2]) self._current_surf_data["aperture"] = self._make_circular_aperture(0.0, r_max) def _read_unsupported_aperture(self, data: list[str]) -> None: # An aperture type the reader cannot build yet. It still replaces the # fixed semi-diameter clip, as it does in OpticStudio. self._current_surf_data["unsupported_aperture"] = data[0] def _make_circular_aperture(self, r_min: float, r_max: float) -> RadialAperture: offset_x, offset_y = self._current_aperture_offset if offset_x == 0.0 and offset_y == 0.0: return RadialAperture(r_min=r_min, r_max=r_max) return OffsetRadialAperture( r_min=r_min, r_max=r_max, offset_x=offset_x, offset_y=offset_y, ) def _read_aperture_decenter(self, data: list[str]) -> None: offset_x = float(data[1]) offset_y = float(data[2]) self._current_aperture_offset = (offset_x, offset_y) aperture = self._current_surf_data["aperture"] if aperture is None: return self._current_surf_data["aperture"] = self._make_circular_aperture( aperture.r_min, aperture.r_max ) # ------------------------------------------------------------------ # Finalizers # ------------------------------------------------------------------ def _finalize_fields(self) -> None: """Deduplicate and sort fields by y-coordinate.""" fields = self.data_model.fields if "x" not in fields or "y" not in fields: return keys = ["x", "y"] for extra in [ "weights", "vignette_decenter_x", "vignette_decenter_y", "vignette_compress_x", "vignette_compress_y", "vignette_tangent_angle", ]: if extra in fields: keys.append(extra) zipped = list(zip(*(fields[k] for k in keys), strict=False)) seen = set() unique = [] for item in zipped: xy = item[:2] if xy not in seen: seen.add(xy) unique.append(item) sorted_items = sorted(unique, key=lambda it: it[1]) if sorted_items: unzipped = list(zip(*sorted_items, strict=False)) for i, k in enumerate(keys): fields[k] = list(unzipped[i]) if self._declared_field_count is None: fields["num_fields"] = len(fields["x"]) def _finalize_surface(self) -> None: """Flush the last in-progress surface into the model.""" self._store_current_surface() def _store_current_surface(self) -> None: """Store the in-progress surface, applying its fixed semi-diameter. OpticStudio vignettes rays at a user-fixed ("U") semi-diameter even when the surface has no explicit aperture, so such a surface gets a circular aperture of that radius. Any surface aperture, including types not imported yet (OBSC, SQAP, ELAP, SPID, USAP), replaces that clip. Automatic semi-diameters never clip. """ if self._current_surf < 0: return surf = self._current_surf_data semi_diameter = surf.get("semi_diameter", 0.0) if ( surf.get("aperture") is None and "unsupported_aperture" not in surf and surf.get("semi_diameter_fixed") and semi_diameter > 0.0 ): surf["aperture"] = RadialAperture(r_min=0.0, r_max=semi_diameter) self.data_model.surfaces[self._current_surf] = surf