Source code for optiland.analysis.angle_vs_height

"""Incident Angle vs. Height Plot Analysis

This module provides classes for analyzing the incident angle versus image height
for optical systems, across both pupil and field.

Original concept by BuergiR, 2025
Implemented in Optiland by Kramer Harrison, 2025
"""

from __future__ import annotations

import abc
from typing import TYPE_CHECKING

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import LineCollection

import optiland.backend as be
from optiland.utils import resolve_wavelength

from .base import BaseAnalysis

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.colors import Colormap
    from matplotlib.figure import Figure


def _plot_angle_vs_height(
    plot_data_list: list,
    axis: int,
    optic_name: str,
    plot_style: str,
    ax: Axes,
    title: str,
    color_label: str,
    cmap: str | Colormap,
) -> None:
    """Helper function to generate a consistent angle vs. image
    height plot on a given axis.

    Args:
        plot_data_list (list): A list of tuples, where each tuple contains:
            (height, angle_deg, scan_range, legend_label).
        axis (int): Specifies the axis for measurement (0 for x, 1 for y).
        optic_name (str): The name of the optic, used for the plot title.
        plot_style (str): Matplotlib plot style for the line.
        ax (matplotlib.axes.Axes): The axes object to plot on.
        title (str or None): An optional subtitle for the plot.
        color_label (str): The label for the colorbar.
        cmap (str): The name of the colormap to use.
    """
    norm = plt.Normalize(-1, 1)
    linewidth = 3

    base_title = ""
    if title and optic_name:
        base_title = f"{title} - {optic_name}"
    elif title:
        base_title = str(title)
    elif optic_name:
        base_title = str(optic_name)

    # Compose the full title
    full_title = base_title
    if full_title:
        full_title += "\n"
    full_title += ", ".join([p[3] for p in plot_data_list])

    for height, angle_deg, scan_range, _ in plot_data_list:
        # Create segments for the LineCollection
        points = np.array([height, angle_deg]).T.reshape(-1, 1, 2)
        segments = np.concatenate([points[:-1], points[1:]], axis=1)

        # Create a LineCollection, color it with the scan_range, and add to plot
        lc = LineCollection(segments, cmap=cmap, norm=norm, linestyle=plot_style)
        lc.set_array(scan_range)
        lc.set_linewidth(linewidth)
        line = ax.add_collection(lc)

    fig = ax.get_figure()
    fig.suptitle("Incident Angle vs Image Height" + (" (x-axis)" if axis == 0 else ""))
    ax.set_title(full_title, fontsize=10)
    ax.set_xlabel("Image Height in Millimeters")
    ax.set_ylabel("Incident Angle in Degrees")
    cbar = fig.colorbar(line, ax=ax, label=color_label)
    cbar.set_label(color_label, labelpad=15)
    ax.grid(alpha=0.25)
    ax.autoscale_view()


[docs] class BaseAngleVsHeightAnalysis(BaseAnalysis, abc.ABC): """Abstract base class for Angle vs. Height analysis routines. This class provides the common framework for generating angle vs. height data using the optic's trace_generic method, and abstract methods for defining how the tracing coordinates vary. Args: optic (Optic): The optic object to analyze. surface_idx (int, optional): Index of the surface at which the angle and height are measured. Defaults to -1 (last surface). axis (int, optional): Specifies the axis for measurement. 0 for x-axis, 1 for y-axis. Defaults to 1 (y-axis). wavelength (str or float, optional): A single wavelength in microns. Defaults to 'primary'. num_points (int, optional): The number of points used for the plot. Defaults to 128. Attributes: optic (Optic): The optic object being analyzed. surface_idx (int): Index of the surface for measurements. axis (int): Axis for measurement (0 for x, 1 for y). wavelengths (list): The wavelengths being analyzed (handled by BaseAnalysis). num_points (int): The number of points generated for the analysis. data (dict): The generated data for the analysis. Structure depends on subclass. """ def __init__( self, optic, surface_idx: int = -1, axis: int = 1, wavelength: str | float = "primary", num_points: int = 128, ): self.surface_idx = surface_idx self.axis = axis self.num_points = num_points # The resolved wavelength is passed as a list to the parent constructor super().__init__(optic, wavelengths=[resolve_wavelength(optic, wavelength)]) @abc.abstractmethod def _get_trace_coordinates(self, scan_range): """Abstract method to define the Hx, Hy, Px, Py coordinates for tracing. This method must be implemented by subclasses to specify how the rays are generated for the trace_generic method. Args: scan_range (numpy.ndarray): The linearly spaced array defining the range of varying coordinates. Returns: tuple: A tuple containing (Hx, Hy, Px, Py, coord_label). Hx, Hy, Px, Py should be backend arrays ready for trace_generic. coord_label is either "Pupil" or "Field" and represents which coordinates are fixed during the scan. """ pass # pragma: no cover def _generate_data(self): """Generates the incident angle vs. image height data using trace_generic. This method is common for all subclasses and orchestrates the ray tracing based on the coordinates provided by _get_trace_coordinates. Returns: dict: A dictionary containing the generated data. Keys are (fixed_param_1, fixed_param_2, wavelength_value) tuples, values are dictionaries with 'height' and 'angle' numpy arrays. """ data = {} scan_range = be.linspace(start=-1, stop=1, num=self.num_points) Hx, Hy, Px, Py, coord_label = self._get_trace_coordinates(scan_range) Hx = be.atleast_1d(Hx) Hy = be.atleast_1d(Hy) Px = be.atleast_1d(Px) Py = be.atleast_1d(Py) # Use the first and only wavelength wavelength_value = self.wavelengths[0].value self.optic.trace_generic( Hx=Hx, Hy=Hy, Px=Px, Py=Py, wavelength=wavelength_value ) if self.axis == 1: # Y-direction measurement incident_dir_cosines = self.optic.surfaces.M[self.surface_idx, :] height = self.optic.surfaces.y[self.surface_idx, :] else: # X-direction measurement incident_dir_cosines = self.optic.surfaces.L[self.surface_idx, :] height = self.optic.surfaces.x[self.surface_idx, :] angle_rad = be.arcsin(incident_dir_cosines) if coord_label == "Pupil": # means pupil is fixed and field is scanned fixed_param_key = ( Px[0].item() if be.size(Px) > 0 else 0, Py[0].item() if be.size(Py) > 0 else 0, float(wavelength_value), ) elif coord_label == "Field": # means field is fixed and pupil is scanned fixed_param_key = ( Hx[0].item() if be.size(Hx) > 0 else 0, Hy[0].item() if be.size(Hy) > 0 else 0, float(wavelength_value), ) else: raise ValueError("Coord. label must be 'Pupil' or 'Field'.") data[fixed_param_key] = { "height": be.to_numpy(height), "angle": be.to_numpy(angle_rad), "fixed_coordinates": coord_label, "scan_range": be.to_numpy(scan_range), } return data
[docs] def view( self, fig_to_plot_on: Figure = None, figsize: tuple[float, float] = (8, 5.5), title: str = None, cmap: str | Colormap = "viridis", line_style: str = "-", *, show: bool = True, ) -> tuple[plt.Figure, Axes]: """Displays a plot of the incident angle vs. image height analysis. Args: fig_to_plot_on (matplotlib.figure.Figure, optional): A figure object to plot on. If None, a new figure is created. Defaults to None. figsize (tuple, optional): The size of the figure. Defaults to (8, 5.5). title (str, optional): An optional subtitle to be added to the plot. If None, lens name is used. cmap (str, optional): The colormap for the plot line. Defaults to 'viridis'. line_style (str, optional): Matplotlib plot style. Defaults to '-'. show (bool): If True (default), calls plt.show(). Set False for headless use. Returns: tuple: A tuple containing the figure and axes objects used for plotting. """ is_gui_embedding = fig_to_plot_on is not None if is_gui_embedding: current_fig = fig_to_plot_on current_fig.clear() ax = current_fig.add_subplot(111) else: current_fig, ax = plt.subplots(figsize=figsize) if not self.data: ax.text( 0.5, 0.5, "Error: Data could not be generated.", ha="center", va="center", color="red", ) if is_gui_embedding: current_fig.canvas.draw_idle() return ax plot_data_list = [] # Determine the colorbar label based on what is being scanned. first_item_data = next(iter(self.data.values())) fixed_coords_type = first_item_data["fixed_coordinates"] if fixed_coords_type == "Pupil": # Pupil is fixed, Field is scanned color_label = ( f"Normalized Field Coordinate ({'Hx' if self.axis == 0 else 'Hy'})" ) else: # Field is fixed, Pupil is scanned color_label = ( f"Normalized Pupil Coordinate ({'Px' if self.axis == 0 else 'Py'})" ) # Iterate through the generated data items to prepare for plotting for (fixed_p1, fixed_p2, wavelength), plot_data in self.data.items(): fixed_p1 = be.to_numpy(fixed_p1) fixed_p2 = be.to_numpy(fixed_p2) wavelength = be.to_numpy(wavelength) fixed_coords = plot_data["fixed_coordinates"] if fixed_coords == "Pupil": legend_label = ( f"Px={np.round(fixed_p1, 4).item()} " f"Py={np.round(fixed_p2, 4).item()}, " f"{np.round(wavelength, 4).item()} µm" ) else: # fixed_coords == 'Field' legend_label = ( f"Hx={np.round(fixed_p1, 4).item()} " f"Hy={np.round(fixed_p2, 4).item()}, " f"{np.round(wavelength, 4).item()} µm" ) plot_data_list.append( ( plot_data["height"], np.rad2deg(plot_data["angle"]), plot_data["scan_range"], legend_label, ) ) _plot_angle_vs_height( plot_data_list=plot_data_list, axis=self.axis, optic_name=self.optic.name, plot_style=line_style, ax=ax, title=title, color_label=color_label, cmap=cmap, ) current_fig.tight_layout() if is_gui_embedding and hasattr(current_fig, "canvas"): current_fig.canvas.draw_idle() if show and not is_gui_embedding: plt.show() return current_fig, ax
[docs] class PupilIncidentAngleVsHeight(BaseAngleVsHeightAnalysis): """Represents an analysis of incident angle vs. image height by varying through all pupil coordinates (Px, Py) for a given image field point. This analysis is useful for testing the telecentricity of a lens after a point light source (object). Args: optic (Optic): The optic object to analyze. surface_idx (int, optional): Index of the surface at which the angle and height are measured. Defaults to -1 (last surface). axis (int, optional): Specifies the axis for measurement. 0 for x-axis, 1 for y-axis. Defaults to 1 (y-axis). wavelength (str or float, optional): A single wavelength in microns. Defaults to 'primary'. field (tuple, optional): A single relative image field point (Hx, Hy). Defaults to (0, 0). num_points (int, optional): The number of points used for the plot. Defaults to 128. Attributes: optic (Optic): The optic object being analyzed. surface_idx (int): Index of the surface for measurements. axis (int): Axis for measurement (0 for x, 1 for y). field (tuple): The relative image field point (fixed for tracing). num_points (int): The number of points generated for the analysis. data (dict): The generated data for the analysis, structured as: {(Hx_fixed, Hy_fixed, wavelength_value): {'height': np.ndarray, 'angle': np.ndarray}} """ def __init__( self, optic, surface_idx: int = -1, axis: int = 1, wavelength: str | float = "primary", field: tuple = (0, 0), num_points: int = 128, ): self.field = field super().__init__(optic, surface_idx, axis, wavelength, num_points) def _get_trace_coordinates(self, scan_range): """Defines how pupil coordinates (Px, Py) vary while field (Hx, Hy) is fixed. Args: scan_range (numpy.ndarray): The linearly spaced array for varying pupil coordinates. Returns: tuple: (Hx, Hy, Px, Py, label_prefix_str) for trace_generic. """ hx_fixed, hy_fixed = self.field # Hx, Hy are constant for this analysis Hx = ( be.full_like(scan_range, hx_fixed) if be.size(scan_range) > 0 else be.array([hx_fixed]) ) Hy = ( be.full_like(scan_range, hy_fixed) if be.size(scan_range) > 0 else be.array([hy_fixed]) ) # Vary pupil coordinate along the specified axis coords = (scan_range, be.zeros_like(scan_range)) Px, Py = coords if self.axis == 0 else coords[::-1] return ( Hx, Hy, Px, Py, "Field", # field coordinates are fixed )
[docs] class FieldIncidentAngleVsHeight(BaseAngleVsHeightAnalysis): """Represents an analysis of incident angle vs. image height by varying through all image field coordinates (Hx, Hy) for a given pupil field point. This analysis is useful for testing the telecentricity of a scan lens with a scan mirror at the entrance pupil. Note: Uses trace_generic(), which is slower than trace(). Args: optic (Optic): The optic object to analyze. surface_idx (int, optional): Index of the surface at which the angle and height are measured. Defaults to -1 (last surface). axis (int, optional): Specifies the axis for measurement. 0 for x-axis, 1 for y-axis. Defaults to 1 (y-axis). wavelength (str or float, optional): A single wavelength in microns. Defaults to 'primary'. pupil (tuple, optional): A single pupil field point (Px, Py). Defaults to (0, 0). num_points (int, optional): The number of points displayed on the plot. Defaults to 128. Attributes: optic (Optic): The optic object being analyzed. surface_idx (int): Index of the surface for measurements. axis (int): Axis for measurement (0 for x, 1 for y). pupil (tuple): The pupil field point (fixed for tracing). num_points (int): The number of points generated for the analysis. data (dict): The generated data for the analysis, structured as: { (Px_fixed, Py_fixed, wavelength_value): {'height': np.ndarray, 'angle': np.ndarray} } """ def __init__( self, optic, surface_idx: int = -1, axis: int = 1, wavelength: str | float = "primary", pupil: tuple = (0, 0), num_points: int = 128, ): self.pupil = pupil super().__init__(optic, surface_idx, axis, wavelength, num_points) def _get_trace_coordinates(self, scan_range): """Defines how field coordinates (Hx, Hy) vary while pupil (Px, Py) is fixed. Args: scan_range (numpy.ndarray): The linearly spaced array for varying field coordinates. Returns: tuple: (Hx, Hy, Px, Py, label_prefix_str) for trace_generic. """ px_fixed, py_fixed = self.pupil # Px, Py are constant for this analysis Px = ( be.full_like(scan_range, px_fixed) if be.size(scan_range) > 0 else be.array([px_fixed]) ) Py = ( be.full_like(scan_range, py_fixed) if be.size(scan_range) > 0 else be.array([py_fixed]) ) # Vary field coordinate along the specified axis coords = (scan_range, be.zeros_like(scan_range)) Hx, Hy = coords if self.axis == 0 else coords[::-1] return ( Hx, Hy, Px, Py, "Pupil", # pupil coordinates are fixed )