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,616 @@
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
+ Surface Interaction Processor
36
+
37
+ Handles surface interaction physics based on surface role:
38
+ - DETECTOR: Record ray data, terminate ray
39
+ - ABSORBER: Terminate ray
40
+ - OPTICAL: Compute Fresnel coefficients, generate reflected/refracted rays
41
+
42
+ This is the second component in the propagator architecture:
43
+ MaterialPropagator -> SurfaceInteractionProcessor -> SimulationOrchestrator
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from dataclasses import dataclass, field
49
+ from typing import TYPE_CHECKING
50
+
51
+ import numpy as np
52
+ import numpy.typing as npt
53
+
54
+ from ...geometry.cell_geometry import CellGeometry
55
+ from ...surfaces import Surface, SurfaceRole
56
+ from ...utilities.fresnel import (
57
+ compute_reflection_direction,
58
+ compute_refraction_direction,
59
+ fresnel_coefficients,
60
+ initialize_polarization_vectors,
61
+ transform_polarization_reflection,
62
+ transform_polarization_refraction,
63
+ )
64
+ from ...utilities.ray_data import RayBatch, create_ray_batch, merge_ray_batches
65
+
66
+ if TYPE_CHECKING:
67
+ from ...geometry import Geometry
68
+ from ...materials import MaterialField
69
+ from ...utilities.recording_sphere import RecordedRays
70
+ from .surface_propagator import HitData
71
+
72
+ # Speed of light
73
+ SPEED_OF_LIGHT = 299792458.0
74
+
75
+
76
+ @dataclass
77
+ class SurfaceCrossing:
78
+ """
79
+ Information about rays crossing a specific surface.
80
+
81
+ Attributes
82
+ ----------
83
+ surface_idx : int
84
+ Index of the surface in the geometry.
85
+ surface : Surface
86
+ The surface object.
87
+ ray_indices : ndarray
88
+ Original indices of rays that hit this surface.
89
+ hit_positions : ndarray, shape (M, 3)
90
+ Intersection positions.
91
+ hit_directions : ndarray, shape (M, 3)
92
+ Ray directions at intersection.
93
+ hit_normals : ndarray, shape (M, 3)
94
+ Surface normals at intersection points.
95
+ from_front : ndarray, shape (M,)
96
+ Whether rays hit from the front side (normal direction).
97
+ """
98
+
99
+ surface_idx: int
100
+ surface: Surface
101
+ ray_indices: npt.NDArray[np.int32]
102
+ hit_positions: npt.NDArray[np.float32]
103
+ hit_directions: npt.NDArray[np.float32]
104
+ hit_normals: npt.NDArray[np.float32]
105
+ from_front: npt.NDArray[np.bool_]
106
+
107
+ @property
108
+ def num_hits(self) -> int:
109
+ """Number of rays that hit this surface."""
110
+ return len(self.ray_indices)
111
+
112
+
113
+ @dataclass
114
+ class OpticalSurfaceHit:
115
+ """
116
+ Record of hits on an optical surface.
117
+
118
+ Attributes
119
+ ----------
120
+ surface_name : str
121
+ Name of the surface.
122
+ positions : ndarray, shape (N, 3)
123
+ Hit positions.
124
+ directions : ndarray, shape (N, 3)
125
+ Ray directions at hit.
126
+ intensities : ndarray, shape (N,)
127
+ Ray intensities.
128
+ wavelengths : ndarray, shape (N,)
129
+ Ray wavelengths.
130
+ """
131
+
132
+ surface_name: str
133
+ positions: npt.NDArray[np.float32]
134
+ directions: npt.NDArray[np.float32]
135
+ intensities: npt.NDArray[np.float32]
136
+ wavelengths: npt.NDArray[np.float32]
137
+
138
+
139
+ @dataclass
140
+ class SurfaceInteractionResult:
141
+ """
142
+ Result from processing surface interactions.
143
+
144
+ Attributes
145
+ ----------
146
+ continuing_rays : RayBatch or None
147
+ Rays that should continue propagating (from optical surfaces).
148
+ reflected_rays : RayBatch or None
149
+ Reflected rays from optical surfaces.
150
+ refracted_rays : RayBatch or None
151
+ Refracted rays from optical surfaces.
152
+ detector_hits : dict
153
+ Mapping from detector name to RecordedRays.
154
+ absorbed_count : int
155
+ Number of rays absorbed.
156
+ detected_count : int
157
+ Number of rays detected.
158
+ optical_hits : list of OpticalSurfaceHit
159
+ If track_surface_hits was enabled, contains hit data for optical surfaces.
160
+ """
161
+
162
+ continuing_rays: RayBatch | None = None
163
+ reflected_rays: RayBatch | None = None
164
+ refracted_rays: RayBatch | None = None
165
+ detector_hits: dict = field(default_factory=dict)
166
+ absorbed_count: int = 0
167
+ detected_count: int = 0
168
+ optical_hits: list[OpticalSurfaceHit] = field(default_factory=list)
169
+
170
+
171
+ class SurfaceInteractionProcessor:
172
+ """
173
+ Processes ray-surface interactions based on surface role.
174
+
175
+ For each surface type:
176
+ - DETECTOR: Record ray data, terminate ray
177
+ - ABSORBER: Terminate ray
178
+ - OPTICAL: Compute Fresnel coefficients, generate reflected/refracted rays
179
+
180
+ Parameters
181
+ ----------
182
+ surfaces : list of Surface
183
+ List of surfaces in the geometry.
184
+ polarization : str, optional
185
+ Polarization state for Fresnel calculations: 's', 'p', or 'unpolarized'.
186
+ Default is 'unpolarized'.
187
+ track_polarization_vector : bool, optional
188
+ Whether to track 3D polarization vectors through interactions.
189
+ Default is False.
190
+
191
+ Examples
192
+ --------
193
+ >>> from lsurf.surfaces import PlaneSurface, SurfaceRole
194
+ >>> detector = PlaneSurface(
195
+ ... point=(0, 0, 1000),
196
+ ... normal=(0, 0, 1),
197
+ ... role=SurfaceRole.DETECTOR,
198
+ ... )
199
+ >>> processor = SurfaceInteractionProcessor([detector])
200
+ >>> result = processor.process_hits(rays, hit_data)
201
+ """
202
+
203
+ def __init__(
204
+ self,
205
+ surfaces: list[Surface],
206
+ polarization: str = "unpolarized",
207
+ track_polarization_vector: bool = False,
208
+ geometry: "Geometry | None" = None,
209
+ ):
210
+ self._surfaces = list(surfaces)
211
+ self._polarization = polarization
212
+ self._track_polarization_vector = track_polarization_vector
213
+ self._geometry = geometry
214
+ self._is_cell_geometry = isinstance(geometry, CellGeometry)
215
+
216
+ # Build indices by role
217
+ self._detector_indices: list[int] = []
218
+ self._optical_indices: list[int] = []
219
+ self._absorber_indices: list[int] = []
220
+
221
+ for i, surface in enumerate(surfaces):
222
+ if surface.role == SurfaceRole.DETECTOR:
223
+ self._detector_indices.append(i)
224
+ elif surface.role == SurfaceRole.OPTICAL:
225
+ self._optical_indices.append(i)
226
+ elif surface.role == SurfaceRole.ABSORBER:
227
+ self._absorber_indices.append(i)
228
+
229
+ @property
230
+ def surfaces(self) -> list[Surface]:
231
+ """List of surfaces."""
232
+ return self._surfaces
233
+
234
+ @property
235
+ def polarization(self) -> str:
236
+ """Polarization state for Fresnel calculations."""
237
+ return self._polarization
238
+
239
+ @property
240
+ def num_detectors(self) -> int:
241
+ """Number of detector surfaces."""
242
+ return len(self._detector_indices)
243
+
244
+ @property
245
+ def num_optical(self) -> int:
246
+ """Number of optical surfaces."""
247
+ return len(self._optical_indices)
248
+
249
+ def process_hits(
250
+ self,
251
+ rays: RayBatch,
252
+ hit_data: "HitData",
253
+ track_surface_hits: bool = False,
254
+ ) -> SurfaceInteractionResult:
255
+ """
256
+ Process all surface hits from a propagation leg.
257
+
258
+ Parameters
259
+ ----------
260
+ rays : RayBatch
261
+ Original ray batch (before hit_data was recorded).
262
+ hit_data : HitData
263
+ Hit information from MaterialPropagator.
264
+ track_surface_hits : bool, optional
265
+ If True, store hit positions for optical surfaces in result.optical_hits.
266
+ Default is False.
267
+
268
+ Returns
269
+ -------
270
+ SurfaceInteractionResult
271
+ Result containing detector hits, reflected/refracted rays, etc.
272
+ """
273
+
274
+ result = SurfaceInteractionResult()
275
+ all_reflected: list[RayBatch] = []
276
+ all_refracted: list[RayBatch] = []
277
+
278
+ # Group hits by surface
279
+ for surface_idx, surface in enumerate(self._surfaces):
280
+ mask = hit_data.hit_surface_idx == surface_idx
281
+ if not np.any(mask):
282
+ continue
283
+
284
+ # Extract hit data for this surface
285
+ crossing = self._extract_crossing(
286
+ rays, hit_data, surface_idx, surface, mask
287
+ )
288
+
289
+ # Process based on role
290
+ if surface.role == SurfaceRole.DETECTOR:
291
+ recorded = self._process_detector_hit(rays, crossing)
292
+ surface_name = surface.name or f"detector_{surface_idx}"
293
+ result.detector_hits[surface_name] = recorded
294
+ result.detected_count += crossing.num_hits
295
+
296
+ elif surface.role == SurfaceRole.ABSORBER:
297
+ result.absorbed_count += crossing.num_hits
298
+
299
+ elif surface.role == SurfaceRole.OPTICAL:
300
+ # Store hit data if tracking enabled
301
+ if track_surface_hits:
302
+ surface_name = surface.name or f"optical_{surface_idx}"
303
+ result.optical_hits.append(
304
+ OpticalSurfaceHit(
305
+ surface_name=surface_name,
306
+ positions=crossing.hit_positions.copy(),
307
+ directions=crossing.hit_directions.copy(),
308
+ intensities=rays.intensities[crossing.ray_indices].copy(),
309
+ wavelengths=rays.wavelengths[crossing.ray_indices].copy(),
310
+ )
311
+ )
312
+
313
+ reflected, refracted = self._process_optical_hit(rays, crossing)
314
+ if reflected is not None and reflected.num_rays > 0:
315
+ all_reflected.append(reflected)
316
+ if refracted is not None and refracted.num_rays > 0:
317
+ all_refracted.append(refracted)
318
+
319
+ # Merge all reflected/refracted rays
320
+ if all_reflected:
321
+ result.reflected_rays = merge_ray_batches(all_reflected)
322
+ if all_refracted:
323
+ result.refracted_rays = merge_ray_batches(all_refracted)
324
+
325
+ return result
326
+
327
+ def _extract_crossing(
328
+ self,
329
+ rays: RayBatch,
330
+ hit_data: "HitData",
331
+ surface_idx: int,
332
+ surface: Surface,
333
+ mask: npt.NDArray[np.bool_],
334
+ ) -> SurfaceCrossing:
335
+ """Extract crossing data for a specific surface."""
336
+ ray_indices = np.where(mask)[0].astype(np.int32)
337
+ hit_positions = hit_data.hit_positions[mask]
338
+ hit_directions = hit_data.hit_directions[mask]
339
+
340
+ # Compute surface normals at hit positions
341
+ hit_normals = surface.normal_at(hit_positions, hit_directions)
342
+
343
+ # Determine if hits are from front (ray going opposite to normal)
344
+ # cos(angle) = -direction . normal
345
+ cos_angle = -np.sum(hit_directions * hit_normals, axis=1)
346
+ from_front = cos_angle > 0
347
+
348
+ # Flip normals for back-side hits to always face the ray
349
+ hit_normals[~from_front] = -hit_normals[~from_front]
350
+
351
+ return SurfaceCrossing(
352
+ surface_idx=surface_idx,
353
+ surface=surface,
354
+ ray_indices=ray_indices,
355
+ hit_positions=hit_positions,
356
+ hit_directions=hit_directions,
357
+ hit_normals=hit_normals,
358
+ from_front=from_front,
359
+ )
360
+
361
+ def _get_materials_at_crossing(
362
+ self,
363
+ crossing: SurfaceCrossing,
364
+ ) -> tuple["MaterialField | None", "MaterialField | None"]:
365
+ """
366
+ Get materials for a surface crossing.
367
+
368
+ For standard Geometry, returns (surface.material_front, surface.material_back).
369
+ For CellGeometry, queries cells to determine materials at hit positions.
370
+
371
+ Parameters
372
+ ----------
373
+ crossing : SurfaceCrossing
374
+ The surface crossing information.
375
+
376
+ Returns
377
+ -------
378
+ tuple[MaterialField | None, MaterialField | None]
379
+ (material_front, material_back) for this crossing.
380
+ """
381
+ surface = crossing.surface
382
+
383
+ if not self._is_cell_geometry or self._geometry is None:
384
+ # Standard geometry: use surface's front/back materials
385
+ return surface.material_front, surface.material_back
386
+
387
+ # CellGeometry: query cells for materials
388
+ # For cell geometry, we need to determine material on each side
389
+ # by checking which cell each side belongs to
390
+ cell_geom: CellGeometry = self._geometry # type: ignore
391
+
392
+ # Get a representative position slightly on each side of the surface
393
+ # Use the first hit position and offset by a small amount in normal direction
394
+ if crossing.num_hits == 0:
395
+ return None, None
396
+
397
+ # Use hit normals (which are oriented toward the ray)
398
+ # For front material: offset in normal direction (toward where ray came from)
399
+ # For back material: offset opposite to normal (where ray is going)
400
+ sample_pos = crossing.hit_positions[0:1].astype(np.float64)
401
+ sample_normal = crossing.hit_normals[0:1].astype(np.float64)
402
+
403
+ # Offset to check material on each side
404
+ offset = 0.001 # Small offset
405
+ front_pos = sample_pos + offset * sample_normal
406
+ back_pos = sample_pos - offset * sample_normal
407
+
408
+ # Query cell geometry for materials
409
+ front_results = cell_geom.get_material_at(front_pos)
410
+ back_results = cell_geom.get_material_at(back_pos)
411
+
412
+ # Extract materials (first result that covers this position)
413
+ mat_front = None
414
+ mat_back = None
415
+
416
+ for material, mask in front_results:
417
+ if mask[0]:
418
+ mat_front = material
419
+ break
420
+
421
+ for material, mask in back_results:
422
+ if mask[0]:
423
+ mat_back = material
424
+ break
425
+
426
+ return mat_front, mat_back
427
+
428
+ def _process_detector_hit(
429
+ self,
430
+ rays: RayBatch,
431
+ crossing: SurfaceCrossing,
432
+ ) -> "RecordedRays":
433
+ """Process hits on a detector surface."""
434
+ from ...utilities.recording_sphere import RecordedRays
435
+
436
+ idx = crossing.ray_indices
437
+
438
+ # Get polarization vectors if available
439
+ polarization_vectors = None
440
+ if self._track_polarization_vector and rays.polarization_vector is not None:
441
+ polarization_vectors = rays.polarization_vector[idx]
442
+
443
+ return RecordedRays(
444
+ positions=crossing.hit_positions.copy(),
445
+ directions=crossing.hit_directions.copy(),
446
+ times=rays.accumulated_time[idx].copy(),
447
+ intensities=rays.intensities[idx].copy(),
448
+ wavelengths=rays.wavelengths[idx].copy(),
449
+ generations=rays.generations[idx].copy(),
450
+ polarization_vectors=polarization_vectors,
451
+ ray_indices=idx, # Track original ray indices for correct mapping
452
+ )
453
+
454
+ def _process_optical_hit(
455
+ self,
456
+ rays: RayBatch,
457
+ crossing: SurfaceCrossing,
458
+ ) -> tuple[RayBatch | None, RayBatch | None]:
459
+ """
460
+ Process hits on an optical surface.
461
+
462
+ Computes Fresnel coefficients and generates reflected/refracted rays.
463
+ """
464
+ idx = crossing.ray_indices
465
+ positions = crossing.hit_positions
466
+ directions = crossing.hit_directions
467
+ normals = crossing.hit_normals
468
+ from_front = crossing.from_front
469
+ num_hits = crossing.num_hits
470
+
471
+ if num_hits == 0:
472
+ return None, None
473
+
474
+ # Get ray properties
475
+ wavelengths = rays.wavelengths[idx]
476
+ intensities = rays.intensities[idx]
477
+ accumulated_time = rays.accumulated_time[idx]
478
+ generations = rays.generations[idx]
479
+
480
+ # Handle polarization vectors
481
+ polarization_vectors = None
482
+ if self._track_polarization_vector:
483
+ if rays.polarization_vector is not None:
484
+ polarization_vectors = rays.polarization_vector[idx]
485
+ else:
486
+ polarization_vectors = initialize_polarization_vectors(
487
+ directions, polarization=self._polarization
488
+ )
489
+
490
+ # Get materials based on hit direction
491
+ # from_front: light coming from front material
492
+ # from_back: light coming from back material
493
+ mat_front, mat_back = self._get_materials_at_crossing(crossing)
494
+
495
+ if mat_front is None or mat_back is None:
496
+ # Missing materials, treat as pass-through
497
+ return None, None
498
+
499
+ # Compute refractive indices
500
+ # n1 = material ray is coming FROM
501
+ # n2 = material ray is going TO
502
+ n1_values = np.empty(num_hits, dtype=np.float32)
503
+ n2_values = np.empty(num_hits, dtype=np.float32)
504
+
505
+ for i in range(num_hits):
506
+ pos = positions[i]
507
+ wl = wavelengths[i]
508
+ if from_front[i]:
509
+ n1_values[i] = mat_front.get_refractive_index(
510
+ pos[0], pos[1], pos[2], wl
511
+ )
512
+ n2_values[i] = mat_back.get_refractive_index(pos[0], pos[1], pos[2], wl)
513
+ else:
514
+ n1_values[i] = mat_back.get_refractive_index(pos[0], pos[1], pos[2], wl)
515
+ n2_values[i] = mat_front.get_refractive_index(
516
+ pos[0], pos[1], pos[2], wl
517
+ )
518
+
519
+ # Compute incident angle cosine
520
+ cos_theta_i = np.abs(np.sum(directions * normals, axis=1))
521
+
522
+ # Compute Fresnel coefficients
523
+ R, T = fresnel_coefficients(
524
+ n1_values, n2_values, cos_theta_i, self._polarization
525
+ )
526
+
527
+ # Get separate R_s, R_p if tracking polarization
528
+ R_s = R_p = None
529
+ if self._track_polarization_vector:
530
+ R_s, _ = fresnel_coefficients(n1_values, n2_values, cos_theta_i, "s")
531
+ R_p, _ = fresnel_coefficients(n1_values, n2_values, cos_theta_i, "p")
532
+
533
+ # Compute reflected directions
534
+ reflected_directions = compute_reflection_direction(directions, normals)
535
+ reflected_intensities = intensities * R
536
+
537
+ # Compute refracted directions
538
+ refracted_directions, tir_mask = compute_refraction_direction(
539
+ directions, normals, n1_values, n2_values
540
+ )
541
+ refracted_intensities = intensities * T
542
+ refracted_intensities[tir_mask] = 0.0
543
+
544
+ # Create reflected ray batch
545
+ reflected_rays = create_ray_batch(
546
+ num_rays=num_hits,
547
+ enable_polarization_vector=self._track_polarization_vector,
548
+ )
549
+ # Offset slightly to avoid re-intersection
550
+ reflected_rays.positions[:] = positions + 0.01 * reflected_directions
551
+ reflected_rays.directions[:] = reflected_directions
552
+ reflected_rays.wavelengths[:] = wavelengths
553
+ reflected_rays.intensities[:] = reflected_intensities
554
+ reflected_rays.optical_path_lengths[:] = rays.optical_path_lengths[idx]
555
+ reflected_rays.geometric_path_lengths[:] = rays.geometric_path_lengths[idx]
556
+ reflected_rays.accumulated_time[:] = accumulated_time
557
+ reflected_rays.generations[:] = generations + 1
558
+ reflected_rays.domain_ids[:] = rays.domain_ids[idx]
559
+ reflected_rays.active[:] = reflected_intensities > 1e-10
560
+
561
+ # Transform polarization vectors for reflection
562
+ if self._track_polarization_vector and polarization_vectors is not None:
563
+ reflected_pol = transform_polarization_reflection(
564
+ polarization_vectors,
565
+ directions,
566
+ reflected_directions,
567
+ normals,
568
+ R_s=R_s,
569
+ R_p=R_p,
570
+ )
571
+ reflected_rays.polarization_vector[:] = reflected_pol
572
+
573
+ # Create refracted ray batch
574
+ refracted_rays = create_ray_batch(
575
+ num_rays=num_hits,
576
+ enable_polarization_vector=self._track_polarization_vector,
577
+ )
578
+ # Offset slightly to avoid re-intersection
579
+ refracted_rays.positions[:] = positions + 0.01 * refracted_directions
580
+ refracted_rays.directions[:] = refracted_directions
581
+ refracted_rays.wavelengths[:] = wavelengths
582
+ refracted_rays.intensities[:] = refracted_intensities
583
+ refracted_rays.optical_path_lengths[:] = rays.optical_path_lengths[idx]
584
+ refracted_rays.geometric_path_lengths[:] = rays.geometric_path_lengths[idx]
585
+ refracted_rays.accumulated_time[:] = accumulated_time
586
+ refracted_rays.generations[:] = generations + 1
587
+ refracted_rays.domain_ids[:] = rays.domain_ids[idx]
588
+ refracted_rays.active[:] = (refracted_intensities > 1e-10) & (~tir_mask)
589
+
590
+ # Transform polarization vectors for refraction
591
+ if self._track_polarization_vector and polarization_vectors is not None:
592
+ refracted_pol = transform_polarization_refraction(
593
+ polarization_vectors,
594
+ directions,
595
+ refracted_directions,
596
+ normals,
597
+ )
598
+ refracted_pol[tir_mask] = 0.0
599
+ refracted_rays.polarization_vector[:] = refracted_pol
600
+
601
+ # Compact to remove inactive rays
602
+ reflected_rays = reflected_rays.compact()
603
+ refracted_rays = refracted_rays.compact()
604
+
605
+ return reflected_rays, refracted_rays
606
+
607
+ def get_surface(self, idx: int) -> Surface:
608
+ """Get surface by index."""
609
+ return self._surfaces[idx]
610
+
611
+ def get_surface_by_name(self, name: str) -> Surface | None:
612
+ """Get surface by name."""
613
+ for surface in self._surfaces:
614
+ if surface.name == name:
615
+ return surface
616
+ return None