Skip to content

Commit d30b169

Browse files
authored
Added airy disc visualization to spot_diagram.py (#51)
* Added airy disc visualization to spot_diagram.py * Applied requested minor changes to spot_diagram.py. Calculation of F# remains the same. * Updated spot_diagram.py with improvements * Updated spot_diagram.py with improvements * Final Airy Disk Enhancement * Update spot_diagram.py: [Moved spot diagram calculations to view function]
1 parent 69fb5f6 commit d30b169

File tree

1 file changed

+204
-9
lines changed

1 file changed

+204
-9
lines changed

optiland/analysis/spot_diagram.py

Lines changed: 204 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from copy import deepcopy
88
import numpy as np
99
import matplotlib.pyplot as plt
10+
import matplotlib.patches as patches
1011

1112
plt.rcParams.update({'font.size': 12, 'font.family': 'cambria'})
1213

@@ -65,12 +66,13 @@ def __init__(self, optic, fields='all', wavelengths='all', num_rings=6,
6566
self.data = self._generate_data(self.fields, self.wavelengths,
6667
num_rings, distribution)
6768

68-
def view(self, figsize=(12, 4)):
69+
def view(self, figsize=(12, 4), add_airy_disk = False):
6970
"""View the spot diagram
7071
7172
Args:
7273
figsize (tuple): the figure size of the output window.
7374
Default is (12, 4).
75+
add_airy_disk (bool): Airy disc visualization controller.
7476
7577
Returns:
7678
None
@@ -88,10 +90,25 @@ def view(self, figsize=(12, 4)):
8890
geometric_size = self.geometric_spot_radius()
8991
axis_lim = np.max(geometric_size)
9092

93+
wavelength = self.optic.wavelengths.primary_wavelength.value
94+
centroids = self.centroid()
95+
chief_ray_centers = self.generate_chief_rays_centers(wavelength=wavelength)
96+
airy_rad_x, airy_rad_y = self.airy_disc_x_y(wavelength=wavelength)
97+
9198
# plot wavelengths for each field
9299
for k, field_data in enumerate(data):
100+
# Calculate the real centroid difference for the current field
101+
real_centroid_x = chief_ray_centers[k][0] - centroids[k][0]
102+
real_centroid_y = chief_ray_centers[k][1] - centroids[k][1]
93103
self._plot_field(axs[k], field_data, self.fields[k],
94-
axis_lim, self.wavelengths)
104+
axis_lim, self.wavelengths,
105+
add_airy_disk=add_airy_disk,
106+
field_index=k,
107+
airy_rad_x=airy_rad_x,
108+
airy_rad_y=airy_rad_y,
109+
real_centroid_x=real_centroid_x,
110+
real_centroid_y=real_centroid_y
111+
)
95112

96113
# remove empty axes
97114
for k in range(N, num_rows * 3):
@@ -101,6 +118,151 @@ def view(self, figsize=(12, 4)):
101118
plt.tight_layout()
102119
plt.show()
103120

121+
def angle_from_cosine(self, a, b):
122+
"""Calculate the angle (in radians) between two vectors given their cosine values.
123+
124+
Returns:
125+
theta_rad (float): angle in radians.
126+
"""
127+
# Compute the angle using arccos
128+
a = a / np.linalg.norm(a)
129+
b = b / np.linalg.norm(b)
130+
theta = np.arccos(np.clip(np.dot(a, b), -1, 1))
131+
#theta_deg = np.degrees(theta_rad) # Convert to degrees
132+
133+
return theta
134+
135+
def f_number(self, n, theta):
136+
"""Calculates the F#
137+
138+
Returns:
139+
N_w (float): Physical F number.
140+
"""
141+
N_w = 1 / (2 * n * np.sin(theta))
142+
return N_w
143+
144+
def airy_radius(self, n_w, wavelength):
145+
"""Calculates the airy radius
146+
Returns:
147+
r (float): Airy radius.
148+
"""
149+
r = 1.22 * n_w * wavelength
150+
return r
151+
152+
def generate_marginal_rays(self, H_x, H_y, wavelength):
153+
"""Generates marginal rays at the stop at each pupil max, Px = (-1, +1), Py = (-1, +1)
154+
155+
Returns:
156+
ray_tuple (tuple): Contains marginal each rays generated by trace_generic method,
157+
in north (Py=1), south (Py=-1), east (Px=1), west (Px=-1) directions.
158+
"""
159+
ray_north = self.optic.trace_generic(Hx=H_x, Hy=H_y, Px=0, Py=1, wavelength=wavelength)
160+
ray_south = self.optic.trace_generic(Hx=H_x, Hy=H_y, Px=0, Py=-1, wavelength=wavelength)
161+
ray_east = self.optic.trace_generic(Hx=H_x, Hy=H_y, Px=1, Py=0, wavelength=wavelength)
162+
ray_west = self.optic.trace_generic(Hx=H_x, Hy=H_y, Px=-1, Py=0, wavelength=wavelength)
163+
164+
ray_tuple = ray_north, ray_south, ray_east, ray_west
165+
166+
return ray_tuple
167+
168+
def generate_marginal_rays_cosines(self, H_x, H_y, wavelength):
169+
"""Generates directional cosines for each marginal ray. Calculates one field at a time (one height at a time).
170+
171+
Returns:
172+
cosines_tuple (tuple): Contains directional cosines of marginal each rays.
173+
"""
174+
ray_north, ray_south, ray_east, ray_west = self.generate_marginal_rays(H_x, H_y, wavelength)
175+
176+
north_cosines = np.array([ray_north.L, ray_north.M, ray_north.N]).ravel()
177+
south_cosines = np.array([ray_south.L, ray_south.M, ray_south.N]).ravel()
178+
east_cosines = np.array([ray_east.L, ray_east.M, ray_east.N]).ravel()
179+
west_cosines = np.array([ray_west.L, ray_west.M, ray_west.N]).ravel()
180+
181+
cosines_tuple = north_cosines, south_cosines, east_cosines, west_cosines
182+
return cosines_tuple
183+
184+
def generate_chief_rays_cosines(self, wavelength):
185+
"""Generates directional cosines for all chief rays of each field.
186+
187+
Returns:
188+
chief_ray_cosines_list (ndarray): Mx3 numpy array, containing M number of directional cosines for all axes (x, y ,z).
189+
[
190+
field 1 -> (ray_chief1.L, ray_chief1.M, ray_chief1.N),
191+
field 2 -> (ray_chief1.L, ray_chief1.M, ray_chief1.N),
192+
. .
193+
. .
194+
. .
195+
field M -> (ray_chiefM.L, ray_chiefM.M, ray_chiefM.N)
196+
]
197+
"""
198+
199+
200+
coords = self.fields
201+
chief_ray_cosines_list = []
202+
for H_x, H_y in coords:
203+
# Always pass the field values—even for 'angle' type.
204+
ray_chief = self.optic.trace_generic(Hx=H_x, Hy=H_y, Px=0, Py=0, wavelength=wavelength)
205+
chief_ray_cosines_list.append(np.array([ray_chief.L, ray_chief.M, ray_chief.N]).ravel())
206+
chief_ray_cosines_list = np.array(chief_ray_cosines_list)
207+
return chief_ray_cosines_list
208+
209+
def generate_chief_rays_centers(self, wavelength):
210+
"""Generates the position of each chief ray. It is used to find centers of the airy discs in the function _plot_field.
211+
212+
Returns:
213+
chief_ray_centers (ndarray): Contains the x, y coordinates of chief ray centers for each field.
214+
"""
215+
coords = self.fields
216+
chief_ray_centers = []
217+
for H_x, H_y in coords:
218+
# Always pass the field values—even for 'angle' type.
219+
ray_chief = self.optic.trace_generic(Hx=H_x, Hy=H_y, Px=0, Py=0, wavelength=wavelength)
220+
x, y = ray_chief.x, ray_chief.y
221+
chief_ray_centers.append([x, y])
222+
223+
chief_ray_centers = np.array(chief_ray_centers)
224+
return chief_ray_centers
225+
226+
def airy_disc_x_y(self, wavelength):
227+
"""Generates the airy radius for each x-y axes, for each field.
228+
229+
Returns:
230+
airy_rad_tuple (tuple): Contains x axis centers (ndarray), and y axis centers (ndarray).
231+
"""
232+
233+
chief_ray_cosines_list = self.generate_chief_rays_cosines(wavelength)
234+
235+
airy_rad_x_list = []
236+
airy_rad_y_list = []
237+
238+
for idx, (H_x, H_y) in enumerate(self.fields):
239+
# Get marginal rays for the current field
240+
north_cos, south_cos, east_cos, west_cos = self.generate_marginal_rays_cosines(H_x, H_y, wavelength)
241+
242+
chief_cos = chief_ray_cosines_list[idx]
243+
244+
# Compute relative angles along x and y axes
245+
rel_angle_north = self.angle_from_cosine(chief_cos, north_cos)
246+
rel_angle_south = self.angle_from_cosine(chief_cos, south_cos)
247+
rel_angle_east = self.angle_from_cosine(chief_cos, east_cos)
248+
rel_angle_west = self.angle_from_cosine(chief_cos, west_cos)
249+
250+
avg_angle_x = (rel_angle_north + rel_angle_south) / 2
251+
avg_angle_y = (rel_angle_east + rel_angle_west) / 2
252+
253+
N_w_x = self.f_number(n=1, theta=avg_angle_x)
254+
N_w_y = self.f_number(n=1, theta=avg_angle_y)
255+
256+
airy_rad_x = self.airy_radius(N_w_x, wavelength)
257+
airy_rad_y = self.airy_radius(N_w_y, wavelength)
258+
259+
airy_rad_x_list.append(airy_rad_x * 1e-3) # * 1e-3 to convert normalized spots
260+
airy_rad_y_list.append(airy_rad_y * 1e-3) #
261+
262+
airy_rad_tuple = (airy_rad_x_list, airy_rad_y_list)
263+
264+
return airy_rad_tuple
265+
104266
def centroid(self):
105267
"""Centroid of each spot
106268
@@ -219,7 +381,11 @@ def _generate_field_data(self, field, wavelength, num_rays=100,
219381
return [x, y, intensity]
220382

221383
def _plot_field(self, ax, field_data, field, axis_lim,
222-
wavelengths, buffer=1.05):
384+
wavelengths, buffer=1.05,
385+
add_airy_disk=False, field_index=None,
386+
airy_rad_x=None, airy_rad_y=None,
387+
real_centroid_x=None, real_centroid_y=None
388+
):
223389
"""
224390
Plot the field data on the given axis.
225391
@@ -244,10 +410,39 @@ def _plot_field(self, ax, field_data, field, axis_lim,
244410
ax.scatter(x[mask], y[mask], s=10,
245411
label=f'{wavelengths[k]:.4f} µm',
246412
marker=markers[k % 3], alpha=0.7)
247-
ax.axis('square')
248-
ax.set_xlabel('X (mm)')
249-
ax.set_ylabel('Y (mm)')
250-
ax.set_xlim((-axis_lim*buffer, axis_lim*buffer))
251-
ax.set_ylim((-axis_lim*buffer, axis_lim*buffer))
413+
414+
if add_airy_disk and field_index is not None:
415+
# Draw ellipse ONLY for the current field_index
416+
ellipse = patches.Ellipse(
417+
(real_centroid_x , real_centroid_y),
418+
#(0, 0),
419+
width=2*airy_rad_y[field_index], # diameter, not radius
420+
height=2*airy_rad_x[field_index],
421+
linestyle="--",
422+
edgecolor="black",
423+
fill=False,
424+
linewidth=2
425+
)
426+
ax.add_patch(ellipse)
427+
428+
offset = abs(max(real_centroid_y, real_centroid_x))
429+
430+
# Find the maximum extent among the geometric spot radius and the airy disk radii.
431+
max_airy_x = max(airy_rad_x)
432+
max_airy_y = max(airy_rad_y)
433+
max_extent = max(axis_lim, max_airy_x, max_airy_y)
434+
435+
# Apply a buffer to ensure the data fits nicely in the plot.
436+
adjusted_axis_lim = (offset + max_extent) * buffer
437+
else:
438+
# Without the airy disk, just use the geometric spot radius with a buffer.
439+
adjusted_axis_lim = axis_lim * buffer
440+
441+
442+
ax.axis('square')
443+
ax.set_xlabel('X (mm)')
444+
ax.set_ylabel('Y (mm)')
445+
ax.set_xlim((-adjusted_axis_lim, adjusted_axis_lim))
446+
ax.set_ylim((-adjusted_axis_lim, adjusted_axis_lim))
252447
ax.set_title(f'Hx: {field[0]:.3f}, Hy: {field[1]:.3f}')
253-
ax.grid(alpha=0.25)
448+
ax.grid(alpha=0.25)

0 commit comments

Comments
 (0)