77from copy import deepcopy
88import numpy as np
99import matplotlib .pyplot as plt
10+ import matplotlib .patches as patches
1011
1112plt .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