Skip to content

Simulator backends

openlithohub.simulators is the vendor-neutral interface for lithography forward simulation. The bundled HopkinsSimulator is the reference implementation; CalibreSimulator and TachyonSimulator are config-validated stubs that activate when the corresponding toolchain is on PATH and licensed.

The module also re-exports load_source_intensity, load_zernike_coefficients, and zernike_phase_map so callers can drive the Hopkins forward model with measured-source maps and Zernike-pupil aberrations without reaching into _utils.optics.

Registry

openlithohub.simulators.registry

Open registry of simulator backends, keyed by string name.

get_simulator(name, config=None)

Construct a simulator by name.

Parameters:

Name Type Description Default
name str

Registered backend name (e.g. "hopkins").

required
config SimulatorConfig | None

Optional :class:SimulatorConfig.

None

Raises:

Type Description
KeyError

If name is not registered.

Source code in src/openlithohub/simulators/registry.py
def get_simulator(
    name: str,
    config: SimulatorConfig | None = None,
) -> BaseSimulator:
    """Construct a simulator by name.

    Args:
        name: Registered backend name (e.g. ``"hopkins"``).
        config: Optional :class:`SimulatorConfig`.

    Raises:
        KeyError: If ``name`` is not registered.
    """

    try:
        cls = _REGISTRY[name]
    except KeyError as exc:
        raise KeyError(f"Unknown simulator {name!r}; registered: {list_simulators()}") from exc
    return cls(config)

list_simulators()

Return the names of all registered simulator backends, sorted.

Source code in src/openlithohub/simulators/registry.py
def list_simulators() -> list[str]:
    """Return the names of all registered simulator backends, sorted."""

    return sorted(_REGISTRY)

register_simulator(name, cls)

Register a simulator class under name.

Overwrites any previous registration to keep the API simple — users that want defensiveness can guard with name in list_simulators().

Source code in src/openlithohub/simulators/registry.py
def register_simulator(name: str, cls: type[BaseSimulator]) -> None:
    """Register a simulator class under ``name``.

    Overwrites any previous registration to keep the API simple — users
    that want defensiveness can guard with ``name in list_simulators()``.
    """

    _REGISTRY[name] = cls

describe_simulators()

Return (name, class) pairs for every registered simulator, sorted by name.

Used by the CLI to print human-readable backend listings without reaching into _REGISTRY from the outside.

Source code in src/openlithohub/simulators/registry.py
def describe_simulators() -> list[tuple[str, type[BaseSimulator]]]:
    """Return ``(name, class)`` pairs for every registered simulator, sorted by name.

    Used by the CLI to print human-readable backend listings without
    reaching into ``_REGISTRY`` from the outside.
    """

    return sorted(_REGISTRY.items())

Base interface

openlithohub.simulators.base

Simulator base class and result/config dataclasses.

BaseSimulator

Bases: ABC

Abstract simulator backend.

A simulator maps mask -> SimulatorResult. The ABC makes no assumption about differentiability; callers that need gradients should pick a backend that documents support (e.g. :class:HopkinsSimulator).

Implementations should be cheap to construct — heavy state (kernel caches, tool licenses) belongs in :meth:prepare so that callers can decide when to pay the cost.

Source code in src/openlithohub/simulators/base.py
class BaseSimulator(ABC):
    """Abstract simulator backend.

    A simulator maps ``mask -> SimulatorResult``. The ABC makes no
    assumption about differentiability; callers that need gradients
    should pick a backend that documents support (e.g.
    :class:`HopkinsSimulator`).

    Implementations should be cheap to construct — heavy state (kernel
    caches, tool licenses) belongs in :meth:`prepare` so that callers
    can decide when to pay the cost.
    """

    name: str = "base"
    differentiable: bool = False

    def __init__(self, config: SimulatorConfig | None = None) -> None:
        self.config = config or SimulatorConfig()

    def prepare(self) -> None:
        """Eagerly initialise backend state (kernels, tool sessions).

        Default no-op. Override when there is meaningful setup cost so
        that callers can amortise it across many simulate() calls.
        """

    def with_config(self, config: SimulatorConfig) -> BaseSimulator:
        """Return a sibling simulator using ``config``, sharing cached state where possible.

        Default builds a fresh instance via ``type(self)(config)``. Subclasses
        with expensive per-config setup (SOCS kernels, vendor sessions) should
        override to clone cheaply when the new config only differs in fields
        the cached state does not depend on (typically ``dose`` / ``threshold``).
        """
        return type(self)(config)

    @abstractmethod
    def simulate(self, mask: torch.Tensor) -> SimulatorResult:
        """Simulate the aerial image (and resist contour, if available).

        Args:
            mask: Real-valued mask. Shape ``(H, W)`` or ``(B, 1, H, W)``,
                values in ``[0, 1]``.

        Returns:
            A :class:`SimulatorResult` with the same spatial shape as
            ``mask``.
        """

    def __repr__(self) -> str:
        return f"{type(self).__name__}(name={self.name!r}, differentiable={self.differentiable})"

prepare()

Eagerly initialise backend state (kernels, tool sessions).

Default no-op. Override when there is meaningful setup cost so that callers can amortise it across many simulate() calls.

Source code in src/openlithohub/simulators/base.py
def prepare(self) -> None:
    """Eagerly initialise backend state (kernels, tool sessions).

    Default no-op. Override when there is meaningful setup cost so
    that callers can amortise it across many simulate() calls.
    """

with_config(config)

Return a sibling simulator using config, sharing cached state where possible.

Default builds a fresh instance via type(self)(config). Subclasses with expensive per-config setup (SOCS kernels, vendor sessions) should override to clone cheaply when the new config only differs in fields the cached state does not depend on (typically dose / threshold).

Source code in src/openlithohub/simulators/base.py
def with_config(self, config: SimulatorConfig) -> BaseSimulator:
    """Return a sibling simulator using ``config``, sharing cached state where possible.

    Default builds a fresh instance via ``type(self)(config)``. Subclasses
    with expensive per-config setup (SOCS kernels, vendor sessions) should
    override to clone cheaply when the new config only differs in fields
    the cached state does not depend on (typically ``dose`` / ``threshold``).
    """
    return type(self)(config)

simulate(mask) abstractmethod

Simulate the aerial image (and resist contour, if available).

Parameters:

Name Type Description Default
mask Tensor

Real-valued mask. Shape (H, W) or (B, 1, H, W), values in [0, 1].

required

Returns:

Name Type Description
A SimulatorResult

class:SimulatorResult with the same spatial shape as

SimulatorResult

mask.

Source code in src/openlithohub/simulators/base.py
@abstractmethod
def simulate(self, mask: torch.Tensor) -> SimulatorResult:
    """Simulate the aerial image (and resist contour, if available).

    Args:
        mask: Real-valued mask. Shape ``(H, W)`` or ``(B, 1, H, W)``,
            values in ``[0, 1]``.

    Returns:
        A :class:`SimulatorResult` with the same spatial shape as
        ``mask``.
    """

SimulatorConfig dataclass

Vendor-neutral simulator configuration.

Backends are free to ignore fields they don't model and to read extra options from :attr:extra. We deliberately keep the surface small — fields here must mean the same thing across every backend.

Attributes:

Name Type Description
wavelength_nm float

Exposure wavelength. 193 = ArF, 13.5 = EUV.

na float

Numerical aperture (image-side).

sigma float

Outer partial-coherence factor.

sigma_inner float

Inner sigma for annular/dipole/quasar (0 = circular).

pixel_size_nm float

Physical size of one mask pixel.

defocus_nm float

Defocus offset.

dose float

Linear dose multiplier.

threshold float

Resist intensity threshold for binarization (0–1 relative to dose). Backends that do not expose a threshold knob round at the model's nominal value.

extra dict[str, Any]

Backend-specific options. Treated as opaque by the ABC.

Source code in src/openlithohub/simulators/base.py
@dataclass(frozen=True)
class SimulatorConfig:
    """Vendor-neutral simulator configuration.

    Backends are free to ignore fields they don't model and to read
    extra options from :attr:`extra`. We deliberately keep the surface
    small — fields here must mean the same thing across every backend.

    Attributes:
        wavelength_nm: Exposure wavelength. 193 = ArF, 13.5 = EUV.
        na: Numerical aperture (image-side).
        sigma: Outer partial-coherence factor.
        sigma_inner: Inner sigma for annular/dipole/quasar (0 = circular).
        pixel_size_nm: Physical size of one mask pixel.
        defocus_nm: Defocus offset.
        dose: Linear dose multiplier.
        threshold: Resist intensity threshold for binarization (0–1
            relative to ``dose``). Backends that do not expose a
            threshold knob round at the model's nominal value.
        extra: Backend-specific options. Treated as opaque by the ABC.
    """

    wavelength_nm: float = 193.0
    na: float = 1.35
    sigma: float = 0.7
    sigma_inner: float = 0.0
    pixel_size_nm: float = 1.0
    defocus_nm: float = 0.0
    dose: float = 1.0
    # Per Yang2023_LithoBench §3.2 (NeurIPS 2023),
    # threshold = 0.225 is the canonical resist cutoff used to score
    # ICCAD16 mask layouts on the simulated wafer image. Confidence A.
    threshold: float = 0.225
    extra: dict[str, Any] = field(default_factory=dict)

SimulatorResult dataclass

Output of a simulator forward pass.

Attributes:

Name Type Description
aerial Tensor

Aerial intensity image, same spatial shape as mask.

resist Tensor | None

Optional binarized resist contour (0/1) at :attr:SimulatorConfig.threshold. None if the backend does not produce one (callers should threshold aerial).

backend str

Name of the simulator that produced the result.

metadata dict[str, Any]

Free-form per-backend metadata (e.g. license info, vendor version, kernel count). Treated as opaque.

Source code in src/openlithohub/simulators/base.py
@dataclass
class SimulatorResult:
    """Output of a simulator forward pass.

    Attributes:
        aerial: Aerial intensity image, same spatial shape as mask.
        resist: Optional binarized resist contour (0/1) at
            :attr:`SimulatorConfig.threshold`. ``None`` if the backend
            does not produce one (callers should threshold ``aerial``).
        backend: Name of the simulator that produced the result.
        metadata: Free-form per-backend metadata (e.g. license info,
            vendor version, kernel count). Treated as opaque.
    """

    aerial: torch.Tensor
    resist: torch.Tensor | None = None
    backend: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)

    def _repr_html_(self) -> str:
        from openlithohub.jupyter._html import (
            kv_table,
            mask_thumbnail_png_b64,
            panel,
            png_b64_to_img_tag,
        )

        shape = "x".join(str(d) for d in tuple(self.aerial.shape))
        rows = [
            ("backend", self.backend or "—"),
            ("aerial shape", shape),
            ("aerial dtype", str(self.aerial.dtype)),
            ("resist", "yes" if self.resist is not None else "no"),
        ]
        for k, v in list(self.metadata.items())[:6]:
            rows.append((f"meta:{k}", str(v)))

        preview_tensor = self.resist if self.resist is not None else self.aerial
        if preview_tensor is not None:
            preview = preview_tensor.detach().cpu()
            if preview.numel() > 0:
                lo, hi = float(preview.min()), float(preview.max())
                if hi > lo:
                    preview = (preview - lo) / (hi - lo)
        img_html = png_b64_to_img_tag(
            mask_thumbnail_png_b64(preview_tensor),
            alt=self.backend or "simulation",
        )
        body = (
            f'<div style="display:flex;gap:10px;align-items:flex-start;">'
            f'<div style="flex:0 0 auto;">{img_html}</div>'
            f'<div style="flex:1 1 auto;">{kv_table(rows)}</div>'
            f"</div>"
        )
        return panel(title="SimulatorResult", header_html="", body_html=body)

Hopkins reference adapter

openlithohub.simulators.hopkins_sim

Hopkins/SOCS simulator adapter — the bundled reference backend.

HopkinsSimulator

Bases: BaseSimulator

Differentiable Hopkins/SOCS simulator.

Wraps :func:openlithohub._utils.hopkins.simulate_aerial_image_hopkins. The full forward pass is auto-differentiable, so this adapter is the right choice when used as a training-loop oracle for ILT or AI-OPC.

Backend-specific options read from config.extra:

  • illumination (circular | annular | dipole | quasar) — source shape, default circular.
  • num_kernels (int) — SOCS truncation order, default 24 (per Yang2023_LithoBench §3.2 / Table II — the reference SOCS decomposition for ICCAD16 evaluation).
  • dipole_angle_deg, pole_opening_deg — pole geometry for dipole/quasar.
Source code in src/openlithohub/simulators/hopkins_sim.py
class HopkinsSimulator(BaseSimulator):
    """Differentiable Hopkins/SOCS simulator.

    Wraps :func:`openlithohub._utils.hopkins.simulate_aerial_image_hopkins`.
    The full forward pass is auto-differentiable, so this adapter is the
    right choice when used as a training-loop oracle for ILT or AI-OPC.

    Backend-specific options read from ``config.extra``:

    * ``illumination`` (``circular`` | ``annular`` | ``dipole`` |
      ``quasar``) — source shape, default ``circular``.
    * ``num_kernels`` (int) — SOCS truncation order, default 24
      (per Yang2023_LithoBench §3.2 / Table II — the reference SOCS
      decomposition for ICCAD16 evaluation).
    * ``dipole_angle_deg``, ``pole_opening_deg`` — pole geometry for
      dipole/quasar.
    """

    name = "hopkins"
    differentiable = True

    def __init__(self, config: SimulatorConfig | None = None) -> None:
        super().__init__(config)
        self._hparams = self._build_hparams(self.config)

    def with_config(self, config: SimulatorConfig) -> HopkinsSimulator:
        """Clone, reusing cached SOCS kernels when only dose/threshold changed.

        SOCS kernel construction depends on optical fields
        (wavelength/NA/sigma/illumination/defocus/pixel_size_nm/num_kernels).
        When those are unchanged, we can hand the new sibling our pre-built
        :class:`HopkinsParams` instead of recomputing.
        """
        sibling = type(self).__new__(type(self))
        BaseSimulator.__init__(sibling, config)
        if self._hparams_match(config):
            sibling._hparams = self._hparams
        else:
            sibling._hparams = self._build_hparams(config)
        return sibling

    def _hparams_match(self, other: SimulatorConfig) -> bool:
        a, b = self.config, other
        if (
            a.wavelength_nm != b.wavelength_nm
            or a.na != b.na
            or a.sigma != b.sigma
            or a.sigma_inner != b.sigma_inner
            or a.pixel_size_nm != b.pixel_size_nm
            or a.defocus_nm != b.defocus_nm
        ):
            return False
        ax = a.extra or {}
        bx = b.extra or {}
        keys = ("num_kernels", "illumination", "dipole_angle_deg", "pole_opening_deg")
        return all(ax.get(k) == bx.get(k) for k in keys)

    @staticmethod
    def _build_hparams(config: SimulatorConfig) -> HopkinsParams:
        extra = config.extra or {}
        return HopkinsParams(
            wavelength_nm=config.wavelength_nm,
            na=config.na,
            sigma=config.sigma,
            sigma_inner=config.sigma_inner,
            pixel_size_nm=config.pixel_size_nm,
            num_kernels=int(extra.get("num_kernels", 24)),
            illumination=extra.get("illumination", "circular"),
            dipole_angle_deg=float(extra.get("dipole_angle_deg", 0.0)),
            pole_opening_deg=float(extra.get("pole_opening_deg", 30.0)),
            defocus_nm=config.defocus_nm,
        )

    def simulate(self, mask: torch.Tensor) -> SimulatorResult:
        aerial = simulate_aerial_image_hopkins(
            mask,
            params=self._hparams,
            dose=self.config.dose,
        )
        # Issue #52: do NOT scale the threshold by dose. `simulate_aerial_image_hopkins`
        # already multiplies the aerial by dose; if we also multiply the threshold by
        # dose, the comparison `aerial >= threshold * dose` reduces to
        # `aerial_unit >= threshold_unit` — i.e. dose is fully cancelled and the
        # resist contour is invariant under dose. The physical convention is the
        # opposite: changing dose changes the resist contour at a *fixed* clearing
        # threshold, since the resist's clearing intensity is a chemical
        # invariant. PW dose corners, stochastic / Monte-Carlo dose jitter, and
        # PVB dose-axis variation all relied on this.
        threshold = self.config.threshold
        resist = (aerial >= threshold).to(aerial.dtype)
        return SimulatorResult(
            aerial=aerial,
            resist=resist,
            backend=self.name,
            metadata={
                "illumination": self._hparams.illumination,
                "num_kernels": self._hparams.num_kernels,
                "differentiable": True,
            },
        )

with_config(config)

Clone, reusing cached SOCS kernels when only dose/threshold changed.

SOCS kernel construction depends on optical fields (wavelength/NA/sigma/illumination/defocus/pixel_size_nm/num_kernels). When those are unchanged, we can hand the new sibling our pre-built :class:HopkinsParams instead of recomputing.

Source code in src/openlithohub/simulators/hopkins_sim.py
def with_config(self, config: SimulatorConfig) -> HopkinsSimulator:
    """Clone, reusing cached SOCS kernels when only dose/threshold changed.

    SOCS kernel construction depends on optical fields
    (wavelength/NA/sigma/illumination/defocus/pixel_size_nm/num_kernels).
    When those are unchanged, we can hand the new sibling our pre-built
    :class:`HopkinsParams` instead of recomputing.
    """
    sibling = type(self).__new__(type(self))
    BaseSimulator.__init__(sibling, config)
    if self._hparams_match(config):
        sibling._hparams = self._hparams
    else:
        sibling._hparams = self._build_hparams(config)
    return sibling

Vendor stubs

openlithohub.simulators.calibre

Calibre nmOPC simulator adapter — stub.

Real Calibre integration requires the Mentor/Siemens EDA Calibre toolchain on PATH plus a valid license file. We do not bundle either, and we do not ship any code path that calls Calibre internals.

This stub exists so that:

  1. Downstream code can target CalibreSimulator symbolically (e.g. in leaderboard config) without conditionally importing it.
  2. The expected config schema is documented in one place.
  3. A user with Calibre access can subclass and override :meth:simulate without rewriting the surrounding wiring.

Track the integration RFC at docs/rfcs/0003-commercial-simulator-hooks.md (TBD).

CalibreSimulator

Bases: BaseSimulator

Stub adapter for Calibre nmOPC.

config.extra should carry:

  • calibre_home (str) — install root containing bin/calibre.
  • runset (str) — path to the SVRF runset to execute.
  • layer_map (dict[str, int]) — maps OpenLithoHub layer names to Calibre layer numbers.
  • license_server (str, optional) — for non-default flexlm setups.
Source code in src/openlithohub/simulators/calibre.py
class CalibreSimulator(BaseSimulator):
    """Stub adapter for Calibre nmOPC.

    ``config.extra`` should carry:

    * ``calibre_home`` (str) — install root containing ``bin/calibre``.
    * ``runset`` (str) — path to the SVRF runset to execute.
    * ``layer_map`` (dict[str, int]) — maps OpenLithoHub layer names to
      Calibre layer numbers.
    * ``license_server`` (str, optional) — for non-default flexlm setups.
    """

    name = "calibre"
    differentiable = False

    def __init__(self, config: SimulatorConfig | None = None) -> None:
        super().__init__(config)
        self._validate_config()

    def _validate_config(self) -> None:
        extra = self.config.extra or {}
        for required in ("calibre_home", "runset"):
            if required not in extra:
                raise ValueError(
                    f"CalibreSimulator requires config.extra[{required!r}]; "
                    f"got keys={sorted(extra.keys())}"
                )

    def simulate(self, mask: torch.Tensor) -> SimulatorResult:
        del mask  # unused in the stub
        raise NotImplementedError(
            "CalibreSimulator is a configuration stub. Real Calibre "
            "integration requires the vendor toolchain and license; "
            "subclass this adapter and override simulate(). See "
            "docs/rfcs/0003-commercial-simulator-hooks.md for the "
            "integration plan."
        )

openlithohub.simulators.tachyon

Tachyon simulator adapter — stub.

Real Tachyon integration requires the ASML Brion Tachyon toolchain on PATH plus a license. We do not bundle either. See :mod:openlithohub.simulators.calibre for the same policy and rationale.

TachyonSimulator

Bases: BaseSimulator

Stub adapter for ASML Brion Tachyon.

config.extra should carry:

  • tachyon_home (str) — install root.
  • recipe (str) — path to the .tcl recipe to execute.
  • layer_map (dict[str, int]) — OpenLithoHub layer → Tachyon layer.
Source code in src/openlithohub/simulators/tachyon.py
class TachyonSimulator(BaseSimulator):
    """Stub adapter for ASML Brion Tachyon.

    ``config.extra`` should carry:

    * ``tachyon_home`` (str) — install root.
    * ``recipe`` (str) — path to the ``.tcl`` recipe to execute.
    * ``layer_map`` (dict[str, int]) — OpenLithoHub layer → Tachyon layer.
    """

    name = "tachyon"
    differentiable = False

    def __init__(self, config: SimulatorConfig | None = None) -> None:
        super().__init__(config)
        self._validate_config()

    def _validate_config(self) -> None:
        extra = self.config.extra or {}
        for required in ("tachyon_home", "recipe"):
            if required not in extra:
                raise ValueError(
                    f"TachyonSimulator requires config.extra[{required!r}]; "
                    f"got keys={sorted(extra.keys())}"
                )

    def simulate(self, mask: torch.Tensor) -> SimulatorResult:
        del mask
        raise NotImplementedError(
            "TachyonSimulator is a configuration stub. Real Tachyon "
            "integration requires the ASML/Brion toolchain and license; "
            "subclass this adapter and override simulate(). See "
            "docs/rfcs/0003-commercial-simulator-hooks.md for the "
            "integration plan."
        )

Measured-source / Zernike-pupil I/O

openlithohub._utils.optics

Standardized I/O for measured sources and pupil aberrations.

Hopkins / SOCS already accepts parametric sources (circular, annular, dipole, quasar) and a clean defocus-only pupil. Real production OPC needs more:

  • Measured / freeform source intensity — a TIFF or PNG dumped from the scanner illuminator, or a custom shape designed by SMO. This is loaded as a 2-D [N, N] array on normalized pupil coordinates (σx, σy) ∈ [-1, 1].
  • Pupil aberrations as Zernike coefficients — typically tens of Zernikes (Z2..Z37 or higher) measured on the scanner. We parse a small text/JSON/CSV file mapping Noll-indexed terms → coefficients in waves of OPD, and synthesize a phase map on the same normalized pupil grid.

Both outputs are plain torch.Tensor so they can be plugged into a custom Hopkins / SOCS run without further conversion. These three functions are re-exported from :mod:openlithohub.simulators for discoverability — the bundled :class:HopkinsSimulator does not yet consume them through SimulatorConfig.extra; users who need measured-source or Zernike-pupil pathing currently do so by calling :func:openlithohub._utils.hopkins.simulate_aerial_image_hopkins directly with the loaded tensor injected into a custom params object. Wiring through the public HopkinsSimulator is roadmap (item #65 in the bug-triage list once the API knobs are settled).

Coordinate convention: normalized pupil coordinates run from -1 to +1 across the diameter, with the unit circle being the NA-defined edge. The center pixel is at (σx, σy) = (0, 0). For a square N x N grid this places the center at index (N-1) / 2.

load_source_intensity(path, *, grid_size=None, normalize=True, device='cpu')

Load a measured source intensity image as a square [N, N] tensor.

Supports any format Pillow can read: TIFF (16-bit OK), PNG, BMP, etc. The image is converted to float32 in the range [0, ∞), optionally resized to grid_size (bilinear), and optionally normalized so the sum is 1 (which is what Hopkins J(f) expects).

Parameters:

Name Type Description Default
path str | Path

Image file.

required
grid_size int | None

If given, resize to [grid_size, grid_size] with bilinear interpolation. If None, keep the native shape but require it to be square.

None
normalize bool

If True (default), rescale so the source sums to 1.

True
device str | device

Torch device for the output tensor.

'cpu'

Returns:

Type Description
Tensor

Float32 tensor on device of shape [N, N] in normalized

Tensor

pupil-coordinate layout (origin at the geometric center).

Source code in src/openlithohub/_utils/optics.py
def load_source_intensity(
    path: str | Path,
    *,
    grid_size: int | None = None,
    normalize: bool = True,
    device: str | torch.device = "cpu",
) -> torch.Tensor:
    """Load a measured source intensity image as a square ``[N, N]`` tensor.

    Supports any format Pillow can read: TIFF (16-bit OK), PNG, BMP, etc.
    The image is converted to float32 in the range ``[0, ∞)``, optionally
    resized to ``grid_size`` (bilinear), and optionally normalized so the
    sum is 1 (which is what Hopkins ``J(f)`` expects).

    Args:
        path: Image file.
        grid_size: If given, resize to ``[grid_size, grid_size]`` with
            bilinear interpolation. If ``None``, keep the native shape
            but require it to be square.
        normalize: If True (default), rescale so the source sums to 1.
        device: Torch device for the output tensor.

    Returns:
        Float32 tensor on ``device`` of shape ``[N, N]`` in normalized
        pupil-coordinate layout (origin at the geometric center).
    """
    try:
        from PIL import Image
    except ImportError as e:
        raise ImportError(
            "Pillow is required to load source intensity images. Install with: pip install Pillow"
        ) from e

    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"Source intensity image not found: {p}")

    with Image.open(p) as img:
        # 'F' = 32-bit float, preserves dynamic range from 16-bit TIFFs etc.
        arr = torch.from_numpy(_pil_to_float32(img).copy())

    if arr.ndim != 2:
        raise ValueError(f"Source intensity must be 2-D (grayscale); got shape {tuple(arr.shape)}")

    if grid_size is None:
        if arr.shape[0] != arr.shape[1]:
            raise ValueError(
                f"Source intensity must be square when grid_size is unset; got {tuple(arr.shape)}"
            )
    else:
        arr = (
            torch.nn.functional.interpolate(
                arr.unsqueeze(0).unsqueeze(0),
                size=(grid_size, grid_size),
                mode="bilinear",
                align_corners=False,
            )
            .squeeze(0)
            .squeeze(0)
        )

    arr = arr.clamp(min=0.0)
    if normalize:
        total = arr.sum()
        if total <= 0:
            raise ValueError("Source intensity has total zero/negative — cannot normalize.")
        arr = arr / total

    return arr.to(device=device, dtype=torch.float32).contiguous()

load_zernike_coefficients(path)

Parse a Zernike coefficient file → {noll_index: coeff_in_waves}.

Three formats are supported, dispatched by extension:

  • .json — flat object {"4": 0.05, "11": -0.02, ...} or a nested form {"zernikes": {"4": 0.05, ...}}.
  • .csv — header row required, columns noll and coeff (case-insensitive). Other columns are ignored.
  • .txt — whitespace-separated noll coeff pairs, # comments.

Coefficients are interpreted as waves of OPD (the scanner / Synopsys convention). Z1 (piston) is silently dropped — it has no optical effect and many vendor tools include it as a sanity column.

Source code in src/openlithohub/_utils/optics.py
def load_zernike_coefficients(path: str | Path) -> dict[int, float]:
    """Parse a Zernike coefficient file → ``{noll_index: coeff_in_waves}``.

    Three formats are supported, dispatched by extension:

    * ``.json`` — flat object ``{"4": 0.05, "11": -0.02, ...}`` or a
      nested form ``{"zernikes": {"4": 0.05, ...}}``.
    * ``.csv`` — header row required, columns ``noll`` and ``coeff``
      (case-insensitive). Other columns are ignored.
    * ``.txt`` — whitespace-separated ``noll coeff`` pairs, ``#`` comments.

    Coefficients are interpreted as **waves of OPD** (the scanner /
    Synopsys convention). Z1 (piston) is silently dropped — it has no
    optical effect and many vendor tools include it as a sanity column.
    """
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"Zernike file not found: {p}")

    suffix = p.suffix.lower()
    if suffix == ".json":
        raw = _zernike_from_json(p)
    elif suffix == ".csv":
        raw = _zernike_from_csv(p)
    elif suffix in (".txt", ".dat", ".zer"):
        raw = _zernike_from_txt(p)
    else:
        raise ValueError(f"Unsupported Zernike format: {suffix!r}. Use .json, .csv, or .txt.")

    out: dict[int, float] = {}
    for noll, coeff in raw.items():
        if noll == 1:
            continue
        if noll < 1:
            raise ValueError(f"Noll indices are 1-based; got {noll}.")
        if noll not in _NOLL_TO_NM:
            raise ValueError(
                f"Noll index {noll} is beyond the supported range "
                f"(1..{max(_NOLL_TO_NM)}); add it to _NOLL_TO_NM if needed."
            )
        out[noll] = coeff
    return out

zernike_phase_map(coeffs, grid_size, *, device='cpu')

Synthesize a pupil OPD map from Noll-indexed Zernike coefficients.

Returns a real [N, N] tensor of optical path difference in waves (multiply by to get phase in radians, or by the wavelength to get OPD in nm). Outside the unit pupil the map is set to 0.

Parameters:

Name Type Description Default
coeffs dict[int, float]

{noll_index: coeff_in_waves} (e.g. from :func:load_zernike_coefficients).

required
grid_size int

Output grid edge N.

required
device str | device

Torch device.

'cpu'
Source code in src/openlithohub/_utils/optics.py
def zernike_phase_map(
    coeffs: dict[int, float],
    grid_size: int,
    *,
    device: str | torch.device = "cpu",
) -> torch.Tensor:
    """Synthesize a pupil OPD map from Noll-indexed Zernike coefficients.

    Returns a real ``[N, N]`` tensor of optical path difference in waves
    (multiply by ``2π`` to get phase in radians, or by the wavelength to
    get OPD in nm). Outside the unit pupil the map is set to 0.

    Args:
        coeffs: ``{noll_index: coeff_in_waves}`` (e.g. from
            :func:`load_zernike_coefficients`).
        grid_size: Output grid edge ``N``.
        device: Torch device.
    """
    if grid_size < 2:
        raise ValueError(f"grid_size must be ≥ 2; got {grid_size}.")

    # Normalized pupil coords on [-1, 1].
    axis = torch.linspace(-1.0, 1.0, grid_size, device=device, dtype=torch.float32)
    yy, xx = torch.meshgrid(axis, axis, indexing="ij")
    rho = torch.sqrt(xx * xx + yy * yy)
    theta = torch.atan2(yy, xx)
    inside = rho <= 1.0

    opd = torch.zeros((grid_size, grid_size), device=device, dtype=torch.float32)
    for noll, c in coeffs.items():
        if c == 0.0:
            continue
        n, m = _NOLL_TO_NM[noll]
        z = _zernike_nm(n, m, rho, theta)
        opd = opd + float(c) * z
    return opd * inside.to(opd.dtype)