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,628 @@
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
+ Fresnel Equations
36
+
37
+ Computes reflection and transmission coefficients at interfaces between
38
+ different media using Fresnel equations.
39
+
40
+ Functions
41
+ ---------
42
+ fresnel_coefficients
43
+ Compute Fresnel reflection and transmission coefficients
44
+ compute_reflection_direction
45
+ Compute reflected ray direction
46
+ compute_refraction_direction
47
+ Compute refracted ray direction (Snell's law)
48
+
49
+ References
50
+ ----------
51
+ .. [1] Born, M., & Wolf, E. (1999). Principles of Optics (7th ed.).
52
+ Cambridge University Press. Chapter 1.5.
53
+ .. [2] Hecht, E. (2017). Optics (5th ed.). Pearson. Chapter 4.
54
+ """
55
+
56
+ import numpy as np
57
+ from numpy.typing import NDArray
58
+
59
+
60
+ def fresnel_coefficients(
61
+ n1: NDArray[np.float32] | float,
62
+ n2: NDArray[np.float32] | float,
63
+ cos_theta_i: NDArray[np.float32],
64
+ polarization: str = "unpolarized",
65
+ ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
66
+ """
67
+ Compute Fresnel reflection and transmission coefficients.
68
+
69
+ Parameters
70
+ ----------
71
+ n1 : float or ndarray
72
+ Refractive index of incident medium
73
+ n2 : float or ndarray
74
+ Refractive index of transmitted medium
75
+ cos_theta_i : ndarray, shape (N,)
76
+ Cosine of incident angle (dot product of direction and normal)
77
+ polarization : str, optional
78
+ Polarization state: 's', 'p', or 'unpolarized' (default)
79
+
80
+ Returns
81
+ -------
82
+ R : ndarray, shape (N,)
83
+ Reflection coefficient (fraction of intensity reflected)
84
+ T : ndarray, shape (N,)
85
+ Transmission coefficient (fraction of intensity transmitted)
86
+
87
+ Notes
88
+ -----
89
+ For unpolarized light, we average s and p polarizations.
90
+ Total internal reflection occurs when n1 > n2 and angle exceeds critical.
91
+
92
+ The Fresnel equations for intensity (not amplitude) are:
93
+ - s-polarization: R_s = |r_s|², T_s = (n2*cos_theta_t)/(n1*cos_theta_i) * |t_s|²
94
+ - p-polarization: R_p = |r_p|², T_p = (n2*cos_theta_t)/(n1*cos_theta_i) * |t_p|²
95
+
96
+ Examples
97
+ --------
98
+ >>> # Air to glass at 45 degrees
99
+ >>> cos_theta_i = np.cos(np.radians(45))
100
+ >>> R, T = fresnel_coefficients(1.0, 1.5, cos_theta_i)
101
+ >>> print(f"Reflection: {R:.3f}, Transmission: {T:.3f}")
102
+ """
103
+ # Ensure arrays
104
+ cos_theta_i = np.atleast_1d(cos_theta_i).astype(np.float32)
105
+ n1 = np.atleast_1d(n1).astype(np.float32)
106
+ n2 = np.atleast_1d(n2).astype(np.float32)
107
+
108
+ # Broadcast to same shape
109
+ n_ratio = n1 / n2
110
+
111
+ # Compute cos(theta_t) using Snell's law
112
+ # n1*sin(theta_i) = n2*sin(theta_t)
113
+ # sin²(theta_t) = (n1/n2)² * sin²(theta_i)
114
+ # cos²(theta_t) = 1 - sin²(theta_t)
115
+
116
+ sin_theta_i_sq = 1.0 - cos_theta_i**2
117
+ sin_theta_t_sq = (n_ratio**2) * sin_theta_i_sq
118
+
119
+ # Check for total internal reflection
120
+ tir_mask = sin_theta_t_sq > 1.0
121
+
122
+ # Compute cos(theta_t) for non-TIR cases
123
+ cos_theta_t = np.sqrt(np.clip(1.0 - sin_theta_t_sq, 0, 1))
124
+
125
+ # Fresnel equations for amplitude coefficients
126
+ # s-polarization (TE): Electric field perpendicular to plane of incidence
127
+ r_s_num = n1 * cos_theta_i - n2 * cos_theta_t
128
+ r_s_den = n1 * cos_theta_i + n2 * cos_theta_t
129
+ r_s = r_s_num / (r_s_den + 1e-10) # Avoid division by zero
130
+
131
+ # p-polarization (TM): Electric field parallel to plane of incidence
132
+ r_p_num = n2 * cos_theta_i - n1 * cos_theta_t
133
+ r_p_den = n2 * cos_theta_i + n1 * cos_theta_t
134
+ r_p = r_p_num / (r_p_den + 1e-10)
135
+
136
+ # Intensity reflection coefficients
137
+ R_s = r_s**2
138
+ R_p = r_p**2
139
+
140
+ # Handle total internal reflection
141
+ R_s = np.where(tir_mask, 1.0, R_s)
142
+ R_p = np.where(tir_mask, 1.0, R_p)
143
+
144
+ # Combine based on polarization
145
+ if polarization == "s":
146
+ R = R_s
147
+ elif polarization == "p":
148
+ R = R_p
149
+ else: # unpolarized
150
+ R = 0.5 * (R_s + R_p)
151
+
152
+ # Transmission coefficient (energy conservation)
153
+ T = 1.0 - R
154
+
155
+ # Ensure physical bounds
156
+ R = np.clip(R, 0.0, 1.0)
157
+ T = np.clip(T, 0.0, 1.0)
158
+
159
+ return R.astype(np.float32), T.astype(np.float32)
160
+
161
+
162
+ def compute_reflection_direction(
163
+ incident: NDArray[np.float32],
164
+ normal: NDArray[np.float32],
165
+ ) -> NDArray[np.float32]:
166
+ """
167
+ Compute reflected ray direction using law of reflection.
168
+
169
+ Parameters
170
+ ----------
171
+ incident : ndarray, shape (N, 3)
172
+ Incident ray directions (should be normalized)
173
+ normal : ndarray, shape (N, 3)
174
+ Surface normals at intersection points (should be normalized)
175
+
176
+ Returns
177
+ -------
178
+ reflected : ndarray, shape (N, 3)
179
+ Reflected ray directions (normalized)
180
+
181
+ Notes
182
+ -----
183
+ Reflection formula: r = d - 2(d·n)n
184
+ where d is incident direction and n is surface normal.
185
+
186
+ The normal should point toward the incident side.
187
+
188
+ Examples
189
+ --------
190
+ >>> # Reflect ray at 45° off horizontal surface
191
+ >>> incident = np.array([[1/np.sqrt(2), 0, -1/np.sqrt(2)]])
192
+ >>> normal = np.array([[0, 0, 1]])
193
+ >>> reflected = compute_reflection_direction(incident, normal)
194
+ >>> print(reflected) # Should be [1/√2, 0, 1/√2]
195
+ """
196
+ # Compute dot product: incident · normal
197
+ dot_in = np.sum(incident * normal, axis=1, keepdims=True)
198
+
199
+ # Reflection formula: r = d - 2(d·n)n
200
+ reflected = incident - 2.0 * dot_in * normal
201
+
202
+ # Normalize (should already be normalized, but ensure it)
203
+ norms = np.linalg.norm(reflected, axis=1, keepdims=True)
204
+ reflected = reflected / (norms + 1e-10)
205
+
206
+ return reflected.astype(np.float32)
207
+
208
+
209
+ def compute_refraction_direction(
210
+ incident: NDArray[np.float32],
211
+ normal: NDArray[np.float32],
212
+ n1: NDArray[np.float32] | float,
213
+ n2: NDArray[np.float32] | float,
214
+ ) -> tuple[NDArray[np.float32], NDArray[np.bool_]]:
215
+ """
216
+ Compute refracted ray direction using Snell's law.
217
+
218
+ Parameters
219
+ ----------
220
+ incident : ndarray, shape (N, 3)
221
+ Incident ray directions (should be normalized)
222
+ normal : ndarray, shape (N, 3)
223
+ Surface normals at intersection points (should be normalized)
224
+ n1 : float or ndarray
225
+ Refractive index of incident medium
226
+ n2 : float or ndarray
227
+ Refractive index of transmitted medium
228
+
229
+ Returns
230
+ -------
231
+ refracted : ndarray, shape (N, 3)
232
+ Refracted ray directions (normalized)
233
+ For TIR cases, returns zero vector
234
+ tir_mask : ndarray, shape (N,)
235
+ Boolean mask indicating total internal reflection
236
+
237
+ Notes
238
+ -----
239
+ Snell's law: n1*sin(θ1) = n2*sin(θ2)
240
+
241
+ Vector form of Snell's law:
242
+ t = (n1/n2)[d - (d·n)n] - n*sqrt(1 - (n1/n2)²*[1-(d·n)²])
243
+
244
+ Total internal reflection occurs when (n1/n2)²*[1-(d·n)²] > 1
245
+
246
+ Examples
247
+ --------
248
+ >>> # Air to glass at normal incidence
249
+ >>> incident = np.array([[0, 0, -1]])
250
+ >>> normal = np.array([[0, 0, 1]])
251
+ >>> refracted, tir = compute_refraction_direction(incident, normal, 1.0, 1.5)
252
+ >>> print(refracted) # Should be [0, 0, -1] (straight through)
253
+ >>> print(tir) # Should be False
254
+ """
255
+ # Ensure arrays
256
+ n1 = np.atleast_1d(n1).astype(np.float32)
257
+ n2 = np.atleast_1d(n2).astype(np.float32)
258
+
259
+ # Compute cos(theta_i) = -incident · normal
260
+ # (negative because incident and normal point in opposite directions)
261
+ cos_theta_i = -np.sum(incident * normal, axis=1)
262
+
263
+ # Compute n1/n2 ratio
264
+ n_ratio = n1 / n2
265
+
266
+ # Check for total internal reflection
267
+ # sin²(theta_t) = (n1/n2)² * sin²(theta_i) = (n1/n2)² * (1 - cos²(theta_i))
268
+ sin_theta_t_sq = (n_ratio**2) * (1.0 - cos_theta_i**2)
269
+ tir_mask = sin_theta_t_sq > 1.0
270
+
271
+ # Compute cos(theta_t)
272
+ cos_theta_t = np.sqrt(np.clip(1.0 - sin_theta_t_sq, 0, 1))
273
+
274
+ # Vector form of Snell's law
275
+ # t = (n1/n2) * incident + [(n1/n2)*cos(theta_i) - cos(theta_t)] * normal
276
+ refracted = (
277
+ n_ratio[:, np.newaxis] * incident
278
+ + (n_ratio * cos_theta_i - cos_theta_t)[:, np.newaxis] * normal
279
+ )
280
+
281
+ # Set TIR rays to zero vector (they won't be transmitted)
282
+ refracted[tir_mask] = 0.0
283
+
284
+ # Normalize
285
+ norms = np.linalg.norm(refracted, axis=1, keepdims=True)
286
+ refracted = np.where(norms > 1e-10, refracted / norms, 0.0)
287
+
288
+ return refracted.astype(np.float32), tir_mask
289
+
290
+
291
+ def compute_polarization_basis(
292
+ ray_directions: NDArray[np.float32],
293
+ surface_normals: NDArray[np.float32],
294
+ ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
295
+ """
296
+ Compute local s and p polarization basis vectors at surface intersections.
297
+
298
+ At a surface, the plane of incidence contains both the ray direction and
299
+ the surface normal. The polarization basis is:
300
+ - s-polarization (TE): perpendicular to plane of incidence
301
+ - p-polarization (TM): in plane of incidence, perpendicular to ray direction
302
+
303
+ Parameters
304
+ ----------
305
+ ray_directions : ndarray, shape (N, 3)
306
+ Ray direction vectors (should be normalized)
307
+ surface_normals : ndarray, shape (N, 3)
308
+ Surface normal vectors (should be normalized)
309
+
310
+ Returns
311
+ -------
312
+ s_vectors : ndarray, shape (N, 3)
313
+ S-polarization unit vectors (perpendicular to plane of incidence)
314
+ p_vectors : ndarray, shape (N, 3)
315
+ P-polarization unit vectors (in plane of incidence, perpendicular to ray)
316
+
317
+ Notes
318
+ -----
319
+ The s-vector is computed as: ŝ = (d × n) / |d × n|
320
+ The p-vector is computed as: p̂ = (d × ŝ) (perpendicular to both ray and s)
321
+
322
+ For rays at normal incidence (d parallel to n), we use a default reference
323
+ direction to define the basis.
324
+ """
325
+ # Compute s-vector: perpendicular to plane of incidence
326
+ # s = ray_direction × normal
327
+ s_vectors = np.cross(ray_directions, surface_normals)
328
+ s_norms = np.linalg.norm(s_vectors, axis=1, keepdims=True)
329
+
330
+ # Handle degenerate case (normal incidence: ray parallel to normal)
331
+ # Use a default reference direction (global Y or X)
332
+ degenerate_mask = s_norms.squeeze() < 1e-6
333
+
334
+ if np.any(degenerate_mask):
335
+ # For degenerate cases, pick a perpendicular direction
336
+ # Try Y axis first, if parallel to ray, use X
337
+ y_axis = np.array([0, 1, 0], dtype=np.float32)
338
+ x_axis = np.array([1, 0, 0], dtype=np.float32)
339
+
340
+ for i in np.where(degenerate_mask)[0]:
341
+ # Check if Y axis is parallel to ray direction
342
+ if abs(np.dot(ray_directions[i], y_axis)) > 0.99:
343
+ s_vectors[i] = x_axis
344
+ else:
345
+ s_vectors[i] = np.cross(ray_directions[i], y_axis)
346
+ # Recompute norms for degenerate cases
347
+ s_norms[degenerate_mask] = np.linalg.norm(
348
+ s_vectors[degenerate_mask], axis=1, keepdims=True
349
+ )
350
+
351
+ # Normalize s-vectors
352
+ s_vectors = s_vectors / np.maximum(s_norms, 1e-10)
353
+
354
+ # Compute p-vector: perpendicular to ray direction, in plane of incidence
355
+ # p = ray_direction × s_vector
356
+ p_vectors = np.cross(ray_directions, s_vectors)
357
+
358
+ # Normalize p-vectors
359
+ p_norms = np.linalg.norm(p_vectors, axis=1, keepdims=True)
360
+ p_vectors = p_vectors / np.maximum(p_norms, 1e-10)
361
+
362
+ return s_vectors.astype(np.float32), p_vectors.astype(np.float32)
363
+
364
+
365
+ def transform_polarization_reflection(
366
+ polarization_vectors: NDArray[np.float32],
367
+ incident_directions: NDArray[np.float32],
368
+ reflected_directions: NDArray[np.float32],
369
+ surface_normals: NDArray[np.float32],
370
+ R_s: NDArray[np.float32] | None = None,
371
+ R_p: NDArray[np.float32] | None = None,
372
+ ) -> NDArray[np.float32]:
373
+ """
374
+ Transform polarization vectors through reflection with optional Fresnel weighting.
375
+
376
+ Upon reflection, the s-polarization component (perpendicular to plane of
377
+ incidence) maintains its direction, while the p-polarization component
378
+ (in plane of incidence) has its component along the propagation reversed.
379
+
380
+ When R_s and R_p are provided, the electric field amplitudes are weighted
381
+ by sqrt(R_s) and sqrt(R_p) respectively, causing unpolarized light to
382
+ become partially polarized after reflection (more s-polarized since R_s > R_p
383
+ for most angles).
384
+
385
+ Parameters
386
+ ----------
387
+ polarization_vectors : ndarray, shape (N, 3)
388
+ Input polarization vectors (E-field direction, unit vectors)
389
+ incident_directions : ndarray, shape (N, 3)
390
+ Incident ray directions
391
+ reflected_directions : ndarray, shape (N, 3)
392
+ Reflected ray directions
393
+ surface_normals : ndarray, shape (N, 3)
394
+ Surface normal vectors
395
+ R_s : ndarray, shape (N,), optional
396
+ Fresnel reflectance for s-polarization. If provided with R_p,
397
+ applies amplitude weighting sqrt(R_s) to s-component.
398
+ R_p : ndarray, shape (N,), optional
399
+ Fresnel reflectance for p-polarization. If provided with R_s,
400
+ applies amplitude weighting sqrt(R_p) to p-component.
401
+
402
+ Returns
403
+ -------
404
+ reflected_polarization : ndarray, shape (N, 3)
405
+ Polarization vectors after reflection (normalized)
406
+
407
+ Notes
408
+ -----
409
+ The Fresnel weighting works on electric field amplitude:
410
+ - E_s_out = sqrt(R_s) * E_s_in
411
+ - E_p_out = sqrt(R_p) * E_p_in
412
+
413
+ Since intensity I ∝ |E|², this means:
414
+ - I_s_out = R_s * I_s_in
415
+ - I_p_out = R_p * I_p_in
416
+
417
+ For unpolarized light (random E direction), after reflection the light
418
+ becomes partially s-polarized because R_s > R_p (except at normal incidence).
419
+ """
420
+ # Compute incident s and p basis
421
+ s_inc, p_inc = compute_polarization_basis(incident_directions, surface_normals)
422
+
423
+ # Project input polarization onto s and p components
424
+ E_s = np.sum(polarization_vectors * s_inc, axis=1, keepdims=True)
425
+ E_p = np.sum(polarization_vectors * p_inc, axis=1, keepdims=True)
426
+
427
+ # Apply Fresnel weighting to electric field amplitudes if provided
428
+ if R_s is not None and R_p is not None:
429
+ # Weight by sqrt(R) since E amplitude, not intensity
430
+ E_s = E_s * np.sqrt(R_s[:, np.newaxis])
431
+ E_p = E_p * np.sqrt(R_p[:, np.newaxis])
432
+
433
+ # For reflection:
434
+ # - s-component: same direction (perpendicular to plane of incidence stays same)
435
+ # - p-component: direction changes because ray direction changes
436
+ # Compute reflected s and p basis
437
+ s_refl, p_refl = compute_polarization_basis(reflected_directions, surface_normals)
438
+
439
+ # Reconstruct polarization in reflected basis
440
+ # The s-component direction is preserved
441
+ # The p-component gets a sign flip (the component of E parallel to the
442
+ # interface stays, the perpendicular component flips)
443
+ reflected_polarization = E_s * s_refl + E_p * p_refl
444
+
445
+ # Normalize
446
+ norms = np.linalg.norm(reflected_polarization, axis=1, keepdims=True)
447
+ reflected_polarization = reflected_polarization / np.maximum(norms, 1e-10)
448
+
449
+ return reflected_polarization.astype(np.float32)
450
+
451
+
452
+ def transform_polarization_refraction(
453
+ polarization_vectors: NDArray[np.float32],
454
+ incident_directions: NDArray[np.float32],
455
+ refracted_directions: NDArray[np.float32],
456
+ surface_normals: NDArray[np.float32],
457
+ ) -> NDArray[np.float32]:
458
+ """
459
+ Transform polarization vectors through refraction.
460
+
461
+ Upon refraction, the s-polarization component stays perpendicular to the
462
+ plane of incidence, and the p-polarization component stays in the plane.
463
+ The basis vectors change because the ray direction changes.
464
+
465
+ Parameters
466
+ ----------
467
+ polarization_vectors : ndarray, shape (N, 3)
468
+ Input polarization vectors (E-field direction, unit vectors)
469
+ incident_directions : ndarray, shape (N, 3)
470
+ Incident ray directions
471
+ refracted_directions : ndarray, shape (N, 3)
472
+ Refracted ray directions
473
+ surface_normals : ndarray, shape (N, 3)
474
+ Surface normal vectors
475
+
476
+ Returns
477
+ -------
478
+ refracted_polarization : ndarray, shape (N, 3)
479
+ Polarization vectors after refraction (normalized)
480
+ """
481
+ # Compute incident s and p basis
482
+ s_inc, p_inc = compute_polarization_basis(incident_directions, surface_normals)
483
+
484
+ # Project input polarization onto s and p components
485
+ E_s = np.sum(polarization_vectors * s_inc, axis=1, keepdims=True)
486
+ E_p = np.sum(polarization_vectors * p_inc, axis=1, keepdims=True)
487
+
488
+ # Compute refracted s and p basis
489
+ s_refr, p_refr = compute_polarization_basis(refracted_directions, surface_normals)
490
+
491
+ # Reconstruct polarization in refracted basis
492
+ # Both components maintain their character (s stays s, p stays p)
493
+ refracted_polarization = E_s * s_refr + E_p * p_refr
494
+
495
+ # Normalize
496
+ norms = np.linalg.norm(refracted_polarization, axis=1, keepdims=True)
497
+ refracted_polarization = refracted_polarization / np.maximum(norms, 1e-10)
498
+
499
+ return refracted_polarization.astype(np.float32)
500
+
501
+
502
+ def initialize_polarization_vectors(
503
+ ray_directions: NDArray[np.float32],
504
+ polarization: str = "unpolarized",
505
+ reference_direction: NDArray[np.float32] = None,
506
+ ) -> NDArray[np.float32]:
507
+ """
508
+ Initialize polarization vectors for rays.
509
+
510
+ Parameters
511
+ ----------
512
+ ray_directions : ndarray, shape (N, 3)
513
+ Ray direction vectors (should be normalized)
514
+ polarization : str, optional
515
+ Initial polarization state:
516
+ - 'unpolarized' or 'random': random polarization perpendicular to ray
517
+ - 's' or 'horizontal': horizontal polarization (perpendicular to vertical plane)
518
+ - 'p' or 'vertical': vertical polarization (in vertical plane)
519
+ - 'custom': use reference_direction projected onto plane perpendicular to ray
520
+ reference_direction : ndarray, shape (3,), optional
521
+ Reference direction for 'custom' polarization. Will be projected onto
522
+ the plane perpendicular to each ray.
523
+
524
+ Returns
525
+ -------
526
+ polarization_vectors : ndarray, shape (N, 3)
527
+ Initial polarization vectors (unit vectors perpendicular to ray directions)
528
+ """
529
+ n_rays = len(ray_directions)
530
+
531
+ if polarization in ["s", "horizontal"]:
532
+ # S-polarization: perpendicular to vertical (Z-containing) plane
533
+ # Use global Z as reference to define "horizontal"
534
+ z_axis = np.array([0, 0, 1], dtype=np.float32)
535
+
536
+ # s = ray × Z (horizontal direction perpendicular to ray)
537
+ pol_vectors = np.cross(ray_directions, z_axis)
538
+ norms = np.linalg.norm(pol_vectors, axis=1, keepdims=True)
539
+
540
+ # Handle rays parallel to Z
541
+ parallel_mask = norms.squeeze() < 1e-6
542
+ if np.any(parallel_mask):
543
+ # For vertical rays, use X as horizontal direction
544
+ pol_vectors[parallel_mask] = np.array([1, 0, 0], dtype=np.float32)
545
+ norms[parallel_mask] = 1.0
546
+
547
+ pol_vectors = pol_vectors / np.maximum(norms, 1e-10)
548
+
549
+ elif polarization in ["p", "vertical"]:
550
+ # P-polarization: in vertical plane containing the ray
551
+ # First get horizontal direction, then p = ray × horizontal
552
+ z_axis = np.array([0, 0, 1], dtype=np.float32)
553
+ horizontal = np.cross(ray_directions, z_axis)
554
+ h_norms = np.linalg.norm(horizontal, axis=1, keepdims=True)
555
+
556
+ # Handle rays parallel to Z
557
+ parallel_mask = h_norms.squeeze() < 1e-6
558
+ if np.any(parallel_mask):
559
+ horizontal[parallel_mask] = np.array([1, 0, 0], dtype=np.float32)
560
+ h_norms[parallel_mask] = 1.0
561
+
562
+ horizontal = horizontal / np.maximum(h_norms, 1e-10)
563
+
564
+ # p = ray × horizontal (vertical component perpendicular to ray)
565
+ pol_vectors = np.cross(ray_directions, horizontal)
566
+ norms = np.linalg.norm(pol_vectors, axis=1, keepdims=True)
567
+ pol_vectors = pol_vectors / np.maximum(norms, 1e-10)
568
+
569
+ elif polarization == "custom" and reference_direction is not None:
570
+ # Project reference direction onto plane perpendicular to each ray
571
+ ref = np.array(reference_direction, dtype=np.float32)
572
+ ref = ref / np.linalg.norm(ref)
573
+
574
+ # For each ray, project ref onto plane perpendicular to ray
575
+ # proj = ref - (ref · ray) * ray
576
+ dots = np.sum(ray_directions * ref, axis=1, keepdims=True)
577
+ pol_vectors = ref - dots * ray_directions
578
+ norms = np.linalg.norm(pol_vectors, axis=1, keepdims=True)
579
+
580
+ # Handle rays parallel to reference direction
581
+ parallel_mask = norms.squeeze() < 1e-6
582
+ if np.any(parallel_mask):
583
+ # Fall back to arbitrary perpendicular
584
+ y_axis = np.array([0, 1, 0], dtype=np.float32)
585
+ fallback = np.cross(ray_directions[parallel_mask], y_axis)
586
+ fallback_norms = np.linalg.norm(fallback, axis=1, keepdims=True)
587
+ # If still degenerate, use X
588
+ still_degen = fallback_norms.squeeze() < 1e-6
589
+ if np.any(still_degen):
590
+ x_axis = np.array([1, 0, 0], dtype=np.float32)
591
+ fallback[still_degen] = np.cross(
592
+ ray_directions[parallel_mask][still_degen], x_axis
593
+ )
594
+ fallback_norms[still_degen] = np.linalg.norm(
595
+ fallback[still_degen], axis=1, keepdims=True
596
+ )
597
+ pol_vectors[parallel_mask] = fallback / np.maximum(fallback_norms, 1e-10)
598
+ norms[parallel_mask] = 1.0
599
+
600
+ pol_vectors = pol_vectors / np.maximum(norms, 1e-10)
601
+
602
+ else: # unpolarized or random
603
+ # Generate random polarization perpendicular to each ray
604
+ # First generate random vectors
605
+ rng = np.random.default_rng()
606
+ random_vecs = rng.standard_normal((n_rays, 3)).astype(np.float32)
607
+
608
+ # Project onto plane perpendicular to ray
609
+ dots = np.sum(ray_directions * random_vecs, axis=1, keepdims=True)
610
+ pol_vectors = random_vecs - dots * ray_directions
611
+ norms = np.linalg.norm(pol_vectors, axis=1, keepdims=True)
612
+
613
+ # Handle any degenerate cases
614
+ degen_mask = norms.squeeze() < 1e-6
615
+ if np.any(degen_mask):
616
+ # Try again with different random vectors
617
+ new_random = rng.standard_normal((np.sum(degen_mask), 3)).astype(np.float32)
618
+ dots_new = np.sum(
619
+ ray_directions[degen_mask] * new_random, axis=1, keepdims=True
620
+ )
621
+ pol_vectors[degen_mask] = new_random - dots_new * ray_directions[degen_mask]
622
+ norms[degen_mask] = np.linalg.norm(
623
+ pol_vectors[degen_mask], axis=1, keepdims=True
624
+ )
625
+
626
+ pol_vectors = pol_vectors / np.maximum(norms, 1e-10)
627
+
628
+ return pol_vectors.astype(np.float32)