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,409 @@
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
+ Custom Ray Source with Per-Ray Properties
36
+
37
+ Provides a source where each ray's position, direction, intensity, and
38
+ wavelength can be individually specified. Ideal for:
39
+ - Chromatic dispersion simulations with different wavelengths per ray
40
+ - Custom ray distributions for specialized optical setups
41
+ - Importing ray data from external sources
42
+ - Testing and validation scenarios
43
+
44
+ Examples
45
+ --------
46
+ >>> from lsurf.sources import CustomRaySource
47
+ >>> import numpy as np
48
+ >>>
49
+ >>> # Create RGB rays from same position but different directions
50
+ >>> positions = np.array([
51
+ ... [0, 0, 0],
52
+ ... [0, 0, 0],
53
+ ... [0, 0, 0],
54
+ ... ])
55
+ >>> directions = np.array([
56
+ ... [1, 0, 0.01], # Slight upward tilt
57
+ ... [1, 0, 0], # Horizontal
58
+ ... [1, 0, -0.01], # Slight downward tilt
59
+ ... ])
60
+ >>> wavelengths = np.array([700e-9, 550e-9, 450e-9]) # RGB
61
+ >>> intensities = np.array([1.0, 1.0, 1.0])
62
+ >>>
63
+ >>> source = CustomRaySource(
64
+ ... positions=positions,
65
+ ... directions=directions,
66
+ ... wavelengths=wavelengths,
67
+ ... intensities=intensities,
68
+ ... )
69
+ >>> rays = source.generate()
70
+ """
71
+
72
+ import numpy as np
73
+ import numpy.typing as npt
74
+
75
+ from ..utilities.ray_data import RayBatch, create_ray_batch
76
+
77
+
78
+ class CustomRaySource:
79
+ """
80
+ Fully customizable ray source with per-ray properties.
81
+
82
+ Each ray can have its own position, direction, wavelength, and intensity.
83
+ This provides maximum flexibility for specialized simulations.
84
+
85
+ Parameters
86
+ ----------
87
+ positions : array_like, shape (N, 3)
88
+ Starting position for each ray in meters.
89
+ directions : array_like, shape (N, 3)
90
+ Direction vector for each ray (will be normalized).
91
+ wavelengths : array_like, shape (N,)
92
+ Wavelength for each ray in meters.
93
+ intensities : array_like, shape (N,), optional
94
+ Intensity/power for each ray in watts. If None, defaults to
95
+ uniform distribution with total power of 1.0.
96
+
97
+ Attributes
98
+ ----------
99
+ num_rays : int
100
+ Number of rays.
101
+ positions : ndarray, shape (N, 3)
102
+ Ray starting positions.
103
+ directions : ndarray, shape (N, 3)
104
+ Normalized ray directions.
105
+ wavelengths : ndarray, shape (N,)
106
+ Per-ray wavelengths.
107
+ intensities : ndarray, shape (N,)
108
+ Per-ray intensities.
109
+ power : float
110
+ Total power (sum of intensities).
111
+
112
+ Examples
113
+ --------
114
+ >>> # Chromatic dispersion study - same start, different wavelengths
115
+ >>> n_rays = 100
116
+ >>> positions = np.tile([0, 0, 1000], (n_rays, 1)) # All at same point
117
+ >>> directions = np.tile([1, 0, 0], (n_rays, 1)) # All same direction
118
+ >>> wavelengths = np.linspace(400e-9, 700e-9, n_rays) # Visible spectrum
119
+ >>> source = CustomRaySource(positions, directions, wavelengths)
120
+ >>> rays = source.generate()
121
+
122
+ >>> # Fan of rays for refraction analysis
123
+ >>> angles = np.linspace(-0.1, 0.1, 50) # +/- 5.7 degrees
124
+ >>> positions = np.zeros((50, 3))
125
+ >>> directions = np.column_stack([
126
+ ... np.cos(angles),
127
+ ... np.zeros(50),
128
+ ... np.sin(angles)
129
+ ... ])
130
+ >>> wavelengths = np.full(50, 550e-9)
131
+ >>> source = CustomRaySource(positions, directions, wavelengths)
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ positions: npt.ArrayLike,
137
+ directions: npt.ArrayLike,
138
+ wavelengths: npt.ArrayLike,
139
+ intensities: npt.ArrayLike | None = None,
140
+ ):
141
+ """
142
+ Initialize custom ray source.
143
+
144
+ Parameters
145
+ ----------
146
+ positions : array_like, shape (N, 3)
147
+ Starting position for each ray in meters.
148
+ directions : array_like, shape (N, 3)
149
+ Direction vector for each ray (will be normalized).
150
+ wavelengths : array_like, shape (N,)
151
+ Wavelength for each ray in meters.
152
+ intensities : array_like, shape (N,), optional
153
+ Intensity for each ray. If None, uniform distribution is used.
154
+
155
+ Raises
156
+ ------
157
+ ValueError
158
+ If array shapes are inconsistent or invalid.
159
+ """
160
+ # Convert and validate positions
161
+ self._positions = np.asarray(positions, dtype=np.float32)
162
+ if self._positions.ndim == 1:
163
+ self._positions = self._positions.reshape(1, 3)
164
+ if self._positions.ndim != 2 or self._positions.shape[1] != 3:
165
+ raise ValueError(
166
+ f"positions must have shape (N, 3), got {self._positions.shape}"
167
+ )
168
+
169
+ self._num_rays = self._positions.shape[0]
170
+
171
+ # Convert and validate directions
172
+ self._directions = np.asarray(directions, dtype=np.float32)
173
+ if self._directions.ndim == 1:
174
+ self._directions = self._directions.reshape(1, 3)
175
+ if self._directions.shape != (self._num_rays, 3):
176
+ raise ValueError(
177
+ f"directions must have shape ({self._num_rays}, 3), "
178
+ f"got {self._directions.shape}"
179
+ )
180
+
181
+ # Normalize directions
182
+ norms = np.linalg.norm(self._directions, axis=1, keepdims=True)
183
+ if np.any(norms < 1e-10):
184
+ raise ValueError("direction vectors cannot be zero")
185
+ self._directions = self._directions / norms
186
+
187
+ # Convert and validate wavelengths
188
+ self._wavelengths = np.asarray(wavelengths, dtype=np.float32).ravel()
189
+ if self._wavelengths.shape[0] != self._num_rays:
190
+ raise ValueError(
191
+ f"wavelengths must have shape ({self._num_rays},), "
192
+ f"got {self._wavelengths.shape}"
193
+ )
194
+ if np.any(self._wavelengths <= 0):
195
+ raise ValueError("wavelengths must be positive")
196
+
197
+ # Convert and validate intensities
198
+ if intensities is None:
199
+ # Default: uniform distribution with total power = 1.0
200
+ self._intensities = np.full(
201
+ self._num_rays, 1.0 / self._num_rays, dtype=np.float32
202
+ )
203
+ else:
204
+ self._intensities = np.asarray(intensities, dtype=np.float32).ravel()
205
+ if self._intensities.shape[0] != self._num_rays:
206
+ raise ValueError(
207
+ f"intensities must have shape ({self._num_rays},), "
208
+ f"got {self._intensities.shape}"
209
+ )
210
+ if np.any(self._intensities < 0):
211
+ raise ValueError("intensities cannot be negative")
212
+
213
+ @property
214
+ def num_rays(self) -> int:
215
+ """Number of rays."""
216
+ return self._num_rays
217
+
218
+ @property
219
+ def positions(self) -> npt.NDArray[np.float32]:
220
+ """Ray starting positions, shape (N, 3)."""
221
+ return self._positions
222
+
223
+ @property
224
+ def directions(self) -> npt.NDArray[np.float32]:
225
+ """Normalized ray directions, shape (N, 3)."""
226
+ return self._directions
227
+
228
+ @property
229
+ def wavelengths(self) -> npt.NDArray[np.float32]:
230
+ """Per-ray wavelengths in meters, shape (N,)."""
231
+ return self._wavelengths
232
+
233
+ @property
234
+ def intensities(self) -> npt.NDArray[np.float32]:
235
+ """Per-ray intensities, shape (N,)."""
236
+ return self._intensities
237
+
238
+ @property
239
+ def power(self) -> float:
240
+ """Total power (sum of intensities)."""
241
+ return float(np.sum(self._intensities))
242
+
243
+ def generate(self) -> RayBatch:
244
+ """
245
+ Generate ray batch with specified properties.
246
+
247
+ Creates a RayBatch with positions, directions, wavelengths, and
248
+ intensities as specified in the constructor.
249
+
250
+ Returns
251
+ -------
252
+ RayBatch
253
+ Ray batch ready for propagation.
254
+ """
255
+ rays = create_ray_batch(num_rays=self._num_rays)
256
+
257
+ # Set all per-ray properties
258
+ rays.positions[:] = self._positions
259
+ rays.directions[:] = self._directions
260
+ rays.wavelengths[:] = self._wavelengths
261
+ rays.intensities[:] = self._intensities
262
+ rays.active[:] = True
263
+
264
+ return rays
265
+
266
+ def __repr__(self) -> str:
267
+ """Return string representation."""
268
+ wl_min = self._wavelengths.min()
269
+ wl_max = self._wavelengths.max()
270
+ if wl_min == wl_max:
271
+ wl_str = f"{wl_min:.2e}m"
272
+ else:
273
+ wl_str = f"[{wl_min:.2e}, {wl_max:.2e}]m"
274
+
275
+ return (
276
+ f"CustomRaySource(num_rays={self._num_rays}, "
277
+ f"wavelengths={wl_str}, power={self.power:.2e}W)"
278
+ )
279
+
280
+ @classmethod
281
+ def from_spectral_fan(
282
+ cls,
283
+ origin: tuple[float, float, float],
284
+ direction: tuple[float, float, float],
285
+ wavelength_range: tuple[float, float],
286
+ num_rays: int,
287
+ total_power: float = 1.0,
288
+ ) -> "CustomRaySource":
289
+ """
290
+ Create rays with same position/direction but varying wavelengths.
291
+
292
+ Convenience factory for chromatic dispersion studies.
293
+
294
+ Parameters
295
+ ----------
296
+ origin : tuple of float
297
+ Starting position for all rays (x, y, z) in meters.
298
+ direction : tuple of float
299
+ Direction for all rays (will be normalized).
300
+ wavelength_range : tuple of float
301
+ (min_wavelength, max_wavelength) in meters.
302
+ num_rays : int
303
+ Number of rays to create.
304
+ total_power : float, optional
305
+ Total power distributed uniformly. Default is 1.0 W.
306
+
307
+ Returns
308
+ -------
309
+ CustomRaySource
310
+ Source with spectral distribution.
311
+
312
+ Examples
313
+ --------
314
+ >>> # Visible spectrum from single point
315
+ >>> source = CustomRaySource.from_spectral_fan(
316
+ ... origin=(0, 0, 0),
317
+ ... direction=(1, 0, 0),
318
+ ... wavelength_range=(400e-9, 700e-9),
319
+ ... num_rays=100,
320
+ ... )
321
+ """
322
+ positions = np.tile(origin, (num_rays, 1)).astype(np.float32)
323
+ directions = np.tile(direction, (num_rays, 1)).astype(np.float32)
324
+ wavelengths = np.linspace(
325
+ wavelength_range[0], wavelength_range[1], num_rays
326
+ ).astype(np.float32)
327
+ intensities = np.full(num_rays, total_power / num_rays, dtype=np.float32)
328
+
329
+ return cls(positions, directions, wavelengths, intensities)
330
+
331
+ @classmethod
332
+ def from_angular_fan(
333
+ cls,
334
+ origin: tuple[float, float, float],
335
+ base_direction: tuple[float, float, float],
336
+ angle_range: tuple[float, float],
337
+ num_rays: int,
338
+ wavelength: float = 550e-9,
339
+ total_power: float = 1.0,
340
+ fan_axis: str = "vertical",
341
+ ) -> "CustomRaySource":
342
+ """
343
+ Create rays from same position with varying angles.
344
+
345
+ Convenience factory for refraction/reflection studies.
346
+
347
+ Parameters
348
+ ----------
349
+ origin : tuple of float
350
+ Starting position for all rays (x, y, z) in meters.
351
+ base_direction : tuple of float
352
+ Central direction of the fan (will be normalized).
353
+ angle_range : tuple of float
354
+ (min_angle, max_angle) deviation from base direction in radians.
355
+ num_rays : int
356
+ Number of rays to create.
357
+ wavelength : float, optional
358
+ Wavelength for all rays. Default is 550 nm.
359
+ total_power : float, optional
360
+ Total power distributed uniformly. Default is 1.0 W.
361
+ fan_axis : str, optional
362
+ 'vertical' for z-rotation, 'horizontal' for y-rotation.
363
+ Default is 'vertical'.
364
+
365
+ Returns
366
+ -------
367
+ CustomRaySource
368
+ Source with angular distribution.
369
+
370
+ Examples
371
+ --------
372
+ >>> # Fan of rays spanning +/- 10 degrees
373
+ >>> source = CustomRaySource.from_angular_fan(
374
+ ... origin=(0, 0, 1000),
375
+ ... base_direction=(1, 0, 0),
376
+ ... angle_range=(-0.17, 0.17), # ~10 degrees
377
+ ... num_rays=50,
378
+ ... )
379
+ """
380
+ positions = np.tile(origin, (num_rays, 1)).astype(np.float32)
381
+
382
+ # Normalize base direction
383
+ base = np.array(base_direction, dtype=np.float32)
384
+ base = base / np.linalg.norm(base)
385
+
386
+ # Create angles
387
+ angles = np.linspace(angle_range[0], angle_range[1], num_rays)
388
+
389
+ # Create directions by rotating base direction
390
+ directions = np.zeros((num_rays, 3), dtype=np.float32)
391
+ if fan_axis == "vertical":
392
+ # Rotate in xz plane (vertical fan)
393
+ cos_a = np.cos(angles)
394
+ sin_a = np.sin(angles)
395
+ directions[:, 0] = base[0] * cos_a - base[2] * sin_a
396
+ directions[:, 1] = base[1]
397
+ directions[:, 2] = base[0] * sin_a + base[2] * cos_a
398
+ else:
399
+ # Rotate in xy plane (horizontal fan)
400
+ cos_a = np.cos(angles)
401
+ sin_a = np.sin(angles)
402
+ directions[:, 0] = base[0] * cos_a - base[1] * sin_a
403
+ directions[:, 1] = base[0] * sin_a + base[1] * cos_a
404
+ directions[:, 2] = base[2]
405
+
406
+ wavelengths = np.full(num_rays, wavelength, dtype=np.float32)
407
+ intensities = np.full(num_rays, total_power / num_rays, dtype=np.float32)
408
+
409
+ return cls(positions, directions, wavelengths, intensities)
@@ -0,0 +1,228 @@
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
+ Diverging Beam Source Implementation
36
+
37
+ Provides a diverging beam source with angular spread, suitable for modeling
38
+ fiber optic outputs, LEDs, and other extended sources.
39
+
40
+ Examples
41
+ --------
42
+ >>> from surface_roughness.sources import DivergingBeam
43
+ >>>
44
+ >>> source = DivergingBeam(
45
+ ... origin=(0, 0, 0),
46
+ ... mean_direction=(0, 0, 1),
47
+ ... divergence_angle=0.05, # ~2.9 degrees
48
+ ... num_rays=1000,
49
+ ... wavelength=850e-9,
50
+ ... power=1.0
51
+ ... )
52
+ >>> rays = source.generate()
53
+ """
54
+
55
+ import numpy as np
56
+
57
+ from ..utilities.ray_data import RayBatch
58
+ from .base import RaySource
59
+
60
+
61
+ class DivergingBeam(RaySource):
62
+ """
63
+ Beam with angular divergence.
64
+
65
+ Generates rays from a single point with directions distributed
66
+ within a cone around the mean direction. Suitable for modeling
67
+ fiber optic outputs, LEDs, and similar diverging sources.
68
+
69
+ Parameters
70
+ ----------
71
+ origin : tuple of float
72
+ Source position (x, y, z) in meters.
73
+ mean_direction : tuple of float
74
+ Mean beam direction (dx, dy, dz), will be normalized.
75
+ divergence_angle : float
76
+ Half-angle divergence in radians (cone half-angle).
77
+ Must be in range (0, π/2).
78
+ num_rays : int
79
+ Number of rays to generate.
80
+ wavelength : float or tuple of float
81
+ Single wavelength (m) or (min, max) range.
82
+ power : float, optional
83
+ Total source power in watts. Default is 1.0.
84
+
85
+ Attributes
86
+ ----------
87
+ origin : ndarray, shape (3,)
88
+ Source position.
89
+ mean_direction : ndarray, shape (3,)
90
+ Normalized mean beam direction.
91
+ divergence_angle : float
92
+ Cone half-angle in radians.
93
+
94
+ Notes
95
+ -----
96
+ The angular distribution is uniform within the cone. For Lambertian
97
+ sources (cosine distribution), a different implementation would be
98
+ needed.
99
+
100
+ Examples
101
+ --------
102
+ >>> # Fiber output with 0.1 rad NA
103
+ >>> source = DivergingBeam(
104
+ ... origin=(0, 0, 0),
105
+ ... mean_direction=(0, 0, 1),
106
+ ... divergence_angle=0.1,
107
+ ... num_rays=5000,
108
+ ... wavelength=1550e-9,
109
+ ... power=1e-3
110
+ ... )
111
+
112
+ >>> # LED with wide angle
113
+ >>> source = DivergingBeam(
114
+ ... origin=(0, 0.1, 0),
115
+ ... mean_direction=(0, 0, 1),
116
+ ... divergence_angle=np.radians(30), # 30 degrees
117
+ ... num_rays=10000,
118
+ ... wavelength=(400e-9, 700e-9),
119
+ ... power=0.5
120
+ ... )
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ origin: tuple[float, float, float],
126
+ mean_direction: tuple[float, float, float],
127
+ divergence_angle: float,
128
+ num_rays: int,
129
+ wavelength: float | tuple[float, float],
130
+ power: float = 1.0,
131
+ ):
132
+ """
133
+ Initialize diverging beam.
134
+
135
+ Parameters
136
+ ----------
137
+ origin : tuple of float
138
+ Source position (x, y, z) in meters.
139
+ mean_direction : tuple of float
140
+ Mean beam direction, will be normalized.
141
+ divergence_angle : float
142
+ Cone half-angle in radians.
143
+ num_rays : int
144
+ Number of rays to generate.
145
+ wavelength : float or tuple of float
146
+ Wavelength in meters or (min, max) range.
147
+ power : float, optional
148
+ Total source power in watts. Default is 1.0.
149
+
150
+ Raises
151
+ ------
152
+ ValueError
153
+ If divergence_angle not in (0, π/2).
154
+ """
155
+ super().__init__(num_rays, wavelength, power)
156
+ self.origin = np.array(origin, dtype=np.float32)
157
+ self.divergence_angle = divergence_angle
158
+
159
+ # Normalize mean direction
160
+ mean_direction_arr = np.array(mean_direction, dtype=np.float32)
161
+ self.mean_direction = mean_direction_arr / np.linalg.norm(mean_direction_arr)
162
+
163
+ if divergence_angle <= 0 or divergence_angle >= np.pi / 2:
164
+ raise ValueError("divergence_angle must be in (0, π/2)")
165
+
166
+ def generate(self) -> RayBatch:
167
+ """
168
+ Generate diverging beam.
169
+
170
+ Creates rays from the origin with directions uniformly distributed
171
+ within a cone around the mean direction.
172
+
173
+ Returns
174
+ -------
175
+ RayBatch
176
+ Ray batch with diverging direction distribution.
177
+
178
+ Notes
179
+ -----
180
+ Uses spherical coordinates relative to the mean direction to
181
+ generate uniformly distributed directions within the cone.
182
+ """
183
+ rays = self._allocate_rays()
184
+
185
+ # All rays start at origin
186
+ rays.positions[:] = self.origin
187
+
188
+ # Create perpendicular basis for direction perturbation
189
+ v1, v2 = self._create_perpendicular_basis(self.mean_direction)
190
+
191
+ # Generate random angles within cone
192
+ # Azimuthal angle: uniform in [0, 2π)
193
+ theta = np.random.uniform(0, 2 * np.pi, self.num_rays)
194
+ # Polar angle: uniform in [0, divergence_angle]
195
+ # For uniform distribution on cone cap, should sample cos(phi) uniformly
196
+ # but for simplicity we use uniform phi for now
197
+ phi = np.random.uniform(0, self.divergence_angle, self.num_rays)
198
+
199
+ # Compute perturbed directions
200
+ sin_phi = np.sin(phi)
201
+ cos_phi = np.cos(phi)
202
+
203
+ # Direction in local coordinates (z = mean_direction)
204
+ local_x = sin_phi * np.cos(theta)
205
+ local_y = sin_phi * np.sin(theta)
206
+ local_z = cos_phi
207
+
208
+ # Transform to global coordinates
209
+ rays.directions[:] = (
210
+ local_x[:, np.newaxis] * v1
211
+ + local_y[:, np.newaxis] * v2
212
+ + local_z[:, np.newaxis] * self.mean_direction
213
+ )
214
+
215
+ # Normalize (should already be normalized, but ensure numerical stability)
216
+ norms = np.linalg.norm(rays.directions, axis=1, keepdims=True)
217
+ rays.directions[:] /= norms
218
+
219
+ self._assign_wavelengths(rays)
220
+
221
+ return rays
222
+
223
+ def __repr__(self) -> str:
224
+ """Return string representation."""
225
+ return (
226
+ f"DivergingBeam(origin={self.origin.tolist()}, "
227
+ f"divergence_angle={self.divergence_angle:.4f})"
228
+ )