Extension Recipes#
Step-by-step recipes for the 8 most common Optiland contribution scenarios. Each recipe lists the file to create, what to subclass, which methods to implement, and where to register.
For the full architecture context behind each recipe, follow the cross-links to the relevant Developer’s Guide section.
See also
Architecture Overview · Surface Overview · Geometry Overview · Interaction Models · Analysis Framework · Optimization Framework · Tolerancing Framework · Configurable Backend · Visualization Framework
Recipe 1: Add a New Surface Geometry#
Scenario: Add a new parametric surface shape (e.g., a Chebyshev variant, a metasurface sag).
Step 1: Create optiland/geometries/my_geometry.py.
Step 2: Subclass BaseGeometry (closed-form) or NewtonRaphsonGeometry (iterative):
from __future__ import annotations
import optiland.backend as be
from optiland.geometries.base import BaseGeometry
class MyGeometry(BaseGeometry):
def distance(self, rays):
# return propagation distance along ray to surface intersection
...
def sag(self, x, y):
# return sag (z deviation from vertex plane) at (x, y)
...
def surface_normal(self, rays):
# return (nx, ny, nz) unit normal at intersection
...
Step 3: Register in optiland/geometries/__init__.py:
from .my_geometry import MyGeometry
__all__ = [..., "MyGeometry"]
Step 4: Add the geometry type string to optiland/surfaces/factories/geometry_factory.py
so it can be created from a string identifier (needed for JSON serialisation).
Step 5: Add tests in tests/test_geometries/test_my_geometry.py using both backends.
See Tutorial 8a: Custom Surface Types for a worked example.
Recipe 2: Add a New Surface Interaction Model#
Scenario: Add a custom ray-surface interaction (e.g., a birefringent crystal, a holographic element with a non-standard diffraction law).
Step 1: Create optiland/interactions/my_interaction.py.
Step 2: Subclass BaseInteractionModel:
from __future__ import annotations
from optiland.interactions.base import BaseInteractionModel
class MyInteractionModel(BaseInteractionModel):
def interact_real_rays(self, rays):
# modify rays.L, rays.M, rays.N, rays.intensity in-place;
# self.parent_surface gives access to the owning Surface
...
def interact_paraxial_rays(self, rays):
# modify paraxial ray height and angle
...
def flip(self):
# flip the interaction model when the surface orientation reverses
...
Step 3: Register in optiland/interactions/__init__.py.
Step 4: Add tests in tests/test_interactions/test_my_interaction.py.
See Interaction Models for the full interaction-model architecture.
Recipe 3: Add a New Geometry (Shortcut Path)#
If you only need to define a sag function and let Newton-Raphson handle the intersection:
Step 1: Subclass NewtonRaphsonGeometry from optiland/geometries/newton_raphson.py:
from optiland.geometries.newton_raphson import NewtonRaphsonGeometry
import optiland.backend as be
class MyNRGeometry(NewtonRaphsonGeometry):
def sag(self, x, y):
# your sag formula; return be.array of same shape as x
...
def _surface_normal(self, x, y):
# return (nx, ny, nz) at (x, y) — used internally
...
Step 2 onward: Same as Recipe 1, steps 3-5.
Recipe 4: Add a New Analysis Class#
Scenario: Add a new optical performance metric (e.g., ghost image intensity, scatter PSF).
Step 1: Create optiland/analysis/my_analysis.py.
Step 2: Subclass BaseAnalysis:
from __future__ import annotations
from optiland.analysis.base import BaseAnalysis
class MyAnalysis(BaseAnalysis):
def _generate_data(self):
# trace rays, compute metric, store results on self
...
def view(self):
# plot or print results
...
Step 3: Register in optiland/analysis/__init__.py.
Step 4: Add tests in tests/test_analysis/test_my_analysis.py.
See Analysis Framework and Tutorial 2c: Aberration Analyses for context.
Recipe 5: Add a New Optimization Operand#
Scenario: Add a custom merit-function term (e.g., chief-ray angle at a specific surface, image distortion at a given field).
Step 1: Add a new function to optiland/optimization/operand/operand.py (or one of the
sibling modules such as ray.py, aberration.py, paraxial.py) and register it, either
by adding it to the METRIC_DICT dict near the top of operand.py, or at runtime via the
operand_registry singleton:
def my_operand(optic, surface_number, **kwargs):
# compute and return a scalar value
...
# Register (at runtime, e.g. from a plugin):
from optiland.optimization.operand.operand import operand_registry
operand_registry.register("my_operand", my_operand)
Step 2: Add tests in tests/test_optimization/test_operand.py.
See Optimization Framework and Tutorial 3c: User-Defined Optimization Operands for context.
Recipe 6: Add a Custom Tolerance Sensitivity Class#
Scenario: Model a non-standard manufacturing error (e.g., index inhomogeneity, surface irregularity described by Zernike coefficients).
Step 1: Optiland’s Perturbation class (optiland/tolerancing/perturbation.py) is not
subclassed per error type — it wraps an existing optimization Variable (selected by a
variable_type string, e.g. "radius") and draws new values from a sampler. To model a
new kind of randomization, subclass BaseSampler and implement sample():
from optiland.tolerancing.perturbation import BaseSampler
class MySampler(BaseSampler):
def __init__(self, magnitude):
self._magnitude = magnitude
def sample(self):
# return the next perturbation value
...
If instead you need to perturb a parameter that has no existing optimization variable, add a new
variable type via VariableBehavior/Variable (see Optimization Framework) — the
tolerancing framework reuses that system directly.
Step 2: Register the perturbation on a Tolerancing instance:
Tolerancing.add_perturbation(variable_type, sampler, **kwargs).
Step 3: Add tests in tests/test_tolerancing/.
See Tolerancing Framework for the complete workflow.
Recipe 7: Add a Backend-Agnostic Utility Function#
Scenario: Add a numerical helper that must work with both NumPy and PyTorch tensors.
Step 1: Create your utility using only optiland.backend operations:
from __future__ import annotations
import optiland.backend as be
def my_metric(x, y):
"""Compute something using the active backend."""
dot_xy = be.sum(x * y)
norm_x = be.sqrt(be.sum(x * x))
norm_y = be.sqrt(be.sum(y * y))
return dot_xy / (norm_x * norm_y)
Step 2: If the function requires a backend-specific API (e.g., torch.autograd.grad),
add abstract and concrete implementations to AbstractBackend, NumpyBackend, and
TorchBackend in optiland/backend/.
Step 3: Test on both backends using the set_test_backend fixture from tests/conftest.py
and assert_allclose from tests/utils.py.
See Configurable Backend for the full backend architecture.
Recipe 8: Add a New 2D or 3D Renderer#
Scenario: Add a new visualization component (e.g., plot a custom ray bundle, render a focal-plane heat map, add a new 3D actor).
For 2D (Matplotlib):
Step 1: Create a component class in optiland/visualization/:
class MyComponent2D:
def __init__(self, optic, ax):
self._optic = optic
self._ax = ax
def draw(self):
# add matplotlib artists to self._ax
...
Step 2: Integrate with OpticViewer in optiland/visualization/system/optic_viewer.py if
the component should appear in the standard lens.draw() output.
For 3D (VTK):
Step 1: Create a component class that generates VTK actors:
import vtk
class MyActor3D:
def get_actor(self):
# return a vtkActor (or vtkAssembly)
...
Step 2: Integrate with OpticViewer3D in optiland/visualization/system/optic_viewer_3d.py.
Step 3: Add tests in tests/test_visualization/.
See Visualization Framework for the full viewer architecture.