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.
- lsurf/__init__.py +471 -0
- lsurf/analysis/__init__.py +107 -0
- lsurf/analysis/healpix_utils.py +418 -0
- lsurf/analysis/sphere_viz.py +1280 -0
- lsurf/cli/__init__.py +48 -0
- lsurf/cli/build.py +398 -0
- lsurf/cli/config_schema.py +318 -0
- lsurf/cli/gui_cmd.py +76 -0
- lsurf/cli/interactive.py +850 -0
- lsurf/cli/main.py +81 -0
- lsurf/cli/run.py +806 -0
- lsurf/detectors/__init__.py +266 -0
- lsurf/detectors/analysis.py +289 -0
- lsurf/detectors/base.py +284 -0
- lsurf/detectors/constant_size_rings.py +485 -0
- lsurf/detectors/directional.py +45 -0
- lsurf/detectors/extended/__init__.py +73 -0
- lsurf/detectors/extended/local_sphere.py +353 -0
- lsurf/detectors/extended/recording_sphere.py +368 -0
- lsurf/detectors/planar.py +45 -0
- lsurf/detectors/protocol.py +187 -0
- lsurf/detectors/recording_spheres.py +63 -0
- lsurf/detectors/results.py +1140 -0
- lsurf/detectors/small/__init__.py +79 -0
- lsurf/detectors/small/directional.py +330 -0
- lsurf/detectors/small/planar.py +401 -0
- lsurf/detectors/small/spherical.py +450 -0
- lsurf/detectors/spherical.py +45 -0
- lsurf/geometry/__init__.py +199 -0
- lsurf/geometry/builder.py +478 -0
- lsurf/geometry/cell.py +228 -0
- lsurf/geometry/cell_geometry.py +247 -0
- lsurf/geometry/detector_arrays.py +1785 -0
- lsurf/geometry/geometry.py +222 -0
- lsurf/geometry/surface_analysis.py +375 -0
- lsurf/geometry/validation.py +91 -0
- lsurf/gui/__init__.py +51 -0
- lsurf/gui/app.py +903 -0
- lsurf/gui/core/__init__.py +39 -0
- lsurf/gui/core/scene.py +343 -0
- lsurf/gui/core/simulation.py +264 -0
- lsurf/gui/renderers/__init__.py +40 -0
- lsurf/gui/renderers/ray_renderer.py +353 -0
- lsurf/gui/renderers/source_renderer.py +505 -0
- lsurf/gui/renderers/surface_renderer.py +477 -0
- lsurf/gui/views/__init__.py +48 -0
- lsurf/gui/views/config_editor.py +3199 -0
- lsurf/gui/views/properties.py +257 -0
- lsurf/gui/views/results.py +291 -0
- lsurf/gui/views/scene_tree.py +180 -0
- lsurf/gui/views/viewport_3d.py +555 -0
- lsurf/gui/views/visualizations.py +712 -0
- lsurf/materials/__init__.py +169 -0
- lsurf/materials/base/__init__.py +64 -0
- lsurf/materials/base/full_inhomogeneous.py +208 -0
- lsurf/materials/base/grid_inhomogeneous.py +319 -0
- lsurf/materials/base/homogeneous.py +342 -0
- lsurf/materials/base/material_field.py +527 -0
- lsurf/materials/base/simple_inhomogeneous.py +418 -0
- lsurf/materials/base/spectral_inhomogeneous.py +497 -0
- lsurf/materials/implementations/__init__.py +120 -0
- lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
- lsurf/materials/implementations/duct_atmosphere.py +390 -0
- lsurf/materials/implementations/exponential_atmosphere.py +435 -0
- lsurf/materials/implementations/gaussian_lens.py +120 -0
- lsurf/materials/implementations/interpolated_data.py +123 -0
- lsurf/materials/implementations/layered_atmosphere.py +134 -0
- lsurf/materials/implementations/linear_gradient.py +109 -0
- lsurf/materials/implementations/linsley_atmosphere.py +764 -0
- lsurf/materials/implementations/standard_materials.py +126 -0
- lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
- lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
- lsurf/materials/utils/__init__.py +77 -0
- lsurf/materials/utils/constants.py +45 -0
- lsurf/materials/utils/device_functions.py +117 -0
- lsurf/materials/utils/dispersion.py +160 -0
- lsurf/materials/utils/factories.py +142 -0
- lsurf/propagation/__init__.py +91 -0
- lsurf/propagation/detector_gpu.py +67 -0
- lsurf/propagation/gpu_device_rays.py +294 -0
- lsurf/propagation/kernels/__init__.py +175 -0
- lsurf/propagation/kernels/absorption/__init__.py +61 -0
- lsurf/propagation/kernels/absorption/grid.py +240 -0
- lsurf/propagation/kernels/absorption/simple.py +232 -0
- lsurf/propagation/kernels/absorption/spectral.py +410 -0
- lsurf/propagation/kernels/detection/__init__.py +64 -0
- lsurf/propagation/kernels/detection/protocol.py +102 -0
- lsurf/propagation/kernels/detection/spherical.py +255 -0
- lsurf/propagation/kernels/device_functions.py +790 -0
- lsurf/propagation/kernels/fresnel/__init__.py +64 -0
- lsurf/propagation/kernels/fresnel/protocol.py +97 -0
- lsurf/propagation/kernels/fresnel/standard.py +258 -0
- lsurf/propagation/kernels/intersection/__init__.py +79 -0
- lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
- lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
- lsurf/propagation/kernels/intersection/plane.py +166 -0
- lsurf/propagation/kernels/intersection/protocol.py +95 -0
- lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
- lsurf/propagation/kernels/intersection/sphere.py +190 -0
- lsurf/propagation/kernels/propagation/__init__.py +85 -0
- lsurf/propagation/kernels/propagation/grid.py +527 -0
- lsurf/propagation/kernels/propagation/protocol.py +105 -0
- lsurf/propagation/kernels/propagation/simple.py +460 -0
- lsurf/propagation/kernels/propagation/spectral.py +875 -0
- lsurf/propagation/kernels/registry.py +331 -0
- lsurf/propagation/kernels/surface/__init__.py +72 -0
- lsurf/propagation/kernels/surface/bisection.py +232 -0
- lsurf/propagation/kernels/surface/detection.py +402 -0
- lsurf/propagation/kernels/surface/reduction.py +166 -0
- lsurf/propagation/propagator_protocol.py +222 -0
- lsurf/propagation/propagators/__init__.py +101 -0
- lsurf/propagation/propagators/detector_handler.py +354 -0
- lsurf/propagation/propagators/factory.py +200 -0
- lsurf/propagation/propagators/fresnel_handler.py +305 -0
- lsurf/propagation/propagators/gpu_gradient.py +566 -0
- lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
- lsurf/propagation/propagators/gradient.py +429 -0
- lsurf/propagation/propagators/intersection_handler.py +327 -0
- lsurf/propagation/propagators/material_propagator.py +398 -0
- lsurf/propagation/propagators/signed_distance_handler.py +522 -0
- lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
- lsurf/propagation/propagators/surface_interaction.py +616 -0
- lsurf/propagation/propagators/surface_propagator.py +719 -0
- lsurf/py.typed +1 -0
- lsurf/simulation/__init__.py +70 -0
- lsurf/simulation/config.py +164 -0
- lsurf/simulation/orchestrator.py +462 -0
- lsurf/simulation/result.py +299 -0
- lsurf/simulation/simulation.py +262 -0
- lsurf/sources/__init__.py +128 -0
- lsurf/sources/base.py +264 -0
- lsurf/sources/collimated.py +252 -0
- lsurf/sources/custom.py +409 -0
- lsurf/sources/diverging.py +228 -0
- lsurf/sources/gaussian.py +272 -0
- lsurf/sources/parallel_from_positions.py +197 -0
- lsurf/sources/point.py +172 -0
- lsurf/sources/uniform_diverging.py +258 -0
- lsurf/surfaces/__init__.py +184 -0
- lsurf/surfaces/cpu/__init__.py +50 -0
- lsurf/surfaces/cpu/curved_wave.py +463 -0
- lsurf/surfaces/cpu/gerstner_wave.py +381 -0
- lsurf/surfaces/cpu/wave_params.py +118 -0
- lsurf/surfaces/gpu/__init__.py +72 -0
- lsurf/surfaces/gpu/annular_plane.py +453 -0
- lsurf/surfaces/gpu/bounded_plane.py +390 -0
- lsurf/surfaces/gpu/curved_wave.py +483 -0
- lsurf/surfaces/gpu/gerstner_wave.py +377 -0
- lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
- lsurf/surfaces/gpu/plane.py +299 -0
- lsurf/surfaces/gpu/recording_sphere.py +587 -0
- lsurf/surfaces/gpu/sphere.py +311 -0
- lsurf/surfaces/protocol.py +336 -0
- lsurf/surfaces/registry.py +373 -0
- lsurf/utilities/__init__.py +175 -0
- lsurf/utilities/detector_analysis.py +814 -0
- lsurf/utilities/fresnel.py +628 -0
- lsurf/utilities/interactions.py +1215 -0
- lsurf/utilities/propagation.py +602 -0
- lsurf/utilities/ray_data.py +532 -0
- lsurf/utilities/recording_sphere.py +745 -0
- lsurf/utilities/time_spread.py +463 -0
- lsurf/visualization/__init__.py +329 -0
- lsurf/visualization/absorption_plots.py +334 -0
- lsurf/visualization/atmospheric_plots.py +754 -0
- lsurf/visualization/common.py +348 -0
- lsurf/visualization/detector_plots.py +1350 -0
- lsurf/visualization/detector_sphere_plots.py +1173 -0
- lsurf/visualization/fresnel_plots.py +1061 -0
- lsurf/visualization/ocean_simulation_plots.py +999 -0
- lsurf/visualization/polarization_plots.py +916 -0
- lsurf/visualization/raytracing_plots.py +1521 -0
- lsurf/visualization/ring_detector_plots.py +1867 -0
- lsurf/visualization/time_spread_plots.py +531 -0
- lsurf-1.0.0.dist-info/METADATA +381 -0
- lsurf-1.0.0.dist-info/RECORD +180 -0
- lsurf-1.0.0.dist-info/WHEEL +5 -0
- lsurf-1.0.0.dist-info/entry_points.txt +2 -0
- lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
- 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
|