Optiland Torch Module - Custom Objective#

This notebook demonstrates a more advanced feature: defining a custom objective function. Instead of just minimizing RMS spot size, we’ll create a composite objective that simultaneously minimizes the spot size and penalizes the total length of the optical system. This showcases the flexibility of combining Optiland with PyTorch for complex design goals.

[1]:
import torch
import matplotlib.pyplot as plt
import optiland.backend as be
from optiland import optic, optimization
from optiland.ml import OpticalSystemModule
from optiland.wavefront import ZernikeOPD

be.set_backend("torch")  # Set the backend to PyTorch
be.grad_mode.enable()  # Enable gradient tracking
[2]:
lens = optic.Optic()
lens.surfaces.add(index=0, thickness=be.inf)
lens.surfaces.add(index=1, thickness=7, radius=200, material="N-SF11", is_stop=True)
lens.surfaces.add(index=2, thickness=50, radius=-200)
lens.surfaces.add(index=3)
lens.set_aperture(aperture_type="EPD", value=20)
lens.fields.set_type(field_type="angle")
lens.fields.add(y=0.0)
lens.wavelengths.add(value=0.55, is_primary=True)
_ = lens.draw()
../../_images/gallery_optimization_torch_module_custom_objective_2_0.png

Build optimization problem#

First, we set up the standard optimization problem. We’ll define the RMS spot size as our primary operand and the lens radii as variables. The custom objective will build upon this foundation.

[3]:
problem = optimization.OptimizationProblem()
input_data = {
    "optic": lens,
    "surface_number": -1,
    "Hx": 0, "Hy": 0,
    "num_rays": 7,
    "wavelength": 0.55,
    "distribution": "hexapolar",
}
problem.add_operand("rms_spot_size", target=0, weight=1, input_data=input_data)
problem.add_variable(lens, "radius", surface_number=1)
problem.add_variable(lens, "radius", surface_number=2)

Define a Custom Objective Function

This is the core of this example. We create a Python function that returns our desired loss value. This function can perform any calculation using the underlying Optiland objects. Our objective will be:

\[\text{Loss} = (\text{RMS Spot Size})^2 + \lambda \cdot (\text{Total System Length})\]

where \(\lambda\) is a weighting factor to balance the two terms.

[4]:
def custom_objective():
    """
    Custom objective that minimizes both RMS spot size and total system length.
    """
    # Calculate the sum of squared errors from the optimization problem (our spot size term)
    spot_size_term = problem.sum_squared()

    # Add a penalty term proportional to the total length (total_track) of the lens
    # The 1e-3 factor balances the magnitude of the two terms in the loss function.
    length_penalty = 1e-3 * lens.total_track

    return spot_size_term + length_penalty

Define PyTorch module in Optiland#

We now pass our custom function to the OpticalSystemModule using the objective_fn argument. The module will call this function during its forward pass to compute the loss.

[5]:
model = OpticalSystemModule(lens, problem, objective_fn=custom_objective)

Define PyTorch Adam Optimizer and pass model parameters#

[6]:
optimizer = torch.optim.Adam(model.parameters(), lr=0.3)

Optimize with PyTorch#

[7]:
losses = []

for step in range(100):
    optimizer.zero_grad()         # Clear gradients
    loss = model()                # Forward pass computes RMS spot size
    loss.backward()               # Backward pass computes gradients
    optimizer.step()              # Update model parameters
    model.apply_bounds()          # Apply parameter bounds
    losses.append(loss.item())
[8]:
_ = lens.draw(num_rays=10)
../../_images/gallery_optimization_torch_module_custom_objective_13_0.png