Skip to content

Commit 4ea9e32

Browse files
Add Jones Pupil Analysis (#432)
* feat: Add JonesPupil analysis class - Added `JonesPupil` class to `optiland/analysis/jones_pupil.py`. - Exposed `JonesPupil` in `optiland/analysis/__init__.py`. - Added `JonesPupil` to `optiland/analysis/jones_pupil.py`. - Added unit tests in `tests/analysis/test_jones_pupil.py`. - Jones pupil analysis generates a grid of rays and computes the Jones matrix elements (Jxx, Jxy, Jyx, Jyy) by projecting the 3x3 global polarization matrix onto a local basis defined by the ray direction. - Visualization displays the real and imaginary parts of the Jones matrix elements. * Fix formatting and linting issues in JonesPupil analysis. - Removed unused `Literal` import. - Sorted imports in `TYPE_CHECKING` block. - Combined nested `if` statements for clarity. - Removed unused variables: `num_fields`, `num_wavelengths`, `x_in`, `y_in`. - Resolved long line length warning. - Applied `ruff` auto-fixes. * feat: Add Jones Pupil analysis module, example notebook, and documentation. * fix: update tests after Jones pupil update --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Kramer <[email protected]>
1 parent 71d8b66 commit 4ea9e32

File tree

10 files changed

+533
-1
lines changed

10 files changed

+533
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Optiland is continually evolving to provide new functionalities for optical desi
136136
- [ ] **Multi-Path Sequential Ray Tracing**
137137
- [x] **Multiple Configurations (Zoom Lenses)**
138138
- [ ] **Thin Film Design and Optimization**
139-
- [ ] **Jones Pupils**
139+
- [x] **Jones Pupils**
140140
- [ ] **Additional Freeforms (Superconic, etc.)**
141141
- [x] **Image Simulation**
142142
- [ ] **Interferogram Analysis**
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
optiland.analysis.jones\_pupil
2+
==============================
3+
4+
.. automodule:: optiland.analysis.jones_pupil
5+
6+
7+
.. rubric:: Classes
8+
9+
.. autosummary::
10+
11+
JonesPupil

docs/api/api_analysis.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ encircled energy, field curvature, distortion, etc.
1818
analysis.intensity
1919
analysis.image_simulation
2020
analysis.irradiance
21+
analysis.jones_pupil
2122
analysis.pupil_aberration
2223
analysis.ray_fan
2324
analysis.rms_vs_field

docs/functionalities.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Analysis Tools
2424
Perform precise ray-based evaluations for both idealized and physically realistic systems.
2525
- **Polarization Ray Tracing**:
2626
Model vectorial light propagation, including polarization effects and birefringent materials.
27+
- **Jones Pupil Analysis**:
28+
Visualize spatially resolved Jones matrix elements at the exit pupil to assess polarization properties.
2729
- **Comprehensive Optical Analysis**:
2830
Generate spot diagrams, ray aberration fans, OPD maps, distortion plots, and more.
2931
- **Wavefront Analysis**:

docs/gallery/analysis.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This section contains examples of typical analysis tasks that can be performed w
1616
analysis/y_ybar
1717
analysis/rms_spot_size_vs_field
1818
analysis/rms_wavefront_error_vs_field
19+
analysis/jones_pupil
1920
analysis/pupil_aberration
2021
analysis/irradiance
2122
analysis/through_focus_spot_diagram

docs/gallery/analysis/jones_pupil.ipynb

Lines changed: 193 additions & 0 deletions
Large diffs are not rendered by default.

optiland/analysis/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
from .intensity import RadiantIntensity
1515
from .through_focus_mtf import ThroughFocusMTF
1616
from .through_focus_spot_diagram import ThroughFocusSpotDiagram
17+
from .jones_pupil import JonesPupil

optiland/analysis/jones_pupil.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Jones Pupil Analysis
2+
3+
This module provides a Jones pupil analysis for optical systems.
4+
5+
Kramer Harrison, 2025
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import TYPE_CHECKING
11+
12+
import matplotlib.pyplot as plt
13+
import numpy as np
14+
15+
import optiland.backend as be
16+
from optiland.analysis.base import BaseAnalysis
17+
from optiland.rays import PolarizationState
18+
19+
if TYPE_CHECKING:
20+
from matplotlib.axes import Axes
21+
from matplotlib.figure import Figure
22+
23+
from optiland.optic import Optic
24+
25+
26+
class JonesPupil(BaseAnalysis):
27+
"""Generates and plots Jones pupil maps.
28+
29+
This class computes the spatially resolved Jones matrix at the exit pupil
30+
(or image plane) as a function of normalized pupil coordinates. It visualizes
31+
the real and imaginary parts of the Jones matrix elements (Jxx, Jxy, Jyx, Jyy).
32+
33+
Attributes:
34+
optic: Instance of the optic object to be assessed.
35+
Attributes:
36+
optic: Instance of the optic object to be assessed.
37+
field: Field at which data is generated (Hx, Hy).
38+
wavelengths: Wavelengths at which data is generated.
39+
grid_size: The side length of the square grid of rays (NxN).
40+
data: Contains Jones matrix data in a list, ordered by wavelength.
41+
"""
42+
43+
def __init__(
44+
self,
45+
optic: Optic,
46+
field: tuple[float, float] = (0, 0),
47+
wavelengths: str | list = "all",
48+
grid_size: int = 65,
49+
):
50+
"""Initializes the JonesPupil analysis.
51+
52+
Args:
53+
optic: An instance of the optic object to be assessed.
54+
field: The normalized field coordinates (Hx, Hy) at which to
55+
generate data. Defaults to (0, 0).
56+
wavelengths: Wavelengths at which to generate data. If 'all', all
57+
defined wavelengths are used. Defaults to "all".
58+
grid_size: The number of points along one dimension of the pupil grid.
59+
Defaults to 65.
60+
"""
61+
self.field = field
62+
self.grid_size = grid_size
63+
super().__init__(optic, wavelengths)
64+
65+
def view(
66+
self,
67+
fig_to_plot_on: Figure | None = None,
68+
figsize: tuple[float, float] = (16, 8),
69+
) -> tuple[Figure, list[Axes]]:
70+
"""Displays the Jones pupil plots.
71+
72+
Args:
73+
fig_to_plot_on: An existing Matplotlib figure to plot on. If None,
74+
a new figure is created. Defaults to None.
75+
figsize: The figure size for the output window. Defaults to (16, 8).
76+
77+
Returns:
78+
A tuple containing the Matplotlib figure and a list of its axes.
79+
"""
80+
# Select primary wavelength index
81+
wl_idx = 0
82+
if self.optic.primary_wavelength in self.wavelengths:
83+
wl_idx = self.wavelengths.index(self.optic.primary_wavelength)
84+
85+
data_fw = self.data[wl_idx]
86+
87+
if fig_to_plot_on:
88+
fig = fig_to_plot_on
89+
fig.clear()
90+
else:
91+
fig = plt.figure(figsize=figsize)
92+
93+
# 2 rows (Real, Imag), 4 columns (Jxx, Jxy, Jyx, Jyy)
94+
axs = fig.subplots(2, 4, sharex=True, sharey=True)
95+
96+
# Elements to plot
97+
elements = [
98+
("Jxx", data_fw["J"][:, 0, 0]),
99+
("Jxy", data_fw["J"][:, 0, 1]),
100+
("Jyx", data_fw["J"][:, 1, 0]),
101+
("Jyy", data_fw["J"][:, 1, 1]),
102+
]
103+
104+
px = be.to_numpy(data_fw["Px"]).reshape(self.grid_size, self.grid_size)
105+
py = be.to_numpy(data_fw["Py"]).reshape(self.grid_size, self.grid_size)
106+
mask = px**2 + py**2 <= 1.0
107+
108+
for col, (name, values) in enumerate(elements):
109+
val_np = be.to_numpy(values).reshape(self.grid_size, self.grid_size)
110+
val_np[~mask] = np.nan
111+
112+
# Real part
113+
ax_real = axs[0, col]
114+
im_real = ax_real.pcolormesh(
115+
px, py, np.real(val_np), shading="nearest", cmap="viridis"
116+
)
117+
ax_real.set_title(f"Re({name})")
118+
ax_real.set_aspect("equal")
119+
fig.colorbar(im_real, ax=ax_real, fraction=0.046, pad=0.04)
120+
121+
# Imag part
122+
ax_imag = axs[1, col]
123+
im_imag = ax_imag.pcolormesh(
124+
px, py, np.imag(val_np), shading="nearest", cmap="viridis"
125+
)
126+
ax_imag.set_title(f"Im({name})")
127+
ax_imag.set_aspect("equal")
128+
fig.colorbar(im_imag, ax=ax_imag, fraction=0.046, pad=0.04)
129+
130+
# Labels
131+
for ax in axs[:, 0]:
132+
ax.set_ylabel("Py")
133+
for ax in axs[-1, :]:
134+
ax.set_xlabel("Px")
135+
136+
field_val = self.field
137+
wl_val = self.wavelengths[wl_idx]
138+
fig.suptitle(f"Jones Pupil - Field: {field_val}, Wavelength: {wl_val:.4f} µm")
139+
fig.tight_layout()
140+
141+
return fig, fig.get_axes()
142+
143+
def _generate_data(self):
144+
"""Generates Jones matrix data for all fields and wavelengths."""
145+
# Generate pupil grid
146+
x = be.linspace(-1.0, 1.0, self.grid_size)
147+
y = be.linspace(-1.0, 1.0, self.grid_size)
148+
Px_grid, Py_grid = be.meshgrid(x, y)
149+
Px = Px_grid.flatten()
150+
Py = Py_grid.flatten()
151+
152+
data = []
153+
Hx, Hy = self.field
154+
for wl in self.wavelengths:
155+
data.append(self._generate_single_data(Hx, Hy, Px, Py, wl))
156+
157+
return data
158+
159+
def _generate_single_data(self, Hx, Hy, Px, Py, wavelength):
160+
"""Generates data for a single field and wavelength configuration."""
161+
162+
# Handle polarization state
163+
original_pol = self.optic.polarization
164+
if original_pol == "ignore":
165+
# Temporarily enable polarization to get PolarizedRays
166+
self.optic.set_polarization(PolarizationState())
167+
168+
try:
169+
rays = self.optic.trace_generic(
170+
Hx=Hx, Hy=Hy, Px=Px, Py=Py, wavelength=wavelength
171+
)
172+
finally:
173+
if original_pol == "ignore":
174+
self.optic.set_polarization("ignore")
175+
176+
if not hasattr(rays, "p"):
177+
# Fallback if rays are not polarized (should not happen w/ check above)
178+
raise RuntimeError("Ray tracing did not return polarized rays.")
179+
180+
# Ray direction vectors (normalized)
181+
k = be.stack([rays.L, rays.M, rays.N], axis=1)
182+
# Normalize k (should be already, but to be safe)
183+
k_norm = be.linalg.norm(k, axis=1)
184+
k = k / be.unsqueeze_last(k_norm)
185+
186+
# Construct local basis vectors (Standard Polar Projection / Dipole-like)
187+
# v ~ Y-axis: perpendicular to k and X=[1,0,0]
188+
x_axis = be.array([1.0, 0.0, 0.0])
189+
# Broadcast x_axis to match k shape
190+
x_axis = be.broadcast_to(x_axis, k.shape)
191+
192+
v = be.cross(k, x_axis)
193+
v_norm = be.linalg.norm(v, axis=1)
194+
195+
# Avoid division by zero
196+
v = v / be.unsqueeze_last(v_norm + 1e-15)
197+
198+
# u ~ X-axis: perpendicular to v and k
199+
u = be.cross(v, k)
200+
u_norm = be.linalg.norm(u, axis=1)
201+
# Avoid division by zero
202+
u = u / be.unsqueeze_last(u_norm + 1e-15)
203+
204+
# Project global P onto local basis (u, v)
205+
# Jxx = u . (P . x_in)
206+
# Jxy = u . (P . y_in)
207+
# Jyx = v . (P . x_in)
208+
# Jyy = v . (P . y_in)
209+
210+
# p has shape (N, 3, 3)
211+
# P . x_in is simply the first column of p
212+
# P . y_in is simply the second column of p
213+
214+
P_x_in = rays.p[:, :, 0] # Shape (N, 3)
215+
P_y_in = rays.p[:, :, 1] # Shape (N, 3)
216+
217+
# Dot products
218+
Jxx = be.sum(u * P_x_in, axis=1)
219+
Jxy = be.sum(u * P_y_in, axis=1)
220+
Jyx = be.sum(v * P_x_in, axis=1)
221+
Jyy = be.sum(v * P_y_in, axis=1)
222+
223+
# Stack into (N, 2, 2)
224+
row1 = be.stack([Jxx, Jxy], axis=1)
225+
row2 = be.stack([Jyx, Jyy], axis=1)
226+
J = be.stack([row1, row2], axis=1)
227+
228+
return {"Px": Px, "Py": Py, "J": J}

optiland/fields/field_group.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ def max_y_field(self):
6060
@property
6161
def max_field(self):
6262
"""float: Maximum radial field value."""
63+
if not self.fields:
64+
return 0.0
6365
return be.max(be.sqrt(self.x_fields**2 + self.y_fields**2))
6466

6567
@property

tests/analysis/test_jones_pupil.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
2+
import pytest
3+
import numpy as np
4+
import matplotlib.pyplot as plt
5+
import optiland.backend as be
6+
from optiland.samples.objectives import CookeTriplet
7+
from optiland.analysis.jones_pupil import JonesPupil
8+
from optiland.rays import PolarizationState
9+
10+
11+
def test_jones_pupil_initialization(set_test_backend):
12+
optic = CookeTriplet()
13+
optic.set_polarization("ignore") # Default state for test
14+
jp = JonesPupil(optic)
15+
assert jp.optic == optic
16+
assert jp.grid_size == 65
17+
assert jp.field == (0, 0)
18+
assert jp.wavelengths is not None
19+
20+
def test_jones_pupil_generate_data(set_test_backend):
21+
optic = CookeTriplet()
22+
optic.set_polarization("ignore") # Default state for test
23+
jp = JonesPupil(optic, grid_size=5)
24+
data = jp.data
25+
26+
# Check structure: list of wavelengths -> dict
27+
assert isinstance(data, list)
28+
assert len(data) == len(jp.wavelengths)
29+
30+
# Check data for first wavelength
31+
single_data = data[0]
32+
assert isinstance(single_data, dict)
33+
assert "Px" in single_data
34+
assert "Py" in single_data
35+
assert "J" in single_data
36+
37+
# Check shapes
38+
num_rays = 5 * 5
39+
assert single_data["Px"].shape == (num_rays,)
40+
assert single_data["J"].shape == (num_rays, 2, 2)
41+
42+
J = single_data["J"]
43+
Jxx = J[:, 0, 0]
44+
Jxy = J[:, 0, 1]
45+
46+
# Center ray (index 12 for 5x5 grid)
47+
center_idx = num_rays // 2
48+
49+
# Use backend agnostic checks
50+
val_xx = be.to_numpy(Jxx[center_idx])
51+
val_xy = be.to_numpy(Jxy[center_idx])
52+
53+
assert np.abs(val_xx) > 0.5
54+
assert np.abs(val_xy) < 0.1
55+
56+
def test_jones_pupil_polarization_handling(set_test_backend):
57+
optic = CookeTriplet()
58+
optic.set_polarization("ignore") # Default state for test
59+
# Ensure it works even if optic polarization is 'ignore'
60+
optic.set_polarization("ignore")
61+
jp = JonesPupil(optic, grid_size=3)
62+
# Trace happens in __init__ / first access to data
63+
64+
# Ensure optic state is restored
65+
assert optic.polarization == "ignore"
66+
67+
# Data should be valid
68+
assert jp.data is not None
69+
70+
def test_jones_pupil_view(set_test_backend):
71+
optic = CookeTriplet()
72+
optic.set_polarization("ignore") # Default state for test
73+
jp = JonesPupil(optic, grid_size=5)
74+
fig, axs = jp.view()
75+
76+
assert fig is not None
77+
# 2 rows, 4 columns = 8 axes + 8 colorbars = 16 axes
78+
assert len(axs) == 16
79+
80+
# Clean up
81+
plt.close(fig)
82+
83+
def test_jones_pupil_view_custom_field(set_test_backend):
84+
optic = CookeTriplet()
85+
optic.set_polarization("ignore") # Default state for test
86+
# Test with off-axis field
87+
jp = JonesPupil(optic, field=(0, 1.0), grid_size=5)
88+
data = jp.data
89+
assert len(data) == len(jp.wavelengths)
90+
91+
# Just verify valid execution
92+
single_data = data[0]
93+
assert "J" in single_data

0 commit comments

Comments
 (0)