lsurf 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. lsurf/__init__.py +471 -0
  2. lsurf/analysis/__init__.py +107 -0
  3. lsurf/analysis/healpix_utils.py +418 -0
  4. lsurf/analysis/sphere_viz.py +1280 -0
  5. lsurf/cli/__init__.py +48 -0
  6. lsurf/cli/build.py +398 -0
  7. lsurf/cli/config_schema.py +318 -0
  8. lsurf/cli/gui_cmd.py +76 -0
  9. lsurf/cli/interactive.py +850 -0
  10. lsurf/cli/main.py +81 -0
  11. lsurf/cli/run.py +806 -0
  12. lsurf/detectors/__init__.py +266 -0
  13. lsurf/detectors/analysis.py +289 -0
  14. lsurf/detectors/base.py +284 -0
  15. lsurf/detectors/constant_size_rings.py +485 -0
  16. lsurf/detectors/directional.py +45 -0
  17. lsurf/detectors/extended/__init__.py +73 -0
  18. lsurf/detectors/extended/local_sphere.py +353 -0
  19. lsurf/detectors/extended/recording_sphere.py +368 -0
  20. lsurf/detectors/planar.py +45 -0
  21. lsurf/detectors/protocol.py +187 -0
  22. lsurf/detectors/recording_spheres.py +63 -0
  23. lsurf/detectors/results.py +1140 -0
  24. lsurf/detectors/small/__init__.py +79 -0
  25. lsurf/detectors/small/directional.py +330 -0
  26. lsurf/detectors/small/planar.py +401 -0
  27. lsurf/detectors/small/spherical.py +450 -0
  28. lsurf/detectors/spherical.py +45 -0
  29. lsurf/geometry/__init__.py +199 -0
  30. lsurf/geometry/builder.py +478 -0
  31. lsurf/geometry/cell.py +228 -0
  32. lsurf/geometry/cell_geometry.py +247 -0
  33. lsurf/geometry/detector_arrays.py +1785 -0
  34. lsurf/geometry/geometry.py +222 -0
  35. lsurf/geometry/surface_analysis.py +375 -0
  36. lsurf/geometry/validation.py +91 -0
  37. lsurf/gui/__init__.py +51 -0
  38. lsurf/gui/app.py +903 -0
  39. lsurf/gui/core/__init__.py +39 -0
  40. lsurf/gui/core/scene.py +343 -0
  41. lsurf/gui/core/simulation.py +264 -0
  42. lsurf/gui/renderers/__init__.py +40 -0
  43. lsurf/gui/renderers/ray_renderer.py +353 -0
  44. lsurf/gui/renderers/source_renderer.py +505 -0
  45. lsurf/gui/renderers/surface_renderer.py +477 -0
  46. lsurf/gui/views/__init__.py +48 -0
  47. lsurf/gui/views/config_editor.py +3199 -0
  48. lsurf/gui/views/properties.py +257 -0
  49. lsurf/gui/views/results.py +291 -0
  50. lsurf/gui/views/scene_tree.py +180 -0
  51. lsurf/gui/views/viewport_3d.py +555 -0
  52. lsurf/gui/views/visualizations.py +712 -0
  53. lsurf/materials/__init__.py +169 -0
  54. lsurf/materials/base/__init__.py +64 -0
  55. lsurf/materials/base/full_inhomogeneous.py +208 -0
  56. lsurf/materials/base/grid_inhomogeneous.py +319 -0
  57. lsurf/materials/base/homogeneous.py +342 -0
  58. lsurf/materials/base/material_field.py +527 -0
  59. lsurf/materials/base/simple_inhomogeneous.py +418 -0
  60. lsurf/materials/base/spectral_inhomogeneous.py +497 -0
  61. lsurf/materials/implementations/__init__.py +120 -0
  62. lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
  63. lsurf/materials/implementations/duct_atmosphere.py +390 -0
  64. lsurf/materials/implementations/exponential_atmosphere.py +435 -0
  65. lsurf/materials/implementations/gaussian_lens.py +120 -0
  66. lsurf/materials/implementations/interpolated_data.py +123 -0
  67. lsurf/materials/implementations/layered_atmosphere.py +134 -0
  68. lsurf/materials/implementations/linear_gradient.py +109 -0
  69. lsurf/materials/implementations/linsley_atmosphere.py +764 -0
  70. lsurf/materials/implementations/standard_materials.py +126 -0
  71. lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
  72. lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
  73. lsurf/materials/utils/__init__.py +77 -0
  74. lsurf/materials/utils/constants.py +45 -0
  75. lsurf/materials/utils/device_functions.py +117 -0
  76. lsurf/materials/utils/dispersion.py +160 -0
  77. lsurf/materials/utils/factories.py +142 -0
  78. lsurf/propagation/__init__.py +91 -0
  79. lsurf/propagation/detector_gpu.py +67 -0
  80. lsurf/propagation/gpu_device_rays.py +294 -0
  81. lsurf/propagation/kernels/__init__.py +175 -0
  82. lsurf/propagation/kernels/absorption/__init__.py +61 -0
  83. lsurf/propagation/kernels/absorption/grid.py +240 -0
  84. lsurf/propagation/kernels/absorption/simple.py +232 -0
  85. lsurf/propagation/kernels/absorption/spectral.py +410 -0
  86. lsurf/propagation/kernels/detection/__init__.py +64 -0
  87. lsurf/propagation/kernels/detection/protocol.py +102 -0
  88. lsurf/propagation/kernels/detection/spherical.py +255 -0
  89. lsurf/propagation/kernels/device_functions.py +790 -0
  90. lsurf/propagation/kernels/fresnel/__init__.py +64 -0
  91. lsurf/propagation/kernels/fresnel/protocol.py +97 -0
  92. lsurf/propagation/kernels/fresnel/standard.py +258 -0
  93. lsurf/propagation/kernels/intersection/__init__.py +79 -0
  94. lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
  95. lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
  96. lsurf/propagation/kernels/intersection/plane.py +166 -0
  97. lsurf/propagation/kernels/intersection/protocol.py +95 -0
  98. lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
  99. lsurf/propagation/kernels/intersection/sphere.py +190 -0
  100. lsurf/propagation/kernels/propagation/__init__.py +85 -0
  101. lsurf/propagation/kernels/propagation/grid.py +527 -0
  102. lsurf/propagation/kernels/propagation/protocol.py +105 -0
  103. lsurf/propagation/kernels/propagation/simple.py +460 -0
  104. lsurf/propagation/kernels/propagation/spectral.py +875 -0
  105. lsurf/propagation/kernels/registry.py +331 -0
  106. lsurf/propagation/kernels/surface/__init__.py +72 -0
  107. lsurf/propagation/kernels/surface/bisection.py +232 -0
  108. lsurf/propagation/kernels/surface/detection.py +402 -0
  109. lsurf/propagation/kernels/surface/reduction.py +166 -0
  110. lsurf/propagation/propagator_protocol.py +222 -0
  111. lsurf/propagation/propagators/__init__.py +101 -0
  112. lsurf/propagation/propagators/detector_handler.py +354 -0
  113. lsurf/propagation/propagators/factory.py +200 -0
  114. lsurf/propagation/propagators/fresnel_handler.py +305 -0
  115. lsurf/propagation/propagators/gpu_gradient.py +566 -0
  116. lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
  117. lsurf/propagation/propagators/gradient.py +429 -0
  118. lsurf/propagation/propagators/intersection_handler.py +327 -0
  119. lsurf/propagation/propagators/material_propagator.py +398 -0
  120. lsurf/propagation/propagators/signed_distance_handler.py +522 -0
  121. lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
  122. lsurf/propagation/propagators/surface_interaction.py +616 -0
  123. lsurf/propagation/propagators/surface_propagator.py +719 -0
  124. lsurf/py.typed +1 -0
  125. lsurf/simulation/__init__.py +70 -0
  126. lsurf/simulation/config.py +164 -0
  127. lsurf/simulation/orchestrator.py +462 -0
  128. lsurf/simulation/result.py +299 -0
  129. lsurf/simulation/simulation.py +262 -0
  130. lsurf/sources/__init__.py +128 -0
  131. lsurf/sources/base.py +264 -0
  132. lsurf/sources/collimated.py +252 -0
  133. lsurf/sources/custom.py +409 -0
  134. lsurf/sources/diverging.py +228 -0
  135. lsurf/sources/gaussian.py +272 -0
  136. lsurf/sources/parallel_from_positions.py +197 -0
  137. lsurf/sources/point.py +172 -0
  138. lsurf/sources/uniform_diverging.py +258 -0
  139. lsurf/surfaces/__init__.py +184 -0
  140. lsurf/surfaces/cpu/__init__.py +50 -0
  141. lsurf/surfaces/cpu/curved_wave.py +463 -0
  142. lsurf/surfaces/cpu/gerstner_wave.py +381 -0
  143. lsurf/surfaces/cpu/wave_params.py +118 -0
  144. lsurf/surfaces/gpu/__init__.py +72 -0
  145. lsurf/surfaces/gpu/annular_plane.py +453 -0
  146. lsurf/surfaces/gpu/bounded_plane.py +390 -0
  147. lsurf/surfaces/gpu/curved_wave.py +483 -0
  148. lsurf/surfaces/gpu/gerstner_wave.py +377 -0
  149. lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
  150. lsurf/surfaces/gpu/plane.py +299 -0
  151. lsurf/surfaces/gpu/recording_sphere.py +587 -0
  152. lsurf/surfaces/gpu/sphere.py +311 -0
  153. lsurf/surfaces/protocol.py +336 -0
  154. lsurf/surfaces/registry.py +373 -0
  155. lsurf/utilities/__init__.py +175 -0
  156. lsurf/utilities/detector_analysis.py +814 -0
  157. lsurf/utilities/fresnel.py +628 -0
  158. lsurf/utilities/interactions.py +1215 -0
  159. lsurf/utilities/propagation.py +602 -0
  160. lsurf/utilities/ray_data.py +532 -0
  161. lsurf/utilities/recording_sphere.py +745 -0
  162. lsurf/utilities/time_spread.py +463 -0
  163. lsurf/visualization/__init__.py +329 -0
  164. lsurf/visualization/absorption_plots.py +334 -0
  165. lsurf/visualization/atmospheric_plots.py +754 -0
  166. lsurf/visualization/common.py +348 -0
  167. lsurf/visualization/detector_plots.py +1350 -0
  168. lsurf/visualization/detector_sphere_plots.py +1173 -0
  169. lsurf/visualization/fresnel_plots.py +1061 -0
  170. lsurf/visualization/ocean_simulation_plots.py +999 -0
  171. lsurf/visualization/polarization_plots.py +916 -0
  172. lsurf/visualization/raytracing_plots.py +1521 -0
  173. lsurf/visualization/ring_detector_plots.py +1867 -0
  174. lsurf/visualization/time_spread_plots.py +531 -0
  175. lsurf-1.0.0.dist-info/METADATA +381 -0
  176. lsurf-1.0.0.dist-info/RECORD +180 -0
  177. lsurf-1.0.0.dist-info/WHEEL +5 -0
  178. lsurf-1.0.0.dist-info/entry_points.txt +2 -0
  179. lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
  180. lsurf-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,916 @@
1
+ # The Clear BSD License
2
+ #
3
+ # Copyright (c) 2026 Tobias Heibges
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted (subject to the limitations in the disclaimer
8
+ # below) provided that the following conditions are met:
9
+ #
10
+ # * Redistributions of source code must retain the above copyright notice,
11
+ # this list of conditions and the following disclaimer.
12
+ #
13
+ # * Redistributions in binary form must reproduce the above copyright
14
+ # notice, this list of conditions and the following disclaimer in the
15
+ # documentation and/or other materials provided with the distribution.
16
+ #
17
+ # * Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from this
19
+ # software without specific prior written permission.
20
+ #
21
+ # NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
22
+ # THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
23
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
25
+ # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
26
+ # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
27
+ # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
28
+ # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
29
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
30
+ # IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
31
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32
+ # POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ """
35
+ Polarization Visualization
36
+
37
+ Functions for plotting polarization vector components, ellipses,
38
+ and polarization-resolved reflectance analysis.
39
+ """
40
+
41
+ from typing import TYPE_CHECKING
42
+
43
+ import matplotlib.pyplot as plt
44
+ import numpy as np
45
+ from matplotlib.figure import Figure
46
+ from numpy.typing import NDArray
47
+
48
+ if TYPE_CHECKING:
49
+ from ..utilities.recording_sphere import RecordedRays
50
+
51
+ from .common import save_figure
52
+
53
+
54
+ def get_ray_coordinates(
55
+ recorded_rays: "RecordedRays", projection: str = "angular"
56
+ ) -> tuple[np.ndarray, np.ndarray, str, str]:
57
+ """Convert recorded rays to coordinates for binning/plotting.
58
+
59
+ Parameters
60
+ ----------
61
+ recorded_rays : RecordedRays
62
+ Recorded rays at the detection sphere.
63
+ projection : str
64
+ Type of projection for binning:
65
+ - 'angular': Use elevation and azimuth angles
66
+ - 'spatial': Use X and Y positions on detection surface
67
+
68
+ Returns
69
+ -------
70
+ x_coord : ndarray
71
+ X coordinates for plotting/binning.
72
+ y_coord : ndarray
73
+ Y coordinates for plotting/binning.
74
+ xlabel : str
75
+ Label for x-axis.
76
+ ylabel : str
77
+ Label for y-axis.
78
+ """
79
+ if projection == "angular":
80
+ # Use ray direction-based angles
81
+ directions = recorded_rays.directions
82
+ # Elevation: angle from horizontal plane (arcsin of z component)
83
+ y_coord = np.degrees(np.arcsin(directions[:, 2]))
84
+ # Azimuth: angle in X-Y plane from X axis
85
+ x_coord = np.degrees(np.arctan2(directions[:, 1], directions[:, 0]))
86
+ xlabel = "Azimuth (degrees)"
87
+ ylabel = "Ray Angle from Horizontal (degrees)"
88
+ else: # spatial
89
+ x_coord = recorded_rays.positions[:, 0]
90
+ y_coord = recorded_rays.positions[:, 1]
91
+ xlabel = "X Position (m)"
92
+ ylabel = "Y Position (m)"
93
+
94
+ return x_coord, y_coord, xlabel, ylabel
95
+
96
+
97
+ def plot_polarization_vector_components(
98
+ recorded_rays: "RecordedRays",
99
+ bins: int = 50,
100
+ figsize: tuple[float, float] = (18, 6),
101
+ save_path: str | None = None,
102
+ vmin: float = None,
103
+ vmax: float = None,
104
+ cmap: str = "RdBu_r",
105
+ projection: str = "angular",
106
+ ) -> Figure:
107
+ """
108
+ Plot 3D polarization vector components at the detection surface.
109
+
110
+ Creates three subfigures showing the average X (horizontal), Y (vertical),
111
+ and Z (depth) components of the polarization vector binned by position.
112
+
113
+ Parameters
114
+ ----------
115
+ recorded_rays : RecordedRays
116
+ Recorded rays at the detection sphere, must have polarization_vectors.
117
+ bins : int
118
+ Number of bins in each dimension for the 2D histogram.
119
+ figsize : tuple
120
+ Figure size (width, height).
121
+ save_path : str, optional
122
+ Path to save figure.
123
+ vmin : float, optional
124
+ Minimum value for colormap. Default is symmetric around 0.
125
+ vmax : float, optional
126
+ Maximum value for colormap. Default is symmetric around 0.
127
+ cmap : str
128
+ Colormap name. Default 'RdBu_r' is diverging (good for signed values).
129
+ projection : str
130
+ Type of projection for binning:
131
+ - 'angular': Use elevation and azimuth angles
132
+ - 'spatial': Use X and Y positions on detection surface
133
+
134
+ Returns
135
+ -------
136
+ Figure
137
+ Matplotlib figure with three subplots.
138
+
139
+ Notes
140
+ -----
141
+ The polarization vector represents the electric field direction at each ray.
142
+ For unpolarized light scattered from a wavy surface:
143
+ - X component: Horizontal polarization
144
+ - Y component: Vertical polarization
145
+ - Z component: Along depth/propagation direction
146
+
147
+ Each bin shows the intensity-weighted average polarization component.
148
+ """
149
+ if recorded_rays.polarization_vectors is None:
150
+ raise ValueError(
151
+ "RecordedRays does not contain polarization_vectors. "
152
+ "Enable track_polarization_vector=True in process_surface_interaction."
153
+ )
154
+
155
+ n_rays = len(recorded_rays.positions)
156
+ if n_rays == 0:
157
+ raise ValueError("No rays to plot")
158
+
159
+ pol_vectors = recorded_rays.polarization_vectors
160
+ intensities = recorded_rays.intensities
161
+
162
+ fig, axes = plt.subplots(1, 3, figsize=figsize, constrained_layout=True)
163
+
164
+ # Get coordinates for binning
165
+ x_coord, y_coord, xlabel, ylabel = get_ray_coordinates(recorded_rays, projection)
166
+
167
+ # Component labels and data
168
+ components = [
169
+ ("X (Horizontal)", pol_vectors[:, 0]),
170
+ ("Y (Vertical)", pol_vectors[:, 1]),
171
+ ("Z (Depth)", pol_vectors[:, 2]),
172
+ ]
173
+
174
+ # Compute intensity-weighted average for each bin
175
+ for ax, (comp_name, comp_data) in zip(axes, components, strict=False):
176
+ # Create 2D histogram
177
+ weighted_sum, x_edges, y_edges = np.histogram2d(
178
+ x_coord,
179
+ y_coord,
180
+ bins=bins,
181
+ weights=comp_data * intensities,
182
+ )
183
+ intensity_sum, _, _ = np.histogram2d(
184
+ x_coord,
185
+ y_coord,
186
+ bins=bins,
187
+ weights=intensities,
188
+ )
189
+
190
+ # Compute average (avoid division by zero)
191
+ with np.errstate(divide="ignore", invalid="ignore"):
192
+ avg_component = np.where(
193
+ intensity_sum > 0, weighted_sum / intensity_sum, np.nan
194
+ )
195
+
196
+ # Set symmetric colormap limits if not specified
197
+ if vmin is None or vmax is None:
198
+ max_abs = np.nanmax(np.abs(avg_component))
199
+ if max_abs == 0:
200
+ max_abs = 1.0
201
+ _vmin = -max_abs if vmin is None else vmin
202
+ _vmax = max_abs if vmax is None else vmax
203
+ else:
204
+ _vmin, _vmax = vmin, vmax
205
+
206
+ # Plot
207
+ x_centers = 0.5 * (x_edges[:-1] + x_edges[1:])
208
+ y_centers = 0.5 * (y_edges[:-1] + y_edges[1:])
209
+ X, Y = np.meshgrid(x_centers, y_centers)
210
+
211
+ im = ax.pcolormesh(
212
+ X,
213
+ Y,
214
+ avg_component.T,
215
+ cmap=cmap,
216
+ vmin=_vmin,
217
+ vmax=_vmax,
218
+ shading="auto",
219
+ )
220
+
221
+ plt.colorbar(im, ax=ax, label=f"Mean {comp_name}")
222
+
223
+ ax.set_xlabel(xlabel, fontsize=11)
224
+ ax.set_ylabel(ylabel, fontsize=11)
225
+ ax.set_title(f"Polarization {comp_name}", fontsize=12, fontweight="bold")
226
+ ax.grid(True, alpha=0.3)
227
+
228
+ fig.suptitle(
229
+ "3D Polarization Vector Components at Detection Surface",
230
+ fontsize=14,
231
+ fontweight="bold",
232
+ )
233
+
234
+ if save_path:
235
+ save_figure(fig, save_path)
236
+
237
+ return fig
238
+
239
+
240
+ def plot_polarization_ellipse(
241
+ recorded_rays: "RecordedRays",
242
+ bins: int = 30,
243
+ figsize: tuple[float, float] = (12, 10),
244
+ save_path: str | None = None,
245
+ projection: str = "angular",
246
+ arrow_scale: float = 1.0,
247
+ ) -> Figure:
248
+ """
249
+ Plot polarization state as arrows/ellipses on the detection surface.
250
+
251
+ Shows the average polarization direction in each bin as an arrow.
252
+
253
+ Parameters
254
+ ----------
255
+ recorded_rays : RecordedRays
256
+ Recorded rays at the detection sphere, must have polarization_vectors.
257
+ bins : int
258
+ Number of bins in each dimension.
259
+ figsize : tuple
260
+ Figure size.
261
+ save_path : str, optional
262
+ Path to save figure.
263
+ projection : str
264
+ Type of projection: 'angular' or 'spatial'.
265
+ arrow_scale : float
266
+ Scale factor for arrow lengths.
267
+
268
+ Returns
269
+ -------
270
+ Figure
271
+ Matplotlib figure with polarization arrows.
272
+ """
273
+ if recorded_rays.polarization_vectors is None:
274
+ raise ValueError("RecordedRays does not contain polarization_vectors.")
275
+
276
+ n_rays = len(recorded_rays.positions)
277
+ if n_rays == 0:
278
+ raise ValueError("No rays to plot")
279
+
280
+ pol_vectors = recorded_rays.polarization_vectors
281
+ intensities = recorded_rays.intensities
282
+
283
+ fig, ax = plt.subplots(figsize=figsize)
284
+
285
+ # Get coordinates for binning
286
+ x_coord, y_coord, xlabel, ylabel = get_ray_coordinates(recorded_rays, projection)
287
+
288
+ # Compute intensity-weighted average polarization in each bin
289
+ pol_x_sum, x_edges, y_edges = np.histogram2d(
290
+ x_coord, y_coord, bins=bins, weights=pol_vectors[:, 0] * intensities
291
+ )
292
+ pol_y_sum, _, _ = np.histogram2d(
293
+ x_coord, y_coord, bins=bins, weights=pol_vectors[:, 1] * intensities
294
+ )
295
+ intensity_sum, _, _ = np.histogram2d(
296
+ x_coord, y_coord, bins=bins, weights=intensities
297
+ )
298
+
299
+ # Compute bin centers
300
+ x_centers = 0.5 * (x_edges[:-1] + x_edges[1:])
301
+ y_centers = 0.5 * (y_edges[:-1] + y_edges[1:])
302
+
303
+ # Background: intensity distribution
304
+ im = ax.pcolormesh(
305
+ x_edges,
306
+ y_edges,
307
+ intensity_sum.T,
308
+ cmap="gray_r",
309
+ alpha=0.5,
310
+ shading="auto",
311
+ )
312
+ plt.colorbar(im, ax=ax, label="Intensity sum", shrink=0.7)
313
+
314
+ # Draw polarization arrows
315
+ for i in range(len(x_centers)):
316
+ for j in range(len(y_centers)):
317
+ if intensity_sum[i, j] > 0:
318
+ avg_pol_x = pol_x_sum[i, j] / intensity_sum[i, j]
319
+ avg_pol_y = pol_y_sum[i, j] / intensity_sum[i, j]
320
+
321
+ # Arrow length proportional to polarization magnitude
322
+ pol_mag = np.sqrt(avg_pol_x**2 + avg_pol_y**2)
323
+
324
+ if pol_mag > 0.01: # Only draw significant polarization
325
+ # Scale arrows to fit in bins
326
+ bin_size = min(x_edges[1] - x_edges[0], y_edges[1] - y_edges[0])
327
+ scale = bin_size * 0.4 * arrow_scale / max(pol_mag, 0.1)
328
+
329
+ ax.arrow(
330
+ x_centers[i],
331
+ y_centers[j],
332
+ avg_pol_x * scale,
333
+ avg_pol_y * scale,
334
+ head_width=bin_size * 0.1,
335
+ head_length=bin_size * 0.05,
336
+ fc="red",
337
+ ec="darkred",
338
+ alpha=0.8,
339
+ linewidth=0.5,
340
+ )
341
+
342
+ ax.set_xlabel(xlabel, fontsize=12)
343
+ ax.set_ylabel(ylabel, fontsize=12)
344
+ ax.set_title(
345
+ "Polarization Direction at Detection Surface\n"
346
+ "(arrows show X-Y polarization component)",
347
+ fontsize=13,
348
+ fontweight="bold",
349
+ )
350
+ ax.grid(True, alpha=0.3)
351
+ ax.set_aspect("equal")
352
+
353
+ if save_path:
354
+ save_figure(fig, save_path)
355
+
356
+ return fig
357
+
358
+
359
+ def plot_polarization_vs_elevation(
360
+ recorded_rays: "RecordedRays",
361
+ bins: int = 50,
362
+ figsize: tuple[float, float] = (14, 5),
363
+ save_path: str | None = None,
364
+ ) -> Figure:
365
+ """
366
+ Plot polarization degree as a function of ray elevation angle.
367
+
368
+ Averages over all azimuth angles to show how polarization varies with
369
+ the ray angle from horizontal.
370
+
371
+ Parameters
372
+ ----------
373
+ recorded_rays : RecordedRays
374
+ Recorded rays at the detection sphere, must have polarization_vectors.
375
+ bins : int
376
+ Number of elevation angle bins.
377
+ figsize : tuple
378
+ Figure size (width, height).
379
+ save_path : str, optional
380
+ Path to save figure.
381
+
382
+ Returns
383
+ -------
384
+ Figure
385
+ Matplotlib figure with polarization vs elevation plots.
386
+
387
+ Notes
388
+ -----
389
+ The polarization degree is computed as:
390
+ DoP = (E_x² - E_y²) / (E_x² + E_y²)
391
+
392
+ Where E_x is the horizontal component and E_y is the vertical component
393
+ of the polarization vector. DoP = +1 means fully horizontal polarization,
394
+ DoP = -1 means fully vertical polarization, DoP = 0 means unpolarized
395
+ or 45° linear polarization.
396
+ """
397
+ if recorded_rays.polarization_vectors is None:
398
+ raise ValueError("RecordedRays does not contain polarization_vectors.")
399
+
400
+ n_rays = len(recorded_rays.positions)
401
+ if n_rays == 0:
402
+ raise ValueError("No rays to plot")
403
+
404
+ pol_vectors = recorded_rays.polarization_vectors
405
+ intensities = recorded_rays.intensities
406
+ directions = recorded_rays.directions
407
+
408
+ # Compute elevation angle from ray direction
409
+ elevation = np.degrees(np.arcsin(directions[:, 2]))
410
+
411
+ # Create figure with 3 subplots
412
+ fig, axes = plt.subplots(1, 3, figsize=figsize, constrained_layout=True)
413
+
414
+ # Bin edges
415
+ elev_min, elev_max = elevation.min(), elevation.max()
416
+ bin_edges = np.linspace(elev_min, elev_max, bins + 1)
417
+ bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
418
+
419
+ # Compute intensity-weighted statistics in each bin
420
+ Ex_weighted = np.zeros(bins)
421
+ Ey_weighted = np.zeros(bins)
422
+ Ez_weighted = np.zeros(bins)
423
+ Ex2_weighted = np.zeros(bins)
424
+ Ey2_weighted = np.zeros(bins)
425
+ intensity_sum = np.zeros(bins)
426
+ ray_count = np.zeros(bins)
427
+
428
+ bin_indices = np.digitize(elevation, bin_edges) - 1
429
+ bin_indices = np.clip(bin_indices, 0, bins - 1)
430
+
431
+ for i in range(bins):
432
+ mask = bin_indices == i
433
+ if np.any(mask):
434
+ weights = intensities[mask]
435
+ total_weight = np.sum(weights)
436
+ if total_weight > 0:
437
+ Ex_weighted[i] = np.sum(pol_vectors[mask, 0] * weights) / total_weight
438
+ Ey_weighted[i] = np.sum(pol_vectors[mask, 1] * weights) / total_weight
439
+ Ez_weighted[i] = np.sum(pol_vectors[mask, 2] * weights) / total_weight
440
+ Ex2_weighted[i] = (
441
+ np.sum(pol_vectors[mask, 0] ** 2 * weights) / total_weight
442
+ )
443
+ Ey2_weighted[i] = (
444
+ np.sum(pol_vectors[mask, 1] ** 2 * weights) / total_weight
445
+ )
446
+ intensity_sum[i] = total_weight
447
+ ray_count[i] = np.sum(mask)
448
+
449
+ # Compute polarization degree: (E_x² - E_y²) / (E_x² + E_y²)
450
+ with np.errstate(divide="ignore", invalid="ignore"):
451
+ polarization_degree = np.where(
452
+ (Ex2_weighted + Ey2_weighted) > 0,
453
+ (Ex2_weighted - Ey2_weighted) / (Ex2_weighted + Ey2_weighted),
454
+ np.nan,
455
+ )
456
+
457
+ # Plot 1: Mean polarization components
458
+ ax1 = axes[0]
459
+ valid = intensity_sum > 0
460
+ ax1.plot(
461
+ bin_centers[valid],
462
+ Ex_weighted[valid],
463
+ "r-",
464
+ linewidth=2,
465
+ label="E_x (horizontal)",
466
+ )
467
+ ax1.plot(
468
+ bin_centers[valid],
469
+ Ey_weighted[valid],
470
+ "b-",
471
+ linewidth=2,
472
+ label="E_y (vertical)",
473
+ )
474
+ ax1.plot(
475
+ bin_centers[valid], Ez_weighted[valid], "g-", linewidth=2, label="E_z (depth)"
476
+ )
477
+ ax1.axhline(0, color="k", linestyle="--", alpha=0.3)
478
+ ax1.set_xlabel("Ray Angle from Horizontal (degrees)", fontsize=11)
479
+ ax1.set_ylabel("Mean Polarization Component", fontsize=11)
480
+ ax1.set_title("Polarization Components vs Elevation", fontweight="bold")
481
+ ax1.legend(loc="best")
482
+ ax1.grid(True, alpha=0.3)
483
+
484
+ # Plot 2: Polarization degree
485
+ ax2 = axes[1]
486
+ ax2.plot(bin_centers[valid], polarization_degree[valid], "purple", linewidth=2)
487
+ ax2.axhline(0, color="k", linestyle="--", alpha=0.3)
488
+ ax2.axhline(1, color="r", linestyle=":", alpha=0.5, label="Fully horizontal")
489
+ ax2.axhline(-1, color="b", linestyle=":", alpha=0.5, label="Fully vertical")
490
+ ax2.set_xlabel("Ray Angle from Horizontal (degrees)", fontsize=11)
491
+ ax2.set_ylabel(
492
+ r"Polarization Degree $(E_x^2 - E_y^2)/(E_x^2 + E_y^2)$", fontsize=11
493
+ )
494
+ ax2.set_title("Degree of Linear Polarization", fontweight="bold")
495
+ ax2.set_ylim(-1.1, 1.1)
496
+ ax2.legend(loc="best")
497
+ ax2.grid(True, alpha=0.3)
498
+
499
+ # Plot 3: Intensity distribution and ray count
500
+ ax3 = axes[2]
501
+ ax3_twin = ax3.twinx()
502
+
503
+ ax3.bar(
504
+ bin_centers,
505
+ intensity_sum,
506
+ width=(elev_max - elev_min) / bins * 0.8,
507
+ alpha=0.6,
508
+ color="orange",
509
+ label="Total intensity",
510
+ )
511
+ ax3_twin.plot(bin_centers, ray_count, "k-", linewidth=1.5, label="Ray count")
512
+
513
+ ax3.set_xlabel("Ray Angle from Horizontal (degrees)", fontsize=11)
514
+ ax3.set_ylabel("Total Intensity", fontsize=11, color="orange")
515
+ ax3_twin.set_ylabel("Ray Count", fontsize=11)
516
+ ax3.set_title("Intensity Distribution", fontweight="bold")
517
+ ax3.tick_params(axis="y", labelcolor="orange")
518
+ ax3.grid(True, alpha=0.3)
519
+
520
+ # Combined legend
521
+ lines1, labels1 = ax3.get_legend_handles_labels()
522
+ lines2, labels2 = ax3_twin.get_legend_handles_labels()
523
+ ax3.legend(lines1 + lines2, labels1 + labels2, loc="best")
524
+
525
+ fig.suptitle(
526
+ "Polarization vs Ray Elevation (averaged over azimuth)",
527
+ fontsize=14,
528
+ fontweight="bold",
529
+ )
530
+
531
+ if save_path:
532
+ save_figure(fig, save_path)
533
+
534
+ return fig
535
+
536
+
537
+ def plot_fresnel_reflectance_curves(
538
+ n1: float = 1.0,
539
+ n2: float = 1.33,
540
+ angles_deg: NDArray[np.float64] | None = None,
541
+ brewster_angle_deg: float | None = None,
542
+ figsize: tuple[float, float] = (8, 6),
543
+ save_path: str | None = None,
544
+ ) -> Figure:
545
+ """
546
+ Plot Fresnel reflection coefficients R_s, R_p, and R_unpolarized vs elevation.
547
+
548
+ Creates a single-subplot figure showing how the reflection coefficients
549
+ for s-polarization, p-polarization, and unpolarized light vary with
550
+ elevation angle (angle above horizontal). This convention matches the
551
+ measured reflectance plots for easy comparison.
552
+
553
+ Parameters
554
+ ----------
555
+ n1 : float, optional
556
+ Refractive index of incident medium (default: 1.0 for air)
557
+ n2 : float, optional
558
+ Refractive index of transmitting medium (default: 1.33 for water)
559
+ angles_deg : ndarray, optional
560
+ Elevation angles in degrees to compute (default: 0 to 90 in 0.5° steps)
561
+ brewster_angle_deg : float, optional
562
+ Brewster angle (as incidence angle) to mark on plot. If None, computed from n1, n2.
563
+ figsize : tuple, optional
564
+ Figure size in inches
565
+ save_path : str, optional
566
+ Path to save figure
567
+
568
+ Returns
569
+ -------
570
+ Figure
571
+ Matplotlib figure
572
+
573
+ Notes
574
+ -----
575
+ The x-axis shows elevation angle (angle above horizontal), which relates to
576
+ incidence angle as: elevation = 90° - incidence_angle.
577
+
578
+ - Low elevation (grazing) → high incidence angle → high reflectance
579
+ - High elevation (steep) → low incidence angle → low reflectance
580
+
581
+ Fresnel equations for reflection:
582
+ - R_s = |r_s|² where r_s = (n1*cos_i - n2*cos_t) / (n1*cos_i + n2*cos_t)
583
+ - R_p = |r_p|² where r_p = (n2*cos_i - n1*cos_t) / (n2*cos_i + n1*cos_t)
584
+ - R_unpolarized = (R_s + R_p) / 2
585
+
586
+ Brewster angle: θ_B = arctan(n2/n1), where R_p = 0
587
+ """
588
+ from ..utilities.fresnel import fresnel_coefficients
589
+
590
+ # Elevation angles (angle above horizontal)
591
+ if angles_deg is None:
592
+ elevation_deg = np.linspace(1, 90, 179) # 1° to 90° elevation
593
+ else:
594
+ elevation_deg = angles_deg
595
+
596
+ # Convert elevation to incidence angle: incidence = 90° - elevation
597
+ incidence_deg = 90 - elevation_deg
598
+ incidence_rad = np.radians(incidence_deg)
599
+
600
+ # Compute Fresnel coefficients at each incidence angle
601
+ R_s = np.zeros(len(elevation_deg))
602
+ R_p = np.zeros(len(elevation_deg))
603
+
604
+ for i, angle_rad in enumerate(incidence_rad):
605
+ cos_theta = np.cos(angle_rad)
606
+ R_s_val, _ = fresnel_coefficients(n1, n2, cos_theta, "s")
607
+ R_p_val, _ = fresnel_coefficients(n1, n2, cos_theta, "p")
608
+ R_s[i] = float(R_s_val.item() if hasattr(R_s_val, "item") else R_s_val)
609
+ R_p[i] = float(R_p_val.item() if hasattr(R_p_val, "item") else R_p_val)
610
+
611
+ # Unpolarized is average of s and p
612
+ R_unpol = (R_s + R_p) / 2
613
+
614
+ # Compute polarization fractions: what fraction of reflected light is s vs p
615
+ R_total = R_s + R_p
616
+ with np.errstate(divide="ignore", invalid="ignore"):
617
+ F_s = np.where(R_total > 0, R_s / R_total, 0.5) # s-fraction
618
+ F_p = np.where(R_total > 0, R_p / R_total, 0.5) # p-fraction
619
+
620
+ # Compute Brewster angle if not provided (as incidence angle)
621
+ if brewster_angle_deg is None:
622
+ brewster_angle_deg = np.degrees(np.arctan(n2 / n1))
623
+
624
+ # Convert Brewster angle to elevation
625
+ brewster_elevation_deg = 90 - brewster_angle_deg
626
+
627
+ # Create figure with two subplots
628
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(figsize[0] * 1.8, figsize[1]))
629
+
630
+ # Left plot: Reflectance coefficients (decreasing with elevation)
631
+ ax1.plot(elevation_deg, R_s, "r-", linewidth=2, label=r"$R_s$ (s-polarization)")
632
+ ax1.plot(elevation_deg, R_p, "b-", linewidth=2, label=r"$R_p$ (p-polarization)")
633
+ ax1.plot(
634
+ elevation_deg, R_unpol, "k--", linewidth=2, label=r"$R_{unpol}$ (unpolarized)"
635
+ )
636
+
637
+ ax1.axvline(
638
+ brewster_elevation_deg,
639
+ color="green",
640
+ linestyle=":",
641
+ linewidth=1.5,
642
+ alpha=0.8,
643
+ label=f"Brewster ({brewster_elevation_deg:.1f}°)",
644
+ )
645
+ ax1.plot(brewster_elevation_deg, 0, "go", markersize=8, zorder=10)
646
+
647
+ ax1.set_xlabel("Elevation Angle (degrees)", fontsize=12)
648
+ ax1.set_ylabel("Reflectance Coefficient", fontsize=12)
649
+ ax1.set_title("Fresnel Reflectance", fontweight="bold")
650
+ ax1.set_xlim(0, 90)
651
+ ax1.set_ylim(0, 1)
652
+ ax1.legend(loc="upper right", fontsize=9)
653
+ ax1.grid(True, alpha=0.3)
654
+
655
+ # Right plot: Polarization fractions (shows what fraction of reflected light is s vs p)
656
+ ax2.plot(
657
+ elevation_deg, F_s, "r-", linewidth=2, label=r"$R_s/(R_s+R_p)$ (s-fraction)"
658
+ )
659
+ ax2.plot(
660
+ elevation_deg, F_p, "b-", linewidth=2, label=r"$R_p/(R_s+R_p)$ (p-fraction)"
661
+ )
662
+
663
+ ax2.axvline(
664
+ brewster_elevation_deg,
665
+ color="green",
666
+ linestyle=":",
667
+ linewidth=1.5,
668
+ alpha=0.8,
669
+ label=f"Brewster ({brewster_elevation_deg:.1f}°)",
670
+ )
671
+ # At Brewster, F_s = 1.0
672
+ ax2.plot(brewster_elevation_deg, 1.0, "go", markersize=8, zorder=10)
673
+ ax2.annotate(
674
+ r"$F_s = 1$",
675
+ xy=(brewster_elevation_deg, 1.0),
676
+ xytext=(brewster_elevation_deg + 5, 0.85),
677
+ fontsize=10,
678
+ arrowprops={"arrowstyle": "->", "color": "green", "alpha": 0.7},
679
+ )
680
+
681
+ ax2.set_xlabel("Elevation Angle (degrees)", fontsize=12)
682
+ ax2.set_ylabel("Polarization Fraction", fontsize=12)
683
+ ax2.set_title("Polarization of Reflected Light", fontweight="bold")
684
+ ax2.set_xlim(0, 90)
685
+ ax2.set_ylim(0, 1)
686
+ ax2.legend(loc="center right", fontsize=9)
687
+ ax2.grid(True, alpha=0.3)
688
+
689
+ fig.suptitle(
690
+ f"Fresnel Reflection: Air ($n$ = {n1:.3f}) → Water ($n$ = {n2:.3f})",
691
+ fontsize=13,
692
+ fontweight="bold",
693
+ )
694
+
695
+ plt.tight_layout()
696
+
697
+ if save_path:
698
+ save_figure(fig, save_path)
699
+
700
+ return fig
701
+
702
+
703
+ def plot_measured_polarization_reflectance(
704
+ recorded_rays: "RecordedRays",
705
+ bins: int = 50,
706
+ figsize: tuple[float, float] = (10, 6),
707
+ save_path: str | None = None,
708
+ ) -> Figure:
709
+ """
710
+ Plot measured reflected intensity decomposed into s and p polarization.
711
+
712
+ Analyzes recorded rays to show how the reflected intensity is distributed
713
+ between s-polarization (horizontal) and p-polarization (vertical) as a
714
+ function of ray elevation angle. This treats the surface as an unknown
715
+ reflector and measures its polarization behavior.
716
+
717
+ Parameters
718
+ ----------
719
+ recorded_rays : RecordedRays
720
+ Recorded rays with polarization vectors
721
+ bins : int, optional
722
+ Number of elevation angle bins (default: 50)
723
+ figsize : tuple, optional
724
+ Figure size in inches
725
+ save_path : str, optional
726
+ Path to save figure
727
+
728
+ Returns
729
+ -------
730
+ Figure
731
+ Matplotlib figure
732
+
733
+ Notes
734
+ -----
735
+ For each ray, the polarization vector is decomposed into:
736
+ - s-component: horizontal (perpendicular to vertical plane containing ray)
737
+ - p-component: vertical (in the vertical plane containing ray)
738
+
739
+ The intensity is then weighted by |E_s|² and |E_p|² to get the
740
+ intensity in each polarization state.
741
+ """
742
+ if recorded_rays.polarization_vectors is None:
743
+ raise ValueError("Recorded rays must have polarization vectors")
744
+
745
+ directions = recorded_rays.directions
746
+ polarizations = recorded_rays.polarization_vectors
747
+ intensities = recorded_rays.intensities
748
+
749
+ # Compute elevation angle from ray direction
750
+ # Elevation = angle from horizontal plane = arcsin(z)
751
+ elevation_deg = np.degrees(np.arcsin(directions[:, 2]))
752
+
753
+ # Compute s and p basis vectors for each ray
754
+ # s = horizontal = ray × Z (perpendicular to vertical plane)
755
+ # p = vertical in plane = ray × s
756
+ z_axis = np.array([0, 0, 1], dtype=np.float32)
757
+
758
+ s_vectors = np.cross(directions, z_axis)
759
+ s_norms = np.linalg.norm(s_vectors, axis=1, keepdims=True)
760
+
761
+ # Handle rays parallel to Z (vertical rays)
762
+ parallel_mask = s_norms.squeeze() < 1e-6
763
+ if np.any(parallel_mask):
764
+ s_vectors[parallel_mask] = np.array([1, 0, 0], dtype=np.float32)
765
+ s_norms[parallel_mask] = 1.0
766
+
767
+ s_vectors = s_vectors / np.maximum(s_norms, 1e-10)
768
+
769
+ # p = ray × s (vertical component perpendicular to ray)
770
+ p_vectors = np.cross(directions, s_vectors)
771
+ p_norms = np.linalg.norm(p_vectors, axis=1, keepdims=True)
772
+ p_vectors = p_vectors / np.maximum(p_norms, 1e-10)
773
+
774
+ # Project polarization onto s and p
775
+ E_s = np.sum(polarizations * s_vectors, axis=1) # s-component magnitude
776
+ E_p = np.sum(polarizations * p_vectors, axis=1) # p-component magnitude
777
+
778
+ # Intensity in each polarization state
779
+ I_s = intensities * E_s**2 # Intensity with s-polarization character
780
+ I_p = intensities * E_p**2 # Intensity with p-polarization character
781
+ I_total = intensities
782
+
783
+ # Bin by elevation angle
784
+ elevation_min, elevation_max = elevation_deg.min(), elevation_deg.max()
785
+ bin_edges = np.linspace(elevation_min, elevation_max, bins + 1)
786
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
787
+
788
+ # Compute binned statistics
789
+ I_s_binned = np.zeros(bins)
790
+ I_p_binned = np.zeros(bins)
791
+ I_total_binned = np.zeros(bins)
792
+ ray_counts = np.zeros(bins)
793
+
794
+ bin_indices = np.digitize(elevation_deg, bin_edges) - 1
795
+ bin_indices = np.clip(bin_indices, 0, bins - 1)
796
+
797
+ for i in range(bins):
798
+ mask = bin_indices == i
799
+ if np.any(mask):
800
+ I_s_binned[i] = np.sum(I_s[mask])
801
+ I_p_binned[i] = np.sum(I_p[mask])
802
+ I_total_binned[i] = np.sum(I_total[mask])
803
+ ray_counts[i] = np.sum(mask)
804
+
805
+ # Normalize to show relative reflectance (per ray or as fraction)
806
+ # Option 1: Show absolute intensity
807
+ # Option 2: Normalize by ray count to show mean intensity per ray
808
+ # Option 3: Normalize by total to show as fraction
809
+
810
+ # Use mean intensity per ray for a cleaner comparison
811
+ valid_bins = ray_counts > 0
812
+ I_s_mean = np.zeros(bins)
813
+ I_p_mean = np.zeros(bins)
814
+ I_total_mean = np.zeros(bins)
815
+
816
+ I_s_mean[valid_bins] = I_s_binned[valid_bins] / ray_counts[valid_bins]
817
+ I_p_mean[valid_bins] = I_p_binned[valid_bins] / ray_counts[valid_bins]
818
+ I_total_mean[valid_bins] = I_total_binned[valid_bins] / ray_counts[valid_bins]
819
+
820
+ # Compute polarization fractions for each bin
821
+ I_sp_sum = I_s_mean + I_p_mean
822
+ F_s_measured = np.zeros(bins)
823
+ F_p_measured = np.zeros(bins)
824
+ valid_fraction = (valid_bins) & (I_sp_sum > 0)
825
+ F_s_measured[valid_fraction] = I_s_mean[valid_fraction] / I_sp_sum[valid_fraction]
826
+ F_p_measured[valid_fraction] = I_p_mean[valid_fraction] / I_sp_sum[valid_fraction]
827
+
828
+ # Create figure with two subplots
829
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(figsize[0] * 1.8, figsize[1]))
830
+
831
+ # Left plot: Mean intensity per ray
832
+ ax1.plot(
833
+ bin_centers[valid_bins],
834
+ I_s_mean[valid_bins],
835
+ "r-",
836
+ linewidth=2,
837
+ label=r"$I_s$ (s-polarization)",
838
+ marker="o",
839
+ markersize=3,
840
+ )
841
+ ax1.plot(
842
+ bin_centers[valid_bins],
843
+ I_p_mean[valid_bins],
844
+ "b-",
845
+ linewidth=2,
846
+ label=r"$I_p$ (p-polarization)",
847
+ marker="s",
848
+ markersize=3,
849
+ )
850
+ ax1.plot(
851
+ bin_centers[valid_bins],
852
+ I_total_mean[valid_bins],
853
+ "k--",
854
+ linewidth=2,
855
+ label=r"$I_{total}$",
856
+ alpha=0.7,
857
+ )
858
+
859
+ ax1.set_xlabel("Ray Elevation Angle (degrees)", fontsize=12)
860
+ ax1.set_ylabel("Mean Reflected Intensity", fontsize=12)
861
+ ax1.set_title("Mean Intensity per Ray", fontweight="bold")
862
+ ax1.legend(loc="best", fontsize=9)
863
+ ax1.grid(True, alpha=0.3)
864
+
865
+ # Right plot: Polarization fractions (comparable to theoretical Fresnel)
866
+ ax2.plot(
867
+ bin_centers[valid_fraction],
868
+ F_s_measured[valid_fraction],
869
+ "r-",
870
+ linewidth=2,
871
+ label=r"$I_s/(I_s+I_p)$ (s-fraction)",
872
+ marker="o",
873
+ markersize=3,
874
+ )
875
+ ax2.plot(
876
+ bin_centers[valid_fraction],
877
+ F_p_measured[valid_fraction],
878
+ "b-",
879
+ linewidth=2,
880
+ label=r"$I_p/(I_s+I_p)$ (p-fraction)",
881
+ marker="s",
882
+ markersize=3,
883
+ )
884
+
885
+ ax2.set_xlabel("Ray Elevation Angle (degrees)", fontsize=12)
886
+ ax2.set_ylabel("Polarization Fraction", fontsize=12)
887
+ ax2.set_title("Polarization of Reflected Light", fontweight="bold")
888
+ ax2.set_ylim(0, 1)
889
+ ax2.legend(loc="best", fontsize=9)
890
+ ax2.grid(True, alpha=0.3)
891
+
892
+ fig.suptitle(
893
+ "Measured Polarization-Resolved Reflectance (from recorded rays)",
894
+ fontsize=13,
895
+ fontweight="bold",
896
+ )
897
+
898
+ # Add secondary axis showing ray count on left plot
899
+ ax1_twin = ax1.twinx()
900
+ ax1_twin.fill_between(
901
+ bin_centers,
902
+ 0,
903
+ ray_counts,
904
+ alpha=0.15,
905
+ color="gray",
906
+ label="Ray count",
907
+ )
908
+ ax1_twin.set_ylabel("Ray Count per Bin", fontsize=10, color="gray")
909
+ ax1_twin.tick_params(axis="y", labelcolor="gray")
910
+
911
+ plt.tight_layout()
912
+
913
+ if save_path:
914
+ save_figure(fig, save_path)
915
+
916
+ return fig